Adding top-level Scopes table

This commit is contained in:
Ian Roddis committed 2026-07-27 14:53:23 -03:00
1 parent bc8b67d585
commit 3febc15a8f
11 files changed
+470 -1

No files matched your search

+3 -1
View File
@@ -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(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(sqlite3 /tmp/claude-1000/-home-iroddis-dev-armament-fitguide/41e5e14d-88fe-45e7-ba3a-be5a2cbde2cd/scratchpad/mig_test.db '.schema users')",
"Bash(docker build *)", "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)"
] ]
} }
} }
+24
View File
@@ -31,6 +31,30 @@ async def list_all(
return list(result.scalars().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( async def list_filtered(
db: AsyncSession, db: AsyncSession,
description: str = "", description: str = "",
+23
View File
@@ -52,6 +52,29 @@ async def list_for_part(db: AsyncSession, augmented_part_id: int) -> list[Fit]:
return list(result.scalars().all()) 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( async def list_all(
db: AsyncSession, db: AsyncSession,
skip: int = 0, skip: int = 0,
+14
View File
@@ -17,6 +17,20 @@ async def get_by_augmented_part(
return result.scalar_one_or_none() 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( async def create(
db: AsyncSession, db: AsyncSession,
*, *,
+2
View File
@@ -12,6 +12,7 @@ from app.core.database import init_db
from app.deps import NotAuthenticated from app.deps import NotAuthenticated
from app.web.admin import router as admin_router from app.web.admin import router as admin_router
from app.web.pages import router as pages_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" 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(api_router, prefix="/api")
app.include_router(pages_router) app.include_router(pages_router)
app.include_router(scopes_router)
app.include_router(admin_router) app.include_router(admin_router)
@app.exception_handler(NotAuthenticated) @app.exception_handler(NotAuthenticated)
+114
View File
@@ -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
+10
View File
@@ -493,6 +493,16 @@ textarea:focus {
.fit-notes { margin-top: 0.4rem; padding-left: 0.25rem; } .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 { font-size: 0.85rem; margin-bottom: 0.3rem; }
.fit-note-meta { color: var(--muted); font-size: 0.75rem; } .fit-note-meta { color: var(--muted); font-size: 0.75rem; }
.fit-note-body { margin: 0.1rem 0 0; } .fit-note-body { margin: 0.1rem 0 0; }
+5
View File
@@ -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_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_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_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 ───────────────────────────────────────────────────────── #} {# ── SVG icon sprite ───────────────────────────────────────────────────────── #}
<svg xmlns="http://www.w3.org/2000/svg" style="display:none"> <svg xmlns="http://www.w3.org/2000/svg" style="display:none">
@@ -127,6 +128,10 @@
<nav> <nav>
<a href="/">{{ icon("home") }} Dashboard</a> <a href="/">{{ icon("home") }} Dashboard</a>
{% if can_scopes %}
<a href="/scopes">{{ icon("crosshair") }} Scopes</a>
{% endif %}
{% if can_admin %} {% if can_admin %}
<div class="nav-group"> <div class="nav-group">
<span class="nav-label">{{ icon("shield") }} Admin ▾</span> <span class="nav-label">{{ icon("shield") }} Admin ▾</span>
+86
View File
@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}Scopes · {{ app_name }}{% endblock %}
{% block body %}
{% include "_nav.html" %}
<main class="container">
<h1>Scopes</h1>
<p class="muted">
Every scope on record, with the accessories that fit it. Each part-type
column lists matching parts; <span class="fit-badge fit-badge-exact">Exact</span>
is a confirmed fit, <span class="fit-badge fit-badge-possible">Possible</span> a candidate.
</p>
{%- 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 -%}
<span class="muted">—</span>
{%- endif -%}
{%- endmacro %}
<div class="card" style="overflow-x:auto">
<table class="table">
<thead>
<tr>
<th>Scope</th>
<th>Manufacturer</th>
<th>Model</th>
<th>Type</th>
<th>Magnification</th>
<th>Objective thread</th>
{% for ct in column_types %}
<th>{{ ct.name }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in rows %}
{% set scope = row.scope %}
{% set info = row.info %}
<tr>
<td>
<strong>{{ scope.description }}</strong>
{% if scope.sku %}<br><span class="muted">{{ scope.sku }}</span>{% endif %}
</td>
<td>{{ scope.manufacturer.name if scope.manufacturer else "—" }}</td>
<td>{{ scope.scope_model.name if scope.scope_model else "—" }}</td>
<td>{{ scope.sub_part_type.name if scope.sub_part_type else "—" }}</td>
<td>{{ magnification(info) }}</td>
<td>
{% if info and info.objective_thread_pitch %}
{{ info.objective_thread_pitch }}
{% else %}<span class="muted">—</span>{% endif %}
</td>
{% for ct in column_types %}
{% set entries = row.cells.get(ct.id) %}
<td>
{% if entries %}
{% for e in entries %}
<div class="fit-cell-item">
<span class="fit-badge fit-badge-{{ e.label }}">{{ e.label | capitalize }}</span>
{{ e.part.description | truncate(48) }}
</div>
{% endfor %}
{% else %}
<span class="muted">—</span>
{% endif %}
</td>
{% endfor %}
</tr>
{% else %}
<tr>
<td colspan="{{ 6 + column_types | length }}" class="muted">
No scopes recorded yet.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</main>
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
"""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.
"""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.user import User
from app.services import scope_guide as scope_guide_service
from app.web.admin import web_require
from app.web.templates import templates
router = APIRouter(include_in_schema=False)
@router.get("/scopes", response_class=HTMLResponse)
async def scopes_page(
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("augmented_parts:read", "fits:read")),
):
guide = await scope_guide_service.build(db)
return templates.TemplateResponse(
request, "scopes.html", {"user": user, **guide}
)
+160
View File
@@ -0,0 +1,160 @@
import pytest
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.services import scope_guide
async def _seed_guide_data(db):
scope_t = PartType(name="Scope")
cover_t = PartType(name="Flip Cover")
ard_t = PartType(name="ARD")
db.add_all([scope_t, cover_t, ard_t])
await db.flush()
def part(desc, type_id):
p = AugmentedPart(
description=desc, colour_id=1, manufacturer_id=1, part_type_id=type_id
)
db.add(p)
return p
s1 = part("Alpha 3-18x50", scope_t.id)
s2 = part("Zeta 1-6x24", scope_t.id)
fc1 = part("Flip cover 50mm", cover_t.id)
a1 = part("ARD 50mm", ard_t.id)
a2 = part("ARD 56mm", ard_t.id)
await db.flush()
db.add(
ScopeInfo(
augmented_part_id=s1.id,
magnification_min=3,
magnification_max=18,
objective_thread_pitch="M52x0.75",
)
)
def fit(ap_a, ap_b, a, b, label):
db.add(
Fit(
attachment_point_a_id=ap_a,
attachment_point_b_id=ap_b,
augmented_part_a_id=a.id,
augmented_part_b_id=b.id,
label=label,
)
)
# S1 fits FC1 (exact) via two attachment points -> should dedup to one exact.
fit(1, 2, s1, fc1, FitLabel.POSSIBLE)
fit(3, 4, s1, fc1, FitLabel.EXACT)
# S1 fits A1 (possible); an explicit NONE to A2 must be excluded.
fit(5, 6, s1, a1, FitLabel.POSSIBLE)
fit(7, 8, s1, a2, FitLabel.NONE)
# S2 fits FC1 (possible); order reversed to exercise the b-side branch.
fit(9, 10, fc1, s2, FitLabel.POSSIBLE)
await db.commit()
return {"scope": scope_t, "cover": cover_t, "ard": ard_t, "s1": s1, "s2": s2}
@pytest.mark.asyncio
async def test_build_columns_exclude_scope_type(db_sessionmaker):
async with db_sessionmaker() as db:
await _seed_guide_data(db)
guide = await scope_guide.build(db)
names = [ct.name for ct in guide["column_types"]]
assert names == ["ARD", "Flip Cover"] # sorted, Scope excluded
@pytest.mark.asyncio
async def test_build_rows_group_fits_by_type_and_dedup(db_sessionmaker):
async with db_sessionmaker() as db:
ids = await _seed_guide_data(db)
guide = await scope_guide.build(db)
cover_id, ard_id = ids["cover"].id, ids["ard"].id
rows = {r["scope"].description: r for r in guide["rows"]}
assert list(rows) == ["Alpha 3-18x50", "Zeta 1-6x24"] # ordered by description
s1 = rows["Alpha 3-18x50"]
# Two fits to the same cover collapse to one entry, keeping the exact label.
cover_cell = s1["cells"][cover_id]
assert [(e["part"].description, e["label"]) for e in cover_cell] == [
("Flip cover 50mm", "exact")
]
# Possible ARD present; the NONE-labelled ARD is excluded entirely.
ard_cell = s1["cells"][ard_id]
assert [(e["part"].description, e["label"]) for e in ard_cell] == [
("ARD 50mm", "possible")
]
assert s1["info"] is not None
assert s1["info"].magnification_max == 18
s2 = rows["Zeta 1-6x24"]
assert [e["part"].description for e in s2["cells"][cover_id]] == ["Flip cover 50mm"]
assert ard_id not in s2["cells"] # no ARD fits for S2
assert s2["info"] is None
@pytest.mark.asyncio
async def test_build_no_scope_type_returns_empty_rows(db_sessionmaker):
async with db_sessionmaker() as db:
db.add(PartType(name="Flip Cover"))
await db.commit()
guide = await scope_guide.build(db)
assert guide["rows"] == []
assert [ct.name for ct in guide["column_types"]] == ["Flip Cover"]
async def _web_login(client, email, password="secret123"):
resp = await client.post(
"/login", data={"email": email, "password": password}
)
assert resp.status_code == 303
@pytest.mark.asyncio
async def test_scopes_page_renders_for_reader(client, seeded, make_user):
await make_user("reader@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "reader@example.com")
resp = await client.get("/scopes")
assert resp.status_code == 200
assert "Scopes" in resp.text
@pytest.mark.asyncio
async def test_scopes_page_renders_rows_and_fits(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
await _seed_guide_data(db)
await make_user("reader2@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "reader2@example.com")
resp = await client.get("/scopes")
assert resp.status_code == 200
body = resp.text
assert "Alpha 3-18x50" in body # scope row
assert "Flip cover 50mm" in body # a matching part in a fit column
assert "fit-badge-exact" in body # the exact fit badge
assert "M52x0.75" in body # scope info: objective thread
assert "3–18×" in body # magnification range, integral values un-padded
@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
await _web_login(client, "plain@example.com")
resp = await client.get("/scopes")
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_scopes_page_redirects_when_anonymous(client):
resp = await client.get("/scopes")
assert resp.status_code == 302
assert resp.headers["location"] == "/login"