auditrum 0.2.0__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.
Files changed (34) hide show
  1. auditrum/__init__.py +0 -0
  2. auditrum/cli.py +237 -0
  3. auditrum/context.py +131 -0
  4. auditrum/integrations/__init__.py +0 -0
  5. auditrum/integrations/django/__init__.py +0 -0
  6. auditrum/integrations/django/admin.py +34 -0
  7. auditrum/integrations/django/apps.py +23 -0
  8. auditrum/integrations/django/audit.py +27 -0
  9. auditrum/integrations/django/hooks.py +29 -0
  10. auditrum/integrations/django/management/__init__.py +0 -0
  11. auditrum/integrations/django/management/commands/__init__.py +0 -0
  12. auditrum/integrations/django/management/commands/audit_add_partitions.py +40 -0
  13. auditrum/integrations/django/middleware.py +25 -0
  14. auditrum/integrations/django/migrations/0001_initial.py +24 -0
  15. auditrum/integrations/django/migrations/__init__.py +0 -0
  16. auditrum/integrations/django/mixins.py +46 -0
  17. auditrum/integrations/django/models.py +31 -0
  18. auditrum/integrations/django/settings.py +14 -0
  19. auditrum/integrations/django/shell_context.py +3 -0
  20. auditrum/integrations/django/templates/audit/object_history.html +58 -0
  21. auditrum/integrations/django/templatetags/__init__.py +0 -0
  22. auditrum/integrations/django/templatetags/audit_tags.py +14 -0
  23. auditrum/integrations/django/utils.py +172 -0
  24. auditrum/revert.py +39 -0
  25. auditrum/schema.py +79 -0
  26. auditrum/settings.py +20 -0
  27. auditrum/triggers.py +118 -0
  28. auditrum/utils.py +0 -0
  29. auditrum-0.2.0.dist-info/METADATA +266 -0
  30. auditrum-0.2.0.dist-info/RECORD +34 -0
  31. auditrum-0.2.0.dist-info/WHEEL +5 -0
  32. auditrum-0.2.0.dist-info/entry_points.txt +2 -0
  33. auditrum-0.2.0.dist-info/licenses/LICENSE +21 -0
  34. auditrum-0.2.0.dist-info/top_level.txt +1 -0
auditrum/__init__.py ADDED
File without changes
auditrum/cli.py ADDED
@@ -0,0 +1,237 @@
1
+ from typing import Callable, Optional, Tuple
2
+
3
+ import structlog
4
+ import typer
5
+ from dotenv import load_dotenv
6
+ from psycopg import connect
7
+ from rich import print as rich_print
8
+ from settings import PgAuditSettings
9
+
10
+ from auditrum.revert import generate_revert_sql_from_log
11
+ from auditrum.schema import (
12
+ generate_auditlog_partitions_sql,
13
+ generate_auditlog_table_sql,
14
+ )
15
+ from auditrum.triggers import generate_trigger_sql
16
+
17
+ load_dotenv()
18
+
19
+ # Helper function to get DB DSN from arguments or environment
20
+ def get_db_dsn(
21
+ dsn: Optional[str] = None,
22
+ host: Optional[str] = None,
23
+ port: Optional[int] = None,
24
+ user: Optional[str] = None,
25
+ password: Optional[str] = None,
26
+ dbname: Optional[str] = None,
27
+ ) -> Optional[str]:
28
+ if dsn:
29
+ return dsn
30
+ if all([host, port, user, password, dbname]):
31
+ return f"postgresql://{user}:{password}@{host}:{port}/{dbname}"
32
+ return settings.db_dsn
33
+
34
+
35
+ app = typer.Typer()
36
+
37
+
38
+ @app.callback()
39
+ def main(
40
+ verbose: Optional[int] = typer.Option(
41
+ 1, "--verbose", "-v", help="Verbosity level (0=quiet, 1=default, 2=debug)"
42
+ ),
43
+ ):
44
+ level = structlog.INFO
45
+ if verbose == 0:
46
+ level = structlog.NOTSET
47
+ elif verbose == 2:
48
+ level = structlog.DEBUG
49
+
50
+ structlog.configure(
51
+ wrapper_class=structlog.make_filtering_bound_logger(level),
52
+ processors=[
53
+ structlog.processors.TimeStamper(fmt="iso"),
54
+ structlog.dev.ConsoleRenderer(),
55
+ ],
56
+ )
57
+
58
+
59
+ log = structlog.get_logger()
60
+
61
+ settings = PgAuditSettings()
62
+
63
+
64
+ @app.command()
65
+ def generate_trigger(
66
+ table: str,
67
+ audit_table: str = settings.audit_table,
68
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print SQL instead of executing"),
69
+ output: Optional[str] = typer.Option(
70
+ None, "--output", "-o", help="Write SQL to file instead of printing"
71
+ ),
72
+ dsn: Optional[str] = typer.Option(None, "--db-dsn"),
73
+ host: Optional[str] = typer.Option(None, "--db-host"),
74
+ port: Optional[int] = typer.Option(None, "--db-port"),
75
+ user: Optional[str] = typer.Option(None, "--db-user"),
76
+ password: Optional[str] = typer.Option(None, "--db-password"),
77
+ dbname: Optional[str] = typer.Option(None, "--db-name"),
78
+ ):
79
+ log.info("Generating trigger SQL")
80
+ sql = generate_trigger_sql(table_name=table, audit_table=audit_table)
81
+ db_dsn = get_db_dsn(dsn, host, port, user, password, dbname)
82
+ execute_or_print_sql(lambda conn: sql, (), dry_run, output, db_dsn=db_dsn)
83
+
84
+
85
+ @app.command()
86
+ def init_schema(
87
+ audit_table: str = settings.audit_table,
88
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print SQL instead of executing"),
89
+ output: Optional[str] = typer.Option(
90
+ None, "--output", "-o", help="Write SQL to file instead of printing"
91
+ ),
92
+ dsn: Optional[str] = typer.Option(None, "--db-dsn"),
93
+ host: Optional[str] = typer.Option(None, "--db-host"),
94
+ port: Optional[int] = typer.Option(None, "--db-port"),
95
+ user: Optional[str] = typer.Option(None, "--db-user"),
96
+ password: Optional[str] = typer.Option(None, "--db-password"),
97
+ dbname: Optional[str] = typer.Option(None, "--db-name"),
98
+ ):
99
+ log.info("Generating auditlog schema SQL")
100
+ sql = generate_auditlog_table_sql(audit_table)
101
+ db_dsn = get_db_dsn(dsn, host, port, user, password, dbname)
102
+ execute_or_print_sql(lambda conn: sql, (), dry_run, output, db_dsn=db_dsn)
103
+
104
+
105
+ @app.command()
106
+ def create_partitions(
107
+ audit_table: str = settings.audit_table,
108
+ months: int = 3,
109
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print SQL instead of executing"),
110
+ output: Optional[str] = typer.Option(
111
+ None, "--output", "-o", help="Write SQL to file instead of printing"
112
+ ),
113
+ dsn: Optional[str] = typer.Option(None, "--db-dsn"),
114
+ host: Optional[str] = typer.Option(None, "--db-host"),
115
+ port: Optional[int] = typer.Option(None, "--db-port"),
116
+ user: Optional[str] = typer.Option(None, "--db-user"),
117
+ password: Optional[str] = typer.Option(None, "--db-password"),
118
+ dbname: Optional[str] = typer.Option(None, "--db-name"),
119
+ ):
120
+ log.info("Generating partition SQL", months=months)
121
+ sql = generate_auditlog_partitions_sql(audit_table, months)
122
+ db_dsn = get_db_dsn(dsn, host, port, user, password, dbname)
123
+ execute_or_print_sql(lambda conn: sql, (), dry_run, output, db_dsn=db_dsn)
124
+
125
+
126
+ @app.command()
127
+ def revert(
128
+ table: str,
129
+ record_id: str,
130
+ log_id: int,
131
+ audit_table: str = settings.audit_table,
132
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print SQL instead of executing"),
133
+ output: Optional[str] = typer.Option(
134
+ None, "--output", "-o", help="Write SQL to file instead of printing"
135
+ ),
136
+ dsn: Optional[str] = typer.Option(None, "--db-dsn"),
137
+ host: Optional[str] = typer.Option(None, "--db-host"),
138
+ port: Optional[int] = typer.Option(None, "--db-port"),
139
+ user: Optional[str] = typer.Option(None, "--db-user"),
140
+ password: Optional[str] = typer.Option(None, "--db-password"),
141
+ dbname: Optional[str] = typer.Option(None, "--db-name"),
142
+ ):
143
+ log.info("Generating revert SQL", table=table, record_id=record_id, log_id=log_id)
144
+ db_dsn = get_db_dsn(dsn, host, port, user, password, dbname)
145
+ execute_or_print_sql(
146
+ sql_fn=generate_revert_sql_from_log,
147
+ sql_args=(audit_table, table, record_id, log_id),
148
+ dry_run=dry_run,
149
+ output=output,
150
+ db_dsn=db_dsn,
151
+ )
152
+
153
+
154
+ @app.command()
155
+ def status(
156
+ audit_table: str = settings.audit_table,
157
+ dsn: Optional[str] = typer.Option(None, "--db-dsn"),
158
+ host: Optional[str] = typer.Option(None, "--db-host"),
159
+ port: Optional[int] = typer.Option(None, "--db-port"),
160
+ user: Optional[str] = typer.Option(None, "--db-user"),
161
+ password: Optional[str] = typer.Option(None, "--db-password"),
162
+ dbname: Optional[str] = typer.Option(None, "--db-name"),
163
+ ):
164
+ log.info("Checking audit trigger and table status")
165
+
166
+ db_dsn = get_db_dsn(dsn, host, port, user, password, dbname)
167
+
168
+ try:
169
+ with connect(dsn=db_dsn) as conn, conn.cursor() as cur:
170
+ # Check triggers
171
+ cur.execute("""
172
+ SELECT event_object_table AS "table", trigger_name
173
+ FROM information_schema.triggers
174
+ WHERE action_statement ILIKE '%audit_trigger_fn%'
175
+ """)
176
+ triggers = cur.fetchall()
177
+
178
+ # Check partitions
179
+ cur.execute(f"""
180
+ SELECT inhrelid::regclass AS partition
181
+ FROM pg_inherits
182
+ WHERE inhparent = '{audit_table}'::regclass
183
+ """)
184
+ partitions = cur.fetchall()
185
+
186
+ # Check auditlog size and row count
187
+ cur.execute(f"SELECT COUNT(*) FROM {audit_table}")
188
+ row_count = cur.fetchone()[0]
189
+
190
+ log.info("Audit triggers found", count=len(triggers))
191
+ for table, trig in triggers:
192
+ rich_print(f"[yellow]- {table}: {trig}[/yellow]")
193
+
194
+ log.info("Auditlog partitions", count=len(partitions))
195
+ for p in partitions:
196
+ rich_print(f"[blue]- {p[0]}[/blue]")
197
+
198
+ log.info("Auditlog row count", rows=row_count)
199
+
200
+ except Exception as e:
201
+ log.error("Failed to check audit status", error=str(e))
202
+
203
+
204
+ def display_sql(sql: str, output: Optional[str] = None):
205
+ if output:
206
+ with open(output, "w") as f:
207
+ f.write(sql + "\n")
208
+ log.info("SQL written to file", path=output)
209
+ else:
210
+ rich_print(f"[bold green]{sql}[/bold green]")
211
+
212
+
213
+ def execute_or_print_sql(
214
+ sql_fn: Callable[..., str],
215
+ sql_args: Tuple,
216
+ dry_run: bool,
217
+ output: Optional[str] = None,
218
+ db_dsn: Optional[str] = None,
219
+ ):
220
+ try:
221
+ with connect(dsn=db_dsn) as conn:
222
+ sql = sql_fn(conn, *sql_args)
223
+
224
+ if dry_run:
225
+ display_sql(sql, output)
226
+ return
227
+
228
+ with conn.cursor() as cur:
229
+ cur.execute(sql)
230
+ conn.commit()
231
+ log.info("SQL successfully executed")
232
+ except Exception as e:
233
+ log.error("Failed to execute SQL", error=str(e))
234
+
235
+
236
+ if __name__ == "__main__":
237
+ app()
auditrum/context.py ADDED
@@ -0,0 +1,131 @@
1
+ from contextlib import contextmanager
2
+ from contextvars import ContextVar
3
+ from functools import wraps
4
+ from typing import Any, Callable, Dict
5
+
6
+
7
+ class AuditContext:
8
+ def __init__(self):
9
+ self._data: ContextVar[Dict[str, Any]] = ContextVar("audit_ctx_data")
10
+ self._reason_stack: ContextVar[list[str]] = ContextVar("audit_ctx_reason")
11
+
12
+ def _ensure_data(self) -> dict:
13
+ try:
14
+ return self._data.get()
15
+ except LookupError:
16
+ value = {}
17
+ self._data.set(value)
18
+ return value
19
+
20
+ def _ensure_reason_stack(self) -> list[str]:
21
+ try:
22
+ return self._reason_stack.get()
23
+ except LookupError:
24
+ value = []
25
+ self._reason_stack.set(value)
26
+ return value
27
+
28
+ def set(self, key: str, value: Any):
29
+ ctx = self._ensure_data().copy()
30
+ ctx[key] = value
31
+ self._data.set(ctx)
32
+
33
+ def get(self, key: str) -> Any:
34
+ return self._ensure_data().get(key)
35
+
36
+ def push_change_reason(self, reason: str):
37
+ stack = self._ensure_reason_stack().copy()
38
+ stack.append(reason)
39
+ self._reason_stack.set(stack)
40
+
41
+ def get_change_reason(self) -> str:
42
+ return " -> ".join(self._ensure_reason_stack())
43
+
44
+ def build_sql(self) -> str:
45
+ ctx = self._ensure_data().copy()
46
+ reason = self.get_change_reason()
47
+ if reason:
48
+ ctx["change_reason"] = reason
49
+
50
+ lines = []
51
+ for k, v in ctx.items():
52
+ key = f"session.myapp_{k}"
53
+ val = str(v).replace("'", "''")
54
+ lines.append(f"SET {key} = '{val}';")
55
+ return "\n".join(lines)
56
+
57
+ @contextmanager
58
+ def use(self, reset: bool = False, **kwargs):
59
+ from django.db import connection
60
+
61
+ original_data = self._ensure_data().copy()
62
+ ctx = original_data.copy()
63
+ ctx.update(kwargs)
64
+ self._data.set(ctx)
65
+
66
+ # SET session variables
67
+ sql = self.build_sql()
68
+ with connection.cursor() as cursor:
69
+ for stmt in sql.strip().split(";"):
70
+ if stmt.strip():
71
+ cursor.execute(stmt)
72
+
73
+ try:
74
+ yield
75
+ finally:
76
+ self._data.set(original_data)
77
+ if reset:
78
+ with connection.cursor() as cursor:
79
+ for k in ctx.keys():
80
+ cursor.execute(f"RESET session.myapp_{k}")
81
+
82
+ @contextmanager
83
+ def use_change_reason(self, reason: str, reset: bool = False):
84
+ from django.db import connection
85
+
86
+ original_stack = self._ensure_reason_stack().copy()
87
+ stack = original_stack.copy()
88
+ stack.append(reason)
89
+ self._reason_stack.set(stack)
90
+
91
+ # SET session.myapp_change_reason
92
+ sql = self.build_sql()
93
+ with connection.cursor() as cursor:
94
+ for stmt in sql.strip().split(";"):
95
+ if stmt.strip():
96
+ cursor.execute(stmt)
97
+
98
+ try:
99
+ yield
100
+ finally:
101
+ self._reason_stack.set(original_stack)
102
+ if reset:
103
+ with connection.cursor() as cursor:
104
+ cursor.execute("RESET session.myapp_change_reason")
105
+
106
+
107
+ audit_context = AuditContext()
108
+
109
+
110
+ def with_context(**kwargs):
111
+ def decorator(func: Callable):
112
+ @wraps(func)
113
+ def wrapper(*args, **fkwargs):
114
+ with audit_context.use(**kwargs):
115
+ return func(*args, **fkwargs)
116
+
117
+ return wrapper
118
+
119
+ return decorator
120
+
121
+
122
+ def with_change_reason(reason: str):
123
+ def decorator(func: Callable):
124
+ @wraps(func)
125
+ def wrapper(*args, **kwargs):
126
+ with audit_context.use_change_reason(reason):
127
+ return func(*args, **kwargs)
128
+
129
+ return wrapper
130
+
131
+ return decorator
File without changes
File without changes
@@ -0,0 +1,34 @@
1
+ from django.contrib import admin
2
+ from django.utils.html import format_html
3
+
4
+ from auditrum.integrations.django.models import AuditLog
5
+
6
+
7
+ @admin.register(AuditLog)
8
+ class AuditLogAdmin(admin.ModelAdmin):
9
+ list_display = (
10
+ "changed_at",
11
+ "operation",
12
+ "table_name",
13
+ "object_id",
14
+ "user_id",
15
+ "source",
16
+ "request_id",
17
+ "linked_object",
18
+ )
19
+ list_filter = ("operation", "table_name", "source")
20
+ search_fields = ("object_id", "request_id", "change_reason")
21
+ readonly_fields = [f.name for f in AuditLog._meta.fields]
22
+ ordering = ("-changed_at",)
23
+
24
+ def linked_object(self, obj):
25
+ if obj.content_object:
26
+ return format_html(
27
+ '<a href="{}">{}</a>',
28
+ obj.content_object.get_absolute_url(),
29
+ obj.content_object,
30
+ )
31
+ return "-"
32
+
33
+ linked_object.allow_tags = True
34
+ linked_object.short_description = "Linked Object"
@@ -0,0 +1,23 @@
1
+ from django.apps import AppConfig
2
+ from django.db.models.signals import post_migrate
3
+
4
+
5
+ class PgAuditIntegrationConfig(AppConfig):
6
+ default_auto_field = "django.db.models.BigAutoField"
7
+ name = "auditrum.integrations.django"
8
+ label = "auditrum_django"
9
+ verbose_name = "PostgreSQL Audit (auditrum)"
10
+
11
+ def ready(self):
12
+ from .hooks import register_post_migrate_hook
13
+ from django.apps import apps
14
+ from importlib import import_module
15
+
16
+ post_migrate.connect(register_post_migrate_hook, sender=self)
17
+
18
+ # Automatically discover and import audit.py from all installed apps
19
+ for app_config in apps.get_app_configs():
20
+ try:
21
+ import_module(f"{app_config.name}.audit")
22
+ except ModuleNotFoundError:
23
+ continue
@@ -0,0 +1,27 @@
1
+ from typing import Any, Callable, Dict, Type
2
+
3
+ from django.db.models import Model
4
+
5
+ registry: Dict[Type[Model], Dict[str, Any]] = {}
6
+
7
+
8
+ def register(
9
+ model_cls: Type[Model],
10
+ *,
11
+ track_only=None,
12
+ exclude_fields=None,
13
+ log_conditions=None,
14
+ meta_fields=None
15
+ ) -> None:
16
+ if track_only and exclude_fields:
17
+ raise ValueError(
18
+ f"Cannot define both `track_only` and `exclude_fields` on model {model_cls.__name__}"
19
+ )
20
+ registry[model_cls] = {
21
+ "table_name": model_cls._meta.db_table,
22
+ "fields": [f.name for f in model_cls._meta.get_fields() if f.concrete],
23
+ "track_only": track_only,
24
+ "exclude_fields": exclude_fields,
25
+ "log_conditions": log_conditions,
26
+ "meta_fields": meta_fields,
27
+ }
@@ -0,0 +1,29 @@
1
+ import structlog
2
+ from django.db import connection
3
+
4
+ from auditrum.integrations.django.audit import registry
5
+ from auditrum.integrations.django.settings import audit_settings
6
+ from auditrum.triggers import generate_trigger_sql
7
+
8
+ log = structlog.get_logger()
9
+
10
+
11
+ def register_post_migrate_hook(sender, **kwargs):
12
+ with connection.cursor() as cursor:
13
+ for _model_cls, config in registry.items():
14
+ table_name = config["table_name"]
15
+ track_only = config["track_only"]
16
+ exclude_fields = config["exclude_fields"]
17
+ log_conditions = config["log_conditions"]
18
+ meta_fields = config["meta_fields"]
19
+
20
+ sql = generate_trigger_sql(
21
+ table_name=table_name,
22
+ track_only=track_only,
23
+ exclude_fields=exclude_fields,
24
+ log_conditions=log_conditions,
25
+ meta_fields=meta_fields,
26
+ audit_table=audit_settings.table_name,
27
+ )
28
+ log.info("Applying audit trigger", table=table_name)
29
+ cursor.execute(sql)
File without changes
@@ -0,0 +1,40 @@
1
+
2
+
3
+ import calendar
4
+ from datetime import date
5
+ from django.core.management.base import BaseCommand
6
+ from django.conf import settings
7
+ from auditrum.schema import generate_auditlog_partitions_sql
8
+ import psycopg
9
+
10
+
11
+ class Command(BaseCommand):
12
+ help = "Create partitions for the auditlog table N months ahead"
13
+
14
+ def add_arguments(self, parser):
15
+ parser.add_argument(
16
+ "--months",
17
+ type=int,
18
+ default=1,
19
+ help="Number of months ahead to create partitions for (default: 1)",
20
+ )
21
+
22
+ def handle(self, *args, **options):
23
+ months = options["months"]
24
+ sql = generate_auditlog_partitions_sql(months_ahead=months)
25
+ self.stdout.write(f"Executing SQL to create partitions for {months} month(s)...")
26
+
27
+ from psycopg.conninfo import make_conninfo
28
+
29
+ db_config = settings.DATABASES["default"]
30
+ db_dsn = make_conninfo(
31
+ host=db_config.get("HOST") or "localhost",
32
+ port=db_config.get("PORT") or "5432",
33
+ dbname=db_config["NAME"],
34
+ user=db_config["USER"],
35
+ password=db_config["PASSWORD"],
36
+ )
37
+
38
+ with psycopg.connect(db_dsn) as conn, conn.cursor() as cur:
39
+ cur.execute(sql)
40
+ self.stdout.write(self.style.SUCCESS("Partitions created successfully."))
@@ -0,0 +1,25 @@
1
+ import uuid
2
+
3
+ from django.utils.deprecation import MiddlewareMixin
4
+
5
+ from auditrum.context import audit_context as ctx
6
+
7
+
8
+ class RequestIDMiddleware(MiddlewareMixin):
9
+ def process_request(self, request):
10
+ request.request_id = str(uuid.uuid4())
11
+ ctx.set("request_id", request.request_id)
12
+
13
+
14
+ class AuditrumMiddleware(MiddlewareMixin):
15
+ def process_request(self, request):
16
+ user = getattr(request, "user", None)
17
+ session = getattr(request, "session", None)
18
+
19
+ ctx.set("user_id", getattr(user, "id", None))
20
+ ctx.set("username", getattr(user, "username", None))
21
+ ctx.set("client_ip", request.META.get("REMOTE_ADDR"))
22
+ ctx.set("user_agent", request.META.get("HTTP_USER_AGENT"))
23
+ ctx.set("session_key", getattr(session, "session_key", None))
24
+ ctx.set("request_id", str(uuid.uuid4()))
25
+ ctx.set("source", "http")
@@ -0,0 +1,24 @@
1
+ from django.db import migrations
2
+
3
+ from auditrum.integrations.django.settings import audit_settings
4
+ from auditrum.schema import (
5
+ generate_auditlog_table_sql,
6
+ generate_auditlog_partitions_sql,
7
+ generate_jsonb_diff_function_sql,
8
+ )
9
+
10
+
11
+ class Migration(migrations.Migration):
12
+ initial = True
13
+ dependencies = []
14
+
15
+ operations = [
16
+ migrations.RunSQL(
17
+ sql="\n\n".join([
18
+ generate_auditlog_table_sql(audit_settings.table_name),
19
+ generate_jsonb_diff_function_sql(),
20
+ generate_auditlog_partitions_sql(audit_settings.table_name, months_ahead=3),
21
+ ]),
22
+ reverse_sql=f"DROP TABLE IF EXISTS {audit_settings.table_name} CASCADE;",
23
+ ),
24
+ ]
File without changes
@@ -0,0 +1,46 @@
1
+ from apps.audit.models import AuditLog
2
+ from django.contrib.contenttypes.models import ContentType
3
+ from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
4
+ from django.shortcuts import render
5
+ from django.urls import path
6
+
7
+
8
+ class AuditHistoryMixin:
9
+ def get_urls(self):
10
+ urls = super().get_urls()
11
+ custom_urls = [
12
+ path(
13
+ "<path:object_id>/history/",
14
+ self.admin_site.admin_view(self.object_history_view),
15
+ name=f"{self.model._meta.app_label}_{self.model._meta.model_name}_audit_history",
16
+ ),
17
+ ]
18
+ return custom_urls + urls
19
+
20
+ def object_history_view(self, request, object_id):
21
+ obj = self.get_object(request, object_id)
22
+ content_type = ContentType.objects.get_for_model(self.model)
23
+ logs = AuditLog.objects.filter(
24
+ content_type=content_type, object_id=str(object_id)
25
+ ).order_by("-changed_at")
26
+
27
+ paginator = Paginator(logs, 20)
28
+ page_number = request.GET.get("page", 1)
29
+
30
+ try:
31
+ page_obj = paginator.page(page_number)
32
+ except PageNotAnInteger:
33
+ page_obj = paginator.page(1)
34
+ except EmptyPage:
35
+ page_obj = paginator.page(paginator.num_pages)
36
+
37
+ context = dict(
38
+ self.admin_site.each_context(request),
39
+ title=f"History: {obj}",
40
+ object=obj,
41
+ opts=self.model._meta,
42
+ audit_logs=page_obj.object_list,
43
+ page_obj=page_obj,
44
+ is_paginated=True,
45
+ )
46
+ return render(request, "audit/object_history.html", context)
@@ -0,0 +1,31 @@
1
+ from django.contrib.contenttypes.fields import GenericForeignKey
2
+ from django.contrib.contenttypes.models import ContentType
3
+ from django.db import models
4
+
5
+ from auditrum.integrations.django.settings import audit_settings
6
+
7
+
8
+ class AuditLog(models.Model):
9
+ operation = models.CharField(max_length=16)
10
+ changed_at = models.DateTimeField(auto_now_add=True)
11
+ content_type = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.SET_NULL)
12
+ object_id = models.CharField(max_length=255)
13
+ content_object = GenericForeignKey("content_type", "object_id")
14
+ table_name = models.CharField(max_length=255)
15
+ user_id = models.IntegerField(null=True, blank=True)
16
+ old_data = models.JSONField(null=True, blank=True)
17
+ new_data = models.JSONField(null=True, blank=True)
18
+ diff = models.JSONField(null=True, blank=True)
19
+ meta = models.JSONField(null=True, blank=True)
20
+ request_id = models.CharField(max_length=255, null=True, blank=True)
21
+ change_reason = models.TextField(null=True, blank=True)
22
+ source = models.CharField(max_length=255, null=True, blank=True)
23
+
24
+ class Meta:
25
+ db_table = audit_settings.table_name
26
+ verbose_name = "Audit Log"
27
+ verbose_name_plural = "Audit Logs"
28
+ managed = False
29
+
30
+ def __str__(self):
31
+ return f"{self.operation} on {self.table_name} ({self.object_id})"
@@ -0,0 +1,14 @@
1
+ from django.conf import settings
2
+
3
+
4
+ class AuditSettings:
5
+ @property
6
+ def table_name(self) -> str:
7
+ return getattr(settings, "PGAUDIT_TABLE_NAME", "auditlog")
8
+
9
+ @property
10
+ def enabled(self) -> bool:
11
+ return getattr(settings, "PGAUDIT_ENABLED", True)
12
+
13
+
14
+ audit_settings = AuditSettings()