Compare commits

...

2 Commits

Author SHA1 Message Date
Sylvain Boissel 0eb0dc1ea4 Merge branch 'main' into sbl-proximity-search 2026-01-14 15:54:39 +01:00
Sylvain Boissel 44654d353b 🚸(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
2026-01-14 15:41:33 +01:00
6 changed files with 269 additions and 6 deletions
+4
View File
@@ -10,6 +10,10 @@ and this project adheres to
- ✅(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
+66 -4
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,75 @@ 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, 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 return the
# 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,
)
candidates.sort(key=_sort_key, reverse=True)
return candidates[: settings.API_USERS_LIST_LIMIT]
@drf.decorators.action(
detail=False,
methods=["get"],
+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 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
# Same partial domain third
- pierre.robert@culture.gouv.fr
# Others last
- paul.thomas@example.com
"""
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),
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()
+16
View File
@@ -100,3 +100,19 @@ 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 = "firstname.lastname@numerique.gouv.fr"
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():
"""Test extraction of email domain parts in case of an empty email."""
empty_email = ""
full_domain, partial_domain = utils.extract_email_domain_parts(empty_email)
assert full_domain == ""
assert partial_domain == ""
@@ -0,0 +1,64 @@
"""
Unit tests for the users_sharing_documents_with utility 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,
}
+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