Skip to content

Commit e44b2a7

Browse files
committed
Add ordering support to list_merged_pre_and_registered_users and related tests
1 parent b00e7e9 commit e44b2a7

File tree

2 files changed

+237
-17
lines changed

2 files changed

+237
-17
lines changed

services/web/server/src/simcore_service_webserver/users/_accounts_repository.py

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import logging
2-
from typing import Any, cast
2+
from typing import Any, Literal, TypeAlias, cast
33

44
import sqlalchemy as sa
55
from common_library.exclude import Unset, is_unset
@@ -8,6 +8,7 @@
88
from models_library.users import (
99
UserID,
1010
)
11+
from pydantic import validate_call
1112
from simcore_postgres_database.models.groups import groups, user_to_groups
1213
from simcore_postgres_database.models.products import products
1314
from simcore_postgres_database.models.users import UserStatus, users
@@ -426,6 +427,11 @@ async def search_merged_pre_and_registered_users(
426427
return result.fetchall()
427428

428429

430+
OrderKeys: TypeAlias = Literal["email", "current_status_created"]
431+
OrderDirs: TypeAlias = Literal["asc", "desc"]
432+
433+
434+
@validate_call(config={"arbitrary_types_allowed": True})
429435
async def list_merged_pre_and_registered_users(
430436
engine: AsyncEngine,
431437
connection: AsyncConnection | None = None,
@@ -435,6 +441,7 @@ async def list_merged_pre_and_registered_users(
435441
filter_include_deleted: bool = False,
436442
pagination_limit: int = 50,
437443
pagination_offset: int = 0,
444+
order_by: list[tuple[OrderKeys, OrderDirs]] | None = None,
438445
) -> tuple[list[dict[str, Any]], int]:
439446
"""Retrieves and merges users from both users and pre-registration tables.
440447
@@ -452,25 +459,29 @@ async def list_merged_pre_and_registered_users(
452459
filter_include_deleted: Whether to include deleted users
453460
pagination_limit: Maximum number of results to return
454461
pagination_offset: Number of results to skip (for pagination)
462+
order_by: List of (field, direction) tuples. Valid fields: "email", "current_status_created"
463+
Default: [("email", "asc"), ("is_pre_registered", "desc"), ("current_status_created", "desc")]
455464
456465
Returns:
457466
Tuple of (list of merged user data, total count)
458467
"""
459468
# Base where conditions for both queries
460-
pre_reg_where = [users_pre_registration_details.c.product_name == product_name]
461-
users_where = []
469+
pre_reg_query_conditions = [
470+
users_pre_registration_details.c.product_name == product_name
471+
]
472+
user_conditions = []
462473

463474
# Add account request status filter if specified
464475
if filter_any_account_request_status:
465-
pre_reg_where.append(
476+
pre_reg_query_conditions.append(
466477
users_pre_registration_details.c.account_request_status.in_(
467478
filter_any_account_request_status
468479
)
469480
)
470481

471482
# Add filter for deleted users
472483
if not filter_include_deleted:
473-
users_where.append(users.c.status != UserStatus.DELETED)
484+
user_conditions.append(users.c.status != UserStatus.DELETED)
474485

475486
# Create subquery for reviewer username
476487
account_request_reviewed_by_username = (
@@ -479,7 +490,7 @@ async def list_merged_pre_and_registered_users(
479490

480491
# Query for pre-registered users
481492
# We need to left join with users to identify if the pre-registered user is already in the system
482-
pre_reg_query = (
493+
pre_registered_users_query = (
483494
sa.select(
484495
users_pre_registration_details.c.id,
485496
users_pre_registration_details.c.pre_email.label("email"),
@@ -495,6 +506,12 @@ async def list_merged_pre_and_registered_users(
495506
users_pre_registration_details.c.user_id.label("pre_reg_user_id"),
496507
users_pre_registration_details.c.extras,
497508
users_pre_registration_details.c.created,
509+
# Computed current_status_created column
510+
sa.func.coalesce(
511+
users.c.created_at, # If user exists, use users.created_at
512+
users_pre_registration_details.c.account_request_reviewed_at, # Else if reviewed, use review date
513+
users_pre_registration_details.c.created, # Else use pre-registration created date
514+
).label("current_status_created"),
498515
users_pre_registration_details.c.account_request_status,
499516
users_pre_registration_details.c.account_request_reviewed_by,
500517
users_pre_registration_details.c.account_request_reviewed_at,
@@ -512,11 +529,11 @@ async def list_merged_pre_and_registered_users(
512529
users, users_pre_registration_details.c.user_id == users.c.id
513530
)
514531
)
515-
.where(sa.and_(*pre_reg_where))
532+
.where(sa.and_(*pre_reg_query_conditions))
516533
)
517534

518535
# Query for users that are associated with the product through groups
519-
users_query = (
536+
registered_users_query = (
520537
sa.select(
521538
sa.literal(None).label("id"),
522539
users.c.email,
@@ -532,6 +549,8 @@ async def list_merged_pre_and_registered_users(
532549
sa.literal(None).label("pre_reg_user_id"),
533550
sa.literal(None).label("extras"),
534551
users.c.created_at.label("created"),
552+
# For regular users, current_status_created is just their created_at
553+
users.c.created_at.label("current_status_created"),
535554
sa.literal(None).label("account_request_status"),
536555
sa.literal(None).label("account_request_reviewed_by"),
537556
sa.literal(None).label("account_request_reviewed_at"),
@@ -549,29 +568,28 @@ async def list_merged_pre_and_registered_users(
549568
.join(groups, groups.c.gid == user_to_groups.c.gid)
550569
.join(products, products.c.group_id == groups.c.gid)
551570
)
552-
.where(sa.and_(products.c.name == product_name, *users_where))
571+
.where(sa.and_(products.c.name == product_name, *user_conditions))
553572
)
554573

555574
# If filtering by account request status, we only want pre-registered users with any of those statuses
556575
# No need to union with regular users as they don't have account_request_status
557576
merged_query: sa.sql.Select | sa.sql.CompoundSelect
558577
if filter_any_account_request_status:
559-
merged_query = pre_reg_query
578+
merged_query = pre_registered_users_query
560579
else:
561-
merged_query = pre_reg_query.union_all(users_query)
580+
merged_query = pre_registered_users_query.union_all(registered_users_query)
562581

563582
# Add distinct on email to eliminate duplicates
564583
merged_query_subq = merged_query.subquery()
584+
585+
# Build ordering clauses using the extracted function
586+
order_by_clauses = _build_ordering_clauses(merged_query_subq, order_by)
587+
565588
distinct_query = (
566589
sa.select(merged_query_subq)
567590
.select_from(merged_query_subq)
568591
.distinct(merged_query_subq.c.email)
569-
.order_by(
570-
merged_query_subq.c.email,
571-
# Prioritize pre-registration records if duplicate emails exist
572-
merged_query_subq.c.is_pre_registered.desc(),
573-
merged_query_subq.c.created.desc(),
574-
)
592+
.order_by(*order_by_clauses)
575593
.limit(pagination_limit)
576594
.offset(pagination_offset)
577595
)
@@ -594,3 +612,42 @@ async def list_merged_pre_and_registered_users(
594612
records = result.mappings().all()
595613

596614
return cast(list[dict[str, Any]], records), total_count
615+
616+
617+
def _build_ordering_clauses(
618+
merged_query_subq: sa.sql.Subquery,
619+
order_by: list[tuple[OrderKeys, OrderDirs]] | None = None,
620+
) -> list[sa.sql.ColumnElement]:
621+
"""Build ORDER BY clauses for merged user queries.
622+
623+
Args:
624+
merged_query_subq: The merged query subquery containing all columns
625+
order_by: List of (field, direction) tuples for ordering
626+
627+
Returns:
628+
List of SQLAlchemy ordering clauses
629+
"""
630+
_ordering_criteria: list[tuple[str, OrderDirs]] = []
631+
632+
if order_by is None:
633+
# Default ordering
634+
_ordering_criteria = [
635+
("email", "asc"),
636+
("is_pre_registered", "desc"),
637+
("current_status_created", "desc"),
638+
]
639+
else:
640+
_ordering_criteria = list(order_by)
641+
# Always append is_pre_registered prioritization for custom ordering
642+
if not any(field == "is_pre_registered" for field, _ in order_by):
643+
_ordering_criteria.append(("is_pre_registered", "desc"))
644+
645+
order_by_clauses = []
646+
for field, direction in _ordering_criteria:
647+
column = merged_query_subq.columns[field]
648+
if direction == "asc":
649+
order_by_clauses.append(column.asc())
650+
else:
651+
order_by_clauses.append(column.desc())
652+
653+
return order_by_clauses

services/web/server/tests/unit/with_dbs/03/users/test_users_accounts_repository.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -959,3 +959,166 @@ async def test_search_merged_users_by_primary_group_id(
959959
assert found_user is not None
960960
assert found_user.first_name == product_owner_user["first_name"]
961961
assert found_user.last_name == product_owner_user["last_name"]
962+
963+
964+
async def test_list_merged_users_default_ordering(
965+
app: web.Application,
966+
product_name: ProductName,
967+
mixed_user_data: MixedUserTestData,
968+
):
969+
"""Test default ordering behavior (email asc, is_pre_registered desc, current_status_created desc)."""
970+
asyncpg_engine = get_asyncpg_engine(app)
971+
972+
# Act - Get all users with default ordering (order_by=None)
973+
users_list, total_count = (
974+
await _accounts_repository.list_merged_pre_and_registered_users(
975+
asyncpg_engine,
976+
product_name=product_name,
977+
filter_include_deleted=False,
978+
order_by=None, # Explicit None to test default
979+
)
980+
)
981+
982+
# Assert
983+
assert total_count >= 3, "Should have at least 3 users"
984+
assert len(users_list) >= 3, "Should return at least 3 users"
985+
986+
# Extract emails in order they were returned
987+
returned_emails = [user["email"] for user in users_list]
988+
989+
# Verify emails are sorted in ascending order (default behavior)
990+
expected_test_emails = sorted(
991+
[
992+
mixed_user_data.pre_reg_email,
993+
mixed_user_data.product_owner_email,
994+
mixed_user_data.approved_email,
995+
]
996+
)
997+
998+
# Find positions of our test emails in the result
999+
test_email_positions = []
1000+
for email in expected_test_emails:
1001+
try:
1002+
pos = returned_emails.index(email)
1003+
test_email_positions.append(pos)
1004+
except ValueError:
1005+
pytest.fail(f"Expected email {email} not found in results")
1006+
1007+
# Verify our test emails appear in ascending alphabetical order
1008+
assert test_email_positions == sorted(test_email_positions), (
1009+
f"Test emails should be in ascending order. "
1010+
f"Expected: {expected_test_emails}, Got: {[returned_emails[pos] for pos in test_email_positions]}"
1011+
)
1012+
1013+
1014+
async def test_list_merged_users_custom_ordering(
1015+
app: web.Application,
1016+
product_name: ProductName,
1017+
mixed_user_data: MixedUserTestData,
1018+
):
1019+
"""Test custom ordering behavior (email desc) vs default (email asc)."""
1020+
asyncpg_engine = get_asyncpg_engine(app)
1021+
1022+
# Act 1 - Get users with default ordering
1023+
default_users, count1 = (
1024+
await _accounts_repository.list_merged_pre_and_registered_users(
1025+
asyncpg_engine,
1026+
product_name=product_name,
1027+
filter_include_deleted=False,
1028+
order_by=None,
1029+
)
1030+
)
1031+
1032+
# Act 2 - Get users with custom ordering (email desc)
1033+
custom_users, count2 = (
1034+
await _accounts_repository.list_merged_pre_and_registered_users(
1035+
asyncpg_engine,
1036+
product_name=product_name,
1037+
filter_include_deleted=False,
1038+
order_by=[("email", "desc")],
1039+
)
1040+
)
1041+
1042+
# Assert
1043+
assert count1 == count2, "Total counts should match"
1044+
assert len(default_users) == len(custom_users), "Should return same number of users"
1045+
1046+
# Extract test emails from both results
1047+
default_test_emails = [
1048+
user["email"]
1049+
for user in default_users
1050+
if user["email"]
1051+
in {
1052+
mixed_user_data.pre_reg_email,
1053+
mixed_user_data.product_owner_email,
1054+
mixed_user_data.approved_email,
1055+
}
1056+
]
1057+
1058+
custom_test_emails = [
1059+
user["email"]
1060+
for user in custom_users
1061+
if user["email"]
1062+
in {
1063+
mixed_user_data.pre_reg_email,
1064+
mixed_user_data.product_owner_email,
1065+
mixed_user_data.approved_email,
1066+
}
1067+
]
1068+
1069+
# Verify we have the same emails in both results
1070+
assert set(default_test_emails) == set(
1071+
custom_test_emails
1072+
), "Should have same emails in both results"
1073+
1074+
# Verify the ordering is different (ascending vs descending)
1075+
assert default_test_emails == sorted(
1076+
default_test_emails
1077+
), "Default should be ascending order"
1078+
assert custom_test_emails == sorted(
1079+
custom_test_emails, reverse=True
1080+
), "Custom should be descending order"
1081+
1082+
# Verify they are actually different (unless all emails are the same, which they're not)
1083+
if (
1084+
len(set(default_test_emails)) > 1
1085+
): # Only test if we have multiple distinct emails
1086+
assert (
1087+
default_test_emails != custom_test_emails
1088+
), "Default and custom ordering should produce different results"
1089+
1090+
1091+
async def test_list_merged_users_custom_ordering_with_current_status_created(
1092+
app: web.Application,
1093+
product_name: ProductName,
1094+
mixed_user_data: MixedUserTestData,
1095+
):
1096+
"""Test ordering by current_status_created field."""
1097+
asyncpg_engine = get_asyncpg_engine(app)
1098+
1099+
# Act - Get users ordered by current_status_created descending
1100+
users_list, _ = await _accounts_repository.list_merged_pre_and_registered_users(
1101+
asyncpg_engine,
1102+
product_name=product_name,
1103+
filter_include_deleted=False,
1104+
order_by=[("current_status_created", "desc")],
1105+
)
1106+
1107+
# Assert
1108+
assert len(users_list) >= 3, "Should have at least 3 users"
1109+
1110+
# Verify all users have current_status_created field
1111+
for user in users_list:
1112+
assert (
1113+
"current_status_created" in user
1114+
), "All users should have current_status_created field"
1115+
assert (
1116+
user["current_status_created"] is not None
1117+
), "current_status_created should not be None"
1118+
1119+
# Verify that current_status_created values are in descending order (newest first)
1120+
current_status_dates = [user["current_status_created"] for user in users_list]
1121+
sorted_dates = sorted(current_status_dates, reverse=True)
1122+
assert (
1123+
current_status_dates == sorted_dates
1124+
), "Users should be ordered by current_status_created desc"

0 commit comments

Comments
 (0)