diff --git a/alembic/versions/b60d0aa3ceda_add_sub_part_types_table.py b/alembic/versions/b60d0aa3ceda_add_sub_part_types_table.py new file mode 100644 index 0000000..bdca259 --- /dev/null +++ b/alembic/versions/b60d0aa3ceda_add_sub_part_types_table.py @@ -0,0 +1,29 @@ +"""add_sub_part_types_table + +Revision ID: b60d0aa3ceda +Revises: fd5379986da4 +Create Date: 2026-06-25 11:29:20.420056 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b60d0aa3ceda' +down_revision: Union[str, None] = 'fd5379986da4' +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/router.py b/app/api/router.py index 0b914e1..f985e69 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, iso_threads, manufacturers, part_types, roles, scope_models, unc_threads, users +from app.api import audit_log, auth, iso_threads, manufacturers, part_types, roles, scope_models, sub_part_types, unc_threads, users api_router = APIRouter() api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) @@ -11,4 +11,5 @@ api_router.include_router(unc_threads.router, prefix="/unc-threads", tags=["unc- api_router.include_router(manufacturers.router, prefix="/manufacturers", tags=["manufacturers"]) api_router.include_router(scope_models.router, prefix="/scope-models", tags=["scope-models"]) api_router.include_router(part_types.router, prefix="/part-types", tags=["part-types"]) +api_router.include_router(sub_part_types.router, prefix="/sub-part-types", tags=["sub-part-types"]) api_router.include_router(audit_log.router, prefix="/audit-log", tags=["audit-log"]) diff --git a/app/api/sub_part_types.py b/app/api/sub_part_types.py new file mode 100644 index 0000000..e37638e --- /dev/null +++ b/app/api/sub_part_types.py @@ -0,0 +1,88 @@ +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 sub_part_type as sub_part_type_crud +from app.deps import require_permission +from app.models.user import User +from app.schemas.sub_part_type import SubPartTypeCreate, SubPartTypeRead, SubPartTypeUpdate + +router = APIRouter() + + +@router.get("", response_model=list[SubPartTypeRead]) +async def list_sub_part_types( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("sub_part_types:read")), +): + return await sub_part_type_crud.list_all(db, skip=skip, limit=limit) + + +@router.post("", response_model=SubPartTypeRead, status_code=status.HTTP_201_CREATED) +async def create_sub_part_type( + payload: SubPartTypeCreate, + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("sub_part_types:write")), +): + spt = await sub_part_type_crud.create( + db, name=payload.name, part_type_id=payload.part_type_id + ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="SubPartType", + resource_id=spt.id, + description=audit_crud.describe_create("SubPartType", spt.id, spt.name), + ) + return spt + + +@router.get("/{sub_part_type_id}", response_model=SubPartTypeRead) +async def get_sub_part_type( + sub_part_type_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("sub_part_types:read")), +): + spt = await sub_part_type_crud.get(db, sub_part_type_id) + if spt is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found") + return spt + + +@router.patch("/{sub_part_type_id}", response_model=SubPartTypeRead) +async def update_sub_part_type( + sub_part_type_id: int, + payload: SubPartTypeUpdate, + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("sub_part_types:write")), +): + spt = await sub_part_type_crud.get(db, sub_part_type_id) + if spt is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found") + changes = payload.model_dump(exclude_unset=True) + spt = await sub_part_type_crud.update(db, spt, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="SubPartType", + resource_id=spt.id, + description=audit_crud.describe_update("SubPartType", spt.id, changes), + ) + return spt + + +@router.delete("/{sub_part_type_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_sub_part_type( + sub_part_type_id: int, + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("sub_part_types:delete")), +): + spt = await sub_part_type_crud.get(db, sub_part_type_id) + if spt is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found") + name = spt.name + await sub_part_type_crud.delete(db, spt) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="SubPartType", + resource_id=sub_part_type_id, + description=audit_crud.describe_delete("SubPartType", sub_part_type_id, name), + ) diff --git a/app/crud/sub_part_type.py b/app/crud/sub_part_type.py new file mode 100644 index 0000000..5e7d183 --- /dev/null +++ b/app/crud/sub_part_type.py @@ -0,0 +1,53 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.sub_part_type import SubPartType + + +async def get(db: AsyncSession, sub_part_type_id: int) -> SubPartType | None: + return await db.get(SubPartType, sub_part_type_id) + + +async def get_by_name_and_part_type( + db: AsyncSession, name: str, part_type_id: int +) -> SubPartType | None: + result = await db.execute( + select(SubPartType).where( + SubPartType.name == name, SubPartType.part_type_id == part_type_id + ) + ) + return result.scalar_one_or_none() + + +async def list_all( + db: AsyncSession, skip: int = 0, limit: int = 100 +) -> list[SubPartType]: + result = await db.execute( + select(SubPartType).order_by(SubPartType.name).offset(skip).limit(limit) + ) + return list(result.scalars().all()) + + +async def create( + db: AsyncSession, *, name: str, part_type_id: int +) -> SubPartType: + spt = SubPartType(name=name, part_type_id=part_type_id) + db.add(spt) + await db.commit() + await db.refresh(spt, attribute_names=["part_type"]) + return spt + + +async def update( + db: AsyncSession, sub_part_type: SubPartType, data: dict +) -> SubPartType: + for field, value in data.items(): + setattr(sub_part_type, field, value) + await db.commit() + await db.refresh(sub_part_type, attribute_names=["part_type"]) + return sub_part_type + + +async def delete(db: AsyncSession, sub_part_type: SubPartType) -> None: + await db.delete(sub_part_type) + await db.commit() diff --git a/app/models/__init__.py b/app/models/__init__.py index 0bd11a6..75d937f 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -7,6 +7,7 @@ from app.models.manufacturer import Manufacturer from app.models.part_type import PartType from app.models.permission import Permission from app.models.scope_model import ScopeModel +from app.models.sub_part_type import SubPartType from app.models.refresh_token import RefreshToken from app.models.role import Role from app.models.unc_thread import UncThread @@ -19,6 +20,7 @@ __all__ = [ "PartType", "Permission", "ScopeModel", + "SubPartType", "RefreshToken", "Role", "UncThread", diff --git a/app/models/sub_part_type.py b/app/models/sub_part_type.py new file mode 100644 index 0000000..77f300f --- /dev/null +++ b/app/models/sub_part_type.py @@ -0,0 +1,18 @@ +from sqlalchemy import ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base +from app.models.part_type import PartType + + +class SubPartType(Base): + __tablename__ = "sub_part_types" + __table_args__ = (UniqueConstraint("name", "part_type_id"),) + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(nullable=False) + part_type_id: Mapped[int] = mapped_column( + ForeignKey("part_types.id", ondelete="RESTRICT"), nullable=False + ) + + part_type: Mapped[PartType] = relationship(lazy="selectin") diff --git a/app/schemas/sub_part_type.py b/app/schemas/sub_part_type.py new file mode 100644 index 0000000..3c3a6dc --- /dev/null +++ b/app/schemas/sub_part_type.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel, ConfigDict + +from app.schemas.part_type import PartTypeRead + + +class SubPartTypeBase(BaseModel): + name: str + part_type_id: int + + +class SubPartTypeCreate(SubPartTypeBase): + pass + + +class SubPartTypeUpdate(BaseModel): + name: str | None = None + part_type_id: int | None = None + + +class SubPartTypeRead(SubPartTypeBase): + model_config = ConfigDict(from_attributes=True) + + id: int + part_type: PartTypeRead diff --git a/app/templates/_nav.html b/app/templates/_nav.html index d855dad..6404c64 100644 --- a/app/templates/_nav.html +++ b/app/templates/_nav.html @@ -23,6 +23,9 @@ {% if user.is_superuser or "part_types:read" in user.permissions %} Part Types {% endif %} + {% if user.is_superuser or "sub_part_types:read" in user.permissions %} + Sub Part Types + {% endif %} {% if user.is_superuser or "audit_log:read" in user.permissions %} Audit Log {% endif %} diff --git a/app/templates/admin/_sub_part_type_row.html b/app/templates/admin/_sub_part_type_row.html new file mode 100644 index 0000000..abe92fe --- /dev/null +++ b/app/templates/admin/_sub_part_type_row.html @@ -0,0 +1,16 @@ +
| ID | Name | Parent Type | + |
|---|---|---|---|
| No sub part types recorded. | |||