Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 47 additions & 17 deletions backend/app/api/v1/endpoint_modules/resources/static_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from sqlalchemy.sql import select

from app.services.cache_service import alias_redirect_cache_control_header
from app.services.distribution_repository import fetch_distribution_context
from app.services.static_map_service import StaticMapService
from db.models import resources

Expand Down Expand Up @@ -40,6 +41,31 @@ async def get_resource_static_map(
"""Compatibility route for the geometry-overlay static map asset."""
try:
map_service = StaticMapService()
async with async_session() as session:
query = select(
resources.c.id,
resources.c.locn_geometry,
resources.c.dcat_bbox,
resources.c.dct_references_s,
).where(resources.c.id == id)
result = await session.execute(query)
row = result.fetchone()

if not row:
return _svg_placeholder(title="Map unavailable", subtitle="Resource not found")

resource_dict = dict(row._mapping)
distribution_context = await fetch_distribution_context(id)
if external_static_map_url := map_service.external_static_map_url(
resource_dict,
distribution_context=distribution_context,
):
return RedirectResponse(
url=external_static_map_url,
status_code=302,
headers={"Cache-Control": "no-store"},
)

hot_map_hash = await map_service.materialize_cached_variant(
id,
variant=map_service.geometry_variant(),
Expand All @@ -54,18 +80,8 @@ async def get_resource_static_map(
},
)

async with async_session() as session:
query = select(resources.c.id, resources.c.locn_geometry, resources.c.dcat_bbox).where(
resources.c.id == id
)
result = await session.execute(query)
row = result.fetchone()

if not row:
return _svg_placeholder(title="Map unavailable", subtitle="Resource not found")

resolved_id = str(row._mapping["id"])
geometry = row._mapping.get("locn_geometry") or row._mapping.get("dcat_bbox")
resolved_id = str(resource_dict["id"])
geometry = resource_dict.get("locn_geometry") or resource_dict.get("dcat_bbox")
source_signature = map_service.geometry_signature(geometry)
current_map_hash = await map_service.materialize_cached_variant(
id,
Expand Down Expand Up @@ -122,20 +138,34 @@ async def get_resource_static_map_no_cache(
try:
# Fetch geometry directly
async with async_session() as session:
query = select(resources.c.id, resources.c.locn_geometry, resources.c.dcat_bbox).where(
resources.c.id == id
)
query = select(
resources.c.id,
resources.c.locn_geometry,
resources.c.dcat_bbox,
resources.c.dct_references_s,
).where(resources.c.id == id)
result = await session.execute(query)
row = result.fetchone()

if not row:
return _svg_placeholder(title="Map unavailable", subtitle="Resource not found")

resource_dict = dict(row._mapping)
geometry = resource_dict.get("locn_geometry") or resource_dict.get("dcat_bbox")

# Generate map synchronously and update cache (geometry or global)
map_service = StaticMapService()
distribution_context = await fetch_distribution_context(id)
if external_static_map_url := map_service.external_static_map_url(
resource_dict,
distribution_context=distribution_context,
):
return RedirectResponse(
url=external_static_map_url,
status_code=302,
headers={"Cache-Control": "no-store"},
)

# Generate map synchronously and update cache (geometry or global)
geometry = resource_dict.get("locn_geometry") or resource_dict.get("dcat_bbox")
source_signature = map_service.geometry_signature(geometry)
if not geometry:
map_bytes = map_service.generate_global_map(id, source_signature=source_signature)
Expand Down
38 changes: 28 additions & 10 deletions backend/app/api/v1/presenters/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,11 @@ async def present_full(
allow_resource_fallback=True,
)
self._attach_allmaps(resource, allmaps_attributes)
self._attach_static_map(resource, resource_dict)
self._attach_static_map(
resource,
resource_dict,
distribution_context=distribution_context,
)

if include_similar_items:
resource = await api_utils.add_similar_items_to_resource(
Expand Down Expand Up @@ -391,7 +395,11 @@ async def present_search_result(
allow_resource_fallback=not hot_only_thumbnail_url,
)
self._attach_allmaps(resource, allmaps_attributes)
self._attach_static_map(resource, resource_dict)
self._attach_static_map(
resource,
resource_dict,
distribution_context=distribution_context,
)

return resource

Expand Down Expand Up @@ -511,15 +519,25 @@ def _attach_allmaps(
resource["meta"].setdefault("ui", {})
resource["meta"]["ui"]["allmaps"] = allmaps_attributes

def _attach_static_map(self, resource: dict[str, Any], resource_dict: dict[str, Any]) -> None:
def _attach_static_map(
self,
resource: dict[str, Any],
resource_dict: dict[str, Any],
*,
distribution_context: DistributionContext | None = None,
) -> None:
api_utils = _api_utils()
geometry = resource_dict.get("locn_geometry") or resource_dict.get("dcat_bbox")
if not geometry:
return

static_map_url = api_utils._hot_static_map_url(
resource_dict
) or api_utils._build_static_map_url(resource_dict["id"])
static_map_url = api_utils._reference_static_map_url(
resource_dict,
distribution_context=distribution_context,
)
if not static_map_url:
geometry = resource_dict.get("locn_geometry") or resource_dict.get("dcat_bbox")
if not geometry:
return
static_map_url = api_utils._hot_static_map_url(
resource_dict
) or api_utils._build_static_map_url(resource_dict["id"])

resource.setdefault("meta", {})
resource["meta"].setdefault("ui", {})
Expand Down
21 changes: 21 additions & 0 deletions backend/app/api/v1/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,27 @@ def _build_static_map_asset_url(map_hash: str, *, kind: str | None = None) -> st
return f"{asset_url}?kind={kind}" if kind else asset_url


def _reference_static_map_url(
resource_dict: Dict[str, Any],
*,
distribution_context: DistributionContext | None = None,
) -> Optional[str]:
try:
from app.services.static_map_service import StaticMapService

return StaticMapService().external_static_map_url(
resource_dict,
distribution_context=distribution_context,
)
except Exception as exc:
logger.debug(
"Failed resolving reference static map for %s: %s",
resource_dict.get("id"),
exc,
)
return None


def _hot_static_map_url(resource_dict: Dict[str, Any]) -> Optional[str]:
geometry = resource_dict.get("locn_geometry") or resource_dict.get("dcat_bbox")
if not geometry:
Expand Down
6 changes: 6 additions & 0 deletions backend/app/services/image_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,9 @@ def _first_url(self, uri: str, references: Optional[Dict[str, Any]] = None) -> O
records = self.by_uri.get(uri, [])
if records:
return records[0].url
legacy_references = self._parse_legacy_references()
if legacy_references:
return self._first_url(uri, references=legacy_references)
else:
# If explicit references are provided, consult those first
val = references.get(uri)
Expand Down Expand Up @@ -923,6 +926,9 @@ def _all_reference_urls(self, references: Optional[Dict[str, Any]] = None) -> Li
for records in self.by_uri.values():
for record in records:
urls.append(record.url)
legacy_references = self._parse_legacy_references()
if legacy_references:
urls.extend(self._all_reference_urls(references=legacy_references))
return urls

def _queue_thumbnail_processing(self, thumbnail_url: str, doc_id: str) -> None:
Expand Down
79 changes: 79 additions & 0 deletions backend/app/services/static_map_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,85 @@ def centered_basemap_signature(self, *, latitude: float, longitude: float, zoom:
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()

def external_static_map_url(
self,
resource_dict: Dict[str, Any] | None,
*,
distribution_context: Any | None = None,
) -> Optional[str]:
"""
Return an explicit schema.org hasMap URL.

Structured distribution rows are authoritative. Legacy dct_references_s
is only a fallback for resources that do not have a distribution-derived
hasMap URL.
"""
for key in ("http://schema.org/hasMap", "https://schema.org/hasMap"):
if url := self._first_distribution_url(distribution_context, key):
return url

references = self._parse_reference_payload(resource_dict)
if not references:
return None

for key in ("http://schema.org/hasMap", "https://schema.org/hasMap"):
if url := self._first_reference_url(references, key):
return url
return None

def _parse_reference_payload(
self, resource_dict: Dict[str, Any] | None
) -> Optional[Dict[str, Any]]:
if not resource_dict:
return None
raw = resource_dict.get("dct_references_s")
if not raw:
return None
if isinstance(raw, dict):
references = raw
elif isinstance(raw, str):
try:
references = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return None
else:
return None
return references if isinstance(references, dict) else None

def _first_reference_url(self, references: Dict[str, Any], uri: str) -> Optional[str]:
value = references.get(uri)
return self._clean_external_static_map_url(self._reference_url_value(value))

def _first_distribution_url(self, distribution_context: Any | None, uri: str) -> Optional[str]:
if not distribution_context:
return None
records_by_uri = getattr(distribution_context, "by_uri", {}) or {}
records = records_by_uri.get(uri, [])
for record in records:
if url := self._clean_external_static_map_url(getattr(record, "url", None)):
return url
return None

def _reference_url_value(self, value: Any) -> Optional[str]:
if isinstance(value, str):
return value
if isinstance(value, dict):
candidate = value.get("url") or value.get("@id") or value.get("id")
return candidate if isinstance(candidate, str) else None
if isinstance(value, list):
for item in value:
if candidate := self._reference_url_value(item):
return candidate
return None

def _clean_external_static_map_url(self, url: Optional[str]) -> Optional[str]:
if not isinstance(url, str):
return None
cleaned = url.strip()
if cleaned.startswith(("http://", "https://")):
return cleaned
return None

def get_asset_hash_sync(
self,
resource_id: str,
Expand Down
45 changes: 45 additions & 0 deletions backend/tests/api/v1/test_resource_presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,51 @@ def _thumbnail_url(item, distribution_context=None, hot_only=False):
return {**item, "ui_thumbnail_url": "https://images.example.edu/res-1-thumb.jpg"}


def test_resource_presenter_static_map_uses_schema_has_map_without_geometry():
presenter = ResourcePresenter(session=None)
external_map_url = "https://maps.example.edu/static/res-1.png"
resource = {}

presenter._attach_static_map(
resource,
{
"id": "res-1",
"dct_references_s": f'{{"http://schema.org/hasMap": "{external_map_url}"}}',
"locn_geometry": None,
"dcat_bbox": None,
},
)

assert resource["meta"]["ui"]["static_map"] == external_map_url


def test_resource_presenter_static_map_prefers_distribution_has_map_over_legacy_reference():
presenter = ResourcePresenter(session=None)
distribution_map_url = "https://maps.example.edu/static/distribution.png"
legacy_map_url = "https://maps.example.edu/static/legacy.png"
distribution_context = SimpleNamespace(
by_uri={
"http://schema.org/hasMap": [
SimpleNamespace(url=distribution_map_url),
]
}
)
resource = {}

presenter._attach_static_map(
resource,
{
"id": "res-1",
"dct_references_s": f'{{"http://schema.org/hasMap": "{legacy_map_url}"}}',
"locn_geometry": None,
"dcat_bbox": None,
},
distribution_context=distribution_context,
)

assert resource["meta"]["ui"]["static_map"] == distribution_map_url


@pytest.mark.asyncio
async def test_resource_presenter_full_profile_contract_snapshot():
presenter = ResourcePresenter(session=None)
Expand Down
Loading
Loading