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
@@ -0,0 +1,3 @@
1
+ from .utils import audit_tracked
2
+
3
+ audit_tracked(source="shell").__enter__()
@@ -0,0 +1,58 @@
1
+
2
+
3
+ {% extends "admin/base_site.html" %}
4
+ {% load i18n static %}
5
+
6
+ {% block content %}
7
+ <div class="app-auditlog module">
8
+ <h1>{{ title }}</h1>
9
+
10
+ {% if is_paginated %}
11
+ <div class="paginator">
12
+ {% if page_obj.has_previous %}
13
+ <a href="?page={{ page_obj.previous_page_number }}">&laquo; Previous</a>
14
+ {% endif %}
15
+ <span class="current">
16
+ Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
17
+ </span>
18
+ {% if page_obj.has_next %}
19
+ <a href="?page={{ page_obj.next_page_number }}">Next &raquo;</a>
20
+ {% endif %}
21
+ </div>
22
+ {% endif %}
23
+
24
+ <table class="admin-auditlog-table" style="width: 100%; border-collapse: collapse;">
25
+ <thead>
26
+ <tr>
27
+ <th>Changed At</th>
28
+ <th>Operation</th>
29
+ <th>User</th>
30
+ <th>Source</th>
31
+ <th>Reason</th>
32
+ <th>Diff</th>
33
+ </tr>
34
+ </thead>
35
+ <tbody>
36
+ {% for log in audit_logs %}
37
+ <tr>
38
+ <td>{{ log.changed_at }}</td>
39
+ <td>{{ log.operation }}</td>
40
+ <td>{{ log.user_id }}</td>
41
+ <td>{{ log.source }}</td>
42
+ <td>{{ log.change_reason|default:"–" }}</td>
43
+ <td>
44
+ <details>
45
+ <summary>View</summary>
46
+ <pre style="white-space: pre-wrap;">{{ log.diff|default:"–" }}</pre>
47
+ </details>
48
+ </td>
49
+ </tr>
50
+ {% empty %}
51
+ <tr>
52
+ <td colspan="6">No audit records found.</td>
53
+ </tr>
54
+ {% endfor %}
55
+ </tbody>
56
+ </table>
57
+ </div>
58
+ {% endblock %}
File without changes
@@ -0,0 +1,14 @@
1
+ from django import template
2
+
3
+ register = template.Library()
4
+
5
+
6
+ @register.simple_tag
7
+ def render_diff(diff: dict) -> str:
8
+ if not diff:
9
+ return "-"
10
+ lines = []
11
+ for field, values in diff.items():
12
+ old, new = values
13
+ lines.append(f"{field}: {old} → {new}")
14
+ return "\n".join(lines)
@@ -0,0 +1,172 @@
1
+ from contextlib import contextmanager
2
+
3
+ from django.contrib.admin.templatetags.admin_urls import admin_urlname
4
+ from django.db import models, connection
5
+ from django.shortcuts import resolve_url
6
+ from django.utils.formats import date_format
7
+ from django.utils.html import format_html
8
+ from django.utils.safestring import mark_safe
9
+ from django.utils import timezone
10
+
11
+
12
+ def link(href: str, text: str) -> str:
13
+ return format_html('<a href="{}">{}</a>', href, text)
14
+
15
+
16
+ def link_to_related_object(obj: models.Model, name: str = None) -> str:
17
+ url = resolve_url(admin_urlname(obj._meta, "change"), obj.pk)
18
+ return link(url, name or str(obj))
19
+
20
+
21
+ def resolve_field_value(model_class, field_name, value):
22
+ try:
23
+ field = model_class._meta.get_field(field_name)
24
+ label = field.verbose_name.title()
25
+
26
+ # Handle choices
27
+ if field.choices and value is not None:
28
+ value = dict(field.choices).get(value, value)
29
+
30
+ # ForeignKey → str(obj)
31
+ elif isinstance(field, models.ForeignKey) and value:
32
+ try:
33
+ value = str(field.remote_field.model.objects.get(pk=value))
34
+ except field.remote_field.model.DoesNotExist:
35
+ value = f"[{value}]"
36
+
37
+ # Date / Time fields
38
+ elif isinstance(field, (models.DateTimeField, models.DateField, models.TimeField)) and value:
39
+ try:
40
+ parsed = value
41
+ if isinstance(value, str):
42
+ parsed = timezone.datetime.fromisoformat(value)
43
+ if isinstance(field, models.DateTimeField):
44
+ value = date_format(parsed, format="DATETIME_FORMAT")
45
+ elif isinstance(field, models.DateField):
46
+ value = date_format(parsed, format="DATE_FORMAT")
47
+ elif isinstance(field, models.TimeField):
48
+ value = date_format(parsed, format="TIME_FORMAT")
49
+ except Exception:
50
+ pass
51
+
52
+ except Exception:
53
+ label = field_name
54
+ return label, value or "—"
55
+
56
+
57
+ def get_user_display(log):
58
+ return getattr(log.user, "username", None) or log.meta.get("username") or "System"
59
+
60
+
61
+ def render_log_changes(log):
62
+ if log.content_type is None:
63
+ return "—"
64
+
65
+ model_class = log.content_type.model_class()
66
+
67
+ if log.operation == "INSERT" and log.new_data:
68
+ return format_html(
69
+ "<ul class='space-y-1'>{}</ul>",
70
+ mark_safe(
71
+ "".join(
72
+ format_html("<li><strong>{}</strong>: {}</li>", *resolve_field_value(model_class, k, v))
73
+ for k, v in log.new_data.items()
74
+ )
75
+ ),
76
+ )
77
+
78
+ elif log.operation == "DELETE" and log.old_data:
79
+ return format_html(
80
+ "<ul class='space-y-1 text-red-700'>{}</ul>",
81
+ mark_safe(
82
+ "".join(
83
+ format_html(
84
+ "<li><strong>{}</strong>: <span class='line-through'>{}</span></li>",
85
+ *resolve_field_value(model_class, k, v),
86
+ )
87
+ for k, v in log.old_data.items()
88
+ )
89
+ ),
90
+ )
91
+
92
+ elif log.old_data and log.new_data:
93
+ diffs = []
94
+ for key in sorted(set(log.old_data) | set(log.new_data)):
95
+ old_val = log.old_data.get(key)
96
+ new_val = log.new_data.get(key)
97
+ if old_val != new_val:
98
+ label, old_str = resolve_field_value(model_class, key, old_val)
99
+ _, new_str = resolve_field_value(model_class, key, new_val)
100
+ diffs.append(
101
+ format_html(
102
+ "<li><strong>{}</strong>: <span class='line-through text-red-600'>{}</span> <span class='text-gray-400 px-1'>→</span> <span class='text-green-700 font-medium'>{}</span></li>",
103
+ label,
104
+ old_str,
105
+ new_str,
106
+ )
107
+ )
108
+ return (
109
+ format_html("<ul class='space-y-1'>{}</ul>", mark_safe("".join(diffs)))
110
+ if diffs
111
+ else mark_safe("<em class='text-gray-500'>No changes</em>")
112
+ )
113
+
114
+ return mark_safe("<em class='text-gray-500'>No changes</em>")
115
+
116
+
117
+ def get_changed_object(log):
118
+ if log.content_object:
119
+ original_model_name = log.content_object.__class__.__name__
120
+ if hasattr(log.content_object, "auction") and original_model_name.lower() in ("lot", "bid", "invoice", "sale"):
121
+ auction = log.content_object.auction
122
+ if not isinstance(auction, str):
123
+ auction = auction.id
124
+
125
+ model_name = f"{auction.capitalize()}{original_model_name}"
126
+ proxy_model = apps.get_model(auction, model_name)
127
+ instance = proxy_model.objects.get(id=log.object_id)
128
+ else:
129
+ instance = log.content_object
130
+
131
+ try:
132
+ return link_to_related_object(instance, str(log.content_object))
133
+ except Exception:
134
+ return str(log.content_object)
135
+ return "-"
136
+
137
+
138
+ def set_var(cursor, key, value):
139
+ if value is not None:
140
+ cursor.execute(f"SET {key} = %s", [str(value)])
141
+
142
+
143
+ # def set_audit_context():
144
+ # cursor = connection.cursor()
145
+ # ctx = get_audit_context_data()
146
+ # reason = get_change_reason()
147
+ # if reason:
148
+ # ctx["change_reason"] = reason
149
+ # for key, value in ctx.items():
150
+ # set_var(cursor, f"session.myapp_{key}", value)
151
+
152
+
153
+ @contextmanager
154
+ def audit_tracked(**kwargs):
155
+ """
156
+ A context manager to set session-local audit context for manual or automated actions.
157
+ Useful for management commands, cron jobs, etc.
158
+
159
+ Example:
160
+ with audit_tracked(change_reason="Sync job", source="cron"):
161
+ ...
162
+ """
163
+ from auditrum.context import audit_context
164
+
165
+ with audit_context.use(**kwargs), audit_context.use_change_reason(kwargs.get("change_reason", "system action")):
166
+ from django.db import connection
167
+ sql = audit_context.build_sql()
168
+ with connection.cursor() as cursor:
169
+ for stmt in sql.strip().split(";"):
170
+ if stmt.strip():
171
+ cursor.execute(stmt)
172
+ yield
auditrum/revert.py ADDED
@@ -0,0 +1,39 @@
1
+ from typing import List
2
+
3
+
4
+ def get_revert_columns_from_log(conn, audit_table: str, log_id: int) -> List[str]:
5
+ with conn.cursor() as cur:
6
+ cur.execute(
7
+ f"""
8
+ SELECT jsonb_object_keys(old_data)
9
+ FROM {audit_table}
10
+ WHERE id = %s
11
+ """,
12
+ (log_id,),
13
+ )
14
+ keys = cur.fetchall()
15
+ return [row[0] for row in keys]
16
+
17
+
18
+ def generate_revert_sql(
19
+ audit_table: str, table_name: str, record_id: str, log_id: int, columns: List[str]
20
+ ) -> str:
21
+ return f"""
22
+ WITH audit_entry AS (
23
+ SELECT old_data
24
+ FROM {audit_table}
25
+ WHERE id = {log_id}
26
+ AND table_name = '{table_name}'
27
+ )
28
+ UPDATE {table_name}
29
+ SET {", ".join([f"{col} = audit_entry.old_data->>'{col}'" for col in columns])}
30
+ FROM audit_entry
31
+ WHERE {table_name}.id = '{record_id}';
32
+ """.strip()
33
+
34
+
35
+ def generate_revert_sql_from_log(
36
+ conn, audit_table: str, table_name: str, record_id: str, log_id: int
37
+ ) -> str:
38
+ columns = get_revert_columns_from_log(conn, audit_table, log_id)
39
+ return generate_revert_sql(audit_table, table_name, record_id, log_id, columns)
auditrum/schema.py ADDED
@@ -0,0 +1,79 @@
1
+ from datetime import UTC, datetime
2
+
3
+ from dateutil.relativedelta import relativedelta
4
+
5
+
6
+ def generate_auditlog_table_sql(table_name: str = "auditlog") -> str:
7
+ return f"""
8
+ CREATE TABLE IF NOT EXISTS {table_name} (
9
+ id serial,
10
+ operation text NOT NULL,
11
+ changed_at timestamp with time zone NOT NULL DEFAULT now(),
12
+ content_type_id integer,
13
+ object_id text,
14
+ table_name text NOT NULL,
15
+ user_id integer,
16
+ old_data jsonb,
17
+ new_data jsonb,
18
+ diff jsonb,
19
+ meta jsonb,
20
+ request_id text,
21
+ change_reason text,
22
+ source text
23
+ ) PARTITION BY RANGE (changed_at);
24
+
25
+ CREATE INDEX IF NOT EXISTS {table_name}_id_idx ON {table_name} (id);
26
+
27
+ -- Initial partition
28
+ CREATE TABLE IF NOT EXISTS {table_name}_p0
29
+ PARTITION OF {table_name}
30
+ FOR VALUES FROM ('2000-01-01') TO ('2025-01-01');
31
+
32
+ -- Indexes
33
+ CREATE INDEX IF NOT EXISTS {table_name}_table_name_idx ON {table_name} (table_name);
34
+ CREATE INDEX IF NOT EXISTS {table_name}_user_id_idx ON {table_name} (user_id);
35
+ CREATE INDEX IF NOT EXISTS {table_name}_changed_at_idx ON {table_name} (changed_at);
36
+ CREATE INDEX IF NOT EXISTS {table_name}_object_id_idx ON {table_name} (object_id);
37
+ CREATE INDEX IF NOT EXISTS {table_name}_request_id_idx ON {table_name} (request_id);
38
+ CREATE INDEX IF NOT EXISTS {table_name}_source_idx ON {table_name} (source);
39
+ CREATE INDEX IF NOT EXISTS {table_name}_meta_gin_idx ON {table_name} USING GIN (meta);
40
+ CREATE INDEX IF NOT EXISTS {table_name}_diff_gin_idx ON {table_name} USING GIN (diff);
41
+ """.strip()
42
+
43
+
44
+ def generate_auditlog_partitions_sql(table_name: str = "auditlog", months_ahead: int = 3) -> str:
45
+ now = datetime.now(UTC).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
46
+ parts = []
47
+
48
+ for i in range(months_ahead):
49
+ start = now + relativedelta(months=i)
50
+ end = start + relativedelta(months=1)
51
+ start_str = start.strftime("%Y-%m-%d")
52
+ end_str = end.strftime("%Y-%m-%d")
53
+ suffix = start.strftime("p%Y_%m")
54
+
55
+ parts.append(
56
+ f"""
57
+ CREATE TABLE IF NOT EXISTS {table_name}_{suffix}
58
+ PARTITION OF {table_name}
59
+ FOR VALUES FROM ('{start_str}') TO ('{end_str}');
60
+ """.strip()
61
+ )
62
+
63
+ return "\n\n".join(parts)
64
+
65
+
66
+ # Function to generate SQL for a custom jsonb_diff() function in PostgreSQL
67
+ def generate_jsonb_diff_function_sql() -> str:
68
+ return """
69
+ CREATE OR REPLACE FUNCTION jsonb_diff(old jsonb, new jsonb)
70
+ RETURNS jsonb AS $$
71
+ BEGIN
72
+ RETURN (
73
+ SELECT jsonb_object_agg(key, value)
74
+ FROM jsonb_each(new)
75
+ WHERE old -> key IS DISTINCT FROM value
76
+ );
77
+ END;
78
+ $$ LANGUAGE plpgsql IMMUTABLE;
79
+ """.strip()
auditrum/settings.py ADDED
@@ -0,0 +1,20 @@
1
+ from pydantic import Field
2
+ from pydantic_settings import BaseSettings
3
+
4
+
5
+ class PgAuditSettings(BaseSettings):
6
+ audit_table: str = Field("auditlog", alias="AUDIT_TABLE")
7
+
8
+ pg_host: str = Field(..., alias="PGHOST")
9
+ pg_port: int = Field(5432, alias="PGPORT")
10
+ pg_user: str = Field(..., alias="PGUSER")
11
+ pg_password: str = Field(..., alias="PGPASSWORD")
12
+ pg_dbname: str = Field(..., alias="PGDATABASE")
13
+
14
+ @property
15
+ def db_dsn(self) -> str:
16
+ return f"postgresql://{self.pg_user}:{self.pg_password}@{self.pg_host}:{self.pg_port}/{self.pg_dbname}"
17
+
18
+ class Config:
19
+ env_file = ".env"
20
+ case_sensitive = False
auditrum/triggers.py ADDED
@@ -0,0 +1,118 @@
1
+ def generate_trigger_sql(
2
+ table_name: str,
3
+ audit_table: str = "auditlog",
4
+ track_only: list[str] | None = None,
5
+ exclude_fields: list[str] | None = None,
6
+ log_conditions: str | None = None,
7
+ meta_fields: list[str] | None = None,
8
+ ) -> str:
9
+ if track_only is not None and exclude_fields is not None:
10
+ raise ValueError(
11
+ f"Cannot specify both track_only and exclude_fields for table {table_name}"
12
+ )
13
+
14
+ if track_only is not None:
15
+ track_only_keys = [f"'{k}'" for k in track_only]
16
+ keys_tuple = "(" + ", ".join(track_only_keys) + ")"
17
+ # We want to ignore keys NOT in track_only
18
+ ignored_keys_expr = f"ARRAY(SELECT key FROM jsonb_object_keys(to_jsonb(NEW)) AS key(key) WHERE key.key NOT IN {keys_tuple})::text[]"
19
+ elif exclude_fields is not None:
20
+ ignored_keys = [f"'{k}'" for k in exclude_fields]
21
+ ignored_keys_expr = f"ARRAY[{', '.join(ignored_keys)}]::text[]"
22
+ else:
23
+ ignored_keys_expr = "ARRAY[]::text[]"
24
+
25
+ default_meta_fields = [
26
+ "'username', current_setting('session.myapp_username', true)",
27
+ "'client_ip', current_setting('session.myapp_client_ip', true)",
28
+ "'user_agent', current_setting('session.myapp_user_agent', true)",
29
+ "'session_key', current_setting('session.myapp_session_key', true)",
30
+ "'source', current_setting('session.myapp_source', true)",
31
+ "'request_id', current_setting('session.myapp_request_id', true)",
32
+ "'change_reason', current_setting('session.myapp_change_reason', true)"
33
+ ]
34
+
35
+ if meta_fields:
36
+ extra_fields = [f"'{field}', to_jsonb(NEW.{field})" for field in meta_fields]
37
+ meta_fields_expr = ", ".join(default_meta_fields + extra_fields)
38
+ else:
39
+ meta_fields_expr = ", ".join(default_meta_fields)
40
+
41
+ log_conditions_expr = f"""
42
+ IF NOT ({log_conditions}) THEN
43
+ RETURN NULL;
44
+ END IF;
45
+ """ if log_conditions else ""
46
+
47
+ function_name = f"audit_{table_name}_trigger"
48
+
49
+ sql = f"""
50
+ CREATE OR REPLACE FUNCTION {function_name}() RETURNS trigger AS $$
51
+ DECLARE
52
+ data JSONB;
53
+ diff JSONB;
54
+ ignored_keys TEXT[] := {ignored_keys_expr};
55
+ old_filtered jsonb := to_jsonb(OLD);
56
+ new_filtered jsonb := to_jsonb(NEW);
57
+ key text;
58
+ BEGIN
59
+ {log_conditions_expr}
60
+ FOREACH key IN ARRAY ignored_keys LOOP
61
+ old_filtered := old_filtered - key;
62
+ new_filtered := new_filtered - key;
63
+ END LOOP;
64
+ RAISE NOTICE 'IGNORED_KEYS: %', ignored_keys;
65
+
66
+ IF (TG_OP = 'UPDATE') THEN
67
+ diff = jsonb_strip_nulls(jsonb_diff(old_filtered, new_filtered));
68
+ RAISE NOTICE 'Diff after filtering: %', diff;
69
+ RAISE NOTICE 'Diff is null?: %', diff is null;
70
+ IF diff is null THEN
71
+ RETURN NULL;
72
+ END IF;
73
+ ELSIF TG_OP = 'INSERT' THEN
74
+ diff = new_filtered;
75
+ IF diff is null THEN
76
+ RETURN NULL;
77
+ END IF;
78
+ ELSIF TG_OP = 'DELETE' THEN
79
+ diff = old_filtered;
80
+ IF diff is null THEN
81
+ RETURN NULL;
82
+ END IF;
83
+ END IF;
84
+
85
+ IF (TG_OP = 'DELETE') THEN
86
+ data = to_jsonb(OLD);
87
+ ELSE
88
+ data = to_jsonb(NEW);
89
+ END IF;
90
+
91
+ INSERT INTO {audit_table} (
92
+ operation, changed_at, content_type_id, object_id, table_name,
93
+ user_id, old_data, new_data, diff, meta, request_id, change_reason, source
94
+ )
95
+ VALUES (
96
+ TG_OP, now(), NULL, NULL, TG_TABLE_NAME,
97
+ NULL,
98
+ CASE WHEN TG_OP IN ('UPDATE', 'DELETE') THEN to_jsonb(OLD) ELSE NULL END,
99
+ CASE WHEN TG_OP IN ('UPDATE', 'INSERT') THEN to_jsonb(NEW) ELSE NULL END,
100
+ CASE WHEN TG_OP = 'UPDATE' THEN diff ELSE NULL END,
101
+ jsonb_build_object({meta_fields_expr}),
102
+ current_setting('session.myapp_request_id', true),
103
+ current_setting('session.myapp_change_reason', true),
104
+ current_setting('session.myapp_source', true)
105
+ );
106
+
107
+ RETURN NULL;
108
+ END;
109
+ $$ LANGUAGE plpgsql;
110
+
111
+ DROP TRIGGER IF EXISTS {function_name} ON {table_name};
112
+
113
+ CREATE TRIGGER {function_name}
114
+ AFTER INSERT OR UPDATE OR DELETE ON {table_name}
115
+ FOR EACH ROW EXECUTE FUNCTION {function_name}();
116
+ """
117
+ print(f"Generated trigger SQL for table '{table_name}':\n{sql}")
118
+ return sql.strip()
auditrum/utils.py ADDED
File without changes