diff --git a/alembic/versions/70e48bda8829_add_attachment_point_names.py b/alembic/versions/70e48bda8829_add_attachment_point_names.py new file mode 100644 index 0000000..d96ab49 --- /dev/null +++ b/alembic/versions/70e48bda8829_add_attachment_point_names.py @@ -0,0 +1,29 @@ +"""add attachment_point_names + +Revision ID: 70e48bda8829 +Revises: c7882cedc9ae +Create Date: 2026-07-02 13:54:58.335394 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '70e48bda8829' +down_revision: Union[str, None] = 'c7882cedc9ae' +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_point_names.py b/app/api/attachment_point_names.py new file mode 100644 index 0000000..26fc5bd --- /dev/null +++ b/app/api/attachment_point_names.py @@ -0,0 +1,92 @@ +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_name as apn_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_name import ( + AttachmentPointNameCreate, + AttachmentPointNameRead, + AttachmentPointNameUpdate, +) + +router = APIRouter() + + +@router.get("", response_model=list[AttachmentPointNameRead]) +async def list_attachment_point_names( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("attachment_point_names:read")), +): + return await apn_crud.list_all(db, skip=skip, limit=limit) + + +@router.post( + "", response_model=AttachmentPointNameRead, status_code=status.HTTP_201_CREATED +) +async def create_attachment_point_name( + payload: AttachmentPointNameCreate, + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("attachment_point_names:write")), +): + apn = await apn_crud.create(db, name=payload.name) + await audit_crud.log( + db, actor=actor, action="create", resource_type="AttachmentPointName", + resource_id=apn.id, + description=audit_crud.describe_create("AttachmentPointName", apn.id, apn.name), + ) + return apn + + +@router.get("/{apn_id}", response_model=AttachmentPointNameRead) +async def get_attachment_point_name( + apn_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("attachment_point_names:read")), +): + apn = await apn_crud.get(db, apn_id) + if apn is None: + raise HTTPException(status_code=404, detail="Attachment point name not found") + return apn + + +@router.patch("/{apn_id}", response_model=AttachmentPointNameRead) +async def update_attachment_point_name( + apn_id: int, + payload: AttachmentPointNameUpdate, + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("attachment_point_names:write")), +): + apn = await apn_crud.get(db, apn_id) + if apn is None: + raise HTTPException(status_code=404, detail="Attachment point name not found") + changes = payload.model_dump(exclude_unset=True) + apn = await apn_crud.update(db, apn, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="AttachmentPointName", + resource_id=apn.id, + description=audit_crud.describe_update("AttachmentPointName", apn.id, changes), + ) + return apn + + +@router.delete("/{apn_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_attachment_point_name( + apn_id: int, + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("attachment_point_names:delete")), +): + apn = await apn_crud.get(db, apn_id) + if apn is None: + raise HTTPException(status_code=404, detail="Attachment point name not found") + name = apn.name + await apn_crud.delete(db, apn) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="AttachmentPointName", + resource_id=apn_id, + description=audit_crud.describe_delete("AttachmentPointName", apn_id, name), + ) diff --git a/app/api/router.py b/app/api/router.py index 8e40377..1e7569f 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api import 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, 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"]) @@ -15,6 +15,7 @@ api_router.include_router(part_types.router, prefix="/part-types", tags=["part-t api_router.include_router(sub_part_types.router, prefix="/sub-part-types", tags=["sub-part-types"]) 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(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_name.py b/app/crud/attachment_point_name.py new file mode 100644 index 0000000..e2195ba --- /dev/null +++ b/app/crud/attachment_point_name.py @@ -0,0 +1,50 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.attachment_point_name import AttachmentPointName + + +async def get(db: AsyncSession, apn_id: int) -> AttachmentPointName | None: + return await db.get(AttachmentPointName, apn_id) + + +async def get_by_name(db: AsyncSession, name: str) -> AttachmentPointName | None: + result = await db.execute( + select(AttachmentPointName).where(AttachmentPointName.name == name) + ) + return result.scalar_one_or_none() + + +async def list_all( + db: AsyncSession, skip: int = 0, limit: int = 100 +) -> list[AttachmentPointName]: + result = await db.execute( + select(AttachmentPointName) + .order_by(AttachmentPointName.name) + .offset(skip) + .limit(limit) + ) + return list(result.scalars().all()) + + +async def create(db: AsyncSession, *, name: str) -> AttachmentPointName: + apn = AttachmentPointName(name=name) + db.add(apn) + await db.commit() + await db.refresh(apn) + return apn + + +async def update( + db: AsyncSession, apn: AttachmentPointName, data: dict +) -> AttachmentPointName: + for field, value in data.items(): + setattr(apn, field, value) + await db.commit() + await db.refresh(apn) + return apn + + +async def delete(db: AsyncSession, apn: AttachmentPointName) -> None: + await db.delete(apn) + await db.commit() diff --git a/app/models/__init__.py b/app/models/__init__.py index a4e1f8f..22e285e 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_name import AttachmentPointName from app.models.audit_log import AuditLog from app.models.augmented_part import AugmentedPart from app.models.clamp_connection import ClampConnection @@ -20,6 +21,7 @@ from app.models.unc_thread import UncThread from app.models.user import User __all__ = [ + "AttachmentPointName", "AuditLog", "AugmentedPart", "ClampConnection", diff --git a/app/models/attachment_point_name.py b/app/models/attachment_point_name.py new file mode 100644 index 0000000..a76a136 --- /dev/null +++ b/app/models/attachment_point_name.py @@ -0,0 +1,12 @@ +from sqlalchemy import UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class AttachmentPointName(Base): + __tablename__ = "attachment_point_names" + __table_args__ = (UniqueConstraint("name"),) + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(nullable=False) diff --git a/app/schemas/attachment_point_name.py b/app/schemas/attachment_point_name.py new file mode 100644 index 0000000..fa799b9 --- /dev/null +++ b/app/schemas/attachment_point_name.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, ConfigDict + + +class AttachmentPointNameBase(BaseModel): + name: str + + +class AttachmentPointNameCreate(AttachmentPointNameBase): + pass + + +class AttachmentPointNameUpdate(BaseModel): + name: str | None = None + + +class AttachmentPointNameRead(AttachmentPointNameBase): + model_config = ConfigDict(from_attributes=True) + id: int diff --git a/app/templates/_nav.html b/app/templates/_nav.html index 51cc99c..9c03ad5 100644 --- a/app/templates/_nav.html +++ b/app/templates/_nav.html @@ -2,7 +2,7 @@ {% set p = user.permissions %} {% 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 "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_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_parts = can_connections or can_labels or can_database %} @@ -103,6 +103,10 @@ + + + + {# ── Nav macro for DRY icon use ────────────────────────────────────────────── #} @@ -159,6 +163,9 @@