🔒️(back) remove pagination and limit to 5 for user list endpoint

The user list endpoint does not use anymore a pagination, the results is
directly return in a list and the max results returned is limited to 5.
In order to modify this limit the settings API_USERS_LIST_LIMIT is
used.
This commit is contained in:
Manuel Raynaud
2025-03-21 09:55:19 +01:00
parent f9a91eda2d
commit 34dfb3fd66
4 changed files with 54 additions and 15 deletions

View File

@@ -21,6 +21,8 @@ and this project adheres to
- 🐛(back) allow only images to be used with the cors-proxy #781 - 🐛(back) allow only images to be used with the cors-proxy #781
- 🐛(backend) stop returning inactive users on the list endpoint #636 - 🐛(backend) stop returning inactive users on the list endpoint #636
- 🔒️(backend) require at least 5 characters to search for users #636 - 🔒️(backend) require at least 5 characters to search for users #636
- 🔒️(back) throttle user list endpoint #636
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
## [2.5.0] - 2025-03-18 ## [2.5.0] - 2025-03-18

View File

@@ -143,6 +143,7 @@ class UserViewSet(
permission_classes = [permissions.IsSelf] permission_classes = [permissions.IsSelf]
queryset = models.User.objects.filter(is_active=True) queryset = models.User.objects.filter(is_active=True)
serializer_class = serializers.UserSerializer serializer_class = serializers.UserSerializer
pagination_class = None
def get_queryset(self): def get_queryset(self):
""" """
@@ -157,10 +158,10 @@ class UserViewSet(
return queryset return queryset
# Exclude all users already in the given document # Exclude all users already in the given document
if document_id := self.request.GET.get("document_id", ""): if document_id := self.request.query_params.get("document_id", ""):
queryset = queryset.exclude(documentaccess__document_id=document_id) queryset = queryset.exclude(documentaccess__document_id=document_id)
if not (query := self.request.GET.get("q", "")) or len(query) < 5: if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
return queryset.none() return queryset.none()
# For emails, match emails by Levenstein distance to prevent typing errors # For emails, match emails by Levenstein distance to prevent typing errors
@@ -170,7 +171,7 @@ class UserViewSet(
distance=RawSQL("levenshtein(email::text, %s::text)", (query,)) distance=RawSQL("levenshtein(email::text, %s::text)", (query,))
) )
.filter(distance__lte=3) .filter(distance__lte=3)
.order_by("distance", "email") .order_by("distance", "email")[: settings.API_USERS_LIST_LIMIT]
) )
# Use trigram similarity for non-email-like queries # Use trigram similarity for non-email-like queries
@@ -180,7 +181,7 @@ class UserViewSet(
queryset.filter(email__trigram_word_similar=query) queryset.filter(email__trigram_word_similar=query)
.annotate(similarity=TrigramSimilarity("email", query)) .annotate(similarity=TrigramSimilarity("email", query))
.filter(similarity__gt=0.2) .filter(similarity__gt=0.2)
.order_by("-similarity", "email") .order_by("-similarity", "email")[: settings.API_USERS_LIST_LIMIT]
) )
@drf.decorators.action( @drf.decorators.action(

View File

@@ -37,7 +37,7 @@ def test_api_users_list_authenticated():
) )
assert response.status_code == 200 assert response.status_code == 200
content = response.json() content = response.json()
assert content["results"] == [] assert content == []
def test_api_users_list_query_email(): def test_api_users_list_query_email():
@@ -58,24 +58,54 @@ def test_api_users_list_query_email():
"/api/v1.0/users/?q=david.bowman@work.com", "/api/v1.0/users/?q=david.bowman@work.com",
) )
assert response.status_code == 200 assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]] user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)] assert user_ids == [str(dave.id)]
response = client.get( response = client.get(
"/api/v1.0/users/?q=davig.bovman@worm.com", "/api/v1.0/users/?q=davig.bovman@worm.com",
) )
assert response.status_code == 200 assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]] user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)] assert user_ids == [str(dave.id)]
response = client.get( response = client.get(
"/api/v1.0/users/?q=davig.bovman@worm.cop", "/api/v1.0/users/?q=davig.bovman@worm.cop",
) )
assert response.status_code == 200 assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]] user_ids = [user["id"] for user in response.json()]
assert user_ids == [] assert user_ids == []
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.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Use a base name with a length equal 5 to test that the limit is applied
base_name = "alice"
for i in range(15):
factories.UserFactory(email=f"{base_name}.{i}@example.com")
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 200
assert len(response.json()) == 5
# if the limit is changed, all users should be returned
settings.API_USERS_LIST_LIMIT = 100
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 200
assert len(response.json()) == 15
def test_api_users_list_query_email_matching(): def test_api_users_list_query_email_matching():
"""While filtering by email, results should be filtered and sorted by Levenstein distance.""" """While filtering by email, results should be filtered and sorted by Levenstein distance."""
user = factories.UserFactory() user = factories.UserFactory()
@@ -94,13 +124,13 @@ def test_api_users_list_query_email_matching():
"/api/v1.0/users/?q=alice.johnson@example.gouv.fr", "/api/v1.0/users/?q=alice.johnson@example.gouv.fr",
) )
assert response.status_code == 200 assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]] user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(user1.id), str(user2.id), str(user3.id), str(user4.id)] assert user_ids == [str(user1.id), str(user2.id), str(user3.id), str(user4.id)]
response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr") response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr")
assert response.status_code == 200 assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]] user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(user4.id), str(user2.id), str(user1.id), str(user5.id)] assert user_ids == [str(user4.id), str(user2.id), str(user1.id), str(user5.id)]
@@ -126,7 +156,7 @@ def test_api_users_list_query_email_exclude_doc_user():
) )
assert response.status_code == 200 assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]] user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(nicole_fool.id)] assert user_ids == [str(nicole_fool.id)]
@@ -143,15 +173,15 @@ def test_api_users_list_query_short_queries():
response = client.get("/api/v1.0/users/?q=jo") response = client.get("/api/v1.0/users/?q=jo")
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["results"] == [] assert response.json() == []
response = client.get("/api/v1.0/users/?q=john") response = client.get("/api/v1.0/users/?q=john")
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["results"] == [] assert response.json() == []
response = client.get("/api/v1.0/users/?q=john.") response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200 assert response.status_code == 200
assert len(response.json()["results"]) == 2 assert len(response.json()) == 2
def test_api_users_list_query_inactive(): def test_api_users_list_query_inactive():
@@ -166,7 +196,7 @@ def test_api_users_list_query_inactive():
response = client.get("/api/v1.0/users/?q=john.") response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200 assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]] user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(lennon.id)] assert user_ids == [str(lennon.id)]

View File

@@ -604,6 +604,12 @@ class Base(Configuration):
}, },
} }
API_USERS_LIST_LIMIT = values.PositiveIntegerValue(
default=5,
environ_name="API_USERS_LIST_LIMIT",
environ_prefix=None,
)
# pylint: disable=invalid-name # pylint: disable=invalid-name
@property @property
def ENVIRONMENT(self): def ENVIRONMENT(self):