33import logging
44from collections .abc import Callable
55
6- from fastapi import APIRouter , Depends , Request
6+ from fastapi import APIRouter , Depends , Query , Request
77from sqlalchemy .exc import SQLAlchemyError
88from sqlalchemy .ext .asyncio import AsyncSession
99
@@ -267,6 +267,8 @@ def _build_cache_key(filter_groups: list[dict]) -> str:
267267 """
268268 Build cache key from filter groups.
269269
270+ Groups are sorted by category so key is stable regardless of query param order.
271+
270272 Args:
271273 filter_groups: List of filter group dicts
272274
@@ -276,7 +278,8 @@ def _build_cache_key(filter_groups: list[dict]) -> str:
276278 if not filter_groups :
277279 return "filter:all"
278280
279- cache_parts = [f"{ g ['category' ]} ={ ',' .join (sorted (g ['values' ]))} " for g in filter_groups ]
281+ normalized = sorted (filter_groups , key = lambda g : g ["category" ])
282+ cache_parts = [f"{ g ['category' ]} ={ ',' .join (sorted (g ['values' ]))} " for g in normalized ]
280283 return f"filter:{ ':' .join (cache_parts )} "
281284
282285
@@ -370,7 +373,12 @@ def _filter_images(
370373
371374
372375@router .get ("/plots/filter" , response_model = FilteredPlotsResponse )
373- async def get_filtered_plots (request : Request , db : AsyncSession = Depends (require_db )):
376+ async def get_filtered_plots (
377+ request : Request ,
378+ db : AsyncSession = Depends (require_db ),
379+ limit : int | None = Query (None , ge = 1 ),
380+ offset : int = Query (0 , ge = 0 ),
381+ ):
374382 """
375383 Get filtered plot images with counts for all filter categories.
376384
@@ -398,55 +406,65 @@ async def get_filtered_plots(request: Request, db: AsyncSession = Depends(requir
398406 # Parse query parameters
399407 filter_groups = _parse_filter_groups (request )
400408
401- # Check cache
409+ # Check cache (cache stores unpaginated result; pagination applied after)
402410 cache_key = _build_cache_key (filter_groups )
411+ cached : FilteredPlotsResponse | None = None
403412 try :
404413 cached = get_cache (cache_key )
405- if cached :
406- return cached
407414 except Exception as e :
408- # Cache failures are non-fatal, log and continue
409415 logger .warning ("Cache read failed for key %s: %s" , cache_key , e )
410416
411- # Fetch data from database
412- try :
413- repo = SpecRepository (db )
414- all_specs = await repo .get_all ()
415- except SQLAlchemyError as e :
416- logger .error ("Database query failed in get_filtered_plots: %s" , e )
417- raise DatabaseQueryError ("fetch_specs" , str (e )) from e
418-
419- # Build data structures
420- spec_lookup = _build_spec_lookup (all_specs )
421- impl_lookup = _build_impl_lookup (all_specs )
422- all_images = _collect_all_images (all_specs )
423- spec_id_to_tags = {spec_id : spec_data ["tags" ] for spec_id , spec_data in spec_lookup .items ()}
424-
425- # Filter images
426- filtered_images = _filter_images (all_images , filter_groups , spec_lookup , impl_lookup )
427-
428- # Calculate counts
429- global_counts = _calculate_global_counts (all_specs )
430- counts = _calculate_contextual_counts (filtered_images , spec_id_to_tags , impl_lookup )
431- or_counts = _calculate_or_counts (filter_groups , all_images , spec_id_to_tags , spec_lookup , impl_lookup )
432-
433- # Build spec_id -> title mapping for search/tooltips
434- spec_titles = {spec_id : data ["spec" ].title for spec_id , data in spec_lookup .items () if data ["spec" ].title }
435-
436- # Build and cache response
437- result = FilteredPlotsResponse (
438- total = len (filtered_images ),
439- images = filtered_images ,
440- counts = counts ,
441- globalCounts = global_counts ,
442- orCounts = or_counts ,
443- specTitles = spec_titles ,
417+ if cached is None :
418+ # Fetch data from database
419+ try :
420+ repo = SpecRepository (db )
421+ all_specs = await repo .get_all ()
422+ except SQLAlchemyError as e :
423+ logger .error ("Database query failed in get_filtered_plots: %s" , e )
424+ raise DatabaseQueryError ("fetch_specs" , str (e )) from e
425+
426+ # Build data structures
427+ spec_lookup = _build_spec_lookup (all_specs )
428+ impl_lookup = _build_impl_lookup (all_specs )
429+ all_images = _collect_all_images (all_specs )
430+ spec_id_to_tags = {spec_id : spec_data ["tags" ] for spec_id , spec_data in spec_lookup .items ()}
431+
432+ # Filter images
433+ filtered_images = _filter_images (all_images , filter_groups , spec_lookup , impl_lookup )
434+
435+ # Calculate counts (always from ALL filtered images, not paginated)
436+ global_counts = _calculate_global_counts (all_specs )
437+ counts = _calculate_contextual_counts (filtered_images , spec_id_to_tags , impl_lookup )
438+ or_counts = _calculate_or_counts (filter_groups , all_images , spec_id_to_tags , spec_lookup , impl_lookup )
439+
440+ # Build spec_id -> title mapping for search/tooltips
441+ spec_titles = {spec_id : data ["spec" ].title for spec_id , data in spec_lookup .items () if data ["spec" ].title }
442+
443+ # Cache the full (unpaginated) result
444+ cached = FilteredPlotsResponse (
445+ total = len (filtered_images ),
446+ images = filtered_images ,
447+ counts = counts ,
448+ globalCounts = global_counts ,
449+ orCounts = or_counts ,
450+ specTitles = spec_titles ,
451+ )
452+
453+ try :
454+ set_cache (cache_key , cached )
455+ except Exception as e :
456+ logger .warning ("Cache write failed for key %s: %s" , cache_key , e )
457+
458+ # Apply pagination on top of (possibly cached) result
459+ paginated = cached .images [offset : offset + limit ] if limit else cached .images [offset :]
460+
461+ return FilteredPlotsResponse (
462+ total = cached .total ,
463+ images = paginated ,
464+ counts = cached .counts ,
465+ globalCounts = cached .globalCounts ,
466+ orCounts = cached .orCounts ,
467+ specTitles = cached .specTitles ,
468+ offset = offset ,
469+ limit = limit ,
444470 )
445-
446- try :
447- set_cache (cache_key , result )
448- except Exception as e :
449- # Cache failures are non-fatal, log and continue
450- logger .warning ("Cache write failed for key %s: %s" , cache_key , e )
451-
452- return result
0 commit comments