django-activity-audit 1.3.0.dev14__tar.gz → 1.3.0.dev16__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.
Files changed (26) hide show
  1. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/PKG-INFO +1 -1
  2. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/activity_audit/formatters.py +2 -1
  3. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/activity_audit/handlers.py +2 -0
  4. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/activity_audit/middleware.py +40 -28
  5. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/activity_audit/signals.py +64 -95
  6. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/pyproject.toml +1 -1
  7. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/.gitignore +0 -0
  8. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/.pre-commit-config.yaml +0 -0
  9. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/LICENSE +0 -0
  10. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/MANIFEST.in +0 -0
  11. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/README.md +0 -0
  12. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/README.rst +0 -0
  13. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/activity_audit/__init__.py +0 -0
  14. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/activity_audit/apps.py +0 -0
  15. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/activity_audit/constants.py +0 -0
  16. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/activity_audit/logger_levels.py +0 -0
  17. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/activity_audit/protocols.py +0 -0
  18. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/activity_audit/settings.py +0 -0
  19. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/activity_audit/unregistered.py +0 -0
  20. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/activity_audit/utils.py +0 -0
  21. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/docs/django-activity-audit.md +0 -0
  22. /django_activity_audit-1.3.0.dev14/activity_audit/REVIEW.md → /django_activity_audit-1.3.0.dev16/docs/improvements.md +0 -0
  23. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/docs/user-activity-feed-plan.md +0 -0
  24. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/hatch +0 -0
  25. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/pytest.ini +0 -0
  26. {django_activity_audit-1.3.0.dev14 → django_activity_audit-1.3.0.dev16}/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.dev14
3
+ Version: 1.3.0.dev16
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
@@ -5,6 +5,7 @@ import logging
5
5
  import uuid
6
6
 
7
7
  from .constants import LogType
8
+ from .middleware import get_request_id
8
9
 
9
10
  def _json_default(obj):
10
11
  """
@@ -49,7 +50,7 @@ class AppFormatter(logging.Formatter):
49
50
  "path": record.pathname,
50
51
  "module": record.module,
51
52
  "function": record.funcName,
52
- "request_id": getattr(record, "request_id", "") or "",
53
+ "request_id": getattr(record, "request_id", None) or get_request_id() or "",
53
54
  "message": record.getMessage(),
54
55
  "exception": "",
55
56
  "log_type": self.log_type,
@@ -170,6 +170,8 @@ class AsyncJsonHandler(QueueHandler):
170
170
  self._listener.start()
171
171
 
172
172
  def prepare(self, record):
173
+ # Capture request_id in the calling thread before the record is queued,
174
+ # since thread-locals are not accessible from the QueueListener thread.
173
175
  record = super().prepare(record)
174
176
  record.request_id = get_request_id() or ""
175
177
  return record
@@ -126,18 +126,6 @@ class AuditLoggingMiddleware(MiddlewareMixin):
126
126
 
127
127
  def __init__(self, get_response):
128
128
  self.get_response = get_response
129
- self.log_data = {
130
- "service_name": SERVICE_NAME,
131
- "request_type": REQUEST_TYPES[0],
132
- "protocol": None,
133
- "request_id": "",
134
- "user_id": "",
135
- "user_info": {},
136
- "request_repr": {},
137
- "response_repr": {},
138
- "error_message": None,
139
- "execution_time": 0,
140
- }
141
129
 
142
130
  if iscoroutinefunction(self.get_response):
143
131
  markcoroutinefunction(self)
@@ -152,6 +140,18 @@ class AuditLoggingMiddleware(MiddlewareMixin):
152
140
  if not should_log_url(request.path):
153
141
  return self.get_response(request)
154
142
 
143
+ log_data = {
144
+ "service_name": SERVICE_NAME,
145
+ "request_type": REQUEST_TYPES[0],
146
+ "protocol": None,
147
+ "request_id": "",
148
+ "user_id": "",
149
+ "user_info": {},
150
+ "request_repr": {},
151
+ "response_repr": {},
152
+ "error_message": None,
153
+ "execution_time": 0,
154
+ }
155
155
  start_time = time.time()
156
156
 
157
157
  # Log request
@@ -175,8 +175,8 @@ class AuditLoggingMiddleware(MiddlewareMixin):
175
175
 
176
176
  # Capture user details AFTER authentication has happened
177
177
  user_id, user_info = get_user_details()
178
- self.log_data["user_id"] = user_id
179
- self.log_data["user_info"] = user_info
178
+ log_data["user_id"] = user_id
179
+ log_data["user_info"] = user_info
180
180
 
181
181
  # TODO: Find way to add status code to response_data
182
182
 
@@ -196,13 +196,13 @@ class AuditLoggingMiddleware(MiddlewareMixin):
196
196
  except UnicodeDecodeError:
197
197
  response_data["body"] = "Binary content"
198
198
 
199
- self.log_data["execution_time"] = end_time - start_time
200
- self.log_data["protocol"] = "https" if request.is_secure() else "http"
201
- self.log_data["request_id"] = request_id
202
- self.log_data["request_repr"] = request_data
203
- self.log_data["response_repr"] = response_data
199
+ log_data["execution_time"] = end_time - start_time
200
+ log_data["protocol"] = "https" if request.is_secure() else "http"
201
+ log_data["request_id"] = request_id
202
+ log_data["request_repr"] = request_data
203
+ log_data["response_repr"] = response_data
204
204
 
205
- logger.api("Audit Internal Request", extra=self.log_data)
205
+ logger.api("Audit Internal Request", extra=log_data)
206
206
 
207
207
  clear_request()
208
208
 
@@ -216,6 +216,18 @@ class AuditLoggingMiddleware(MiddlewareMixin):
216
216
  if not should_log_url(request.path):
217
217
  return await self.get_response(request)
218
218
 
219
+ log_data = {
220
+ "service_name": SERVICE_NAME,
221
+ "request_type": REQUEST_TYPES[0],
222
+ "protocol": None,
223
+ "request_id": "",
224
+ "user_id": "",
225
+ "user_info": {},
226
+ "request_repr": {},
227
+ "response_repr": {},
228
+ "error_message": None,
229
+ "execution_time": 0,
230
+ }
219
231
  start_time = time.time()
220
232
 
221
233
  # Log request
@@ -241,8 +253,8 @@ class AuditLoggingMiddleware(MiddlewareMixin):
241
253
  user_id, user_info = await sync_to_async(
242
254
  get_user_details, thread_sensitive=True
243
255
  )()
244
- self.log_data["user_id"] = user_id
245
- self.log_data["user_info"] = user_info
256
+ log_data["user_id"] = user_id
257
+ log_data["user_info"] = user_info
246
258
 
247
259
  # TODO: Find way to add status code to response_data
248
260
 
@@ -262,13 +274,13 @@ class AuditLoggingMiddleware(MiddlewareMixin):
262
274
  except UnicodeDecodeError:
263
275
  response_data["body"] = "Binary content"
264
276
 
265
- self.log_data["execution_time"] = end_time - start_time
266
- self.log_data["protocol"] = "https" if request.is_secure() else "http"
267
- self.log_data["request_id"] = request_id
268
- self.log_data["request_repr"] = request_data
269
- self.log_data["response_repr"] = response_data
277
+ log_data["execution_time"] = end_time - start_time
278
+ log_data["protocol"] = "https" if request.is_secure() else "http"
279
+ log_data["request_id"] = request_id
280
+ log_data["request_repr"] = request_data
281
+ log_data["response_repr"] = response_data
270
282
 
271
- logger.api("Audit Internal Request", extra=self.log_data)
283
+ logger.api("Audit Internal Request", extra=log_data)
272
284
 
273
285
  clear_request()
274
286
 
@@ -2,7 +2,7 @@ import inspect
2
2
  import logging
3
3
 
4
4
  from functools import wraps
5
- from typing import Any, List, Optional
5
+ from typing import Any, List
6
6
 
7
7
  from django.apps import apps
8
8
  from django.db import models, transaction
@@ -54,32 +54,70 @@ def should_audit(instance_or_class):
54
54
  return True
55
55
 
56
56
 
57
- def get_calling_model() -> Optional[str]:
58
- """Get the model name from the calling function's frame."""
59
- try:
60
- # Get the current frame
61
- frame = inspect.currentframe()
62
- # Go up 3 frames to get to the actual calling function
63
- # (1 for get_calling_model, 1 for the signal handler, 1 for the bulk operation)
64
- for _ in range(3):
65
- frame = frame.f_back
66
- if frame is None:
67
- return None
68
-
69
- # Get the calling function's name
70
- calling_function = frame.f_code.co_name
71
- # Get the module name
72
- module_name = frame.f_globals.get("__name__", "")
73
-
74
- # Check if this is a direct bulk operation call
75
- if "bulk_create" in calling_function or "bulk_update" in calling_function:
76
- return module_name.split(".")[-1]
77
- except Exception:
78
- pass
79
- return None
57
+ _patched_models: set = set()
58
+ _queryset_patched: bool = False
80
59
 
81
60
 
82
- _patched_models: set = set()
61
+ def patch_queryset_bulk_methods() -> None:
62
+ """Patch QuerySet.bulk_create and bulk_update exactly once."""
63
+ global _queryset_patched
64
+ if _queryset_patched:
65
+ return
66
+ _queryset_patched = True
67
+
68
+ original_bulk_create = models.QuerySet.bulk_create
69
+ original_bulk_update = models.QuerySet.bulk_update
70
+
71
+ @wraps(original_bulk_create)
72
+ def bulk_create_with_signals(
73
+ self, objs: List[models.Model], *args: Any, **kwargs: Any
74
+ ) -> List[models.Model]:
75
+ if not objs:
76
+ return original_bulk_create(self, objs, *args, **kwargs)
77
+
78
+ created_objs = original_bulk_create(self, objs, *args, **kwargs)
79
+
80
+ model_class = self.model
81
+ if not should_audit(model_class):
82
+ return created_objs
83
+
84
+ first_obj = created_objs[0]
85
+ push_log(
86
+ f"{EVENT_TYPES[3]} event by {model_class.__name__} (id: {first_obj.pk})",
87
+ model_class.__name__,
88
+ EVENT_TYPES[3],
89
+ str(first_obj.pk),
90
+ instance_to_dict(first_obj),
91
+ {"total_count": len(created_objs)},
92
+ )
93
+ return created_objs
94
+
95
+ @wraps(original_bulk_update)
96
+ def bulk_update_with_signals(
97
+ self, objs: List[models.Model], fields: List[str], batch_size=None
98
+ ) -> None:
99
+ if not objs:
100
+ return original_bulk_update(self, objs, fields, batch_size)
101
+
102
+ result = original_bulk_update(self, objs, fields, batch_size)
103
+
104
+ model_class = self.model
105
+ if not should_audit(model_class):
106
+ return result
107
+
108
+ first_obj = objs[0]
109
+ push_log(
110
+ f"{EVENT_TYPES[4]} event by {model_class.__name__}",
111
+ model_class.__name__,
112
+ EVENT_TYPES[4],
113
+ str(first_obj.pk),
114
+ instance_to_dict(first_obj),
115
+ {"total_count": len(objs), "fields": fields},
116
+ )
117
+ return result
118
+
119
+ models.QuerySet.bulk_create = bulk_create_with_signals
120
+ models.QuerySet.bulk_update = bulk_update_with_signals
83
121
 
84
122
 
85
123
  def push_log(
@@ -126,8 +164,6 @@ def patch_model_event(model_class: type[models.Model]) -> None:
126
164
 
127
165
  # Store the original methods
128
166
  original_save = model_class.save
129
- original_bulk_create = models.QuerySet.bulk_create
130
- original_bulk_update = models.QuerySet.bulk_update
131
167
 
132
168
  # SAVE ---------------------------------------------------------------------------
133
169
  @wraps(original_save)
@@ -175,76 +211,8 @@ def patch_model_event(model_class: type[models.Model]) -> None:
175
211
  instance_repr,
176
212
  )
177
213
 
178
- # BULK --------------------------------------------------------------------------
179
- @wraps(original_bulk_create)
180
- def bulk_create_with_signals(
181
- self, objs: List[models.Model], *args: Any, **kwargs: Any
182
- ) -> List[models.Model]:
183
- if not objs:
184
- return original_bulk_create(self, objs, *args, **kwargs)
185
-
186
- # Get the calling model
187
- calling_model = get_calling_model()
188
- if not calling_model:
189
- return original_bulk_create(self, objs, *args, **kwargs)
190
-
191
- # Call the original bulk_create method
192
- created_objs = original_bulk_create(self, objs, *args, **kwargs)
193
-
194
- # Log only if this is the calling model
195
- if calling_model == model_class.__name__:
196
- first_obj = created_objs[0]
197
- instance_repr = instance_to_dict(first_obj)
198
-
199
- push_log(
200
- f"{EVENT_TYPES[3]} event by {model_class.__name__} (id: {first_obj.pk})",
201
- model_class.__name__,
202
- EVENT_TYPES[3],
203
- str(first_obj.pk),
204
- instance_repr,
205
- {
206
- "total_count": len(created_objs),
207
- },
208
- )
209
-
210
- return created_objs
211
-
212
- @wraps(original_bulk_update)
213
- def bulk_update_with_signals(
214
- self, objs: List[models.Model], fields: List[str], batch_size=None
215
- ) -> None:
216
- if not objs:
217
- return original_bulk_update(self, objs, fields, batch_size)
218
-
219
- # Call the original bulk_update method
220
- bulk_update = original_bulk_update(self, objs, fields, batch_size)
221
-
222
- # Get the calling model
223
- calling_model = get_calling_model()
224
- if not calling_model:
225
- return bulk_update
226
-
227
- # Log only if this is the calling model
228
- if calling_model == model_class.__name__:
229
- first_obj = objs[0]
230
- instance_repr = instance_to_dict(first_obj)
231
-
232
- push_log(
233
- f"{EVENT_TYPES[4]} event by {model_class.__name__}",
234
- model_class.__name__,
235
- EVENT_TYPES[4],
236
- str(first_obj.pk),
237
- instance_repr,
238
- {
239
- "total_count": len(objs),
240
- "fields": fields,
241
- },
242
- )
243
-
244
214
  # Replace the methods
245
215
  model_class.save = save_with_signals
246
- models.QuerySet.bulk_create = bulk_create_with_signals
247
- models.QuerySet.bulk_update = bulk_update_with_signals
248
216
 
249
217
  # DELETE -----------------------------------------------------------------------
250
218
  @receiver(pre_delete, sender=model_class)
@@ -311,6 +279,7 @@ def patch_model_event(model_class: type[models.Model]) -> None:
311
279
 
312
280
  def setup_model_signals() -> None:
313
281
  """Set up signals for all models in the project."""
282
+ patch_queryset_bulk_methods()
314
283
  for app_config in apps.get_app_configs():
315
284
  for model in app_config.get_models():
316
285
  if not should_audit(model):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "django-activity-audit"
3
- version = "1.3.0.dev14"
3
+ version = "1.3.0.dev16"
4
4
  description = "A Django package for easy CRUD operation logging and container logs"
5
5
  authors = [
6
6
  { name = "Shreeshan", email = "shreeshan256@gmail.com" }