Skip to content

Commit 9951619

Browse files
Add recipient list member listing API
1 parent edb85bf commit 9951619

5 files changed

Lines changed: 168 additions & 0 deletions

File tree

mailing/services/api_docs.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"mailing:api_subscribe": "/api/subscriptions/subscribe",
6363
"mailing:api_unsubscribe": "/api/subscriptions/unsubscribe",
6464
"mailing:api_recipient_list": "/api/recipient-lists/{list_key}",
65+
"mailing:api_recipient_list_members": "/api/recipient-lists/{list_key}/members",
6566
"mailing:api_recipient_list_member": "/api/recipient-lists/{list_key}/members/{source_object_key}",
6667
"mailing:api_recipient_list_bulk_upsert": "/api/recipient-lists/{list_key}/members/bulk-upsert",
6768
"mailing:api_recipient_list_reconcile": "/api/recipient-lists/{list_key}/members/reconcile",
@@ -797,6 +798,7 @@ def endpoint_groups():
797798
"endpoints": [
798799
("PUT", "/api/recipient-lists/{list_key}", "Create or update a client-scoped recipient list."),
799800
("GET", "/api/recipient-lists/{list_key}", "Get recipient list metadata and counts."),
801+
("GET", "/api/recipient-lists/{list_key}/members", "List recipient-list members for audit."),
800802
(
801803
"PUT",
802804
"/api/recipient-lists/{list_key}/members/{source_object_key}",
@@ -895,6 +897,10 @@ def route_path_map():
895897
"mailing:api_recipient_list",
896898
args=["ml-zoomcamp-2026"],
897899
),
900+
API_DOC_PATHS["mailing:api_recipient_list_members"]: reverse(
901+
"mailing:api_recipient_list_members",
902+
args=["ml-zoomcamp-2026"],
903+
),
898904
API_DOC_PATHS["mailing:api_recipient_list_member"]: reverse(
899905
"mailing:api_recipient_list_member",
900906
args=["ml-zoomcamp-2026:@e:@homework:homework-1", "homework-submission:42"],
@@ -1354,6 +1360,24 @@ def bearer_responses(success, *, accepted=False):
13541360
),
13551361
},
13561362
},
1363+
"/api/recipient-lists/{list_key}/members": {
1364+
"get": {
1365+
"tags": ["Recipient Lists"],
1366+
"summary": "List recipient list members",
1367+
"description": "Returns client-scoped list members for audit and drift checks. Active members are returned by default.",
1368+
"security": [{"BearerAuth": []}],
1369+
"parameters": [
1370+
LIST_KEY_PARAM,
1371+
{"name": "audience", "in": "query", "required": True, "schema": {"type": "string"}},
1372+
{"name": "client", "in": "query", "required": True, "schema": {"type": "string"}},
1373+
{"name": "include_removed", "in": "query", "required": False, "schema": {"type": "boolean", "default": False}},
1374+
{"name": "limit", "in": "query", "required": False, "schema": {"type": "integer", "minimum": 1, "maximum": 10000, "default": 1000}},
1375+
],
1376+
"responses": bearer_responses(
1377+
json_response("Recipient list members", "#/components/schemas/RecipientListMembersResponse")
1378+
),
1379+
},
1380+
},
13571381
"/api/recipient-lists/{list_key}/members/{source_object_key}": {
13581382
"put": {
13591383
"tags": ["Recipient Lists"],
@@ -2323,6 +2347,18 @@ def bearer_responses(success, *, accepted=False):
23232347
"type": "object",
23242348
"properties": {"recipient_list": {"$ref": "#/components/schemas/RecipientList"}},
23252349
},
2350+
"RecipientListMembersResponse": {
2351+
"type": "object",
2352+
"properties": {
2353+
"recipient_list": {"$ref": "#/components/schemas/RecipientList"},
2354+
"members": {
2355+
"type": "array",
2356+
"items": {"$ref": "#/components/schemas/RecipientListMember"},
2357+
},
2358+
"count": {"type": "integer"},
2359+
"has_more": {"type": "boolean"},
2360+
},
2361+
},
23262362
"RecipientListUpsertResponse": {
23272363
"type": "object",
23282364
"properties": {

mailing/services/recipient_lists.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,51 @@ def get_recipient_list_for_client(list_key, data, authenticated_client):
365365
return {"recipient_list": recipient_list_payload(recipient_list)}
366366

367367

368+
def get_recipient_list_members_for_client(list_key, data, authenticated_client):
369+
list_key = validate_path_key(list_key, "list_key")
370+
scope = validate_recipient_list_scope(data, authenticated_client)
371+
recipient_list = RecipientList.objects.filter(
372+
client=scope.client,
373+
audience=scope.audience,
374+
key=list_key,
375+
).first()
376+
if recipient_list is None:
377+
raise ApiValidationError({"list_key": "not_found"}, status_code=404)
378+
379+
include_removed = data.get("include_removed", "false")
380+
if include_removed in ("", None, "false", "0", "no"):
381+
include_removed = False
382+
elif include_removed in ("true", "1", "yes"):
383+
include_removed = True
384+
else:
385+
raise ApiValidationError({"include_removed": "must_be_boolean"})
386+
387+
limit = data.get("limit", 1000)
388+
try:
389+
limit = int(limit)
390+
except (TypeError, ValueError) as exc:
391+
raise ApiValidationError({"limit": "must_be_integer"}) from exc
392+
if limit <= 0 or limit > 10000:
393+
raise ApiValidationError({"limit": "must_be_between_1_and_10000"})
394+
395+
queryset = (
396+
recipient_list.members.select_related("contact")
397+
.order_by("source_object_key", "id")
398+
)
399+
if not include_removed:
400+
queryset = queryset.filter(active=True)
401+
402+
members = list(queryset[: limit + 1])
403+
has_more = len(members) > limit
404+
members = members[:limit]
405+
return {
406+
"recipient_list": recipient_list_payload(recipient_list),
407+
"members": [recipient_list_member_payload(member) for member in members],
408+
"count": len(members),
409+
"has_more": has_more,
410+
}
411+
412+
368413
def upsert_member(recipient_list, source_object_key, member_data):
369414
contact, _ = upsert_contact(member_data["email"])
370415
existing_by_source = RecipientListMember.objects.filter(

mailing/tests/test_client_api.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,66 @@ def test_recipient_list_member_upsert_creates_list_and_member_idempotently(clien
266266
assert get_response.json()["recipient_list"]["member_count"] == 1
267267

268268

269+
def test_recipient_list_members_endpoint_lists_scoped_members(client, audience, api_client_record):
270+
list_key = "ml-zoomcamp-2026:@e"
271+
payload = {
272+
"audience": audience.slug,
273+
"client": api_client_record.slug,
274+
"list": {"name": "ML Zoomcamp 2026 enrolled"},
275+
"member": {
276+
"email": "learner@example.com",
277+
"metadata": {"enrollment_id": 1},
278+
},
279+
}
280+
removed_payload = {
281+
**payload,
282+
"member": {
283+
"email": "removed@example.com",
284+
"metadata": {"enrollment_id": 2},
285+
},
286+
}
287+
assert put_json(client, "mailing:api_recipient_list_member", payload, list_key, "enrollment:1").status_code == 200
288+
assert put_json(client, "mailing:api_recipient_list_member", removed_payload, list_key, "enrollment:2").status_code == 200
289+
assert delete_json(
290+
client,
291+
"mailing:api_recipient_list_member",
292+
{"audience": audience.slug, "client": api_client_record.slug},
293+
list_key,
294+
"enrollment:2",
295+
).status_code == 200
296+
297+
active_response = client.get(
298+
reverse("mailing:api_recipient_list_members", args=[list_key]),
299+
{"audience": audience.slug, "client": api_client_record.slug},
300+
**auth_headers(),
301+
)
302+
all_response = client.get(
303+
reverse("mailing:api_recipient_list_members", args=[list_key]),
304+
{
305+
"audience": audience.slug,
306+
"client": api_client_record.slug,
307+
"include_removed": "true",
308+
"limit": "1",
309+
},
310+
**auth_headers(),
311+
)
312+
313+
assert active_response.status_code == 200
314+
active_body = active_response.json()
315+
assert active_body["recipient_list"]["key"] == list_key
316+
assert active_body["count"] == 1
317+
assert active_body["has_more"] is False
318+
assert [member["source_object_key"] for member in active_body["members"]] == ["enrollment:1"]
319+
assert active_body["members"][0]["email"] == "learner@example.com"
320+
assert active_body["members"][0]["metadata"] == {"enrollment_id": 1}
321+
322+
assert all_response.status_code == 200
323+
all_body = all_response.json()
324+
assert all_body["count"] == 1
325+
assert all_body["has_more"] is True
326+
assert all_body["members"][0]["source_object_key"] == "enrollment:1"
327+
328+
269329
def test_recipient_list_reconcile_removes_cascaded_parent_reason(client, audience, api_client_record):
270330
list_key = "ml-zoomcamp-2026:@e:@homework:homework-1"
271331
payload = {

mailing/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@
9292
path("api/subscriptions/subscribe", views.api_subscribe, name="api_subscribe"),
9393
path("api/subscriptions/unsubscribe", views.api_unsubscribe, name="api_unsubscribe"),
9494
path("api/recipient-lists/<str:list_key>", views.api_recipient_list, name="api_recipient_list"),
95+
path(
96+
"api/recipient-lists/<str:list_key>/members",
97+
views.api_recipient_list_members,
98+
name="api_recipient_list_members",
99+
),
95100
path(
96101
"api/recipient-lists/<str:list_key>/members/bulk-upsert",
97102
views.api_recipient_list_bulk_upsert,

mailing/views.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@
147147
create_recipient_list_import_job_for_client,
148148
get_recipient_list_for_client,
149149
get_recipient_list_import_job_for_client,
150+
get_recipient_list_members_for_client,
150151
reconcile_recipient_list_for_client,
151152
remove_recipient_list_member_for_client,
152153
upsert_recipient_list_for_client,
@@ -1345,6 +1346,27 @@ def api_recipient_list_member(request, list_key, source_object_key):
13451346
return JsonResponse(payload, status=200)
13461347

13471348

1349+
@csrf_exempt
1350+
def api_recipient_list_members(request, list_key):
1351+
if request.method != "GET":
1352+
return method_not_allowed_response(["GET"])
1353+
1354+
client, error_response = authenticate_api_request(request)
1355+
if error_response:
1356+
return error_response
1357+
1358+
try:
1359+
payload = get_recipient_list_members_for_client(
1360+
list_key,
1361+
request.GET,
1362+
client,
1363+
)
1364+
except ApiValidationError as exc:
1365+
return validation_error_response(exc)
1366+
1367+
return JsonResponse(payload, status=200)
1368+
1369+
13481370
@csrf_exempt
13491371
def api_recipient_list_bulk_upsert(request, list_key):
13501372
if request.method != "POST":

0 commit comments

Comments
 (0)