diff --git a/securedrop/journalist_app/api2/events.py b/securedrop/journalist_app/api2/events.py index d592d91d81..07233041fd 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,42 @@ 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_items = [ + item for item in source.collection if item.interaction_count <= event.data.upper_bound + ] + + 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: item for item in seen_items}, + ) + @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..77e0210f73 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_CONVERSATION_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_CONVERSATION_SEEN: (SourceTarget, SourceConversationSeenData), } diff --git a/securedrop/tests/test_journalist_api2.py b/securedrop/tests/test_journalist_api2.py index 3e8afcbad6..0a574916db 100644 --- a/securedrop/tests/test_journalist_api2.py +++ b/securedrop/tests/test_journalist_api2.py @@ -1302,3 +1302,99 @@ 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() + + 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 + elif submission is not None: + assert submission.seen is False + + # 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