A Django app for logging changes in model fields.
- Add
fieldloggerto yourINSTALLED_APPS - Run
python manage.py migrateto initialize the model - Add
FIELD_LOGGER_SETTINGSto yoursettings.pyfile.
FIELD_LOGGER_SETTINGS = {
'ENCODER': 'path.to.your.json.Encoder', # (default: None)
'DECODER': 'path.to.your.json.Decoder', # (default: None)
'LOGGING_ENABLED': True, # (default: True)
'FAIL_SILENTLY': True, # (default: True)
'LOGGING_APPS': {
'your_app': {
'logging_enabled': True, # (default: True)
'fail_silently': True, # (default: True)
'models': {
'YourModel': {
'logging_enabled': True, # (default: True)
'fail_silently': True, # (default: True)
'fields': ['field1', 'field2'], # (default: [])
'exclude_fields': ['field3', 'field4'], # (default: [])
'callbacks': [
lambda instance, fields, logs: print(instance, fields, logs),
'yourapp.app.callbacks.your_function_name'
], # (default: [])
},
},
'callbacks': [
lambda instance, fields, logs: print(instance, fields, logs),
'yourapp.app.callbacks.your_function_name'
], # (default: [])
},
},
'CALLBACKS': [
lambda instance, fields, logs: print(instance, fields, logs),
'yourapp.app.callbacks.your_function_name'
], # (default: [])
}ENCODERandDECODERare optional. If you want to encode/decode your model instance fields, you can specify your encoder/decoder classes here. Your encoder/decoder classes must be subclasses ofjson.JSONEncoderandjson.JSONDecoderrespectively.LOGGING_ENABLEDis optional. If you want to disable logging globally, you can set this toFalse.FAIL_SILENTLYis optional. If it is set toFalse, exceptions will be raised if the callback function fails.LOGGING_APPSapps to be logged.modelsmodels to be logged.fieldsis optional. If you want to log only specific fields, you can specify them here. If you want to log all fields, you can use__all__as a value.exclude_fieldsis optional. Iffieldsis not specified, all fields in the model will be logged except the ones specified here.Only fields with their own database column are loggable: reverse relations and database-generated fields (
GeneratedField) are always skipped. Many-to-many fields are supported through them2m_changedsignal (see Many-to-many fields).callbacksis optional. If you want to add a callback function to be called after logging all models in all apps, you can add it here. Callback functions must be callable objects. You can optionally specify a callback function path in your configuration. The best practice is to place your callback function in yourapp/callbacks.py. Callback functions must have three parameters as follows:# callback as a named function def your_callback(instance, fields, logs): # your code here # callback as a lambda function lambda instance, fields, logs: # your code here
instancethe model instance that is being logged.fieldslist of fields that are being logged.logsdict of logs that are being created. The key is the field name and the value is theFieldLoginstance.
- Obtains the
FIELD_LOGGER_SETTINGSfrom your respective settings file based on your environment. - Initializes
LOGGING_APPSwith the relative project paths of your models based on your configuration variable. - Binds to the
pre_saveandpost_savesignals of each loggable model, and to them2m_changedsignal of each loggable many-to-many field. - For each field specified in the configuration variable, creates a
record in the
FieldLogmodel for each instance update. - Fixture loading (
loaddata) is a restore, not a change, so it is never logged.
This section serves as a small example to demonstrate how to use this package.
Supposing you have this configuration in your settings.py file:
FIELD_LOGGER_SETTINGS = {
'LOGGING_APPS': {
'drivers': {
'models': {
'Driver': {
'fields': ['driver_name']
},
},
},
},
}Supposing you have a model called Driver with fields called
latest_speed, driver_name, driver_id:
from fieldlogger.models import FieldLog
driver = Driver.objects.last()
driver.latest_speed = 5
driver.save() # fieldlogger won't create a record since 'latest_speed' was not among the loggable fields
driver.driver_name = 'John Doe'
driver.save() # a record with this driver is created
driver.driver_name = 'Jane Doe'
driver.save() # a record with this driver is created
instance_id = driver.id
app_label = driver._meta.app_label
model_name = driver._meta.model_name
log = FieldLog.objects.filter(instance_id=instance_id, app_label=app_label, model_name=model_name).last()
print(log.field, log.old_value, log.new_value) # prints: driver_name John Doe Jane DoeSupposing you have this function in yourapp/callbacks.py which sets the
extra_data field of the FieldLog model:
def set_extra_data_for_driver_name(instance, fields, logs):
log = logs.get('driver_name')
if log:
log.extra_data = {
'name_length': len(log.new_value)
}
log.save()Then you can add this callback function to your configuration like this:
FIELD_LOGGER_SETTINGS = {
'LOGGING_APPS': {
'drivers': {
'models': {
'Driver': {
'fields': ['driver_name'],
'callbacks': [
'yourapp.callbacks.set_extra_data_for_driver_name'
]
},
},
},
},
}Note
You can also add lambda functions to your callbacks
This package provides you a django model which is called FieldLog;
which tracks each change to a model instance specified in your
configuration mapping. An example record is as follows:
{
'id': 2,
'app_label': 'drivers',
'model_name': 'driver',
'instance_id': 1,
'field': 'driver_name',
'timestamp': datetime.datetime(2024, 1, 16, 9, 1, 14, 619568, tzinfo=<UTC>), # set when the log is created
'old_value': 'John Doe',
'new_value': 'Jane Doe',
'extra_data': {}, # this is a JSONField, you can store any extra data here using callbacks or by overriding it directly
'created': False, # this is a boolean field, if it is True, it means that instance is a newly created instance
}
Additionally, FieldLog model provides the following properties:
model: returns the model class of the instance that is being logged.instance: returns the instance that is being logged.previous_log: returns the previous log of the same field of the same instance, if any.
Values are stored as JSON and converted back to Python objects when a log is loaded:
- Binary values are stored base64-encoded, so any binary content is supported.
- Foreign key values are resolved back to model instances lazily (no query until the value is accessed). If the related instance was deleted, an unsaved instance carrying only the primary key is returned, so reading old logs never fails.
- Models with a composite primary key (Django >= 5.2) are not supported.
This package provides you a mixin class which is called
FieldLoggerMixin. This mixin class provides you the following
property:
fieldlog_setsince theFieldLogmodel has not a direct relation to the model that is being logged, you can use this property to get the logs of the instance that is being logged.driver = Driver.objects.last() logs = driver.fieldlog_set.all()
Django signals are not fired on bulk operations, so changes made through
bulk_create and bulk_update are not logged by default. This
package provides a manager called FieldLoggerManager that overrides
both methods to log field changes as well:
from django.db import models
from fieldlogger.managers import FieldLoggerManager
class Driver(models.Model):
# ...
objects = FieldLoggerManager()Both methods accept two extra keyword arguments:
log_fieldsset it toFalseto skip logging for that call (default:True).run_callbacksset it toFalseto skip the configured callbacks for that call (default:True).
Driver.objects.bulk_create([Driver(driver_name='John Doe')])
Driver.objects.bulk_update(drivers, ['driver_name'], run_callbacks=False)Many-to-many changes do not go through save(), so they are logged
from the m2m_changed signal instead. Any loggable many-to-many
field gets one log per change, holding the sorted lists of related
primary keys before and after:
driver.cars.add(car1, car2)
log = driver.fieldlog_set.get(field='cars')
print(log.old_value, log.new_value) # prints: [] [1, 2]add,remove,setandclearare logged, from both sides of the relation (car.drivers.add(driver)also logs the change ondriver).- Changes made directly on an explicit
throughmodel (e.g.Membership.objects.create(...)) do not firem2m_changed, so they are not logged; this mirrors Django's own behavior.
Copyright (C) 2024 Sergio Rodríguez
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See the LICENSE file for details.