Adding fits
This commit is contained in:
1 parent
8185f1f8b3
commit
ab39191af5
19 files changed
+1253
-1
No files matched your search
@@ -3,7 +3,10 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"Bash(uv run *)",
|
"Bash(uv run *)",
|
||||||
"Bash(uv sync *)",
|
"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 *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
+171
@@ -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)
|
||||||
@@ -7,6 +7,7 @@ from app.api import (
|
|||||||
augmented_parts,
|
augmented_parts,
|
||||||
auth,
|
auth,
|
||||||
clamp_connections,
|
clamp_connections,
|
||||||
|
fits,
|
||||||
imported_parts,
|
imported_parts,
|
||||||
iso_threads,
|
iso_threads,
|
||||||
manufacturers,
|
manufacturers,
|
||||||
@@ -64,3 +65,4 @@ api_router.include_router(
|
|||||||
augmented_parts.router, prefix="/augmented-parts", tags=["augmented-parts"]
|
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(audit_log.router, prefix="/audit-log", tags=["audit-log"])
|
||||||
|
api_router.include_router(fits.router, prefix="/fits", tags=["fits"])
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -10,6 +10,7 @@ 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
|
||||||
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.imported_part import ImportedPart
|
from app.models.imported_part import ImportedPart
|
||||||
from app.models.imported_part_config import ImportedPartConfig
|
from app.models.imported_part_config import ImportedPartConfig
|
||||||
from app.models.iso_thread import IsoThread
|
from app.models.iso_thread import IsoThread
|
||||||
@@ -31,6 +32,9 @@ __all__ = [
|
|||||||
"AuditLog",
|
"AuditLog",
|
||||||
"AugmentedPart",
|
"AugmentedPart",
|
||||||
"ClampConnection",
|
"ClampConnection",
|
||||||
|
"Fit",
|
||||||
|
"FitLabel",
|
||||||
|
"FitNote",
|
||||||
"ImportedPart",
|
"ImportedPart",
|
||||||
"ImportedPartConfig",
|
"ImportedPartConfig",
|
||||||
"IsoThread",
|
"IsoThread",
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -460,6 +460,46 @@ textarea:focus {
|
|||||||
.ap-item-conn { flex: 1; }
|
.ap-item-conn { flex: 1; }
|
||||||
.ap-item .row-actions { margin-left: auto; }
|
.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 */
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex;
|
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-trigger="load"
|
||||||
hx-swap="outerHTML">
|
hx-swap="outerHTML">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div hx-get="/admin/augmented-parts/{{ ap.id }}/fits"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
||||||
@@ -65,6 +65,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</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" %}
|
{% include "admin/_augmented_parts_table.html" %}
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -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 as attachment_point_crud
|
||||||
from app.crud import attachment_point_name as apn_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 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 static_connection as static_connection_crud
|
||||||
from app.crud import sub_part_type as sub_part_type_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 unc_thread as unc_thread_crud
|
||||||
from app.crud import user as user_crud
|
from app.crud import user as user_crud
|
||||||
from app.deps import get_current_web_user
|
from app.deps import get_current_web_user
|
||||||
|
from app.models.fit import FitLabel
|
||||||
from app.models.unc_thread import UncSeries
|
from app.models.unc_thread import UncSeries
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services import augmented_part_sync as augmented_part_sync_service
|
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.services import odata as odata_service
|
||||||
from app.web.templates import templates
|
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 ------------------------------------------------------------
|
# --- Part Colours ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ PERMISSIONS = [
|
|||||||
("augmented_parts:read", "View augmented parts"),
|
("augmented_parts:read", "View augmented parts"),
|
||||||
("augmented_parts:write", "Create and update augmented parts"),
|
("augmented_parts:write", "Create and update augmented parts"),
|
||||||
("augmented_parts:delete", "Delete augmented parts"),
|
("augmented_parts:delete", "Delete augmented parts"),
|
||||||
|
("fits:read", "View part fits"),
|
||||||
|
("fits:write", "Create, promote and recompute part fits"),
|
||||||
|
("fits:delete", "Delete part fits"),
|
||||||
("audit_log:read", "View audit log"),
|
("audit_log:read", "View audit log"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -92,6 +95,7 @@ ROLES = {
|
|||||||
"attachment_point_names:read",
|
"attachment_point_names:read",
|
||||||
"attachment_points:read",
|
"attachment_points:read",
|
||||||
"augmented_parts:read",
|
"augmented_parts:read",
|
||||||
|
"fits:read",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
from app.models.attachment_point import AttachmentPoint
|
||||||
|
from app.models.augmented_part import AugmentedPart
|
||||||
|
from app.models.clamp_connection import ClampConnection
|
||||||
|
from app.models.iso_thread import IsoThread
|
||||||
|
from app.models.static_connection import StaticConnection
|
||||||
|
from app.models.unc_thread import UncSeries, UncThread
|
||||||
|
from app.services.fit_matching import attachment_points_match, parts_could_fit
|
||||||
|
|
||||||
|
|
||||||
|
def _ap(id_, name_id, **conn):
|
||||||
|
ap = AttachmentPoint(attachment_point_name_id=name_id, augmented_part_id=1)
|
||||||
|
ap.id = id_
|
||||||
|
for field, value in conn.items():
|
||||||
|
setattr(ap, field, value)
|
||||||
|
return ap
|
||||||
|
|
||||||
|
|
||||||
|
def test_iso_thread_exact_match():
|
||||||
|
a = _ap(1, 1, iso_thread=IsoThread(diameter_mm=8, pitch_mm=1.25))
|
||||||
|
b = _ap(2, 2, iso_thread=IsoThread(diameter_mm=8, pitch_mm=1.25))
|
||||||
|
assert attachment_points_match(a, b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_iso_thread_mismatched_dimensions():
|
||||||
|
a = _ap(1, 1, iso_thread=IsoThread(diameter_mm=8, pitch_mm=1.25))
|
||||||
|
b = _ap(2, 2, iso_thread=IsoThread(diameter_mm=8, pitch_mm=1.0))
|
||||||
|
assert not attachment_points_match(a, b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_label_never_matches_even_with_identical_threads():
|
||||||
|
a = _ap(1, 1, iso_thread=IsoThread(diameter_mm=8, pitch_mm=1.25))
|
||||||
|
b = _ap(2, 1, iso_thread=IsoThread(diameter_mm=8, pitch_mm=1.25))
|
||||||
|
assert not attachment_points_match(a, b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unc_thread_exact_match():
|
||||||
|
a = _ap(1, 1, unc_thread=UncThread(diameter_fraction="1/4", tpi=20, series=UncSeries.UNC))
|
||||||
|
b = _ap(2, 2, unc_thread=UncThread(diameter_fraction="1/4", tpi=20, series=UncSeries.UNC))
|
||||||
|
assert attachment_points_match(a, b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unc_thread_series_mismatch():
|
||||||
|
a = _ap(1, 1, unc_thread=UncThread(diameter_fraction="1/4", tpi=20, series=UncSeries.UNC))
|
||||||
|
b = _ap(2, 2, unc_thread=UncThread(diameter_fraction="1/4", tpi=20, series=UncSeries.UNF))
|
||||||
|
assert not attachment_points_match(a, b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_iso_and_unc_never_match_each_other():
|
||||||
|
a = _ap(1, 1, iso_thread=IsoThread(diameter_mm=8, pitch_mm=1.25))
|
||||||
|
b = _ap(2, 2, unc_thread=UncThread(diameter_fraction="1/4", tpi=20, series=UncSeries.UNC))
|
||||||
|
assert not attachment_points_match(a, b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_within_clamp_range_mm():
|
||||||
|
a = _ap(1, 1, static_connection=StaticConnection(od_mm=30, od_in=1.18))
|
||||||
|
b = _ap(2, 2, clamp_connection=ClampConnection(min_mm=25, max_mm=35))
|
||||||
|
assert attachment_points_match(a, b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_outside_clamp_range_mm():
|
||||||
|
a = _ap(1, 1, static_connection=StaticConnection(od_mm=40, od_in=1.57))
|
||||||
|
b = _ap(2, 2, clamp_connection=ClampConnection(min_mm=25, max_mm=35))
|
||||||
|
assert not attachment_points_match(a, b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_matches_clamp_defined_only_in_inches():
|
||||||
|
a = _ap(1, 1, static_connection=StaticConnection(od_mm=30, od_in=1.1811))
|
||||||
|
b = _ap(2, 2, clamp_connection=ClampConnection(min_in=1.0, max_in=1.5))
|
||||||
|
assert attachment_points_match(a, b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_matches_clamp_with_only_a_minimum():
|
||||||
|
a = _ap(1, 1, static_connection=StaticConnection(od_mm=40, od_in=1.57))
|
||||||
|
b = _ap(2, 2, clamp_connection=ClampConnection(min_mm=25))
|
||||||
|
assert attachment_points_match(a, b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamp_with_no_bounds_never_matches():
|
||||||
|
a = _ap(1, 1, static_connection=StaticConnection(od_mm=30, od_in=1.18))
|
||||||
|
b = _ap(2, 2, clamp_connection=ClampConnection())
|
||||||
|
assert not attachment_points_match(a, b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_of_static_and_clamp_does_not_matter():
|
||||||
|
a = _ap(1, 1, clamp_connection=ClampConnection(min_mm=25, max_mm=35))
|
||||||
|
b = _ap(2, 2, static_connection=StaticConnection(od_mm=30, od_in=1.18))
|
||||||
|
assert attachment_points_match(a, b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_bare_attachment_points_never_match():
|
||||||
|
a = _ap(1, 1)
|
||||||
|
b = _ap(2, 2)
|
||||||
|
assert not attachment_points_match(a, b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parts_could_fit_requires_different_types_and_identity():
|
||||||
|
a = AugmentedPart(id=1, part_type_id=1, description="a", colour_id=1, manufacturer_id=1)
|
||||||
|
b = AugmentedPart(id=2, part_type_id=1, description="b", colour_id=1, manufacturer_id=1)
|
||||||
|
c = AugmentedPart(id=3, part_type_id=2, description="c", colour_id=1, manufacturer_id=1)
|
||||||
|
assert not parts_could_fit(a, b)
|
||||||
|
assert parts_could_fit(a, c)
|
||||||
|
assert not parts_could_fit(a, a)
|
||||||
Reference in new issue
Block a user