From c8696e67b55754e7c569f1f04c265464dd88ca23 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:14:37 -0300 Subject: [PATCH] Adding sub-parts --- .../b60d0aa3ceda_add_sub_part_types_table.py | 29 +++++ app/api/router.py | 3 +- app/api/sub_part_types.py | 88 +++++++++++++ app/crud/sub_part_type.py | 53 ++++++++ app/models/__init__.py | 2 + app/models/sub_part_type.py | 18 +++ app/schemas/sub_part_type.py | 24 ++++ app/templates/_nav.html | 3 + app/templates/admin/_sub_part_type_row.html | 16 +++ .../admin/_sub_part_type_row_edit.html | 25 ++++ .../admin/_sub_part_types_table.html | 16 +++ app/templates/admin/sub_part_types.html | 26 ++++ app/web/admin.py | 117 ++++++++++++++++++ scripts/seed.py | 18 ++- 14 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/b60d0aa3ceda_add_sub_part_types_table.py create mode 100644 app/api/sub_part_types.py create mode 100644 app/crud/sub_part_type.py create mode 100644 app/models/sub_part_type.py create mode 100644 app/schemas/sub_part_type.py create mode 100644 app/templates/admin/_sub_part_type_row.html create mode 100644 app/templates/admin/_sub_part_type_row_edit.html create mode 100644 app/templates/admin/_sub_part_types_table.html create mode 100644 app/templates/admin/sub_part_types.html 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 @@ + + {{ spt.id }} + {{ spt.name }} + {{ spt.part_type.name }} + + + + + diff --git a/app/templates/admin/_sub_part_type_row_edit.html b/app/templates/admin/_sub_part_type_row_edit.html new file mode 100644 index 0000000..07f5cef --- /dev/null +++ b/app/templates/admin/_sub_part_type_row_edit.html @@ -0,0 +1,25 @@ + + +
+ #{{ spt.id }} + + +
+ + +
+
+ + diff --git a/app/templates/admin/_sub_part_types_table.html b/app/templates/admin/_sub_part_types_table.html new file mode 100644 index 0000000..ff05ffb --- /dev/null +++ b/app/templates/admin/_sub_part_types_table.html @@ -0,0 +1,16 @@ +
+ + + + + + + + {% for spt in sub_part_types %} + {% include "admin/_sub_part_type_row.html" %} + {% else %} + + {% endfor %} + +
IDNameParent Type
No sub part types recorded.
+
diff --git a/app/templates/admin/sub_part_types.html b/app/templates/admin/sub_part_types.html new file mode 100644 index 0000000..da833fe --- /dev/null +++ b/app/templates/admin/sub_part_types.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Sub Part Types · {{ app_name }}{% endblock %} +{% block body %} +{% include "_nav.html" %} +
+

Sub Part Types

+ +
+

Add sub part type

+
+ + + +
+
+ + {% include "admin/_sub_part_types_table.html" %} +
+{% endblock %} diff --git a/app/web/admin.py b/app/web/admin.py index fe75b84..2d16fcd 100644 --- a/app/web/admin.py +++ b/app/web/admin.py @@ -7,6 +7,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 iso_thread as iso_thread_crud +from app.crud import sub_part_type as sub_part_type_crud from app.crud import manufacturer as manufacturer_crud from app.crud import part_type as part_type_crud from app.crud import role as role_crud @@ -890,6 +891,122 @@ async def delete_part_type( return Response(status_code=status.HTTP_200_OK) +# --- Sub Part Types ---------------------------------------------------------- + + +@router.get("/sub-part-types", response_class=HTMLResponse) +async def sub_part_types_page( + request: Request, + db: AsyncSession = Depends(get_db), + user: User = Depends(web_require("sub_part_types:read")), +): + return templates.TemplateResponse( + request, + "admin/sub_part_types.html", + { + "user": user, + "sub_part_types": await sub_part_type_crud.list_all(db), + "part_types": await part_type_crud.list_all(db), + }, + ) + + +@router.post("/sub-part-types", response_class=HTMLResponse) +async def create_sub_part_type( + request: Request, + name: str = Form(...), + part_type_id: int = Form(...), + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("sub_part_types:write")), +): + if not await sub_part_type_crud.get_by_name_and_part_type(db, name, part_type_id): + created = await sub_part_type_crud.create(db, name=name, part_type_id=part_type_id) + await audit_crud.log( + db, actor=actor, action="create", resource_type="SubPartType", + resource_id=created.id, + description=audit_crud.describe_create("SubPartType", created.id, created.name), + ) + return templates.TemplateResponse( + request, + "admin/_sub_part_types_table.html", + {"sub_part_types": await sub_part_type_crud.list_all(db)}, + ) + + +@router.get("/sub-part-types/{sub_part_type_id}/edit", response_class=HTMLResponse) +async def edit_sub_part_type_row( + request: Request, + sub_part_type_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("sub_part_types:write")), +): + spt = await sub_part_type_crud.get(db, sub_part_type_id) + if spt is None: + raise HTTPException(status_code=404, detail="Sub part type not found") + return templates.TemplateResponse( + request, + "admin/_sub_part_type_row_edit.html", + {"spt": spt, "part_types": await part_type_crud.list_all(db)}, + ) + + +@router.get("/sub-part-types/{sub_part_type_id}/row", response_class=HTMLResponse) +async def sub_part_type_row( + request: Request, + sub_part_type_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("sub_part_types:read")), +): + spt = await sub_part_type_crud.get(db, sub_part_type_id) + if spt is None: + raise HTTPException(status_code=404, detail="Sub part type not found") + return templates.TemplateResponse( + request, "admin/_sub_part_type_row.html", {"spt": spt} + ) + + +@router.post("/sub-part-types/{sub_part_type_id}", response_class=HTMLResponse) +async def update_sub_part_type( + request: Request, + sub_part_type_id: int, + name: str = Form(...), + part_type_id: int = Form(...), + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("sub_part_types:write")), +): + spt = await sub_part_type_crud.get(db, sub_part_type_id) + if spt is None: + raise HTTPException(status_code=404, detail="Sub part type not found") + changes = {"name": name, "part_type_id": part_type_id} + await sub_part_type_crud.update(db, spt, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="SubPartType", + resource_id=sub_part_type_id, + description=audit_crud.describe_update("SubPartType", sub_part_type_id, changes), + ) + return templates.TemplateResponse( + request, "admin/_sub_part_type_row.html", {"spt": spt} + ) + + +@router.delete("/sub-part-types/{sub_part_type_id}") +async def delete_sub_part_type( + sub_part_type_id: int, + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("sub_part_types:delete")), +): + spt = await sub_part_type_crud.get(db, sub_part_type_id) + if spt is not None: + 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), + ) + return Response(status_code=status.HTTP_200_OK) + + # --- Audit Log --------------------------------------------------------------- diff --git a/scripts/seed.py b/scripts/seed.py index f3e970f..50150b6 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -13,10 +13,15 @@ from app.core.config import settings from app.core.database import AsyncSessionLocal, init_db from app.crud import part_type as part_type_crud from app.crud import role as role_crud +from app.crud import sub_part_type as sub_part_type_crud from app.crud import user as user_crud PART_TYPES = ["Flip Cover", "ARD", "Polarizer", "Adapter Ring", "Scope"] +SUB_PART_TYPES = { + "Scope": ["Riflescope", "Binoculars", "Combat Optic", "Red Dot", "Spotter"], +} + # Default permission catalogue: ":". PERMISSIONS = [ ("users:read", "View users"), @@ -39,13 +44,16 @@ PERMISSIONS = [ ("part_types:read", "View part types"), ("part_types:write", "Create and update part types"), ("part_types:delete", "Delete part types"), + ("sub_part_types:read", "View sub part types"), + ("sub_part_types:write", "Create and update sub part types"), + ("sub_part_types:delete", "Delete sub part types"), ("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_types:read"], + "user": ["users:read", "iso_threads:read", "unc_threads:read", "manufacturers:read", "scope_models:read", "part_types:read", "sub_part_types:read"], } @@ -81,6 +89,14 @@ async def seed() -> None: if await part_type_crud.get_by_name(db, pt_name) is None: await part_type_crud.create(db, name=pt_name) + # Sub part types + for pt_name, spt_names in SUB_PART_TYPES.items(): + pt = await part_type_crud.get_by_name(db, pt_name) + if pt is not None: + for spt_name in spt_names: + if await sub_part_type_crud.get_by_name_and_part_type(db, spt_name, pt.id) is None: + await sub_part_type_crud.create(db, name=spt_name, part_type_id=pt.id) + # Superuser existing = await user_crud.get_by_email(db, settings.first_superuser_email) if existing is None: