diff --git a/app/crud/augmented_part.py b/app/crud/augmented_part.py index 692f9a3..7fce239 100644 --- a/app/crud/augmented_part.py +++ b/app/crud/augmented_part.py @@ -31,20 +31,6 @@ async def list_all( return list(result.scalars().all()) -async def list_by_part_type( - db: AsyncSession, part_type_id: int, skip: int = 0, limit: int = 1000 -) -> list[AugmentedPart]: - """All augmented parts of a given part type, ordered by description.""" - result = await db.execute( - select(AugmentedPart) - .where(AugmentedPart.part_type_id == part_type_id) - .order_by(AugmentedPart.description) - .offset(skip) - .limit(limit) - ) - return list(result.scalars().all()) - - async def get_many(db: AsyncSession, ids: list[int]) -> list[AugmentedPart]: """Fetch augmented parts by id in one query (selectin relationships loaded).""" if not ids: diff --git a/app/services/scope_guide.py b/app/services/scope_guide.py index 45b2615..a3f7d87 100644 --- a/app/services/scope_guide.py +++ b/app/services/scope_guide.py @@ -19,9 +19,22 @@ SCOPE_TYPE_NAME = "Scope" _LABEL_RANK = {FitLabel.EXACT.value: 2, FitLabel.POSSIBLE.value: 1} -async def build(db: AsyncSession) -> dict: +async def build( + db: AsyncSession, + *, + description: str = "", + manufacturer_id: int | None = None, + scope_model_id: int | None = None, + sub_part_type_id: int | None = None, + fits_part_type_id: int | None = None, +) -> dict: """Return the context for ``scopes.html``. + The scope rows are narrowed by the scope-attribute filters (``description`` + matches the scope's description; the ``*_id`` filters match exactly). + ``fits_part_type_id`` keeps only scopes that have at least one (non-``none``) + fit for that part type. + Shape:: { @@ -47,7 +60,15 @@ async def build(db: AsyncSession) -> dict: if scope_type is None: return {"column_types": column_types, "rows": []} - scopes = await augmented_part_crud.list_by_part_type(db, scope_type.id) + scopes = await augmented_part_crud.list_filtered( + db, + description=description, + manufacturer_id=manufacturer_id, + scope_model_id=scope_model_id, + sub_part_type_id=sub_part_type_id, + part_type_id=scope_type.id, + limit=1000, + ) scope_ids = {s.id for s in scopes} if not scope_ids: return {"column_types": column_types, "rows": []} @@ -97,6 +118,8 @@ async def build(db: AsyncSession) -> dict: ) for type_id, entries in cell_map[scope.id].items() } + if fits_part_type_id is not None and fits_part_type_id not in cells: + continue # scope has no fit for the required accessory type rows.append( {"scope": scope, "info": infos.get(scope.id), "cells": cells} ) diff --git a/app/templates/_scopes_table_card.html b/app/templates/_scopes_table_card.html new file mode 100644 index 0000000..fb866d1 --- /dev/null +++ b/app/templates/_scopes_table_card.html @@ -0,0 +1,71 @@ +{%- macro mag(v) -%}{{ v | int if v == v | int else v | round(1) }}{%- endmacro -%} +{%- macro magnification(info) -%} + {%- if info and info.magnification_min is not none and info.magnification_max is not none -%} + {{ mag(info.magnification_min) }}–{{ mag(info.magnification_max) }}× + {%- elif info and info.magnification_min is not none -%} + {{ mag(info.magnification_min) }}× + {%- elif info and info.magnification_max is not none -%} + {{ mag(info.magnification_max) }}× + {%- else -%} + — + {%- endif -%} +{%- endmacro -%} +
+ + + + + + + + + + {% for ct in column_types %} + + {% endfor %} + + + + {% for row in rows %} + {% set scope = row.scope %} + {% set info = row.info %} + + + + + + + + {% for ct in column_types %} + {% set entries = row.cells.get(ct.id) %} + + {% endfor %} + + {% else %} + + + + {% endfor %} + +
ScopeManufacturerModelTypeMagnificationObjective thread{{ ct.name }}
+ {{ scope.description }} + {% if scope.sku %}
{{ scope.sku }}{% endif %} +
{{ scope.manufacturer.name if scope.manufacturer else "—" }}{{ scope.scope_model.name if scope.scope_model else "—" }}{{ scope.sub_part_type.name if scope.sub_part_type else "—" }}{{ magnification(info) }} + {% if info and info.objective_thread_pitch %} + {{ info.objective_thread_pitch }} + {% else %}—{% endif %} + + {% if entries %} + {% for e in entries %} +
+ {{ e.label | capitalize }} + {{ e.part.description | truncate(48) }} +
+ {% endfor %} + {% else %} + — + {% endif %} +
+ No scopes match your search. +
+
diff --git a/app/templates/scopes.html b/app/templates/scopes.html index 85658dc..58ab65c 100644 --- a/app/templates/scopes.html +++ b/app/templates/scopes.html @@ -10,77 +10,40 @@ is a confirmed fit, Possible a candidate.

- {%- macro mag(v) -%}{{ v | int if v == v | int else v | round(1) }}{%- endmacro -%} - {% macro magnification(info) -%} - {%- if info and info.magnification_min is not none and info.magnification_max is not none -%} - {{ mag(info.magnification_min) }}–{{ mag(info.magnification_max) }}× - {%- elif info and info.magnification_min is not none -%} - {{ mag(info.magnification_min) }}× - {%- elif info and info.magnification_max is not none -%} - {{ mag(info.magnification_max) }}× - {%- else -%} - — - {%- endif -%} - {%- endmacro %} - -
- - - - - - - - - - {% for ct in column_types %} - - {% endfor %} - - - - {% for row in rows %} - {% set scope = row.scope %} - {% set info = row.info %} - - - - - - - - {% for ct in column_types %} - {% set entries = row.cells.get(ct.id) %} - - {% endfor %} - - {% else %} - - - +
+
+ +
-
ScopeManufacturerModelTypeMagnificationObjective thread{{ ct.name }}
- {{ scope.description }} - {% if scope.sku %}
{{ scope.sku }}{% endif %} -
{{ scope.manufacturer.name if scope.manufacturer else "—" }}{{ scope.scope_model.name if scope.scope_model else "—" }}{{ scope.sub_part_type.name if scope.sub_part_type else "—" }}{{ magnification(info) }} - {% if info and info.objective_thread_pitch %} - {{ info.objective_thread_pitch }} - {% else %}—{% endif %} - - {% if entries %} - {% for e in entries %} -
- {{ e.label | capitalize }} - {{ e.part.description | truncate(48) }} -
- {% endfor %} - {% else %} - — - {% endif %} -
- No scopes recorded yet. -
+ + + + +
+ + {% include "_scopes_table_card.html" %} {% endblock %} diff --git a/app/web/scopes.py b/app/web/scopes.py index e55f5b8..e3607d6 100644 --- a/app/web/scopes.py +++ b/app/web/scopes.py @@ -1,7 +1,8 @@ """Top-level, read-only scope fit guide at ``/scopes``. The site's primary view: a table of scopes with their specs, plus one column per -non-Scope part type listing the accessories that fit each scope. +non-Scope part type listing the accessories that fit each scope. Supports HTMX +search/filter over scope attributes and required accessory fits. """ from fastapi import APIRouter, Depends, Request @@ -9,6 +10,10 @@ from fastapi.responses import HTMLResponse from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db +from app.crud import manufacturer as manufacturer_crud +from app.crud import part_type as part_type_crud +from app.crud import scope_model as scope_model_crud +from app.crud import sub_part_type as sub_part_type_crud from app.models.user import User from app.services import scope_guide as scope_guide_service from app.web.admin import web_require @@ -17,13 +22,82 @@ from app.web.templates import templates router = APIRouter(include_in_schema=False) +def _to_int(value: str) -> int | None: + return int(value) if value.strip() else None + + +async def _build(db: AsyncSession, filters: dict) -> dict: + """Run the guide query for the given raw (string) filter values.""" + return await scope_guide_service.build( + db, + description=filters["description"], + manufacturer_id=_to_int(filters["manufacturer_id"]), + scope_model_id=_to_int(filters["scope_model_id"]), + sub_part_type_id=_to_int(filters["sub_part_type_id"]), + fits_part_type_id=_to_int(filters["fits_part_type_id"]), + ) + + +async def _filter_options(db: AsyncSession) -> dict: + """Dropdown option lists for the filter bar.""" + scope_type = await part_type_crud.get_by_name( + db, scope_guide_service.SCOPE_TYPE_NAME + ) + sub_part_types = [ + spt + for spt in await sub_part_type_crud.list_all(db, limit=1000) + if scope_type is not None and spt.part_type_id == scope_type.id + ] + return { + "manufacturers": await manufacturer_crud.list_all(db, limit=1000), + "scope_models": await scope_model_crud.list_all(db, limit=1000), + "sub_part_types": sub_part_types, + } + + @router.get("/scopes", response_class=HTMLResponse) async def scopes_page( request: Request, + description: str = "", + manufacturer_id: str = "", + scope_model_id: str = "", + sub_part_type_id: str = "", + fits_part_type_id: str = "", db: AsyncSession = Depends(get_db), user: User = Depends(web_require("augmented_parts:read", "fits:read")), ): - guide = await scope_guide_service.build(db) + filters = { + "description": description, + "manufacturer_id": manufacturer_id, + "scope_model_id": scope_model_id, + "sub_part_type_id": sub_part_type_id, + "fits_part_type_id": fits_part_type_id, + } + guide = await _build(db, filters) return templates.TemplateResponse( - request, "scopes.html", {"user": user, **guide} + request, + "scopes.html", + {"user": user, "filters": filters, **guide, **(await _filter_options(db))}, ) + + +@router.get("/scopes/filter", response_class=HTMLResponse) +async def scopes_filter( + request: Request, + description: str = "", + manufacturer_id: str = "", + scope_model_id: str = "", + sub_part_type_id: str = "", + fits_part_type_id: str = "", + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("augmented_parts:read", "fits:read")), +): + filters = { + "description": description, + "manufacturer_id": manufacturer_id, + "scope_model_id": scope_model_id, + "sub_part_type_id": sub_part_type_id, + "fits_part_type_id": fits_part_type_id, + } + guide = await _build(db, filters) + return templates.TemplateResponse(request, "_scopes_table_card.html", guide) diff --git a/tests/test_scope_guide.py b/tests/test_scope_guide.py index d4443e7..c231b3c 100644 --- a/tests/test_scope_guide.py +++ b/tests/test_scope_guide.py @@ -4,6 +4,7 @@ from app.models.augmented_part import AugmentedPart from app.models.fit import Fit, FitLabel from app.models.part_type import PartType from app.models.scope_info import ScopeInfo +from app.models.sub_part_type import SubPartType from app.services import scope_guide @@ -14,15 +15,24 @@ async def _seed_guide_data(db): db.add_all([scope_t, cover_t, ard_t]) await db.flush() - def part(desc, type_id): + rifle = SubPartType(name="Riflescope", part_type_id=scope_t.id) + reddot = SubPartType(name="Red Dot", part_type_id=scope_t.id) + db.add_all([rifle, reddot]) + await db.flush() + + def part(desc, type_id, manufacturer_id=1, sub_part_type_id=None): p = AugmentedPart( - description=desc, colour_id=1, manufacturer_id=1, part_type_id=type_id + description=desc, + colour_id=1, + manufacturer_id=manufacturer_id, + part_type_id=type_id, + sub_part_type_id=sub_part_type_id, ) db.add(p) return p - s1 = part("Alpha 3-18x50", scope_t.id) - s2 = part("Zeta 1-6x24", scope_t.id) + s1 = part("Alpha 3-18x50", scope_t.id, manufacturer_id=10, sub_part_type_id=rifle.id) + s2 = part("Zeta 1-6x24", scope_t.id, manufacturer_id=20, sub_part_type_id=reddot.id) fc1 = part("Flip cover 50mm", cover_t.id) a1 = part("ARD 50mm", ard_t.id) a2 = part("ARD 56mm", ard_t.id) @@ -58,7 +68,15 @@ async def _seed_guide_data(db): fit(9, 10, fc1, s2, FitLabel.POSSIBLE) await db.commit() - return {"scope": scope_t, "cover": cover_t, "ard": ard_t, "s1": s1, "s2": s2} + return { + "scope": scope_t, + "cover": cover_t, + "ard": ard_t, + "s1": s1, + "s2": s2, + "rifle": rifle, + "reddot": reddot, + } @pytest.mark.asyncio @@ -111,6 +129,45 @@ async def test_build_no_scope_type_returns_empty_rows(db_sessionmaker): assert [ct.name for ct in guide["column_types"]] == ["Flip Cover"] +def _descriptions(guide): + return [r["scope"].description for r in guide["rows"]] + + +@pytest.mark.asyncio +async def test_build_filters_by_description(db_sessionmaker): + async with db_sessionmaker() as db: + await _seed_guide_data(db) + guide = await scope_guide.build(db, description="alpha") + assert _descriptions(guide) == ["Alpha 3-18x50"] + + +@pytest.mark.asyncio +async def test_build_filters_by_manufacturer(db_sessionmaker): + async with db_sessionmaker() as db: + await _seed_guide_data(db) + guide = await scope_guide.build(db, manufacturer_id=20) + assert _descriptions(guide) == ["Zeta 1-6x24"] + + +@pytest.mark.asyncio +async def test_build_filters_by_sub_part_type(db_sessionmaker): + async with db_sessionmaker() as db: + ids = await _seed_guide_data(db) + guide = await scope_guide.build(db, sub_part_type_id=ids["rifle"].id) + assert _descriptions(guide) == ["Alpha 3-18x50"] + + +@pytest.mark.asyncio +async def test_build_filters_by_required_fit_type(db_sessionmaker): + async with db_sessionmaker() as db: + ids = await _seed_guide_data(db) + # Only Alpha has an ARD fit; both scopes fit a flip cover. + ard_only = await scope_guide.build(db, fits_part_type_id=ids["ard"].id) + cover = await scope_guide.build(db, fits_part_type_id=ids["cover"].id) + assert _descriptions(ard_only) == ["Alpha 3-18x50"] + assert _descriptions(cover) == ["Alpha 3-18x50", "Zeta 1-6x24"] + + async def _web_login(client, email, password="secret123"): resp = await client.post( "/login", data={"email": email, "password": password} @@ -145,6 +202,30 @@ async def test_scopes_page_renders_rows_and_fits( assert "3–18×" in body # magnification range, integral values un-padded +@pytest.mark.asyncio +async def test_scopes_filter_endpoint_narrows_rows( + client, seeded, make_user, db_sessionmaker +): + async with db_sessionmaker() as db: + await _seed_guide_data(db) + await make_user("reader3@example.com", role_ids=[seeded["roles"]["user"].id]) + await _web_login(client, "reader3@example.com") + resp = await client.get("/scopes/filter", params={"description": "Alpha"}) + assert resp.status_code == 200 + assert "Alpha 3-18x50" in resp.text + assert "Zeta 1-6x24" not in resp.text + + +@pytest.mark.asyncio +async def test_scopes_filter_endpoint_forbidden_without_permission( + client, seeded, make_user +): + await make_user("plain2@example.com") # no roles, not a superuser + await _web_login(client, "plain2@example.com") + resp = await client.get("/scopes/filter") + assert resp.status_code == 403 + + @pytest.mark.asyncio async def test_scopes_page_forbidden_without_permission(client, seeded, make_user): await make_user("plain@example.com") # no roles, not a superuser