diff --git a/alembic/versions/5b6a7817b408_add_static_connections.py b/alembic/versions/5b6a7817b408_add_static_connections.py new file mode 100644 index 0000000..505f4a1 --- /dev/null +++ b/alembic/versions/5b6a7817b408_add_static_connections.py @@ -0,0 +1,29 @@ +"""add static_connections + +Revision ID: 5b6a7817b408 +Revises: 7be5ef599d3f +Create Date: 2026-07-02 11:57:13.095417 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5b6a7817b408' +down_revision: Union[str, None] = '7be5ef599d3f' +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 72b0f10..725a2a6 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, sub_part_types, unc_threads, users +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 api_router = APIRouter() api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) @@ -12,5 +12,6 @@ api_router.include_router(manufacturers.router, prefix="/manufacturers", tags=[" 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(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(audit_log.router, prefix="/audit-log", tags=["audit-log"]) diff --git a/app/api/static_connections.py b/app/api/static_connections.py new file mode 100644 index 0000000..4323c0b --- /dev/null +++ b/app/api/static_connections.py @@ -0,0 +1,92 @@ +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 static_connection as static_connection_crud +from app.deps import require_permission +from app.models.user import User +from app.schemas.static_connection import ( + StaticConnectionCreate, + StaticConnectionRead, + StaticConnectionUpdate, +) + +router = APIRouter() + + +@router.get("", response_model=list[StaticConnectionRead]) +async def list_static_connections( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("static_connections:read")), +): + return await static_connection_crud.list_all(db, skip=skip, limit=limit) + + +@router.post("", response_model=StaticConnectionRead, status_code=status.HTTP_201_CREATED) +async def create_static_connection( + payload: StaticConnectionCreate, + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("static_connections:write")), +): + sc = await static_connection_crud.create(db, od_mm=payload.od_mm, od_in=payload.od_in) + await audit_crud.log( + db, actor=actor, action="create", resource_type="StaticConnection", + resource_id=sc.id, + description=audit_crud.describe_create("StaticConnection", sc.id, f"{sc.od_mm} mm"), + ) + return sc + + +@router.get("/{static_connection_id}", response_model=StaticConnectionRead) +async def get_static_connection( + static_connection_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("static_connections:read")), +): + sc = await static_connection_crud.get(db, static_connection_id) + if sc is None: + raise HTTPException(status_code=404, detail="Static connection not found") + return sc + + +@router.patch("/{static_connection_id}", response_model=StaticConnectionRead) +async def update_static_connection( + static_connection_id: int, + payload: StaticConnectionUpdate, + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("static_connections:write")), +): + sc = await static_connection_crud.get(db, static_connection_id) + if sc is None: + raise HTTPException(status_code=404, detail="Static connection not found") + changes = payload.model_dump(exclude_unset=True) + sc = await static_connection_crud.update(db, sc, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="StaticConnection", + resource_id=sc.id, + description=audit_crud.describe_update("StaticConnection", sc.id, changes), + ) + return sc + + +@router.delete("/{static_connection_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_static_connection( + static_connection_id: int, + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("static_connections:delete")), +): + sc = await static_connection_crud.get(db, static_connection_id) + if sc is None: + raise HTTPException(status_code=404, detail="Static connection not found") + od_mm = sc.od_mm + await static_connection_crud.delete(db, sc) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="StaticConnection", + resource_id=static_connection_id, + description=audit_crud.describe_delete( + "StaticConnection", static_connection_id, f"{od_mm} mm" + ), + ) diff --git a/app/crud/static_connection.py b/app/crud/static_connection.py new file mode 100644 index 0000000..b4db31b --- /dev/null +++ b/app/crud/static_connection.py @@ -0,0 +1,52 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.static_connection import StaticConnection + + +async def get(db: AsyncSession, static_connection_id: int) -> StaticConnection | None: + return await db.get(StaticConnection, static_connection_id) + + +async def get_by_od_mm(db: AsyncSession, od_mm: float) -> StaticConnection | None: + result = await db.execute( + select(StaticConnection).where(StaticConnection.od_mm == od_mm) + ) + return result.scalar_one_or_none() + + +async def list_all( + db: AsyncSession, skip: int = 0, limit: int = 100 +) -> list[StaticConnection]: + result = await db.execute( + select(StaticConnection).order_by(StaticConnection.od_mm).offset(skip).limit(limit) + ) + return list(result.scalars().all()) + + +async def create( + db: AsyncSession, + *, + od_mm: float, + od_in: float, +) -> StaticConnection: + sc = StaticConnection(od_mm=od_mm, od_in=od_in) + db.add(sc) + await db.commit() + await db.refresh(sc) + return sc + + +async def update( + db: AsyncSession, sc: StaticConnection, data: dict +) -> StaticConnection: + for field, value in data.items(): + setattr(sc, field, value) + await db.commit() + await db.refresh(sc) + return sc + + +async def delete(db: AsyncSession, sc: StaticConnection) -> None: + await db.delete(sc) + await db.commit() diff --git a/app/models/__init__.py b/app/models/__init__.py index da7ef11..536fc53 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -9,6 +9,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.static_connection import StaticConnection from app.models.sub_part_type import SubPartType from app.models.refresh_token import RefreshToken from app.models.role import Role @@ -24,6 +25,7 @@ __all__ = [ "PartType", "Permission", "ScopeModel", + "StaticConnection", "SubPartType", "RefreshToken", "Role", diff --git a/app/models/static_connection.py b/app/models/static_connection.py new file mode 100644 index 0000000..e713ef5 --- /dev/null +++ b/app/models/static_connection.py @@ -0,0 +1,13 @@ +from sqlalchemy import Float, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class StaticConnection(Base): + __tablename__ = "static_connections" + __table_args__ = (UniqueConstraint("od_mm"),) + + id: Mapped[int] = mapped_column(primary_key=True) + od_mm: Mapped[float] = mapped_column(Float, nullable=False) + od_in: Mapped[float] = mapped_column(Float, nullable=False) diff --git a/app/schemas/static_connection.py b/app/schemas/static_connection.py new file mode 100644 index 0000000..375b55d --- /dev/null +++ b/app/schemas/static_connection.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel, ConfigDict + + +class StaticConnectionBase(BaseModel): + od_mm: float + od_in: float + + +class StaticConnectionCreate(StaticConnectionBase): + pass + + +class StaticConnectionUpdate(BaseModel): + od_mm: float | None = None + od_in: float | None = None + + +class StaticConnectionRead(StaticConnectionBase): + model_config = ConfigDict(from_attributes=True) + id: int diff --git a/app/templates/_nav.html b/app/templates/_nav.html index aac8a7b..99a8a7b 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 "static_connections:read" in user.permissions %} + Static Connections + {% endif %} {% if user.is_superuser or "audit_log:read" in user.permissions %} Audit Log {% endif %} diff --git a/app/templates/admin/_static_connection_row.html b/app/templates/admin/_static_connection_row.html new file mode 100644 index 0000000..b1980e5 --- /dev/null +++ b/app/templates/admin/_static_connection_row.html @@ -0,0 +1,16 @@ + + {{ sc.id }} + {{ sc.od_mm }} + {{ sc.od_in }} + + + + + diff --git a/app/templates/admin/_static_connection_row_edit.html b/app/templates/admin/_static_connection_row_edit.html new file mode 100644 index 0000000..558f270 --- /dev/null +++ b/app/templates/admin/_static_connection_row_edit.html @@ -0,0 +1,19 @@ + + +
+ #{{ sc.id }} + + +
+ + +
+
+ + diff --git a/app/templates/admin/_static_connections_table.html b/app/templates/admin/_static_connections_table.html new file mode 100644 index 0000000..4f7002d --- /dev/null +++ b/app/templates/admin/_static_connections_table.html @@ -0,0 +1,19 @@ +
+ + + + + + + + + + + {% for sc in static_connections %} + {% include "admin/_static_connection_row.html" %} + {% else %} + + {% endfor %} + +
IDOD (mm)OD (in)
No static connections recorded.
+
diff --git a/app/templates/admin/static_connections.html b/app/templates/admin/static_connections.html new file mode 100644 index 0000000..38446a6 --- /dev/null +++ b/app/templates/admin/static_connections.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Static Connections ยท {{ app_name }}{% endblock %} +{% block body %} +{% include "_nav.html" %} +
+

Static Connections

+ +
+

Add connection

+
+ + + +
+
+ + {% include "admin/_static_connections_table.html" %} +
+{% endblock %} diff --git a/app/web/admin.py b/app/web/admin.py index c0032cc..5c51281 100644 --- a/app/web/admin.py +++ b/app/web/admin.py @@ -13,6 +13,7 @@ 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 from app.crud import scope_model as scope_model_crud +from app.crud import static_connection as static_connection_crud from app.crud import sub_part_type as sub_part_type_crud from app.crud import unc_thread as unc_thread_crud from app.crud import user as user_crud @@ -1117,6 +1118,120 @@ async def refresh_imported_parts( ) +# --- Static Connections ------------------------------------------------------- + + +@router.get("/static-connections", response_class=HTMLResponse) +async def static_connections_page( + request: Request, + db: AsyncSession = Depends(get_db), + user: User = Depends(web_require("static_connections:read")), +): + return templates.TemplateResponse( + request, + "admin/static_connections.html", + {"user": user, "static_connections": await static_connection_crud.list_all(db)}, + ) + + +@router.post("/static-connections", response_class=HTMLResponse) +async def create_static_connection( + request: Request, + od_mm: float = Form(...), + od_in: float = Form(...), + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("static_connections:write")), +): + if not await static_connection_crud.get_by_od_mm(db, od_mm): + created = await static_connection_crud.create(db, od_mm=od_mm, od_in=od_in) + await audit_crud.log( + db, actor=actor, action="create", resource_type="StaticConnection", + resource_id=created.id, + description=audit_crud.describe_create( + "StaticConnection", created.id, f"{created.od_mm} mm" + ), + ) + return templates.TemplateResponse( + request, + "admin/_static_connections_table.html", + {"static_connections": await static_connection_crud.list_all(db)}, + ) + + +@router.get("/static-connections/{static_connection_id}/edit", response_class=HTMLResponse) +async def edit_static_connection_row( + request: Request, + static_connection_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("static_connections:write")), +): + sc = await static_connection_crud.get(db, static_connection_id) + if sc is None: + raise HTTPException(status_code=404, detail="Static connection not found") + return templates.TemplateResponse( + request, "admin/_static_connection_row_edit.html", {"sc": sc} + ) + + +@router.get("/static-connections/{static_connection_id}/row", response_class=HTMLResponse) +async def static_connection_row( + request: Request, + static_connection_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("static_connections:read")), +): + sc = await static_connection_crud.get(db, static_connection_id) + if sc is None: + raise HTTPException(status_code=404, detail="Static connection not found") + return templates.TemplateResponse( + request, "admin/_static_connection_row.html", {"sc": sc} + ) + + +@router.post("/static-connections/{static_connection_id}", response_class=HTMLResponse) +async def update_static_connection( + request: Request, + static_connection_id: int, + od_mm: float = Form(...), + od_in: float = Form(...), + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("static_connections:write")), +): + sc = await static_connection_crud.get(db, static_connection_id) + if sc is None: + raise HTTPException(status_code=404, detail="Static connection not found") + changes = {"od_mm": od_mm, "od_in": od_in} + await static_connection_crud.update(db, sc, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="StaticConnection", + resource_id=static_connection_id, + description=audit_crud.describe_update("StaticConnection", static_connection_id, changes), + ) + return templates.TemplateResponse( + request, "admin/_static_connection_row.html", {"sc": sc} + ) + + +@router.delete("/static-connections/{static_connection_id}") +async def delete_static_connection( + static_connection_id: int, + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("static_connections:delete")), +): + sc = await static_connection_crud.get(db, static_connection_id) + if sc is not None: + od_mm = sc.od_mm + await static_connection_crud.delete(db, sc) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="StaticConnection", + resource_id=static_connection_id, + description=audit_crud.describe_delete( + "StaticConnection", static_connection_id, f"{od_mm} mm" + ), + ) + return Response(status_code=status.HTTP_200_OK) + + # --- Audit Log --------------------------------------------------------------- diff --git a/scripts/seed.py b/scripts/seed.py index 457ffc3..9e50c06 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -50,13 +50,16 @@ PERMISSIONS = [ ("imported_parts:read", "View imported parts"), ("imported_parts:write", "Sync and configure imported parts"), ("imported_parts:delete", "Delete imported parts"), + ("static_connections:read", "View static connections"), + ("static_connections:write", "Create and update static connections"), + ("static_connections:delete", "Delete static connections"), ("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", "sub_part_types:read", "imported_parts: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"], }