mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 23:22:15 +02:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
302e5a503f | ||
|
|
a1c497ef27 | ||
|
|
89a44e4979 | ||
|
|
993394d91f | ||
|
|
8f386402e8 | ||
|
|
c7c252f9a1 | ||
|
|
5c0e7b9043 | ||
|
|
e35671c450 | ||
|
|
15235a9bc2 | ||
|
|
b360bd8494 | ||
|
|
6a95d24441 | ||
|
|
e816f0afc8 | ||
|
|
7e8732822b | ||
|
|
9ed6b11bb1 | ||
|
|
62124ae475 | ||
|
|
c327928921 | ||
|
|
be26a9457f | ||
|
|
5dc43cbc8b | ||
|
|
9abf6888aa | ||
|
|
aff3b43c9d | ||
|
|
e8d95facdf | ||
|
|
a9f08df566 | ||
|
|
2fecbc1162 | ||
|
|
1fc3029d12 | ||
|
|
bbcb5e0cf1 | ||
|
|
e4a7ac0f3c | ||
|
|
24630791d8 | ||
|
|
97d00b678f | ||
|
|
52eb973164 | ||
|
|
789879a9cc | ||
|
|
52c52d53b7 | ||
|
|
54fe6a2319 | ||
|
|
bc5dcb0ed5 | ||
|
|
6c3f3f6a77 | ||
|
|
6e64bad1e2 | ||
|
|
0d5b2382ab |
126
.github/workflows/impress-frontend.yml
vendored
126
.github/workflows/impress-frontend.yml
vendored
@@ -39,29 +39,6 @@ jobs:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
build-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
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: Build CI App
|
||||
run: cd src/frontend/ && yarn ci:build
|
||||
|
||||
- name: Cache build frontend
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: src/frontend/apps/impress/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
|
||||
test-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
@@ -98,25 +75,11 @@ jobs:
|
||||
|
||||
test-e2e-chromium:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-front
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set services env variables
|
||||
run: |
|
||||
make data/media
|
||||
make create-env-files
|
||||
cat env.d/development/common.e2e.dist >> env.d/development/common
|
||||
|
||||
- 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: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
@@ -124,42 +87,11 @@ jobs:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Restore the build cache
|
||||
uses: actions/cache@v4
|
||||
id: cache-build
|
||||
with:
|
||||
path: src/frontend/apps/impress/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build the Docker images
|
||||
uses: docker/bake-action@v4
|
||||
with:
|
||||
targets: |
|
||||
app-dev
|
||||
y-provider
|
||||
load: true
|
||||
set: |
|
||||
*.cache-from=type=gha,scope=cached-stage
|
||||
*.cache-to=type=gha,scope=cached-stage,mode=max
|
||||
- name: Set e2e env variables
|
||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||
|
||||
- name: Start Docker services
|
||||
run: |
|
||||
make run
|
||||
|
||||
- name: Start Nginx for the frontend
|
||||
run: |
|
||||
docker compose up --force-recreate -d nginx-front
|
||||
|
||||
- name: Apply DRF migrations
|
||||
run: |
|
||||
make migrate
|
||||
|
||||
- name: Add dummy data
|
||||
run: |
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
|
||||
@@ -176,25 +108,12 @@ jobs:
|
||||
|
||||
test-e2e-other-browser:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-front
|
||||
needs: test-e2e-chromium
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set services env variables
|
||||
run: |
|
||||
make data/media
|
||||
make create-env-files
|
||||
cat env.d/development/common.e2e.dist >> env.d/development/common
|
||||
|
||||
- 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: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
@@ -202,42 +121,11 @@ jobs:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Restore the build cache
|
||||
uses: actions/cache@v4
|
||||
id: cache-build
|
||||
with:
|
||||
path: src/frontend/apps/impress/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build the Docker images
|
||||
uses: docker/bake-action@v4
|
||||
with:
|
||||
targets: |
|
||||
app-dev
|
||||
y-provider
|
||||
load: true
|
||||
set: |
|
||||
*.cache-from=type=gha,scope=cached-stage
|
||||
*.cache-to=type=gha,scope=cached-stage,mode=max
|
||||
- name: Set e2e env variables
|
||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||
|
||||
- name: Start Docker services
|
||||
run: |
|
||||
make run
|
||||
|
||||
- name: Start Nginx for the frontend
|
||||
run: |
|
||||
docker compose up --force-recreate -d nginx-front
|
||||
|
||||
- name: Apply DRF migrations
|
||||
run: |
|
||||
make migrate
|
||||
|
||||
- name: Add dummy data
|
||||
run: |
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install-playwright firefox webkit chromium
|
||||
|
||||
37
CHANGELOG.md
37
CHANGELOG.md
@@ -9,6 +9,39 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## Added
|
||||
|
||||
- 📝Contributing.md #352
|
||||
|
||||
|
||||
## [1.6.0] - 2024-10-17
|
||||
|
||||
## Added
|
||||
|
||||
- ✨AI to doc editor #250
|
||||
- ✨(backend) allow uploading more types of attachments #309
|
||||
- ✨(frontend) add buttons to copy document to clipboard as HTML/Markdown #300
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️(frontend) More multi theme friendly #325
|
||||
- ♻️ Bootstrap frontend #257
|
||||
- ♻️ Add username in email #314
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🛂(backend) do not duplicate user when disabled
|
||||
- 🐛(frontend) invalidate queries after removing user #336
|
||||
- 🐛(backend) Fix dysfunctional permissions on document create #329
|
||||
- 🐛(backend) fix nginx docker container #340
|
||||
- 🐛(frontend) fix copy paste firefox #353
|
||||
|
||||
|
||||
## [1.5.1] - 2024-10-10
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(db) fix users duplicate #316
|
||||
|
||||
## [1.5.0] - 2024-10-09
|
||||
|
||||
@@ -182,7 +215,9 @@ and this project adheres to
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.5.0...main
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.6.0...main
|
||||
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
|
||||
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
|
||||
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
|
||||
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
|
||||
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
|
||||
|
||||
54
CONTRIBUTING.md
Normal file
54
CONTRIBUTING.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Contributing to the Project
|
||||
|
||||
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
|
||||
|
||||
To get started with the project, please refer to the [README.md](https://github.com/numerique-gouv/impress/blob/main/README.md) for detailed instructions.
|
||||
|
||||
## Creating an Issue
|
||||
|
||||
When creating an issue, please provide the following details:
|
||||
|
||||
1. **Title**: A concise and descriptive title for the issue.
|
||||
2. **Description**: A detailed explanation of the issue, including relevant context or screenshots if applicable.
|
||||
3. **Steps to Reproduce**: If the issue is a bug, include the steps needed to reproduce the problem.
|
||||
4. **Expected vs. Actual Behavior**: Describe what you expected to happen and what actually happened.
|
||||
5. **Labels**: Add appropriate labels to categorize the issue (e.g., bug, feature request, documentation).
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
All commit messages must adhere to the following format:
|
||||
|
||||
`<gitmoji>(type) title description`
|
||||
|
||||
* **<gitmoji>**: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
|
||||
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
|
||||
* **title**: A short, descriptive title for the change, starting with a lowercase character.
|
||||
* **description**: Include additional details about what was changed and why.
|
||||
|
||||
### Example Commit Message
|
||||
|
||||
```
|
||||
✨(frontend) add user authentication logic
|
||||
|
||||
Implemented login and signup features, and integrated OAuth2 for social login.
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes.
|
||||
|
||||
Once all the required tests have passed, you can request a review from the project maintainers.
|
||||
|
||||
## Code Style
|
||||
|
||||
Please maintain consistency in code style. Run any linting tools available to make sure the code is clean and follows the project's conventions.
|
||||
|
||||
## Tests
|
||||
|
||||
Make sure that all new features or fixes have corresponding tests. Run the test suite before pushing your changes to ensure that nothing is broken.
|
||||
|
||||
## Asking for Help
|
||||
|
||||
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
|
||||
|
||||
Thank you for your contributions! 👍
|
||||
27
Dockerfile
27
Dockerfile
@@ -1,7 +1,7 @@
|
||||
# Django impress
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.12.6-alpine3.20 as base
|
||||
FROM python:3.12.6-alpine3.20 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
@@ -11,7 +11,7 @@ RUN apk update && \
|
||||
apk upgrade
|
||||
|
||||
# ---- Back-end builder image ----
|
||||
FROM base as back-builder
|
||||
FROM base AS back-builder
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
@@ -23,7 +23,7 @@ RUN mkdir /install && \
|
||||
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 as mail-builder
|
||||
FROM node:20 AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
@@ -34,7 +34,7 @@ RUN yarn install --frozen-lockfile && \
|
||||
|
||||
|
||||
# ---- static link collector ----
|
||||
FROM base as link-collector
|
||||
FROM base AS link-collector
|
||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||
|
||||
# Install pango & rdfind
|
||||
@@ -59,20 +59,21 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
||||
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${IMPRESS_STATIC_ROOT}
|
||||
|
||||
# ---- Core application image ----
|
||||
FROM base as core
|
||||
FROM base AS core
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install required system libs
|
||||
RUN apk add \
|
||||
gettext \
|
||||
cairo \
|
||||
libffi-dev \
|
||||
gdk-pixbuf \
|
||||
pango \
|
||||
pandoc \
|
||||
font-noto-emoji \
|
||||
file \
|
||||
font-noto \
|
||||
font-noto-emoji \
|
||||
gettext \
|
||||
gdk-pixbuf \
|
||||
libffi-dev \
|
||||
pandoc \
|
||||
pango \
|
||||
shared-mime-info
|
||||
|
||||
# Copy entrypoint
|
||||
@@ -97,7 +98,7 @@ WORKDIR /app
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
# ---- Development image ----
|
||||
FROM core as backend-development
|
||||
FROM core AS backend-development
|
||||
|
||||
# Switch back to the root user to install development dependencies
|
||||
USER root:root
|
||||
@@ -123,7 +124,7 @@ ENV DB_HOST=postgresql \
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
|
||||
# ---- Production image ----
|
||||
FROM core as backend-production
|
||||
FROM core AS backend-production
|
||||
|
||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||
|
||||
|
||||
46
Makefile
46
Makefile
@@ -81,7 +81,7 @@ bootstrap: \
|
||||
data/static \
|
||||
create-env-files \
|
||||
build \
|
||||
run-frontend-dev \
|
||||
run-with-frontend \
|
||||
migrate \
|
||||
demo \
|
||||
back-i18n-compile \
|
||||
@@ -90,11 +90,28 @@ bootstrap: \
|
||||
.PHONY: bootstrap
|
||||
|
||||
# -- Docker/compose
|
||||
build: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev --no-cache
|
||||
@$(COMPOSE) build frontend-dev --no-cache
|
||||
build: cache ?= --no-cache
|
||||
build: ## build the project containers
|
||||
@$(MAKE) build-backend cache=$(cache)
|
||||
@$(MAKE) build-yjs-provider cache=$(cache)
|
||||
@$(MAKE) build-frontend cache=$(cache)
|
||||
.PHONY: build
|
||||
|
||||
build-backend: cache ?=
|
||||
build-backend: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev $(cache)
|
||||
.PHONY: build-backend
|
||||
|
||||
build-yjs-provider: cache ?=
|
||||
build-yjs-provider: ## build the y-provider container
|
||||
@$(COMPOSE) build y-provider $(cache)
|
||||
.PHONY: build-yjs-provider
|
||||
|
||||
build-frontend: cache ?=
|
||||
build-frontend: ## build the frontend container
|
||||
@$(COMPOSE) build frontend-dev $(cache)
|
||||
.PHONY: build-frontend
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@$(COMPOSE) down
|
||||
.PHONY: down
|
||||
@@ -105,11 +122,17 @@ logs: ## display app-dev logs (follow mode)
|
||||
|
||||
run: ## start the wsgi (production) and development server
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
@$(COMPOSE) up --force-recreate -d y-provider
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_DB)
|
||||
.PHONY: run
|
||||
|
||||
run-with-frontend: ## Start all the containers needed (backend to frontend)
|
||||
@$(MAKE) run
|
||||
@$(COMPOSE) up --force-recreate -d frontend-dev
|
||||
.PHONY: run-with-frontend
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
@$(COMPOSE) ps
|
||||
.PHONY: status
|
||||
@@ -286,10 +309,15 @@ help:
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
|
||||
.PHONY: help
|
||||
|
||||
# Front
|
||||
run-frontend-dev: ## Install and run the frontend dev
|
||||
@$(COMPOSE) up --force-recreate -d frontend-dev
|
||||
.PHONY: run-frontend-dev
|
||||
# Front
|
||||
frontend-install: ## install the frontend locally
|
||||
cd $(PATH_FRONT_IMPRESS) && yarn
|
||||
.PHONY: frontend-install
|
||||
|
||||
run-frontend-development: ## Run the frontend in development mode
|
||||
@$(COMPOSE) stop frontend-dev
|
||||
cd $(PATH_FRONT_IMPRESS) && yarn dev
|
||||
.PHONY: run-frontend-development
|
||||
|
||||
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
|
||||
cd $(PATH_FRONT) && yarn i18n:extract
|
||||
@@ -314,7 +342,7 @@ start-tilt: ## start the kubernetes cluster using kind
|
||||
tilt up -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
VERSION_TYPE ?= minor
|
||||
bump-packages-version: VERSION_TYPE ?= minor
|
||||
bump-packages-version: ## bump the version of the project - VERSION_TYPE can be "major", "minor", "patch"
|
||||
cd ./src/mail && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
|
||||
41
README.md
41
README.md
@@ -37,14 +37,6 @@ The easiest way to start working on the project is to use GNU Make:
|
||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||
```
|
||||
|
||||
Then you can access to the project in development mode by going to http://localhost:3000.
|
||||
You will be prompted to log in, the default credentials are:
|
||||
```bash
|
||||
username: impress
|
||||
password: impress
|
||||
```
|
||||
---
|
||||
|
||||
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
|
||||
@@ -52,12 +44,41 @@ dependency-releated or migration-releated issues.
|
||||
|
||||
Your Docker services should now be up and running 🎉
|
||||
|
||||
Note that if you need to run them afterwards, you can use the eponym Make rule:
|
||||
You can access to the project by going to http://localhost:3000.
|
||||
You will be prompted to log in, the default credentials are:
|
||||
```bash
|
||||
username: impress
|
||||
password: impress
|
||||
```
|
||||
|
||||
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
|
||||
|
||||
```bash
|
||||
$ make run-frontend-dev
|
||||
$ make run-with-frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
|
||||
To do so, install the frontend dependencies with the following command:
|
||||
|
||||
```bash
|
||||
$ make frontend-install
|
||||
```
|
||||
And run the frontend locally in development mode with the following command:
|
||||
|
||||
```bash
|
||||
$ make run-frontend-development
|
||||
```
|
||||
|
||||
To start all the services, except the frontend container, you can use the following command:
|
||||
|
||||
```bash
|
||||
$ make run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Adding content
|
||||
|
||||
You can create a basic demo site by running:
|
||||
|
||||
12
bin/Tiltfile
12
bin/Tiltfile
@@ -39,6 +39,18 @@ docker_build(
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/impress-blocknote:latest',
|
||||
context='..',
|
||||
dockerfile='../src/blocknote/Dockerfile',
|
||||
only=['./src/blocknote', './docker', './.dockerignore'],
|
||||
target = 'production',
|
||||
live_update=[
|
||||
sync('../src/blocknote', '/home/blocknote'),
|
||||
]
|
||||
)
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
|
||||
|
||||
migration = '''
|
||||
|
||||
@@ -63,7 +63,6 @@ services:
|
||||
- mailcatcher
|
||||
- redis
|
||||
- createbuckets
|
||||
- nginx
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -118,14 +117,32 @@ services:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- keycloak
|
||||
- app-dev
|
||||
|
||||
nginx-front:
|
||||
image: nginx:1.25
|
||||
frontend-dev:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
args:
|
||||
API_ORIGIN: "http://localhost:8071"
|
||||
Y_PROVIDER_URL: "ws://localhost:4444"
|
||||
MEDIA_URL: "http://localhost:8083"
|
||||
SW_DEACTIVATED: "true"
|
||||
image: impress:frontend-development
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./src/frontend/apps/impress/conf/default.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./src/frontend/apps/impress/out:/usr/share/nginx/html
|
||||
|
||||
blocknote-converter:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/blocknote/Dockerfile
|
||||
target: production
|
||||
image: blocknote:blocknote-production
|
||||
ports:
|
||||
- "8081:8081"
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
@@ -161,21 +178,6 @@ services:
|
||||
- /home/frontend/servers/y-provider/node_modules/
|
||||
- /home/frontend/servers/y-provider/dist/
|
||||
|
||||
frontend-dev:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: impress-dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./src/frontend/apps/impress:/home/frontend/apps/impress
|
||||
- /home/frontend/node_modules/
|
||||
depends_on:
|
||||
- y-provider
|
||||
- celery-dev
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
|
||||
@@ -39,3 +39,8 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# AI
|
||||
AI_BASE_URL=https://openaiendpoint.com
|
||||
AI_API_KEY=password
|
||||
AI_MODEL=llama
|
||||
|
||||
@@ -6,9 +6,11 @@ from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import magic
|
||||
from rest_framework import exceptions, serializers
|
||||
|
||||
from core import models
|
||||
from core import enums, models
|
||||
from core.services.ai_services import AI_ACTIONS
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
@@ -69,6 +71,7 @@ class BaseAccessSerializer(serializers.ModelSerializer):
|
||||
if not self.Meta.model.objects.filter( # pylint: disable=no-member
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
||||
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
|
||||
).exists():
|
||||
raise exceptions.PermissionDenied(
|
||||
"You are not allowed to manage accesses for this resource."
|
||||
@@ -218,16 +221,42 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
f"File size exceeds the maximum limit of {max_size:d} MB."
|
||||
)
|
||||
|
||||
# Validate file type
|
||||
mime_type, _ = mimetypes.guess_type(file.name)
|
||||
if mime_type not in settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES:
|
||||
mime_types = ", ".join(settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES)
|
||||
raise serializers.ValidationError(
|
||||
f"File type '{mime_type:s}' is not allowed. Allowed types are: {mime_types:s}"
|
||||
)
|
||||
extension = file.name.rpartition(".")[-1] if "." in file.name else None
|
||||
|
||||
# Read the first few bytes to determine the MIME type accurately
|
||||
mime = magic.Magic(mime=True)
|
||||
magic_mime_type = mime.from_buffer(file.read(1024))
|
||||
file.seek(0) # Reset file pointer to the beginning after reading
|
||||
|
||||
self.context["is_unsafe"] = (
|
||||
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
|
||||
)
|
||||
|
||||
extension_mime_type, _ = mimetypes.guess_type(file.name)
|
||||
|
||||
# Try guessing a coherent extension from the mimetype
|
||||
if extension_mime_type != magic_mime_type:
|
||||
self.context["is_unsafe"] = True
|
||||
|
||||
guessed_ext = mimetypes.guess_extension(magic_mime_type)
|
||||
# Missing extensions or extensions longer than 5 characters (it's as long as an extension
|
||||
# can be) are replaced by the extension we eventually guessed from mimetype.
|
||||
if (extension is None or len(extension) > 5) and guessed_ext:
|
||||
extension = guessed_ext[1:]
|
||||
|
||||
if extension is None:
|
||||
raise serializers.ValidationError("Could not determine file extension.")
|
||||
|
||||
self.context["expected_extension"] = extension
|
||||
|
||||
return file
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Override validate to add the computed extension to validated_data."""
|
||||
attrs["expected_extension"] = self.context["expected_extension"]
|
||||
attrs["is_unsafe"] = self.context["is_unsafe"]
|
||||
return attrs
|
||||
|
||||
|
||||
class TemplateSerializer(BaseResourceSerializer):
|
||||
"""Serialize templates."""
|
||||
@@ -350,3 +379,33 @@ class VersionFilterSerializer(serializers.Serializer):
|
||||
page_size = serializers.IntegerField(
|
||||
required=False, min_value=1, max_value=50, default=20
|
||||
)
|
||||
|
||||
|
||||
class AITransformSerializer(serializers.Serializer):
|
||||
"""Serializer for AI transform requests."""
|
||||
|
||||
action = serializers.ChoiceField(choices=AI_ACTIONS, required=True)
|
||||
text = serializers.CharField(required=True)
|
||||
|
||||
def validate_text(self, value):
|
||||
"""Ensure the text field is not empty."""
|
||||
|
||||
if len(value.strip()) == 0:
|
||||
raise serializers.ValidationError("Text field cannot be empty.")
|
||||
return value
|
||||
|
||||
|
||||
class AITranslateSerializer(serializers.Serializer):
|
||||
"""Serializer for AI translate requests."""
|
||||
|
||||
language = serializers.ChoiceField(
|
||||
choices=tuple(enums.ALL_LANGUAGES.items()), required=True
|
||||
)
|
||||
text = serializers.CharField(required=True)
|
||||
|
||||
def validate_text(self, value):
|
||||
"""Ensure the text field is not empty."""
|
||||
|
||||
if len(value.strip()) == 0:
|
||||
raise serializers.ValidationError("Text field cannot be empty.")
|
||||
return value
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"""Util to generate S3 authorization headers for object storage access control"""
|
||||
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import botocore
|
||||
from rest_framework.throttling import BaseThrottle
|
||||
|
||||
|
||||
def generate_s3_authorization_headers(key):
|
||||
@@ -31,3 +37,93 @@ def generate_s3_authorization_headers(key):
|
||||
auth.add_auth(request)
|
||||
|
||||
return request
|
||||
|
||||
|
||||
class AIBaseRateThrottle(BaseThrottle, ABC):
|
||||
"""Base throttle class for AI-related rate limiting with backoff."""
|
||||
|
||||
def __init__(self, rates):
|
||||
"""Initialize instance attributes with configurable rates."""
|
||||
super().__init__()
|
||||
self.rates = rates
|
||||
self.cache_key = None
|
||||
self.recent_requests_minute = 0
|
||||
self.recent_requests_hour = 0
|
||||
self.recent_requests_day = 0
|
||||
|
||||
@abstractmethod
|
||||
def get_cache_key(self, request, view):
|
||||
"""Abstract method to generate cache key for throttling."""
|
||||
|
||||
def allow_request(self, request, view):
|
||||
"""Check if the request is allowed based on rate limits."""
|
||||
self.cache_key = self.get_cache_key(request, view)
|
||||
if not self.cache_key:
|
||||
return True # Allow if no cache key is generated
|
||||
|
||||
now = time.time()
|
||||
history = cache.get(self.cache_key, [])
|
||||
# Keep requests within the last 24 hours
|
||||
history = [req for req in history if req > now - 86400]
|
||||
|
||||
# Calculate recent requests
|
||||
self.recent_requests_minute = len([req for req in history if req > now - 60])
|
||||
self.recent_requests_hour = len([req for req in history if req > now - 3600])
|
||||
self.recent_requests_day = len(history)
|
||||
|
||||
# Check rate limits
|
||||
if self.recent_requests_minute >= self.rates["minute"]:
|
||||
return False
|
||||
if self.recent_requests_hour >= self.rates["hour"]:
|
||||
return False
|
||||
if self.recent_requests_day >= self.rates["day"]:
|
||||
return False
|
||||
|
||||
# Log the request
|
||||
history.append(now)
|
||||
cache.set(self.cache_key, history, timeout=86400)
|
||||
return True
|
||||
|
||||
def wait(self):
|
||||
"""Implement a backoff strategy by increasing wait time based on limits hit."""
|
||||
if self.recent_requests_day >= self.rates["day"]:
|
||||
return 86400
|
||||
if self.recent_requests_hour >= self.rates["hour"]:
|
||||
return 3600
|
||||
if self.recent_requests_minute >= self.rates["minute"]:
|
||||
return 60
|
||||
return None
|
||||
|
||||
|
||||
class AIDocumentRateThrottle(AIBaseRateThrottle):
|
||||
"""Throttle for limiting AI requests per document with backoff."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(settings.AI_DOCUMENT_RATE_THROTTLE_RATES)
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
"""Include document ID in the cache key."""
|
||||
document_id = view.kwargs["pk"]
|
||||
return f"document_{document_id}_throttle_ai"
|
||||
|
||||
|
||||
class AIUserRateThrottle(AIBaseRateThrottle):
|
||||
"""Throttle that limits requests per user or IP with backoff and rate limits."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(settings.AI_USER_RATE_THROTTLE_RATES)
|
||||
|
||||
def get_cache_key(self, request, view=None):
|
||||
"""Generate a cache key based on the user ID or IP for anonymous users."""
|
||||
if request.user.is_authenticated:
|
||||
return f"user_{request.user.id!s}_throttle_ai"
|
||||
return f"anonymous_{self.get_ident(request)}_throttle_ai"
|
||||
|
||||
def get_ident(self, request):
|
||||
"""Return the request IP address."""
|
||||
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||
return (
|
||||
x_forwarded_for.split(",")[0]
|
||||
if x_forwarded_for
|
||||
else request.META.get("REMOTE_ADDR")
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""API endpoints"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
@@ -22,6 +21,7 @@ from rest_framework import (
|
||||
decorators,
|
||||
exceptions,
|
||||
filters,
|
||||
metadata,
|
||||
mixins,
|
||||
pagination,
|
||||
status,
|
||||
@@ -31,7 +31,8 @@ from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
|
||||
from core import models
|
||||
from core import enums, models
|
||||
from core.services.ai_services import AIService
|
||||
|
||||
from . import permissions, serializers, utils
|
||||
|
||||
@@ -303,6 +304,23 @@ class ResourceAccessViewsetMixin:
|
||||
serializer.save()
|
||||
|
||||
|
||||
class DocumentMetadata(metadata.SimpleMetadata):
|
||||
"""Custom metadata class to add information"""
|
||||
|
||||
def determine_metadata(self, request, view):
|
||||
"""Add language choices only for the list endpoint."""
|
||||
simple_metadata = super().determine_metadata(request, view)
|
||||
|
||||
if request.path.endswith("/documents/"):
|
||||
simple_metadata["actions"]["POST"]["language"] = {
|
||||
"choices": [
|
||||
{"value": code, "display_name": name}
|
||||
for code, name in enums.ALL_LANGUAGES.items()
|
||||
]
|
||||
}
|
||||
return simple_metadata
|
||||
|
||||
|
||||
class DocumentViewSet(
|
||||
ResourceViewsetMixin,
|
||||
mixins.CreateModelMixin,
|
||||
@@ -320,6 +338,7 @@ class DocumentViewSet(
|
||||
resource_field_name = "document"
|
||||
queryset = models.Document.objects.all()
|
||||
ordering = ["-updated_at"]
|
||||
metadata_class = DocumentMetadata
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Restrict resources returned by the list endpoint"""
|
||||
@@ -456,10 +475,7 @@ class DocumentViewSet(
|
||||
serializer = serializers.LinkDocumentSerializer(
|
||||
document, data=request.data, partial=True
|
||||
)
|
||||
if not serializer.is_valid():
|
||||
return drf_response.Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
serializer.save()
|
||||
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -472,19 +488,23 @@ class DocumentViewSet(
|
||||
|
||||
# Validate metadata in payload
|
||||
serializer = serializers.FileUploadSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return drf_response.Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
# Extract the file extension from the original filename
|
||||
file = serializer.validated_data["file"]
|
||||
extension = os.path.splitext(file.name)[1]
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Generate a generic yet unique filename to store the image in object storage
|
||||
file_id = uuid.uuid4()
|
||||
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}{extension:s}"
|
||||
extension = serializer.validated_data["expected_extension"]
|
||||
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}.{extension:s}"
|
||||
|
||||
# Prepare metadata for storage
|
||||
extra_args = {"Metadata": {"owner": str(request.user.id)}}
|
||||
if serializer.validated_data["is_unsafe"]:
|
||||
extra_args["Metadata"]["is_unsafe"] = "true"
|
||||
|
||||
file = serializer.validated_data["file"]
|
||||
default_storage.connection.meta.client.upload_fileobj(
|
||||
file, default_storage.bucket_name, key, ExtraArgs=extra_args
|
||||
)
|
||||
|
||||
default_storage.save(key, file)
|
||||
return drf_response.Response(
|
||||
{"file": f"{settings.MEDIA_URL:s}{key:s}"}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
@@ -531,6 +551,63 @@ class DocumentViewSet(
|
||||
request = utils.generate_s3_authorization_headers(f"{pk:s}/{attachment_key:s}")
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
name="Apply a transformation action on a piece of text with AI",
|
||||
url_path="ai-transform",
|
||||
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
|
||||
)
|
||||
def ai_transform(self, request, *args, **kwargs):
|
||||
"""
|
||||
POST /api/v1.0/documents/<resource_id>/ai-transform
|
||||
with expected data:
|
||||
- text: str
|
||||
- action: str [prompt, correct, rephrase, summarize]
|
||||
Return JSON response with the processed text.
|
||||
"""
|
||||
# Check permissions first
|
||||
self.get_object()
|
||||
|
||||
serializer = serializers.AITransformSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
text = serializer.validated_data["text"]
|
||||
action = serializer.validated_data["action"]
|
||||
|
||||
response = AIService().transform(text, action)
|
||||
|
||||
return drf_response.Response(response, status=status.HTTP_200_OK)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
name="Translate a piece of text with AI",
|
||||
serializer_class=serializers.AITranslateSerializer,
|
||||
url_path="ai-translate",
|
||||
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
|
||||
)
|
||||
def ai_translate(self, request, *args, **kwargs):
|
||||
"""
|
||||
POST /api/v1.0/documents/<resource_id>/ai-translate
|
||||
with expected data:
|
||||
- text: str
|
||||
- language: str [settings.LANGUAGES]
|
||||
Return JSON response with the translated text.
|
||||
"""
|
||||
# Check permissions first
|
||||
self.get_object()
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
text = serializer.validated_data["text"]
|
||||
language = serializer.validated_data["language"]
|
||||
|
||||
response = AIService().translate(text, language)
|
||||
|
||||
return drf_response.Response(response, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class DocumentAccessViewSet(
|
||||
ResourceAccessViewsetMixin,
|
||||
@@ -576,8 +653,12 @@ class DocumentAccessViewSet(
|
||||
"""Add a new access to the document and send an email to the new added user."""
|
||||
access = serializer.save()
|
||||
language = self.request.headers.get("Content-Language", "en-us")
|
||||
|
||||
access.document.email_invitation(
|
||||
language, access.user.email, access.role, self.request.user.email
|
||||
language,
|
||||
access.user.email,
|
||||
access.role,
|
||||
self.request.user,
|
||||
)
|
||||
|
||||
|
||||
@@ -778,6 +859,7 @@ class InvitationViewset(
|
||||
invitation = serializer.save()
|
||||
|
||||
language = self.request.headers.get("Content-Language", "en-us")
|
||||
|
||||
invitation.document.email_invitation(
|
||||
language, invitation.email, invitation.role, self.request.user.email
|
||||
language, invitation.email, invitation.role, self.request.user
|
||||
)
|
||||
|
||||
@@ -84,6 +84,8 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
user = self.get_existing_user(sub, email)
|
||||
|
||||
if user:
|
||||
if not user.is_active:
|
||||
raise SuspiciousOperation(_("User account is disabled"))
|
||||
self.update_user_if_needed(user, claims)
|
||||
elif self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
|
||||
@@ -101,11 +103,11 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
return User.objects.get(sub=sub, is_active=True)
|
||||
return User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return User.objects.get(email=email, is_active=True)
|
||||
return User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
Core application enums declaration
|
||||
"""
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.conf import global_settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
|
||||
# the choice of languages which should not be limited to the few languages active in
|
||||
# the app.
|
||||
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
|
||||
# We can use it for the choice of languages which should not be limited to the few languages
|
||||
# active in the app.
|
||||
# pylint: disable=no-member
|
||||
ALL_LANGUAGES = getattr(
|
||||
settings,
|
||||
"ALL_LANGUAGES",
|
||||
[(language, _(name)) for language, name in global_settings.LANGUAGES],
|
||||
)
|
||||
ALL_LANGUAGES = {language: _(name) for language, name in global_settings.LANGUAGES}
|
||||
|
||||
@@ -27,6 +27,24 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
||||
password = make_password("password")
|
||||
|
||||
@factory.post_generation
|
||||
def with_owned_document(self, create, extracted, **kwargs):
|
||||
"""
|
||||
Create a document for which the user is owner to check
|
||||
that there is no interference
|
||||
"""
|
||||
if create and (extracted is True):
|
||||
UserDocumentAccessFactory(user=self, role="owner")
|
||||
|
||||
@factory.post_generation
|
||||
def with_owned_template(self, create, extracted, **kwargs):
|
||||
"""
|
||||
Create a template for which the user is owner to check
|
||||
that there is no interference
|
||||
"""
|
||||
if create and (extracted is True):
|
||||
UserTemplateAccessFactory(user=self, role="owner")
|
||||
|
||||
|
||||
class DocumentFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create documents"""
|
||||
|
||||
128
src/backend/core/migrations/0007_fix_users_duplicate.py
Normal file
128
src/backend/core/migrations/0007_fix_users_duplicate.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-10 11:45
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
procedure = """
|
||||
DO $$
|
||||
DECLARE
|
||||
user_email TEXT;
|
||||
BEGIN
|
||||
-- Step 1: Create a temporary table (without the unique constraint)
|
||||
-- impress_document_access
|
||||
DROP TABLE IF EXISTS impress_document_access_tmp;
|
||||
CREATE TEMP TABLE impress_document_access_tmp AS
|
||||
SELECT * FROM impress_document_access;
|
||||
|
||||
-- impress_link_trace
|
||||
DROP TABLE IF EXISTS impress_link_trace_tmp;
|
||||
CREATE TEMP TABLE impress_link_trace_tmp AS
|
||||
SELECT * FROM impress_link_trace;
|
||||
|
||||
-- Step 2: Loop through each email that appears more than once
|
||||
FOR user_email IN
|
||||
SELECT email
|
||||
FROM impress_user
|
||||
GROUP BY email
|
||||
HAVING COUNT(email) > 1
|
||||
LOOP
|
||||
-- Step 3: Update user_id in the temporary table based on email
|
||||
-- For impress_document_access
|
||||
UPDATE impress_document_access_tmp
|
||||
SET user_id = (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE user_id IN (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
);
|
||||
|
||||
-- For impress_link_trace
|
||||
UPDATE impress_link_trace_tmp
|
||||
SET user_id = (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE user_id IN (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
);
|
||||
|
||||
-- update impress_invitation
|
||||
UPDATE impress_invitation
|
||||
SET issuer_id = (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE issuer_id IN (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
);
|
||||
|
||||
DELETE FROM impress_user
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
)
|
||||
AND id != (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
RAISE NOTICE 'Processed updates for email: %', user_email;
|
||||
END LOOP;
|
||||
|
||||
-- Step 4: Remove duplicate rows from the temporary table, keeping only one row per (document_id, user_id)
|
||||
-- For impress_document_access
|
||||
DELETE FROM impress_document_access_tmp a
|
||||
USING impress_document_access_tmp b
|
||||
WHERE a.ctid < b.ctid -- Keep one row
|
||||
AND a.document_id = b.document_id
|
||||
AND a.user_id = b.user_id;
|
||||
|
||||
-- Step 5: Replace the original table with the cleaned-up temporary table
|
||||
TRUNCATE TABLE impress_document_access;
|
||||
|
||||
-- Insert cleaned-up data back into the original table
|
||||
INSERT INTO impress_document_access
|
||||
SELECT * FROM impress_document_access_tmp;
|
||||
|
||||
-- For impress_link_trace
|
||||
DELETE FROM impress_link_trace_tmp a
|
||||
USING impress_link_trace_tmp b
|
||||
WHERE a.ctid < b.ctid -- Keep one row
|
||||
AND a.document_id = b.document_id
|
||||
AND a.user_id = b.user_id;
|
||||
|
||||
-- Step 5: Replace the original table with the cleaned-up temporary table
|
||||
TRUNCATE TABLE impress_link_trace;
|
||||
|
||||
-- Insert cleaned-up data back into the original table
|
||||
INSERT INTO impress_link_trace
|
||||
SELECT * FROM impress_link_trace_tmp;
|
||||
|
||||
RAISE NOTICE 'Update and deduplication process completed.';
|
||||
END $$;
|
||||
"""
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_add_user_full_name_and_short_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(procedure),
|
||||
]
|
||||
@@ -508,6 +508,8 @@ class Document(BaseModel):
|
||||
can_get = bool(roles)
|
||||
|
||||
return {
|
||||
"ai_transform": is_owner_or_admin or is_editor,
|
||||
"ai_translate": is_owner_or_admin or is_editor,
|
||||
"attachment_upload": is_owner_or_admin or is_editor,
|
||||
"destroy": RoleChoices.OWNER in roles,
|
||||
"link_configuration": is_owner_or_admin,
|
||||
@@ -520,15 +522,18 @@ class Document(BaseModel):
|
||||
"versions_retrieve": can_get_versions,
|
||||
}
|
||||
|
||||
def email_invitation(self, language, email, role, username_sender):
|
||||
def email_invitation(self, language, email, role, sender):
|
||||
"""Send email invitation."""
|
||||
|
||||
sender_name = sender.full_name or sender.email
|
||||
domain = Site.objects.get_current().domain
|
||||
|
||||
try:
|
||||
with override(language):
|
||||
title = _("%(username)s shared a document with you: %(document)s") % {
|
||||
"username": username_sender,
|
||||
title = _(
|
||||
"%(sender_name)s shared a document with you: %(document)s"
|
||||
) % {
|
||||
"sender_name": sender_name,
|
||||
"document": self.title,
|
||||
}
|
||||
template_vars = {
|
||||
@@ -536,7 +541,10 @@ class Document(BaseModel):
|
||||
"domain": domain,
|
||||
"document": self,
|
||||
"link": f"{domain}/docs/{self.id}/",
|
||||
"username": username_sender,
|
||||
"sender_name": sender_name,
|
||||
"sender_name_email": f"{sender.full_name} ({sender.email})"
|
||||
if sender.full_name
|
||||
else sender.email,
|
||||
"role": RoleChoices(role).label.lower(),
|
||||
}
|
||||
msg_html = render_to_string("mail/html/invitation.html", template_vars)
|
||||
|
||||
0
src/backend/core/services/__init__.py
Normal file
0
src/backend/core/services/__init__.py
Normal file
89
src/backend/core/services/ai_services.py
Normal file
89
src/backend/core/services/ai_services.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""AI services."""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from core import enums
|
||||
|
||||
AI_ACTIONS = {
|
||||
"prompt": (
|
||||
"Answer the prompt in markdown format. Return JSON: "
|
||||
'{"answer": "Your markdown answer"}. '
|
||||
"Do not provide any other information."
|
||||
),
|
||||
"correct": (
|
||||
"Correct grammar and spelling of the markdown text, "
|
||||
"preserving language and markdown formatting. "
|
||||
'Return JSON: {"answer": "your corrected markdown text"}. '
|
||||
"Do not provide any other information."
|
||||
),
|
||||
"rephrase": (
|
||||
"Rephrase the given markdown text, "
|
||||
"preserving language and markdown formatting. "
|
||||
'Return JSON: {"answer": "your rephrased markdown text"}. '
|
||||
"Do not provide any other information."
|
||||
),
|
||||
"summarize": (
|
||||
"Summarize the markdown text, preserving language and markdown formatting. "
|
||||
'Return JSON: {"answer": "your markdown summary"}. '
|
||||
"Do not provide any other information."
|
||||
),
|
||||
}
|
||||
|
||||
AI_TRANSLATE = (
|
||||
"Translate the markdown text to {language:s}, preserving markdown formatting. "
|
||||
'Return JSON: {{"answer": "your translated markdown text in {language:s}"}}. '
|
||||
"Do not provide any other information."
|
||||
)
|
||||
|
||||
|
||||
class AIService:
|
||||
"""Service class for AI-related operations."""
|
||||
|
||||
def __init__(self):
|
||||
"""Ensure that the AI configuration is set properly."""
|
||||
if (
|
||||
settings.AI_BASE_URL is None
|
||||
or settings.AI_API_KEY is None
|
||||
or settings.AI_MODEL is None
|
||||
):
|
||||
raise ImproperlyConfigured("AI configuration not set")
|
||||
self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY)
|
||||
|
||||
def call_ai_api(self, system_content, text):
|
||||
"""Helper method to call the OpenAI API and process the response."""
|
||||
response = self.client.chat.completions.create(
|
||||
model=settings.AI_MODEL,
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{"role": "system", "content": system_content},
|
||||
{"role": "user", "content": json.dumps({"markdown_input": text})},
|
||||
],
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content
|
||||
sanitized_content = re.sub(r"(?<!\\)\n", "\\\\n", content)
|
||||
sanitized_content = re.sub(r"(?<!\\)\t", "\\\\t", sanitized_content)
|
||||
|
||||
json_response = json.loads(sanitized_content)
|
||||
|
||||
if "answer" not in json_response:
|
||||
raise RuntimeError("AI response does not contain an answer")
|
||||
|
||||
return json_response
|
||||
|
||||
def transform(self, text, action):
|
||||
"""Transform text based on specified action."""
|
||||
system_content = AI_ACTIONS[action]
|
||||
return self.call_ai_api(system_content, text)
|
||||
|
||||
def translate(self, text, language):
|
||||
"""Translate text to a specified language."""
|
||||
language_display = enums.ALL_LANGUAGES.get(language, language)
|
||||
system_content = AI_TRANSLATE.format(language=language_display)
|
||||
return self.call_ai_api(system_content, text)
|
||||
@@ -305,3 +305,63 @@ def test_authentication_get_userinfo_invalid_response():
|
||||
match="Invalid response format or token verification failed",
|
||||
):
|
||||
oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
|
||||
def test_authentication_getter_existing_disabled_user_via_sub(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user matches the sub but is disabled,
|
||||
an error should be raised and a user should not be created.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(is_active=False)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": db_user.sub,
|
||||
"email": db_user.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(1),
|
||||
pytest.raises(SuspiciousOperation, match="User account is disabled"),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_existing_disabled_user_via_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user does not matches the sub but matches the email and is disabled,
|
||||
an error should be raised and a user should not be created.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(is_active=False)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "random",
|
||||
"email": db_user.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(2),
|
||||
pytest.raises(SuspiciousOperation, match="User account is disabled"),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
@@ -149,7 +149,7 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
|
||||
Authenticated users should not be allowed to retrieve a document access for
|
||||
a document to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -246,7 +246,7 @@ def test_api_document_accesses_update_authenticated_unrelated():
|
||||
Authenticated users should not be allowed to update a document access for a document to which
|
||||
they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -279,7 +279,7 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""Readers or editors of a document should not be allowed to update its accesses."""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -321,7 +321,7 @@ def test_api_document_accesses_update_administrator_except_owner(via, mock_user_
|
||||
A user who is a direct administrator in a document should be allowed to update a user
|
||||
access for this document, as long as they don't try to set the role to owner.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -378,7 +378,7 @@ def test_api_document_accesses_update_administrator_from_owner(via, mock_user_te
|
||||
A user who is an administrator in a document, should not be allowed to update
|
||||
the user access of an "owner" for this document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -425,7 +425,7 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_team
|
||||
A user who is an administrator in a document, should not be allowed to update
|
||||
the user access of another user to grant document ownership.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -479,7 +479,7 @@ def test_api_document_accesses_update_owner(via, mock_user_teams):
|
||||
A user who is an owner in a document should be allowed to update
|
||||
a user access for this document whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -535,7 +535,7 @@ def test_api_document_accesses_update_owner_self(via, mock_user_teams):
|
||||
A user who is owner of a document should be allowed to update
|
||||
their own user access provided there are other owners in the document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -605,7 +605,7 @@ def test_api_document_accesses_delete_authenticated():
|
||||
Authenticated users should not be allowed to delete a document access for a
|
||||
document to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -617,7 +617,7 @@ def test_api_document_accesses_delete_authenticated():
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor"])
|
||||
@@ -627,7 +627,7 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_team
|
||||
Authenticated users should not be allowed to delete a document access for a
|
||||
document in which they are a simple reader or editor.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -643,7 +643,7 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_team
|
||||
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
assert models.DocumentAccess.objects.count() == 3
|
||||
assert models.DocumentAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
@@ -651,7 +651,7 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_team
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
assert models.DocumentAccess.objects.count() == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@@ -699,7 +699,7 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea
|
||||
Users who are administrators in a document should not be allowed to delete an ownership
|
||||
access from the document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -717,7 +717,7 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea
|
||||
|
||||
access = factories.UserDocumentAccessFactory(document=document, role="owner")
|
||||
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
assert models.DocumentAccess.objects.count() == 3
|
||||
assert models.DocumentAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
@@ -725,7 +725,7 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
assert models.DocumentAccess.objects.count() == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@@ -766,7 +766,7 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
|
||||
"""
|
||||
It should not be possible to delete the last owner access from a document
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -783,10 +783,10 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
|
||||
document=document, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
|
||||
@@ -18,13 +18,13 @@ pytestmark = pytest.mark.django_db
|
||||
|
||||
def test_api_document_accesses_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create document accesses."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||
{
|
||||
"user": str(user.id),
|
||||
"user_id": str(other_user.id),
|
||||
"document": str(document.id),
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
},
|
||||
@@ -43,7 +43,7 @@ def test_api_document_accesses_create_authenticated_unrelated():
|
||||
Authenticated users should not be allowed to create document accesses for a document to
|
||||
which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -54,7 +54,7 @@ def test_api_document_accesses_create_authenticated_unrelated():
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"user_id": str(other_user.id),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
@@ -69,7 +69,7 @@ def test_api_document_accesses_create_authenticated_reader_or_editor(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""Readers or editors of a document should not be allowed to create document accesses."""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -89,7 +89,7 @@ def test_api_document_accesses_create_authenticated_reader_or_editor(
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"user_id": str(other_user.id),
|
||||
"role": new_role,
|
||||
},
|
||||
format="json",
|
||||
@@ -107,7 +107,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
|
||||
except for the "owner" role.
|
||||
An email should be sent to the accesses to notify them of the adding.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -129,7 +129,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"user_id": str(other_user.id),
|
||||
"role": "owner",
|
||||
},
|
||||
format="json",
|
||||
@@ -171,7 +171,10 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
|
||||
email = mail.outbox[0]
|
||||
assert email.to == [other_user["email"]]
|
||||
email_content = " ".join(email.body.split())
|
||||
assert f"{user.email} shared a document with you: {document.title}" in email_content
|
||||
assert (
|
||||
f"{user.full_name} shared a document with you: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
assert "docs/" + str(document.id) + "/" in email_content
|
||||
|
||||
|
||||
@@ -225,5 +228,8 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
|
||||
email = mail.outbox[0]
|
||||
assert email.to == [other_user["email"]]
|
||||
email_content = " ".join(email.body.split())
|
||||
assert f"{user.email} shared a document with you: {document.title}" in email_content
|
||||
assert (
|
||||
f"{user.full_name} shared a document with you: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
assert "docs/" + str(document.id) + "/" in email_content
|
||||
|
||||
@@ -119,7 +119,7 @@ def test_api_document_invitations__create__privileged_members(
|
||||
assert email.to == ["guest@example.com"]
|
||||
email_content = " ".join(email.body.split())
|
||||
assert (
|
||||
f"{user.email} shared a document with you: {document.title}"
|
||||
f"{user.full_name} shared a document with you: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
else:
|
||||
@@ -162,7 +162,7 @@ def test_api_document_invitations__create__email_from_content_language():
|
||||
|
||||
email_content = " ".join(email.body.split())
|
||||
assert (
|
||||
f"{user.email} a partagé un document avec vous: {document.title}"
|
||||
f"{user.full_name} a partagé un document avec vous: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
|
||||
@@ -201,8 +201,52 @@ def test_api_document_invitations__create__email_from_content_language_not_suppo
|
||||
|
||||
assert email.to == ["guest@example.com"]
|
||||
|
||||
email_content = " ".join(email.body.split())
|
||||
assert (
|
||||
f"{user.full_name} shared a document with you: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
|
||||
|
||||
def test_api_document_invitations__create__email__full_name_empty():
|
||||
"""
|
||||
If the full name of the user is empty, it will display the email address.
|
||||
"""
|
||||
user = factories.UserFactory(full_name="")
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
invitation_values = {
|
||||
"email": "guest@example.com",
|
||||
"role": "reader",
|
||||
}
|
||||
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
headers={"Content-Language": "not-supported"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["email"] == "guest@example.com"
|
||||
assert models.Invitation.objects.count() == 1
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
email = mail.outbox[0]
|
||||
|
||||
assert email.to == ["guest@example.com"]
|
||||
|
||||
email_content = " ".join(email.body.split())
|
||||
assert f"{user.email} shared a document with you: {document.title}" in email_content
|
||||
assert (
|
||||
f'{user.email} invited you with the role "reader" on the '
|
||||
f"following document : {document.title}" in email_content
|
||||
)
|
||||
|
||||
|
||||
def test_api_document_invitations__create__issuer_and_document_override():
|
||||
@@ -557,7 +601,7 @@ def test_api_document_invitations__update__forbidden__not_authenticated(
|
||||
"""
|
||||
Update of invitations is currently forbidden.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
invitation = factories.InvitationFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
@@ -597,7 +641,7 @@ def test_api_document_invitations__delete__anonymous():
|
||||
|
||||
def test_api_document_invitations__delete__authenticated_outsider():
|
||||
"""Members unrelated to a document should not be allowed to cancel invitations."""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
invitation = factories.InvitationFactory(document=document)
|
||||
@@ -640,7 +684,7 @@ def test_api_document_invitations__delete__privileged_members(
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_invitations_delete_readers_or_editors(via, role, mock_user_teams):
|
||||
"""Readers or editors should not be able to cancel invitation."""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
|
||||
@@ -39,7 +39,7 @@ def test_api_document_versions_list_authenticated_unrelated(reach):
|
||||
Authenticated users should not be allowed to list document versions for a document
|
||||
to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -231,7 +231,7 @@ def test_api_document_versions_retrieve_authenticated_unrelated(reach):
|
||||
Authenticated users should not be allowed to retrieve specific versions for a
|
||||
document to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -475,7 +475,7 @@ def test_api_document_versions_delete_authenticated(reach):
|
||||
Authenticated users should not be allowed to delete a document version for a
|
||||
public document to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -500,7 +500,7 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_team
|
||||
Authenticated users should not be allowed to delete a document version for a
|
||||
document in which they are a simple reader or editor.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
Test AI transform API endpoint for users in impress's core app.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache():
|
||||
"""Fixture to clear the cache before each test."""
|
||||
cache.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ai_settings():
|
||||
"""Fixture to set AI settings."""
|
||||
with override_settings(
|
||||
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama"
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
("restricted", "reader"),
|
||||
("restricted", "editor"),
|
||||
("authenticated", "reader"),
|
||||
("authenticated", "editor"),
|
||||
("public", "reader"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_ai_transform_anonymous_forbidden(reach, role):
|
||||
"""
|
||||
Anonymous users should not be able to request AI transform if the link reach
|
||||
and role don't allow it.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
|
||||
response = APIClient().post(url, {"text": "hello", "action": "prompt"})
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_transform_anonymous_success(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 == 200
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
mock_create.assert_called_once_with(
|
||||
model="llama",
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Summarize the markdown text, preserving language and markdown formatting. "
|
||||
'Return JSON: {"answer": "your markdown summary"}. Do not provide any other '
|
||||
"information."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": '{"markdown_input": "Hello"}'},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
("restricted", "reader"),
|
||||
("restricted", "editor"),
|
||||
("authenticated", "reader"),
|
||||
("public", "reader"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_ai_transform_authenticated_forbidden(reach, role):
|
||||
"""
|
||||
Users who are not related to a document can't request AI transform if the
|
||||
link reach and role don't allow it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
|
||||
response = client.post(url, {"text": "Hello", "action": "prompt"})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
("authenticated", "editor"),
|
||||
("public", "editor"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role):
|
||||
"""
|
||||
Autenticated who are not related to a document should be able to request AI transform
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
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 = client.post(url, {"text": "Hello", "action": "prompt"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
mock_create.assert_called_once_with(
|
||||
model="llama",
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
'Answer the prompt in markdown format. Return JSON: {"answer": '
|
||||
'"Your markdown answer"}. Do not provide any other information.'
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": '{"markdown_input": "Hello"}'},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_ai_transform_reader(via, mock_user_teams):
|
||||
"""
|
||||
Users who are simple readers on a document should not be allowed to request AI transform.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_role="reader")
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="reader"
|
||||
)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
|
||||
response = client.post(url, {"text": "Hello", "action": "prompt"})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_teams):
|
||||
"""
|
||||
Editors, administrators and owners of a document should be able to request AI transform.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
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 = client.post(url, {"text": "Hello", "action": "prompt"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
mock_create.assert_called_once_with(
|
||||
model="llama",
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
'Answer the prompt in markdown format. Return JSON: {"answer": '
|
||||
'"Your markdown answer"}. Do not provide any other information.'
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": '{"markdown_input": "Hello"}'},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_api_documents_ai_transform_empty_text():
|
||||
"""The text should not be empty when requesting AI transform."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
|
||||
response = client.post(url, {"text": " ", "action": "prompt"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"text": ["This field may not be blank."]}
|
||||
|
||||
|
||||
def test_api_documents_ai_transform_invalid_action():
|
||||
"""The action should valid when requesting AI transform."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
|
||||
response = client.post(url, {"text": "Hello", "action": "invalid"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"action": ['"invalid" is not a valid choice.']}
|
||||
|
||||
|
||||
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_transform_throttling_document(mock_create):
|
||||
"""
|
||||
Throttling per document should be triggered on the AI transform endpoint.
|
||||
For full throttle class test see: `test_api_utils_ai_document_rate_throttles`
|
||||
"""
|
||||
client = APIClient()
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
|
||||
for _ in range(3):
|
||||
user = factories.UserFactory()
|
||||
client.force_login(user)
|
||||
response = client.post(url, {"text": "Hello", "action": "summarize"})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
|
||||
user = factories.UserFactory()
|
||||
client.force_login(user)
|
||||
response = client.post(url, {"text": "Hello", "action": "summarize"})
|
||||
|
||||
assert response.status_code == 429
|
||||
assert response.json() == {
|
||||
"detail": "Request was throttled. Expected available in 60 seconds."
|
||||
}
|
||||
|
||||
|
||||
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_transform_throttling_user(mock_create):
|
||||
"""
|
||||
Throttling per user should be triggered on the AI transform endpoint.
|
||||
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
)
|
||||
|
||||
for _ in range(3):
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
|
||||
response = client.post(url, {"text": "Hello", "action": "summarize"})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
|
||||
response = client.post(url, {"text": "Hello", "action": "summarize"})
|
||||
|
||||
assert response.status_code == 429
|
||||
assert response.json() == {
|
||||
"detail": "Request was throttled. Expected available in 60 seconds."
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
Test AI translate API endpoint for users in impress's core app.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache():
|
||||
"""Fixture to clear the cache before each test."""
|
||||
cache.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ai_settings():
|
||||
"""Fixture to set AI settings."""
|
||||
with override_settings(
|
||||
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama"
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def test_api_documents_ai_translate_viewset_options_metadata():
|
||||
"""The documents endpoint should give us the list of available languages."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
response = APIClient().options("/api/v1.0/documents/")
|
||||
|
||||
assert response.status_code == 200
|
||||
metadata = response.json()
|
||||
assert metadata["name"] == "Document List"
|
||||
assert metadata["actions"]["POST"]["language"]["choices"][0] == {
|
||||
"value": "af",
|
||||
"display_name": "Afrikaans",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
("restricted", "reader"),
|
||||
("restricted", "editor"),
|
||||
("authenticated", "reader"),
|
||||
("authenticated", "editor"),
|
||||
("public", "reader"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_ai_translate_anonymous_forbidden(reach, role):
|
||||
"""
|
||||
Anonymous users should not be able to request AI translate if the link reach
|
||||
and role don't allow it.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
|
||||
response = APIClient().post(url, {"text": "hello", "language": "es"})
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_translate_anonymous_success(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 == 200
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
mock_create.assert_called_once_with(
|
||||
model="llama",
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Translate the markdown text to Spanish, preserving markdown formatting. "
|
||||
'Return JSON: {"answer": "your translated markdown text in Spanish"}. '
|
||||
"Do not provide any other information."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": '{"markdown_input": "Hello"}'},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
("restricted", "reader"),
|
||||
("restricted", "editor"),
|
||||
("authenticated", "reader"),
|
||||
("public", "reader"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_ai_translate_authenticated_forbidden(reach, role):
|
||||
"""
|
||||
Users who are not related to a document can't request AI translate if the
|
||||
link reach and role don't allow it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
|
||||
response = client.post(url, {"text": "Hello", "language": "es"})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
("authenticated", "editor"),
|
||||
("public", "editor"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role):
|
||||
"""
|
||||
Autenticated who are not related to a document should be able to request AI translate
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
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 = client.post(url, {"text": "Hello", "language": "es-co"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
mock_create.assert_called_once_with(
|
||||
model="llama",
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Translate the markdown text to Colombian Spanish, "
|
||||
"preserving markdown formatting. Return JSON: "
|
||||
'{"answer": "your translated markdown text in Colombian Spanish"}. '
|
||||
"Do not provide any other information."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": '{"markdown_input": "Hello"}'},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_ai_translate_reader(via, mock_user_teams):
|
||||
"""
|
||||
Users who are simple readers on a document should not be allowed to request AI translate.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_role="reader")
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="reader"
|
||||
)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
|
||||
response = client.post(url, {"text": "Hello", "language": "es"})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_teams):
|
||||
"""
|
||||
Editors, administrators and owners of a document should be able to request AI translate.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
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 = client.post(url, {"text": "Hello", "language": "es-co"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
mock_create.assert_called_once_with(
|
||||
model="llama",
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Translate the markdown text to Colombian Spanish, "
|
||||
"preserving markdown formatting. Return JSON: "
|
||||
'{"answer": "your translated markdown text in Colombian Spanish"}. '
|
||||
"Do not provide any other information."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": '{"markdown_input": "Hello"}'},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_api_documents_ai_translate_empty_text():
|
||||
"""The text should not be empty when requesting AI translate."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
|
||||
response = client.post(url, {"text": " ", "language": "es"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"text": ["This field may not be blank."]}
|
||||
|
||||
|
||||
def test_api_documents_ai_translate_invalid_action():
|
||||
"""The action should valid when requesting AI translate."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
|
||||
response = client.post(url, {"text": "Hello", "language": "invalid"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"language": ['"invalid" is not a valid choice.']}
|
||||
|
||||
|
||||
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_translate_throttling_document(mock_create):
|
||||
"""
|
||||
Throttling per document should be triggered on the AI translate endpoint.
|
||||
For full throttle class test see: `test_api_utils_ai_document_rate_throttles`
|
||||
"""
|
||||
client = APIClient()
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
|
||||
for _ in range(3):
|
||||
user = factories.UserFactory()
|
||||
client.force_login(user)
|
||||
response = client.post(url, {"text": "Hello", "language": "es"})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
|
||||
user = factories.UserFactory()
|
||||
client.force_login(user)
|
||||
response = client.post(url, {"text": "Hello", "language": "es"})
|
||||
|
||||
assert response.status_code == 429
|
||||
assert response.json() == {
|
||||
"detail": "Request was throttled. Expected available in 60 seconds."
|
||||
}
|
||||
|
||||
|
||||
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_translate_throttling_user(mock_create):
|
||||
"""
|
||||
Throttling per user should be triggered on the AI translate endpoint.
|
||||
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
)
|
||||
|
||||
for _ in range(3):
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
|
||||
response = client.post(url, {"text": "Hello", "language": "es"})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
|
||||
response = client.post(url, {"text": "Hello", "language": "es"})
|
||||
|
||||
assert response.status_code == 429
|
||||
assert response.json() == {
|
||||
"detail": "Request was throttled. Expected available in 60 seconds."
|
||||
}
|
||||
@@ -5,7 +5,7 @@ Test file uploads API endpoint for users in impress's core app.
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
|
||||
import pytest
|
||||
@@ -16,6 +16,12 @@ from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
PIXEL = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
|
||||
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
|
||||
b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
@@ -33,7 +39,7 @@ def test_api_documents_attachment_upload_anonymous_forbidden(reach, role):
|
||||
and role don't allow it.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
|
||||
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
response = APIClient().post(url, {"file": file}, format="multipart")
|
||||
@@ -50,14 +56,14 @@ def test_api_documents_attachment_upload_anonymous_success():
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
|
||||
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
response = APIClient().post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.jpg")
|
||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
|
||||
match = pattern.search(response.json()["file"])
|
||||
file_id = match.group(1)
|
||||
|
||||
@@ -79,13 +85,13 @@ def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
|
||||
Users who are not related to a document can't upload attachments if the
|
||||
link reach and role don't allow it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
|
||||
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
@@ -114,14 +120,14 @@ def test_api_documents_attachment_upload_authenticated_success(reach, role):
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
|
||||
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.jpg")
|
||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
|
||||
match = pattern.search(response.json()["file"])
|
||||
file_id = match.group(1)
|
||||
|
||||
@@ -134,7 +140,7 @@ def test_api_documents_attachment_upload_reader(via, mock_user_teams):
|
||||
"""
|
||||
Users who are simple readers on a document should not be allowed to upload an attachment.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -148,7 +154,7 @@ def test_api_documents_attachment_upload_reader(via, mock_user_teams):
|
||||
document=document, team="lasuite", role="reader"
|
||||
)
|
||||
|
||||
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
|
||||
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
@@ -179,20 +185,28 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
|
||||
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.jpg")
|
||||
match = pattern.search(response.json()["file"])
|
||||
file_path = response.json()["file"]
|
||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
|
||||
match = pattern.search(file_path)
|
||||
file_id = match.group(1)
|
||||
|
||||
# Validate that file_id is a valid UUID
|
||||
uuid.UUID(file_id)
|
||||
|
||||
# Now, check the metadata of the uploaded file
|
||||
key = file_path.replace("/media", "")
|
||||
file_head = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=key
|
||||
)
|
||||
assert file_head["Metadata"] == {"owner": str(user.id)}
|
||||
|
||||
|
||||
def test_api_documents_attachment_upload_invalid(client):
|
||||
"""Attempt to upload without a file should return an explicit error."""
|
||||
@@ -222,8 +236,9 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings):
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
|
||||
# Create a temporary file larger than the allowed size
|
||||
content = b"a" * (1048576 + 1)
|
||||
file = ContentFile(content, name="test.jpg")
|
||||
file = SimpleUploadedFile(
|
||||
name="test.txt", content=b"a" * (1048576 + 1), content_type="text/plain"
|
||||
)
|
||||
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
|
||||
@@ -231,10 +246,21 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings):
|
||||
assert response.json() == {"file": ["File size exceeds the maximum limit of 1 MB."]}
|
||||
|
||||
|
||||
def test_api_documents_attachment_upload_type_not_allowed(settings):
|
||||
"""The uploaded file should be of a whitelisted type."""
|
||||
settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES = ["image/jpeg", "image/png"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,content,extension",
|
||||
[
|
||||
("test.exe", b"text", "exe"),
|
||||
("test", b"text", "txt"),
|
||||
("test.aaaaaa", b"test", "txt"),
|
||||
("test.txt", PIXEL, "txt"),
|
||||
("test.py", b"#!/usr/bin/python", "py"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_attachment_upload_fix_extension(name, content, extension):
|
||||
"""
|
||||
A file with no extension or a wrong extension is accepted and the extension
|
||||
is corrected in storage.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -242,14 +268,70 @@ def test_api_documents_attachment_upload_type_not_allowed(settings):
|
||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
|
||||
# Create a temporary file with a not allowed type (e.g., text file)
|
||||
file = ContentFile(b"a" * 1048576, name="test.txt")
|
||||
file = SimpleUploadedFile(name=name, content=content)
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
file_path = response.json()["file"]
|
||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.{extension:s}")
|
||||
match = pattern.search(file_path)
|
||||
file_id = match.group(1)
|
||||
|
||||
# Validate that file_id is a valid UUID
|
||||
uuid.UUID(file_id)
|
||||
|
||||
# Now, check the metadata of the uploaded file
|
||||
key = file_path.replace("/media", "")
|
||||
file_head = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=key
|
||||
)
|
||||
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
|
||||
|
||||
|
||||
def test_api_documents_attachment_upload_empty_file():
|
||||
"""An empty file should be rejected."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
|
||||
file = SimpleUploadedFile(name="test.png", content=b"")
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"file": [
|
||||
"File type 'text/plain' is not allowed. Allowed types are: image/jpeg, image/png"
|
||||
]
|
||||
}
|
||||
assert response.json() == {"file": ["The submitted file is empty."]}
|
||||
|
||||
|
||||
def test_api_documents_attachment_upload_unsafe():
|
||||
"""A file with an unsafe mime type should be tagged as such."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
|
||||
file = SimpleUploadedFile(
|
||||
name="script.exe", content=b"\x4d\x5a\x90\x00\x03\x00\x00\x00"
|
||||
)
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
file_path = response.json()["file"]
|
||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.exe")
|
||||
match = pattern.search(file_path)
|
||||
file_id = match.group(1)
|
||||
|
||||
# Validate that file_id is a valid UUID
|
||||
uuid.UUID(file_id)
|
||||
|
||||
# Now, check the metadata of the uploaded file
|
||||
key = file_path.replace("/media", "")
|
||||
file_head = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=key
|
||||
)
|
||||
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
|
||||
|
||||
@@ -30,7 +30,7 @@ def test_api_documents_delete_authenticated_unrelated(reach, role):
|
||||
Authenticated users should not be allowed to delete a document to which
|
||||
they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -42,7 +42,7 @@ def test_api_documents_delete_authenticated_unrelated(reach, role):
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.Document.objects.count() == 1
|
||||
assert models.Document.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
|
||||
@@ -52,7 +52,7 @@ def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_teams
|
||||
Authenticated users should not be allowed to delete a document for which they are
|
||||
only a reader, editor or administrator.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -74,7 +74,7 @@ def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_teams
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert models.Document.objects.count() == 1
|
||||
assert models.Document.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
|
||||
@@ -42,7 +42,7 @@ def test_api_documents_link_configuration_update_authenticated_unrelated(reach,
|
||||
Authenticated users should not be allowed to update the link configuration for
|
||||
a document to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -78,7 +78,7 @@ def test_api_documents_link_configuration_update_authenticated_related_forbidden
|
||||
Users who are readers or editors of a document should not be allowed to update
|
||||
the link configuration.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -21,6 +21,8 @@ def test_api_documents_retrieve_anonymous_public():
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
"ai_transform": document.link_role == "editor",
|
||||
"ai_translate": document.link_role == "editor",
|
||||
"attachment_upload": document.link_role == "editor",
|
||||
"destroy": False,
|
||||
"link_configuration": False,
|
||||
@@ -75,6 +77,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
"ai_transform": document.link_role == "editor",
|
||||
"ai_translate": document.link_role == "editor",
|
||||
"attachment_upload": document.link_role == "editor",
|
||||
"link_configuration": False,
|
||||
"destroy": False,
|
||||
@@ -133,7 +137,7 @@ def test_api_documents_retrieve_authenticated_unrelated_restricted():
|
||||
Authenticated users should not be allowed to retrieve a document that is restricted and
|
||||
to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -208,7 +212,7 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams)
|
||||
"""
|
||||
mock_user_teams.return_value = []
|
||||
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -142,7 +142,7 @@ def test_api_documents_retrieve_auth_authenticated_restricted():
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
|
||||
@@ -66,14 +66,14 @@ def test_api_documents_update_authenticated_unrelated_forbidden(reach, role):
|
||||
Authenticated users should not be allowed to update a document to which
|
||||
they are not related if the link configuration does not allow it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
new_document_values = serializers.DocumentSerializer(
|
||||
instance=factories.DocumentFactory()
|
||||
).data
|
||||
@@ -111,14 +111,14 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
|
||||
client = APIClient()
|
||||
|
||||
if is_authenticated:
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client.force_login(user)
|
||||
else:
|
||||
user = AnonymousUser()
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
new_document_values = serializers.DocumentSerializer(
|
||||
instance=factories.DocumentFactory()
|
||||
).data
|
||||
@@ -146,7 +146,7 @@ def test_api_documents_update_authenticated_reader(via, mock_user_teams):
|
||||
Users who are reader of a document but not administrators should
|
||||
not be allowed to update it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -187,7 +187,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""A user who is editor, administrator or owner of a document should be allowed to update it."""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -227,7 +227,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_update_authenticated_owners(via, mock_user_teams):
|
||||
"""Administrators of a document should be allowed to update it."""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -269,7 +269,7 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t
|
||||
Being administrator or owner of a document should not grant authorization to update
|
||||
another document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -32,7 +32,7 @@ def test_api_template_accesses_list_authenticated_unrelated():
|
||||
Authenticated users should not be allowed to list template accesses for a template
|
||||
to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -62,7 +62,7 @@ def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
|
||||
Authenticated users should be able to list template accesses for a template
|
||||
to which they are directly related, whatever their role in the template.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -146,7 +146,7 @@ def test_api_template_accesses_retrieve_authenticated_unrelated():
|
||||
Authenticated users should not be allowed to retrieve a template access for
|
||||
a template to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -183,7 +183,7 @@ def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_tea
|
||||
A user who is related to a template should be allowed to retrieve the
|
||||
associated template user accesses.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -211,199 +211,6 @@ def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_tea
|
||||
}
|
||||
|
||||
|
||||
def test_api_template_accesses_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create template accesses."""
|
||||
user = factories.UserFactory()
|
||||
template = factories.TemplateFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(user.id),
|
||||
"template": str(template.id),
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
assert models.TemplateAccess.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_template_accesses_create_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to create template accesses for a template to
|
||||
which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
template = factories.TemplateFactory()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_create_authenticated_editor_or_reader(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""Editors or readers of a template should not be allowed to create template accesses."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role=role
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
for new_role in [role[0] for role in models.RoleChoices.choices]:
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": new_role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_create_authenticated_administrator(via, mock_user_teams):
|
||||
"""
|
||||
Administrators of a template should be able to create template accesses
|
||||
except for the "owner" role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
# It should not be allowed to create an owner access
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": "owner",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Only owners of a resource can assign other users as owners."
|
||||
}
|
||||
|
||||
# It should be allowed to create a lower access
|
||||
role = random.choice(
|
||||
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
|
||||
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"abilities": new_template_access.get_abilities(user),
|
||||
"id": str(new_template_access.id),
|
||||
"team": "",
|
||||
"role": role,
|
||||
"user": str(other_user.id),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_create_authenticated_owner(via, mock_user_teams):
|
||||
"""
|
||||
Owners of a template should be able to create template accesses whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
|
||||
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"id": str(new_template_access.id),
|
||||
"user": str(other_user.id),
|
||||
"team": "",
|
||||
"role": role,
|
||||
"abilities": new_template_access.get_abilities(user),
|
||||
}
|
||||
|
||||
|
||||
def test_api_template_accesses_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a template access."""
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
@@ -434,14 +241,14 @@ def test_api_template_accesses_update_authenticated_unrelated():
|
||||
Authenticated users should not be allowed to update a template access for a template to which
|
||||
they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
@@ -467,7 +274,7 @@ def test_api_template_accesses_update_authenticated_editor_or_reader(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""Editors or readers of a template should not be allowed to update its accesses."""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -509,7 +316,7 @@ def test_api_template_accesses_update_administrator_except_owner(via, mock_user_
|
||||
A user who is a direct administrator in a template should be allowed to update a user
|
||||
access for this template, as long as they don't try to set the role to owner.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -529,8 +336,8 @@ def test_api_template_accesses_update_administrator_except_owner(via, mock_user_
|
||||
template=template,
|
||||
role=random.choice(["administrator", "editor", "reader"]),
|
||||
)
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
@@ -566,7 +373,7 @@ def test_api_template_accesses_update_administrator_from_owner(via, mock_user_te
|
||||
A user who is an administrator in a template, should not be allowed to update
|
||||
the user access of an "owner" for this template.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -586,8 +393,8 @@ def test_api_template_accesses_update_administrator_from_owner(via, mock_user_te
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template, user=other_user, role="owner"
|
||||
)
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
@@ -613,7 +420,7 @@ def test_api_template_accesses_update_administrator_to_owner(via, mock_user_team
|
||||
A user who is an administrator in a template, should not be allowed to update
|
||||
the user access of another user to grant template ownership.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -635,8 +442,8 @@ def test_api_template_accesses_update_administrator_to_owner(via, mock_user_team
|
||||
user=other_user,
|
||||
role=random.choice(["administrator", "editor", "reader"]),
|
||||
)
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
@@ -667,7 +474,7 @@ def test_api_template_accesses_update_owner(via, mock_user_teams):
|
||||
A user who is an owner in a template should be allowed to update
|
||||
a user access for this template whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -685,8 +492,8 @@ def test_api_template_accesses_update_owner(via, mock_user_teams):
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template,
|
||||
)
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
@@ -723,22 +530,21 @@ def test_api_template_accesses_update_owner_self(via, mock_user_teams):
|
||||
A user who is owner of a template should be allowed to update
|
||||
their own user access provided there are other owners in the template.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
access = None
|
||||
if via == USER:
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="owner"
|
||||
)
|
||||
elif via == TEAM:
|
||||
if via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
access = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
else:
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="owner"
|
||||
)
|
||||
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
new_role = random.choice(["administrator", "editor", "reader"])
|
||||
@@ -787,7 +593,7 @@ def test_api_template_accesses_delete_authenticated():
|
||||
Authenticated users should not be allowed to delete a template access for a
|
||||
template to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -799,7 +605,7 @@ def test_api_template_accesses_delete_authenticated():
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TemplateAccess.objects.count() == 1
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor"])
|
||||
@@ -809,7 +615,7 @@ def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_team
|
||||
Authenticated users should not be allowed to delete a template access for a
|
||||
template in which they are a simple editor or reader.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -825,7 +631,7 @@ def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_team
|
||||
|
||||
access = factories.UserTemplateAccessFactory(template=template)
|
||||
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
assert models.TemplateAccess.objects.count() == 3
|
||||
assert models.TemplateAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
@@ -833,7 +639,7 @@ def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_team
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
assert models.TemplateAccess.objects.count() == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@@ -881,7 +687,7 @@ def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_tea
|
||||
Users who are administrators in a template should not be allowed to delete an ownership
|
||||
access from the template.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -899,7 +705,7 @@ def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_tea
|
||||
|
||||
access = factories.UserTemplateAccessFactory(template=template, role="owner")
|
||||
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
assert models.TemplateAccess.objects.count() == 3
|
||||
assert models.TemplateAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
@@ -907,7 +713,7 @@ def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_tea
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
assert models.TemplateAccess.objects.count() == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@@ -948,7 +754,7 @@ def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
|
||||
"""
|
||||
It should not be possible to delete the last owner access from a template
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -965,10 +771,10 @@ def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
assert models.TemplateAccess.objects.count() == 1
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TemplateAccess.objects.count() == 1
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Test template accesses create API endpoint for users in impress's core app.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_template_accesses_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create template accesses."""
|
||||
template = factories.TemplateFactory()
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"template": str(template.id),
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
assert models.TemplateAccess.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_template_accesses_create_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to create template accesses for a template to
|
||||
which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
template = factories.TemplateFactory()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_create_authenticated_editor_or_reader(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""Editors or readers of a template should not be allowed to create template accesses."""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role=role
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
for new_role in [role[0] for role in models.RoleChoices.choices]:
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": new_role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_create_authenticated_administrator(via, mock_user_teams):
|
||||
"""
|
||||
Administrators of a template should be able to create template accesses
|
||||
except for the "owner" role.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
# It should not be allowed to create an owner access
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": "owner",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Only owners of a resource can assign other users as owners."
|
||||
}
|
||||
|
||||
# It should be allowed to create a lower access
|
||||
role = random.choice(
|
||||
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
|
||||
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"abilities": new_template_access.get_abilities(user),
|
||||
"id": str(new_template_access.id),
|
||||
"team": "",
|
||||
"role": role,
|
||||
"user": str(other_user.id),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_create_authenticated_owner(via, mock_user_teams):
|
||||
"""
|
||||
Owners of a template should be able to create template accesses whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_template=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
|
||||
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"id": str(new_template_access.id),
|
||||
"user": str(other_user.id),
|
||||
"team": "",
|
||||
"role": role,
|
||||
"abilities": new_template_access.get_abilities(user),
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Test throttling on documents for the AI endpoint.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.test import APIRequestFactory
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from core.api.utils import AIDocumentRateThrottle
|
||||
|
||||
|
||||
class DocumentAPIView(APIView):
|
||||
"""A simple view to test the throttle"""
|
||||
|
||||
throttle_classes = [AIDocumentRateThrottle]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Minimal get method for testing purposes."""
|
||||
return Response({"message": "Success"})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache():
|
||||
"""Fixture to clear the cache before each test."""
|
||||
cache.clear()
|
||||
|
||||
|
||||
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
|
||||
@patch("time.time")
|
||||
def test_api_utils_ai_document_rate_throttle_minute_limit(mock_time):
|
||||
"""Test that minute limit is enforced."""
|
||||
api_rf = APIRequestFactory()
|
||||
mock_time.return_value = 1000000
|
||||
|
||||
# Simulate requests to the document API
|
||||
for _i in range(3): # 3 first requests should be allowed
|
||||
request = api_rf.get("/documents/1/")
|
||||
response = DocumentAPIView.as_view()(request, pk=1)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Simulate passage of time
|
||||
mock_time.return_value += 59
|
||||
|
||||
# 4th request should be throttled
|
||||
request = api_rf.get("/documents/1/")
|
||||
response = DocumentAPIView.as_view()(request, pk=1)
|
||||
assert response.status_code == 429
|
||||
|
||||
# After the 60s backoff wait time has passed, we can make a request again
|
||||
mock_time.return_value += 1
|
||||
|
||||
request = api_rf.get("/documents/1/")
|
||||
response = DocumentAPIView.as_view()(request, pk=1)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 100000, "hour": 6, "day": 10}
|
||||
)
|
||||
@patch("time.time")
|
||||
def test_ai_document_rate_throttle_hour_limit(mock_time):
|
||||
"""Test that the hour limit is enforced without hitting the minute limit."""
|
||||
api_rf = APIRequestFactory()
|
||||
mock_time.return_value = 1000000
|
||||
|
||||
# Make requests to the document API, one per 21 seconds to avoid hitting the minute limit
|
||||
for _i in range(6):
|
||||
request = api_rf.get("/documents/1/")
|
||||
response = DocumentAPIView.as_view()(request, pk=1)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Simulate passage of time
|
||||
mock_time.return_value += 21
|
||||
|
||||
# Simulate passage of time
|
||||
mock_time.return_value += 3600 - 6 * 21 - 1
|
||||
|
||||
# 7th request should be throttled
|
||||
request = api_rf.get("/documents/1/")
|
||||
response = DocumentAPIView.as_view()(request, pk=1)
|
||||
assert response.status_code == 429
|
||||
|
||||
# After the 1h backoff wait time has passed, we can make a request again
|
||||
mock_time.return_value += 1
|
||||
|
||||
request = api_rf.get("/documents/1/")
|
||||
response = DocumentAPIView.as_view()(request, pk=1)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
|
||||
@patch("time.time")
|
||||
def test_api_utils_ai_document_rate_throttle_day_limit(mock_time):
|
||||
"""Test that day limit is enforced."""
|
||||
api_rf = APIRequestFactory()
|
||||
mock_time.return_value = 1000000
|
||||
|
||||
# Make requests to the document API, one per 10 minutes to avoid hitting
|
||||
# the minute and hour limits
|
||||
for _i in range(10): # 10 requests should be allowed
|
||||
request = api_rf.get("/documents/1/")
|
||||
response = DocumentAPIView.as_view()(request, pk=1)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Simulate passage of time
|
||||
mock_time.return_value += 60 * 10
|
||||
|
||||
# Simulate passage of time
|
||||
mock_time.return_value += 24 * 3600 - 10 * 60 * 10 - 1
|
||||
|
||||
# 11th request should be throttled
|
||||
request = api_rf.get("/documents/1/")
|
||||
response = DocumentAPIView.as_view()(request, pk=1)
|
||||
assert response.status_code == 429
|
||||
|
||||
# After the 24h backoff wait time has passed we can make a request again
|
||||
mock_time.return_value += 1
|
||||
|
||||
request = api_rf.get("/documents/1/")
|
||||
response = DocumentAPIView.as_view()(request, pk=1)
|
||||
assert response.status_code == 200
|
||||
146
src/backend/core/tests/test_api_utils_ai_user_rate_throttles.py
Normal file
146
src/backend/core/tests/test_api_utils_ai_user_rate_throttles.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
Test throttling on users for the AI endpoint.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.test import APIRequestFactory
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from core.api.utils import AIUserRateThrottle
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
class DocumentAPIView(APIView):
|
||||
"""A simple view to test the throttle"""
|
||||
|
||||
throttle_classes = [AIUserRateThrottle]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Minimal get method for testing purposes."""
|
||||
return Response({"message": "Success"})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache():
|
||||
"""Fixture to clear the cache before each test."""
|
||||
cache.clear()
|
||||
|
||||
|
||||
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
|
||||
@patch("time.time")
|
||||
def test_api_utils_ai_user_rate_throttle_minute_limit(mock_time):
|
||||
"""Test that minute limit is enforced."""
|
||||
user = UserFactory()
|
||||
api_rf = APIRequestFactory()
|
||||
mock_time.return_value = 1000000
|
||||
|
||||
# Simulate requests to the document API
|
||||
for _i in range(3): # 3 first requests should be allowed
|
||||
document_id = str(uuid4())
|
||||
request = api_rf.get(f"/documents/{document_id:s}/")
|
||||
request.user = user
|
||||
response = DocumentAPIView.as_view()(request, pk=document_id)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Simulate passage of time
|
||||
mock_time.return_value += 59
|
||||
|
||||
# 4th request should be throttled
|
||||
document_id = str(uuid4())
|
||||
request = api_rf.get(f"/documents/{document_id:s}/")
|
||||
request.user = user
|
||||
response = DocumentAPIView.as_view()(request, pk=document_id)
|
||||
assert response.status_code == 429
|
||||
|
||||
# After the 60s backoff wait time has passed, we can make a request again
|
||||
mock_time.return_value += 1
|
||||
|
||||
document_id = str(uuid4())
|
||||
request = api_rf.get(f"/documents/{document_id:s}/")
|
||||
request.user = user
|
||||
response = DocumentAPIView.as_view()(request, pk=document_id)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 100000, "hour": 6, "day": 10})
|
||||
@patch("time.time")
|
||||
def test_ai_user_rate_throttle_hour_limit(mock_time):
|
||||
"""Test that the hour limit is enforced without hitting the minute limit."""
|
||||
user = UserFactory()
|
||||
api_rf = APIRequestFactory()
|
||||
mock_time.return_value = 1000000
|
||||
|
||||
# Make requests to the document API, one per 21 seconds to avoid hitting the minute limit
|
||||
for _i in range(6):
|
||||
document_id = str(uuid4())
|
||||
request = api_rf.get(f"/documents/{document_id:s}/")
|
||||
request.user = user
|
||||
response = DocumentAPIView.as_view()(request, pk=document_id)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Simulate passage of time
|
||||
mock_time.return_value += 21
|
||||
|
||||
# Simulate passage of time
|
||||
mock_time.return_value += 3600 - 6 * 21 - 1
|
||||
|
||||
# 7th request should be throttled
|
||||
request = api_rf.get(f"/documents/{document_id:s}/")
|
||||
request.user = user
|
||||
response = DocumentAPIView.as_view()(request, pk=document_id)
|
||||
assert response.status_code == 429
|
||||
|
||||
# After the 1h backoff wait time has passed, we can make a request again
|
||||
mock_time.return_value += 1
|
||||
|
||||
request = api_rf.get(f"/documents/{document_id:s}/")
|
||||
request.user = user
|
||||
response = DocumentAPIView.as_view()(request, pk=document_id)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
|
||||
@patch("time.time")
|
||||
def test_api_utils_ai_user_rate_throttle_day_limit(mock_time):
|
||||
"""Test that day limit is enforced."""
|
||||
user = UserFactory()
|
||||
api_rf = APIRequestFactory()
|
||||
mock_time.return_value = 1000000
|
||||
|
||||
# Make requests to the document API, one per 10 minutes to avoid hitting
|
||||
# the minute and hour limits
|
||||
for _i in range(10): # 10 requests should be allowed
|
||||
document_id = str(uuid4())
|
||||
request = api_rf.get(f"/documents/{document_id:s}/")
|
||||
request.user = user
|
||||
response = DocumentAPIView.as_view()(request, pk=document_id)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Simulate passage of time
|
||||
mock_time.return_value += 60 * 10
|
||||
|
||||
# Simulate passage of time
|
||||
mock_time.return_value += 24 * 3600 - 10 * 60 * 10 - 1
|
||||
|
||||
# 11th request should be throttled
|
||||
request = api_rf.get(f"/documents/{document_id:s}/")
|
||||
request.user = user
|
||||
response = DocumentAPIView.as_view()(request, pk=document_id)
|
||||
assert response.status_code == 429
|
||||
|
||||
# After the 24h backoff wait time has passed we can make a request again
|
||||
mock_time.return_value += 1
|
||||
|
||||
request = api_rf.get(f"/documents/{document_id:s}/")
|
||||
request.user = user
|
||||
response = DocumentAPIView.as_view()(request, pk=document_id)
|
||||
assert response.status_code == 200
|
||||
@@ -83,6 +83,8 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
|
||||
user = factories.UserFactory() if is_authenticated else AnonymousUser()
|
||||
abilities = document.get_abilities(user)
|
||||
assert abilities == {
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"link_configuration": False,
|
||||
"destroy": False,
|
||||
@@ -113,6 +115,8 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
|
||||
user = factories.UserFactory() if is_authenticated else AnonymousUser()
|
||||
abilities = document.get_abilities(user)
|
||||
assert abilities == {
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"destroy": False,
|
||||
"link_configuration": False,
|
||||
@@ -143,6 +147,8 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach):
|
||||
user = factories.UserFactory() if is_authenticated else AnonymousUser()
|
||||
abilities = document.get_abilities(user)
|
||||
assert abilities == {
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"destroy": False,
|
||||
"link_configuration": False,
|
||||
@@ -162,6 +168,8 @@ def test_models_documents_get_abilities_owner():
|
||||
access = factories.UserDocumentAccessFactory(role="owner", user=user)
|
||||
abilities = access.document.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"destroy": True,
|
||||
"link_configuration": True,
|
||||
@@ -180,6 +188,8 @@ def test_models_documents_get_abilities_administrator():
|
||||
access = factories.UserDocumentAccessFactory(role="administrator")
|
||||
abilities = access.document.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"destroy": False,
|
||||
"link_configuration": True,
|
||||
@@ -201,6 +211,8 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
abilities = access.document.get_abilities(access.user)
|
||||
|
||||
assert abilities == {
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"destroy": False,
|
||||
"link_configuration": False,
|
||||
@@ -224,6 +236,8 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
|
||||
abilities = access.document.get_abilities(access.user)
|
||||
|
||||
assert abilities == {
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"destroy": False,
|
||||
"link_configuration": False,
|
||||
@@ -248,6 +262,8 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
abilities = access.document.get_abilities(access.user)
|
||||
|
||||
assert abilities == {
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"destroy": False,
|
||||
"link_configuration": False,
|
||||
@@ -363,8 +379,9 @@ def test_models_documents__email_invitation__success():
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
sender = factories.UserFactory(full_name="Test Sender", email="sender@example.com")
|
||||
document.email_invitation(
|
||||
"en", "guest@example.com", models.RoleChoices.EDITOR, "sender@example.com"
|
||||
"en", "guest@example.com", models.RoleChoices.EDITOR, sender
|
||||
)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
@@ -377,8 +394,8 @@ def test_models_documents__email_invitation__success():
|
||||
email_content = " ".join(email.body.split())
|
||||
|
||||
assert (
|
||||
f"sender@example.com invited you as an editor on the following document : {document.title}"
|
||||
in email_content
|
||||
f'Test Sender (sender@example.com) invited you with the role "editor" '
|
||||
f"on the following document : {document.title}" in email_content
|
||||
)
|
||||
assert f"docs/{document.id}/" in email_content
|
||||
|
||||
@@ -392,8 +409,14 @@ def test_models_documents__email_invitation__success_fr():
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
sender = factories.UserFactory(
|
||||
full_name="Test Sender2", email="sender2@example.com"
|
||||
)
|
||||
document.email_invitation(
|
||||
"fr-fr", "guest2@example.com", models.RoleChoices.OWNER, "sender2@example.com"
|
||||
"fr-fr",
|
||||
"guest2@example.com",
|
||||
models.RoleChoices.OWNER,
|
||||
sender,
|
||||
)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
@@ -406,7 +429,7 @@ def test_models_documents__email_invitation__success_fr():
|
||||
email_content = " ".join(email.body.split())
|
||||
|
||||
assert (
|
||||
f"sender2@example.com vous a invité en tant que propriétaire "
|
||||
f'Test Sender2 (sender2@example.com) vous a invité avec le rôle "propriétaire" '
|
||||
f"sur le document suivant : {document.title}" in email_content
|
||||
)
|
||||
assert f"docs/{document.id}/" in email_content
|
||||
@@ -424,8 +447,12 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
sender = factories.UserFactory()
|
||||
document.email_invitation(
|
||||
"en", "guest3@example.com", models.RoleChoices.ADMIN, "sender3@example.com"
|
||||
"en",
|
||||
"guest3@example.com",
|
||||
models.RoleChoices.ADMIN,
|
||||
sender,
|
||||
)
|
||||
|
||||
# No email has been sent
|
||||
|
||||
104
src/backend/core/tests/test_services_ai_services.py
Normal file
104
src/backend/core/tests/test_services_ai_services.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Test ai API endpoints in the impress core app.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from openai import OpenAIError
|
||||
|
||||
from core.services.ai_services import AIService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"setting_name, setting_value",
|
||||
[
|
||||
("AI_BASE_URL", None),
|
||||
("AI_API_KEY", None),
|
||||
("AI_MODEL", None),
|
||||
],
|
||||
)
|
||||
def test_api_ai_setting_missing(setting_name, setting_value):
|
||||
"""Setting should be set"""
|
||||
|
||||
with override_settings(**{setting_name: setting_value}):
|
||||
with pytest.raises(
|
||||
ImproperlyConfigured,
|
||||
match="AI configuration not set",
|
||||
):
|
||||
AIService()
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
|
||||
)
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_ai__client_error(mock_create):
|
||||
"""Fail when the client raises an error"""
|
||||
|
||||
mock_create.side_effect = OpenAIError("Mocked client error")
|
||||
|
||||
with pytest.raises(
|
||||
OpenAIError,
|
||||
match="Mocked client error",
|
||||
):
|
||||
AIService().transform("hello", "prompt")
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
|
||||
)
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_ai__client_invalid_response(mock_create):
|
||||
"""Fail when the client response is invalid"""
|
||||
|
||||
answer = {"no_answer": "This is an invalid response"}
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=json.dumps(answer)))]
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="AI response does not contain an answer",
|
||||
):
|
||||
AIService().transform("hello", "prompt")
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
|
||||
)
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_ai__success(mock_create):
|
||||
"""The AI request should work as expect when called with valid arguments."""
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
)
|
||||
|
||||
response = AIService().transform("hello", "prompt")
|
||||
|
||||
assert response == {"answer": "Salut"}
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
|
||||
)
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_ai__success_sanitize(mock_create):
|
||||
"""The AI response should be sanitized"""
|
||||
|
||||
answer = '{"answer": "Salut\\n \tle \nmonde"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
)
|
||||
|
||||
response = AIService().transform("hello", "prompt")
|
||||
|
||||
assert response == {"answer": "Salut\n \tle \nmonde"}
|
||||
@@ -144,14 +144,75 @@ class Base(Configuration):
|
||||
environ_name="DOCUMENT_IMAGE_MAX_SIZE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
DOCUMENT_IMAGE_ALLOWED_MIME_TYPES = [
|
||||
"image/bmp",
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
|
||||
DOCUMENT_UNSAFE_MIME_TYPES = [
|
||||
# Executable Files
|
||||
"application/x-msdownload",
|
||||
"application/x-bat",
|
||||
"application/x-dosexec",
|
||||
"application/x-sh",
|
||||
"application/x-ms-dos-executable",
|
||||
"application/x-msi",
|
||||
"application/java-archive",
|
||||
"application/octet-stream",
|
||||
# Dynamic Web Pages
|
||||
"application/x-httpd-php",
|
||||
"application/x-asp",
|
||||
"application/x-aspx",
|
||||
"application/jsp",
|
||||
"application/xhtml+xml",
|
||||
"application/x-python-code",
|
||||
"application/x-perl",
|
||||
"text/html",
|
||||
"text/javascript",
|
||||
"text/x-php",
|
||||
# System Files
|
||||
"application/x-msdownload",
|
||||
"application/x-sys",
|
||||
"application/x-drv",
|
||||
"application/cpl",
|
||||
"application/x-apple-diskimage",
|
||||
# Script Files
|
||||
"application/javascript",
|
||||
"application/x-vbscript",
|
||||
"application/x-powershell",
|
||||
"application/x-shellscript",
|
||||
# Compressed/Archive Files
|
||||
"application/zip",
|
||||
"application/x-tar",
|
||||
"application/gzip",
|
||||
"application/x-bzip2",
|
||||
"application/x-7z-compressed",
|
||||
"application/x-rar",
|
||||
"application/x-rar-compressed",
|
||||
"application/x-compress",
|
||||
"application/x-lzma",
|
||||
# Macros in Documents
|
||||
"application/vnd.ms-word",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.ms-word.document.macroenabled.12",
|
||||
"application/vnd.ms-excel.sheet.macroenabled.12",
|
||||
"application/vnd.ms-powerpoint.presentation.macroenabled.12",
|
||||
# Disk Images & Virtual Disk Files
|
||||
"application/x-iso9660-image",
|
||||
"application/x-vmdk",
|
||||
"application/x-apple-diskimage",
|
||||
"application/x-dmg",
|
||||
# Other Dangerous MIME Types
|
||||
"application/x-ms-application",
|
||||
"application/x-msdownload",
|
||||
"application/x-shockwave-flash",
|
||||
"application/x-silverlight-app",
|
||||
"application/x-java-vm",
|
||||
"application/x-bittorrent",
|
||||
"application/hta",
|
||||
"application/x-csh",
|
||||
"application/x-ksh",
|
||||
"application/x-ms-regedit",
|
||||
"application/x-msdownload",
|
||||
"application/xml",
|
||||
"image/svg+xml",
|
||||
"image/tiff",
|
||||
"image/webp",
|
||||
]
|
||||
|
||||
# Document versions
|
||||
@@ -393,6 +454,20 @@ class Base(Configuration):
|
||||
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
|
||||
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
||||
)
|
||||
AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None)
|
||||
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
|
||||
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
|
||||
|
||||
AI_DOCUMENT_RATE_THROTTLE_RATES = {
|
||||
"minute": 5,
|
||||
"hour": 100,
|
||||
"day": 500,
|
||||
}
|
||||
AI_USER_RATE_THROTTLE_RATES = {
|
||||
"minute": 3,
|
||||
"hour": 50,
|
||||
"day": 200,
|
||||
}
|
||||
|
||||
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
|
||||
default=["first_name", "last_name"],
|
||||
|
||||
Binary file not shown.
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-09-25 10:15+0000\n"
|
||||
"PO-Revision-Date: 2024-09-25 10:17\n"
|
||||
"POT-Creation-Date: 2024-10-15 07:19+0000\n"
|
||||
"PO-Revision-Date: 2024-10-15 07:23\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -17,15 +17,15 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 8\n"
|
||||
|
||||
#: core/admin.py:32
|
||||
#: core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:34
|
||||
#: core/admin.py:46
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:46
|
||||
#: core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
@@ -41,7 +41,7 @@ msgstr ""
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:56
|
||||
#: core/authentication/backends.py:57
|
||||
msgid "Invalid response format or token verification failed"
|
||||
msgstr ""
|
||||
|
||||
@@ -49,10 +49,6 @@ msgstr ""
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:101
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:62 core/models.py:69
|
||||
msgid "Reader"
|
||||
msgstr ""
|
||||
@@ -117,223 +113,236 @@ msgstr ""
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:148
|
||||
#: core/models.py:149
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:150
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:152
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:153
|
||||
#: core/models.py:157
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:160
|
||||
#: core/models.py:164
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:165
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:167
|
||||
#: core/models.py:171
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:174
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:172
|
||||
#: core/models.py:176
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:175
|
||||
#: core/models.py:179
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:181
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:180
|
||||
#: core/models.py:184
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:187
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:195
|
||||
#: core/models.py:199
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:196
|
||||
#: core/models.py:200
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:328 core/models.py:644
|
||||
#: core/models.py:332 core/models.py:638
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:343
|
||||
#: core/models.py:347
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:344
|
||||
#: core/models.py:348
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:347
|
||||
#: core/models.py:351
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:537
|
||||
#: core/models.py:530
|
||||
#, python-format
|
||||
msgid "%(username)s shared a document with you: %(document)s"
|
||||
msgid "%(sender_name)s shared a document with you: %(document)s"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:580
|
||||
#: core/models.py:574
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:581
|
||||
#: core/models.py:575
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:587
|
||||
#: core/models.py:581
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:608
|
||||
#: core/models.py:602
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:609
|
||||
#: core/models.py:603
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:615
|
||||
#: core/models.py:609
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:621
|
||||
#: core/models.py:615
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:627 core/models.py:816
|
||||
#: core/models.py:621 core/models.py:810
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:645
|
||||
#: core/models.py:639
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:646
|
||||
#: core/models.py:640
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:647
|
||||
#: core/models.py:641
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:649
|
||||
#: core/models.py:643
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:651
|
||||
#: core/models.py:645
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:657
|
||||
#: core/models.py:651
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:658
|
||||
#: core/models.py:652
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:797
|
||||
#: core/models.py:791
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:798
|
||||
#: core/models.py:792
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:804
|
||||
#: core/models.py:798
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:810
|
||||
#: core/models.py:804
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:833
|
||||
#: core/models.py:827
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:850
|
||||
#: core/models.py:844
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:851
|
||||
#: core/models.py:845
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:868
|
||||
#: core/models.py:862
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:160
|
||||
#: core/templates/mail/html/invitation2.html:160
|
||||
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
|
||||
msgid "Company logo"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
#, python-format
|
||||
msgid "Hello %(name)s"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
msgid "Hello"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
|
||||
msgid "Thank you very much for your visit!"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:221
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/invitation2.txt:3
|
||||
msgid "La Suite Numérique"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:190
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
#, python-format
|
||||
msgid " %(username)s shared a document with you ! "
|
||||
msgid " %(sender_name)s shared a document with you ! "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:197
|
||||
#: core/templates/mail/html/invitation.html:196
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
#, python-format
|
||||
msgid " %(username)s invited you as an %(role)s on the following document : "
|
||||
msgid " %(sender_name_email)s invited you with the role \"%(role)s\" on the following document : "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:206
|
||||
#: core/templates/mail/html/invitation2.html:211
|
||||
#: core/templates/mail/html/invitation.html:205
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
#: core/templates/mail/text/invitation2.txt:11
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:223
|
||||
#: core/templates/mail/html/invitation.html:222
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:230
|
||||
#: core/templates/mail/html/invitation2.html:235
|
||||
#: core/templates/mail/html/invitation.html:229
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#: core/templates/mail/text/invitation2.txt:17
|
||||
msgid "Brought to you by La Suite Numérique"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation2.html:190
|
||||
#: core/templates/mail/text/hello.txt:8
|
||||
#, python-format
|
||||
msgid "%(username)s shared a document with you"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation2.html:197
|
||||
#: core/templates/mail/text/invitation2.txt:8
|
||||
#, python-format
|
||||
msgid "%(username)s invited you as an %(role)s on the following document :"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation2.html:228
|
||||
#: core/templates/mail/text/invitation2.txt:15
|
||||
msgid "Docs, your new essential tool for organizing, sharing and collaborate on your document as a team."
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:176
|
||||
|
||||
Binary file not shown.
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-09-25 10:15+0000\n"
|
||||
"PO-Revision-Date: 2024-09-25 10:21\n"
|
||||
"POT-Creation-Date: 2024-10-15 07:19+0000\n"
|
||||
"PO-Revision-Date: 2024-10-15 07:23\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -17,15 +17,15 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 8\n"
|
||||
|
||||
#: core/admin.py:32
|
||||
#: core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Infos Personnelles"
|
||||
|
||||
#: core/admin.py:34
|
||||
#: core/admin.py:46
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: core/admin.py:46
|
||||
#: core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
@@ -41,7 +41,7 @@ msgstr ""
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:56
|
||||
#: core/authentication/backends.py:57
|
||||
msgid "Invalid response format or token verification failed"
|
||||
msgstr ""
|
||||
|
||||
@@ -49,10 +49,6 @@ msgstr ""
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:101
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:62 core/models.py:69
|
||||
msgid "Reader"
|
||||
msgstr "Lecteur"
|
||||
@@ -117,224 +113,237 @@ msgstr ""
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:148
|
||||
#: core/models.py:149
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:150
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:152
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:153
|
||||
#: core/models.py:157
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:160
|
||||
#: core/models.py:164
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:165
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:167
|
||||
#: core/models.py:171
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:174
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:172
|
||||
#: core/models.py:176
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:175
|
||||
#: core/models.py:179
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:181
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:180
|
||||
#: core/models.py:184
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:187
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:195
|
||||
#: core/models.py:199
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:196
|
||||
#: core/models.py:200
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:328 core/models.py:644
|
||||
#: core/models.py:332 core/models.py:638
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:343
|
||||
#: core/models.py:347
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:344
|
||||
#: core/models.py:348
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:347
|
||||
#: core/models.py:351
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:537
|
||||
#: core/models.py:530
|
||||
#, python-format
|
||||
msgid "%(username)s shared a document with you: %(document)s"
|
||||
msgstr "%(username)s a partagé un document avec vous: %(document)s"
|
||||
msgid "%(sender_name)s shared a document with you: %(document)s"
|
||||
msgstr "%(sender_name)s a partagé un document avec vous: %(document)s"
|
||||
|
||||
#: core/models.py:580
|
||||
#: core/models.py:574
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:581
|
||||
#: core/models.py:575
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:587
|
||||
#: core/models.py:581
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:608
|
||||
#: core/models.py:602
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:609
|
||||
#: core/models.py:603
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:615
|
||||
#: core/models.py:609
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:621
|
||||
#: core/models.py:615
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:627 core/models.py:816
|
||||
#: core/models.py:621 core/models.py:810
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:645
|
||||
#: core/models.py:639
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:646
|
||||
#: core/models.py:640
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:647
|
||||
#: core/models.py:641
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:649
|
||||
#: core/models.py:643
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:651
|
||||
#: core/models.py:645
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:657
|
||||
#: core/models.py:651
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:658
|
||||
#: core/models.py:652
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:797
|
||||
#: core/models.py:791
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:798
|
||||
#: core/models.py:792
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:804
|
||||
#: core/models.py:798
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:810
|
||||
#: core/models.py:804
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:833
|
||||
#: core/models.py:827
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:850
|
||||
#: core/models.py:844
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:851
|
||||
#: core/models.py:845
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:868
|
||||
#: core/models.py:862
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:160
|
||||
#: core/templates/mail/html/invitation2.html:160
|
||||
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
|
||||
msgid "Company logo"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
#, python-format
|
||||
msgid "Hello %(name)s"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
msgid "Hello"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
|
||||
msgid "Thank you very much for your visit!"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:221
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/invitation2.txt:3
|
||||
msgid "La Suite Numérique"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:190
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
#, python-format
|
||||
msgid " %(username)s shared a document with you ! "
|
||||
msgstr " %(username)s a partagé un document avec vous ! "
|
||||
msgid " %(sender_name)s shared a document with you ! "
|
||||
msgstr " %(sender_name)s a partagé un document avec vous ! "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:197
|
||||
#: core/templates/mail/html/invitation.html:196
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
#, python-format
|
||||
msgid " %(username)s invited you as an %(role)s on the following document : "
|
||||
msgstr " %(username)s vous a invité en tant que %(role)s sur le document suivant : "
|
||||
msgid " %(sender_name_email)s invited you with the role \"%(role)s\" on the following document : "
|
||||
msgstr " %(sender_name_email)s vous a invité avec le rôle \"%(role)s\" sur le document suivant : "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:206
|
||||
#: core/templates/mail/html/invitation2.html:211
|
||||
#: core/templates/mail/html/invitation.html:205
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
#: core/templates/mail/text/invitation2.txt:11
|
||||
msgid "Open"
|
||||
msgstr "Ouvrir"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:223
|
||||
#: core/templates/mail/html/invitation.html:222
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
|
||||
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:230
|
||||
#: core/templates/mail/html/invitation2.html:235
|
||||
#: core/templates/mail/html/invitation.html:229
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#: core/templates/mail/text/invitation2.txt:17
|
||||
msgid "Brought to you by La Suite Numérique"
|
||||
msgstr "Proposé par La Suite Numérique"
|
||||
|
||||
#: core/templates/mail/html/invitation2.html:190
|
||||
#: core/templates/mail/text/hello.txt:8
|
||||
#, python-format
|
||||
msgid "%(username)s shared a document with you"
|
||||
msgstr "%(username)s a partagé un document avec vous"
|
||||
|
||||
#: core/templates/mail/html/invitation2.html:197
|
||||
#: core/templates/mail/text/invitation2.txt:8
|
||||
#, python-format
|
||||
msgid "%(username)s invited you as an %(role)s on the following document :"
|
||||
msgstr "%(username)s vous a invité en tant que %(role)s sur le document suivant :"
|
||||
|
||||
#: core/templates/mail/html/invitation2.html:228
|
||||
#: core/templates/mail/text/invitation2.txt:15
|
||||
msgid "Docs, your new essential tool for organizing, sharing and collaborate on your document as a team."
|
||||
msgstr "Docs, votre nouvel outil essentiel pour organiser, partager et collaborer sur votre document en équipe."
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:176
|
||||
msgid "English"
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "1.5.0"
|
||||
version = "1.6.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,34 +25,35 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.35.34",
|
||||
"boto3==1.35.41",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.4.0",
|
||||
"django-cors-headers==4.5.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-parler==2.3",
|
||||
"redis==5.1.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.1",
|
||||
"django==5.1.2",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.27.2",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10",
|
||||
"factory_boy==3.3.1",
|
||||
"freezegun==1.5.1",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.23.0",
|
||||
"markdown==3.7",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"openai==1.44.1",
|
||||
"psycopg[binary]==3.2.3",
|
||||
"PyJWT==2.9.0",
|
||||
"pypandoc==1.13",
|
||||
"pypandoc==1.14",
|
||||
"python-frontmatter==1.1.0",
|
||||
"python-magic==0.4.27",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.15.0",
|
||||
"sentry-sdk==2.16.0",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.7.0",
|
||||
@@ -69,10 +70,11 @@ dependencies = [
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.7.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.28.0",
|
||||
"pyfakefs==5.6.0",
|
||||
"pylint-django==2.5.5",
|
||||
"pyfakefs==5.7.1",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.1",
|
||||
"pytest-cov==5.0.0",
|
||||
"pytest-django==4.9.0",
|
||||
@@ -81,7 +83,7 @@ dev = [
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.3",
|
||||
"ruff==0.6.9",
|
||||
"types-requests==2.32.0.20240914",
|
||||
"types-requests==2.32.0.20241016",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
5
src/blocknote/.prettierrc
Normal file
5
src/blocknote/.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"semi": false,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true
|
||||
}
|
||||
28
src/blocknote/Dockerfile
Normal file
28
src/blocknote/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM node:20-alpine AS dependencies
|
||||
|
||||
WORKDIR /home/blocknote
|
||||
|
||||
COPY ./src/blocknote/package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY .dockerignore ./.dockerignore
|
||||
COPY ./src/blocknote/ .
|
||||
|
||||
FROM dependencies AS blocknote-builder
|
||||
|
||||
WORKDIR /home/blocknote
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---- Blocknote image ----
|
||||
FROM blocknote-builder AS production
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint"]
|
||||
CMD ["npm", "run", "start"]
|
||||
5
src/blocknote/nodemon.json
Normal file
5
src/blocknote/nodemon.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"watch": ["src"],
|
||||
"ext": "ts",
|
||||
"exec": "concurrently \"npx tsc --watch\" \"ts-node src/index.ts\""
|
||||
}
|
||||
5084
src/blocknote/package-lock.json
generated
Normal file
5084
src/blocknote/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
src/blocknote/package.json
Normal file
35
src/blocknote/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "blocknote-server",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"main": "dist/index.js",
|
||||
"keywords": [
|
||||
"nodejs",
|
||||
"bootstrap",
|
||||
"express"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npx tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "nodemon src/index.ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"format": "prettier --write ./src",
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blocknote/server-util": "0.17.1",
|
||||
"dotenv": "16.4.5",
|
||||
"express": "4.21.1",
|
||||
"prettier": "3.3.3",
|
||||
"yjs": "13.6.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "5.0.0",
|
||||
"@types/node": "22.7.7",
|
||||
"concurrently": "9.0.1",
|
||||
"nodemon": "3.1.7",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.6.3",
|
||||
"prettier": "3.3.3"
|
||||
}
|
||||
}
|
||||
37
src/blocknote/src/index.ts
Normal file
37
src/blocknote/src/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import express, { Express, Request, Response } from 'express'
|
||||
import { asyncWrapper, convertMarkdown } from './utils'
|
||||
import dotenv from 'dotenv'
|
||||
import bodyParser from 'body-parser'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const app: Express = express()
|
||||
const router = express.Router()
|
||||
const port = process.env.PORT ?? 8081
|
||||
|
||||
app.use(bodyParser.json())
|
||||
app.use(bodyParser.urlencoded({ extended: true }))
|
||||
|
||||
// Logging middleware, logs the request method and path for each incoming request
|
||||
router.use(async function (req, res, next) {
|
||||
console.log(`/${req.method}`)
|
||||
next()
|
||||
})
|
||||
|
||||
// Liveness probe endpoint for Kubernetes health checks
|
||||
router.get('/__heartbeat__', (req: Request, res: Response) => {
|
||||
res.status(200).send({ status: 'OK' })
|
||||
})
|
||||
|
||||
// Load balancer heartbeat check, useful to detect app readiness
|
||||
router.get('/__lbheartbeat__', (req: Request, res: Response) => {
|
||||
res.status(200).send({ status: 'OK' })
|
||||
})
|
||||
|
||||
router.post('/', asyncWrapper(convertMarkdown))
|
||||
|
||||
app.use('/', router)
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`[server]: Server listening on port ${port}`)
|
||||
})
|
||||
60
src/blocknote/src/utils.ts
Normal file
60
src/blocknote/src/utils.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Utility functions for handling markdown conversion and related operations
|
||||
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
import { ServerBlockNoteEditor } from '@blocknote/server-util'
|
||||
import Y from 'yjs'
|
||||
|
||||
const toBase64 = function (str: Uint8Array) {
|
||||
return Buffer.from(str).toString('base64')
|
||||
}
|
||||
|
||||
export const asyncWrapper = (
|
||||
asyncFn: (req: Request, res: Response) => Promise<Response>
|
||||
) => {
|
||||
return function (req: Request, res: Response, next: NextFunction) {
|
||||
asyncFn(req, res).catch(next)
|
||||
}
|
||||
}
|
||||
|
||||
const validateContent = (content: string | undefined): string => {
|
||||
if (!content) {
|
||||
throw new Error('Content is required')
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
const parseMarkdownToBlocks = async (
|
||||
blockNoteEditor: ServerBlockNoteEditor,
|
||||
content: string
|
||||
) => {
|
||||
try {
|
||||
const blocks = await blockNoteEditor.tryParseMarkdownToBlocks(content)
|
||||
if (!blocks || blocks.length === 0) {
|
||||
throw new Error('No valid blocks generated')
|
||||
}
|
||||
return blocks
|
||||
} catch (error) {
|
||||
throw new Error('Failed to parse markdown content')
|
||||
}
|
||||
}
|
||||
|
||||
const processContentBlocks = (server: ServerBlockNoteEditor, blocks: any[]) => {
|
||||
try {
|
||||
const yDocument = server.blocksToYDoc(blocks, 'document-store')
|
||||
return toBase64(Y.encodeStateAsUpdate(yDocument))
|
||||
} catch (error) {
|
||||
throw new Error('Failed to process content blocks')
|
||||
}
|
||||
}
|
||||
|
||||
export const convertMarkdown = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const content = validateContent(req.body.content)
|
||||
const editor = ServerBlockNoteEditor.create()
|
||||
const blocks = await parseMarkdownToBlocks(editor, content)
|
||||
const encodedContent = processContentBlocks(editor, blocks)
|
||||
return res.send({ content: encodedContent })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: (error as Error).message })
|
||||
}
|
||||
}
|
||||
11
src/blocknote/tsconfig.json
Normal file
11
src/blocknote/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine as frontend-deps-y-provider
|
||||
FROM node:20-alpine AS frontend-deps-y-provider
|
||||
|
||||
WORKDIR /home/frontend/
|
||||
|
||||
@@ -15,7 +15,7 @@ COPY ./src/frontend/ .
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
# ---- y-provider ----
|
||||
FROM frontend-deps-y-provider as y-provider
|
||||
FROM frontend-deps-y-provider AS y-provider
|
||||
|
||||
WORKDIR /home/frontend/servers/y-provider
|
||||
RUN yarn build
|
||||
@@ -28,7 +28,7 @@ ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
FROM node:20-alpine as frontend-deps
|
||||
FROM node:20-alpine AS frontend-deps
|
||||
|
||||
WORKDIR /home/frontend/
|
||||
|
||||
@@ -43,11 +43,11 @@ COPY .dockerignore ./.dockerignore
|
||||
COPY ./src/frontend/ .
|
||||
|
||||
### ---- Front-end builder image ----
|
||||
FROM frontend-deps as impress
|
||||
FROM frontend-deps AS impress
|
||||
|
||||
WORKDIR /home/frontend/apps/impress
|
||||
|
||||
FROM frontend-deps as impress-dev
|
||||
FROM frontend-deps AS impress-dev
|
||||
|
||||
WORKDIR /home/frontend/apps/impress
|
||||
|
||||
@@ -57,7 +57,7 @@ CMD [ "yarn", "dev"]
|
||||
|
||||
# Tilt will rebuild impress target so, we dissociate impress and impress-builder
|
||||
# to avoid rebuilding the app at every changes.
|
||||
FROM impress as impress-builder
|
||||
FROM impress AS impress-builder
|
||||
|
||||
WORKDIR /home/frontend/apps/impress
|
||||
|
||||
@@ -70,10 +70,16 @@ ENV NEXT_PUBLIC_Y_PROVIDER_URL=${Y_PROVIDER_URL}
|
||||
ARG API_ORIGIN
|
||||
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
|
||||
|
||||
ARG MEDIA_URL
|
||||
ENV NEXT_PUBLIC_MEDIA_URL=${MEDIA_URL}
|
||||
|
||||
ARG SW_DEACTIVATED
|
||||
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine as frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
|
||||
@@ -51,6 +51,7 @@ export const createDoc = async (
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
force: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -181,4 +181,57 @@ test.describe('Doc Editor', () => {
|
||||
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
|
||||
);
|
||||
});
|
||||
|
||||
test('it checks the AI buttons', async ({ page, browserName }) => {
|
||||
await page.route(/.*\/ai-translate\//, async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('POST')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
answer: 'Bonjour le monde',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await createDoc(page, 'doc-ai', browserName, 1);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('Hello World');
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.getByText('Hello').dblclick();
|
||||
|
||||
await page.getByRole('button', { name: 'AI' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Use as prompt' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Rephrase' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Summarize' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Language' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Language' }).hover();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'English', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'French', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'German', exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'English', exact: true }).click();
|
||||
|
||||
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ test.describe('Documents Grid', () => {
|
||||
orderDefault: '',
|
||||
orderDesc: '&ordering=-title',
|
||||
orderAsc: '&ordering=title',
|
||||
defaultColumn: false,
|
||||
},
|
||||
{
|
||||
nameColumn: 'Created at',
|
||||
@@ -60,6 +61,7 @@ test.describe('Documents Grid', () => {
|
||||
orderDefault: '',
|
||||
orderDesc: '&ordering=-created_at',
|
||||
orderAsc: '&ordering=created_at',
|
||||
defaultColumn: false,
|
||||
},
|
||||
{
|
||||
nameColumn: 'Updated at',
|
||||
@@ -68,6 +70,7 @@ test.describe('Documents Grid', () => {
|
||||
orderDefault: '&ordering=-updated_at',
|
||||
orderDesc: '&ordering=updated_at',
|
||||
orderAsc: '',
|
||||
defaultColumn: true,
|
||||
},
|
||||
].forEach(
|
||||
({
|
||||
@@ -77,6 +80,7 @@ test.describe('Documents Grid', () => {
|
||||
orderDefault,
|
||||
orderDesc,
|
||||
orderAsc,
|
||||
defaultColumn,
|
||||
}) => {
|
||||
test(`checks datagrid ordering ${ordering}`, async ({ page }) => {
|
||||
const responsePromise = page.waitForResponse(
|
||||
@@ -98,20 +102,19 @@ test.describe('Documents Grid', () => {
|
||||
);
|
||||
|
||||
// Checks the initial state
|
||||
const datagrid = page
|
||||
.getByLabel('Datagrid of the documents page 1')
|
||||
.getByRole('table');
|
||||
const thead = datagrid.locator('thead');
|
||||
const datagrid = page.getByLabel('Datagrid of the documents page 1');
|
||||
const datagridTable = datagrid.getByRole('table');
|
||||
const thead = datagridTable.locator('thead');
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const docNameRow1 = datagrid
|
||||
const docNameRow1 = datagridTable
|
||||
.getByRole('row')
|
||||
.nth(1)
|
||||
.getByRole('cell')
|
||||
.nth(cellNumber);
|
||||
const docNameRow2 = datagrid
|
||||
const docNameRow2 = datagridTable
|
||||
.getByRole('row')
|
||||
.nth(2)
|
||||
.getByRole('cell')
|
||||
@@ -144,13 +147,21 @@ test.describe('Documents Grid', () => {
|
||||
await expect(docNameRow2).toHaveText(/.*/);
|
||||
const textDocNameRow1Asc = await docNameRow1.textContent();
|
||||
const textDocNameRow2Asc = await docNameRow2.textContent();
|
||||
|
||||
const compare = (comp1: string, comp2: string) => {
|
||||
const comparisonResult = comp1.localeCompare(comp2, 'en', {
|
||||
caseFirst: 'false',
|
||||
ignorePunctuation: true,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
return defaultColumn ? comparisonResult >= 0 : comparisonResult <= 0;
|
||||
};
|
||||
|
||||
expect(
|
||||
textDocNameRow1Asc &&
|
||||
textDocNameRow2Asc &&
|
||||
textDocNameRow1Asc.localeCompare(textDocNameRow2Asc, 'en', {
|
||||
caseFirst: 'false',
|
||||
ignorePunctuation: true,
|
||||
}) <= 0,
|
||||
compare(textDocNameRow1Asc, textDocNameRow2Asc),
|
||||
).toBeTruthy();
|
||||
|
||||
// Ordering Desc
|
||||
@@ -171,10 +182,7 @@ test.describe('Documents Grid', () => {
|
||||
expect(
|
||||
textDocNameRow1Desc &&
|
||||
textDocNameRow2Desc &&
|
||||
textDocNameRow1Desc.localeCompare(textDocNameRow2Desc, 'en', {
|
||||
caseFirst: 'false',
|
||||
ignorePunctuation: true,
|
||||
}) >= 0,
|
||||
compare(textDocNameRow2Desc, textDocNameRow1Desc),
|
||||
).toBeTruthy();
|
||||
});
|
||||
},
|
||||
|
||||
@@ -384,6 +384,81 @@ test.describe('Doc Header', () => {
|
||||
}),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('It checks the copy as Markdown button', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(
|
||||
browserName === 'webkit',
|
||||
'navigator.clipboard is not working with webkit and playwright',
|
||||
);
|
||||
|
||||
// create page and navigate to it
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
})
|
||||
.click();
|
||||
|
||||
// Add dummy content to the doc
|
||||
const editor = page.locator('.ProseMirror');
|
||||
const docFirstBlock = editor.locator('.bn-block-content').first();
|
||||
await docFirstBlock.click();
|
||||
await page.keyboard.type('# Hello World', { delay: 100 });
|
||||
const docFirstBlockContent = docFirstBlock.locator('h1');
|
||||
await expect(docFirstBlockContent).toHaveText('Hello World');
|
||||
|
||||
// Copy content to clipboard
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('button', { name: 'Copy as Markdown' }).click();
|
||||
await expect(page.getByText('Copied to clipboard')).toBeVisible();
|
||||
|
||||
// Test that clipboard is in Markdown format
|
||||
const handle = await page.evaluateHandle(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
const clipboardContent = await handle.jsonValue();
|
||||
expect(clipboardContent.trim()).toBe('# Hello World');
|
||||
});
|
||||
|
||||
test('It checks the copy as HTML button', async ({ page, browserName }) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(
|
||||
browserName === 'webkit',
|
||||
'navigator.clipboard is not working with webkit and playwright',
|
||||
);
|
||||
|
||||
// create page and navigate to it
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
})
|
||||
.click();
|
||||
|
||||
// Add dummy content to the doc
|
||||
const editor = page.locator('.ProseMirror');
|
||||
const docFirstBlock = editor.locator('.bn-block-content').first();
|
||||
await docFirstBlock.click();
|
||||
await page.keyboard.type('# Hello World', { delay: 100 });
|
||||
const docFirstBlockContent = docFirstBlock.locator('h1');
|
||||
await expect(docFirstBlockContent).toHaveText('Hello World');
|
||||
|
||||
// Copy content to clipboard
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('button', { name: 'Copy as HTML' }).click();
|
||||
await expect(page.getByText('Copied to clipboard')).toBeVisible();
|
||||
|
||||
// Test that clipboard is in HTML format
|
||||
const handle = await page.evaluateHandle(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
const clipboardContent = await handle.jsonValue();
|
||||
expect(clipboardContent.trim()).toBe(
|
||||
`<h1 data-level="1">Hello World</h1><p></p>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Documents Header mobile', () => {
|
||||
|
||||
@@ -19,17 +19,16 @@ test.describe('Doc Visibility', () => {
|
||||
const header = page.locator('header').first();
|
||||
await header.locator('h2').getByText('Docs').click();
|
||||
|
||||
const datagrid = page
|
||||
.getByLabel('Datagrid of the documents page 1')
|
||||
.getByRole('table');
|
||||
const datagrid = page.getByLabel('Datagrid of the documents page 1');
|
||||
const datagridTable = datagrid.getByRole('table');
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await expect(datagrid.getByText(docTitle)).toBeVisible();
|
||||
await expect(datagridTable.getByText(docTitle)).toBeVisible();
|
||||
|
||||
const row = datagrid.getByRole('row').filter({
|
||||
const row = datagridTable.getByRole('row').filter({
|
||||
hasText: docTitle,
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ test.describe('Header', () => {
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(header.getByAltText('Language Icon')).toBeVisible();
|
||||
await expect(header.getByText('English')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
header.getByRole('button', {
|
||||
@@ -87,7 +87,7 @@ test.describe('Header mobile', () => {
|
||||
}),
|
||||
).toBeHidden();
|
||||
|
||||
await expect(page.getByAltText('Language Icon')).toBeHidden();
|
||||
await expect(page.getByText('English')).toBeHidden();
|
||||
|
||||
await header.getByLabel('Open the header menu').click();
|
||||
|
||||
@@ -97,7 +97,7 @@ test.describe('Header mobile', () => {
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByAltText('Language Icon')).toBeVisible();
|
||||
await expect(page.getByText('English')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
@@ -12,7 +12,7 @@
|
||||
"test:ui::chromium": "yarn test:ui --project=chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.47.2",
|
||||
"@playwright/test": "1.48.1",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.4",
|
||||
"eslint-config-impress": "*",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=
|
||||
NEXT_PUBLIC_Y_PROVIDER_URL=
|
||||
NEXT_PUBLIC_MEDIA_URL=
|
||||
NEXT_PUBLIC_THEME=dsfr
|
||||
NEXT_PUBLIC_THEME=dsfr
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=
|
||||
|
||||
@@ -189,6 +189,9 @@ const config = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'la-gauffre': {
|
||||
activated: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
dsfr: {
|
||||
@@ -321,6 +324,7 @@ const config = {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
},
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
color: 'var(--c--theme--colors--primary-600)',
|
||||
},
|
||||
},
|
||||
datagrid: {
|
||||
@@ -335,6 +339,7 @@ const config = {
|
||||
pagination: {
|
||||
'background-color': 'transparent',
|
||||
'background-color-active': 'var(--c--theme--colors--primary-300)',
|
||||
'border-color': 'var(--c--theme--colors--primary-400)',
|
||||
},
|
||||
},
|
||||
'forms-checkbox': {
|
||||
@@ -384,6 +389,9 @@ const config = {
|
||||
'forms-textarea': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'la-gauffre': {
|
||||
activated: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -19,36 +19,36 @@
|
||||
"@blocknote/mantine": "*",
|
||||
"@blocknote/react": "*",
|
||||
"@gouvfr-lasuite/integration": "1.0.2",
|
||||
"@hocuspocus/provider": "2.13.6",
|
||||
"@hocuspocus/provider": "2.13.7",
|
||||
"@openfun/cunningham-react": "2.9.4",
|
||||
"@tanstack/react-query": "5.56.2",
|
||||
"i18next": "23.15.1",
|
||||
"@tanstack/react-query": "5.59.15",
|
||||
"i18next": "23.16.0",
|
||||
"idb": "8.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.5.0",
|
||||
"next": "14.2.13",
|
||||
"next": "14.2.15",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.3.3",
|
||||
"react-aria-components": "1.4.1",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "15.0.2",
|
||||
"react-i18next": "15.0.3",
|
||||
"react-select": "5.8.1",
|
||||
"styled-components": "6.1.13",
|
||||
"y-protocols": "1.0.6",
|
||||
"yjs": "*",
|
||||
"zustand": "4.5.5"
|
||||
"zustand": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.58.0",
|
||||
"@tanstack/react-query-devtools": "5.59.15",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "6.5.0",
|
||||
"@testing-library/jest-dom": "6.6.1",
|
||||
"@testing-library/react": "16.0.1",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/jest": "29.5.13",
|
||||
"@types/lodash": "4.17.9",
|
||||
"@types/lodash": "4.17.10",
|
||||
"@types/luxon": "3.4.2",
|
||||
"@types/node": "*",
|
||||
"@types/react": "18.3.10",
|
||||
"@types/react": "18.3.11",
|
||||
"@types/react-dom": "*",
|
||||
"cross-env": "*",
|
||||
"dotenv": "16.4.5",
|
||||
@@ -58,7 +58,7 @@
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.3.3",
|
||||
"stylelint": "16.9.0",
|
||||
"stylelint": "16.10.0",
|
||||
"stylelint-config-standard": "36.0.1",
|
||||
"stylelint-prettier": "5.0.2",
|
||||
"typescript": "*",
|
||||
|
||||
@@ -18,3 +18,7 @@ export class APIError<T = unknown> extends Error implements IAPIError<T> {
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
export const isAPIError = (error: unknown): error is APIError => {
|
||||
return error instanceof APIError;
|
||||
};
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,77 +1,101 @@
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Thin.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Thin.woff2') format('woff2'),
|
||||
url('Marianne-Thin.woff') format('woff');
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Thin_Italic.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Thin_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Thin_Italic.woff') format('woff');
|
||||
font-weight: 100;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Light.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Light.woff2') format('woff2'),
|
||||
url('Marianne-Light.woff') format('woff');
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Light_Italic.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Light_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Light_Italic.woff') format('woff');
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Regular.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Regular.woff2') format('woff2'),
|
||||
url('Marianne-Regular.woff') format('woff');
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Regular_Italic.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Regular_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Regular_Italic.woff') format('woff');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Medium.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Medium.woff2') format('woff2'),
|
||||
url('Marianne-Medium.woff') format('woff');
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Medium_Italic.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Medium_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Medium_Italic.woff') format('woff');
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Bold.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Bold.woff2') format('woff2'),
|
||||
url('Marianne-Bold.woff') format('woff');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Bold_Italic.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Bold_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Bold_Italic.woff') format('woff');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-ExtraBold.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-ExtraBold.woff2') format('woff2'),
|
||||
url('Marianne-ExtraBold.woff') format('woff');
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-ExtraBold_Italic.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-ExtraBold_Italic.woff2') format('woff2'),
|
||||
url('Marianne-ExtraBold_Italic.woff') format('woff');
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
|
||||
$isMaterialIcon
|
||||
$size="36px"
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
$background={colorsTokens()['primary-bg']}
|
||||
$css={`
|
||||
border: 1px solid ${colorsTokens()['primary-200']};
|
||||
|
||||
@@ -255,6 +255,7 @@ input:-webkit-autofill:focus {
|
||||
gap: 3px;
|
||||
border-radius: 4px;
|
||||
background: var(--c--components--datagrid--pagination--background-color);
|
||||
border-color: var(--c--components--datagrid--pagination--border-color);
|
||||
}
|
||||
|
||||
.c__pagination__list .c__button--tertiary-text.c__button--active {
|
||||
@@ -452,6 +453,7 @@ input:-webkit-autofill:focus {
|
||||
|
||||
.c__button--tertiary-text {
|
||||
border: none;
|
||||
color: var(--c--components--button--tertiary-text--color);
|
||||
}
|
||||
|
||||
.c__button--tertiary-text:hover,
|
||||
|
||||
@@ -283,6 +283,7 @@
|
||||
);
|
||||
--c--components--button--disabled--color: white;
|
||||
--c--components--button--disabled--background--color: #b3cef0;
|
||||
--c--components--la-gauffre--activated: false;
|
||||
}
|
||||
|
||||
.cunningham-theme--dark {
|
||||
@@ -451,6 +452,9 @@
|
||||
--c--components--button--tertiary-text--color-hover: var(
|
||||
--c--theme--colors--primary-text
|
||||
);
|
||||
--c--components--button--tertiary-text--color: var(
|
||||
--c--theme--colors--primary-600
|
||||
);
|
||||
--c--components--datagrid--header--color: var(
|
||||
--c--theme--colors--primary-text
|
||||
);
|
||||
@@ -461,6 +465,9 @@
|
||||
--c--components--datagrid--pagination--background-color-active: var(
|
||||
--c--theme--colors--primary-300
|
||||
);
|
||||
--c--components--datagrid--pagination--border-color: var(
|
||||
--c--theme--colors--primary-400
|
||||
);
|
||||
--c--components--forms-checkbox--border-radius: 0;
|
||||
--c--components--forms-checkbox--color: var(--c--theme--colors--primary-text);
|
||||
--c--components--forms-checkbox--text--color: var(
|
||||
@@ -504,6 +511,7 @@
|
||||
--c--theme--colors--primary-text
|
||||
);
|
||||
--c--components--forms-textarea--border-radius: 0;
|
||||
--c--components--la-gauffre--activated: true;
|
||||
}
|
||||
|
||||
.clr-secondary-text {
|
||||
|
||||
@@ -276,6 +276,7 @@ export const tokens = {
|
||||
},
|
||||
disabled: { color: 'white', background: { color: '#b3cef0' } },
|
||||
},
|
||||
'la-gauffre': { activated: false },
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
@@ -450,6 +451,7 @@ export const tokens = {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
},
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
color: 'var(--c--theme--colors--primary-600)',
|
||||
},
|
||||
},
|
||||
datagrid: {
|
||||
@@ -464,6 +466,7 @@ export const tokens = {
|
||||
pagination: {
|
||||
'background-color': 'transparent',
|
||||
'background-color-active': 'var(--c--theme--colors--primary-300)',
|
||||
'border-color': 'var(--c--theme--colors--primary-400)',
|
||||
},
|
||||
},
|
||||
'forms-checkbox': {
|
||||
@@ -503,6 +506,7 @@ export const tokens = {
|
||||
'accent-color': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-textarea': { 'border-radius': '0' },
|
||||
'la-gauffre': { activated: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './useCreateDocUpload';
|
||||
export * from './useDocAITransform';
|
||||
export * from './useDocAITranslate';
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
export type AITransformActions =
|
||||
| 'correct'
|
||||
| 'prompt'
|
||||
| 'rephrase'
|
||||
| 'summarize';
|
||||
|
||||
export type DocAITransform = {
|
||||
docId: string;
|
||||
text: string;
|
||||
action: AITransformActions;
|
||||
};
|
||||
|
||||
export type DocAITransformResponse = {
|
||||
answer: string;
|
||||
};
|
||||
|
||||
export const docAITransform = async ({
|
||||
docId,
|
||||
...params
|
||||
}: DocAITransform): Promise<DocAITransformResponse> => {
|
||||
const response = await fetchAPI(`documents/${docId}/ai-transform/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...params,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to request ai transform',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<DocAITransformResponse>;
|
||||
};
|
||||
|
||||
export function useDocAITransform() {
|
||||
return useMutation<DocAITransformResponse, APIError, DocAITransform>({
|
||||
mutationFn: docAITransform,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
export type DocAITranslate = {
|
||||
docId: string;
|
||||
text: string;
|
||||
language: string;
|
||||
};
|
||||
|
||||
export type DocAITranslateResponse = {
|
||||
answer: string;
|
||||
};
|
||||
|
||||
export const docAITranslate = async ({
|
||||
docId,
|
||||
...params
|
||||
}: DocAITranslate): Promise<DocAITranslateResponse> => {
|
||||
const response = await fetchAPI(`documents/${docId}/ai-translate/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...params,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to request ai translate',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<DocAITranslateResponse>;
|
||||
};
|
||||
|
||||
export function useDocAITranslate() {
|
||||
return useMutation<DocAITranslateResponse, APIError, DocAITranslate>({
|
||||
mutationFn: docAITranslate,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import {
|
||||
ComponentProps,
|
||||
useBlockNoteEditor,
|
||||
useComponentsContext,
|
||||
useSelectedBlocks,
|
||||
} from '@blocknote/react';
|
||||
import {
|
||||
Loader,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import {
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isAPIError } from '@/api';
|
||||
import { Box, Text } from '@/components';
|
||||
import { useDocOptions } from '@/features/docs/doc-management/';
|
||||
|
||||
import {
|
||||
AITransformActions,
|
||||
useDocAITransform,
|
||||
useDocAITranslate,
|
||||
} from '../api/';
|
||||
import { useDocStore } from '../stores';
|
||||
|
||||
type LanguageTranslate = {
|
||||
value: string;
|
||||
display_name: string;
|
||||
};
|
||||
|
||||
const sortByPopularLanguages = (
|
||||
languages: LanguageTranslate[],
|
||||
popularLanguages: string[],
|
||||
) => {
|
||||
languages.sort((a, b) => {
|
||||
const indexA = popularLanguages.indexOf(a.value);
|
||||
const indexB = popularLanguages.indexOf(b.value);
|
||||
|
||||
// If both languages are in the popular list, sort based on their order in popularLanguages
|
||||
if (indexA !== -1 && indexB !== -1) {
|
||||
return indexA - indexB;
|
||||
}
|
||||
|
||||
// If only a is in the popular list, it should come first
|
||||
if (indexA !== -1) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// If only b is in the popular list, it should come first
|
||||
if (indexB !== -1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If neither a nor b is in the popular list, maintain their relative order
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
export function AIGroupButton() {
|
||||
const editor = useBlockNoteEditor();
|
||||
const Components = useComponentsContext();
|
||||
const selectedBlocks = useSelectedBlocks(editor);
|
||||
const { t } = useTranslation();
|
||||
const { currentDoc } = useDocStore();
|
||||
const { data: docOptions } = useDocOptions();
|
||||
const [languages, setLanguages] = useState<LanguageTranslate[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const languages = docOptions?.actions.POST.language.choices;
|
||||
|
||||
if (!languages) {
|
||||
return;
|
||||
}
|
||||
|
||||
sortByPopularLanguages(languages, [
|
||||
'fr',
|
||||
'en',
|
||||
'de',
|
||||
'es',
|
||||
'it',
|
||||
'pt',
|
||||
'nl',
|
||||
'pl',
|
||||
]);
|
||||
|
||||
setLanguages(languages);
|
||||
}, [docOptions?.actions.POST.language.choices]);
|
||||
|
||||
const show = useMemo(() => {
|
||||
return !!selectedBlocks.find((block) => block.content !== undefined);
|
||||
}, [selectedBlocks]);
|
||||
|
||||
if (!show || !editor.isEditable || !Components || !currentDoc || !languages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Components.Generic.Menu.Root>
|
||||
<Components.Generic.Menu.Trigger>
|
||||
<Components.FormattingToolbar.Button
|
||||
className="bn-button bn-menu-item"
|
||||
data-test="ai-actions"
|
||||
label="AI"
|
||||
mainTooltip={t('AI Actions')}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="l">
|
||||
auto_awesome
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
<Components.Generic.Menu.Dropdown
|
||||
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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item is derived from Mantime, some props seem lacking or incorrect.
|
||||
*/
|
||||
type ItemDefault = ComponentProps['Generic']['Menu']['Item'];
|
||||
type ItemProps = Omit<ItemDefault, 'onClick'> & {
|
||||
rightSection?: ReactNode;
|
||||
closeMenuOnClick?: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
interface AIMenuItemTransform {
|
||||
action: AITransformActions;
|
||||
docId: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
const AIMenuItemTransform = ({
|
||||
docId,
|
||||
action,
|
||||
children,
|
||||
icon,
|
||||
}: PropsWithChildren<AIMenuItemTransform>) => {
|
||||
const editor = useBlockNoteEditor();
|
||||
const { mutateAsync: requestAI, isPending } = useDocAITransform();
|
||||
const handleAIError = useHandleAIError();
|
||||
|
||||
const handleAIAction = useCallback(async () => {
|
||||
const selectedBlocks = editor.getSelection()?.blocks;
|
||||
|
||||
if (!selectedBlocks || selectedBlocks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markdown = await editor.blocksToMarkdownLossy(selectedBlocks);
|
||||
|
||||
try {
|
||||
const responseAI = await requestAI({
|
||||
text: markdown,
|
||||
action,
|
||||
docId,
|
||||
});
|
||||
|
||||
if (!responseAI.answer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blockMarkdown = await editor.tryParseMarkdownToBlocks(
|
||||
responseAI.answer,
|
||||
);
|
||||
editor.replaceBlocks(selectedBlocks, blockMarkdown);
|
||||
} catch (error) {
|
||||
handleAIError(error);
|
||||
}
|
||||
}, [editor, requestAI, action, docId, handleAIError]);
|
||||
|
||||
return (
|
||||
<AIMenuItem
|
||||
icon={icon}
|
||||
handleAIAction={handleAIAction}
|
||||
isPending={isPending}
|
||||
>
|
||||
{children}
|
||||
</AIMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
interface AIMenuItemTranslate {
|
||||
language: string;
|
||||
docId: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
const AIMenuItemTranslate = ({
|
||||
children,
|
||||
docId,
|
||||
icon,
|
||||
language,
|
||||
}: PropsWithChildren<AIMenuItemTranslate>) => {
|
||||
const editor = useBlockNoteEditor();
|
||||
const { mutateAsync: requestAI, isPending } = useDocAITranslate();
|
||||
const handleAIError = useHandleAIError();
|
||||
|
||||
const handleAIAction = useCallback(async () => {
|
||||
const selectedBlocks = editor.getSelection()?.blocks;
|
||||
|
||||
if (!selectedBlocks || selectedBlocks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markdown = await editor.blocksToMarkdownLossy(selectedBlocks);
|
||||
|
||||
try {
|
||||
const responseAI = await requestAI({
|
||||
text: markdown,
|
||||
language,
|
||||
docId,
|
||||
});
|
||||
|
||||
if (!responseAI.answer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blockMarkdown = await editor.tryParseMarkdownToBlocks(
|
||||
responseAI.answer,
|
||||
);
|
||||
editor.replaceBlocks(selectedBlocks, blockMarkdown);
|
||||
} catch (error) {
|
||||
handleAIError(error);
|
||||
}
|
||||
}, [editor, requestAI, language, docId, handleAIError]);
|
||||
|
||||
return (
|
||||
<AIMenuItem
|
||||
icon={icon}
|
||||
handleAIAction={handleAIAction}
|
||||
isPending={isPending}
|
||||
>
|
||||
{children}
|
||||
</AIMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
interface AIMenuItemProps {
|
||||
handleAIAction: () => Promise<void>;
|
||||
isPending: boolean;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
const AIMenuItem = ({
|
||||
handleAIAction,
|
||||
isPending,
|
||||
children,
|
||||
icon,
|
||||
}: PropsWithChildren<AIMenuItemProps>) => {
|
||||
const Components = useComponentsContext();
|
||||
|
||||
if (!Components) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Item = Components.Generic.Menu.Item as React.FC<ItemProps>;
|
||||
|
||||
return (
|
||||
<Item
|
||||
closeMenuOnClick={false}
|
||||
icon={icon}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
void handleAIAction();
|
||||
}}
|
||||
rightSection={isPending ? <Loader size="small" /> : undefined}
|
||||
>
|
||||
{children}
|
||||
</Item>
|
||||
);
|
||||
};
|
||||
|
||||
const useHandleAIError = () => {
|
||||
const { toast } = useToastProvider();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleAIError = useCallback(
|
||||
(error: unknown) => {
|
||||
if (isAPIError(error)) {
|
||||
error.cause?.forEach((cause) => {
|
||||
if (
|
||||
cause === 'Request was throttled. Expected available in 60 seconds.'
|
||||
) {
|
||||
toast(
|
||||
t('Too many requests. Please wait 60 seconds.'),
|
||||
VariantType.ERROR,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toast(t('AI seems busy! Please try again.'), VariantType.ERROR);
|
||||
console.error(error);
|
||||
},
|
||||
[toast, t],
|
||||
);
|
||||
|
||||
return handleAIError;
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@blocknote/react';
|
||||
import React from 'react';
|
||||
|
||||
import { AIGroupButton } from './AIButton';
|
||||
import { MarkdownButton } from './MarkdownButton';
|
||||
|
||||
export const BlockNoteToolbar = () => {
|
||||
@@ -21,6 +22,9 @@ export const BlockNoteToolbar = () => {
|
||||
<FormattingToolbar>
|
||||
<BlockTypeSelect key="blockTypeSelect" />
|
||||
|
||||
{/* Extra button to do some AI powered actions */}
|
||||
<AIGroupButton key="AIButton" />
|
||||
|
||||
{/* Extra button to convert from markdown to json */}
|
||||
<MarkdownButton key="customButton" />
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ export const PanelEditor = ({
|
||||
$weight="bold"
|
||||
$size="m"
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
$padding={{ vertical: 'small', horizontal: 'small' }}
|
||||
>
|
||||
{t('Table of content')}
|
||||
@@ -118,6 +119,7 @@ export const PanelEditor = ({
|
||||
$weight="bold"
|
||||
$size="m"
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
$padding={{ vertical: 'small', horizontal: 'small' }}
|
||||
>
|
||||
{t('Versions')}
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as Y from 'yjs';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { providerUrl } from '@/core';
|
||||
import { Base64 } from '@/features/docs/doc-management';
|
||||
import { Base64, Doc } from '@/features/docs/doc-management';
|
||||
|
||||
import { blocksToYDoc } from '../utils';
|
||||
|
||||
@@ -14,14 +14,17 @@ interface DocStore {
|
||||
}
|
||||
|
||||
export interface UseDocStore {
|
||||
currentDoc?: Doc;
|
||||
docsStore: {
|
||||
[storeId: string]: DocStore;
|
||||
};
|
||||
createProvider: (storeId: string, initialDoc: Base64) => HocuspocusProvider;
|
||||
setStore: (storeId: string, props: Partial<DocStore>) => void;
|
||||
setCurrentDoc: (doc: Doc | undefined) => void;
|
||||
}
|
||||
|
||||
export const useDocStore = create<UseDocStore>((set, get) => ({
|
||||
currentDoc: undefined,
|
||||
docsStore: {},
|
||||
createProvider: (storeId: string, initialDoc: Base64) => {
|
||||
const doc = new Y.Doc({
|
||||
@@ -52,8 +55,9 @@ export const useDocStore = create<UseDocStore>((set, get) => ({
|
||||
return provider;
|
||||
},
|
||||
setStore: (storeId, props) => {
|
||||
set(({ docsStore }) => {
|
||||
set(({ docsStore }, ...store) => {
|
||||
return {
|
||||
...store,
|
||||
docsStore: {
|
||||
...docsStore,
|
||||
[storeId]: {
|
||||
@@ -64,4 +68,7 @@ export const useDocStore = create<UseDocStore>((set, get) => ({
|
||||
};
|
||||
});
|
||||
},
|
||||
setCurrentDoc: (doc) => {
|
||||
set({ currentDoc: doc });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -44,6 +44,7 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
|
||||
<Text
|
||||
$isMaterialIcon
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
$size="2rem"
|
||||
$css={`&:hover {background-color: ${colorsTokens()['primary-100']}; };`}
|
||||
$hasTransition
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import {
|
||||
Button,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, DropButton, IconOptions, Text } from '@/components';
|
||||
import { Box, DropButton, IconOptions } from '@/components';
|
||||
import { useAuthStore } from '@/core';
|
||||
import { usePanelEditorStore } from '@/features/docs/doc-editor/';
|
||||
import { useDocStore, usePanelEditorStore } from '@/features/docs/doc-editor/';
|
||||
import {
|
||||
Doc,
|
||||
ModalRemoveDoc,
|
||||
@@ -31,6 +35,32 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
|
||||
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
|
||||
const { isSmallMobile } = useResponsiveStore();
|
||||
const { authenticated } = useAuthStore();
|
||||
const { docsStore } = useDocStore();
|
||||
const { toast } = useToastProvider();
|
||||
|
||||
const copyCurrentEditorToClipboard = async (
|
||||
asFormat: 'html' | 'markdown',
|
||||
) => {
|
||||
const editor = docsStore[doc.id]?.editor;
|
||||
if (!editor) {
|
||||
toast(t('Editor unavailable'), VariantType.ERROR, { duration: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const editorContentFormatted =
|
||||
asFormat === 'html'
|
||||
? await editor.blocksToHTMLLossy()
|
||||
: await editor.blocksToMarkdownLossy();
|
||||
await navigator.clipboard.writeText(editorContentFormatted);
|
||||
toast(t('Copied to clipboard'), VariantType.SUCCESS, { duration: 3000 });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast(t('Failed to copy to clipboard'), VariantType.ERROR, {
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -86,7 +116,7 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
|
||||
icon={<span className="material-icons">history</span>}
|
||||
size="small"
|
||||
>
|
||||
<Text $theme="primary">{t('Version history')}</Text>
|
||||
{t('Version history')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -99,7 +129,7 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
|
||||
icon={<span className="material-icons">summarize</span>}
|
||||
size="small"
|
||||
>
|
||||
<Text $theme="primary">{t('Table of contents')}</Text>
|
||||
{t('Table of contents')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -110,7 +140,7 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
|
||||
icon={<span className="material-icons">file_download</span>}
|
||||
size="small"
|
||||
>
|
||||
<Text $theme="primary">{t('Export')}</Text>
|
||||
{t('Export')}
|
||||
</Button>
|
||||
{doc.abilities.destroy && (
|
||||
<Button
|
||||
@@ -122,9 +152,31 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
|
||||
icon={<span className="material-icons">delete</span>}
|
||||
size="small"
|
||||
>
|
||||
<Text $theme="primary">{t('Delete document')}</Text>
|
||||
{t('Delete document')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsDropOpen(false);
|
||||
void copyCurrentEditorToClipboard('markdown');
|
||||
}}
|
||||
color="primary-text"
|
||||
icon={<span className="material-icons">content_copy</span>}
|
||||
size="small"
|
||||
>
|
||||
{t('Copy as {{format}}', { format: 'Markdown' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsDropOpen(false);
|
||||
void copyCurrentEditorToClipboard('html');
|
||||
}}
|
||||
color="primary-text"
|
||||
icon={<span className="material-icons">content_copy</span>}
|
||||
size="small"
|
||||
>
|
||||
{t('Copy as {{format}}', { format: 'HTML' })}
|
||||
</Button>
|
||||
</Box>
|
||||
</DropButton>
|
||||
</Box>
|
||||
|
||||
@@ -152,7 +152,12 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Box $align="center" $gap="1rem">
|
||||
<Text className="material-icons" $size="3.5rem" $theme="primary">
|
||||
<Text
|
||||
className="material-icons"
|
||||
$size="3.5rem"
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
>
|
||||
picture_as_pdf
|
||||
</Text>
|
||||
<Text as="h2" $size="h3" $margin="none" $theme="primary">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './useCreateDoc';
|
||||
export * from './useDoc';
|
||||
export * from './useDocOptions';
|
||||
export * from './useDocs';
|
||||
export * from './useUpdateDoc';
|
||||
export * from './useUpdateDocLink';
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
type DocOptionsResponse = {
|
||||
actions: {
|
||||
POST: {
|
||||
language: {
|
||||
choices: {
|
||||
value: string;
|
||||
display_name: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const docOptions = async (): Promise<DocOptionsResponse> => {
|
||||
const response = await fetchAPI(`documents/`, {
|
||||
method: 'OPTIONS',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the doc options',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<DocOptionsResponse>;
|
||||
};
|
||||
|
||||
export const KEY_DOC_OPTIONS = 'doc-options';
|
||||
|
||||
export function useDocOptions(
|
||||
queryConfig?: UseQueryOptions<
|
||||
DocOptionsResponse,
|
||||
APIError,
|
||||
DocOptionsResponse
|
||||
>,
|
||||
) {
|
||||
return useQuery<DocOptionsResponse, APIError, DocOptionsResponse>({
|
||||
queryKey: [KEY_DOC_OPTIONS],
|
||||
queryFn: docOptions,
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 38C12 40.2 13.8 42 16 42H32C34.2 42 36 40.2 36 38V14H12V38ZM18.34 25.18C17.56 24.4 17.56 23.14 18.34 22.36C19.12 21.58 20.38 21.58 21.16 22.36L24 25.18L26.82 22.36C27.6 21.58 28.86 21.58 29.64 22.36C30.42 23.14 30.42 24.4 29.64 25.18L26.82 28L29.64 30.82C30.42 31.6 30.42 32.86 29.64 33.64C28.86 34.42 27.6 34.42 26.82 33.64L24 30.82L21.18 33.64C20.4 34.42 19.14 34.42 18.36 33.64C17.58 32.86 17.58 31.6 18.36 30.82L21.18 28L18.34 25.18ZM36 8H31L29.58 6.58C29.22 6.22 28.7 6 28.18 6H19.82C19.3 6 18.78 6.22 18.42 6.58L17 8H12C10.9 8 10 8.9 10 10C10 11.1 10.9 12 12 12H36C37.1 12 38 11.1 38 10C38 8.9 37.1 8 36 8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 742 B |
@@ -14,7 +14,6 @@ import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
|
||||
|
||||
import { useRemoveDoc } from '../api/useRemoveDoc';
|
||||
import IconDoc from '../assets/icon-doc.svg';
|
||||
import IconRemove from '../assets/icon-trash.svg';
|
||||
import { Doc } from '../types';
|
||||
|
||||
interface ModalRemoveDocProps {
|
||||
@@ -73,7 +72,9 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Box $align="center" $gap="1rem">
|
||||
<IconRemove width={48} color={colorsTokens()['primary-text']} />
|
||||
<Text $isMaterialIcon $size="48px" $theme="primary" $variation="600">
|
||||
delete_forever
|
||||
</Text>
|
||||
<Text as="h2" $size="h3" $margin="none">
|
||||
{t('Deleting the document "{{title}}"', { title: doc.title })}
|
||||
</Text>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from 'i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { Box, Card, SideModal, Text } from '@/components';
|
||||
import { Box, Card, IconBG, SideModal, Text } from '@/components';
|
||||
import { InvitationList } from '@/features/docs/members/invitation-list';
|
||||
import { AddMembers } from '@/features/docs/members/members-add';
|
||||
import { MemberList } from '@/features/docs/members/members-list';
|
||||
@@ -62,9 +62,12 @@ export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
|
||||
$padding="tiny"
|
||||
$gap="1rem"
|
||||
>
|
||||
<Text $isMaterialIcon $size="48px" $theme="primary">
|
||||
share
|
||||
</Text>
|
||||
<IconBG
|
||||
$isMaterialIcon
|
||||
$size="48px"
|
||||
iconName="share"
|
||||
$margin="none"
|
||||
/>
|
||||
<Box $align="flex-start">
|
||||
<Text as="h3" $size="26px" $margin="none">
|
||||
{t('Share')}
|
||||
|
||||
@@ -35,13 +35,13 @@ export const VersionItem = ({
|
||||
as="li"
|
||||
$background={isActive ? colorsTokens()['primary-300'] : 'transparent'}
|
||||
$css={`
|
||||
border-left: 4px solid transparent;
|
||||
border-bottom: 1px solid ${colorsTokens()['primary-100']};
|
||||
&:hover{
|
||||
border-left: 4px solid ${colorsTokens()['primary-400']};
|
||||
background: ${colorsTokens()['primary-300']};
|
||||
}
|
||||
`}
|
||||
border-left: 4px solid transparent;
|
||||
border-bottom: 1px solid ${colorsTokens()['primary-100']};
|
||||
&:hover{
|
||||
border-left: 4px solid ${colorsTokens()['primary-400']};
|
||||
background: ${colorsTokens()['primary-300']};
|
||||
}
|
||||
`}
|
||||
$hasTransition
|
||||
$minWidth="13rem"
|
||||
>
|
||||
@@ -54,10 +54,15 @@ export const VersionItem = ({
|
||||
$width="100%"
|
||||
>
|
||||
<Box $direction="row" $gap="0.5rem" $align="center">
|
||||
<Text $isMaterialIcon $size="24px" $theme="primary">
|
||||
<Text
|
||||
$isMaterialIcon
|
||||
$size="24px"
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
>
|
||||
description
|
||||
</Text>
|
||||
<Text $weight="bold" $theme="primary" $size="m">
|
||||
<Text $weight="bold" $theme="primary" $size="m" $variation="600">
|
||||
{text}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -81,7 +86,7 @@ export const VersionItem = ({
|
||||
icon={<span className="material-icons">save</span>}
|
||||
size="small"
|
||||
>
|
||||
<Text $theme="primary">{t('Restore the version')}</Text>
|
||||
{t('Restore the version')}
|
||||
</Button>
|
||||
</Box>
|
||||
</DropButton>
|
||||
|
||||
@@ -94,6 +94,15 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
dataError.cause?.[0] ===
|
||||
'This email is already associated to a registered user.'
|
||||
) {
|
||||
messageError = t('"{{email}}" is already member of the document.', {
|
||||
email: dataError['data']?.value,
|
||||
});
|
||||
}
|
||||
|
||||
toast(messageError, VariantType.ERROR, toastOptions);
|
||||
};
|
||||
|
||||
@@ -123,6 +132,7 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
|
||||
setIsPending(false);
|
||||
setResetKey(resetKey + 1);
|
||||
setSelectedUsers([]);
|
||||
setSelectedRole(undefined);
|
||||
|
||||
settledPromises.forEach((settledPromise) => {
|
||||
switch (settledPromise.status) {
|
||||
@@ -156,7 +166,7 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
|
||||
<Box $gap="0.7rem" $direction="row" $wrap="wrap" $css="flex: 80%;">
|
||||
<Box $css="flex: auto;" $width="15rem">
|
||||
<SearchUsers
|
||||
key={resetKey + 1}
|
||||
key={resetKey}
|
||||
doc={doc}
|
||||
setSelectedUsers={setSelectedUsers}
|
||||
selectedUsers={selectedUsers}
|
||||
|
||||
@@ -70,6 +70,7 @@ export const SearchUsers = ({
|
||||
|
||||
if (!isFoundUser && !isFoundEmail) {
|
||||
users = [
|
||||
...users,
|
||||
{
|
||||
value: { email: userQuery },
|
||||
label: userQuery,
|
||||
@@ -83,8 +84,7 @@ export const SearchUsers = ({
|
||||
resolveOptionsRef.current = null;
|
||||
|
||||
return users;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options, selectedUsers]);
|
||||
}, [options, selectedUsers, userQuery]);
|
||||
|
||||
const loadOptions = (): Promise<OptionsSelect> => {
|
||||
return new Promise<OptionsSelect>((resolve) => {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user