From 00fb43c8ed19ad8199b249efe265c2c64040e222 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:38:59 -0300 Subject: [PATCH] Adding augmented parts --- .../8bc411b5814e_add_augmented_parts.py | 29 ++++ app/api/augmented_parts.py | 99 ++++++++++++ app/api/router.py | 3 +- app/crud/augmented_part.py | 63 ++++++++ app/models/__init__.py | 2 + app/models/augmented_part.py | 38 +++++ app/schemas/augmented_part.py | 42 +++++ app/templates/_nav.html | 3 + app/templates/admin/_augmented_part_row.html | 21 +++ .../admin/_augmented_part_row_edit.html | 56 +++++++ .../admin/_augmented_parts_table.html | 24 +++ app/templates/admin/augmented_parts.html | 59 +++++++ app/web/admin.py | 148 ++++++++++++++++++ scripts/seed.py | 5 +- 14 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/8bc411b5814e_add_augmented_parts.py create mode 100644 app/api/augmented_parts.py create mode 100644 app/crud/augmented_part.py create mode 100644 app/models/augmented_part.py create mode 100644 app/schemas/augmented_part.py create mode 100644 app/templates/admin/_augmented_part_row.html create mode 100644 app/templates/admin/_augmented_part_row_edit.html create mode 100644 app/templates/admin/_augmented_parts_table.html create mode 100644 app/templates/admin/augmented_parts.html 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 @@ +
| ID | +SKU | +Description | +Colour | +Manufacturer | +Model | +Type | +Sub Type | ++ |
|---|---|---|---|---|---|---|---|---|
| No augmented parts recorded. | ||||||||