mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 15:12:27 +02:00
Compare commits
16 Commits
feat/e2ee
...
add/clean-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c96c3c1775 | ||
|
|
3ab0a47c3a | ||
|
|
685464f2d7 | ||
|
|
9af540de35 | ||
|
|
6c43ecc324 | ||
|
|
607bae0022 | ||
|
|
1d8b730715 | ||
|
|
d02c6250c9 | ||
|
|
b8c1504e7a | ||
|
|
18edcf8537 | ||
|
|
5d8741a70a | ||
|
|
48df68195a | ||
|
|
7cf42e6404 | ||
|
|
9903bd73e2 | ||
|
|
44b38347c4 | ||
|
|
709076067b |
12
.github/workflows/docker-hub.yml
vendored
12
.github/workflows/docker-hub.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/impress.yml
vendored
4
.github/workflows/impress.yml
vendored
@@ -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
|
||||
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -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
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
30
docs/user_account_reconciliation.md
Normal file
30
docs/user_account_reconciliation.md
Normal 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"
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
150
src/backend/core/management/commands/clean_document.py
Normal file
150
src/backend/core/management/commands/clean_document.py
Normal 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.")
|
||||
@@ -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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
135
src/backend/core/tasks/user_reconciliation.py
Normal file
135
src/backend/core/tasks/user_reconciliation.py
Normal 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()
|
||||
313
src/backend/core/tests/commands/test_clean_document.py
Normal file
313
src/backend/core/tests/commands/test_clean_document.py
Normal 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
|
||||
@@ -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
|
||||
|
@@ -0,0 +1,2 @@
|
||||
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
|
||||
"user.test40@example.com",,0,0,pending,40
|
||||
|
@@ -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
|
||||
|
@@ -0,0 +1,2 @@
|
||||
merge_accept,active_email,inactive_email,status,id
|
||||
true,user.test20@example.com,user.test20@example.com,pending,20
|
||||
|
@@ -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
|
||||
|
@@ -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"],
|
||||
|
||||
85
src/backend/core/tests/test_api_user_reconciliation.py
Normal file
85
src/backend/core/tests/test_api_user_reconciliation.py
Normal 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"}
|
||||
@@ -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)
|
||||
|
||||
|
||||
669
src/backend/core/tests/test_models_user_reconciliation.py
Normal file
669
src/backend/core/tests/test_models_user_reconciliation.py
Normal 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()
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -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 == {}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Binary file not shown.
@@ -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 ({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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/');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
239
src/frontend/apps/e2e/__tests__/app-impress/utils-export.ts
Normal file
239
src/frontend/apps/e2e/__tests__/app-impress/utils-export.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
BIN
src/frontend/apps/impress/src/assets/icons/error-coffee.png
Normal file
BIN
src/frontend/apps/impress/src/assets/icons/error-coffee.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './useAuthQuery';
|
||||
export * from './types';
|
||||
export * from './useAuthQuery';
|
||||
export * from './useUserReconciliations';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './Auth';
|
||||
export * from './ButtonLogin';
|
||||
export * from './UserAvatar';
|
||||
export * from './UserReconciliation';
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const moveChildNodes = (from: Element, to: Element) => {
|
||||
while (from.firstChild) {
|
||||
to.appendChild(from.firstChild);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Derives a stable, readable filename for media exported in the HTML ZIP.
|
||||
*
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -71,6 +71,7 @@ export const ResizableLeftPanel = ({
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel
|
||||
ref={ref}
|
||||
className="--docs--resizable-left-panel"
|
||||
order={0}
|
||||
defaultSize={
|
||||
isDesktop
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
Reference in New Issue
Block a user