simple-module-audit-log 0.0.24__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- audit_log/__init__.py +0 -0
- audit_log/capture.py +28 -0
- audit_log/constants.py +42 -0
- audit_log/contracts/__init__.py +0 -0
- audit_log/contracts/schemas.py +29 -0
- audit_log/deps.py +20 -0
- audit_log/endpoints/__init__.py +0 -0
- audit_log/endpoints/api.py +40 -0
- audit_log/endpoints/views.py +82 -0
- audit_log/locales/en.json +43 -0
- audit_log/models.py +47 -0
- audit_log/module.py +69 -0
- audit_log/package.json +16 -0
- audit_log/pages/Browse.tsx +245 -0
- audit_log/pages/components/FilterBar.tsx +126 -0
- audit_log/py.typed +0 -0
- audit_log/service.py +82 -0
- simple_module_audit_log-0.0.24.dist-info/METADATA +82 -0
- simple_module_audit_log-0.0.24.dist-info/RECORD +22 -0
- simple_module_audit_log-0.0.24.dist-info/WHEEL +4 -0
- simple_module_audit_log-0.0.24.dist-info/entry_points.txt +2 -0
- simple_module_audit_log-0.0.24.dist-info/licenses/LICENSE +21 -0
audit_log/__init__.py
ADDED
|
File without changes
|
audit_log/capture.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Audit callback that converts AuditRecords into AuditEntry rows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from simple_module_db.audit import AuditRecord
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
|
|
10
|
+
from audit_log.models import AuditEntry
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def audit_callback(session: Session, records: list[AuditRecord]) -> None:
|
|
16
|
+
try:
|
|
17
|
+
for record in records:
|
|
18
|
+
entry = AuditEntry(
|
|
19
|
+
entity_type=record.entity_type,
|
|
20
|
+
entity_id=record.entity_id,
|
|
21
|
+
action=record.action,
|
|
22
|
+
changes=record.changes,
|
|
23
|
+
user_id=record.user_id,
|
|
24
|
+
correlation_id=record.correlation_id,
|
|
25
|
+
)
|
|
26
|
+
session.add(entry)
|
|
27
|
+
except Exception:
|
|
28
|
+
logger.exception("Failed to write audit entries")
|
audit_log/constants.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Centralized constants for the Audit Log module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
MODULE_NAME: Final = "AuditLog"
|
|
8
|
+
MODULE_PACKAGE: Final = "audit_log"
|
|
9
|
+
LOCALE_NAMESPACE: Final = MODULE_PACKAGE
|
|
10
|
+
|
|
11
|
+
API_PREFIX: Final = "/api/audit_log"
|
|
12
|
+
VIEW_PREFIX: Final = "/audit_log"
|
|
13
|
+
|
|
14
|
+
MENU_LABEL: Final = "Audit Log"
|
|
15
|
+
MENU_URL: Final = VIEW_PREFIX
|
|
16
|
+
MENU_ICON: Final = "scroll-text"
|
|
17
|
+
MENU_ORDER: Final = 210
|
|
18
|
+
|
|
19
|
+
PERM_GROUP: Final = MODULE_NAME
|
|
20
|
+
PERM_VIEW: Final = "audit_log.view"
|
|
21
|
+
ALL_PERMISSIONS: Final = (PERM_VIEW,)
|
|
22
|
+
|
|
23
|
+
TABLE_AUDIT_ENTRY: Final = "audit_log_audit_entry"
|
|
24
|
+
|
|
25
|
+
ACTION_CREATED: Final = "created"
|
|
26
|
+
ACTION_UPDATED: Final = "updated"
|
|
27
|
+
ACTION_DELETED: Final = "deleted"
|
|
28
|
+
ACTION_SOFT_DELETED: Final = "soft_deleted"
|
|
29
|
+
ALL_ACTIONS: Final = (ACTION_CREATED, ACTION_UPDATED, ACTION_DELETED, ACTION_SOFT_DELETED)
|
|
30
|
+
|
|
31
|
+
ENTITY_TYPE_MAX_LENGTH: Final = 255
|
|
32
|
+
ENTITY_ID_MAX_LENGTH: Final = 255
|
|
33
|
+
ACTION_MAX_LENGTH: Final = 20
|
|
34
|
+
USER_ID_MAX_LENGTH: Final = 255
|
|
35
|
+
CORRELATION_ID_MAX_LENGTH: Final = 255
|
|
36
|
+
|
|
37
|
+
DEFAULT_PAGE_SIZE: Final = 50
|
|
38
|
+
MAX_PAGE_SIZE: Final = 200
|
|
39
|
+
|
|
40
|
+
PAGE_BROWSE: Final = f"{MODULE_NAME}/Browse"
|
|
41
|
+
|
|
42
|
+
STATUS_OK: Final = 200
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""SQLModel DTOs for the Audit Log module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from pydantic import ConfigDict
|
|
9
|
+
from sqlmodel import SQLModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuditEntryRead(SQLModel):
|
|
13
|
+
model_config = ConfigDict(from_attributes=True)
|
|
14
|
+
|
|
15
|
+
id: uuid.UUID
|
|
16
|
+
entity_type: str
|
|
17
|
+
entity_id: str
|
|
18
|
+
action: str
|
|
19
|
+
changes: list[dict]
|
|
20
|
+
user_id: str | None = None
|
|
21
|
+
correlation_id: str | None = None
|
|
22
|
+
created_at: datetime
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuditEntryList(SQLModel):
|
|
26
|
+
items: list[AuditEntryRead]
|
|
27
|
+
total: int
|
|
28
|
+
page: int
|
|
29
|
+
page_size: int
|
audit_log/deps.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""FastAPI dependencies for the Audit Log module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends
|
|
8
|
+
from simple_module_db.deps import get_db
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from audit_log.service import AuditLogService
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def get_audit_log_service(
|
|
15
|
+
db: AsyncSession = Depends(get_db),
|
|
16
|
+
) -> AuditLogService:
|
|
17
|
+
return AuditLogService(db)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
AuditLogServiceDep = Annotated[AuditLogService, Depends(get_audit_log_service)]
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""REST API endpoints for the Audit Log module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, Query
|
|
8
|
+
from simple_module_hosting.permissions import RequiresPermission
|
|
9
|
+
|
|
10
|
+
from audit_log.constants import DEFAULT_PAGE_SIZE, PERM_VIEW
|
|
11
|
+
from audit_log.contracts.schemas import AuditEntryList
|
|
12
|
+
from audit_log.deps import AuditLogServiceDep
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
_VIEW = [Depends(RequiresPermission(PERM_VIEW))]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.get("/", response_model=AuditEntryList, dependencies=_VIEW)
|
|
20
|
+
async def list_audit_entries(
|
|
21
|
+
service: AuditLogServiceDep,
|
|
22
|
+
entity_type: str | None = Query(default=None),
|
|
23
|
+
entity_id: str | None = Query(default=None),
|
|
24
|
+
action: str | None = Query(default=None),
|
|
25
|
+
user_id: str | None = Query(default=None),
|
|
26
|
+
from_date: datetime | None = Query(default=None),
|
|
27
|
+
to_date: datetime | None = Query(default=None),
|
|
28
|
+
page: int = Query(default=1, ge=1),
|
|
29
|
+
page_size: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, le=200),
|
|
30
|
+
) -> AuditEntryList:
|
|
31
|
+
return await service.list_entries(
|
|
32
|
+
entity_type=entity_type,
|
|
33
|
+
entity_id=entity_id,
|
|
34
|
+
action=action,
|
|
35
|
+
user_id=user_id,
|
|
36
|
+
from_date=from_date,
|
|
37
|
+
to_date=to_date,
|
|
38
|
+
page=page,
|
|
39
|
+
page_size=page_size,
|
|
40
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Inertia view endpoints for the Audit Log admin UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, Query
|
|
8
|
+
from inertia import InertiaResponse
|
|
9
|
+
from simple_module_hosting.inertia_deps import InertiaDep
|
|
10
|
+
from simple_module_hosting.permissions import RequiresPermission
|
|
11
|
+
|
|
12
|
+
from audit_log.constants import (
|
|
13
|
+
DEFAULT_PAGE_SIZE,
|
|
14
|
+
MAX_PAGE_SIZE,
|
|
15
|
+
PAGE_BROWSE,
|
|
16
|
+
PERM_VIEW,
|
|
17
|
+
)
|
|
18
|
+
from audit_log.deps import AuditLogServiceDep
|
|
19
|
+
|
|
20
|
+
router = APIRouter()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _safe_int(raw: str | None, default: int) -> int:
|
|
24
|
+
"""Parse *raw* as an integer, returning *default* on failure."""
|
|
25
|
+
if raw is None:
|
|
26
|
+
return default
|
|
27
|
+
try:
|
|
28
|
+
return int(raw)
|
|
29
|
+
except (ValueError, TypeError):
|
|
30
|
+
return default
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get(
|
|
34
|
+
"/",
|
|
35
|
+
response_model=None,
|
|
36
|
+
dependencies=[Depends(RequiresPermission(PERM_VIEW))],
|
|
37
|
+
)
|
|
38
|
+
async def browse(
|
|
39
|
+
inertia: InertiaDep,
|
|
40
|
+
service: AuditLogServiceDep,
|
|
41
|
+
entity_type: str | None = Query(default=None),
|
|
42
|
+
entity_id: str | None = Query(default=None),
|
|
43
|
+
action: str | None = Query(default=None),
|
|
44
|
+
user_id: str | None = Query(default=None),
|
|
45
|
+
from_date: datetime | None = Query(default=None),
|
|
46
|
+
to_date: datetime | None = Query(default=None),
|
|
47
|
+
page: str | None = Query(default=None),
|
|
48
|
+
page_size: str | None = Query(default=None),
|
|
49
|
+
) -> InertiaResponse:
|
|
50
|
+
# Sanitize pagination — never raise a validation error for bad values.
|
|
51
|
+
page_int = max(_safe_int(page, 1), 1)
|
|
52
|
+
page_size_int = max(1, min(_safe_int(page_size, DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE))
|
|
53
|
+
result = await service.list_entries(
|
|
54
|
+
entity_type=entity_type,
|
|
55
|
+
entity_id=entity_id,
|
|
56
|
+
action=action,
|
|
57
|
+
user_id=user_id,
|
|
58
|
+
from_date=from_date,
|
|
59
|
+
to_date=to_date,
|
|
60
|
+
page=page_int,
|
|
61
|
+
page_size=page_size_int,
|
|
62
|
+
)
|
|
63
|
+
entity_types = await service.distinct_entity_types()
|
|
64
|
+
|
|
65
|
+
return await inertia.render(
|
|
66
|
+
PAGE_BROWSE,
|
|
67
|
+
{
|
|
68
|
+
"items": [item.model_dump(mode="json") for item in result.items],
|
|
69
|
+
"total": result.total,
|
|
70
|
+
"page": result.page,
|
|
71
|
+
"page_size": result.page_size,
|
|
72
|
+
"entity_types": entity_types,
|
|
73
|
+
"filters": {
|
|
74
|
+
"entity_type": entity_type,
|
|
75
|
+
"entity_id": entity_id,
|
|
76
|
+
"action": action,
|
|
77
|
+
"user_id": user_id,
|
|
78
|
+
"from_date": from_date.isoformat() if from_date else None,
|
|
79
|
+
"to_date": to_date.isoformat() if to_date else None,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"browse": {
|
|
3
|
+
"title": "Audit Log",
|
|
4
|
+
"description": "Track all entity changes across the system.",
|
|
5
|
+
"empty_title": "No audit entries",
|
|
6
|
+
"empty_description": "Changes to entities will appear here automatically.",
|
|
7
|
+
"showing": "Showing {from}–{to} of {total} entries",
|
|
8
|
+
"previous": "Previous",
|
|
9
|
+
"next": "Next"
|
|
10
|
+
},
|
|
11
|
+
"filters": {
|
|
12
|
+
"entity_type_label": "Entity Type",
|
|
13
|
+
"entity_type_all": "All types",
|
|
14
|
+
"action_label": "Action",
|
|
15
|
+
"action_all": "All actions",
|
|
16
|
+
"user_label": "User ID",
|
|
17
|
+
"user_placeholder": "Filter by user…",
|
|
18
|
+
"from_date_label": "From",
|
|
19
|
+
"to_date_label": "To",
|
|
20
|
+
"apply": "Apply",
|
|
21
|
+
"clear": "Clear"
|
|
22
|
+
},
|
|
23
|
+
"table": {
|
|
24
|
+
"timestamp": "Timestamp",
|
|
25
|
+
"action": "Action",
|
|
26
|
+
"entity": "Entity",
|
|
27
|
+
"user": "User",
|
|
28
|
+
"changes": "Changes"
|
|
29
|
+
},
|
|
30
|
+
"actions": {
|
|
31
|
+
"created": "Created",
|
|
32
|
+
"updated": "Updated",
|
|
33
|
+
"deleted": "Deleted",
|
|
34
|
+
"soft_deleted": "Archived"
|
|
35
|
+
},
|
|
36
|
+
"changes": {
|
|
37
|
+
"fields_set": "{count} fields set",
|
|
38
|
+
"show_more": "Show {count} more…",
|
|
39
|
+
"show_less": "Show less",
|
|
40
|
+
"system_user": "System",
|
|
41
|
+
"no_changes": "—"
|
|
42
|
+
}
|
|
43
|
+
}
|
audit_log/models.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""SQLModel table for the Audit Log module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
from simple_module_db.base import create_module_base
|
|
9
|
+
from sqlalchemy import JSON, DateTime, Index, func
|
|
10
|
+
from sqlmodel import Column, Field
|
|
11
|
+
|
|
12
|
+
from audit_log.constants import (
|
|
13
|
+
ACTION_MAX_LENGTH,
|
|
14
|
+
CORRELATION_ID_MAX_LENGTH,
|
|
15
|
+
ENTITY_ID_MAX_LENGTH,
|
|
16
|
+
ENTITY_TYPE_MAX_LENGTH,
|
|
17
|
+
MODULE_PACKAGE,
|
|
18
|
+
TABLE_AUDIT_ENTRY,
|
|
19
|
+
USER_ID_MAX_LENGTH,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
Base = create_module_base(MODULE_PACKAGE)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuditEntry(Base, table=True): # ty: ignore[unsupported-base]
|
|
26
|
+
__tablename__ = TABLE_AUDIT_ENTRY
|
|
27
|
+
__audit_exclude__ = True
|
|
28
|
+
|
|
29
|
+
__table_args__ = (
|
|
30
|
+
Index("ix_audit_entry_entity_type", "entity_type"),
|
|
31
|
+
Index("ix_audit_entry_entity_id", "entity_id"),
|
|
32
|
+
Index("ix_audit_entry_user_id", "user_id"),
|
|
33
|
+
Index("ix_audit_entry_created_at", "created_at"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
37
|
+
entity_type: str = Field(max_length=ENTITY_TYPE_MAX_LENGTH)
|
|
38
|
+
entity_id: str = Field(max_length=ENTITY_ID_MAX_LENGTH)
|
|
39
|
+
action: str = Field(max_length=ACTION_MAX_LENGTH)
|
|
40
|
+
changes: dict | list = Field(default_factory=list, sa_column=Column(JSON))
|
|
41
|
+
user_id: str | None = Field(default=None, max_length=USER_ID_MAX_LENGTH)
|
|
42
|
+
correlation_id: str | None = Field(default=None, max_length=CORRELATION_ID_MAX_LENGTH)
|
|
43
|
+
created_at: datetime = Field(
|
|
44
|
+
default_factory=lambda: datetime.now(UTC),
|
|
45
|
+
sa_type=DateTime(timezone=True),
|
|
46
|
+
sa_column_kwargs={"server_default": func.now()},
|
|
47
|
+
)
|
audit_log/module.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Audit Log module definition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, FastAPI
|
|
9
|
+
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
|
|
10
|
+
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
11
|
+
from simple_module_core.permissions import PermissionRegistry
|
|
12
|
+
|
|
13
|
+
from audit_log.constants import (
|
|
14
|
+
ALL_PERMISSIONS,
|
|
15
|
+
API_PREFIX,
|
|
16
|
+
LOCALE_NAMESPACE,
|
|
17
|
+
MENU_ICON,
|
|
18
|
+
MENU_LABEL,
|
|
19
|
+
MENU_ORDER,
|
|
20
|
+
MENU_URL,
|
|
21
|
+
MODULE_NAME,
|
|
22
|
+
PERM_GROUP,
|
|
23
|
+
VIEW_PREFIX,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_MODULE_USERS = "Users"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuditLogModule(ModuleBase):
|
|
30
|
+
meta = ModuleMeta(
|
|
31
|
+
name=MODULE_NAME,
|
|
32
|
+
route_prefix=API_PREFIX,
|
|
33
|
+
view_prefix=VIEW_PREFIX,
|
|
34
|
+
depends_on=[_MODULE_USERS],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
|
|
38
|
+
from audit_log.endpoints.api import router as api
|
|
39
|
+
from audit_log.endpoints.views import router as views
|
|
40
|
+
|
|
41
|
+
api_router.include_router(api)
|
|
42
|
+
view_router.include_router(views)
|
|
43
|
+
|
|
44
|
+
def register_menu_items(self, registry: MenuRegistry) -> None:
|
|
45
|
+
registry.add(
|
|
46
|
+
MenuItem(
|
|
47
|
+
label=MENU_LABEL,
|
|
48
|
+
url=MENU_URL,
|
|
49
|
+
icon=MENU_ICON,
|
|
50
|
+
order=MENU_ORDER,
|
|
51
|
+
section=MenuSection.SIDEBAR,
|
|
52
|
+
group="System",
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def register_permissions(self, registry: PermissionRegistry) -> None:
|
|
57
|
+
registry.add_group(PERM_GROUP, list(ALL_PERMISSIONS))
|
|
58
|
+
|
|
59
|
+
async def on_startup(self, app: FastAPI) -> None:
|
|
60
|
+
from audit_log.capture import audit_callback
|
|
61
|
+
|
|
62
|
+
app.state.sm.db.audit_callback = audit_callback
|
|
63
|
+
|
|
64
|
+
async def on_shutdown(self, app: FastAPI) -> None:
|
|
65
|
+
app.state.sm.db.audit_callback = None
|
|
66
|
+
|
|
67
|
+
def locale_dirs(self) -> dict[str, Path]:
|
|
68
|
+
base = Path(str(importlib.resources.files(__package__) / "locales"))
|
|
69
|
+
return {LOCALE_NAMESPACE: base}
|
audit_log/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@simple-module-py/audit-log",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Frontend assets for the Audit Log module",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"react": "^19.0.0",
|
|
8
|
+
"react-dom": "^19.0.0",
|
|
9
|
+
"@inertiajs/react": "^2.0.0",
|
|
10
|
+
"@simple-module-py/ui": "*"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@simple-module-py/tsconfig": "*"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {}
|
|
16
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { Head, router, usePage } from '@inertiajs/react';
|
|
2
|
+
import { keys, useT } from '@simple-module-py/i18n';
|
|
3
|
+
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
4
|
+
import { Badge } from '@simple-module-py/ui/components/ui/badge';
|
|
5
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
6
|
+
import { Card } from '@simple-module-py/ui/components/ui/card';
|
|
7
|
+
import {
|
|
8
|
+
Table,
|
|
9
|
+
TableBody,
|
|
10
|
+
TableCell,
|
|
11
|
+
TableHead,
|
|
12
|
+
TableHeader,
|
|
13
|
+
TableRow,
|
|
14
|
+
} from '@simple-module-py/ui/components/ui/table';
|
|
15
|
+
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
16
|
+
import { ScrollText } from 'lucide-react';
|
|
17
|
+
import type React from 'react';
|
|
18
|
+
import { useState } from 'react';
|
|
19
|
+
import { ALL, FilterBar, type FilterState } from './components/FilterBar';
|
|
20
|
+
|
|
21
|
+
interface Change {
|
|
22
|
+
field: string;
|
|
23
|
+
old?: unknown;
|
|
24
|
+
new?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface AuditEntryRead {
|
|
28
|
+
id: string;
|
|
29
|
+
entity_type: string;
|
|
30
|
+
entity_id: string;
|
|
31
|
+
action: 'created' | 'updated' | 'deleted' | 'soft_deleted';
|
|
32
|
+
changes: Change[];
|
|
33
|
+
user_id: string | null;
|
|
34
|
+
correlation_id: string | null;
|
|
35
|
+
created_at: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Filters {
|
|
39
|
+
entity_type: string | null;
|
|
40
|
+
action: string | null;
|
|
41
|
+
user_id: string | null;
|
|
42
|
+
from_date: string | null;
|
|
43
|
+
to_date: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Props {
|
|
47
|
+
items: AuditEntryRead[];
|
|
48
|
+
total: number;
|
|
49
|
+
page: number;
|
|
50
|
+
page_size: number;
|
|
51
|
+
entity_types: string[];
|
|
52
|
+
filters: Filters;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ACTION_BADGE: Record<string, string> = {
|
|
56
|
+
created: 'border-green-200 bg-green-50 text-green-700',
|
|
57
|
+
updated: 'border-blue-200 bg-blue-50 text-blue-700',
|
|
58
|
+
deleted: 'border-red-200 bg-red-50 text-red-700',
|
|
59
|
+
soft_deleted: 'border-amber-200 bg-amber-50 text-amber-700',
|
|
60
|
+
};
|
|
61
|
+
const TH = 'sm:px-6 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground';
|
|
62
|
+
|
|
63
|
+
function ChangesList({ entry }: { entry: AuditEntryRead }) {
|
|
64
|
+
const { t } = useT();
|
|
65
|
+
const [expanded, setExpanded] = useState(false);
|
|
66
|
+
if (entry.action === 'deleted' || entry.action === 'soft_deleted')
|
|
67
|
+
return <span className="text-muted-foreground">{t(keys.audit_log.changes.no_changes)}</span>;
|
|
68
|
+
if (entry.action === 'created')
|
|
69
|
+
return (
|
|
70
|
+
<span className="text-muted-foreground">
|
|
71
|
+
{t(keys.audit_log.changes.fields_set, { count: entry.changes.length })}
|
|
72
|
+
</span>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const visible = expanded ? entry.changes : entry.changes.slice(0, 3);
|
|
76
|
+
const remaining = entry.changes.length - 3;
|
|
77
|
+
return (
|
|
78
|
+
<div className="space-y-0.5 text-xs">
|
|
79
|
+
{visible.map((c) => (
|
|
80
|
+
<div key={c.field} className="font-mono">
|
|
81
|
+
<span className="font-semibold">{c.field}</span>{' '}
|
|
82
|
+
<span className="text-muted-foreground">
|
|
83
|
+
{String(c.old ?? '""')}→{String(c.new ?? '""')}
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
))}
|
|
87
|
+
{remaining > 0 && (
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
className="text-primary-700 hover:underline text-xs"
|
|
91
|
+
onClick={() => setExpanded(!expanded)}
|
|
92
|
+
>
|
|
93
|
+
{expanded
|
|
94
|
+
? t(keys.audit_log.changes.show_less)
|
|
95
|
+
: t(keys.audit_log.changes.show_more, { count: remaining })}
|
|
96
|
+
</button>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function Browse() {
|
|
103
|
+
const { items, total, page, page_size, entity_types, filters } = usePage<{ props: Props }>()
|
|
104
|
+
.props as unknown as Props;
|
|
105
|
+
const { t } = useT();
|
|
106
|
+
|
|
107
|
+
const [state, setState] = useState<FilterState>({
|
|
108
|
+
entityType: filters.entity_type ?? ALL,
|
|
109
|
+
action: filters.action ?? ALL,
|
|
110
|
+
userId: filters.user_id ?? '',
|
|
111
|
+
fromDate: filters.from_date ?? '',
|
|
112
|
+
toDate: filters.to_date ?? '',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
function navigate(next: FilterState, nextPage = 1) {
|
|
116
|
+
const p: Record<string, string> = {};
|
|
117
|
+
if (next.entityType && next.entityType !== ALL) p.entity_type = next.entityType;
|
|
118
|
+
if (next.action && next.action !== ALL) p.action = next.action;
|
|
119
|
+
if (next.userId) p.user_id = next.userId;
|
|
120
|
+
if (next.fromDate) p.from_date = next.fromDate;
|
|
121
|
+
if (next.toDate) p.to_date = next.toDate;
|
|
122
|
+
if (nextPage > 1) p.page = String(nextPage);
|
|
123
|
+
if (page_size !== 50) p.page_size = String(page_size);
|
|
124
|
+
router.visit(`/audit_log?${new URLSearchParams(p).toString()}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function handleClear() {
|
|
128
|
+
const cleared: FilterState = {
|
|
129
|
+
entityType: ALL,
|
|
130
|
+
action: ALL,
|
|
131
|
+
userId: '',
|
|
132
|
+
fromDate: '',
|
|
133
|
+
toDate: '',
|
|
134
|
+
};
|
|
135
|
+
setState(cleared);
|
|
136
|
+
navigate(cleared);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const totalPages = Math.ceil(total / page_size);
|
|
140
|
+
const from = total === 0 ? 0 : (page - 1) * page_size + 1;
|
|
141
|
+
const to = Math.min(page * page_size, total);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<>
|
|
145
|
+
<Head title="Audit Log" />
|
|
146
|
+
<PageShell
|
|
147
|
+
title={t(keys.audit_log.browse.title)}
|
|
148
|
+
description={t(keys.audit_log.browse.description)}
|
|
149
|
+
>
|
|
150
|
+
<FilterBar
|
|
151
|
+
state={state}
|
|
152
|
+
entity_types={entity_types}
|
|
153
|
+
onChange={setState}
|
|
154
|
+
onSubmit={() => navigate(state)}
|
|
155
|
+
onClear={handleClear}
|
|
156
|
+
/>
|
|
157
|
+
|
|
158
|
+
{items.length === 0 ? (
|
|
159
|
+
<Card className="border-border">
|
|
160
|
+
<div className="flex flex-col items-center gap-2 py-16 text-muted-foreground">
|
|
161
|
+
<ScrollText className="size-8" />
|
|
162
|
+
<h2 className="text-base font-semibold text-foreground font-[var(--font-display)]">
|
|
163
|
+
{t(keys.audit_log.browse.empty_title)}
|
|
164
|
+
</h2>
|
|
165
|
+
<p className="text-sm">{t(keys.audit_log.browse.empty_description)}</p>
|
|
166
|
+
</div>
|
|
167
|
+
</Card>
|
|
168
|
+
) : (
|
|
169
|
+
<Card className="border-border overflow-hidden p-0">
|
|
170
|
+
<Table>
|
|
171
|
+
<TableHeader className="bg-secondary/40">
|
|
172
|
+
<TableRow>
|
|
173
|
+
<TableHead className={TH}>{t(keys.audit_log.table.timestamp)}</TableHead>
|
|
174
|
+
<TableHead className={TH}>{t(keys.audit_log.table.action)}</TableHead>
|
|
175
|
+
<TableHead className={TH}>{t(keys.audit_log.table.entity)}</TableHead>
|
|
176
|
+
<TableHead className={`${TH} hidden sm:table-cell`}>
|
|
177
|
+
{t(keys.audit_log.table.user)}
|
|
178
|
+
</TableHead>
|
|
179
|
+
<TableHead className={`${TH} hidden md:table-cell`}>
|
|
180
|
+
{t(keys.audit_log.table.changes)}
|
|
181
|
+
</TableHead>
|
|
182
|
+
</TableRow>
|
|
183
|
+
</TableHeader>
|
|
184
|
+
<TableBody>
|
|
185
|
+
{items.map((entry) => (
|
|
186
|
+
<TableRow key={entry.id} className="hover:bg-secondary/40">
|
|
187
|
+
<TableCell className="sm:px-6 whitespace-nowrap text-sm tabular-nums text-muted-foreground">
|
|
188
|
+
{new Date(entry.created_at).toLocaleString()}
|
|
189
|
+
</TableCell>
|
|
190
|
+
<TableCell className="sm:px-6">
|
|
191
|
+
<Badge variant="outline" className={ACTION_BADGE[entry.action] ?? ''}>
|
|
192
|
+
{t(keys.audit_log.actions[entry.action])}
|
|
193
|
+
</Badge>
|
|
194
|
+
</TableCell>
|
|
195
|
+
<TableCell className="sm:px-6">
|
|
196
|
+
<span className="font-medium text-sm">{entry.entity_type}</span>
|
|
197
|
+
<span className="ml-1 font-mono text-xs text-muted-foreground">
|
|
198
|
+
{entry.entity_id}
|
|
199
|
+
</span>
|
|
200
|
+
</TableCell>
|
|
201
|
+
<TableCell className="sm:px-6 hidden sm:table-cell text-sm text-muted-foreground">
|
|
202
|
+
{entry.user_id ?? t(keys.audit_log.changes.system_user)}
|
|
203
|
+
</TableCell>
|
|
204
|
+
<TableCell className="sm:px-6 hidden md:table-cell">
|
|
205
|
+
<ChangesList entry={entry} />
|
|
206
|
+
</TableCell>
|
|
207
|
+
</TableRow>
|
|
208
|
+
))}
|
|
209
|
+
</TableBody>
|
|
210
|
+
</Table>
|
|
211
|
+
</Card>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{totalPages > 1 && (
|
|
215
|
+
<div className="mt-4 flex items-center justify-between">
|
|
216
|
+
<span className="text-sm text-muted-foreground">
|
|
217
|
+
{t(keys.audit_log.browse.showing, { from, to, total })}
|
|
218
|
+
</span>
|
|
219
|
+
<div className="flex items-center gap-2">
|
|
220
|
+
<Button
|
|
221
|
+
variant="outline"
|
|
222
|
+
size="sm"
|
|
223
|
+
disabled={page <= 1}
|
|
224
|
+
onClick={() => navigate(state, page - 1)}
|
|
225
|
+
>
|
|
226
|
+
{t(keys.audit_log.browse.previous)}
|
|
227
|
+
</Button>
|
|
228
|
+
<Button
|
|
229
|
+
variant="outline"
|
|
230
|
+
size="sm"
|
|
231
|
+
disabled={page >= totalPages}
|
|
232
|
+
onClick={() => navigate(state, page + 1)}
|
|
233
|
+
>
|
|
234
|
+
{t(keys.audit_log.browse.next)}
|
|
235
|
+
</Button>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
</PageShell>
|
|
240
|
+
</>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
Browse.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
|
|
245
|
+
export default Browse;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { keys, useT } from '@simple-module-py/i18n';
|
|
2
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
3
|
+
import { Card } from '@simple-module-py/ui/components/ui/card';
|
|
4
|
+
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
5
|
+
import {
|
|
6
|
+
Select,
|
|
7
|
+
SelectContent,
|
|
8
|
+
SelectItem,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectValue,
|
|
11
|
+
} from '@simple-module-py/ui/components/ui/select';
|
|
12
|
+
|
|
13
|
+
export const ALL = '__all__';
|
|
14
|
+
const ACTIONS = ['created', 'updated', 'deleted', 'soft_deleted'] as const;
|
|
15
|
+
|
|
16
|
+
export interface FilterState {
|
|
17
|
+
entityType: string;
|
|
18
|
+
action: string;
|
|
19
|
+
userId: string;
|
|
20
|
+
fromDate: string;
|
|
21
|
+
toDate: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface FilterBarProps {
|
|
25
|
+
state: FilterState;
|
|
26
|
+
entity_types: string[];
|
|
27
|
+
onChange: (next: FilterState) => void;
|
|
28
|
+
onSubmit: () => void;
|
|
29
|
+
onClear: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function FilterBar({ state, entity_types, onChange, onSubmit, onClear }: FilterBarProps) {
|
|
33
|
+
const { t } = useT();
|
|
34
|
+
const set = (patch: Partial<FilterState>) => onChange({ ...state, ...patch });
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Card className="mb-4 p-4">
|
|
38
|
+
<form
|
|
39
|
+
className="flex flex-wrap items-end gap-3"
|
|
40
|
+
onSubmit={(e) => {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
onSubmit();
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
<div className="min-w-[140px]">
|
|
46
|
+
<label htmlFor="audit-entity-type" className="block text-sm font-medium mb-1">
|
|
47
|
+
{t(keys.audit_log.filters.entity_type_label)}
|
|
48
|
+
</label>
|
|
49
|
+
<Select value={state.entityType} onValueChange={(v) => set({ entityType: v })}>
|
|
50
|
+
<SelectTrigger id="audit-entity-type" size="sm" className="w-full">
|
|
51
|
+
<SelectValue placeholder={t(keys.audit_log.filters.entity_type_all)} />
|
|
52
|
+
</SelectTrigger>
|
|
53
|
+
<SelectContent>
|
|
54
|
+
<SelectItem value={ALL}>{t(keys.audit_log.filters.entity_type_all)}</SelectItem>
|
|
55
|
+
{entity_types.map((et) => (
|
|
56
|
+
<SelectItem key={et} value={et}>
|
|
57
|
+
{et}
|
|
58
|
+
</SelectItem>
|
|
59
|
+
))}
|
|
60
|
+
</SelectContent>
|
|
61
|
+
</Select>
|
|
62
|
+
</div>
|
|
63
|
+
<div className="min-w-[130px]">
|
|
64
|
+
<label htmlFor="audit-action" className="block text-sm font-medium mb-1">
|
|
65
|
+
{t(keys.audit_log.filters.action_label)}
|
|
66
|
+
</label>
|
|
67
|
+
<Select value={state.action} onValueChange={(v) => set({ action: v })}>
|
|
68
|
+
<SelectTrigger id="audit-action" size="sm" className="w-full">
|
|
69
|
+
<SelectValue placeholder={t(keys.audit_log.filters.action_all)} />
|
|
70
|
+
</SelectTrigger>
|
|
71
|
+
<SelectContent>
|
|
72
|
+
<SelectItem value={ALL}>{t(keys.audit_log.filters.action_all)}</SelectItem>
|
|
73
|
+
{ACTIONS.map((a) => (
|
|
74
|
+
<SelectItem key={a} value={a}>
|
|
75
|
+
{t(keys.audit_log.actions[a])}
|
|
76
|
+
</SelectItem>
|
|
77
|
+
))}
|
|
78
|
+
</SelectContent>
|
|
79
|
+
</Select>
|
|
80
|
+
</div>
|
|
81
|
+
<div className="min-w-[160px]">
|
|
82
|
+
<label htmlFor="audit-user-id" className="block text-sm font-medium mb-1">
|
|
83
|
+
{t(keys.audit_log.filters.user_label)}
|
|
84
|
+
</label>
|
|
85
|
+
<Input
|
|
86
|
+
id="audit-user-id"
|
|
87
|
+
value={state.userId}
|
|
88
|
+
onChange={(e) => set({ userId: e.target.value })}
|
|
89
|
+
placeholder={t(keys.audit_log.filters.user_placeholder)}
|
|
90
|
+
className="h-8 text-sm"
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="min-w-[140px]">
|
|
94
|
+
<label htmlFor="audit-from-date" className="block text-sm font-medium mb-1">
|
|
95
|
+
{t(keys.audit_log.filters.from_date_label)}
|
|
96
|
+
</label>
|
|
97
|
+
<Input
|
|
98
|
+
id="audit-from-date"
|
|
99
|
+
type="datetime-local"
|
|
100
|
+
value={state.fromDate}
|
|
101
|
+
onChange={(e) => set({ fromDate: e.target.value })}
|
|
102
|
+
className="h-8 text-sm"
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
<div className="min-w-[140px]">
|
|
106
|
+
<label htmlFor="audit-to-date" className="block text-sm font-medium mb-1">
|
|
107
|
+
{t(keys.audit_log.filters.to_date_label)}
|
|
108
|
+
</label>
|
|
109
|
+
<Input
|
|
110
|
+
id="audit-to-date"
|
|
111
|
+
type="datetime-local"
|
|
112
|
+
value={state.toDate}
|
|
113
|
+
onChange={(e) => set({ toDate: e.target.value })}
|
|
114
|
+
className="h-8 text-sm"
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
<Button type="submit" size="sm">
|
|
118
|
+
{t(keys.audit_log.filters.apply)}
|
|
119
|
+
</Button>
|
|
120
|
+
<Button type="button" variant="ghost" size="sm" onClick={onClear}>
|
|
121
|
+
{t(keys.audit_log.filters.clear)}
|
|
122
|
+
</Button>
|
|
123
|
+
</form>
|
|
124
|
+
</Card>
|
|
125
|
+
);
|
|
126
|
+
}
|
audit_log/py.typed
ADDED
|
File without changes
|
audit_log/service.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Read-only query service for audit log entries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import func, select
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from audit_log.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
|
11
|
+
from audit_log.contracts.schemas import AuditEntryList, AuditEntryRead
|
|
12
|
+
from audit_log.models import AuditEntry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuditLogService:
|
|
16
|
+
def __init__(self, db: AsyncSession) -> None:
|
|
17
|
+
self.db = db
|
|
18
|
+
|
|
19
|
+
async def list_entries(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
entity_type: str | None = None,
|
|
23
|
+
entity_id: str | None = None,
|
|
24
|
+
action: str | None = None,
|
|
25
|
+
user_id: str | None = None,
|
|
26
|
+
from_date: datetime | None = None,
|
|
27
|
+
to_date: datetime | None = None,
|
|
28
|
+
page: int = 1,
|
|
29
|
+
page_size: int = DEFAULT_PAGE_SIZE,
|
|
30
|
+
) -> AuditEntryList:
|
|
31
|
+
page_size = min(max(page_size, 1), MAX_PAGE_SIZE)
|
|
32
|
+
page = max(page, 1)
|
|
33
|
+
|
|
34
|
+
conditions = []
|
|
35
|
+
if entity_type:
|
|
36
|
+
conditions.append(AuditEntry.entity_type == entity_type)
|
|
37
|
+
if entity_id:
|
|
38
|
+
conditions.append(AuditEntry.entity_id == entity_id)
|
|
39
|
+
if action:
|
|
40
|
+
conditions.append(AuditEntry.action == action)
|
|
41
|
+
if user_id:
|
|
42
|
+
conditions.append(AuditEntry.user_id == user_id)
|
|
43
|
+
if from_date:
|
|
44
|
+
conditions.append(AuditEntry.created_at >= from_date)
|
|
45
|
+
if to_date:
|
|
46
|
+
conditions.append(AuditEntry.created_at <= to_date)
|
|
47
|
+
|
|
48
|
+
# Select only AuditEntryRead's columns (plain rows, no ORM hydration) and
|
|
49
|
+
# count the same conditions directly — avoids hydrating full AuditEntry
|
|
50
|
+
# objects per page and the subquery wrapper around the count.
|
|
51
|
+
cols = select(
|
|
52
|
+
AuditEntry.id,
|
|
53
|
+
AuditEntry.entity_type,
|
|
54
|
+
AuditEntry.entity_id,
|
|
55
|
+
AuditEntry.action,
|
|
56
|
+
AuditEntry.changes,
|
|
57
|
+
AuditEntry.user_id,
|
|
58
|
+
AuditEntry.correlation_id,
|
|
59
|
+
AuditEntry.created_at,
|
|
60
|
+
)
|
|
61
|
+
count_stmt = select(func.count()).select_from(AuditEntry)
|
|
62
|
+
for cond in conditions:
|
|
63
|
+
cols = cols.where(cond)
|
|
64
|
+
count_stmt = count_stmt.where(cond)
|
|
65
|
+
|
|
66
|
+
total = (await self.db.execute(count_stmt)).scalar_one()
|
|
67
|
+
|
|
68
|
+
offset = (page - 1) * page_size
|
|
69
|
+
stmt = (
|
|
70
|
+
cols.order_by(AuditEntry.created_at.desc(), AuditEntry.id)
|
|
71
|
+
.offset(offset)
|
|
72
|
+
.limit(page_size)
|
|
73
|
+
)
|
|
74
|
+
rows = (await self.db.execute(stmt)).all()
|
|
75
|
+
items = [AuditEntryRead(**row._mapping) for row in rows]
|
|
76
|
+
|
|
77
|
+
return AuditEntryList(items=items, total=total, page=page, page_size=page_size)
|
|
78
|
+
|
|
79
|
+
async def distinct_entity_types(self) -> list[str]:
|
|
80
|
+
stmt = select(AuditEntry.entity_type).distinct().order_by(AuditEntry.entity_type)
|
|
81
|
+
result = await self.db.execute(stmt)
|
|
82
|
+
return list(result.scalars())
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simple_module_audit_log
|
|
3
|
+
Version: 0.0.24
|
|
4
|
+
Summary: Automatic field-level audit trail for all SQLModel entities
|
|
5
|
+
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
|
+
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
7
|
+
Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Anto Subash <antosubash@live.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: audit-log,change-tracking,simple-module
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: simple-module-core==0.0.24
|
|
25
|
+
Requires-Dist: simple-module-db==0.0.24
|
|
26
|
+
Requires-Dist: simple-module-hosting==0.0.24
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# simple_module_audit_log
|
|
30
|
+
|
|
31
|
+
Automatic field-level audit trail for all SQLModel entities in [simple_module](https://github.com/antosubash/simple_module_python) apps. Every create / update / delete / soft-delete is captured in the `audit_log_audit_entry` table with the changed fields, the user who made the change, and the request correlation id — no instrumentation required in module code.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install simple_module_audit_log
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Add `simple_module_audit_log` to your host's dependencies and the entry point will be discovered automatically.
|
|
40
|
+
|
|
41
|
+
## What it provides
|
|
42
|
+
|
|
43
|
+
- Two-phase capture (`before_flush` snapshots diffs, `after_flush_postexec` resolves DB-assigned primary keys) so integer-PK entities record the real id, not the empty string.
|
|
44
|
+
- `AuditEntry` SQLModel table with indexed `entity_type`, `entity_id`, `user_id`, `created_at` columns.
|
|
45
|
+
- `GET /api/audit_log/` REST endpoint with `entity_type`, `entity_id`, `action`, `user_id`, `from_date`, `to_date`, paging filters.
|
|
46
|
+
- `/audit_log` Inertia browse page with the same filter set rendered as a table, gated by the `audit_log.view` permission.
|
|
47
|
+
- Opt-outs: set `__audit_exclude__ = True` on a model to skip it entirely, or `__audit_exclude_fields__ = {"password_hash"}` to skip specific columns. `AuditMixin` / `SoftDeleteMixin` bookkeeping fields are excluded automatically.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
Install the module and grant the `audit_log.view` permission to your admin role. Audit entries appear automatically for every entity that does not opt out.
|
|
52
|
+
|
|
53
|
+
To exclude a sensitive column:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from typing import ClassVar
|
|
57
|
+
from sqlmodel import Field
|
|
58
|
+
from simple_module_db.base import create_module_base
|
|
59
|
+
from simple_module_db.mixins import AuditMixin
|
|
60
|
+
|
|
61
|
+
Base = create_module_base("auth")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Credential(Base, AuditMixin, table=True):
|
|
65
|
+
__audit_exclude_fields__: ClassVar[set[str]] = {"password_hash"}
|
|
66
|
+
|
|
67
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
68
|
+
user_id: str
|
|
69
|
+
password_hash: str
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
To opt a whole table out:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
class Cache(Base, table=True):
|
|
76
|
+
__audit_exclude__ = True
|
|
77
|
+
...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
audit_log/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
audit_log/capture.py,sha256=sxF4uOLQiNhSo7aU2JocHhIPhE6qwFA_R3ooJl4svKY,821
|
|
3
|
+
audit_log/constants.py,sha256=q3vGKFfn3BuLOG3d8dGloGizfWI8ByVOCG65-bOteDc,1115
|
|
4
|
+
audit_log/deps.py,sha256=bymJeX2S7Bo7hwfh8CG-A8ygd0zDj_n6iEx-yxY9wG0,494
|
|
5
|
+
audit_log/models.py,sha256=fYc0xVj86THtg60_U4fq0xgSnbLOGDK4ALFXXh01L3o,1606
|
|
6
|
+
audit_log/module.py,sha256=ol92qEKtM4CZRjQiZRKuvgA-Q1sPvHC2zxrfXdLpGuA,1984
|
|
7
|
+
audit_log/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
audit_log/service.py,sha256=pMo4Mieo_NQNOKQU8Vhkba5YBYfXJoCnU-G8XxuoxdQ,2893
|
|
9
|
+
audit_log/contracts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
audit_log/contracts/schemas.py,sha256=TPWMP2-ffjwKuyxo9HfB_wPHkBn2WyjEid350dA2NFI,581
|
|
11
|
+
audit_log/endpoints/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
audit_log/endpoints/api.py,sha256=K1gOYEa-tbr8GMfEKKOYEeTdRmhGzAJ553SfSU-rVFo,1284
|
|
13
|
+
audit_log/endpoints/views.py,sha256=rIx9-qOTlhknY984DesvanYFlM1OFBitT3b7IzB8Nm0,2550
|
|
14
|
+
audit_log/locales/en.json,sha256=gpZUFrE4Y3iJyoZ3OmG7AdqN8fgWRC1eUHjBhtIHl50,1127
|
|
15
|
+
audit_log/pages/Browse.tsx,sha256=ro5p-gza544UXVHA03_byA47cwZ3OFShTgvmf5WZrdI,8731
|
|
16
|
+
audit_log/pages/components/FilterBar.tsx,sha256=yr9WkG_E1mG-0wyVcJiPY5Wkw4rw3VYx2DtqRz6W8nc,4494
|
|
17
|
+
audit_log/package.json,sha256=vJl7D1od7Ad2txIZ2nc8bjPLOykC-BpArUaQc-xfano,381
|
|
18
|
+
simple_module_audit_log-0.0.24.dist-info/METADATA,sha256=sf_IOwLXXtzRZ58ZReWtGnN8hpWf_E7_EDLfIEOHDPk,3373
|
|
19
|
+
simple_module_audit_log-0.0.24.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
20
|
+
simple_module_audit_log-0.0.24.dist-info/entry_points.txt,sha256=2M-_3LD-RclZjaRXjVjmH2Pr6eOxMxTnf8zaCs2di-Q,60
|
|
21
|
+
simple_module_audit_log-0.0.24.dist-info/licenses/LICENSE,sha256=uM3NiXzdM3GqHSyKCZdIgOtd0CArtlKOfAmG0hvZe3M,1068
|
|
22
|
+
simple_module_audit_log-0.0.24.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Anto Subash
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|