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.
- auditrum/__init__.py +0 -0
- auditrum/cli.py +237 -0
- auditrum/context.py +131 -0
- auditrum/integrations/__init__.py +0 -0
- auditrum/integrations/django/__init__.py +0 -0
- auditrum/integrations/django/admin.py +34 -0
- auditrum/integrations/django/apps.py +23 -0
- auditrum/integrations/django/audit.py +27 -0
- auditrum/integrations/django/hooks.py +29 -0
- auditrum/integrations/django/management/__init__.py +0 -0
- auditrum/integrations/django/management/commands/__init__.py +0 -0
- auditrum/integrations/django/management/commands/audit_add_partitions.py +40 -0
- auditrum/integrations/django/middleware.py +25 -0
- auditrum/integrations/django/migrations/0001_initial.py +24 -0
- auditrum/integrations/django/migrations/__init__.py +0 -0
- auditrum/integrations/django/mixins.py +46 -0
- auditrum/integrations/django/models.py +31 -0
- auditrum/integrations/django/settings.py +14 -0
- auditrum/integrations/django/shell_context.py +3 -0
- auditrum/integrations/django/templates/audit/object_history.html +58 -0
- auditrum/integrations/django/templatetags/__init__.py +0 -0
- auditrum/integrations/django/templatetags/audit_tags.py +14 -0
- auditrum/integrations/django/utils.py +172 -0
- auditrum/revert.py +39 -0
- auditrum/schema.py +79 -0
- auditrum/settings.py +20 -0
- auditrum/triggers.py +118 -0
- auditrum/utils.py +0 -0
- auditrum-0.2.0.dist-info/METADATA +266 -0
- auditrum-0.2.0.dist-info/RECORD +34 -0
- auditrum-0.2.0.dist-info/WHEEL +5 -0
- auditrum-0.2.0.dist-info/entry_points.txt +2 -0
- auditrum-0.2.0.dist-info/licenses/LICENSE +21 -0
- auditrum-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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 }}">« 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 »</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
|