From 3febc15a8feb06d0a18c926644481509e9afb893 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:53:23 -0300 Subject: [PATCH] Adding top-level Scopes table --- .claude/settings.local.json | 4 +- app/crud/augmented_part.py | 24 ++++++ app/crud/fit.py | 23 ++++++ app/crud/scope_info.py | 14 ++++ app/main.py | 2 + app/services/scope_guide.py | 114 +++++++++++++++++++++++++ app/static/css/style.css | 10 +++ app/templates/_nav.html | 5 ++ app/templates/scopes.html | 86 +++++++++++++++++++ app/web/scopes.py | 29 +++++++ tests/test_scope_guide.py | 160 ++++++++++++++++++++++++++++++++++++ 11 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 app/services/scope_guide.py create mode 100644 app/templates/scopes.html create mode 100644 app/web/scopes.py create mode 100644 tests/test_scope_guide.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2fc4558..d309f38 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,9 @@ "Bash(DATABASE_URL=\"sqlite+aiosqlite:////tmp/claude-1000/-home-iroddis-dev-armament-fitguide/41e5e14d-88fe-45e7-ba3a-be5a2cbde2cd/scratchpad/mig_test.db\" uv run alembic upgrade head)", "Bash(sqlite3 /tmp/claude-1000/-home-iroddis-dev-armament-fitguide/41e5e14d-88fe-45e7-ba3a-be5a2cbde2cd/scratchpad/mig_test.db '.schema users')", "Bash(docker build *)", - "Bash(git status *)" + "Bash(git status *)", + "Bash(git -C /home/iroddis/dev/armament/fitguide status --short)", + "Bash(git -C /home/iroddis/dev/armament/fitguide log --oneline -10)" ] } } diff --git a/app/crud/augmented_part.py b/app/crud/augmented_part.py index 7e5f3e8..692f9a3 100644 --- a/app/crud/augmented_part.py +++ b/app/crud/augmented_part.py @@ -31,6 +31,30 @@ 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: + return [] + result = await db.execute( + select(AugmentedPart).where(AugmentedPart.id.in_(ids)) + ) + return list(result.scalars().all()) + + async def list_filtered( db: AsyncSession, description: str = "", diff --git a/app/crud/fit.py b/app/crud/fit.py index 2402514..4f5c438 100644 --- a/app/crud/fit.py +++ b/app/crud/fit.py @@ -52,6 +52,29 @@ async def list_for_part(db: AsyncSession, augmented_part_id: int) -> list[Fit]: return list(result.scalars().all()) +async def list_for_parts( + db: AsyncSession, + part_ids: list[int], + exclude_label: FitLabel | None = None, +) -> list[Fit]: + """All fits touching any of the given augmented parts, in one query. + + Pass ``exclude_label`` to drop, e.g., confirmed non-fits (``FitLabel.NONE``). + """ + if not part_ids: + return [] + q = select(Fit).where( + or_( + Fit.augmented_part_a_id.in_(part_ids), + Fit.augmented_part_b_id.in_(part_ids), + ) + ) + if exclude_label is not None: + q = q.where(Fit.label != exclude_label) + result = await db.execute(q.order_by(Fit.id)) + return list(result.scalars().all()) + + async def list_all( db: AsyncSession, skip: int = 0, diff --git a/app/crud/scope_info.py b/app/crud/scope_info.py index 2677143..54cd901 100644 --- a/app/crud/scope_info.py +++ b/app/crud/scope_info.py @@ -17,6 +17,20 @@ async def get_by_augmented_part( return result.scalar_one_or_none() +async def list_by_augmented_parts( + db: AsyncSession, augmented_part_ids: list[int] +) -> list[ScopeInfo]: + """ScopeInfo rows for the given augmented parts, fetched in one query.""" + if not augmented_part_ids: + return [] + result = await db.execute( + select(ScopeInfo).where( + ScopeInfo.augmented_part_id.in_(augmented_part_ids) + ) + ) + return list(result.scalars().all()) + + async def create( db: AsyncSession, *, diff --git a/app/main.py b/app/main.py index 69adaa0..87ee1dc 100644 --- a/app/main.py +++ b/app/main.py @@ -12,6 +12,7 @@ from app.core.database import init_db from app.deps import NotAuthenticated from app.web.admin import router as admin_router from app.web.pages import router as pages_router +from app.web.scopes import router as scopes_router STATIC_DIR = Path(__file__).resolve().parent / "static" @@ -39,6 +40,7 @@ def create_app() -> FastAPI: app.include_router(api_router, prefix="/api") app.include_router(pages_router) + app.include_router(scopes_router) app.include_router(admin_router) @app.exception_handler(NotAuthenticated) diff --git a/app/services/scope_guide.py b/app/services/scope_guide.py new file mode 100644 index 0000000..45b2615 --- /dev/null +++ b/app/services/scope_guide.py @@ -0,0 +1,114 @@ +"""Assemble the scope-centric fit guide. + +Builds the data for the top-level ``/scopes`` page: one row per augmented part +of part type "Scope", one column per non-Scope part type, where each cell lists +the parts of that type that fit the row's scope. +""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.crud import augmented_part as augmented_part_crud +from app.crud import fit as fit_crud +from app.crud import part_type as part_type_crud +from app.crud import scope_info as scope_info_crud +from app.models.fit import FitLabel + +SCOPE_TYPE_NAME = "Scope" + +# "exact" outranks "possible" when the same part fits a scope via several fits. +_LABEL_RANK = {FitLabel.EXACT.value: 2, FitLabel.POSSIBLE.value: 1} + + +async def build(db: AsyncSession) -> dict: + """Return the context for ``scopes.html``. + + Shape:: + + { + "column_types": [PartType, ...], # non-Scope, ordered by name + "rows": [ + { + "scope": AugmentedPart, + "info": ScopeInfo | None, + "cells": {part_type_id: [{"part": AugmentedPart, "label": str}]}, + }, + ..., + ], + } + """ + part_types = await part_type_crud.list_all(db, limit=1000) + scope_type = next( + (pt for pt in part_types if pt.name == SCOPE_TYPE_NAME), None + ) + column_types = [ + pt for pt in part_types if scope_type is None or pt.id != scope_type.id + ] + + if scope_type is None: + return {"column_types": column_types, "rows": []} + + scopes = await augmented_part_crud.list_by_part_type(db, scope_type.id) + scope_ids = {s.id for s in scopes} + if not scope_ids: + return {"column_types": column_types, "rows": []} + + fits = await fit_crud.list_for_parts( + db, list(scope_ids), exclude_label=FitLabel.NONE + ) + + # Resolve the "other" (non-scope) part on each fit in a single batched query. + other_ids: set[int] = set() + for fit in fits: + scope_id, other_id = _scope_and_other(fit, scope_ids) + if scope_id is not None and other_id not in scope_ids: + other_ids.add(other_id) + other_parts = { + p.id: p for p in await augmented_part_crud.get_many(db, list(other_ids)) + } + + infos = { + info.augmented_part_id: info + for info in await scope_info_crud.list_by_augmented_parts(db, list(scope_ids)) + } + + # cell_map[scope_id][type_id][other_part_id] -> {"part", "label"}, dedup'd so a + # part appears once per cell, keeping its strongest fit label. + cell_map: dict[int, dict[int, dict[int, dict]]] = { + sid: {} for sid in scope_ids + } + for fit in fits: + scope_id, other_id = _scope_and_other(fit, scope_ids) + if scope_id is None: + continue + other = other_parts.get(other_id) + if other is None or other.part_type_id == scope_type.id: + continue # scope-to-scope fits have no column + by_type = cell_map[scope_id].setdefault(other.part_type_id, {}) + existing = by_type.get(other.id) + label = fit.label.value + if existing is None or _LABEL_RANK[label] > _LABEL_RANK[existing["label"]]: + by_type[other.id] = {"part": other, "label": label} + + rows = [] + for scope in scopes: + cells = { + type_id: sorted( + entries.values(), key=lambda e: e["part"].description.lower() + ) + for type_id, entries in cell_map[scope.id].items() + } + rows.append( + {"scope": scope, "info": infos.get(scope.id), "cells": cells} + ) + + return {"column_types": column_types, "rows": rows} + + +def _scope_and_other(fit, scope_ids: set[int]) -> tuple[int | None, int]: + """Return (scope_part_id, other_part_id) for a fit, or (None, _) if neither + side is a scope. When both sides are scopes, side A is treated as the scope.""" + if fit.augmented_part_a_id in scope_ids: + return fit.augmented_part_a_id, fit.augmented_part_b_id + if fit.augmented_part_b_id in scope_ids: + return fit.augmented_part_b_id, fit.augmented_part_a_id + return None, fit.augmented_part_b_id diff --git a/app/static/css/style.css b/app/static/css/style.css index 6c1f7ca..6642b3f 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -493,6 +493,16 @@ textarea:focus { .fit-notes { margin-top: 0.4rem; padding-left: 0.25rem; } +/* Scope guide: stacked matching-part entries within a fit column cell. */ +.fit-cell-item { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + white-space: nowrap; +} +.fit-cell-item + .fit-cell-item { margin-top: 0.3rem; } + .fit-note { font-size: 0.85rem; margin-bottom: 0.3rem; } .fit-note-meta { color: var(--muted); font-size: 0.75rem; } .fit-note-body { margin: 0.1rem 0 0; } diff --git a/app/templates/_nav.html b/app/templates/_nav.html index 037e296..d1de06a 100644 --- a/app/templates/_nav.html +++ b/app/templates/_nav.html @@ -5,6 +5,7 @@ {% set can_labels = su or "attachment_point_names:read" in p or "manufacturers:read" in p or "part_colours:read" in p or "part_types:read" in p or "scope_models:read" in p or "sub_part_types:read" in p %} {% set can_database = su or "imported_parts:read" in p or "augmented_parts:read" in p or "attachment_points:read" in p %} {% set can_parts = can_connections or can_labels or can_database %} +{% set can_scopes = su or ("augmented_parts:read" in p and "fits:read" in p) %} {# ── SVG icon sprite ───────────────────────────────────────────────────────── #} @@ -127,6 +128,10 @@