Skip to content

Commit 67d4ced

Browse files
authored
Merge pull request #42 from dimagi/sk/audit-service
extract main audit logic into a service class
2 parents 20b953e + 057d4bb commit 67d4ced

File tree

12 files changed

+699
-226
lines changed

12 files changed

+699
-226
lines changed

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,42 @@ FIELD_AUDIT_AUDITORS = []
6565
|:----------------------------------|:---------------------------------------------------------------|:------------------------
6666
| `FIELD_AUDIT_AUDITEVENT_MANAGER` | A custom manager to use for the `AuditEvent` Model. | `field_audit.models.DefaultAuditEventManager`
6767
| `FIELD_AUDIT_AUDITORS` | A custom list of auditors for acquiring `change_context` info. | `["field_audit.auditors.RequestAuditor", "field_audit.auditors.SystemUserAuditor"]`
68+
| `FIELD_AUDIT_SERVICE_CLASS` | A custom service class for audit logic implementation. | `field_audit.services.AuditService`
69+
70+
### Custom Audit Service
71+
72+
The audit logic has been extracted into a separate `AuditService` class to improve separation of concerns and enable easier customization of audit behavior. Users can provide custom audit implementations by subclassing `AuditService` and configuring the `FIELD_AUDIT_SERVICE_CLASS` setting.
73+
74+
#### Creating a Custom Audit Service
75+
76+
```python
77+
# myapp/audit.py
78+
79+
from field_audit import AuditService
80+
81+
class CustomAuditService(AuditService):
82+
def get_field_value(self, instance, field_name, bootstrap=False):
83+
# Custom logic for extracting field values
84+
value = super().get_field_value(instance, field_name, bootstrap)
85+
86+
# Example: custom serialization or transformation
87+
if field_name == 'sensitive_field':
88+
value = '[REDACTED]'
89+
90+
return value
91+
```
92+
93+
Then configure it in your Django settings:
94+
95+
```python
96+
# settings.py
97+
98+
FIELD_AUDIT_SERVICE_CLASS = 'myapp.audit.CustomAuditService'
99+
```
100+
101+
#### Backward Compatibility
102+
103+
The original `AuditEvent` class methods are maintained for backward compatibility but are now deprecated in favor of the service-based approach. These methods will issue deprecation warnings and delegate to the configured audit service.
68104

69105
### Model Auditing
70106

field_audit/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from .field_audit import audit_fields # noqa: F401
2+
from .services import AuditService, get_audit_service # noqa: F401
23

34
__version__ = "1.3.0"

field_audit/field_audit.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def wrapper(cls):
5757
raise AlreadyAudited(cls)
5858
if not issubclass(cls, models.Model):
5959
raise ValueError(f"expected Model subclass, got: {cls}")
60-
AuditEvent.attach_field_names(cls, field_names)
60+
service.attach_field_names(cls, field_names)
6161
if audit_special_queryset_writes:
6262
_verify_auditing_manager(cls)
6363
cls.__init__ = _decorate_init(cls.__init__)
@@ -70,7 +70,8 @@ def wrapper(cls):
7070
return cls
7171
if not field_names:
7272
raise ValueError("at least one field name is required")
73-
from .models import AuditEvent
73+
from .services import get_audit_service
74+
service = get_audit_service()
7475
return wrapper
7576

7677

@@ -101,8 +102,9 @@ def _decorate_init(init):
101102
@wraps(init)
102103
def wrapper(self, *args, **kw):
103104
init(self, *args, **kw)
104-
AuditEvent.attach_initial_values(self)
105-
from .models import AuditEvent
105+
service.attach_initial_values(self)
106+
from .services import get_audit_service
107+
service = get_audit_service()
106108
return wrapper
107109

108110

@@ -124,7 +126,7 @@ def wrapper(self, *args, **kw):
124126
db = router.db_for_write(type(self))
125127
with transaction.atomic(using=db):
126128
ret = func(self, *args, **kw)
127-
AuditEvent.audit_field_changes(
129+
service.audit_field_changes(
128130
self,
129131
is_create,
130132
is_delete,
@@ -136,7 +138,8 @@ def wrapper(self, *args, **kw):
136138
is_delete = func.__name__ == "delete"
137139
if not is_save and not is_delete:
138140
raise ValueError(f"invalid function for decoration: {func}")
139-
from .models import AuditEvent
141+
from .services import get_audit_service
142+
service = get_audit_service()
140143
return wrapper
141144

142145

@@ -150,10 +153,11 @@ def _decorate_refresh_from_db(func):
150153
@wraps(func)
151154
def wrapper(self, using=None, fields=None, **kwargs):
152155
if fields is not None:
153-
fields = set(fields) | set(AuditEvent.field_names(self))
156+
fields = set(fields) | set(service.get_field_names(self))
154157
func(self, using, fields, **kwargs)
155158

156-
from .models import AuditEvent
159+
from .services import get_audit_service
160+
service = get_audit_service()
157161
return wrapper
158162

159163

@@ -185,7 +189,9 @@ def _m2m_changed_handler(sender, instance, action, pk_set, **kwargs):
185189
:param action: A string indicating the type of update
186190
:param pk_set: For add/remove actions, set of primary key values
187191
"""
188-
from .models import AuditEvent
192+
from .services import get_audit_service
193+
194+
service = get_audit_service()
189195

190196
if action not in ('post_add', 'post_remove', 'post_clear', 'pre_clear'):
191197
return
@@ -206,17 +212,17 @@ def _m2m_changed_handler(sender, instance, action, pk_set, **kwargs):
206212
field_name = field.name
207213
break
208214

209-
if not m2m_field or field_name not in AuditEvent.field_names(instance):
215+
if not m2m_field or field_name not in service.get_field_names(instance):
210216
return
211217

212218
if action == 'pre_clear':
213219
# `pk_set` not supplied for clear actions. Determine initial values
214220
# in the `pre_clear` event
215-
AuditEvent.attach_initial_m2m_values(instance, field_name)
221+
service.attach_initial_m2m_values(instance, field_name)
216222
return
217223

218224
if action == 'post_clear':
219-
initial_values = AuditEvent.get_initial_m2m_values(instance, field_name)
225+
initial_values = service.get_initial_m2m_values(instance, field_name)
220226
if not initial_values:
221227
return
222228
delta = {field_name: {'remove': initial_values}}
@@ -228,13 +234,13 @@ def _m2m_changed_handler(sender, instance, action, pk_set, **kwargs):
228234
delta = {field_name: {delta_key: list(pk_set)}}
229235

230236
req = request.get()
231-
event = AuditEvent.create_audit_event(
237+
event = service.create_audit_event(
232238
instance.pk, instance.__class__, delta, False, False, req
233239
)
234240
if event is not None:
235241
event.save()
236242

237-
AuditEvent.clear_initial_m2m_field_values(instance, field_name)
243+
service.clear_initial_m2m_field_values(instance, field_name)
238244

239245

240246
def get_audited_models():

field_audit/management/commands/bootstrap_field_audit_events.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from field_audit.const import BOOTSTRAP_BATCH_SIZE
66
from field_audit.field_audit import get_audited_models
7-
from field_audit.models import AuditEvent
7+
from field_audit.services import get_audit_service
88

99

1010
class Command(BaseCommand):
@@ -64,6 +64,9 @@ def handle(self, operation, models, batch_size, **options):
6464
if batch_size == 0:
6565
batch_size = None
6666
self.batch_size = batch_size
67+
68+
self.service = get_audit_service()
69+
6770
for name in models:
6871
model_class = self.models[name]
6972
self.operations[operation](self, model_class)
@@ -74,19 +77,19 @@ def init_all(self, model_class):
7477
with self.bootstrap_action_log(log_head) as stream:
7578
count = self.do_bootstrap(
7679
model_class,
77-
AuditEvent.bootstrap_existing_model_records,
80+
self.service.bootstrap_existing_model_records,
7881
iter_records=query.iterator,
7982
)
8083
stream.write(f"done ({count})")
8184

8285
def top_up_missing(self, model_class):
8386
log_head = f"top-up: {model_class} ... "
8487
with self.bootstrap_action_log(log_head) as stream:
85-
count = self.do_bootstrap(model_class, AuditEvent.bootstrap_top_up)
88+
count = self.do_bootstrap(model_class, self.service.bootstrap_top_up)
8689
stream.write(f"done ({count})")
8790

8891
def do_bootstrap(self, model_class, bootstrap_method, **bootstrap_kw):
89-
field_names = AuditEvent.field_names(model_class)
92+
field_names = self.service.get_field_names(model_class)
9093
if not field_names:
9194
raise CommandError(
9295
f"invalid fields ({field_names!r}) for model: {model_class}"

0 commit comments

Comments
 (0)