Skip to content

Commit 7c5032b

Browse files
committed
[api2] events: add source_conversation_seen event
1 parent 9213895 commit 7c5032b

3 files changed

Lines changed: 149 additions & 0 deletions

File tree

securedrop/journalist_app/api2/events.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def process(self, event: Event, minor: int) -> EventResult:
7474
EventType.SOURCE_STARRED: self.handle_source_starred,
7575
EventType.SOURCE_UNSTARRED: self.handle_source_unstarred,
7676
EventType.SOURCE_CONVERSATION_TRUNCATED: self.handle_source_conversation_truncated,
77+
EventType.SOURCE_CONVERSATION_SEEN: self.handle_source_conversation_seen,
7778
}[event.type]
7879
except KeyError:
7980
return EventResult(
@@ -281,6 +282,41 @@ def handle_source_conversation_truncated(event: Event, minor: int) -> EventResul
281282
items={item_uuid: None for item_uuid in deleted},
282283
)
283284

285+
@staticmethod
286+
def handle_source_conversation_seen(event: Event, minor: int) -> EventResult:
287+
"""
288+
A `source_conversation_seen` event involves marking as seen items
289+
in the source's collection with interaction counts less than or equal to
290+
the specified upper bound.
291+
"""
292+
293+
try:
294+
source = Source.query.filter(Source.uuid == event.target.source_uuid).one()
295+
except NoResultFound:
296+
return EventResult(
297+
event_id=event.id,
298+
status=(
299+
EventStatusCode.Gone,
300+
None,
301+
),
302+
)
303+
304+
user = session.get_user()
305+
seen: list[ItemUUID] = []
306+
for item in source.collection:
307+
if item.interaction_count <= event.data.upper_bound:
308+
utils.mark_seen([item], user)
309+
seen.append(item.uuid)
310+
db.session.refresh(item)
311+
312+
db.session.refresh(source)
313+
return EventResult(
314+
event_id=event.id,
315+
status=(EventStatusCode.OK, None),
316+
sources={source.uuid: source},
317+
items={item_uuid: None for item_uuid in seen},
318+
)
319+
284320
@staticmethod
285321
def handle_source_starred(event: Event, minor: int) -> EventResult:
286322
try:

securedrop/journalist_app/api2/types.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class EventType(StrEnum):
3333
SOURCE_CONVERSATION_TRUNCATED = auto()
3434
SOURCE_STARRED = auto()
3535
SOURCE_UNSTARRED = auto()
36+
SOURCE_SEEN = auto()
3637

3738

3839
class EventStatusCode(IntEnum):
@@ -151,6 +152,17 @@ def __post_init__(self) -> None:
151152
raise ValueError("upper_bound must be non-negative")
152153

153154

155+
@dataclass(frozen=True)
156+
class SourceConversationSeenData(EventData):
157+
# An upper bound of n means "mark as seen items with interaction counts (sparsely)
158+
# up to and including n".
159+
upper_bound: int
160+
161+
def __post_init__(self) -> None:
162+
if self.upper_bound < 0:
163+
raise ValueError("upper_bound must be non-negative")
164+
165+
154166
EVENT_TYPES = {
155167
EventType.REPLY_SENT: (SourceTarget, ReplySentData),
156168
EventType.ITEM_DELETED: (ItemTarget, None),
@@ -160,6 +172,7 @@ def __post_init__(self) -> None:
160172
EventType.SOURCE_CONVERSATION_TRUNCATED: (SourceTarget, SourceConversationTruncatedData),
161173
EventType.SOURCE_STARRED: (SourceTarget, None),
162174
EventType.SOURCE_UNSTARRED: (SourceTarget, None),
175+
EventType.SOURCE_SEEN: (SourceTarget, SourceConversationSeenData),
163176
}
164177

165178

securedrop/tests/test_journalist_api2.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,3 +1302,103 @@ def test_api2_source_conversation_truncated(
13021302
)
13031303
assert res2.status_code == 200
13041304
assert res2.json["events"][event.id][0] == 208
1305+
1306+
1307+
def test_api2_source_conversation_seen(
1308+
journalist_app,
1309+
journalist_api_token,
1310+
test_files,
1311+
):
1312+
"""
1313+
Test processing of the "source_conversation_seen" event.
1314+
Items with interaction_count <= upper_bound must be marked as seen.
1315+
Items with interaction_count > upper_bound must remain unseen.
1316+
"""
1317+
with journalist_app.test_client() as app:
1318+
source = test_files["source"]
1319+
1320+
assert len(test_files["submissions"]) >= 1
1321+
assert len(test_files["replies"]) >= 1
1322+
1323+
# Fetch index to get current versions
1324+
index = app.get(
1325+
url_for("api2.index"),
1326+
headers=get_api_headers(journalist_api_token),
1327+
)
1328+
assert index.status_code == 200
1329+
1330+
# Build a map of item_uuid -> interaction_count
1331+
item_uuids = [item.uuid for item in (test_files["submissions"] + test_files["replies"])]
1332+
1333+
batch_resp = app.post(
1334+
url_for("api2.data"),
1335+
json={"items": item_uuids},
1336+
headers=get_api_headers(journalist_api_token),
1337+
)
1338+
assert batch_resp.status_code == 200
1339+
data = batch_resp.json
1340+
1341+
initial_counts = {
1342+
item_uuid: item["interaction_count"] for item_uuid, item in data["items"].items()
1343+
}
1344+
1345+
# Choose a bound that marks some but not all items as seen
1346+
sorted_counts = sorted(initial_counts.values())
1347+
upper_bound = sorted_counts[len(sorted_counts) // 2]
1348+
1349+
source_version = index.json["sources"][source.uuid]
1350+
1351+
event = Event(
1352+
id="888001",
1353+
target=SourceTarget(source_uuid=source.uuid, version=source_version),
1354+
type=EventType.SOURCE_CONVERSATION_SEEN,
1355+
data={"upper_bound": upper_bound},
1356+
)
1357+
1358+
response = app.post(
1359+
url_for("api2.data"),
1360+
json={"events": [asdict(event)]},
1361+
headers=get_api_headers(journalist_api_token),
1362+
)
1363+
assert response.status_code == 200
1364+
assert response.json["events"][event.id] == [200, None]
1365+
1366+
# Items within bound must be marked seen; items outside must not be
1367+
for item_uuid, count in initial_counts.items():
1368+
submission = Submission.query.filter(Submission.uuid == item_uuid).one_or_none()
1369+
reply = Reply.query.filter(Reply.uuid == item_uuid).one_or_none()
1370+
1371+
if count <= upper_bound:
1372+
assert item_uuid in response.json["items"]
1373+
if submission is not None:
1374+
assert submission.seen is True
1375+
else:
1376+
assert len(reply.seen_replies) > 0
1377+
elif submission is not None:
1378+
assert submission.seen is False
1379+
else:
1380+
assert len(reply.seen_replies) == 0
1381+
1382+
# Resubmission must yield "Already Reported" (208)
1383+
res2 = app.post(
1384+
url_for("api2.data"),
1385+
json={"events": [asdict(event)]},
1386+
headers=get_api_headers(journalist_api_token),
1387+
)
1388+
assert res2.status_code == 200
1389+
assert res2.json["events"][event.id][0] == 208
1390+
1391+
# Non-existent source must yield Gone (410)
1392+
gone_event = Event(
1393+
id="888002",
1394+
target=SourceTarget(source_uuid=str(uuid4()), version=source_version),
1395+
type=EventType.SOURCE_CONVERSATION_SEEN,
1396+
data={"upper_bound": upper_bound},
1397+
)
1398+
res3 = app.post(
1399+
url_for("api2.data"),
1400+
json={"events": [asdict(gone_event)]},
1401+
headers=get_api_headers(journalist_api_token),
1402+
)
1403+
assert res3.status_code == 200
1404+
assert res3.json["events"][gone_event.id][0] == 410

0 commit comments

Comments
 (0)