From 2e7002b73bd82e9b33a38edd9576c1293c0f1d2f Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:34:33 -0300 Subject: [PATCH] adding part colour --- .../versions/0c3d1a5cff1f_add_part_colours.py | 29 +++++ app/api/part_colours.py | 86 ++++++++++++++ app/api/router.py | 3 +- app/crud/part_colour.py | 45 ++++++++ app/models/__init__.py | 2 + app/models/part_colour.py | 12 ++ app/schemas/part_colour.py | 18 +++ app/templates/_nav.html | 3 + app/templates/admin/_part_colour_row.html | 15 +++ .../admin/_part_colour_row_edit.html | 18 +++ app/templates/admin/_part_colours_table.html | 18 +++ app/templates/admin/part_colours.html | 18 +++ app/web/admin.py | 109 ++++++++++++++++++ scripts/seed.py | 5 +- 14 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/0c3d1a5cff1f_add_part_colours.py create mode 100644 app/api/part_colours.py create mode 100644 app/crud/part_colour.py create mode 100644 app/models/part_colour.py create mode 100644 app/schemas/part_colour.py create mode 100644 app/templates/admin/_part_colour_row.html create mode 100644 app/templates/admin/_part_colour_row_edit.html create mode 100644 app/templates/admin/_part_colours_table.html create mode 100644 app/templates/admin/part_colours.html diff --git a/alembic/versions/0c3d1a5cff1f_add_part_colours.py b/alembic/versions/0c3d1a5cff1f_add_part_colours.py new file mode 100644 index 0000000..1a6896c --- /dev/null +++ b/alembic/versions/0c3d1a5cff1f_add_part_colours.py @@ -0,0 +1,29 @@ +"""add part_colours + +Revision ID: 0c3d1a5cff1f +Revises: 5b6a7817b408 +Create Date: 2026-07-02 12:22:24.106795 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0c3d1a5cff1f' +down_revision: Union[str, None] = '5b6a7817b408' +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/part_colours.py b/app/api/part_colours.py new file mode 100644 index 0000000..569d347 --- /dev/null +++ b/app/api/part_colours.py @@ -0,0 +1,86 @@ +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 part_colour as part_colour_crud +from app.deps import require_permission +from app.models.user import User +from app.schemas.part_colour import PartColourCreate, PartColourRead, PartColourUpdate + +router = APIRouter() + + +@router.get("", response_model=list[PartColourRead]) +async def list_part_colours( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("part_colours:read")), +): + return await part_colour_crud.list_all(db, skip=skip, limit=limit) + + +@router.post("", response_model=PartColourRead, status_code=status.HTTP_201_CREATED) +async def create_part_colour( + payload: PartColourCreate, + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("part_colours:write")), +): + pc = await part_colour_crud.create(db, name=payload.name) + await audit_crud.log( + db, actor=actor, action="create", resource_type="PartColour", + resource_id=pc.id, + description=audit_crud.describe_create("PartColour", pc.id, pc.name), + ) + return pc + + +@router.get("/{part_colour_id}", response_model=PartColourRead) +async def get_part_colour( + part_colour_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("part_colours:read")), +): + pc = await part_colour_crud.get(db, part_colour_id) + if pc is None: + raise HTTPException(status_code=404, detail="Part colour not found") + return pc + + +@router.patch("/{part_colour_id}", response_model=PartColourRead) +async def update_part_colour( + part_colour_id: int, + payload: PartColourUpdate, + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("part_colours:write")), +): + pc = await part_colour_crud.get(db, part_colour_id) + if pc is None: + raise HTTPException(status_code=404, detail="Part colour not found") + changes = payload.model_dump(exclude_unset=True) + pc = await part_colour_crud.update(db, pc, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="PartColour", + resource_id=pc.id, + description=audit_crud.describe_update("PartColour", pc.id, changes), + ) + return pc + + +@router.delete("/{part_colour_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_part_colour( + part_colour_id: int, + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("part_colours:delete")), +): + pc = await part_colour_crud.get(db, part_colour_id) + if pc is None: + raise HTTPException(status_code=404, detail="Part colour not found") + name = pc.name + await part_colour_crud.delete(db, pc) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="PartColour", + resource_id=part_colour_id, + description=audit_crud.describe_delete("PartColour", part_colour_id, name), + ) diff --git a/app/api/router.py b/app/api/router.py index 725a2a6..1867d9e 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_types, roles, scope_models, static_connections, sub_part_types, unc_threads, users +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 api_router = APIRouter() api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) @@ -10,6 +10,7 @@ api_router.include_router(iso_threads.router, prefix="/iso-threads", tags=["iso- api_router.include_router(unc_threads.router, prefix="/unc-threads", tags=["unc-threads"]) 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_colours.router, prefix="/part-colours", tags=["part-colours"]) 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(static_connections.router, prefix="/static-connections", tags=["static-connections"]) diff --git a/app/crud/part_colour.py b/app/crud/part_colour.py new file mode 100644 index 0000000..af0bd93 --- /dev/null +++ b/app/crud/part_colour.py @@ -0,0 +1,45 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.part_colour import PartColour + + +async def get(db: AsyncSession, part_colour_id: int) -> PartColour | None: + return await db.get(PartColour, part_colour_id) + + +async def get_by_name(db: AsyncSession, name: str) -> PartColour | None: + result = await db.execute( + select(PartColour).where(PartColour.name == name) + ) + return result.scalar_one_or_none() + + +async def list_all( + db: AsyncSession, skip: int = 0, limit: int = 100 +) -> list[PartColour]: + result = await db.execute( + select(PartColour).order_by(PartColour.name).offset(skip).limit(limit) + ) + return list(result.scalars().all()) + + +async def create(db: AsyncSession, *, name: str) -> PartColour: + pc = PartColour(name=name) + db.add(pc) + await db.commit() + await db.refresh(pc) + return pc + + +async def update(db: AsyncSession, pc: PartColour, data: dict) -> PartColour: + for field, value in data.items(): + setattr(pc, field, value) + await db.commit() + await db.refresh(pc) + return pc + + +async def delete(db: AsyncSession, pc: PartColour) -> None: + await db.delete(pc) + await db.commit() diff --git a/app/models/__init__.py b/app/models/__init__.py index 536fc53..ab6a99b 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -6,6 +6,7 @@ from app.models.imported_part import ImportedPart from app.models.imported_part_config import ImportedPartConfig from app.models.iso_thread import IsoThread from app.models.manufacturer import Manufacturer +from app.models.part_colour import PartColour from app.models.part_type import PartType from app.models.permission import Permission from app.models.scope_model import ScopeModel @@ -22,6 +23,7 @@ __all__ = [ "ImportedPartConfig", "IsoThread", "Manufacturer", + "PartColour", "PartType", "Permission", "ScopeModel", diff --git a/app/models/part_colour.py b/app/models/part_colour.py new file mode 100644 index 0000000..66a45c4 --- /dev/null +++ b/app/models/part_colour.py @@ -0,0 +1,12 @@ +from sqlalchemy import UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class PartColour(Base): + __tablename__ = "part_colours" + __table_args__ = (UniqueConstraint("name"),) + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(nullable=False) diff --git a/app/schemas/part_colour.py b/app/schemas/part_colour.py new file mode 100644 index 0000000..d999e09 --- /dev/null +++ b/app/schemas/part_colour.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, ConfigDict + + +class PartColourBase(BaseModel): + name: str + + +class PartColourCreate(PartColourBase): + pass + + +class PartColourUpdate(BaseModel): + name: str | None = None + + +class PartColourRead(PartColourBase): + model_config = ConfigDict(from_attributes=True) + id: int diff --git a/app/templates/_nav.html b/app/templates/_nav.html index 99a8a7b..7293219 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 "part_colours:read" in user.permissions %} + Part Colours + {% endif %} {% if user.is_superuser or "static_connections:read" in user.permissions %} Static Connections {% endif %} diff --git a/app/templates/admin/_part_colour_row.html b/app/templates/admin/_part_colour_row.html new file mode 100644 index 0000000..c63231d --- /dev/null +++ b/app/templates/admin/_part_colour_row.html @@ -0,0 +1,15 @@ + + {{ pc.id }} + {{ pc.name }} + + + + + diff --git a/app/templates/admin/_part_colour_row_edit.html b/app/templates/admin/_part_colour_row_edit.html new file mode 100644 index 0000000..097dff9 --- /dev/null +++ b/app/templates/admin/_part_colour_row_edit.html @@ -0,0 +1,18 @@ + + +
+ #{{ pc.id }} + +
+ + +
+
+ + diff --git a/app/templates/admin/_part_colours_table.html b/app/templates/admin/_part_colours_table.html new file mode 100644 index 0000000..c0d15ce --- /dev/null +++ b/app/templates/admin/_part_colours_table.html @@ -0,0 +1,18 @@ +
+ + + + + + + + + + {% for pc in part_colours %} + {% include "admin/_part_colour_row.html" %} + {% else %} + + {% endfor %} + +
IDName
No part colours recorded.
+
diff --git a/app/templates/admin/part_colours.html b/app/templates/admin/part_colours.html new file mode 100644 index 0000000..45de587 --- /dev/null +++ b/app/templates/admin/part_colours.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}Part Colours ยท {{ app_name }}{% endblock %} +{% block body %} +{% include "_nav.html" %} +
+

Part Colours

+ +
+

Add colour

+
+ + +
+
+ + {% include "admin/_part_colours_table.html" %} +
+{% endblock %} diff --git a/app/web/admin.py b/app/web/admin.py index 5c51281..eb6d2b6 100644 --- a/app/web/admin.py +++ b/app/web/admin.py @@ -10,6 +10,7 @@ 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 from app.crud import manufacturer as manufacturer_crud +from app.crud import part_colour as part_colour_crud from app.crud import part_type as part_type_crud from app.crud import role as role_crud from app.crud import scope_model as scope_model_crud @@ -1118,6 +1119,114 @@ async def refresh_imported_parts( ) +# --- Part Colours ------------------------------------------------------------ + + +@router.get("/part-colours", response_class=HTMLResponse) +async def part_colours_page( + request: Request, + db: AsyncSession = Depends(get_db), + user: User = Depends(web_require("part_colours:read")), +): + return templates.TemplateResponse( + request, + "admin/part_colours.html", + {"user": user, "part_colours": await part_colour_crud.list_all(db)}, + ) + + +@router.post("/part-colours", response_class=HTMLResponse) +async def create_part_colour( + request: Request, + name: str = Form(...), + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("part_colours:write")), +): + if not await part_colour_crud.get_by_name(db, name): + created = await part_colour_crud.create(db, name=name) + await audit_crud.log( + db, actor=actor, action="create", resource_type="PartColour", + resource_id=created.id, + description=audit_crud.describe_create("PartColour", created.id, created.name), + ) + return templates.TemplateResponse( + request, + "admin/_part_colours_table.html", + {"part_colours": await part_colour_crud.list_all(db)}, + ) + + +@router.get("/part-colours/{part_colour_id}/edit", response_class=HTMLResponse) +async def edit_part_colour_row( + request: Request, + part_colour_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("part_colours:write")), +): + pc = await part_colour_crud.get(db, part_colour_id) + if pc is None: + raise HTTPException(status_code=404, detail="Part colour not found") + return templates.TemplateResponse( + request, "admin/_part_colour_row_edit.html", {"pc": pc} + ) + + +@router.get("/part-colours/{part_colour_id}/row", response_class=HTMLResponse) +async def part_colour_row( + request: Request, + part_colour_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("part_colours:read")), +): + pc = await part_colour_crud.get(db, part_colour_id) + if pc is None: + raise HTTPException(status_code=404, detail="Part colour not found") + return templates.TemplateResponse( + request, "admin/_part_colour_row.html", {"pc": pc} + ) + + +@router.post("/part-colours/{part_colour_id}", response_class=HTMLResponse) +async def update_part_colour( + request: Request, + part_colour_id: int, + name: str = Form(...), + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("part_colours:write")), +): + pc = await part_colour_crud.get(db, part_colour_id) + if pc is None: + raise HTTPException(status_code=404, detail="Part colour not found") + changes = {"name": name} + await part_colour_crud.update(db, pc, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="PartColour", + resource_id=part_colour_id, + description=audit_crud.describe_update("PartColour", part_colour_id, changes), + ) + return templates.TemplateResponse( + request, "admin/_part_colour_row.html", {"pc": pc} + ) + + +@router.delete("/part-colours/{part_colour_id}") +async def delete_part_colour( + part_colour_id: int, + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("part_colours:delete")), +): + pc = await part_colour_crud.get(db, part_colour_id) + if pc is not None: + name = pc.name + await part_colour_crud.delete(db, pc) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="PartColour", + resource_id=part_colour_id, + description=audit_crud.describe_delete("PartColour", part_colour_id, name), + ) + return Response(status_code=status.HTTP_200_OK) + + # --- Static Connections ------------------------------------------------------- diff --git a/scripts/seed.py b/scripts/seed.py index 9e50c06..56719e0 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -41,6 +41,9 @@ PERMISSIONS = [ ("scope_models:read", "View scope models"), ("scope_models:write", "Create and update scope models"), ("scope_models:delete", "Delete scope models"), + ("part_colours:read", "View part colours"), + ("part_colours:write", "Create and update part colours"), + ("part_colours:delete", "Delete part colours"), ("part_types:read", "View part types"), ("part_types:write", "Create and update part types"), ("part_types:delete", "Delete part types"), @@ -59,7 +62,7 @@ PERMISSIONS = [ # 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", "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"], }