Adding top-level Scopes table
This commit is contained in:
1 parent
bc8b67d585
commit
3febc15a8f
11 files changed
+470
-1
No files matched your search
@@ -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 = "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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}
|
||||
)
|
||||
Reference in new issue
Block a user