diff --git a/alembic/versions/8bc411b5814e_add_augmented_parts.py b/alembic/versions/8bc411b5814e_add_augmented_parts.py new file mode 100644 index 0000000..949891b --- /dev/null +++ b/alembic/versions/8bc411b5814e_add_augmented_parts.py @@ -0,0 +1,29 @@ +"""add augmented_parts + +Revision ID: 8bc411b5814e +Revises: 0c3d1a5cff1f +Create Date: 2026-07-02 12:37:59.457033 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8bc411b5814e' +down_revision: Union[str, None] = '0c3d1a5cff1f' +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/augmented_parts.py b/app/api/augmented_parts.py new file mode 100644 index 0000000..87b149f --- /dev/null +++ b/app/api/augmented_parts.py @@ -0,0 +1,99 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.crud import audit as audit_crud +from app.crud import augmented_part as augmented_part_crud +from app.deps import require_permission +from app.models.user import User +from app.schemas.augmented_part import ( + AugmentedPartCreate, + AugmentedPartRead, + AugmentedPartUpdate, +) + +router = APIRouter() + + +@router.get("", response_model=list[AugmentedPartRead]) +async def list_augmented_parts( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("augmented_parts:read")), +): + return await augmented_part_crud.list_all(db, skip=skip, limit=limit) + + +@router.post("", response_model=AugmentedPartRead, status_code=status.HTTP_201_CREATED) +async def create_augmented_part( + payload: AugmentedPartCreate, + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("augmented_parts:write")), +): + ap = await augmented_part_crud.create( + db, + sku=payload.sku, + description=payload.description, + colour_id=payload.colour_id, + manufacturer_id=payload.manufacturer_id, + scope_model_id=payload.scope_model_id, + part_type_id=payload.part_type_id, + sub_part_type_id=payload.sub_part_type_id, + ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="AugmentedPart", + resource_id=ap.id, + description=audit_crud.describe_create("AugmentedPart", ap.id, ap.sku or ap.description), + ) + return ap + + +@router.get("/{augmented_part_id}", response_model=AugmentedPartRead) +async def get_augmented_part( + augmented_part_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("augmented_parts:read")), +): + ap = await augmented_part_crud.get(db, augmented_part_id) + if ap is None: + raise HTTPException(status_code=404, detail="Augmented part not found") + return ap + + +@router.patch("/{augmented_part_id}", response_model=AugmentedPartRead) +async def update_augmented_part( + augmented_part_id: int, + payload: AugmentedPartUpdate, + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("augmented_parts:write")), +): + ap = await augmented_part_crud.get(db, augmented_part_id) + if ap is None: + raise HTTPException(status_code=404, detail="Augmented part not found") + changes = payload.model_dump(exclude_unset=True) + ap = await augmented_part_crud.update(db, ap, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="AugmentedPart", + resource_id=ap.id, + description=audit_crud.describe_update("AugmentedPart", ap.id, changes), + ) + return ap + + +@router.delete("/{augmented_part_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_augmented_part( + augmented_part_id: int, + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("augmented_parts:delete")), +): + ap = await augmented_part_crud.get(db, augmented_part_id) + if ap is None: + raise HTTPException(status_code=404, detail="Augmented part not found") + label = ap.sku or ap.description + await augmented_part_crud.delete(db, ap) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="AugmentedPart", + resource_id=augmented_part_id, + description=audit_crud.describe_delete("AugmentedPart", augmented_part_id, label), + ) diff --git a/app/api/router.py b/app/api/router.py index 1867d9e..a9745ef 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, auth, imported_parts, iso_threads, manufacturers, part_colours, part_types, roles, scope_models, static_connections, sub_part_types, unc_threads, users +from app.api import audit_log, augmented_parts, auth, 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,4 +15,5 @@ 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(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/augmented_part.py b/app/crud/augmented_part.py new file mode 100644 index 0000000..456f2c6 --- /dev/null +++ b/app/crud/augmented_part.py @@ -0,0 +1,63 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.augmented_part import AugmentedPart + +_RELATIONSHIPS = ["colour", "manufacturer", "scope_model", "part_type", "sub_part_type"] + + +async def get(db: AsyncSession, augmented_part_id: int) -> AugmentedPart | None: + return await db.get(AugmentedPart, augmented_part_id) + + +async def list_all( + db: AsyncSession, skip: int = 0, limit: int = 100 +) -> list[AugmentedPart]: + result = await db.execute( + select(AugmentedPart) + .order_by(AugmentedPart.description) + .offset(skip) + .limit(limit) + ) + return list(result.scalars().all()) + + +async def create( + db: AsyncSession, + *, + sku: str | None, + description: str, + colour_id: int, + manufacturer_id: int, + scope_model_id: int | None, + part_type_id: int, + sub_part_type_id: int | None, +) -> AugmentedPart: + ap = AugmentedPart( + sku=sku, + description=description, + colour_id=colour_id, + manufacturer_id=manufacturer_id, + scope_model_id=scope_model_id, + part_type_id=part_type_id, + sub_part_type_id=sub_part_type_id, + ) + db.add(ap) + await db.commit() + await db.refresh(ap, attribute_names=_RELATIONSHIPS) + return ap + + +async def update( + db: AsyncSession, ap: AugmentedPart, data: dict +) -> AugmentedPart: + 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: AugmentedPart) -> None: + await db.delete(ap) + await db.commit() diff --git a/app/models/__init__.py b/app/models/__init__.py index ab6a99b..4773712 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -2,6 +2,7 @@ from app.models.associations import role_permissions, user_roles from app.models.audit_log import AuditLog +from app.models.augmented_part import AugmentedPart from app.models.imported_part import ImportedPart from app.models.imported_part_config import ImportedPartConfig from app.models.iso_thread import IsoThread @@ -19,6 +20,7 @@ from app.models.user import User __all__ = [ "AuditLog", + "AugmentedPart", "ImportedPart", "ImportedPartConfig", "IsoThread", diff --git a/app/models/augmented_part.py b/app/models/augmented_part.py new file mode 100644 index 0000000..6b86e44 --- /dev/null +++ b/app/models/augmented_part.py @@ -0,0 +1,38 @@ +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base +from app.models.manufacturer import Manufacturer +from app.models.part_colour import PartColour +from app.models.part_type import PartType +from app.models.scope_model import ScopeModel +from app.models.sub_part_type import SubPartType + + +class AugmentedPart(Base): + __tablename__ = "augmented_parts" + + id: Mapped[int] = mapped_column(primary_key=True) + sku: Mapped[str | None] = mapped_column(String, nullable=True, default=None) + description: Mapped[str] = mapped_column(String, nullable=False) + colour_id: Mapped[int] = mapped_column( + ForeignKey("part_colours.id", ondelete="RESTRICT"), nullable=False + ) + manufacturer_id: Mapped[int] = mapped_column( + ForeignKey("manufacturers.id", ondelete="RESTRICT"), nullable=False + ) + scope_model_id: Mapped[int | None] = mapped_column( + ForeignKey("scope_models.id", ondelete="SET NULL"), nullable=True, default=None + ) + part_type_id: Mapped[int] = mapped_column( + ForeignKey("part_types.id", ondelete="RESTRICT"), nullable=False + ) + sub_part_type_id: Mapped[int | None] = mapped_column( + ForeignKey("sub_part_types.id", ondelete="SET NULL"), nullable=True, default=None + ) + + colour: Mapped[PartColour] = relationship(lazy="selectin") + manufacturer: Mapped[Manufacturer] = relationship(lazy="selectin") + scope_model: Mapped[ScopeModel | None] = relationship(lazy="selectin") + part_type: Mapped[PartType] = relationship(lazy="selectin") + sub_part_type: Mapped[SubPartType | None] = relationship(lazy="selectin") diff --git a/app/schemas/augmented_part.py b/app/schemas/augmented_part.py new file mode 100644 index 0000000..1114661 --- /dev/null +++ b/app/schemas/augmented_part.py @@ -0,0 +1,42 @@ +from pydantic import BaseModel, ConfigDict + +from app.schemas.manufacturer import ManufacturerRead +from app.schemas.part_colour import PartColourRead +from app.schemas.part_type import PartTypeRead +from app.schemas.scope_model import ScopeModelRead +from app.schemas.sub_part_type import SubPartTypeRead + + +class AugmentedPartBase(BaseModel): + sku: str | None = None + description: str + colour_id: int + manufacturer_id: int + scope_model_id: int | None = None + part_type_id: int + sub_part_type_id: int | None = None + + +class AugmentedPartCreate(AugmentedPartBase): + pass + + +class AugmentedPartUpdate(BaseModel): + sku: str | None = None + description: str | None = None + colour_id: int | None = None + manufacturer_id: int | None = None + scope_model_id: int | None = None + part_type_id: int | None = None + sub_part_type_id: int | None = None + + +class AugmentedPartRead(AugmentedPartBase): + model_config = ConfigDict(from_attributes=True) + + id: int + colour: PartColourRead + manufacturer: ManufacturerRead + scope_model: ScopeModelRead | None + part_type: PartTypeRead + sub_part_type: SubPartTypeRead | None diff --git a/app/templates/_nav.html b/app/templates/_nav.html index 7293219..fb82ea3 100644 --- a/app/templates/_nav.html +++ b/app/templates/_nav.html @@ -29,6 +29,9 @@ {% if user.is_superuser or "imported_parts:read" in user.permissions %} Imported Parts {% endif %} + {% if user.is_superuser or "augmented_parts:read" in user.permissions %} + Augmented Parts + {% endif %} {% if user.is_superuser or "part_colours:read" in user.permissions %} Part Colours {% endif %} diff --git a/app/templates/admin/_augmented_part_row.html b/app/templates/admin/_augmented_part_row.html new file mode 100644 index 0000000..67f8699 --- /dev/null +++ b/app/templates/admin/_augmented_part_row.html @@ -0,0 +1,21 @@ + + {{ ap.id }} + {{ ap.sku or '—' }} + {{ ap.description | truncate(60) }} + {{ ap.colour.name }} + {{ ap.manufacturer.name }} + {{ ap.scope_model.name if ap.scope_model else '—' }} + {{ ap.part_type.name }} + {{ ap.sub_part_type.name if ap.sub_part_type else '—' }} + + + + + diff --git a/app/templates/admin/_augmented_part_row_edit.html b/app/templates/admin/_augmented_part_row_edit.html new file mode 100644 index 0000000..575be13 --- /dev/null +++ b/app/templates/admin/_augmented_part_row_edit.html @@ -0,0 +1,56 @@ + + +
+ #{{ ap.id }} + + + + + + + +
+ + +
+
+ + diff --git a/app/templates/admin/_augmented_parts_table.html b/app/templates/admin/_augmented_parts_table.html new file mode 100644 index 0000000..4d7eb82 --- /dev/null +++ b/app/templates/admin/_augmented_parts_table.html @@ -0,0 +1,24 @@ +
+ + + + + + + + + + + + + + + + {% for ap in augmented_parts %} + {% include "admin/_augmented_part_row.html" %} + {% else %} + + {% endfor %} + +
IDSKUDescriptionColourManufacturerModelTypeSub Type
No augmented parts recorded.
+
diff --git a/app/templates/admin/augmented_parts.html b/app/templates/admin/augmented_parts.html new file mode 100644 index 0000000..3dcc1d9 --- /dev/null +++ b/app/templates/admin/augmented_parts.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %}Augmented Parts · {{ app_name }}{% endblock %} +{% block body %} +{% include "_nav.html" %} +
+

Augmented Parts

+ +
+

Add part

+
+ + + + + + + + +
+
+ + {% include "admin/_augmented_parts_table.html" %} +
+{% endblock %} diff --git a/app/web/admin.py b/app/web/admin.py index eb6d2b6..7a2b559 100644 --- a/app/web/admin.py +++ b/app/web/admin.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.crud import audit as audit_crud +from app.crud import augmented_part as augmented_part_crud from app.crud import imported_part as imported_part_crud from app.crud import imported_part_config as imported_part_config_crud from app.crud import iso_thread as iso_thread_crud @@ -1119,6 +1120,153 @@ async def refresh_imported_parts( ) +# --- Augmented Parts --------------------------------------------------------- + + +async def _augmented_part_form_ctx(db: AsyncSession) -> dict: + return { + "part_colours": await part_colour_crud.list_all(db), + "manufacturers": await manufacturer_crud.list_all(db), + "scope_models": await scope_model_crud.list_all(db), + "part_types": await part_type_crud.list_all(db), + "sub_part_types": await sub_part_type_crud.list_all(db), + } + + +@router.get("/augmented-parts", response_class=HTMLResponse) +async def augmented_parts_page( + request: Request, + db: AsyncSession = Depends(get_db), + user: User = Depends(web_require("augmented_parts:read")), +): + ctx = await _augmented_part_form_ctx(db) + ctx["user"] = user + ctx["augmented_parts"] = await augmented_part_crud.list_all(db) + return templates.TemplateResponse(request, "admin/augmented_parts.html", ctx) + + +@router.post("/augmented-parts", response_class=HTMLResponse) +async def create_augmented_part( + request: Request, + sku: str = Form(""), + description: str = Form(...), + colour_id: int = Form(...), + manufacturer_id: int = Form(...), + scope_model_id: str = Form(""), + part_type_id: int = Form(...), + sub_part_type_id: str = Form(""), + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("augmented_parts:write")), +): + created = await augmented_part_crud.create( + db, + sku=sku.strip() or None, + description=description, + colour_id=colour_id, + manufacturer_id=manufacturer_id, + scope_model_id=int(scope_model_id) if scope_model_id else None, + part_type_id=part_type_id, + sub_part_type_id=int(sub_part_type_id) if sub_part_type_id else None, + ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="AugmentedPart", + resource_id=created.id, + description=audit_crud.describe_create( + "AugmentedPart", created.id, created.sku or created.description + ), + ) + return templates.TemplateResponse( + request, + "admin/_augmented_parts_table.html", + {"augmented_parts": await augmented_part_crud.list_all(db)}, + ) + + +@router.get("/augmented-parts/{augmented_part_id}/edit", response_class=HTMLResponse) +async def edit_augmented_part_row( + request: Request, + augmented_part_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("augmented_parts:write")), +): + ap = await augmented_part_crud.get(db, augmented_part_id) + if ap is None: + raise HTTPException(status_code=404, detail="Augmented part not found") + ctx = await _augmented_part_form_ctx(db) + ctx["ap"] = ap + return templates.TemplateResponse(request, "admin/_augmented_part_row_edit.html", ctx) + + +@router.get("/augmented-parts/{augmented_part_id}/row", response_class=HTMLResponse) +async def augmented_part_row( + request: Request, + augmented_part_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("augmented_parts:read")), +): + ap = await augmented_part_crud.get(db, augmented_part_id) + if ap is None: + raise HTTPException(status_code=404, detail="Augmented part not found") + return templates.TemplateResponse( + request, "admin/_augmented_part_row.html", {"ap": ap} + ) + + +@router.post("/augmented-parts/{augmented_part_id}", response_class=HTMLResponse) +async def update_augmented_part( + request: Request, + augmented_part_id: int, + sku: str = Form(""), + description: str = Form(...), + colour_id: int = Form(...), + manufacturer_id: int = Form(...), + scope_model_id: str = Form(""), + part_type_id: int = Form(...), + sub_part_type_id: str = Form(""), + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("augmented_parts:write")), +): + ap = await augmented_part_crud.get(db, augmented_part_id) + if ap is None: + raise HTTPException(status_code=404, detail="Augmented part not found") + changes = { + "sku": sku.strip() or None, + "description": description, + "colour_id": colour_id, + "manufacturer_id": manufacturer_id, + "scope_model_id": int(scope_model_id) if scope_model_id else None, + "part_type_id": part_type_id, + "sub_part_type_id": int(sub_part_type_id) if sub_part_type_id else None, + } + await augmented_part_crud.update(db, ap, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="AugmentedPart", + resource_id=augmented_part_id, + description=audit_crud.describe_update("AugmentedPart", augmented_part_id, changes), + ) + return templates.TemplateResponse( + request, "admin/_augmented_part_row.html", {"ap": ap} + ) + + +@router.delete("/augmented-parts/{augmented_part_id}") +async def delete_augmented_part( + augmented_part_id: int, + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("augmented_parts:delete")), +): + ap = await augmented_part_crud.get(db, augmented_part_id) + if ap is not None: + label = ap.sku or ap.description + await augmented_part_crud.delete(db, ap) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="AugmentedPart", + resource_id=augmented_part_id, + description=audit_crud.describe_delete("AugmentedPart", augmented_part_id, label), + ) + return Response(status_code=status.HTTP_200_OK) + + # --- Part Colours ------------------------------------------------------------ diff --git a/scripts/seed.py b/scripts/seed.py index 56719e0..8081f37 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -56,13 +56,16 @@ PERMISSIONS = [ ("static_connections:read", "View static connections"), ("static_connections:write", "Create and update static connections"), ("static_connections:delete", "Delete static connections"), + ("augmented_parts:read", "View augmented parts"), + ("augmented_parts:write", "Create and update augmented parts"), + ("augmented_parts:delete", "Delete augmented parts"), ("audit_log:read", "View audit log"), ] # 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"], + "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", "augmented_parts:read"], }