django-activity-audit 1.3.0.dev7__tar.gz → 1.3.0.dev9__tar.gz
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.
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/PKG-INFO +1 -1
- django_activity_audit-1.3.0.dev9/activity_audit/handlers.py +166 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/activity_audit/signals.py +17 -32
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/activity_audit/utils.py +52 -0
- django_activity_audit-1.3.0.dev9/django-activity-audit.md +625 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/pyproject.toml +1 -1
- django_activity_audit-1.3.0.dev9/user-activity-feed-plan.md +416 -0
- django_activity_audit-1.3.0.dev7/activity_audit/handlers.py +0 -74
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/.gitignore +0 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/.pre-commit-config.yaml +0 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/LICENSE +0 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/MANIFEST.in +0 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/README.md +0 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/README.rst +0 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/activity_audit/__init__.py +0 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/activity_audit/apps.py +0 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/activity_audit/constants.py +0 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/activity_audit/formatters.py +0 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/activity_audit/logger_levels.py +0 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/activity_audit/middleware.py +0 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/activity_audit/protocols.py +0 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/activity_audit/settings.py +0 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/hatch +0 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/pytest.ini +0 -0
- {django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-activity-audit
|
|
3
|
-
Version: 1.3.0.
|
|
3
|
+
Version: 1.3.0.dev9
|
|
4
4
|
Summary: A Django package for easy CRUD operation logging and container logs
|
|
5
5
|
Project-URL: Homepage, https://github.com/shree256/django-activity-audit
|
|
6
6
|
Project-URL: Repository, https://github.com/shree256/django-activity-audit
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import queue
|
|
3
|
+
|
|
4
|
+
from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler
|
|
5
|
+
|
|
6
|
+
from .formatters import APIFormatter, AuditFormatter, JsonFormatter, LoginFormatter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseAuditHandler(RotatingFileHandler):
|
|
10
|
+
"""Base handler for all audit logs with common emit logic."""
|
|
11
|
+
|
|
12
|
+
def emit(self, record):
|
|
13
|
+
"""
|
|
14
|
+
Emit a record with additional values for audit-specific fields.
|
|
15
|
+
"""
|
|
16
|
+
try:
|
|
17
|
+
# Handle extra if present
|
|
18
|
+
if hasattr(record, "extra"):
|
|
19
|
+
for key, value in record.extra.items():
|
|
20
|
+
setattr(record, key, value)
|
|
21
|
+
|
|
22
|
+
super().emit(record)
|
|
23
|
+
|
|
24
|
+
except Exception as e:
|
|
25
|
+
self.handleError(record)
|
|
26
|
+
# Log the error to the root logger
|
|
27
|
+
logging.getLogger().error(f"Error in AuditLogHandler: {str(e)}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class APILogHandler(BaseAuditHandler):
|
|
31
|
+
"""Handler for API audit logs with default APIFormatter."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
filename,
|
|
36
|
+
mode="a",
|
|
37
|
+
maxBytes=0,
|
|
38
|
+
backupCount=0,
|
|
39
|
+
encoding=None,
|
|
40
|
+
delay=False,
|
|
41
|
+
):
|
|
42
|
+
super().__init__(filename, mode, maxBytes, backupCount, encoding, delay)
|
|
43
|
+
self.setFormatter(APIFormatter())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AuditLogHandler(BaseAuditHandler):
|
|
47
|
+
"""Handler for model audit logs with default AuditFormatter."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
filename,
|
|
52
|
+
mode="a",
|
|
53
|
+
maxBytes=0,
|
|
54
|
+
backupCount=0,
|
|
55
|
+
encoding=None,
|
|
56
|
+
delay=False,
|
|
57
|
+
):
|
|
58
|
+
super().__init__(filename, mode, maxBytes, backupCount, encoding, delay)
|
|
59
|
+
self.setFormatter(AuditFormatter())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class LoginLogHandler(BaseAuditHandler):
|
|
63
|
+
"""Handler for login audit logs with default LoginFormatter."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
filename,
|
|
68
|
+
mode="a",
|
|
69
|
+
maxBytes=0,
|
|
70
|
+
backupCount=0,
|
|
71
|
+
encoding=None,
|
|
72
|
+
delay=False,
|
|
73
|
+
):
|
|
74
|
+
super().__init__(filename, mode, maxBytes, backupCount, encoding, delay)
|
|
75
|
+
self.setFormatter(LoginFormatter())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ASYNC ------------------------------------------------------
|
|
79
|
+
class AsyncBaseAuditHandler(QueueHandler):
|
|
80
|
+
"""
|
|
81
|
+
Base async handler. Enqueues records on the calling thread; a background
|
|
82
|
+
QueueListener thread does the actual file write via the wrapped sync handler.
|
|
83
|
+
|
|
84
|
+
Subclasses pass the concrete sync handler class and formatter via
|
|
85
|
+
_sync_handler_class and _formatter_class.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
_sync_handler_class = None
|
|
89
|
+
_formatter_class = None
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
filename,
|
|
94
|
+
mode="a",
|
|
95
|
+
maxBytes=0,
|
|
96
|
+
backupCount=0,
|
|
97
|
+
encoding=None,
|
|
98
|
+
delay=False,
|
|
99
|
+
):
|
|
100
|
+
log_queue = queue.Queue(-1)
|
|
101
|
+
super().__init__(log_queue)
|
|
102
|
+
|
|
103
|
+
sync_handler = self._sync_handler_class(
|
|
104
|
+
filename, mode, maxBytes, backupCount, encoding, delay
|
|
105
|
+
)
|
|
106
|
+
self._listener = QueueListener(
|
|
107
|
+
log_queue, sync_handler, respect_handler_level=True
|
|
108
|
+
)
|
|
109
|
+
self._listener.start()
|
|
110
|
+
|
|
111
|
+
def close(self):
|
|
112
|
+
self._listener.stop()
|
|
113
|
+
super().close()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class AsyncAPILogHandler(AsyncBaseAuditHandler):
|
|
117
|
+
"""Non-blocking handler for API audit logs."""
|
|
118
|
+
|
|
119
|
+
_sync_handler_class = APILogHandler
|
|
120
|
+
_formatter_class = APIFormatter
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class AsyncAuditLogHandler(AsyncBaseAuditHandler):
|
|
124
|
+
"""Non-blocking handler for model audit logs."""
|
|
125
|
+
|
|
126
|
+
_sync_handler_class = AuditLogHandler
|
|
127
|
+
_formatter_class = AuditFormatter
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class AsyncLoginLogHandler(AsyncBaseAuditHandler):
|
|
131
|
+
"""Non-blocking handler for login audit logs."""
|
|
132
|
+
|
|
133
|
+
_sync_handler_class = LoginLogHandler
|
|
134
|
+
_formatter_class = LoginFormatter
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class AsyncJsonHandler(QueueHandler):
|
|
138
|
+
"""
|
|
139
|
+
Non-blocking handler for general JSON logs. Wraps a RotatingFileHandler
|
|
140
|
+
with JsonFormatter on the background thread.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
filename,
|
|
146
|
+
mode="a",
|
|
147
|
+
maxBytes=0,
|
|
148
|
+
backupCount=0,
|
|
149
|
+
encoding=None,
|
|
150
|
+
delay=False,
|
|
151
|
+
):
|
|
152
|
+
log_queue = queue.Queue(-1)
|
|
153
|
+
super().__init__(log_queue)
|
|
154
|
+
|
|
155
|
+
sync_handler = RotatingFileHandler(
|
|
156
|
+
filename, mode, maxBytes, backupCount, encoding, delay
|
|
157
|
+
)
|
|
158
|
+
sync_handler.setFormatter(JsonFormatter())
|
|
159
|
+
self._listener = QueueListener(
|
|
160
|
+
log_queue, sync_handler, respect_handler_level=True
|
|
161
|
+
)
|
|
162
|
+
self._listener.start()
|
|
163
|
+
|
|
164
|
+
def close(self):
|
|
165
|
+
self._listener.stop()
|
|
166
|
+
super().close()
|
{django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/activity_audit/signals.py
RENAMED
|
@@ -79,25 +79,7 @@ def get_calling_model() -> Optional[str]:
|
|
|
79
79
|
return None
|
|
80
80
|
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
"""Mixin to add signal handling capabilities to models."""
|
|
84
|
-
|
|
85
|
-
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
86
|
-
super().__init__(*args, **kwargs)
|
|
87
|
-
self._original_m2m = self._get_m2m_state()
|
|
88
|
-
|
|
89
|
-
def _get_m2m_state(self) -> dict:
|
|
90
|
-
try:
|
|
91
|
-
"""Get the current state of M2M fields."""
|
|
92
|
-
return {
|
|
93
|
-
field.name: set(
|
|
94
|
-
getattr(self, field.name).all().values_list("id", flat=True)
|
|
95
|
-
)
|
|
96
|
-
for field in self._meta.many_to_many
|
|
97
|
-
}
|
|
98
|
-
except Exception as e:
|
|
99
|
-
logger.error(f"Error getting M2M state: {e}")
|
|
100
|
-
return {}
|
|
82
|
+
_patched_models: set = set()
|
|
101
83
|
|
|
102
84
|
|
|
103
85
|
def push_log(
|
|
@@ -131,12 +113,15 @@ def push_log(
|
|
|
131
113
|
logger.error(f"Failed to prepare audit log: {e}")
|
|
132
114
|
|
|
133
115
|
|
|
116
|
+
def instance_to_dict(instance: models.Model) -> dict:
|
|
117
|
+
return model_to_dict(instance, fields=[f.name for f in instance._meta.fields])
|
|
118
|
+
|
|
119
|
+
|
|
134
120
|
def patch_model_event(model_class: type[models.Model]) -> None:
|
|
135
121
|
"""Monkey patch a model to add signal handling capabilities."""
|
|
136
122
|
|
|
137
|
-
if not
|
|
138
|
-
|
|
139
|
-
model_class.__bases__ = (ModelSignalMixin,) + model_class.__bases__
|
|
123
|
+
if model_class not in _patched_models:
|
|
124
|
+
_patched_models.add(model_class)
|
|
140
125
|
|
|
141
126
|
# Store the original methods
|
|
142
127
|
original_save = model_class.save
|
|
@@ -154,7 +139,7 @@ def patch_model_event(model_class: type[models.Model]) -> None:
|
|
|
154
139
|
# Log the event
|
|
155
140
|
event_type = EVENT_TYPES[0] if is_new else EVENT_TYPES[1]
|
|
156
141
|
|
|
157
|
-
instance_repr =
|
|
142
|
+
instance_repr = instance_to_dict(self)
|
|
158
143
|
|
|
159
144
|
push_log(
|
|
160
145
|
f"{event_type} event by {model_class.__name__} (id: {self.pk})",
|
|
@@ -179,7 +164,7 @@ def patch_model_event(model_class: type[models.Model]) -> None:
|
|
|
179
164
|
|
|
180
165
|
# For new instances, we might not have a pk yet, so use a placeholder
|
|
181
166
|
instance_id = str(instance.pk) if instance.pk else "pending"
|
|
182
|
-
instance_repr =
|
|
167
|
+
instance_repr = instance_to_dict(instance)
|
|
183
168
|
|
|
184
169
|
push_log(
|
|
185
170
|
f"{event_type} event by {model_class.__name__} (id: {instance_id})",
|
|
@@ -208,7 +193,7 @@ def patch_model_event(model_class: type[models.Model]) -> None:
|
|
|
208
193
|
# Log only if this is the calling model
|
|
209
194
|
if calling_model == model_class.__name__:
|
|
210
195
|
first_obj = created_objs[0]
|
|
211
|
-
instance_repr =
|
|
196
|
+
instance_repr = instance_to_dict(first_obj)
|
|
212
197
|
|
|
213
198
|
push_log(
|
|
214
199
|
f"{EVENT_TYPES[3]} event by {model_class.__name__} (id: {first_obj.pk})",
|
|
@@ -231,17 +216,17 @@ def patch_model_event(model_class: type[models.Model]) -> None:
|
|
|
231
216
|
return original_bulk_update(self, objs, fields, batch_size)
|
|
232
217
|
|
|
233
218
|
# Call the original bulk_update method
|
|
234
|
-
original_bulk_update(self, objs, fields, batch_size)
|
|
219
|
+
bulk_update = original_bulk_update(self, objs, fields, batch_size)
|
|
235
220
|
|
|
236
221
|
# Get the calling model
|
|
237
222
|
calling_model = get_calling_model()
|
|
238
223
|
if not calling_model:
|
|
239
|
-
return
|
|
224
|
+
return bulk_update
|
|
240
225
|
|
|
241
226
|
# Log only if this is the calling model
|
|
242
227
|
if calling_model == model_class.__name__:
|
|
243
228
|
first_obj = objs[0]
|
|
244
|
-
instance_repr =
|
|
229
|
+
instance_repr = instance_to_dict(first_obj)
|
|
245
230
|
|
|
246
231
|
push_log(
|
|
247
232
|
f"{EVENT_TYPES[4]} event by {model_class.__name__}",
|
|
@@ -268,7 +253,7 @@ def patch_model_event(model_class: type[models.Model]) -> None:
|
|
|
268
253
|
if not should_audit(instance):
|
|
269
254
|
return
|
|
270
255
|
|
|
271
|
-
instance_repr =
|
|
256
|
+
instance_repr = instance_to_dict(instance)
|
|
272
257
|
|
|
273
258
|
push_log(
|
|
274
259
|
f"{EVENT_TYPES[8]} event by {model_class.__name__} (id: {instance.pk})",
|
|
@@ -283,7 +268,7 @@ def patch_model_event(model_class: type[models.Model]) -> None:
|
|
|
283
268
|
def handle_delete(
|
|
284
269
|
sender: type[models.Model], instance: models.Model, **kwargs: Any
|
|
285
270
|
) -> None:
|
|
286
|
-
instance_repr =
|
|
271
|
+
instance_repr = instance_to_dict(instance)
|
|
287
272
|
|
|
288
273
|
push_log(
|
|
289
274
|
f"{EVENT_TYPES[2]} event by {model_class.__name__} (id: {instance.pk})",
|
|
@@ -308,7 +293,7 @@ def patch_model_event(model_class: type[models.Model]) -> None:
|
|
|
308
293
|
return
|
|
309
294
|
|
|
310
295
|
field_name = kwargs.get("model", sender).__name__.lower()
|
|
311
|
-
instance_repr =
|
|
296
|
+
instance_repr = instance_to_dict(instance)
|
|
312
297
|
|
|
313
298
|
push_log(
|
|
314
299
|
f"M2M {action} event by {model_class.__name__} (id: {instance.pk})",
|
|
@@ -330,7 +315,7 @@ def setup_model_signals() -> None:
|
|
|
330
315
|
if not should_audit(model):
|
|
331
316
|
continue
|
|
332
317
|
|
|
333
|
-
if not
|
|
318
|
+
if model not in _patched_models:
|
|
334
319
|
patch_model_event(model)
|
|
335
320
|
|
|
336
321
|
|
{django_activity_audit-1.3.0.dev7 → django_activity_audit-1.3.0.dev9}/activity_audit/utils.py
RENAMED
|
@@ -63,6 +63,58 @@ def get_login_handler(
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
# ASYNC ------------------------------------------------------
|
|
67
|
+
def get_async_json_handler(
|
|
68
|
+
level: str = "DEBUG",
|
|
69
|
+
filename: str = "audit_logs/app.log",
|
|
70
|
+
max_bytes: int = 1024 * 1024 * 10,
|
|
71
|
+
backup_count: int = 5,
|
|
72
|
+
) -> dict:
|
|
73
|
+
return {
|
|
74
|
+
"level": level,
|
|
75
|
+
"class": "activity_audit.handlers.AsyncJsonHandler",
|
|
76
|
+
"filename": filename,
|
|
77
|
+
"maxBytes": max_bytes,
|
|
78
|
+
"backupCount": backup_count,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_async_api_handler(
|
|
83
|
+
filename: str = "audit_logs/api.log",
|
|
84
|
+
) -> dict:
|
|
85
|
+
return {
|
|
86
|
+
"class": "activity_audit.handlers.AsyncAPILogHandler",
|
|
87
|
+
"filename": filename,
|
|
88
|
+
"maxBytes": 1024 * 1024 * 10, # 10MB
|
|
89
|
+
"backupCount": 5,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_async_audit_handler(
|
|
94
|
+
filename: str = "audit_logs/audit.log",
|
|
95
|
+
) -> dict:
|
|
96
|
+
return {
|
|
97
|
+
"class": "activity_audit.handlers.AsyncAuditLogHandler",
|
|
98
|
+
"filename": filename,
|
|
99
|
+
"maxBytes": 1024 * 1024 * 10, # 10MB
|
|
100
|
+
"backupCount": 5,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_async_login_handler(
|
|
105
|
+
filename: str = "audit_logs/login.log",
|
|
106
|
+
) -> dict:
|
|
107
|
+
return {
|
|
108
|
+
"class": "activity_audit.handlers.AsyncLoginLogHandler",
|
|
109
|
+
"filename": filename,
|
|
110
|
+
"maxBytes": 1024 * 1024 * 5, # 5MB
|
|
111
|
+
"backupCount": 5,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ----------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
66
118
|
def push_usage_log(
|
|
67
119
|
message: str,
|
|
68
120
|
event: str,
|