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 @@ + + + + + {# ── Nav macro for DRY icon use ────────────────────────────────────────────── #} @@ -195,6 +200,9 @@ {% if su or "augmented_parts:read" in p %} {{ icon("layers") }} Augmented Parts {% endif %} + {% if su or "attachment_points:read" in p %} + {{ icon("anchor") }} Attachment Points + {% endif %} {% if su or "augmented_parts:write" in p %} {{ icon("alert-circle") }} Skipped Parts {% endif %} diff --git a/app/templates/admin/_attachment_point_connection_select.html b/app/templates/admin/_attachment_point_connection_select.html new file mode 100644 index 0000000..f4b5365 --- /dev/null +++ b/app/templates/admin/_attachment_point_connection_select.html @@ -0,0 +1,51 @@ +{% if connection_type == "iso_thread" %} + +{% elif connection_type == "unc_thread" %} + +{% elif connection_type == "clamp_connection" %} + +{% elif connection_type == "static_connection" %} + +{% endif %} diff --git a/app/templates/admin/_attachment_point_row.html b/app/templates/admin/_attachment_point_row.html new file mode 100644 index 0000000..e09a8de --- /dev/null +++ b/app/templates/admin/_attachment_point_row.html @@ -0,0 +1,38 @@ +{% if ap.iso_thread %} + {% set conn_type = "ISO Thread" %} + {% set conn_label = "M" ~ ap.iso_thread.diameter_mm ~ "×" ~ ap.iso_thread.pitch_mm %} +{% elif ap.unc_thread %} + {% set conn_type = "UNC Thread" %} + {% set conn_label = ap.unc_thread.diameter_fraction ~ "-" ~ ap.unc_thread.tpi ~ " " ~ ap.unc_thread.series.value %} +{% elif ap.clamp_connection %} + {% set conn_type = "Clamp Connection" %} + {% if ap.clamp_connection.min_mm is not none %} + {% set conn_label = ap.clamp_connection.min_mm ~ "–" ~ ap.clamp_connection.max_mm ~ " mm" %} + {% else %} + {% set conn_label = ap.clamp_connection.min_in ~ "–" ~ ap.clamp_connection.max_in ~ " in" %} + {% endif %} +{% elif ap.static_connection %} + {% set conn_type = "Static Connection" %} + {% set conn_label = ap.static_connection.od_mm ~ " mm OD" %} +{% else %} + {% set conn_type = "—" %} + {% set conn_label = "—" %} +{% endif %} + + {{ ap.id }} + {{ ap.augmented_part.description | truncate(40) }} + {{ ap.attachment_point_name.name }} + {{ conn_type }} + {{ conn_label }} + + + + + diff --git a/app/templates/admin/_attachment_point_row_edit.html b/app/templates/admin/_attachment_point_row_edit.html new file mode 100644 index 0000000..2b4ce5d --- /dev/null +++ b/app/templates/admin/_attachment_point_row_edit.html @@ -0,0 +1,59 @@ +{% if ap.iso_thread_id %}{% set current_type = "iso_thread" %} +{% elif ap.unc_thread_id %}{% set current_type = "unc_thread" %} +{% elif ap.clamp_connection_id %}{% set current_type = "clamp_connection" %} +{% elif ap.static_connection_id %}{% set current_type = "static_connection" %} +{% else %}{% set current_type = "" %}{% endif %} + + +
+ #{{ ap.id }} + + + + {# Render the current connection select pre-filled #} +
+ {% include "admin/_attachment_point_connection_select.html" %} +
+
+ + +
+
+ + diff --git a/app/templates/admin/_attachment_points_table.html b/app/templates/admin/_attachment_points_table.html new file mode 100644 index 0000000..ae18778 --- /dev/null +++ b/app/templates/admin/_attachment_points_table.html @@ -0,0 +1,21 @@ +
+ + + + + + + + + + + + + {% for ap in attachment_points %} + {% include "admin/_attachment_point_row.html" %} + {% else %} + + {% endfor %} + +
IDAugmented PartPoint NameConnection TypeConnection
No attachment points recorded.
+
diff --git a/app/templates/admin/attachment_points.html b/app/templates/admin/attachment_points.html new file mode 100644 index 0000000..c2df89b --- /dev/null +++ b/app/templates/admin/attachment_points.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Attachment Points · {{ app_name }}{% endblock %} +{% block body %} +{% include "_nav.html" %} +
+

Attachment Points

+ +
+

Add attachment point

+
+ + + +
+ +
+
+ + {% include "admin/_attachment_points_table.html" %} +
+{% endblock %} diff --git a/app/web/admin.py b/app/web/admin.py index 6ba8277..f1d7181 100644 --- a/app/web/admin.py +++ b/app/web/admin.py @@ -15,6 +15,7 @@ from app.crud import part_colour as part_colour_crud from app.crud import part_type as part_type_crud from app.crud import role as role_crud from app.crud import scope_model as scope_model_crud +from app.crud import attachment_point as attachment_point_crud from app.crud import attachment_point_name as apn_crud from app.crud import clamp_connection as clamp_connection_crud from app.crud import static_connection as static_connection_crud @@ -1600,6 +1601,203 @@ async def delete_attachment_point_name( return Response(status_code=status.HTTP_200_OK) +# --- Attachment Points -------------------------------------------------------- + + +async def _attachment_point_form_ctx(db: AsyncSession) -> dict: + return { + "augmented_parts": await augmented_part_crud.list_all(db), + "attachment_point_names": await apn_crud.list_all(db), + "iso_threads": await iso_thread_crud.list_all(db), + "unc_threads": await unc_thread_crud.list_all(db), + "clamp_connections": await clamp_connection_crud.list_all(db), + "static_connections": await static_connection_crud.list_all(db), + } + + +def _ap_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.value}" + 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("/attachment-points", response_class=HTMLResponse) +async def attachment_points_page( + request: Request, + db: AsyncSession = Depends(get_db), + user: User = Depends(web_require("attachment_points:read")), +): + ctx = await _attachment_point_form_ctx(db) + ctx["user"] = user + ctx["attachment_points"] = await attachment_point_crud.list_all(db) + return templates.TemplateResponse(request, "admin/attachment_points.html", ctx) + + +# Must come before /{attachment_point_id} +@router.get("/attachment-points/connection-options", response_class=HTMLResponse) +async def attachment_point_connection_options( + request: Request, + connection_type: str = "", + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("attachment_points:write")), +): + ctx = await _attachment_point_form_ctx(db) + ctx["connection_type"] = connection_type + ctx["ap"] = None + return templates.TemplateResponse( + request, "admin/_attachment_point_connection_select.html", ctx + ) + + +@router.post("/attachment-points", response_class=HTMLResponse) +async def create_attachment_point( + request: Request, + augmented_part_id: int = Form(...), + attachment_point_name_id: int = Form(...), + connection_type: str = Form(""), + iso_thread_id: str = Form(""), + unc_thread_id: str = Form(""), + clamp_connection_id: str = Form(""), + static_connection_id: str = Form(""), + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("attachment_points:write")), +): + iso_id = int(iso_thread_id) if iso_thread_id else None + unc_id = int(unc_thread_id) if unc_thread_id else None + clamp_id = int(clamp_connection_id) if clamp_connection_id else None + static_id = int(static_connection_id) if static_connection_id else None + ap = await attachment_point_crud.create( + db, + augmented_part_id=augmented_part_id, + attachment_point_name_id=attachment_point_name_id, + iso_thread_id=iso_id, + unc_thread_id=unc_id, + clamp_connection_id=clamp_id, + static_connection_id=static_id, + ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="AttachmentPoint", + resource_id=ap.id, + description=audit_crud.describe_create( + "AttachmentPoint", ap.id, _ap_connection_label(ap) + ), + ) + ctx = await _attachment_point_form_ctx(db) + ctx["attachment_points"] = await attachment_point_crud.list_all(db) + return templates.TemplateResponse( + request, "admin/_attachment_points_table.html", ctx + ) + + +@router.get("/attachment-points/{attachment_point_id}/edit", response_class=HTMLResponse) +async def edit_attachment_point_row( + request: Request, + attachment_point_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("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") + ctx = await _attachment_point_form_ctx(db) + ctx["ap"] = ap + if ap.iso_thread_id: + ctx["connection_type"] = "iso_thread" + elif ap.unc_thread_id: + ctx["connection_type"] = "unc_thread" + elif ap.clamp_connection_id: + ctx["connection_type"] = "clamp_connection" + elif ap.static_connection_id: + ctx["connection_type"] = "static_connection" + else: + ctx["connection_type"] = "" + return templates.TemplateResponse( + request, "admin/_attachment_point_row_edit.html", ctx + ) + + +@router.get("/attachment-points/{attachment_point_id}/row", response_class=HTMLResponse) +async def attachment_point_row( + request: Request, + attachment_point_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("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 templates.TemplateResponse( + request, "admin/_attachment_point_row.html", {"ap": ap} + ) + + +@router.post("/attachment-points/{attachment_point_id}", response_class=HTMLResponse) +async def update_attachment_point( + request: Request, + attachment_point_id: int, + augmented_part_id: int = Form(...), + attachment_point_name_id: int = Form(...), + connection_type: str = Form(""), + iso_thread_id: str = Form(""), + unc_thread_id: str = Form(""), + clamp_connection_id: str = Form(""), + static_connection_id: str = Form(""), + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("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 = { + "augmented_part_id": augmented_part_id, + "attachment_point_name_id": attachment_point_name_id, + "iso_thread_id": int(iso_thread_id) if iso_thread_id else None, + "unc_thread_id": int(unc_thread_id) if unc_thread_id else None, + "clamp_connection_id": int(clamp_connection_id) if clamp_connection_id else None, + "static_connection_id": int(static_connection_id) if static_connection_id else None, + } + ap = await attachment_point_crud.update(db, ap, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="AttachmentPoint", + resource_id=attachment_point_id, + description=audit_crud.describe_update( + "AttachmentPoint", attachment_point_id, changes + ), + ) + return templates.TemplateResponse( + request, "admin/_attachment_point_row.html", {"ap": ap} + ) + + +@router.delete("/attachment-points/{attachment_point_id}") +async def delete_attachment_point( + attachment_point_id: int, + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("attachment_points:delete")), +): + ap = await attachment_point_crud.get(db, attachment_point_id) + if ap is not None: + label = _ap_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 + ), + ) + return Response(status_code=status.HTTP_200_OK) + + # --- Static Connections ------------------------------------------------------- diff --git a/scripts/seed.py b/scripts/seed.py index c3403fb..72a2505 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -65,6 +65,9 @@ PERMISSIONS = [ ("attachment_point_names:read", "View attachment point names"), ("attachment_point_names:write", "Create and update attachment point names"), ("attachment_point_names:delete", "Delete attachment point names"), + ("attachment_points:read", "View attachment points"), + ("attachment_points:write", "Create and update attachment points"), + ("attachment_points:delete", "Delete attachment points"), ("augmented_parts:read", "View augmented parts"), ("augmented_parts:write", "Create and update augmented parts"), ("augmented_parts:delete", "Delete augmented parts"), @@ -74,7 +77,7 @@ PERMISSIONS = [ # Default roles -> permission names. "admin" gets everything. ROLES = { "admin": [name for name, _ in PERMISSIONS], # includes audit_log:read - "user": ["users:read", "iso_threads:read", "unc_threads:read", "manufacturers:read", "scope_models:read", "part_colours:read", "part_types:read", "sub_part_types:read", "imported_parts:read", "static_connections:read", "clamp_connections:read", "attachment_point_names:read", "augmented_parts:read"], + "user": ["users:read", "iso_threads:read", "unc_threads:read", "manufacturers:read", "scope_models:read", "part_colours:read", "part_types:read", "sub_part_types:read", "imported_parts:read", "static_connections:read", "clamp_connections:read", "attachment_point_names:read", "attachment_points:read", "augmented_parts:read"], }