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

+24
View File
@@ -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 = "",
+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())
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,
+14
View File
@@ -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,
*,
+2
View File
@@ -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)
+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; }
/* 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; }
+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_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 ───────────────────────────────────────────────────────── #}
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
@@ -127,6 +128,10 @@
<nav>
<a href="/">{{ icon("home") }} Dashboard</a>
{% if can_scopes %}
<a href="/scopes">{{ icon("crosshair") }} Scopes</a>
{% endif %}
{% if can_admin %}
<div class="nav-group">
<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}
)