mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-08 08:02:15 +02:00
Compare commits
39 Commits
feature/cu
...
fix/create
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fb51a2453 | ||
|
|
042034a9c9 | ||
|
|
f28081a72a | ||
|
|
15dc1e3012 | ||
|
|
6cc20aeacb | ||
|
|
7da7214afb | ||
|
|
c369419512 | ||
|
|
d9ad397c94 | ||
|
|
3191d890f3 | ||
|
|
68f3387539 | ||
|
|
0dc8b4556c | ||
|
|
e123e91959 | ||
|
|
2709400773 | ||
|
|
8281c6159b | ||
|
|
296dbb7957 | ||
|
|
3827f0f799 | ||
|
|
d89e3dc6d4 | ||
|
|
91cf5f9367 | ||
|
|
5cc4b07cf6 | ||
|
|
0cfc242e09 | ||
|
|
a6b3cfdb0c | ||
|
|
5ead18c94c | ||
|
|
5eeb8cae5c | ||
|
|
68bf024005 | ||
|
|
fdd1068c90 | ||
|
|
ba695bf647 | ||
|
|
27e7aec193 | ||
|
|
58b712a1de | ||
|
|
08f9036523 | ||
|
|
ebe3efc8f7 | ||
|
|
66fbf27913 | ||
|
|
20e4a4e42a | ||
|
|
1aa4844eeb | ||
|
|
4bb9c092cb | ||
|
|
c493eb8924 | ||
|
|
40fdf97520 | ||
|
|
91b10e75dd | ||
|
|
7a6da10e1c | ||
|
|
004e8ec645 |
5
.github/workflows/crowdin_download.yml
vendored
5
.github/workflows/crowdin_download.yml
vendored
@@ -7,10 +7,11 @@ on:
|
||||
- 'release/**'
|
||||
|
||||
jobs:
|
||||
install-front:
|
||||
uses: ./.github/workflows/front-dependencies-installation.yml
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '20.x'
|
||||
with-front-dependencies-installation: true
|
||||
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
15
.github/workflows/crowdin_upload.yml
vendored
15
.github/workflows/crowdin_upload.yml
vendored
@@ -7,13 +7,15 @@ on:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
install-front:
|
||||
uses: ./.github/workflows/front-dependencies-installation.yml
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '20.x'
|
||||
with-front-dependencies-installation: true
|
||||
with-build_mails: true
|
||||
|
||||
synchronize-with-crowdin:
|
||||
needs: install-front
|
||||
needs: install-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -29,6 +31,13 @@ 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
.github/workflows/dependencies.yml
vendored
Normal file
85
.github/workflows/dependencies.yml
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
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') }}
|
||||
@@ -1,36 +0,0 @@
|
||||
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') }}
|
||||
33
.github/workflows/impress-frontend.yml
vendored
33
.github/workflows/impress-frontend.yml
vendored
@@ -10,13 +10,14 @@ on:
|
||||
|
||||
jobs:
|
||||
|
||||
install-front:
|
||||
uses: ./.github/workflows/front-dependencies-installation.yml
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '20.x'
|
||||
with-front-dependencies-installation: true
|
||||
|
||||
test-front:
|
||||
needs: install-front
|
||||
needs: install-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -39,7 +40,7 @@ jobs:
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
needs: install-dependencies
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -60,7 +61,7 @@ jobs:
|
||||
|
||||
test-e2e-chromium:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
needs: install-dependencies
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -87,28 +88,6 @@ jobs:
|
||||
- name: Start Docker services
|
||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
||||
|
||||
# Tool to wait for a service to be ready
|
||||
- name: Install Dockerize
|
||||
run: |
|
||||
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
|
||||
|
||||
- name: Wait for services to be ready
|
||||
run: |
|
||||
printf "Minio check...\n"
|
||||
dockerize -wait tcp://localhost:9000 -timeout 20s
|
||||
printf "Keyclock check...\n"
|
||||
dockerize -wait tcp://localhost:8080 -timeout 20s
|
||||
printf "Server collaboration check...\n"
|
||||
dockerize -wait tcp://localhost:4444 -timeout 20s
|
||||
printf "Ngnix check...\n"
|
||||
dockerize -wait tcp://localhost:8083 -timeout 20s
|
||||
printf "DRF check...\n"
|
||||
dockerize -wait tcp://localhost:8071 -timeout 20s
|
||||
printf "Postgres Keyclock check...\n"
|
||||
dockerize -wait tcp://localhost:5433 -timeout 20s
|
||||
printf "Postgres back check...\n"
|
||||
dockerize -wait tcp://localhost:15432 -timeout 20s
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test --project='chromium'
|
||||
|
||||
|
||||
48
.github/workflows/impress.yml
vendored
48
.github/workflows/impress.yml
vendored
@@ -9,6 +9,11 @@ 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
|
||||
@@ -56,46 +61,6 @@ 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:
|
||||
@@ -121,7 +86,7 @@ jobs:
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-mails
|
||||
needs: install-dependencies
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -169,6 +134,7 @@ jobs:
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -9,9 +9,34 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## Changed
|
||||
|
||||
- 📝(doc) minor README.md formatting and wording enhancements
|
||||
- ♻️Stop setting a default title on doc creation #634
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(backend) race condition create doc #633
|
||||
|
||||
## [2.2.0] - 2025-02-10
|
||||
|
||||
## Added
|
||||
|
||||
- 📝(doc) Add security.md and codeofconduct.md #604
|
||||
- ✨(frontend) add Alert, Quote, and Divider blocks to the editor #566
|
||||
- ✨(frontend) add home page #608
|
||||
- ✨(frontend) cursor display on activity #609
|
||||
- ✨(frontend) Add export page break #623
|
||||
|
||||
## 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
|
||||
@@ -396,7 +421,8 @@ and this project adheres to
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.1.0...main
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.2.0...main
|
||||
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
|
||||
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
|
||||
[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
|
||||
|
||||
30
Makefile
30
Makefile
@@ -44,7 +44,6 @@ COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
@@ -81,12 +80,12 @@ bootstrap: \
|
||||
data/static \
|
||||
create-env-files \
|
||||
build \
|
||||
run-with-frontend \
|
||||
migrate \
|
||||
demo \
|
||||
back-i18n-compile \
|
||||
mails-install \
|
||||
mails-build
|
||||
mails-build \
|
||||
run
|
||||
.PHONY: bootstrap
|
||||
|
||||
# -- Docker/compose
|
||||
@@ -109,7 +108,7 @@ build-yjs-provider: ## build the y-provider container
|
||||
|
||||
build-frontend: cache ?=
|
||||
build-frontend: ## build the frontend container
|
||||
@$(COMPOSE) build frontend-dev $(cache)
|
||||
@$(COMPOSE) build frontend $(cache)
|
||||
.PHONY: build-frontend
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@@ -120,18 +119,17 @@ logs: ## display app-dev logs (follow mode)
|
||||
@$(COMPOSE) logs -f app-dev
|
||||
.PHONY: logs
|
||||
|
||||
run: ## start the wsgi (production) and development server
|
||||
run-backend: ## Start only the backend application and all needed services
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d y-provider
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_DB)
|
||||
.PHONY: run
|
||||
.PHONY: run-backend
|
||||
|
||||
run-with-frontend: ## Start all the containers needed (backend to frontend)
|
||||
@$(MAKE) run
|
||||
@$(COMPOSE) up --force-recreate -d frontend-dev
|
||||
.PHONY: run-with-frontend
|
||||
run: ## start the wsgi (production) and development server
|
||||
run:
|
||||
@$(MAKE) run-backend
|
||||
@$(COMPOSE) up --force-recreate -d frontend
|
||||
.PHONY: run
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
@$(COMPOSE) ps
|
||||
@@ -188,14 +186,12 @@ test-back-parallel: ## run all back-end tests in parallel
|
||||
makemigrations: ## run django makemigrations for the impress project.
|
||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) makemigrations
|
||||
.PHONY: makemigrations
|
||||
|
||||
migrate: ## run django migrations for the impress project.
|
||||
@echo "$(BOLD)Running migrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) migrate
|
||||
.PHONY: migrate
|
||||
|
||||
@@ -310,16 +306,16 @@ help:
|
||||
.PHONY: help
|
||||
|
||||
# Front
|
||||
frontend-install: ## install the frontend locally
|
||||
frontend-development-install: ## install the frontend locally
|
||||
cd $(PATH_FRONT_IMPRESS) && yarn
|
||||
.PHONY: frontend-install
|
||||
.PHONY: frontend-development-install
|
||||
|
||||
frontend-lint: ## run the frontend linter
|
||||
cd $(PATH_FRONT) && yarn lint
|
||||
.PHONY: frontend-lint
|
||||
|
||||
run-frontend-development: ## Run the frontend in development mode
|
||||
@$(COMPOSE) stop frontend-dev
|
||||
@$(COMPOSE) stop frontend
|
||||
cd $(PATH_FRONT_IMPRESS) && yarn dev
|
||||
.PHONY: run-frontend-development
|
||||
|
||||
|
||||
40
README.md
40
README.md
@@ -23,6 +23,7 @@ Welcome to Docs! The open source document editor where your notes can become kno
|
||||
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
|
||||
|
||||
## Why use Docs ❓
|
||||
|
||||
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
|
||||
|
||||
### Write
|
||||
@@ -33,23 +34,31 @@ Docs is a collaborative text editor designed to address common challenges in kno
|
||||
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
|
||||
|
||||
### Collaborate
|
||||
* 🤝 Collaborate in realtime with your team mates
|
||||
* 🔒 Granular access control to keep your information secure and shared with the right people
|
||||
* 🤝 Collaborate with your team in real time
|
||||
* 🔒 Granular access control to ensure your information is secure and only shared with the right people
|
||||
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
|
||||
* 📚 Built-in wiki functionality to transform your team's collaborative work into organized knowledge `ETA 02/2025`
|
||||
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 02/2025`
|
||||
|
||||
### Self-host
|
||||
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
|
||||
|
||||
## Getting started 🔧
|
||||
|
||||
### Test it
|
||||
|
||||
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/docs/0aa856e9-da41-4d59-b73d-a61cb2c1245f/)
|
||||
|
||||
```
|
||||
email: test.docs@yopmail.com
|
||||
password: I'd<3ToTestDocs
|
||||
```
|
||||
|
||||
### Run it locally
|
||||
|
||||
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution: if you want to use Minio for production deployment of Docs, you will need to comply with Minio's AGPL-3.0 licence.
|
||||
|
||||
**Prerequisite**
|
||||
|
||||
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
|
||||
|
||||
```shellscript
|
||||
@@ -57,23 +66,22 @@ $ docker -v
|
||||
|
||||
Docker version 20.10.2, build 2291f61
|
||||
|
||||
$ docker compose -v
|
||||
$ docker compose version
|
||||
|
||||
docker compose version 1.27.4, build 40524192
|
||||
Docker Compose version v2.32.4
|
||||
```
|
||||
|
||||
> ⚠️ You may need to run the following commands with sudo but this can be avoided by adding your user to the `docker` group.
|
||||
|
||||
**Project bootstrap**
|
||||
|
||||
The easiest way to start working on the project is to use GNU Make:
|
||||
|
||||
```shellscript
|
||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||
```
|
||||
|
||||
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this
|
||||
|
||||
command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
|
||||
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
|
||||
|
||||
Your Docker services should now be up and running 🎉
|
||||
|
||||
@@ -89,7 +97,7 @@ password: impress
|
||||
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
|
||||
|
||||
```shellscript
|
||||
$ make run-with-frontend
|
||||
$ make run
|
||||
```
|
||||
|
||||
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
|
||||
@@ -97,7 +105,7 @@ $ make run-with-frontend
|
||||
To do so, install the frontend dependencies with the following command:
|
||||
|
||||
```shellscript
|
||||
$ make frontend-install
|
||||
$ make frontend-development-install
|
||||
```
|
||||
|
||||
And run the frontend locally in development mode with the following command:
|
||||
@@ -109,7 +117,7 @@ $ make run-frontend-development
|
||||
To start all the services, except the frontend container, you can use the following command:
|
||||
|
||||
```shellscript
|
||||
$ make run
|
||||
$ make run-backend
|
||||
```
|
||||
|
||||
**Adding content**
|
||||
@@ -126,6 +134,7 @@ $ make help
|
||||
```
|
||||
|
||||
**Django admin**
|
||||
|
||||
You can access the Django admin site at
|
||||
|
||||
<http://localhost:8071/admin>.
|
||||
@@ -137,17 +146,21 @@ $ make superuser
|
||||
```
|
||||
|
||||
## Feedback 🙋♂️🙋♀️
|
||||
|
||||
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
|
||||
|
||||
## Roadmap
|
||||
|
||||
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
|
||||
|
||||
## Licence 📝
|
||||
|
||||
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
|
||||
|
||||
While Docs is a public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
|
||||
|
||||
## Contributing 🙌
|
||||
|
||||
This project is intended to be community-driven, so please, do not hesitate to [get in touch](https://matrix.to/#/#docs-official:matrix.org) if you have any question related to our implementation or design decisions.
|
||||
|
||||
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
|
||||
@@ -169,10 +182,13 @@ docs
|
||||
```
|
||||
|
||||
## Credits ❤️
|
||||
|
||||
### Stack
|
||||
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [MinIO](https://min.io/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/)
|
||||
|
||||
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/).
|
||||
|
||||
### Gov ❤️ open source
|
||||
|
||||
Docs is the result of a joint effort led by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)).
|
||||
|
||||
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
|
||||
|
||||
@@ -15,3 +15,8 @@ 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".
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
env_file:
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
@@ -23,6 +28,11 @@ services:
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 1s
|
||||
timeout: 20s
|
||||
retries: 300
|
||||
entrypoint: ""
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
@@ -31,7 +41,9 @@ services:
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
- minio
|
||||
minio:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
/usr/bin/mc alias set impress http://minio:9000 impress password && \
|
||||
@@ -59,10 +71,15 @@ services:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- redis
|
||||
- createbuckets
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
mailcatcher:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_started
|
||||
createbuckets:
|
||||
condition: service_started
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -93,9 +110,13 @@ services:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
depends_on:
|
||||
- postgresql
|
||||
- redis
|
||||
- minio
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
redis:
|
||||
condition: service_started
|
||||
minio:
|
||||
condition: service_started
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -116,11 +137,15 @@ services:
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- keycloak
|
||||
- app-dev
|
||||
- y-provider
|
||||
app-dev:
|
||||
condition: service_started
|
||||
y-provider:
|
||||
condition: service_started
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
frontend-dev:
|
||||
frontend:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
@@ -135,9 +160,6 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:3.16.0
|
||||
volumes:
|
||||
@@ -169,6 +191,11 @@ services:
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
ports:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
@@ -187,6 +214,13 @@ services:
|
||||
- --hostname-admin-url=http://localhost:8083/
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
- --health-enabled=true
|
||||
- --metrics-enabled=true
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
@@ -200,4 +234,6 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
@@ -88,5 +88,11 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# Increase proxy buffer size to allow keycloak to send large
|
||||
# header responses when a user is created.
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ from django.contrib.postgres.fields import ArrayField
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import connection, transaction
|
||||
from django.db import models as db
|
||||
from django.db import transaction
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Left, Length
|
||||
from django.http import Http404
|
||||
@@ -573,6 +573,12 @@ class DocumentViewSet(
|
||||
@transaction.atomic
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as creator and owner of the newly created object."""
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
|
||||
"IN SHARE ROW EXCLUSIVE MODE;"
|
||||
)
|
||||
|
||||
obj = models.Document.add_root(
|
||||
creator=self.request.user,
|
||||
**serializer.validated_data,
|
||||
@@ -631,10 +637,17 @@ class DocumentViewSet(
|
||||
permission_classes=[],
|
||||
url_path="create-for-owner",
|
||||
)
|
||||
@transaction.atomic
|
||||
def create_for_owner(self, request):
|
||||
"""
|
||||
Create a document on behalf of a specified owner (pre-existing user or invited).
|
||||
"""
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
|
||||
"IN SHARE ROW EXCLUSIVE MODE;"
|
||||
)
|
||||
|
||||
# Deserialize and validate the data
|
||||
serializer = serializers.ServerCreateDocumentSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
@@ -741,7 +754,11 @@ class DocumentViewSet(
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
with transaction.atomic():
|
||||
child_document = document.add_child(
|
||||
locked_parent = models.Document.objects.select_for_update().get(
|
||||
pk=document.pk
|
||||
)
|
||||
|
||||
child_document = locked_parent.add_child(
|
||||
creator=request.user,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
|
||||
@@ -1,166 +1,552 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-28 20:29
|
||||
|
||||
import uuid
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import timezone_field.fields
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import timezone_field.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Document',
|
||||
name="Document",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('title', models.CharField(max_length=255, verbose_name='title')),
|
||||
('is_public', models.BooleanField(default=False, help_text='Whether this document is public for anyone to use.', verbose_name='public')),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=255, verbose_name="title")),
|
||||
(
|
||||
"is_public",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this document is public for anyone to use.",
|
||||
verbose_name="public",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document',
|
||||
'verbose_name_plural': 'Documents',
|
||||
'db_table': 'impress_document',
|
||||
'ordering': ('title',),
|
||||
"verbose_name": "Document",
|
||||
"verbose_name_plural": "Documents",
|
||||
"db_table": "impress_document",
|
||||
"ordering": ("title",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Template',
|
||||
name="Template",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('title', models.CharField(max_length=255, verbose_name='title')),
|
||||
('description', models.TextField(blank=True, verbose_name='description')),
|
||||
('code', models.TextField(blank=True, verbose_name='code')),
|
||||
('css', models.TextField(blank=True, verbose_name='css')),
|
||||
('is_public', models.BooleanField(default=False, help_text='Whether this template is public for anyone to use.', verbose_name='public')),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=255, verbose_name="title")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, verbose_name="description"),
|
||||
),
|
||||
("code", models.TextField(blank=True, verbose_name="code")),
|
||||
("css", models.TextField(blank=True, verbose_name="css")),
|
||||
(
|
||||
"is_public",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this template is public for anyone to use.",
|
||||
verbose_name="public",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Template',
|
||||
'verbose_name_plural': 'Templates',
|
||||
'db_table': 'impress_template',
|
||||
'ordering': ('title',),
|
||||
"verbose_name": "Template",
|
||||
"verbose_name_plural": "Templates",
|
||||
"db_table": "impress_template",
|
||||
"ordering": ("title",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
name="User",
|
||||
fields=[
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
|
||||
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
||||
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
||||
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"sub",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.",
|
||||
max_length=255,
|
||||
null=True,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
message="Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.",
|
||||
regex="^[\\w.@+-]+\\Z",
|
||||
)
|
||||
],
|
||||
verbose_name="sub",
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True,
|
||||
max_length=254,
|
||||
null=True,
|
||||
verbose_name="identity email address",
|
||||
),
|
||||
),
|
||||
(
|
||||
"admin_email",
|
||||
models.EmailField(
|
||||
blank=True,
|
||||
max_length=254,
|
||||
null=True,
|
||||
unique=True,
|
||||
verbose_name="admin email address",
|
||||
),
|
||||
),
|
||||
(
|
||||
"language",
|
||||
models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
(
|
||||
"timezone",
|
||||
timezone_field.fields.TimeZoneField(
|
||||
choices_display="WITH_GMT_OFFSET",
|
||||
default="UTC",
|
||||
help_text="The timezone in which the user wants to see times.",
|
||||
use_pytz=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_device",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether the user is a device or a real user.",
|
||||
verbose_name="device",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'db_table': 'impress_user',
|
||||
"verbose_name": "user",
|
||||
"verbose_name_plural": "users",
|
||||
"db_table": "impress_user",
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DocumentAccess',
|
||||
name="DocumentAccess",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('team', models.CharField(blank=True, max_length=100)),
|
||||
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.document')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
("team", models.CharField(blank=True, max_length=100)),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("reader", "Reader"),
|
||||
("editor", "Editor"),
|
||||
("administrator", "Administrator"),
|
||||
("owner", "Owner"),
|
||||
],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="accesses",
|
||||
to="core.document",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document/user relation',
|
||||
'verbose_name_plural': 'Document/user relations',
|
||||
'db_table': 'impress_document_access',
|
||||
'ordering': ('-created_at',),
|
||||
"verbose_name": "Document/user relation",
|
||||
"verbose_name_plural": "Document/user relations",
|
||||
"db_table": "impress_document_access",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Invitation',
|
||||
name="Invitation",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='email address')),
|
||||
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.document')),
|
||||
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(max_length=254, verbose_name="email address"),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("reader", "Reader"),
|
||||
("editor", "Editor"),
|
||||
("administrator", "Administrator"),
|
||||
("owner", "Owner"),
|
||||
],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="invitations",
|
||||
to="core.document",
|
||||
),
|
||||
),
|
||||
(
|
||||
"issuer",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="invitations",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document invitation',
|
||||
'verbose_name_plural': 'Document invitations',
|
||||
'db_table': 'impress_invitation',
|
||||
"verbose_name": "Document invitation",
|
||||
"verbose_name_plural": "Document invitations",
|
||||
"db_table": "impress_invitation",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TemplateAccess',
|
||||
name="TemplateAccess",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('team', models.CharField(blank=True, max_length=100)),
|
||||
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
|
||||
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.template')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
("team", models.CharField(blank=True, max_length=100)),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("reader", "Reader"),
|
||||
("editor", "Editor"),
|
||||
("administrator", "Administrator"),
|
||||
("owner", "Owner"),
|
||||
],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"template",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="accesses",
|
||||
to="core.template",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Template/user relation',
|
||||
'verbose_name_plural': 'Template/user relations',
|
||||
'db_table': 'impress_template_access',
|
||||
'ordering': ('-created_at',),
|
||||
"verbose_name": "Template/user relation",
|
||||
"verbose_name_plural": "Template/user relations",
|
||||
"db_table": "impress_template_access",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='documentaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'document'), name='unique_document_user', violation_error_message='This user is already in this document.'),
|
||||
model_name="documentaccess",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("user__isnull", False)),
|
||||
fields=("user", "document"),
|
||||
name="unique_document_user",
|
||||
violation_error_message="This user is already in this document.",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='documentaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'document'), name='unique_document_team', violation_error_message='This team is already in this document.'),
|
||||
model_name="documentaccess",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("team__gt", "")),
|
||||
fields=("team", "document"),
|
||||
name="unique_document_team",
|
||||
violation_error_message="This team is already in this document.",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='documentaccess',
|
||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
||||
model_name="documentaccess",
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(
|
||||
models.Q(("team", ""), ("user__isnull", False)),
|
||||
models.Q(("team__gt", ""), ("user__isnull", True)),
|
||||
_connector="OR",
|
||||
),
|
||||
name="check_document_access_either_user_or_team",
|
||||
violation_error_message="Either user or team must be set, not both.",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='invitation',
|
||||
constraint=models.UniqueConstraint(fields=('email', 'document'), name='email_and_document_unique_together'),
|
||||
model_name="invitation",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("email", "document"), name="email_and_document_unique_together"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='templateaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'template'), name='unique_template_user', violation_error_message='This user is already in this template.'),
|
||||
model_name="templateaccess",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("user__isnull", False)),
|
||||
fields=("user", "template"),
|
||||
name="unique_template_user",
|
||||
violation_error_message="This user is already in this template.",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='templateaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'template'), name='unique_template_team', violation_error_message='This team is already in this template.'),
|
||||
model_name="templateaccess",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("team__gt", "")),
|
||||
fields=("team", "template"),
|
||||
name="unique_template_team",
|
||||
violation_error_message="This team is already in this template.",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='templateaccess',
|
||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
||||
model_name="templateaccess",
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(
|
||||
models.Q(("team", ""), ("user__isnull", False)),
|
||||
models.Q(("team__gt", ""), ("user__isnull", True)),
|
||||
_connector="OR",
|
||||
),
|
||||
name="check_template_access_either_user_or_team",
|
||||
violation_error_message="Either user or team must be set, not both.",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from django.db import migrations
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
("core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
@@ -1,52 +1,114 @@
|
||||
# Generated by Django 5.1 on 2024-09-08 16:55
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_create_pg_trgm_extension'),
|
||||
("core", "0002_create_pg_trgm_extension"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='link_reach',
|
||||
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='authenticated', max_length=20),
|
||||
model_name="document",
|
||||
name="link_reach",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("restricted", "Restricted"),
|
||||
("authenticated", "Authenticated"),
|
||||
("public", "Public"),
|
||||
],
|
||||
default="authenticated",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='link_role',
|
||||
field=models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor')], default='reader', max_length=20),
|
||||
model_name="document",
|
||||
name="link_role",
|
||||
field=models.CharField(
|
||||
choices=[("reader", "Reader"), ("editor", "Editor")],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='is_public',
|
||||
model_name="document",
|
||||
name="is_public",
|
||||
field=models.BooleanField(null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LinkTrace',
|
||||
name="LinkTrace",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to='core.document')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="link_traces",
|
||||
to="core.document",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="link_traces",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document/user link trace',
|
||||
'verbose_name_plural': 'Document/user link traces',
|
||||
'db_table': 'impress_link_trace',
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_link_trace_document_user', violation_error_message='A link trace already exists for this document/user.')],
|
||||
"verbose_name": "Document/user link trace",
|
||||
"verbose_name_plural": "Document/user link traces",
|
||||
"db_table": "impress_link_trace",
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("user", "document"),
|
||||
name="unique_link_trace_document_user",
|
||||
violation_error_message="A link trace already exists for this document/user.",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Generated by Django 5.1 on 2024-09-08 17:04
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def migrate_is_public_to_link_reach(apps, schema_editor):
|
||||
"""
|
||||
Forward migration: Migrate 'is_public' to 'link_reach'.
|
||||
If is_public == True, set link_reach to 'public'
|
||||
"""
|
||||
Document = apps.get_model('core', 'Document')
|
||||
Document.objects.filter(is_public=True).update(link_reach='public')
|
||||
Document = apps.get_model("core", "Document")
|
||||
Document.objects.filter(is_public=True).update(link_reach="public")
|
||||
|
||||
|
||||
def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
|
||||
@@ -16,20 +17,20 @@ def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
|
||||
- If link_reach == 'public', set is_public to True
|
||||
- Else set is_public to False
|
||||
"""
|
||||
Document = apps.get_model('core', 'Document')
|
||||
Document.objects.filter(link_reach='public').update(is_public=True)
|
||||
Document.objects.filter(link_reach__in=['restricted', "authenticated"]).update(is_public=False)
|
||||
Document = apps.get_model("core", "Document")
|
||||
Document.objects.filter(link_reach="public").update(is_public=True)
|
||||
Document.objects.filter(link_reach__in=["restricted", "authenticated"]).update(
|
||||
is_public=False
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_document_link_reach_document_link_role_and_more'),
|
||||
("core", "0003_document_link_reach_document_link_role_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
migrate_is_public_to_link_reach,
|
||||
reverse_migrate_link_reach_to_is_public
|
||||
migrate_is_public_to_link_reach, reverse_migrate_link_reach_to_is_public
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,15 +4,16 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_migrate_is_public_to_link_reach'),
|
||||
("core", "0004_migrate_is_public_to_link_reach"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='title',
|
||||
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='title'),
|
||||
model_name="document",
|
||||
name="title",
|
||||
field=models.CharField(
|
||||
blank=True, max_length=255, null=True, verbose_name="title"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,25 +4,34 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_remove_document_is_public_alter_document_link_reach_and_more'),
|
||||
("core", "0005_remove_document_is_public_alter_document_link_reach_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='full_name',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
|
||||
model_name="user",
|
||||
name="full_name",
|
||||
field=models.CharField(
|
||||
blank=True, max_length=100, null=True, verbose_name="full name"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='short_name',
|
||||
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='short name'),
|
||||
model_name="user",
|
||||
name="short_name",
|
||||
field=models.CharField(
|
||||
blank=True, max_length=20, null=True, verbose_name="short name"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -117,10 +117,10 @@ BEGIN
|
||||
END $$;
|
||||
"""
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('core', '0006_add_user_full_name_and_short_name'),
|
||||
("core", "0006_add_user_full_name_and_short_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
@@ -4,15 +4,22 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_fix_users_duplicate'),
|
||||
("core", "0007_fix_users_duplicate"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='link_reach',
|
||||
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
|
||||
model_name="document",
|
||||
name="link_reach",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("restricted", "Restricted"),
|
||||
("authenticated", "Authenticated"),
|
||||
("public", "Public"),
|
||||
],
|
||||
default="restricted",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,37 +1,87 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-08 07:59
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_alter_document_link_reach'),
|
||||
("core", "0008_alter_document_link_reach"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DocumentFavorite',
|
||||
name="DocumentFavorite",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by_users', to='core.document')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_documents', to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="favorited_by_users",
|
||||
to="core.document",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="favorite_documents",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document favorite',
|
||||
'verbose_name_plural': 'Document favorites',
|
||||
'db_table': 'impress_document_favorite',
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_document_favorite_user', violation_error_message='This document is already targeted by a favorite relation instance for the same user.')],
|
||||
"verbose_name": "Document favorite",
|
||||
"verbose_name_plural": "Document favorites",
|
||||
"db_table": "impress_document_favorite",
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("user", "document"),
|
||||
name="unique_document_favorite_user",
|
||||
violation_error_message="This document is already targeted by a favorite relation instance for the same user.",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -7,25 +7,48 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_add_document_favorite'),
|
||||
("core", "0009_add_document_favorite"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='creator',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
|
||||
model_name="document",
|
||||
name="creator",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.RESTRICT,
|
||||
related_name="documents_created",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='sub',
|
||||
field=models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub'),
|
||||
model_name="user",
|
||||
name="sub",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.",
|
||||
max_length=255,
|
||||
null=True,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
message="Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.",
|
||||
regex="^[\\w.@+-:]+\\Z",
|
||||
)
|
||||
],
|
||||
verbose_name="sub",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
from django.db.models import F, ForeignKey, Subquery, OuterRef, Q
|
||||
from django.db.models import F, ForeignKey, OuterRef, Q, Subquery
|
||||
|
||||
|
||||
def set_creator_from_document_access(apps, schema_editor):
|
||||
@@ -25,28 +25,37 @@ def set_creator_from_document_access(apps, schema_editor):
|
||||
DocumentAccess = apps.get_model("core", "DocumentAccess")
|
||||
|
||||
# Update `creator` using the "owner" role
|
||||
owner_subquery = DocumentAccess.objects.filter(
|
||||
document=OuterRef('pk'),
|
||||
user__isnull=False,
|
||||
role='owner',
|
||||
).order_by('created_at').values('user_id')[:1]
|
||||
owner_subquery = (
|
||||
DocumentAccess.objects.filter(
|
||||
document=OuterRef("pk"),
|
||||
user__isnull=False,
|
||||
role="owner",
|
||||
)
|
||||
.order_by("created_at")
|
||||
.values("user_id")[:1]
|
||||
)
|
||||
|
||||
Document.objects.filter(
|
||||
creator__isnull=True
|
||||
).update(creator=Subquery(owner_subquery))
|
||||
Document.objects.filter(creator__isnull=True).update(
|
||||
creator=Subquery(owner_subquery)
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0010_add_field_creator_to_document'),
|
||||
("core", "0010_add_field_creator_to_document"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_creator_from_document_access, reverse_code=migrations.RunPython.noop),
|
||||
migrations.RunPython(
|
||||
set_creator_from_document_access, reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='creator',
|
||||
field=ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
|
||||
model_name="document",
|
||||
name="creator",
|
||||
field=ForeignKey(
|
||||
on_delete=django.db.models.deletion.RESTRICT,
|
||||
related_name="documents_created",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,25 +6,42 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0011_populate_creator_field_and_make_it_required'),
|
||||
("core", "0011_populate_creator_field_and_make_it_required"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='creator',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
|
||||
model_name="document",
|
||||
name="creator",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.RESTRICT,
|
||||
related_name="documents_created",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invitation',
|
||||
name='issuer',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL),
|
||||
model_name="invitation",
|
||||
name="issuer",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="invitations",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('core', '0012_make_document_creator_and_invitation_issuer_optional'),
|
||||
("core", "0012_make_document_creator_and_invitation_issuer_optional"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
@@ -4,28 +4,29 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0013_activate_fuzzystrmatch_extension'),
|
||||
("core", "0013_activate_fuzzystrmatch_extension"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='depth',
|
||||
model_name="document",
|
||||
name="depth",
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='numchild',
|
||||
model_name="document",
|
||||
name="numchild",
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='path',
|
||||
model_name="document",
|
||||
name="path",
|
||||
# Allow null values pending the next datamigration to populate the field
|
||||
field=models.CharField(db_collation='C', max_length=252, null=True, unique=True),
|
||||
field=models.CharField(
|
||||
db_collation="C", max_length=252, null=True, unique=True
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -7,9 +7,10 @@ from treebeard.numconv import NumConv
|
||||
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
STEPLEN = 7
|
||||
|
||||
|
||||
def set_path_on_existing_documents(apps, schema_editor):
|
||||
"""
|
||||
Updates the `path` and `depth` fields for all existing Document records
|
||||
Updates the `path` and `depth` fields for all existing Document records
|
||||
to ensure valid materialized paths.
|
||||
|
||||
This function assigns a unique `path` to each Document as a root node
|
||||
@@ -26,27 +27,25 @@ def set_path_on_existing_documents(apps, schema_editor):
|
||||
updates = []
|
||||
for i, pk in enumerate(documents):
|
||||
key = numconv.int2str(i)
|
||||
path = "{0}{1}".format(
|
||||
ALPHABET[0] * (STEPLEN - len(key)),
|
||||
key
|
||||
)
|
||||
path = "{0}{1}".format(ALPHABET[0] * (STEPLEN - len(key)), key)
|
||||
updates.append(Document(pk=pk, path=path, depth=1))
|
||||
|
||||
# Bulk update using the prepared updates list
|
||||
Document.objects.bulk_update(updates, ['depth', 'path'])
|
||||
Document.objects.bulk_update(updates, ["depth", "path"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0014_add_tree_structure_to_documents'),
|
||||
("core", "0014_add_tree_structure_to_documents"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_path_on_existing_documents, reverse_code=migrations.RunPython.noop),
|
||||
migrations.RunPython(
|
||||
set_path_on_existing_documents, reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='path',
|
||||
field=models.CharField(db_collation='C', max_length=252, unique=True),
|
||||
model_name="document",
|
||||
name="path",
|
||||
field=models.CharField(db_collation="C", max_length=252, unique=True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,20 +4,27 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0015_set_path_on_existing_documents'),
|
||||
("core", "0015_set_path_on_existing_documents"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='excerpt',
|
||||
field=models.TextField(blank=True, max_length=300, null=True, verbose_name='excerpt'),
|
||||
model_name="document",
|
||||
name="excerpt",
|
||||
field=models.TextField(
|
||||
blank=True, max_length=300, null=True, verbose_name="excerpt"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,33 +4,49 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0016_add_document_excerpt'),
|
||||
("core", "0016_add_document_excerpt"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='document',
|
||||
options={'ordering': ('path',), 'verbose_name': 'Document', 'verbose_name_plural': 'Documents'},
|
||||
name="document",
|
||||
options={
|
||||
"ordering": ("path",),
|
||||
"verbose_name": "Document",
|
||||
"verbose_name_plural": "Documents",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='ancestors_deleted_at',
|
||||
model_name="document",
|
||||
name="ancestors_deleted_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='deleted_at',
|
||||
model_name="document",
|
||||
name="deleted_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='document',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('deleted_at__isnull', True), ('deleted_at', models.F('ancestors_deleted_at')), _connector='OR'), name='check_deleted_at_matches_ancestors_deleted_at_when_set'),
|
||||
model_name="document",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("deleted_at__isnull", True),
|
||||
("deleted_at", models.F("ancestors_deleted_at")),
|
||||
_connector="OR",
|
||||
),
|
||||
name="check_deleted_at_matches_ancestors_deleted_at_when_set",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
24
src/backend/core/migrations/0018_update_blank_title.py
Normal file
24
src/backend/core/migrations/0018_update_blank_title.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_titles_to_null(apps, schema_editor):
|
||||
"""
|
||||
If the titles are "Untitled document" or "Unbenanntes Dokument" or "Document sans titre"
|
||||
we set them to Null
|
||||
"""
|
||||
Document = apps.get_model("core", "Document")
|
||||
Document.objects.filter(
|
||||
title__in=["Untitled document", "Unbenanntes Dokument", "Document sans titre"]
|
||||
).update(title=None)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0017_add_fields_for_soft_delete"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
update_titles_to_null, reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -629,6 +629,9 @@ 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
|
||||
|
||||
@@ -647,11 +650,23 @@ 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": can_update,
|
||||
"ai_translate": can_update,
|
||||
"ai_transform": ai_access,
|
||||
"ai_translate": ai_access,
|
||||
"attachment_upload": can_update,
|
||||
"children_list": can_get,
|
||||
"children_create": can_update and user.is_authenticated,
|
||||
|
||||
@@ -458,6 +458,10 @@ 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,6 +2,7 @@
|
||||
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
|
||||
@@ -31,6 +32,9 @@ def ai_settings():
|
||||
yield
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
@@ -57,6 +61,7 @@ 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):
|
||||
@@ -93,6 +98,27 @@ 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,6 +2,7 @@
|
||||
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
|
||||
@@ -51,6 +52,9 @@ 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",
|
||||
[
|
||||
@@ -77,6 +81,7 @@ 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):
|
||||
@@ -113,6 +118,27 @@ 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",
|
||||
[
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Tests for Documents API endpoint in impress's core app: create
|
||||
"""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@@ -249,3 +250,41 @@ def test_api_documents_children_create_force_id_existing():
|
||||
assert response.json() == {
|
||||
"id": ["A document with this ID already exists. You cannot override it."]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_api_documents_create_document_children_race_condition():
|
||||
"""
|
||||
It should be possible to create several documents at the same time
|
||||
without causing any race conditions or data integrity issues.
|
||||
"""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
factories.UserDocumentAccessFactory(user=user, document=document, role="owner")
|
||||
|
||||
def create_document():
|
||||
return client.post(
|
||||
f"/api/v1.0/documents/{document.id}/children/",
|
||||
{
|
||||
"title": "my child",
|
||||
},
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
future1 = executor.submit(create_document)
|
||||
future2 = executor.submit(create_document)
|
||||
|
||||
response1 = future1.result()
|
||||
response2 = future2.result()
|
||||
|
||||
assert response1.status_code == 201
|
||||
assert response2.status_code == 201
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.numchild == 2
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Tests for Documents API endpoint in impress's core app: create
|
||||
"""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@@ -51,6 +52,36 @@ def test_api_documents_create_authenticated_success():
|
||||
assert document.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_api_documents_create_document_race_condition():
|
||||
"""
|
||||
It should be possible to create several documents at the same time
|
||||
without causing any race conditions or data integrity issues.
|
||||
"""
|
||||
|
||||
def create_document(title):
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
return client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"title": title,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
future1 = executor.submit(create_document, "my document 1")
|
||||
future2 = executor.submit(create_document, "my document 2")
|
||||
|
||||
response1 = future1.result()
|
||||
response2 = future2.result()
|
||||
|
||||
assert response1.status_code == 201
|
||||
assert response2.status_code == 201
|
||||
|
||||
|
||||
def test_api_documents_create_authenticated_title_null():
|
||||
"""It should be possible to create several documents with a null title."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -4,6 +4,7 @@ Tests for Documents API endpoint in impress's core app: create
|
||||
|
||||
# pylint: disable=W0621
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core import mail
|
||||
@@ -425,6 +426,36 @@ def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplic
|
||||
assert document.creator == user
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_api_documents_create_document_race_condition():
|
||||
"""
|
||||
It should be possible to create several documents at the same time
|
||||
without causing any race conditions or data integrity issues.
|
||||
"""
|
||||
|
||||
def create_document(title):
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
return client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"title": title,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
future1 = executor.submit(create_document, "my document 1")
|
||||
future2 = executor.submit(create_document, "my document 2")
|
||||
|
||||
response1 = future1.result()
|
||||
response2 = future2.result()
|
||||
|
||||
assert response1.status_code == 201
|
||||
assert response2.status_code == 201
|
||||
|
||||
|
||||
@patch.object(ServerCreateDocumentSerializer, "_send_email_notification")
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"], LANGUAGE_CODE="de-de")
|
||||
def test_api_documents_create_for_owner_with_default_language(
|
||||
|
||||
@@ -28,8 +28,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"abilities": {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_transform": document.link_role == "editor",
|
||||
"ai_translate": document.link_role == "editor",
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": document.link_role == "editor",
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
@@ -84,8 +84,8 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"abilities": {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_transform": grand_parent.link_role == "editor",
|
||||
"ai_translate": grand_parent.link_role == "editor",
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": grand_parent.link_role == "editor",
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
|
||||
0
src/backend/core/tests/migrations/__init__.py
Normal file
0
src/backend/core/tests/migrations/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_blank_title_migration(migrator):
|
||||
"""
|
||||
Test that the migration fixes the titles of documents that are
|
||||
"Untitled document", "Unbenanntes Dokument" or "Document sans titre"
|
||||
"""
|
||||
migrator.apply_initial_migration(("core", "0017_add_fields_for_soft_delete"))
|
||||
|
||||
english_doc = factories.DocumentFactory(title="Untitled document")
|
||||
german_doc = factories.DocumentFactory(title="Unbenanntes Dokument")
|
||||
french_doc = factories.DocumentFactory(title="Document sans titre")
|
||||
other_doc = factories.DocumentFactory(title="My document")
|
||||
|
||||
assert english_doc.title == "Untitled document"
|
||||
assert german_doc.title == "Unbenanntes Dokument"
|
||||
assert french_doc.title == "Document sans titre"
|
||||
assert other_doc.title == "My document"
|
||||
|
||||
# Apply the migration
|
||||
migrator.apply_tested_migration(("core", "0018_update_blank_title"))
|
||||
|
||||
english_doc.refresh_from_db()
|
||||
german_doc.refresh_from_db()
|
||||
french_doc.refresh_from_db()
|
||||
other_doc.refresh_from_db()
|
||||
|
||||
assert english_doc.title == None
|
||||
assert german_doc.title == None
|
||||
assert french_doc.title == None
|
||||
assert other_doc.title == "My document"
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
@@ -124,6 +125,9 @@ 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",
|
||||
[
|
||||
@@ -175,6 +179,9 @@ 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",
|
||||
[
|
||||
@@ -243,8 +250,8 @@ def test_models_documents_get_abilities_editor(
|
||||
expected_abilities = {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"ai_transform": is_authenticated,
|
||||
"ai_translate": is_authenticated,
|
||||
"attachment_upload": True,
|
||||
"children_create": is_authenticated,
|
||||
"children_list": True,
|
||||
@@ -271,6 +278,9 @@ def test_models_documents_get_abilities_editor(
|
||||
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()
|
||||
@@ -300,12 +310,16 @@ 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()
|
||||
@@ -335,11 +349,15 @@ 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 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()
|
||||
@@ -369,23 +387,31 @@ 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 value in document.get_abilities(user).values())
|
||||
|
||||
|
||||
def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
|
||||
@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"])
|
||||
def test_models_documents_get_abilities_reader_user(
|
||||
ai_access_setting, 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,
|
||||
"ai_transform": access_from_link,
|
||||
"ai_translate": access_from_link,
|
||||
# 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",
|
||||
"attachment_upload": access_from_link,
|
||||
"children_create": access_from_link,
|
||||
"children_list": True,
|
||||
@@ -404,11 +430,14 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
}
|
||||
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())
|
||||
|
||||
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 value in document.get_abilities(user).values())
|
||||
|
||||
|
||||
def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
@@ -446,6 +475,44 @@ 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
|
||||
|
||||
@@ -516,7 +516,12 @@ 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,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-01-29 13:43+0000\n"
|
||||
"PO-Revision-Date: 2025-01-30 10:24\n"
|
||||
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
|
||||
"PO-Revision-Date: 2025-02-10 14:14\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -391,3 +391,24 @@ 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,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-01-29 13:43+0000\n"
|
||||
"PO-Revision-Date: 2025-01-30 10:24\n"
|
||||
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
|
||||
"PO-Revision-Date: 2025-02-10 14:14\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -391,3 +391,24 @@ 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,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-01-29 13:43+0000\n"
|
||||
"PO-Revision-Date: 2025-01-30 10:24\n"
|
||||
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
|
||||
"PO-Revision-Date: 2025-02-10 14:14\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -391,3 +391,24 @@ 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,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-01-29 13:43+0000\n"
|
||||
"PO-Revision-Date: 2025-01-30 10:24\n"
|
||||
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
|
||||
"PO-Revision-Date: 2025-02-10 14:14\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
@@ -391,3 +391,24 @@ 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 ""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -68,6 +68,7 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"django-test-migrations==1.4.0",
|
||||
"drf-spectacular-sidecar==2024.12.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
@@ -99,7 +100,6 @@ exclude = [
|
||||
"build",
|
||||
"venv",
|
||||
"__pycache__",
|
||||
"*/migrations/*",
|
||||
]
|
||||
line-length = 88
|
||||
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export const keyCloakSignIn = async (page: Page, browserName: string) => {
|
||||
export const keyCloakSignIn = async (
|
||||
page: Page,
|
||||
browserName: string,
|
||||
fromHome: boolean = true,
|
||||
) => {
|
||||
if (fromHome) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Proconnect Login' })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
const login = `user-e2e-${browserName}`;
|
||||
const password = `password-e2e-${browserName}`;
|
||||
|
||||
@@ -258,3 +269,8 @@ export const mockedAccesses = async (page: Page, json?: object) => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const expectLoginPage = async (page: Page) =>
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Collaborative writing' }),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -63,27 +63,6 @@ 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,
|
||||
@@ -161,3 +140,28 @@ 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,8 +24,6 @@ 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,3 +1,5 @@
|
||||
/* eslint-disable playwright/no-conditional-expect */
|
||||
/* eslint-disable playwright/no-conditional-in-test */
|
||||
import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
@@ -369,83 +371,76 @@ test.describe('Doc Editor', () => {
|
||||
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks the divider block', async ({ page, browserName }) => {
|
||||
await createDoc(page, 'divider-block', browserName, 1);
|
||||
[
|
||||
{ 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',
|
||||
});
|
||||
|
||||
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 goToGridDoc(page);
|
||||
|
||||
await expect(
|
||||
editor.locator('.bn-block-content[data-content-type="divider"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
await verifyDocName(page, 'Mocked document');
|
||||
|
||||
test('it checks the quote block', async ({ page, browserName }) => {
|
||||
await createDoc(page, 'divider-block', browserName, 1);
|
||||
await page.locator('.bn-block-outer').last().fill('Hello World');
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
// Trigger slash menu to show menu
|
||||
await editor.click();
|
||||
await editor.fill('/');
|
||||
await page.getByText('Quote', { exact: true }).click();
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.getByText('Hello').dblclick();
|
||||
|
||||
await expect(
|
||||
editor.locator('.bn-block-content[data-content-type="quote"]'),
|
||||
).toBeVisible();
|
||||
if (!ai_transform && !ai_translate) {
|
||||
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
|
||||
return;
|
||||
}
|
||||
|
||||
await editor.fill('Hello World');
|
||||
await page.getByRole('button', { name: 'AI' }).click();
|
||||
|
||||
await expect(editor.getByText('Hello World')).toHaveCSS(
|
||||
'font-style',
|
||||
'italic',
|
||||
);
|
||||
});
|
||||
if (ai_transform) {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Use as prompt' }),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Use as prompt' }),
|
||||
).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();
|
||||
if (ai_translate) {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Language' }),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Language' }),
|
||||
).toBeHidden();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,8 +41,16 @@ test.describe('Doc Export', () => {
|
||||
await expect(page.getByRole('button', { name: 'Download' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('it exports the doc to pdf', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
test('it exports the doc with pdf line break', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-editor-line-break',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
@@ -50,8 +58,20 @@ test.describe('Doc Export', () => {
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
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
|
||||
.getByRole('button', {
|
||||
@@ -69,9 +89,10 @@ test.describe('Doc Export', () => {
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfText = (await pdf(pdfBuffer)).text;
|
||||
const pdfData = await pdf(pdfBuffer);
|
||||
|
||||
expect(pdfText).toContain('Hello World'); // This is the doc text
|
||||
expect(pdfData.numpages).toBe(2);
|
||||
expect(pdfData.text).toContain('\n\nHello\n\nWorld'); // This is the doc text
|
||||
});
|
||||
|
||||
test('it exports the doc to docx', async ({ page, browserName }) => {
|
||||
|
||||
@@ -213,7 +213,6 @@ test.describe('Document grid item options', () => {
|
||||
test.describe('Documents filters', () => {
|
||||
test('it checks the prebuild left panel filters', async ({ page }) => {
|
||||
// All Docs
|
||||
await expect(page.getByTestId('grid-loader')).toBeVisible();
|
||||
const response = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('documents/?page=1') &&
|
||||
@@ -254,7 +253,6 @@ 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') &&
|
||||
@@ -270,7 +268,6 @@ 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') &&
|
||||
@@ -291,8 +288,6 @@ test.describe('Documents Grid', () => {
|
||||
|
||||
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') &&
|
||||
|
||||
@@ -395,9 +395,7 @@ test.describe('Doc Header', () => {
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
const clipboardContent = await handle.jsonValue();
|
||||
expect(clipboardContent.trim()).toBe(
|
||||
`<h1 data-level=\"1\">Hello World</h1><p></p>`,
|
||||
);
|
||||
expect(clipboardContent.trim()).toBe(`<h1>Hello World</h1><p></p>`);
|
||||
});
|
||||
|
||||
test('it checks the copy link button', async ({ page }) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { keyCloakSignIn, mockedDocument } from './common';
|
||||
import { expectLoginPage, keyCloakSignIn, mockedDocument } from './common';
|
||||
|
||||
test.describe('Doc Routing', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -63,16 +63,13 @@ 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);
|
||||
await keyCloakSignIn(page, browserName, false);
|
||||
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 expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Sign In',
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expectLoginPage(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, keyCloakSignIn, verifyDocName } from './common';
|
||||
import {
|
||||
createDoc,
|
||||
expectLoginPage,
|
||||
keyCloakSignIn,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
|
||||
const browsersName = ['chromium', 'webkit', 'firefox'];
|
||||
|
||||
@@ -91,7 +96,7 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
|
||||
await expectLoginPage(page);
|
||||
|
||||
await page.goto(urlDoc);
|
||||
|
||||
@@ -121,6 +126,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(
|
||||
@@ -169,10 +178,11 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
|
||||
await keyCloakSignIn(page, otherBrowser!);
|
||||
|
||||
await page.goto(urlDoc);
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Docs Logo Docs BETA' }),
|
||||
).toBeVisible();
|
||||
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await verifyDocName(page, docTitle);
|
||||
await expect(page.getByLabel('Share button')).toBeVisible();
|
||||
@@ -247,7 +257,7 @@ test.describe('Doc Visibility: Public', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
|
||||
await expectLoginPage(page);
|
||||
|
||||
await page.goto(urlDoc);
|
||||
|
||||
@@ -313,7 +323,7 @@ test.describe('Doc Visibility: Public', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
|
||||
await expectLoginPage(page);
|
||||
|
||||
await page.goto(urlDoc);
|
||||
|
||||
@@ -364,7 +374,7 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
|
||||
await expectLoginPage(page);
|
||||
|
||||
await page.goto(urlDoc);
|
||||
|
||||
@@ -414,6 +424,10 @@ 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();
|
||||
@@ -470,6 +484,10 @@ 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,13 +1,10 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { goToGridDoc } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Footer', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
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();
|
||||
@@ -47,12 +44,6 @@ 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/' },
|
||||
@@ -60,6 +51,8 @@ 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 { keyCloakSignIn } from './common';
|
||||
import { expectLoginPage, 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.getByAltText('Docs Logo')).toBeVisible();
|
||||
await expect(header.getByLabel('Docs Logo')).toBeVisible();
|
||||
await expect(header.locator('h2').getByText('Docs')).toHaveCSS(
|
||||
'color',
|
||||
'rgb(0, 0, 145)',
|
||||
@@ -88,6 +88,7 @@ test.describe('Header mobile', () => {
|
||||
test.describe('Header: Log out', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test('checks logout button', async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, browserName);
|
||||
@@ -98,6 +99,6 @@ test.describe('Header: Log out', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
|
||||
await expectLoginPage(page);
|
||||
});
|
||||
});
|
||||
|
||||
52
src/frontend/apps/e2e/__tests__/app-impress/home.spec.ts
Normal file
52
src/frontend/apps/e2e/__tests__/app-impress/home.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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('combobox', { 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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.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.0",
|
||||
"@playwright/test": "1.50.1",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.4",
|
||||
"eslint-config-impress": "*",
|
||||
|
||||
@@ -5,6 +5,7 @@ const config = {
|
||||
colors: {
|
||||
'card-border': '#ededed',
|
||||
'primary-bg': '#FAFAFA',
|
||||
'primary-action': '#1212FF',
|
||||
'primary-050': '#F5F5FE',
|
||||
'primary-100': '#EDF5FA',
|
||||
'primary-150': '#E5EEFA',
|
||||
@@ -59,6 +60,11 @@ 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,
|
||||
@@ -224,7 +230,7 @@ const config = {
|
||||
'color-hover': 'var(--c--theme--colors--primary-700)',
|
||||
},
|
||||
border: {
|
||||
color: 'var(--c--theme--colors--primary-200)',
|
||||
color: 'var(--c--theme--colors--greyscale-300)',
|
||||
},
|
||||
},
|
||||
tertiary: {
|
||||
@@ -247,6 +253,9 @@ const config = {
|
||||
'la-gauffre': {
|
||||
activated: false,
|
||||
},
|
||||
'home-proconnect': {
|
||||
activated: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
dsfr: {
|
||||
@@ -379,8 +388,8 @@ const config = {
|
||||
'color-active': '#EDEDED',
|
||||
},
|
||||
border: {
|
||||
color: 'var(--c--theme--colors--primary-600)',
|
||||
'color-hover': 'var(--c--theme--colors--primary-600)',
|
||||
color: 'var(--c--theme--colors--greyscale-300)',
|
||||
'color-hover': 'var(--c--theme--colors--greyscale-300)',
|
||||
},
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
@@ -462,6 +471,9 @@ const config = {
|
||||
'la-gauffre': {
|
||||
activated: true,
|
||||
},
|
||||
'home-proconnect': {
|
||||
activated: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -15,34 +15,34 @@
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"@gouvfr-lasuite/integration": "1.0.2",
|
||||
"@hocuspocus/provider": "2.15.1",
|
||||
"@hocuspocus/provider": "2.15.2",
|
||||
"@openfun/cunningham-react": "2.9.4",
|
||||
"@react-pdf/renderer": "4.1.6",
|
||||
"@sentry/nextjs": "8.52.0",
|
||||
"@tanstack/react-query": "5.65.1",
|
||||
"@sentry/nextjs": "8.54.0",
|
||||
"@tanstack/react-query": "5.66.0",
|
||||
"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.1",
|
||||
"idb": "8.0.2",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.5.0",
|
||||
"next": "15.1.6",
|
||||
"posthog-js": "1.211.3",
|
||||
"posthog-js": "1.215.6",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.6.0",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "15.4.0",
|
||||
"react-intersection-observer": "9.15.1",
|
||||
"react-select": "5.10.0",
|
||||
"styled-components": "6.1.14",
|
||||
"styled-components": "6.1.15",
|
||||
"use-debounce": "10.0.4",
|
||||
"y-protocols": "1.0.6",
|
||||
"yjs": "13.6.23",
|
||||
@@ -50,7 +50,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.65.1",
|
||||
"@tanstack/react-query-devtools": "5.66.0",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "6.6.3",
|
||||
"@testing-library/react": "16.2.0",
|
||||
|
||||
BIN
src/frontend/apps/impress/public/assets/SC1-en.webm
Normal file
BIN
src/frontend/apps/impress/public/assets/SC1-en.webm
Normal file
Binary file not shown.
BIN
src/frontend/apps/impress/public/assets/SC1-fr.webm
Normal file
BIN
src/frontend/apps/impress/public/assets/SC1-fr.webm
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 5.3 KiB |
12
src/frontend/apps/impress/src/assets/icons/icon-docs.svg
Normal file
12
src/frontend/apps/impress/src/assets/icons/icon-docs.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -3,10 +3,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Auth } from '@/features/auth';
|
||||
import '@/i18n/initI18n';
|
||||
import { useResponsiveStore } from '@/stores/';
|
||||
|
||||
import { Auth } from './auth/';
|
||||
import { ConfigProvider } from './config/';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { PropsWithChildren, useEffect, useState } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
import { useAuthStore } from './useAuthStore';
|
||||
|
||||
/**
|
||||
* TODO: Remove this restriction when we will have a homepage design for non-authenticated users.
|
||||
*
|
||||
* We define the paths that are not allowed without authentication.
|
||||
* Actually, only the home page and the docs page are not allowed without authentication.
|
||||
* When we will have a homepage design for non-authenticated users, we will remove this restriction to have
|
||||
* the full website accessible without authentication.
|
||||
*/
|
||||
const regexpUrlsAuth = [/\/docs\/$/g, /^\/$/g];
|
||||
|
||||
export const Auth = ({ children }: PropsWithChildren) => {
|
||||
const { initAuth, initiated, authenticated, login, getAuthUrl } =
|
||||
useAuthStore();
|
||||
const { asPath, replace } = useRouter();
|
||||
|
||||
const [pathAllowed, setPathAllowed] = useState<boolean>(
|
||||
!regexpUrlsAuth.some((regexp) => !!asPath.match(regexp)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
initAuth();
|
||||
}, [initAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
setPathAllowed(!regexpUrlsAuth.some((regexp) => !!asPath.match(regexp)));
|
||||
}, [asPath]);
|
||||
|
||||
// We force to login except on allowed paths
|
||||
useEffect(() => {
|
||||
if (!initiated || authenticated || pathAllowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
login();
|
||||
}, [authenticated, pathAllowed, login, initiated]);
|
||||
|
||||
// Redirect to the path before login
|
||||
useEffect(() => {
|
||||
if (!authenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authUrl = getAuthUrl();
|
||||
if (authUrl) {
|
||||
void replace(authUrl);
|
||||
}
|
||||
}, [authenticated, getAuthUrl, replace]);
|
||||
|
||||
if ((!initiated && pathAllowed) || (!authenticated && !pathAllowed)) {
|
||||
return (
|
||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthStore } from '@/core/auth';
|
||||
|
||||
export const ButtonLogin = () => {
|
||||
const { t } = useTranslation();
|
||||
const { logout, authenticated, login } = useAuthStore();
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<Button onClick={login} color="primary-text" aria-label={t('Login')}>
|
||||
{t('Login')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={logout} color="primary-text" aria-label={t('Logout')}>
|
||||
{t('Logout')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './getMe';
|
||||
export * from './types';
|
||||
@@ -1 +0,0 @@
|
||||
export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth';
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './api/types';
|
||||
export * from './Auth';
|
||||
export * from './ButtonLogin';
|
||||
export * from './useAuthStore';
|
||||
@@ -1,64 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { baseApiUrl } from '@/api';
|
||||
import { terminateCrispSession } from '@/services';
|
||||
|
||||
import { User, getMe } from './api';
|
||||
import { PATH_AUTH_LOCAL_STORAGE } from './conf';
|
||||
|
||||
interface AuthStore {
|
||||
initiated: boolean;
|
||||
authenticated: boolean;
|
||||
initAuth: () => void;
|
||||
logout: () => void;
|
||||
login: () => void;
|
||||
setAuthUrl: (url: string) => void;
|
||||
getAuthUrl: () => string | undefined;
|
||||
userData?: User;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
initiated: false,
|
||||
authenticated: false,
|
||||
userData: undefined,
|
||||
};
|
||||
|
||||
export const useAuthStore = create<AuthStore>((set, get) => ({
|
||||
initiated: initialState.initiated,
|
||||
authenticated: initialState.authenticated,
|
||||
userData: initialState.userData,
|
||||
initAuth: () => {
|
||||
getMe()
|
||||
.then((data: User) => {
|
||||
set({ authenticated: true, userData: data });
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
set({ initiated: true });
|
||||
});
|
||||
},
|
||||
login: () => {
|
||||
get().setAuthUrl(window.location.pathname);
|
||||
|
||||
window.location.replace(`${baseApiUrl()}authenticate/`);
|
||||
},
|
||||
logout: () => {
|
||||
terminateCrispSession();
|
||||
window.location.replace(`${baseApiUrl()}logout/`);
|
||||
},
|
||||
// If we try to access a specific page and we are not authenticated
|
||||
// we store the path in the local storage to redirect to it after login
|
||||
setAuthUrl() {
|
||||
if (window.location.pathname !== '/') {
|
||||
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);
|
||||
}
|
||||
},
|
||||
// If a path is stored in the local storage, we return it then remove it
|
||||
getAuthUrl() {
|
||||
const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE);
|
||||
if (path_auth) {
|
||||
localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE);
|
||||
return path_auth;
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './AppProvider';
|
||||
export * from './auth';
|
||||
export * from './config';
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
--c--theme--colors--danger-text: var(--c--theme--colors--greyscale-000);
|
||||
--c--theme--colors--card-border: #ededed;
|
||||
--c--theme--colors--primary-bg: #fafafa;
|
||||
--c--theme--colors--primary-action: #1212ff;
|
||||
--c--theme--colors--primary-050: #f5f5fe;
|
||||
--c--theme--colors--primary-150: #e5eefa;
|
||||
--c--theme--colors--primary-950: #1b1b35;
|
||||
@@ -122,6 +123,11 @@
|
||||
--c--theme--font--sizes--ml: 0.938rem;
|
||||
--c--theme--font--sizes--xl: 1.25rem;
|
||||
--c--theme--font--sizes--t: 0.6875rem;
|
||||
--c--theme--font--sizes--xl-alt: 5rem;
|
||||
--c--theme--font--sizes--lg-alt: 4.5rem;
|
||||
--c--theme--font--sizes--md-alt: 4rem;
|
||||
--c--theme--font--sizes--sm-alt: 3.5rem;
|
||||
--c--theme--font--sizes--xs-alt: 3rem;
|
||||
--c--theme--font--weights--thin: 100;
|
||||
--c--theme--font--weights--light: 300;
|
||||
--c--theme--font--weights--regular: 400;
|
||||
@@ -316,7 +322,7 @@
|
||||
--c--theme--colors--primary-700
|
||||
);
|
||||
--c--components--button--secondary--border--color: var(
|
||||
--c--theme--colors--primary-200
|
||||
--c--theme--colors--greyscale-300
|
||||
);
|
||||
--c--components--button--tertiary--color: var(
|
||||
--c--theme--colors--primary-text
|
||||
@@ -339,6 +345,7 @@
|
||||
--c--components--button--disabled--color: white;
|
||||
--c--components--button--disabled--background--color: #b3cef0;
|
||||
--c--components--la-gauffre--activated: false;
|
||||
--c--components--home-proconnect--activated: false;
|
||||
}
|
||||
|
||||
.cunningham-theme--dark {
|
||||
@@ -501,10 +508,10 @@
|
||||
--c--components--button--secondary--background--color-hover: #f6f6f6;
|
||||
--c--components--button--secondary--background--color-active: #ededed;
|
||||
--c--components--button--secondary--border--color: var(
|
||||
--c--theme--colors--primary-600
|
||||
--c--theme--colors--greyscale-300
|
||||
);
|
||||
--c--components--button--secondary--border--color-hover: var(
|
||||
--c--theme--colors--primary-600
|
||||
--c--theme--colors--greyscale-300
|
||||
);
|
||||
--c--components--button--secondary--color: var(
|
||||
--c--theme--colors--primary-text
|
||||
@@ -584,6 +591,7 @@
|
||||
);
|
||||
--c--components--forms-textarea--border-radius: 0;
|
||||
--c--components--la-gauffre--activated: true;
|
||||
--c--components--home-proconnect--activated: true;
|
||||
}
|
||||
|
||||
.clr-secondary-text {
|
||||
@@ -874,6 +882,10 @@
|
||||
color: var(--c--theme--colors--primary-bg);
|
||||
}
|
||||
|
||||
.clr-primary-action {
|
||||
color: var(--c--theme--colors--primary-action);
|
||||
}
|
||||
|
||||
.clr-primary-050 {
|
||||
color: var(--c--theme--colors--primary-050);
|
||||
}
|
||||
@@ -1302,6 +1314,10 @@
|
||||
background-color: var(--c--theme--colors--primary-bg);
|
||||
}
|
||||
|
||||
.bg-primary-action {
|
||||
background-color: var(--c--theme--colors--primary-action);
|
||||
}
|
||||
|
||||
.bg-primary-050 {
|
||||
background-color: var(--c--theme--colors--primary-050);
|
||||
}
|
||||
@@ -1550,6 +1566,31 @@
|
||||
letter-spacing: var(--c--theme--font--letterspacings--t);
|
||||
}
|
||||
|
||||
.fs-xl-alt {
|
||||
font-size: var(--c--theme--font--sizes--xl-alt);
|
||||
letter-spacing: var(--c--theme--font--letterspacings--xl-alt);
|
||||
}
|
||||
|
||||
.fs-lg-alt {
|
||||
font-size: var(--c--theme--font--sizes--lg-alt);
|
||||
letter-spacing: var(--c--theme--font--letterspacings--lg-alt);
|
||||
}
|
||||
|
||||
.fs-md-alt {
|
||||
font-size: var(--c--theme--font--sizes--md-alt);
|
||||
letter-spacing: var(--c--theme--font--letterspacings--md-alt);
|
||||
}
|
||||
|
||||
.fs-sm-alt {
|
||||
font-size: var(--c--theme--font--sizes--sm-alt);
|
||||
letter-spacing: var(--c--theme--font--letterspacings--sm-alt);
|
||||
}
|
||||
|
||||
.fs-xs-alt {
|
||||
font-size: var(--c--theme--font--sizes--xs-alt);
|
||||
letter-spacing: var(--c--theme--font--letterspacings--xs-alt);
|
||||
}
|
||||
|
||||
.f-base {
|
||||
font-family: var(--c--theme--font--families--base);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export const tokens = {
|
||||
'danger-text': '#fff',
|
||||
'card-border': '#ededed',
|
||||
'primary-bg': '#FAFAFA',
|
||||
'primary-action': '#1212FF',
|
||||
'primary-050': '#F5F5FE',
|
||||
'primary-150': '#E5EEFA',
|
||||
'primary-950': '#1B1B35',
|
||||
@@ -129,6 +130,11 @@ export const tokens = {
|
||||
ml: '0.938rem',
|
||||
xl: '1.25rem',
|
||||
t: '0.6875rem',
|
||||
'xl-alt': '5rem',
|
||||
'lg-alt': '4.5rem',
|
||||
'md-alt': '4rem',
|
||||
'sm-alt': '3.5rem',
|
||||
'xs-alt': '3rem',
|
||||
},
|
||||
weights: {
|
||||
thin: 100,
|
||||
@@ -315,7 +321,7 @@ export const tokens = {
|
||||
color: 'white',
|
||||
'color-hover': 'var(--c--theme--colors--primary-700)',
|
||||
},
|
||||
border: { color: 'var(--c--theme--colors--primary-200)' },
|
||||
border: { color: 'var(--c--theme--colors--greyscale-300)' },
|
||||
},
|
||||
tertiary: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
@@ -330,6 +336,7 @@ export const tokens = {
|
||||
disabled: { color: 'white', background: { color: '#b3cef0' } },
|
||||
},
|
||||
'la-gauffre': { activated: false },
|
||||
'home-proconnect': { activated: false },
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
@@ -502,8 +509,8 @@ export const tokens = {
|
||||
secondary: {
|
||||
background: { 'color-hover': '#F6F6F6', 'color-active': '#EDEDED' },
|
||||
border: {
|
||||
color: 'var(--c--theme--colors--primary-600)',
|
||||
'color-hover': 'var(--c--theme--colors--primary-600)',
|
||||
color: 'var(--c--theme--colors--greyscale-300)',
|
||||
'color-hover': 'var(--c--theme--colors--greyscale-300)',
|
||||
},
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
@@ -575,6 +582,7 @@ export const tokens = {
|
||||
},
|
||||
'forms-textarea': { 'border-radius': '0' },
|
||||
'la-gauffre': { activated: true },
|
||||
'home-proconnect': { activated: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Crisp } from 'crisp-sdk-web';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { useAuthStore } from '../useAuthStore';
|
||||
import { gotoLogout } from '../utils';
|
||||
|
||||
jest.mock('crisp-sdk-web', () => ({
|
||||
...jest.requireActual('crisp-sdk-web'),
|
||||
@@ -17,7 +17,7 @@ jest.mock('crisp-sdk-web', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
describe('utils', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fetchMock.restore();
|
||||
@@ -33,7 +33,7 @@ describe('useAuthStore', () => {
|
||||
writable: true,
|
||||
});
|
||||
|
||||
useAuthStore.getState().logout();
|
||||
gotoLogout();
|
||||
|
||||
expect(Crisp.session.reset).toHaveBeenCalled();
|
||||
});
|
||||
2
src/frontend/apps/impress/src/features/auth/api/index.ts
Normal file
2
src/frontend/apps/impress/src/features/auth/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './useAuthQuery';
|
||||
export * from './types';
|
||||
@@ -1,4 +1,6 @@
|
||||
import { fetchAPI } from '@/api';
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, fetchAPI } from '@/api';
|
||||
|
||||
import { User } from './types';
|
||||
|
||||
@@ -19,3 +21,16 @@ export const getMe = async (): Promise<User> => {
|
||||
}
|
||||
return response.json() as Promise<User>;
|
||||
};
|
||||
|
||||
export const KEY_AUTH = 'auth';
|
||||
|
||||
export function useAuthQuery(
|
||||
queryConfig?: UseQueryOptions<User, APIError, User>,
|
||||
) {
|
||||
return useQuery<User, APIError, User>({
|
||||
queryKey: [KEY_AUTH],
|
||||
queryFn: getMe,
|
||||
staleTime: 1000 * 60 * 15, // 15 minutes
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,47 @@
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
import { useAuth } from '../hooks';
|
||||
|
||||
export const Auth = ({ children }: PropsWithChildren) => {
|
||||
const { isLoading, pathAllowed, isFetchedAfterMount, authenticated } =
|
||||
useAuth();
|
||||
const { replace, pathname } = useRouter();
|
||||
|
||||
if (isLoading && !isFetchedAfterMount) {
|
||||
return (
|
||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user is not authenticated and the path is not allowed, we redirect to the login page.
|
||||
*/
|
||||
if (!authenticated && !pathAllowed) {
|
||||
void replace('/login');
|
||||
return (
|
||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user is authenticated and the path is the login page, we redirect to the home page.
|
||||
*/
|
||||
if (pathname === '/login' && authenticated) {
|
||||
void replace('/');
|
||||
return (
|
||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { BoxButton } from '@/components';
|
||||
|
||||
import ProConnectImg from '../assets/button-proconnect.svg';
|
||||
import { useAuth } from '../hooks';
|
||||
import { gotoLogin, gotoLogout } from '../utils';
|
||||
|
||||
export const ButtonLogin = () => {
|
||||
const { t } = useTranslation();
|
||||
const { authenticated } = useAuth();
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<Button onClick={gotoLogin} color="primary-text" aria-label={t('Login')}>
|
||||
{t('Login')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={gotoLogout} color="primary-text" aria-label={t('Logout')}>
|
||||
{t('Logout')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProConnectButton = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<BoxButton
|
||||
onClick={gotoLogin}
|
||||
aria-label={t('Proconnect Login')}
|
||||
$css={css`
|
||||
background-color: var(--c--theme--colors--primary-text);
|
||||
&:hover {
|
||||
background-color: var(--c--theme--colors--primary-action);
|
||||
}
|
||||
`}
|
||||
$radius="4px"
|
||||
>
|
||||
<ProConnectImg />
|
||||
</BoxButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './Auth';
|
||||
export * from './ButtonLogin';
|
||||
5
src/frontend/apps/impress/src/features/auth/conf.ts
Normal file
5
src/frontend/apps/impress/src/features/auth/conf.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { baseApiUrl } from '@/api';
|
||||
|
||||
export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth';
|
||||
export const LOGIN_URL = `${baseApiUrl()}authenticate/`;
|
||||
export const LOGOUT_URL = `${baseApiUrl()}logout/`;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useAuth';
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useAuthQuery } from '../api';
|
||||
import { getAuthUrl } from '../utils';
|
||||
|
||||
const regexpUrlsAuth = [/\/docs\/$/g, /\/docs$/g, /^\/$/g];
|
||||
|
||||
export const useAuth = () => {
|
||||
const { data: user, ...authStates } = useAuthQuery();
|
||||
const { pathname, replace } = useRouter();
|
||||
|
||||
const [pathAllowed, setPathAllowed] = useState<boolean>(
|
||||
!regexpUrlsAuth.some((regexp) => !!pathname.match(regexp)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPathAllowed(!regexpUrlsAuth.some((regexp) => !!pathname.match(regexp)));
|
||||
}, [pathname]);
|
||||
|
||||
// Redirect to the path before login
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authUrl = getAuthUrl();
|
||||
if (authUrl) {
|
||||
void replace(authUrl);
|
||||
}
|
||||
}, [user, replace]);
|
||||
|
||||
return { user, authenticated: !!user, pathAllowed, ...authStates };
|
||||
};
|
||||
4
src/frontend/apps/impress/src/features/auth/index.ts
Normal file
4
src/frontend/apps/impress/src/features/auth/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './api/types';
|
||||
export * from './components';
|
||||
export * from './hooks';
|
||||
export * from './utils';
|
||||
27
src/frontend/apps/impress/src/features/auth/utils.ts
Normal file
27
src/frontend/apps/impress/src/features/auth/utils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { terminateCrispSession } from '@/services/Crisp';
|
||||
|
||||
import { LOGIN_URL, LOGOUT_URL, PATH_AUTH_LOCAL_STORAGE } from './conf';
|
||||
|
||||
export const getAuthUrl = () => {
|
||||
const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE);
|
||||
if (path_auth) {
|
||||
localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE);
|
||||
return path_auth;
|
||||
}
|
||||
};
|
||||
|
||||
export const setAuthUrl = () => {
|
||||
if (window.location.pathname !== '/') {
|
||||
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);
|
||||
}
|
||||
};
|
||||
|
||||
export const gotoLogin = () => {
|
||||
setAuthUrl();
|
||||
window.location.replace(LOGIN_URL);
|
||||
};
|
||||
|
||||
export const gotoLogout = () => {
|
||||
terminateCrispSession();
|
||||
window.location.replace(LOGOUT_URL);
|
||||
};
|
||||
@@ -92,6 +92,13 @@ export function AIGroupButton() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canAITransform = currentDoc.abilities.ai_transform;
|
||||
const canAITranslate = currentDoc.abilities.ai_translate;
|
||||
|
||||
if (!canAITransform && !canAITranslate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Components.Generic.Menu.Root>
|
||||
<Components.Generic.Menu.Trigger>
|
||||
@@ -111,79 +118,85 @@ export function AIGroupButton() {
|
||||
className="bn-menu-dropdown bn-drag-handle-menu"
|
||||
sub={true}
|
||||
>
|
||||
<AIMenuItemTransform
|
||||
action="prompt"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
text_fields
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Use as prompt')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="rephrase"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
refresh
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Rephrase')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="summarize"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
summarize
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Summarize')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="correct"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
check
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Correct')}
|
||||
</AIMenuItemTransform>
|
||||
<Components.Generic.Menu.Root position="right" sub={true}>
|
||||
<Components.Generic.Menu.Trigger sub={false}>
|
||||
<Components.Generic.Menu.Item
|
||||
className="bn-menu-item"
|
||||
subTrigger={true}
|
||||
>
|
||||
<Box $direction="row" $gap="0.6rem">
|
||||
{canAITransform && (
|
||||
<>
|
||||
<AIMenuItemTransform
|
||||
action="prompt"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
translate
|
||||
text_fields
|
||||
</Text>
|
||||
{t('Language')}
|
||||
</Box>
|
||||
</Components.Generic.Menu.Item>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
<Components.Generic.Menu.Dropdown
|
||||
sub={true}
|
||||
className="bn-menu-dropdown"
|
||||
>
|
||||
{languages.map((language) => (
|
||||
<AIMenuItemTranslate
|
||||
key={language.value}
|
||||
language={language.value}
|
||||
docId={currentDoc.id}
|
||||
}
|
||||
>
|
||||
{t('Use as prompt')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="rephrase"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
refresh
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Rephrase')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="summarize"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
summarize
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Summarize')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="correct"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
check
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Correct')}
|
||||
</AIMenuItemTransform>
|
||||
</>
|
||||
)}
|
||||
{canAITranslate && (
|
||||
<Components.Generic.Menu.Root position="right" sub={true}>
|
||||
<Components.Generic.Menu.Trigger sub={false}>
|
||||
<Components.Generic.Menu.Item
|
||||
className="bn-menu-item"
|
||||
subTrigger={true}
|
||||
>
|
||||
{language.display_name}
|
||||
</AIMenuItemTranslate>
|
||||
))}
|
||||
</Components.Generic.Menu.Dropdown>
|
||||
</Components.Generic.Menu.Root>
|
||||
<Box $direction="row" $gap="0.6rem">
|
||||
<Text $isMaterialIcon $size="s">
|
||||
translate
|
||||
</Text>
|
||||
{t('Language')}
|
||||
</Box>
|
||||
</Components.Generic.Menu.Item>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
<Components.Generic.Menu.Dropdown
|
||||
sub={true}
|
||||
className="bn-menu-dropdown"
|
||||
>
|
||||
{languages.map((language) => (
|
||||
<AIMenuItemTranslate
|
||||
key={language.value}
|
||||
language={language.value}
|
||||
docId={currentDoc.id}
|
||||
>
|
||||
{language.display_name}
|
||||
</AIMenuItemTranslate>
|
||||
))}
|
||||
</Components.Generic.Menu.Dropdown>
|
||||
</Components.Generic.Menu.Root>
|
||||
)}
|
||||
</Components.Generic.Menu.Dropdown>
|
||||
</Components.Generic.Menu.Root>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import {
|
||||
BlockNoteEditor as BlockNoteEditorCore,
|
||||
BlockNoteSchema,
|
||||
Dictionary,
|
||||
defaultBlockSpecs,
|
||||
locales,
|
||||
withPageBreak,
|
||||
} from '@blocknote/core';
|
||||
import '@blocknote/core/fonts/inter.css';
|
||||
import { BlockNoteView } from '@blocknote/mantine';
|
||||
@@ -15,7 +14,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, TextErrors } from '@/components';
|
||||
import { useAuthStore } from '@/core/auth';
|
||||
import { useAuth } from '@/features/auth';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
|
||||
import { useUploadFile } from '../hook';
|
||||
@@ -27,22 +26,8 @@ import { randomColor } from '../utils';
|
||||
|
||||
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
|
||||
import { BlockNoteToolbar } from './BlockNoteToolbar';
|
||||
import { AlertBlock, DividerBlock, QuoteBlock } from './custom-blocks';
|
||||
|
||||
export const schema = BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
alert: AlertBlock,
|
||||
quote: QuoteBlock,
|
||||
divider: DividerBlock,
|
||||
},
|
||||
});
|
||||
|
||||
export type DocsBlockNoteEditor = BlockNoteEditorCore<
|
||||
typeof schema.blockSchema,
|
||||
typeof schema.inlineContentSchema,
|
||||
typeof schema.styleSchema
|
||||
>;
|
||||
export const blockNoteSchema = withPageBreak(BlockNoteSchema.create());
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
doc: Doc;
|
||||
@@ -50,7 +35,7 @@ interface BlockNoteEditorProps {
|
||||
}
|
||||
|
||||
export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
const { userData } = useAuthStore();
|
||||
const { user } = useAuth();
|
||||
const { setEditor } = useEditorStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -63,7 +48,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
|
||||
const collabName = readOnly
|
||||
? 'Reader'
|
||||
: userData?.full_name || userData?.email || t('Anonymous');
|
||||
: user?.full_name || user?.email || t('Anonymous');
|
||||
const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity';
|
||||
|
||||
const editor = useCreateBlockNote(
|
||||
{
|
||||
@@ -75,34 +61,50 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
color: randomColor(),
|
||||
},
|
||||
/**
|
||||
* We re-use the blocknote code to render the cursor but we:
|
||||
* - fix rendering issue with Firefox
|
||||
* - We don't want to show the cursor when anonymous users
|
||||
* We render the cursor with a custom element to:
|
||||
* - fix rendering issue with the default cursor
|
||||
* - hide the cursor when anonymous users
|
||||
*/
|
||||
renderCursor: (user: { color: string; name: string }) => {
|
||||
const cursor = document.createElement('span');
|
||||
const cursorElement = document.createElement('span');
|
||||
|
||||
if (user.name === 'Reader') {
|
||||
return cursor;
|
||||
return cursorElement;
|
||||
}
|
||||
|
||||
cursor.classList.add('collaboration-cursor__caret');
|
||||
cursor.setAttribute('style', `border-color: ${user.color}`);
|
||||
cursorElement.classList.add('collaboration-cursor-custom__base');
|
||||
const caretElement = document.createElement('span');
|
||||
caretElement.classList.add('collaboration-cursor-custom__caret');
|
||||
caretElement.setAttribute('spellcheck', `false`);
|
||||
caretElement.setAttribute('style', `background-color: ${user.color}`);
|
||||
|
||||
const label = document.createElement('span');
|
||||
if (showCursorLabels === 'always') {
|
||||
cursorElement.setAttribute('data-active', '');
|
||||
}
|
||||
|
||||
label.classList.add('collaboration-cursor__label');
|
||||
label.setAttribute('style', `background-color: ${user.color}`);
|
||||
label.insertBefore(document.createTextNode(user.name), null);
|
||||
const labelElement = document.createElement('span');
|
||||
|
||||
cursor.insertBefore(label, null);
|
||||
labelElement.classList.add('collaboration-cursor-custom__label');
|
||||
labelElement.setAttribute('spellcheck', `false`);
|
||||
labelElement.setAttribute(
|
||||
'style',
|
||||
`background-color: ${user.color};border: 1px solid ${user.color};`,
|
||||
);
|
||||
labelElement.insertBefore(document.createTextNode(user.name), null);
|
||||
|
||||
return cursor;
|
||||
caretElement.insertBefore(labelElement, null);
|
||||
|
||||
cursorElement.insertBefore(document.createTextNode('\u2060'), null); // Non-breaking space
|
||||
cursorElement.insertBefore(caretElement, null);
|
||||
cursorElement.insertBefore(document.createTextNode('\u2060'), null); // Non-breaking space
|
||||
|
||||
return cursorElement;
|
||||
},
|
||||
showCursorLabels: showCursorLabels as 'always' | 'activity',
|
||||
},
|
||||
dictionary: locales[lang as keyof typeof locales] as Dictionary,
|
||||
schema,
|
||||
uploadFile,
|
||||
schema: blockNoteSchema,
|
||||
},
|
||||
[collabName, lang, provider, uploadFile],
|
||||
);
|
||||
@@ -135,12 +137,12 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
formattingToolbar={false}
|
||||
editable={!readOnly}
|
||||
slashMenu={false}
|
||||
editable={!readOnly}
|
||||
theme="light"
|
||||
>
|
||||
<BlockNoteSuggestionMenu />
|
||||
<BlockNoteToolbar />
|
||||
<BlockNoteSuggestionMenu />
|
||||
</BlockNoteView>
|
||||
</Box>
|
||||
);
|
||||
@@ -165,7 +167,7 @@ export const BlockNoteEditorVersion = ({
|
||||
},
|
||||
provider: undefined,
|
||||
},
|
||||
schema,
|
||||
schema: blockNoteSchema,
|
||||
},
|
||||
[initialContent],
|
||||
);
|
||||
|
||||
@@ -3,17 +3,15 @@ import '@blocknote/mantine/style.css';
|
||||
import {
|
||||
SuggestionMenuController,
|
||||
getDefaultReactSlashMenuItems,
|
||||
getPageBreakReactSlashMenuItems,
|
||||
useBlockNoteEditor,
|
||||
} from '@blocknote/react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { DocsBlockNoteEditor } from './BlockNoteEditor';
|
||||
import { insertAlert, insertDivider, insertQuote } from './custom-blocks';
|
||||
import { DocsBlockNoteEditor } from '../types';
|
||||
|
||||
export const BlockNoteSuggestionMenu = () => {
|
||||
const editor = useBlockNoteEditor() as DocsBlockNoteEditor;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getSlashMenuItems = useMemo(() => {
|
||||
return async (query: string) =>
|
||||
@@ -21,14 +19,12 @@ export const BlockNoteSuggestionMenu = () => {
|
||||
filterSuggestionItems(
|
||||
combineByGroup(
|
||||
getDefaultReactSlashMenuItems(editor),
|
||||
[insertAlert(editor, t)],
|
||||
[insertQuote(editor, t)],
|
||||
[insertDivider(editor, t)],
|
||||
getPageBreakReactSlashMenuItems(editor),
|
||||
),
|
||||
query,
|
||||
),
|
||||
);
|
||||
}, [editor, t]);
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<SuggestionMenuController
|
||||
|
||||
@@ -65,7 +65,7 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
|
||||
$css="overflow-x: clip; flex: 1;"
|
||||
$position="relative"
|
||||
>
|
||||
<Box $css="flex:1;" $overflow="auto" $position="relative">
|
||||
<Box $css="flex:1;" $position="relative" $width="100%">
|
||||
{isVersion ? (
|
||||
<DocVersionEditor docId={doc.id} versionId={versionId} />
|
||||
) : (
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
|
||||
import { createReactBlockSpec } from '@blocknote/react';
|
||||
import { Menu } from '@mantine/core';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../BlockNoteEditor';
|
||||
|
||||
// The types of alerts that users can choose from.
|
||||
export const alertTypes = [
|
||||
{
|
||||
title: 'Warning',
|
||||
value: 'warning',
|
||||
icon: 'warning',
|
||||
color: 'warning-500',
|
||||
backgroundColor: 'warning-300',
|
||||
},
|
||||
{
|
||||
title: 'Error',
|
||||
value: 'danger',
|
||||
icon: 'error',
|
||||
color: 'danger-500',
|
||||
backgroundColor: 'danger-300',
|
||||
},
|
||||
{
|
||||
title: 'Info',
|
||||
value: 'info',
|
||||
icon: 'info',
|
||||
color: 'info-500',
|
||||
backgroundColor: 'info-300',
|
||||
},
|
||||
{
|
||||
title: 'Success',
|
||||
value: 'success',
|
||||
icon: 'check_circle',
|
||||
color: 'success-500',
|
||||
backgroundColor: 'success-100',
|
||||
},
|
||||
] as const;
|
||||
|
||||
// The Alert block.
|
||||
export const AlertBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'alert',
|
||||
propSchema: {
|
||||
textAlignment: defaultProps.textAlignment,
|
||||
textColor: defaultProps.textColor,
|
||||
type: {
|
||||
default: 'warning',
|
||||
values: ['warning', 'danger', 'info', 'success'],
|
||||
},
|
||||
},
|
||||
content: 'inline',
|
||||
},
|
||||
{
|
||||
render: (props) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { t } = useTranslation();
|
||||
let alertType = alertTypes.find(
|
||||
(a) => a.value === props.block.props.type,
|
||||
);
|
||||
|
||||
if (!alertType) {
|
||||
alertType = alertTypes[0];
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="alert"
|
||||
data-alert-type={props.block.props.type}
|
||||
$direction="row"
|
||||
$justify="center"
|
||||
$align="center"
|
||||
$radius="4px"
|
||||
$padding="4px"
|
||||
$background={colorsTokens()[alertType.backgroundColor]}
|
||||
$minHeight="48px"
|
||||
$css={css`
|
||||
flex-grow: 1;
|
||||
`}
|
||||
>
|
||||
<Menu withinPortal={false}>
|
||||
<Menu.Target>
|
||||
<Box
|
||||
className="alert-icon-wrapper"
|
||||
$margin={{ horizontal: '12px' }}
|
||||
$radius="16px"
|
||||
$justify="center"
|
||||
$align="center"
|
||||
$height="24px"
|
||||
$width="24px"
|
||||
contentEditable={false}
|
||||
$css="user-select: none; cursor: pointer;"
|
||||
>
|
||||
<Text
|
||||
$isMaterialIcon
|
||||
$theme={alertType.value}
|
||||
$variation="500"
|
||||
$size="20px"
|
||||
>
|
||||
{alertType.icon}
|
||||
</Text>
|
||||
</Box>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ zIndex: 9999 }}>
|
||||
<Menu.Label>{t('Alert Type')}</Menu.Label>
|
||||
<Menu.Divider />
|
||||
{alertTypes.map((type) => (
|
||||
<Menu.Item
|
||||
key={type.value}
|
||||
leftSection={
|
||||
<Text
|
||||
$isMaterialIcon
|
||||
$color={colorsTokens()[type.color]}
|
||||
$size="16px"
|
||||
>
|
||||
{type.icon}
|
||||
</Text>
|
||||
}
|
||||
onClick={() =>
|
||||
props.editor.updateBlock(props.block, {
|
||||
type: 'alert',
|
||||
props: { type: type.value },
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(type.title)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<Box
|
||||
className="inline-content"
|
||||
$css={css`
|
||||
flex-grow: 1;
|
||||
& * {
|
||||
color: ${colorsTokens()[alertType.color]};
|
||||
}
|
||||
`}
|
||||
ref={props.contentRef}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const insertAlert = (
|
||||
editor: DocsBlockNoteEditor,
|
||||
t: TFunction<'translation', undefined>,
|
||||
) => ({
|
||||
title: t('Alert'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, {
|
||||
type: 'alert',
|
||||
});
|
||||
},
|
||||
aliases: [
|
||||
'alert',
|
||||
'notification',
|
||||
'emphasize',
|
||||
'warning',
|
||||
'error',
|
||||
'info',
|
||||
'success',
|
||||
],
|
||||
group: t('Others'),
|
||||
icon: (
|
||||
<Text $isMaterialIcon $size="18px">
|
||||
warning
|
||||
</Text>
|
||||
),
|
||||
subtext: t('Add a colored alert box'),
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
|
||||
import { createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../BlockNoteEditor';
|
||||
|
||||
export const DividerBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'divider',
|
||||
propSchema: {
|
||||
textAlignment: defaultProps.textAlignment,
|
||||
textColor: defaultProps.textColor,
|
||||
},
|
||||
content: 'none',
|
||||
},
|
||||
{
|
||||
render: () => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '2px',
|
||||
backgroundColor: colorsTokens()['greyscale-300'],
|
||||
margin: '1rem 0',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const insertDivider = (
|
||||
editor: DocsBlockNoteEditor,
|
||||
t: TFunction<'translation', undefined>,
|
||||
) => ({
|
||||
title: t('Divider'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, {
|
||||
type: 'divider',
|
||||
});
|
||||
},
|
||||
aliases: ['divider', 'hr', 'horizontal rule', 'line', 'separator'],
|
||||
group: t('Others'),
|
||||
icon: (
|
||||
<span className="material-icons" style={{ fontSize: '18px' }}>
|
||||
remove
|
||||
</span>
|
||||
),
|
||||
subtext: t('Add a horizontal line'),
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
|
||||
import { createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import React from 'react';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../BlockNoteEditor';
|
||||
|
||||
export const QuoteBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'quote',
|
||||
propSchema: {
|
||||
textAlignment: defaultProps.textAlignment,
|
||||
textColor: defaultProps.textColor,
|
||||
},
|
||||
content: 'inline',
|
||||
},
|
||||
{
|
||||
render: (props) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-content"
|
||||
style={{
|
||||
borderLeft: `4px solid ${colorsTokens()['greyscale-300']}`,
|
||||
margin: '0 0 1rem 0',
|
||||
padding: '0.5rem 1rem',
|
||||
color: colorsTokens()['greyscale-600'],
|
||||
fontStyle: 'italic',
|
||||
flexGrow: 1,
|
||||
}}
|
||||
ref={props.contentRef}
|
||||
/>
|
||||
);
|
||||
},
|
||||
parse: () => {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const insertQuote = (
|
||||
editor: DocsBlockNoteEditor,
|
||||
t: TFunction<'translation', undefined>,
|
||||
) => ({
|
||||
title: t('Quote'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, {
|
||||
type: 'quote',
|
||||
});
|
||||
},
|
||||
aliases: ['quote', 'blockquote', 'citation'],
|
||||
group: t('Others'),
|
||||
icon: (
|
||||
<span className="material-icons" style={{ fontSize: '18px' }}>
|
||||
format_quote
|
||||
</span>
|
||||
),
|
||||
subtext: t('Add a quote block'),
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './AlertBlock';
|
||||
export * from './DividerBlock';
|
||||
export * from './QuoteBlock';
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../components/BlockNoteEditor';
|
||||
import { useHeadingStore } from '../stores';
|
||||
import { DocsBlockNoteEditor } from '../types';
|
||||
|
||||
export const useHeadings = (editor: DocsBlockNoteEditor) => {
|
||||
const { setHeadings, resetHeadings } = useHeadingStore();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user