Adding fits

This commit is contained in:
Ian Roddis committed 2026-07-14 15:24:05 -03:00
1 parent 8185f1f8b3
commit ab39191af5
19 files changed
+1253 -1

No files matched your search

+171
View File
@@ -0,0 +1,171 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import attachment_point as attachment_point_crud
from app.crud import audit as audit_crud
from app.crud import fit as fit_crud
from app.crud import fit_note as fit_note_crud
from app.deps import require_permission
from app.models.fit import FitLabel
from app.models.user import User
from app.schemas.fit import (
FitCreate,
FitNoteCreate,
FitNoteRead,
FitRead,
FitRecomputeResult,
FitUpdate,
)
from app.services import fit_matching
router = APIRouter()
@router.get("", response_model=list[FitRead])
async def list_fits(
augmented_part_id: int | None = None,
label: FitLabel | None = None,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("fits:read")),
):
if augmented_part_id is not None:
fits = await fit_crud.list_for_part(db, augmented_part_id)
if label is not None:
fits = [f for f in fits if f.label == label]
return fits[skip : skip + limit]
return await fit_crud.list_all(db, skip=skip, limit=limit, label=label)
@router.get("/{fit_id}", response_model=FitRead)
async def get_fit(
fit_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("fits:read")),
):
fit = await fit_crud.get(db, fit_id)
if fit is None:
raise HTTPException(status_code=404, detail="Fit not found")
return fit
@router.post("", response_model=FitRead, status_code=status.HTTP_201_CREATED)
async def create_fit(
payload: FitCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("fits:write")),
):
"""Manually declare a fit between two attachment points; recorded as Exact."""
if payload.attachment_point_a_id == payload.attachment_point_b_id:
raise HTTPException(
status_code=422, detail="attachment points must be different"
)
ap_a = await attachment_point_crud.get(db, payload.attachment_point_a_id)
ap_b = await attachment_point_crud.get(db, payload.attachment_point_b_id)
if ap_a is None or ap_b is None:
raise HTTPException(status_code=404, detail="Attachment point not found")
if ap_a.augmented_part_id == ap_b.augmented_part_id:
raise HTTPException(
status_code=422, detail="a part cannot fit itself"
)
existing = await fit_crud.get_by_attachment_points(db, ap_a, ap_b)
if existing is not None:
raise HTTPException(status_code=409, detail="Fit already exists")
fit = await fit_crud.create(db, ap_a=ap_a, ap_b=ap_b, label=FitLabel.EXACT)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="Fit",
resource_id=fit.id,
description=audit_crud.describe_create(
"Fit", fit.id, f"attachment points {ap_a.id}↔{ap_b.id}"
),
)
return fit
@router.patch("/{fit_id}", response_model=FitRead)
async def update_fit_label(
fit_id: int,
payload: FitUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("fits:write")),
):
"""Promote a Possible fit to Exact, or mark a pairing as None (impossible)."""
fit = await fit_crud.get(db, fit_id)
if fit is None:
raise HTTPException(status_code=404, detail="Fit not found")
fit = await fit_crud.set_label(db, fit, payload.label)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="Fit",
resource_id=fit.id,
description=audit_crud.describe_update(
"Fit", fit.id, {"label": payload.label.value}
),
)
return fit
@router.delete("/{fit_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_fit(
fit_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("fits:delete")),
):
fit = await fit_crud.get(db, fit_id)
if fit is None:
raise HTTPException(status_code=404, detail="Fit not found")
label = f"attachment points {fit.attachment_point_a_id}↔{fit.attachment_point_b_id}"
await fit_crud.delete(db, fit)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="Fit",
resource_id=fit_id,
description=audit_crud.describe_delete("Fit", fit_id, label),
)
@router.post("/recompute", response_model=FitRecomputeResult)
async def recompute_fits(
augmented_part_id: int | None = None,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("fits:write")),
):
"""Re-run the matching algorithm, scoped to one part or across all parts."""
if augmented_part_id is not None:
result = await fit_matching.recompute_for_part(db, augmented_part_id)
else:
result = await fit_matching.recompute_all(db)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="Fit",
resource_id=augmented_part_id,
description=f"Recomputed fits: {result.created} created, {result.removed} removed",
)
return FitRecomputeResult(created=result.created, removed=result.removed)
@router.post(
"/{fit_id}/notes", response_model=FitNoteRead, status_code=status.HTTP_201_CREATED
)
async def create_fit_note(
fit_id: int,
payload: FitNoteCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("fits:write")),
):
fit = await fit_crud.get(db, fit_id)
if fit is None:
raise HTTPException(status_code=404, detail="Fit not found")
return await fit_note_crud.create(db, fit=fit, author=actor, body=payload.body)
+2
View File
@@ -7,6 +7,7 @@ from app.api import (
augmented_parts,
auth,
clamp_connections,
fits,
imported_parts,
iso_threads,
manufacturers,
@@ -64,3 +65,4 @@ api_router.include_router(
augmented_parts.router, prefix="/augmented-parts", tags=["augmented-parts"]
)
api_router.include_router(audit_log.router, prefix="/audit-log", tags=["audit-log"])
api_router.include_router(fits.router, prefix="/fits", tags=["fits"])
+98
View File
@@ -0,0 +1,98 @@
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.attachment_point import AttachmentPoint
from app.models.fit import Fit, FitLabel
# "updated_at" is included because it's a server-side onupdate default: after an
# UPDATE, SQLAlchemy doesn't know its new value unless it's named explicitly here
# too, and access to an unrefreshed attribute would otherwise trigger a lazy load
# outside of an awaited context (MissingGreenlet) during response serialization.
_RELATIONSHIPS = ["attachment_point_a", "attachment_point_b", "notes", "updated_at"]
def _ordered(
ap_a: AttachmentPoint, ap_b: AttachmentPoint
) -> tuple[AttachmentPoint, AttachmentPoint]:
"""Canonicalize so attachment_point_a.id < attachment_point_b.id.
Keeps a single row per unordered pair of attachment points.
"""
return (ap_a, ap_b) if ap_a.id < ap_b.id else (ap_b, ap_a)
async def get(db: AsyncSession, fit_id: int) -> Fit | None:
return await db.get(Fit, fit_id)
async def get_by_attachment_points(
db: AsyncSession, ap_a: AttachmentPoint, ap_b: AttachmentPoint
) -> Fit | None:
first, second = _ordered(ap_a, ap_b)
result = await db.execute(
select(Fit).where(
Fit.attachment_point_a_id == first.id,
Fit.attachment_point_b_id == second.id,
)
)
return result.scalar_one_or_none()
async def list_for_part(db: AsyncSession, augmented_part_id: int) -> list[Fit]:
result = await db.execute(
select(Fit)
.where(
or_(
Fit.augmented_part_a_id == augmented_part_id,
Fit.augmented_part_b_id == augmented_part_id,
)
)
.order_by(Fit.id)
)
return list(result.scalars().all())
async def list_all(
db: AsyncSession,
skip: int = 0,
limit: int = 100,
label: FitLabel | None = None,
) -> list[Fit]:
q = select(Fit)
if label is not None:
q = q.where(Fit.label == label)
result = await db.execute(q.order_by(Fit.id).offset(skip).limit(limit))
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
ap_a: AttachmentPoint,
ap_b: AttachmentPoint,
label: FitLabel,
) -> Fit:
first, second = _ordered(ap_a, ap_b)
fit = Fit(
attachment_point_a_id=first.id,
attachment_point_b_id=second.id,
augmented_part_a_id=first.augmented_part_id,
augmented_part_b_id=second.augmented_part_id,
label=label,
)
db.add(fit)
await db.commit()
await db.refresh(fit, attribute_names=_RELATIONSHIPS)
return fit
async def set_label(db: AsyncSession, fit: Fit, label: FitLabel) -> Fit:
fit.label = label
await db.commit()
await db.refresh(fit, attribute_names=_RELATIONSHIPS)
return fit
async def delete(db: AsyncSession, fit: Fit) -> None:
await db.delete(fit)
await db.commit()
+17
View File
@@ -0,0 +1,17 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fit import Fit, FitNote
from app.models.user import User
async def create(db: AsyncSession, *, fit: Fit, author: User, body: str) -> FitNote:
note = FitNote(
fit_id=fit.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
+4
View File
@@ -10,6 +10,7 @@ from app.models.attachment_point_name import AttachmentPointName
from app.models.audit_log import AuditLog
from app.models.augmented_part import AugmentedPart
from app.models.clamp_connection import ClampConnection
from app.models.fit import Fit, FitLabel, FitNote
from app.models.imported_part import ImportedPart
from app.models.imported_part_config import ImportedPartConfig
from app.models.iso_thread import IsoThread
@@ -31,6 +32,9 @@ __all__ = [
"AuditLog",
"AugmentedPart",
"ClampConnection",
"Fit",
"FitLabel",
"FitNote",
"ImportedPart",
"ImportedPartConfig",
"IsoThread",
+89
View File
@@ -0,0 +1,89 @@
import enum
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class FitLabel(str, enum.Enum):
POSSIBLE = "possible"
EXACT = "exact"
NONE = "none"
class Fit(Base):
"""A candidate or confirmed fit between two attachment points on different parts."""
__tablename__ = "fits"
__table_args__ = (
UniqueConstraint("attachment_point_a_id", "attachment_point_b_id"),
)
id: Mapped[int] = mapped_column(primary_key=True)
# Canonical ordering (attachment_point_a_id < attachment_point_b_id) is enforced
# by app.crud.fit so an unordered pair only ever has one row.
attachment_point_a_id: Mapped[int] = mapped_column(
ForeignKey("attachment_points.id", ondelete="CASCADE"), nullable=False
)
attachment_point_b_id: Mapped[int] = mapped_column(
ForeignKey("attachment_points.id", ondelete="CASCADE"), nullable=False
)
# Denormalized from the attachment points' augmented_part_id, for querying
# "fits for this part" without joining through attachment_points.
augmented_part_a_id: Mapped[int] = mapped_column(
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
)
augmented_part_b_id: Mapped[int] = mapped_column(
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
)
label: Mapped[FitLabel] = mapped_column(
Enum(FitLabel, values_callable=lambda enum_cls: [e.value for e in enum_cls]),
nullable=False,
default=FitLabel.POSSIBLE,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
attachment_point_a = relationship(
"AttachmentPoint", foreign_keys=[attachment_point_a_id], lazy="selectin"
)
attachment_point_b = relationship(
"AttachmentPoint", foreign_keys=[attachment_point_b_id], lazy="selectin"
)
notes: Mapped[list["FitNote"]] = relationship(
back_populates="fit",
lazy="selectin",
order_by="FitNote.created_at",
cascade="all, delete-orphan",
)
class FitNote(Base):
__tablename__ = "fit_notes"
id: Mapped[int] = mapped_column(primary_key=True)
fit_id: Mapped[int] = mapped_column(
ForeignKey("fits.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 AuditLog.user_email),
# 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()
)
fit: Mapped[Fit] = relationship(back_populates="notes")
+60
View File
@@ -0,0 +1,60 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, field_validator
from app.models.fit import FitLabel
from app.schemas.attachment_point import AttachmentPointRead
class FitNoteCreate(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 FitNoteRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
fit_id: int
user_id: int | None
username: str
body: str
created_at: datetime
class FitCreate(BaseModel):
"""Manually-declared fit; recorded as Exact regardless of the match algorithm."""
attachment_point_a_id: int
attachment_point_b_id: int
class FitUpdate(BaseModel):
label: FitLabel
class FitRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
augmented_part_a_id: int
augmented_part_b_id: int
attachment_point_a_id: int
attachment_point_b_id: int
attachment_point_a: AttachmentPointRead
attachment_point_b: AttachmentPointRead
label: FitLabel
created_at: datetime
updated_at: datetime
notes: list[FitNoteRead] = []
class FitRecomputeResult(BaseModel):
created: int
removed: int
+201
View File
@@ -0,0 +1,201 @@
"""Detect "Possible" fits between AugmentedParts.
A part fits another part if:
- They are different PartTypes
- They have two attachment points that:
- Have different labels (AttachmentPointName)
- either:
- Are an ISO or UNC thread that exactly match in type and dimensions
- Are a Static Connection paired with a Clamp Connection where the
static dimension falls within the range of the clamp connection
Matches found here are recorded as Fit rows labeled Possible. Exact (manually
entered, or promoted from Possible) and None (explicitly marked impossible)
labels are user-asserted and are never created or removed by this algorithm.
"""
from collections import defaultdict
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import fit as fit_crud
from app.models.attachment_point import AttachmentPoint
from app.models.augmented_part import AugmentedPart
from app.models.clamp_connection import ClampConnection
from app.models.fit import FitLabel
from app.models.static_connection import StaticConnection
MM_PER_INCH = 25.4
def _iso_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
a, b = ap_a.iso_thread, ap_b.iso_thread
if a is None or b is None:
return False
return a.diameter_mm == b.diameter_mm and a.pitch_mm == b.pitch_mm
def _unc_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
a, b = ap_a.unc_thread, ap_b.unc_thread
if a is None or b is None:
return False
return (
a.diameter_fraction == b.diameter_fraction
and a.tpi == b.tpi
and a.series == b.series
)
def _clamp_range_mm(clamp: ClampConnection) -> tuple[float | None, float | None]:
if clamp.min_mm is not None or clamp.max_mm is not None:
return clamp.min_mm, clamp.max_mm
min_mm = clamp.min_in * MM_PER_INCH if clamp.min_in is not None else None
max_mm = clamp.max_in * MM_PER_INCH if clamp.max_in is not None else None
return min_mm, max_mm
def _static_in_clamp_range(static: StaticConnection, clamp: ClampConnection) -> bool:
min_mm, max_mm = _clamp_range_mm(clamp)
if min_mm is None and max_mm is None:
return False
if min_mm is not None and static.od_mm < min_mm:
return False
if max_mm is not None and static.od_mm > max_mm:
return False
return True
def _static_clamp_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
if ap_a.static_connection is not None and ap_b.clamp_connection is not None:
return _static_in_clamp_range(ap_a.static_connection, ap_b.clamp_connection)
if ap_b.static_connection is not None and ap_a.clamp_connection is not None:
return _static_in_clamp_range(ap_b.static_connection, ap_a.clamp_connection)
return False
def attachment_points_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
"""True if these two attachment points satisfy the fit-matching algorithm."""
if ap_a.attachment_point_name_id == ap_b.attachment_point_name_id:
return False
return (
_iso_match(ap_a, ap_b)
or _unc_match(ap_a, ap_b)
or _static_clamp_match(ap_a, ap_b)
)
def parts_could_fit(part_a: AugmentedPart, part_b: AugmentedPart) -> bool:
return part_a.id != part_b.id and part_a.part_type_id != part_b.part_type_id
@dataclass
class RecomputeResult:
created: int = 0
removed: int = 0
def __iadd__(self, other: "RecomputeResult") -> "RecomputeResult":
self.created += other.created
self.removed += other.removed
return self
async def _all_parts_by_id(db: AsyncSession) -> dict[int, AugmentedPart]:
result = await db.execute(select(AugmentedPart))
return {p.id: p for p in result.scalars().all()}
async def _attachment_points_by_part(
db: AsyncSession,
) -> dict[int, list[AttachmentPoint]]:
result = await db.execute(select(AttachmentPoint))
by_part: dict[int, list[AttachmentPoint]] = defaultdict(list)
for ap in result.scalars().all():
by_part[ap.augmented_part_id].append(ap)
return by_part
def _canonical_pair(a_id: int, b_id: int) -> tuple[int, int]:
return (a_id, b_id) if a_id < b_id else (b_id, a_id)
async def _reconcile(
db: AsyncSession,
*,
scoped_part_ids: set[int],
parts_by_id: dict[int, AugmentedPart],
points_by_part: dict[int, list[AttachmentPoint]],
) -> RecomputeResult:
"""Reconcile Possible fits for the given parts against every other part."""
candidate_pairs: set[tuple[int, int]] = set()
for part_id in scoped_part_ids:
part = parts_by_id.get(part_id)
if part is None:
continue
own_points = points_by_part.get(part_id, [])
if not own_points:
continue
for other_id, other in parts_by_id.items():
if not parts_could_fit(part, other):
continue
for own_ap in own_points:
for other_ap in points_by_part.get(other_id, []):
if attachment_points_match(own_ap, other_ap):
candidate_pairs.add(
_canonical_pair(own_ap.id, other_ap.id)
)
result = RecomputeResult()
existing_fits = []
for part_id in scoped_part_ids:
existing_fits.extend(await fit_crud.list_for_part(db, part_id))
existing_by_pair = {
(f.attachment_point_a_id, f.attachment_point_b_id): f for f in existing_fits
}
for pair, existing in existing_by_pair.items():
if existing.label == FitLabel.POSSIBLE and pair not in candidate_pairs:
await fit_crud.delete(db, existing)
result.removed += 1
for ap_a_id, ap_b_id in candidate_pairs:
if (ap_a_id, ap_b_id) in existing_by_pair:
continue
ap_a = await db.get(AttachmentPoint, ap_a_id)
ap_b = await db.get(AttachmentPoint, ap_b_id)
await fit_crud.create(db, ap_a=ap_a, ap_b=ap_b, label=FitLabel.POSSIBLE)
result.created += 1
return result
async def recompute_for_part(db: AsyncSession, augmented_part_id: int) -> RecomputeResult:
"""Reconcile Possible fits involving this part against the current algorithm.
Creates Possible fits for newly-matching attachment point pairs, and removes
previously-algorithmic Possible fits that no longer match. Exact/None labels
are user-asserted and are never touched.
"""
parts_by_id = await _all_parts_by_id(db)
if augmented_part_id not in parts_by_id:
return RecomputeResult()
points_by_part = await _attachment_points_by_part(db)
return await _reconcile(
db,
scoped_part_ids={augmented_part_id},
parts_by_id=parts_by_id,
points_by_part=points_by_part,
)
async def recompute_all(db: AsyncSession) -> RecomputeResult:
parts_by_id = await _all_parts_by_id(db)
points_by_part = await _attachment_points_by_part(db)
return await _reconcile(
db,
scoped_part_ids=set(parts_by_id),
parts_by_id=parts_by_id,
points_by_part=points_by_part,
)
+40
View File
@@ -460,6 +460,46 @@ textarea:focus {
.ap-item-conn { flex: 1; }
.ap-item .row-actions { margin-left: auto; }
.fit-item {
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
.fit-item:last-of-type { border-bottom: none; }
.fit-item-main {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.3rem;
}
.fit-item-arrow { color: var(--muted); }
.fit-badge {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-weight: 600;
}
.fit-badge-possible { background: var(--surface-2); color: var(--muted); }
.fit-badge-exact { background: #16a34a26; color: #16a34a; }
.fit-badge-none { background: #dc262626; color: #dc2626; }
.fit-notes { margin-top: 0.4rem; padding-left: 0.25rem; }
.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; }
.fit-note-form { margin-top: 0.3rem; }
.fit-note-form input[type="text"] { flex: 1; min-width: 12rem; }
/* Pagination */
.pagination {
display: flex;
@@ -0,0 +1,62 @@
{% macro conn_desc(ap) -%}
{%- if ap.iso_thread -%}
ISO M{{ ap.iso_thread.diameter_mm }}×{{ ap.iso_thread.pitch_mm }}
{%- elif ap.unc_thread -%}
UNC {{ ap.unc_thread.diameter_fraction }}-{{ ap.unc_thread.tpi }} {{ ap.unc_thread.series.value }}
{%- elif ap.clamp_connection -%}
Clamp {{ ap.clamp_connection.min_mm if ap.clamp_connection.min_mm is not none else ap.clamp_connection.min_in }}–{{ ap.clamp_connection.max_mm if ap.clamp_connection.max_mm is not none else ap.clamp_connection.max_in }}
{%- elif ap.static_connection -%}
Static {{ ap.static_connection.od_mm }} mm OD
{%- else -%}
—
{%- endif -%}
{%- endmacro %}
<div id="fit-{{ fit.id }}" class="fit-item">
<div class="fit-item-main">
<span class="fit-badge fit-badge-{{ fit.label.value }}">{{ fit.label.value | capitalize }}</span>
<span class="fit-item-side"><strong>{{ own_ap.attachment_point_name.name }}</strong> ({{ conn_desc(own_ap) }})</span>
<span class="fit-item-arrow">↔</span>
<span class="fit-item-side">{{ other_part.description if other_part else "Unknown part" }} — <strong>{{ other_ap.attachment_point_name.name }}</strong> ({{ conn_desc(other_ap) }})</span>
</div>
<div class="row-actions">
{% if fit.label.value != "exact" %}
<button class="btn btn-ghost"
hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits/{{ fit.id }}/label"
hx-vals='{"label": "exact"}'
hx-target="#fit-{{ fit.id }}" hx-swap="outerHTML">Mark Exact</button>
{% endif %}
{% if fit.label.value != "possible" %}
<button class="btn btn-ghost"
hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits/{{ fit.id }}/label"
hx-vals='{"label": "possible"}'
hx-target="#fit-{{ fit.id }}" hx-swap="outerHTML">Mark Possible</button>
{% endif %}
{% if fit.label.value != "none" %}
<button class="btn btn-ghost"
hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits/{{ fit.id }}/label"
hx-vals='{"label": "none"}'
hx-target="#fit-{{ fit.id }}" hx-swap="outerHTML">Mark Impossible</button>
{% endif %}
<button class="btn btn-danger"
hx-delete="/admin/augmented-parts/{{ augmented_part_id }}/fits/{{ fit.id }}"
hx-target="#ap-{{ augmented_part_id }}-fits"
hx-swap="outerHTML"
hx-confirm="Delete this fit?">Delete</button>
</div>
<div class="fit-notes">
{% for note in fit.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>
{% endfor %}
<form hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits/{{ fit.id }}/notes"
hx-target="#fit-{{ fit.id }}" 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>
</div>
</div>
@@ -0,0 +1,49 @@
<div id="ap-{{ augmented_part_id }}-fits" class="ap-inline-section">
<h3>Fits</h3>
{% if message %}<p class="alert alert-success">{{ message }}</p>{% endif %}
{% if error %}<p class="alert alert-error">{{ error }}</p>{% endif %}
{% for view in fit_views %}
{% with fit=view.fit, own_ap=view.own_ap, other_ap=view.other_ap, other_part=view.other_part %}
{% include "admin/_augmented_part_fit_row.html" %}
{% endwith %}
{% else %}
<p class="muted" style="font-size:0.85rem;margin:0 0 0.5rem">No fits recorded yet.</p>
{% endfor %}
<div class="row-actions" style="margin:0.5rem 0">
<form hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits/recompute"
hx-target="#ap-{{ augmented_part_id }}-fits" hx-swap="outerHTML">
<button type="submit" class="btn btn-ghost">Recompute possible fits</button>
</form>
</div>
<form hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits"
hx-target="#ap-{{ augmented_part_id }}-fits"
hx-swap="outerHTML"
class="inline-edit" style="margin-top:0.5rem">
<label>This part's point
<select name="own_attachment_point_id" required>
<option value="">— select —</option>
{% for p in own_points %}
<option value="{{ p.id }}">{{ p.attachment_point_name.name }}</option>
{% endfor %}
</select>
</label>
<label>Fits with part
<select name="other_augmented_part_id" required
hx-get="/admin/augmented-parts/{{ augmented_part_id }}/fits/other-points"
hx-target="#ap-{{ augmented_part_id }}-fit-other-points"
hx-trigger="change"
hx-include="[name='other_augmented_part_id']">
<option value="">— select —</option>
{% for p in other_parts %}
<option value="{{ p.id }}">{{ p.description | truncate(40) }}</option>
{% endfor %}
</select>
</label>
<div id="ap-{{ augmented_part_id }}-fit-other-points"></div>
<button type="submit" class="btn btn-primary">Add fit</button>
</form>
</div>
@@ -58,5 +58,10 @@
hx-trigger="load"
hx-swap="outerHTML">
</div>
<div hx-get="/admin/augmented-parts/{{ ap.id }}/fits"
hx-trigger="load"
hx-swap="outerHTML">
</div>
</td>
</tr>
@@ -0,0 +1,6 @@
<select name="other_attachment_point_id" required>
<option value="">— select —</option>
{% for p in points %}
<option value="{{ p.id }}">{{ p.attachment_point_name.name }}</option>
{% endfor %}
</select>
+11
View File
@@ -65,6 +65,17 @@
</div>
</div>
<div class="card">
<h2>Recompute Fits</h2>
<p class="muted">Re-runs the fit-matching algorithm across every part, adding newly Possible fits and pruning stale ones. Exact and Impossible labels are never changed.</p>
<div class="row-actions">
<form hx-post="/admin/augmented-parts/fits/recompute" hx-target="#fits-recompute-result" hx-swap="innerHTML">
<button type="submit" class="btn btn-primary">Recompute all fits</button>
</form>
</div>
<p id="fits-recompute-result" class="muted"></p>
</div>
{% include "admin/_augmented_parts_table.html" %}
</main>
{% endblock %}
+256
View File
@@ -21,14 +21,18 @@ from app.crud import scope_model as scope_model_crud
from app.crud import attachment_point as attachment_point_crud
from app.crud import attachment_point_name as apn_crud
from app.crud import clamp_connection as clamp_connection_crud
from app.crud import fit as fit_crud
from app.crud import fit_note as fit_note_crud
from app.crud import static_connection as static_connection_crud
from app.crud import sub_part_type as sub_part_type_crud
from app.crud import unc_thread as unc_thread_crud
from app.crud import user as user_crud
from app.deps import get_current_web_user
from app.models.fit import FitLabel
from app.models.unc_thread import UncSeries
from app.models.user import User
from app.services import augmented_part_sync as augmented_part_sync_service
from app.services import fit_matching as fit_matching_service
from app.services import odata as odata_service
from app.web.templates import templates
@@ -1978,6 +1982,258 @@ async def delete_augmented_part_attachment_point(
)
# --- Fits ----------------------------------------------------------------
async def _fit_view(db: AsyncSession, fit, part_id: int) -> dict:
if fit.augmented_part_a_id == part_id:
own_ap, other_ap, other_part_id = (
fit.attachment_point_a,
fit.attachment_point_b,
fit.augmented_part_b_id,
)
else:
own_ap, other_ap, other_part_id = (
fit.attachment_point_b,
fit.attachment_point_a,
fit.augmented_part_a_id,
)
other_part = await augmented_part_crud.get(db, other_part_id)
return {"fit": fit, "own_ap": own_ap, "other_ap": other_ap, "other_part": other_part}
async def _augmented_part_fits_ctx(
db: AsyncSession,
augmented_part_id: int,
*,
message: str | None = None,
error: str | None = None,
) -> dict:
fits = await fit_crud.list_for_part(db, augmented_part_id)
other_parts = [
p
for p in await augmented_part_crud.list_all(db, limit=500)
if p.id != augmented_part_id
]
return {
"augmented_part_id": augmented_part_id,
"fit_views": [await _fit_view(db, f, augmented_part_id) for f in fits],
"own_points": await attachment_point_crud.list_for_augmented_part(
db, augmented_part_id
),
"other_parts": other_parts,
"message": message,
"error": error,
}
@router.get("/augmented-parts/{augmented_part_id}/fits", response_class=HTMLResponse)
async def augmented_part_fits(
request: Request,
augmented_part_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("fits:read")),
):
ctx = await _augmented_part_fits_ctx(db, augmented_part_id)
return templates.TemplateResponse(request, "admin/_augmented_part_fits.html", ctx)
@router.post(
"/augmented-parts/{augmented_part_id}/fits/recompute", response_class=HTMLResponse
)
async def recompute_augmented_part_fits(
request: Request,
augmented_part_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("fits:write")),
):
result = await fit_matching_service.recompute_for_part(db, augmented_part_id)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="Fit",
resource_id=augmented_part_id,
description=(
f"Recomputed fits for AugmentedPart #{augmented_part_id}: "
f"{result.created} created, {result.removed} removed"
),
)
ctx = await _augmented_part_fits_ctx(
db,
augmented_part_id,
message=f"{result.created} created, {result.removed} removed.",
)
return templates.TemplateResponse(request, "admin/_augmented_part_fits.html", ctx)
@router.get(
"/augmented-parts/{augmented_part_id}/fits/other-points",
response_class=HTMLResponse,
)
async def fit_other_points(
request: Request,
augmented_part_id: int,
other_augmented_part_id: str = "",
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("fits:write")),
):
points = (
await attachment_point_crud.list_for_augmented_part(
db, int(other_augmented_part_id)
)
if other_augmented_part_id
else []
)
return templates.TemplateResponse(
request, "admin/_fit_other_points_select.html", {"points": points}
)
@router.post("/augmented-parts/{augmented_part_id}/fits", response_class=HTMLResponse)
async def create_augmented_part_fit(
request: Request,
augmented_part_id: int,
own_attachment_point_id: int = Form(...),
other_attachment_point_id: str = Form(""),
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("fits:write")),
):
error = None
if not other_attachment_point_id:
error = "Select the other part's attachment point."
else:
ap_a = await attachment_point_crud.get(db, own_attachment_point_id)
ap_b = await attachment_point_crud.get(db, int(other_attachment_point_id))
if ap_a is None or ap_b is None:
error = "Attachment point not found."
elif ap_a.augmented_part_id == ap_b.augmented_part_id:
error = "A part cannot fit itself."
elif await fit_crud.get_by_attachment_points(db, ap_a, ap_b) is not None:
error = "That fit already exists."
else:
fit = await fit_crud.create(db, ap_a=ap_a, ap_b=ap_b, label=FitLabel.EXACT)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="Fit",
resource_id=fit.id,
description=audit_crud.describe_create(
"Fit", fit.id, f"attachment points {ap_a.id}↔{ap_b.id}"
),
)
ctx = await _augmented_part_fits_ctx(db, augmented_part_id, error=error)
return templates.TemplateResponse(request, "admin/_augmented_part_fits.html", ctx)
@router.post(
"/augmented-parts/{augmented_part_id}/fits/{fit_id}/label",
response_class=HTMLResponse,
)
async def update_augmented_part_fit_label(
request: Request,
augmented_part_id: int,
fit_id: int,
label: FitLabel = Form(...),
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("fits:write")),
):
fit = await fit_crud.get(db, fit_id)
if fit is None:
raise HTTPException(status_code=404, detail="Fit not found")
fit = await fit_crud.set_label(db, fit, label)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="Fit",
resource_id=fit.id,
description=audit_crud.describe_update("Fit", fit.id, {"label": label.value}),
)
view = await _fit_view(db, fit, augmented_part_id)
return templates.TemplateResponse(
request,
"admin/_augmented_part_fit_row.html",
{"augmented_part_id": augmented_part_id, **view},
)
@router.post(
"/augmented-parts/{augmented_part_id}/fits/{fit_id}/notes",
response_class=HTMLResponse,
)
async def create_augmented_part_fit_note(
request: Request,
augmented_part_id: int,
fit_id: int,
body: str = Form(...),
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("fits:write")),
):
fit = await fit_crud.get(db, fit_id)
if fit is None:
raise HTTPException(status_code=404, detail="Fit not found")
if body.strip():
await fit_note_crud.create(db, fit=fit, author=actor, body=body.strip())
await db.refresh(fit, attribute_names=["notes"])
view = await _fit_view(db, fit, augmented_part_id)
return templates.TemplateResponse(
request,
"admin/_augmented_part_fit_row.html",
{"augmented_part_id": augmented_part_id, **view},
)
@router.delete(
"/augmented-parts/{augmented_part_id}/fits/{fit_id}", response_class=HTMLResponse
)
async def delete_augmented_part_fit(
request: Request,
augmented_part_id: int,
fit_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("fits:delete")),
):
fit = await fit_crud.get(db, fit_id)
if fit is not None:
label = f"attachment points {fit.attachment_point_a_id}↔{fit.attachment_point_b_id}"
await fit_crud.delete(db, fit)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="Fit",
resource_id=fit_id,
description=audit_crud.describe_delete("Fit", fit_id, label),
)
ctx = await _augmented_part_fits_ctx(db, augmented_part_id)
return templates.TemplateResponse(request, "admin/_augmented_part_fits.html", ctx)
@router.post("/augmented-parts/fits/recompute", response_class=HTMLResponse)
async def recompute_all_fits(
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("fits:write")),
):
result = await fit_matching_service.recompute_all(db)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="Fit",
resource_id=None,
description=(
f"Recomputed fits for all parts: {result.created} created, "
f"{result.removed} removed"
),
)
return HTMLResponse(
f"Recompute complete: {result.created} created, {result.removed} removed."
)
# --- Part Colours ------------------------------------------------------------