From ceae27a12e2cc60e37ee022878c95fe507b68486 Mon Sep 17 00:00:00 2001
From: Ian Roddis <31021769+iroddis@users.noreply.github.com>
Date: Thu, 2 Jul 2026 14:06:18 -0300
Subject: [PATCH] Adding Attachment Points
---
.../adf4a5b9493e_add_attachment_points.py | 29 +++
app/api/attachment_points.py | 118 +++++++++++
app/api/router.py | 3 +-
app/crud/attachment_point.py | 67 ++++++
app/models/__init__.py | 2 +
app/models/attachment_point.py | 42 ++++
app/schemas/attachment_point.py | 39 ++++
app/templates/_nav.html | 10 +-
.../_attachment_point_connection_select.html | 51 +++++
.../admin/_attachment_point_row.html | 38 ++++
.../admin/_attachment_point_row_edit.html | 59 ++++++
.../admin/_attachment_points_table.html | 21 ++
app/templates/admin/attachment_points.html | 50 +++++
app/web/admin.py | 198 ++++++++++++++++++
scripts/seed.py | 5 +-
15 files changed, 729 insertions(+), 3 deletions(-)
create mode 100644 alembic/versions/adf4a5b9493e_add_attachment_points.py
create mode 100644 app/api/attachment_points.py
create mode 100644 app/crud/attachment_point.py
create mode 100644 app/models/attachment_point.py
create mode 100644 app/schemas/attachment_point.py
create mode 100644 app/templates/admin/_attachment_point_connection_select.html
create mode 100644 app/templates/admin/_attachment_point_row.html
create mode 100644 app/templates/admin/_attachment_point_row_edit.html
create mode 100644 app/templates/admin/_attachment_points_table.html
create mode 100644 app/templates/admin/attachment_points.html
diff --git a/alembic/versions/adf4a5b9493e_add_attachment_points.py b/alembic/versions/adf4a5b9493e_add_attachment_points.py
new file mode 100644
index 0000000..9a428a8
--- /dev/null
+++ b/alembic/versions/adf4a5b9493e_add_attachment_points.py
@@ -0,0 +1,29 @@
+"""add attachment_points
+
+Revision ID: adf4a5b9493e
+Revises: 70e48bda8829
+Create Date: 2026-07-02 14:03:23.561253
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'adf4a5b9493e'
+down_revision: Union[str, None] = '70e48bda8829'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ pass
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ pass
+ # ### end Alembic commands ###
diff --git a/app/api/attachment_points.py b/app/api/attachment_points.py
new file mode 100644
index 0000000..9d05978
--- /dev/null
+++ b/app/api/attachment_points.py
@@ -0,0 +1,118 @@
+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.deps import require_permission
+from app.models.user import User
+from app.schemas.attachment_point import (
+ AttachmentPointCreate,
+ AttachmentPointRead,
+ AttachmentPointUpdate,
+)
+
+router = APIRouter()
+
+
+def _connection_label(ap) -> str:
+ if ap.iso_thread:
+ return f"M{ap.iso_thread.diameter_mm}×{ap.iso_thread.pitch_mm}"
+ if ap.unc_thread:
+ return f"{ap.unc_thread.diameter_fraction}-{ap.unc_thread.tpi} {ap.unc_thread.series}"
+ if ap.clamp_connection:
+ cc = ap.clamp_connection
+ if cc.min_mm is not None:
+ return f"{cc.min_mm}–{cc.max_mm} mm"
+ return f"{cc.min_in}–{cc.max_in} in"
+ if ap.static_connection:
+ return f"{ap.static_connection.od_mm} mm OD"
+ return "—"
+
+
+@router.get("", response_model=list[AttachmentPointRead])
+async def list_attachment_points(
+ skip: int = 0,
+ limit: int = 100,
+ db: AsyncSession = Depends(get_db),
+ _: User = Depends(require_permission("attachment_points:read")),
+):
+ return await attachment_point_crud.list_all(db, skip=skip, limit=limit)
+
+
+@router.post("", response_model=AttachmentPointRead, status_code=status.HTTP_201_CREATED)
+async def create_attachment_point(
+ payload: AttachmentPointCreate,
+ db: AsyncSession = Depends(get_db),
+ actor: User = Depends(require_permission("attachment_points:write")),
+):
+ conn_ids = [
+ payload.iso_thread_id,
+ payload.unc_thread_id,
+ payload.clamp_connection_id,
+ payload.static_connection_id,
+ ]
+ if sum(v is not None for v in conn_ids) != 1:
+ raise HTTPException(
+ status_code=400,
+ detail="Exactly one connection (iso_thread, unc_thread, clamp_connection, or static_connection) must be provided.",
+ )
+ ap = await attachment_point_crud.create(db, **payload.model_dump())
+ await audit_crud.log(
+ db, actor=actor, action="create", resource_type="AttachmentPoint",
+ resource_id=ap.id,
+ description=audit_crud.describe_create(
+ "AttachmentPoint", ap.id, _connection_label(ap)
+ ),
+ )
+ return ap
+
+
+@router.get("/{attachment_point_id}", response_model=AttachmentPointRead)
+async def get_attachment_point(
+ attachment_point_id: int,
+ db: AsyncSession = Depends(get_db),
+ _: User = Depends(require_permission("attachment_points:read")),
+):
+ ap = await attachment_point_crud.get(db, attachment_point_id)
+ if ap is None:
+ raise HTTPException(status_code=404, detail="Attachment point not found")
+ return ap
+
+
+@router.patch("/{attachment_point_id}", response_model=AttachmentPointRead)
+async def update_attachment_point(
+ attachment_point_id: int,
+ payload: AttachmentPointUpdate,
+ db: AsyncSession = Depends(get_db),
+ actor: User = Depends(require_permission("attachment_points:write")),
+):
+ ap = await attachment_point_crud.get(db, attachment_point_id)
+ if ap is None:
+ raise HTTPException(status_code=404, detail="Attachment point not found")
+ changes = payload.model_dump(exclude_unset=True)
+ ap = await attachment_point_crud.update(db, ap, changes)
+ await audit_crud.log(
+ db, actor=actor, action="update", resource_type="AttachmentPoint",
+ resource_id=ap.id,
+ description=audit_crud.describe_update("AttachmentPoint", ap.id, changes),
+ )
+ return ap
+
+
+@router.delete("/{attachment_point_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_attachment_point(
+ attachment_point_id: int,
+ db: AsyncSession = Depends(get_db),
+ actor: User = Depends(require_permission("attachment_points:delete")),
+):
+ ap = await attachment_point_crud.get(db, attachment_point_id)
+ if ap is None:
+ raise HTTPException(status_code=404, detail="Attachment point not found")
+ label = _connection_label(ap)
+ await attachment_point_crud.delete(db, ap)
+ await audit_crud.log(
+ db, actor=actor, action="delete", resource_type="AttachmentPoint",
+ resource_id=attachment_point_id,
+ description=audit_crud.describe_delete("AttachmentPoint", attachment_point_id, label),
+ )
diff --git a/app/api/router.py b/app/api/router.py
index 1e7569f..3eb265a 100644
--- a/app/api/router.py
+++ b/app/api/router.py
@@ -1,6 +1,6 @@
from fastapi import APIRouter
-from app.api import attachment_point_names, audit_log, augmented_parts, auth, clamp_connections, imported_parts, iso_threads, manufacturers, part_colours, part_types, roles, scope_models, static_connections, sub_part_types, unc_threads, users
+from app.api import attachment_point_names, attachment_points, audit_log, augmented_parts, auth, clamp_connections, imported_parts, iso_threads, manufacturers, part_colours, part_types, roles, scope_models, static_connections, sub_part_types, unc_threads, users
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@@ -16,6 +16,7 @@ api_router.include_router(sub_part_types.router, prefix="/sub-part-types", tags=
api_router.include_router(static_connections.router, prefix="/static-connections", tags=["static-connections"])
api_router.include_router(clamp_connections.router, prefix="/clamp-connections", tags=["clamp-connections"])
api_router.include_router(attachment_point_names.router, prefix="/attachment-point-names", tags=["attachment-point-names"])
+api_router.include_router(attachment_points.router, prefix="/attachment-points", tags=["attachment-points"])
api_router.include_router(imported_parts.router, prefix="/imported-parts", tags=["imported-parts"])
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"])
diff --git a/app/crud/attachment_point.py b/app/crud/attachment_point.py
new file mode 100644
index 0000000..17c146e
--- /dev/null
+++ b/app/crud/attachment_point.py
@@ -0,0 +1,67 @@
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.attachment_point import AttachmentPoint
+
+_RELATIONSHIPS = [
+ "attachment_point_name",
+ "iso_thread",
+ "unc_thread",
+ "clamp_connection",
+ "static_connection",
+]
+
+
+async def get(db: AsyncSession, attachment_point_id: int) -> AttachmentPoint | None:
+ return await db.get(AttachmentPoint, attachment_point_id)
+
+
+async def list_all(
+ db: AsyncSession, skip: int = 0, limit: int = 100
+) -> list[AttachmentPoint]:
+ result = await db.execute(
+ select(AttachmentPoint)
+ .order_by(AttachmentPoint.augmented_part_id, AttachmentPoint.id)
+ .offset(skip)
+ .limit(limit)
+ )
+ return list(result.scalars().all())
+
+
+async def create(
+ db: AsyncSession,
+ *,
+ augmented_part_id: int,
+ attachment_point_name_id: int,
+ iso_thread_id: int | None = None,
+ unc_thread_id: int | None = None,
+ clamp_connection_id: int | None = None,
+ static_connection_id: int | None = None,
+) -> AttachmentPoint:
+ ap = AttachmentPoint(
+ augmented_part_id=augmented_part_id,
+ attachment_point_name_id=attachment_point_name_id,
+ iso_thread_id=iso_thread_id,
+ unc_thread_id=unc_thread_id,
+ clamp_connection_id=clamp_connection_id,
+ static_connection_id=static_connection_id,
+ )
+ db.add(ap)
+ await db.commit()
+ await db.refresh(ap, attribute_names=_RELATIONSHIPS)
+ return ap
+
+
+async def update(
+ db: AsyncSession, ap: AttachmentPoint, data: dict
+) -> AttachmentPoint:
+ for field, value in data.items():
+ setattr(ap, field, value)
+ await db.commit()
+ await db.refresh(ap, attribute_names=_RELATIONSHIPS)
+ return ap
+
+
+async def delete(db: AsyncSession, ap: AttachmentPoint) -> None:
+ await db.delete(ap)
+ await db.commit()
diff --git a/app/models/__init__.py b/app/models/__init__.py
index 22e285e..1c9ff3f 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -1,6 +1,7 @@
"""Import all models so they register with ``Base.metadata``."""
from app.models.associations import augmented_part_imported_parts, role_permissions, user_roles
+from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName
from app.models.audit_log import AuditLog
from app.models.augmented_part import AugmentedPart
@@ -21,6 +22,7 @@ from app.models.unc_thread import UncThread
from app.models.user import User
__all__ = [
+ "AttachmentPoint",
"AttachmentPointName",
"AuditLog",
"AugmentedPart",
diff --git a/app/models/attachment_point.py b/app/models/attachment_point.py
new file mode 100644
index 0000000..f1b1197
--- /dev/null
+++ b/app/models/attachment_point.py
@@ -0,0 +1,42 @@
+from sqlalchemy import ForeignKey
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.core.database import Base
+from app.models.attachment_point_name import AttachmentPointName
+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 UncThread
+
+
+class AttachmentPoint(Base):
+ __tablename__ = "attachment_points"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+
+ augmented_part_id: Mapped[int] = mapped_column(
+ ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
+ )
+ attachment_point_name_id: Mapped[int] = mapped_column(
+ ForeignKey("attachment_point_names.id", ondelete="RESTRICT"), nullable=False
+ )
+
+ # Exactly one of the four below must be non-null (enforced in application layer)
+ iso_thread_id: Mapped[int | None] = mapped_column(
+ ForeignKey("iso_threads.id", ondelete="RESTRICT"), nullable=True, default=None
+ )
+ unc_thread_id: Mapped[int | None] = mapped_column(
+ ForeignKey("unc_threads.id", ondelete="RESTRICT"), nullable=True, default=None
+ )
+ clamp_connection_id: Mapped[int | None] = mapped_column(
+ ForeignKey("clamp_connections.id", ondelete="RESTRICT"), nullable=True, default=None
+ )
+ static_connection_id: Mapped[int | None] = mapped_column(
+ ForeignKey("static_connections.id", ondelete="RESTRICT"), nullable=True, default=None
+ )
+
+ attachment_point_name: Mapped[AttachmentPointName] = relationship(lazy="selectin")
+ iso_thread: Mapped[IsoThread | None] = relationship(lazy="selectin")
+ unc_thread: Mapped[UncThread | None] = relationship(lazy="selectin")
+ clamp_connection: Mapped[ClampConnection | None] = relationship(lazy="selectin")
+ static_connection: Mapped[StaticConnection | None] = relationship(lazy="selectin")
diff --git a/app/schemas/attachment_point.py b/app/schemas/attachment_point.py
new file mode 100644
index 0000000..4fceaeb
--- /dev/null
+++ b/app/schemas/attachment_point.py
@@ -0,0 +1,39 @@
+from pydantic import BaseModel, ConfigDict
+
+from app.schemas.attachment_point_name import AttachmentPointNameRead
+from app.schemas.clamp_connection import ClampConnectionRead
+from app.schemas.iso_thread import IsoThreadRead
+from app.schemas.static_connection import StaticConnectionRead
+from app.schemas.unc_thread import UncThreadRead
+
+
+class AttachmentPointBase(BaseModel):
+ augmented_part_id: int
+ attachment_point_name_id: int
+ iso_thread_id: int | None = None
+ unc_thread_id: int | None = None
+ clamp_connection_id: int | None = None
+ static_connection_id: int | None = None
+
+
+class AttachmentPointCreate(AttachmentPointBase):
+ pass
+
+
+class AttachmentPointUpdate(BaseModel):
+ augmented_part_id: int | None = None
+ attachment_point_name_id: int | None = None
+ iso_thread_id: int | None = None
+ unc_thread_id: int | None = None
+ clamp_connection_id: int | None = None
+ static_connection_id: int | None = None
+
+
+class AttachmentPointRead(AttachmentPointBase):
+ model_config = ConfigDict(from_attributes=True)
+ id: int
+ attachment_point_name: AttachmentPointNameRead
+ iso_thread: IsoThreadRead | None = None
+ unc_thread: UncThreadRead | None = None
+ clamp_connection: ClampConnectionRead | None = None
+ static_connection: StaticConnectionRead | None = None
diff --git a/app/templates/_nav.html b/app/templates/_nav.html
index 9c03ad5..0bbe045 100644
--- a/app/templates/_nav.html
+++ b/app/templates/_nav.html
@@ -3,7 +3,7 @@
{% set can_admin = su or "users:read" in p or "roles:read" in p or "audit_log:read" in p %}
{% set can_connections = su or "clamp_connections:read" in p or "iso_threads:read" in p or "static_connections:read" in p or "unc_threads:read" in p %}
{% set can_labels = su or "attachment_point_names:read" in p or "manufacturers:read" in p or "part_colours:read" in p or "part_types:read" in p or "scope_models:read" in p or "sub_part_types:read" in p %}
-{% set can_database = su or "imported_parts:read" in p or "augmented_parts:read" in p %}
+{% set can_database = su or "imported_parts:read" in p or "augmented_parts:read" in p or "attachment_points:read" in p %}
{% set can_parts = can_connections or can_labels or can_database %}
{# ── SVG icon sprite ───────────────────────────────────────────────────────── #}
@@ -107,6 +107,11 @@
{{ conn_label }}| ID | +Augmented Part | +Point Name | +Connection Type | +Connection | ++ |
|---|---|---|---|---|---|
| No attachment points recorded. | |||||