Compare commits

...

96 commits

Author SHA1 Message Date
Alieh Rymašeŭski
aa4f7a8108 Fix import order in tests 2021-06-25 13:23:42 +03:00
Alieh Rymašeŭski
bdf595e4dc Use predictable names for coverage reports 2021-06-25 13:21:04 +03:00
Alieh Rymašeŭski
a13f8d9e60 Pin psycopg2 to version compatible with Django 2.2 2021-06-25 12:21:03 +03:00
Alieh Rymašeŭski
d7a73e5cbf Merge changes from upstream 2021-06-24 15:19:21 +03:00
Alieh Rymašeŭski
f12e8a74fc Remove multiselectfield from tests
The upstream dropped this test long ago, and these tests now break on
Django 3.1+, so I don't want to invest into these tests too much.
2021-06-24 14:58:26 +03:00
Alieh Rymašeŭski
1fc1e678c7 Replace list() with get_models() 2021-06-24 14:57:30 +03:00
Hasan Ramezani
d067de73b6 Replace MIDDLEWARE_CLASSES with MIDDLEWARE in docs. 2021-06-24 14:41:16 +03:00
Hasan Ramezani
cb99bcfbc4 Add DEFAULT_AUTO_FIELD to test settings. 2021-06-24 14:41:14 +03:00
Hasan Ramezani
093b8093de Add Django 3.2 support. 2021-06-24 14:41:09 +03:00
Hasan Ramezani
cf63860dad Remove Django 3.0 support. 2021-06-24 14:41:05 +03:00
Blas Isaias Fernández
943d5a9e6b dict.iteritems was removed from python 3 2021-06-24 14:40:36 +03:00
Hasan Ramezani
ea57dfd9c1 Remove Python 3.5 support. (#301) 2021-06-24 14:40:12 +03:00
Jannis Leidel
ccf4b69113 Remove note about maintenance. 2021-06-24 14:39:53 +03:00
Hasan Ramezani
327f8c7067 Add Django supported versions to setup.py classifiers. 2021-06-24 14:39:42 +03:00
Hasan Ramezani
94182f86e9 Replace old-style routing with new-style 2021-06-24 14:37:32 +03:00
Hasan Ramezani
59bf633fbf Add Supported Django versions badge to README. 2021-06-24 14:37:15 +03:00
Alieh Rymašeŭski
783316331f Support Python 3.9 2021-06-24 14:37:07 +03:00
Alieh Rymašeŭski
e404e82795 Apply CI changes from upstream
Notable changes include:
- removal of Travis in favor of GH Actions;
- configuration to get version from git and not from code.
2021-06-24 14:37:03 +03:00
Alieh Rymašeŭski
458f3e3766 Bump version to 0.7.3 2021-06-24 14:33:46 +03:00
Alieh Rymašeŭski
4232d685bd Apply isort to the code base 2021-06-24 14:33:30 +03:00
Alieh Rymašeŭski
49d92b30fe Replace relative imports with absolute 2021-06-24 14:31:23 +03:00
Alieh Rymašeŭski
b130e3088e Add isort 2021-06-24 14:31:18 +03:00
Alieh Rymašeŭski
7801239387 Blacken the code 2021-06-24 13:04:48 +03:00
Alieh Rymašeŭski
c57be3a2c1 Add black 2021-06-24 13:03:36 +03:00
Alieh Rymašeŭski
5d2bc88b2d Test LogEntry.actor field 2021-06-24 12:55:22 +03:00
Alieh Rymašeŭski
f647966210 Stop handling an impossible case
We check eliminate the case with zero log entries when checking that
obj.history.count() is exactly 1.
2021-06-24 12:55:22 +03:00
Alieh Rymašeŭski
2b44eebd50 Use assertEqual to assert equality 2021-06-24 12:55:22 +03:00
Alieh Rymašeŭski
6c0c83e7e5 Remove unused imports
Import of RelatedModel was left in place as it just lacks respective
tests.
2021-06-24 12:55:22 +03:00
Alieh Rymašeŭski
034ba57d93 Fix a misleading docstring 2021-06-24 12:55:22 +03:00
Alieh Rymašeŭski
13cad5b25a Explicitly unset the threadlocal 2021-06-24 12:55:22 +03:00
Alieh Rymašeŭski
9629f3f8d7 Move signal management to a context manager
This change allows setting the same signals when the request is not
present, i.e. in a celery task.
2021-06-24 12:55:22 +03:00
Alieh Rymašeŭski
3eb5d66c39 Use get_user_model 2021-06-24 12:55:22 +03:00
Alieh Rymašeŭski
a0a0726982 Bump version to 0.7.2 2020-11-18 13:29:50 +03:00
Alieh Rymašeŭski
ff6349a89a Order filter by field alphabetically 2020-11-18 13:29:29 +03:00
Alieh Rymašeŭski
8b97cc2acb Bump version to 0.7.1 2020-11-12 18:04:00 +03:00
Alieh Rymašeŭski
5dcb069bb8 Add a short filter by actor__isnull 2020-11-12 18:03:27 +03:00
Alieh Rymašeŭski
d7a6496ad8 Bump version to 0.7.0 2020-11-12 17:59:02 +03:00
Alieh Rymašeŭski
df16b2a8da Changes for Jazzband (#269)
* Update repository references to Jazzband
Issue #268

* Add Jazzband badge to README
Issue #268

* Add Jazzband contribution guideline
Issue #268

Cherry-picking 31418d54f2
2020-11-12 16:51:20 +03:00
Alieh Rymašeŭski
be82018266 Use more generic .pk to get primary key instead of .id
Fixes #140

Cherry-picking fdf6ed7149c2731e3bd9144f33a5d70418e05889
2020-11-12 16:50:12 +03:00
Alieh Rymašeŭski
de693dd092 Management command improvements
Cherry-picking f4edfc0592
2020-11-12 16:50:12 +03:00
Alieh Rymašeŭski
6379683f77 Remove stale code
Cherry-picking f14f6b34ee
2020-11-12 16:41:42 +03:00
Alieh Rymašeŭski
c346adb8b5 Code improvements
Cherry-picking 469fe362de
2020-11-12 16:40:48 +03:00
Alieh Rymašeŭski
701f867a04 Remove a deprecated alias 2020-11-12 16:32:41 +03:00
Alieh Rymašeŭski
0ca00faafc Simplify field.base_field.choices check 2020-11-12 16:21:31 +03:00
Alieh Rymašeŭski
cb1fefb793 Allow setting database parameters through env 2020-11-12 16:21:09 +03:00
Alieh Rymašeŭski
536a841bf3 Remove Python 2 cruft 2020-11-12 16:15:32 +03:00
Alieh Rymašeŭski
08a7b82acc Remove version constraints on test requirements 2020-11-12 16:15:25 +03:00
Alieh Rymašeŭski
8d2bb0f319 Drop support for outdated Django versions 2020-11-12 16:15:12 +03:00
Alieh Rymašeŭski
3ac4311ae4 Align Travis test matrix with tox.ini 2020-11-12 16:15:12 +03:00
Alieh Rymašeŭski
917b490ee4 Bump copyright year
Cherry-picking 4e7c640ba0
2020-11-12 16:15:12 +03:00
Alieh Rymašeŭski
47a268eef9 Clean up project structure
Cherry-picking ee8a700b1b
2020-11-12 16:15:05 +03:00
Alieh Rymašeŭski
ca29848d78 Remove compatibility code for old Django versions 2020-11-12 12:31:23 +03:00
Alieh Rymašeŭski
a6cea38c9e Bump version to 0.6.10 2020-06-11 17:05:29 +03:00
Alieh Rymašeŭski
33a35e841b Export the logic to limit database query time 2020-06-11 17:05:29 +03:00
Alieh Rymašeŭski
a11d1a3e46 Bump version to 0.6.9 2020-06-10 15:40:07 +03:00
Alieh Rymašeŭski
45a27ec1d6 Support Django 3.0 2020-06-10 15:31:41 +03:00
Alieh Rymašeŭski
e6f3f12bae Relax requirements for tests 2020-06-10 15:21:48 +03:00
Alieh Rymašeŭski
28a60af5f3 Move Travis-specific requirements into own file 2020-06-10 15:21:48 +03:00
Alieh Rymašeŭski
fd169771df Support running tox -p 2020-06-10 15:21:48 +03:00
Alieh Rymašeŭski
d97ac056d4 Remove unused requirements.txt
tox installs the dependencies specified in setup.py.
2020-06-10 14:20:39 +03:00
Alieh Rymašeŭski
33c35b42db Support python 3.8 2020-06-10 14:08:33 +03:00
Alieh Rymašeŭski
0f3c7b430f Bump version to 0.6.8 2020-04-22 17:57:21 +03:00
Alieh Rymašeŭski
bf8ba7a0be Add admin filter by timestamp
It's backed by django-admin-rangefilter if able.
2020-04-22 17:42:53 +03:00
Alieh Rymašeŭski
2b536fc87e Bump version to 0.6.7 2020-04-22 16:09:27 +03:00
Alieh Rymašeŭski
106417c684 Display timestamps in server timezone 2020-04-22 16:03:01 +03:00
Alieh Rymašeŭski
358971aafe Bump version to 0.6.6 2020-04-17 12:54:54 +03:00
Alieh Rymašeŭski
f58b3d7685 Introduce admin filter by changed field 2020-04-17 12:52:56 +03:00
Alieh Rymašeŭski
5cd55ac38c Bump version to 0.6.5 2020-04-07 12:03:09 +03:00
Alieh Rymašeŭski
8397754a20 Replace dumb paginator with a time-limited one 2020-04-07 11:55:55 +03:00
Alieh Rymašeŭski
da49432924 Bump version to 0.6.4 2020-04-07 11:23:48 +03:00
Alieh Rymašeŭski
3af06e13c7 Add a missing line to manifest 2020-04-07 11:17:39 +03:00
Alieh Rymašeŭski
ae57b0c322 Merge upstream changes from jjkester/master
Conflicts:
    setup.py  - conflicting version bump, expected
    src/auditlog_tests/router.py - removed by them
    tox.ini - upstream extended test matrix with dj21 and dj22, we did
              the same but dropped older versions, keepign our variant
2020-04-07 11:13:45 +03:00
Alieh Rymašeŭski
bde49bdb4f Allow newer versions of dateutil 2019-11-25 15:49:00 +03:00
Alieh Rymašeŭski
c66b36c700 Bump version to 0.6.2 2019-08-22 18:24:46 +03:00
Alieh Rymašeŭski
2ae401a04f Shave off two SELECT COUNT(*) from admin page 2019-08-22 18:24:10 +03:00
Alieh Rymašeŭski
5ba554af56 Bump version to 0.6.1 2019-08-22 18:06:48 +03:00
Alieh Rymašeŭski
df2bf0a05c Replace timestamp index with timestamp+id index
This change is supposed to improve performance of admin view.
2019-08-22 18:05:39 +03:00
Alieh Rymašeŭski
05e6b179fd Bump version to 0.6.0 2019-08-22 16:31:08 +03:00
Alieh Rymašeŭski
07b38a9345 Update references to the maintainer 2019-08-22 16:31:08 +03:00
Alieh Rymašeŭski
5784247180 Update requirements section in the docs
We've dropped support for Python2 and unsupported versions of Python
and Django.
2019-08-22 16:31:08 +03:00
Alieh Rymašeŭski
2a43cff96f Add python3.7 to tox and classifiers 2019-08-22 16:31:08 +03:00
Alieh Rymašeŭski
b16b1a0df3 Drop Django 1.9 compatibility 2019-08-22 16:31:08 +03:00
Alieh Rymašeŭski
9152d225bb Remove obsolete versions from setup and tox 2019-08-22 16:31:08 +03:00
Alieh Rymašeŭski
c53b766132 Drop python2.7 support
Our dependency jsonfield broke compatibility with python2.7 recently,
and having no real reasons to support python2.7 we just drop it now.
2019-08-22 16:31:08 +03:00
Alieh Rymašeŭski
e35d0f4194 Bump version to 0.5.3 2019-08-21 19:41:02 +03:00
Alieh Rymašeŭski
e60876ae14 Add index for timestamp 2019-08-21 19:30:25 +03:00
Alieh Rymašeŭski
5dbea8a9a1 Bump version to 0.5.2 2019-05-17 12:41:31 +03:00
Alieh Rymašeŭski
cfbc588cc1 Query ContentTypes instead of distinct LogEntry
SELECT DISTINCT app_label, model FROM log_entry is a very expensive
request for longer logs, while we can always get the list of all
tracked models straight from the registry.

This new approach has two downsides:
1. It only provides filters for currently tracked models.
2. It can list such filter options that don't have any log entries.
2019-05-17 12:38:39 +03:00
Alieh Rymašeŭski
ee6bb33bc9 Bump version to 0.5.1 2019-05-16 17:24:13 +03:00
Alieh Rymašeŭski
c9c97b6861 Improve admin list performance 2019-05-16 17:23:28 +03:00
Alieh Rymašeŭski
5f5cc7f7e9 Bump version to 0.5.0 2019-05-11 18:43:36 +03:00
Alieh Rymašeŭski
a5381b6195 Move signal management to a context manager
This change allows setting the same signals when the request is not
present, i.e. in a celery task.
2019-05-11 18:25:08 +03:00
Alieh Rymašeŭski
2dc0ac43b5 Use get_user_model 2019-05-11 18:16:00 +03:00
Alieh Rymašeŭski
aa28009d3b Configure tests for Django 2.2 2019-05-11 18:16:00 +03:00
Alieh Rymašeŭski
03b8616dac Configure tests for Django 2.1 2019-05-11 18:15:58 +03:00
Alieh Rymašeŭski
62c1e676cc Bump version 2019-03-28 14:29:31 +03:00
19 changed files with 587 additions and 302 deletions

View file

@ -1,6 +1,7 @@
The MIT License (MIT)
Copyright (c) 2013-2020 Jan-Jelle Kester
Copyright (c) 2019-2020 Alieh Rymašeuski <alieh.rymasheuski@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in

View file

@ -1,10 +1,30 @@
from django.conf import settings
from django.contrib import admin
from django.core.paginator import Paginator
from django.utils.functional import cached_property
from auditlog.filters import ResourceTypeFilter
from auditlog.count import limit_query_time
from auditlog.filters import (
FieldFilter,
ResourceTypeFilter,
ShortActorFilter,
get_timestamp_filter,
)
from auditlog.mixins import LogEntryAdminMixin
from auditlog.models import LogEntry
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
)
def count(self):
return super().count
class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
list_display = ["created", "resource_url", "action", "msg_short", "user_url"]
search_fields = [
@ -14,12 +34,21 @@ class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
"actor__first_name",
"actor__last_name",
]
list_filter = ["action", ResourceTypeFilter]
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
admin.site.register(LogEntry, LogEntryAdmin)

67
auditlog/context.py Normal file
View file

@ -0,0 +1,67 @@
import contextlib
import threading
import time
from functools import partial
from django.contrib.auth import get_user_model
from django.db.models.signals import pre_save
from auditlog.models import LogEntry
threadlocal = threading.local()
@contextlib.contextmanager
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,
}
# 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,
)
try:
yield
finally:
try:
auditlog = threadlocal.auditlog
except AttributeError:
pass
else:
pre_save.disconnect(sender=LogEntry, dispatch_uid=auditlog["signal_duid"])
del threadlocal.auditlog
def _set_actor(user, sender, instance, signal_duid, **kwargs):
"""Signal receiver with extra 'user' and 'signal_duid' kwargs.
This function becomes a valid signal receiver when it is curried with the actor and a dispatch id.
"""
try:
auditlog = threadlocal.auditlog
except AttributeError:
pass
else:
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
):
instance.actor = user
instance.remote_addr = auditlog["remote_addr"]

23
auditlog/count.py Normal file
View file

@ -0,0 +1,23 @@
from django.db import OperationalError, connection, transaction
def limit_query_time(timeout, default=None):
"""A PostgreSQL-specific decorator with a hard time limit and a default return value.
Timeout in milliseconds.
Courtesy of https://medium.com/@hakibenita/optimizing-django-admin-paginator-53c4eb6bfca3
"""
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,))
try:
return function(*args, **kwargs)
except OperationalError:
return default
return _limit_query_time
return decorator

View file

@ -1,4 +1,29 @@
from django.apps import apps
from django.contrib.admin import SimpleListFilter
from django.contrib.admin.filters import DateFieldListFilter
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField
from django.db import connection
from django.db.models import Value
from django.db.models.functions import Cast, Concat
from auditlog.registry import auditlog
class ShortActorFilter(SimpleListFilter):
title = "Actor"
parameter_name = "actor"
def lookups(self, request, model_admin):
return [("null", "System"), ("not_null", "Users")]
def queryset(self, request, queryset):
value = self.value()
if value is None:
return queryset
if value == "null":
return queryset.filter(actor__isnull=True)
return queryset.filter(actor__isnull=False)
class ResourceTypeFilter(SimpleListFilter):
@ -6,11 +31,68 @@ class ResourceTypeFilter(SimpleListFilter):
parameter_name = "resource_type"
def lookups(self, request, model_admin):
qs = model_admin.get_queryset(request)
types = qs.values_list("content_type_id", "content_type__model")
return list(types.order_by("content_type__model").distinct())
tracked_model_names = [
"{}.{}".format(m._meta.app_label, m._meta.model_name)
for m in auditlog.get_models()
]
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")
def queryset(self, request, queryset):
if self.value() is None:
return queryset
return queryset.filter(content_type_id=self.value())
class FieldFilter(SimpleListFilter):
title = "Field"
parameter_name = "field"
parent = ResourceTypeFilter
def __init__(self, request, *args, **kwargs):
self.target_model = self._get_target_model(request)
super().__init__(request, *args, **kwargs)
def _get_target_model(self, request):
# the parameters consumed by previous filters aren't passed to subsequent filters,
# so we have to look into the request parameters explicitly
content_type_id = request.GET.get(self.parent.parameter_name)
if not content_type_id:
return None
return ContentType.objects.get(id=content_type_id).model_class()
def lookups(self, request, model_admin):
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
)
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}
)
def get_timestamp_filter():
"""Returns rangefilter filter class if able or a simple list filter as a fallback."""
if apps.is_installed("rangefilter"):
try:
from rangefilter.filter import DateTimeRangeFilter
return DateTimeRangeFilter
except ImportError:
pass
return DateFieldListFilter

View file

@ -1,97 +1,35 @@
import threading
import time
from functools import partial
import contextlib
from django.apps import apps
from django.conf import settings
from django.db.models.signals import pre_save
from django.utils.deprecation import MiddlewareMixin
from auditlog.models import LogEntry
threadlocal = threading.local()
from auditlog.context import set_actor
class AuditlogMiddleware(MiddlewareMixin):
@contextlib.contextmanager
def nullcontext():
"""Equivalent to contextlib.nullcontext(None) from Python 3.7."""
yield
class AuditlogMiddleware(object):
"""
Middleware to couple the request's user to log items. This is accomplished by currying the signal receiver with the
user from the request (or None if the user is not authenticated).
"""
def process_request(self, request):
"""
Gets the current user from the request and prepares and connects a signal receiver with the user already
attached to it.
"""
# Initialize thread local storage
threadlocal.auditlog = {
"signal_duid": (self.__class__, time.time()),
"remote_addr": request.META.get("REMOTE_ADDR"),
}
def __init__(self, get_response=None):
self.get_response = get_response
def __call__(self, request):
# In case of proxy, set 'original' address
if request.META.get("HTTP_X_FORWARDED_FOR"):
threadlocal.auditlog["remote_addr"] = request.META.get(
"HTTP_X_FORWARDED_FOR"
).split(",")[0]
# In case of proxy, set 'original' address
remote_addr = request.META.get("HTTP_X_FORWARDED_FOR").split(",")[0]
else:
remote_addr = request.META.get("REMOTE_ADDR")
# Connect signal for automatic logging
if hasattr(request, "user") and getattr(
request.user, "is_authenticated", False
):
set_actor = partial(
self.set_actor,
user=request.user,
signal_duid=threadlocal.auditlog["signal_duid"],
)
pre_save.connect(
set_actor,
sender=LogEntry,
dispatch_uid=threadlocal.auditlog["signal_duid"],
weak=False,
)
if hasattr(request, "user") and request.user.is_authenticated:
context = set_actor(actor=request.user, remote_addr=remote_addr)
else:
context = nullcontext()
def process_response(self, request, response):
"""
Disconnects the signal receiver to prevent it from staying active.
"""
if hasattr(threadlocal, "auditlog"):
pre_save.disconnect(
sender=LogEntry, dispatch_uid=threadlocal.auditlog["signal_duid"]
)
return response
def process_exception(self, request, exception):
"""
Disconnects the signal receiver to prevent it from staying active in case of an exception.
"""
if hasattr(threadlocal, "auditlog"):
pre_save.disconnect(
sender=LogEntry, dispatch_uid=threadlocal.auditlog["signal_duid"]
)
return None
@staticmethod
def set_actor(user, sender, instance, signal_duid, **kwargs):
"""
Signal receiver with an extra, required 'user' kwarg. This method becomes a real (valid) signal receiver when
it is curried with the actor.
"""
if hasattr(threadlocal, "auditlog"):
if signal_duid != threadlocal.auditlog["signal_duid"]:
return
try:
app_label, model_name = settings.AUTH_USER_MODEL.split(".")
auth_user_model = apps.get_model(app_label, model_name)
except ValueError:
auth_user_model = apps.get_model("auth", "user")
if (
sender == LogEntry
and isinstance(user, auth_user_model)
and instance.actor is None
):
instance.actor = user
instance.remote_addr = threadlocal.auditlog["remote_addr"]
with context:
return self.get_response(request)

View file

@ -1,5 +1,5 @@
import jsonfield.fields
from django.db import migrations, models
from django.db import migrations
class Migration(migrations.Migration):

View file

@ -1,5 +1,5 @@
import jsonfield.fields
from django.db import migrations, models
from django.db import migrations
class Migration(migrations.Migration):

View file

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("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"
),
),
]

View file

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0008_timestamp_index"),
]
operations = [
migrations.AlterIndexTogether(
name="logentry",
index_together={("timestamp", "id")},
),
migrations.AlterField(
model_name="logentry",
name="timestamp",
field=models.DateTimeField(auto_now_add=True, verbose_name="timestamp"),
),
]

View file

@ -5,6 +5,7 @@ from django.conf import settings
from django.urls.exceptions import NoReverseMatch
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.timezone import localtime
from auditlog.models import LogEntry
@ -13,7 +14,7 @@ MAX = 75
class LogEntryAdminMixin(object):
def created(self, obj):
return obj.timestamp.strftime("%Y-%m-%d %H:%M:%S")
return localtime(obj.timestamp).strftime("%Y-%m-%d %H:%M:%S")
created.short_description = "Created"

View file

@ -8,10 +8,10 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist
from django.db import DEFAULT_DB_ALIAS, models
from django.db.models import Field, Q, QuerySet
from django.db.models import Q, QuerySet
from django.utils import formats, timezone
from django.utils.encoding import smart_str
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from jsonfield.fields import JSONField
@ -239,6 +239,7 @@ class LogEntry(models.Model):
ordering = ["-timestamp"]
verbose_name = _("log entry")
verbose_name_plural = _("log entries")
index_together = ("timestamp", "id")
def __str__(self):
if self.action == self.Action.CREATE:
@ -310,14 +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") and len(field.choices) > 0:
if getattr(field, "choices", []):
choices_dict = dict(field.choices)
if (
hasattr(field, "base_field")
and isinstance(field.base_field, Field)
and getattr(field.base_field, "choices")
and len(field.base_field.choices) > 0
):
if getattr(getattr(field, "base_field", None), "choices", []):
choices_dict = dict(field.base_field.choices)
if choices_dict:
@ -332,9 +328,7 @@ class LogEntry(models.Model):
)
else:
values_display.append(choices_dict.get(value, "None"))
except ValueError:
values_display.append(choices_dict.get(value, "None"))
except:
except Exception:
values_display.append(choices_dict.get(value, "None"))
else:
try:

View file

@ -107,6 +107,7 @@ class AuditlogModelRegistry(object):
self._disconnect_signals(model)
def get_models(self) -> List[ModelBase]:
"""Get a list of all registered models."""
return list(self._registry.keys())
def get_model_fields(self, model: ModelBase):

View file

@ -18,18 +18,20 @@ INSTALLED_APPS = [
"auditlog_tests",
]
MIDDLEWARE = (
MIDDLEWARE = [
"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_tests_db"),
"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"),

View file

@ -1,17 +1,19 @@
import datetime
import itertools
import json
import django
import mock
from dateutil.tz import gettz
from django.conf import settings
from django.contrib import auth
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, User
from django.core.exceptions import ValidationError
from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import pre_save
from django.http import HttpResponse
from django.test import RequestFactory, TestCase
from django.utils import dateformat, formats, timezone
from auditlog.context import set_actor
from auditlog.middleware import AuditlogMiddleware
from auditlog.models import LogEntry
from auditlog.registry import auditlog
@ -36,7 +38,11 @@ from auditlog_tests.models import (
class SimpleModelTest(TestCase):
def setUp(self):
self.obj = SimpleModel.objects.create(text="I am not difficult.")
self.obj = self.make_object()
super().setUp()
def make_object(self):
return SimpleModel.objects.create(text="I am not difficult.")
def test_create(self):
"""Creation is logged correctly."""
@ -44,19 +50,16 @@ class SimpleModelTest(TestCase):
obj = self.obj
# Check for log entries
self.assertTrue(obj.history.count() == 1, msg="There is one log entry")
self.assertEqual(obj.history.count(), 1, msg="There is one log entry")
try:
history = obj.history.get()
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"
)
history = obj.history.get()
self.check_create_log_entry(obj, history)
def check_create_log_entry(self, obj, history):
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."""
@ -64,17 +67,23 @@ class SimpleModelTest(TestCase):
obj = self.obj
# Change something
obj.boolean = True
obj.save()
self.update(obj)
# Check for log entries
self.assertTrue(
obj.history.filter(action=LogEntry.Action.UPDATE).count() == 1,
self.assertEqual(
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.check_update_log_entry(obj, history)
def update(self, obj):
obj.boolean = True
obj.save()
def check_update_log_entry(self, obj, history):
self.assertJSONEqual(
history.changes,
'{"boolean": ["False", "True"]}',
@ -85,39 +94,104 @@ class SimpleModelTest(TestCase):
"""Deletion is logged correctly."""
# Get the object to work with
obj = self.obj
history = obj.history.latest()
content_type = ContentType.objects.get_for_model(obj.__class__)
pk = obj.pk
# Delete the object
obj.delete()
self.delete(obj)
# 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'",
)
qs = LogEntry.objects.filter(content_type=content_type, object_pk=pk)
self.assertEqual(qs.count(), 1, msg="There is one log entry for 'DELETE'")
history = qs.get()
self.check_delete_log_entry(obj, history)
def delete(self, obj):
obj.delete()
def check_delete_log_entry(self, obj, history):
pass
def test_recreate(self):
SimpleModel.objects.all().delete()
self.obj.delete()
self.setUp()
self.test_create()
class AltPrimaryKeyModelTest(SimpleModelTest):
class NoActorMixin:
def check_create_log_entry(self, obj, log_entry):
super().check_create_log_entry(obj, log_entry)
self.assertIsNone(log_entry.actor)
def check_update_log_entry(self, obj, log_entry):
super().check_update_log_entry(obj, log_entry)
self.assertIsNone(log_entry.actor)
def check_delete_log_entry(self, obj, log_entry):
super().check_delete_log_entry(obj, log_entry)
self.assertIsNone(log_entry.actor)
class WithActorMixin:
sequence = itertools.count()
def setUp(self):
self.obj = AltPrimaryKeyModel.objects.create(
username = "actor_{}".format(next(self.sequence))
self.user = get_user_model().objects.create(
username=username,
email="{}@example.com".format(username),
password="secret",
)
super().setUp()
def tearDown(self):
self.user.delete()
super().tearDown()
def make_object(self):
with set_actor(self.user):
return super().make_object()
def check_create_log_entry(self, obj, log_entry):
super().check_create_log_entry(obj, log_entry)
self.assertEqual(log_entry.actor, self.user)
def update(self, obj):
with set_actor(self.user):
return super().update(obj)
def check_update_log_entry(self, obj, log_entry):
super().check_update_log_entry(obj, log_entry)
self.assertEqual(log_entry.actor, self.user)
def delete(self, obj):
with set_actor(self.user):
return super().delete(obj)
def check_delete_log_entry(self, obj, log_entry):
super().check_delete_log_entry(obj, log_entry)
self.assertEqual(log_entry.actor, self.user)
class AltPrimaryKeyModelBase(SimpleModelTest):
def make_object(self):
return 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.")
class AltPrimaryKeyModelTest(NoActorMixin, AltPrimaryKeyModelBase):
pass
class AltPrimaryKeyModelWithActorTest(WithActorMixin, AltPrimaryKeyModelBase):
pass
class UUIDPrimaryKeyModelModelBase(SimpleModelTest):
def make_object(self):
return UUIDPrimaryKeyModel.objects.create(text="I am strange.")
def test_get_for_object(self):
self.obj.boolean = True
@ -135,9 +209,27 @@ class UUIDPrimaryKeyModelModelTest(SimpleModelTest):
)
class ProxyModelTest(SimpleModelTest):
def setUp(self):
self.obj = ProxyModel.objects.create(text="I am not what you think.")
class UUIDPrimaryKeyModelModelTest(NoActorMixin, UUIDPrimaryKeyModelModelBase):
pass
class UUIDPrimaryKeyModelModelWithActorTest(
WithActorMixin, UUIDPrimaryKeyModelModelBase
):
pass
class ProxyModelBase(SimpleModelTest):
def make_object(self):
return ProxyModel.objects.create(text="I am not what you think.")
class ProxyModelTest(NoActorMixin, ProxyModelBase):
pass
class ProxyModelWithActorTest(WithActorMixin, ProxyModelBase):
pass
class ManyRelatedModelTest(TestCase):
@ -167,72 +259,66 @@ class MiddlewareTest(TestCase):
"""
def setUp(self):
self.middleware = AuditlogMiddleware()
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"
)
def side_effect(self, assertion):
def inner(request):
assertion()
return self.response_mock
return inner
def assert_has_listeners(self):
self.assertTrue(pre_save.has_listeners(LogEntry))
def assert_no_listeners(self):
self.assertFalse(pre_save.has_listeners(LogEntry))
def test_request_anonymous(self):
"""No actor will be logged when a user is not logged in."""
# Create a request
request = self.factory.get("/")
request.user = AnonymousUser()
# Run middleware
self.middleware.process_request(request)
self.get_response_mock.side_effect = self.side_effect(self.assert_no_listeners)
# Validate result
self.assertFalse(pre_save.has_listeners(LogEntry))
response = self.middleware(request)
# Finalize transaction
self.middleware.process_exception(request, None)
self.assertIs(response, self.response_mock)
self.get_response_mock.assert_called_once_with(request)
self.assert_no_listeners()
def test_request(self):
"""The actor will be logged when a user is logged in."""
# Create a request
request = self.factory.get("/")
request.user = self.user
# Run middleware
self.middleware.process_request(request)
# Validate result
self.assertTrue(pre_save.has_listeners(LogEntry))
# Finalize transaction
self.middleware.process_exception(request, None)
def test_response(self):
"""The signal will be disconnected when the request is processed."""
# Create a request
request = self.factory.get("/")
request.user = self.user
# Run middleware
self.middleware.process_request(request)
self.assertTrue(
pre_save.has_listeners(LogEntry)
) # The signal should be present before trying to disconnect it.
self.middleware.process_response(request, HttpResponse())
self.get_response_mock.side_effect = self.side_effect(self.assert_has_listeners)
# Validate result
self.assertFalse(pre_save.has_listeners(LogEntry))
response = self.middleware(request)
self.assertIs(response, self.response_mock)
self.get_response_mock.assert_called_once_with(request)
self.assert_no_listeners()
def test_exception(self):
"""The signal will be disconnected when an exception is raised."""
# Create a request
request = self.factory.get("/")
request.user = self.user
# Run middleware
self.middleware.process_request(request)
self.assertTrue(
pre_save.has_listeners(LogEntry)
) # The signal should be present before trying to disconnect it.
self.middleware.process_exception(request, ValidationError("Test"))
SomeException = type("SomeException", (Exception,), {})
# Validate result
self.assertFalse(pre_save.has_listeners(LogEntry))
self.get_response_mock.side_effect = SomeException
with self.assertRaises(SomeException):
self.middleware(request)
self.assert_no_listeners()
class SimpeIncludeModelTest(TestCase):
@ -241,17 +327,17 @@ class SimpeIncludeModelTest(TestCase):
def test_register_include_fields(self):
sim = SimpleIncludeModel(label="Include model", text="Looong text")
sim.save()
self.assertTrue(sim.history.count() == 1, msg="There is one log entry")
self.assertEqual(sim.history.count(), 1, msg="There is one log entry")
# Change label, record
sim.label = "Changed label"
sim.save()
self.assertTrue(sim.history.count() == 2, msg="There are two log entries")
self.assertEqual(sim.history.count(), 2, msg="There are two log entries")
# Change text, ignore
sim.text = "Short text"
sim.save()
self.assertTrue(sim.history.count() == 2, msg="There are two log entries")
self.assertEqual(sim.history.count(), 2, msg="There are two log entries")
class SimpeExcludeModelTest(TestCase):
@ -260,17 +346,17 @@ class SimpeExcludeModelTest(TestCase):
def test_register_exclude_fields(self):
sem = SimpleExcludeModel(label="Exclude model", text="Looong text")
sem.save()
self.assertTrue(sem.history.count() == 1, msg="There is one log entry")
self.assertEqual(sem.history.count(), 1, msg="There is one log entry")
# Change label, ignore
sem.label = "Changed label"
sem.save()
self.assertTrue(sem.history.count() == 2, msg="There are two log entries")
self.assertEqual(sem.history.count(), 2, msg="There are two log entries")
# Change text, record
sem.text = "Short text"
sem.save()
self.assertTrue(sem.history.count() == 2, msg="There are two log entries")
self.assertEqual(sem.history.count(), 2, msg="There are two log entries")
class SimpleMappingModelTest(TestCase):
@ -281,28 +367,32 @@ class SimpleMappingModelTest(TestCase):
sku="ASD301301A6", vtxt="2.1.5", not_mapped="Not mapped"
)
smm.save()
self.assertTrue(
smm.history.latest().changes_dict["sku"][1] == "ASD301301A6",
self.assertEqual(
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",
self.assertEqual(
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",
self.assertEqual(
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",
self.assertEqual(
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",
self.assertEqual(
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."
@ -326,8 +416,8 @@ class AdditionalDataModelTest(TestCase):
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.assertEqual(
obj_with_additional_data.history.count(), 1, msg="There is 1 log entry"
)
log_entry = obj_with_additional_data.history.get()
# FIXME: Work-around for the fact that additional_data isn't working
@ -337,12 +427,14 @@ class AdditionalDataModelTest(TestCase):
else:
extra_data = log_entry.additional_data
self.assertIsNotNone(extra_data)
self.assertTrue(
extra_data["related_model_text"] == related_model.text,
self.assertEqual(
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,
self.assertEqual(
extra_data["related_model_id"],
related_model.id,
msg="Related model's id is logged",
)
@ -365,7 +457,7 @@ class DateTimeFieldModelTest(TestCase):
naive_dt=self.now,
)
dtm.save()
self.assertTrue(dtm.history.count() == 1, msg="There is one log entry")
self.assertEqual(dtm.history.count(), 1, msg="There is one log entry")
# Change timestamp to same datetime and timezone
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
@ -375,7 +467,7 @@ class DateTimeFieldModelTest(TestCase):
dtm.save()
# Nothing should have changed
self.assertTrue(dtm.history.count() == 1, msg="There is one log entry")
self.assertEqual(dtm.history.count(), 1, msg="There is one log entry")
def test_model_with_different_timezone(self):
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
@ -389,7 +481,7 @@ class DateTimeFieldModelTest(TestCase):
naive_dt=self.now,
)
dtm.save()
self.assertTrue(dtm.history.count() == 1, msg="There is one log entry")
self.assertEqual(dtm.history.count(), 1, msg="There is one log entry")
# Change timestamp to same datetime in another timezone
timestamp = datetime.datetime(2017, 1, 10, 13, 0, tzinfo=self.utc_plus_one)
@ -397,7 +489,7 @@ class DateTimeFieldModelTest(TestCase):
dtm.save()
# Nothing should have changed
self.assertTrue(dtm.history.count() == 1, msg="There is one log entry")
self.assertEqual(dtm.history.count(), 1, msg="There is one log entry")
def test_model_with_different_datetime(self):
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
@ -411,7 +503,7 @@ class DateTimeFieldModelTest(TestCase):
naive_dt=self.now,
)
dtm.save()
self.assertTrue(dtm.history.count() == 1, msg="There is one log entry")
self.assertEqual(dtm.history.count(), 1, msg="There is one log entry")
# Change timestamp to another datetime in the same timezone
timestamp = datetime.datetime(2017, 1, 10, 13, 0, tzinfo=timezone.utc)
@ -419,7 +511,7 @@ class DateTimeFieldModelTest(TestCase):
dtm.save()
# The time should have changed.
self.assertTrue(dtm.history.count() == 2, msg="There are two log entries")
self.assertEqual(dtm.history.count(), 2, msg="There are two log entries")
def test_model_with_different_date(self):
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
@ -433,7 +525,7 @@ class DateTimeFieldModelTest(TestCase):
naive_dt=self.now,
)
dtm.save()
self.assertTrue(dtm.history.count() == 1, msg="There is one log entry")
self.assertEqual(dtm.history.count(), 1, msg="There is one log entry")
# Change timestamp to another datetime in the same timezone
date = datetime.datetime(2017, 1, 11)
@ -441,7 +533,7 @@ class DateTimeFieldModelTest(TestCase):
dtm.save()
# The time should have changed.
self.assertTrue(dtm.history.count() == 2, msg="There are two log entries")
self.assertEqual(dtm.history.count(), 2, msg="There are two log entries")
def test_model_with_different_time(self):
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
@ -455,7 +547,7 @@ class DateTimeFieldModelTest(TestCase):
naive_dt=self.now,
)
dtm.save()
self.assertTrue(dtm.history.count() == 1, msg="There is one log entry")
self.assertEqual(dtm.history.count(), 1, msg="There is one log entry")
# Change timestamp to another datetime in the same timezone
time = datetime.time(6, 0)
@ -463,7 +555,7 @@ class DateTimeFieldModelTest(TestCase):
dtm.save()
# The time should have changed.
self.assertTrue(dtm.history.count() == 2, msg="There are two log entries")
self.assertEqual(dtm.history.count(), 2, msg="There are two log entries")
def test_model_with_different_time_and_timezone(self):
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
@ -477,7 +569,7 @@ class DateTimeFieldModelTest(TestCase):
naive_dt=self.now,
)
dtm.save()
self.assertTrue(dtm.history.count() == 1, msg="There is one log entry")
self.assertEqual(dtm.history.count(), 1, msg="There is one log entry")
# Change timestamp to another datetime and another timezone
timestamp = datetime.datetime(2017, 1, 10, 14, 0, tzinfo=self.utc_plus_one)
@ -485,7 +577,7 @@ class DateTimeFieldModelTest(TestCase):
dtm.save()
# The time should have changed.
self.assertTrue(dtm.history.count() == 2, msg="There are two log entries")
self.assertEqual(dtm.history.count(), 2, msg="There are two log entries")
def test_changes_display_dict_datetime(self):
timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc)
@ -500,9 +592,9 @@ class DateTimeFieldModelTest(TestCase):
)
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),
self.assertEqual(
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"
@ -512,9 +604,9 @@ class DateTimeFieldModelTest(TestCase):
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),
self.assertEqual(
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"
@ -523,9 +615,9 @@ class DateTimeFieldModelTest(TestCase):
# 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),
self.assertEqual(
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."
@ -544,9 +636,9 @@ class DateTimeFieldModelTest(TestCase):
naive_dt=self.now,
)
dtm.save()
self.assertTrue(
dtm.history.latest().changes_display_dict["date"][1]
== dateformat.format(date, settings.DATE_FORMAT),
self.assertEqual(
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."
@ -555,9 +647,9 @@ class DateTimeFieldModelTest(TestCase):
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),
self.assertEqual(
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."
@ -566,9 +658,9 @@ class DateTimeFieldModelTest(TestCase):
# 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),
self.assertEqual(
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."
@ -587,9 +679,9 @@ class DateTimeFieldModelTest(TestCase):
naive_dt=self.now,
)
dtm.save()
self.assertTrue(
dtm.history.latest().changes_display_dict["time"][1]
== dateformat.format(time, settings.TIME_FORMAT),
self.assertEqual(
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."
@ -598,9 +690,9 @@ class DateTimeFieldModelTest(TestCase):
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),
self.assertEqual(
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."
@ -609,9 +701,9 @@ class DateTimeFieldModelTest(TestCase):
# 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),
self.assertEqual(
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."
@ -651,7 +743,7 @@ class UnregisterTest(TestCase):
obj = self.obj
# Check for log entries
self.assertTrue(obj.history.count() == 0, msg="There are no log entries")
self.assertEqual(obj.history.count(), 0, msg="There are no log entries")
def test_unregister_update(self):
"""Updates are not logged after unregistering."""
@ -663,7 +755,7 @@ class UnregisterTest(TestCase):
obj.save()
# Check for log entries
self.assertTrue(obj.history.count() == 0, msg="There are no log entries")
self.assertEqual(obj.history.count(), 0, msg="There are no log entries")
def test_unregister_delete(self):
"""Deletion is not logged after unregistering."""
@ -674,7 +766,7 @@ class UnregisterTest(TestCase):
obj.delete()
# Check for log entries
self.assertTrue(LogEntry.objects.count() == 0, msg="There are no log entries")
self.assertEqual(LogEntry.objects.count(), 0, msg="There are no log entries")
class ChoicesFieldModelTest(TestCase):
@ -690,28 +782,30 @@ class ChoicesFieldModelTest(TestCase):
def test_changes_display_dict_single_choice(self):
self.assertTrue(
self.obj.history.latest().changes_display_dict["status"][1] == "Red",
self.assertEqual(
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",
self.assertEqual(
self.obj.history.latest().changes_display_dict["status"][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",
self.assertEqual(
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",
self.assertEqual(
self.obj.history.latest().changes_display_dict["multiplechoice"][1],
"Red",
msg="The human readable text 'Red' is displayed.",
)
@ -726,32 +820,32 @@ 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]),
self.assertEqual(
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,
self.assertEqual(
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]),
self.assertEqual(
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,
self.assertEqual(
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",
)
@ -769,26 +863,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",
self.assertEqual(
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",
self.assertEqual(
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.assertEqual(
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",
self.assertEqual(
self.latest_array_change,
"Green",
msg="The human readable text 'Green' is displayed.",
)

View file

@ -9,6 +9,7 @@
import os
import sys
from datetime import date
from pkg_resources import get_distribution
# -- Path setup --------------------------------------------------------------
@ -18,22 +19,23 @@ from pkg_resources import get_distribution
# 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}"
release = get_distribution('django-auditlog').version
release = get_distribution("django-auditlog").version
# for example take major/minor
version = '.'.join(release.split('.')[:2])
version = ".".join(release.split(".")[:2])
# -- General configuration ---------------------------------------------------
@ -41,27 +43,27 @@ version = '.'.join(release.split('.')[:2])
# 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,

View file

@ -1,6 +1,6 @@
[tool.black]
target-version = ["py36"]
# black compatible isort
[tool.isort]
profile = "black"
known_first_party = "auditlog"

View file

@ -16,13 +16,18 @@ setup(
"auditlog.management",
"auditlog.management.commands",
],
url="https://github.com/jazzband/django-auditlog",
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",
install_requires=["django-jsonfield>=1.0.0", "python-dateutil>=2.6.0"],
install_requires=[
"django-admin-rangefilter>=0.5.0",
"django-jsonfield>=1.0.0",
"python-dateutil>=2.6.0",
],
zip_safe=False,
classifiers=[
"Programming Language :: Python :: 3",

View file

@ -5,6 +5,8 @@ envlist =
py38-qa
[testenv]
setenv =
COVERAGE_FILE={toxworkdir}/.coverage.{envname}
commands =
coverage run --source auditlog runtests.py
coverage xml
@ -15,7 +17,9 @@ deps =
# Test requirements
coverage
codecov
psycopg2-binary
django-multiselectfield
mock
psycopg2-binary>=2.8,<2.9
passenv=
TEST_DB_HOST
TEST_DB_USER