Skip to content

Commit 5990995

Browse files
committed
Add lean site summaries for the Sites page
1 parent a1da6c6 commit 5990995

File tree

5 files changed

+178
-0
lines changed

5 files changed

+178
-0
lines changed

domains/sta/services/thing.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import uuid
2+
from collections import defaultdict
23
from typing import Optional, Literal, List, get_args
34
from ninja.errors import HttpError
45
from django.http import HttpResponse
@@ -22,6 +23,7 @@
2223
FileAttachmentType,
2324
)
2425
from interfaces.api.schemas import (
26+
TagGetResponse,
2527
ThingSummaryResponse,
2628
ThingDetailResponse,
2729
ThingPostBody,
@@ -226,6 +228,67 @@ def get_marker_values(queryset: QuerySet):
226228
"longitude_value",
227229
)
228230

231+
@classmethod
232+
def get_site_summary_values(cls, queryset: QuerySet):
233+
return queryset.annotate(
234+
latitude_value=Cast("latitude", FloatField()),
235+
longitude_value=Cast("longitude", FloatField()),
236+
).values(
237+
"thing_id",
238+
"thing__workspace_id",
239+
"thing__name",
240+
"thing__sampling_feature_code",
241+
"thing__site_type",
242+
"thing__is_private",
243+
"latitude_value",
244+
"longitude_value",
245+
)
246+
247+
@staticmethod
248+
def get_tags_by_thing_id(
249+
principal: Optional[User | APIKey],
250+
thing_ids: list[uuid.UUID],
251+
) -> dict[str, list[TagGetResponse]]:
252+
if not thing_ids:
253+
return {}
254+
255+
tags_by_thing_id: dict[str, list[TagGetResponse]] = defaultdict(list)
256+
tag_rows = (
257+
ThingTag.objects.visible(principal=principal)
258+
.filter(thing_id__in=thing_ids)
259+
.values("thing_id", "key", "value")
260+
.order_by("thing_id", "key", "value")
261+
.distinct()
262+
)
263+
for tag in tag_rows:
264+
tags_by_thing_id[str(tag["thing_id"])].append(
265+
{
266+
"key": tag["key"],
267+
"value": tag["value"],
268+
}
269+
)
270+
return tags_by_thing_id
271+
272+
@staticmethod
273+
def serialize_site_summary_rows(
274+
site_rows,
275+
tags_by_thing_id: dict[str, list[TagGetResponse]],
276+
) -> list[dict]:
277+
return [
278+
{
279+
"id": str(site["thing_id"]),
280+
"workspace_id": str(site["thing__workspace_id"]),
281+
"name": site["thing__name"],
282+
"sampling_feature_code": site["thing__sampling_feature_code"],
283+
"site_type": site["thing__site_type"],
284+
"is_private": site["thing__is_private"],
285+
"latitude": site["latitude_value"],
286+
"longitude": site["longitude_value"],
287+
"tags": tags_by_thing_id.get(str(site["thing_id"]), []),
288+
}
289+
for site in site_rows
290+
]
291+
229292
@classmethod
230293
def filter_cached_markers(
231294
cls, markers: list[dict], filtering: Optional[dict] = None
@@ -310,6 +373,24 @@ def list_markers(
310373
markers.sort(key=lambda marker: marker["id"])
311374
return markers
312375

376+
def list_site_summaries(
377+
self,
378+
principal: Optional[User | APIKey],
379+
filtering: Optional[dict] = None,
380+
) -> list[dict]:
381+
site_queryset = Location.objects.visible(principal=principal)
382+
site_queryset = self.apply_marker_filters(site_queryset, filtering=filtering)
383+
site_rows = list(
384+
self.get_site_summary_values(
385+
site_queryset.order_by("thing_id").distinct()
386+
)
387+
)
388+
tags_by_thing_id = self.get_tags_by_thing_id(
389+
principal=principal,
390+
thing_ids=[site["thing_id"] for site in site_rows],
391+
)
392+
return self.serialize_site_summary_rows(site_rows, tags_by_thing_id)
393+
313394
def list(
314395
self,
315396
principal: Optional[User | APIKey],

interfaces/api/schemas/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
from .thing import (
3030
ThingMarkerResponse,
3131
ThingMarkerQueryParameters,
32+
ThingSiteSummaryResponse,
33+
ThingSiteSummaryQueryParameters,
3234
ThingSummaryResponse,
3335
ThingDetailResponse,
3436
ThingPostBody,

interfaces/api/schemas/thing.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,25 @@ class ThingMarkerResponse(BaseGetResponse):
157157
longitude: float
158158

159159

160+
class ThingSiteSummaryQueryParameters(BaseQueryParameters):
161+
workspace_id: list[uuid.UUID] = Query(
162+
[], description="Filter site summaries by workspace ID."
163+
)
164+
site_type: list[str] = Query([], description="Filter site summaries by site type.")
165+
166+
167+
class ThingSiteSummaryResponse(BaseGetResponse):
168+
id: uuid.UUID
169+
workspace_id: uuid.UUID
170+
name: str = Field(..., max_length=200)
171+
sampling_feature_code: str = Field(..., max_length=200)
172+
site_type: str = Field(..., max_length=200)
173+
is_private: bool
174+
latitude: float
175+
longitude: float
176+
tags: list[TagGetResponse]
177+
178+
160179
class ThingSummaryResponse(BaseGetResponse, ThingFields):
161180
id: uuid.UUID
162181
workspace_id: uuid.UUID

interfaces/api/views/thing.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from interfaces.api.schemas import (
1111
ThingMarkerResponse,
1212
ThingMarkerQueryParameters,
13+
ThingSiteSummaryResponse,
14+
ThingSiteSummaryQueryParameters,
1315
ThingSummaryResponse,
1416
ThingDetailResponse,
1517
ThingPostBody,
@@ -81,6 +83,29 @@ def get_thing_markers(
8183
)
8284

8385

86+
@thing_router.get(
87+
"/site-summaries",
88+
auth=[session_auth, bearer_auth, apikey_auth, anonymous_auth],
89+
response={
90+
200: list[ThingSiteSummaryResponse],
91+
401: str,
92+
},
93+
by_alias=True,
94+
)
95+
def get_thing_site_summaries(
96+
request: HydroServerHttpRequest,
97+
query: Query[ThingSiteSummaryQueryParameters],
98+
):
99+
"""
100+
Get lean site summary data for public Things and Things associated with the authenticated user.
101+
"""
102+
103+
return 200, thing_service.list_site_summaries(
104+
principal=request.principal,
105+
filtering=query.dict(exclude_unset=True),
106+
)
107+
108+
84109
@thing_router.post(
85110
"",
86111
auth=[session_auth, bearer_auth, apikey_auth],

tests/sta/services/test_thing.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,57 @@ def test_list_thing_markers_invalidates_public_cache_on_update():
185185
assert [marker["name"] for marker in markers] == ["Updated Public Thing"]
186186

187187

188+
def test_list_thing_site_summaries_returns_lean_payload_with_tags():
189+
site_summaries = thing_service.list_site_summaries(principal=None)
190+
191+
assert site_summaries == [
192+
{
193+
"id": "3b7818af-eff7-4149-8517-e5cad9dc22e1",
194+
"workspace_id": "6e0deaf2-a92b-421b-9ece-86783265596f",
195+
"name": "Public Thing",
196+
"sampling_feature_code": "UWRL",
197+
"site_type": "Public",
198+
"is_private": False,
199+
"latitude": 41.739742,
200+
"longitude": -111.793766,
201+
"tags": [
202+
{
203+
"key": "Test Public Key",
204+
"value": "Test Public Value",
205+
}
206+
],
207+
}
208+
]
209+
210+
211+
def test_list_thing_site_summaries_filters_by_workspace(get_principal):
212+
site_summaries = thing_service.list_site_summaries(
213+
principal=get_principal("owner"),
214+
filtering={"workspace_id": ["6e0deaf2-a92b-421b-9ece-86783265596f"]},
215+
)
216+
217+
assert Counter(site["name"] for site in site_summaries) == Counter(
218+
[
219+
"Public Thing",
220+
"Private Thing Public Workspace",
221+
]
222+
)
223+
assert all(
224+
set(site) == {
225+
"id",
226+
"workspace_id",
227+
"name",
228+
"sampling_feature_code",
229+
"site_type",
230+
"is_private",
231+
"latitude",
232+
"longitude",
233+
"tags",
234+
}
235+
for site in site_summaries
236+
)
237+
238+
188239
@pytest.mark.parametrize(
189240
"principal, thing, message, error_code",
190241
[

0 commit comments

Comments
 (0)