Skip to content

Commit 9a70ce1

Browse files
authored
Content lists: sticky on move, unique positions, and article content enrichment (#3224)
* Add sticky flag on move and article content enrichment to content lists * More data in item, provide only existing ones
1 parent 6f5c27a commit 9a70ce1

4 files changed

Lines changed: 222 additions & 18 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from typing import Any
2+
3+
import superdesk
4+
from superdesk.core.types import RestGetResponse
5+
from superdesk.etree import parse_html
6+
7+
8+
async def attach_article_content(items: list[dict[str, Any]]) -> None:
9+
"""Add an ``article_content`` summary to each content list item in place."""
10+
article_ids = [item["content"] for item in items if item.get("content")]
11+
if not article_ids:
12+
for item in items:
13+
item["article_content"] = None
14+
return
15+
16+
archive_service = superdesk.get_resource_service("archive")
17+
cursor = await archive_service.get_from_mongo_async(
18+
req=None,
19+
lookup={"_id": {"$in": article_ids}},
20+
projection={
21+
"headline": 1,
22+
"state": 1,
23+
"associations": 1,
24+
"body_html": 1,
25+
"anpa_category": 1,
26+
"subject": 1,
27+
"_updated": 1,
28+
"_created": 1,
29+
"firstpublished": 1,
30+
},
31+
)
32+
articles_by_id: dict[str, dict[str, Any]] = {}
33+
async for article in cursor:
34+
articles_by_id[article["_id"]] = article
35+
36+
for item in items:
37+
article = articles_by_id.get(item.get("content"))
38+
item["article_content"] = _build_article_content(article) if article else None
39+
40+
41+
def _build_article_content(article: dict[str, Any]) -> dict[str, Any]:
42+
content: dict[str, Any] = {}
43+
if "headline" in article:
44+
content["title"] = article["headline"]
45+
if "state" in article:
46+
content["state"] = article["state"]
47+
thumbnail = _get_thumbnail(article)
48+
if thumbnail is not None:
49+
content["thumbnail"] = thumbnail
50+
for field in ("anpa_category", "subject", "_updated", "_created", "firstpublished"):
51+
if field in article:
52+
content[field] = article[field]
53+
return content
54+
55+
56+
def _get_thumbnail(article: dict[str, Any]) -> dict[str, Any] | str | None:
57+
feature_media = (article.get("associations") or {}).get("featuremedia") or {}
58+
thumbnail = (feature_media.get("renditions") or {}).get("thumbnail")
59+
if thumbnail:
60+
return thumbnail
61+
62+
body_html = article.get("body_html")
63+
if body_html:
64+
return _first_body_html_image(body_html)
65+
66+
return None
67+
68+
69+
def _first_body_html_image(body_html: str) -> str | None:
70+
try:
71+
root = parse_html(body_html, content="html")
72+
except Exception:
73+
return None
74+
img = root.find(".//img")
75+
if img is None:
76+
return None
77+
src = img.get("src")
78+
return src or None
79+
80+
81+
async def enrich_fetched_response(doc: RestGetResponse) -> None:
82+
"""Hook for ``on_fetched``: enriches all items in a search response."""
83+
await attach_article_content(doc.get("_items") or [])
84+
85+
86+
async def enrich_fetched_item(item: dict[str, Any]) -> None:
87+
"""Hook for ``on_fetched_item``: enriches a single item."""
88+
await attach_article_content([item])

apps/content_lists/rest_endpoints.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from bson import ObjectId
22

3-
from superdesk.core.types import Request, Response
3+
from superdesk.core.types import Request, Response, RestGetResponse
44
from superdesk.core.web import Endpoint
55
from superdesk.core.resources import ResourceRestEndpoints
66

7+
from .article_content import enrich_fetched_item, enrich_fetched_response
8+
79

810
class ContentListItemsEndpoints(ResourceRestEndpoints):
911
def add_endpoints(self):
@@ -23,3 +25,9 @@ async def bulk_patch_items(self, request: Request) -> Response:
2325
data = await request.get_json()
2426
result = await self.service.bulk_patch(list_id, data)
2527
return Response(body=result.to_dict(), status_code=200)
28+
29+
async def on_fetched(self, request: Request, doc: RestGetResponse) -> None:
30+
await enrich_fetched_response(doc)
31+
32+
async def on_fetched_item(self, request: Request, doc: dict) -> None:
33+
await enrich_fetched_item(doc)

apps/content_lists/service.py

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ async def bulk_patch(self, list_id: ObjectId, data: dict) -> ContentList:
6767
elif action == "move":
6868
existing = await self.find_one(req=None, list_id=list_id, content=content_id)
6969
if existing:
70-
await self.update(existing.id, {"position": item_data.get("position")})
70+
new_sticky = item_data.get("sticky", existing.sticky)
71+
await self.update(existing.id, {"position": item_data.get("position"), "sticky": new_sticky})
7172
touched_contents.append(content_id)
7273
elif action == "delete":
7374
existing = await self.find_one(req=None, list_id=list_id, content=content_id)
@@ -85,36 +86,62 @@ async def bulk_patch(self, list_id: ObjectId, data: dict) -> ContentList:
8586
return result
8687

8788
async def _renumber(self, list_id: ObjectId, touched_contents: list[str]) -> None:
88-
"""Rewrite non-sticky positions as a contiguous ``0..N-1`` sequence.
89-
90-
Items in ``touched_contents`` (added or moved in this batch) win position
91-
ties: when an item lands on a slot already occupied, the touched item
92-
keeps the slot and the prior occupant shifts to the next one. Later
93-
entries in ``touched_contents`` outrank earlier ones.
89+
"""Reassign positions so every item has a unique slot.
90+
91+
Sticky items keep their declared position. Non-sticky items in
92+
``touched_contents`` (added or moved in this batch) also anchor at the
93+
position they were just set to. Remaining untouched non-sticky items
94+
fill the lowest-numbered free positions in their previous order. If
95+
two anchors land on the same slot, sticky beats non-sticky and the
96+
most recently touched wins within a group; the loser spills to the
97+
nearest higher free slot.
9498
"""
9599
docs = await self.mongo_async.find(
96-
{"list_id": list_id, "sticky": {"$ne": True}},
97-
projection={"_id": 1, "content": 1, "position": 1},
100+
{"list_id": list_id},
101+
projection={"_id": 1, "content": 1, "position": 1, "sticky": 1},
98102
).to_list(None)
103+
if not docs:
104+
return
99105

100106
touched_rank = {c: i for i, c in enumerate(touched_contents)}
101107

102-
def sort_key(d: dict) -> tuple:
103-
pos = d.get("position")
108+
def anchor_priority(d: dict) -> tuple:
104109
rank = touched_rank.get(d.get("content"))
105110
return (
106-
pos is None,
107-
pos if pos is not None else 0,
108-
rank is None,
109-
-rank if rank is not None else 0,
111+
0 if d.get("sticky") else 1,
112+
-(rank if rank is not None else -1),
110113
d["_id"],
111114
)
112115

113-
docs.sort(key=sort_key)
116+
anchors = [d for d in docs if d.get("sticky") or d.get("content") in touched_rank]
117+
anchors.sort(key=anchor_priority)
118+
119+
placement: dict[int, dict] = {}
120+
for doc in anchors:
121+
pos = doc.get("position")
122+
slot = pos if pos is not None and pos >= 0 else 0
123+
while slot in placement:
124+
slot += 1
125+
placement[slot] = doc
126+
127+
rest = [d for d in docs if not d.get("sticky") and d.get("content") not in touched_rank]
128+
rest.sort(
129+
key=lambda d: (
130+
d.get("position") is None,
131+
d.get("position") if d.get("position") is not None else 0,
132+
d["_id"],
133+
)
134+
)
135+
next_slot = 0
136+
for doc in rest:
137+
while next_slot in placement:
138+
next_slot += 1
139+
placement[next_slot] = doc
140+
next_slot += 1
114141

115142
ops = [
116143
UpdateOne({"_id": doc["_id"]}, {"$set": {"position": i}})
117-
for i, doc in enumerate(docs)
144+
for i, doc in placement.items()
118145
if doc.get("position") != i
119146
]
120147
if ops:

features/content_lists.feature

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,87 @@ Feature: Content Lists
159159
{"_items": [{"content": "article-1", "position": 5}]}
160160
"""
161161

162+
@auth
163+
Scenario: Move action toggles sticky flag on an item
164+
Given an archive item "article-1"
165+
When we post to "/content_lists"
166+
"""
167+
{"name": "Breaking News", "type": "manual"}
168+
"""
169+
Then we get OK response
170+
When we bulk patch items for "/content_lists/#content_lists._id#/items"
171+
"""
172+
{
173+
"updatedAt": null,
174+
"items": [{"action": "add", "contentId": "article-1", "position": 0}]
175+
}
176+
"""
177+
Then we get OK response
178+
And we store response as "content_lists"
179+
When we get "/content_lists/#content_lists._id#/items"
180+
Then we get existing resource
181+
"""
182+
{"_items": [{"content": "article-1", "position": 0, "sticky": false}]}
183+
"""
184+
When we bulk patch items for "/content_lists/#content_lists._id#/items"
185+
"""
186+
{
187+
"updatedAt": "#content_lists.content_list_items_updated_at#",
188+
"items": [{"action": "move", "contentId": "article-1", "position": 3, "sticky": true}]
189+
}
190+
"""
191+
Then we get OK response
192+
And we store response as "content_lists"
193+
When we get "/content_lists/#content_lists._id#/items"
194+
Then we get existing resource
195+
"""
196+
{"_items": [{"content": "article-1", "position": 3, "sticky": true}]}
197+
"""
198+
When we bulk patch items for "/content_lists/#content_lists._id#/items"
199+
"""
200+
{
201+
"updatedAt": "#content_lists.content_list_items_updated_at#",
202+
"items": [{"action": "move", "contentId": "article-1", "position": 2, "sticky": false}]
203+
}
204+
"""
205+
Then we get OK response
206+
When we get "/content_lists/#content_lists._id#/items"
207+
Then we get existing resource
208+
"""
209+
{"_items": [{"content": "article-1", "position": 2, "sticky": false}]}
210+
"""
211+
212+
@auth
213+
Scenario: Move action preserves sticky flag when not provided
214+
Given an archive item "article-1"
215+
When we post to "/content_lists"
216+
"""
217+
{"name": "Breaking News", "type": "manual"}
218+
"""
219+
Then we get OK response
220+
When we bulk patch items for "/content_lists/#content_lists._id#/items"
221+
"""
222+
{
223+
"updatedAt": null,
224+
"items": [{"action": "add", "contentId": "article-1", "position": 0, "sticky": true}]
225+
}
226+
"""
227+
Then we get OK response
228+
And we store response as "content_lists"
229+
When we bulk patch items for "/content_lists/#content_lists._id#/items"
230+
"""
231+
{
232+
"updatedAt": "#content_lists.content_list_items_updated_at#",
233+
"items": [{"action": "move", "contentId": "article-1", "position": 4}]
234+
}
235+
"""
236+
Then we get OK response
237+
When we get "/content_lists/#content_lists._id#/items"
238+
Then we get existing resource
239+
"""
240+
{"_items": [{"content": "article-1", "position": 4, "sticky": true}]}
241+
"""
242+
162243
@auth
163244
Scenario: Moving an item shifts other items to keep positions unique
164245
Given an archive item "article-a"

0 commit comments

Comments
 (0)