diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1bb1854..3b86e28 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,10 @@ "allow": [ "Bash(uv run *)", "Bash(uv sync *)", - "Bash(git stash *)" + "Bash(git stash *)", + "Bash(python3 *)", + "Bash(curl -s -X POST http://localhost:8000/api/auth/login -d \"username=admin@armament.com&password=changethis\" -H \"Content-Type: application/x-www-form-urlencoded\")", + "Bash(PYTHONPATH=. uv run *)" ] } } diff --git a/alembic/versions/1b99be8a9b1e_add_fits_tables.py b/alembic/versions/1b99be8a9b1e_add_fits_tables.py new file mode 100644 index 0000000..6005c88 --- /dev/null +++ b/alembic/versions/1b99be8a9b1e_add_fits_tables.py @@ -0,0 +1,72 @@ +"""add_fits_tables + +Revision ID: 1b99be8a9b1e +Revises: adf4a5b9493e +Create Date: 2026-07-14 15:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "1b99be8a9b1e" +down_revision: Union[str, None] = "adf4a5b9493e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "fits", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("attachment_point_a_id", sa.Integer(), nullable=False), + sa.Column("attachment_point_b_id", sa.Integer(), nullable=False), + sa.Column("augmented_part_a_id", sa.Integer(), nullable=False), + sa.Column("augmented_part_b_id", sa.Integer(), nullable=False), + sa.Column( + "label", + sa.Enum("possible", "exact", "none", name="fitlabel"), + nullable=False, + ), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.ForeignKeyConstraint( + ["attachment_point_a_id"], ["attachment_points.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["attachment_point_b_id"], ["attachment_points.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["augmented_part_a_id"], ["augmented_parts.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["augmented_part_b_id"], ["augmented_parts.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("attachment_point_a_id", "attachment_point_b_id"), + ) + op.create_table( + "fit_notes", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("fit_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(["fit_id"], ["fits.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("fit_notes") + op.drop_table("fits") diff --git a/app/api/fits.py b/app/api/fits.py new file mode 100644 index 0000000..545f19f --- /dev/null +++ b/app/api/fits.py @@ -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) diff --git a/app/api/router.py b/app/api/router.py index 61fa1d4..bc5a2af 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -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"]) diff --git a/app/crud/fit.py b/app/crud/fit.py new file mode 100644 index 0000000..2402514 --- /dev/null +++ b/app/crud/fit.py @@ -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() diff --git a/app/crud/fit_note.py b/app/crud/fit_note.py new file mode 100644 index 0000000..ccbeec1 --- /dev/null +++ b/app/crud/fit_note.py @@ -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 diff --git a/app/models/__init__.py b/app/models/__init__.py index e5d5c71..9870033 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", diff --git a/app/models/fit.py b/app/models/fit.py new file mode 100644 index 0000000..ff37d9e --- /dev/null +++ b/app/models/fit.py @@ -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") diff --git a/app/schemas/fit.py b/app/schemas/fit.py new file mode 100644 index 0000000..7ae5128 --- /dev/null +++ b/app/schemas/fit.py @@ -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 diff --git a/app/services/fit_matching.py b/app/services/fit_matching.py new file mode 100644 index 0000000..7d56775 --- /dev/null +++ b/app/services/fit_matching.py @@ -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, + ) diff --git a/app/static/css/style.css b/app/static/css/style.css index bbb7d12..fb41736 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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; diff --git a/app/templates/admin/_augmented_part_fit_row.html b/app/templates/admin/_augmented_part_fit_row.html new file mode 100644 index 0000000..15f014f --- /dev/null +++ b/app/templates/admin/_augmented_part_fit_row.html @@ -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 %} + +
{{ note.body }}
+{{ message }}
{% endif %} + {% if error %}{{ error }}
{% 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 %} +No fits recorded yet.
+ {% endfor %} + +Re-runs the fit-matching algorithm across every part, adding newly Possible fits and pruning stale ones. Exact and Impossible labels are never changed.
+