Compare commits

...
8 Commits
42 changed files with 1302 additions and 191 deletions

No files matched your search

+18 -1
View File
@@ -13,7 +13,24 @@
"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 status --short)",
"Bash(git -C /home/iroddis/dev/armament/fitguide log --oneline -10)" "Bash(git -C /home/iroddis/dev/armament/fitguide log --oneline -10)",
"Bash(node --check /Users/roddis/dev/armament/fitguide/app/static/js/sortable-tables.js)",
"Bash(sqlite3 /Users/roddis/dev/armament/fitguide/app.db \"select count\\(*\\) from users;\")",
"Bash(sqlite3 /Users/roddis/dev/armament/fitguide/app.db \"select count\\(*\\) from part_colours;\")",
"Bash(sqlite3 /Users/roddis/dev/armament/fitguide/app.db \"select count\\(*\\) from manufacturers;\")",
"Bash(curl -s -c /tmp/fg_cookies.txt -o /dev/null -w \"login:%{http_code}\\\\n\" -d \"email=admin@armament.com&password=changethis\" http://localhost:8000/login)",
"Bash(curl -s -b /tmp/fg_cookies.txt http://localhost:8000/admin/manufacturers)",
"Bash(curl -s -o /dev/null -w \"%{http_code} %{content_type}\\\\n\" http://localhost:8000/static/js/sortable-tables.js)",
"Bash(curl -s -b /tmp/fg_cookies.txt \"http://localhost:8000/admin/augmented-parts\")",
"Bash(curl -s -b /tmp/fg_cookies.txt \"http://localhost:8000/admin/augmented-parts/filter?sort=colour&dir=desc\")",
"Bash(curl -s -b /tmp/fg_cookies.txt \"http://localhost:8000/admin/roles\")",
"Bash(curl -s http://localhost:8000/scopes)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:8000/scopes)",
"Bash(curl -s -b /tmp/fg_cookies.txt http://localhost:8000/scopes)",
"Bash(lsof -ti:8000 -sTCP:LISTEN)",
"Bash(xargs -r kill)",
"Bash(rm -f /tmp/fg_cookies.txt /private/tmp/claude-501/-Users-roddis-dev-armament-fitguide/7873fd9c-2f2b-4583-877e-72fc88718f24/scratchpad/smoke_sort.py)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:8000/login)"
] ]
} }
} }
+94 -13
View File
@@ -1,26 +1,41 @@
# CLAUDE.md # CLAUDE.md
Guidance for working in this repo. Read alongside `README.md` (user-facing setup). This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Guidance for working in this repo. Read alongside `README.md` (user-facing setup
for the original FastAPI skeleton this app was built from).
## What this is ## What this is
A FastAPI skeleton serving **both** a JSON API (`/api/*`) and server-rendered **Fitguide**: a parts-compatibility guide for Armament scopes and accessories,
HTML via **Jinja2 + HTMX** (`/`, `/admin/*`). Async SQLAlchemy 2.0 ORM, JWT auth built on a FastAPI skeleton that serves **both** a JSON API (`/api/*`) and
with refresh-token revocation, and RBAC (users → roles → permissions). server-rendered HTML via **Jinja2 + HTMX** (`/`, `/scopes`, `/admin/*`). Async
SQLAlchemy 2.0 ORM, JWT auth with refresh-token revocation (plus optional LDAP
direct-bind login), and RBAC (users → roles → permissions). The domain layer on
top of the skeleton tracks parts, their attachment points, and which parts fit
together — see "Domain model" below.
## Commands ## Commands
```bash ```bash
uv sync --extra dev # install (incl. dev tools) uv sync --extra dev # install (incl. dev tools)
uv run alembic upgrade head # apply migrations
uv run python -m scripts.seed # seed perms/roles/superuser (idempotent) uv run python -m scripts.seed # seed perms/roles/superuser (idempotent)
uv run uvicorn app.main:app --reload # dev server on :8000 uv run uvicorn app.main:app --reload # dev server on :8000
uv run pytest # tests (in-memory SQLite) uv run pytest # tests (in-memory SQLite)
uv run pytest tests/test_foo.py::test_bar # single test
uv run ruff check . [--fix] # lint uv run ruff check . [--fix] # lint
uv run alembic upgrade head # apply migrations uv run black . # format (ruff does not format here)
uv run alembic revision --autogenerate -m "msg" # new migration after model changes uv run alembic revision --autogenerate -m "msg" # new migration after model changes
docker compose up --build # app + Postgres, auto-seeded docker compose up --build # app + Postgres, auto-seeded
``` ```
`Makefile` wraps the common ones (`make sync|seed|dev|test|fmt|migrate`).
`make clean` deletes the local `app.db`, re-migrates, and reseeds — the quick
way to get a clean dev database. `make seed` runs `alembic upgrade head` before
seeding, i.e. day-to-day dev relies on migrations, not just
`AUTO_CREATE_TABLES` (see "Database").
## Architecture / layering ## Architecture / layering
Request → router (`app/api/` JSON or `app/web/` HTML) → `app/crud/` (data access) Request → router (`app/api/` JSON or `app/web/` HTML) → `app/crud/` (data access)
@@ -28,40 +43,106 @@ Request → router (`app/api/` JSON or `app/web/` HTML) → `app/crud/` (data ac
token logic in `app/services/auth.py`. Keep route handlers thin; put DB logic in token logic in `app/services/auth.py`. Keep route handlers thin; put DB logic in
`crud/`, not in routers. `crud/`, not in routers.
`app/api/` is one router module per resource, all mounted under `/api` by
`app/api/router.py`. `app/web/` is organized differently: `pages.py` (dashboard/
login), `scopes.py` (the public-facing `/scopes` fit guide), and **`admin.py`**
— a single ~3000-line router covering every admin screen (users, roles, part
taxonomy tables, imported parts, augmented parts + detail + nested attachment
points/fits, audit log, etc.), organized into `# --- Section ---` banner
comments per resource rather than split into files. When adding an admin screen,
follow the existing banner-section convention there rather than starting a new
web router module.
## Domain model
The business domain, independent of the auth/RBAC skeleton:
- **`AugmentedPart`** (`augmented_parts`) is the generic "part" entity — a
scope, flip cover, ARD, polarizer, adapter ring, etc. — distinguished by
`PartType`/`SubPartType`, with `PartColour`, `Manufacturer`, and an optional
`ScopeModel`. "Scope" is just one `PartType` value (see `SCOPE_TYPE_NAME` in
`app/services/scope_guide.py`), not a separate table.
- **`ScopeInfo`** is a 1:1 extension of `AugmentedPart` (magnification,
objective thread, model suffix) populated only for scope-type parts.
- **`AttachmentPoint`** belongs to one `AugmentedPart`, is named via
`AttachmentPointName` (e.g. "Ocular", "Objective"), and connects via
*exactly one* of `IsoThread` / `UncThread` / `ClampConnection` /
`StaticConnection` — that "exactly one" invariant is enforced in the app
layer (CRUD/API validation), not by a DB constraint.
- **`Fit`** (`app/models/fit.py`) pairs two `AttachmentPoint`s (canonical
ordering enforced in `app/crud/fit.py` so a pair only ever has one row),
labeled `possible` / `exact` / `none`, with threaded `FitNote`s.
`app/services/fit_matching.py` computes `possible` fits automatically
(matching ISO/UNC thread specs exactly, or a static connection's dimension
falling inside a clamp connection's range); `exact`/`none` are user-asserted
and never touched by the algorithm.
- **`ImportedPart`** (`imported_parts`, PK is the string `InventoryID` — not an
int id) holds raw rows from an external inventory feed, fetched via
`app/services/odata.py` (OData v4, paginated). `app/services/
augmented_part_sync.py` reconciles these into `AugmentedPart` +
`AttachmentPoint` rows: infers `PartType` by substring match on the
description, parses `TBXThreadAttribute` against ISO/UNC regexes (falling
back to an OD-derived `StaticConnection`), and prunes `AugmentedPart`s whose
backing `ImportedPart`s have disappeared.
- **`app/services/scope_import.py`** is a separate, manually-triggered bulk
importer: reads an uploaded `.xlsx` of third-party scopes, upserting by
`product_number` (→ `AugmentedPart.sku`) and creating `ScopeModel`,
`ScopeInfo`, up to three `AttachmentPoint`s, and up to two `Fit`s per row
(see the module docstring for the full column mapping).
- **`app/services/scope_guide.py`** assembles the `/scopes` page: one row per
Scope-type `AugmentedPart`, one column per other `PartType`, each cell
listing parts of that type with a recorded `Fit`.
## Conventions that matter ## Conventions that matter
- **Everything async.** Engine, sessions, CRUD, routes, tests. Use `await`; never - **Everything async.** Engine, sessions, CRUD, routes, tests. Use `await`; never
add a sync DB call. `get_db` (in `app/core/database.py`) yields the request session. add a sync DB call. `get_db` (in `app/core/database.py`) yields the request session.
- **Relationships use `lazy="selectin"`** (`User.roles`, `Role.permissions`) so - **Relationships use `lazy="selectin"`** (`User.roles`, `Role.permissions`,
attributes are safe to read after the session closes — required under async. `AttachmentPoint.iso_thread`, etc.) so attributes are safe to read after the
Preserve this when adding relationships that templates/schemas read. session closes — required under async. Preserve this when adding relationships
that templates/schemas read.
- **Config** is `app/core/config.py` (`pydantic-settings`). Add new settings there; - **Config** is `app/core/config.py` (`pydantic-settings`). Add new settings there;
they're auto-read from env vars (uppercased field name) or `.env`. Env wins. they're auto-read from env vars (uppercased field name) or `.env`. Env wins.
- **RBAC**: guard API routes with `Depends(require_permission("resource:action"))`; - **RBAC**: guard API routes with `Depends(require_permission("resource:action"))`;
guard web routes with `web_require(...)` in `app/web/admin.py`. Superusers bypass guard web routes with `web_require(...)` in `app/web/admin.py`. Superusers bypass
all checks (`User.is_superuser` / `User.has_permission`). Add new permission all checks (`User.is_superuser` / `User.has_permission`). Add new permission
strings to `scripts/seed.py`'s `PERMISSIONS`/`ROLES`. strings to `scripts/seed.py`'s `PERMISSIONS`/`ROLES`.
- **HTMX UI-level write gating**: on top of route-level RBAC, pages that mix
read-only and read-write viewers (e.g. `augmented_part_detail.html`'s Fits and
Attachment Points sections) compute a `can_write_<thing>` bool from the
current user's permissions and wrap add/edit/delete controls in
`{% if can_write_<thing> %}` in the template, so read-only users see data but
not controls they'd get a 403 on. The route-level `web_require(...)` check
remains the real security boundary; the template flag is only for UX.
- **Auth, two transports**: API uses `Authorization: Bearer`; the HTMX UI uses an - **Auth, two transports**: API uses `Authorization: Bearer`; the HTMX UI uses an
httpOnly cookie (`settings.cookie_name`). `app/deps.py` reads both. Web auth httpOnly cookie (`settings.cookie_name`). `app/deps.py` reads both. Web auth
failures raise `NotAuthenticated`, handled in `main.py` as a redirect to `/login`. failures raise `NotAuthenticated`, handled in `main.py` as a redirect to `/login`
(preserving the original path as `?next=`).
- **Refresh tokens** are stored server-side (hash + `jti` + `revoked`) in the - **Refresh tokens** are stored server-side (hash + `jti` + `revoked`) in the
`refresh_tokens` table. `services/auth.rotate_refresh_token` revokes-then-reissues. `refresh_tokens` table. `services/auth.rotate_refresh_token` revokes-then-reissues.
Note: JWT payload `exp` is a tz-aware `datetime` (PyJWT only epoch-encodes inside Note: JWT payload `exp` is a tz-aware `datetime` (PyJWT only epoch-encodes inside
the token) — pass it straight to the model, don't `fromtimestamp` it. the token) — pass it straight to the model, don't `fromtimestamp` it.
- **LDAP login** (`app/services/ldap.py`) is optional (`settings.ldap_enabled`),
direct-bind, and just-in-time provisions a local `User` on first successful
login. `ldap3` is sync, so its calls run via `run_in_threadpool`.
## Database ## Database
One `DATABASE_URL` drives SQLite (`sqlite+aiosqlite://`, the default) or Postgres One `DATABASE_URL` drives SQLite (`sqlite+aiosqlite://`, the default) or Postgres
(`postgresql+asyncpg://`). `AUTO_CREATE_TABLES=true` calls `Base.metadata.create_all` (`postgresql+asyncpg://`). `AUTO_CREATE_TABLES=true` calls `Base.metadata.create_all`
on startup for dev convenience; production should use Alembic instead. `init_db()` on startup for dev convenience, but the day-to-day `make seed`/`make clean` flow
imports `app.models` inside the function to avoid a circular import. runs `alembic upgrade head` first — treat Alembic migrations as the source of
truth and keep them in sync with model changes. `init_db()` imports `app.models`
inside the function to avoid a circular import.
## Tests ## Tests
`tests/conftest.py` builds an in-memory SQLite engine with `StaticPool`, creates `tests/conftest.py` builds an in-memory SQLite engine with `StaticPool`, creates
tables, and overrides `get_db`. Fixtures: `client` (httpx ASGI), `seeded` tables, and overrides `get_db`. Fixtures: `client` (httpx ASGI), `seeded`
(perms+roles), `make_user`. `asyncio_mode = "auto"` — write `async def test_*` (perms+roles from `scripts.seed.PERMISSIONS`/`ROLES`), `make_user`, and
without decorators. `db_sessionmaker` (used directly in tests to seed domain rows — models, fits,
attachment points — beyond what `seeded`/`make_user` cover). `asyncio_mode =
"auto"` — write `async def test_*` without decorators.
## When extending ## When extending
@@ -0,0 +1,43 @@
"""add_augmented_part_notes_table
Revision ID: 8012c00b0c04
Revises: d1e2f3a4b5c6
Create Date: 2026-07-31 09:07:06.137175
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "8012c00b0c04"
down_revision: Union[str, None] = "d1e2f3a4b5c6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"augmented_part_notes",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("augmented_part_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("username", sa.String(length=255), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(
["augmented_part_id"], ["augmented_parts.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("augmented_part_notes")
+22
View File
@@ -4,10 +4,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import augmented_part as augmented_part_crud from app.crud import augmented_part as augmented_part_crud
from app.crud import augmented_part_note as augmented_part_note_crud
from app.deps import require_permission from app.deps import require_permission
from app.models.user import User from app.models.user import User
from app.schemas.augmented_part import ( from app.schemas.augmented_part import (
AugmentedPartCreate, AugmentedPartCreate,
AugmentedPartNoteCreate,
AugmentedPartNoteRead,
AugmentedPartRead, AugmentedPartRead,
AugmentedPartSyncResult, AugmentedPartSyncResult,
AugmentedPartUpdate, AugmentedPartUpdate,
@@ -132,3 +135,22 @@ async def delete_augmented_part(
"AugmentedPart", augmented_part_id, label "AugmentedPart", augmented_part_id, label
), ),
) )
@router.post(
"/{augmented_part_id}/notes",
response_model=AugmentedPartNoteRead,
status_code=status.HTTP_201_CREATED,
)
async def create_augmented_part_note(
augmented_part_id: int,
payload: AugmentedPartNoteCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:write")),
):
ap = await augmented_part_crud.get(db, augmented_part_id)
if ap is None:
raise HTTPException(status_code=404, detail="Augmented part not found")
return await augmented_part_note_crud.create(
db, augmented_part=ap, author=actor, body=payload.body
)
+43 -6
View File
@@ -4,9 +4,47 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.associations import augmented_part_imported_parts from app.models.associations import augmented_part_imported_parts
from app.models.augmented_part import AugmentedPart from app.models.augmented_part import AugmentedPart
from app.models.imported_part import ImportedPart from app.models.imported_part import ImportedPart
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
from app.models.scope_model import ScopeModel
from app.models.sub_part_type import SubPartType
_RELATIONSHIPS = ["colour", "manufacturer", "scope_model", "part_type", "sub_part_type"] _RELATIONSHIPS = ["colour", "manufacturer", "scope_model", "part_type", "sub_part_type"]
# Sortable columns for the admin list, keyed by the query-string "sort" value.
# Columns backed by a related table's name require an outer join (the FK may be
# nullable, e.g. scope_model_id) so unrelated rows still appear, ordered last.
SORT_COLUMNS = {
"id": (AugmentedPart.id, None),
"sku": (AugmentedPart.sku, None),
"description": (AugmentedPart.description, None),
"colour": (PartColour.name, (PartColour, AugmentedPart.colour_id == PartColour.id)),
"manufacturer": (
Manufacturer.name,
(Manufacturer, AugmentedPart.manufacturer_id == Manufacturer.id),
),
"scope_model": (
ScopeModel.name,
(ScopeModel, AugmentedPart.scope_model_id == ScopeModel.id),
),
"part_type": (PartType.name, (PartType, AugmentedPart.part_type_id == PartType.id)),
"sub_part_type": (
SubPartType.name,
(SubPartType, AugmentedPart.sub_part_type_id == SubPartType.id),
),
}
def _apply_sort(q, sort: str, direction: str):
column, join = SORT_COLUMNS.get(sort, SORT_COLUMNS["description"])
if join is not None:
table, cond = join
q = q.outerjoin(table, cond)
order = column.desc() if direction == "desc" else column.asc()
# AugmentedPart.id as a tiebreaker keeps paginated ordering stable.
return q.order_by(order, AugmentedPart.id)
async def get(db: AsyncSession, augmented_part_id: int) -> AugmentedPart | None: async def get(db: AsyncSession, augmented_part_id: int) -> AugmentedPart | None:
return await db.get(AugmentedPart, augmented_part_id) return await db.get(AugmentedPart, augmented_part_id)
@@ -35,9 +73,7 @@ async def get_many(db: AsyncSession, ids: list[int]) -> list[AugmentedPart]:
"""Fetch augmented parts by id in one query (selectin relationships loaded).""" """Fetch augmented parts by id in one query (selectin relationships loaded)."""
if not ids: if not ids:
return [] return []
result = await db.execute( result = await db.execute(select(AugmentedPart).where(AugmentedPart.id.in_(ids)))
select(AugmentedPart).where(AugmentedPart.id.in_(ids))
)
return list(result.scalars().all()) return list(result.scalars().all())
@@ -49,6 +85,8 @@ async def list_filtered(
scope_model_id: int | None = None, scope_model_id: int | None = None,
part_type_id: int | None = None, part_type_id: int | None = None,
sub_part_type_id: int | None = None, sub_part_type_id: int | None = None,
sort: str = "description",
direction: str = "asc",
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
) -> list[AugmentedPart]: ) -> list[AugmentedPart]:
@@ -65,9 +103,8 @@ async def list_filtered(
q = q.where(AugmentedPart.part_type_id == part_type_id) q = q.where(AugmentedPart.part_type_id == part_type_id)
if sub_part_type_id is not None: if sub_part_type_id is not None:
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id) q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
result = await db.execute( q = _apply_sort(q, sort, direction)
q.order_by(AugmentedPart.description).offset(skip).limit(limit) result = await db.execute(q.offset(skip).limit(limit))
)
return list(result.scalars().all()) return list(result.scalars().all())
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.augmented_part import AugmentedPart, AugmentedPartNote
from app.models.user import User
async def create(
db: AsyncSession, *, augmented_part: AugmentedPart, author: User, body: str
) -> AugmentedPartNote:
note = AugmentedPartNote(
augmented_part_id=augmented_part.id,
user_id=author.id,
username=author.full_name or author.email,
body=body,
)
db.add(note)
await db.commit()
await db.refresh(note)
return note
+40 -6
View File
@@ -16,14 +16,37 @@ _FIELDS = frozenset(
} }
) )
# Sortable columns for the admin list, keyed by the query-string "sort" value.
SORT_COLUMNS = {
"InventoryID": ImportedPart.InventoryID,
"Description": ImportedPart.Description,
"TBXColour": ImportedPart.TBXColour,
"ProductMaxThreadDepthmm": ImportedPart.ProductMaxThreadDepthmm,
"ProductMaxThreadDepthin": ImportedPart.ProductMaxThreadDepthin,
"ProductODmm": ImportedPart.ProductODmm,
"ProductODin": ImportedPart.ProductODin,
"TBXThreadAttribute": ImportedPart.TBXThreadAttribute,
}
def _apply_sort(q, sort: str, direction: str):
column = SORT_COLUMNS.get(sort, ImportedPart.InventoryID)
order = column.desc() if direction == "desc" else column.asc()
return q.order_by(order, ImportedPart.InventoryID)
async def get(db: AsyncSession, inventory_id: str) -> ImportedPart | None: async def get(db: AsyncSession, inventory_id: str) -> ImportedPart | None:
return await db.get(ImportedPart, inventory_id) return await db.get(ImportedPart, inventory_id)
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 500) -> list[ImportedPart]: async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 500
) -> list[ImportedPart]:
result = await db.execute( result = await db.execute(
select(ImportedPart).order_by(ImportedPart.InventoryID).offset(skip).limit(limit) select(ImportedPart)
.order_by(ImportedPart.InventoryID)
.offset(skip)
.limit(limit)
) )
return list(result.scalars().all()) return list(result.scalars().all())
@@ -31,13 +54,16 @@ async def list_all(db: AsyncSession, skip: int = 0, limit: int = 500) -> list[Im
async def list_filtered( async def list_filtered(
db: AsyncSession, db: AsyncSession,
description: str = "", description: str = "",
sort: str = "InventoryID",
direction: str = "asc",
skip: int = 0, skip: int = 0,
limit: int = 500, limit: int = 500,
) -> list[ImportedPart]: ) -> list[ImportedPart]:
q = select(ImportedPart) q = select(ImportedPart)
if description.strip(): if description.strip():
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%")) q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
result = await db.execute(q.order_by(ImportedPart.InventoryID).offset(skip).limit(limit)) q = _apply_sort(q, sort, direction)
result = await db.execute(q.offset(skip).limit(limit))
return list(result.scalars().all()) return list(result.scalars().all())
@@ -78,7 +104,11 @@ async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
return 0, 0 return 0, 0
clean = [ clean = [
{k: str(v).strip() if v is not None else None for k, v in r.items() if k in _FIELDS} {
k: str(v).strip() if v is not None else None
for k, v in r.items()
if k in _FIELDS
}
for r in records for r in records
if r.get("InventoryID") if r.get("InventoryID")
] ]
@@ -88,7 +118,9 @@ async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
incoming_ids = {r["InventoryID"] for r in clean} incoming_ids = {r["InventoryID"] for r in clean}
result = await db.execute( result = await db.execute(
select(ImportedPart.InventoryID).where(ImportedPart.InventoryID.in_(incoming_ids)) select(ImportedPart.InventoryID).where(
ImportedPart.InventoryID.in_(incoming_ids)
)
) )
existing_ids = {row[0] for row in result.all()} existing_ids = {row[0] for row in result.all()}
@@ -103,7 +135,9 @@ async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
vals = {k: v for k, v in record.items() if k != "InventoryID"} vals = {k: v for k, v in record.items() if k != "InventoryID"}
if vals: if vals:
await db.execute( await db.execute(
update(ImportedPart).where(ImportedPart.InventoryID == inv_id).values(vals) update(ImportedPart)
.where(ImportedPart.InventoryID == inv_id)
.values(vals)
) )
await db.commit() await db.commit()
+2 -1
View File
@@ -8,7 +8,7 @@ from app.models.associations import (
from app.models.attachment_point import AttachmentPoint from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName from app.models.attachment_point_name import AttachmentPointName
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.augmented_part import AugmentedPart from app.models.augmented_part import AugmentedPart, AugmentedPartNote
from app.models.clamp_connection import ClampConnection from app.models.clamp_connection import ClampConnection
from app.models.fit import Fit, FitLabel, FitNote from app.models.fit import Fit, FitLabel, FitNote
from app.models.imported_part import ImportedPart from app.models.imported_part import ImportedPart
@@ -32,6 +32,7 @@ __all__ = [
"AttachmentPointName", "AttachmentPointName",
"AuditLog", "AuditLog",
"AugmentedPart", "AugmentedPart",
"AugmentedPartNote",
"ClampConnection", "ClampConnection",
"Fit", "Fit",
"FitLabel", "FitLabel",
+30 -1
View File
@@ -1,4 +1,6 @@
from sqlalchemy import ForeignKey, String from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
@@ -45,3 +47,30 @@ class AugmentedPart(Base):
scope_model: Mapped[ScopeModel | None] = relationship(lazy="selectin") scope_model: Mapped[ScopeModel | None] = relationship(lazy="selectin")
part_type: Mapped[PartType] = relationship(lazy="selectin") part_type: Mapped[PartType] = relationship(lazy="selectin")
sub_part_type: Mapped[SubPartType | None] = relationship(lazy="selectin") sub_part_type: Mapped[SubPartType | None] = relationship(lazy="selectin")
notes: Mapped[list["AugmentedPartNote"]] = relationship(
back_populates="augmented_part",
lazy="selectin",
order_by="AugmentedPartNote.created_at",
cascade="all, delete-orphan",
)
class AugmentedPartNote(Base):
__tablename__ = "augmented_part_notes"
id: Mapped[int] = mapped_column(primary_key=True)
augmented_part_id: Mapped[int] = mapped_column(
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# Denormalized snapshot of who wrote the note (mirrors FitNote.username), so
# the note stays attributable even if the user is later deleted.
username: Mapped[str] = mapped_column(String(255), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
augmented_part: Mapped[AugmentedPart] = relationship(back_populates="notes")
+26 -1
View File
@@ -1,4 +1,6 @@
from pydantic import BaseModel, ConfigDict from datetime import datetime
from pydantic import BaseModel, ConfigDict, field_validator
from app.schemas.manufacturer import ManufacturerRead from app.schemas.manufacturer import ManufacturerRead
from app.schemas.part_colour import PartColourRead from app.schemas.part_colour import PartColourRead
@@ -7,6 +9,28 @@ from app.schemas.scope_model import ScopeModelRead
from app.schemas.sub_part_type import SubPartTypeRead from app.schemas.sub_part_type import SubPartTypeRead
class AugmentedPartNoteCreate(BaseModel):
body: str
@field_validator("body")
@classmethod
def validate_body(cls, v: str) -> str:
if not v.strip():
raise ValueError("body must not be blank")
return v.strip()
class AugmentedPartNoteRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
augmented_part_id: int
user_id: int | None
username: str
body: str
created_at: datetime
class AugmentedPartBase(BaseModel): class AugmentedPartBase(BaseModel):
sku: str | None = None sku: str | None = None
description: str description: str
@@ -46,3 +70,4 @@ class AugmentedPartRead(AugmentedPartBase):
scope_model: ScopeModelRead | None scope_model: ScopeModelRead | None
part_type: PartTypeRead part_type: PartTypeRead
sub_part_type: SubPartTypeRead | None sub_part_type: SubPartTypeRead | None
notes: list[AugmentedPartNoteRead] = []
+32 -12
View File
@@ -55,7 +55,7 @@ MM_PER_INCH = 25.4
# M50x.75 - No leading digit on floats # M50x.75 - No leading digit on floats
# Float pattern: \d*\.?\d+ # Float pattern: \d*\.?\d+
_ISO_THREAD_RE = re.compile( _ISO_THREAD_RE = re.compile(
r"^M\s*(?P<diameter>\d*\.?\d+)\s*[xX-]\s*P?(?P<pitch>\d*\.?\d+)(?P<extra>.*)$" r"M\s*(?P<diameter>\d*\.?\d+)\s*[xX-]\s*P?(?P<pitch>\d*\.?\d+)(?P<extra>.*)"
) )
# e.g. # e.g.
@@ -65,8 +65,8 @@ _ISO_THREAD_RE = re.compile(
# #
# Leading digit / period, one or more digits, a dash, then # Leading digit / period, one or more digits, a dash, then
_UNC_THREAD_RE = re.compile( _UNC_THREAD_RE = re.compile(
r"^(?P<diameter>\d*\.?\d+)\s*-\s*(?P<tpi>\d*\.?\d+)" r"(?P<diameter>\d*\.?\d+)\s*-\s*(?P<tpi>\d*\.?\d+)"
r"\s*(?P<series>UNEF|UNC|UNF|UNS|UN)?$", r"\s*(?P<series>UNEF|UNC|UNF|UNS|UN)?",
re.IGNORECASE, re.IGNORECASE,
) )
@@ -111,7 +111,9 @@ async def _get_or_create_attachment_point_name(db: AsyncSession, name: str):
return apn return apn
async def _add_attachment_point(db: AsyncSession, ap: AugmentedPart, ip: ImportedPart) -> None: async def _add_attachment_point(
db: AsyncSession, ap: AugmentedPart, ip: ImportedPart
) -> None:
"""Infer and create an attachment point for a newly-synced AugmentedPart. """Infer and create an attachment point for a newly-synced AugmentedPart.
Preference order: TBXThreadAttribute (ISO/UNC thread) then ProductODmm/in Preference order: TBXThreadAttribute (ISO/UNC thread) then ProductODmm/in
@@ -119,7 +121,11 @@ async def _add_attachment_point(db: AsyncSession, ap: AugmentedPart, ip: Importe
attribute is present but unparseable. attribute is present but unparseable.
""" """
thread_value = (ip.TBXThreadAttribute or "").strip() thread_value = (ip.TBXThreadAttribute or "").strip()
parsed = _parse_thread_attribute(thread_value) if thread_value else None parsed = None
if thread_value:
parsed = _parse_thread_attribute(thread_value)
else:
parsed = _parse_thread_attribute(ip.Description or "")
if parsed is not None: if parsed is not None:
kind, fields = parsed kind, fields = parsed
@@ -127,7 +133,9 @@ async def _add_attachment_point(db: AsyncSession, ap: AugmentedPart, ip: Importe
if kind == "iso": if kind == "iso":
depth_mm = _parse_float(ip.ProductMaxThreadDepthmm) depth_mm = _parse_float(ip.ProductMaxThreadDepthmm)
thread = await iso_thread_crud.get_by_values(db, depth_mm=depth_mm, **fields) thread = await iso_thread_crud.get_by_values(
db, depth_mm=depth_mm, **fields
)
if thread is None: if thread is None:
thread = await iso_thread_crud.create(db, depth_mm=depth_mm, **fields) thread = await iso_thread_crud.create(db, depth_mm=depth_mm, **fields)
await attachment_point_crud.create( await attachment_point_crud.create(
@@ -138,7 +146,9 @@ async def _add_attachment_point(db: AsyncSession, ap: AugmentedPart, ip: Importe
) )
elif kind == "unc": elif kind == "unc":
depth_in = _parse_float(ip.ProductMaxThreadDepthin) depth_in = _parse_float(ip.ProductMaxThreadDepthin)
thread = await unc_thread_crud.get_by_values(db, depth_in=depth_in, **fields) thread = await unc_thread_crud.get_by_values(
db, depth_in=depth_in, **fields
)
if thread is None: if thread is None:
thread = await unc_thread_crud.create(db, depth_in=depth_in, **fields) thread = await unc_thread_crud.create(db, depth_in=depth_in, **fields)
await attachment_point_crud.create( await attachment_point_crud.create(
@@ -236,7 +246,9 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# ------------------------------------------------------------------ phase 1 # ------------------------------------------------------------------ phase 1
# IDs referenced in existing associations # IDs referenced in existing associations
result = await db.execute(select(augmented_part_imported_parts.c.imported_part_id).distinct()) result = await db.execute(
select(augmented_part_imported_parts.c.imported_part_id).distinct()
)
all_linked_ids: set[str] = {row[0] for row in result.all()} all_linked_ids: set[str] = {row[0] for row in result.all()}
# IDs that still exist in imported_parts # IDs that still exist in imported_parts
@@ -250,7 +262,9 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# Which AugmentedParts are affected? # Which AugmentedParts are affected?
result = await db.execute( result = await db.execute(
select(augmented_part_imported_parts.c.augmented_part_id).where( select(augmented_part_imported_parts.c.augmented_part_id).where(
augmented_part_imported_parts.c.imported_part_id.in_(orphaned_import_ids) augmented_part_imported_parts.c.imported_part_id.in_(
orphaned_import_ids
)
) )
) )
orphaned_ap_ids: set[int] = {row[0] for row in result.all()} orphaned_ap_ids: set[int] = {row[0] for row in result.all()}
@@ -258,7 +272,9 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# Remove the stale association rows # Remove the stale association rows
await db.execute( await db.execute(
augmented_part_imported_parts.delete().where( augmented_part_imported_parts.delete().where(
augmented_part_imported_parts.c.imported_part_id.in_(orphaned_import_ids) augmented_part_imported_parts.c.imported_part_id.in_(
orphaned_import_ids
)
) )
) )
await db.flush() await db.flush()
@@ -281,7 +297,9 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# ------------------------------------------------------------------ phase 2 # ------------------------------------------------------------------ phase 2
# Re-query linked IDs after phase 1 cleanup # Re-query linked IDs after phase 1 cleanup
result = await db.execute(select(augmented_part_imported_parts.c.imported_part_id).distinct()) result = await db.execute(
select(augmented_part_imported_parts.c.imported_part_id).distinct()
)
now_linked_ids: set[str] = {row[0] for row in result.all()} now_linked_ids: set[str] = {row[0] for row in result.all()}
unlinked_ids = current_ids - now_linked_ids unlinked_ids = current_ids - now_linked_ids
@@ -289,7 +307,9 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
skipped = 0 skipped = 0
if not unlinked_ids: if not unlinked_ids:
return AugmentedPartSyncResult(created=created, deleted=deleted, skipped=skipped) return AugmentedPartSyncResult(
created=created, deleted=deleted, skipped=skipped
)
result = await db.execute( result = await db.execute(
select(ImportedPart).where(ImportedPart.InventoryID.in_(unlinked_ids)) select(ImportedPart).where(ImportedPart.InventoryID.in_(unlinked_ids))
+31
View File
@@ -247,6 +247,37 @@ h2 {
gap: 0.4rem; gap: 0.4rem;
} }
/* Sortable table headers */
table.sortable thead th:not([data-sort-off]) {
cursor: pointer;
user-select: none;
}
table.sortable thead th:not([data-sort-off]):hover {
color: var(--text);
}
table.sortable thead th[aria-sort]::after {
margin-left: 0.3rem;
}
table.sortable thead th[aria-sort="ascending"]::after {
content: "\25B2";
}
table.sortable thead th[aria-sort="descending"]::after {
content: "\25BC";
}
.sort-link {
color: inherit;
text-decoration: none;
}
.sort-link:hover {
text-decoration: underline;
}
/* Tags / definition lists */ /* Tags / definition lists */
.tag { .tag {
display: inline-block; display: inline-block;
+77
View File
@@ -0,0 +1,77 @@
(function () {
"use strict";
function getCellText(row, index) {
var cell = row.children[index];
return cell ? cell.textContent.trim() : "";
}
function isEmptyValue(text) {
return text === "" || text === "—";
}
function isPureNumber(text) {
return /^-?\d+(\.\d+)?$/.test(text);
}
function columnIsNumeric(rows, index) {
var sawValue = false;
for (var i = 0; i < rows.length; i++) {
var text = getCellText(rows[i], index);
if (isEmptyValue(text)) continue;
sawValue = true;
if (!isPureNumber(text)) return false;
}
return sawValue;
}
function compareRows(a, b, index, numeric, dir) {
var textA = getCellText(a, index);
var textB = getCellText(b, index);
var emptyA = isEmptyValue(textA);
var emptyB = isEmptyValue(textB);
if (emptyA && emptyB) return 0;
if (emptyA) return 1;
if (emptyB) return -1;
var result = numeric
? parseFloat(textA) - parseFloat(textB)
: textA.localeCompare(textB, undefined, { numeric: true, sensitivity: "base" });
return dir === "desc" ? -result : result;
}
function sortTable(table, th) {
var thead = table.tHead;
var tbody = table.tBodies[0];
if (!thead || !tbody) return;
var headerRow = thead.rows[0];
var index = Array.prototype.indexOf.call(headerRow.children, th);
if (index === -1) return;
var rows = Array.prototype.slice.call(tbody.rows);
if (rows.length < 2) return;
var nextDir = th.getAttribute("data-sort-dir") === "asc" ? "desc" : "asc";
Array.prototype.forEach.call(headerRow.children, function (cell) {
cell.removeAttribute("data-sort-dir");
cell.removeAttribute("aria-sort");
});
th.setAttribute("data-sort-dir", nextDir);
th.setAttribute("aria-sort", nextDir === "asc" ? "ascending" : "descending");
var numeric = columnIsNumeric(rows, index);
rows.sort(function (a, b) {
return compareRows(a, b, index, numeric, nextDir);
});
rows.forEach(function (row) {
tbody.appendChild(row);
});
}
document.addEventListener("click", function (event) {
var th = event.target.closest("table.sortable thead th");
if (!th || th.hasAttribute("data-sort-off") || th.textContent.trim() === "") return;
sortTable(th.closest("table"), th);
});
})();
+2 -2
View File
@@ -11,7 +11,7 @@
{%- endif -%} {%- endif -%}
{%- endmacro -%} {%- endmacro -%}
<div id="scopes-table-card" class="card" style="overflow-x:auto"> <div id="scopes-table-card" class="card" style="overflow-x:auto">
<table class="table"> <table class="table sortable">
<thead> <thead>
<tr> <tr>
<th>Scope</th> <th>Scope</th>
@@ -21,7 +21,7 @@
<th>Magnification</th> <th>Magnification</th>
<th>Objective thread</th> <th>Objective thread</th>
{% for ct in column_types %} {% for ct in column_types %}
<th>{{ ct.name }}</th> <th data-sort-off>{{ ct.name }}</th>
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>
@@ -1,5 +1,5 @@
<div id="attachment-point-name-list" class="card"> <div id="attachment-point-name-list" class="card">
<table class="table"> <table class="table sortable">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
@@ -20,7 +20,6 @@
{% endif %} {% endif %}
<tr id="attachment-point-{{ ap.id }}"> <tr id="attachment-point-{{ ap.id }}">
<td>{{ ap.id }}</td> <td>{{ ap.id }}</td>
<td>{{ ap.augmented_part.description | truncate(40) }}</td>
<td>{{ ap.attachment_point_name.name }}</td> <td>{{ ap.attachment_point_name.name }}</td>
<td>{{ conn_type }}</td> <td>{{ conn_type }}</td>
<td><code>{{ conn_label }}</code></td> <td><code>{{ conn_label }}</code></td>
@@ -1,9 +1,8 @@
<div id="attachment-point-list" class="card" style="overflow-x:auto"> <div id="attachment-point-list" class="card" style="overflow-x:auto">
<table class="table"> <table class="table sortable">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Augmented Part</th>
<th>Point Name</th> <th>Point Name</th>
<th>Connection Type</th> <th>Connection Type</th>
<th>Connection</th> <th>Connection</th>
@@ -22,6 +22,7 @@
<span class="ap-item-name">{{ apoint.attachment_point_name.name }}</span> <span class="ap-item-name">{{ apoint.attachment_point_name.name }}</span>
<span class="ap-item-type">{{ conn_type }}</span> <span class="ap-item-type">{{ conn_type }}</span>
<code class="ap-item-conn">{{ conn_label }}</code> <code class="ap-item-conn">{{ conn_label }}</code>
{% if can_write_attachment_points %}
<div class="row-actions"> <div class="row-actions">
<button class="btn btn-ghost" <button class="btn btn-ghost"
hx-get="/admin/augmented-parts/{{ apoint.augmented_part_id }}/attachment-points/{{ apoint.id }}/edit" hx-get="/admin/augmented-parts/{{ apoint.augmented_part_id }}/attachment-points/{{ apoint.id }}/edit"
@@ -33,4 +34,5 @@
hx-swap="outerHTML" hx-swap="outerHTML"
hx-confirm="Delete this attachment point?">Delete</button> hx-confirm="Delete this attachment point?">Delete</button>
</div> </div>
{% endif %}
</div> </div>
@@ -7,6 +7,7 @@
<p class="muted" style="font-size:0.85rem;margin:0 0 0.5rem">No attachment points yet.</p> <p class="muted" style="font-size:0.85rem;margin:0 0 0.5rem">No attachment points yet.</p>
{% endfor %} {% endfor %}
{% if can_write_attachment_points %}
<form hx-post="/admin/augmented-parts/{{ augmented_part_id }}/attachment-points" <form hx-post="/admin/augmented-parts/{{ augmented_part_id }}/attachment-points"
hx-target="#ap-{{ augmented_part_id }}-attachment-points" hx-target="#ap-{{ augmented_part_id }}-attachment-points"
hx-swap="outerHTML" hx-swap="outerHTML"
@@ -35,4 +36,5 @@
<div id="ap-{{ augmented_part_id }}-add-connection-options"></div> <div id="ap-{{ augmented_part_id }}-add-connection-options"></div>
<button type="submit" class="btn btn-primary">Add</button> <button type="submit" class="btn btn-primary">Add</button>
</form> </form>
{% endif %}
</div> </div>
@@ -45,6 +45,16 @@
</select> </select>
</label> </label>
<div id="ap-{{ augmented_part_id }}-fit-other-points"></div> <div id="ap-{{ augmented_part_id }}-fit-other-points"></div>
<label>Fit type
<select name="label">
<option value="exact" selected>Exact</option>
<option value="possible">Possible</option>
<option value="none">None</option>
</select>
</label>
<label>Note (optional)
<input type="text" name="note" placeholder="Add a note…">
</label>
<button type="submit" class="btn btn-primary">Add fit</button> <button type="submit" class="btn btn-primary">Add fit</button>
</form> </form>
{% endif %} {% endif %}
@@ -0,0 +1,22 @@
<div id="ap-{{ augmented_part_id }}-notes" class="ap-inline-section">
<h3>Notes</h3>
<div class="fit-notes">
{% for note in notes %}
<div class="fit-note">
<span class="fit-note-meta">{{ note.username }} · {{ note.created_at.strftime("%Y-%m-%d %H:%M") }}</span>
<p class="fit-note-body">{{ note.body }}</p>
</div>
{% else %}
<p class="muted" style="font-size:0.85rem;margin:0 0 0.5rem">No notes recorded yet.</p>
{% endfor %}
{% if can_write_notes %}
<form hx-post="/admin/augmented-parts/{{ augmented_part_id }}/notes"
hx-target="#ap-{{ augmented_part_id }}-notes" hx-swap="outerHTML"
class="inline-edit fit-note-form">
<input type="text" name="body" placeholder="Add a note…" required>
<button type="submit" class="btn btn-ghost">Add note</button>
</form>
{% endif %}
</div>
</div>
@@ -10,6 +10,8 @@
hx-target="#augmented-parts-table-card" hx-target="#augmented-parts-table-card"
hx-trigger="input delay:300ms, change" hx-trigger="input delay:300ms, change"
hx-swap="outerHTML"> hx-swap="outerHTML">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="dir" value="{{ dir }}">
<input type="text" name="description" placeholder="Filter by description…" value="{{ filter_description or '' }}"> <input type="text" name="description" placeholder="Filter by description…" value="{{ filter_description or '' }}">
<select name="colour_id"> <select name="colour_id">
<option value="">All colours</option> <option value="">All colours</option>
@@ -1,16 +1,17 @@
{% from "admin/_sort_macros.html" import sort_th %}
<div id="augmented-parts-table-card" class="card"> <div id="augmented-parts-table-card" class="card">
{% include "admin/_pagination.html" %} {% include "admin/_pagination.html" %}
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>ID</th> {{ sort_th("id", "ID", sort_headers) }}
<th>SKU</th> {{ sort_th("sku", "SKU", sort_headers) }}
<th>Description</th> {{ sort_th("description", "Description", sort_headers) }}
<th>Colour</th> {{ sort_th("colour", "Colour", sort_headers) }}
<th>Manufacturer</th> {{ sort_th("manufacturer", "Manufacturer", sort_headers) }}
<th>Model</th> {{ sort_th("scope_model", "Model", sort_headers) }}
<th>Type</th> {{ sort_th("part_type", "Type", sort_headers) }}
<th>Sub Type</th> {{ sort_th("sub_part_type", "Sub Type", sort_headers) }}
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -1,5 +1,5 @@
<div id="clamp-connection-list" class="card"> <div id="clamp-connection-list" class="card">
<table class="table"> <table class="table sortable">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
@@ -1,6 +1,13 @@
{% if points %}
<select name="other_attachment_point_id" required> <select name="other_attachment_point_id" required>
<option value="">— select —</option> <option value="">— select —</option>
{% for p in points %} {% for p in points %}
<option value="{{ p.id }}">{{ p.attachment_point_name.name }}</option> <option value="{{ p.id }}">{{ p.attachment_point_name.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
{% else %}
<input type="hidden" name="other_attachment_point_id" value="">
<p class="muted" style="font-size:0.85rem;margin:0">
This part has no attachment points yet — one matching yours will be created automatically.
</p>
{% endif %}
@@ -11,6 +11,8 @@
hx-target="#imported-parts-table-card" hx-target="#imported-parts-table-card"
hx-trigger="input delay:300ms, change" hx-trigger="input delay:300ms, change"
hx-swap="outerHTML"> hx-swap="outerHTML">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="dir" value="{{ dir }}">
<input type="text" name="description" placeholder="Filter by description…" value="{{ filter_description or '' }}"> <input type="text" name="description" placeholder="Filter by description…" value="{{ filter_description or '' }}">
</form> </form>
</div> </div>
@@ -1,16 +1,17 @@
{% from "admin/_sort_macros.html" import sort_th %}
<div id="imported-parts-table-card" class="card" style="overflow-x:auto"> <div id="imported-parts-table-card" class="card" style="overflow-x:auto">
{% include "admin/_pagination.html" %} {% include "admin/_pagination.html" %}
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>InventoryID</th> {{ sort_th("InventoryID", "InventoryID", sort_headers) }}
<th>Description</th> {{ sort_th("Description", "Description", sort_headers) }}
<th>TBX Colour</th> {{ sort_th("TBXColour", "TBX Colour", sort_headers) }}
<th>Max Thread Depth (mm)</th> {{ sort_th("ProductMaxThreadDepthmm", "Max Thread Depth (mm)", sort_headers) }}
<th>Max Thread Depth (in)</th> {{ sort_th("ProductMaxThreadDepthin", "Max Thread Depth (in)", sort_headers) }}
<th>OD (mm)</th> {{ sort_th("ProductODmm", "OD (mm)", sort_headers) }}
<th>OD (in)</th> {{ sort_th("ProductODin", "OD (in)", sort_headers) }}
<th>Thread Attribute</th> {{ sort_th("TBXThreadAttribute", "Thread Attribute", sort_headers) }}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1,8 +1,8 @@
<div id="manufacturer-list" class="card"> <div id="manufacturer-list" class="card">
<table class="table"> <table class="table sortable">
<thead> <thead>
<tr> <tr>
<th>ID</th><th>Name</th><th>Homepage</th><th>Logo</th><th></th> <th>ID</th><th>Name</th><th>Homepage</th><th data-sort-off>Logo</th><th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
+1 -1
View File
@@ -1,5 +1,5 @@
<div id="part-colour-list" class="card"> <div id="part-colour-list" class="card">
<table class="table"> <table class="table sortable">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
+1 -1
View File
@@ -1,5 +1,5 @@
<div id="part-type-list" class="card"> <div id="part-type-list" class="card">
<table class="table"> <table class="table sortable">
<thead> <thead>
<tr> <tr>
<th>ID</th><th>Name</th><th></th> <th>ID</th><th>Name</th><th></th>
+2 -2
View File
@@ -1,7 +1,7 @@
<div id="role-list" class="card"> <div id="role-list" class="card">
<table class="table"> <table class="table sortable">
<thead> <thead>
<tr><th>ID</th><th>Name</th><th>Description</th><th>Permissions</th><th>Default</th><th></th></tr> <tr><th>ID</th><th>Name</th><th>Description</th><th data-sort-off>Permissions</th><th data-sort-off>Default</th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
{% for r in roles %} {% for r in roles %}
+1 -1
View File
@@ -1,5 +1,5 @@
<div id="scope-model-list" class="card"> <div id="scope-model-list" class="card">
<table class="table"> <table class="table sortable">
<thead> <thead>
<tr> <tr>
<th>ID</th><th>Name</th><th>Manufacturer</th><th>Product Page</th><th></th> <th>ID</th><th>Name</th><th>Manufacturer</th><th>Product Page</th><th></th>
+13
View File
@@ -0,0 +1,13 @@
{% macro sort_th(key, label, sort_headers) %}
{% if sort_headers and key in sort_headers %}
{% set h = sort_headers[key] %}
<th>
<a href="#" class="sort-link{{ ' active' if h.active else '' }}"
hx-get="{{ h.url }}"
hx-target="{{ h.target_id }}"
hx-swap="outerHTML">{{ label }}{% if h.active %}<span aria-hidden="true">{{ ' ▲' if h.dir == 'asc' else ' ▼' }}</span>{% endif %}</a>
</th>
{% else %}
<th>{{ label }}</th>
{% endif %}
{% endmacro %}
@@ -1,5 +1,5 @@
<div id="static-connection-list" class="card"> <div id="static-connection-list" class="card">
<table class="table"> <table class="table sortable">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
@@ -1,5 +1,5 @@
<div id="sub-part-type-list" class="card"> <div id="sub-part-type-list" class="card">
<table class="table"> <table class="table sortable">
<thead> <thead>
<tr> <tr>
<th>ID</th><th>Name</th><th>Parent Type</th><th></th> <th>ID</th><th>Name</th><th>Parent Type</th><th></th>
+2 -2
View File
@@ -1,8 +1,8 @@
<div id="user-list" class="card"> <div id="user-list" class="card">
<table class="table"> <table class="table sortable">
<thead> <thead>
<tr> <tr>
<th>ID</th><th>Email</th><th>Name</th><th>Active</th><th>Superuser</th><th>Roles</th><th></th> <th>ID</th><th>Email</th><th>Name</th><th>Active</th><th>Superuser</th><th data-sort-off>Roles</th><th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
+1 -1
View File
@@ -7,7 +7,7 @@
<div class="card"> <div class="card">
{% if entries %} {% if entries %}
<table class="table"> <table class="table sortable">
<thead> <thead>
<tr> <tr>
<th>Time</th> <th>Time</th>
+8 -24
View File
@@ -3,20 +3,6 @@
{% block body %} {% block body %}
{% include "_nav.html" %} {% include "_nav.html" %}
{%- macro conn_desc(point) -%}
{%- if point.iso_thread -%}
ISO M{{ point.iso_thread.diameter_mm }}×{{ point.iso_thread.pitch_mm }}
{%- elif point.unc_thread -%}
UNC {{ point.unc_thread.diameter_fraction }}-{{ point.unc_thread.tpi }} {{ point.unc_thread.series.value }}
{%- elif point.clamp_connection -%}
Clamp {{ point.clamp_connection.min_mm if point.clamp_connection.min_mm is not none else point.clamp_connection.min_in }}–{{ point.clamp_connection.max_mm if point.clamp_connection.max_mm is not none else point.clamp_connection.max_in }}
{%- elif point.static_connection -%}
Static {{ point.static_connection.od_mm }} mm OD
{%- else -%}
—
{%- endif -%}
{%- endmacro -%}
<main class="container"> <main class="container">
<p><a href="/admin/augmented-parts" class="btn btn-ghost">← Augmented Parts</a></p> <p><a href="/admin/augmented-parts" class="btn btn-ghost">← Augmented Parts</a></p>
@@ -62,24 +48,22 @@
</dl> </dl>
</div> </div>
{% endif %} {% endif %}
</div>
{% if can_read_attachment_points %}
<div class="card"> <div class="card">
<h2>Attachment points</h2> {% include "admin/_augmented_part_attachment_points.html" %}
{% for point in attachment_points %}
<div class="ap-item">
<span class="ap-item-name">{{ point.attachment_point_name.name }}</span>
<code class="ap-item-conn">{{ conn_desc(point) }}</code>
</div>
{% else %}
<p class="muted">No attachment points recorded.</p>
{% endfor %}
</div>
</div> </div>
{% endif %}
{% if can_read_fits %} {% if can_read_fits %}
<div class="card"> <div class="card">
{% include "admin/_augmented_part_fits.html" %} {% include "admin/_augmented_part_fits.html" %}
</div> </div>
{% endif %} {% endif %}
<div class="card">
{% include "admin/_augmented_part_notes.html" %}
</div>
</main> </main>
{% endblock %} {% endblock %}
+1
View File
@@ -8,6 +8,7 @@
<script src="https://unpkg.com/htmx.org@1.9.12" <script src="https://unpkg.com/htmx.org@1.9.12"
integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2" integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="/static/js/sortable-tables.js" defer></script>
</head> </head>
<body> <body>
{% block body %}{% endblock %} {% block body %}{% endblock %}
+341 -82
View File
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import augmented_part as augmented_part_crud from app.crud import augmented_part as augmented_part_crud
from app.crud import augmented_part_note as augmented_part_note_crud
from app.crud import imported_part as imported_part_crud from app.crud import imported_part as imported_part_crud
from app.crud import imported_part_config as imported_part_config_crud from app.crud import imported_part_config as imported_part_config_crud
from app.crud import iso_thread as iso_thread_crud from app.crud import iso_thread as iso_thread_crud
@@ -52,6 +53,11 @@ router = APIRouter(prefix="/admin", include_in_schema=False)
_PAGE_SIZE = 50 _PAGE_SIZE = 50
def _query_url(base_path: str, params: dict) -> str:
clean = {k: v for k, v in params.items() if v not in (None, "", 0)}
return f"{base_path}?{urllib.parse.urlencode(clean)}"
def _paginate( def _paginate(
base_path: str, base_path: str,
filter_params: dict, filter_params: dict,
@@ -65,10 +71,9 @@ def _paginate(
skip = (page - 1) * page_size skip = (page - 1) * page_size
def _url(p: int) -> str: def _url(p: int) -> str:
params = {k: v for k, v in filter_params.items() if v not in (None, "", 0)} return _query_url(
params["page"] = p base_path, {**filter_params, "page": p, "page_size": page_size}
params["page_size"] = page_size )
return f"{base_path}?{urllib.parse.urlencode(params)}"
return { return {
"page": page, "page": page,
@@ -84,6 +89,33 @@ def _paginate(
} }
def _sort_headers(
base_path: str,
filter_params: dict,
columns: list[str],
sort: str,
direction: str,
target_id: str,
) -> dict:
"""Per-column header link context for click-to-sort table headers.
``filter_params`` should NOT include "sort"/"dir"/"page" — those are added here.
"""
headers = {}
for key in columns:
next_dir = "desc" if sort == key and direction == "asc" else "asc"
headers[key] = {
"url": _query_url(
base_path,
{**filter_params, "sort": key, "dir": next_dir, "page": 1},
),
"active": sort == key,
"dir": direction if sort == key else None,
"target_id": target_id,
}
return headers
def web_require(*perms: str): def web_require(*perms: str):
"""Web dependency: allow superusers or holders of all given permissions.""" """Web dependency: allow superusers or holders of all given permissions."""
@@ -1205,10 +1237,50 @@ async def delete_sub_part_type(
# --- Imported Parts ---------------------------------------------------------- # --- Imported Parts ----------------------------------------------------------
_IMPORTED_PARTS_SORT_COLUMNS = [
"InventoryID",
"Description",
"TBXColour",
"ProductMaxThreadDepthmm",
"ProductMaxThreadDepthin",
"ProductODmm",
"ProductODin",
"TBXThreadAttribute",
]
async def _default_imported_parts_table_ctx(db: AsyncSession) -> dict:
"""Table-card context (default sort, no filter, page 1) after a refresh."""
total = await imported_part_crud.count(db)
pagination = _paginate(
"/admin/imported-parts/filter",
{},
1,
_PAGE_SIZE,
total,
"#imported-parts-table-card",
)
return {
"parts": await imported_part_crud.list_filtered(db, limit=_PAGE_SIZE),
"pagination": pagination,
"sort_headers": _sort_headers(
"/admin/imported-parts/filter",
{},
_IMPORTED_PARTS_SORT_COLUMNS,
"InventoryID",
"asc",
"#imported-parts-table-card",
),
"sort": "InventoryID",
"dir": "asc",
}
@router.get("/imported-parts", response_class=HTMLResponse) @router.get("/imported-parts", response_class=HTMLResponse)
async def imported_parts_page( async def imported_parts_page(
request: Request, request: Request,
sort: str = "InventoryID",
dir: str = "asc",
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("imported_parts:read")), user: User = Depends(web_require("imported_parts:read")),
): ):
@@ -1219,19 +1291,37 @@ async def imported_parts_page(
"has_password": bool(config and config.password), "has_password": bool(config and config.password),
} }
total = await imported_part_crud.count(db) total = await imported_part_crud.count(db)
parts = await imported_part_crud.list_filtered(db, limit=_PAGE_SIZE) parts = await imported_part_crud.list_filtered(
db, sort=sort, direction=dir, limit=_PAGE_SIZE
)
pagination = _paginate( pagination = _paginate(
"/admin/imported-parts/filter", "/admin/imported-parts/filter",
{}, {"sort": sort, "dir": dir},
1, 1,
_PAGE_SIZE, _PAGE_SIZE,
total, total,
"#imported-parts-table-card", "#imported-parts-table-card",
) )
sort_headers = _sort_headers(
"/admin/imported-parts/filter",
{},
_IMPORTED_PARTS_SORT_COLUMNS,
sort,
dir,
"#imported-parts-table-card",
)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/imported_parts.html", "admin/imported_parts.html",
{"user": user, "config": config_view, "parts": parts, "pagination": pagination}, {
"user": user,
"config": config_view,
"parts": parts,
"pagination": pagination,
"sort_headers": sort_headers,
"sort": sort,
"dir": dir,
},
) )
@@ -1273,19 +1363,8 @@ async def refresh_imported_parts(
config = await imported_part_config_crud.get(db) config = await imported_part_config_crud.get(db)
async def _imported_table_ctx(sync_result=None, sync_error=None): async def _imported_table_ctx(sync_result=None, sync_error=None):
total = await imported_part_crud.count(db)
parts = await imported_part_crud.list_filtered(db, limit=_PAGE_SIZE)
pagination = _paginate(
"/admin/imported-parts/filter",
{},
1,
_PAGE_SIZE,
total,
"#imported-parts-table-card",
)
return { return {
"parts": parts, **await _default_imported_parts_table_ctx(db),
"pagination": pagination,
"sync_result": sync_result, "sync_result": sync_result,
"sync_error": sync_error, "sync_error": sync_error,
} }
@@ -1328,15 +1407,18 @@ async def refresh_imported_parts(
async def filter_imported_parts( async def filter_imported_parts(
request: Request, request: Request,
description: str = "", description: str = "",
sort: str = "InventoryID",
dir: str = "asc",
page: int = 1, page: int = 1,
page_size: int = _PAGE_SIZE, page_size: int = _PAGE_SIZE,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("imported_parts:read")), _: User = Depends(web_require("imported_parts:read")),
): ):
total = await imported_part_crud.count_filtered(db, description=description) total = await imported_part_crud.count_filtered(db, description=description)
filter_params = {"description": description}
pagination = _paginate( pagination = _paginate(
"/admin/imported-parts/filter", "/admin/imported-parts/filter",
{"description": description}, {**filter_params, "sort": sort, "dir": dir},
page, page,
page_size, page_size,
total, total,
@@ -1345,13 +1427,29 @@ async def filter_imported_parts(
parts = await imported_part_crud.list_filtered( parts = await imported_part_crud.list_filtered(
db, db,
description=description, description=description,
sort=sort,
direction=dir,
skip=pagination["skip"], skip=pagination["skip"],
limit=page_size, limit=page_size,
) )
sort_headers = _sort_headers(
"/admin/imported-parts/filter",
filter_params,
_IMPORTED_PARTS_SORT_COLUMNS,
sort,
dir,
"#imported-parts-table-card",
)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_imported_parts_table_card.html", "admin/_imported_parts_table_card.html",
{"parts": parts, "pagination": pagination}, {
"parts": parts,
"pagination": pagination,
"sort_headers": sort_headers,
"sort": sort,
"dir": dir,
},
) )
@@ -1430,18 +1528,22 @@ async def _augmented_part_form_ctx(db: AsyncSession) -> dict:
} }
@router.get("/augmented-parts", response_class=HTMLResponse) _AUGMENTED_PARTS_SORT_COLUMNS = [
async def augmented_parts_page( "id",
request: Request, "sku",
db: AsyncSession = Depends(get_db), "description",
user: User = Depends(web_require("augmented_parts:read")), "colour",
): "manufacturer",
ctx = await _augmented_part_form_ctx(db) "scope_model",
"part_type",
"sub_part_type",
]
async def _default_augmented_parts_table_ctx(db: AsyncSession) -> dict:
"""Table-card context (default sort, no filters, page 1) after create/sync actions."""
total = await augmented_part_crud.count_filtered(db) total = await augmented_part_crud.count_filtered(db)
ctx["augmented_parts"] = await augmented_part_crud.list_filtered( pagination = _paginate(
db, limit=_PAGE_SIZE
)
ctx["pagination"] = _paginate(
"/admin/augmented-parts/filter", "/admin/augmented-parts/filter",
{}, {},
1, 1,
@@ -1449,6 +1551,55 @@ async def augmented_parts_page(
total, total,
"#augmented-parts-table-card", "#augmented-parts-table-card",
) )
return {
"augmented_parts": await augmented_part_crud.list_filtered(
db, limit=_PAGE_SIZE
),
"pagination": pagination,
"sort_headers": _sort_headers(
"/admin/augmented-parts/filter",
{},
_AUGMENTED_PARTS_SORT_COLUMNS,
"description",
"asc",
"#augmented-parts-table-card",
),
"sort": "description",
"dir": "asc",
}
@router.get("/augmented-parts", response_class=HTMLResponse)
async def augmented_parts_page(
request: Request,
sort: str = "description",
dir: str = "asc",
db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("augmented_parts:read")),
):
ctx = await _augmented_part_form_ctx(db)
total = await augmented_part_crud.count_filtered(db)
ctx["augmented_parts"] = await augmented_part_crud.list_filtered(
db, sort=sort, direction=dir, limit=_PAGE_SIZE
)
ctx["pagination"] = _paginate(
"/admin/augmented-parts/filter",
{"sort": sort, "dir": dir},
1,
_PAGE_SIZE,
total,
"#augmented-parts-table-card",
)
ctx["sort_headers"] = _sort_headers(
"/admin/augmented-parts/filter",
{},
_AUGMENTED_PARTS_SORT_COLUMNS,
sort,
dir,
"#augmented-parts-table-card",
)
ctx["sort"] = sort
ctx["dir"] = dir
ctx["user"] = user ctx["user"] = user
return templates.TemplateResponse(request, "admin/augmented_parts.html", ctx) return templates.TemplateResponse(request, "admin/augmented_parts.html", ctx)
@@ -1487,25 +1638,10 @@ async def create_augmented_part(
), ),
) )
form_ctx = await _augmented_part_form_ctx(db) form_ctx = await _augmented_part_form_ctx(db)
total = await augmented_part_crud.count_filtered(db)
pagination = _paginate(
"/admin/augmented-parts/filter",
{},
1,
_PAGE_SIZE,
total,
"#augmented-parts-table-card",
)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_augmented_parts_table.html", "admin/_augmented_parts_table.html",
{ {**form_ctx, **await _default_augmented_parts_table_ctx(db)},
**form_ctx,
"augmented_parts": await augmented_part_crud.list_filtered(
db, limit=_PAGE_SIZE
),
"pagination": pagination,
},
) )
@@ -1542,24 +1678,12 @@ async def sync_augmented_parts(
sync_result = None sync_result = None
sync_error = f"Sync failed: {exc}" sync_error = f"Sync failed: {exc}"
form_ctx = await _augmented_part_form_ctx(db) form_ctx = await _augmented_part_form_ctx(db)
total = await augmented_part_crud.count_filtered(db)
pagination = _paginate(
"/admin/augmented-parts/filter",
{},
1,
_PAGE_SIZE,
total,
"#augmented-parts-table-card",
)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_augmented_parts_table.html", "admin/_augmented_parts_table.html",
{ {
**form_ctx, **form_ctx,
"augmented_parts": await augmented_part_crud.list_filtered( **await _default_augmented_parts_table_ctx(db),
db, limit=_PAGE_SIZE
),
"pagination": pagination,
"sync_result": sync_result, "sync_result": sync_result,
"sync_error": sync_error, "sync_error": sync_error,
}, },
@@ -1575,6 +1699,8 @@ async def filter_augmented_parts(
scope_model_id: str = "", scope_model_id: str = "",
part_type_id: str = "", part_type_id: str = "",
sub_part_type_id: str = "", sub_part_type_id: str = "",
sort: str = "description",
dir: str = "asc",
page: int = 1, page: int = 1,
page_size: int = _PAGE_SIZE, page_size: int = _PAGE_SIZE,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -1604,7 +1730,7 @@ async def filter_augmented_parts(
} }
pagination = _paginate( pagination = _paginate(
"/admin/augmented-parts/filter", "/admin/augmented-parts/filter",
filter_params, {**filter_params, "sort": sort, "dir": dir},
page, page,
page_size, page_size,
total, total,
@@ -1618,13 +1744,29 @@ async def filter_augmented_parts(
scope_model_id=scope_model_id_int, scope_model_id=scope_model_id_int,
part_type_id=part_type_id_int, part_type_id=part_type_id_int,
sub_part_type_id=sub_part_type_id_int, sub_part_type_id=sub_part_type_id_int,
sort=sort,
direction=dir,
skip=pagination["skip"], skip=pagination["skip"],
limit=page_size, limit=page_size,
) )
sort_headers = _sort_headers(
"/admin/augmented-parts/filter",
filter_params,
_AUGMENTED_PARTS_SORT_COLUMNS,
sort,
dir,
"#augmented-parts-table-card",
)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_augmented_parts_table_card.html", "admin/_augmented_parts_table_card.html",
{"augmented_parts": parts, "pagination": pagination}, {
"augmented_parts": parts,
"pagination": pagination,
"sort_headers": sort_headers,
"sort": sort,
"dir": dir,
},
) )
@@ -1792,17 +1934,29 @@ async def augmented_part_detail(
if ap is None: if ap is None:
raise HTTPException(status_code=404, detail="Augmented part not found") raise HTTPException(status_code=404, detail="Augmented part not found")
can_read_fits = user.is_superuser or "fits:read" in user.permissions can_read_fits = user.is_superuser or "fits:read" in user.permissions
can_read_attachment_points = (
user.is_superuser or "attachment_points:read" in user.permissions
)
ctx = { ctx = {
"user": user, "user": user,
"ap": ap, "ap": ap,
"scope_info": await scope_info_crud.get_by_augmented_part( "scope_info": await scope_info_crud.get_by_augmented_part(
db, augmented_part_id db, augmented_part_id
), ),
"attachment_points": await attachment_point_crud.list_for_augmented_part(
db, augmented_part_id
),
"can_read_fits": can_read_fits, "can_read_fits": can_read_fits,
"can_read_attachment_points": can_read_attachment_points,
"augmented_part_id": augmented_part_id,
"notes": ap.notes,
"can_write_notes": _can_write_augmented_part_notes(user),
} }
if can_read_attachment_points:
ctx.update(
await _augmented_part_ap_ctx(
db,
augmented_part_id,
can_write=_can_write_attachment_points(user),
)
)
if can_read_fits: if can_read_fits:
ctx.update( ctx.update(
await _augmented_part_fits_ctx( await _augmented_part_fits_ctx(
@@ -1909,7 +2063,13 @@ async def delete_augmented_part(
return Response(status_code=status.HTTP_200_OK) return Response(status_code=status.HTTP_200_OK)
async def _augmented_part_ap_ctx(db: AsyncSession, augmented_part_id: int) -> dict: def _can_write_attachment_points(user: User) -> bool:
return user.is_superuser or "attachment_points:write" in user.permissions
async def _augmented_part_ap_ctx(
db: AsyncSession, augmented_part_id: int, *, can_write: bool = True
) -> dict:
return { return {
"augmented_part_id": augmented_part_id, "augmented_part_id": augmented_part_id,
"attachment_points_list": await attachment_point_crud.list_for_augmented_part( "attachment_points_list": await attachment_point_crud.list_for_augmented_part(
@@ -1920,6 +2080,7 @@ async def _augmented_part_ap_ctx(db: AsyncSession, augmented_part_id: int) -> di
"unc_threads": await unc_thread_crud.list_all(db), "unc_threads": await unc_thread_crud.list_all(db),
"clamp_connections": await clamp_connection_crud.list_all(db), "clamp_connections": await clamp_connection_crud.list_all(db),
"static_connections": await static_connection_crud.list_all(db), "static_connections": await static_connection_crud.list_all(db),
"can_write_attachment_points": can_write,
} }
@@ -1931,9 +2092,11 @@ async def augmented_part_attachment_points(
request: Request, request: Request,
augmented_part_id: int, augmented_part_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("attachment_points:read")), user: User = Depends(web_require("attachment_points:read")),
): ):
ctx = await _augmented_part_ap_ctx(db, augmented_part_id) ctx = await _augmented_part_ap_ctx(
db, augmented_part_id, can_write=_can_write_attachment_points(user)
)
return templates.TemplateResponse( return templates.TemplateResponse(
request, "admin/_augmented_part_attachment_points.html", ctx request, "admin/_augmented_part_attachment_points.html", ctx
) )
@@ -1976,7 +2139,9 @@ async def create_augmented_part_attachment_point(
"AttachmentPoint", apoint.id, f"part {augmented_part_id}" "AttachmentPoint", apoint.id, f"part {augmented_part_id}"
), ),
) )
ctx = await _augmented_part_ap_ctx(db, augmented_part_id) ctx = await _augmented_part_ap_ctx(
db, augmented_part_id, can_write=_can_write_attachment_points(actor)
)
return templates.TemplateResponse( return templates.TemplateResponse(
request, "admin/_augmented_part_attachment_points.html", ctx request, "admin/_augmented_part_attachment_points.html", ctx
) )
@@ -2022,13 +2187,18 @@ async def augmented_part_attachment_point_row(
augmented_part_id: int, augmented_part_id: int,
attachment_point_id: int, attachment_point_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("attachment_points:read")), user: User = Depends(web_require("attachment_points:read")),
): ):
apoint = await attachment_point_crud.get(db, attachment_point_id) apoint = await attachment_point_crud.get(db, attachment_point_id)
if apoint is None: if apoint is None:
raise HTTPException(status_code=404, detail="Attachment point not found") raise HTTPException(status_code=404, detail="Attachment point not found")
return templates.TemplateResponse( return templates.TemplateResponse(
request, "admin/_augmented_part_ap_row.html", {"apoint": apoint} request,
"admin/_augmented_part_ap_row.html",
{
"apoint": apoint,
"can_write_attachment_points": _can_write_attachment_points(user),
},
) )
@@ -2075,7 +2245,12 @@ async def update_augmented_part_attachment_point(
), ),
) )
return templates.TemplateResponse( return templates.TemplateResponse(
request, "admin/_augmented_part_ap_row.html", {"apoint": apoint} request,
"admin/_augmented_part_ap_row.html",
{
"apoint": apoint,
"can_write_attachment_points": _can_write_attachment_points(actor),
},
) )
@@ -2103,7 +2278,9 @@ async def delete_augmented_part_attachment_point(
"AttachmentPoint", attachment_point_id, f"part {augmented_part_id}" "AttachmentPoint", attachment_point_id, f"part {augmented_part_id}"
), ),
) )
ctx = await _augmented_part_ap_ctx(db, augmented_part_id) ctx = await _augmented_part_ap_ctx(
db, augmented_part_id, can_write=_can_write_attachment_points(actor)
)
return templates.TemplateResponse( return templates.TemplateResponse(
request, "admin/_augmented_part_attachment_points.html", ctx request, "admin/_augmented_part_attachment_points.html", ctx
) )
@@ -2236,24 +2413,64 @@ async def create_augmented_part_fit(
request: Request, request: Request,
augmented_part_id: int, augmented_part_id: int,
own_attachment_point_id: int = Form(...), own_attachment_point_id: int = Form(...),
other_augmented_part_id: str = Form(""),
other_attachment_point_id: str = Form(""), other_attachment_point_id: str = Form(""),
label: FitLabel = Form(FitLabel.EXACT),
note: str = Form(""),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("fits:write")), actor: User = Depends(web_require("fits:write")),
): ):
error = None error = None
if not other_attachment_point_id: auto_created_ap = None
ap_a = await attachment_point_crud.get(db, own_attachment_point_id)
ap_b = None
if ap_a is None:
error = "Attachment point not found."
elif other_attachment_point_id:
ap_b = await attachment_point_crud.get(db, int(other_attachment_point_id))
if ap_b is None:
error = "Attachment point not found."
elif other_augmented_part_id:
other_part = await augmented_part_crud.get(db, int(other_augmented_part_id))
if other_part is None:
error = "Part not found."
elif await attachment_point_crud.list_for_augmented_part(db, other_part.id):
error = "Select the other part's attachment point." error = "Select the other part's attachment point."
else: else:
ap_a = await attachment_point_crud.get(db, own_attachment_point_id) ap_b = await attachment_point_crud.create(
ap_b = await attachment_point_crud.get(db, int(other_attachment_point_id)) db,
if ap_a is None or ap_b is None: augmented_part_id=other_part.id,
error = "Attachment point not found." attachment_point_name_id=ap_a.attachment_point_name_id,
elif ap_a.augmented_part_id == ap_b.augmented_part_id: iso_thread_id=ap_a.iso_thread_id,
unc_thread_id=ap_a.unc_thread_id,
clamp_connection_id=ap_a.clamp_connection_id,
static_connection_id=ap_a.static_connection_id,
)
auto_created_ap = ap_b
else:
error = "Select the other part's attachment point."
if error is None and ap_b is not None:
if ap_a.augmented_part_id == ap_b.augmented_part_id:
error = "A part cannot fit itself." error = "A part cannot fit itself."
elif await fit_crud.get_by_attachment_points(db, ap_a, ap_b) is not None: elif await fit_crud.get_by_attachment_points(db, ap_a, ap_b) is not None:
error = "That fit already exists." error = "That fit already exists."
else: else:
fit = await fit_crud.create(db, ap_a=ap_a, ap_b=ap_b, label=FitLabel.EXACT) if auto_created_ap is not None:
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="AttachmentPoint",
resource_id=auto_created_ap.id,
description=audit_crud.describe_create(
"AttachmentPoint",
auto_created_ap.id,
f"auto-created on AugmentedPart #{auto_created_ap.augmented_part_id} "
f"to match attachment point {ap_a.id}",
),
)
fit = await fit_crud.create(db, ap_a=ap_a, ap_b=ap_b, label=label)
await audit_crud.log( await audit_crud.log(
db, db,
actor=actor, actor=actor,
@@ -2261,9 +2478,14 @@ async def create_augmented_part_fit(
resource_type="Fit", resource_type="Fit",
resource_id=fit.id, resource_id=fit.id,
description=audit_crud.describe_create( description=audit_crud.describe_create(
"Fit", fit.id, f"attachment points {ap_a.id}↔{ap_b.id}" "Fit",
fit.id,
f"attachment points {ap_a.id}↔{ap_b.id} ({label.value})",
), ),
) )
if note.strip():
await fit_note_crud.create(db, fit=fit, author=actor, body=note.strip())
await db.refresh(fit, attribute_names=["notes"])
ctx = await _augmented_part_fits_ctx( ctx = await _augmented_part_fits_ctx(
db, augmented_part_id, can_write=_can_write_fits(actor), error=error db, augmented_part_id, can_write=_can_write_fits(actor), error=error
) )
@@ -2389,6 +2611,43 @@ async def recompute_all_fits(
) )
# --- Augmented Part Notes -------------------------------------------------
def _can_write_augmented_part_notes(user: User) -> bool:
return user.is_superuser or "augmented_parts:write" in user.permissions
@router.post(
"/augmented-parts/{augmented_part_id}/notes",
response_class=HTMLResponse,
)
async def create_augmented_part_note(
request: Request,
augmented_part_id: int,
body: str = Form(...),
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("augmented_parts:write")),
):
ap = await augmented_part_crud.get(db, augmented_part_id)
if ap is None:
raise HTTPException(status_code=404, detail="Augmented part not found")
if body.strip():
await augmented_part_note_crud.create(
db, augmented_part=ap, author=actor, body=body.strip()
)
await db.refresh(ap, attribute_names=["notes"])
return templates.TemplateResponse(
request,
"admin/_augmented_part_notes.html",
{
"augmented_part_id": augmented_part_id,
"notes": ap.notes,
"can_write_notes": _can_write_augmented_part_notes(actor),
},
)
# --- Part Colours ------------------------------------------------------------ # --- Part Colours ------------------------------------------------------------
+148
View File
@@ -0,0 +1,148 @@
import pytest
from app.crud import augmented_part as augmented_part_crud
from app.models.augmented_part import AugmentedPart
from app.models.imported_part import ImportedPart
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
async def _web_login(client, email, password="secret123"):
resp = await client.post("/login", data={"email": email, "password": password})
assert resp.status_code == 303
async def _seed_augmented_parts(db):
part_t = PartType(name="Scope")
db.add(part_t)
await db.flush()
parts = [
AugmentedPart(
description=desc,
sku=sku,
colour_id=1,
manufacturer_id=1,
part_type_id=part_t.id,
)
for desc, sku in [
("Alpha scope", "SKU-C"),
("Bravo scope", "SKU-A"),
("Charlie scope", "SKU-B"),
]
]
db.add_all(parts)
await db.commit()
async def _seed_imported_parts(db):
db.add_all(
[
ImportedPart(InventoryID="INV-2", Description="Charlie widget"),
ImportedPart(InventoryID="INV-1", Description="Alpha widget"),
ImportedPart(InventoryID="INV-3", Description="Bravo widget"),
]
)
await db.commit()
def _order_of(body: str, needles: list[str]) -> list[str]:
positions = {n: body.find(n) for n in needles}
return sorted(positions, key=lambda n: positions[n])
@pytest.mark.asyncio
async def test_augmented_parts_page_sorts_by_description_desc(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
await _seed_augmented_parts(db)
await make_user("reader@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "reader@example.com")
resp = await client.get("/admin/augmented-parts?sort=description&dir=desc")
assert resp.status_code == 200
body = resp.text
assert _order_of(body, ["Charlie scope", "Bravo scope", "Alpha scope"]) == [
"Charlie scope",
"Bravo scope",
"Alpha scope",
]
@pytest.mark.asyncio
async def test_augmented_parts_filter_sorts_by_sku_asc(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
await _seed_augmented_parts(db)
await make_user("reader2@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "reader2@example.com")
resp = await client.get("/admin/augmented-parts/filter?sort=sku&dir=asc")
assert resp.status_code == 200
body = resp.text
# SKU-A (Bravo), SKU-B (Charlie), SKU-C (Alpha)
assert _order_of(body, ["Bravo scope", "Charlie scope", "Alpha scope"]) == [
"Bravo scope",
"Charlie scope",
"Alpha scope",
]
@pytest.mark.asyncio
async def test_imported_parts_page_sorts_by_description_desc(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
await _seed_imported_parts(db)
await make_user("reader3@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "reader3@example.com")
resp = await client.get("/admin/imported-parts?sort=Description&dir=desc")
assert resp.status_code == 200
body = resp.text
assert _order_of(body, ["Charlie widget", "Bravo widget", "Alpha widget"]) == [
"Charlie widget",
"Bravo widget",
"Alpha widget",
]
@pytest.mark.asyncio
async def test_list_filtered_sorts_by_joined_colour_name(db_sessionmaker):
async with db_sessionmaker() as db:
red = PartColour(name="Red")
blue = PartColour(name="Blue")
manufacturer = Manufacturer(name="Acme", url="https://acme.example")
part_t = PartType(name="Scope")
db.add_all([red, blue, manufacturer, part_t])
await db.flush()
db.add_all(
[
AugmentedPart(
description="Red one",
colour_id=red.id,
manufacturer_id=manufacturer.id,
part_type_id=part_t.id,
),
AugmentedPart(
description="Blue one",
colour_id=blue.id,
manufacturer_id=manufacturer.id,
part_type_id=part_t.id,
),
]
)
await db.commit()
parts = await augmented_part_crud.list_filtered(
db, sort="colour", direction="asc"
)
assert [p.description for p in parts] == ["Blue one", "Red one"]
parts_desc = await augmented_part_crud.list_filtered(
db, sort="colour", direction="desc"
)
assert [p.description for p in parts_desc] == ["Red one", "Blue one"]
+232 -9
View File
@@ -3,8 +3,9 @@ import pytest
from app.crud import role as role_crud from app.crud import role as role_crud
from app.models.attachment_point import AttachmentPoint from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName from app.models.attachment_point_name import AttachmentPointName
from app.models.augmented_part import AugmentedPart from app.models.augmented_part import AugmentedPart, AugmentedPartNote
from app.models.fit import Fit, FitLabel from app.models.fit import Fit, FitLabel, FitNote
from app.models.iso_thread import IsoThread
from app.models.part_type import PartType from app.models.part_type import PartType
from app.models.scope_info import ScopeInfo from app.models.scope_info import ScopeInfo
@@ -40,12 +41,8 @@ async def _seed_part_with_fit(db):
objective_thread_pitch="M52x0.75", objective_thread_pitch="M52x0.75",
) )
) )
ap_s = AttachmentPoint( ap_s = AttachmentPoint(augmented_part_id=scope.id, attachment_point_name_id=apn.id)
augmented_part_id=scope.id, attachment_point_name_id=apn.id ap_c = AttachmentPoint(augmented_part_id=cover.id, attachment_point_name_id=apn.id)
)
ap_c = AttachmentPoint(
augmented_part_id=cover.id, attachment_point_name_id=apn.id
)
db.add_all([ap_s, ap_c]) db.add_all([ap_s, ap_c])
await db.flush() await db.flush()
@@ -91,6 +88,37 @@ async def test_detail_shows_attributes_and_fits_readonly(
assert "Add fit" not in body assert "Add fit" not in body
assert "Mark Exact" not in body assert "Mark Exact" not in body
assert "Recompute possible fits" not in body assert "Recompute possible fits" not in body
assert "Add note" not in body
# ...and attachment-point add/edit/delete controls are hidden too
assert 'name="attachment_point_name_id"' not in body
assert "Delete this attachment point?" not in body
@pytest.mark.asyncio
async def test_detail_write_controls_for_attachment_point_editor(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
ids = await _seed_part_with_fit(db)
perms = seeded["perms"]
editor = await role_crud.create(
db,
name="ap-editor",
permission_ids=[
perms["augmented_parts:read"].id,
perms["attachment_points:read"].id,
perms["attachment_points:write"].id,
],
)
editor_id = editor.id
await make_user("apeditor@example.com", role_ids=[editor_id])
await _web_login(client, "apeditor@example.com")
resp = await client.get(f"/admin/augmented-parts/{ids['scope_id']}")
assert resp.status_code == 200
body = resp.text
assert 'name="attachment_point_name_id"' in body
assert "Delete this attachment point?" in body
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -121,6 +149,199 @@ async def test_detail_write_controls_for_fit_editor(
assert "Recompute possible fits" in body assert "Recompute possible fits" in body
@pytest.mark.asyncio
async def test_create_fit_auto_creates_matching_attachment_point(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
apn = AttachmentPointName(name="Objective")
scope_t = PartType(name="Scope")
cover_t = PartType(name="Flip Cover")
db.add_all([apn, scope_t, cover_t])
await db.flush()
thread = IsoThread(diameter_mm=42.0, pitch_mm=0.75)
db.add(thread)
await db.flush()
scope = AugmentedPart(
description="Alpha 3-18x50",
sku="ALP-318",
colour_id=1,
manufacturer_id=1,
part_type_id=scope_t.id,
)
cover = AugmentedPart(
description="Flip cover 50mm",
colour_id=1,
manufacturer_id=1,
part_type_id=cover_t.id,
)
db.add_all([scope, cover])
await db.flush()
own_ap = AttachmentPoint(
augmented_part_id=scope.id,
attachment_point_name_id=apn.id,
iso_thread_id=thread.id,
)
db.add(own_ap)
await db.commit()
scope_id, cover_id, own_ap_id = scope.id, cover.id, own_ap.id
perms = seeded["perms"]
editor = await role_crud.create(
db,
name="fit-editor2",
permission_ids=[
perms["augmented_parts:read"].id,
perms["fits:read"].id,
perms["fits:write"].id,
],
)
editor_id = editor.id
await make_user("editor2@example.com", role_ids=[editor_id])
await _web_login(client, "editor2@example.com")
resp = await client.post(
f"/admin/augmented-parts/{scope_id}/fits",
data={
"own_attachment_point_id": own_ap_id,
"other_augmented_part_id": cover_id,
"other_attachment_point_id": "",
"label": "possible",
},
)
assert resp.status_code == 200
body = resp.text
assert "already exists" not in body
assert "not found" not in body
async with db_sessionmaker() as db:
cover_points = [
ap
for ap in (await db.execute(AttachmentPoint.__table__.select())).all()
if ap.augmented_part_id == cover_id
]
assert len(cover_points) == 1
assert cover_points[0].iso_thread_id == thread.id
assert cover_points[0].attachment_point_name_id == apn.id
fits = (await db.execute(Fit.__table__.select())).all()
assert len(fits) == 1
assert fits[0].label == FitLabel.POSSIBLE
@pytest.mark.asyncio
async def test_create_fit_with_note_creates_fit_note(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
apn = AttachmentPointName(name="Objective")
scope_t = PartType(name="Scope")
cover_t = PartType(name="Flip Cover")
db.add_all([apn, scope_t, cover_t])
await db.flush()
scope = AugmentedPart(
description="Alpha 3-18x50",
sku="ALP-318",
colour_id=1,
manufacturer_id=1,
part_type_id=scope_t.id,
)
cover = AugmentedPart(
description="Flip cover 50mm",
colour_id=1,
manufacturer_id=1,
part_type_id=cover_t.id,
)
db.add_all([scope, cover])
await db.flush()
own_ap = AttachmentPoint(
augmented_part_id=scope.id, attachment_point_name_id=apn.id
)
other_ap = AttachmentPoint(
augmented_part_id=cover.id, attachment_point_name_id=apn.id
)
db.add_all([own_ap, other_ap])
await db.commit()
scope_id, own_ap_id, other_ap_id = scope.id, own_ap.id, other_ap.id
perms = seeded["perms"]
editor = await role_crud.create(
db,
name="fit-editor3",
permission_ids=[
perms["augmented_parts:read"].id,
perms["fits:read"].id,
perms["fits:write"].id,
],
)
editor_id = editor.id
await make_user("editor3@example.com", role_ids=[editor_id])
await _web_login(client, "editor3@example.com")
resp = await client.post(
f"/admin/augmented-parts/{scope_id}/fits",
data={
"own_attachment_point_id": own_ap_id,
"other_attachment_point_id": other_ap_id,
"label": "exact",
"note": "Confirmed fit on the bench.",
},
)
assert resp.status_code == 200
body = resp.text
assert "Confirmed fit on the bench." in body
async with db_sessionmaker() as db:
fits = (await db.execute(Fit.__table__.select())).all()
assert len(fits) == 1
notes = (await db.execute(FitNote.__table__.select())).all()
assert len(notes) == 1
assert notes[0].fit_id == fits[0].id
assert notes[0].body == "Confirmed fit on the bench."
@pytest.mark.asyncio
async def test_create_augmented_part_note(client, seeded, make_user, db_sessionmaker):
async with db_sessionmaker() as db:
ids = await _seed_part_with_fit(db)
perms = seeded["perms"]
editor = await role_crud.create(
db,
name="ap-note-editor",
permission_ids=[
perms["augmented_parts:read"].id,
perms["augmented_parts:write"].id,
],
)
editor_id = editor.id
await make_user("noteeditor@example.com", role_ids=[editor_id])
await _web_login(client, "noteeditor@example.com")
resp = await client.get(f"/admin/augmented-parts/{ids['scope_id']}")
assert resp.status_code == 200
assert "Add note" in resp.text
resp = await client.post(
f"/admin/augmented-parts/{ids['scope_id']}/notes",
data={"body": "Verified thread depth on the bench."},
)
assert resp.status_code == 200
assert "Verified thread depth on the bench." in resp.text
async with db_sessionmaker() as db:
notes = (await db.execute(AugmentedPartNote.__table__.select())).all()
assert len(notes) == 1
assert notes[0].augmented_part_id == ids["scope_id"]
assert notes[0].body == "Verified thread depth on the bench."
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_detail_404_for_missing_part(client, seeded, make_user): async def test_detail_404_for_missing_part(client, seeded, make_user):
await make_user("reader2@example.com", role_ids=[seeded["roles"]["user"].id]) await make_user("reader2@example.com", role_ids=[seeded["roles"]["user"].id])
@@ -130,7 +351,9 @@ async def test_detail_404_for_missing_part(client, seeded, make_user):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_detail_requires_read_permission(client, seeded, make_user, db_sessionmaker): async def test_detail_requires_read_permission(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db: async with db_sessionmaker() as db:
ids = await _seed_part_with_fit(db) ids = await _seed_part_with_fit(db)
await make_user("plain@example.com") # no roles, not superuser await make_user("plain@example.com") # no roles, not superuser