🚸(backend) sort user search results by proxmity with the active user

Allows a user to find more easily the other users they search, with
the following order of priority:
- users they already share documents with (more recent first)
- users that share the same full email domain
- users that share the same partial email domain (last two parts)
- other users
This commit is contained in:
Sylvain Boissel
2026-01-14 15:41:33 +01:00
parent fa6f3e8b7c
commit 44654d353b
40 changed files with 478 additions and 120 deletions
+20 -8
View File
@@ -6,13 +6,29 @@ and this project adheres to
## [Unreleased]
### Fixed
- ✅(e2e) fix e2e test for other browsers #1799
### Changed
- 🚸(backend) sort user search results by proximity with the active user #1802
## [4.4.0] - 2026-01-13
### Added
- ✨(backend) add documents/all endpoint with descendants #1553
- ✅(export) add PDF regression tests #1762
- 📝(docs) Add language configuration documentation #1757
- 🔒(helm) Set default security context #1750
- ✨(backend) use langfuse to monitor AI actions
- ✨(backend) use langfuse to monitor AI actions #1776
### Changed
- ♿(frontend) improve accessibility:
- ♿(frontend) make html export accessible to screen reader users #1743
- ♿(frontend) add missing label and fix Axes errors to improve a11y #1693
### Fixed
@@ -24,12 +40,7 @@ and this project adheres to
### Security
- 🔒️(backend) validate more strictly url used by cors-proxy endpoint #1768
### Changed
- ♿(frontend) improve accessibility:
- ♿(frontend) make html export accessible to screen reader users #1743
- ♿(frontend) add missing label and fix Axes errors to improve a11y #1693
- 🔒️(frontend) fix props vulnerability in Interlinking #1792
## [4.3.0] - 2026-01-05
@@ -987,7 +998,8 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.3.0...main
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.4.0...main
[v4.4.0]: https://github.com/suitenumerique/docs/releases/v4.4.0
[v4.3.0]: https://github.com/suitenumerique/docs/releases/v4.3.0
[v4.2.0]: https://github.com/suitenumerique/docs/releases/v4.2.0
[v4.1.0]: https://github.com/suitenumerique/docs/releases/v4.1.0
+7
View File
@@ -31,6 +31,13 @@
"matchPackageNames": ["django"],
"allowedVersions": "<6.0.0"
},
{
"groupName": "allowed celery versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["celery"],
"allowedVersions": "<5.6.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
+69 -6
View File
@@ -59,7 +59,12 @@ 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,
extract_email_domain_parts,
filter_descendants,
users_sharing_documents_with,
)
from . import permissions, serializers, utils
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
@@ -218,18 +223,77 @@ 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 we reorder results to prefer users "closer" to the current
# user: users they recently shared documents with, same full domain, same
# partial domain (e.g. both end with "gouv.fr"). To achieve that without
# complex SQL we build a proximity score in Python and returnthe top N results.
current_user = self.request.user
shared_map = users_sharing_documents_with(current_user)
return (
user_full_domain, user_partial_domain = extract_email_domain_parts(
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")
)
# 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_full_domain, candidate_partial_domain = (
extract_email_domain_parts(u.email or "")
)
same_full_domain = (
1
if candidate_full_domain and candidate_full_domain == user_full_domain
else 0
)
same_partial_domain = (
1
if candidate_partial_domain
and candidate_partial_domain == user_partial_domain
else 0
)
# similarity fallback
sim = getattr(u, "similarity", 0) or 0
return (
is_shared,
shared_score,
same_full_domain,
same_partial_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"],
@@ -2231,8 +2295,7 @@ class InvitationViewset(
)
# Abilities are computed based on logged-in user's role and
# the user role on each document access
.annotate(user_roles=db.Subquery(user_roles_query))
.distinct()
.annotate(user_roles=db.Subquery(user_roles_query)).distinct()
)
return queryset
+78 -1
View File
@@ -2,6 +2,8 @@
Test users API endpoints in the impress core app.
"""
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
@@ -201,10 +203,85 @@ 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)
- Same partial email domain (gouv.fr)
Case in point: the logged-in user has recently shared documents
with [email protected] and less recently with [email protected].
Other users named Pierre also exist:
- [email protected]
- [email protected]
- [email protected]
The search results should be ordered as follows:
# Shared with first
- [email protected] # Most recent first
- [email protected]
# Same full domain second
- [email protected]
# Same partial domain third
- [email protected]
# Others last
- [email protected]
"""
user = factories.UserFactory(
email="[email protected]", full_name="Martin Bernard"
)
client = APIClient()
client.force_login(user)
pierre_1 = factories.UserFactory(email="[email protected]")
pierre_2 = factories.UserFactory(email="[email protected]")
pierre_3 = factories.UserFactory(email="[email protected]")
pierre_4 = factories.UserFactory(email="[email protected]")
pierre_5 = factories.UserFactory(email="[email protected]")
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),
str(pierre_5.email),
str(pierre_3.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()
+15
View File
@@ -100,3 +100,18 @@ def test_utils_get_ancestor_to_descendants_map_multiple_paths():
"000100020005": {"000100020005"},
"00010003": {"00010003"},
}
def test_utils_extract_email_domain_parts_when_email_is_valid():
"""Test extraction of email domain parts."""
email = "[email protected]"
full_domain, partial_domain = utils.extract_email_domain_parts(email)
assert full_domain == "numerique.gouv.fr"
assert partial_domain == "gouv.fr"
def test_utils_extract_email_domain_parts_when_email_is_empty():
empty_email = ""
full_domain, partial_domain = utils.extract_email_domain_parts(empty_email)
assert full_domain == ""
assert partial_domain == ""
@@ -0,0 +1,60 @@
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="[email protected]", full_name="Martin Bernard"
)
pierre_1 = factories.UserFactory(
email="[email protected]", full_name="Pierre Dupont"
)
pierre_2 = factories.UserFactory(
email="[email protected]", 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,
}
+41 -1
View File
@@ -4,10 +4,14 @@ import base64
import re
from collections import defaultdict
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db import models as db
import pycrdt
from bs4 import BeautifulSoup
from core import enums
from core import enums, models
def get_ancestor_to_descendants_map(paths, steplen):
@@ -96,3 +100,39 @@ def extract_attachments(content):
xml_content = base64_yjs_to_xml(content)
return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content)
def users_sharing_documents_with(user):
"""
Returns a map of users sharing documents with the given user,
sorted by last shared date.
"""
user_docs_qs = models.DocumentAccess.objects.filter(user=user).values_list(
"document_id", flat=True
)
shared_qs = (
models.DocumentAccess.objects.filter(document_id__in=user_docs_qs)
.exclude(user=user)
.values("user")
.annotate(last_shared=db.Max("created_at"))
)
return {item["user"]: item["last_shared"] for item in shared_qs}
def extract_email_domain_parts(email):
"""
Extracts the full domain and partial domain from an email address as a tuple.
The partial domain consists of the last two segments of the domain, eg. "gouv.fr".
If the email is invalid (eg, is an empty string), returns empty strings.
"""
try:
validate_email(email)
except ValidationError:
return "", ""
domain = email.split("@", 1)[1].lower()
parts = domain.split(".")
partial_domain = ".".join(parts[-2:]) if len(parts) >= 2 else domain
return domain, partial_domain
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
@@ -79,7 +79,7 @@ msgstr "Doare korf"
msgid "Format"
msgstr "Stumm"
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "eilenn {title}"
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -79,7 +79,7 @@ msgstr "Typ"
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "Kopie von {title}"
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -79,7 +79,7 @@ msgstr "Tipo de Cuerpo"
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "copia de {title}"
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -79,7 +79,7 @@ msgstr "Type de corps"
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "copie de {title}"
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "copia di {title}"
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -79,7 +79,7 @@ msgstr "Text type"
msgid "Format"
msgstr "Formaat"
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "kopie van {title}"
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
@@ -79,7 +79,7 @@ msgstr "Tipo de corpo"
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "cópia de {title}"
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -79,7 +79,7 @@ msgstr "Тип сообщения"
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "копия {title}"
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
@@ -79,7 +79,7 @@ msgstr "Vrsta telesa"
msgid "Format"
msgstr "Oblika"
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
@@ -79,7 +79,7 @@ msgstr "Тип вмісту"
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "копія {title}"
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
@@ -79,7 +79,7 @@ msgstr "正文类型"
msgid "Format"
msgstr "格式"
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "{title} 的副本"
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "4.3.0"
version = "4.4.0"
authors = [{ "name" = "DINUM", "email" = "[email protected]" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -28,7 +28,7 @@ dependencies = [
"beautifulsoup4==4.14.3",
"boto3==1.42.17",
"Brotli==1.2.0",
"celery[redis]==5.6.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
@@ -58,7 +58,7 @@ test.describe('Doc Comments', () => {
await page.getByRole('button', { name: '👍' }).click();
await expect(
thread.getByRole('img', { name: 'E2E Chromium' }).first(),
thread.getByRole('img', { name: `E2E ${browserName}` }).first(),
).toBeVisible();
await expect(thread.getByText('This is a comment').first()).toBeVisible();
await expect(thread.getByText(`E2E ${browserName}`).first()).toBeVisible();
@@ -21,7 +21,7 @@ test.describe('Inherited share accesses', () => {
`doc-share-member-row-user.test@${browserName}.test`,
);
await expect(user).toBeVisible();
await expect(user.getByText('E2E Chromium')).toBeVisible();
await expect(user.getByText(`E2E ${browserName}`)).toBeVisible();
await expect(user.getByText('Owner')).toBeVisible();
await page
@@ -224,7 +224,9 @@ export const updateDocTitle = async (page: Page, title: string) => {
await expect(input).toHaveText('');
await expect(input).toBeVisible();
await input.click();
await input.fill(title);
await input.fill(title, {
force: true,
});
await input.click();
await input.blur();
await verifyDocName(page, title);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "4.3.0",
"version": "4.4.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "4.3.0",
"version": "4.4.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -68,6 +68,7 @@
"react-select": "5.10.2",
"styled-components": "6.1.19",
"use-debounce": "10.0.6",
"uuid": "13.0.0",
"y-protocols": "1.0.7",
"yjs": "*",
"zustand": "5.0.9"
@@ -1,21 +1,23 @@
/* eslint-disable react-hooks/rules-of-hooks */
import {
PartialCustomInlineContentFromConfig,
StyleSchema,
} from '@blocknote/core';
import { createReactInlineContentSpec } from '@blocknote/react';
import * as Sentry from '@sentry/nextjs';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { css } from 'styled-components';
import { validate as uuidValidate } from 'uuid';
import { BoxButton, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg';
import { getEmojiAndTitle, useDoc } from '@/docs/doc-management';
import { getEmojiAndTitle, useDoc } from '@/docs/doc-management/';
export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
{
type: 'interlinkingLinkInline',
propSchema: {
url: {
default: '',
},
docId: {
default: '',
},
@@ -27,46 +29,97 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
},
{
render: ({ editor, inlineContent, updateInlineContent }) => {
const { data: doc } = useDoc({ id: inlineContent.props.docId });
const isEditable = editor.isEditable;
if (!inlineContent.props.docId) {
return null;
}
/**
* Update the content title if the referenced doc title changes
* Should not happen
*/
useEffect(() => {
if (
isEditable &&
doc?.title &&
doc.title !== inlineContent.props.title
) {
updateInlineContent({
type: 'interlinkingLinkInline',
props: {
...inlineContent.props,
title: doc.title,
},
});
}
if (!uuidValidate(inlineContent.props.docId)) {
Sentry.captureException(
new Error(`Invalid docId: ${inlineContent.props.docId}`),
{
extra: { info: 'InterlinkingLinkInlineContent' },
},
);
/**
* ⚠️ When doing collaborative editing, doc?.title might be out of sync
* causing an infinite loop of updates.
* To prevent this, we only run this effect when doc?.title changes,
* not when inlineContent.props.title changes.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doc?.title, isEditable]);
updateInlineContent({
type: 'interlinkingLinkInline',
props: {
docId: '',
title: '',
},
});
return <LinkSelected {...inlineContent.props} />;
return null;
}
return (
<LinkSelected
docId={inlineContent.props.docId}
title={inlineContent.props.title}
isEditable={editor.isEditable}
updateInlineContent={updateInlineContent}
/>
);
},
},
);
interface LinkSelectedProps {
url: string;
docId: string;
title: string;
isEditable: boolean;
updateInlineContent: (
update: PartialCustomInlineContentFromConfig<
{
readonly type: 'interlinkingLinkInline';
readonly propSchema: {
readonly docId: {
readonly default: '';
};
readonly title: {
readonly default: '';
};
};
readonly content: 'none';
},
StyleSchema
>,
) => void;
}
const LinkSelected = ({ url, title }: LinkSelectedProps) => {
export const LinkSelected = ({
docId,
title,
isEditable,
updateInlineContent,
}: LinkSelectedProps) => {
const { data: doc } = useDoc({ id: docId });
/**
* Update the content title if the referenced doc title changes
*/
useEffect(() => {
if (isEditable && doc?.title && doc.title !== title) {
updateInlineContent({
type: 'interlinkingLinkInline',
props: {
docId,
title: doc.title,
},
});
}
/**
* ⚠️ When doing collaborative editing, doc?.title might be out of sync
* causing an infinite loop of updates.
* To prevent this, we only run this effect when doc?.title changes,
* not when inlineContent.props.title changes.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doc?.title, docId, isEditable]);
const { colorsTokens } = useCunninghamTheme();
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title);
@@ -74,7 +127,7 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
void router.push(url);
void router.push(`/docs/${docId}/`);
};
return (
@@ -247,7 +247,6 @@ export const SearchPage = ({
{
type: 'interlinkingLinkInline',
props: {
url: `/docs/${doc.id}`,
docId: doc.id,
title: doc.title || untitledDocument,
},
@@ -1,16 +1,24 @@
import { ExternalHyperlink, TextRun } from 'docx';
import { getEmojiAndTitle } from '@/docs/doc-management';
import { DocsExporterDocx } from '../types';
export const inlineContentMappingInterlinkingLinkDocx: DocsExporterDocx['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
(inline) => {
if (!inline.props.docId) {
return new TextRun('');
}
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title);
return new ExternalHyperlink({
children: [
new TextRun({
text: `📄${inline.props.title}`,
text: `${emoji || '📄'}${titleWithoutEmoji}`,
bold: true,
}),
],
link: window.location.origin + inline.props.url,
link: window.location.origin + `/docs/${inline.props.docId}/`,
});
};
@@ -1,11 +1,17 @@
import React from 'react';
import { getEmojiAndTitle } from '@/docs/doc-management';
import { DocsExporterODT } from '../types';
export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
(inline) => {
const url = window.location.origin + inline.props.url;
const title = inline.props.title;
if (!inline.props.docId) {
return null;
}
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title);
const url = window.location.origin + `/docs/${inline.props.docId}/`;
// Create ODT hyperlink using React.createElement to avoid TypeScript JSX namespace issues
// Uses the same structure as BlockNote's default link mapping
@@ -18,6 +24,6 @@ export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings'
xlinkShow: 'replace',
xlinkHref: url,
},
`📄${title}`,
`${emoji || '📄'}${titleWithoutEmoji}`,
);
};
@@ -1,21 +1,29 @@
import { Image, Link, Text } from '@react-pdf/renderer';
import { getEmojiAndTitle } from '@/docs/doc-management';
import DocSelectedIcon from '../assets/doc-selected.png';
import { DocsExporterPDF } from '../types';
export const inlineContentMappingInterlinkingLinkPDF: DocsExporterPDF['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
(inline) => {
if (!inline.props.docId) {
return <></>;
}
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title);
return (
<Link
src={window.location.origin + inline.props.url}
src={window.location.origin + `/docs/${inline.props.docId}/`}
style={{
textDecoration: 'none',
color: 'black',
}}
>
{' '}
<Image src={DocSelectedIcon.src} />{' '}
<Text>{inline.props.title}</Text>{' '}
{emoji || <Image src={DocSelectedIcon.src} />}{' '}
<Text>{titleWithoutEmoji}</Text>{' '}
</Link>
);
};
@@ -779,12 +779,12 @@
"Document deleted": "Document supprimé",
"Document duplicated successfully!": "Document dupliqué avec succès !",
"Document editor": "Éditeur de document",
"Document emoji": "Émoji de document",
"Document owner": "Propriétaire du document",
"Document role text": "Texte du rôle du document",
"Document sections": "Sections du document",
"Document title": "Titre du document",
"Document tree": "Arborescence du document",
"Document viewer": "Visualiseur de document",
"Document visibility": "Visibilité du document",
"Documents grid": "Grille des documents",
"Docx": "Docx",
@@ -826,6 +826,7 @@
"Image 403": "Image 403",
"Image: {{title}}": "Image : {{title}}",
"Insufficient access rights to view the document.": "Droits d'accès insuffisants pour voir le document.",
"Invalid or missing PDF file.": "Fichier PDF non valide ou manquant.",
"Invite": "Inviter",
"Invite new members": "Inviter de nouveaux membres",
"Invite {{count}} members_many": "Inviter {{count}} membres",
@@ -884,6 +885,7 @@
"Others are editing. Your network prevent changes.": "D'autres sont en cours d'édition. Votre réseau empêche les changements.",
"Owner": "Propriétaire",
"PDF": "PDF",
"PDF document": "Document PDF",
"Page Not Found - Error 404": "Page introuvable - Erreur 404",
"Pending invitations": "Invitations en attente",
"People with access via the parent document": "Personnes ayant accès au document parent",
@@ -1222,7 +1224,6 @@
"Document sections": "Document secties",
"Document title": "Documenttitel",
"Document tree": "Boomstructuur document",
"Document viewer": "Document-viewer",
"Document visibility": "Document toegankelijkheid",
"Documents grid": "Documenten overzicht",
"Docx": "Docx",
@@ -1264,6 +1265,7 @@
"Image 403": "Afbeelding 403",
"Image: {{title}}": "Afbeelding: {{title}}",
"Insufficient access rights to view the document.": "Onvoldoende toegangsrechten om het document te bekijken.",
"Invalid or missing PDF file.": "Ongeldig of ontbrekend PDF-bestand.",
"Invite": "Uitnodigen",
"Invite new members": "Nieuwe leden uitnodigen",
"Invite {{count}} members_many": "Nodig {{count}} leden uit",
@@ -1322,6 +1324,7 @@
"Others are editing. Your network prevent changes.": "Anderen zijn aan het bewerken. Uw netwerk voorkomt wijzigingen.",
"Owner": "Eigenaar",
"PDF": "PDF",
"PDF document": "Pdf-document",
"Page Not Found - Error 404": "Pagina niet gevonden - Fout 404",
"Pending invitations": "Openstaande uitnodigingen",
"People with access via the parent document": "Gebruikers met toegang via het bovenliggend document",
@@ -1516,12 +1519,12 @@
"Document deleted": "Документ удалён",
"Document duplicated successfully!": "Документ успешно дублирован!",
"Document editor": "Редактор документа",
"Document emoji": "Эмодзи документа",
"Document owner": "Владелец документа",
"Document role text": "Текст роли документа",
"Document sections": "Разделы документа",
"Document title": "Заголовок документа",
"Document tree": "Иерархия документа",
"Document viewer": "Просмотрщик документа",
"Document visibility": "Видимость документа",
"Documents grid": "Сетка документов",
"Docx": "Docx",
@@ -1563,6 +1566,7 @@
"Image 403": "Изображение 403",
"Image: {{title}}": "Изображение: {{title}}",
"Insufficient access rights to view the document.": "Недостаточно прав доступа для просмотра документа.",
"Invalid or missing PDF file.": "Повреждённый или отсутствующий PDF-файл.",
"Invite": "Приглашение",
"Invite new members": "Пригласить новых участников",
"Invite {{count}} members_many": "Пригласить {{count}} участников",
@@ -1621,6 +1625,7 @@
"Others are editing. Your network prevent changes.": "Другие участники редактируют этот документ. Ваша сеть не позволяет вам присоединиться.",
"Owner": "Владелец",
"PDF": "PDF",
"PDF document": "Документ PDF",
"Page Not Found - Error 404": "Страница не найдена - ошибка 404",
"Pending invitations": "Ожидающие приглашения",
"People with access via the parent document": "Люди, имеющие доступ через родительский документ",
@@ -1918,12 +1923,12 @@
"Document deleted": "Документ видалено",
"Document duplicated successfully!": "Документ успішно продубльовано!",
"Document editor": "Редактор документа",
"Document emoji": "Емодзі документу",
"Document owner": "Власник документа",
"Document role text": "Текст ролі документа",
"Document sections": "Розділи документу",
"Document title": "Назва документа",
"Document tree": "Дерево документа",
"Document viewer": "Переглядач документа",
"Document visibility": "Видимість документа",
"Documents grid": "Сітка документів",
"Docx": "Docx",
@@ -1965,6 +1970,7 @@
"Image 403": "Зображення 403",
"Image: {{title}}": "Зображення: {{title}}",
"Insufficient access rights to view the document.": "Недостатньо прав для перегляду документа.",
"Invalid or missing PDF file.": "Неприпустимий або відсутній PDF-файл.",
"Invite": "Запрошення",
"Invite new members": "Запросити нових учасників",
"Invite {{count}} members_many": "Запросити {{count}} учасників",
@@ -2023,6 +2029,7 @@
"Others are editing. Your network prevent changes.": "Інші учасники редагують документ. Ваша мережа не дозволяє вам вносити зміни.",
"Owner": "Власник",
"PDF": "PDF",
"PDF document": "Документ PDF",
"Page Not Found - Error 404": "Сторінку не знайдено - Помилка 404",
"Pending invitations": "Запрошення в очікуванні",
"People with access via the parent document": "Люди з доступом через батьківський документ",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "impress",
"version": "4.3.0",
"version": "4.4.0",
"private": true,
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
@@ -1,6 +1,6 @@
{
"name": "eslint-plugin-docs",
"version": "4.3.0",
"version": "4.4.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "4.3.0",
"version": "4.4.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "server-y-provider",
"version": "4.3.0",
"version": "4.4.0",
"description": "Y.js provider for docs",
"repository": "https://github.com/suitenumerique/docs",
"license": "MIT",
+2 -2
View File
@@ -1,10 +1,10 @@
environments:
dev:
values:
- version: 4.3.0
- version: 4.4.0
feature:
values:
- version: 4.3.0
- version: 4.4.0
feature: ci
domain: example.com
imageTag: demo
+1 -1
View File
@@ -1,5 +1,5 @@
apiVersion: v2
type: application
name: docs
version: 4.3.0
version: 4.4.0
appVersion: latest
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "4.3.0",
"version": "4.4.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {