From 7c5032bfacdd018d35baa152ed5096080cbf4d03 Mon Sep 17 00:00:00 2001 From: vicki Date: Thu, 5 Mar 2026 11:16:11 -0500 Subject: [PATCH 1/3] [api2] events: add source_conversation_seen event --- securedrop/journalist_app/api2/events.py | 36 ++++++++ securedrop/journalist_app/api2/types.py | 13 +++ securedrop/tests/test_journalist_api2.py | 100 +++++++++++++++++++++++ 3 files changed, 149 insertions(+) diff --git a/securedrop/journalist_app/api2/events.py b/securedrop/journalist_app/api2/events.py index d592d91d81..182777b7be 100644 --- a/securedrop/journalist_app/api2/events.py +++ b/securedrop/journalist_app/api2/events.py @@ -74,6 +74,7 @@ def process(self, event: Event, minor: int) -> EventResult: EventType.SOURCE_STARRED: self.handle_source_starred, EventType.SOURCE_UNSTARRED: self.handle_source_unstarred, EventType.SOURCE_CONVERSATION_TRUNCATED: self.handle_source_conversation_truncated, + EventType.SOURCE_CONVERSATION_SEEN: self.handle_source_conversation_seen, }[event.type] except KeyError: return EventResult( @@ -281,6 +282,41 @@ def handle_source_conversation_truncated(event: Event, minor: int) -> EventResul items={item_uuid: None for item_uuid in deleted}, ) + @staticmethod + def handle_source_conversation_seen(event: Event, minor: int) -> EventResult: + """ + A `source_conversation_seen` event involves marking as seen items + in the source's collection with interaction counts less than or equal to + the specified upper bound. + """ + + try: + source = Source.query.filter(Source.uuid == event.target.source_uuid).one() + except NoResultFound: + return EventResult( + event_id=event.id, + status=( + EventStatusCode.Gone, + None, + ), + ) + + user = session.get_user() + seen: list[ItemUUID] = [] + for item in source.collection: + if item.interaction_count <= event.data.upper_bound: + utils.mark_seen([item], user) + seen.append(item.uuid) + db.session.refresh(item) + + db.session.refresh(source) + return EventResult( + event_id=event.id, + status=(EventStatusCode.OK, None), + sources={source.uuid: source}, + items={item_uuid: None for item_uuid in seen}, + ) + @staticmethod def handle_source_starred(event: Event, minor: int) -> EventResult: try: diff --git a/securedrop/journalist_app/api2/types.py b/securedrop/journalist_app/api2/types.py index 9f068d41ef..690cc2f55d 100644 --- a/securedrop/journalist_app/api2/types.py +++ b/securedrop/journalist_app/api2/types.py @@ -33,6 +33,7 @@ class EventType(StrEnum): SOURCE_CONVERSATION_TRUNCATED = auto() SOURCE_STARRED = auto() SOURCE_UNSTARRED = auto() + SOURCE_SEEN = auto() class EventStatusCode(IntEnum): @@ -151,6 +152,17 @@ def __post_init__(self) -> None: raise ValueError("upper_bound must be non-negative") +@dataclass(frozen=True) +class SourceConversationSeenData(EventData): + # An upper bound of n means "mark as seen items with interaction counts (sparsely) + # up to and including n". + upper_bound: int + + def __post_init__(self) -> None: + if self.upper_bound < 0: + raise ValueError("upper_bound must be non-negative") + + EVENT_TYPES = { EventType.REPLY_SENT: (SourceTarget, ReplySentData), EventType.ITEM_DELETED: (ItemTarget, None), @@ -160,6 +172,7 @@ def __post_init__(self) -> None: EventType.SOURCE_CONVERSATION_TRUNCATED: (SourceTarget, SourceConversationTruncatedData), EventType.SOURCE_STARRED: (SourceTarget, None), EventType.SOURCE_UNSTARRED: (SourceTarget, None), + EventType.SOURCE_SEEN: (SourceTarget, SourceConversationSeenData), } diff --git a/securedrop/tests/test_journalist_api2.py b/securedrop/tests/test_journalist_api2.py index 3e8afcbad6..a0f19eefa1 100644 --- a/securedrop/tests/test_journalist_api2.py +++ b/securedrop/tests/test_journalist_api2.py @@ -1302,3 +1302,103 @@ def test_api2_source_conversation_truncated( ) assert res2.status_code == 200 assert res2.json["events"][event.id][0] == 208 + + +def test_api2_source_conversation_seen( + journalist_app, + journalist_api_token, + test_files, +): + """ + Test processing of the "source_conversation_seen" event. + Items with interaction_count <= upper_bound must be marked as seen. + Items with interaction_count > upper_bound must remain unseen. + """ + with journalist_app.test_client() as app: + source = test_files["source"] + + assert len(test_files["submissions"]) >= 1 + assert len(test_files["replies"]) >= 1 + + # Fetch index to get current versions + index = app.get( + url_for("api2.index"), + headers=get_api_headers(journalist_api_token), + ) + assert index.status_code == 200 + + # Build a map of item_uuid -> interaction_count + item_uuids = [item.uuid for item in (test_files["submissions"] + test_files["replies"])] + + batch_resp = app.post( + url_for("api2.data"), + json={"items": item_uuids}, + headers=get_api_headers(journalist_api_token), + ) + assert batch_resp.status_code == 200 + data = batch_resp.json + + initial_counts = { + item_uuid: item["interaction_count"] for item_uuid, item in data["items"].items() + } + + # Choose a bound that marks some but not all items as seen + sorted_counts = sorted(initial_counts.values()) + upper_bound = sorted_counts[len(sorted_counts) // 2] + + source_version = index.json["sources"][source.uuid] + + event = Event( + id="888001", + target=SourceTarget(source_uuid=source.uuid, version=source_version), + type=EventType.SOURCE_CONVERSATION_SEEN, + data={"upper_bound": upper_bound}, + ) + + response = app.post( + url_for("api2.data"), + json={"events": [asdict(event)]}, + headers=get_api_headers(journalist_api_token), + ) + assert response.status_code == 200 + assert response.json["events"][event.id] == [200, None] + + # Items within bound must be marked seen; items outside must not be + for item_uuid, count in initial_counts.items(): + submission = Submission.query.filter(Submission.uuid == item_uuid).one_or_none() + reply = Reply.query.filter(Reply.uuid == item_uuid).one_or_none() + + if count <= upper_bound: + assert item_uuid in response.json["items"] + if submission is not None: + assert submission.seen is True + else: + assert len(reply.seen_replies) > 0 + elif submission is not None: + assert submission.seen is False + else: + assert len(reply.seen_replies) == 0 + + # Resubmission must yield "Already Reported" (208) + res2 = app.post( + url_for("api2.data"), + json={"events": [asdict(event)]}, + headers=get_api_headers(journalist_api_token), + ) + assert res2.status_code == 200 + assert res2.json["events"][event.id][0] == 208 + + # Non-existent source must yield Gone (410) + gone_event = Event( + id="888002", + target=SourceTarget(source_uuid=str(uuid4()), version=source_version), + type=EventType.SOURCE_CONVERSATION_SEEN, + data={"upper_bound": upper_bound}, + ) + res3 = app.post( + url_for("api2.data"), + json={"events": [asdict(gone_event)]}, + headers=get_api_headers(journalist_api_token), + ) + assert res3.status_code == 200 + assert res3.json["events"][gone_event.id][0] == 410 From f42470759f25b42ea780db31102646baf234a120 Mon Sep 17 00:00:00 2001 From: vicki Date: Thu, 12 Mar 2026 11:55:37 -0400 Subject: [PATCH 2/3] fix event name, make batch call to seen --- securedrop/journalist_app/api2/events.py | 17 +++++++++-------- securedrop/journalist_app/api2/types.py | 4 ++-- securedrop/tests/test_journalist_api2.py | 1 + 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/securedrop/journalist_app/api2/events.py b/securedrop/journalist_app/api2/events.py index 182777b7be..33b7756a27 100644 --- a/securedrop/journalist_app/api2/events.py +++ b/securedrop/journalist_app/api2/events.py @@ -302,19 +302,20 @@ def handle_source_conversation_seen(event: Event, minor: int) -> EventResult: ) user = session.get_user() - seen: list[ItemUUID] = [] - for item in source.collection: - if item.interaction_count <= event.data.upper_bound: - utils.mark_seen([item], user) - seen.append(item.uuid) - db.session.refresh(item) + seen_items = [ + item for item in source.collection if item.interaction_count <= event.data.upper_bound + ] - db.session.refresh(source) + if seen_items: + utils.mark_seen(seen_items, user) + for item in seen_items: + db.session.refresh(item) + db.session.refresh(source) return EventResult( event_id=event.id, status=(EventStatusCode.OK, None), sources={source.uuid: source}, - items={item_uuid: None for item_uuid in seen}, + items={item.uuid: item.uuid for item in seen_items}, ) @staticmethod diff --git a/securedrop/journalist_app/api2/types.py b/securedrop/journalist_app/api2/types.py index 690cc2f55d..77e0210f73 100644 --- a/securedrop/journalist_app/api2/types.py +++ b/securedrop/journalist_app/api2/types.py @@ -33,7 +33,7 @@ class EventType(StrEnum): SOURCE_CONVERSATION_TRUNCATED = auto() SOURCE_STARRED = auto() SOURCE_UNSTARRED = auto() - SOURCE_SEEN = auto() + SOURCE_CONVERSATION_SEEN = auto() class EventStatusCode(IntEnum): @@ -172,7 +172,7 @@ def __post_init__(self) -> None: EventType.SOURCE_CONVERSATION_TRUNCATED: (SourceTarget, SourceConversationTruncatedData), EventType.SOURCE_STARRED: (SourceTarget, None), EventType.SOURCE_UNSTARRED: (SourceTarget, None), - EventType.SOURCE_SEEN: (SourceTarget, SourceConversationSeenData), + EventType.SOURCE_CONVERSATION_SEEN: (SourceTarget, SourceConversationSeenData), } diff --git a/securedrop/tests/test_journalist_api2.py b/securedrop/tests/test_journalist_api2.py index a0f19eefa1..5613f90605 100644 --- a/securedrop/tests/test_journalist_api2.py +++ b/securedrop/tests/test_journalist_api2.py @@ -1370,6 +1370,7 @@ def test_api2_source_conversation_seen( if count <= upper_bound: assert item_uuid in response.json["items"] + assert response.json["items"][item_uuid] is not None if submission is not None: assert submission.seen is True else: From 77296a145a659e087e43e909cd9fb5fb3b14f8fd Mon Sep 17 00:00:00 2001 From: vicki Date: Mon, 16 Mar 2026 14:55:33 -0400 Subject: [PATCH 3/3] fix failing test --- securedrop/journalist_app/api2/events.py | 2 +- securedrop/tests/test_journalist_api2.py | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/securedrop/journalist_app/api2/events.py b/securedrop/journalist_app/api2/events.py index 33b7756a27..07233041fd 100644 --- a/securedrop/journalist_app/api2/events.py +++ b/securedrop/journalist_app/api2/events.py @@ -315,7 +315,7 @@ def handle_source_conversation_seen(event: Event, minor: int) -> EventResult: event_id=event.id, status=(EventStatusCode.OK, None), sources={source.uuid: source}, - items={item.uuid: item.uuid for item in seen_items}, + items={item.uuid: item for item in seen_items}, ) @staticmethod diff --git a/securedrop/tests/test_journalist_api2.py b/securedrop/tests/test_journalist_api2.py index 5613f90605..0a574916db 100644 --- a/securedrop/tests/test_journalist_api2.py +++ b/securedrop/tests/test_journalist_api2.py @@ -1366,19 +1366,14 @@ def test_api2_source_conversation_seen( # Items within bound must be marked seen; items outside must not be for item_uuid, count in initial_counts.items(): submission = Submission.query.filter(Submission.uuid == item_uuid).one_or_none() - reply = Reply.query.filter(Reply.uuid == item_uuid).one_or_none() if count <= upper_bound: assert item_uuid in response.json["items"] assert response.json["items"][item_uuid] is not None if submission is not None: assert submission.seen is True - else: - assert len(reply.seen_replies) > 0 elif submission is not None: assert submission.seen is False - else: - assert len(reply.seen_replies) == 0 # Resubmission must yield "Already Reported" (208) res2 = app.post(