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 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 ?? '""')}&rarr;{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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [simple_module]
2
+ audit_log = audit_log.module:AuditLogModule
@@ -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.