From 45bc5bf8f6ec1298c5ff94f6c70bdf64af59d5bc Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:25:26 -0300 Subject: [PATCH] Adding audit log --- .../fd5379986da4_add_audit_logs_table.py | 29 ++ app/api/audit_log.py | 19 ++ app/api/iso_threads.py | 30 ++- app/api/manufacturers.py | 29 +- app/api/part_types.py | 30 ++- app/api/roles.py | 30 ++- app/api/router.py | 3 +- app/api/scope_models.py | 29 +- app/api/unc_threads.py | 30 ++- app/api/users.py | 46 +++- app/crud/audit.py | 48 ++++ app/models/__init__.py | 2 + app/models/audit_log.py | 23 ++ app/schemas/audit_log.py | 16 ++ app/templates/_nav.html | 3 + app/templates/admin/audit_log.html | 37 +++ app/web/admin.py | 255 +++++++++++++----- scripts/seed.py | 3 +- 18 files changed, 543 insertions(+), 119 deletions(-) create mode 100644 alembic/versions/fd5379986da4_add_audit_logs_table.py create mode 100644 app/api/audit_log.py create mode 100644 app/crud/audit.py create mode 100644 app/models/audit_log.py create mode 100644 app/schemas/audit_log.py create mode 100644 app/templates/admin/audit_log.html diff --git a/alembic/versions/fd5379986da4_add_audit_logs_table.py b/alembic/versions/fd5379986da4_add_audit_logs_table.py new file mode 100644 index 0000000..b891ef2 --- /dev/null +++ b/alembic/versions/fd5379986da4_add_audit_logs_table.py @@ -0,0 +1,29 @@ +"""add_audit_logs_table + +Revision ID: fd5379986da4 +Revises: e23aa8dd2536 +Create Date: 2026-06-25 11:06:13.028067 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fd5379986da4' +down_revision: Union[str, None] = 'e23aa8dd2536' +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/audit_log.py b/app/api/audit_log.py new file mode 100644 index 0000000..477801d --- /dev/null +++ b/app/api/audit_log.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.crud import audit as audit_crud +from app.deps import require_permission +from app.models.user import User +from app.schemas.audit_log import AuditLogRead + +router = APIRouter() + + +@router.get("", response_model=list[AuditLogRead]) +async def list_audit_log( + limit: int = 200, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("audit_log:read")), +): + return await audit_crud.list_recent(db, limit=limit) diff --git a/app/api/iso_threads.py b/app/api/iso_threads.py index f0f1c6e..acc5b12 100644 --- a/app/api/iso_threads.py +++ b/app/api/iso_threads.py @@ -2,6 +2,7 @@ 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 iso_thread as iso_thread_crud from app.deps import require_permission from app.models.user import User @@ -24,14 +25,20 @@ async def list_iso_threads( async def create_iso_thread( payload: IsoThreadCreate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("iso_threads:write")), + actor: User = Depends(require_permission("iso_threads:write")), ): - return await iso_thread_crud.create( + thread = await iso_thread_crud.create( db, diameter_mm=payload.diameter_mm, pitch_mm=payload.pitch_mm, depth_mm=payload.depth_mm, ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="IsoThread", + resource_id=thread.id, + description=audit_crud.describe_create("IsoThread", thread.id, f"M{thread.diameter_mm}×{thread.pitch_mm}"), + ) + return thread @router.get("/{iso_thread_id}", response_model=IsoThreadRead) @@ -51,21 +58,34 @@ async def update_iso_thread( iso_thread_id: int, payload: IsoThreadUpdate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("iso_threads:write")), + actor: User = Depends(require_permission("iso_threads:write")), ): thread = await iso_thread_crud.get(db, iso_thread_id) if thread is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found") - return await iso_thread_crud.update(db, thread, payload.model_dump(exclude_unset=True)) + changes = payload.model_dump(exclude_unset=True) + thread = await iso_thread_crud.update(db, thread, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="IsoThread", + resource_id=thread.id, + description=audit_crud.describe_update("IsoThread", thread.id, changes), + ) + return thread @router.delete("/{iso_thread_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_iso_thread( iso_thread_id: int, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("iso_threads:delete")), + actor: User = Depends(require_permission("iso_threads:delete")), ): thread = await iso_thread_crud.get(db, iso_thread_id) if thread is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found") + name = f"M{thread.diameter_mm}×{thread.pitch_mm}" await iso_thread_crud.delete(db, thread) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="IsoThread", + resource_id=iso_thread_id, + description=audit_crud.describe_delete("IsoThread", iso_thread_id, name), + ) diff --git a/app/api/manufacturers.py b/app/api/manufacturers.py index 1781a86..0013a3e 100644 --- a/app/api/manufacturers.py +++ b/app/api/manufacturers.py @@ -2,6 +2,7 @@ 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 manufacturer as manufacturer_crud from app.deps import require_permission from app.models.user import User @@ -24,14 +25,20 @@ async def list_manufacturers( async def create_manufacturer( payload: ManufacturerCreate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("manufacturers:write")), + actor: User = Depends(require_permission("manufacturers:write")), ): - return await manufacturer_crud.create( + m = await manufacturer_crud.create( db, name=payload.name, url=str(payload.url), logo_url=str(payload.logo_url) if payload.logo_url else None, ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="Manufacturer", + resource_id=m.id, + description=audit_crud.describe_create("Manufacturer", m.id, m.name), + ) + return m @router.get("/{manufacturer_id}", response_model=ManufacturerRead) @@ -51,7 +58,7 @@ async def update_manufacturer( manufacturer_id: int, payload: ManufacturerUpdate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("manufacturers:write")), + actor: User = Depends(require_permission("manufacturers:write")), ): m = await manufacturer_crud.get(db, manufacturer_id) if m is None: @@ -61,16 +68,28 @@ async def update_manufacturer( data["url"] = str(data["url"]) if "logo_url" in data and data["logo_url"] is not None: data["logo_url"] = str(data["logo_url"]) - return await manufacturer_crud.update(db, m, data) + m = await manufacturer_crud.update(db, m, data) + await audit_crud.log( + db, actor=actor, action="update", resource_type="Manufacturer", + resource_id=m.id, + description=audit_crud.describe_update("Manufacturer", m.id, data), + ) + return m @router.delete("/{manufacturer_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_manufacturer( manufacturer_id: int, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("manufacturers:delete")), + actor: User = Depends(require_permission("manufacturers:delete")), ): m = await manufacturer_crud.get(db, manufacturer_id) if m is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Manufacturer not found") + name = m.name await manufacturer_crud.delete(db, m) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="Manufacturer", + resource_id=manufacturer_id, + description=audit_crud.describe_delete("Manufacturer", manufacturer_id, name), + ) diff --git a/app/api/part_types.py b/app/api/part_types.py index b1024b8..2e41e92 100644 --- a/app/api/part_types.py +++ b/app/api/part_types.py @@ -2,6 +2,7 @@ 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_type as part_type_crud from app.deps import require_permission from app.models.user import User @@ -24,9 +25,15 @@ async def list_part_types( async def create_part_type( payload: PartTypeCreate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("part_types:write")), + actor: User = Depends(require_permission("part_types:write")), ): - return await part_type_crud.create(db, name=payload.name) + pt = await part_type_crud.create(db, name=payload.name) + await audit_crud.log( + db, actor=actor, action="create", resource_type="PartType", + resource_id=pt.id, + description=audit_crud.describe_create("PartType", pt.id, pt.name), + ) + return pt @router.get("/{part_type_id}", response_model=PartTypeRead) @@ -46,21 +53,34 @@ async def update_part_type( part_type_id: int, payload: PartTypeUpdate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("part_types:write")), + actor: User = Depends(require_permission("part_types:write")), ): pt = await part_type_crud.get(db, part_type_id) if pt is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found") - return await part_type_crud.update(db, pt, payload.model_dump(exclude_unset=True)) + changes = payload.model_dump(exclude_unset=True) + pt = await part_type_crud.update(db, pt, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="PartType", + resource_id=pt.id, + description=audit_crud.describe_update("PartType", pt.id, changes), + ) + return pt @router.delete("/{part_type_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_part_type( part_type_id: int, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("part_types:delete")), + actor: User = Depends(require_permission("part_types:delete")), ): pt = await part_type_crud.get(db, part_type_id) if pt is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found") + name = pt.name await part_type_crud.delete(db, pt) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="PartType", + resource_id=part_type_id, + description=audit_crud.describe_delete("PartType", part_type_id, name), + ) diff --git a/app/api/roles.py b/app/api/roles.py index 1410b74..b37be45 100644 --- a/app/api/roles.py +++ b/app/api/roles.py @@ -2,6 +2,7 @@ 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 role as role_crud from app.deps import require_permission from app.models.user import User @@ -22,18 +23,24 @@ async def list_roles( async def create_role( payload: RoleCreate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("roles:write")), + actor: User = Depends(require_permission("roles:write")), ): if await role_crud.get_by_name(db, payload.name): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Role already exists" ) - return await role_crud.create( + role = await role_crud.create( db, name=payload.name, description=payload.description, permission_ids=payload.permission_ids, ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="Role", + resource_id=role.id, + description=audit_crud.describe_create("Role", role.id, role.name), + ) + return role @router.get("/permissions", response_model=list[PermissionRead]) @@ -63,25 +70,38 @@ async def update_role( role_id: int, payload: RoleUpdate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("roles:write")), + actor: User = Depends(require_permission("roles:write")), ): role = await role_crud.get(db, role_id) if role is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" ) - return await role_crud.update(db, role, payload.model_dump(exclude_unset=True)) + changes = payload.model_dump(exclude_unset=True) + role = await role_crud.update(db, role, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="Role", + resource_id=role.id, + description=audit_crud.describe_update("Role", role.id, changes), + ) + return role @router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_role( role_id: int, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("roles:write")), + actor: User = Depends(require_permission("roles:write")), ): role = await role_crud.get(db, role_id) if role is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" ) + name = role.name await role_crud.delete(db, role) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="Role", + resource_id=role_id, + description=audit_crud.describe_delete("Role", role_id, name), + ) diff --git a/app/api/router.py b/app/api/router.py index c6c7a02..0b914e1 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api import 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, unc_threads, users api_router = APIRouter() api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) @@ -11,3 +11,4 @@ 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(audit_log.router, prefix="/audit-log", tags=["audit-log"]) diff --git a/app/api/scope_models.py b/app/api/scope_models.py index f7d6f9b..239953c 100644 --- a/app/api/scope_models.py +++ b/app/api/scope_models.py @@ -2,6 +2,7 @@ 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 scope_model as scope_model_crud from app.deps import require_permission from app.models.user import User @@ -24,14 +25,20 @@ async def list_scope_models( async def create_scope_model( payload: ScopeModelCreate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("scope_models:write")), + actor: User = Depends(require_permission("scope_models:write")), ): - return await scope_model_crud.create( + sm = await scope_model_crud.create( db, name=payload.name, manufacturer_id=payload.manufacturer_id, url=str(payload.url) if payload.url else None, ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="ScopeModel", + resource_id=sm.id, + description=audit_crud.describe_create("ScopeModel", sm.id, sm.name), + ) + return sm @router.get("/{scope_model_id}", response_model=ScopeModelRead) @@ -51,7 +58,7 @@ async def update_scope_model( scope_model_id: int, payload: ScopeModelUpdate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("scope_models:write")), + actor: User = Depends(require_permission("scope_models:write")), ): sm = await scope_model_crud.get(db, scope_model_id) if sm is None: @@ -59,16 +66,28 @@ async def update_scope_model( data = payload.model_dump(exclude_unset=True) if "url" in data and data["url"] is not None: data["url"] = str(data["url"]) - return await scope_model_crud.update(db, sm, data) + sm = await scope_model_crud.update(db, sm, data) + await audit_crud.log( + db, actor=actor, action="update", resource_type="ScopeModel", + resource_id=sm.id, + description=audit_crud.describe_update("ScopeModel", sm.id, data), + ) + return sm @router.delete("/{scope_model_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_scope_model( scope_model_id: int, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("scope_models:delete")), + actor: User = Depends(require_permission("scope_models:delete")), ): sm = await scope_model_crud.get(db, scope_model_id) if sm is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Scope model not found") + name = sm.name await scope_model_crud.delete(db, sm) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="ScopeModel", + resource_id=scope_model_id, + description=audit_crud.describe_delete("ScopeModel", scope_model_id, name), + ) diff --git a/app/api/unc_threads.py b/app/api/unc_threads.py index f0d32ce..7b52203 100644 --- a/app/api/unc_threads.py +++ b/app/api/unc_threads.py @@ -2,6 +2,7 @@ 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 unc_thread as unc_thread_crud from app.deps import require_permission from app.models.user import User @@ -24,15 +25,21 @@ async def list_unc_threads( async def create_unc_thread( payload: UncThreadCreate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("unc_threads:write")), + actor: User = Depends(require_permission("unc_threads:write")), ): - return await unc_thread_crud.create( + thread = await unc_thread_crud.create( db, diameter_fraction=payload.diameter_fraction, tpi=payload.tpi, series=payload.series, depth_in=payload.depth_in, ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="UncThread", + resource_id=thread.id, + description=audit_crud.describe_create("UncThread", thread.id, f"{thread.diameter_fraction}-{thread.tpi} {thread.series.value}"), + ) + return thread @router.get("/{unc_thread_id}", response_model=UncThreadRead) @@ -52,21 +59,34 @@ async def update_unc_thread( unc_thread_id: int, payload: UncThreadUpdate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("unc_threads:write")), + actor: User = Depends(require_permission("unc_threads:write")), ): thread = await unc_thread_crud.get(db, unc_thread_id) if thread is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found") - return await unc_thread_crud.update(db, thread, payload.model_dump(exclude_unset=True)) + changes = payload.model_dump(exclude_unset=True) + thread = await unc_thread_crud.update(db, thread, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="UncThread", + resource_id=thread.id, + description=audit_crud.describe_update("UncThread", thread.id, changes), + ) + return thread @router.delete("/{unc_thread_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_unc_thread( unc_thread_id: int, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("unc_threads:delete")), + actor: User = Depends(require_permission("unc_threads:delete")), ): thread = await unc_thread_crud.get(db, unc_thread_id) if thread is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found") + name = f"{thread.diameter_fraction}-{thread.tpi} {thread.series.value}" await unc_thread_crud.delete(db, thread) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="UncThread", + resource_id=unc_thread_id, + description=audit_crud.describe_delete("UncThread", unc_thread_id, name), + ) diff --git a/app/api/users.py b/app/api/users.py index 57b0500..1b48b71 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -2,6 +2,7 @@ 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 user as user_crud from app.deps import get_current_user, require_permission from app.models.user import User @@ -29,13 +30,13 @@ async def list_users( async def create_user( payload: UserCreate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("users:write")), + actor: User = Depends(require_permission("users:write")), ): if await user_crud.get_by_email(db, payload.email): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Email already registered" ) - return await user_crud.create( + created = await user_crud.create( db, email=payload.email, password=payload.password, @@ -43,6 +44,12 @@ async def create_user( is_superuser=payload.is_superuser, role_ids=payload.role_ids, ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="User", + resource_id=created.id, + description=audit_crud.describe_create("User", created.id, created.email), + ) + return created @router.get("/{user_id}", response_model=UserRead) @@ -51,12 +58,12 @@ async def get_user( db: AsyncSession = Depends(get_db), _: User = Depends(require_permission("users:read")), ): - user = await user_crud.get(db, user_id) - if user is None: + target = await user_crud.get(db, user_id) + if target is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) - return user + return target @router.patch("/{user_id}", response_model=UserRead) @@ -64,25 +71,38 @@ async def update_user( user_id: int, payload: UserUpdate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("users:write")), + actor: User = Depends(require_permission("users:write")), ): - user = await user_crud.get(db, user_id) - if user is None: + target = await user_crud.get(db, user_id) + if target is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) - return await user_crud.update(db, user, payload.model_dump(exclude_unset=True)) + changes = payload.model_dump(exclude_unset=True) + target = await user_crud.update(db, target, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="User", + resource_id=user_id, + description=audit_crud.describe_update("User", user_id, changes), + ) + return target @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_user( user_id: int, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission("users:delete")), + actor: User = Depends(require_permission("users:delete")), ): - user = await user_crud.get(db, user_id) - if user is None: + target = await user_crud.get(db, user_id) + if target is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) - await user_crud.delete(db, user) + email = target.email + await user_crud.delete(db, target) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="User", + resource_id=user_id, + description=audit_crud.describe_delete("User", user_id, email), + ) diff --git a/app/crud/audit.py b/app/crud/audit.py new file mode 100644 index 0000000..b072d4f --- /dev/null +++ b/app/crud/audit.py @@ -0,0 +1,48 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.audit_log import AuditLog +from app.models.user import User + + +def describe_create(resource_type: str, resource_id: int, name: str) -> str: + return f"Created {resource_type} '{name}' (#{resource_id})" + + +def describe_update(resource_type: str, resource_id: int, changes: dict) -> str: + safe = {k: ("***" if k == "password" else v) for k, v in changes.items()} + parts = ", ".join(f"{k}={v!r}" for k, v in safe.items()) + suffix = f": {parts}" if parts else "" + return f"Updated {resource_type} #{resource_id}{suffix}" + + +def describe_delete(resource_type: str, resource_id: int, name: str) -> str: + return f"Deleted {resource_type} '{name}' (#{resource_id})" + + +async def log( + db: AsyncSession, + *, + actor: User, + action: str, + resource_type: str, + resource_id: int | None, + description: str, +) -> None: + entry = AuditLog( + user_id=actor.id, + user_email=actor.email, + action=action, + resource_type=resource_type, + resource_id=resource_id, + description=description, + ) + db.add(entry) + await db.commit() + + +async def list_recent(db: AsyncSession, limit: int = 200) -> list[AuditLog]: + result = await db.execute( + select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit) + ) + return list(result.scalars().all()) diff --git a/app/models/__init__.py b/app/models/__init__.py index 377190b..0bd11a6 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,6 +1,7 @@ """Import all models so they register with ``Base.metadata``.""" from app.models.associations import role_permissions, user_roles +from app.models.audit_log import AuditLog from app.models.iso_thread import IsoThread from app.models.manufacturer import Manufacturer from app.models.part_type import PartType @@ -12,6 +13,7 @@ from app.models.unc_thread import UncThread from app.models.user import User __all__ = [ + "AuditLog", "IsoThread", "Manufacturer", "PartType", diff --git a/app/models/audit_log.py b/app/models/audit_log.py new file mode 100644 index 0000000..3d4cf27 --- /dev/null +++ b/app/models/audit_log.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id: Mapped[int] = mapped_column(primary_key=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True + ) + user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + user_email: Mapped[str] = mapped_column(String(255), nullable=False) + action: Mapped[str] = mapped_column(String(16), nullable=False) + resource_type: Mapped[str] = mapped_column(String(64), nullable=False) + resource_id: Mapped[int | None] = mapped_column(nullable=True) + description: Mapped[str] = mapped_column(nullable=False) diff --git a/app/schemas/audit_log.py b/app/schemas/audit_log.py new file mode 100644 index 0000000..456e981 --- /dev/null +++ b/app/schemas/audit_log.py @@ -0,0 +1,16 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class AuditLogRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + created_at: datetime + user_id: int | None + user_email: str + action: str + resource_type: str + resource_id: int | None + description: str diff --git a/app/templates/_nav.html b/app/templates/_nav.html index 6ece4e8..d855dad 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 "audit_log:read" in user.permissions %} + Audit Log + {% endif %} API docs
diff --git a/app/templates/admin/audit_log.html b/app/templates/admin/audit_log.html new file mode 100644 index 0000000..a1521d7 --- /dev/null +++ b/app/templates/admin/audit_log.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Audit Log · {{ app_name }}{% endblock %} +{% block body %} +{% include "_nav.html" %} +
+

Audit Log

+ +
+ {% if entries %} + + + + + + + + + + + + {% for entry in entries %} + + + + + + + + {% endfor %} + +
TimeUserActionResourceDescription
{{ entry.created_at.strftime("%Y-%m-%d %H:%M:%S") }}{{ entry.user_email }}{{ entry.action }}{{ entry.resource_type }}{% if entry.resource_id %} #{{ entry.resource_id }}{% endif %}{{ entry.description }}
+ {% else %} +

No audit log entries yet.

+ {% endif %} +
+
+{% endblock %} diff --git a/app/web/admin.py b/app/web/admin.py index b2e30b0..fe75b84 100644 --- a/app/web/admin.py +++ b/app/web/admin.py @@ -5,6 +5,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, Response 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 manufacturer as manufacturer_crud from app.crud import part_type as part_type_crud @@ -68,10 +69,10 @@ async def create_user( is_superuser: bool = Form(False), role_ids: list[int] = Form(default=[]), db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("users:write")), + actor: User = Depends(web_require("users:write")), ): if not await user_crud.get_by_email(db, email): - await user_crud.create( + created = await user_crud.create( db, email=email, password=password, @@ -79,6 +80,11 @@ async def create_user( is_superuser=is_superuser, role_ids=role_ids, ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="User", + resource_id=created.id, + description=audit_crud.describe_create("User", created.id, created.email), + ) return templates.TemplateResponse( request, "admin/_users_table.html", @@ -129,21 +135,23 @@ async def update_user( is_superuser: bool = Form(False), role_ids: list[int] = Form(default=[]), db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("users:write")), + actor: User = Depends(web_require("users:write")), ): target = await user_crud.get(db, user_id) if target is None: raise HTTPException(status_code=404, detail="User not found") - await user_crud.update( - db, - target, - { - "full_name": full_name or None, - "password": password or None, - "is_active": is_active, - "is_superuser": is_superuser, - "role_ids": role_ids, - }, + changes = { + "full_name": full_name or None, + "password": password or None, + "is_active": is_active, + "is_superuser": is_superuser, + "role_ids": role_ids, + } + await user_crud.update(db, target, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="User", + resource_id=user_id, + description=audit_crud.describe_update("User", user_id, changes), ) return templates.TemplateResponse(request, "admin/_user_row.html", {"u": target}) @@ -152,12 +160,17 @@ async def update_user( async def delete_user( user_id: int, db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("users:delete")), + actor: User = Depends(web_require("users:delete")), ): target = await user_crud.get(db, user_id) if target is not None: + email = target.email await user_crud.delete(db, target) - # Empty body — HTMX swaps the row out of existence. + await audit_crud.log( + db, actor=actor, action="delete", resource_type="User", + resource_id=user_id, + description=audit_crud.describe_delete("User", user_id, email), + ) return Response(status_code=status.HTTP_200_OK) @@ -188,15 +201,20 @@ async def create_role( description: str = Form(""), permission_ids: list[int] = Form(default=[]), db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("roles:write")), + actor: User = Depends(web_require("roles:write")), ): if not await role_crud.get_by_name(db, name): - await role_crud.create( + created = await role_crud.create( db, name=name, description=description or None, permission_ids=permission_ids, ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="Role", + resource_id=created.id, + description=audit_crud.describe_create("Role", created.id, created.name), + ) return templates.TemplateResponse( request, "admin/_roles_table.html", @@ -243,12 +261,18 @@ async def update_role( role_id: int, permission_ids: list[int] = Form(default=[]), db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("roles:write")), + actor: User = Depends(web_require("roles:write")), ): role = await role_crud.get(db, role_id) if role is None: raise HTTPException(status_code=404, detail="Role not found") - await role_crud.update(db, role, {"permission_ids": permission_ids}) + changes = {"permission_ids": permission_ids} + await role_crud.update(db, role, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="Role", + resource_id=role_id, + description=audit_crud.describe_update("Role", role_id, changes), + ) return templates.TemplateResponse(request, "admin/_role_row.html", {"r": role}) @@ -256,11 +280,17 @@ async def update_role( async def delete_role( role_id: int, db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("roles:write")), + actor: User = Depends(web_require("roles:write")), ): role = await role_crud.get(db, role_id) if role is not None: + name = role.name await role_crud.delete(db, role) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="Role", + resource_id=role_id, + description=audit_crud.describe_delete("Role", role_id, name), + ) return Response(status_code=status.HTTP_200_OK) @@ -287,14 +317,19 @@ async def create_iso_thread( pitch_mm: float = Form(...), depth_mm: str = Form(""), db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("iso_threads:write")), + actor: User = Depends(web_require("iso_threads:write")), ): - await iso_thread_crud.create( + thread = await iso_thread_crud.create( db, diameter_mm=diameter_mm, pitch_mm=pitch_mm, depth_mm=float(depth_mm) if depth_mm.strip() else None, ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="IsoThread", + resource_id=thread.id, + description=audit_crud.describe_create("IsoThread", thread.id, f"M{thread.diameter_mm}×{thread.pitch_mm}"), + ) return templates.TemplateResponse( request, "admin/_iso_threads_table.html", @@ -340,19 +375,21 @@ async def update_iso_thread( pitch_mm: float = Form(...), depth_mm: str = Form(""), db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("iso_threads:write")), + actor: User = Depends(web_require("iso_threads:write")), ): thread = await iso_thread_crud.get(db, thread_id) if thread is None: raise HTTPException(status_code=404, detail="ISO thread not found") - await iso_thread_crud.update( - db, - thread, - { - "diameter_mm": diameter_mm, - "pitch_mm": pitch_mm, - "depth_mm": float(depth_mm) if depth_mm.strip() else None, - }, + changes = { + "diameter_mm": diameter_mm, + "pitch_mm": pitch_mm, + "depth_mm": float(depth_mm) if depth_mm.strip() else None, + } + await iso_thread_crud.update(db, thread, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="IsoThread", + resource_id=thread_id, + description=audit_crud.describe_update("IsoThread", thread_id, changes), ) return templates.TemplateResponse( request, "admin/_iso_thread_row.html", {"t": thread} @@ -363,11 +400,17 @@ async def update_iso_thread( async def delete_iso_thread( thread_id: int, db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("iso_threads:delete")), + actor: User = Depends(web_require("iso_threads:delete")), ): thread = await iso_thread_crud.get(db, thread_id) if thread is not None: + name = f"M{thread.diameter_mm}×{thread.pitch_mm}" await iso_thread_crud.delete(db, thread) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="IsoThread", + resource_id=thread_id, + description=audit_crud.describe_delete("IsoThread", thread_id, name), + ) return Response(status_code=status.HTTP_200_OK) @@ -399,15 +442,20 @@ async def create_unc_thread( series: UncSeries = Form(...), depth_in: str = Form(""), db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("unc_threads:write")), + actor: User = Depends(web_require("unc_threads:write")), ): - await unc_thread_crud.create( + thread = await unc_thread_crud.create( db, diameter_fraction=diameter_fraction, tpi=tpi, series=series, depth_in=float(depth_in) if depth_in.strip() else None, ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="UncThread", + resource_id=thread.id, + description=audit_crud.describe_create("UncThread", thread.id, f"{thread.diameter_fraction}-{thread.tpi} {thread.series.value}"), + ) return templates.TemplateResponse( request, "admin/_unc_threads_table.html", @@ -456,20 +504,22 @@ async def update_unc_thread( series: UncSeries = Form(...), depth_in: str = Form(""), db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("unc_threads:write")), + actor: User = Depends(web_require("unc_threads:write")), ): thread = await unc_thread_crud.get(db, thread_id) if thread is None: raise HTTPException(status_code=404, detail="UNC thread not found") - await unc_thread_crud.update( - db, - thread, - { - "diameter_fraction": diameter_fraction, - "tpi": tpi, - "series": series, - "depth_in": float(depth_in) if depth_in.strip() else None, - }, + changes = { + "diameter_fraction": diameter_fraction, + "tpi": tpi, + "series": series, + "depth_in": float(depth_in) if depth_in.strip() else None, + } + await unc_thread_crud.update(db, thread, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="UncThread", + resource_id=thread_id, + description=audit_crud.describe_update("UncThread", thread_id, changes), ) return templates.TemplateResponse( request, "admin/_unc_thread_row.html", {"t": thread} @@ -480,11 +530,17 @@ async def update_unc_thread( async def delete_unc_thread( thread_id: int, db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("unc_threads:delete")), + actor: User = Depends(web_require("unc_threads:delete")), ): thread = await unc_thread_crud.get(db, thread_id) if thread is not None: + name = f"{thread.diameter_fraction}-{thread.tpi} {thread.series.value}" await unc_thread_crud.delete(db, thread) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="UncThread", + resource_id=thread_id, + description=audit_crud.describe_delete("UncThread", thread_id, name), + ) return Response(status_code=status.HTTP_200_OK) @@ -511,14 +567,19 @@ async def create_manufacturer( url: str = Form(...), logo_url: str = Form(""), db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("manufacturers:write")), + actor: User = Depends(web_require("manufacturers:write")), ): - await manufacturer_crud.create( + created = await manufacturer_crud.create( db, name=name, url=url, logo_url=logo_url.strip() or None, ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="Manufacturer", + resource_id=created.id, + description=audit_crud.describe_create("Manufacturer", created.id, created.name), + ) return templates.TemplateResponse( request, "admin/_manufacturers_table.html", @@ -564,19 +625,17 @@ async def update_manufacturer( url: str = Form(...), logo_url: str = Form(""), db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("manufacturers:write")), + actor: User = Depends(web_require("manufacturers:write")), ): m = await manufacturer_crud.get(db, manufacturer_id) if m is None: raise HTTPException(status_code=404, detail="Manufacturer not found") - await manufacturer_crud.update( - db, - m, - { - "name": name, - "url": url, - "logo_url": logo_url.strip() or None, - }, + changes = {"name": name, "url": url, "logo_url": logo_url.strip() or None} + await manufacturer_crud.update(db, m, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="Manufacturer", + resource_id=manufacturer_id, + description=audit_crud.describe_update("Manufacturer", manufacturer_id, changes), ) return templates.TemplateResponse( request, "admin/_manufacturer_row.html", {"m": m} @@ -587,11 +646,17 @@ async def update_manufacturer( async def delete_manufacturer( manufacturer_id: int, db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("manufacturers:delete")), + actor: User = Depends(web_require("manufacturers:delete")), ): m = await manufacturer_crud.get(db, manufacturer_id) if m is not None: + name = m.name await manufacturer_crud.delete(db, m) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="Manufacturer", + resource_id=manufacturer_id, + description=audit_crud.describe_delete("Manufacturer", manufacturer_id, name), + ) return Response(status_code=status.HTTP_200_OK) @@ -622,14 +687,19 @@ async def create_scope_model( manufacturer_id: int = Form(...), url: str = Form(""), db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("scope_models:write")), + actor: User = Depends(web_require("scope_models:write")), ): - await scope_model_crud.create( + created = await scope_model_crud.create( db, name=name, manufacturer_id=manufacturer_id, url=url.strip() or None, ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="ScopeModel", + resource_id=created.id, + description=audit_crud.describe_create("ScopeModel", created.id, created.name), + ) return templates.TemplateResponse( request, "admin/_scope_models_table.html", @@ -677,19 +747,17 @@ async def update_scope_model( manufacturer_id: int = Form(...), url: str = Form(""), db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("scope_models:write")), + actor: User = Depends(web_require("scope_models:write")), ): sm = await scope_model_crud.get(db, scope_model_id) if sm is None: raise HTTPException(status_code=404, detail="Scope model not found") - await scope_model_crud.update( - db, - sm, - { - "name": name, - "manufacturer_id": manufacturer_id, - "url": url.strip() or None, - }, + changes = {"name": name, "manufacturer_id": manufacturer_id, "url": url.strip() or None} + await scope_model_crud.update(db, sm, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="ScopeModel", + resource_id=scope_model_id, + description=audit_crud.describe_update("ScopeModel", scope_model_id, changes), ) return templates.TemplateResponse( request, "admin/_scope_model_row.html", {"sm": sm} @@ -700,11 +768,17 @@ async def update_scope_model( async def delete_scope_model( scope_model_id: int, db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("scope_models:delete")), + actor: User = Depends(web_require("scope_models:delete")), ): sm = await scope_model_crud.get(db, scope_model_id) if sm is not None: + name = sm.name await scope_model_crud.delete(db, sm) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="ScopeModel", + resource_id=scope_model_id, + description=audit_crud.describe_delete("ScopeModel", scope_model_id, name), + ) return Response(status_code=status.HTTP_200_OK) @@ -729,10 +803,15 @@ async def create_part_type( request: Request, name: str = Form(...), db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("part_types:write")), + actor: User = Depends(web_require("part_types:write")), ): if not await part_type_crud.get_by_name(db, name): - await part_type_crud.create(db, name=name) + created = await part_type_crud.create(db, name=name) + await audit_crud.log( + db, actor=actor, action="create", resource_type="PartType", + resource_id=created.id, + description=audit_crud.describe_create("PartType", created.id, created.name), + ) return templates.TemplateResponse( request, "admin/_part_types_table.html", @@ -776,12 +855,18 @@ async def update_part_type( part_type_id: int, name: str = Form(...), db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("part_types:write")), + actor: User = Depends(web_require("part_types:write")), ): pt = await part_type_crud.get(db, part_type_id) if pt is None: raise HTTPException(status_code=404, detail="Part type not found") - await part_type_crud.update(db, pt, {"name": name}) + changes = {"name": name} + await part_type_crud.update(db, pt, changes) + await audit_crud.log( + db, actor=actor, action="update", resource_type="PartType", + resource_id=part_type_id, + description=audit_crud.describe_update("PartType", part_type_id, changes), + ) return templates.TemplateResponse( request, "admin/_part_type_row.html", {"pt": pt} ) @@ -791,9 +876,31 @@ async def update_part_type( async def delete_part_type( part_type_id: int, db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("part_types:delete")), + actor: User = Depends(web_require("part_types:delete")), ): pt = await part_type_crud.get(db, part_type_id) if pt is not None: + name = pt.name await part_type_crud.delete(db, pt) + await audit_crud.log( + db, actor=actor, action="delete", resource_type="PartType", + resource_id=part_type_id, + description=audit_crud.describe_delete("PartType", part_type_id, name), + ) return Response(status_code=status.HTTP_200_OK) + + +# --- Audit Log --------------------------------------------------------------- + + +@router.get("/audit-log", response_class=HTMLResponse) +async def audit_log_page( + request: Request, + db: AsyncSession = Depends(get_db), + user: User = Depends(web_require("audit_log:read")), +): + return templates.TemplateResponse( + request, + "admin/audit_log.html", + {"user": user, "entries": await audit_crud.list_recent(db)}, + ) diff --git a/scripts/seed.py b/scripts/seed.py index 79af52d..f3e970f 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -39,11 +39,12 @@ PERMISSIONS = [ ("part_types:read", "View part types"), ("part_types:write", "Create and update part types"), ("part_types:delete", "Delete part types"), + ("audit_log:read", "View audit log"), ] # Default roles -> permission names. "admin" gets everything. ROLES = { - "admin": [name for name, _ in PERMISSIONS], + "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"], }