Skip to content

Commit 99b7b13

Browse files
committed
feat(api): Include order in paginated queries
- add crtime to element metadata - add manifests route for campaigns
1 parent fbd79f3 commit 99b7b13

5 files changed

Lines changed: 100 additions & 25 deletions

File tree

src/lsst/cmservice/routers/v2/campaigns.py

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515

1616
from ...common.graph import graph_from_edge_list_v2, graph_to_dict
1717
from ...common.logging import LOGGER
18-
from ...db.campaigns_v2 import Campaign, CampaignUpdate, Edge, Node
18+
from ...common.timestamp import element_time
19+
from ...db.campaigns_v2 import Campaign, CampaignUpdate, Edge, Manifest, Node
1920
from ...db.manifests_v2 import CampaignManifest
2021
from ...db.session import db_session_dependency
2122

@@ -42,10 +43,16 @@ async def read_campaign_collection(
4243
offset: Annotated[int, Query()] = 0,
4344
) -> Sequence[Campaign]:
4445
"""A paginated API returning a list of all Campaigns known to the
45-
application.
46+
application, from newest to oldest.
4647
"""
4748
try:
48-
campaigns = await session.exec(select(Campaign).offset(offset).limit(limit))
49+
statement = (
50+
select(Campaign)
51+
.order_by(Campaign.metadata_["crtime"].desc().nulls_last())
52+
.offset(offset)
53+
.limit(limit)
54+
)
55+
campaigns = await session.exec(statement)
4956

5057
response.headers["Next"] = str(
5158
request.url_for("read_campaign_collection").include_query_params(
@@ -95,6 +102,9 @@ async def read_campaign_resource(
95102
response.headers["Edges"] = str(
96103
request.url_for("read_campaign_edge_collection", campaign_name=campaign.id)
97104
)
105+
response.headers["Manifests"] = str(
106+
request.url_for("read_campaign_manifest_collection", campaign_name=campaign.id)
107+
)
98108
return campaign
99109
else:
100110
raise HTTPException(status_code=404)
@@ -196,22 +206,60 @@ async def read_campaign_node_collection(
196206

197207
# The input could be a campaign UUID or it could be a literal name.
198208
# TODO this could just as well be a campaign query with a join to nodes
199-
s = select(Node)
209+
statement = select(Node).order_by(Node.metadata_["crtime"].asc().nulls_last())
210+
200211
try:
201212
if campaign_id := UUID(campaign_name):
202-
s = s.where(Node.namespace == campaign_id)
213+
statement = statement.where(Node.namespace == campaign_id)
203214
except ValueError:
204215
# FIXME get an id from a name
205216
raise HTTPException(status_code=422, detail="campaign_name must be a uuid")
206-
s = s.offset(offset).limit(limit)
207-
nodes = await session.exec(s)
217+
statement = statement.offset(offset).limit(limit)
218+
nodes = await session.exec(statement)
208219
response.headers["Next"] = str(
209220
request.url_for(
210221
"read_campaign_node_collection",
211-
campaign_name=campaign_name,
222+
campaign_name=campaign_id,
212223
).include_query_params(offset=(offset + limit), limit=limit),
213224
)
214-
# TODO Previous
225+
response.headers["Self"] = str(request.url_for("read_campaign_resource", campaign_name=campaign_id))
226+
return nodes.all()
227+
228+
229+
@router.get(
230+
"/{campaign_name}/manifests",
231+
summary="Get campaign Manifests",
232+
)
233+
async def read_campaign_manifest_collection(
234+
request: Request,
235+
response: Response,
236+
session: Annotated[AsyncSession, Depends(db_session_dependency)],
237+
campaign_name: str,
238+
limit: Annotated[int, Query(le=100)] = 10,
239+
offset: Annotated[int, Query()] = 0,
240+
) -> Sequence[Manifest]:
241+
"""A paginated API returning a list of all Manifests in the namespace of a
242+
single Campaign.
243+
"""
244+
245+
# The input could be a campaign UUID or it could be a literal name.
246+
statement = select(Manifest).order_by(Manifest.metadata_["crtime"].asc().nulls_last())
247+
248+
try:
249+
if campaign_id := UUID(campaign_name):
250+
statement = statement.where(Manifest.namespace == campaign_id)
251+
except ValueError:
252+
# FIXME get an id from a name
253+
raise HTTPException(status_code=422, detail="campaign_name must be a uuid")
254+
statement = statement.offset(offset).limit(limit)
255+
nodes = await session.exec(statement)
256+
response.headers["Next"] = str(
257+
request.url_for(
258+
"read_campaign_manifest_collection",
259+
campaign_name=campaign_id,
260+
).include_query_params(offset=(offset + limit), limit=limit),
261+
)
262+
response.headers["Self"] = str(request.url_for("read_campaign_resource", campaign_name=campaign_id))
215263
return nodes.all()
216264

217265

@@ -252,14 +300,16 @@ async def read_campaign_edge_collection(
252300
.join_from(Edge, target_nodes, Edge.target == target_nodes.id)
253301
)
254302
else:
255-
s = select(Edge)
303+
s = select(Edge).order_by(col(Edge.name).asc().nulls_last())
256304
try:
257305
if campaign_id := UUID(campaign_name):
258306
s = s.where(Edge.namespace == campaign_id)
259307
except ValueError:
260308
# FIXME get an id from a name
261309
raise HTTPException(status_code=422, detail="campaign_name must be a uuid")
262310
edges = await session.exec(s)
311+
312+
response.headers["Self"] = str(request.url_for("read_campaign_resource", campaign_name=campaign_id))
263313
return edges.all()
264314

265315

@@ -313,10 +363,12 @@ async def create_campaign_resource(
313363
# Create a campaign spec from the manifest, delegating the creation of new
314364
# dynamic fields to the model validation method, -OR- create new dynamic
315365
# fields here.
366+
campaign_metadata = manifest.metadata_.model_dump()
367+
campaign_metadata |= {"crtime": element_time()}
316368
campaign = Campaign.model_validate(
317369
dict(
318-
name=manifest.metadata_.name,
319-
metadata_=manifest.metadata_.model_dump(),
370+
name=campaign_metadata.pop("name"),
371+
metadata_=campaign_metadata,
320372
# owner = ... # TODO Get username from gafaelfawr # noqa: ERA001
321373
)
322374
)
@@ -376,12 +428,8 @@ async def read_campaign_graph(
376428
edges = (await session.exec(statement)).all()
377429

378430
# Organize the edges into a graph. The graph nodes are annotated with their
379-
# current database attributes.
380-
# TODO it makes sense for the graph to include expunged Nodes in the meta-
381-
# data for campaign processing, but for the purposes of this api route,
382-
# only the most relevant information should be associated with each node,
383-
# e.g., its name, status, id, and its URL
431+
# current database attributes according to the "simple" node view.
384432
graph = await graph_from_edge_list_v2(edges=edges, node_type=Node, session=session, node_view="simple")
385433

386-
response.headers["Self"] = ""
434+
response.headers["Self"] = str(request.url_for("read_campaign_resource", campaign_name=campaign_id))
387435
return graph_to_dict(graph)

src/lsst/cmservice/routers/v2/edges.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from uuid import UUID, uuid5
1010

1111
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
12-
from sqlmodel import select
12+
from sqlmodel import col, select
1313
from sqlmodel.ext.asyncio.session import AsyncSession
1414

1515
from ...common.logging import LOGGER
@@ -46,7 +46,8 @@ async def read_edges_collection(
4646
For campaign-scoped edges, one should use the /campaigns/{}/edges route.
4747
"""
4848
try:
49-
edges = await session.exec(select(Edge).offset(offset).limit(limit))
49+
statement = select(Edge).order_by(col(Edge.name).desc()).offset(offset).limit(limit)
50+
edges = await session.exec(statement)
5051
response.headers["Next"] = (
5152
request.url_for("read_edges_collection")
5253
.include_query_params(offset=(offset + limit), limit=limit)

src/lsst/cmservice/routers/v2/manifests.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from ...common.jsonpatch import JSONPatch, JSONPatchError, apply_json_patch
1616
from ...common.logging import LOGGER
17+
from ...common.timestamp import element_time
1718
from ...db.campaigns_v2 import Campaign, Manifest, _default_campaign_namespace
1819
from ...db.manifests_v2 import ManifestModel
1920
from ...db.session import db_session_dependency
@@ -51,7 +52,13 @@ async def read_manifest_collection(
5152
)
5253
)
5354
try:
54-
nodes = await session.exec(select(Manifest).offset(offset).limit(limit))
55+
statement = (
56+
select(Manifest)
57+
.order_by(Manifest.metadata_["crtime"].desc().nulls_last())
58+
.offset(offset)
59+
.limit(limit)
60+
)
61+
nodes = await session.exec(statement)
5562
return nodes.all()
5663
except Exception as msg:
5764
logger.exception()
@@ -150,13 +157,16 @@ async def create_one_or_more_manifests(
150157
_previous = (await session.exec(s)).one_or_none()
151158
_version = _previous.version if _previous else manifest.metadata_.version
152159
_version += 1
160+
161+
_manifest_metadata = manifest.metadata_.model_dump()
162+
_manifest_metadata |= {"crtime": element_time()}
153163
_manifest = Manifest(
154164
id=uuid5(_namespace_uuid, f"{_name}.{_version}"),
155-
name=_name,
165+
name=_manifest_metadata.pop("name"),
156166
namespace=_namespace_uuid,
157167
kind=manifest.kind,
158168
version=_version,
159-
metadata_=manifest.metadata_.model_dump(),
169+
metadata_=_manifest_metadata,
160170
spec=manifest.spec.model_dump(),
161171
)
162172

@@ -240,6 +250,7 @@ async def update_manifest_resource(
240250
)
241251

242252
# create Manifest from new_manifest, add to session, and commit
253+
new_manifest["metadata"] |= {"crtime": element_time()}
243254
new_manifest_db = Manifest.model_validate(new_manifest)
244255
session.add(new_manifest_db)
245256
await session.commit()

src/lsst/cmservice/routers/v2/nodes.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from ...common.jsonpatch import JSONPatch, JSONPatchError, apply_json_patch
1616
from ...common.logging import LOGGER
17+
from ...common.timestamp import element_time
1718
from ...db.campaigns_v2 import Campaign, Node
1819
from ...db.manifests_v2 import NodeManifest
1920
from ...db.session import db_session_dependency
@@ -47,7 +48,10 @@ async def read_nodes_collection(
4748
For campaign-scoped nodes, one should use the /campaigns/{}/nodes route.
4849
"""
4950
try:
50-
nodes = await session.exec(select(Node).offset(offset).limit(limit))
51+
statement = (
52+
select(Node).order_by(Node.metadata_["crtime"].desc().nulls_last()).offset(offset).limit(limit)
53+
)
54+
nodes = await session.exec(statement)
5155
response.headers["Next"] = (
5256
request.url_for("read_nodes_collection")
5357
.include_query_params(offset=(offset + limit), limit=limit)
@@ -136,12 +140,15 @@ async def create_node_resource(
136140

137141
node_version = previous_node.version if previous_node else node_version
138142
node_version += 1
143+
node_metadata = manifest.metadata_.model_dump()
144+
node_metadata |= {"crtime": element_time()}
139145
node = Node(
140146
id=uuid5(node_namespace_uuid, f"{node_name}.{node_version}"),
141-
name=node_name,
147+
name=node_metadata.pop("name"),
142148
namespace=node_namespace_uuid,
143149
version=node_version,
144150
configuration=manifest.spec.model_dump(),
151+
metadata_=node_metadata,
145152
)
146153

147154
# Put the node in the database
@@ -228,6 +235,7 @@ async def update_node_resource(
228235
)
229236

230237
# create Manifest from new_manifest, add to session, and commit
238+
new_manifest["metadata"] |= {"crtime": element_time()}
231239
new_manifest_db = Node.model_validate(new_manifest)
232240
session.add(new_manifest_db)
233241
await session.commit()

tests/v2/test_manifest_routes.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,13 @@ async def test_load_manifests(aclient: AsyncClient) -> None:
131131
assert len(manifests) == 3
132132
assert manifests[-1]["spec"]["one"] == 1
133133

134+
# Get all the loaded manifests from the campaign route
135+
x = await aclient.get(f"/cm-service/v2/campaigns/{campaign_id}/manifests")
136+
assert x.is_success
137+
manifests = x.json()
138+
assert len(manifests) == 2
139+
assert manifests[-1]["spec"]["one"] == 1
140+
134141

135142
async def test_patch_manifest(aclient: AsyncClient) -> None:
136143
"""Tests partial update of manifests and single resource retrieval."""

0 commit comments

Comments
 (0)