diff --git a/auditlog/__init__.py b/auditlog/__init__.py index 1c09251..75b6337 100644 --- a/auditlog/__init__.py +++ b/auditlog/__init__.py @@ -1,3 +1,3 @@ -__version__ = '0.7.2' +__version__ = "0.7.2" -default_app_config = 'auditlog.apps.AuditlogConfig' +default_app_config = "auditlog.apps.AuditlogConfig" diff --git a/auditlog/admin.py b/auditlog/admin.py index 612e640..6998900 100644 --- a/auditlog/admin.py +++ b/auditlog/admin.py @@ -6,27 +6,47 @@ from django.utils.functional import cached_property from .count import limit_query_time from .models import LogEntry from .mixins import LogEntryAdminMixin -from .filters import ShortActorFilter, ResourceTypeFilter, FieldFilter, get_timestamp_filter +from .filters import ( + ShortActorFilter, + ResourceTypeFilter, + FieldFilter, + get_timestamp_filter, +) class TimeLimitedPaginator(Paginator): """A PostgreSQL-specific paginator with a hard time limit for total count of pages.""" + @cached_property - @limit_query_time(getattr(settings, 'AUDITLOG_PAGINATOR_TIMEOUT', 500), default=100000) + @limit_query_time( + getattr(settings, "AUDITLOG_PAGINATOR_TIMEOUT", 500), default=100000 + ) def count(self): return super().count class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin): - list_display = ['created', 'resource_url', 'action', 'msg_short', 'user_url'] - search_fields = ['timestamp', 'object_repr', 'changes', 'actor__first_name', 'actor__last_name'] - list_filter = ['action', ShortActorFilter, ResourceTypeFilter, FieldFilter, ('timestamp', get_timestamp_filter())] - readonly_fields = ['created', 'resource_url', 'action', 'user_url', 'msg'] - fieldsets = [ - (None, {'fields': ['created', 'user_url', 'resource_url']}), - ('Changes', {'fields': ['action', 'msg']}), + list_display = ["created", "resource_url", "action", "msg_short", "user_url"] + search_fields = [ + "timestamp", + "object_repr", + "changes", + "actor__first_name", + "actor__last_name", ] - list_select_related = ['actor', 'content_type'] + list_filter = [ + "action", + ShortActorFilter, + ResourceTypeFilter, + FieldFilter, + ("timestamp", get_timestamp_filter()), + ] + readonly_fields = ["created", "resource_url", "action", "user_url", "msg"] + fieldsets = [ + (None, {"fields": ["created", "user_url", "resource_url"]}), + ("Changes", {"fields": ["action", "msg"]}), + ] + list_select_related = ["actor", "content_type"] show_full_result_count = False paginator = TimeLimitedPaginator diff --git a/auditlog/apps.py b/auditlog/apps.py index d7629f0..e592f8d 100644 --- a/auditlog/apps.py +++ b/auditlog/apps.py @@ -2,5 +2,5 @@ from django.apps import AppConfig class AuditlogConfig(AppConfig): - name = 'auditlog' + name = "auditlog" verbose_name = "Audit log" diff --git a/auditlog/context.py b/auditlog/context.py index d8c279d..bf95d46 100644 --- a/auditlog/context.py +++ b/auditlog/context.py @@ -16,13 +16,20 @@ def set_actor(actor, remote_addr=None): """Connect a signal receiver with current user attached.""" # Initialize thread local storage threadlocal.auditlog = { - 'signal_duid': ('set_actor', time.time()), - 'remote_addr': remote_addr, + "signal_duid": ("set_actor", time.time()), + "remote_addr": remote_addr, } # Connect signal for automatic logging - set_actor = partial(_set_actor, user=actor, signal_duid=threadlocal.auditlog['signal_duid']) - pre_save.connect(set_actor, sender=LogEntry, dispatch_uid=threadlocal.auditlog['signal_duid'], weak=False) + set_actor = partial( + _set_actor, user=actor, signal_duid=threadlocal.auditlog["signal_duid"] + ) + pre_save.connect( + set_actor, + sender=LogEntry, + dispatch_uid=threadlocal.auditlog["signal_duid"], + weak=False, + ) try: yield @@ -33,7 +40,7 @@ def set_actor(actor, remote_addr=None): except AttributeError: pass else: - pre_save.disconnect(sender=LogEntry, dispatch_uid=auditlog['signal_duid']) + pre_save.disconnect(sender=LogEntry, dispatch_uid=auditlog["signal_duid"]) def _set_actor(user, sender, instance, signal_duid, **kwargs): @@ -46,10 +53,14 @@ def _set_actor(user, sender, instance, signal_duid, **kwargs): except AttributeError: pass else: - if signal_duid != auditlog['signal_duid']: + if signal_duid != auditlog["signal_duid"]: return auth_user_model = get_user_model() - if sender == LogEntry and isinstance(user, auth_user_model) and instance.actor is None: + if ( + sender == LogEntry + and isinstance(user, auth_user_model) + and instance.actor is None + ): instance.actor = user - instance.remote_addr = auditlog['remote_addr'] + instance.remote_addr = auditlog["remote_addr"] diff --git a/auditlog/count.py b/auditlog/count.py index a206e5d..b135122 100644 --- a/auditlog/count.py +++ b/auditlog/count.py @@ -12,7 +12,7 @@ def limit_query_time(timeout, default=None): def decorator(function): def _limit_query_time(*args, **kwargs): with transaction.atomic(), connection.cursor() as cursor: - cursor.execute('SET LOCAL statement_timeout TO %s;', (timeout,)) + cursor.execute("SET LOCAL statement_timeout TO %s;", (timeout,)) try: return function(*args, **kwargs) except OperationalError: diff --git a/auditlog/diff.py b/auditlog/diff.py index 8b62d09..833db01 100644 --- a/auditlog/diff.py +++ b/auditlog/diff.py @@ -17,12 +17,16 @@ def track_field(field): :rtype: bool """ from auditlog.models import LogEntry + # Do not track many to many relations if field.many_to_many: return False # Do not track relations to LogEntry - if getattr(field, 'remote_field', None) is not None and field.remote_field.model == LogEntry: + if ( + getattr(field, "remote_field", None) is not None + and field.remote_field.model == LogEntry + ): return False return True @@ -108,16 +112,26 @@ def model_instance_diff(old, new): model_fields = None # Check if fields must be filtered - if model_fields and (model_fields['include_fields'] or model_fields['exclude_fields']) and fields: + if ( + model_fields + and (model_fields["include_fields"] or model_fields["exclude_fields"]) + and fields + ): filtered_fields = [] - if model_fields['include_fields']: - filtered_fields = [field for field in fields - if field.name in model_fields['include_fields']] + if model_fields["include_fields"]: + filtered_fields = [ + field + for field in fields + if field.name in model_fields["include_fields"] + ] else: filtered_fields = fields - if model_fields['exclude_fields']: - filtered_fields = [field for field in filtered_fields - if field.name not in model_fields['exclude_fields']] + if model_fields["exclude_fields"]: + filtered_fields = [ + field + for field in filtered_fields + if field.name not in model_fields["exclude_fields"] + ] fields = filtered_fields for field in fields: diff --git a/auditlog/filters.py b/auditlog/filters.py index 72ad50c..5dff491 100644 --- a/auditlog/filters.py +++ b/auditlog/filters.py @@ -11,8 +11,8 @@ from auditlog.registry import auditlog class ShortActorFilter(SimpleListFilter): - title = 'Actor' - parameter_name = 'actor' + title = "Actor" + parameter_name = "actor" def lookups(self, request, model_admin): return [("null", "System"), ("not_null", "Users")] @@ -27,21 +27,21 @@ class ShortActorFilter(SimpleListFilter): class ResourceTypeFilter(SimpleListFilter): - title = 'Resource Type' - parameter_name = 'resource_type' + title = "Resource Type" + parameter_name = "resource_type" def lookups(self, request, model_admin): tracked_model_names = [ - '{}.{}'.format(m._meta.app_label, m._meta.model_name) + "{}.{}".format(m._meta.app_label, m._meta.model_name) for m in auditlog.list() ] - model_name_concat = Concat('app_label', Value('.'), 'model') + model_name_concat = Concat("app_label", Value("."), "model") content_types = ContentType.objects.annotate( model_name=model_name_concat, ).filter( model_name__in=tracked_model_names, ) - return content_types.order_by('model_name').values_list('id', 'model_name') + return content_types.order_by("model_name").values_list("id", "model_name") def queryset(self, request, queryset): if self.value() is None: @@ -50,8 +50,8 @@ class ResourceTypeFilter(SimpleListFilter): class FieldFilter(SimpleListFilter): - title = 'Field' - parameter_name = 'field' + title = "Field" + parameter_name = "field" parent = ResourceTypeFilter def __init__(self, request, *args, **kwargs): @@ -68,19 +68,20 @@ class FieldFilter(SimpleListFilter): return ContentType.objects.get(id=content_type_id).model_class() def lookups(self, request, model_admin): - if connection.vendor != 'postgresql': + if connection.vendor != "postgresql": # filtering inside JSON is PostgreSQL-specific for now return [] if not self.target_model: return [] - return sorted((field.name, field.name) for field in self.target_model._meta.fields) + return sorted( + (field.name, field.name) for field in self.target_model._meta.fields + ) def queryset(self, request, queryset): if self.value() is None: return queryset - return ( - queryset.annotate(changes_json=Cast("changes", JSONField())) - .filter(**{'changes_json__{}__isnull'.format(self.value()): False}) + return queryset.annotate(changes_json=Cast("changes", JSONField())).filter( + **{"changes_json__{}__isnull".format(self.value()): False} ) @@ -89,6 +90,7 @@ def get_timestamp_filter(): if apps.is_installed("rangefilter"): try: from rangefilter.filter import DateTimeRangeFilter + return DateTimeRangeFilter except ImportError: pass diff --git a/auditlog/management/commands/auditlogflush.py b/auditlog/management/commands/auditlogflush.py index ecf4bd7..a2becd4 100644 --- a/auditlog/management/commands/auditlogflush.py +++ b/auditlog/management/commands/auditlogflush.py @@ -7,16 +7,25 @@ class Command(BaseCommand): help = "Deletes all log entries from the database." def add_arguments(self, parser): - parser.add_argument('-y, --yes', action='store_true', default=None, - help="Continue without asking confirmation.", dest='yes') + parser.add_argument( + "-y, --yes", + action="store_true", + default=None, + help="Continue without asking confirmation.", + dest="yes", + ) def handle(self, *args, **options): - answer = options['yes'] + answer = options["yes"] if answer is None: - self.stdout.write("This action will clear all log entries from the database.") - response = input("Are you sure you want to continue? [y/N]: ").lower().strip() - answer = response == 'y' + self.stdout.write( + "This action will clear all log entries from the database." + ) + response = ( + input("Are you sure you want to continue? [y/N]: ").lower().strip() + ) + answer = response == "y" if answer: count, _ = LogEntry.objects.all().delete() diff --git a/auditlog/middleware.py b/auditlog/middleware.py index 9422232..6ce6ebb 100644 --- a/auditlog/middleware.py +++ b/auditlog/middleware.py @@ -20,13 +20,13 @@ class AuditlogMiddleware(object): def __call__(self, request): - if request.META.get('HTTP_X_FORWARDED_FOR'): + if request.META.get("HTTP_X_FORWARDED_FOR"): # In case of proxy, set 'original' address - remote_addr = request.META.get('HTTP_X_FORWARDED_FOR').split(',')[0] + remote_addr = request.META.get("HTTP_X_FORWARDED_FOR").split(",")[0] else: - remote_addr = request.META.get('REMOTE_ADDR') + remote_addr = request.META.get("REMOTE_ADDR") - if hasattr(request, 'user') and request.user.is_authenticated: + if hasattr(request, "user") and request.user.is_authenticated: context = set_actor(actor=request.user, remote_addr=remote_addr) else: context = nullcontext() diff --git a/auditlog/migrations/0001_initial.py b/auditlog/migrations/0001_initial.py index 7abec05..0e2bd55 100644 --- a/auditlog/migrations/0001_initial.py +++ b/auditlog/migrations/0001_initial.py @@ -7,28 +7,71 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('contenttypes', '0001_initial'), + ("contenttypes", "0001_initial"), ] operations = [ migrations.CreateModel( - name='LogEntry', + name="LogEntry", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('object_pk', models.TextField(verbose_name='object pk')), - ('object_id', models.PositiveIntegerField(db_index=True, null=True, verbose_name='object id', blank=True)), - ('object_repr', models.TextField(verbose_name='object representation')), - ('action', models.PositiveSmallIntegerField(verbose_name='action', choices=[(0, 'create'), (1, 'update'), (2, 'delete')])), - ('changes', models.TextField(verbose_name='change message', blank=True)), - ('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='timestamp')), - ('actor', models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.SET_NULL, verbose_name='actor', blank=True, to=settings.AUTH_USER_MODEL, null=True)), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', verbose_name='content type', to='contenttypes.ContentType')), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("object_pk", models.TextField(verbose_name="object pk")), + ( + "object_id", + models.PositiveIntegerField( + db_index=True, null=True, verbose_name="object id", blank=True + ), + ), + ("object_repr", models.TextField(verbose_name="object representation")), + ( + "action", + models.PositiveSmallIntegerField( + verbose_name="action", + choices=[(0, "create"), (1, "update"), (2, "delete")], + ), + ), + ( + "changes", + models.TextField(verbose_name="change message", blank=True), + ), + ( + "timestamp", + models.DateTimeField(auto_now_add=True, verbose_name="timestamp"), + ), + ( + "actor", + models.ForeignKey( + related_name="+", + on_delete=django.db.models.deletion.SET_NULL, + verbose_name="actor", + blank=True, + to=settings.AUTH_USER_MODEL, + null=True, + ), + ), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + verbose_name="content type", + to="contenttypes.ContentType", + ), + ), ], options={ - 'ordering': ['-timestamp'], - 'get_latest_by': 'timestamp', - 'verbose_name': 'log entry', - 'verbose_name_plural': 'log entries', + "ordering": ["-timestamp"], + "get_latest_by": "timestamp", + "verbose_name": "log entry", + "verbose_name_plural": "log entries", }, bases=(models.Model,), ), diff --git a/auditlog/migrations/0002_auto_support_long_primary_keys.py b/auditlog/migrations/0002_auto_support_long_primary_keys.py index e34b3c9..01c7d6e 100644 --- a/auditlog/migrations/0002_auto_support_long_primary_keys.py +++ b/auditlog/migrations/0002_auto_support_long_primary_keys.py @@ -4,13 +4,15 @@ from django.db import models, migrations class Migration(migrations.Migration): dependencies = [ - ('auditlog', '0001_initial'), + ("auditlog", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='logentry', - name='object_id', - field=models.BigIntegerField(db_index=True, null=True, verbose_name='object id', blank=True), + model_name="logentry", + name="object_id", + field=models.BigIntegerField( + db_index=True, null=True, verbose_name="object id", blank=True + ), ), ] diff --git a/auditlog/migrations/0003_logentry_remote_addr.py b/auditlog/migrations/0003_logentry_remote_addr.py index adf2c89..706dc4f 100644 --- a/auditlog/migrations/0003_logentry_remote_addr.py +++ b/auditlog/migrations/0003_logentry_remote_addr.py @@ -4,13 +4,15 @@ from django.db import models, migrations class Migration(migrations.Migration): dependencies = [ - ('auditlog', '0002_auto_support_long_primary_keys'), + ("auditlog", "0002_auto_support_long_primary_keys"), ] operations = [ migrations.AddField( - model_name='logentry', - name='remote_addr', - field=models.GenericIPAddressField(null=True, verbose_name='remote address', blank=True), + model_name="logentry", + name="remote_addr", + field=models.GenericIPAddressField( + null=True, verbose_name="remote address", blank=True + ), ), ] diff --git a/auditlog/migrations/0004_logentry_detailed_object_repr.py b/auditlog/migrations/0004_logentry_detailed_object_repr.py index 370b3a6..cd194b2 100644 --- a/auditlog/migrations/0004_logentry_detailed_object_repr.py +++ b/auditlog/migrations/0004_logentry_detailed_object_repr.py @@ -5,13 +5,13 @@ import jsonfield.fields class Migration(migrations.Migration): dependencies = [ - ('auditlog', '0003_logentry_remote_addr'), + ("auditlog", "0003_logentry_remote_addr"), ] operations = [ migrations.AddField( - model_name='logentry', - name='additional_data', + model_name="logentry", + name="additional_data", field=jsonfield.fields.JSONField(null=True, blank=True), ), ] diff --git a/auditlog/migrations/0005_logentry_additional_data_verbose_name.py b/auditlog/migrations/0005_logentry_additional_data_verbose_name.py index 2b8efdd..f04e131 100644 --- a/auditlog/migrations/0005_logentry_additional_data_verbose_name.py +++ b/auditlog/migrations/0005_logentry_additional_data_verbose_name.py @@ -5,13 +5,15 @@ import jsonfield.fields class Migration(migrations.Migration): dependencies = [ - ('auditlog', '0004_logentry_detailed_object_repr'), + ("auditlog", "0004_logentry_detailed_object_repr"), ] operations = [ migrations.AlterField( - model_name='logentry', - name='additional_data', - field=jsonfield.fields.JSONField(null=True, verbose_name='additional data', blank=True), + model_name="logentry", + name="additional_data", + field=jsonfield.fields.JSONField( + null=True, verbose_name="additional data", blank=True + ), ), ] diff --git a/auditlog/migrations/0006_object_pk_index.py b/auditlog/migrations/0006_object_pk_index.py index ac431c0..729ebe2 100644 --- a/auditlog/migrations/0006_object_pk_index.py +++ b/auditlog/migrations/0006_object_pk_index.py @@ -4,13 +4,15 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('auditlog', '0005_logentry_additional_data_verbose_name'), + ("auditlog", "0005_logentry_additional_data_verbose_name"), ] operations = [ migrations.AlterField( - model_name='logentry', - name='object_pk', - field=models.CharField(verbose_name='object pk', max_length=255, db_index=True), + model_name="logentry", + name="object_pk", + field=models.CharField( + verbose_name="object pk", max_length=255, db_index=True + ), ), ] diff --git a/auditlog/migrations/0007_object_pk_type.py b/auditlog/migrations/0007_object_pk_type.py index 275db7e..d6514e4 100644 --- a/auditlog/migrations/0007_object_pk_type.py +++ b/auditlog/migrations/0007_object_pk_type.py @@ -4,13 +4,15 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('auditlog', '0006_object_pk_index'), + ("auditlog", "0006_object_pk_index"), ] operations = [ migrations.AlterField( - model_name='logentry', - name='object_pk', - field=models.CharField(verbose_name='object pk', max_length=255, db_index=True), + model_name="logentry", + name="object_pk", + field=models.CharField( + verbose_name="object pk", max_length=255, db_index=True + ), ), ] diff --git a/auditlog/migrations/0008_timestamp_index.py b/auditlog/migrations/0008_timestamp_index.py index d79eab8..88c1128 100644 --- a/auditlog/migrations/0008_timestamp_index.py +++ b/auditlog/migrations/0008_timestamp_index.py @@ -5,13 +5,15 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('auditlog', '0007_object_pk_type'), + ("auditlog", "0007_object_pk_type"), ] operations = [ migrations.AlterField( - model_name='logentry', - name='timestamp', - field=models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='timestamp'), + model_name="logentry", + name="timestamp", + field=models.DateTimeField( + auto_now_add=True, db_index=True, verbose_name="timestamp" + ), ), ] diff --git a/auditlog/migrations/0009_timestamp_id_index.py b/auditlog/migrations/0009_timestamp_id_index.py index 491885d..1afce24 100644 --- a/auditlog/migrations/0009_timestamp_id_index.py +++ b/auditlog/migrations/0009_timestamp_id_index.py @@ -5,17 +5,17 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('auditlog', '0008_timestamp_index'), + ("auditlog", "0008_timestamp_index"), ] operations = [ migrations.AlterIndexTogether( - name='logentry', - index_together={('timestamp', 'id')}, + name="logentry", + index_together={("timestamp", "id")}, ), migrations.AlterField( - model_name='logentry', - name='timestamp', - field=models.DateTimeField(auto_now_add=True, verbose_name='timestamp'), + model_name="logentry", + name="timestamp", + field=models.DateTimeField(auto_now_add=True, verbose_name="timestamp"), ), ] diff --git a/auditlog/mixins.py b/auditlog/mixins.py index 9fc7364..672ee40 100644 --- a/auditlog/mixins.py +++ b/auditlog/mixins.py @@ -13,62 +13,65 @@ MAX = 75 class LogEntryAdminMixin(object): - def created(self, obj): - return localtime(obj.timestamp).strftime('%Y-%m-%d %H:%M:%S') + return localtime(obj.timestamp).strftime("%Y-%m-%d %H:%M:%S") - created.short_description = 'Created' + created.short_description = "Created" def user_url(self, obj): if obj.actor: - app_label, model = settings.AUTH_USER_MODEL.split('.') - viewname = 'admin:%s_%s_change' % (app_label, model.lower()) + app_label, model = settings.AUTH_USER_MODEL.split(".") + viewname = "admin:%s_%s_change" % (app_label, model.lower()) try: link = urlresolvers.reverse(viewname, args=[obj.actor.pk]) except NoReverseMatch: - return u'%s' % (obj.actor) - return format_html(u'{}', link, obj.actor) + return "%s" % (obj.actor) + return format_html('{}', link, obj.actor) - return 'system' + return "system" - user_url.short_description = 'User' + user_url.short_description = "User" def resource_url(self, obj): app_label, model = obj.content_type.app_label, obj.content_type.model - viewname = 'admin:%s_%s_change' % (app_label, model) + viewname = "admin:%s_%s_change" % (app_label, model) try: args = [obj.object_pk] if obj.object_id is None else [obj.object_id] link = urlresolvers.reverse(viewname, args=args) except NoReverseMatch: return obj.object_repr else: - return format_html(u'{}', link, obj.object_repr) + return format_html('{}', link, obj.object_repr) - resource_url.short_description = 'Resource' + resource_url.short_description = "Resource" def msg_short(self, obj): if obj.action == LogEntry.Action.DELETE: - return '' # delete + return "" # delete changes = json.loads(obj.changes) - s = '' if len(changes) == 1 else 's' - fields = ', '.join(changes.keys()) + s = "" if len(changes) == 1 else "s" + fields = ", ".join(changes.keys()) if len(fields) > MAX: - i = fields.rfind(' ', 0, MAX) - fields = fields[:i] + ' ..' - return '%d change%s: %s' % (len(changes), s, fields) + i = fields.rfind(" ", 0, MAX) + fields = fields[:i] + " .." + return "%d change%s: %s" % (len(changes), s, fields) - msg_short.short_description = 'Changes' + msg_short.short_description = "Changes" def msg(self, obj): if obj.action == LogEntry.Action.DELETE: - return '' # delete + return "" # delete changes = json.loads(obj.changes) - msg = '' + msg = "
#FieldFromTo
" for i, field in enumerate(sorted(changes), 1): - value = [i, field] + (['***', '***'] if field == 'password' else changes[field]) - msg += format_html('', *value) + value = [i, field] + ( + ["***", "***"] if field == "password" else changes[field] + ) + msg += format_html( + "", *value + ) - msg += '
#FieldFromTo
{}{}{}{}
{}{}{}{}
' + msg += "" return mark_safe(msg) - msg.short_description = 'Changes' + msg.short_description = "Changes" diff --git a/auditlog/models.py b/auditlog/models.py index ce0d41b..3dda7ed 100644 --- a/auditlog/models.py +++ b/auditlog/models.py @@ -31,31 +31,49 @@ class LogEntryManager(models.Manager): :return: The new log entry or `None` if there were no changes. :rtype: LogEntry """ - changes = kwargs.get('changes', None) + changes = kwargs.get("changes", None) pk = self._get_pk_value(instance) if changes is not None: - kwargs.setdefault('content_type', ContentType.objects.get_for_model(instance)) - kwargs.setdefault('object_pk', pk) - kwargs.setdefault('object_repr', smart_str(instance)) + kwargs.setdefault( + "content_type", ContentType.objects.get_for_model(instance) + ) + kwargs.setdefault("object_pk", pk) + kwargs.setdefault("object_repr", smart_str(instance)) if isinstance(pk, int): - kwargs.setdefault('object_id', pk) + kwargs.setdefault("object_id", pk) - get_additional_data = getattr(instance, 'get_additional_data', None) + get_additional_data = getattr(instance, "get_additional_data", None) if callable(get_additional_data): - kwargs.setdefault('additional_data', get_additional_data()) + kwargs.setdefault("additional_data", get_additional_data()) # Delete log entries with the same pk as a newly created model. This should only be necessary when an pk is # used twice. - if kwargs.get('action', None) is LogEntry.Action.CREATE: - if kwargs.get('object_id', None) is not None and self.filter(content_type=kwargs.get('content_type'), object_id=kwargs.get('object_id')).exists(): - self.filter(content_type=kwargs.get('content_type'), object_id=kwargs.get('object_id')).delete() + if kwargs.get("action", None) is LogEntry.Action.CREATE: + if ( + kwargs.get("object_id", None) is not None + and self.filter( + content_type=kwargs.get("content_type"), + object_id=kwargs.get("object_id"), + ).exists() + ): + self.filter( + content_type=kwargs.get("content_type"), + object_id=kwargs.get("object_id"), + ).delete() else: - self.filter(content_type=kwargs.get('content_type'), object_pk=kwargs.get('object_pk', '')).delete() + self.filter( + content_type=kwargs.get("content_type"), + object_pk=kwargs.get("object_pk", ""), + ).delete() # save LogEntry to same database instance is using db = instance._state.db - return self.create(**kwargs) if db is None or db == '' else self.using(db).create(**kwargs) + return ( + self.create(**kwargs) + if db is None or db == "" + else self.using(db).create(**kwargs) + ) return None def get_for_object(self, instance): @@ -92,15 +110,29 @@ class LogEntryManager(models.Manager): return self.none() content_type = ContentType.objects.get_for_model(queryset.model) - primary_keys = list(queryset.values_list(queryset.model._meta.pk.name, flat=True)) + primary_keys = list( + queryset.values_list(queryset.model._meta.pk.name, flat=True) + ) if isinstance(primary_keys[0], int): - return self.filter(content_type=content_type).filter(Q(object_id__in=primary_keys)).distinct() + return ( + self.filter(content_type=content_type) + .filter(Q(object_id__in=primary_keys)) + .distinct() + ) elif isinstance(queryset.model._meta.pk, models.UUIDField): primary_keys = [smart_str(pk) for pk in primary_keys] - return self.filter(content_type=content_type).filter(Q(object_pk__in=primary_keys)).distinct() + return ( + self.filter(content_type=content_type) + .filter(Q(object_pk__in=primary_keys)) + .distinct() + ) else: - return self.filter(content_type=content_type).filter(Q(object_pk__in=primary_keys)).distinct() + return ( + self.filter(content_type=content_type) + .filter(Q(object_pk__in=primary_keys)) + .distinct() + ) def get_for_model(self, model): """ @@ -156,6 +188,7 @@ class LogEntry(models.Model): The valid actions are :py:attr:`Action.CREATE`, :py:attr:`Action.UPDATE` and :py:attr:`Action.DELETE`. """ + CREATE = 0 UPDATE = 1 DELETE = 2 @@ -166,25 +199,47 @@ class LogEntry(models.Model): (DELETE, _("delete")), ) - content_type = models.ForeignKey(to='contenttypes.ContentType', on_delete=models.CASCADE, related_name='+', verbose_name=_("content type")) - object_pk = models.CharField(db_index=True, max_length=255, verbose_name=_("object pk")) - object_id = models.BigIntegerField(blank=True, db_index=True, null=True, verbose_name=_("object id")) + content_type = models.ForeignKey( + to="contenttypes.ContentType", + on_delete=models.CASCADE, + related_name="+", + verbose_name=_("content type"), + ) + object_pk = models.CharField( + db_index=True, max_length=255, verbose_name=_("object pk") + ) + object_id = models.BigIntegerField( + blank=True, db_index=True, null=True, verbose_name=_("object id") + ) object_repr = models.TextField(verbose_name=_("object representation")) - action = models.PositiveSmallIntegerField(choices=Action.choices, verbose_name=_("action")) + action = models.PositiveSmallIntegerField( + choices=Action.choices, verbose_name=_("action") + ) changes = models.TextField(blank=True, verbose_name=_("change message")) - actor = models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, blank=True, null=True, related_name='+', verbose_name=_("actor")) - remote_addr = models.GenericIPAddressField(blank=True, null=True, verbose_name=_("remote address")) + actor = models.ForeignKey( + to=settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="+", + verbose_name=_("actor"), + ) + remote_addr = models.GenericIPAddressField( + blank=True, null=True, verbose_name=_("remote address") + ) timestamp = models.DateTimeField(auto_now_add=True, verbose_name=_("timestamp")) - additional_data = JSONField(blank=True, null=True, verbose_name=_("additional data")) + additional_data = JSONField( + blank=True, null=True, verbose_name=_("additional data") + ) objects = LogEntryManager() class Meta: - get_latest_by = 'timestamp' - ordering = ['-timestamp'] + get_latest_by = "timestamp" + ordering = ["-timestamp"] verbose_name = _("log entry") verbose_name_plural = _("log entries") - index_together = (("timestamp", "id")) + index_together = ("timestamp", "id") def __str__(self): if self.action == self.Action.CREATE: @@ -209,7 +264,7 @@ class LogEntry(models.Model): return {} @property - def changes_str(self, colon=': ', arrow=' \u2192 ', separator='; '): + def changes_str(self, colon=": ", arrow=" \u2192 ", separator="; "): """ Return the changes recorded in this log entry as a string. The formatting of the string can be customized by setting alternate values for colon, arrow and separator. If the formatting is still not satisfying, please use @@ -223,7 +278,7 @@ class LogEntry(models.Model): substrings = [] for field, values in self.changes_dict.items(): - substring = '{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}'.format( + substring = "{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}".format( field_name=field, colon=colon, old=values[0], @@ -241,6 +296,7 @@ class LogEntry(models.Model): """ # Get the model and model_fields from auditlog.registry import auditlog + model = self.content_type.model_class() model_fields = auditlog.get_model_fields(model._meta.model) changes_display_dict = {} @@ -255,9 +311,9 @@ class LogEntry(models.Model): values_display = [] # handle choices fields and Postgres ArrayField to get human readable version choices_dict = None - if getattr(field, 'choices', []): + if getattr(field, "choices", []): choices_dict = dict(field.choices) - if getattr(getattr(field, 'base_field', None), 'choices', []): + if getattr(getattr(field, "base_field", None), "choices", []): choices_dict = dict(field.base_field.choices) if choices_dict: @@ -265,13 +321,17 @@ class LogEntry(models.Model): try: value = ast.literal_eval(value) if type(value) is [].__class__: - values_display.append(', '.join([choices_dict.get(val, 'None') for val in value])) + values_display.append( + ", ".join( + [choices_dict.get(val, "None") for val in value] + ) + ) else: - values_display.append(choices_dict.get(value, 'None')) + values_display.append(choices_dict.get(value, "None")) except ValueError: - values_display.append(choices_dict.get(value, 'None')) + values_display.append(choices_dict.get(value, "None")) except: - values_display.append(choices_dict.get(value, 'None')) + values_display.append(choices_dict.get(value, "None")) else: try: field_type = field.get_internal_type() @@ -298,7 +358,9 @@ class LogEntry(models.Model): value = "{}...".format(value[:140]) values_display.append(value) - verbose_name = model_fields['mapping_fields'].get(field.name, getattr(field, 'verbose_name', field.name)) + verbose_name = model_fields["mapping_fields"].get( + field.name, getattr(field, "verbose_name", field.name) + ) changes_display_dict[verbose_name] = values_display return changes_display_dict @@ -324,14 +386,14 @@ class AuditlogHistoryField(GenericRelation): """ def __init__(self, pk_indexable=True, delete_related=True, **kwargs): - kwargs['to'] = LogEntry + kwargs["to"] = LogEntry if pk_indexable: - kwargs['object_id_field'] = 'object_id' + kwargs["object_id_field"] = "object_id" else: - kwargs['object_id_field'] = 'object_pk' + kwargs["object_id_field"] = "object_pk" - kwargs['content_type_field'] = 'content_type' + kwargs["content_type_field"] = "content_type" self.delete_related = delete_related super(AuditlogHistoryField, self).__init__(**kwargs) @@ -353,6 +415,8 @@ try: from south.modelsinspector import add_introspection_rules add_introspection_rules([], ["^auditlog\.models\.AuditlogHistoryField"]) - raise DeprecationWarning("South support will be dropped in django-auditlog 0.4.0 or later.") + raise DeprecationWarning( + "South support will be dropped in django-auditlog 0.4.0 or later." + ) except ImportError: pass diff --git a/auditlog/registry.py b/auditlog/registry.py index 5f6403c..6f9df97 100644 --- a/auditlog/registry.py +++ b/auditlog/registry.py @@ -12,8 +12,13 @@ class AuditlogModelRegistry(object): A registry that keeps track of the models that use Auditlog to track changes. """ - def __init__(self, create: bool = True, update: bool = True, delete: bool = True, - custom: Optional[Dict[ModelSignal, Callable]] = None): + def __init__( + self, + create: bool = True, + update: bool = True, + delete: bool = True, + custom: Optional[Dict[ModelSignal, Callable]] = None, + ): from auditlog.receivers import log_create, log_update, log_delete self._registry = {} @@ -29,8 +34,13 @@ class AuditlogModelRegistry(object): if custom is not None: self._signals.update(custom) - def register(self, model: ModelBase = None, include_fields: Optional[List[str]] = None, - exclude_fields: Optional[List[str]] = None, mapping_fields: Optional[Dict[str, str]] = None): + def register( + self, + model: ModelBase = None, + include_fields: Optional[List[str]] = None, + exclude_fields: Optional[List[str]] = None, + mapping_fields: Optional[Dict[str, str]] = None, + ): """ Register a model with auditlog. Auditlog will then track mutations on this model's instances. @@ -54,9 +64,9 @@ class AuditlogModelRegistry(object): raise TypeError("Supplied model is not a valid model.") self._registry[cls] = { - 'include_fields': include_fields, - 'exclude_fields': exclude_fields, - 'mapping_fields': mapping_fields, + "include_fields": include_fields, + "exclude_fields": exclude_fields, + "mapping_fields": mapping_fields, } self._connect_signals(cls) @@ -104,9 +114,9 @@ class AuditlogModelRegistry(object): def get_model_fields(self, model: ModelBase): return { - 'include_fields': list(self._registry[model]['include_fields']), - 'exclude_fields': list(self._registry[model]['exclude_fields']), - 'mapping_fields': dict(self._registry[model]['mapping_fields']), + "include_fields": list(self._registry[model]["include_fields"]), + "exclude_fields": list(self._registry[model]["exclude_fields"]), + "mapping_fields": dict(self._registry[model]["mapping_fields"]), } def _connect_signals(self, model): @@ -115,14 +125,18 @@ class AuditlogModelRegistry(object): """ for signal in self._signals: receiver = self._signals[signal] - signal.connect(receiver, sender=model, dispatch_uid=self._dispatch_uid(signal, model)) + signal.connect( + receiver, sender=model, dispatch_uid=self._dispatch_uid(signal, model) + ) def _disconnect_signals(self, model): """ Disconnect signals for the model. """ for signal, receiver in self._signals.items(): - signal.disconnect(sender=model, dispatch_uid=self._dispatch_uid(signal, model)) + signal.disconnect( + sender=model, dispatch_uid=self._dispatch_uid(signal, model) + ) def _dispatch_uid(self, signal, model) -> DispatchUID: """ diff --git a/auditlog_tests/__init__.py b/auditlog_tests/__init__.py index 1f12d80..fa61512 100644 --- a/auditlog_tests/__init__.py +++ b/auditlog_tests/__init__.py @@ -1 +1 @@ -default_app_config = 'auditlog_tests.apps.AuditlogTestConfig' +default_app_config = "auditlog_tests.apps.AuditlogTestConfig" diff --git a/auditlog_tests/apps.py b/auditlog_tests/apps.py index 85148cb..2bcc080 100644 --- a/auditlog_tests/apps.py +++ b/auditlog_tests/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class AuditlogTestConfig(AppConfig): - name = 'auditlog_tests' + name = "auditlog_tests" diff --git a/auditlog_tests/models.py b/auditlog_tests/models.py index 50b5833..86b135d 100644 --- a/auditlog_tests/models.py +++ b/auditlog_tests/models.py @@ -66,7 +66,7 @@ class RelatedModel(models.Model): A model with a foreign key. """ - related = models.ForeignKey(to='self', on_delete=models.CASCADE) + related = models.ForeignKey(to="self", on_delete=models.CASCADE) history = AuditlogHistoryField() @@ -76,12 +76,12 @@ class ManyRelatedModel(models.Model): A model with a many to many relation. """ - related = models.ManyToManyField('self') + related = models.ManyToManyField("self") history = AuditlogHistoryField() -@auditlog.register(include_fields=['label']) +@auditlog.register(include_fields=["label"]) class SimpleIncludeModel(models.Model): """ A simple model used for register's include_fields kwarg @@ -110,7 +110,7 @@ class SimpleMappingModel(models.Model): """ sku = models.CharField(max_length=100) - vtxt = models.CharField(verbose_name='Version', max_length=100) + vtxt = models.CharField(verbose_name="Version", max_length=100) not_mapped = models.CharField(max_length=100) history = AuditlogHistoryField() @@ -135,8 +135,8 @@ class AdditionalDataIncludedModel(models.Model): manager and added to each logentry instance on creation. """ object_details = { - 'related_model_id': self.related.id, - 'related_model_text': self.related.text + "related_model_id": self.related.id, + "related_model_text": self.related.text, } return object_details @@ -146,6 +146,7 @@ class DateTimeFieldModel(models.Model): A model with a DateTimeField, used to test DateTimeField changes are detected properly. """ + label = models.CharField(max_length=100) timestamp = models.DateTimeField() date = models.DateField() @@ -160,14 +161,15 @@ class ChoicesFieldModel(models.Model): A model with a CharField restricted to a set of choices. This model is used to test the changes_display_dict method. """ - RED = 'r' - YELLOW = 'y' - GREEN = 'g' + + RED = "r" + YELLOW = "y" + GREEN = "g" STATUS_CHOICES = ( - (RED, 'Red'), - (YELLOW, 'Yellow'), - (GREEN, 'Green'), + (RED, "Red"), + (YELLOW, "Yellow"), + (GREEN, "Green"), ) status = models.CharField(max_length=1, choices=STATUS_CHOICES) @@ -194,17 +196,20 @@ class PostgresArrayFieldModel(models.Model): """ Test auditlog with Postgres's ArrayField """ - RED = 'r' - YELLOW = 'y' - GREEN = 'g' + + RED = "r" + YELLOW = "y" + GREEN = "g" STATUS_CHOICES = ( - (RED, 'Red'), - (YELLOW, 'Yellow'), - (GREEN, 'Green'), + (RED, "Red"), + (YELLOW, "Yellow"), + (GREEN, "Green"), ) - arrayfield = ArrayField(models.CharField(max_length=1, choices=STATUS_CHOICES), size=3) + arrayfield = ArrayField( + models.CharField(max_length=1, choices=STATUS_CHOICES), size=3 + ) history = AuditlogHistoryField() @@ -221,8 +226,8 @@ auditlog.register(ProxyModel) auditlog.register(RelatedModel) auditlog.register(ManyRelatedModel) auditlog.register(ManyRelatedModel.related.through) -auditlog.register(SimpleExcludeModel, exclude_fields=['text']) -auditlog.register(SimpleMappingModel, mapping_fields={'sku': 'Product No.'}) +auditlog.register(SimpleExcludeModel, exclude_fields=["text"]) +auditlog.register(SimpleMappingModel, mapping_fields={"sku": "Product No."}) auditlog.register(AdditionalDataIncludedModel) auditlog.register(DateTimeFieldModel) auditlog.register(ChoicesFieldModel) diff --git a/auditlog_tests/test_settings.py b/auditlog_tests/test_settings.py index 31e4610..7bb9a3a 100644 --- a/auditlog_tests/test_settings.py +++ b/auditlog_tests/test_settings.py @@ -3,52 +3,54 @@ Settings file for the Auditlog test suite. """ import os -SECRET_KEY = 'test' +SECRET_KEY = "test" INSTALLED_APPS = [ - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.messages', - 'django.contrib.sessions', - 'django.contrib.admin', - 'auditlog', - 'auditlog_tests', - 'multiselectfield', + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.messages", + "django.contrib.sessions", + "django.contrib.admin", + "auditlog", + "auditlog_tests", + "multiselectfield", ] MIDDLEWARE = [ - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'auditlog.middleware.AuditlogMiddleware', + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "auditlog.middleware.AuditlogMiddleware", ] DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': os.getenv('TEST_DB_NAME', 'auditlog' + os.environ.get("TOX_PARALLEL_ENV", "")), - 'USER': os.getenv('TEST_DB_USER', 'postgres'), - 'PASSWORD': os.getenv('TEST_DB_PASS', ''), - 'HOST': os.getenv('TEST_DB_HOST', '127.0.0.1'), - 'PORT': os.getenv('TEST_DB_PORT', '5432'), + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv( + "TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "") + ), + "USER": os.getenv("TEST_DB_USER", "postgres"), + "PASSWORD": os.getenv("TEST_DB_PASS", ""), + "HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"), + "PORT": os.getenv("TEST_DB_PORT", "5432"), } } TEMPLATES = [ { - 'APP_DIRS': True, - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'OPTIONS': { - 'context_processors': [ - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "APP_DIRS": True, + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "OPTIONS": { + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ] }, }, ] -ROOT_URLCONF = 'auditlog_tests.urls' +ROOT_URLCONF = "auditlog_tests.urls" USE_TZ = True diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py index c944deb..0817e24 100644 --- a/auditlog_tests/tests.py +++ b/auditlog_tests/tests.py @@ -10,15 +10,28 @@ import mock from auditlog.middleware import AuditlogMiddleware from auditlog.models import LogEntry from auditlog.registry import auditlog -from auditlog_tests.models import SimpleModel, AltPrimaryKeyModel, UUIDPrimaryKeyModel, \ - ProxyModel, SimpleIncludeModel, SimpleExcludeModel, SimpleMappingModel, RelatedModel, \ - ManyRelatedModel, AdditionalDataIncludedModel, DateTimeFieldModel, ChoicesFieldModel, \ - CharfieldTextfieldModel, PostgresArrayFieldModel, NoDeleteHistoryModel +from auditlog_tests.models import ( + SimpleModel, + AltPrimaryKeyModel, + UUIDPrimaryKeyModel, + ProxyModel, + SimpleIncludeModel, + SimpleExcludeModel, + SimpleMappingModel, + RelatedModel, + ManyRelatedModel, + AdditionalDataIncludedModel, + DateTimeFieldModel, + ChoicesFieldModel, + CharfieldTextfieldModel, + PostgresArrayFieldModel, + NoDeleteHistoryModel, +) class SimpleModelTest(TestCase): def setUp(self): - self.obj = SimpleModel.objects.create(text='I am not difficult.') + self.obj = SimpleModel.objects.create(text="I am not difficult.") def test_create(self): """Creation is logged correctly.""" @@ -33,8 +46,12 @@ class SimpleModelTest(TestCase): except obj.history.DoesNotExist: self.assertTrue(False, "Log entry exists") else: - self.assertEqual(history.action, LogEntry.Action.CREATE, msg="Action is 'CREATE'") - self.assertEqual(history.object_repr, str(obj), msg="Representation is equal") + self.assertEqual( + history.action, LogEntry.Action.CREATE, msg="Action is 'CREATE'" + ) + self.assertEqual( + history.object_repr, str(obj), msg="Representation is equal" + ) def test_update(self): """Updates are logged correctly.""" @@ -46,11 +63,18 @@ class SimpleModelTest(TestCase): obj.save() # Check for log entries - self.assertTrue(obj.history.filter(action=LogEntry.Action.UPDATE).count() == 1, msg="There is one log entry for 'UPDATE'") + self.assertTrue( + obj.history.filter(action=LogEntry.Action.UPDATE).count() == 1, + msg="There is one log entry for 'UPDATE'", + ) history = obj.history.get(action=LogEntry.Action.UPDATE) - self.assertJSONEqual(history.changes, '{"boolean": ["False", "True"]}', msg="The change is correctly logged") + self.assertJSONEqual( + history.changes, + '{"boolean": ["False", "True"]}', + msg="The change is correctly logged", + ) def test_delete(self): """Deletion is logged correctly.""" @@ -63,7 +87,15 @@ class SimpleModelTest(TestCase): obj.delete() # Check for log entries - self.assertTrue(LogEntry.objects.filter(content_type=history.content_type, object_pk=history.object_pk, action=LogEntry.Action.DELETE).count() == 1, msg="There is one log entry for 'DELETE'") + self.assertTrue( + LogEntry.objects.filter( + content_type=history.content_type, + object_pk=history.object_pk, + action=LogEntry.Action.DELETE, + ).count() + == 1, + msg="There is one log entry for 'DELETE'", + ) def test_recreate(self): SimpleModel.objects.all().delete() @@ -73,12 +105,14 @@ class SimpleModelTest(TestCase): class AltPrimaryKeyModelTest(SimpleModelTest): def setUp(self): - self.obj = AltPrimaryKeyModel.objects.create(key=str(datetime.datetime.now()), text='I am strange.') + self.obj = AltPrimaryKeyModel.objects.create( + key=str(datetime.datetime.now()), text="I am strange." + ) class UUIDPrimaryKeyModelModelTest(SimpleModelTest): def setUp(self): - self.obj = UUIDPrimaryKeyModel.objects.create(text='I am strange.') + self.obj = UUIDPrimaryKeyModel.objects.create(text="I am strange.") def test_get_for_object(self): self.obj.boolean = True @@ -90,38 +124,51 @@ class UUIDPrimaryKeyModelModelTest(SimpleModelTest): self.obj.boolean = True self.obj.save() - self.assertEqual(LogEntry.objects.get_for_objects(UUIDPrimaryKeyModel.objects.all()).count(), 2) + self.assertEqual( + LogEntry.objects.get_for_objects(UUIDPrimaryKeyModel.objects.all()).count(), + 2, + ) class ProxyModelTest(SimpleModelTest): def setUp(self): - self.obj = ProxyModel.objects.create(text='I am not what you think.') + self.obj = ProxyModel.objects.create(text="I am not what you think.") class ManyRelatedModelTest(TestCase): """ Test the behaviour of a many-to-many relationship. """ + def setUp(self): self.obj = ManyRelatedModel.objects.create() self.rel_obj = ManyRelatedModel.objects.create() self.obj.related.add(self.rel_obj) def test_related(self): - self.assertEqual(LogEntry.objects.get_for_objects(self.obj.related.all()).count(), self.rel_obj.history.count()) - self.assertEqual(LogEntry.objects.get_for_objects(self.obj.related.all()).first(), self.rel_obj.history.first()) + self.assertEqual( + LogEntry.objects.get_for_objects(self.obj.related.all()).count(), + self.rel_obj.history.count(), + ) + self.assertEqual( + LogEntry.objects.get_for_objects(self.obj.related.all()).first(), + self.rel_obj.history.first(), + ) class MiddlewareTest(TestCase): """ Test the middleware responsible for connecting and disconnecting the signals used in automatic logging. """ + def setUp(self): self.get_response_mock = mock.Mock() self.response_mock = mock.Mock() self.middleware = AuditlogMiddleware(get_response=self.get_response_mock) self.factory = RequestFactory() - self.user = User.objects.create_user(username='test', email='test@example.com', password='top_secret') + self.user = User.objects.create_user( + username="test", email="test@example.com", password="top_secret" + ) def side_effect(self, assertion): def inner(request): @@ -138,7 +185,7 @@ class MiddlewareTest(TestCase): def test_request_anonymous(self): """No actor will be logged when a user is not logged in.""" - request = self.factory.get('/') + request = self.factory.get("/") request.user = AnonymousUser() self.get_response_mock.side_effect = self.side_effect(self.assert_no_listeners) @@ -151,7 +198,7 @@ class MiddlewareTest(TestCase): def test_request(self): """The actor will be logged when a user is logged in.""" - request = self.factory.get('/') + request = self.factory.get("/") request.user = self.user self.get_response_mock.side_effect = self.side_effect(self.assert_has_listeners) @@ -164,10 +211,10 @@ class MiddlewareTest(TestCase): def test_exception(self): """The signal will be disconnected when an exception is raised.""" - request = self.factory.get('/') + request = self.factory.get("/") request.user = self.user - SomeException = type('SomeException', (Exception,), {}) + SomeException = type("SomeException", (Exception,), {}) self.get_response_mock.side_effect = SomeException @@ -181,17 +228,17 @@ class SimpeIncludeModelTest(TestCase): """Log only changes in include_fields""" def test_register_include_fields(self): - sim = SimpleIncludeModel(label='Include model', text='Looong text') + sim = SimpleIncludeModel(label="Include model", text="Looong text") sim.save() self.assertTrue(sim.history.count() == 1, msg="There is one log entry") # Change label, record - sim.label = 'Changed label' + sim.label = "Changed label" sim.save() self.assertTrue(sim.history.count() == 2, msg="There are two log entries") # Change text, ignore - sim.text = 'Short text' + sim.text = "Short text" sim.save() self.assertTrue(sim.history.count() == 2, msg="There are two log entries") @@ -200,17 +247,17 @@ class SimpeExcludeModelTest(TestCase): """Log only changes that are not in exclude_fields""" def test_register_exclude_fields(self): - sem = SimpleExcludeModel(label='Exclude model', text='Looong text') + sem = SimpleExcludeModel(label="Exclude model", text="Looong text") sem.save() self.assertTrue(sem.history.count() == 1, msg="There is one log entry") # Change label, ignore - sem.label = 'Changed label' + sem.label = "Changed label" sem.save() self.assertTrue(sem.history.count() == 2, msg="There are two log entries") # Change text, record - sem.text = 'Short text' + sem.text = "Short text" sem.save() self.assertTrue(sem.history.count() == 2, msg="There are two log entries") @@ -219,45 +266,69 @@ class SimpleMappingModelTest(TestCase): """Diff displays fields as mapped field names where available through mapping_fields""" def test_register_mapping_fields(self): - smm = SimpleMappingModel(sku='ASD301301A6', vtxt='2.1.5', not_mapped='Not mapped') + smm = SimpleMappingModel( + sku="ASD301301A6", vtxt="2.1.5", not_mapped="Not mapped" + ) smm.save() - self.assertTrue(smm.history.latest().changes_dict['sku'][1] == 'ASD301301A6', - msg="The diff function retains 'sku' and can be retrieved.") - self.assertTrue(smm.history.latest().changes_dict['not_mapped'][1] == 'Not mapped', - msg="The diff function does not map 'not_mapped' and can be retrieved.") - self.assertTrue(smm.history.latest().changes_display_dict['Product No.'][1] == 'ASD301301A6', - msg="The diff function maps 'sku' as 'Product No.' and can be retrieved.") - self.assertTrue(smm.history.latest().changes_display_dict['Version'][1] == '2.1.5', - msg=("The diff function maps 'vtxt' as 'Version' through verbose_name" - " setting on the model field and can be retrieved.")) - self.assertTrue(smm.history.latest().changes_display_dict['not mapped'][1] == 'Not mapped', - msg=("The diff function uses the django default verbose name for 'not_mapped'" - " and can be retrieved.")) + self.assertTrue( + smm.history.latest().changes_dict["sku"][1] == "ASD301301A6", + msg="The diff function retains 'sku' and can be retrieved.", + ) + self.assertTrue( + smm.history.latest().changes_dict["not_mapped"][1] == "Not mapped", + msg="The diff function does not map 'not_mapped' and can be retrieved.", + ) + self.assertTrue( + smm.history.latest().changes_display_dict["Product No."][1] + == "ASD301301A6", + msg="The diff function maps 'sku' as 'Product No.' and can be retrieved.", + ) + self.assertTrue( + smm.history.latest().changes_display_dict["Version"][1] == "2.1.5", + msg=( + "The diff function maps 'vtxt' as 'Version' through verbose_name" + " setting on the model field and can be retrieved." + ), + ) + self.assertTrue( + smm.history.latest().changes_display_dict["not mapped"][1] == "Not mapped", + msg=( + "The diff function uses the django default verbose name for 'not_mapped'" + " and can be retrieved." + ), + ) class AdditionalDataModelTest(TestCase): """Log additional data if get_additional_data is defined in the model""" def test_model_without_additional_data(self): - obj_wo_additional_data = SimpleModel.objects.create(text='No additional ' - 'data') + obj_wo_additional_data = SimpleModel.objects.create( + text="No additional " "data" + ) obj_log_entry = obj_wo_additional_data.history.get() self.assertIsNone(obj_log_entry.additional_data) def test_model_with_additional_data(self): - related_model = SimpleModel.objects.create(text='Log my reference') + related_model = SimpleModel.objects.create(text="Log my reference") obj_with_additional_data = AdditionalDataIncludedModel( - label='Additional data to log entries', related=related_model) + label="Additional data to log entries", related=related_model + ) obj_with_additional_data.save() - self.assertTrue(obj_with_additional_data.history.count() == 1, - msg="There is 1 log entry") + self.assertTrue( + obj_with_additional_data.history.count() == 1, msg="There is 1 log entry" + ) log_entry = obj_with_additional_data.history.get() self.assertIsNotNone(log_entry.additional_data) extra_data = log_entry.additional_data - self.assertTrue(extra_data['related_model_text'] == related_model.text, - msg="Related model's text is logged") - self.assertTrue(extra_data['related_model_id'] == related_model.id, - msg="Related model's id is logged") + self.assertTrue( + extra_data["related_model_text"] == related_model.text, + msg="Related model's text is logged", + ) + self.assertTrue( + extra_data["related_model_id"] == related_model.id, + msg="Related model's id is logged", + ) class DateTimeFieldModelTest(TestCase): @@ -270,7 +341,13 @@ class DateTimeFieldModelTest(TestCase): timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) date = datetime.date(2017, 1, 10) time = datetime.time(12, 0) - dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time, naive_dt=self.now) + dtm = DateTimeFieldModel( + label="DateTimeField model", + timestamp=timestamp, + date=date, + time=time, + naive_dt=self.now, + ) dtm.save() self.assertTrue(dtm.history.count() == 1, msg="There is one log entry") @@ -288,7 +365,13 @@ class DateTimeFieldModelTest(TestCase): timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) date = datetime.date(2017, 1, 10) time = datetime.time(12, 0) - dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time, naive_dt=self.now) + dtm = DateTimeFieldModel( + label="DateTimeField model", + timestamp=timestamp, + date=date, + time=time, + naive_dt=self.now, + ) dtm.save() self.assertTrue(dtm.history.count() == 1, msg="There is one log entry") @@ -304,7 +387,13 @@ class DateTimeFieldModelTest(TestCase): timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) date = datetime.date(2017, 1, 10) time = datetime.time(12, 0) - dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time, naive_dt=self.now) + dtm = DateTimeFieldModel( + label="DateTimeField model", + timestamp=timestamp, + date=date, + time=time, + naive_dt=self.now, + ) dtm.save() self.assertTrue(dtm.history.count() == 1, msg="There is one log entry") @@ -320,7 +409,13 @@ class DateTimeFieldModelTest(TestCase): timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) date = datetime.date(2017, 1, 10) time = datetime.time(12, 0) - dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time, naive_dt=self.now) + dtm = DateTimeFieldModel( + label="DateTimeField model", + timestamp=timestamp, + date=date, + time=time, + naive_dt=self.now, + ) dtm.save() self.assertTrue(dtm.history.count() == 1, msg="There is one log entry") @@ -336,7 +431,13 @@ class DateTimeFieldModelTest(TestCase): timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) date = datetime.date(2017, 1, 10) time = datetime.time(12, 0) - dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time, naive_dt=self.now) + dtm = DateTimeFieldModel( + label="DateTimeField model", + timestamp=timestamp, + date=date, + time=time, + naive_dt=self.now, + ) dtm.save() self.assertTrue(dtm.history.count() == 1, msg="There is one log entry") @@ -352,7 +453,13 @@ class DateTimeFieldModelTest(TestCase): timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) date = datetime.date(2017, 1, 10) time = datetime.time(12, 0) - dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time, naive_dt=self.now) + dtm = DateTimeFieldModel( + label="DateTimeField model", + timestamp=timestamp, + date=date, + time=time, + naive_dt=self.now, + ) dtm.save() self.assertTrue(dtm.history.count() == 1, msg="There is one log entry") @@ -368,85 +475,144 @@ class DateTimeFieldModelTest(TestCase): timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc) date = datetime.date(2017, 1, 10) time = datetime.time(12, 0) - dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time, naive_dt=self.now) + dtm = DateTimeFieldModel( + label="DateTimeField model", + timestamp=timestamp, + date=date, + time=time, + naive_dt=self.now, + ) dtm.save() localized_timestamp = timestamp.astimezone(gettz(settings.TIME_ZONE)) - self.assertTrue(dtm.history.latest().changes_display_dict["timestamp"][1] == \ - dateformat.format(localized_timestamp, settings.DATETIME_FORMAT), - msg=("The datetime should be formatted according to Django's settings for" - " DATETIME_FORMAT")) + self.assertTrue( + dtm.history.latest().changes_display_dict["timestamp"][1] + == dateformat.format(localized_timestamp, settings.DATETIME_FORMAT), + msg=( + "The datetime should be formatted according to Django's settings for" + " DATETIME_FORMAT" + ), + ) timestamp = timezone.now() dtm.timestamp = timestamp dtm.save() localized_timestamp = timestamp.astimezone(gettz(settings.TIME_ZONE)) - self.assertTrue(dtm.history.latest().changes_display_dict["timestamp"][1] == \ - dateformat.format(localized_timestamp, settings.DATETIME_FORMAT), - msg=("The datetime should be formatted according to Django's settings for" - " DATETIME_FORMAT")) + self.assertTrue( + dtm.history.latest().changes_display_dict["timestamp"][1] + == dateformat.format(localized_timestamp, settings.DATETIME_FORMAT), + msg=( + "The datetime should be formatted according to Django's settings for" + " DATETIME_FORMAT" + ), + ) # Change USE_L10N = True - with self.settings(USE_L10N=True, LANGUAGE_CODE='en-GB'): - self.assertTrue(dtm.history.latest().changes_display_dict["timestamp"][1] == \ - formats.localize(localized_timestamp), - msg=("The datetime should be formatted according to Django's settings for" - " USE_L10N is True with a different LANGUAGE_CODE.")) - + with self.settings(USE_L10N=True, LANGUAGE_CODE="en-GB"): + self.assertTrue( + dtm.history.latest().changes_display_dict["timestamp"][1] + == formats.localize(localized_timestamp), + msg=( + "The datetime should be formatted according to Django's settings for" + " USE_L10N is True with a different LANGUAGE_CODE." + ), + ) def test_changes_display_dict_date(self): timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc) date = datetime.date(2017, 1, 10) time = datetime.time(12, 0) - dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time, naive_dt=self.now) + dtm = DateTimeFieldModel( + label="DateTimeField model", + timestamp=timestamp, + date=date, + time=time, + naive_dt=self.now, + ) dtm.save() - self.assertTrue(dtm.history.latest().changes_display_dict["date"][1] == \ - dateformat.format(date, settings.DATE_FORMAT), - msg=("The date should be formatted according to Django's settings for" - " DATE_FORMAT unless USE_L10N is True.")) + self.assertTrue( + dtm.history.latest().changes_display_dict["date"][1] + == dateformat.format(date, settings.DATE_FORMAT), + msg=( + "The date should be formatted according to Django's settings for" + " DATE_FORMAT unless USE_L10N is True." + ), + ) date = datetime.date(2017, 1, 11) dtm.date = date dtm.save() - self.assertTrue(dtm.history.latest().changes_display_dict["date"][1] == \ - dateformat.format(date, settings.DATE_FORMAT), - msg=("The date should be formatted according to Django's settings for" - " DATE_FORMAT unless USE_L10N is True.")) + self.assertTrue( + dtm.history.latest().changes_display_dict["date"][1] + == dateformat.format(date, settings.DATE_FORMAT), + msg=( + "The date should be formatted according to Django's settings for" + " DATE_FORMAT unless USE_L10N is True." + ), + ) # Change USE_L10N = True - with self.settings(USE_L10N=True, LANGUAGE_CODE='en-GB'): - self.assertTrue(dtm.history.latest().changes_display_dict["date"][1] == \ - formats.localize(date), - msg=("The date should be formatted according to Django's settings for" - " USE_L10N is True with a different LANGUAGE_CODE.")) + with self.settings(USE_L10N=True, LANGUAGE_CODE="en-GB"): + self.assertTrue( + dtm.history.latest().changes_display_dict["date"][1] + == formats.localize(date), + msg=( + "The date should be formatted according to Django's settings for" + " USE_L10N is True with a different LANGUAGE_CODE." + ), + ) def test_changes_display_dict_time(self): timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc) date = datetime.date(2017, 1, 10) time = datetime.time(12, 0) - dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time, naive_dt=self.now) + dtm = DateTimeFieldModel( + label="DateTimeField model", + timestamp=timestamp, + date=date, + time=time, + naive_dt=self.now, + ) dtm.save() - self.assertTrue(dtm.history.latest().changes_display_dict["time"][1] == \ - dateformat.format(time, settings.TIME_FORMAT), - msg=("The time should be formatted according to Django's settings for" - " TIME_FORMAT unless USE_L10N is True.")) + self.assertTrue( + dtm.history.latest().changes_display_dict["time"][1] + == dateformat.format(time, settings.TIME_FORMAT), + msg=( + "The time should be formatted according to Django's settings for" + " TIME_FORMAT unless USE_L10N is True." + ), + ) time = datetime.time(6, 0) dtm.time = time dtm.save() - self.assertTrue(dtm.history.latest().changes_display_dict["time"][1] == \ - dateformat.format(time, settings.TIME_FORMAT), - msg=("The time should be formatted according to Django's settings for" - " TIME_FORMAT unless USE_L10N is True.")) + self.assertTrue( + dtm.history.latest().changes_display_dict["time"][1] + == dateformat.format(time, settings.TIME_FORMAT), + msg=( + "The time should be formatted according to Django's settings for" + " TIME_FORMAT unless USE_L10N is True." + ), + ) # Change USE_L10N = True - with self.settings(USE_L10N=True, LANGUAGE_CODE='en-GB'): - self.assertTrue(dtm.history.latest().changes_display_dict["time"][1] == \ - formats.localize(time), - msg=("The time should be formatted according to Django's settings for" - " USE_L10N is True with a different LANGUAGE_CODE.")) + with self.settings(USE_L10N=True, LANGUAGE_CODE="en-GB"): + self.assertTrue( + dtm.history.latest().changes_display_dict["time"][1] + == formats.localize(time), + msg=( + "The time should be formatted according to Django's settings for" + " USE_L10N is True with a different LANGUAGE_CODE." + ), + ) def test_update_naive_dt(self): timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc) date = datetime.date(2017, 1, 10) time = datetime.time(12, 0) - dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time, naive_dt=self.now) + dtm = DateTimeFieldModel( + label="DateTimeField model", + timestamp=timestamp, + date=date, + time=time, + naive_dt=self.now, + ) dtm.save() # Change with naive field doesnt raise error @@ -457,7 +623,7 @@ class DateTimeFieldModelTest(TestCase): class UnregisterTest(TestCase): def setUp(self): auditlog.unregister(SimpleModel) - self.obj = SimpleModel.objects.create(text='No history') + self.obj = SimpleModel.objects.create(text="No history") def tearDown(self): # Re-register for future tests @@ -496,49 +662,71 @@ class UnregisterTest(TestCase): class ChoicesFieldModelTest(TestCase): - def setUp(self): self.obj = ChoicesFieldModel.objects.create( status=ChoicesFieldModel.RED, multiselect=[ChoicesFieldModel.RED, ChoicesFieldModel.GREEN], - multiplechoice=[ChoicesFieldModel.RED, ChoicesFieldModel.YELLOW, ChoicesFieldModel.GREEN], + multiplechoice=[ + ChoicesFieldModel.RED, + ChoicesFieldModel.YELLOW, + ChoicesFieldModel.GREEN, + ], ) def test_changes_display_dict_single_choice(self): - self.assertTrue(self.obj.history.latest().changes_display_dict["status"][1] == "Red", - msg="The human readable text 'Red' is displayed.") + self.assertTrue( + self.obj.history.latest().changes_display_dict["status"][1] == "Red", + msg="The human readable text 'Red' is displayed.", + ) self.obj.status = ChoicesFieldModel.GREEN self.obj.save() - self.assertTrue(self.obj.history.latest().changes_display_dict["status"][1] == "Green", msg="The human readable text 'Green' is displayed.") + self.assertTrue( + self.obj.history.latest().changes_display_dict["status"][1] == "Green", + msg="The human readable text 'Green' is displayed.", + ) def test_changes_display_dict_multiselect(self): - self.assertTrue(self.obj.history.latest().changes_display_dict["multiselect"][1] == "Red, Green", - msg="The human readable text for the two choices, 'Red, Green' is displayed.") + self.assertTrue( + self.obj.history.latest().changes_display_dict["multiselect"][1] + == "Red, Green", + msg="The human readable text for the two choices, 'Red, Green' is displayed.", + ) self.obj.multiselect = ChoicesFieldModel.GREEN self.obj.save() - self.assertTrue(self.obj.history.latest().changes_display_dict["multiselect"][1] == "Green", - msg="The human readable text 'Green' is displayed.") + self.assertTrue( + self.obj.history.latest().changes_display_dict["multiselect"][1] == "Green", + msg="The human readable text 'Green' is displayed.", + ) self.obj.multiselect = None self.obj.save() - self.assertTrue(self.obj.history.latest().changes_display_dict["multiselect"][1] == "None", - msg="The human readable text 'None' is displayed.") + self.assertTrue( + self.obj.history.latest().changes_display_dict["multiselect"][1] == "None", + msg="The human readable text 'None' is displayed.", + ) self.obj.multiselect = ChoicesFieldModel.GREEN self.obj.save() - self.assertTrue(self.obj.history.latest().changes_display_dict["multiselect"][1] == "Green", - msg="The human readable text 'Green' is displayed.") + self.assertTrue( + self.obj.history.latest().changes_display_dict["multiselect"][1] == "Green", + msg="The human readable text 'Green' is displayed.", + ) def test_changes_display_dict_multiplechoice(self): - self.assertTrue(self.obj.history.latest().changes_display_dict["multiplechoice"][1] == "Red, Yellow, Green", - msg="The human readable text 'Red, Yellow, Green' is displayed.") + self.assertTrue( + self.obj.history.latest().changes_display_dict["multiplechoice"][1] + == "Red, Yellow, Green", + msg="The human readable text 'Red, Yellow, Green' is displayed.", + ) self.obj.multiplechoice = ChoicesFieldModel.RED self.obj.save() - self.assertTrue(self.obj.history.latest().changes_display_dict["multiplechoice"][1] == "Red", - msg="The human readable text 'Red' is displayed.") + self.assertTrue( + self.obj.history.latest().changes_display_dict["multiplechoice"][1] + == "Red", + msg="The human readable text 'Red' is displayed.", + ) class CharfieldTextfieldModelTest(TestCase): - def setUp(self): self.PLACEHOLDER_LONGCHAR = "s" * 255 self.PLACEHOLDER_LONGTEXTFIELD = "s" * 1000 @@ -548,28 +736,38 @@ class CharfieldTextfieldModelTest(TestCase): ) def test_changes_display_dict_longchar(self): - self.assertTrue(self.obj.history.latest().changes_display_dict["longchar"][1] == \ - "{}...".format(self.PLACEHOLDER_LONGCHAR[:140]), - msg="The string should be truncated at 140 characters with an ellipsis at the end.") + self.assertTrue( + self.obj.history.latest().changes_display_dict["longchar"][1] + == "{}...".format(self.PLACEHOLDER_LONGCHAR[:140]), + msg="The string should be truncated at 140 characters with an ellipsis at the end.", + ) SHORTENED_PLACEHOLDER = self.PLACEHOLDER_LONGCHAR[:139] self.obj.longchar = SHORTENED_PLACEHOLDER self.obj.save() - self.assertTrue(self.obj.history.latest().changes_display_dict["longchar"][1] == SHORTENED_PLACEHOLDER, - msg="The field should display the entire string because it is less than 140 characters") + self.assertTrue( + self.obj.history.latest().changes_display_dict["longchar"][1] + == SHORTENED_PLACEHOLDER, + msg="The field should display the entire string because it is less than 140 characters", + ) def test_changes_display_dict_longtextfield(self): - self.assertTrue(self.obj.history.latest().changes_display_dict["longtextfield"][1] == \ - "{}...".format(self.PLACEHOLDER_LONGTEXTFIELD[:140]), - msg="The string should be truncated at 140 characters with an ellipsis at the end.") + self.assertTrue( + self.obj.history.latest().changes_display_dict["longtextfield"][1] + == "{}...".format(self.PLACEHOLDER_LONGTEXTFIELD[:140]), + msg="The string should be truncated at 140 characters with an ellipsis at the end.", + ) SHORTENED_PLACEHOLDER = self.PLACEHOLDER_LONGTEXTFIELD[:139] self.obj.longtextfield = SHORTENED_PLACEHOLDER self.obj.save() - self.assertTrue(self.obj.history.latest().changes_display_dict["longtextfield"][1] == SHORTENED_PLACEHOLDER, - msg="The field should display the entire string because it is less than 140 characters") + self.assertTrue( + self.obj.history.latest().changes_display_dict["longtextfield"][1] + == SHORTENED_PLACEHOLDER, + msg="The field should display the entire string because it is less than 140 characters", + ) class PostgresArrayFieldModelTest(TestCase): - databases = '__all__' + databases = "__all__" def setUp(self): self.obj = PostgresArrayFieldModel.objects.create( @@ -581,20 +779,28 @@ class PostgresArrayFieldModelTest(TestCase): return self.obj.history.latest().changes_display_dict["arrayfield"][1] def test_changes_display_dict_arrayfield(self): - self.assertTrue(self.latest_array_change == "Red, Green", - msg="The human readable text for the two choices, 'Red, Green' is displayed.") + self.assertTrue( + self.latest_array_change == "Red, Green", + msg="The human readable text for the two choices, 'Red, Green' is displayed.", + ) self.obj.arrayfield = [PostgresArrayFieldModel.GREEN] self.obj.save() - self.assertTrue(self.latest_array_change == "Green", - msg="The human readable text 'Green' is displayed.") + self.assertTrue( + self.latest_array_change == "Green", + msg="The human readable text 'Green' is displayed.", + ) self.obj.arrayfield = [] self.obj.save() - self.assertTrue(self.latest_array_change == "", - msg="The human readable text '' is displayed.") + self.assertTrue( + self.latest_array_change == "", + msg="The human readable text '' is displayed.", + ) self.obj.arrayfield = [PostgresArrayFieldModel.GREEN] self.obj.save() - self.assertTrue(self.latest_array_change == "Green", - msg="The human readable text 'Green' is displayed.") + self.assertTrue( + self.latest_array_change == "Green", + msg="The human readable text 'Green' is displayed.", + ) class AdminPanelTest(TestCase): @@ -608,7 +814,7 @@ class AdminPanelTest(TestCase): cls.user.is_superuser = True cls.user.is_active = True cls.user.save() - cls.obj = SimpleModel.objects.create(text='For admin logentry test') + cls.obj = SimpleModel.objects.create(text="For admin logentry test") def test_auditlog_admin(self): self.client.login(username=self.username, password=self.password) @@ -617,7 +823,9 @@ class AdminPanelTest(TestCase): assert res.status_code == 200 res = self.client.get("/admin/auditlog/logentry/add/") assert res.status_code == 200 - res = self.client.get("/admin/auditlog/logentry/{}/".format(log_pk), follow=True) + res = self.client.get( + "/admin/auditlog/logentry/{}/".format(log_pk), follow=True + ) assert res.status_code == 200 res = self.client.get("/admin/auditlog/logentry/{}/delete/".format(log_pk)) assert res.status_code == 200 @@ -634,7 +842,7 @@ class NoDeleteHistoryTest(TestCase): assert LogEntry.objects.all().count() == 2 instance.delete() - entries = LogEntry.objects.order_by('id') + entries = LogEntry.objects.order_by("id") # The "DELETE" record is always retained assert LogEntry.objects.all().count() == 1 @@ -648,9 +856,9 @@ class NoDeleteHistoryTest(TestCase): self.assertEqual(LogEntry.objects.all().count(), 2) instance.delete() - entries = LogEntry.objects.order_by('id') + entries = LogEntry.objects.order_by("id") self.assertEqual(entries.count(), 3) self.assertEqual( - list(entries.values_list('action', flat=True)), - [LogEntry.Action.CREATE, LogEntry.Action.UPDATE, LogEntry.Action.DELETE] + list(entries.values_list("action", flat=True)), + [LogEntry.Action.CREATE, LogEntry.Action.UPDATE, LogEntry.Action.DELETE], ) diff --git a/auditlog_tests/urls.py b/auditlog_tests/urls.py index d52422e..60bf9ac 100644 --- a/auditlog_tests/urls.py +++ b/auditlog_tests/urls.py @@ -3,5 +3,5 @@ from django.contrib import admin urlpatterns = [ - url(r'^admin/', admin.site.urls), + url(r"^admin/", admin.site.urls), ] diff --git a/docs/source/conf.py b/docs/source/conf.py index 8855625..ecb5b3f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -17,21 +17,23 @@ from datetime import date # documentation root, use os.path.abspath to make it absolute, like shown here. # Add sources folder -sys.path.insert(0, os.path.abspath('../../')) +sys.path.insert(0, os.path.abspath("../../")) # Setup Django for autodoc -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'auditlog_tests.test_settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "auditlog_tests.test_settings") import django + django.setup() # -- Project information ----------------------------------------------------- -project = 'django-auditlog' -author = 'Jan-Jelle Kester and contributors' -copyright = f'2013-{date.today().year}, {author}' +project = "django-auditlog" +author = "Jan-Jelle Kester and contributors" +copyright = f"2013-{date.today().year}, {author}" # The full version, including alpha/beta/rc tags import auditlog + release = auditlog.__version__ # -- General configuration --------------------------------------------------- @@ -40,29 +42,29 @@ release = auditlog.__version__ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.viewcode', + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", ] # Master document that contains the root table of contents -master_doc = 'index' +master_doc = "index" # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] diff --git a/runtests.py b/runtests.py index b7df405..dea36f6 100644 --- a/runtests.py +++ b/runtests.py @@ -7,7 +7,7 @@ from django.conf import settings from django.test.utils import get_runner if __name__ == "__main__": - os.environ['DJANGO_SETTINGS_MODULE'] = 'auditlog_tests.test_settings' + os.environ["DJANGO_SETTINGS_MODULE"] = "auditlog_tests.test_settings" django.setup() TestRunner = get_runner(settings) test_runner = TestRunner() diff --git a/setup.py b/setup.py index f5688a3..cb0eb05 100644 --- a/setup.py +++ b/setup.py @@ -9,28 +9,33 @@ with open(os.path.join(os.path.dirname(__file__), "README.md"), "r") as readme_f long_description = readme_file.read() setup( - name='django-auditlog', + name="django-auditlog", version=auditlog.__version__, - packages=['auditlog', 'auditlog.migrations', 'auditlog.management', 'auditlog.management.commands'], - url='https://github.com/MacmillanPlatform/django-auditlog/', - license='MIT', - author='Jan-Jelle Kester', - maintainer='Alieh Rymašeŭski', - description='Audit log app for Django', + packages=[ + "auditlog", + "auditlog.migrations", + "auditlog.management", + "auditlog.management.commands", + ], + url="https://github.com/MacmillanPlatform/django-auditlog/", + license="MIT", + author="Jan-Jelle Kester", + maintainer="Alieh Rymašeŭski", + description="Audit log app for Django", long_description=long_description, - long_description_content_type='text/markdown', + long_description_content_type="text/markdown", install_requires=[ - 'django-admin-rangefilter>=0.5.0', - 'django-jsonfield>=1.0.0', - 'python-dateutil>=2.6.0', + "django-admin-rangefilter>=0.5.0", + "django-jsonfield>=1.0.0", + "python-dateutil>=2.6.0", ], zip_safe=False, classifiers=[ - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'License :: OSI Approved :: MIT License', + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "License :: OSI Approved :: MIT License", ], )