Skip to content
Open
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
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.env.r2.local
.git
.gitignore
.env
Expand Down Expand Up @@ -39,3 +40,5 @@ backend/data/fixtures/*
backend/data/gazetteers/*
backend/data/opengeometadata/*
backend/data/harvest_dumps/*

.kamal/
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.env.r2.local
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
135 changes: 129 additions & 6 deletions backend/app/api/v1/endpoint_modules/static_maps.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import asyncio
import io
import logging
import os
from typing import Literal

from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import Response
from PIL import Image
from sqlalchemy.sql import select
Expand All @@ -15,6 +17,7 @@
weak_etag_from_body,
)
from app.services.static_map_service import StaticMapService
from app.services.visual_asset_cache import get_visual_asset_public_url
from db.models import resources

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -203,15 +206,29 @@ async def _get_or_generate_map_data(
raise HTTPException(status_code=400, detail="Unsupported static map variant")


@router.get("/static-maps/institutions/{map_id}/native", response_class=Response)
@router.get("/static-maps/institutions/{map_id}", response_class=Response)
async def get_institution_static_map(
map_id: str,
request: Request,
lat: float,
lon: float,
zoom: int = 15,
lat: float = Query(..., ge=-85, le=85),
lon: float = Query(..., ge=-180, le=180),
zoom: int = Query(15, ge=1, le=20),
renderer: str | None = None,
):
"""Serve or generate a cached campus-centered static basemap image."""
from app.services.native_static_maps import renderer_url

if request.url.path.endswith("/native"):
renderer = "maplibre"
if renderer not in (None, "maplibre"):
raise HTTPException(status_code=422, detail="Unknown renderer")
if renderer == "maplibre" and not renderer_url():
raise HTTPException(
status_code=503,
detail="Native renderer is not configured",
headers={"Cache-Control": "no-store", "Retry-After": "30"},
)
cache_id = f"institution:{map_id}"
try:
map_service = StaticMapService(map_width=640, map_height=320)
Expand All @@ -220,10 +237,50 @@ async def get_institution_static_map(
longitude=lon,
zoom=zoom,
)
digest = await map_service.get_asset_hash(
cache_id,
variant=map_service.basemap_variant(),
source_signature=source_signature,
hydrate_asset=False,
)
if digest:
url = await asyncio.to_thread(get_visual_asset_public_url, digest)
if url:
return Response(
status_code=302,
headers={"Location": url, "Cache-Control": "public, max-age=300"},
)
durable_data = await map_service.get_cached_asset(digest)
if durable_data:
return _validated_static_asset_response(
durable_data,
request,
cache_control="public, max-age=3600, stale-while-revalidate=86400",
)
map_data = await map_service.get_cached_basemap(
cache_id,
source_signature=source_signature,
)
if not map_data and renderer_url():
from app.tasks.worker import celery_app

lock_key = f"native-campus-pending:{map_id}:{source_signature}"
acquired = await asyncio.to_thread(
map_service.map_cache.set, lock_key, "pending", nx=True, ex=300
)
if acquired:
try:
await asyncio.to_thread(
celery_app.send_task,
"generate_campus_static_map",
args=[map_id, lat, lon, zoom, lock_key],
)
except Exception:
await asyncio.to_thread(map_service.map_cache.delete, lock_key)
raise
return Response(
status_code=503, headers={"Cache-Control": "no-store", "Retry-After": "2"}
)
if not map_data:
map_data = map_service.generate_centered_basemap(
cache_id,
Expand All @@ -239,12 +296,19 @@ async def get_institution_static_map(
if not map_data:
raise HTTPException(status_code=404, detail="Institution static map not found")

return _validated_static_asset_response(map_data, request)
return _validated_static_asset_response(
map_data, request, cache_control="public, max-age=3600, stale-while-revalidate=86400"
)


@router.get("/static-map-assets/{map_hash}", response_class=Response)
async def get_static_map_asset(map_hash: str, request: Request):
"""Serve a hash-addressed immutable static map or icon asset directly from Redis."""
"""Serve a durable static map, preferring its verified CDN object."""
url = await asyncio.to_thread(get_visual_asset_public_url, map_hash)
if url:
return Response(
status_code=302, headers={"Location": url, "Cache-Control": "public, max-age=300"}
)
map_service = StaticMapService()
map_data = await map_service.get_cached_asset(map_hash)
if not map_data:
Expand Down Expand Up @@ -401,3 +465,62 @@ async def get_static_map(resource_id: str, request: Request):
raise HTTPException(status_code=500, detail="Failed to retrieve static map") from e

return _validated_static_asset_response(map_data, request)


@router.get("/static-maps/{resource_id}/geometry/native", response_class=Response)
async def get_native_resource_geometry(
resource_id: str, request: Request, size: Literal["full", "thumbnail"] = "full"
):
"""Versioned native geography; never reuse legacy CARTO hashes or aliases."""
from app.services.native_static_maps import renderer_url
from app.tasks.worker import celery_app

if not renderer_url():
raise HTTPException(
status_code=503,
detail="Native renderer is not configured",
headers={"Cache-Control": "no-store", "Retry-After": "30"},
)
resource = await _fetch_resource_dict(resource_id)
if not resource:
raise HTTPException(status_code=404, detail="Resource not found")
service = StaticMapService(384, 288) if size == "thumbnail" else StaticMapService()
geometry = _resource_geometry(resource)
signature = service.geometry_signature(geometry)
digest = await service.get_asset_hash(
resource_id,
variant=service.geometry_variant(),
source_signature=signature,
hydrate_asset=False,
)
if digest:
url = await asyncio.to_thread(get_visual_asset_public_url, digest)
if url:
return Response(
status_code=302, headers={"Location": url, "Cache-Control": "public, max-age=300"}
)
data = await service.get_cached_asset(digest)
if data:
return _validated_static_asset_response(
data, request, cache_control="public, max-age=3600, stale-while-revalidate=86400"
)
from app.services.native_static_maps import reuse_geometry

data = await asyncio.to_thread(reuse_geometry, service, resource_id, geometry)
if data:
return _validated_static_asset_response(
data, request, cache_control="public, max-age=3600, stale-while-revalidate=86400"
)
lock_key = f"native-resource-pending:{resource_id}:{signature}"
acquired = await asyncio.to_thread(service.map_cache.set, lock_key, "pending", nx=True, ex=600)
if acquired:
try:
await asyncio.to_thread(
celery_app.send_task,
"generate_native_resource_map",
args=[resource_id, geometry, lock_key, service.map_width, service.map_height],
)
except Exception:
await asyncio.to_thread(service.map_cache.delete, lock_key)
raise
return Response(status_code=503, headers={"Cache-Control": "no-store", "Retry-After": "2"})
10 changes: 10 additions & 0 deletions backend/app/api/v1/endpoint_modules/thumbnails.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import io
import logging
import os
Expand All @@ -15,6 +16,7 @@
)
from app.services.image_service import ImageService
from app.services.thumbnail_alias_service import is_thumbnail_hash
from app.services.visual_asset_cache import get_visual_asset_public_url

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -155,6 +157,14 @@ async def get_thumbnail(resource_id: str, request: Request):
if redirect is not None:
return redirect

if is_thumbnail_hash(resource_id):
url = await asyncio.to_thread(get_visual_asset_public_url, resource_id)
if url:
# Legacy thumbnail hashes describe sources, so keep the mapping redirect short-lived.
return Response(
status_code=302, headers={"Location": url, "Cache-Control": "public, max-age=300"}
)

try:
# Create service without resource (we only need cache access)
image_service = ImageService({})
Expand Down
27 changes: 16 additions & 11 deletions backend/app/services/image_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@
safe_record_thumbnail_state_sync,
thumbnail_state_service,
)
from app.services.visual_asset_cache import cache_visual_asset, get_durable_visual_asset
from app.services.visual_asset_cache import (
cache_visual_asset,
get_durable_visual_asset,
has_durable_visual_asset,
)

# Load environment variables from .env file
try:
Expand Down Expand Up @@ -793,7 +797,7 @@ def has_cached_image_sync(self, image_hash: str) -> bool:
return True
except Exception as e:
self.logger.error(f"Error checking cached image: {e}")
return get_durable_visual_asset(image_hash) is not None
return has_durable_visual_asset(image_hash)

def _candidate_cached_thumbnail_hash_sync(self, source_url: str) -> Optional[str]:
"""Return the immutable thumbnail hash for a cached source URL, if known."""
Expand Down Expand Up @@ -1142,14 +1146,15 @@ async def get_cached_image(self, image_hash: str) -> Optional[bytes]:
durable = await asyncio.to_thread(get_durable_visual_asset, image_hash)
if durable:
image_bytes, content_type = durable
await asyncio.to_thread(cache_visual_asset, self.image_cache, image_key, image_bytes)
await asyncio.to_thread(
cache_visual_asset,
self.image_cache,
f"image_type:{image_hash}",
content_type,
)
self.logger.debug(f"Rehydrated cached image {image_hash} from durable store")
try:
await asyncio.to_thread(
cache_visual_asset, self.image_cache, image_key, image_bytes
)
await asyncio.to_thread(
cache_visual_asset, self.image_cache, f"image_type:{image_hash}", content_type
)
except Exception as exc:
self.logger.warning("Optional Redis image hydration failed: %s", type(exc).__name__)
return image_bytes
return None

Expand All @@ -1162,7 +1167,7 @@ async def has_cached_image(self, image_hash: str) -> bool:
return True
except Exception as e:
self.logger.error(f"Error checking cached image: {e}")
return await asyncio.to_thread(get_durable_visual_asset, image_hash) is not None
return await asyncio.to_thread(has_durable_visual_asset, image_hash)

def is_pmtiles_skip_cached(self, image_hash: str) -> bool:
"""
Expand Down
Loading
Loading