Compare commits

...

16 Commits

Author SHA1 Message Date
Manuel Raynaud
c96c3c1775 (backend) add management command to reset a Document
We need a management command to reset a Document to an initial state and
deletes everything related to it. This command can be usefull to reset a
demo for example.
2026-02-11 19:19:00 +01:00
Sylvain Boissel
3ab0a47c3a (backend) manage reconciliation requests for user accounts (#1878)
For now, the reconciliation requests are imported through CSV in the
Django admin, which sends confirmation email to both addresses. When
both are checked, the actual reconciliation is processed, and all
user-related content is updated.

## Purpose

Fix #1616 // Replaces #1708

For now, the reconciliation requests are imported through CSV in the
Django admin, which sends confirmation email to both addresses. When
both are checked, the actual reconciliation is processed, and all
user-related content is updated.


## Proposal
- [x] New `UserReconciliationCsvImport` model to manage the import of
reconciliation requests through a task
(`user_reconciliation_csv_import_job`)
- [x] New `UserReconciliation` model to store the user reconciliation
requests themselves (a row = a `active_user`/`inactive_user` pair)
  - [x] On save, a confirmation email is sent to the users
- [x] A `process_reconciliation` admin action process the action on the
requested entries, if both emails have been checked.
- [x] Bulk update the `DocumentAccess` items, while managing the case
where both users have access to the document (keeping the higher role)
- [x] Bulk update the `LinkTrace` items, while managing the case where
both users have link traces to the document
- [x] Bulk update the `DocumentFavorite` items, while managing the case
where both users have put the document in their favorites
- [x] Bulk update the comment system items (`Thread`, `Comment` and
`Reaction` items)
  - [x] Bulk update the `is_active` status on both users
- [x] New `USER_RECONCILIATION_FORM_URL` env variable for the "make a
new request" URL in an email.
- [x] Write unit tests
- [x] Remove the unused `email_user()` method on `User`, replaced with
`send_email()` similar to the one on the `Document` model


## Demo page reconciliation success

<img width="1149" height="746" alt="image"
src="https://github.com/user-attachments/assets/09ba2b38-7af3-41fa-a64f-ce3c4fd8548d"
/>

---------

Co-authored-by: Anthony LC <anthony.le-courric@mail.numerique.gouv.fr>
2026-02-11 18:09:20 +00:00
Sylvain Boissel
685464f2d7 🚸(backend) sort user search results by proximity with the active user (#1802)
## Purpose
Allows a user to find more easily the other users they search, with the
following order of priority:
- users they already share documents with (more recent first)
- users that share the same full email domain
- ~~users that share the same partial email domain (last two parts)~~
- ~~other users~~

Edit: We need to ilter out other users in order to not reveal email
addresses from members of other organisations. It's still possible to
invite them by email.

Solves #1521

## Proposal
- [x] Add a new function in `core/utils.py`:
`users_sharing_documents_with()`
- [x] Use it as a key to sort the results of a basic user search
- [x] Filter user results to avoid reveal of users (and email addresses)
of other orgs or that have not been interacted with.
- [x] User research through "full" email address (contains the '@') is
left unaffected.

---------

Co-authored-by: Anthony LC <anthony.le-courric@mail.numerique.gouv.fr>
2026-02-11 18:51:45 +01:00
virgile-dev
9af540de35 📝(readme) replace demo link (#1875)
so that the sandbox is not a public gov one

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2026-02-11 16:48:24 +00:00
Manuel Raynaud
6c43ecc324 🔧(docker) change mime.types url in Dockerfile
Change mime.types url in Dockerfile
2026-02-11 14:17:32 +00:00
renovate[bot]
607bae0022 ⬆️(dependencies) update axios to v1.13.5 [SECURITY] 2026-02-10 09:29:05 +00:00
Anthony LC
1d8b730715 (e2e) add threshold in regression test
When comparing PDF screenshots, we can have some
minor differences due to the different environments
(OS, fonts, etc.).
To avoid false positives in our regression
tests, we can set a threshold for the number of
different pixels allowed before considering the
test as failed.
If the test fails we will now report the PDF
and the differences to identify quickly
what are the regressions.
2026-02-09 16:17:05 +01:00
Anthony LC
d02c6250c9 🔒️(frontend) harden security check on url
We harden the security check on url to prevent attacks.
2026-02-09 16:17:05 +01:00
Anthony LC
b8c1504e7a ♻️(export) change pdf block from embed to iframe
When trying to print with a embed PDF the
browser's print dialog stays blocked and the user
can't print the document. Changing the PDF block
to use an iframe instead of an embed resolves
this issue.
2026-02-09 16:17:05 +01:00
Anthony LC
18edcf8537 🚨(ci) limit print check to backend
We added a feature to print documents directly
from the browser. The function is called
`window.print()`, this name collides with Python's
`print()` function. To avoid false positives in our
CI when checking for print statements, we limit
the search to only the backend code.
2026-02-09 16:17:05 +01:00
Anthony LC
5d8741a70a (frontend) print a doc with native browser
We can now print a doc with the native browser
print dialog.
This feature uses the browser's built-in print
capabilities to generate a print preview and
allows users to print directly from the application.
It has as well a powerfull print to PDF feature
that leverages the browser's PDF generation
capabilities for better compatibility and
quality.

Co-authored-by: AntoLC <anthony.le-courric@mail.numerique.gouv.fr>
Co-authored-by: Cyril <c.gromoff@gmail.com>
2026-02-09 16:17:04 +01:00
Cyril
48df68195a ️(frontend) focus docs list title after filter navigation
Explain focus shift to match skip-to-content behavior.

hook useRouteChangeCompleteFocus

Positionne the focus on the first target or main element after a route change.
2026-02-05 11:13:30 +01:00
buildwithricky
7cf42e6404 🐛(frontend) fix doc timestamp display
Implemented the logic to show 'Just now' instead
of '0 seconds ago' when the difference is under
one second.

Signed-off-by: buildwithricky <nwakezepatrick@gmail.com>
2026-02-04 09:34:21 +01:00
Manuel Raynaud
9903bd73e2 ️(actions) enable trivy scan on backend image
The trivy was disabled because protobuf library was blocking the release
process. We can now enable it again, a new release of protobuf is
available.
2026-02-03 16:45:13 +00:00
Anthony LC
44b38347c4 🐛(frontend) fix broadcast store sync
When going from one subdoc to another by example,
the broadcast store could have difficulty to resync.
This commit ensures that the broadcast store
cleans up and resets its state when rerendering.
It will stop as well triggering the action for
the current user avoiding potential unecessary
requests.
2026-02-03 14:25:35 +01:00
Thai Pangsakulyanont
709076067b 🐛(backend) add AWS_S3_SIGNATURE_VERSION environment variable support
Add support for the `AWS_S3_SIGNATURE_VERSION` environment variable to
allow configuring S3 signature version for compatibility with
S3-compatible storage services like Linode Object Storage.

Fixes #1788

Signed-off-by: dtinth on MBP M1 <dtinth@spacet.me>
2026-02-02 10:47:14 +00:00
80 changed files with 3915 additions and 363 deletions

View File

@@ -36,12 +36,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '--target backend-production -f Dockerfile'
# docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6

View File

@@ -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 -- . ':(exclude)**/impress.yml' | grep "print("
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- src/backend ':(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://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
sudo wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/conf/mime.types -O /etc/mime.types
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages

View File

@@ -6,9 +6,24 @@ 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
## [v4.5.0] - 2026-01-28
### Added
### Added
- ✨(frontend) integrate configurable Waffle #1795
- ✨ Import of documents #1609

View File

@@ -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://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
RUN wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/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"]

View File

@@ -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://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
You can test Docs on your browser by visiting this [demo document](https://docs.la-suite.eu/docs/9137bbb5-3e8a-4ff7-8a36-fcc4e8bd57f4/)
### Run Docs locally

View File

@@ -17,13 +17,15 @@ 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 | |
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
| AWS_S3_SIGNATURE_VERSION | S3 signature version (`s3v4` or `s3`) | s3v4 |
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
| CACHES_DEFAULT_KEY_PREFIX | The prefix used to every cache keys. | docs |
| CACHES_DEFAULT_KEY_PREFIX | The prefix used to every cache keys. | docs |
| COLLABORATION_API_URL | Collaboration api host | |
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
@@ -118,6 +120,7 @@ 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 | |

View File

@@ -67,6 +67,7 @@ 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 }}"

View File

@@ -0,0 +1,30 @@
# 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"
```

View File

@@ -59,6 +59,9 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# 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
# AI
AI_FEATURE_ENABLED=true
AI_BASE_URL=https://openaiendpoint.com

View File

@@ -53,6 +53,9 @@ 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

View File

@@ -1,12 +1,14 @@
"""Admin classes and registrations for core app."""
from django.contrib import admin
from django.contrib import admin, messages
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 . import models
from core import models
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
@admin.register(models.User)
@@ -95,6 +97,44 @@ 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."""

View File

@@ -2,6 +2,7 @@
import unicodedata
from django.conf import settings
from django.utils.translation import gettext_lazy as _
import django_filters
@@ -135,4 +136,6 @@ class UserSearchFilter(django_filters.FilterSet):
Custom filter for searching users.
"""
q = django_filters.CharFilter(min_length=5, max_length=254)
q = django_filters.CharFilter(
min_length=settings.API_USERS_SEARCH_QUERY_MIN_LENGTH, max_length=254
)

View File

@@ -37,9 +37,11 @@ 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
@@ -61,7 +63,11 @@ 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
from core.utils import (
extract_attachments,
filter_descendants,
users_sharing_documents_with,
)
from . import permissions, serializers, utils
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
@@ -220,18 +226,80 @@ 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
# 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)
return (
user_email_domain = get_domain_from_email(current_user.email) or ""
candidates = list(
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")[: settings.API_USERS_LIST_LIMIT]
.order_by("-similarity")
)
# 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"],
@@ -249,6 +317,59 @@ 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."""
@@ -2338,6 +2459,7 @@ 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",

View File

@@ -0,0 +1,150 @@
"""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.")

View File

@@ -0,0 +1,178 @@
# 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"],
},
),
]

View File

@@ -15,7 +15,6 @@ 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
@@ -33,14 +32,14 @@ from rest_framework.exceptions import ValidationError
from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
from .choices import (
from core.choices import (
PRIVILEGED_ROLES,
LinkReachChoices,
LinkRoleChoices,
RoleChoices,
get_equivalent_link_definition,
)
from .validators import sub_validator
from core.validators import sub_validator
logger = getLogger(__name__)
@@ -251,11 +250,37 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
valid_invitations.delete()
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)
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)
@cached_property
def teams(self):
@@ -266,6 +291,417 @@ 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."""

View File

@@ -4,12 +4,14 @@ 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 . import models
from .tasks.search import trigger_batch_document_indexer
from core import models
from core.tasks.search import trigger_batch_document_indexer
from core.utils import get_users_sharing_documents_with_cache_key
@receiver(signals.post_save, sender=models.Document)
@@ -26,8 +28,24 @@ 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)

View File

@@ -0,0 +1,135 @@
"""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()

View File

@@ -0,0 +1,313 @@
"""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

View File

@@ -0,0 +1,6 @@
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 active_email inactive_email active_email_checked inactive_email_checked status id
2 user.test40@example.com user.test41@example.com 0 0 pending 1
3 user.test42@example.com user.test43@example.com 0 1 pending 2
4 user.test44@example.com user.test45@example.com 1 0 pending 3
5 user.test46@example.com user.test47@example.com 1 1 pending 4
6 user.test48@example.com user.test49@example.com 1 1 pending 5

View File

@@ -0,0 +1,2 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
"user.test40@example.com",,0,0,pending,40
1 active_email inactive_email active_email_checked inactive_email_checked status id
2 user.test40@example.com 0 0 pending 40

View File

@@ -0,0 +1,5 @@
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 merge_accept active_email inactive_email status id
2 true user.test10@example.com user.test11@example.com|user.test12@example.com pending 10
3 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
4 true user.test20@example.com user.test21@example.com pending 12
5 true user.test22@example.com user.test23@example.com pending 13

View File

@@ -0,0 +1,2 @@
merge_accept,active_email,inactive_email,status,id
true,user.test20@example.com,user.test20@example.com,pending,20
1 merge_accept active_email inactive_email status id
2 true user.test20@example.com user.test20@example.com pending 20

View File

@@ -0,0 +1,6 @@
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 active_email inactive_email active_email_checked inactive_email_checked status
2 user.test40@example.com user.test41@example.com 0 0 pending
3 user.test42@example.com user.test43@example.com 0 1 pending
4 user.test44@example.com user.test45@example.com 1 0 pending
5 user.test46@example.com user.test47@example.com 1 1 pending
6 user.test48@example.com user.test49@example.com 1 1 pending

View File

@@ -20,6 +20,7 @@ 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",
@@ -44,6 +45,7 @@ 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"],

View File

@@ -0,0 +1,85 @@
"""
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"}

View File

@@ -2,6 +2,8 @@
Test users API endpoints in the impress core app.
"""
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
@@ -121,12 +123,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()
user = factories.UserFactory(email="user@example.com")
client = APIClient()
client.force_login(user)
dave = factories.UserFactory(email="contact@work.com", full_name="David Bowman")
dave = factories.UserFactory(email="contact@example.com", full_name="David Bowman")
response = client.get(
"/api/v1.0/users/?q=David",
@@ -166,13 +168,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()
user = factories.UserFactory(email="user@example.com")
client = APIClient()
client.force_login(user)
fred = factories.UserFactory(
email="contact@work.com", full_name="Frédérique Lefèvre"
email="contact@example.com", full_name="Frédérique Lefèvre"
)
response = client.get("/api/v1.0/users/?q=Frédérique")
@@ -201,12 +203,82 @@ 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 10.
should be limited to API_USERS_LIST_LIMIT (by default 5).
"""
user = factories.UserFactory()
user = factories.UserFactory(email="user@example.com")
client = APIClient()
client.force_login(user)
@@ -309,28 +381,16 @@ def test_api_users_list_query_email_exclude_doc_user():
def test_api_users_list_query_short_queries():
"""
Queries shorter than 5 characters should return an empty result set.
If API_USERS_SEARCH_QUERY_MIN_LENGTH is not set, the default minimum length should be 3.
"""
user = factories.UserFactory(email="paul@example.com", full_name="Paul")
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com")
factories.UserFactory(email="john.lennon@example.com")
factories.UserFactory(email="john.doe@example.com", full_name="John Doe")
factories.UserFactory(email="john.lennon@example.com", full_name="John Lennon")
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.")
response = client.get("/api/v1.0/users/?q=joh")
assert response.status_code == 200
assert len(response.json()) == 2
@@ -356,7 +416,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()
user = factories.UserFactory(email="user@example.com")
client = APIClient()
client.force_login(user)

View File

@@ -0,0 +1,669 @@
"""
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()

View File

@@ -2,8 +2,6 @@
Unit tests for the User model
"""
from unittest import mock
from django.core.exceptions import ValidationError
import pytest
@@ -26,26 +24,6 @@ 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",
[

View File

@@ -3,9 +3,14 @@
import base64
import uuid
import pycrdt
from django.core.cache import cache
from core import utils
import pycrdt
import pytest
from core import factories, utils
pytestmark = pytest.mark.django_db
# This base64 string is an example of what is saved in the database.
# This base64 is generated from the blocknote editor, it contains
@@ -100,3 +105,103 @@ def test_utils_get_ancestor_to_descendants_map_multiple_paths():
"000100020005": {"000100020005"},
"00010003": {"00010003"},
}
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()
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
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()
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
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()
# 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
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()
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 == {}

View File

@@ -0,0 +1,62 @@
"""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,
}

View File

@@ -59,6 +59,10 @@ 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(),
),
]
),
),

View File

@@ -1,13 +1,21 @@
"""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
from core import enums, models
logger = logging.getLogger(__name__)
def get_ancestor_to_descendants_map(paths, steplen):
@@ -96,3 +104,46 @@ 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

View File

@@ -169,6 +169,11 @@ class Base(Configuration):
environ_name="AWS_STORAGE_BUCKET_NAME",
environ_prefix=None,
)
AWS_S3_SIGNATURE_VERSION = values.Value(
"s3v4",
environ_name="AWS_S3_SIGNATURE_VERSION",
environ_prefix=None,
)
# Document images
DOCUMENT_IMAGE_MAX_SIZE = values.IntegerValue(
@@ -837,6 +842,11 @@ 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.
@@ -870,6 +880,11 @@ 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,

View File

@@ -976,7 +976,10 @@ test.describe('Doc Editor', () => {
await expect(pdfBlock).toBeVisible();
// Try with invalid PDF first
await page.getByText(/Add (PDF|file)/).click();
await page
.getByText(/Add (PDF|file)/)
.first()
.click();
await page.locator('[data-test="embed-tab"]').click();
@@ -994,7 +997,6 @@ 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;
@@ -1003,24 +1005,16 @@ test.describe('Doc Editor', () => {
// Wait for the media-check to be processed
await page.waitForTimeout(1000);
const pdfEmbed = page
.locator('.--docs--editor-container embed.bn-visual-media')
const pdfIframe = page
.locator('.--docs--editor-container iframe.bn-visual-media')
.first();
// Check src of pdf
expect(await pdfEmbed.getAttribute('src')).toMatch(
expect(await pdfIframe.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.pdf/,
);
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');
await expect(pdfIframe).toHaveAttribute('role', 'presentation');
});
test('it preserves text when switching between mobile and desktop views', async ({

View File

@@ -1,19 +1,18 @@
import fs from 'fs';
import path from 'path';
import { Download, Page, expect, test } from '@playwright/test';
import { 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('/');
@@ -33,7 +32,9 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
page.getByText(/Download your document in a \.docx, \.odt.*format\./i),
page.getByText(
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
),
).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
await expect(
@@ -306,10 +307,63 @@ 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
@@ -325,10 +379,6 @@ 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`);
});
@@ -338,152 +388,16 @@ 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(download);
//await savePDFToAssetFolder(pdfBuffer, 'doc-export-regressions.pdf');
// Assert the generated PDF matches "assets/doc-export-regressions.pdf"
await comparePDFWithAssetFolder(download);
await comparePDFWithAssetFolder({
originPdfBuffer: pdfBuffer,
filename: 'doc-export-regressions.pdf',
testInfo,
});
});
});
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;
};

View File

@@ -7,7 +7,12 @@ import {
mockedDocument,
verifyDocName,
} from './utils-common';
import { mockedAccesses, mockedInvitations } from './utils-share';
import {
connectOtherUserToDoc,
mockedAccesses,
mockedInvitations,
updateShareLink,
} from './utils-share';
import { createRootSubPage, getTreeRow } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
@@ -52,13 +57,54 @@ test.describe('Doc Header', () => {
).toBeVisible();
});
test('it updates the title doc', async ({ page, browserName }) => {
await createDoc(page, 'doc-update', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'Document title' });
await expect(docTitle).toBeVisible();
await docTitle.fill('Hello World');
await docTitle.blur();
test('it updates the title doc and check the broadcast', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(
page,
'doc-title-update',
browserName,
1,
);
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Editing');
const docUrl = page.url();
const { otherPage, cleanup } = await connectOtherUserToDoc({
docUrl,
browserName,
withoutSignIn: true,
docTitle,
});
// Wait for other page to sync
await page.waitForTimeout(1000);
await page.keyboard.press('Escape');
const elTitle = page.getByRole('textbox', { name: 'Document title' });
await expect(elTitle).toBeVisible();
await elTitle.fill('Hello World');
await elTitle.blur();
await verifyDocName(page, 'Hello World');
// Wait for other page to sync
await page.waitForTimeout(1000);
// Check other user page
await verifyDocName(otherPage, 'Hello World');
const elTitleOther = otherPage.getByRole('textbox', {
name: 'Document title',
});
await elTitleOther.fill('Hello Other World');
await elTitleOther.blur();
// Check first user page
await verifyDocName(page, 'Hello Other World');
await cleanup();
});
test('it updates the title doc adding a leading emoji', async ({

View File

@@ -45,7 +45,7 @@ test.describe('Document search', () => {
const listSearch = page.getByRole('listbox').getByRole('group');
const rowdoc = listSearch.getByRole('option').first();
await expect(rowdoc.getByText('keyboard_return')).toBeVisible();
await expect(rowdoc.getByText(/seconds? ago/)).toBeVisible();
await expect(rowdoc.getByText(/just now/)).toBeVisible();
await expect(
listSearch.getByRole('option').getByText(doc1Title),

View File

@@ -8,6 +8,7 @@ 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/');

View File

@@ -18,6 +18,20 @@ 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,

View File

@@ -1,10 +1,14 @@
import { Locator, Page, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { Locator, Page, TestInfo, 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,
@@ -388,3 +392,30 @@ 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,
});
};

View File

@@ -0,0 +1,239 @@
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}`,
);
}
}
};

View File

@@ -22,8 +22,11 @@
"typescript": "*"
},
"dependencies": {
"@types/pngjs": "6.0.5",
"convert-stream": "1.0.2",
"pdf-parse": "2.4.5"
"pdf-parse": "2.4.5",
"pixelmatch": "7.1.0",
"pngjs": "7.0.0"
},
"packageManager": "yarn@1.22.22"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -9,6 +9,7 @@ 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/';
@@ -51,6 +52,7 @@ const queryClient = new QueryClient({
export function AppProvider({ children }: { children: React.ReactNode }) {
const { theme } = useCunninghamTheme();
const { replace } = useRouter();
useRouteChangeCompleteFocus();
const initializeResizeListener = useResponsiveStore(
(state) => state.initializeResizeListener,

View File

@@ -16,6 +16,7 @@ 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[];

View File

@@ -0,0 +1,68 @@
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();
});
});

View File

@@ -1,2 +1,3 @@
export * from './useAuthQuery';
export * from './types';
export * from './useAuthQuery';
export * from './useUserReconciliations';

View File

@@ -0,0 +1,51 @@
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,
});
}

View File

@@ -0,0 +1,14 @@
<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>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,97 @@
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>
);
};

View File

@@ -1,3 +1,4 @@
export * from './Auth';
export * from './ButtonLogin';
export * from './UserAvatar';
export * from './UserReconciliation';

View File

@@ -1,5 +1,8 @@
import { APIError, errorCauses } from '@/api';
import { sleep } from '@/utils';
import { isSafeUrl } from '@/utils/url';
import { ANALYZE_URL } from '../conf';
interface CheckDocMediaStatusResponse {
file?: string;
@@ -13,6 +16,10 @@ 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',
});

View File

@@ -15,14 +15,19 @@ import {
import { TFunction } from 'i18next';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import { createGlobalStyle } 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;
}
@@ -59,11 +64,7 @@ interface PdfBlockComponentProps {
>;
}
const PdfBlockComponent = ({
editor,
block,
contentRef,
}: PdfBlockComponentProps) => {
const PdfBlockComponent = ({ editor, block }: PdfBlockComponentProps) => {
const pdfUrl = block.props.url;
const { i18n, t } = useTranslation();
const lang = i18n.resolvedLanguage;
@@ -87,7 +88,7 @@ const PdfBlockComponent = ({
}, [lang, t]);
useEffect(() => {
if (!pdfUrl || pdfUrl.includes(ANALYZE_URL)) {
if (!pdfUrl || pdfUrl.includes(ANALYZE_URL) || !isSafeUrl(pdfUrl)) {
return;
}
@@ -114,27 +115,30 @@ const PdfBlockComponent = ({
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;" />
@@ -144,21 +148,19 @@ const PdfBlockComponent = ({
>
{!isPDFContentLoading && isPDFContent && (
<Box
as="embed"
as="iframe"
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>
</>
);
};

View File

@@ -11,6 +11,7 @@ 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';
@@ -67,7 +68,7 @@ const UploadLoaderBlockComponent = ({
block.props.type === 'loading' &&
isEditable;
if (!shouldCheckStatus) {
if (!shouldCheckStatus || !isSafeUrl(block.props.blockUploadUrl)) {
return;
}

View File

@@ -4,6 +4,7 @@ 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';
@@ -57,7 +58,8 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
if (
!block ||
!('url' in block.props) ||
('url' in block.props && !block.props.url.includes(ANALYZE_URL))
('url' in block.props && !block.props.url.includes(ANALYZE_URL)) ||
!isSafeUrl(block.props.url)
) {
return;
}

View File

@@ -34,12 +34,14 @@ 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 {
@@ -66,6 +68,14 @@ 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')
@@ -199,13 +209,15 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
</Button>
<Button
data-testid="doc-export-download-button"
aria-label={t('Download')}
aria-label={
format === DocDownloadFormat.PRINT ? t('Print') : t('Download')
}
variant="primary"
fullWidth
onClick={() => void onSubmit()}
disabled={isExporting}
>
{t('Download')}
{format === DocDownloadFormat.PRINT ? t('Print') : t('Download')}
</Button>
</>
}
@@ -225,7 +237,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
$align="flex-start"
data-testid="modal-export-title"
>
{t('Download')}
{t('Export')}
</Text>
<ButtonCloseModal
aria-label={t('Close the download modal')}
@@ -243,7 +255,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
>
<Text $variation="secondary" $size="sm" as="p">
{t(
'Download your document in a .docx, .odt, .pdf or .html(zip) format.',
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
)}
</Text>
<Select
@@ -251,10 +263,11 @@ 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) =>

View File

@@ -1,5 +1,7 @@
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.
@@ -11,6 +13,12 @@ export const escapeHtml = (value: string): string =>
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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.
*
@@ -138,7 +146,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}`);
heading.innerHTML = block.innerHTML;
moveChildNodes(block, heading);
block.replaceWith(heading);
});
@@ -252,7 +260,7 @@ export const improveHtmlAccessibility = (
// Create list item and add content
const li = parsedDocument.createElement('li');
li.innerHTML = listItem.innerHTML;
moveChildNodes(listItem, li);
targetList.appendChild(li);
// Remove original block-outer
@@ -265,7 +273,7 @@ export const improveHtmlAccessibility = (
);
quoteBlocks.forEach((block) => {
const quote = parsedDocument.createElement('blockquote');
quote.innerHTML = block.innerHTML;
moveChildNodes(block, quote);
block.replaceWith(quote);
});
@@ -276,7 +284,7 @@ export const improveHtmlAccessibility = (
calloutBlocks.forEach((block) => {
const aside = parsedDocument.createElement('aside');
aside.setAttribute('role', 'note');
aside.innerHTML = block.innerHTML;
moveChildNodes(block, aside);
block.replaceWith(aside);
});
@@ -303,7 +311,7 @@ export const improveHtmlAccessibility = (
}
const li = parsedDocument.createElement('li');
li.innerHTML = item.innerHTML;
moveChildNodes(item, li);
// Ensure checkbox has an accessible state; fall back to aria-checked if missing.
const checkbox = li.querySelector<HTMLInputElement>(
@@ -340,7 +348,7 @@ export const improveHtmlAccessibility = (
});
// Move content inside <code>.
code.innerHTML = block.innerHTML;
moveChildNodes(block, code);
pre.appendChild(code);
block.replaceWith(pre);
});
@@ -408,7 +416,7 @@ export const addMediaFilesToZip = async (
url = null;
}
if (!url || url.origin !== mediaUrl) {
if (!url || url.origin !== mediaUrl || !isSafeUrl(url.href)) {
return;
}

View File

@@ -0,0 +1,266 @@
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);
}

View File

@@ -46,7 +46,9 @@ 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(/Download your document in a .docx, .odt.*format\./i),
screen.getByText(
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
),
).toBeInTheDocument();
}, 10000);

View File

@@ -1,9 +1,9 @@
import { DateTime } from 'luxon';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useDate } from '@/hooks/useDate';
import { useResponsiveStore } from '@/stores';
import ChildDocument from '../assets/child-document.svg';
@@ -38,6 +38,7 @@ export const SimpleDocItem = ({
const { isDesktop } = useResponsiveStore();
const { untitledDocument } = useTrans();
const { isChild } = useDocUtils(doc);
const { relativeDate } = useDate();
return (
<Box
@@ -100,7 +101,7 @@ export const SimpleDocItem = ({
aria-hidden="true"
>
<Text $size="xs" $variation="tertiary">
{DateTime.fromISO(doc.updated_at).toRelative()}
{relativeDate(doc.updated_at)}
</Text>
</Box>
)}

View File

@@ -8,7 +8,7 @@ import { Base64 } from '../types';
export const useCollaboration = (room?: string, initialContent?: Base64) => {
const collaborationUrl = useCollaborationUrl(room);
const { setBroadcastProvider } = useBroadcastStore();
const { setBroadcastProvider, cleanupBroadcast } = useBroadcastStore();
const { provider, createProvider, destroyProvider } = useProviderStore();
useEffect(() => {
@@ -33,8 +33,9 @@ export const useCollaboration = (room?: string, initialContent?: Base64) => {
useEffect(() => {
return () => {
if (room) {
cleanupBroadcast();
destroyProvider();
}
};
}, [destroyProvider, room]);
}, [destroyProvider, room, cleanupBroadcast]);
};

View File

@@ -11,6 +11,7 @@ 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';
@@ -57,6 +58,9 @@ 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();
@@ -83,7 +87,6 @@ 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]);
@@ -111,7 +114,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
const searchUsersQuery = useUsers(
{ query: userQuery, docId: doc.id },
{
enabled: userQuery?.length > MIN_CHARACTERS_FOR_SEARCH,
enabled: userQuery?.length >= API_USERS_SEARCH_QUERY_MIN_LENGTH,
queryKey: [KEY_LIST_USER, { query: userQuery }],
},
);

View File

@@ -13,9 +13,7 @@ const getDocVersion = async ({
versionId,
docId,
}: DocVersionParam): Promise<Version> => {
const url = `documents/${docId}/versions/${versionId}/`;
const response = await fetchAPI(url);
const response = await fetchAPI(`documents/${docId}/versions/${versionId}/`);
if (!response.ok) {
throw new APIError(

View File

@@ -29,9 +29,9 @@ const getDocVersions = async ({
versionId,
docId,
}: DocVersionsAPIParams): Promise<VersionsResponse> => {
const url = `documents/${docId}/versions/?version_id=${versionId}`;
const response = await fetchAPI(url);
const response = await fetchAPI(
`documents/${docId}/versions/?version_id=${versionId}`,
);
if (!response.ok) {
throw new APIError(

View File

@@ -237,7 +237,7 @@ const DocGridTitleBar = ({
>
<Box $direction="row" $gap="xs" $align="center">
{icon}
<Text as="h2" $size="h4" $margin="none">
<Text as="h2" $size="h4" $margin="none" tabIndex={-1}>
{title}
</Text>
</Box>

View File

@@ -31,6 +31,10 @@ describe('DocsGridItemDate', () => {
});
[
{
updated_at: DateTime.now().minus({ seconds: 1 }).toISO(),
rendered: 'just now',
},
{
updated_at: DateTime.now().minus({ minutes: 1 }).toISO(),
rendered: '1 minute ago',

View File

@@ -70,7 +70,9 @@ 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;

View File

@@ -71,6 +71,7 @@ export const ResizableLeftPanel = ({
<PanelGroup direction="horizontal">
<Panel
ref={ref}
className="--docs--resizable-left-panel"
order={0}
defaultSize={
isDesktop

View File

@@ -10,7 +10,7 @@ const formatDefault: DateTimeFormatOptions = {
};
export const useDate = () => {
const { i18n } = useTranslation();
const { i18n, t } = useTranslation();
const formatDate = (
date: string,
@@ -22,7 +22,19 @@ export const useDate = () => {
};
const relativeDate = (date: string): string => {
return DateTime.fromISO(date).setLocale(i18n.language).toRelative() || '';
const dateToCompare = DateTime.fromISO(date);
if (!dateToCompare.isValid) {
return '';
}
const dateNow = DateTime.now();
const differenceInSeconds = dateNow.diff(dateToCompare).as('seconds');
return Math.abs(differenceInSeconds) >= 5
? dateToCompare.toRelative({ base: dateNow, locale: i18n.language })
: t('just now');
};
const calculateDaysLeft = (date: string, daysLimit: number): number =>

View File

@@ -0,0 +1,38 @@
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]);
};

View File

@@ -0,0 +1,40 @@
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;

View File

@@ -0,0 +1,40 @@
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;

View File

@@ -5,7 +5,9 @@ import { create } from 'zustand';
interface BroadcastState {
addTask: (taskLabel: string, action: () => void) => void;
broadcast: (taskLabel: string) => void;
cleanupBroadcast: () => void;
getBroadcastProvider: () => HocuspocusProvider | undefined;
handleProviderSync: () => void;
provider?: HocuspocusProvider;
setBroadcastProvider: (provider: HocuspocusProvider) => void;
setTask: (
@@ -15,11 +17,12 @@ interface BroadcastState {
) => void;
tasks: {
[taskLabel: string]: {
task: Y.Array<string>;
action: () => void;
observer: (
event: Y.YArrayEvent<string>,
transaction: Y.Transaction,
) => void;
task: Y.Array<string>;
};
};
}
@@ -27,7 +30,22 @@ interface BroadcastState {
export const useBroadcastStore = create<BroadcastState>((set, get) => ({
provider: undefined,
tasks: {},
setBroadcastProvider: (provider) => set({ provider }),
setBroadcastProvider: (provider) => {
// Clean up old provider listeners
const oldProvider = get().provider;
if (oldProvider) {
oldProvider.off('synced', get().handleProviderSync);
}
provider.on('synced', get().handleProviderSync);
set({ provider });
},
handleProviderSync: () => {
const tasks = get().tasks;
Object.entries(tasks).forEach(([taskLabel, { action }]) => {
get().addTask(taskLabel, action);
});
},
getBroadcastProvider: () => {
const provider = get().provider;
if (!provider) {
@@ -43,20 +61,16 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
return;
}
const existingTask = get().tasks[taskLabel];
if (existingTask) {
existingTask.task.unobserve(existingTask.observer);
get().setTask(taskLabel, existingTask.task, action);
return;
}
const task = provider.document.getArray<string>(taskLabel);
get().setTask(taskLabel, task, action);
},
setTask: (taskLabel: string, task: Y.Array<string>, action: () => void) => {
let isInitializing = true;
const observer = () => {
if (!isInitializing) {
const observer = (
_event: Y.YArrayEvent<string>,
transaction: Y.Transaction,
) => {
if (!isInitializing && !transaction.local) {
action();
}
};
@@ -73,16 +87,27 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
[taskLabel]: {
task,
observer,
action,
},
},
}));
},
broadcast: (taskLabel) => {
// Broadcast via Y.js provider (for users on the same document)
const obTask = get().tasks?.[taskLabel];
if (!obTask || !obTask.task) {
console.warn(`Task ${taskLabel} is not defined`);
return;
if (obTask?.task) {
obTask.task.push([`broadcast: ${taskLabel}`]);
}
obTask.task.push([`broadcast: ${taskLabel}`]);
},
cleanupBroadcast: () => {
const provider = get().provider;
if (provider) {
provider.off('synced', get().handleProviderSync);
}
// Unobserve all document-specific tasks
Object.values(get().tasks).forEach(({ task, observer }) => {
task.unobserve(observer);
});
},
}));

View File

@@ -1,4 +1,23 @@
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 });
},
}));

View File

@@ -21,7 +21,7 @@
"@sentry/node": "10.34.0",
"@sentry/profiling-node": "10.34.0",
"@tiptap/extensions": "*",
"axios": "1.13.2",
"axios": "1.13.5",
"cors": "2.8.5",
"express": "5.2.1",
"express-ws": "5.0.2",

View File

@@ -1802,30 +1802,25 @@
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.8.0":
"@eslint-community/eslint-utils@^4.7.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.9.1":
"@eslint-community/eslint-utils@^4.8.0", "@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.2":
"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.1", "@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"
@@ -1850,9 +1845,9 @@
"@types/json-schema" "^7.0.15"
"@eslint/eslintrc@^3.3.1":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz#e55f7f1dd400600dd066dbba349c4c0bac916964"
integrity sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==
version "3.3.3"
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.3.tgz#26393a0806501b5e2b6a43aa588a4d8df67880ac"
integrity sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==
dependencies:
ajv "^6.12.4"
debug "^4.3.2"
@@ -1860,7 +1855,7 @@
globals "^14.0.0"
ignore "^5.2.0"
import-fresh "^3.2.1"
js-yaml "^4.1.0"
js-yaml "^4.1.1"
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
@@ -2654,52 +2649,107 @@
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@0.1.80", "@napi-rs/canvas@^0.1.80":
"@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":
version "0.1.80"
resolved "https://registry.yarnpkg.com/@napi-rs/canvas/-/canvas-0.1.80.tgz#53615bea56fd94e07331ab13caa7a39efc4914ab"
integrity sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==
@@ -2715,6 +2765,23 @@
"@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"
@@ -6987,6 +7054,13 @@
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"
@@ -8110,13 +8184,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.2:
version "1.13.2"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.2.tgz#9ada120b7b5ab24509553ec3e40123521117f687"
integrity sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==
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==
dependencies:
follow-redirects "^1.15.6"
form-data "^4.0.4"
follow-redirects "^1.15.11"
form-data "^4.0.5"
proxy-from-env "^1.1.0"
axobject-query@^4.1.0:
@@ -9913,9 +9987,9 @@ esprima@^4.0.0:
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
esquery@^1.5.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7"
integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==
version "1.7.0"
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d"
integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==
dependencies:
estraverse "^5.1.0"
@@ -10265,7 +10339,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.6:
follow-redirects@^1.15.11:
version "1.15.11"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340"
integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==
@@ -10300,7 +10374,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.4:
form-data@^4.0.0:
version "4.0.4"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4"
integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==
@@ -12007,7 +12081,7 @@ js-yaml@^3.13.1:
argparse "^1.0.7"
esprima "^4.0.0"
js-yaml@^4.1.0:
js-yaml@^4.1.0, js-yaml@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
@@ -13533,6 +13607,13 @@ 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"
@@ -13561,6 +13642,11 @@ 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"

View File

@@ -70,6 +70,7 @@ 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 }}"

View File

@@ -71,6 +71,7 @@ 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 }}"