Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6742a23759 | |||
| 6a0d65b4f4 | |||
| 82fe1a01ca | |||
| a04248693f | |||
| 3f76a96de8 | |||
| 30aeceb097 | |||
| de3dcfb7a0 | |||
| f7a90d10b6 | |||
| 32ee62deae | |||
| 524fa53fdb | |||
| 1637a74ce9 | |||
| b96779eb08 | |||
| e579326f11 | |||
| 28d7d16922 | |||
| 85182590fb | |||
| ef19de7ada | |||
| 745ab6aabc | |||
| 2b0afead5b | |||
| 3fd29f7057 | |||
| 80a7285942 | |||
| 7ca54f7eed |
@@ -27,7 +27,7 @@ jobs:
|
||||
- name: Enforce absence of print statements in code
|
||||
if: always()
|
||||
run: |
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- src/backend ':(exclude)**/impress.yml' | grep "print("
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
|
||||
- name: Check absence of fixup commits
|
||||
if: always()
|
||||
run: |
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext pandoc shared-mime-info
|
||||
sudo wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/conf/mime.types -O /etc/mime.types
|
||||
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: python manage.py compilemessages
|
||||
|
||||
+8
-13
@@ -6,24 +6,13 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) Can print a doc #1832
|
||||
- ✨(backend) manage reconciliation requests for user accounts #1878
|
||||
- ✨(backend) add management command to reset a Document #1882
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) Focus main container after navigation #1854
|
||||
- 🚸(backend) sort user search results by proximity with the active user #1802
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) fix broadcast store sync #1846
|
||||
🐛(frontend) fix broadcast store sync #1846
|
||||
|
||||
## [v4.5.0] - 2026-01-28
|
||||
|
||||
### Added
|
||||
### Added
|
||||
|
||||
- ✨(frontend) integrate configurable Waffle #1795
|
||||
- ✨ Import of documents #1609
|
||||
@@ -31,12 +20,16 @@ and this project adheres to
|
||||
- ✨(frontend) Add stat for Crisp #1824
|
||||
- ✨(auth) add silent login #1690
|
||||
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
|
||||
- ✨(frontend) activate Find search #1834
|
||||
- ✨ handle searching on subdocuments #1834
|
||||
- ✨(backend) add search feature flags #1897
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿️(frontend) fix subdoc opening and emoji pick focus #1745
|
||||
- ✨(backend) add field for button label in email template #1817
|
||||
- ✨(backend) improve fallback logic on search endpoint #1834
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -50,6 +43,8 @@ and this project adheres to
|
||||
### Removed
|
||||
|
||||
- 🔥(project) remove all code related to template #1780
|
||||
- 🔥(api) remove `documents/<document_id>/descendants/` endpoint #1834
|
||||
- 🔥(api) remove pagination on `documents/search/` endpoint #1834
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
+8
-8
@@ -36,7 +36,7 @@ COPY ./src/mail /mail/app
|
||||
WORKDIR /mail/app
|
||||
|
||||
RUN yarn install --frozen-lockfile && \
|
||||
yarn build
|
||||
yarn build
|
||||
|
||||
|
||||
# ---- static link collector ----
|
||||
@@ -58,7 +58,7 @@ WORKDIR /app
|
||||
|
||||
# collectstatic
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py collectstatic --noinput
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
# Replace duplicated file by a symlink to decrease the overall size of the
|
||||
# final image
|
||||
@@ -81,7 +81,7 @@ RUN apk add --no-cache \
|
||||
pango \
|
||||
shared-mime-info
|
||||
|
||||
RUN wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/conf/mime.types -O /etc/mime.types
|
||||
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
@@ -98,9 +98,9 @@ COPY --from=back-builder /install /usr/local
|
||||
# when python is upgraded and the path to the certificate changes.
|
||||
# The space between print and the ( is intended otherwise the git lint is failing
|
||||
RUN mkdir /cert && \
|
||||
path=`python -c 'import certifi;print (certifi.where())'` && \
|
||||
mv $path /cert/ && \
|
||||
ln -s /cert/cacert.pem $path
|
||||
path=`python -c 'import certifi;print (certifi.where())'` && \
|
||||
mv $path /cert/ && \
|
||||
ln -s /cert/cacert.pem $path
|
||||
|
||||
# Copy impress application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
@@ -109,7 +109,7 @@ WORKDIR /app
|
||||
|
||||
# Generate compiled translation messages
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py compilemessages
|
||||
python manage.py compilemessages
|
||||
|
||||
|
||||
# We wrap commands run in this container by the following entrypoint that
|
||||
@@ -138,7 +138,7 @@ USER ${DOCKER_USER}
|
||||
# Target database host (e.g. database engine following docker compose services
|
||||
# name) & port
|
||||
ENV DB_HOST=postgresql \
|
||||
DB_PORT=5432
|
||||
DB_PORT=5432
|
||||
|
||||
# Run django development server
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
|
||||
@@ -79,10 +79,16 @@ create-env-local-files:
|
||||
@touch env.d/development/kc_postgresql.local
|
||||
.PHONY: create-env-local-files
|
||||
|
||||
generate-secret-keys:
|
||||
generate-secret-keys: ## generate secret keys to be stored in common.local
|
||||
@bin/generate-oidc-store-refresh-token-key.sh
|
||||
.PHONY: generate-secret-keys
|
||||
|
||||
pre-bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-local-files
|
||||
create-env-local-files \
|
||||
generate-secret-keys
|
||||
.PHONY: pre-bootstrap
|
||||
|
||||
post-bootstrap: \
|
||||
|
||||
@@ -72,7 +72,7 @@ For some advanced features (ex: Export as PDF) Docs relies on XL packages from B
|
||||
|
||||
### Test it
|
||||
|
||||
You can test Docs on your browser by visiting this [demo document](https://docs.la-suite.eu/docs/9137bbb5-3e8a-4ff7-8a36-fcc4e8bd57f4/)
|
||||
You can test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
|
||||
|
||||
### Run Docs locally
|
||||
|
||||
@@ -147,6 +147,11 @@ $ make frontend-test
|
||||
$ make frontend-lint
|
||||
```
|
||||
|
||||
Backend tests can be run without docker. This is useful to configure PyCharm of VSCode to do it.
|
||||
Removing docker for testing requires to overwrite some URL and port values that are different in and out of
|
||||
Docker. `env.d/development/common` contains all variables, some fo them having to be overwritten by those in
|
||||
`env.d/development/common.test`.
|
||||
|
||||
**Adding content**
|
||||
|
||||
You can create a basic demo site by running this command:
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run app-dev python -c 'from cryptography.fernet import Fernet;import sys; sys.stdout.write("\n" + Fernet.generate_key().decode() + "\n");'
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Generate the secret OIDC_STORE_REFRESH_TOKEN_KEY and store it to common.local
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
COMMON_LOCAL="env.d/development/common.local"
|
||||
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY=$(openssl rand -base64 32)
|
||||
|
||||
echo "OIDC_STORE_REFRESH_TOKEN_KEY=${OIDC_STORE_REFRESH_TOKEN_KEY}" > "${COMMON_LOCAL}"
|
||||
echo "✓ OIDC_STORE_REFRESH_TOKEN_KEY generated and stored in ${COMMON_LOCAL}"
|
||||
+10
-8
@@ -17,7 +17,6 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
|
||||
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
|
||||
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
|
||||
| API_USERS_SEARCH_QUERY_MIN_LENGTH | Minimum characters to insert to search a user | 3 |
|
||||
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
|
||||
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
|
||||
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
|
||||
@@ -35,7 +34,7 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| CONVERSION_API_SECURE | Require secure conversion api | false |
|
||||
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
|
||||
| CONVERSION_FILE_MAX_SIZE | The file max size allowed when uploaded to convert it | 20971520 (20MB) |
|
||||
| CONVERSION_FILE_EXTENSIONS_ALLOWED | Extension list managed by the conversion service | [".docx", ".md"]
|
||||
| CONVERSION_FILE_EXTENSIONS_ALLOWED | Extension list managed by the conversion service | [".docx", ".md"] |
|
||||
| CRISP_WEBSITE_ID | Crisp website id for support | |
|
||||
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_HOST | Host of the database | localhost |
|
||||
@@ -58,15 +57,15 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
|
||||
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
|
||||
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
|
||||
| DJANGO_EMAIL_URL_APP | Url used in the email to go to the app | |
|
||||
| DJANGO_EMAIL_URL_APP | Url used in the email to go to the app | |
|
||||
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
|
||||
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
|
||||
| DJANGO_SECRET_KEY | Secret key | |
|
||||
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
|
||||
| DOCSPEC_API_URL | URL to endpoint of DocSpec conversion API | |
|
||||
| DOCSPEC_API_URL | URL to endpoint of DocSpec conversion API | |
|
||||
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
|
||||
| FRONTEND_CSS_URL | To add a external css file to the app | |
|
||||
| FRONTEND_JS_URL | To add a external js file to the app | |
|
||||
| FRONTEND_JS_URL | To add a external js file to the app | |
|
||||
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
|
||||
| FRONTEND_THEME | Frontend theme to use | |
|
||||
| LANGUAGE_CODE | Default language | en-us |
|
||||
@@ -100,6 +99,9 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
|
||||
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
|
||||
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
|
||||
| OIDC_STORE_ACCESS_TOKEN | If True stores OIDC access token in session. | false |
|
||||
| OIDC_STORE_REFRESH_TOKEN | If True stores OIDC refresh token in session. | false |
|
||||
| OIDC_STORE_REFRESH_TOKEN_KEY | Key to encrypt refresh token stored in session, must be a valid Fernet key | |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
|
||||
| OIDC_USE_NONCE | Use nonce for OIDC | true |
|
||||
@@ -109,8 +111,9 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| SEARCH_INDEXER_CLASS | Class of the backend for document indexation & search | |
|
||||
| SEARCH_INDEXER_COUNTDOWN | Minimum debounce delay of indexation jobs (in seconds) | 1 |
|
||||
| SEARCH_INDEXER_QUERY_LIMIT | Maximum number of results expected from search endpoint | 50 |
|
||||
| SEARCH_INDEXER_SECRET | Token for indexation queries | |
|
||||
| SEARCH_INDEXER_URL | Find application endpoint for indexation | |
|
||||
| SEARCH_URL | Find application endpoint for search queries | |
|
||||
| SEARCH_INDEXER_SECRET | Token required for indexation queries | |
|
||||
| INDEXING_URL | Find application endpoint for indexation | |
|
||||
| SENTRY_DSN | Sentry host | |
|
||||
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Register new user to the marketing onboarding. If True, see env LASUITE_MARKETING_* system | False |
|
||||
@@ -120,7 +123,6 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
|
||||
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
|
||||
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
|
||||
| USER_RECONCILIATION_FORM_URL | URL of a third-party form for user reconciliation requests | |
|
||||
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
|
||||
| Y_PROVIDER_API_KEY | Y provider API key | |
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ backend:
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_STORAGE_BUCKET_NAME: docs-media-storage
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
USER_RECONCILIATION_FORM_URL: https://docs.127.0.0.1.nip.io
|
||||
Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/
|
||||
Y_PROVIDER_API_KEY: my-secret
|
||||
CACHES_KEY_PREFIX: "{{ now | unixEpoch }}"
|
||||
|
||||
+29
-18
@@ -1,8 +1,8 @@
|
||||
# Setup the Find search for Impress
|
||||
# Setup Find search for Docs
|
||||
|
||||
This configuration will enable the fulltext search feature for Docs :
|
||||
- Each save on **core.Document** or **core.DocumentAccess** will trigger the indexer
|
||||
- The `api/v1.0/documents/search/` will work as a proxy with the Find API for fulltext search.
|
||||
This configuration will enable Find searches:
|
||||
- Each save on **core.Document** or **core.DocumentAccess** will trigger the indexing of the document into Find.
|
||||
- The `api/v1.0/documents/search/` will be used as proxy for searching documents from Find indexes.
|
||||
|
||||
## Create an index service for Docs
|
||||
|
||||
@@ -15,27 +15,38 @@ See [how-to-use-indexer.md](how-to-use-indexer.md) for details.
|
||||
|
||||
## Configure settings of Docs
|
||||
|
||||
Add those Django settings the Docs application to enable the feature.
|
||||
Find uses a service provider authentication for indexing and a OIDC authentication for searching.
|
||||
|
||||
Add those Django settings to the Docs application to enable the feature.
|
||||
|
||||
```shell
|
||||
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
|
||||
|
||||
SEARCH_INDEXER_COUNTDOWN=10 # Debounce delay in seconds for the indexer calls.
|
||||
SEARCH_INDEXER_QUERY_LIMIT=50 # Maximum number of results expected from the search endpoint
|
||||
|
||||
# The token from service "docs" of Find application (development).
|
||||
INDEXING_URL="http://find:8000/api/v1.0/documents/index/"
|
||||
SEARCH_URL="http://find:8000/api/v1.0/documents/search/"
|
||||
|
||||
# Service provider authentication
|
||||
SEARCH_INDEXER_SECRET="find-api-key-for-docs-with-exactly-50-chars-length"
|
||||
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
|
||||
|
||||
# Search endpoint. Uses the OIDC token for authentication
|
||||
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
|
||||
# Maximum number of results expected from the search endpoint
|
||||
SEARCH_INDEXER_QUERY_LIMIT=50
|
||||
# OIDC authentication
|
||||
OIDC_STORE_ACCESS_TOKEN=True # Store the access token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY="<your-32-byte-encryption-key==>"
|
||||
```
|
||||
|
||||
We also need to enable the **OIDC Token** refresh or the authentication will fail quickly.
|
||||
`OIDC_STORE_REFRESH_TOKEN_KEY` must be a valid Fernet key (32 url-safe base64-encoded bytes).
|
||||
To create one, use the `bin/generate-oidc-store-refresh-token-key.sh` command.
|
||||
|
||||
```shell
|
||||
# Store OIDC tokens in the session
|
||||
OIDC_STORE_ACCESS_TOKEN = True # Store the access token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY = "your-32-byte-encryption-key==" # Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
```
|
||||
## Feature flags
|
||||
|
||||
The Find search integration is controlled by two feature flags:
|
||||
- `flag_find_hybrid_search`
|
||||
- `flag_find_full_text_search`
|
||||
|
||||
If a user has both flags activated the most advanced search is used (hybrid > full text > title).
|
||||
A user with no flag will default to the basic title search.
|
||||
|
||||
Feature flags can be activated through the admin interface.
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# User account reconciliation
|
||||
|
||||
It is possible to merge user accounts based on their email addresses.
|
||||
|
||||
Docs does not have an internal process to requests, but it allows the import of a CSV from an external form
|
||||
(e.g. made with Grist) in the Django admin panel (in "Core" > "User reconciliation CSV imports" > "Add user reconciliation")
|
||||
|
||||
## CSV file format
|
||||
|
||||
The CSV must contain the following mandatory columns:
|
||||
|
||||
- `active_email`: the email of the user that will remain active after the process.
|
||||
- `inactive_email`: the email of the user(s) that will be merged into the active user. It is possible to indicate several emails, so the user only has to make one request even if they have more than two accounts.
|
||||
- `id`: a unique row id, so that entries already processed in a previous import are ignored.
|
||||
|
||||
The following columns are optional: `active_email_checked` and `inactive_email_checked` (both must contain `0` (False) or `1` (True), and both default to False.)
|
||||
If present, it allows to indicate that the source form has a way to validate that the user making the request actually controls the email addresses, skipping the need to send confirmation emails (cf. below)
|
||||
|
||||
Once the CSV file is processed, this will create entries in "Core" > "User reconciliations" and send verification emails to validate that the user making the request actually controls the email addresses (unless `active_email_checked` and `inactive_email_checked` were set to `1` in the CSV)
|
||||
|
||||
In "Core" > "User reconciliations", an admin can then select all rows they wish to process and check the action "Process selected user reconciliations". Only rows that have the status `ready` and for which both emails have been validated will be processed.
|
||||
|
||||
## Settings
|
||||
|
||||
If there is a problem with the reconciliation attempt (e.g., one of the addresses given by the user does not match an existing account), the email signaling the error can give back the link to the reconciliation form. This is configured through the following environment variable:
|
||||
|
||||
```env
|
||||
USER_RECONCILIATION_FORM_URL=<url used in the email for reconciliation with errors to allow a new requests>
|
||||
# e.g. "https://yourgristinstance.tld/xxxx/UserReconciliationForm"
|
||||
```
|
||||
@@ -52,15 +52,8 @@ OIDC_REDIRECT_ALLOWED_HOSTS="localhost:8083,localhost:3000"
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# Store OIDC tokens in the session. Needed by search/ endpoint.
|
||||
# OIDC_STORE_ACCESS_TOKEN = True
|
||||
# OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
|
||||
|
||||
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
# To create one, use the bin/fernetkey command.
|
||||
# OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key=="
|
||||
|
||||
# User reconciliation
|
||||
USER_RECONCILIATION_FORM_URL=http://localhost:3000
|
||||
OIDC_STORE_ACCESS_TOKEN=True
|
||||
OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session.
|
||||
|
||||
# AI
|
||||
AI_FEATURE_ENABLED=true
|
||||
@@ -85,8 +78,9 @@ DOCSPEC_API_URL=http://docspec:4000/conversion
|
||||
# Theme customization
|
||||
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
|
||||
|
||||
# Indexer (disabled)
|
||||
# SEARCH_INDEXER_CLASS="core.services.search_indexers.SearchIndexer"
|
||||
# Indexer
|
||||
SEARCH_INDEXER_CLASS=core.services.search_indexers.FindDocumentIndexer
|
||||
SEARCH_INDEXER_SECRET=find-api-key-for-docs-with-exactly-50-chars-length # Key generated by create_demo in Find app.
|
||||
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
|
||||
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
|
||||
INDEXING_URL=http://find:8000/api/v1.0/documents/index/
|
||||
SEARCH_URL=http://find:8000/api/v1.0/documents/search/
|
||||
SEARCH_INDEXER_QUERY_LIMIT=50
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Test environment configuration for running tests without docker
|
||||
# Base configuration is loaded from 'common' file
|
||||
|
||||
DJANGO_SETTINGS_MODULE=impress.settings
|
||||
DJANGO_CONFIGURATION=Test
|
||||
DB_PORT=15432
|
||||
AWS_S3_ENDPOINT_URL=http://localhost:9000
|
||||
@@ -53,9 +53,6 @@ LOGOUT_REDIRECT_URL=https://${DOCS_HOST}
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["https://${DOCS_HOST}"]
|
||||
|
||||
# User reconciliation
|
||||
#USER_RECONCILIATION_FORM_URL=https://${DOCS_HOST}
|
||||
|
||||
# AI
|
||||
#AI_FEATURE_ENABLED=true # is false by default
|
||||
#AI_BASE_URL=https://openaiendpoint.com
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"""Admin classes and registrations for core app."""
|
||||
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from treebeard.admin import TreeAdmin
|
||||
|
||||
from core import models
|
||||
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
|
||||
from . import models
|
||||
|
||||
|
||||
@admin.register(models.User)
|
||||
@@ -97,44 +95,6 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
||||
|
||||
|
||||
@admin.register(models.UserReconciliationCsvImport)
|
||||
class UserReconciliationCsvImportAdmin(admin.ModelAdmin):
|
||||
"""Admin class for UserReconciliationCsvImport model."""
|
||||
|
||||
list_display = ("id", "__str__", "created_at", "status")
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Override save_model to trigger the import task on creation."""
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
if not change:
|
||||
user_reconciliation_csv_import_job.delay(obj.pk)
|
||||
messages.success(request, _("Import job created and queued."))
|
||||
return redirect("..")
|
||||
|
||||
|
||||
@admin.action(description=_("Process selected user reconciliations"))
|
||||
def process_reconciliation(_modeladmin, _request, queryset):
|
||||
"""
|
||||
Admin action to process selected user reconciliations.
|
||||
The action will process only entries that are ready and have both emails checked.
|
||||
"""
|
||||
processable_entries = queryset.filter(
|
||||
status="ready", active_email_checked=True, inactive_email_checked=True
|
||||
)
|
||||
|
||||
for entry in processable_entries:
|
||||
entry.process_reconciliation_request()
|
||||
|
||||
|
||||
@admin.register(models.UserReconciliation)
|
||||
class UserReconciliationAdmin(admin.ModelAdmin):
|
||||
"""Admin class for UserReconciliation model."""
|
||||
|
||||
list_display = ["id", "__str__", "created_at", "status"]
|
||||
actions = [process_reconciliation]
|
||||
|
||||
|
||||
class DocumentAccessInline(admin.TabularInline):
|
||||
"""Inline admin class for document accesses."""
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import unicodedata
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import django_filters
|
||||
@@ -136,6 +135,4 @@ class UserSearchFilter(django_filters.FilterSet):
|
||||
Custom filter for searching users.
|
||||
"""
|
||||
|
||||
q = django_filters.CharFilter(
|
||||
min_length=settings.API_USERS_SEARCH_QUERY_MIN_LENGTH, max_length=254
|
||||
)
|
||||
q = django_filters.CharFilter(min_length=5, max_length=254)
|
||||
|
||||
@@ -12,6 +12,7 @@ from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
|
||||
"children": {"GET": "children_list", "POST": "children_create"},
|
||||
"search": {"GET": "retrieve"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -980,8 +980,5 @@ class ThreadSerializer(serializers.ModelSerializer):
|
||||
class SearchDocumentSerializer(serializers.Serializer):
|
||||
"""Serializer for fulltext search requests through Find application"""
|
||||
|
||||
q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
|
||||
page_size = serializers.IntegerField(
|
||||
required=False, min_value=1, max_value=50, default=20
|
||||
)
|
||||
page = serializers.IntegerField(required=False, min_value=1, default=1)
|
||||
q = serializers.CharField(required=True, allow_blank=True, trim_whitespace=True)
|
||||
path = serializers.CharField(required=False, allow_blank=False)
|
||||
|
||||
+113
-207
@@ -32,16 +32,15 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
import rest_framework as drf
|
||||
import waffle
|
||||
from botocore.exceptions import ClientError
|
||||
from csp.constants import NONE
|
||||
from csp.decorators import csp_update
|
||||
from lasuite.malware_detection import malware_detection
|
||||
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
||||
from lasuite.tools.email import get_domain_from_email
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from core import authentication, choices, enums, models
|
||||
from core.api.filters import remove_accents
|
||||
@@ -63,14 +62,15 @@ from core.services.search_indexers import (
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
from core.tasks.mail import send_ask_for_access_mail
|
||||
from core.utils import (
|
||||
extract_attachments,
|
||||
filter_descendants,
|
||||
users_sharing_documents_with,
|
||||
)
|
||||
from core.utils import extract_attachments, filter_descendants
|
||||
|
||||
from ..enums import SearchType
|
||||
from . import permissions, serializers, utils
|
||||
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
|
||||
from .filters import (
|
||||
DocumentFilter,
|
||||
ListDocumentFilter,
|
||||
UserSearchFilter,
|
||||
)
|
||||
from .throttling import (
|
||||
DocumentThrottle,
|
||||
UserListThrottleBurst,
|
||||
@@ -226,80 +226,18 @@ class UserViewSet(
|
||||
|
||||
# Use trigram similarity for non-email-like queries
|
||||
# For performance reasons we filter first by similarity, which relies on an
|
||||
# index, then only calculate precise similarity scores for sorting purposes.
|
||||
#
|
||||
# Additionally results are reordered to prefer users "closer" to the current
|
||||
# user: users they recently shared documents with, then same email domain.
|
||||
# To achieve that without complex SQL, we build a proximity score in Python
|
||||
# and return the top N results.
|
||||
# For security results, users that match neither of these proximity criteria
|
||||
# are not returned at all, to prevent email enumeration.
|
||||
current_user = self.request.user
|
||||
shared_map = users_sharing_documents_with(current_user)
|
||||
# index, then only calculate precise similarity scores for sorting purposes
|
||||
|
||||
user_email_domain = get_domain_from_email(current_user.email) or ""
|
||||
|
||||
candidates = list(
|
||||
return (
|
||||
queryset.annotate(
|
||||
sim_email=TrigramSimilarity("email", query),
|
||||
sim_name=TrigramSimilarity("full_name", query),
|
||||
)
|
||||
.annotate(similarity=Greatest("sim_email", "sim_name"))
|
||||
.filter(similarity__gt=0.2)
|
||||
.order_by("-similarity")
|
||||
.order_by("-similarity")[: settings.API_USERS_LIST_LIMIT]
|
||||
)
|
||||
|
||||
# Keep only users that either share documents with the current user
|
||||
# or have an email with the same domain as the current user.
|
||||
filtered_candidates = []
|
||||
for u in candidates:
|
||||
candidate_domain = get_domain_from_email(u.email) or ""
|
||||
if shared_map.get(u.id) or (
|
||||
user_email_domain and candidate_domain == user_email_domain
|
||||
):
|
||||
filtered_candidates.append(u)
|
||||
|
||||
candidates = filtered_candidates
|
||||
|
||||
# Build ordering key for each candidate
|
||||
def _sort_key(u):
|
||||
# shared priority: most recent first
|
||||
# Use shared_last_at timestamp numeric for secondary ordering when shared.
|
||||
shared_last_at = shared_map.get(u.id)
|
||||
if shared_last_at:
|
||||
is_shared = 1
|
||||
shared_score = int(shared_last_at.timestamp())
|
||||
else:
|
||||
is_shared = 0
|
||||
shared_score = 0
|
||||
|
||||
# domain proximity
|
||||
candidate_email_domain = get_domain_from_email(u.email) or ""
|
||||
|
||||
same_full_domain = (
|
||||
1
|
||||
if candidate_email_domain
|
||||
and candidate_email_domain == user_email_domain
|
||||
else 0
|
||||
)
|
||||
|
||||
# similarity fallback
|
||||
sim = getattr(u, "similarity", 0) or 0
|
||||
|
||||
return (
|
||||
is_shared,
|
||||
shared_score,
|
||||
same_full_domain,
|
||||
sim,
|
||||
)
|
||||
|
||||
# Sort candidates by the key descending and return top N as a queryset-like
|
||||
# list. Keep return type consistent with previous behavior (QuerySet slice
|
||||
# was returned) by returning a list of model instances.
|
||||
candidates.sort(key=_sort_key, reverse=True)
|
||||
|
||||
return candidates[: settings.API_USERS_LIST_LIMIT]
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=False,
|
||||
methods=["get"],
|
||||
@@ -317,59 +255,6 @@ class UserViewSet(
|
||||
)
|
||||
|
||||
|
||||
class ReconciliationConfirmView(APIView):
|
||||
"""API endpoint to confirm user reconciliation emails.
|
||||
|
||||
GET /user-reconciliations/{user_type}/{confirmation_id}/
|
||||
Marks `active_email_checked` or `inactive_email_checked` to True.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request, user_type, confirmation_id):
|
||||
"""
|
||||
Check the confirmation ID and mark the corresponding email as checked.
|
||||
"""
|
||||
try:
|
||||
# validate UUID
|
||||
uuid_obj = uuid.UUID(str(confirmation_id))
|
||||
except ValueError:
|
||||
return drf_response.Response(
|
||||
{"detail": "Badly formatted confirmation id"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if user_type not in ("active", "inactive"):
|
||||
return drf_response.Response(
|
||||
{"detail": "Invalid user_type"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
lookup = (
|
||||
{"active_email_confirmation_id": uuid_obj}
|
||||
if user_type == "active"
|
||||
else {"inactive_email_confirmation_id": uuid_obj}
|
||||
)
|
||||
|
||||
try:
|
||||
rec = models.UserReconciliation.objects.get(**lookup)
|
||||
except models.UserReconciliation.DoesNotExist:
|
||||
return drf_response.Response(
|
||||
{"detail": "Reconciliation entry not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
field_name = (
|
||||
"active_email_checked"
|
||||
if user_type == "active"
|
||||
else "inactive_email_checked"
|
||||
)
|
||||
if not getattr(rec, field_name):
|
||||
setattr(rec, field_name, True)
|
||||
rec.save()
|
||||
|
||||
return drf_response.Response({"detail": "Confirmation received"})
|
||||
|
||||
|
||||
class ResourceAccessViewsetMixin:
|
||||
"""Mixin with methods common to all access viewsets."""
|
||||
|
||||
@@ -580,14 +465,12 @@ class DocumentViewSet(
|
||||
It performs early filtering on model fields, annotates user roles, and removes
|
||||
descendant documents to keep only the highest ancestors readable by the current user.
|
||||
"""
|
||||
user = self.request.user
|
||||
user = request.user
|
||||
|
||||
# Not calling filter_queryset. We do our own cooking.
|
||||
queryset = self.get_queryset()
|
||||
|
||||
filterset = ListDocumentFilter(
|
||||
self.request.GET, queryset=queryset, request=self.request
|
||||
)
|
||||
filterset = ListDocumentFilter(request.GET, queryset=queryset, request=request)
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
filter_data = filterset.form.cleaned_data
|
||||
@@ -1077,26 +960,6 @@ class DocumentViewSet(
|
||||
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
ordering=["path"],
|
||||
)
|
||||
def descendants(self, request, *args, **kwargs):
|
||||
"""Handle listing descendants of a document"""
|
||||
document = self.get_object()
|
||||
|
||||
queryset = document.get_descendants().filter(ancestors_deleted_at__isnull=True)
|
||||
queryset = self.filter_queryset(queryset)
|
||||
|
||||
filterset = DocumentFilter(request.GET, queryset=queryset)
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.qs
|
||||
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
@@ -1304,57 +1167,6 @@ class DocumentViewSet(
|
||||
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
def _search_simple(self, request, text):
|
||||
"""
|
||||
Returns a queryset filtered by the content of the document title
|
||||
"""
|
||||
# As the 'list' view we get a prefiltered queryset (deleted docs are excluded)
|
||||
queryset = self.get_queryset()
|
||||
filterset = DocumentFilter({"title": text}, queryset=queryset)
|
||||
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.filter_queryset(queryset)
|
||||
|
||||
return self.get_response_for_queryset(
|
||||
queryset.order_by("-updated_at"),
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
def _search_fulltext(self, indexer, request, params):
|
||||
"""
|
||||
Returns a queryset from the results the fulltext search of Find
|
||||
"""
|
||||
access_token = request.session.get("oidc_access_token")
|
||||
user = request.user
|
||||
text = params.validated_data["q"]
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
# Retrieve the documents ids from Find.
|
||||
results = indexer.search(
|
||||
text=text,
|
||||
token=access_token,
|
||||
visited=get_visited_document_ids_of(queryset, user),
|
||||
)
|
||||
|
||||
docs_by_uuid = {str(d.pk): d for d in queryset.filter(pk__in=results)}
|
||||
ordered_docs = [docs_by_uuid[id] for id in results]
|
||||
|
||||
page = self.paginate_queryset(ordered_docs)
|
||||
|
||||
serializer = self.get_serializer(
|
||||
page if page else ordered_docs,
|
||||
many=True,
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
|
||||
@method_decorator(refresh_oidc_access_token)
|
||||
def search(self, request, *args, **kwargs):
|
||||
@@ -1371,15 +1183,110 @@ class DocumentViewSet(
|
||||
"""
|
||||
params = serializers.SearchDocumentSerializer(data=request.query_params)
|
||||
params.is_valid(raise_exception=True)
|
||||
search_type = self._get_search_type()
|
||||
|
||||
if search_type == SearchType.TITLE:
|
||||
return self._title_search(request, params.validated_data, *args, **kwargs)
|
||||
|
||||
indexer = get_document_indexer()
|
||||
if indexer is None:
|
||||
# fallback on title search if the indexer is not configured
|
||||
return self._title_search(request, params.validated_data, *args, **kwargs)
|
||||
|
||||
if indexer:
|
||||
return self._search_fulltext(indexer, request, params=params)
|
||||
try:
|
||||
return self._search_with_indexer(
|
||||
indexer, request, params=params, search_type=search_type
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("Error while searching documents with indexer: %s", e)
|
||||
# fallback on title search if the indexer is not reached
|
||||
return self._title_search(request, params.validated_data, *args, **kwargs)
|
||||
|
||||
# The indexer is not configured, we fallback on a simple icontains filter by the
|
||||
# model field 'title'.
|
||||
return self._search_simple(request, text=params.validated_data["q"])
|
||||
def _get_search_type(self) -> SearchType:
|
||||
"""
|
||||
Returns the search type to use for the search endpoint based on feature flags.
|
||||
If a user has both flags activated the most advanced search is used
|
||||
(HYBRID > FULL_TEXT > TITLE).
|
||||
A user with no flag will default to the basic title search.
|
||||
"""
|
||||
if waffle.flag_is_active(self.request, "flag_find_hybrid_search"):
|
||||
return SearchType.HYBRID
|
||||
if waffle.flag_is_active(self.request, "flag_find_full_text_search"):
|
||||
return SearchType.FULL_TEXT
|
||||
return SearchType.TITLE
|
||||
|
||||
@staticmethod
|
||||
def _search_with_indexer(indexer, request, params, search_type):
|
||||
"""
|
||||
Returns a list of documents matching the query (q) according to the configured indexer.
|
||||
"""
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
results = indexer.search(
|
||||
q=params.validated_data["q"],
|
||||
search_type=search_type,
|
||||
token=request.session.get("oidc_access_token"),
|
||||
path=(
|
||||
params.validated_data["path"]
|
||||
if "path" in params.validated_data
|
||||
else None
|
||||
),
|
||||
visited=get_visited_document_ids_of(queryset, request.user),
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{
|
||||
"count": len(results),
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": results,
|
||||
}
|
||||
)
|
||||
|
||||
def _title_search(self, request, validated_data, *args, **kwargs):
|
||||
"""
|
||||
Fallback search method when no indexer is configured.
|
||||
Only searches in the title field of documents.
|
||||
"""
|
||||
request.GET = request.GET.copy()
|
||||
request.GET["title"] = validated_data["q"]
|
||||
|
||||
if "path" not in validated_data or not validated_data["path"]:
|
||||
return self.list(request, *args, **kwargs)
|
||||
|
||||
return self._list_descendants(request)
|
||||
|
||||
def _list_descendants(self, request):
|
||||
"""
|
||||
List all documents whose path starts with the provided path parameter.
|
||||
Includes the parent document itself.
|
||||
Used internally by the search endpoint when path filtering is requested.
|
||||
"""
|
||||
# Get parent document without access filtering
|
||||
parent_path = request.GET["path"]
|
||||
try:
|
||||
parent = models.Document.objects.get(path=parent_path)
|
||||
except models.Document.DoesNotExist as exc:
|
||||
raise drf.exceptions.NotFound("Document not found from path.") from exc
|
||||
|
||||
# Check object-level permissions using DocumentPermission logic
|
||||
self.check_object_permissions(request, parent)
|
||||
|
||||
# Get descendants and include the parent, ordered by path
|
||||
queryset = (
|
||||
parent.get_descendants(include_self=True)
|
||||
.filter(ancestors_deleted_at__isnull=True)
|
||||
.order_by("path")
|
||||
)
|
||||
queryset = self.filter_queryset(queryset)
|
||||
|
||||
# filter by title
|
||||
filterset = DocumentFilter(request.GET, queryset=queryset)
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.qs
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
def versions_list(self, request, *args, **kwargs):
|
||||
@@ -2459,7 +2366,6 @@ class ConfigView(drf.views.APIView):
|
||||
"""
|
||||
array_settings = [
|
||||
"AI_FEATURE_ENABLED",
|
||||
"API_USERS_SEARCH_QUERY_MIN_LENGTH",
|
||||
"COLLABORATION_WS_URL",
|
||||
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY",
|
||||
"CONVERSION_FILE_EXTENSIONS_ALLOWED",
|
||||
|
||||
@@ -3,7 +3,7 @@ Core application enums declaration
|
||||
"""
|
||||
|
||||
import re
|
||||
from enum import StrEnum
|
||||
from enum import Enum, StrEnum
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.db import models
|
||||
@@ -46,3 +46,15 @@ class DocumentAttachmentStatus(StrEnum):
|
||||
|
||||
PROCESSING = "processing"
|
||||
READY = "ready"
|
||||
|
||||
|
||||
class SearchType(str, Enum):
|
||||
"""
|
||||
Defines the possible search types for a document search query.
|
||||
- TITLE: DRF based search in the title of the documents only.
|
||||
- HYBRID and FULL_TEXT: more advanced search based on Find indexer.
|
||||
"""
|
||||
|
||||
TITLE = "title"
|
||||
HYBRID = "hybrid"
|
||||
FULL_TEXT = "full-text"
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
"""Clean a document by resetting it (keeping its title) and deleting all descendants."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from core.choices import LinkReachChoices, LinkRoleChoices, RoleChoices
|
||||
from core.models import Document, DocumentAccess, Invitation, Thread
|
||||
|
||||
logger = logging.getLogger("impress.commands.clean_document")
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Reset a document (keeping its title) and delete all its descendants."""
|
||||
|
||||
help = __doc__
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Define command arguments."""
|
||||
parser.add_argument(
|
||||
"document_id",
|
||||
type=str,
|
||||
help="UUID of the document to clean",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--force",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Force command execution despite DEBUG is set to False",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--title",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Update the document title to this value",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--link_reach",
|
||||
type=str,
|
||||
default=LinkReachChoices.RESTRICTED,
|
||||
choices=LinkReachChoices,
|
||||
help="Update the link_reach to this value",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--link_role",
|
||||
type=str,
|
||||
default=LinkRoleChoices.READER,
|
||||
choices=LinkRoleChoices,
|
||||
help="update the link_role to this value",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Execute the clean_document command."""
|
||||
if not settings.DEBUG and not options["force"]:
|
||||
raise CommandError(
|
||||
"This command is not meant to be used in production environment "
|
||||
"except you know what you are doing, if so use --force parameter"
|
||||
)
|
||||
|
||||
document_id = options["document_id"]
|
||||
|
||||
try:
|
||||
document = Document.objects.get(pk=document_id)
|
||||
except (Document.DoesNotExist, ValueError) as err:
|
||||
raise CommandError(f"Document {document_id} does not exist.") from err
|
||||
|
||||
descendants = list(document.get_descendants())
|
||||
descendant_ids = [doc.id for doc in descendants]
|
||||
all_documents = [document, *descendants]
|
||||
|
||||
# Collect all attachment keys before the transaction clears them
|
||||
all_attachment_keys = []
|
||||
for doc in all_documents:
|
||||
all_attachment_keys.extend(doc.attachments)
|
||||
|
||||
self.stdout.write(
|
||||
f"Cleaning document {document_id} and deleting "
|
||||
f"{len(descendants)} descendant(s)..."
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
# Clean accesses and invitations on the root document
|
||||
access_count, _ = DocumentAccess.objects.filter(
|
||||
Q(document_id=document.id) & ~Q(role=RoleChoices.OWNER)
|
||||
).delete()
|
||||
self.stdout.write(f"Deleted {access_count} access(es) on root document.")
|
||||
|
||||
invitation_count, _ = Invitation.objects.filter(
|
||||
document_id=document.id
|
||||
).delete()
|
||||
self.stdout.write(
|
||||
f"Deleted {invitation_count} invitation(s) on root document."
|
||||
)
|
||||
|
||||
thread_count, _ = Thread.objects.filter(document_id=document.id).delete()
|
||||
self.stdout.write(f"Deleted {thread_count} thread(s) on root document.")
|
||||
|
||||
# Reset root document fields
|
||||
update_fields = {
|
||||
"excerpt": None,
|
||||
"link_reach": options["link_reach"],
|
||||
"link_role": options["link_role"],
|
||||
"attachments": [],
|
||||
}
|
||||
if options["title"] is not None:
|
||||
update_fields["title"] = options["title"]
|
||||
Document.objects.filter(id=document.id).update(**update_fields)
|
||||
|
||||
if options["title"] is not None:
|
||||
self.stdout.write(
|
||||
f'Reset fields on root document (title set to "{options["title"]}").'
|
||||
)
|
||||
else:
|
||||
self.stdout.write("Reset fields on root document (title kept).")
|
||||
|
||||
# Delete all descendants (cascades accesses and invitations)
|
||||
if descendants:
|
||||
deleted_count, _ = Document.objects.filter(
|
||||
id__in=descendant_ids
|
||||
).delete()
|
||||
self.stdout.write(f"Deleted {deleted_count} descendant(s).")
|
||||
|
||||
# Delete S3 content outside the transaction (S3 is not transactional)
|
||||
s3_client = default_storage.connection.meta.client
|
||||
bucket = default_storage.bucket_name
|
||||
|
||||
for doc in all_documents:
|
||||
try:
|
||||
s3_client.delete_object(Bucket=bucket, Key=doc.file_key)
|
||||
except ClientError:
|
||||
logger.warning("Failed to delete S3 file for document %s", doc.id)
|
||||
|
||||
self.stdout.write(f"Deleted S3 content for {len(all_documents)} document(s).")
|
||||
|
||||
for key in all_attachment_keys:
|
||||
try:
|
||||
s3_client.delete_object(Bucket=bucket, Key=key)
|
||||
except ClientError:
|
||||
logger.warning("Failed to delete S3 attachment %s", key)
|
||||
|
||||
self.stdout.write(f"Deleted {len(all_attachment_keys)} attachment(s) from S3.")
|
||||
self.stdout.write("Done.")
|
||||
@@ -1,178 +0,0 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-10 15:47
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0028_remove_templateaccess_template_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserReconciliationCsvImport",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"file",
|
||||
models.FileField(upload_to="imports/", verbose_name="CSV file"),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("running", "Running"),
|
||||
("done", "Done"),
|
||||
("error", "Error"),
|
||||
],
|
||||
default="pending",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("logs", models.TextField(blank=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "user reconciliation CSV import",
|
||||
"verbose_name_plural": "user reconciliation CSV imports",
|
||||
"db_table": "impress_user_reconciliation_csv_import",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserReconciliation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"active_email",
|
||||
models.EmailField(
|
||||
max_length=254, verbose_name="Active email address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"inactive_email",
|
||||
models.EmailField(
|
||||
max_length=254, verbose_name="Email address to deactivate"
|
||||
),
|
||||
),
|
||||
("active_email_checked", models.BooleanField(default=False)),
|
||||
("inactive_email_checked", models.BooleanField(default=False)),
|
||||
(
|
||||
"active_email_confirmation_id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, null=True, unique=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"inactive_email_confirmation_id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, null=True, unique=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"source_unique_id",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
max_length=100,
|
||||
null=True,
|
||||
verbose_name="Unique ID in the source file",
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("ready", "Ready"),
|
||||
("done", "Done"),
|
||||
("error", "Error"),
|
||||
],
|
||||
default="pending",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("logs", models.TextField(blank=True)),
|
||||
(
|
||||
"active_user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="active_user",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"inactive_user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="inactive_user",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "user reconciliation",
|
||||
"verbose_name_plural": "user reconciliations",
|
||||
"db_table": "impress_user_reconciliation",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
+8
-444
@@ -15,6 +15,7 @@ from django.contrib.auth import models as auth_models
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import mail
|
||||
from django.core.cache import cache
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
@@ -32,14 +33,14 @@ from rest_framework.exceptions import ValidationError
|
||||
from timezone_field import TimeZoneField
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
|
||||
|
||||
from core.choices import (
|
||||
from .choices import (
|
||||
PRIVILEGED_ROLES,
|
||||
LinkReachChoices,
|
||||
LinkRoleChoices,
|
||||
RoleChoices,
|
||||
get_equivalent_link_definition,
|
||||
)
|
||||
from core.validators import sub_validator
|
||||
from .validators import sub_validator
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -250,37 +251,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
|
||||
valid_invitations.delete()
|
||||
|
||||
def send_email(self, subject, context=None, language=None):
|
||||
"""Generate and send email to the user from a template."""
|
||||
emails = [self.email]
|
||||
context = context or {}
|
||||
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
|
||||
|
||||
language = language or get_language()
|
||||
context.update(
|
||||
{
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"domain": domain,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
}
|
||||
)
|
||||
|
||||
with override(language):
|
||||
msg_html = render_to_string("mail/html/template.html", context)
|
||||
msg_plain = render_to_string("mail/text/template.txt", context)
|
||||
subject = str(subject) # Force translation
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject.capitalize(),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
emails,
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("invitation to %s was not sent: %s", emails, exception)
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
"""Email this user."""
|
||||
if not self.email:
|
||||
raise ValueError("User has no email address.")
|
||||
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
|
||||
|
||||
@cached_property
|
||||
def teams(self):
|
||||
@@ -291,417 +266,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
return []
|
||||
|
||||
|
||||
class UserReconciliation(BaseModel):
|
||||
"""Model to run batch jobs to replace an active user by another one"""
|
||||
|
||||
active_email = models.EmailField(_("Active email address"))
|
||||
inactive_email = models.EmailField(_("Email address to deactivate"))
|
||||
active_email_checked = models.BooleanField(default=False)
|
||||
inactive_email_checked = models.BooleanField(default=False)
|
||||
active_user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="active_user",
|
||||
)
|
||||
inactive_user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="inactive_user",
|
||||
)
|
||||
active_email_confirmation_id = models.UUIDField(
|
||||
default=uuid.uuid4, unique=True, editable=False, null=True
|
||||
)
|
||||
inactive_email_confirmation_id = models.UUIDField(
|
||||
default=uuid.uuid4, unique=True, editable=False, null=True
|
||||
)
|
||||
source_unique_id = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Unique ID in the source file"),
|
||||
)
|
||||
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
("pending", _("Pending")),
|
||||
("ready", _("Ready")),
|
||||
("done", _("Done")),
|
||||
("error", _("Error")),
|
||||
],
|
||||
default="pending",
|
||||
)
|
||||
logs = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_user_reconciliation"
|
||||
verbose_name = _("user reconciliation")
|
||||
verbose_name_plural = _("user reconciliations")
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"Reconciliation from {self.inactive_email} to {self.active_email}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
For pending queries, identify the actual users and send validation emails
|
||||
"""
|
||||
if self.status == "pending":
|
||||
self.active_user = User.objects.filter(email=self.active_email).first()
|
||||
self.inactive_user = User.objects.filter(email=self.inactive_email).first()
|
||||
|
||||
if self.active_user and self.inactive_user:
|
||||
if not self.active_email_checked:
|
||||
self.send_reconciliation_confirm_email(
|
||||
self.active_user, "active", self.active_email_confirmation_id
|
||||
)
|
||||
if not self.inactive_email_checked:
|
||||
self.send_reconciliation_confirm_email(
|
||||
self.inactive_user,
|
||||
"inactive",
|
||||
self.inactive_email_confirmation_id,
|
||||
)
|
||||
self.status = "ready"
|
||||
else:
|
||||
self.status = "error"
|
||||
self.logs = "Error: Both active and inactive users need to exist."
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@transaction.atomic
|
||||
def process_reconciliation_request(self):
|
||||
"""
|
||||
Process the reconciliation request as a transaction.
|
||||
|
||||
- Transfer document accesses from inactive to active user, updating roles as needed.
|
||||
- Transfer document favorites from inactive to active user.
|
||||
- Transfer link traces from inactive to active user.
|
||||
- Transfer comment-related content from inactive to active user
|
||||
(threads, comments and reactions)
|
||||
- Activate the active user and deactivate the inactive user.
|
||||
- Update the reconciliation entry itself.
|
||||
"""
|
||||
|
||||
# Prepare the data to perform the reconciliation on
|
||||
updated_accesses, removed_accesses = (
|
||||
self.prepare_documentaccess_reconciliation()
|
||||
)
|
||||
updated_linktraces, removed_linktraces = self.prepare_linktrace_reconciliation()
|
||||
update_favorites, removed_favorites = (
|
||||
self.prepare_document_favorite_reconciliation()
|
||||
)
|
||||
updated_threads = self.prepare_thread_reconciliation()
|
||||
updated_comments = self.prepare_comment_reconciliation()
|
||||
updated_reactions, removed_reactions = self.prepare_reaction_reconciliation()
|
||||
|
||||
self.active_user.is_active = True
|
||||
self.inactive_user.is_active = False
|
||||
|
||||
# Actually perform the bulk operations
|
||||
DocumentAccess.objects.bulk_update(updated_accesses, ["user", "role"])
|
||||
|
||||
if removed_accesses:
|
||||
ids_to_delete = [entry.id for entry in removed_accesses]
|
||||
DocumentAccess.objects.filter(id__in=ids_to_delete).delete()
|
||||
|
||||
DocumentFavorite.objects.bulk_update(update_favorites, ["user"])
|
||||
if removed_favorites:
|
||||
ids_to_delete = [entry.id for entry in removed_favorites]
|
||||
DocumentFavorite.objects.filter(id__in=ids_to_delete).delete()
|
||||
|
||||
LinkTrace.objects.bulk_update(updated_linktraces, ["user"])
|
||||
if removed_linktraces:
|
||||
ids_to_delete = [entry.id for entry in removed_linktraces]
|
||||
LinkTrace.objects.filter(id__in=ids_to_delete).delete()
|
||||
|
||||
Thread.objects.bulk_update(updated_threads, ["creator"])
|
||||
Comment.objects.bulk_update(updated_comments, ["user"])
|
||||
|
||||
# pylint: disable=C0103
|
||||
ReactionThroughModel = Reaction.users.through
|
||||
reactions_to_create = []
|
||||
for updated_reaction in updated_reactions:
|
||||
reactions_to_create.append(
|
||||
ReactionThroughModel(
|
||||
user_id=self.active_user.pk, reaction_id=updated_reaction.pk
|
||||
)
|
||||
)
|
||||
|
||||
if reactions_to_create:
|
||||
ReactionThroughModel.objects.bulk_create(reactions_to_create)
|
||||
|
||||
if removed_reactions:
|
||||
ids_to_delete = [entry.id for entry in removed_reactions]
|
||||
ReactionThroughModel.objects.filter(
|
||||
reaction_id__in=ids_to_delete, user_id=self.inactive_user.pk
|
||||
).delete()
|
||||
|
||||
User.objects.bulk_update([self.active_user, self.inactive_user], ["is_active"])
|
||||
|
||||
# Wrap up the reconciliation entry
|
||||
self.logs += f"""Requested update for {len(updated_accesses)} DocumentAccess items
|
||||
and deletion for {len(removed_accesses)} DocumentAccess items.\n"""
|
||||
self.status = "done"
|
||||
self.save()
|
||||
|
||||
self.send_reconciliation_done_email()
|
||||
|
||||
def prepare_documentaccess_reconciliation(self):
|
||||
"""
|
||||
Prepare the reconciliation by transferring document accesses from the inactive user
|
||||
to the active user.
|
||||
"""
|
||||
updated_accesses = []
|
||||
removed_accesses = []
|
||||
inactive_accesses = DocumentAccess.objects.filter(user=self.inactive_user)
|
||||
|
||||
# Check documents where the active user already has access
|
||||
inactive_accesses_documents = inactive_accesses.values_list(
|
||||
"document", flat=True
|
||||
)
|
||||
existing_accesses = DocumentAccess.objects.filter(user=self.active_user).filter(
|
||||
document__in=inactive_accesses_documents
|
||||
)
|
||||
existing_roles_per_doc = dict(existing_accesses.values_list("document", "role"))
|
||||
|
||||
for entry in inactive_accesses:
|
||||
if entry.document_id in existing_roles_per_doc:
|
||||
# Update role if needed
|
||||
existing_role = existing_roles_per_doc[entry.document_id]
|
||||
max_role = RoleChoices.max(entry.role, existing_role)
|
||||
if existing_role != max_role:
|
||||
existing_access = existing_accesses.get(document=entry.document)
|
||||
existing_access.role = max_role
|
||||
updated_accesses.append(existing_access)
|
||||
removed_accesses.append(entry)
|
||||
else:
|
||||
entry.user = self.active_user
|
||||
updated_accesses.append(entry)
|
||||
|
||||
return updated_accesses, removed_accesses
|
||||
|
||||
def prepare_document_favorite_reconciliation(self):
|
||||
"""
|
||||
Prepare the reconciliation by transferring document favorites from the inactive user
|
||||
to the active user.
|
||||
"""
|
||||
updated_favorites = []
|
||||
removed_favorites = []
|
||||
|
||||
existing_favorites = DocumentFavorite.objects.filter(user=self.active_user)
|
||||
existing_favorite_doc_ids = set(
|
||||
existing_favorites.values_list("document_id", flat=True)
|
||||
)
|
||||
|
||||
inactive_favorites = DocumentFavorite.objects.filter(user=self.inactive_user)
|
||||
|
||||
for entry in inactive_favorites:
|
||||
if entry.document_id in existing_favorite_doc_ids:
|
||||
removed_favorites.append(entry)
|
||||
else:
|
||||
entry.user = self.active_user
|
||||
updated_favorites.append(entry)
|
||||
|
||||
return updated_favorites, removed_favorites
|
||||
|
||||
def prepare_linktrace_reconciliation(self):
|
||||
"""
|
||||
Prepare the reconciliation by transferring link traces from the inactive user
|
||||
to the active user.
|
||||
"""
|
||||
updated_linktraces = []
|
||||
removed_linktraces = []
|
||||
|
||||
existing_linktraces = LinkTrace.objects.filter(user=self.active_user)
|
||||
inactive_linktraces = LinkTrace.objects.filter(user=self.inactive_user)
|
||||
|
||||
for entry in inactive_linktraces:
|
||||
if existing_linktraces.filter(document=entry.document).exists():
|
||||
removed_linktraces.append(entry)
|
||||
else:
|
||||
entry.user = self.active_user
|
||||
updated_linktraces.append(entry)
|
||||
|
||||
return updated_linktraces, removed_linktraces
|
||||
|
||||
def prepare_thread_reconciliation(self):
|
||||
"""
|
||||
Prepare the reconciliation by transferring threads from the inactive user
|
||||
to the active user.
|
||||
"""
|
||||
updated_threads = []
|
||||
|
||||
inactive_threads = Thread.objects.filter(creator=self.inactive_user)
|
||||
|
||||
for entry in inactive_threads:
|
||||
entry.creator = self.active_user
|
||||
updated_threads.append(entry)
|
||||
|
||||
return updated_threads
|
||||
|
||||
def prepare_comment_reconciliation(self):
|
||||
"""
|
||||
Prepare the reconciliation by transferring comments from the inactive user
|
||||
to the active user.
|
||||
"""
|
||||
updated_comments = []
|
||||
|
||||
inactive_comments = Comment.objects.filter(user=self.inactive_user)
|
||||
|
||||
for entry in inactive_comments:
|
||||
entry.user = self.active_user
|
||||
updated_comments.append(entry)
|
||||
|
||||
return updated_comments
|
||||
|
||||
def prepare_reaction_reconciliation(self):
|
||||
"""
|
||||
Prepare the reconciliation by creating missing reactions for the active user
|
||||
(ie, the ones that exist for the inactive user but not the active user)
|
||||
and then deleting all reactions of the inactive user.
|
||||
"""
|
||||
|
||||
inactive_reactions = Reaction.objects.filter(users=self.inactive_user)
|
||||
updated_reactions = inactive_reactions.exclude(users=self.active_user)
|
||||
|
||||
return updated_reactions, inactive_reactions
|
||||
|
||||
def send_reconciliation_confirm_email(
|
||||
self, user, user_type, confirmation_id, language=None
|
||||
):
|
||||
"""Method allowing to send confirmation email for reconciliation requests."""
|
||||
language = language or get_language()
|
||||
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
|
||||
|
||||
message = _(
|
||||
"""You have requested a reconciliation of your user accounts on Docs.
|
||||
To confirm that you are the one who initiated the request
|
||||
and that this email belongs to you:"""
|
||||
)
|
||||
|
||||
with override(language):
|
||||
subject = _("Confirm by clicking the link to start the reconciliation")
|
||||
context = {
|
||||
"title": subject,
|
||||
"message": message,
|
||||
"link": f"{domain}/user-reconciliations/{user_type}/{confirmation_id}/",
|
||||
"link_label": str(_("Click here")),
|
||||
"button_label": str(_("Confirm")),
|
||||
}
|
||||
|
||||
user.send_email(subject, context, language)
|
||||
|
||||
def send_reconciliation_done_email(self, language=None):
|
||||
"""Method allowing to send done email for reconciliation requests."""
|
||||
language = language or get_language()
|
||||
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
|
||||
|
||||
message = _(
|
||||
"""Your reconciliation request has been processed.
|
||||
New documents are likely associated with your account:"""
|
||||
)
|
||||
|
||||
with override(language):
|
||||
subject = _("Your accounts have been merged")
|
||||
context = {
|
||||
"title": subject,
|
||||
"message": message,
|
||||
"link": f"{domain}/",
|
||||
"link_label": str(_("Click here to see")),
|
||||
"button_label": str(_("See my documents")),
|
||||
}
|
||||
|
||||
self.active_user.send_email(subject, context, language)
|
||||
|
||||
|
||||
class UserReconciliationCsvImport(BaseModel):
|
||||
"""Model to import reconciliations requests from an external source
|
||||
(eg, )"""
|
||||
|
||||
file = models.FileField(upload_to="imports/", verbose_name=_("CSV file"))
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
("pending", _("Pending")),
|
||||
("running", _("Running")),
|
||||
("done", _("Done")),
|
||||
("error", _("Error")),
|
||||
],
|
||||
default="pending",
|
||||
)
|
||||
logs = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_user_reconciliation_csv_import"
|
||||
verbose_name = _("user reconciliation CSV import")
|
||||
verbose_name_plural = _("user reconciliation CSV imports")
|
||||
|
||||
def __str__(self):
|
||||
return f"User reconciliation CSV import {self.id}"
|
||||
|
||||
def send_email(self, subject, emails, context=None, language=None):
|
||||
"""Generate and send email to the user from a template."""
|
||||
context = context or {}
|
||||
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
|
||||
language = language or get_language()
|
||||
context.update(
|
||||
{
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"domain": domain,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
}
|
||||
)
|
||||
|
||||
with override(language):
|
||||
msg_html = render_to_string("mail/html/template.html", context)
|
||||
msg_plain = render_to_string("mail/text/template.txt", context)
|
||||
subject = str(subject) # Force translation
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject.capitalize(),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
emails,
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("invitation to %s was not sent: %s", emails, exception)
|
||||
|
||||
def send_reconciliation_error_email(
|
||||
self, recipient_email, other_email, language=None
|
||||
):
|
||||
"""Method allowing to send email for reconciliation requests with errors."""
|
||||
language = language or get_language()
|
||||
|
||||
emails = [recipient_email]
|
||||
|
||||
message = _(
|
||||
"""Your request for reconciliation was unsuccessful.
|
||||
Reconciliation failed for the following email addresses:
|
||||
{recipient_email}, {other_email}.
|
||||
Please check for typos.
|
||||
You can submit another request with the valid email addresses."""
|
||||
).format(recipient_email=recipient_email, other_email=other_email)
|
||||
|
||||
with override(language):
|
||||
subject = _("Reconciliation of your Docs accounts not completed")
|
||||
context = {
|
||||
"title": subject,
|
||||
"message": message,
|
||||
"link": settings.USER_RECONCILIATION_FORM_URL,
|
||||
"link_label": str(_("Click here")),
|
||||
"button_label": str(_("Make a new request")),
|
||||
}
|
||||
|
||||
self.send_email(subject, emails, context, language)
|
||||
|
||||
|
||||
class BaseAccess(BaseModel):
|
||||
"""Base model for accesses to handle resources."""
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ from functools import cache
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import Subquery
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import requests
|
||||
|
||||
from core import models, utils
|
||||
from core.enums import SearchType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,7 +69,7 @@ def get_batch_accesses_by_users_and_teams(paths):
|
||||
return dict(access_by_document_path)
|
||||
|
||||
|
||||
def get_visited_document_ids_of(queryset, user):
|
||||
def get_visited_document_ids_of(queryset, user) -> tuple[str, ...]:
|
||||
"""
|
||||
Returns the ids of the documents that have a linktrace to the user and NOT owned.
|
||||
It will be use to limit the opensearch responses to the public documents already
|
||||
@@ -78,7 +78,9 @@ def get_visited_document_ids_of(queryset, user):
|
||||
if isinstance(user, AnonymousUser):
|
||||
return []
|
||||
|
||||
qs = models.LinkTrace.objects.filter(user=user)
|
||||
visited_ids = models.LinkTrace.objects.filter(user=user).values_list(
|
||||
"document_id", flat=True
|
||||
)
|
||||
|
||||
docs = (
|
||||
queryset.exclude(accesses__user=user)
|
||||
@@ -86,12 +88,12 @@ def get_visited_document_ids_of(queryset, user):
|
||||
deleted_at__isnull=True,
|
||||
ancestors_deleted_at__isnull=True,
|
||||
)
|
||||
.filter(pk__in=Subquery(qs.values("document_id")))
|
||||
.filter(pk__in=visited_ids)
|
||||
.order_by("pk")
|
||||
.distinct("pk")
|
||||
)
|
||||
|
||||
return [str(id) for id in docs.values_list("pk", flat=True)]
|
||||
return tuple(str(id) for id in docs.values_list("pk", flat=True))
|
||||
|
||||
|
||||
class BaseDocumentIndexer(ABC):
|
||||
@@ -107,15 +109,13 @@ class BaseDocumentIndexer(ABC):
|
||||
Initialize the indexer.
|
||||
"""
|
||||
self.batch_size = settings.SEARCH_INDEXER_BATCH_SIZE
|
||||
self.indexer_url = settings.SEARCH_INDEXER_URL
|
||||
self.indexer_url = settings.INDEXING_URL
|
||||
self.indexer_secret = settings.SEARCH_INDEXER_SECRET
|
||||
self.search_url = settings.SEARCH_INDEXER_QUERY_URL
|
||||
self.search_url = settings.SEARCH_URL
|
||||
self.search_limit = settings.SEARCH_INDEXER_QUERY_LIMIT
|
||||
|
||||
if not self.indexer_url:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_URL must be set in Django settings."
|
||||
)
|
||||
raise ImproperlyConfigured("INDEXING_URL must be set in Django settings.")
|
||||
|
||||
if not self.indexer_secret:
|
||||
raise ImproperlyConfigured(
|
||||
@@ -123,9 +123,7 @@ class BaseDocumentIndexer(ABC):
|
||||
)
|
||||
|
||||
if not self.search_url:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_QUERY_URL must be set in Django settings."
|
||||
)
|
||||
raise ImproperlyConfigured("SEARCH_URL must be set in Django settings.")
|
||||
|
||||
def index(self, queryset=None, batch_size=None):
|
||||
"""
|
||||
@@ -184,8 +182,16 @@ class BaseDocumentIndexer(ABC):
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
||||
def search(self, text, token, visited=(), nb_results=None):
|
||||
# pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
def search( # noqa : PLR0913
|
||||
self,
|
||||
q: str,
|
||||
token: str,
|
||||
visited: tuple[str, ...] = (),
|
||||
nb_results: int = None,
|
||||
path: str = None,
|
||||
search_type: SearchType = None,
|
||||
):
|
||||
"""
|
||||
Search for documents in Find app.
|
||||
Ensure the same default ordering as "Docs" list : -updated_at
|
||||
@@ -193,7 +199,7 @@ class BaseDocumentIndexer(ABC):
|
||||
Returns ids of the documents
|
||||
|
||||
Args:
|
||||
text (str): Text search content.
|
||||
q (str): user query.
|
||||
token (str): OIDC Authentication token.
|
||||
visited (list, optional):
|
||||
List of ids of active public documents with LinkTrace
|
||||
@@ -201,21 +207,27 @@ class BaseDocumentIndexer(ABC):
|
||||
nb_results (int, optional):
|
||||
The number of results to return.
|
||||
Defaults to 50 if not specified.
|
||||
path (str, optional):
|
||||
The parent path to search descendants of.
|
||||
search_type:
|
||||
hybrid or full-text search
|
||||
"""
|
||||
nb_results = nb_results or self.search_limit
|
||||
response = self.search_query(
|
||||
results = self.search_query(
|
||||
data={
|
||||
"q": text,
|
||||
"q": q,
|
||||
"visited": visited,
|
||||
"services": ["docs"],
|
||||
"nb_results": nb_results,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
"path": path,
|
||||
"search_type": search_type,
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
|
||||
return [d["_id"] for d in response]
|
||||
return results
|
||||
|
||||
@abstractmethod
|
||||
def search_query(self, data, token) -> dict:
|
||||
@@ -226,11 +238,76 @@ class BaseDocumentIndexer(ABC):
|
||||
"""
|
||||
|
||||
|
||||
class SearchIndexer(BaseDocumentIndexer):
|
||||
class FindDocumentIndexer(BaseDocumentIndexer):
|
||||
"""
|
||||
Document indexer that pushes documents to La Suite Find app.
|
||||
Document indexer that indexes and searches documents with La Suite Find app.
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
def search( # noqa : PLR0913
|
||||
self,
|
||||
q: str,
|
||||
token: str,
|
||||
visited: tuple[()] = (),
|
||||
nb_results: int = None,
|
||||
path: str = None,
|
||||
search_type: SearchType = None,
|
||||
):
|
||||
"""format Find search results"""
|
||||
search_results = (
|
||||
super()
|
||||
.search(
|
||||
q=q,
|
||||
token=token,
|
||||
visited=visited,
|
||||
nb_results=nb_results,
|
||||
path=path,
|
||||
search_type=search_type,
|
||||
)
|
||||
.get("results", [])
|
||||
)
|
||||
return [
|
||||
{
|
||||
**hit["_source"],
|
||||
"id": hit["_id"],
|
||||
"title": self.get_title(hit["_source"]),
|
||||
}
|
||||
for hit in search_results
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_title(source):
|
||||
"""
|
||||
Find returns the titles with an extension depending on the language.
|
||||
This function extracts the title in a generic way.
|
||||
|
||||
Handles multiple cases:
|
||||
- Localized title fields like "title.<some_extension>"
|
||||
- Fallback to plain "title" field if localized version not found
|
||||
- Returns empty string if no title field exists
|
||||
|
||||
Args:
|
||||
source (dict): The _source dictionary from a search hit
|
||||
|
||||
Returns:
|
||||
str: The extracted title or empty string if not found
|
||||
|
||||
Example:
|
||||
>>> get_title({"title.fr": "Bonjour", "id": 1})
|
||||
"Bonjour"
|
||||
>>> get_title({"title": "Hello", "id": 1})
|
||||
"Hello"
|
||||
>>> get_title({"id": 1})
|
||||
""
|
||||
"""
|
||||
titles = utils.get_value_by_pattern(source, r"^title\.")
|
||||
for title in titles:
|
||||
if title:
|
||||
return title
|
||||
if "title" in source:
|
||||
return source["title"]
|
||||
return ""
|
||||
|
||||
def serialize_document(self, document, accesses):
|
||||
"""
|
||||
Convert a Document to the JSON format expected by La Suite Find.
|
||||
|
||||
@@ -4,14 +4,12 @@ Declare and configure the signals for the impress core application
|
||||
|
||||
from functools import partial
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
from django.db.models import signals
|
||||
from django.dispatch import receiver
|
||||
|
||||
from core import models
|
||||
from core.tasks.search import trigger_batch_document_indexer
|
||||
from core.utils import get_users_sharing_documents_with_cache_key
|
||||
from . import models
|
||||
from .tasks.search import trigger_batch_document_indexer
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.Document)
|
||||
@@ -28,24 +26,8 @@ def document_post_save(sender, instance, **kwargs): # pylint: disable=unused-ar
|
||||
def document_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Asynchronous call to the document indexer at the end of the transaction.
|
||||
Clear cache for the affected user.
|
||||
"""
|
||||
if not created:
|
||||
transaction.on_commit(
|
||||
partial(trigger_batch_document_indexer, instance.document)
|
||||
)
|
||||
|
||||
# Invalidate cache for the user
|
||||
if instance.user:
|
||||
cache_key = get_users_sharing_documents_with_cache_key(instance.user)
|
||||
cache.delete(cache_key)
|
||||
|
||||
|
||||
@receiver(signals.post_delete, sender=models.DocumentAccess)
|
||||
def document_access_post_delete(sender, instance, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Clear cache for the affected user when document access is deleted.
|
||||
"""
|
||||
if instance.user:
|
||||
cache_key = get_users_sharing_documents_with_cache_key(instance.user)
|
||||
cache.delete(cache_key)
|
||||
|
||||
@@ -63,7 +63,7 @@ def batch_document_indexer_task(timestamp):
|
||||
logger.info("Indexed %d documents", count)
|
||||
|
||||
|
||||
def trigger_batch_document_indexer(item):
|
||||
def trigger_batch_document_indexer(document):
|
||||
"""
|
||||
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
|
||||
|
||||
@@ -82,14 +82,14 @@ def trigger_batch_document_indexer(item):
|
||||
if batch_indexer_throttle_acquire(timeout=countdown):
|
||||
logger.info(
|
||||
"Add task for batch document indexation from updated_at=%s in %d seconds",
|
||||
item.updated_at.isoformat(),
|
||||
document.updated_at.isoformat(),
|
||||
countdown,
|
||||
)
|
||||
|
||||
batch_document_indexer_task.apply_async(
|
||||
args=[item.updated_at], countdown=countdown
|
||||
args=[document.updated_at], countdown=countdown
|
||||
)
|
||||
else:
|
||||
logger.info("Skip task for batch document %s indexation", item.pk)
|
||||
logger.info("Skip task for batch document %s indexation", document.pk)
|
||||
else:
|
||||
document_indexer_task.apply(args=[item.pk])
|
||||
document_indexer_task.apply(args=[document.pk])
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
"""Processing tasks for user reconciliation CSV imports."""
|
||||
|
||||
import csv
|
||||
import traceback
|
||||
import uuid
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.db import IntegrityError
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from core.models import UserReconciliation, UserReconciliationCsvImport
|
||||
|
||||
from impress.celery_app import app
|
||||
|
||||
|
||||
def _process_row(row, job, counters):
|
||||
"""Process a single row from the CSV file."""
|
||||
|
||||
source_unique_id = row["id"].strip()
|
||||
|
||||
# Skip entries if they already exist with this source_unique_id
|
||||
if UserReconciliation.objects.filter(source_unique_id=source_unique_id).exists():
|
||||
counters["already_processed_source_ids"] += 1
|
||||
return counters
|
||||
|
||||
active_email_checked = row.get("active_email_checked", "0") == "1"
|
||||
inactive_email_checked = row.get("inactive_email_checked", "0") == "1"
|
||||
|
||||
active_email = row["active_email"]
|
||||
inactive_emails = row["inactive_email"].split("|")
|
||||
try:
|
||||
validate_email(active_email)
|
||||
except ValidationError:
|
||||
job.send_reconciliation_error_email(
|
||||
recipient_email=inactive_emails[0], other_email=active_email
|
||||
)
|
||||
job.logs += f"Invalid active email address on row {source_unique_id}."
|
||||
counters["rows_with_errors"] += 1
|
||||
return counters
|
||||
|
||||
for inactive_email in inactive_emails:
|
||||
try:
|
||||
validate_email(inactive_email)
|
||||
except (ValidationError, ValueError):
|
||||
job.send_reconciliation_error_email(
|
||||
recipient_email=active_email, other_email=inactive_email
|
||||
)
|
||||
job.logs += f"Invalid inactive email address on row {source_unique_id}.\n"
|
||||
counters["rows_with_errors"] += 1
|
||||
continue
|
||||
|
||||
if inactive_email == active_email:
|
||||
job.send_reconciliation_error_email(
|
||||
recipient_email=active_email, other_email=inactive_email
|
||||
)
|
||||
job.logs += (
|
||||
f"Error on row {source_unique_id}: "
|
||||
f"{active_email} set as both active and inactive email.\n"
|
||||
)
|
||||
counters["rows_with_errors"] += 1
|
||||
continue
|
||||
|
||||
_rec_entry = UserReconciliation.objects.create(
|
||||
active_email=active_email,
|
||||
inactive_email=inactive_email,
|
||||
active_email_checked=active_email_checked,
|
||||
inactive_email_checked=inactive_email_checked,
|
||||
active_email_confirmation_id=uuid.uuid4(),
|
||||
inactive_email_confirmation_id=uuid.uuid4(),
|
||||
source_unique_id=source_unique_id,
|
||||
status="pending",
|
||||
)
|
||||
counters["rec_entries_created"] += 1
|
||||
|
||||
return counters
|
||||
|
||||
|
||||
@app.task
|
||||
def user_reconciliation_csv_import_job(job_id):
|
||||
"""Process a UserReconciliationCsvImport job.
|
||||
Creates UserReconciliation entries from the CSV file.
|
||||
|
||||
Does some sanity checks on the data:
|
||||
- active_email and inactive_email must be valid email addresses
|
||||
- active_email and inactive_email cannot be the same
|
||||
|
||||
Rows with errors are logged in the job logs and skipped, but do not cause
|
||||
the entire job to fail or prevent the next rows from being processed.
|
||||
"""
|
||||
# Imports the CSV file, breaks it into UserReconciliation items
|
||||
job = UserReconciliationCsvImport.objects.get(id=job_id)
|
||||
job.status = "running"
|
||||
job.save()
|
||||
|
||||
counters = {
|
||||
"rec_entries_created": 0,
|
||||
"rows_with_errors": 0,
|
||||
"already_processed_source_ids": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
with job.file.open(mode="r") as f:
|
||||
reader = csv.DictReader(f)
|
||||
|
||||
if not {"active_email", "inactive_email", "id"}.issubset(reader.fieldnames):
|
||||
raise KeyError(
|
||||
"CSV is missing mandatory columns: active_email, inactive_email, id"
|
||||
)
|
||||
|
||||
for row in reader:
|
||||
counters = _process_row(row, job, counters)
|
||||
|
||||
job.status = "done"
|
||||
job.logs += (
|
||||
f"Import completed successfully. {reader.line_num} rows processed."
|
||||
f" {counters['rec_entries_created']} reconciliation entries created."
|
||||
f" {counters['already_processed_source_ids']} rows were already processed."
|
||||
f" {counters['rows_with_errors']} rows had errors."
|
||||
)
|
||||
except (
|
||||
csv.Error,
|
||||
KeyError,
|
||||
ValidationError,
|
||||
ValueError,
|
||||
IntegrityError,
|
||||
OSError,
|
||||
ClientError,
|
||||
) as e:
|
||||
# Catch expected I/O/CSV/model errors and record traceback in logs for debugging
|
||||
job.status = "error"
|
||||
job.logs += f"{e!s}\n{traceback.format_exc()}"
|
||||
finally:
|
||||
job.save()
|
||||
@@ -1,313 +0,0 @@
|
||||
"""Unit tests for the `clean_document` management command."""
|
||||
|
||||
import random
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.management import CommandError, call_command
|
||||
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from core import choices, factories, models
|
||||
from core.choices import LinkReachChoices, LinkRoleChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_clean_document_with_descendants(settings):
|
||||
"""The command should reset the root (keeping title) and delete descendants."""
|
||||
settings.DEBUG = True
|
||||
|
||||
# Create a root document with subdocuments
|
||||
root = factories.DocumentFactory(
|
||||
title="Root",
|
||||
link_reach=LinkReachChoices.PUBLIC,
|
||||
link_role=LinkRoleChoices.EDITOR,
|
||||
)
|
||||
child = factories.DocumentFactory(
|
||||
parent=root,
|
||||
title="Child",
|
||||
link_reach=LinkReachChoices.AUTHENTICATED,
|
||||
link_role=LinkRoleChoices.EDITOR,
|
||||
)
|
||||
grandchild = factories.DocumentFactory(
|
||||
parent=child,
|
||||
title="Grandchild",
|
||||
)
|
||||
|
||||
# Create accesses and invitations
|
||||
factories.UserDocumentAccessFactory.create_batch(
|
||||
5,
|
||||
document=root,
|
||||
role=random.choice(
|
||||
[
|
||||
role
|
||||
for role in choices.RoleChoices
|
||||
if role not in choices.PRIVILEGED_ROLES
|
||||
],
|
||||
),
|
||||
)
|
||||
# One owner role
|
||||
factories.UserDocumentAccessFactory(document=root, role=choices.RoleChoices.OWNER)
|
||||
factories.UserDocumentAccessFactory(document=child)
|
||||
factories.InvitationFactory(document=root)
|
||||
factories.InvitationFactory(document=child)
|
||||
factories.ThreadFactory.create_batch(5, document=root)
|
||||
|
||||
assert models.Invitation.objects.filter(document=root).exists()
|
||||
assert models.Thread.objects.filter(document=root).exists()
|
||||
assert models.DocumentAccess.objects.filter(document=root).exists()
|
||||
|
||||
with mock.patch(
|
||||
"core.management.commands.clean_document.default_storage"
|
||||
) as mock_storage:
|
||||
call_command("clean_document", str(root.id), "--force")
|
||||
|
||||
# Root document should still exist with title kept and other fields reset
|
||||
root.refresh_from_db()
|
||||
assert root.title == "Root"
|
||||
assert root.excerpt is None
|
||||
assert root.link_reach == LinkReachChoices.RESTRICTED
|
||||
assert root.link_role == LinkRoleChoices.READER
|
||||
assert root.attachments == []
|
||||
|
||||
# Accesses and invitations on root should be deleted. Only owner should be kept
|
||||
keeping_accesses = list(models.DocumentAccess.objects.filter(document=root))
|
||||
assert len(keeping_accesses) == 1
|
||||
assert keeping_accesses[0].role == models.RoleChoices.OWNER
|
||||
assert not models.Invitation.objects.filter(document=root).exists()
|
||||
assert not models.Thread.objects.filter(document=root).exists()
|
||||
|
||||
# Descendants should be deleted entirely
|
||||
assert not models.Document.objects.filter(id__in=[child.id, grandchild.id]).exists()
|
||||
|
||||
# Root should have no descendants
|
||||
root.refresh_from_db()
|
||||
assert root.get_descendants().count() == 0
|
||||
|
||||
# S3 delete should have been called for document files + attachments
|
||||
delete_calls = mock_storage.connection.meta.client.delete_object.call_args_list
|
||||
assert len(delete_calls) == 3
|
||||
|
||||
|
||||
def test_clean_document_invalid_uuid(settings):
|
||||
"""The command should raise an error for a non-existent document."""
|
||||
settings.DEBUG = True
|
||||
|
||||
fake_id = str(uuid4())
|
||||
with pytest.raises(CommandError, match=f"Document {fake_id} does not exist."):
|
||||
call_command("clean_document", fake_id, "--force")
|
||||
|
||||
|
||||
def test_clean_document_no_force_in_production(settings):
|
||||
"""The command should require --force when DEBUG is False."""
|
||||
settings.DEBUG = False
|
||||
|
||||
doc = factories.DocumentFactory()
|
||||
with pytest.raises(CommandError, match="not meant to be used in production"):
|
||||
call_command("clean_document", str(doc.id))
|
||||
|
||||
|
||||
def test_clean_document_single_document(settings):
|
||||
"""The command should work on a single document without children."""
|
||||
settings.DEBUG = True
|
||||
|
||||
doc = factories.DocumentFactory(
|
||||
title="Single",
|
||||
link_reach=LinkReachChoices.PUBLIC,
|
||||
link_role=LinkRoleChoices.EDITOR,
|
||||
)
|
||||
factories.UserDocumentAccessFactory.create_batch(
|
||||
5,
|
||||
document=doc,
|
||||
role=random.choice(
|
||||
[
|
||||
role
|
||||
for role in choices.RoleChoices
|
||||
if role not in choices.PRIVILEGED_ROLES
|
||||
],
|
||||
),
|
||||
)
|
||||
# One owner role
|
||||
factories.UserDocumentAccessFactory(document=doc, role=choices.RoleChoices.OWNER)
|
||||
factories.ThreadFactory.create_batch(5, document=doc)
|
||||
factories.InvitationFactory(document=doc)
|
||||
|
||||
with mock.patch(
|
||||
"core.management.commands.clean_document.default_storage"
|
||||
) as mock_storage:
|
||||
call_command("clean_document", str(doc.id), "--force")
|
||||
|
||||
# Accesses and invitations on root should be deleted. Only owner should be kept
|
||||
keeping_accesses = list(models.DocumentAccess.objects.filter(document=doc))
|
||||
assert len(keeping_accesses) == 1
|
||||
assert keeping_accesses[0].role == models.RoleChoices.OWNER
|
||||
assert not models.Invitation.objects.filter(document=doc).exists()
|
||||
assert not models.Thread.objects.filter(document=doc).exists()
|
||||
|
||||
doc.refresh_from_db()
|
||||
assert doc.title == "Single"
|
||||
assert doc.excerpt is None
|
||||
assert doc.link_reach == LinkReachChoices.RESTRICTED
|
||||
assert doc.link_role == LinkRoleChoices.READER
|
||||
assert doc.attachments == []
|
||||
|
||||
mock_storage.connection.meta.client.delete_object.assert_called_once()
|
||||
|
||||
|
||||
def test_clean_document_with_title_option(settings):
|
||||
"""The --title option should update the document title."""
|
||||
settings.DEBUG = True
|
||||
|
||||
doc = factories.DocumentFactory(
|
||||
title="Old Title",
|
||||
link_reach=LinkReachChoices.PUBLIC,
|
||||
link_role=LinkRoleChoices.EDITOR,
|
||||
)
|
||||
|
||||
with mock.patch("core.management.commands.clean_document.default_storage"):
|
||||
call_command("clean_document", str(doc.id), "--force", "--title", "New Title")
|
||||
|
||||
doc.refresh_from_db()
|
||||
assert doc.title == "New Title"
|
||||
assert doc.excerpt is None
|
||||
assert doc.link_reach == LinkReachChoices.RESTRICTED
|
||||
assert doc.link_role == LinkRoleChoices.READER
|
||||
assert doc.attachments == []
|
||||
|
||||
|
||||
def test_clean_document_deletes_attachments_from_s3(settings):
|
||||
"""The command should delete attachment files from S3."""
|
||||
settings.DEBUG = True
|
||||
|
||||
root = factories.DocumentFactory(
|
||||
attachments=["root-id/attachments/file1.png", "root-id/attachments/file2.pdf"],
|
||||
)
|
||||
child = factories.DocumentFactory(
|
||||
parent=root,
|
||||
attachments=["child-id/attachments/file3.png"],
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"core.management.commands.clean_document.default_storage"
|
||||
) as mock_storage:
|
||||
call_command("clean_document", str(root.id), "--force")
|
||||
|
||||
delete_calls = mock_storage.connection.meta.client.delete_object.call_args_list
|
||||
deleted_keys = [call.kwargs["Key"] for call in delete_calls]
|
||||
|
||||
# Document files (root + child)
|
||||
assert root.file_key in deleted_keys
|
||||
assert child.file_key in deleted_keys
|
||||
|
||||
# Attachment files
|
||||
assert "root-id/attachments/file1.png" in deleted_keys
|
||||
assert "root-id/attachments/file2.pdf" in deleted_keys
|
||||
assert "child-id/attachments/file3.png" in deleted_keys
|
||||
|
||||
assert len(delete_calls) == 5
|
||||
|
||||
|
||||
def test_clean_document_s3_errors_do_not_stop_command(settings):
|
||||
"""S3 deletion errors should be logged but not stop the command."""
|
||||
settings.DEBUG = True
|
||||
|
||||
doc = factories.DocumentFactory(
|
||||
attachments=["doc-id/attachments/file1.png"],
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"core.management.commands.clean_document.default_storage"
|
||||
) as mock_storage:
|
||||
mock_storage.connection.meta.client.delete_object.side_effect = ClientError(
|
||||
{"Error": {"Code": "500", "Message": "Internal Error"}},
|
||||
"DeleteObject",
|
||||
)
|
||||
# Command should complete without raising
|
||||
call_command("clean_document", str(doc.id), "--force")
|
||||
|
||||
|
||||
def test_clean_document_with_options(settings):
|
||||
"""Run the command using optional argument link_reach and link_role."""
|
||||
|
||||
settings.DEBUG = True
|
||||
|
||||
# Create a root document with subdocuments
|
||||
root = factories.DocumentFactory(
|
||||
title="Root",
|
||||
link_reach=LinkReachChoices.PUBLIC,
|
||||
link_role=LinkRoleChoices.READER,
|
||||
)
|
||||
child = factories.DocumentFactory(
|
||||
parent=root,
|
||||
title="Child",
|
||||
link_reach=LinkReachChoices.AUTHENTICATED,
|
||||
link_role=LinkRoleChoices.EDITOR,
|
||||
)
|
||||
grandchild = factories.DocumentFactory(
|
||||
parent=child,
|
||||
title="Grandchild",
|
||||
)
|
||||
|
||||
# Create accesses and invitations
|
||||
factories.UserDocumentAccessFactory.create_batch(
|
||||
5,
|
||||
document=root,
|
||||
role=random.choice(
|
||||
[
|
||||
role
|
||||
for role in choices.RoleChoices
|
||||
if role not in choices.PRIVILEGED_ROLES
|
||||
],
|
||||
),
|
||||
)
|
||||
# One owner role
|
||||
factories.UserDocumentAccessFactory(document=root, role=choices.RoleChoices.OWNER)
|
||||
factories.UserDocumentAccessFactory(document=child)
|
||||
factories.InvitationFactory(document=root)
|
||||
factories.InvitationFactory(document=child)
|
||||
factories.ThreadFactory.create_batch(5, document=root)
|
||||
|
||||
assert models.Invitation.objects.filter(document=root).exists()
|
||||
assert models.Thread.objects.filter(document=root).exists()
|
||||
assert models.DocumentAccess.objects.filter(document=root).exists()
|
||||
|
||||
with mock.patch(
|
||||
"core.management.commands.clean_document.default_storage"
|
||||
) as mock_storage:
|
||||
call_command(
|
||||
"clean_document",
|
||||
str(root.id),
|
||||
"--force",
|
||||
"--link_reach",
|
||||
"public",
|
||||
"--link_role",
|
||||
"editor",
|
||||
)
|
||||
|
||||
# Root document should still exist with title kept and other fields reset
|
||||
root.refresh_from_db()
|
||||
assert root.title == "Root"
|
||||
assert root.excerpt is None
|
||||
assert root.link_reach == LinkReachChoices.PUBLIC
|
||||
assert root.link_role == LinkRoleChoices.EDITOR
|
||||
assert root.attachments == []
|
||||
|
||||
# Accesses and invitations on root should be deleted. Only owner should be kept
|
||||
keeping_accesses = list(models.DocumentAccess.objects.filter(document=root))
|
||||
assert len(keeping_accesses) == 1
|
||||
assert keeping_accesses[0].role == models.RoleChoices.OWNER
|
||||
assert not models.Invitation.objects.filter(document=root).exists()
|
||||
assert not models.Thread.objects.filter(document=root).exists()
|
||||
|
||||
# Descendants should be deleted entirely
|
||||
assert not models.Document.objects.filter(id__in=[child.id, grandchild.id]).exists()
|
||||
|
||||
# Root should have no descendants
|
||||
root.refresh_from_db()
|
||||
assert root.get_descendants().count() == 0
|
||||
|
||||
# S3 delete should have been called for document files + attachments
|
||||
delete_calls = mock_storage.connection.meta.client.delete_object.call_args_list
|
||||
assert len(delete_calls) == 3
|
||||
@@ -11,7 +11,7 @@ from django.db import transaction
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
from core.services.search_indexers import SearchIndexer
|
||||
from core.services.search_indexers import FindDocumentIndexer
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -19,7 +19,7 @@ from core.services.search_indexers import SearchIndexer
|
||||
def test_index():
|
||||
"""Test the command `index` that run the Find app indexer for all the available documents."""
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
@@ -36,7 +36,7 @@ def test_index():
|
||||
str(no_title_doc.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
call_command("index")
|
||||
|
||||
push_call_args = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
@@ -39,12 +39,10 @@ def indexer_settings_fixture(settings):
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
|
||||
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.SearchIndexer"
|
||||
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.FindDocumentIndexer"
|
||||
settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest"
|
||||
settings.SEARCH_INDEXER_URL = "http://localhost:8081/api/v1.0/documents/index/"
|
||||
settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://localhost:8081/api/v1.0/documents/search/"
|
||||
)
|
||||
settings.INDEXING_URL = "http://localhost:8081/api/v1.0/documents/index/"
|
||||
settings.SEARCH_URL = "http://localhost:8081/api/v1.0/documents/search/"
|
||||
settings.SEARCH_INDEXER_COUNTDOWN = 1
|
||||
|
||||
yield settings
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
|
||||
"user.test40@example.com","user.test41@example.com",0,0,pending,1
|
||||
"user.test42@example.com","user.test43@example.com",0,1,pending,2
|
||||
"user.test44@example.com","user.test45@example.com",1,0,pending,3
|
||||
"user.test46@example.com","user.test47@example.com",1,1,pending,4
|
||||
"user.test48@example.com","user.test49@example.com",1,1,pending,5
|
||||
|
@@ -1,2 +0,0 @@
|
||||
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
|
||||
"user.test40@example.com",,0,0,pending,40
|
||||
|
@@ -1,5 +0,0 @@
|
||||
merge_accept,active_email,inactive_email,status,id
|
||||
true,user.test10@example.com,user.test11@example.com|user.test12@example.com,pending,10
|
||||
true,user.test30@example.com,user.test31@example.com|user.test32@example.com|user.test33@example.com|user.test34@example.com|user.test35@example.com,pending,11
|
||||
true,user.test20@example.com,user.test21@example.com,pending,12
|
||||
true,user.test22@example.com,user.test23@example.com,pending,13
|
||||
|
@@ -1,2 +0,0 @@
|
||||
merge_accept,active_email,inactive_email,status,id
|
||||
true,user.test20@example.com,user.test20@example.com,pending,20
|
||||
|
@@ -1,6 +0,0 @@
|
||||
active_email,inactive_email,active_email_checked,inactive_email_checked,status
|
||||
"user.test40@example.com","user.test41@example.com",0,0,pending
|
||||
"user.test42@example.com","user.test43@example.com",0,1,pending
|
||||
"user.test44@example.com","user.test45@example.com",1,0,pending
|
||||
"user.test46@example.com","user.test47@example.com",1,1,pending
|
||||
"user.test48@example.com","user.test49@example.com",1,1,pending
|
||||
|
@@ -1,95 +0,0 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.filters import remove_accents
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# Filters: unknown field
|
||||
|
||||
|
||||
def test_api_documents_descendants_filter_unknown_field():
|
||||
"""
|
||||
Trying to filter by an unknown field should be ignored.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory()
|
||||
|
||||
document = factories.DocumentFactory(users=[user])
|
||||
expected_ids = {
|
||||
str(document.id)
|
||||
for document in factories.DocumentFactory.create_batch(2, parent=document)
|
||||
}
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/?unknown=true"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
assert {result["id"] for result in results} == expected_ids
|
||||
|
||||
|
||||
# Filters: title
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,nb_results",
|
||||
[
|
||||
("Project Alpha", 1), # Exact match
|
||||
("project", 2), # Partial match (case-insensitive)
|
||||
("Guide", 2), # Word match within a title
|
||||
("Special", 0), # No match (nonexistent keyword)
|
||||
("2024", 2), # Match by numeric keyword
|
||||
("", 6), # Empty string
|
||||
("velo", 1), # Accent-insensitive match (velo vs vélo)
|
||||
("bêta", 1), # Accent-insensitive match (bêta vs beta)
|
||||
],
|
||||
)
|
||||
def test_api_documents_descendants_filter_title(query, nb_results):
|
||||
"""Authenticated users should be able to search documents by their unaccented title."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[user])
|
||||
|
||||
# Create documents with predefined titles
|
||||
titles = [
|
||||
"Project Alpha Documentation",
|
||||
"Project Beta Overview",
|
||||
"User Guide",
|
||||
"Financial Report 2024",
|
||||
"Annual Review 2024",
|
||||
"Guide du vélo urbain", # <-- Title with accent for accent-insensitive test
|
||||
]
|
||||
for title in titles:
|
||||
factories.DocumentFactory(title=title, parent=document)
|
||||
|
||||
# Perform the search query
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/?title={query:s}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == nb_results
|
||||
|
||||
# Ensure all results contain the query in their title
|
||||
for result in results:
|
||||
assert (
|
||||
remove_accents(query).lower().strip()
|
||||
in remove_accents(result["title"]).lower()
|
||||
)
|
||||
@@ -1,46 +1,40 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: list
|
||||
Tests for Documents API endpoint in impress's core app: search
|
||||
"""
|
||||
|
||||
import random
|
||||
from json import loads as json_loads
|
||||
|
||||
from django.test import RequestFactory
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from faker import Faker
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.test import APIClient
|
||||
from waffle.testutils import override_flag
|
||||
|
||||
from core import factories, models
|
||||
from core import factories
|
||||
from core.enums import SearchType
|
||||
from core.services.search_indexers import get_document_indexer
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def build_search_url(**kwargs):
|
||||
"""Build absolute uri for search endpoint with ORDERED query arguments"""
|
||||
return (
|
||||
RequestFactory()
|
||||
.get("/api/v1.0/documents/search/", dict(sorted(kwargs.items())))
|
||||
.build_absolute_uri()
|
||||
)
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_flag_find_hybrid_search():
|
||||
"""Enable flag_find_hybrid_search for all tests in this module."""
|
||||
with override_flag("flag_find_hybrid_search", active=True):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
@mock.patch("core.services.search_indexers.FindDocumentIndexer.search_query")
|
||||
@responses.activate
|
||||
def test_api_documents_search_anonymous(reach, role, indexer_settings):
|
||||
def test_api_documents_search_anonymous(search_query, indexer_settings):
|
||||
"""
|
||||
Anonymous users should not be allowed to search documents whatever the
|
||||
link reach and link role
|
||||
Anonymous users should be allowed to search documents with Find.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
||||
|
||||
factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
# Find response
|
||||
# mock Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
@@ -48,7 +42,23 @@ def test_api_documents_search_anonymous(reach, role, indexer_settings):
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
q = "alpha"
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": q})
|
||||
|
||||
assert search_query.call_count == 1
|
||||
assert search_query.call_args[1] == {
|
||||
"data": {
|
||||
"q": q,
|
||||
"visited": [],
|
||||
"services": ["docs"],
|
||||
"nb_results": 50,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
"path": None,
|
||||
"search_type": SearchType.HYBRID,
|
||||
},
|
||||
"token": None,
|
||||
}
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
@@ -59,64 +69,121 @@ def test_api_documents_search_anonymous(reach, role, indexer_settings):
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_endpoint_is_none(indexer_settings):
|
||||
@mock.patch("core.api.viewsets.DocumentViewSet.list")
|
||||
def test_api_documents_search_fall_back_on_search_list(mock_list, indexer_settings):
|
||||
"""
|
||||
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured.
|
||||
Should fallback on title filter
|
||||
When indexer is not configured and no path is provided,
|
||||
should fall back on list method
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
indexer_settings.SEARCH_URL = None
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(title="alpha")
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert content == {
|
||||
"count": 1,
|
||||
mocked_response = {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [{"title": "mocked list result"}],
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"user_role": access.role,
|
||||
mock_list.return_value = drf_response.Response(mocked_response)
|
||||
|
||||
q = "alpha"
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": q})
|
||||
|
||||
assert mock_list.call_count == 1
|
||||
assert mock_list.call_args[0][0].GET.get("title") == q
|
||||
assert response.json() == mocked_response
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.DocumentViewSet._list_descendants")
|
||||
def test_api_documents_search_fallback_on_search_list_sub_docs(
|
||||
mock_list_descendants, indexer_settings
|
||||
):
|
||||
"""
|
||||
When indexer is not configured and path parameter is provided,
|
||||
should call _list_descendants() method
|
||||
"""
|
||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
parent = factories.DocumentFactory(title="parent", users=[user])
|
||||
|
||||
mocked_response = {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [{"title": "mocked _list_descendants result"}],
|
||||
}
|
||||
mock_list_descendants.return_value = drf_response.Response(mocked_response)
|
||||
|
||||
q = "alpha"
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": q, "path": parent.path}
|
||||
)
|
||||
|
||||
assert mock_list_descendants.call_count == 1
|
||||
assert mock_list_descendants.call_args[0][0].GET.get("title") == q
|
||||
assert mock_list_descendants.call_args[0][0].GET.get("path") == parent.path
|
||||
assert response.json() == mocked_response
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.DocumentViewSet._title_search")
|
||||
def test_api_documents_search_indexer_crashes(mock_title_search, indexer_settings):
|
||||
"""
|
||||
When indexer is configured but crashes -> falls back on title_search
|
||||
"""
|
||||
# indexer is properly configured
|
||||
indexer_settings.SEARCH_URL = None
|
||||
assert get_document_indexer() is None
|
||||
# but returns an error when the query is sent
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[{"error": "Some indexer error"}],
|
||||
status=404,
|
||||
)
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
mocked_response = {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [{"title": "mocked title_search result"}],
|
||||
}
|
||||
mock_title_search.return_value = drf_response.Response(mocked_response)
|
||||
|
||||
parent = factories.DocumentFactory(title="parent", users=[user])
|
||||
q = "alpha"
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "alpha", "path": parent.path}
|
||||
)
|
||||
|
||||
# the search endpoint did not crash
|
||||
assert response.status_code == 200
|
||||
# fallback on title_search
|
||||
assert mock_title_search.call_count == 1
|
||||
assert mock_title_search.call_args[0][0].GET.get("q") == q
|
||||
assert mock_title_search.call_args[0][0].GET.get("path") == parent.path
|
||||
assert response.json() == mocked_response
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_invalid_params(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -125,49 +192,28 @@ def test_api_documents_search_invalid_params(indexer_settings):
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field is required."]}
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": " "})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field may not be blank."]}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "any", "page": "NaN"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"page": ["A valid integer is required."]}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_format(indexer_settings):
|
||||
def test_api_documents_search_success(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
user_a, user_b, user_c = factories.UserFactory.create_batch(3)
|
||||
document = factories.DocumentFactory(
|
||||
title="alpha",
|
||||
users=(user_a, user_c),
|
||||
link_traces=(user, user_b),
|
||||
)
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
document = {"id": "doc-123", "title": "alpha", "path": "path/to/alpha.pdf"}
|
||||
|
||||
# Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[
|
||||
{"_id": str(document.pk)},
|
||||
{
|
||||
"_id": str(document["id"]),
|
||||
"_source": {"title": document["title"], "path": document["path"]},
|
||||
},
|
||||
],
|
||||
status=200,
|
||||
)
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
@@ -177,249 +223,6 @@ def test_api_documents_search_format(indexer_settings):
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 3,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"pagination, status, expected",
|
||||
(
|
||||
(
|
||||
{"page": 1, "page_size": 10},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"range": (0, None),
|
||||
},
|
||||
),
|
||||
(
|
||||
{},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"range": (0, None),
|
||||
"api_page_size": 21, # default page_size is 20
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 10},
|
||||
404,
|
||||
{},
|
||||
),
|
||||
(
|
||||
{"page": 1, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": {"page": 2, "page_size": 5},
|
||||
"range": (0, 5),
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": {"page_size": 5},
|
||||
"next": None,
|
||||
"range": (5, None),
|
||||
},
|
||||
),
|
||||
({"page": 3, "page_size": 5}, 404, {}),
|
||||
),
|
||||
)
|
||||
def test_api_documents_search_pagination(
|
||||
indexer_settings, pagination, status, expected
|
||||
):
|
||||
"""Documents should be ordered by descending "score" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
docs = factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
|
||||
|
||||
docs_by_uuid = {str(doc.pk): doc for doc in docs}
|
||||
api_results = [{"_id": id} for id in docs_by_uuid.keys()]
|
||||
|
||||
# reorder randomly to simulate score ordering
|
||||
random.shuffle(api_results)
|
||||
|
||||
# Find response
|
||||
# pylint: disable-next=assignment-from-none
|
||||
api_search = responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=api_results,
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/",
|
||||
data={
|
||||
"q": "alpha",
|
||||
**pagination,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
|
||||
if response.status_code < 300:
|
||||
previous_url = (
|
||||
build_search_url(q="alpha", **expected["previous"])
|
||||
if expected["previous"]
|
||||
else None
|
||||
)
|
||||
next_url = (
|
||||
build_search_url(q="alpha", **expected["next"])
|
||||
if expected["next"]
|
||||
else None
|
||||
)
|
||||
start, end = expected["range"]
|
||||
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == expected["count"]
|
||||
assert content["previous"] == previous_url
|
||||
assert content["next"] == next_url
|
||||
|
||||
results = content.pop("results")
|
||||
|
||||
# The find api results ordering by score is kept
|
||||
assert [r["id"] for r in results] == [r["_id"] for r in api_results[start:end]]
|
||||
|
||||
# Check the query parameters.
|
||||
assert api_search.call_count == 1
|
||||
assert api_search.calls[0].response.status_code == 200
|
||||
assert json_loads(api_search.calls[0].request.body) == {
|
||||
"q": "alpha",
|
||||
"visited": [],
|
||||
"services": ["docs"],
|
||||
"nb_results": 50,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"pagination, status, expected",
|
||||
(
|
||||
(
|
||||
{"page": 1, "page_size": 10},
|
||||
200,
|
||||
{"count": 10, "previous": None, "next": None, "range": (0, None)},
|
||||
),
|
||||
(
|
||||
{},
|
||||
200,
|
||||
{"count": 10, "previous": None, "next": None, "range": (0, None)},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 10},
|
||||
404,
|
||||
{},
|
||||
),
|
||||
(
|
||||
{"page": 1, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": {"page": 2, "page_size": 5},
|
||||
"range": (0, 5),
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": {"page_size": 5},
|
||||
"next": None,
|
||||
"range": (5, None),
|
||||
},
|
||||
),
|
||||
({"page": 3, "page_size": 5}, 404, {}),
|
||||
),
|
||||
)
|
||||
def test_api_documents_search_pagination_endpoint_is_none(
|
||||
indexer_settings, pagination, status, expected
|
||||
):
|
||||
"""Documents should be ordered by descending "-updated_at" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/",
|
||||
data={
|
||||
"q": "alpha",
|
||||
**pagination,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
|
||||
if response.status_code < 300:
|
||||
previous_url = (
|
||||
build_search_url(q="alpha", **expected["previous"])
|
||||
if expected["previous"]
|
||||
else None
|
||||
)
|
||||
next_url = (
|
||||
build_search_url(q="alpha", **expected["next"])
|
||||
if expected["next"]
|
||||
else None
|
||||
)
|
||||
queryset = models.Document.objects.order_by("-updated_at")
|
||||
start, end = expected["range"]
|
||||
expected_results = [str(d.pk) for d in queryset[start:end]]
|
||||
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == expected["count"]
|
||||
assert content["previous"] == previous_url
|
||||
assert content["next"] == next_url
|
||||
|
||||
results = content.pop("results")
|
||||
|
||||
assert [r["id"] for r in results] == expected_results
|
||||
assert results == [
|
||||
{"id": document["id"], "title": document["title"], "path": document["path"]}
|
||||
]
|
||||
|
||||
+197
-50
@@ -1,5 +1,6 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: descendants
|
||||
Tests for search API endpoint in impress's core app when indexer is not
|
||||
available and a path param is given.
|
||||
"""
|
||||
|
||||
import random
|
||||
@@ -10,26 +11,61 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.filters import remove_accents
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_indexer(indexer_settings):
|
||||
"""Disable search indexer for all tests in this file."""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"""Anonymous users should be allowed to retrieve the descendants of a public document."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
document = factories.DocumentFactory(link_reach="public", title="doc parent")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="doc child"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="doc grand child")
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"count": 4,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
# the search should include the parent document itself
|
||||
"abilities": document.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 2,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
@@ -110,26 +146,60 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
Anonymous users should be allowed to retrieve the descendants of a document who
|
||||
has a public ancestor.
|
||||
"""
|
||||
grand_parent = factories.DocumentFactory(link_reach="public")
|
||||
grand_parent = factories.DocumentFactory(
|
||||
link_reach="public", title="grand parent doc"
|
||||
)
|
||||
parent = factories.DocumentFactory(
|
||||
parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"])
|
||||
parent=grand_parent,
|
||||
link_reach=random.choice(["authenticated", "restricted"]),
|
||||
title="parent doc",
|
||||
)
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=random.choice(["authenticated", "restricted"]), parent=parent
|
||||
link_reach=random.choice(["authenticated", "restricted"]),
|
||||
parent=parent,
|
||||
title="document",
|
||||
)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child doc"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child doc")
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"count": 4,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
# the search should include the parent document itself
|
||||
"abilities": document.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 2,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
@@ -208,11 +278,13 @@ def test_api_documents_descendants_list_anonymous_restricted_or_authenticated(re
|
||||
"""
|
||||
Anonymous users should not be able to retrieve descendants of a document that is not public.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child)
|
||||
document = factories.DocumentFactory(title="parent", link_reach=reach)
|
||||
child = factories.DocumentFactory(title="child", parent=document)
|
||||
_grand_child = factories.DocumentFactory(title="grand child", parent=child)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
@@ -232,17 +304,18 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
document = factories.DocumentFactory(link_reach=reach, title="parent")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, link_reach="restricted"
|
||||
2, parent=document, link_reach="restricted", title="child"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
@@ -335,17 +408,23 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
grand_parent = factories.DocumentFactory(link_reach=reach)
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(link_reach="restricted", parent=parent)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, link_reach="restricted"
|
||||
grand_parent = factories.DocumentFactory(link_reach=reach, title="grand parent")
|
||||
parent = factories.DocumentFactory(
|
||||
parent=grand_parent, link_reach="restricted", title="parent"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", parent=parent, title="document"
|
||||
)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, link_reach="restricted", title="child"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
@@ -435,15 +514,18 @@ def test_api_documents_descendants_list_authenticated_unrelated_restricted():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child1)
|
||||
document = factories.DocumentFactory(link_reach="restricted", title="parent")
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child"
|
||||
)
|
||||
_grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
@@ -460,17 +542,19 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
document = factories.DocumentFactory(title="parent")
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child"
|
||||
)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
@@ -561,21 +645,27 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
grand_parent = factories.DocumentFactory(link_reach="restricted")
|
||||
grand_parent = factories.DocumentFactory(link_reach="restricted", title="parent")
|
||||
grand_parent_access = factories.UserDocumentAccessFactory(
|
||||
document=grand_parent, user=user
|
||||
)
|
||||
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
parent = factories.DocumentFactory(
|
||||
parent=grand_parent, link_reach="restricted", title="parent"
|
||||
)
|
||||
document = factories.DocumentFactory(
|
||||
parent=parent, link_reach="restricted", title="document"
|
||||
)
|
||||
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child"
|
||||
)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
@@ -673,7 +763,7 @@ def test_api_documents_descendants_list_authenticated_related_child():
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
@@ -694,12 +784,15 @@ def test_api_documents_descendants_list_authenticated_related_team_none(
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.DocumentFactory.create_batch(2, parent=document)
|
||||
document = factories.DocumentFactory(link_reach="restricted", title="document")
|
||||
factories.DocumentFactory.create_batch(2, parent=document, title="child")
|
||||
|
||||
factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
@@ -719,13 +812,17 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
document = factories.DocumentFactory(link_reach="restricted", title="parent")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
@@ -805,3 +902,53 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,nb_results",
|
||||
[
|
||||
("", 7), # Empty string
|
||||
("Project Alpha", 1), # Exact match
|
||||
("project", 2), # Partial match (case-insensitive)
|
||||
("Guide", 2), # Word match within a title
|
||||
("Special", 0), # No match (nonexistent keyword)
|
||||
("2024", 2), # Match by numeric keyword
|
||||
("velo", 1), # Accent-insensitive match (velo vs vélo)
|
||||
("bêta", 1), # Accent-insensitive match (bêta vs beta)
|
||||
],
|
||||
)
|
||||
def test_api_documents_descendants_search_on_title(query, nb_results):
|
||||
"""Authenticated users should be able to search documents by their unaccented title."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
parent = factories.DocumentFactory(users=[user])
|
||||
|
||||
# Create documents with predefined titles
|
||||
titles = [
|
||||
"Project Alpha Documentation",
|
||||
"Project Beta Overview",
|
||||
"User Guide",
|
||||
"Financial Report 2024",
|
||||
"Annual Review 2024",
|
||||
"Guide du vélo urbain", # <-- Title with accent for accent-insensitive test
|
||||
]
|
||||
for title in titles:
|
||||
factories.DocumentFactory(title=title, parent=parent)
|
||||
|
||||
# Perform the search query
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": query, "path": parent.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == nb_results
|
||||
|
||||
# Ensure all results contain the query in their title
|
||||
for result in results:
|
||||
assert (
|
||||
remove_accents(query).lower().strip()
|
||||
in remove_accents(result["title"]).lower()
|
||||
)
|
||||
@@ -20,7 +20,6 @@ pytestmark = pytest.mark.django_db
|
||||
|
||||
@override_settings(
|
||||
AI_FEATURE_ENABLED=False,
|
||||
API_USERS_SEARCH_QUERY_MIN_LENGTH=6,
|
||||
COLLABORATION_WS_URL="http://testcollab/",
|
||||
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True,
|
||||
CRISP_WEBSITE_ID="123",
|
||||
@@ -45,7 +44,6 @@ def test_api_config(is_authenticated):
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"AI_FEATURE_ENABLED": False,
|
||||
"API_USERS_SEARCH_QUERY_MIN_LENGTH": 6,
|
||||
"COLLABORATION_WS_URL": "http://testcollab/",
|
||||
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True,
|
||||
"CONVERSION_FILE_EXTENSIONS_ALLOWED": [".docx", ".md"],
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"""
|
||||
Unit tests for the ReconciliationConfirmView API view.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_reconciliation_confirm_view_sets_active_checked():
|
||||
"""GETting the active confirmation endpoint should set active_email_checked."""
|
||||
user = factories.UserFactory(email="user.confirm1@example.com")
|
||||
other = factories.UserFactory(email="user.confirm2@example.com")
|
||||
rec = models.UserReconciliation.objects.create(
|
||||
active_email=user.email,
|
||||
inactive_email=other.email,
|
||||
active_user=user,
|
||||
inactive_user=other,
|
||||
active_email_checked=False,
|
||||
inactive_email_checked=False,
|
||||
status="ready",
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
conf_id = rec.active_email_confirmation_id
|
||||
url = f"/api/{settings.API_VERSION}/user-reconciliations/active/{conf_id}/"
|
||||
resp = client.get(url)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"detail": "Confirmation received"}
|
||||
|
||||
rec.refresh_from_db()
|
||||
assert rec.active_email_checked is True
|
||||
|
||||
|
||||
def test_reconciliation_confirm_view_sets_inactive_checked():
|
||||
"""GETting the inactive confirmation endpoint should set inactive_email_checked."""
|
||||
user = factories.UserFactory(email="user.confirm3@example.com")
|
||||
other = factories.UserFactory(email="user.confirm4@example.com")
|
||||
rec = models.UserReconciliation.objects.create(
|
||||
active_email=user.email,
|
||||
inactive_email=other.email,
|
||||
active_user=user,
|
||||
inactive_user=other,
|
||||
active_email_checked=False,
|
||||
inactive_email_checked=False,
|
||||
status="ready",
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
conf_id = rec.inactive_email_confirmation_id
|
||||
url = f"/api/{settings.API_VERSION}/user-reconciliations/inactive/{conf_id}/"
|
||||
resp = client.get(url)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"detail": "Confirmation received"}
|
||||
|
||||
rec.refresh_from_db()
|
||||
assert rec.inactive_email_checked is True
|
||||
|
||||
|
||||
def test_reconciliation_confirm_view_invalid_user_type_returns_400():
|
||||
"""GETting with an invalid user_type should return 400."""
|
||||
client = APIClient()
|
||||
# Use a valid uuid format but invalid user_type
|
||||
|
||||
url = f"/api/{settings.API_VERSION}/user-reconciliations/other/{uuid.uuid4()}/"
|
||||
resp = client.get(url)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json() == {"detail": "Invalid user_type"}
|
||||
|
||||
|
||||
def test_reconciliation_confirm_view_not_found_returns_404():
|
||||
"""GETting with a non-existing confirmation_id should return 404."""
|
||||
client = APIClient()
|
||||
|
||||
url = f"/api/{settings.API_VERSION}/user-reconciliations/active/{uuid.uuid4()}/"
|
||||
resp = client.get(url)
|
||||
assert resp.status_code == 404
|
||||
assert resp.json() == {"detail": "Reconciliation entry not found"}
|
||||
@@ -2,8 +2,6 @@
|
||||
Test users API endpoints in the impress core app.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -123,12 +121,12 @@ def test_api_users_list_query_full_name():
|
||||
Authenticated users should be able to list users and filter by full name.
|
||||
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
|
||||
"""
|
||||
user = factories.UserFactory(email="user@example.com")
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.UserFactory(email="contact@example.com", full_name="David Bowman")
|
||||
dave = factories.UserFactory(email="contact@work.com", full_name="David Bowman")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=David",
|
||||
@@ -168,13 +166,13 @@ def test_api_users_list_query_accented_full_name():
|
||||
Authenticated users should be able to list users and filter by full name with accents.
|
||||
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
|
||||
"""
|
||||
user = factories.UserFactory(email="user@example.com")
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
fred = factories.UserFactory(
|
||||
email="contact@example.com", full_name="Frédérique Lefèvre"
|
||||
email="contact@work.com", full_name="Frédérique Lefèvre"
|
||||
)
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=Frédérique")
|
||||
@@ -203,82 +201,12 @@ def test_api_users_list_query_accented_full_name():
|
||||
assert users == []
|
||||
|
||||
|
||||
def test_api_users_list_sorted_by_closest_match():
|
||||
"""
|
||||
Authenticated users should be able to list users and the results should be
|
||||
sorted by closest match to the query.
|
||||
|
||||
Sorting criteria are :
|
||||
- Shared documents with the user (most recent first)
|
||||
- Same full email domain (example.gouv.fr)
|
||||
|
||||
Addresses that match neither criteria should be excluded from the results.
|
||||
|
||||
Case in point: the logged-in user has recently shared documents
|
||||
with pierre.dupont@beta.gouv.fr and less recently with pierre.durand@impots.gouv.fr.
|
||||
|
||||
Other users named Pierre also exist:
|
||||
- pierre.thomas@example.com
|
||||
- pierre.petit@anct.gouv.fr
|
||||
- pierre.robert@culture.gouv.fr
|
||||
|
||||
The search results should be ordered as follows:
|
||||
|
||||
# Shared with first
|
||||
- pierre.dupond@beta.gouv.fr # Most recent first
|
||||
- pierre.durand@impots.gouv.fr
|
||||
# Same full domain second
|
||||
- pierre.petit@anct.gouv.fr
|
||||
"""
|
||||
|
||||
user = factories.UserFactory(
|
||||
email="martin.bernard@anct.gouv.fr", full_name="Martin Bernard"
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
pierre_1 = factories.UserFactory(email="pierre.dupont@beta.gouv.fr")
|
||||
pierre_2 = factories.UserFactory(email="pierre.durand@impots.gouv.fr")
|
||||
_pierre_3 = factories.UserFactory(email="pierre.thomas@example.com")
|
||||
pierre_4 = factories.UserFactory(email="pierre.petit@anct.gouv.fr")
|
||||
_pierre_5 = factories.UserFactory(email="pierre.robert@culture.gouv.fr")
|
||||
|
||||
document_1 = factories.DocumentFactory(creator=user)
|
||||
document_2 = factories.DocumentFactory(creator=user)
|
||||
factories.UserDocumentAccessFactory(user=user, document=document_1)
|
||||
factories.UserDocumentAccessFactory(user=user, document=document_2)
|
||||
|
||||
now = timezone.now()
|
||||
last_week = now - timezone.timedelta(days=7)
|
||||
last_month = now - timezone.timedelta(days=30)
|
||||
|
||||
# The factory cannot set the created_at directly, so we force it after creation
|
||||
p1_d1 = factories.UserDocumentAccessFactory(user=pierre_1, document=document_1)
|
||||
p1_d1.created_at = last_week
|
||||
p1_d1.save()
|
||||
|
||||
p2_d2 = factories.UserDocumentAccessFactory(user=pierre_2, document=document_2)
|
||||
p2_d2.created_at = last_month
|
||||
p2_d2.save()
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=Pierre")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["email"] for user in response.json()]
|
||||
|
||||
assert user_ids == [
|
||||
str(pierre_1.email),
|
||||
str(pierre_2.email),
|
||||
str(pierre_4.email),
|
||||
]
|
||||
|
||||
|
||||
def test_api_users_list_limit(settings):
|
||||
"""
|
||||
Authenticated users should be able to list users and the number of results
|
||||
should be limited to API_USERS_LIST_LIMIT (by default 5).
|
||||
should be limited to 10.
|
||||
"""
|
||||
user = factories.UserFactory(email="user@example.com")
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -381,16 +309,28 @@ def test_api_users_list_query_email_exclude_doc_user():
|
||||
|
||||
def test_api_users_list_query_short_queries():
|
||||
"""
|
||||
If API_USERS_SEARCH_QUERY_MIN_LENGTH is not set, the default minimum length should be 3.
|
||||
Queries shorter than 5 characters should return an empty result set.
|
||||
"""
|
||||
user = factories.UserFactory(email="paul@example.com", full_name="Paul")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory(email="john.doe@example.com", full_name="John Doe")
|
||||
factories.UserFactory(email="john.lennon@example.com", full_name="John Lennon")
|
||||
factories.UserFactory(email="john.doe@example.com")
|
||||
factories.UserFactory(email="john.lennon@example.com")
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=joh")
|
||||
response = client.get("/api/v1.0/users/?q=jo")
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"q": ["Ensure this value has at least 5 characters (it has 2)."]
|
||||
}
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=john")
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"q": ["Ensure this value has at least 5 characters (it has 4)."]
|
||||
}
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=john.")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 2
|
||||
|
||||
@@ -416,7 +356,7 @@ def test_api_users_list_query_long_queries():
|
||||
|
||||
def test_api_users_list_query_inactive():
|
||||
"""Inactive users should not be listed."""
|
||||
user = factories.UserFactory(email="user@example.com")
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
|
||||
@@ -1,669 +0,0 @@
|
||||
"""
|
||||
Unit tests for the UserReconciliationCsvImport model
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from django.core import mail
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.admin import process_reconciliation
|
||||
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(name="import_example_csv_basic")
|
||||
def fixture_import_example_csv_basic():
|
||||
"""
|
||||
Import an example CSV file for user reconciliation
|
||||
and return the created import object.
|
||||
"""
|
||||
# Create users referenced in the CSV
|
||||
for i in range(40, 50):
|
||||
factories.UserFactory(email=f"user.test{i}@example.com")
|
||||
|
||||
example_csv_path = Path(__file__).parent / "data/example_reconciliation_basic.csv"
|
||||
with open(example_csv_path, "rb") as f:
|
||||
csv_file = ContentFile(f.read(), name="example_reconciliation_basic.csv")
|
||||
csv_import = models.UserReconciliationCsvImport(file=csv_file)
|
||||
csv_import.save()
|
||||
|
||||
return csv_import
|
||||
|
||||
|
||||
@pytest.fixture(name="import_example_csv_grist_form")
|
||||
def fixture_import_example_csv_grist_form():
|
||||
"""
|
||||
Import an example CSV file for user reconciliation
|
||||
and return the created import object.
|
||||
"""
|
||||
# Create users referenced in the CSV
|
||||
for i in range(10, 40):
|
||||
factories.UserFactory(email=f"user.test{i}@example.com")
|
||||
|
||||
example_csv_path = (
|
||||
Path(__file__).parent / "data/example_reconciliation_grist_form.csv"
|
||||
)
|
||||
with open(example_csv_path, "rb") as f:
|
||||
csv_file = ContentFile(f.read(), name="example_reconciliation_grist_form.csv")
|
||||
csv_import = models.UserReconciliationCsvImport(file=csv_file)
|
||||
csv_import.save()
|
||||
|
||||
return csv_import
|
||||
|
||||
|
||||
def test_user_reconciliation_csv_import_entry_is_created(import_example_csv_basic):
|
||||
"""Test that a UserReconciliationCsvImport entry is created correctly."""
|
||||
assert import_example_csv_basic.status == "pending"
|
||||
assert import_example_csv_basic.file.name.endswith(
|
||||
"example_reconciliation_basic.csv"
|
||||
)
|
||||
|
||||
|
||||
def test_user_reconciliation_csv_import_entry_is_created_grist_form(
|
||||
import_example_csv_grist_form,
|
||||
):
|
||||
"""Test that a UserReconciliationCsvImport entry is created correctly."""
|
||||
assert import_example_csv_grist_form.status == "pending"
|
||||
assert import_example_csv_grist_form.file.name.endswith(
|
||||
"example_reconciliation_grist_form.csv"
|
||||
)
|
||||
|
||||
|
||||
def test_incorrect_csv_format_handling():
|
||||
"""Test that an incorrectly formatted CSV file is handled gracefully."""
|
||||
example_csv_path = (
|
||||
Path(__file__).parent / "data/example_reconciliation_missing_column.csv"
|
||||
)
|
||||
with open(example_csv_path, "rb") as f:
|
||||
csv_file = ContentFile(
|
||||
f.read(), name="example_reconciliation_missing_column.csv"
|
||||
)
|
||||
csv_import = models.UserReconciliationCsvImport(file=csv_file)
|
||||
csv_import.save()
|
||||
|
||||
assert csv_import.status == "pending"
|
||||
|
||||
user_reconciliation_csv_import_job(csv_import.id)
|
||||
csv_import.refresh_from_db()
|
||||
|
||||
assert (
|
||||
"CSV is missing mandatory columns: active_email, inactive_email, id"
|
||||
in csv_import.logs
|
||||
)
|
||||
assert csv_import.status == "error"
|
||||
|
||||
|
||||
def test_incorrect_email_format_handling():
|
||||
"""Test that an incorrectly formatted CSV file is handled gracefully."""
|
||||
example_csv_path = Path(__file__).parent / "data/example_reconciliation_error.csv"
|
||||
with open(example_csv_path, "rb") as f:
|
||||
csv_file = ContentFile(f.read(), name="example_reconciliation_error.csv")
|
||||
csv_import = models.UserReconciliationCsvImport(file=csv_file)
|
||||
csv_import.save()
|
||||
|
||||
assert csv_import.status == "pending"
|
||||
|
||||
user_reconciliation_csv_import_job(csv_import.id)
|
||||
csv_import.refresh_from_db()
|
||||
|
||||
assert "Invalid inactive email address on row 40" in csv_import.logs
|
||||
assert csv_import.status == "done"
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
email = mail.outbox[0]
|
||||
|
||||
assert email.to == ["user.test40@example.com"]
|
||||
email_content = " ".join(email.body.split())
|
||||
|
||||
assert "Reconciliation of your Docs accounts not completed" in email_content
|
||||
|
||||
|
||||
def test_incorrect_csv_data_handling_grist_form():
|
||||
"""Test that a CSV file with incorrect data is handled gracefully."""
|
||||
example_csv_path = (
|
||||
Path(__file__).parent / "data/example_reconciliation_grist_form_error.csv"
|
||||
)
|
||||
with open(example_csv_path, "rb") as f:
|
||||
csv_file = ContentFile(
|
||||
f.read(), name="example_reconciliation_grist_form_error.csv"
|
||||
)
|
||||
csv_import = models.UserReconciliationCsvImport(file=csv_file)
|
||||
csv_import.save()
|
||||
|
||||
assert csv_import.status == "pending"
|
||||
|
||||
user_reconciliation_csv_import_job(csv_import.id)
|
||||
csv_import.refresh_from_db()
|
||||
|
||||
assert (
|
||||
"user.test20@example.com set as both active and inactive email"
|
||||
in csv_import.logs
|
||||
)
|
||||
assert csv_import.status == "done"
|
||||
|
||||
|
||||
def test_job_creates_reconciliation_entries(import_example_csv_basic):
|
||||
"""Test that the CSV import job creates UserReconciliation entries."""
|
||||
assert import_example_csv_basic.status == "pending"
|
||||
user_reconciliation_csv_import_job(import_example_csv_basic.id)
|
||||
|
||||
# Verify the job status changed
|
||||
import_example_csv_basic.refresh_from_db()
|
||||
assert import_example_csv_basic.status == "done"
|
||||
assert "Import completed successfully." in import_example_csv_basic.logs
|
||||
assert "6 rows processed." in import_example_csv_basic.logs
|
||||
assert "5 reconciliation entries created." in import_example_csv_basic.logs
|
||||
|
||||
# Verify reconciliation entries were created
|
||||
reconciliations = models.UserReconciliation.objects.all()
|
||||
assert reconciliations.count() == 5
|
||||
|
||||
|
||||
def test_job_does_not_create_duplicated_reconciliation_entries(
|
||||
import_example_csv_basic,
|
||||
):
|
||||
"""Test that the CSV import job doesn't create UserReconciliation entries
|
||||
for source unique IDs that have already been processed."""
|
||||
|
||||
_already_created_entry = models.UserReconciliation.objects.create(
|
||||
active_email="user.test40@example.com",
|
||||
inactive_email="user.test41@example.com",
|
||||
active_email_checked=0,
|
||||
inactive_email_checked=0,
|
||||
status="pending",
|
||||
source_unique_id=1,
|
||||
)
|
||||
|
||||
assert import_example_csv_basic.status == "pending"
|
||||
user_reconciliation_csv_import_job(import_example_csv_basic.id)
|
||||
|
||||
# Verify the job status changed
|
||||
import_example_csv_basic.refresh_from_db()
|
||||
assert import_example_csv_basic.status == "done"
|
||||
assert "Import completed successfully." in import_example_csv_basic.logs
|
||||
assert "6 rows processed." in import_example_csv_basic.logs
|
||||
assert "4 reconciliation entries created." in import_example_csv_basic.logs
|
||||
assert "1 rows were already processed." in import_example_csv_basic.logs
|
||||
|
||||
# Verify the correct number of reconciliation entries were created
|
||||
reconciliations = models.UserReconciliation.objects.all()
|
||||
assert reconciliations.count() == 5
|
||||
|
||||
|
||||
def test_job_creates_reconciliation_entries_grist_form(import_example_csv_grist_form):
|
||||
"""Test that the CSV import job creates UserReconciliation entries."""
|
||||
assert import_example_csv_grist_form.status == "pending"
|
||||
user_reconciliation_csv_import_job(import_example_csv_grist_form.id)
|
||||
|
||||
# Verify the job status changed
|
||||
import_example_csv_grist_form.refresh_from_db()
|
||||
assert "Import completed successfully" in import_example_csv_grist_form.logs
|
||||
assert import_example_csv_grist_form.status == "done"
|
||||
|
||||
# Verify reconciliation entries were created
|
||||
reconciliations = models.UserReconciliation.objects.all()
|
||||
assert reconciliations.count() == 9
|
||||
|
||||
|
||||
def test_csv_import_reconciliation_data_is_correct(import_example_csv_basic):
|
||||
"""Test that the data in created UserReconciliation entries matches the CSV."""
|
||||
user_reconciliation_csv_import_job(import_example_csv_basic.id)
|
||||
|
||||
reconciliations = models.UserReconciliation.objects.order_by("created_at")
|
||||
first_entry = reconciliations.first()
|
||||
|
||||
assert first_entry.active_email == "user.test40@example.com"
|
||||
assert first_entry.inactive_email == "user.test41@example.com"
|
||||
assert first_entry.active_email_checked is False
|
||||
assert first_entry.inactive_email_checked is False
|
||||
|
||||
for rec in reconciliations:
|
||||
assert rec.status == "ready"
|
||||
|
||||
|
||||
@pytest.fixture(name="user_reconciliation_users_and_docs")
|
||||
def fixture_user_reconciliation_users_and_docs():
|
||||
"""Fixture to create two users with overlapping document accesses
|
||||
for reconciliation tests."""
|
||||
user_1 = factories.UserFactory(email="user.test1@example.com")
|
||||
user_2 = factories.UserFactory(email="user.test2@example.com")
|
||||
|
||||
# Create 10 distinct document accesses for each user
|
||||
userdocs_u1 = [
|
||||
factories.UserDocumentAccessFactory(user=user_1, role="editor")
|
||||
for _ in range(10)
|
||||
]
|
||||
userdocs_u2 = [
|
||||
factories.UserDocumentAccessFactory(user=user_2, role="editor")
|
||||
for _ in range(10)
|
||||
]
|
||||
|
||||
# Make the first 3 documents of each list shared with the other user
|
||||
# with a lower role
|
||||
for ud in userdocs_u1[0:3]:
|
||||
factories.UserDocumentAccessFactory(
|
||||
user=user_2, document=ud.document, role="reader"
|
||||
)
|
||||
|
||||
for ud in userdocs_u2[0:3]:
|
||||
factories.UserDocumentAccessFactory(
|
||||
user=user_1, document=ud.document, role="reader"
|
||||
)
|
||||
|
||||
# Make the next 3 documents of each list shared with the other user
|
||||
# with a higher role
|
||||
for ud in userdocs_u1[3:6]:
|
||||
factories.UserDocumentAccessFactory(
|
||||
user=user_2, document=ud.document, role="owner"
|
||||
)
|
||||
|
||||
for ud in userdocs_u2[3:6]:
|
||||
factories.UserDocumentAccessFactory(
|
||||
user=user_1, document=ud.document, role="owner"
|
||||
)
|
||||
|
||||
return (user_1, user_2, userdocs_u1, userdocs_u2)
|
||||
|
||||
|
||||
def test_user_reconciliation_is_created(user_reconciliation_users_and_docs):
|
||||
"""Test that a UserReconciliation entry can be created and saved."""
|
||||
user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs
|
||||
rec = models.UserReconciliation.objects.create(
|
||||
active_email=user_1.email,
|
||||
inactive_email=user_2.email,
|
||||
active_email_checked=False,
|
||||
inactive_email_checked=True,
|
||||
active_email_confirmation_id=uuid.uuid4(),
|
||||
inactive_email_confirmation_id=uuid.uuid4(),
|
||||
status="pending",
|
||||
)
|
||||
|
||||
rec.save()
|
||||
assert rec.status == "ready"
|
||||
|
||||
|
||||
def test_user_reconciliation_verification_emails_are_sent(
|
||||
user_reconciliation_users_and_docs,
|
||||
):
|
||||
"""Test that both UserReconciliation verification emails are sent."""
|
||||
user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs
|
||||
rec = models.UserReconciliation.objects.create(
|
||||
active_email=user_1.email,
|
||||
inactive_email=user_2.email,
|
||||
active_email_checked=False,
|
||||
inactive_email_checked=False,
|
||||
active_email_confirmation_id=uuid.uuid4(),
|
||||
inactive_email_confirmation_id=uuid.uuid4(),
|
||||
status="pending",
|
||||
)
|
||||
|
||||
rec.save()
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 2
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
email_1 = mail.outbox[0]
|
||||
|
||||
assert email_1.to == [user_1.email]
|
||||
email_1_content = " ".join(email_1.body.split())
|
||||
|
||||
assert (
|
||||
"You have requested a reconciliation of your user accounts on Docs."
|
||||
in email_1_content
|
||||
)
|
||||
active_email_confirmation_id = rec.active_email_confirmation_id
|
||||
inactive_email_confirmation_id = rec.inactive_email_confirmation_id
|
||||
assert (
|
||||
f"user-reconciliations/active/{active_email_confirmation_id}/"
|
||||
in email_1_content
|
||||
)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
email_2 = mail.outbox[1]
|
||||
|
||||
assert email_2.to == [user_2.email]
|
||||
email_2_content = " ".join(email_2.body.split())
|
||||
|
||||
assert (
|
||||
"You have requested a reconciliation of your user accounts on Docs."
|
||||
in email_2_content
|
||||
)
|
||||
|
||||
assert (
|
||||
f"user-reconciliations/inactive/{inactive_email_confirmation_id}/"
|
||||
in email_2_content
|
||||
)
|
||||
|
||||
|
||||
def test_user_reconciliation_only_starts_if_checks_are_made(
|
||||
user_reconciliation_users_and_docs,
|
||||
):
|
||||
"""Test that the admin action does not process entries
|
||||
unless both email checks are confirmed.
|
||||
"""
|
||||
user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs
|
||||
|
||||
# Create a reconciliation entry where only one email has been checked
|
||||
rec = models.UserReconciliation.objects.create(
|
||||
active_email=user_1.email,
|
||||
inactive_email=user_2.email,
|
||||
active_email_checked=True,
|
||||
inactive_email_checked=False,
|
||||
status="pending",
|
||||
)
|
||||
rec.save()
|
||||
|
||||
# Capture counts before running admin action
|
||||
accesses_before_active = models.DocumentAccess.objects.filter(user=user_1).count()
|
||||
accesses_before_inactive = models.DocumentAccess.objects.filter(user=user_2).count()
|
||||
users_active_before = (user_1.is_active, user_2.is_active)
|
||||
|
||||
# Call the admin action with the queryset containing our single rec
|
||||
qs = models.UserReconciliation.objects.filter(id=rec.id)
|
||||
process_reconciliation(None, None, qs)
|
||||
|
||||
# Reload from DB and assert nothing was processed (checks prevent processing)
|
||||
rec.refresh_from_db()
|
||||
user_1.refresh_from_db()
|
||||
user_2.refresh_from_db()
|
||||
|
||||
assert rec.status == "ready"
|
||||
assert (
|
||||
models.DocumentAccess.objects.filter(user=user_1).count()
|
||||
== accesses_before_active
|
||||
)
|
||||
assert (
|
||||
models.DocumentAccess.objects.filter(user=user_2).count()
|
||||
== accesses_before_inactive
|
||||
)
|
||||
assert (user_1.is_active, user_2.is_active) == users_active_before
|
||||
|
||||
|
||||
def test_process_reconciliation_updates_accesses(
|
||||
user_reconciliation_users_and_docs,
|
||||
):
|
||||
"""Test that accesses are consolidated on the active user."""
|
||||
user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
|
||||
|
||||
u1_2 = userdocs_u1[2]
|
||||
u1_5 = userdocs_u1[5]
|
||||
u2doc1 = userdocs_u2[1].document
|
||||
u2doc5 = userdocs_u2[5].document
|
||||
|
||||
rec = models.UserReconciliation.objects.create(
|
||||
active_email=user_1.email,
|
||||
inactive_email=user_2.email,
|
||||
active_user=user_1,
|
||||
inactive_user=user_2,
|
||||
active_email_checked=True,
|
||||
inactive_email_checked=True,
|
||||
status="ready",
|
||||
)
|
||||
|
||||
qs = models.UserReconciliation.objects.filter(id=rec.id)
|
||||
process_reconciliation(None, None, qs)
|
||||
|
||||
rec.refresh_from_db()
|
||||
user_1.refresh_from_db()
|
||||
user_2.refresh_from_db()
|
||||
u1_2.refresh_from_db(
|
||||
from_queryset=models.DocumentAccess.objects.select_for_update()
|
||||
)
|
||||
u1_5.refresh_from_db(
|
||||
from_queryset=models.DocumentAccess.objects.select_for_update()
|
||||
)
|
||||
|
||||
# After processing, inactive user should have no accesses
|
||||
# and active user should have one access per union document
|
||||
# with the highest role
|
||||
assert rec.status == "done"
|
||||
assert "Requested update for 10 DocumentAccess items" in rec.logs
|
||||
assert "and deletion for 12 DocumentAccess items" in rec.logs
|
||||
assert models.DocumentAccess.objects.filter(user=user_2).count() == 0
|
||||
assert models.DocumentAccess.objects.filter(user=user_1).count() == 20
|
||||
assert u1_2.role == "editor"
|
||||
assert u1_5.role == "owner"
|
||||
|
||||
assert (
|
||||
models.DocumentAccess.objects.filter(user=user_1, document=u2doc1).first().role
|
||||
== "editor"
|
||||
)
|
||||
assert (
|
||||
models.DocumentAccess.objects.filter(user=user_1, document=u2doc5).first().role
|
||||
== "owner"
|
||||
)
|
||||
|
||||
assert user_1.is_active is True
|
||||
assert user_2.is_active is False
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
email = mail.outbox[0]
|
||||
|
||||
assert email.to == [user_1.email]
|
||||
email_content = " ".join(email.body.split())
|
||||
|
||||
assert "Your accounts have been merged" in email_content
|
||||
|
||||
|
||||
def test_process_reconciliation_updates_linktraces(
|
||||
user_reconciliation_users_and_docs,
|
||||
):
|
||||
"""Test that linktraces are consolidated on the active user."""
|
||||
user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
|
||||
|
||||
u1_2 = userdocs_u1[2]
|
||||
u1_5 = userdocs_u1[5]
|
||||
|
||||
doc_both = u1_2.document
|
||||
models.LinkTrace.objects.create(document=doc_both, user=user_1)
|
||||
models.LinkTrace.objects.create(document=doc_both, user=user_2)
|
||||
|
||||
doc_inactive_only = userdocs_u2[4].document
|
||||
models.LinkTrace.objects.create(
|
||||
document=doc_inactive_only, user=user_2, is_masked=True
|
||||
)
|
||||
|
||||
doc_active_only = userdocs_u1[4].document
|
||||
models.LinkTrace.objects.create(document=doc_active_only, user=user_1)
|
||||
|
||||
rec = models.UserReconciliation.objects.create(
|
||||
active_email=user_1.email,
|
||||
inactive_email=user_2.email,
|
||||
active_user=user_1,
|
||||
inactive_user=user_2,
|
||||
active_email_checked=True,
|
||||
inactive_email_checked=True,
|
||||
status="ready",
|
||||
)
|
||||
|
||||
qs = models.UserReconciliation.objects.filter(id=rec.id)
|
||||
process_reconciliation(None, None, qs)
|
||||
|
||||
rec.refresh_from_db()
|
||||
user_1.refresh_from_db()
|
||||
user_2.refresh_from_db()
|
||||
u1_2.refresh_from_db(
|
||||
from_queryset=models.DocumentAccess.objects.select_for_update()
|
||||
)
|
||||
u1_5.refresh_from_db(
|
||||
from_queryset=models.DocumentAccess.objects.select_for_update()
|
||||
)
|
||||
|
||||
# Inactive user should have no linktraces
|
||||
assert models.LinkTrace.objects.filter(user=user_2).count() == 0
|
||||
|
||||
# doc_both should have a single LinkTrace owned by the active user
|
||||
assert (
|
||||
models.LinkTrace.objects.filter(user=user_1, document=doc_both).exists() is True
|
||||
)
|
||||
assert models.LinkTrace.objects.filter(user=user_1, document=doc_both).count() == 1
|
||||
assert (
|
||||
models.LinkTrace.objects.filter(user=user_2, document=doc_both).exists()
|
||||
is False
|
||||
)
|
||||
|
||||
# doc_inactive_only should now be linked to active user and preserve is_masked
|
||||
lt = models.LinkTrace.objects.filter(
|
||||
user=user_1, document=doc_inactive_only
|
||||
).first()
|
||||
assert lt is not None
|
||||
assert lt.is_masked is True
|
||||
|
||||
# doc_active_only should still belong to active user
|
||||
assert models.LinkTrace.objects.filter(
|
||||
user=user_1, document=doc_active_only
|
||||
).exists()
|
||||
|
||||
|
||||
def test_process_reconciliation_updates_threads_comments_reactions(
|
||||
user_reconciliation_users_and_docs,
|
||||
):
|
||||
"""Test that threads, comments and reactions are transferred/deduplicated
|
||||
on reconciliation."""
|
||||
user_1, user_2, _userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
|
||||
|
||||
# Use a document from the inactive user's set
|
||||
document = userdocs_u2[0].document
|
||||
|
||||
# Thread and comment created by inactive user -> should be moved to active
|
||||
thread = factories.ThreadFactory(document=document, creator=user_2)
|
||||
comment = factories.CommentFactory(thread=thread, user=user_2)
|
||||
|
||||
# Reaction where only inactive user reacted -> should be moved to active user
|
||||
reaction_inactive_only = factories.ReactionFactory(comment=comment, users=[user_2])
|
||||
|
||||
# Reaction where both users reacted -> inactive user's participation should be removed
|
||||
thread2 = factories.ThreadFactory(document=document, creator=user_1)
|
||||
comment2 = factories.CommentFactory(thread=thread2, user=user_1)
|
||||
reaction_both = factories.ReactionFactory(comment=comment2, users=[user_1, user_2])
|
||||
|
||||
# Reaction where only active user reacted -> unchanged
|
||||
thread3 = factories.ThreadFactory(document=document, creator=user_1)
|
||||
comment3 = factories.CommentFactory(thread=thread3, user=user_1)
|
||||
reaction_active_only = factories.ReactionFactory(comment=comment3, users=[user_1])
|
||||
|
||||
rec = models.UserReconciliation.objects.create(
|
||||
active_email=user_1.email,
|
||||
inactive_email=user_2.email,
|
||||
active_user=user_1,
|
||||
inactive_user=user_2,
|
||||
active_email_checked=True,
|
||||
inactive_email_checked=True,
|
||||
status="ready",
|
||||
)
|
||||
|
||||
qs = models.UserReconciliation.objects.filter(id=rec.id)
|
||||
process_reconciliation(None, None, qs)
|
||||
|
||||
# Refresh objects
|
||||
thread.refresh_from_db()
|
||||
comment.refresh_from_db()
|
||||
reaction_inactive_only.refresh_from_db()
|
||||
reaction_both.refresh_from_db()
|
||||
reaction_active_only.refresh_from_db()
|
||||
|
||||
# Thread and comment creator should now be the active user
|
||||
assert thread.creator == user_1
|
||||
assert comment.user == user_1
|
||||
|
||||
# reaction_inactive_only: inactive user's participation should be removed and
|
||||
# active user's participation added
|
||||
reaction_inactive_only.refresh_from_db()
|
||||
assert not reaction_inactive_only.users.filter(pk=user_2.pk).exists()
|
||||
assert reaction_inactive_only.users.filter(pk=user_1.pk).exists()
|
||||
|
||||
# reaction_both: should end up with only active user's participation
|
||||
assert reaction_both.users.filter(pk=user_2.pk).exists() is False
|
||||
assert reaction_both.users.filter(pk=user_1.pk).exists() is True
|
||||
|
||||
# reaction_active_only should still have active user's participation
|
||||
assert reaction_active_only.users.filter(pk=user_1.pk).exists()
|
||||
|
||||
|
||||
def test_process_reconciliation_updates_favorites(
|
||||
user_reconciliation_users_and_docs,
|
||||
):
|
||||
"""Test that favorites are consolidated on the active user."""
|
||||
user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
|
||||
|
||||
u1_2 = userdocs_u1[2]
|
||||
u1_5 = userdocs_u1[5]
|
||||
|
||||
doc_both = u1_2.document
|
||||
models.DocumentFavorite.objects.create(document=doc_both, user=user_1)
|
||||
models.DocumentFavorite.objects.create(document=doc_both, user=user_2)
|
||||
|
||||
doc_inactive_only = userdocs_u2[4].document
|
||||
models.DocumentFavorite.objects.create(document=doc_inactive_only, user=user_2)
|
||||
|
||||
doc_active_only = userdocs_u1[4].document
|
||||
models.DocumentFavorite.objects.create(document=doc_active_only, user=user_1)
|
||||
|
||||
rec = models.UserReconciliation.objects.create(
|
||||
active_email=user_1.email,
|
||||
inactive_email=user_2.email,
|
||||
active_user=user_1,
|
||||
inactive_user=user_2,
|
||||
active_email_checked=True,
|
||||
inactive_email_checked=True,
|
||||
status="ready",
|
||||
)
|
||||
|
||||
qs = models.UserReconciliation.objects.filter(id=rec.id)
|
||||
process_reconciliation(None, None, qs)
|
||||
|
||||
rec.refresh_from_db()
|
||||
user_1.refresh_from_db()
|
||||
user_2.refresh_from_db()
|
||||
u1_2.refresh_from_db(
|
||||
from_queryset=models.DocumentAccess.objects.select_for_update()
|
||||
)
|
||||
u1_5.refresh_from_db(
|
||||
from_queryset=models.DocumentAccess.objects.select_for_update()
|
||||
)
|
||||
|
||||
# Inactive user should have no document favorites
|
||||
assert models.DocumentFavorite.objects.filter(user=user_2).count() == 0
|
||||
|
||||
# doc_both should have a single DocumentFavorite owned by the active user
|
||||
assert (
|
||||
models.DocumentFavorite.objects.filter(user=user_1, document=doc_both).exists()
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
models.DocumentFavorite.objects.filter(user=user_1, document=doc_both).count()
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
models.DocumentFavorite.objects.filter(user=user_2, document=doc_both).exists()
|
||||
is False
|
||||
)
|
||||
|
||||
# doc_inactive_only should now be linked to active user
|
||||
assert (
|
||||
models.DocumentFavorite.objects.filter(
|
||||
user=user_2, document=doc_inactive_only
|
||||
).count()
|
||||
== 0
|
||||
)
|
||||
assert models.DocumentFavorite.objects.filter(
|
||||
user=user_1, document=doc_inactive_only
|
||||
).exists()
|
||||
|
||||
# doc_active_only should still belong to active user
|
||||
assert models.DocumentFavorite.objects.filter(
|
||||
user=user_1, document=doc_active_only
|
||||
).exists()
|
||||
@@ -2,6 +2,8 @@
|
||||
Unit tests for the User model
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
@@ -24,6 +26,26 @@ def test_models_users_id_unique():
|
||||
factories.UserFactory(id=user.id)
|
||||
|
||||
|
||||
def test_models_users_send_mail_main_existing():
|
||||
"""The "email_user' method should send mail to the user's email address."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
with mock.patch("django.core.mail.send_mail") as mock_send:
|
||||
user.email_user("my subject", "my message")
|
||||
|
||||
mock_send.assert_called_once_with("my subject", "my message", None, [user.email])
|
||||
|
||||
|
||||
def test_models_users_send_mail_main_missing():
|
||||
"""The "email_user' method should fail if the user has no email address."""
|
||||
user = factories.UserFactory(email=None)
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
user.email_user("my subject", "my message")
|
||||
|
||||
assert str(excinfo.value) == "User has no email address."
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sub,is_valid",
|
||||
[
|
||||
|
||||
+98
-23
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Unit tests for the Document model
|
||||
Unit tests for FindDocumentIndexer
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
@@ -12,7 +12,8 @@ from django.db import transaction
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.services.search_indexers import SearchIndexer
|
||||
from core.enums import SearchType
|
||||
from core.services.search_indexers import FindDocumentIndexer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -30,7 +31,7 @@ def reset_throttle():
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer(mock_push):
|
||||
@@ -41,7 +42,7 @@ def test_models_documents_post_save_indexer(mock_push):
|
||||
accesses = {}
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
assert len(data) == 1
|
||||
|
||||
@@ -64,14 +65,14 @@ def test_models_documents_post_save_indexer_no_batches(indexer_settings):
|
||||
"""Test indexation task on doculment creation, no throttle"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
accesses = {}
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
# 3 calls
|
||||
assert len(data) == 3
|
||||
@@ -91,7 +92,7 @@ def test_models_documents_post_save_indexer_no_batches(indexer_settings):
|
||||
assert cache.get("file-batch-indexer-throttle") is None
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_settings):
|
||||
"""Task should not start an indexation when disabled"""
|
||||
@@ -106,13 +107,13 @@ def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_se
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_wrongly_configured(
|
||||
mock_push, indexer_settings
|
||||
):
|
||||
"""Task should not start an indexation when disabled"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = None
|
||||
indexer_settings.INDEXING_URL = None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -123,7 +124,7 @@ def test_models_documents_post_save_indexer_wrongly_configured(
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_with_accesses(mock_push):
|
||||
@@ -145,7 +146,7 @@ def test_models_documents_post_save_indexer_with_accesses(mock_push):
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
assert len(data) == 1
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
@@ -158,7 +159,7 @@ def test_models_documents_post_save_indexer_with_accesses(mock_push):
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_deleted(mock_push):
|
||||
@@ -207,7 +208,7 @@ def test_models_documents_post_save_indexer_deleted(mock_push):
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
assert len(data) == 2
|
||||
|
||||
@@ -244,14 +245,14 @@ def test_models_documents_indexer_hard_deleted():
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
# Call task on deleted document.
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
doc.delete()
|
||||
|
||||
# Hard delete document are not re-indexed.
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_restored(mock_push):
|
||||
@@ -308,7 +309,7 @@ def test_models_documents_post_save_indexer_restored(mock_push):
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
# All docs are re-indexed
|
||||
assert len(data) == 2
|
||||
@@ -337,16 +338,16 @@ def test_models_documents_post_save_indexer_restored(mock_push):
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_models_documents_post_save_indexer_throttle():
|
||||
"""Test indexation task skipping on document update"""
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
user = factories.UserFactory()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push"):
|
||||
with mock.patch.object(FindDocumentIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
docs = factories.DocumentFactory.create_batch(5, users=(user,))
|
||||
|
||||
accesses = {str(item.path): {"users": [user.sub]} for item in docs}
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
# Simulate 1 running task
|
||||
cache.set("document-batch-indexer-throttle", 1)
|
||||
|
||||
@@ -359,7 +360,7 @@ def test_models_documents_post_save_indexer_throttle():
|
||||
|
||||
assert [call.args[0] for call in mock_push.call_args_list] == []
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
# No waiting task
|
||||
cache.delete("document-batch-indexer-throttle")
|
||||
|
||||
@@ -389,7 +390,7 @@ def test_models_documents_access_post_save_indexer():
|
||||
"""Test indexation task on DocumentAccess update"""
|
||||
users = factories.UserFactory.create_batch(3)
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push"):
|
||||
with mock.patch.object(FindDocumentIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(users=users)
|
||||
doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by(
|
||||
@@ -398,7 +399,7 @@ def test_models_documents_access_post_save_indexer():
|
||||
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
for doc_access in doc_accesses:
|
||||
doc_access.save()
|
||||
@@ -426,7 +427,7 @@ def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
|
||||
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
for doc_access in doc_accesses:
|
||||
doc_access.save()
|
||||
@@ -439,3 +440,77 @@ def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
|
||||
assert [len(d) for d in data] == [1] * 3
|
||||
# the same document is indexed 3 times
|
||||
assert [d[0]["id"] for d in data] == [str(doc.pk)] * 3
|
||||
|
||||
|
||||
@mock.patch.object(FindDocumentIndexer, "search_query")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_find_document_indexer_search(mock_search_query):
|
||||
"""Test search function of FindDocumentIndexer returns formatted results"""
|
||||
|
||||
# Mock API response from Find
|
||||
hits = [
|
||||
{
|
||||
"_id": "doc-123",
|
||||
"_source": {
|
||||
"title": "Test Document",
|
||||
"content": "This is test content",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
"path": "/some/path/doc-123",
|
||||
},
|
||||
},
|
||||
{
|
||||
"_id": "doc-456",
|
||||
"_source": {
|
||||
"title.fr": "Document de test",
|
||||
"content": "Contenu de test",
|
||||
"updated_at": "2024-01-02T00:00:00Z",
|
||||
},
|
||||
},
|
||||
]
|
||||
mock_search_query.return_value = hits
|
||||
|
||||
q = "test"
|
||||
token = "fake-token"
|
||||
nb_results = 10
|
||||
path = "/some/path/"
|
||||
visited = ["doc-123"]
|
||||
search_type = SearchType.HYBRID
|
||||
results = FindDocumentIndexer().search(
|
||||
q=q,
|
||||
token=token,
|
||||
nb_results=nb_results,
|
||||
path=path,
|
||||
visited=visited,
|
||||
search_type=search_type,
|
||||
)
|
||||
|
||||
mock_search_query.assert_called_once()
|
||||
call_args = mock_search_query.call_args
|
||||
assert call_args[1]["data"] == {
|
||||
"q": q,
|
||||
"visited": visited,
|
||||
"services": ["docs"],
|
||||
"nb_results": nb_results,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
"path": path,
|
||||
"search_type": search_type,
|
||||
}
|
||||
|
||||
assert len(results) == 2
|
||||
assert results == [
|
||||
{
|
||||
"id": hits[0]["_id"],
|
||||
"title": hits[0]["_source"]["title"],
|
||||
"content": hits[0]["_source"]["content"],
|
||||
"updated_at": hits[0]["_source"]["updated_at"],
|
||||
"path": hits[0]["_source"]["path"],
|
||||
},
|
||||
{
|
||||
"id": hits[1]["_id"],
|
||||
"title": hits[1]["_source"]["title.fr"],
|
||||
"title.fr": hits[1]["_source"]["title.fr"], # <- Find response artefact
|
||||
"content": hits[1]["_source"]["content"],
|
||||
"updated_at": hits[1]["_source"]["updated_at"],
|
||||
},
|
||||
]
|
||||
@@ -15,7 +15,7 @@ from requests import HTTPError
|
||||
from core import factories, models, utils
|
||||
from core.services.search_indexers import (
|
||||
BaseDocumentIndexer,
|
||||
SearchIndexer,
|
||||
FindDocumentIndexer,
|
||||
get_document_indexer,
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
@@ -78,41 +78,41 @@ def test_services_search_indexer_is_configured(indexer_settings):
|
||||
|
||||
# Valid class
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = (
|
||||
"core.services.search_indexers.SearchIndexer"
|
||||
"core.services.search_indexers.FindDocumentIndexer"
|
||||
)
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_URL = ""
|
||||
indexer_settings.INDEXING_URL = ""
|
||||
|
||||
# Invalid url
|
||||
get_document_indexer.cache_clear()
|
||||
assert not get_document_indexer()
|
||||
|
||||
|
||||
def test_services_search_indexer_url_is_none(indexer_settings):
|
||||
def test_services_indexing_url_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is None or empty.
|
||||
Indexer should raise RuntimeError if INDEXING_URL is None or empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = None
|
||||
indexer_settings.INDEXING_URL = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
|
||||
assert "INDEXING_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_indexer_url_is_empty(indexer_settings):
|
||||
def test_services_indexing_url_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is empty string.
|
||||
Indexer should raise RuntimeError if INDEXING_URL is empty string.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = ""
|
||||
indexer_settings.INDEXING_URL = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
|
||||
assert "INDEXING_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_indexer_secret_is_none(indexer_settings):
|
||||
@@ -122,7 +122,7 @@ def test_services_search_indexer_secret_is_none(indexer_settings):
|
||||
indexer_settings.SEARCH_INDEXER_SECRET = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
@@ -136,39 +136,35 @@ def test_services_search_indexer_secret_is_empty(indexer_settings):
|
||||
indexer_settings.SEARCH_INDEXER_SECRET = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_endpoint_is_none(indexer_settings):
|
||||
def test_services_search_url_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is None.
|
||||
Indexer should raise RuntimeError if SEARCH_URL is None.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
indexer_settings.SEARCH_URL = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
assert "SEARCH_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_endpoint_is_empty(indexer_settings):
|
||||
def test_services_search_url_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is empty.
|
||||
Indexer should raise RuntimeError if SEARCH_URL is empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = ""
|
||||
indexer_settings.SEARCH_URL = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
assert "SEARCH_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@@ -192,7 +188,7 @@ def test_services_search_indexers_serialize_document_returns_expected_json():
|
||||
}
|
||||
}
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
result = indexer.serialize_document(document, accesses)
|
||||
|
||||
assert set(result.pop("users")) == {str(user_a.sub), str(user_b.sub)}
|
||||
@@ -221,7 +217,7 @@ def test_services_search_indexers_serialize_document_deleted():
|
||||
parent.soft_delete()
|
||||
document.refresh_from_db()
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["is_active"] is False
|
||||
@@ -232,7 +228,7 @@ def test_services_search_indexers_serialize_document_empty():
|
||||
"""Empty documents returns empty content in the serialized json."""
|
||||
document = factories.DocumentFactory(content="", title=None)
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["content"] == ""
|
||||
@@ -246,7 +242,7 @@ def test_services_search_indexers_index_errors(indexer_settings):
|
||||
"""
|
||||
factories.DocumentFactory()
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_URL = "http://app-find/api/v1.0/documents/index/"
|
||||
indexer_settings.INDEXING_URL = "http://app-find/api/v1.0/documents/index/"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
@@ -256,10 +252,10 @@ def test_services_search_indexers_index_errors(indexer_settings):
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
SearchIndexer().index()
|
||||
FindDocumentIndexer().index()
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
mock_push, indexer_settings
|
||||
):
|
||||
@@ -276,7 +272,7 @@ def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
expected_user_subs[str(document.id)] = str(access.user.sub)
|
||||
|
||||
assert SearchIndexer().index() == 5
|
||||
assert FindDocumentIndexer().index() == 5
|
||||
|
||||
# Should be 3 batches: 2 + 2 + 1
|
||||
assert mock_push.call_count == 3
|
||||
@@ -299,7 +295,7 @@ def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
assert seen_doc_ids == {str(d.id) for d in documents}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_batch_size_argument(mock_push):
|
||||
"""
|
||||
@@ -314,7 +310,7 @@ def test_services_search_indexers_batch_size_argument(mock_push):
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
expected_user_subs[str(document.id)] = str(access.user.sub)
|
||||
|
||||
assert SearchIndexer().index(batch_size=2) == 5
|
||||
assert FindDocumentIndexer().index(batch_size=2) == 5
|
||||
|
||||
# Should be 3 batches: 2 + 2 + 1
|
||||
assert mock_push.call_count == 3
|
||||
@@ -337,7 +333,7 @@ def test_services_search_indexers_batch_size_argument(mock_push):
|
||||
assert seen_doc_ids == {str(d.id) for d in documents}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ignore_empty_documents(mock_push):
|
||||
"""
|
||||
@@ -349,7 +345,7 @@ def test_services_search_indexers_ignore_empty_documents(mock_push):
|
||||
empty_title = factories.DocumentFactory(title="")
|
||||
empty_content = factories.DocumentFactory(content="")
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
|
||||
assert mock_push.call_count == 1
|
||||
|
||||
@@ -365,7 +361,7 @@ def test_services_search_indexers_ignore_empty_documents(mock_push):
|
||||
}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings):
|
||||
"""
|
||||
Documents indexing batch can be empty if all the docs are empty.
|
||||
@@ -377,14 +373,14 @@ def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings
|
||||
# Only empty docs
|
||||
factories.DocumentFactory.create_batch(5, content="", title="")
|
||||
|
||||
assert SearchIndexer().index() == 1
|
||||
assert FindDocumentIndexer().index() == 1
|
||||
assert mock_push.call_count == 1
|
||||
|
||||
results = [doc["id"] for doc in mock_push.call_args[0][0]]
|
||||
assert results == [str(document.id)]
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_link_reach(mock_push):
|
||||
"""Document accesses and reach should take into account ancestors link reaches."""
|
||||
@@ -395,7 +391,7 @@ def test_services_search_indexers_ancestors_link_reach(mock_push):
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="public")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
|
||||
assert SearchIndexer().index() == 4
|
||||
assert FindDocumentIndexer().index() == 4
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 4
|
||||
@@ -405,7 +401,7 @@ def test_services_search_indexers_ancestors_link_reach(mock_push):
|
||||
assert results[str(document.id)]["reach"] == "public"
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_users(mock_push):
|
||||
"""Document accesses and reach should include users from ancestors."""
|
||||
@@ -415,7 +411,7 @@ def test_services_search_indexers_ancestors_users(mock_push):
|
||||
parent = factories.DocumentFactory(parent=grand_parent, users=[user_p])
|
||||
document = factories.DocumentFactory(parent=parent, users=[user_d])
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
@@ -428,7 +424,7 @@ def test_services_search_indexers_ancestors_users(mock_push):
|
||||
}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_teams(mock_push):
|
||||
"""Document accesses and reach should include teams from ancestors."""
|
||||
@@ -436,7 +432,7 @@ def test_services_search_indexers_ancestors_teams(mock_push):
|
||||
parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"])
|
||||
document = factories.DocumentFactory(parent=parent, teams=["team_d"])
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
@@ -451,9 +447,9 @@ def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
|
||||
push() should call requests.post with the correct URL from settings
|
||||
the timeout set to 10 seconds and the data as JSON.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = "http://example.com/index"
|
||||
indexer_settings.INDEXING_URL = "http://example.com/index"
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
sample_data = [{"id": "123", "title": "Test"}]
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
@@ -464,7 +460,7 @@ def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
|
||||
mock_post.assert_called_once()
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_URL
|
||||
assert args[0] == indexer_settings.INDEXING_URL
|
||||
assert kwargs.get("json") == sample_data
|
||||
assert kwargs.get("timeout") == 10
|
||||
|
||||
@@ -498,7 +494,7 @@ def test_get_visited_document_ids_of():
|
||||
factories.UserDocumentAccessFactory(user=user, document=doc2)
|
||||
|
||||
# The second document have an access for the user
|
||||
assert get_visited_document_ids_of(queryset, user) == [str(doc1.pk)]
|
||||
assert get_visited_document_ids_of(queryset, user) == (str(doc1.pk),)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@@ -532,7 +528,7 @@ def test_get_visited_document_ids_of_deleted():
|
||||
doc_deleted.soft_delete()
|
||||
|
||||
# Only the first document is not deleted
|
||||
assert get_visited_document_ids_of(queryset, user) == [str(doc.pk)]
|
||||
assert get_visited_document_ids_of(queryset, user) == (str(doc.pk),)
|
||||
|
||||
|
||||
@responses.activate
|
||||
@@ -542,9 +538,7 @@ def test_services_search_indexers_search_errors(indexer_settings):
|
||||
"""
|
||||
factories.DocumentFactory()
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://app-find/api/v1.0/documents/search/"
|
||||
)
|
||||
indexer_settings.SEARCH_URL = "http://app-find/api/v1.0/documents/search/"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
@@ -554,17 +548,17 @@ def test_services_search_indexers_search_errors(indexer_settings):
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
SearchIndexer().search("alpha", token="mytoken")
|
||||
FindDocumentIndexer().search(q="alpha", token="mytoken")
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_services_search_indexers_search(mock_post, indexer_settings):
|
||||
"""
|
||||
search() should call requests.post to SEARCH_INDEXER_QUERY_URL with the
|
||||
search() should call requests.post to SEARCH_URL with the
|
||||
document ids from linktraces.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
@@ -578,11 +572,11 @@ def test_services_search_indexers_search(mock_post, indexer_settings):
|
||||
|
||||
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
|
||||
|
||||
indexer.search("alpha", visited=visited, token="mytoken")
|
||||
indexer.search(q="alpha", visited=visited, token="mytoken")
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
assert args[0] == indexer_settings.SEARCH_URL
|
||||
|
||||
query_data = kwargs.get("json")
|
||||
assert query_data["q"] == "alpha"
|
||||
@@ -605,7 +599,7 @@ def test_services_search_indexers_search_nb_results(mock_post, indexer_settings)
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_LIMIT = 25
|
||||
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
@@ -619,17 +613,65 @@ def test_services_search_indexers_search_nb_results(mock_post, indexer_settings)
|
||||
|
||||
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
|
||||
|
||||
indexer.search("alpha", visited=visited, token="mytoken")
|
||||
indexer.search(q="alpha", visited=visited, token="mytoken")
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
assert args[0] == indexer_settings.SEARCH_URL
|
||||
assert kwargs.get("json")["nb_results"] == 25
|
||||
|
||||
# The argument overrides the setting value
|
||||
indexer.search("alpha", visited=visited, token="mytoken", nb_results=109)
|
||||
indexer.search(q="alpha", visited=visited, token="mytoken", nb_results=109)
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
assert args[0] == indexer_settings.SEARCH_URL
|
||||
assert kwargs.get("json")["nb_results"] == 109
|
||||
|
||||
|
||||
def test_search_indexer_get_title_with_localized_field():
|
||||
"""Test extracting title from localized title field."""
|
||||
source = {"title.extension": "Bonjour", "id": 1, "content": "test"}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == "Bonjour"
|
||||
|
||||
|
||||
def test_search_indexer_get_title_with_multiple_localized_fields():
|
||||
"""Test that first matching localized title is returned."""
|
||||
source = {"title.extension": "Bonjour", "title.en": "Hello", "id": 1}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result in ["Bonjour", "Hello"]
|
||||
|
||||
|
||||
def test_search_indexer_get_title_fallback_to_plain_title():
|
||||
"""Test fallback to plain 'title' field when no localized field exists."""
|
||||
source = {"title": "Hello World", "id": 1}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == "Hello World"
|
||||
|
||||
|
||||
def test_search_indexer_get_title_no_title_field():
|
||||
"""Test that empty string is returned when no title field exists."""
|
||||
source = {"id": 1, "content": "test"}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_search_indexer_get_title_with_empty_localized_title():
|
||||
"""Test that fallback works when localized title is empty."""
|
||||
source = {"title.extension": "", "title": "Fallback Title", "id": 1}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == "Fallback Title"
|
||||
|
||||
|
||||
def test_search_indexer_get_title_with_multiple_extension():
|
||||
"""Test extracting title from title field with multiple extensions."""
|
||||
source = {"title.extension_1.extension_2": "Bonjour", "id": 1, "content": "test"}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == "Bonjour"
|
||||
|
||||
@@ -3,14 +3,9 @@
|
||||
import base64
|
||||
import uuid
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
import pycrdt
|
||||
import pytest
|
||||
|
||||
from core import factories, utils
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
from core import utils
|
||||
|
||||
# This base64 string is an example of what is saved in the database.
|
||||
# This base64 is generated from the blocknote editor, it contains
|
||||
@@ -107,101 +102,36 @@ def test_utils_get_ancestor_to_descendants_map_multiple_paths():
|
||||
}
|
||||
|
||||
|
||||
def test_utils_users_sharing_documents_with_cache_miss():
|
||||
"""Test cache miss: should query database and cache result."""
|
||||
user1 = factories.UserFactory()
|
||||
user2 = factories.UserFactory()
|
||||
user3 = factories.UserFactory()
|
||||
doc1 = factories.DocumentFactory()
|
||||
doc2 = factories.DocumentFactory()
|
||||
def test_utils_get_value_by_pattern_matching_key():
|
||||
"""Test extracting value from a dictionary with a matching key pattern."""
|
||||
data = {"title.extension": "Bonjour", "id": 1, "content": "test"}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
factories.UserDocumentAccessFactory(user=user1, document=doc1)
|
||||
factories.UserDocumentAccessFactory(user=user2, document=doc1)
|
||||
factories.UserDocumentAccessFactory(user=user3, document=doc2)
|
||||
|
||||
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
|
||||
cache.delete(cache_key)
|
||||
|
||||
result = utils.users_sharing_documents_with(user1)
|
||||
|
||||
assert user2.id in result
|
||||
|
||||
cached_data = cache.get(cache_key)
|
||||
assert cached_data == result
|
||||
assert set(result) == {"Bonjour"}
|
||||
|
||||
|
||||
def test_utils_users_sharing_documents_with_cache_hit():
|
||||
"""Test cache hit: should return cached data without querying database."""
|
||||
user1 = factories.UserFactory()
|
||||
user2 = factories.UserFactory()
|
||||
doc1 = factories.DocumentFactory()
|
||||
def test_utils_get_value_by_pattern_multiple_matches():
|
||||
"""Test that all matching keys are returned."""
|
||||
data = {"title.extension_1": "Bonjour", "title.extension_2": "Hello", "id": 1}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
factories.UserDocumentAccessFactory(user=user1, document=doc1)
|
||||
factories.UserDocumentAccessFactory(user=user2, document=doc1)
|
||||
|
||||
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
|
||||
|
||||
test_cached_data = {user2.id: "2025-02-10"}
|
||||
cache.set(cache_key, test_cached_data, 86400)
|
||||
|
||||
result = utils.users_sharing_documents_with(user1)
|
||||
assert result == test_cached_data
|
||||
assert set(result) == {
|
||||
"Bonjour",
|
||||
"Hello",
|
||||
}
|
||||
|
||||
|
||||
def test_utils_users_sharing_documents_with_cache_invalidation_on_create():
|
||||
"""Test that cache is invalidated when a DocumentAccess is created."""
|
||||
# Create test data
|
||||
user1 = factories.UserFactory()
|
||||
user2 = factories.UserFactory()
|
||||
doc1 = factories.DocumentFactory()
|
||||
def test_utils_get_value_by_pattern_multiple_extensions():
|
||||
"""Test that all matching keys are returned."""
|
||||
data = {"title.extension_1.extension_2": "Bonjour", "id": 1}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
# Pre-populate cache
|
||||
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
|
||||
cache.set(cache_key, {}, 86400)
|
||||
|
||||
# Verify cache exists
|
||||
assert cache.get(cache_key) is not None
|
||||
|
||||
# Create new DocumentAccess
|
||||
factories.UserDocumentAccessFactory(user=user2, document=doc1)
|
||||
|
||||
# Cache should still exist (only created for user2 who was added)
|
||||
# But if we create access for user1 being shared with, cache should be cleared
|
||||
cache.set(cache_key, {"test": "data"}, 86400)
|
||||
factories.UserDocumentAccessFactory(user=user1, document=doc1)
|
||||
|
||||
# Cache for user1 should be invalidated (cleared)
|
||||
assert cache.get(cache_key) is None
|
||||
assert set(result) == {"Bonjour"}
|
||||
|
||||
|
||||
def test_utils_users_sharing_documents_with_cache_invalidation_on_delete():
|
||||
"""Test that cache is invalidated when a DocumentAccess is deleted."""
|
||||
user1 = factories.UserFactory()
|
||||
user2 = factories.UserFactory()
|
||||
doc1 = factories.DocumentFactory()
|
||||
def test_utils_get_value_by_pattern_no_match():
|
||||
"""Test that empty list is returned when no key matches the pattern."""
|
||||
data = {"name": "Test", "id": 1}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
doc_access = factories.UserDocumentAccessFactory(user=user1, document=doc1)
|
||||
|
||||
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
|
||||
cache.set(cache_key, {user2.id: "2025-02-10"}, 86400)
|
||||
|
||||
assert cache.get(cache_key) is not None
|
||||
|
||||
doc_access.delete()
|
||||
|
||||
assert cache.get(cache_key) is None
|
||||
|
||||
|
||||
def test_utils_users_sharing_documents_with_empty_result():
|
||||
"""Test when user is not sharing any documents."""
|
||||
user1 = factories.UserFactory()
|
||||
|
||||
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
|
||||
cache.delete(cache_key)
|
||||
|
||||
result = utils.users_sharing_documents_with(user1)
|
||||
|
||||
assert result == {}
|
||||
|
||||
cached_data = cache.get(cache_key)
|
||||
assert cached_data == {}
|
||||
assert result == []
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Tests for utils.users_sharing_documents_with function."""
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, utils
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_utils_users_sharing_documents_with():
|
||||
"""Test users_sharing_documents_with function."""
|
||||
|
||||
user = factories.UserFactory(
|
||||
email="martin.bernard@anct.gouv.fr", full_name="Martin Bernard"
|
||||
)
|
||||
|
||||
pierre_1 = factories.UserFactory(
|
||||
email="pierre.dupont@beta.gouv.fr", full_name="Pierre Dupont"
|
||||
)
|
||||
pierre_2 = factories.UserFactory(
|
||||
email="pierre.durand@impots.gouv.fr", full_name="Pierre Durand"
|
||||
)
|
||||
|
||||
now = timezone.now()
|
||||
yesterday = now - timezone.timedelta(days=1)
|
||||
last_week = now - timezone.timedelta(days=7)
|
||||
last_month = now - timezone.timedelta(days=30)
|
||||
|
||||
document_1 = factories.DocumentFactory(creator=user)
|
||||
document_2 = factories.DocumentFactory(creator=user)
|
||||
document_3 = factories.DocumentFactory(creator=user)
|
||||
|
||||
factories.UserDocumentAccessFactory(user=user, document=document_1)
|
||||
factories.UserDocumentAccessFactory(user=user, document=document_2)
|
||||
factories.UserDocumentAccessFactory(user=user, document=document_3)
|
||||
|
||||
# The factory cannot set the created_at directly, so we force it after creation
|
||||
doc_1_pierre_1 = factories.UserDocumentAccessFactory(
|
||||
user=pierre_1, document=document_1, created_at=last_week
|
||||
)
|
||||
doc_1_pierre_1.created_at = last_week
|
||||
doc_1_pierre_1.save()
|
||||
doc_2_pierre_2 = factories.UserDocumentAccessFactory(
|
||||
user=pierre_2, document=document_2
|
||||
)
|
||||
doc_2_pierre_2.created_at = last_month
|
||||
doc_2_pierre_2.save()
|
||||
|
||||
doc_3_pierre_2 = factories.UserDocumentAccessFactory(
|
||||
user=pierre_2, document=document_3
|
||||
)
|
||||
doc_3_pierre_2.created_at = yesterday
|
||||
doc_3_pierre_2.save()
|
||||
|
||||
shared_map = utils.users_sharing_documents_with(user)
|
||||
|
||||
assert shared_map == {
|
||||
pierre_1.id: last_week,
|
||||
pierre_2.id: yesterday,
|
||||
}
|
||||
@@ -59,10 +59,6 @@ urlpatterns = [
|
||||
r"^documents/(?P<resource_id>[0-9a-z-]*)/threads/(?P<thread_id>[0-9a-z-]*)/",
|
||||
include(thread_related_router.urls),
|
||||
),
|
||||
path(
|
||||
"user-reconciliations/<str:user_type>/<uuid:confirmation_id>/",
|
||||
viewsets.ReconciliationConfirmView.as_view(),
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
|
||||
+21
-51
@@ -1,21 +1,34 @@
|
||||
"""Utils for the core app."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import models as db
|
||||
from django.db.models import Subquery
|
||||
|
||||
import pycrdt
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from core import enums, models
|
||||
from core import enums
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_value_by_pattern(data, pattern):
|
||||
"""
|
||||
Get all values from keys matching a regex pattern in a dictionary.
|
||||
|
||||
Args:
|
||||
data (dict): Source dictionary to search
|
||||
pattern (str): Regex pattern to match against keys
|
||||
|
||||
Returns:
|
||||
list: List of values for all matching keys, empty list if no matches
|
||||
|
||||
Example:
|
||||
>>> get_value_by_pattern({"title.fr": "Bonjour", "id": 1}, r"^title\\.")
|
||||
["Bonjour"]
|
||||
>>> get_value_by_pattern({"title.fr": "Bonjour", "title.en": "Hello"}, r"^title\\.")
|
||||
["Bonjour", "Hello"]
|
||||
"""
|
||||
regex = re.compile(pattern)
|
||||
return [value for key, value in data.items() if regex.match(key)]
|
||||
|
||||
|
||||
def get_ancestor_to_descendants_map(paths, steplen):
|
||||
@@ -104,46 +117,3 @@ def extract_attachments(content):
|
||||
|
||||
xml_content = base64_yjs_to_xml(content)
|
||||
return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content)
|
||||
|
||||
|
||||
def get_users_sharing_documents_with_cache_key(user):
|
||||
"""Generate a unique cache key for each user."""
|
||||
return f"users_sharing_documents_with_{user.id}"
|
||||
|
||||
|
||||
def users_sharing_documents_with(user):
|
||||
"""
|
||||
Returns a map of users sharing documents with the given user,
|
||||
sorted by last shared date.
|
||||
"""
|
||||
start_time = time.time()
|
||||
cache_key = get_users_sharing_documents_with_cache_key(user)
|
||||
cached_result = cache.get(cache_key)
|
||||
|
||||
if cached_result is not None:
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(
|
||||
"users_sharing_documents_with cache hit for user %s (took %.3fs)",
|
||||
user.id,
|
||||
elapsed,
|
||||
)
|
||||
return cached_result
|
||||
|
||||
user_docs_qs = models.DocumentAccess.objects.filter(user=user).values_list(
|
||||
"document_id", flat=True
|
||||
)
|
||||
shared_qs = (
|
||||
models.DocumentAccess.objects.filter(document_id__in=Subquery(user_docs_qs))
|
||||
.exclude(user=user)
|
||||
.values("user")
|
||||
.annotate(last_shared=db.Max("created_at"))
|
||||
)
|
||||
result = {item["user"]: item["last_shared"] for item in shared_qs}
|
||||
cache.set(cache_key, result, 86400) # Cache for 1 day
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(
|
||||
"users_sharing_documents_with cache miss for user %s (took %.3fs)",
|
||||
user.id,
|
||||
elapsed,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -112,8 +112,8 @@ class Base(Configuration):
|
||||
SEARCH_INDEXER_BATCH_SIZE = values.IntegerValue(
|
||||
default=100_000, environ_name="SEARCH_INDEXER_BATCH_SIZE", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_URL", environ_prefix=None
|
||||
INDEXING_URL = values.Value(
|
||||
default=None, environ_name="INDEXING_URL", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_COUNTDOWN = values.IntegerValue(
|
||||
default=1, environ_name="SEARCH_INDEXER_COUNTDOWN", environ_prefix=None
|
||||
@@ -121,8 +121,8 @@ class Base(Configuration):
|
||||
SEARCH_INDEXER_SECRET = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_SECRET", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_QUERY_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_QUERY_URL", environ_prefix=None
|
||||
SEARCH_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_URL", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_QUERY_LIMIT = values.PositiveIntegerValue(
|
||||
default=50, environ_name="SEARCH_INDEXER_QUERY_LIMIT", environ_prefix=None
|
||||
@@ -329,6 +329,7 @@ class Base(Configuration):
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"dockerflow.django.middleware.DockerflowMiddleware",
|
||||
"csp.middleware.CSPMiddleware",
|
||||
"waffle.middleware.WaffleMiddleware",
|
||||
]
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
@@ -350,6 +351,7 @@ class Base(Configuration):
|
||||
"parler",
|
||||
"treebeard",
|
||||
"easy_thumbnails",
|
||||
"waffle",
|
||||
# Django
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
@@ -842,11 +844,6 @@ class Base(Configuration):
|
||||
environ_name="API_USERS_LIST_LIMIT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
API_USERS_SEARCH_QUERY_MIN_LENGTH = values.PositiveIntegerValue(
|
||||
default=3,
|
||||
environ_name="API_USERS_SEARCH_QUERY_MIN_LENGTH",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Content Security Policy
|
||||
# See https://content-security-policy.com/ for more information.
|
||||
@@ -880,11 +877,6 @@ class Base(Configuration):
|
||||
),
|
||||
}
|
||||
|
||||
# User accounts management
|
||||
USER_RECONCILIATION_FORM_URL = values.Value(
|
||||
None, environ_name="USER_RECONCILIATION_FORM_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
# Marketing and communication settings
|
||||
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
|
||||
False,
|
||||
|
||||
@@ -42,6 +42,7 @@ dependencies = [
|
||||
"django<6.0.0",
|
||||
"django-treebeard==4.8.0",
|
||||
"djangorestframework==3.16.1",
|
||||
"django-waffle==5.0.0",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10.1",
|
||||
|
||||
BIN
Binary file not shown.
@@ -976,10 +976,7 @@ test.describe('Doc Editor', () => {
|
||||
await expect(pdfBlock).toBeVisible();
|
||||
|
||||
// Try with invalid PDF first
|
||||
await page
|
||||
.getByText(/Add (PDF|file)/)
|
||||
.first()
|
||||
.click();
|
||||
await page.getByText(/Add (PDF|file)/).click();
|
||||
|
||||
await page.locator('[data-test="embed-tab"]').click();
|
||||
|
||||
@@ -997,6 +994,7 @@ test.describe('Doc Editor', () => {
|
||||
// Now with a valid PDF
|
||||
await page.getByText(/Add (PDF|file)/).click();
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByText(/Upload (PDF|file)/).click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
|
||||
@@ -1005,16 +1003,24 @@ test.describe('Doc Editor', () => {
|
||||
// Wait for the media-check to be processed
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const pdfIframe = page
|
||||
.locator('.--docs--editor-container iframe.bn-visual-media')
|
||||
const pdfEmbed = page
|
||||
.locator('.--docs--editor-container embed.bn-visual-media')
|
||||
.first();
|
||||
|
||||
// Check src of pdf
|
||||
expect(await pdfIframe.getAttribute('src')).toMatch(
|
||||
expect(await pdfEmbed.getAttribute('src')).toMatch(
|
||||
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.pdf/,
|
||||
);
|
||||
|
||||
await expect(pdfIframe).toHaveAttribute('role', 'presentation');
|
||||
await expect(pdfEmbed).toHaveAttribute('type', 'application/pdf');
|
||||
await expect(pdfEmbed).toHaveAttribute('role', 'presentation');
|
||||
|
||||
// Check download with original filename
|
||||
await pdfBlock.click();
|
||||
await page.locator('[data-test="downloadfile"]').click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe('test-pdf.pdf');
|
||||
});
|
||||
|
||||
test('it preserves text when switching between mobile and desktop views', async ({
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { Download, Page, expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
import JSZip from 'jszip';
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
|
||||
import {
|
||||
BrowserName,
|
||||
TestLanguage,
|
||||
createDoc,
|
||||
verifyDocName,
|
||||
waitForLanguageSwitch,
|
||||
} from './utils-common';
|
||||
import { openSuggestionMenu, writeInEditor } from './utils-editor';
|
||||
import { comparePDFWithAssetFolder, overrideDocContent } from './utils-export';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -32,9 +33,7 @@ test.describe('Doc Export', () => {
|
||||
|
||||
await expect(page.getByTestId('modal-export-title')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
|
||||
),
|
||||
page.getByText(/Download your document in a \.docx, \.odt.*format\./i),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
|
||||
await expect(
|
||||
@@ -307,63 +306,10 @@ test.describe('Doc Export', () => {
|
||||
expect(pdfString).toContain('/Lang (fr)');
|
||||
});
|
||||
|
||||
test('it exports the doc to PDF with PRINT feature and checks regressions', async ({
|
||||
page,
|
||||
browserName,
|
||||
}, testInfo) => {
|
||||
// PDF Binary comparison is different depending on the browser used
|
||||
// We only run this test on Chromium to avoid having to maintain
|
||||
// multiple sets of PDF fixtures
|
||||
if (browserName !== 'chromium') {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
await overrideDocContent({ page, browserName });
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Export the document',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page.getByRole('combobox', { name: 'Format' }).click();
|
||||
await page.getByRole('option', { name: 'Print' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Print' }).click();
|
||||
|
||||
await expect(page.locator('#print-only-content-styles')).toBeAttached();
|
||||
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
|
||||
const pdfBuffer = await page.pdf({
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
format: 'A4',
|
||||
scale: 1,
|
||||
});
|
||||
|
||||
// If we need to update the PDF regression fixture, uncomment the line below
|
||||
// await savePDFToAssetFolder(
|
||||
// pdfBuffer,
|
||||
// 'doc-export-PDF-browser-regressions.pdf',
|
||||
// );
|
||||
|
||||
// Assert the generated PDF matches the initial PDF regression fixture
|
||||
await comparePDFWithAssetFolder({
|
||||
originPdfBuffer: pdfBuffer,
|
||||
filename: 'doc-export-PDF-browser-regressions.pdf',
|
||||
compareTextContent: false,
|
||||
comparePixel: false,
|
||||
testInfo,
|
||||
});
|
||||
|
||||
await expect(page.locator('#print-only-content-styles')).not.toBeAttached();
|
||||
});
|
||||
|
||||
test('it exports the doc to PDF and checks regressions', async ({
|
||||
page,
|
||||
browserName,
|
||||
}, testInfo) => {
|
||||
}) => {
|
||||
// PDF Binary comparison is different depending on the browser used
|
||||
// We only run this test on Chromium to avoid having to maintain
|
||||
// multiple sets of PDF fixtures
|
||||
@@ -379,6 +325,10 @@ test.describe('Doc Export', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId('doc-open-modal-download-button'),
|
||||
).toBeVisible();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
});
|
||||
@@ -388,16 +338,152 @@ test.describe('Doc Export', () => {
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
|
||||
// If we need to update the PDF regression fixture, uncomment the line below
|
||||
//await savePDFToAssetFolder(pdfBuffer, 'doc-export-regressions.pdf');
|
||||
//await savePDFToAssetFolder(download);
|
||||
|
||||
// Assert the generated PDF matches "assets/doc-export-regressions.pdf"
|
||||
await comparePDFWithAssetFolder({
|
||||
originPdfBuffer: pdfBuffer,
|
||||
filename: 'doc-export-regressions.pdf',
|
||||
testInfo,
|
||||
});
|
||||
await comparePDFWithAssetFolder(download);
|
||||
});
|
||||
});
|
||||
|
||||
export const savePDFToAssetFolder = async (download: Download) => {
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfPath = path.join(__dirname, 'assets', `doc-export-regressions.pdf`);
|
||||
fs.writeFileSync(pdfPath, pdfBuffer);
|
||||
};
|
||||
|
||||
export const comparePDFWithAssetFolder = async (download: Download) => {
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
|
||||
// Load reference PDF for comparison
|
||||
const referencePdfPath = path.join(
|
||||
__dirname,
|
||||
'assets',
|
||||
'doc-export-regressions.pdf',
|
||||
);
|
||||
|
||||
const referencePdfBuffer = fs.readFileSync(referencePdfPath);
|
||||
|
||||
// Parse both PDFs
|
||||
const generatedPdf = new PDFParse({ data: pdfBuffer });
|
||||
const referencePdf = new PDFParse({ data: referencePdfBuffer });
|
||||
|
||||
const [generatedInfo, referenceInfo] = await Promise.all([
|
||||
generatedPdf.getInfo(),
|
||||
referencePdf.getInfo(),
|
||||
]);
|
||||
|
||||
const [generatedScreenshot, referenceScreenshot] = await Promise.all([
|
||||
generatedPdf.getScreenshot(),
|
||||
referencePdf.getScreenshot(),
|
||||
]);
|
||||
generatedScreenshot.pages[0].data;
|
||||
|
||||
const [generatedText, referenceText] = await Promise.all([
|
||||
generatedPdf.getText(),
|
||||
referencePdf.getText(),
|
||||
]);
|
||||
|
||||
// Compare page count
|
||||
expect(generatedInfo.total).toBe(referenceInfo.total);
|
||||
|
||||
// Compare text content
|
||||
expect(generatedText.text).toBe(referenceText.text);
|
||||
|
||||
// Compare screenshots page by page
|
||||
for (let i = 0; i < generatedScreenshot.pages.length; i++) {
|
||||
const genPage = generatedScreenshot.pages[i];
|
||||
const refPage = referenceScreenshot.pages[i];
|
||||
|
||||
expect(genPage.width).toBe(refPage.width);
|
||||
expect(genPage.height).toBe(refPage.height);
|
||||
try {
|
||||
expect(genPage.data).toStrictEqual(refPage.data);
|
||||
} catch {
|
||||
throw new Error(`PDF page ${i + 1} screenshot does not match reference.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Override the document content API response to use a test content
|
||||
* This test content contains many blocks to facilitate testing
|
||||
* @param page
|
||||
*/
|
||||
export const overrideDocContent = async ({
|
||||
page,
|
||||
browserName,
|
||||
}: {
|
||||
page: Page;
|
||||
browserName: BrowserName;
|
||||
}) => {
|
||||
// Override content prop with assets/base-content-test-pdf.txt
|
||||
await page.route(/\**\/documents\/\**/, async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
request.method().includes('GET') &&
|
||||
!request.url().includes('page=') &&
|
||||
!request.url().includes('versions') &&
|
||||
!request.url().includes('accesses') &&
|
||||
!request.url().includes('invitations')
|
||||
) {
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
json.content = fs.readFileSync(
|
||||
path.join(__dirname, 'assets/base-content-test-pdf.txt'),
|
||||
'utf-8',
|
||||
);
|
||||
void route.fulfill({
|
||||
response,
|
||||
body: JSON.stringify(json),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-export-override-content',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Add Image SVG
|
||||
await page.keyboard.press('Enter');
|
||||
const { suggestionMenu } = await openSuggestionMenu({ page });
|
||||
await suggestionMenu.getByText('Resizable image with caption').click();
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.getByText('Upload image').click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
|
||||
const image = page
|
||||
.locator('.--docs--editor-container img.bn-visual-media[src$=".svg"]')
|
||||
.first();
|
||||
await expect(image).toBeVisible();
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Add Image PNG
|
||||
await openSuggestionMenu({ page });
|
||||
await suggestionMenu.getByText('Resizable image with caption').click();
|
||||
const fileChooserPNGPromise = page.waitForEvent('filechooser');
|
||||
await page.getByText('Upload image').click();
|
||||
const fileChooserPNG = await fileChooserPNGPromise;
|
||||
await fileChooserPNG.setFiles(
|
||||
path.join(__dirname, 'assets/logo-suite-numerique.png'),
|
||||
);
|
||||
const imagePng = page
|
||||
.locator('.--docs--editor-container img.bn-visual-media[src$=".png"]')
|
||||
.first();
|
||||
await expect(imagePng).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
return randomDoc;
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ test.beforeEach(async ({ page }) => {
|
||||
|
||||
test.describe('Home page', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('checks all the elements are visible', async ({ page }) => {
|
||||
await page.goto('/docs/');
|
||||
|
||||
|
||||
@@ -18,20 +18,6 @@ test.describe('Left panel desktop', () => {
|
||||
await expect(page.getByTestId('home-button')).toBeVisible();
|
||||
});
|
||||
|
||||
test('focuses main content after switching the docs filter', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
|
||||
const myDocsLink = page.getByRole('link', { name: 'My docs' });
|
||||
await myDocsLink.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page).toHaveURL(/target=my_docs/);
|
||||
|
||||
const mainContent = page.locator('main#mainContent');
|
||||
await expect(mainContent).toBeFocused();
|
||||
});
|
||||
|
||||
test('checks resize handle is present and functional on document page', async ({
|
||||
page,
|
||||
browserName,
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { Locator, Page, TestInfo, expect } from '@playwright/test';
|
||||
import { Locator, Page, expect } from '@playwright/test';
|
||||
|
||||
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
|
||||
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
|
||||
|
||||
export const CONFIG = {
|
||||
AI_FEATURE_ENABLED: true,
|
||||
API_USERS_SEARCH_QUERY_MIN_LENGTH: 3,
|
||||
CRISP_WEBSITE_ID: null,
|
||||
COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/',
|
||||
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
|
||||
@@ -392,30 +388,3 @@ export const clickInGridMenu = async (
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: textButton }).click();
|
||||
};
|
||||
|
||||
export const writeReport = async (
|
||||
testInfo: TestInfo,
|
||||
filename: string,
|
||||
attachName: string,
|
||||
buffer: Buffer,
|
||||
contentType: string,
|
||||
) => {
|
||||
const REPORT_DIRNAME = 'extra-report';
|
||||
const REPORT_NAME = 'test-results';
|
||||
const outDir = testInfo
|
||||
? path.join(testInfo.outputDir, REPORT_DIRNAME, path.parse(filename).name)
|
||||
: path.join(
|
||||
process.cwd(),
|
||||
REPORT_NAME,
|
||||
REPORT_DIRNAME,
|
||||
path.parse(filename).name,
|
||||
);
|
||||
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const pathToFile = path.join(outDir, filename);
|
||||
fs.writeFileSync(pathToFile, buffer);
|
||||
await testInfo.attach(attachName, {
|
||||
path: pathToFile,
|
||||
contentType: contentType,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { Page, TestInfo, expect } from '@playwright/test';
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
import pixelmatch from 'pixelmatch';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
import {
|
||||
BrowserName,
|
||||
createDoc,
|
||||
verifyDocName,
|
||||
writeReport,
|
||||
} from './utils-common';
|
||||
import { openSuggestionMenu } from './utils-editor';
|
||||
|
||||
/**
|
||||
* Override the document content API response to use a test content
|
||||
* This test content contains many blocks to facilitate testing
|
||||
* @param page
|
||||
*/
|
||||
export const overrideDocContent = async ({
|
||||
page,
|
||||
browserName,
|
||||
}: {
|
||||
page: Page;
|
||||
browserName: BrowserName;
|
||||
}) => {
|
||||
// Override content prop with assets/base-content-test-pdf.txt
|
||||
await page.route(/\**\/documents\/\**/, async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
request.method().includes('GET') &&
|
||||
!request.url().includes('page=') &&
|
||||
!request.url().includes('versions') &&
|
||||
!request.url().includes('accesses') &&
|
||||
!request.url().includes('invitations')
|
||||
) {
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
json.content = fs.readFileSync(
|
||||
path.join(__dirname, 'assets/base-content-test-pdf.txt'),
|
||||
'utf-8',
|
||||
);
|
||||
void route.fulfill({
|
||||
response,
|
||||
body: JSON.stringify(json),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-export-override-content',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Add Image SVG
|
||||
await page.keyboard.press('Enter');
|
||||
const { suggestionMenu } = await openSuggestionMenu({ page });
|
||||
await suggestionMenu.getByText('Resizable image with caption').click();
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.getByText('Upload image').click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
|
||||
const image = page
|
||||
.locator('.--docs--editor-container img.bn-visual-media[src$=".svg"]')
|
||||
.first();
|
||||
await expect(image).toBeVisible();
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Add Image PNG
|
||||
await openSuggestionMenu({ page });
|
||||
await suggestionMenu.getByText('Resizable image with caption').click();
|
||||
const fileChooserPNGPromise = page.waitForEvent('filechooser');
|
||||
await page.getByText('Upload image').click();
|
||||
const fileChooserPNG = await fileChooserPNGPromise;
|
||||
await fileChooserPNG.setFiles(
|
||||
path.join(__dirname, 'assets/logo-suite-numerique.png'),
|
||||
);
|
||||
const imagePng = page
|
||||
.locator('.--docs--editor-container img.bn-visual-media[src$=".png"]')
|
||||
.first();
|
||||
await expect(imagePng).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
return randomDoc;
|
||||
};
|
||||
|
||||
export const savePDFToAssetFolder = async (
|
||||
pdfBuffer: Buffer,
|
||||
filename: string,
|
||||
) => {
|
||||
const pdfPath = path.join(__dirname, 'assets', filename);
|
||||
fs.writeFileSync(pdfPath, pdfBuffer);
|
||||
};
|
||||
|
||||
interface ComparePDFWithAssetFolderOptions {
|
||||
originPdfBuffer: Buffer;
|
||||
filename: string;
|
||||
compareTextContent?: boolean;
|
||||
comparePixel?: boolean;
|
||||
testInfo?: TestInfo;
|
||||
}
|
||||
export const comparePDFWithAssetFolder = async ({
|
||||
originPdfBuffer,
|
||||
filename,
|
||||
compareTextContent = true,
|
||||
comparePixel = true,
|
||||
testInfo,
|
||||
}: ComparePDFWithAssetFolderOptions) => {
|
||||
// Load reference PDF for comparison
|
||||
const referencePdfPath = path.join(__dirname, 'assets', filename);
|
||||
const referencePdfBuffer = fs.readFileSync(referencePdfPath);
|
||||
|
||||
// Parse both PDFs
|
||||
const generatedPdf = new PDFParse({ data: originPdfBuffer });
|
||||
const referencePdf = new PDFParse({ data: referencePdfBuffer });
|
||||
|
||||
const [generatedInfo, referenceInfo] = await Promise.all([
|
||||
generatedPdf.getInfo(),
|
||||
referencePdf.getInfo(),
|
||||
]);
|
||||
|
||||
const [generatedScreenshot, referenceScreenshot] = await Promise.all([
|
||||
generatedPdf.getScreenshot(),
|
||||
referencePdf.getScreenshot(),
|
||||
]);
|
||||
|
||||
const [generatedText, referenceText] = await Promise.all([
|
||||
generatedPdf.getText(),
|
||||
referencePdf.getText(),
|
||||
]);
|
||||
|
||||
// Compare page count
|
||||
expect(generatedInfo.total).toBe(referenceInfo.total);
|
||||
|
||||
/*
|
||||
Compare text content
|
||||
We make this optional because text extraction from PDFs can vary
|
||||
slightly between environments and PDF versions, leading to false negatives.
|
||||
Particularly with emojis which can be represented differently when
|
||||
exporting or parsing the PDF.
|
||||
*/
|
||||
if (compareTextContent) {
|
||||
expect(generatedText.text).toBe(referenceText.text);
|
||||
}
|
||||
|
||||
// Compare screenshots page by page
|
||||
for (let i = 0; i < generatedScreenshot.pages.length; i++) {
|
||||
const genPage = generatedScreenshot.pages[i];
|
||||
const refPage = referenceScreenshot.pages[i];
|
||||
|
||||
const genPng = PNG.sync.read(Buffer.from(genPage.data));
|
||||
const refPng = PNG.sync.read(Buffer.from(refPage.data));
|
||||
|
||||
// Compare actual raster dimensions (integers)
|
||||
expect(genPng.width).toBe(refPng.width);
|
||||
expect(genPng.height).toBe(refPng.height);
|
||||
|
||||
if (!comparePixel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const diffPng = new PNG({ width: genPng.width, height: genPng.height });
|
||||
|
||||
const numDiffPixels = pixelmatch(
|
||||
genPng.data,
|
||||
refPng.data,
|
||||
diffPng.data,
|
||||
genPng.width,
|
||||
genPng.height,
|
||||
{ threshold: 0.1, includeAA: false },
|
||||
);
|
||||
|
||||
const totalPixels = genPng.width * genPng.height;
|
||||
const diffRatio = numDiffPixels / totalPixels;
|
||||
const maxDiffRatio = 0.0005;
|
||||
|
||||
try {
|
||||
expect(numDiffPixels).toBeLessThan(0.0005);
|
||||
} catch {
|
||||
if (testInfo) {
|
||||
const pageNo = String(i + 1).padStart(2, '0');
|
||||
|
||||
await writeReport(
|
||||
testInfo,
|
||||
`generated.pdf`,
|
||||
`pdf-generated`,
|
||||
originPdfBuffer,
|
||||
'application/pdf',
|
||||
);
|
||||
await writeReport(
|
||||
testInfo,
|
||||
`reference.pdf`,
|
||||
`pdf-reference`,
|
||||
referencePdfBuffer,
|
||||
'application/pdf',
|
||||
);
|
||||
await writeReport(
|
||||
testInfo,
|
||||
`page-${pageNo}-diff.png`,
|
||||
`page-${pageNo}-diff`,
|
||||
PNG.sync.write(diffPng),
|
||||
'image/png',
|
||||
);
|
||||
await writeReport(
|
||||
testInfo,
|
||||
`page-${pageNo}-generated.png`,
|
||||
`page-${pageNo}-generated`,
|
||||
PNG.sync.write(genPng),
|
||||
'image/png',
|
||||
);
|
||||
await writeReport(
|
||||
testInfo,
|
||||
`page-${pageNo}-reference.png`,
|
||||
`page-${pageNo}-reference`,
|
||||
PNG.sync.write(refPng),
|
||||
'image/png',
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`PDF visual regression: ${filename} page ${i + 1} diffRatio=${diffRatio.toFixed(6)} (${numDiffPixels} px) > ${maxDiffRatio}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -22,11 +22,8 @@
|
||||
"typescript": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/pngjs": "6.0.5",
|
||||
"convert-stream": "1.0.2",
|
||||
"pdf-parse": "2.4.5",
|
||||
"pixelmatch": "7.1.0",
|
||||
"pngjs": "7.0.0"
|
||||
"pdf-parse": "2.4.5"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
@@ -9,7 +9,6 @@ import { useEffect } from 'react';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Auth, KEY_AUTH, setAuthUrl } from '@/features/auth';
|
||||
import { useRouteChangeCompleteFocus } from '@/hooks/useRouteChangeCompleteFocus';
|
||||
import { useResponsiveStore } from '@/stores/';
|
||||
|
||||
import { ConfigProvider } from './config/';
|
||||
@@ -52,7 +51,6 @@ const queryClient = new QueryClient({
|
||||
export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
const { theme } = useCunninghamTheme();
|
||||
const { replace } = useRouter();
|
||||
useRouteChangeCompleteFocus();
|
||||
|
||||
const initializeResizeListener = useResponsiveStore(
|
||||
(state) => state.initializeResizeListener,
|
||||
|
||||
@@ -16,7 +16,6 @@ interface ThemeCustomization {
|
||||
|
||||
export interface ConfigResponse {
|
||||
AI_FEATURE_ENABLED?: boolean;
|
||||
API_USERS_SEARCH_QUERY_MIN_LENGTH?: number;
|
||||
COLLABORATION_WS_URL?: string;
|
||||
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean;
|
||||
CONVERSION_FILE_EXTENSIONS_ALLOWED: string[];
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import React from 'react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { UserReconciliation } from '../components/UserReconciliation';
|
||||
|
||||
vi.mock('../assets/mail-check-filled.svg', () => ({
|
||||
default: () => <div data-testid="success-svg">SuccessSvg</div>,
|
||||
}));
|
||||
|
||||
describe('UserReconciliation', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
['active', 'inactive'].forEach((type) => {
|
||||
test(`renders when reconciliation is a ${type} success`, async () => {
|
||||
fetchMock.get(
|
||||
`http://test.jest/api/v1.0/user-reconciliations/${type}/123456/`,
|
||||
{ details: 'Success' },
|
||||
);
|
||||
|
||||
render(
|
||||
<UserReconciliation
|
||||
type={type as 'active' | 'inactive'}
|
||||
reconciliationId="123456"
|
||||
/>,
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/To complete the unification of your user accounts/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('renders when reconciliation fails', async () => {
|
||||
fetchMock.get(
|
||||
`http://test.jest/api/v1.0/user-reconciliations/active/invalid-id/`,
|
||||
{
|
||||
throws: new Error('invalid id'),
|
||||
},
|
||||
);
|
||||
|
||||
render(<UserReconciliation type="active" reconciliationId="invalid-id" />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText(/An error occurred during email validation./i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './types';
|
||||
export * from './useAuthQuery';
|
||||
export * from './useUserReconciliations';
|
||||
export * from './types';
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
type UserReconciliationResponse = {
|
||||
details: string;
|
||||
};
|
||||
|
||||
interface UserReconciliationProps {
|
||||
type: 'active' | 'inactive';
|
||||
reconciliationId?: string;
|
||||
}
|
||||
|
||||
export const userReconciliations = async ({
|
||||
type,
|
||||
reconciliationId,
|
||||
}: UserReconciliationProps): Promise<UserReconciliationResponse> => {
|
||||
const response = await fetchAPI(
|
||||
`user-reconciliations/${type}/${reconciliationId}/`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to do the user reconciliation',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<UserReconciliationResponse>;
|
||||
};
|
||||
|
||||
export const KEY_USER_RECONCILIATIONS = 'user_reconciliations';
|
||||
|
||||
export function useUserReconciliationsQuery(
|
||||
param: UserReconciliationProps,
|
||||
queryConfig?: UseQueryOptions<
|
||||
UserReconciliationResponse,
|
||||
APIError,
|
||||
UserReconciliationResponse
|
||||
>,
|
||||
) {
|
||||
return useQuery<
|
||||
UserReconciliationResponse,
|
||||
APIError,
|
||||
UserReconciliationResponse
|
||||
>({
|
||||
queryKey: [KEY_USER_RECONCILIATIONS, param],
|
||||
queryFn: () => userReconciliations(param),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_12770_18024)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.5924 17.5492C27.3201 17.5492 28.0035 17.686 28.6419 17.9606C29.2802 18.2352 29.8434 18.6165 30.3307 19.1038C30.8179 19.5911 31.1994 20.1579 31.474 20.8031C31.7551 21.4411 31.8958 22.1241 31.8958 22.8512C31.8958 23.5717 31.7552 24.2521 31.474 24.8903C31.1994 25.5354 30.8144 26.1022 30.3203 26.5895C29.833 27.0768 29.2663 27.4582 28.6211 27.7327C27.9828 28.0141 27.3063 28.1546 26.5924 28.1546C25.8649 28.1546 25.1813 28.0177 24.543 27.7432C23.9049 27.4687 23.3427 27.0835 22.8555 26.5895C22.3682 26.1022 21.9833 25.539 21.7018 24.9007C21.4273 24.2624 21.2904 23.5787 21.2904 22.8512C21.2904 22.124 21.4274 21.4412 21.7018 20.8031C21.9832 20.1647 22.3682 19.6016 22.8555 19.1143C23.3427 18.6202 23.9049 18.2351 24.543 17.9606C25.1813 17.6861 25.8649 17.5492 26.5924 17.5492ZM28.9609 20.1846C28.7003 20.1846 28.4947 20.2947 28.3438 20.514L25.9128 23.8708L24.7188 22.5635C24.6502 22.4949 24.5709 22.4406 24.4818 22.3994C24.3926 22.3582 24.2863 22.3369 24.1628 22.3369C23.9637 22.3369 23.7913 22.4054 23.6471 22.5426C23.5032 22.673 23.431 22.8492 23.431 23.0687C23.4311 23.1576 23.4489 23.2467 23.4831 23.3356C23.5242 23.4316 23.5763 23.5215 23.638 23.6038L25.3672 25.4775C25.4358 25.5598 25.5257 25.6213 25.6354 25.6624C25.7451 25.7036 25.8515 25.7249 25.9544 25.7249C26.2219 25.7249 26.4239 25.6314 26.5612 25.4463L29.5378 21.3486C29.5926 21.2732 29.6304 21.1975 29.651 21.1221C29.6784 21.0468 29.6913 20.978 29.6914 20.9163C29.6914 20.7105 29.6158 20.538 29.4648 20.4007C29.3208 20.2569 29.1529 20.1846 28.9609 20.1846Z" fill="#367664"/>
|
||||
<path d="M20.293 19.5153C20.0526 19.9618 19.8626 20.4428 19.7253 20.958C19.5948 21.4663 19.5299 21.9958 19.5299 22.5452C19.53 22.9022 19.5606 23.2528 19.6224 23.596C19.6842 23.9393 19.7704 24.2756 19.8802 24.6051H4.1888C3.81102 24.6051 3.47375 24.5639 3.17839 24.4814C2.88337 24.4059 2.63299 24.2996 2.42708 24.1624L10.2878 16.29L11.6992 17.5479C12.0221 17.8295 12.3451 18.0394 12.668 18.1768C12.9906 18.314 13.3271 18.3824 13.6771 18.3825C14.0273 18.3825 14.3647 18.3141 14.6875 18.1768C15.0172 18.0394 15.3438 17.8295 15.6667 17.5479L17.0781 16.29L20.293 19.5153Z" fill="#367664"/>
|
||||
<path d="M8.97917 15.1468L1.32422 22.7822C1.24179 22.583 1.17699 22.3589 1.12891 22.1117C1.08772 21.8644 1.06641 21.5789 1.06641 21.2562V9.86165C1.06641 9.51134 1.08994 9.20825 1.13802 8.9541C1.19294 8.69333 1.25202 8.49729 1.3138 8.36686L8.97917 15.1468Z" fill="#367664"/>
|
||||
<path d="M26.0521 8.36686C26.107 8.49725 26.1612 8.69345 26.2161 8.9541C26.2711 9.20825 26.2995 9.51134 26.2995 9.86165V15.7757C25.3104 15.7757 24.3789 15.9821 23.5065 16.3942C22.6413 16.7994 21.8993 17.3455 21.2812 18.0322L18.3867 15.1468L26.0521 8.36686Z" fill="#367664"/>
|
||||
<path d="M22.9089 6.5127C23.7331 6.5127 24.4135 6.68136 24.9492 7.0179L14.625 16.1468C14.3161 16.4281 13.9997 16.5687 13.6771 16.5687C13.3613 16.5685 13.0449 16.4283 12.7292 16.1468L2.40625 7.0179C2.94872 6.68149 3.62829 6.51278 4.44531 6.5127H22.9089Z" fill="#367664"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_12770_18024">
|
||||
<rect width="32" height="32" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.2 KiB |
@@ -1,97 +0,0 @@
|
||||
import Image from 'next/image';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import error_img from '@/assets/icons/error-coffee.png';
|
||||
import { Box, Loading, Text } from '@/components';
|
||||
|
||||
import { useUserReconciliationsQuery } from '../api';
|
||||
import SuccessSvg from '../assets/mail-check-filled.svg';
|
||||
|
||||
interface UserReconciliationProps {
|
||||
reconciliationId: string;
|
||||
type: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
export const UserReconciliation = ({
|
||||
reconciliationId,
|
||||
type,
|
||||
}: UserReconciliationProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: userReconciliations, isError } = useUserReconciliationsQuery({
|
||||
type,
|
||||
reconciliationId,
|
||||
});
|
||||
|
||||
if (!userReconciliations && !isError) {
|
||||
return (
|
||||
<Loading
|
||||
$height="100vh"
|
||||
$width="100vw"
|
||||
$position="absolute"
|
||||
$css="top: 0;"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let render = (
|
||||
<Box $gap="xs" $align="center">
|
||||
<SuccessSvg />
|
||||
<Text
|
||||
as="h3"
|
||||
$textAlign="center"
|
||||
$maxWidth="350px"
|
||||
$theme="neutral"
|
||||
$margin="0"
|
||||
$size="16px"
|
||||
>
|
||||
{t('Email Address Confirmed')}
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
$textAlign="center"
|
||||
$maxWidth="330px"
|
||||
$theme="neutral"
|
||||
$variation="secondary"
|
||||
$margin="0"
|
||||
$size="sm"
|
||||
>
|
||||
{t(
|
||||
'To complete the unification of your user accounts, please click the confirmation links sent to all the email addresses you provided.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (isError) {
|
||||
render = (
|
||||
<Box $gap="xs" $align="center">
|
||||
<Image
|
||||
src={error_img}
|
||||
alt=""
|
||||
width={300}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
as="p"
|
||||
$textAlign="center"
|
||||
$maxWidth="330px"
|
||||
$theme="neutral"
|
||||
$variation="secondary"
|
||||
$margin="0"
|
||||
$size="sm"
|
||||
>
|
||||
{t('An error occurred during email validation.')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box $align="center" $margin="auto" $padding={{ bottom: '2rem' }}>
|
||||
{render}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './Auth';
|
||||
export * from './ButtonLogin';
|
||||
export * from './UserAvatar';
|
||||
export * from './UserReconciliation';
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { APIError, errorCauses } from '@/api';
|
||||
import { sleep } from '@/utils';
|
||||
import { isSafeUrl } from '@/utils/url';
|
||||
|
||||
import { ANALYZE_URL } from '../conf';
|
||||
|
||||
interface CheckDocMediaStatusResponse {
|
||||
file?: string;
|
||||
@@ -16,10 +13,6 @@ interface CheckDocMediaStatus {
|
||||
export const checkDocMediaStatus = async ({
|
||||
urlMedia,
|
||||
}: CheckDocMediaStatus): Promise<CheckDocMediaStatusResponse> => {
|
||||
if (!isSafeUrl(urlMedia) || !urlMedia.includes(ANALYZE_URL)) {
|
||||
throw new APIError('Url invalid', { status: 400 });
|
||||
}
|
||||
|
||||
const response = await fetch(urlMedia, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
+29
-31
@@ -15,19 +15,14 @@ import {
|
||||
import { TFunction } from 'i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
|
||||
import { Box, Icon, Loading } from '@/components';
|
||||
import { isSafeUrl } from '@/utils/url';
|
||||
|
||||
import Warning from '../../assets/warning.svg';
|
||||
import { ANALYZE_URL } from '../../conf';
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
|
||||
const PDFBlockStyle = createGlobalStyle`
|
||||
.bn-block-content[data-content-type="pdf"] .bn-file-block-content-wrapper {
|
||||
width: fit-content;
|
||||
}
|
||||
.bn-block-content[data-content-type="pdf"] .bn-file-block-content-wrapper[style*="fit-content"] {
|
||||
width: 100% !important;
|
||||
}
|
||||
@@ -64,7 +59,11 @@ interface PdfBlockComponentProps {
|
||||
>;
|
||||
}
|
||||
|
||||
const PdfBlockComponent = ({ editor, block }: PdfBlockComponentProps) => {
|
||||
const PdfBlockComponent = ({
|
||||
editor,
|
||||
block,
|
||||
contentRef,
|
||||
}: PdfBlockComponentProps) => {
|
||||
const pdfUrl = block.props.url;
|
||||
const { i18n, t } = useTranslation();
|
||||
const lang = i18n.resolvedLanguage;
|
||||
@@ -88,7 +87,7 @@ const PdfBlockComponent = ({ editor, block }: PdfBlockComponentProps) => {
|
||||
}, [lang, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pdfUrl || pdfUrl.includes(ANALYZE_URL) || !isSafeUrl(pdfUrl)) {
|
||||
if (!pdfUrl || pdfUrl.includes(ANALYZE_URL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,30 +114,27 @@ const PdfBlockComponent = ({ editor, block }: PdfBlockComponentProps) => {
|
||||
void validatePDFContent();
|
||||
}, [pdfUrl]);
|
||||
|
||||
const isInvalidPDF =
|
||||
(!isPDFContentLoading && isPDFContent !== null && !isPDFContent) ||
|
||||
!isSafeUrl(pdfUrl);
|
||||
|
||||
if (isInvalidPDF) {
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap="0.5rem"
|
||||
$width="inherit"
|
||||
$css="pointer-events: none;"
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
>
|
||||
<Warning />
|
||||
{t('Invalid or missing PDF file.')}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box ref={contentRef} className="bn-file-block-content-wrapper">
|
||||
<PDFBlockStyle />
|
||||
{isPDFContentLoading && <Loading />}
|
||||
{!isPDFContentLoading && isPDFContent !== null && !isPDFContent && (
|
||||
<Box
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$color="#666"
|
||||
$background="#f5f5f5"
|
||||
$border="1px solid #ddd"
|
||||
$height="300px"
|
||||
$css={css`
|
||||
text-align: center;
|
||||
`}
|
||||
contentEditable={false}
|
||||
onClick={() => editor.setTextCursorPosition(block)}
|
||||
>
|
||||
{t('Invalid or missing PDF file.')}
|
||||
</Box>
|
||||
)}
|
||||
<ResizableFileBlockWrapper
|
||||
buttonIcon={
|
||||
<Icon iconName="upload" $size="24px" $css="line-height: normal;" />
|
||||
@@ -148,19 +144,21 @@ const PdfBlockComponent = ({ editor, block }: PdfBlockComponentProps) => {
|
||||
>
|
||||
{!isPDFContentLoading && isPDFContent && (
|
||||
<Box
|
||||
as="iframe"
|
||||
as="embed"
|
||||
className="bn-visual-media"
|
||||
role="presentation"
|
||||
$width="100%"
|
||||
$height="450px"
|
||||
type="application/pdf"
|
||||
src={pdfUrl}
|
||||
aria-label={block.props.name || t('PDF document')}
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
onClick={() => editor.setTextCursorPosition(block)}
|
||||
/>
|
||||
)}
|
||||
</ResizableFileBlockWrapper>
|
||||
</>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+1
-2
@@ -11,7 +11,6 @@ import { useEffect } from 'react';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useMediaUrl } from '@/core';
|
||||
import { isSafeUrl } from '@/utils/url';
|
||||
|
||||
import { loopCheckDocMediaStatus } from '../../api';
|
||||
import Loader from '../../assets/loader.svg';
|
||||
@@ -68,7 +67,7 @@ const UploadLoaderBlockComponent = ({
|
||||
block.props.type === 'loading' &&
|
||||
isEditable;
|
||||
|
||||
if (!shouldCheckStatus || !isSafeUrl(block.props.blockUploadUrl)) {
|
||||
if (!shouldCheckStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+8
-5
@@ -3,6 +3,7 @@ import {
|
||||
StyleSchema,
|
||||
} from '@blocknote/core';
|
||||
import { useBlockNoteEditor } from '@blocknote/react';
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -26,12 +27,14 @@ import {
|
||||
import FoundPageIcon from '@/docs/doc-editor/assets/doc-found.svg';
|
||||
import AddPageIcon from '@/docs/doc-editor/assets/doc-plus.svg';
|
||||
import {
|
||||
Doc,
|
||||
getEmojiAndTitle,
|
||||
useCreateChildDocTree,
|
||||
useDocStore,
|
||||
useTrans,
|
||||
} from '@/docs/doc-management';
|
||||
import { DocSearchSubPageContent, DocSearchTarget } from '@/docs/doc-search';
|
||||
import { DocSearchTarget } from '@/docs/doc-search';
|
||||
import { DocSearchContent } from '@/docs/doc-search/components/DocSearchContent';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
const inputStyle = css`
|
||||
@@ -87,7 +90,7 @@ export const SearchPage = ({
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { untitledDocument } = useTrans();
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
/**
|
||||
* createReactInlineContentSpec add automatically the focus after
|
||||
* the inline content, so we need to set the focus on the input
|
||||
@@ -225,14 +228,14 @@ export const SearchPage = ({
|
||||
`}
|
||||
$margin={{ top: '0.5rem' }}
|
||||
>
|
||||
<DocSearchSubPageContent
|
||||
<DocSearchContent
|
||||
search={search}
|
||||
filters={{ target: DocSearchTarget.CURRENT }}
|
||||
target={DocSearchTarget.CURRENT}
|
||||
parentPath={treeContext?.root?.path}
|
||||
onSelect={(doc) => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateInlineContent({
|
||||
type: 'interlinkingSearchInline',
|
||||
props: {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { backendUrl } from '@/api';
|
||||
import { isSafeUrl } from '@/utils/url';
|
||||
|
||||
import { useCreateDocAttachment } from '../api';
|
||||
import { ANALYZE_URL } from '../conf';
|
||||
@@ -58,8 +57,7 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
|
||||
if (
|
||||
!block ||
|
||||
!('url' in block.props) ||
|
||||
('url' in block.props && !block.props.url.includes(ANALYZE_URL)) ||
|
||||
!isSafeUrl(block.props.url)
|
||||
('url' in block.props && !block.props.url.includes(ANALYZE_URL))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,14 +34,12 @@ import {
|
||||
generateHtmlDocument,
|
||||
improveHtmlAccessibility,
|
||||
} from '../utils_html';
|
||||
import { printDocumentWithStyles } from '../utils_print';
|
||||
|
||||
enum DocDownloadFormat {
|
||||
HTML = 'html',
|
||||
PDF = 'pdf',
|
||||
DOCX = 'docx',
|
||||
ODT = 'odt',
|
||||
PRINT = 'print',
|
||||
}
|
||||
|
||||
interface ModalExportProps {
|
||||
@@ -68,14 +66,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
|
||||
setIsExporting(true);
|
||||
|
||||
// Handle print separately as it doesn't download a file
|
||||
if (format === DocDownloadFormat.PRINT) {
|
||||
printDocumentWithStyles();
|
||||
setIsExporting(false);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = (doc.title || untitledDocument)
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
@@ -209,15 +199,13 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="doc-export-download-button"
|
||||
aria-label={
|
||||
format === DocDownloadFormat.PRINT ? t('Print') : t('Download')
|
||||
}
|
||||
aria-label={t('Download')}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={() => void onSubmit()}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{format === DocDownloadFormat.PRINT ? t('Print') : t('Download')}
|
||||
{t('Download')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
@@ -237,7 +225,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
$align="flex-start"
|
||||
data-testid="modal-export-title"
|
||||
>
|
||||
{t('Export')}
|
||||
{t('Download')}
|
||||
</Text>
|
||||
<ButtonCloseModal
|
||||
aria-label={t('Close the download modal')}
|
||||
@@ -255,7 +243,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
>
|
||||
<Text $variation="secondary" $size="sm" as="p">
|
||||
{t(
|
||||
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
|
||||
'Download your document in a .docx, .odt, .pdf or .html(zip) format.',
|
||||
)}
|
||||
</Text>
|
||||
<Select
|
||||
@@ -263,11 +251,10 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
fullWidth
|
||||
label={t('Format')}
|
||||
options={[
|
||||
{ label: t('PDF'), value: DocDownloadFormat.PDF },
|
||||
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
|
||||
{ label: t('ODT'), value: DocDownloadFormat.ODT },
|
||||
{ label: t('PDF'), value: DocDownloadFormat.PDF },
|
||||
{ label: t('HTML'), value: DocDownloadFormat.HTML },
|
||||
{ label: t('Print'), value: DocDownloadFormat.PRINT },
|
||||
]}
|
||||
value={format}
|
||||
onChange={(options) =>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import JSZip from 'jszip';
|
||||
|
||||
import { isSafeUrl } from '@/utils/url';
|
||||
|
||||
import { exportResolveFileUrl } from './api';
|
||||
|
||||
// Escape user-provided text before injecting it into the exported HTML document.
|
||||
@@ -13,12 +11,6 @@ export const escapeHtml = (value: string): string =>
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const moveChildNodes = (from: Element, to: Element) => {
|
||||
while (from.firstChild) {
|
||||
to.appendChild(from.firstChild);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Derives a stable, readable filename for media exported in the HTML ZIP.
|
||||
*
|
||||
@@ -146,7 +138,7 @@ export const improveHtmlAccessibility = (
|
||||
const rawLevel = Number(block.getAttribute('data-level')) || 1;
|
||||
const level = Math.min(Math.max(rawLevel, 1), 6);
|
||||
const heading = parsedDocument.createElement(`h${level}`);
|
||||
moveChildNodes(block, heading);
|
||||
heading.innerHTML = block.innerHTML;
|
||||
block.replaceWith(heading);
|
||||
});
|
||||
|
||||
@@ -260,7 +252,7 @@ export const improveHtmlAccessibility = (
|
||||
|
||||
// Create list item and add content
|
||||
const li = parsedDocument.createElement('li');
|
||||
moveChildNodes(listItem, li);
|
||||
li.innerHTML = listItem.innerHTML;
|
||||
targetList.appendChild(li);
|
||||
|
||||
// Remove original block-outer
|
||||
@@ -273,7 +265,7 @@ export const improveHtmlAccessibility = (
|
||||
);
|
||||
quoteBlocks.forEach((block) => {
|
||||
const quote = parsedDocument.createElement('blockquote');
|
||||
moveChildNodes(block, quote);
|
||||
quote.innerHTML = block.innerHTML;
|
||||
block.replaceWith(quote);
|
||||
});
|
||||
|
||||
@@ -284,7 +276,7 @@ export const improveHtmlAccessibility = (
|
||||
calloutBlocks.forEach((block) => {
|
||||
const aside = parsedDocument.createElement('aside');
|
||||
aside.setAttribute('role', 'note');
|
||||
moveChildNodes(block, aside);
|
||||
aside.innerHTML = block.innerHTML;
|
||||
block.replaceWith(aside);
|
||||
});
|
||||
|
||||
@@ -311,7 +303,7 @@ export const improveHtmlAccessibility = (
|
||||
}
|
||||
|
||||
const li = parsedDocument.createElement('li');
|
||||
moveChildNodes(item, li);
|
||||
li.innerHTML = item.innerHTML;
|
||||
|
||||
// Ensure checkbox has an accessible state; fall back to aria-checked if missing.
|
||||
const checkbox = li.querySelector<HTMLInputElement>(
|
||||
@@ -348,7 +340,7 @@ export const improveHtmlAccessibility = (
|
||||
});
|
||||
|
||||
// Move content inside <code>.
|
||||
moveChildNodes(block, code);
|
||||
code.innerHTML = block.innerHTML;
|
||||
pre.appendChild(code);
|
||||
block.replaceWith(pre);
|
||||
});
|
||||
@@ -416,7 +408,7 @@ export const addMediaFilesToZip = async (
|
||||
url = null;
|
||||
}
|
||||
|
||||
if (!url || url.origin !== mediaUrl || !isSafeUrl(url.href)) {
|
||||
if (!url || url.origin !== mediaUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
import { isSafeUrl } from '@/utils/url';
|
||||
|
||||
const PRINT_ONLY_CONTENT_STYLES_ID = 'print-only-content-styles';
|
||||
const PRINT_APPLY_DELAY_MS = 200;
|
||||
const PRINT_CLEANUP_DELAY_MS = 1000;
|
||||
const PRINT_ONLY_CONTENT_CSS = `
|
||||
@media print {
|
||||
/* Reset body and html for proper pagination */
|
||||
html, body {
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
background: var(--c--theme--colors--greyscale-000) !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Hide non-essential elements for printing */
|
||||
.--docs--header,
|
||||
.--docs--resizable-left-panel,
|
||||
.--docs--doc-editor-header,
|
||||
.--docs--doc-header,
|
||||
.--docs--doc-toolbox,
|
||||
.--docs--table-content,
|
||||
.--docs--doc-footer,
|
||||
.--docs--footer,
|
||||
footer,
|
||||
[role="contentinfo"],
|
||||
div[data-is-empty-and-focused="true"],
|
||||
div[data-floating-ui-focusable],
|
||||
.collaboration-cursor-custom__base
|
||||
{
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Hide selection highlights */
|
||||
.ProseMirror-yjs-selection {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Reset all layout containers for print flow */
|
||||
.--docs--main-layout,
|
||||
.--docs--main-layout > *,
|
||||
main[role="main"],
|
||||
#mainContent {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
background: var(--c--theme--colors--greyscale-000) !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Prevent any ancestor from clipping the end of the document */
|
||||
.--docs--main-layout,
|
||||
.--docs--main-layout * {
|
||||
overflow: visible !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
/* Allow editor containers to flow across pages */
|
||||
.--docs--editor-container,
|
||||
.--docs--doc-editor,
|
||||
.--docs--doc-editor-content {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* Reset all Box components that might have height constraints */
|
||||
.--docs--doc-editor > div,
|
||||
.--docs--doc-editor-content > div {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Ensure BlockNote content flows properly */
|
||||
.bn-editor,
|
||||
.bn-container,
|
||||
.--docs--main-editor,
|
||||
.bn-block-outer {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Hide media/embed placeholders and render their URLs */
|
||||
[data-content-type="file"] .bn-file-block-content-wrapper,
|
||||
[data-content-type="pdf"] .bn-file-block-content-wrapper,
|
||||
[data-content-type="audio"] .bn-file-block-content-wrapper,
|
||||
[data-content-type="video"] .bn-file-block-content-wrapper {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
div[data-page-break] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Allow large blocks/media to split across pages */
|
||||
.bn-block-content {
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.--docs--main-editor {
|
||||
width: 100% !important;
|
||||
padding: 0.5cm !important;
|
||||
}
|
||||
|
||||
/* Force print all colors and backgrounds */
|
||||
* {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
color-adjust: exact !important;
|
||||
}
|
||||
|
||||
/* Add minimal print margins */
|
||||
@page {
|
||||
margin: 0cm;
|
||||
margin-bottom: 0.7cm;
|
||||
margin-top: 0.7cm;
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
.print-url-label {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Removes the print-only styles from the document head if they exist.
|
||||
*/
|
||||
function removePrintOnlyStyles() {
|
||||
const stylesElement = document.getElementById(PRINT_ONLY_CONTENT_STYLES_ID);
|
||||
if (stylesElement) {
|
||||
stylesElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a style element containing CSS rules that are applied only during printing.
|
||||
*/
|
||||
function createPrintOnlyStyleElement() {
|
||||
const printStyles = document.createElement('style');
|
||||
printStyles.id = PRINT_ONLY_CONTENT_STYLES_ID;
|
||||
printStyles.textContent = PRINT_ONLY_CONTENT_CSS;
|
||||
return printStyles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes any existing print-only styles and appends new ones to the document head.
|
||||
*/
|
||||
function appendPrintOnlyStyles() {
|
||||
removePrintOnlyStyles();
|
||||
document.head.appendChild(createPrintOnlyStyleElement());
|
||||
return removePrintOnlyStyles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps media elements with links to their source URLs for printing.
|
||||
*/
|
||||
function wrapMediaWithLink() {
|
||||
const createdShadowWrapper: HTMLElement[] = [];
|
||||
|
||||
const prependLink = (
|
||||
el: Element,
|
||||
url: string | null,
|
||||
name: string | null,
|
||||
type: 'file' | 'audio' | 'video' | 'pdf',
|
||||
) => {
|
||||
if (!url || !isSafeUrl(url)) {
|
||||
return;
|
||||
}
|
||||
const block = document.createElement('div');
|
||||
block.className = 'print-url-block-media';
|
||||
const link = document.createElement('a');
|
||||
link.className = 'print-url-link';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'print-url-label';
|
||||
|
||||
if (type === 'audio') {
|
||||
label.textContent = '🔊: ';
|
||||
} else if (type === 'video') {
|
||||
label.textContent = '📹: ';
|
||||
} else if (type === 'pdf') {
|
||||
label.textContent = '📑: ';
|
||||
} else {
|
||||
label.textContent = '🔗: ';
|
||||
}
|
||||
|
||||
link.href = url;
|
||||
link.textContent = name || url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.setAttribute('data-print-link', 'true');
|
||||
block.appendChild(label);
|
||||
block.appendChild(link);
|
||||
|
||||
const shadowWrapper = document.createElement('div');
|
||||
el.prepend(shadowWrapper);
|
||||
|
||||
// Use a shadow root to avoid propagatic the changes to the collaboration provider
|
||||
const shadowRoot = shadowWrapper.attachShadow({ mode: 'open' });
|
||||
shadowRoot.appendChild(block);
|
||||
createdShadowWrapper.push(shadowWrapper);
|
||||
};
|
||||
|
||||
document
|
||||
.querySelectorAll(
|
||||
'[data-content-type="pdf"], [data-content-type="file"], [data-content-type="audio"], [data-content-type="video"]',
|
||||
)
|
||||
.forEach((el) => {
|
||||
const url = el?.getAttribute('data-url');
|
||||
const name = el?.getAttribute('data-name');
|
||||
const type = el?.getAttribute('data-content-type') as
|
||||
| 'file'
|
||||
| 'audio'
|
||||
| 'video'
|
||||
| 'pdf';
|
||||
if (type) {
|
||||
prependLink(el, url, name, type);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
// remove the shadow roots that were created
|
||||
createdShadowWrapper.forEach((link) => {
|
||||
link.remove();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function printDocumentWithStyles() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanupPrintStyles = appendPrintOnlyStyles();
|
||||
|
||||
// Small delay to ensure styles are applied
|
||||
setTimeout(() => {
|
||||
const cleanupLinks = wrapMediaWithLink();
|
||||
const cleanup = () => {
|
||||
cleanupLinks();
|
||||
cleanupPrintStyles();
|
||||
};
|
||||
|
||||
window.addEventListener('afterprint', cleanup, { once: true });
|
||||
requestAnimationFrame(() => window.print());
|
||||
|
||||
// Also clean up after a delay as fallback
|
||||
setTimeout(cleanup, PRINT_CLEANUP_DELAY_MS);
|
||||
}, PRINT_APPLY_DELAY_MS);
|
||||
}
|
||||
+1
-3
@@ -46,9 +46,7 @@ describe('DocToolBox - Licence', () => {
|
||||
// Wait for the export modal to be visible, then assert on its content text.
|
||||
await screen.findByTestId('modal-export-title');
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
|
||||
),
|
||||
screen.getByText(/Download your document in a .docx, .odt.*format\./i),
|
||||
).toBeInTheDocument();
|
||||
}, 10000);
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@ export * from './useDocs';
|
||||
export * from './useDocsFavorite';
|
||||
export * from './useDuplicateDoc';
|
||||
export * from './useRestoreDoc';
|
||||
export * from './useSubDocs';
|
||||
export * from './useUpdateDoc';
|
||||
export * from './useSearchDocs';
|
||||
|
||||
@@ -15,7 +15,6 @@ export type DocsParams = {
|
||||
page: number;
|
||||
ordering?: DocsOrdering;
|
||||
is_creator_me?: boolean;
|
||||
title?: string;
|
||||
is_favorite?: boolean;
|
||||
};
|
||||
|
||||
@@ -31,9 +30,6 @@ export const constructParams = (params: DocsParams): URLSearchParams => {
|
||||
if (params.is_creator_me !== undefined) {
|
||||
searchParams.set('is_creator_me', params.is_creator_me.toString());
|
||||
}
|
||||
if (params.title && params.title.length > 0) {
|
||||
searchParams.set('title', params.title);
|
||||
}
|
||||
if (params.is_favorite !== undefined) {
|
||||
searchParams.set('is_favorite', params.is_favorite.toString());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI, useAPIInfiniteQuery } from '@/api';
|
||||
import { DocSearchTarget } from '@/docs/doc-search';
|
||||
|
||||
import { DocsResponse, KEY_LIST_DOC } from './useDocs';
|
||||
|
||||
export type SearchDocsParams = {
|
||||
page: number;
|
||||
q: string;
|
||||
target?: DocSearchTarget;
|
||||
parentPath?: string;
|
||||
};
|
||||
|
||||
const constructParams = ({
|
||||
q,
|
||||
page,
|
||||
target,
|
||||
parentPath,
|
||||
}: SearchDocsParams): URLSearchParams => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set('q', q);
|
||||
|
||||
if (target === DocSearchTarget.CURRENT && parentPath) {
|
||||
searchParams.set('path', parentPath);
|
||||
}
|
||||
if (page) {
|
||||
searchParams.set('page', page.toString());
|
||||
}
|
||||
|
||||
return searchParams;
|
||||
};
|
||||
|
||||
const searchDocs = async ({
|
||||
q,
|
||||
page,
|
||||
target,
|
||||
parentPath,
|
||||
}: SearchDocsParams): Promise<DocsResponse> => {
|
||||
const searchParams = constructParams({ q, page, target, parentPath });
|
||||
const response = await fetchAPI(
|
||||
`documents/search/?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the docs', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<DocsResponse>;
|
||||
};
|
||||
|
||||
export const useSearchDocs = (
|
||||
{ q, page, target, parentPath }: SearchDocsParams,
|
||||
queryConfig?: { enabled?: boolean },
|
||||
) => {
|
||||
return useQuery<DocsResponse, APIError, DocsResponse>({
|
||||
queryKey: [KEY_LIST_DOC, 'search', { q, page, target, parentPath }],
|
||||
queryFn: () => searchDocs({ q, page, target, parentPath }),
|
||||
...queryConfig,
|
||||
});
|
||||
};
|
||||
|
||||
export const useInfiniteSearchDocs = (
|
||||
params: SearchDocsParams,
|
||||
queryConfig?: { enabled?: boolean },
|
||||
) => {
|
||||
return useAPIInfiniteQuery(KEY_LIST_DOC, searchDocs, params, queryConfig);
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
APIError,
|
||||
InfiniteQueryConfig,
|
||||
errorCauses,
|
||||
fetchAPI,
|
||||
useAPIInfiniteQuery,
|
||||
} from '@/api';
|
||||
|
||||
import { DocsOrdering } from '../types';
|
||||
|
||||
import { DocsResponse, constructParams } from './useDocs';
|
||||
|
||||
export type SubDocsParams = {
|
||||
page: number;
|
||||
ordering?: DocsOrdering;
|
||||
is_creator_me?: boolean;
|
||||
title?: string;
|
||||
is_favorite?: boolean;
|
||||
parent_id: string;
|
||||
};
|
||||
|
||||
export const getSubDocs = async (
|
||||
params: SubDocsParams,
|
||||
): Promise<DocsResponse> => {
|
||||
const searchParams = constructParams(params);
|
||||
searchParams.set('parent_id', params.parent_id);
|
||||
|
||||
const response: Response = await fetchAPI(
|
||||
`documents/${params.parent_id}/descendants/?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the sub docs',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<DocsResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_SUB_DOC = 'sub-docs';
|
||||
|
||||
export function useSubDocs(
|
||||
params: SubDocsParams,
|
||||
queryConfig?: UseQueryOptions<DocsResponse, APIError, DocsResponse>,
|
||||
) {
|
||||
return useQuery<DocsResponse, APIError, DocsResponse>({
|
||||
queryKey: [KEY_LIST_SUB_DOC, params],
|
||||
queryFn: () => getSubDocs(params),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
export const useInfiniteSubDocs = (
|
||||
params: SubDocsParams,
|
||||
queryConfig?: InfiniteQueryConfig<DocsResponse>,
|
||||
) => {
|
||||
return useAPIInfiniteQuery(KEY_LIST_SUB_DOC, getSubDocs, params, queryConfig);
|
||||
};
|
||||
+34
-12
@@ -3,24 +3,31 @@ import { useEffect, useMemo } from 'react';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
|
||||
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
|
||||
import { useInfiniteSearchDocs } from '@/docs/doc-management/api/useSearchDocs';
|
||||
import { DocSearchTarget } from '@/docs/doc-search';
|
||||
|
||||
import { Doc, useInfiniteDocs } from '../../doc-management';
|
||||
import { Doc } from '../../doc-management';
|
||||
|
||||
import { DocSearchFiltersValues } from './DocSearchFilters';
|
||||
import { DocSearchItem } from './DocSearchItem';
|
||||
|
||||
type DocSearchContentProps = {
|
||||
search: string;
|
||||
filters: DocSearchFiltersValues;
|
||||
onSelect: (doc: Doc) => void;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
renderElement?: (doc: Doc) => React.ReactNode;
|
||||
target?: DocSearchTarget;
|
||||
parentPath?: string;
|
||||
emptySearchText?: string;
|
||||
};
|
||||
|
||||
export const DocSearchContent = ({
|
||||
search,
|
||||
filters,
|
||||
onSelect,
|
||||
onLoadingChange,
|
||||
renderElement = (doc) => <DocSearchItem doc={doc} />,
|
||||
target,
|
||||
parentPath,
|
||||
emptySearchText,
|
||||
}: DocSearchContentProps) => {
|
||||
const {
|
||||
data,
|
||||
@@ -29,21 +36,29 @@ export const DocSearchContent = ({
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
} = useInfiniteDocs({
|
||||
page: 1,
|
||||
title: search,
|
||||
...filters,
|
||||
});
|
||||
} = useInfiniteSearchDocs(
|
||||
{
|
||||
q: search,
|
||||
page: 1,
|
||||
target,
|
||||
parentPath,
|
||||
},
|
||||
{
|
||||
enabled: target !== DocSearchTarget.CURRENT || !!parentPath,
|
||||
},
|
||||
);
|
||||
|
||||
const loading = isFetching || isRefetching || isLoading;
|
||||
|
||||
const docsData: QuickSearchData<Doc> = useMemo(() => {
|
||||
const docs = data?.pages.flatMap((page) => page.results) || [];
|
||||
const defaultEmptyText = emptySearchText || t('No document found');
|
||||
const emptyText = search ? defaultEmptyText : t('Search by title');
|
||||
|
||||
return {
|
||||
groupName: docs.length > 0 ? t('Select a document') : '',
|
||||
elements: search ? docs : [],
|
||||
emptyString: t('No document found'),
|
||||
emptyString: target ? emptyText : defaultEmptyText,
|
||||
endActions: hasNextPage
|
||||
? [
|
||||
{
|
||||
@@ -52,7 +67,14 @@ export const DocSearchContent = ({
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}, [search, data?.pages, fetchNextPage, hasNextPage]);
|
||||
}, [
|
||||
search,
|
||||
data?.pages,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
emptySearchText,
|
||||
target,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onLoadingChange?.(loading);
|
||||
@@ -62,7 +84,7 @@ export const DocSearchContent = ({
|
||||
<QuickSearchGroup
|
||||
onSelect={onSelect}
|
||||
group={docsData}
|
||||
renderElement={(doc) => <DocSearchItem doc={doc} />}
|
||||
renderElement={renderElement}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+26
-31
@@ -1,4 +1,5 @@
|
||||
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { TreeContextType, useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
@@ -8,45 +9,40 @@ import { useDebouncedCallback } from 'use-debounce';
|
||||
import { Box, ButtonCloseModal, Text } from '@/components';
|
||||
import { QuickSearch } from '@/components/quick-search';
|
||||
import { Doc, useDocUtils } from '@/docs/doc-management';
|
||||
import {
|
||||
DocSearchFilters,
|
||||
DocSearchFiltersValues,
|
||||
DocSearchTarget,
|
||||
} from '@/docs/doc-search';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
|
||||
|
||||
import { DocSearchContent } from './DocSearchContent';
|
||||
import {
|
||||
DocSearchFilters,
|
||||
DocSearchFiltersValues,
|
||||
DocSearchTarget,
|
||||
} from './DocSearchFilters';
|
||||
import { DocSearchItem } from './DocSearchItem';
|
||||
import { DocSearchSubPageContent } from './DocSearchSubPageContent';
|
||||
|
||||
type DocSearchModalGlobalProps = {
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
showFilters?: boolean;
|
||||
defaultFilters?: DocSearchFiltersValues;
|
||||
treeContext?: TreeContextType<Doc> | null;
|
||||
};
|
||||
|
||||
const DocSearchModalGlobal = ({
|
||||
showFilters = false,
|
||||
defaultFilters,
|
||||
treeContext,
|
||||
...modalProps
|
||||
}: DocSearchModalGlobalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const isDocPage = router.pathname === '/docs/[id]';
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [filters, setFilters] = useState<DocSearchFiltersValues>(
|
||||
defaultFilters ?? {},
|
||||
);
|
||||
|
||||
const target = filters.target ?? DocSearchTarget.ALL;
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
const handleInputSearch = useDebouncedCallback(setSearch, 300);
|
||||
|
||||
const handleSelect = (doc: Doc) => {
|
||||
@@ -120,25 +116,22 @@ const DocSearchModalGlobal = ({
|
||||
</Box>
|
||||
)}
|
||||
{search && (
|
||||
<>
|
||||
{target === DocSearchTarget.ALL && (
|
||||
<DocSearchContent
|
||||
search={search}
|
||||
filters={filters}
|
||||
onSelect={handleSelect}
|
||||
onLoadingChange={setLoading}
|
||||
/>
|
||||
)}
|
||||
{isDocPage && target === DocSearchTarget.CURRENT && (
|
||||
<DocSearchSubPageContent
|
||||
search={search}
|
||||
filters={filters}
|
||||
onSelect={handleSelect}
|
||||
onLoadingChange={setLoading}
|
||||
renderElement={(doc) => <DocSearchItem doc={doc} />}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<DocSearchContent
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
onLoadingChange={setLoading}
|
||||
renderElement={(doc) => <DocSearchItem doc={doc} />}
|
||||
target={
|
||||
filters.target === DocSearchTarget.CURRENT
|
||||
? DocSearchTarget.CURRENT
|
||||
: DocSearchTarget.ALL
|
||||
}
|
||||
parentPath={
|
||||
filters.target === DocSearchTarget.CURRENT
|
||||
? treeContext?.root?.path
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</QuickSearch>
|
||||
@@ -157,6 +150,7 @@ const DocSearchModalDetail = ({
|
||||
}: DocSearchModalDetailProps) => {
|
||||
const { hasChildren, isChild } = useDocUtils(doc);
|
||||
const isWithChildren = isChild || hasChildren;
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
|
||||
let defaultFilters = DocSearchTarget.ALL;
|
||||
let showFilters = false;
|
||||
@@ -170,6 +164,7 @@ const DocSearchModalDetail = ({
|
||||
{...modalProps}
|
||||
showFilters={showFilters}
|
||||
defaultFilters={{ target: defaultFilters }}
|
||||
treeContext={treeContext}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import { t } from 'i18next';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
|
||||
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
|
||||
import { Doc, useInfiniteSubDocs } from '@/docs/doc-management';
|
||||
|
||||
import { DocSearchFiltersValues } from './DocSearchFilters';
|
||||
|
||||
type DocSearchSubPageContentProps = {
|
||||
search: string;
|
||||
filters: DocSearchFiltersValues;
|
||||
onSelect: (doc: Doc) => void;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
renderElement: (doc: Doc) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const DocSearchSubPageContent = ({
|
||||
search,
|
||||
filters,
|
||||
onSelect,
|
||||
onLoadingChange,
|
||||
renderElement,
|
||||
}: DocSearchSubPageContentProps) => {
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
|
||||
const {
|
||||
data: subDocsData,
|
||||
isFetching,
|
||||
isRefetching,
|
||||
isLoading,
|
||||
fetchNextPage: subDocsFetchNextPage,
|
||||
hasNextPage: subDocsHasNextPage,
|
||||
} = useInfiniteSubDocs(
|
||||
{
|
||||
page: 1,
|
||||
title: search,
|
||||
...filters,
|
||||
parent_id: treeContext?.root?.id ?? '',
|
||||
},
|
||||
{
|
||||
enabled: !!treeContext?.root?.id,
|
||||
},
|
||||
);
|
||||
const [docsData, setDocsData] = useState<QuickSearchData<Doc>>({
|
||||
groupName: '',
|
||||
elements: [],
|
||||
emptyString: '',
|
||||
});
|
||||
|
||||
const loading = isFetching || isRefetching || isLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subDocs = subDocsData?.pages.flatMap((page) => page.results) || [];
|
||||
|
||||
if (treeContext?.root) {
|
||||
const isRootTitleIncludeSearch = treeContext.root?.title
|
||||
?.toLowerCase()
|
||||
.includes(search.toLowerCase());
|
||||
|
||||
if (isRootTitleIncludeSearch) {
|
||||
subDocs.unshift(treeContext.root);
|
||||
}
|
||||
}
|
||||
|
||||
setDocsData({
|
||||
groupName: subDocs.length > 0 ? t('Select a doc') : '',
|
||||
elements: search ? subDocs : [],
|
||||
emptyString: search ? t('No document found') : t('Search by title'),
|
||||
endActions: subDocsHasNextPage
|
||||
? [
|
||||
{
|
||||
content: <InView onChange={() => void subDocsFetchNextPage()} />,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}, [
|
||||
loading,
|
||||
search,
|
||||
subDocsData?.pages,
|
||||
subDocsFetchNextPage,
|
||||
subDocsHasNextPage,
|
||||
treeContext?.root,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onLoadingChange?.(loading);
|
||||
}, [loading, onLoadingChange]);
|
||||
|
||||
return (
|
||||
<QuickSearchGroup
|
||||
onSelect={onSelect}
|
||||
group={docsData}
|
||||
renderElement={renderElement}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './DocSearchModal';
|
||||
export * from './DocSearchFilters';
|
||||
export * from './DocSearchSubPageContent';
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
QuickSearchData,
|
||||
QuickSearchGroup,
|
||||
} from '@/components/quick-search/';
|
||||
import { useConfig } from '@/core';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
import { User } from '@/features/auth';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
@@ -58,9 +57,6 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const selectedUsersRef = useRef<HTMLDivElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { data: config } = useConfig();
|
||||
const API_USERS_SEARCH_QUERY_MIN_LENGTH =
|
||||
config?.API_USERS_SEARCH_QUERY_MIN_LENGTH || 5;
|
||||
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
@@ -87,6 +83,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
const canViewAccesses = doc.abilities.accesses_view;
|
||||
const showMemberSection = inputValue === '' && selectedUsers.length === 0;
|
||||
const showFooter = selectedUsers.length === 0 && !inputValue;
|
||||
const MIN_CHARACTERS_FOR_SEARCH = 4;
|
||||
|
||||
const onSelect = (user: User) => {
|
||||
setSelectedUsers((prev) => [...prev, user]);
|
||||
@@ -114,7 +111,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
const searchUsersQuery = useUsers(
|
||||
{ query: userQuery, docId: doc.id },
|
||||
{
|
||||
enabled: userQuery?.length >= API_USERS_SEARCH_QUERY_MIN_LENGTH,
|
||||
enabled: userQuery?.length > MIN_CHARACTERS_FOR_SEARCH,
|
||||
queryKey: [KEY_LIST_USER, { query: userQuery }],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,7 +13,9 @@ const getDocVersion = async ({
|
||||
versionId,
|
||||
docId,
|
||||
}: DocVersionParam): Promise<Version> => {
|
||||
const response = await fetchAPI(`documents/${docId}/versions/${versionId}/`);
|
||||
const url = `documents/${docId}/versions/${versionId}/`;
|
||||
|
||||
const response = await fetchAPI(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
|
||||
@@ -29,9 +29,9 @@ const getDocVersions = async ({
|
||||
versionId,
|
||||
docId,
|
||||
}: DocVersionsAPIParams): Promise<VersionsResponse> => {
|
||||
const response = await fetchAPI(
|
||||
`documents/${docId}/versions/?version_id=${versionId}`,
|
||||
);
|
||||
const url = `documents/${docId}/versions/?version_id=${versionId}`;
|
||||
|
||||
const response = await fetchAPI(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
|
||||
@@ -237,7 +237,7 @@ const DocGridTitleBar = ({
|
||||
>
|
||||
<Box $direction="row" $gap="xs" $align="center">
|
||||
{icon}
|
||||
<Text as="h2" $size="h4" $margin="none" tabIndex={-1}>
|
||||
<Text as="h2" $size="h4" $margin="none">
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
+1
-3
@@ -70,9 +70,7 @@ export const LeftPanelTargetFilters = () => {
|
||||
href={href}
|
||||
aria-label={query.label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => {
|
||||
handleClick();
|
||||
}}
|
||||
onClick={handleClick}
|
||||
$css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -71,7 +71,6 @@ export const ResizableLeftPanel = ({
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel
|
||||
ref={ref}
|
||||
className="--docs--resizable-left-panel"
|
||||
order={0}
|
||||
defaultSize={
|
||||
isDesktop
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
||||
|
||||
export const useRouteChangeCompleteFocus = () => {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const handleRouteChangeComplete = () => {
|
||||
requestAnimationFrame(() => {
|
||||
const mainContent =
|
||||
document.getElementById(MAIN_LAYOUT_ID) ??
|
||||
document.getElementsByTagName('main')[0];
|
||||
|
||||
if (!mainContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstHeading = mainContent.querySelector('h1, h2, h3');
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
'(prefers-reduced-motion: reduce)',
|
||||
).matches;
|
||||
|
||||
(mainContent as HTMLElement | null)?.focus({ preventScroll: true });
|
||||
(firstHeading ?? mainContent)?.scrollIntoView({
|
||||
behavior: prefersReducedMotion ? 'auto' : 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
router.events.on('routeChangeComplete', handleRouteChangeComplete);
|
||||
return () => {
|
||||
router.events.off('routeChangeComplete', handleRouteChangeComplete);
|
||||
};
|
||||
}, [router.events]);
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ReactElement } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { UserReconciliation } from '@/features/auth/components/UserReconciliation';
|
||||
import { PageLayout } from '@/layouts';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
query: { id },
|
||||
} = useRouter();
|
||||
|
||||
if (typeof id !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>{`${t('User reconciliation')} - ${t('Docs')}`}</title>
|
||||
<meta
|
||||
property="og:title"
|
||||
content={`${t('User reconciliation')} - ${t('Docs')}`}
|
||||
key="title"
|
||||
/>
|
||||
</Head>
|
||||
<UserReconciliation type="active" reconciliationId={id} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <PageLayout withFooter={false}>{page}</PageLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,40 +0,0 @@
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ReactElement } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { UserReconciliation } from '@/features/auth/components/UserReconciliation';
|
||||
import { PageLayout } from '@/layouts';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
query: { id },
|
||||
} = useRouter();
|
||||
|
||||
if (typeof id !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>{`${t('User reconciliation')} - ${t('Docs')}`}</title>
|
||||
<meta
|
||||
property="og:title"
|
||||
content={`${t('User reconciliation')} - ${t('Docs')}`}
|
||||
key="title"
|
||||
/>
|
||||
</Head>
|
||||
<UserReconciliation type="inactive" reconciliationId={id} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <PageLayout withFooter={false}>{page}</PageLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,23 +1,4 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import * as dotenv from 'dotenv';
|
||||
import React from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
dotenv.config({ path: './.env.test', quiet: true });
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => {
|
||||
const {
|
||||
src,
|
||||
alt = '',
|
||||
unoptimized: _unoptimized,
|
||||
priority: _priority,
|
||||
...rest
|
||||
} = props;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const resolved = typeof src === 'string' ? src : src?.src;
|
||||
return React.createElement('img', { src: resolved, alt, ...rest });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@sentry/node": "10.34.0",
|
||||
"@sentry/profiling-node": "10.34.0",
|
||||
"@tiptap/extensions": "*",
|
||||
"axios": "1.13.5",
|
||||
"axios": "1.13.2",
|
||||
"cors": "2.8.5",
|
||||
"express": "5.2.1",
|
||||
"express-ws": "5.0.2",
|
||||
|
||||
+25
-111
@@ -1802,25 +1802,30 @@
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b"
|
||||
integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==
|
||||
|
||||
"@eslint-community/eslint-utils@^4.7.0":
|
||||
"@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.8.0":
|
||||
version "4.9.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3"
|
||||
integrity sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==
|
||||
dependencies:
|
||||
eslint-visitor-keys "^3.4.3"
|
||||
|
||||
"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1":
|
||||
"@eslint-community/eslint-utils@^4.9.1":
|
||||
version "4.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595"
|
||||
integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==
|
||||
dependencies:
|
||||
eslint-visitor-keys "^3.4.3"
|
||||
|
||||
"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.1", "@eslint-community/regexpp@^4.12.2":
|
||||
"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.2":
|
||||
version "4.12.2"
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
|
||||
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
|
||||
|
||||
"@eslint-community/regexpp@^4.12.1":
|
||||
version "4.12.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0"
|
||||
integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==
|
||||
|
||||
"@eslint/config-array@^0.21.1":
|
||||
version "0.21.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.1.tgz#7d1b0060fea407f8301e932492ba8c18aff29713"
|
||||
@@ -1845,9 +1850,9 @@
|
||||
"@types/json-schema" "^7.0.15"
|
||||
|
||||
"@eslint/eslintrc@^3.3.1":
|
||||
version "3.3.3"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.3.tgz#26393a0806501b5e2b6a43aa588a4d8df67880ac"
|
||||
integrity sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz#e55f7f1dd400600dd066dbba349c4c0bac916964"
|
||||
integrity sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==
|
||||
dependencies:
|
||||
ajv "^6.12.4"
|
||||
debug "^4.3.2"
|
||||
@@ -1855,7 +1860,7 @@
|
||||
globals "^14.0.0"
|
||||
ignore "^5.2.0"
|
||||
import-fresh "^3.2.1"
|
||||
js-yaml "^4.1.1"
|
||||
js-yaml "^4.1.0"
|
||||
minimatch "^3.1.2"
|
||||
strip-json-comments "^3.1.1"
|
||||
|
||||
@@ -2649,107 +2654,52 @@
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz#2779ca5c8aaeb46c85eb72d29f1eb34efd46fb45"
|
||||
integrity sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==
|
||||
|
||||
"@napi-rs/canvas-android-arm64@0.1.90":
|
||||
version "0.1.90"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.90.tgz#c5f2a17e68395f8c695a90bff4356dbdce4bc5d3"
|
||||
integrity sha512-3JBULVF+BIgr7yy7Rf8UjfbkfFx4CtXrkJFD1MDgKJ83b56o0U9ciT8ZGTCNmwWkzu8RbNKlyqPP3KYRG88y7Q==
|
||||
|
||||
"@napi-rs/canvas-darwin-arm64@0.1.80":
|
||||
version "0.1.80"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz#638eaa2d0a2a373c7d15748743182718dcd95c4b"
|
||||
integrity sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==
|
||||
|
||||
"@napi-rs/canvas-darwin-arm64@0.1.90":
|
||||
version "0.1.90"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.90.tgz#6141f9dcaffebaa7c5ca3cbdcc1664298bd817d3"
|
||||
integrity sha512-L8XVTXl+8vd8u7nPqcX77NyG5RuFdVsJapQrKV9WE3jBayq1aSMht/IH7Dwiz/RNJ86E5ZSg9pyUPFIlx52PZA==
|
||||
|
||||
"@napi-rs/canvas-darwin-x64@0.1.80":
|
||||
version "0.1.80"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz#bd6bc048dbd4b02b9620d9d07117ed93e6970978"
|
||||
integrity sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==
|
||||
|
||||
"@napi-rs/canvas-darwin-x64@0.1.90":
|
||||
version "0.1.90"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.90.tgz#4f35e54b33ccd437d577e91075d4af1a00f042a9"
|
||||
integrity sha512-h0ukhlnGhacbn798VWYTQZpf6JPDzQYaow+vtQ2Fat7j7ImDdpg6tfeqvOTO1r8wS+s+VhBIFITC7aA1Aik0ZQ==
|
||||
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf@0.1.80":
|
||||
version "0.1.80"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz#ce6bfbeb19d9234c42df5c384e5989aa7d734789"
|
||||
integrity sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==
|
||||
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf@0.1.90":
|
||||
version "0.1.90"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.90.tgz#1ab8a389c853f4a1c48d37ad11aca3ccbb620165"
|
||||
integrity sha512-JCvTl99b/RfdBtgftqrf+5UNF7GIbp7c5YBFZ+Bd6++4Y3phaXG/4vD9ZcF1bw1P4VpALagHmxvodHuQ9/TfTg==
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-gnu@0.1.80":
|
||||
version "0.1.80"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz#3b7a7832fef763826fa5fb740d5757204e52607d"
|
||||
integrity sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-gnu@0.1.90":
|
||||
version "0.1.90"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.90.tgz#f4cb9d795e7b6b4cda6846a3e90da96dfb9f2bb3"
|
||||
integrity sha512-vbWFp8lrP8NIM5L4zNOwnsqKIkJo0+GIRUDcLFV9XEJCptCc1FY6/tM02PT7GN4PBgochUPB1nBHdji6q3ieyQ==
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-musl@0.1.80":
|
||||
version "0.1.80"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz#d8ccd91f31d70760628623cd575134ada17690a3"
|
||||
integrity sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-musl@0.1.90":
|
||||
version "0.1.90"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.90.tgz#233160e64397370d84068c258cf3ee927f6d8730"
|
||||
integrity sha512-8Bc0BgGEeOaux4EfIfNzcRRw0JE+lO9v6RWQFCJNM9dJFE4QJffTf88hnmbOaI6TEMpgWOKipbha3dpIdUqb/g==
|
||||
|
||||
"@napi-rs/canvas-linux-riscv64-gnu@0.1.80":
|
||||
version "0.1.80"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz#927a3b859a0e3c691beaf52a19bc4736c4ffc9b8"
|
||||
integrity sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==
|
||||
|
||||
"@napi-rs/canvas-linux-riscv64-gnu@0.1.90":
|
||||
version "0.1.90"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.90.tgz#9d8dbecc8653cd7611ab0fd241c3909fee18a423"
|
||||
integrity sha512-0iiVDG5IH+gJb/YUrY/pRdbsjcgvwUmeckL/0gShWAA7004ygX2ST69M1wcfyxXrzFYjdF8S/Sn6aCAeBi89XQ==
|
||||
|
||||
"@napi-rs/canvas-linux-x64-gnu@0.1.80":
|
||||
version "0.1.80"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz#25c0416bcedd6fadc15295e9afa8d9697232050c"
|
||||
integrity sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==
|
||||
|
||||
"@napi-rs/canvas-linux-x64-gnu@0.1.90":
|
||||
version "0.1.90"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.90.tgz#a21fb6fce85f9e85f4f52eb7db93b4f79c2d66d7"
|
||||
integrity sha512-SkKmlHMvA5spXuKfh7p6TsScDf7lp5XlMbiUhjdCtWdOS6Qke/A4qGVOciy6piIUCJibL+YX+IgdGqzm2Mpx/w==
|
||||
|
||||
"@napi-rs/canvas-linux-x64-musl@0.1.80":
|
||||
version "0.1.80"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz#de85d644e09120a60996bbe165cc2efee804551b"
|
||||
integrity sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==
|
||||
|
||||
"@napi-rs/canvas-linux-x64-musl@0.1.90":
|
||||
version "0.1.90"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.90.tgz#d1276fa2fc857a4133b6dc70257743b2b1210c96"
|
||||
integrity sha512-o6QgS10gAS4vvELGDOOWYfmERXtkVRYFWBCjomILWfMgCvBVutn8M97fsMW5CrEuJI8YuxuJ7U+/DQ9oG93vDA==
|
||||
|
||||
"@napi-rs/canvas-win32-arm64-msvc@0.1.90":
|
||||
version "0.1.90"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.90.tgz#293325cbdad36a40654760699b87c59b9003cae7"
|
||||
integrity sha512-2UHO/DC1oyuSjeCAhHA0bTD9qsg58kknRqjJqRfvIEFtdqdtNTcWXMCT9rQCuJ8Yx5ldhyh2SSp7+UDqD2tXZQ==
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc@0.1.80":
|
||||
version "0.1.80"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz#6bb95885d9377912d71f1372fc1916fb54d6ef0a"
|
||||
integrity sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc@0.1.90":
|
||||
version "0.1.90"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.90.tgz#57601aa9be595693168d27fb0cd0c4060284c668"
|
||||
integrity sha512-48CxEbzua5BP4+OumSZdi3+9fNiRO8cGNBlO2bKwx1PoyD1R2AXzPtqd/no1f1uSl0W2+ihOO1v3pqT3USbmgQ==
|
||||
|
||||
"@napi-rs/canvas@0.1.80":
|
||||
"@napi-rs/canvas@0.1.80", "@napi-rs/canvas@^0.1.80":
|
||||
version "0.1.80"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas/-/canvas-0.1.80.tgz#53615bea56fd94e07331ab13caa7a39efc4914ab"
|
||||
integrity sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==
|
||||
@@ -2765,23 +2715,6 @@
|
||||
"@napi-rs/canvas-linux-x64-musl" "0.1.80"
|
||||
"@napi-rs/canvas-win32-x64-msvc" "0.1.80"
|
||||
|
||||
"@napi-rs/canvas@^0.1.80":
|
||||
version "0.1.90"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/canvas/-/canvas-0.1.90.tgz#f82e8f52dacc552e7feb9a136d77d9002374bad7"
|
||||
integrity sha512-vO9j7TfwF9qYCoTOPO39yPLreTRslBVOaeIwhDZkizDvBb0MounnTl0yeWUMBxP4Pnkg9Sv+3eQwpxNUmTwt0w==
|
||||
optionalDependencies:
|
||||
"@napi-rs/canvas-android-arm64" "0.1.90"
|
||||
"@napi-rs/canvas-darwin-arm64" "0.1.90"
|
||||
"@napi-rs/canvas-darwin-x64" "0.1.90"
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf" "0.1.90"
|
||||
"@napi-rs/canvas-linux-arm64-gnu" "0.1.90"
|
||||
"@napi-rs/canvas-linux-arm64-musl" "0.1.90"
|
||||
"@napi-rs/canvas-linux-riscv64-gnu" "0.1.90"
|
||||
"@napi-rs/canvas-linux-x64-gnu" "0.1.90"
|
||||
"@napi-rs/canvas-linux-x64-musl" "0.1.90"
|
||||
"@napi-rs/canvas-win32-arm64-msvc" "0.1.90"
|
||||
"@napi-rs/canvas-win32-x64-msvc" "0.1.90"
|
||||
|
||||
"@napi-rs/wasm-runtime@^0.2.11":
|
||||
version "0.2.12"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2"
|
||||
@@ -7054,13 +6987,6 @@
|
||||
pg-protocol "*"
|
||||
pg-types "^2.2.0"
|
||||
|
||||
"@types/pngjs@6.0.5":
|
||||
version "6.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-6.0.5.tgz#6dec2f7eb8284543ca4e423f3c09b119fa939ea3"
|
||||
integrity sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/qs@*":
|
||||
version "6.14.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.14.0.tgz#d8b60cecf62f2db0fb68e5e006077b9178b85de5"
|
||||
@@ -8184,13 +8110,13 @@ axe-core@^4.10.0:
|
||||
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.11.0.tgz#16f74d6482e343ff263d4f4503829e9ee91a86b6"
|
||||
integrity sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==
|
||||
|
||||
axios@1.13.5:
|
||||
version "1.13.5"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.5.tgz#5e464688fa127e11a660a2c49441c009f6567a43"
|
||||
integrity sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==
|
||||
axios@1.13.2:
|
||||
version "1.13.2"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.2.tgz#9ada120b7b5ab24509553ec3e40123521117f687"
|
||||
integrity sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==
|
||||
dependencies:
|
||||
follow-redirects "^1.15.11"
|
||||
form-data "^4.0.5"
|
||||
follow-redirects "^1.15.6"
|
||||
form-data "^4.0.4"
|
||||
proxy-from-env "^1.1.0"
|
||||
|
||||
axobject-query@^4.1.0:
|
||||
@@ -9987,9 +9913,9 @@ esprima@^4.0.0:
|
||||
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
|
||||
|
||||
esquery@^1.5.0:
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d"
|
||||
integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7"
|
||||
integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==
|
||||
dependencies:
|
||||
estraverse "^5.1.0"
|
||||
|
||||
@@ -10339,7 +10265,7 @@ flatted@^3.2.9, flatted@^3.3.3:
|
||||
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358"
|
||||
integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==
|
||||
|
||||
follow-redirects@^1.15.11:
|
||||
follow-redirects@^1.15.6:
|
||||
version "1.15.11"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340"
|
||||
integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==
|
||||
@@ -10374,7 +10300,7 @@ foreground-child@^3.1.0:
|
||||
cross-spawn "^7.0.6"
|
||||
signal-exit "^4.0.1"
|
||||
|
||||
form-data@^4.0.0:
|
||||
form-data@^4.0.0, form-data@^4.0.4:
|
||||
version "4.0.4"
|
||||
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4"
|
||||
integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==
|
||||
@@ -12081,7 +12007,7 @@ js-yaml@^3.13.1:
|
||||
argparse "^1.0.7"
|
||||
esprima "^4.0.0"
|
||||
|
||||
js-yaml@^4.1.0, js-yaml@^4.1.1:
|
||||
js-yaml@^4.1.0:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
|
||||
integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
|
||||
@@ -13607,13 +13533,6 @@ pirates@^4.0.7:
|
||||
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22"
|
||||
integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==
|
||||
|
||||
pixelmatch@7.1.0:
|
||||
version "7.1.0"
|
||||
resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-7.1.0.tgz#9d59bddc8c779340e791106c0f245ac33ae4d113"
|
||||
integrity sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==
|
||||
dependencies:
|
||||
pngjs "^7.0.0"
|
||||
|
||||
pkg-dir@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
|
||||
@@ -13642,11 +13561,6 @@ plimit-lit@^1.2.6:
|
||||
dependencies:
|
||||
queue-lit "^1.5.1"
|
||||
|
||||
pngjs@7.0.0, pngjs@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-7.0.0.tgz#a8b7446020ebbc6ac739db6c5415a65d17090e26"
|
||||
integrity sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==
|
||||
|
||||
possible-typed-array-names@^1.0.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae"
|
||||
|
||||
@@ -70,7 +70,6 @@ backend:
|
||||
AWS_STORAGE_BUCKET_NAME: docs-media-storage
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
DOCSPEC_API_URL: http://impress-docs-docspec:4000/conversion
|
||||
USER_RECONCILIATION_FORM_URL: https://docs.127.0.0.1.nip.io
|
||||
Y_PROVIDER_API_BASE_URL: http://impress-docs-y-provider:443/api/
|
||||
Y_PROVIDER_API_KEY: my-secret
|
||||
CACHES_KEY_PREFIX: "{{ now | unixEpoch }}"
|
||||
|
||||
@@ -71,7 +71,6 @@ backend:
|
||||
AWS_STORAGE_BUCKET_NAME: docs-media-storage
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
DOCSPEC_API_URL: http://impress-docs-docspec:4000/conversion
|
||||
USER_RECONCILIATION_FORM_URL: https://{{ .Values.feature }}-docs.{{ .Values.domain }}
|
||||
Y_PROVIDER_API_BASE_URL: http://impress-docs-y-provider:443/api/
|
||||
Y_PROVIDER_API_KEY: my-secret
|
||||
CACHES_KEY_PREFIX: "{{ now | unixEpoch }}"
|
||||
|
||||
Reference in New Issue
Block a user