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) The MIT License (MIT)
Copyright (c) 2013-2020 Jan-Jelle Kester 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 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 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.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.mixins import LogEntryAdminMixin
from auditlog.models import LogEntry 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): class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
list_display = ["created", "resource_url", "action", "msg_short", "user_url"] list_display = ["created", "resource_url", "action", "msg_short", "user_url"]
search_fields = [ search_fields = [
@ -14,12 +34,21 @@ class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
"actor__first_name", "actor__first_name",
"actor__last_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"] readonly_fields = ["created", "resource_url", "action", "user_url", "msg"]
fieldsets = [ fieldsets = [
(None, {"fields": ["created", "user_url", "resource_url"]}), (None, {"fields": ["created", "user_url", "resource_url"]}),
("Changes", {"fields": ["action", "msg"]}), ("Changes", {"fields": ["action", "msg"]}),
] ]
list_select_related = ["actor", "content_type"]
show_full_result_count = False
paginator = TimeLimitedPaginator
admin.site.register(LogEntry, LogEntryAdmin) 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 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): class ResourceTypeFilter(SimpleListFilter):
@ -6,11 +31,68 @@ class ResourceTypeFilter(SimpleListFilter):
parameter_name = "resource_type" parameter_name = "resource_type"
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
qs = model_admin.get_queryset(request) tracked_model_names = [
types = qs.values_list("content_type_id", "content_type__model") "{}.{}".format(m._meta.app_label, m._meta.model_name)
return list(types.order_by("content_type__model").distinct()) 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): def queryset(self, request, queryset):
if self.value() is None: if self.value() is None:
return queryset return queryset
return queryset.filter(content_type_id=self.value()) 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 contextlib
import time
from functools import partial
from django.apps import apps from auditlog.context import set_actor
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()
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 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). user from the request (or None if the user is not authenticated).
""" """
def process_request(self, request): def __init__(self, get_response=None):
""" self.get_response = get_response
Gets the current user from the request and prepares and connects a signal receiver with the user already
attached to it. def __call__(self, request):
"""
# Initialize thread local storage
threadlocal.auditlog = {
"signal_duid": (self.__class__, time.time()),
"remote_addr": request.META.get("REMOTE_ADDR"),
}
# In case of proxy, set 'original' address
if request.META.get("HTTP_X_FORWARDED_FOR"): if request.META.get("HTTP_X_FORWARDED_FOR"):
threadlocal.auditlog["remote_addr"] = request.META.get( # In case of proxy, set 'original' address
"HTTP_X_FORWARDED_FOR" remote_addr = request.META.get("HTTP_X_FORWARDED_FOR").split(",")[0]
).split(",")[0] else:
remote_addr = request.META.get("REMOTE_ADDR")
# Connect signal for automatic logging if hasattr(request, "user") and request.user.is_authenticated:
if hasattr(request, "user") and getattr( context = set_actor(actor=request.user, remote_addr=remote_addr)
request.user, "is_authenticated", False else:
): context = nullcontext()
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,
)
def process_response(self, request, response): with context:
""" return self.get_response(request)
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"]

View file

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

View file

@ -1,5 +1,5 @@
import jsonfield.fields import jsonfield.fields
from django.db import migrations, models from django.db import migrations
class Migration(migrations.Migration): 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.urls.exceptions import NoReverseMatch
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.timezone import localtime
from auditlog.models import LogEntry from auditlog.models import LogEntry
@ -13,7 +14,7 @@ MAX = 75
class LogEntryAdminMixin(object): class LogEntryAdminMixin(object):
def created(self, obj): 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" 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.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist
from django.db import DEFAULT_DB_ALIAS, models 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 import formats, timezone
from django.utils.encoding import smart_str 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 from jsonfield.fields import JSONField
@ -239,6 +239,7 @@ class LogEntry(models.Model):
ordering = ["-timestamp"] ordering = ["-timestamp"]
verbose_name = _("log entry") verbose_name = _("log entry")
verbose_name_plural = _("log entries") verbose_name_plural = _("log entries")
index_together = ("timestamp", "id")
def __str__(self): def __str__(self):
if self.action == self.Action.CREATE: if self.action == self.Action.CREATE:
@ -310,14 +311,9 @@ class LogEntry(models.Model):
values_display = [] values_display = []
# handle choices fields and Postgres ArrayField to get human readable version # handle choices fields and Postgres ArrayField to get human readable version
choices_dict = None choices_dict = None
if getattr(field, "choices") and len(field.choices) > 0: if getattr(field, "choices", []):
choices_dict = dict(field.choices) choices_dict = dict(field.choices)
if ( if getattr(getattr(field, "base_field", None), "choices", []):
hasattr(field, "base_field")
and isinstance(field.base_field, Field)
and getattr(field.base_field, "choices")
and len(field.base_field.choices) > 0
):
choices_dict = dict(field.base_field.choices) choices_dict = dict(field.base_field.choices)
if choices_dict: if choices_dict:
@ -332,9 +328,7 @@ class LogEntry(models.Model):
) )
else: else:
values_display.append(choices_dict.get(value, "None")) values_display.append(choices_dict.get(value, "None"))
except ValueError: except Exception:
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: else:
try: try:

View file

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

View file

@ -18,18 +18,20 @@ INSTALLED_APPS = [
"auditlog_tests", "auditlog_tests",
] ]
MIDDLEWARE = ( MIDDLEWARE = [
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"auditlog.middleware.AuditlogMiddleware", "auditlog.middleware.AuditlogMiddleware",
) ]
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.postgresql", "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"), "USER": os.getenv("TEST_DB_USER", "postgres"),
"PASSWORD": os.getenv("TEST_DB_PASS", ""), "PASSWORD": os.getenv("TEST_DB_PASS", ""),
"HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"), "HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"),

View file

@ -1,17 +1,19 @@
import datetime import datetime
import itertools
import json import json
import django import django
import mock
from dateutil.tz import gettz from dateutil.tz import gettz
from django.conf import settings 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.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.db.models.signals import pre_save
from django.http import HttpResponse
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
from django.utils import dateformat, formats, timezone from django.utils import dateformat, formats, timezone
from auditlog.context import set_actor
from auditlog.middleware import AuditlogMiddleware from auditlog.middleware import AuditlogMiddleware
from auditlog.models import LogEntry from auditlog.models import LogEntry
from auditlog.registry import auditlog from auditlog.registry import auditlog
@ -36,7 +38,11 @@ from auditlog_tests.models import (
class SimpleModelTest(TestCase): class SimpleModelTest(TestCase):
def setUp(self): 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): def test_create(self):
"""Creation is logged correctly.""" """Creation is logged correctly."""
@ -44,19 +50,16 @@ class SimpleModelTest(TestCase):
obj = self.obj obj = self.obj
# Check for log entries # 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()
history = obj.history.get() self.check_create_log_entry(obj, history)
except obj.history.DoesNotExist:
self.assertTrue(False, "Log entry exists") def check_create_log_entry(self, obj, history):
else: self.assertEqual(
self.assertEqual( history.action, LogEntry.Action.CREATE, msg="Action is 'CREATE'"
history.action, LogEntry.Action.CREATE, msg="Action is 'CREATE'" )
) self.assertEqual(history.object_repr, str(obj), msg="Representation is equal")
self.assertEqual(
history.object_repr, str(obj), msg="Representation is equal"
)
def test_update(self): def test_update(self):
"""Updates are logged correctly.""" """Updates are logged correctly."""
@ -64,17 +67,23 @@ class SimpleModelTest(TestCase):
obj = self.obj obj = self.obj
# Change something # Change something
obj.boolean = True self.update(obj)
obj.save()
# Check for log entries # Check for log entries
self.assertTrue( self.assertEqual(
obj.history.filter(action=LogEntry.Action.UPDATE).count() == 1, obj.history.filter(action=LogEntry.Action.UPDATE).count(),
1,
msg="There is one log entry for 'UPDATE'", msg="There is one log entry for 'UPDATE'",
) )
history = obj.history.get(action=LogEntry.Action.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( self.assertJSONEqual(
history.changes, history.changes,
'{"boolean": ["False", "True"]}', '{"boolean": ["False", "True"]}',
@ -85,39 +94,104 @@ class SimpleModelTest(TestCase):
"""Deletion is logged correctly.""" """Deletion is logged correctly."""
# Get the object to work with # Get the object to work with
obj = self.obj obj = self.obj
content_type = ContentType.objects.get_for_model(obj.__class__)
history = obj.history.latest() pk = obj.pk
# Delete the object # Delete the object
obj.delete() self.delete(obj)
# Check for log entries # Check for log entries
self.assertTrue( qs = LogEntry.objects.filter(content_type=content_type, object_pk=pk)
LogEntry.objects.filter( self.assertEqual(qs.count(), 1, msg="There is one log entry for 'DELETE'")
content_type=history.content_type,
object_pk=history.object_pk, history = qs.get()
action=LogEntry.Action.DELETE, self.check_delete_log_entry(obj, history)
).count()
== 1, def delete(self, obj):
msg="There is one log entry for 'DELETE'", obj.delete()
)
def check_delete_log_entry(self, obj, history):
pass
def test_recreate(self): def test_recreate(self):
SimpleModel.objects.all().delete() self.obj.delete()
self.setUp() self.setUp()
self.test_create() 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): 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." key=str(datetime.datetime.now()), text="I am strange."
) )
class UUIDPrimaryKeyModelModelTest(SimpleModelTest): class AltPrimaryKeyModelTest(NoActorMixin, AltPrimaryKeyModelBase):
def setUp(self): pass
self.obj = UUIDPrimaryKeyModel.objects.create(text="I am strange.")
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): def test_get_for_object(self):
self.obj.boolean = True self.obj.boolean = True
@ -135,9 +209,27 @@ class UUIDPrimaryKeyModelModelTest(SimpleModelTest):
) )
class ProxyModelTest(SimpleModelTest): class UUIDPrimaryKeyModelModelTest(NoActorMixin, UUIDPrimaryKeyModelModelBase):
def setUp(self): pass
self.obj = ProxyModel.objects.create(text="I am not what you think.")
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): class ManyRelatedModelTest(TestCase):
@ -167,72 +259,66 @@ class MiddlewareTest(TestCase):
""" """
def setUp(self): 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.factory = RequestFactory()
self.user = User.objects.create_user( self.user = User.objects.create_user(
username="test", email="test@example.com", password="top_secret" 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): def test_request_anonymous(self):
"""No actor will be logged when a user is not logged in.""" """No actor will be logged when a user is not logged in."""
# Create a request
request = self.factory.get("/") request = self.factory.get("/")
request.user = AnonymousUser() request.user = AnonymousUser()
# Run middleware self.get_response_mock.side_effect = self.side_effect(self.assert_no_listeners)
self.middleware.process_request(request)
# Validate result response = self.middleware(request)
self.assertFalse(pre_save.has_listeners(LogEntry))
# Finalize transaction self.assertIs(response, self.response_mock)
self.middleware.process_exception(request, None) self.get_response_mock.assert_called_once_with(request)
self.assert_no_listeners()
def test_request(self): def test_request(self):
"""The actor will be logged when a user is logged in.""" """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 = self.factory.get("/")
request.user = self.user request.user = self.user
# Run middleware self.get_response_mock.side_effect = self.side_effect(self.assert_has_listeners)
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())
# Validate result response = self.middleware(request)
self.assertFalse(pre_save.has_listeners(LogEntry))
self.assertIs(response, self.response_mock)
self.get_response_mock.assert_called_once_with(request)
self.assert_no_listeners()
def test_exception(self): def test_exception(self):
"""The signal will be disconnected when an exception is raised.""" """The signal will be disconnected when an exception is raised."""
# Create a request
request = self.factory.get("/") request = self.factory.get("/")
request.user = self.user request.user = self.user
# Run middleware SomeException = type("SomeException", (Exception,), {})
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"))
# Validate result self.get_response_mock.side_effect = SomeException
self.assertFalse(pre_save.has_listeners(LogEntry))
with self.assertRaises(SomeException):
self.middleware(request)
self.assert_no_listeners()
class SimpeIncludeModelTest(TestCase): class SimpeIncludeModelTest(TestCase):
@ -241,17 +327,17 @@ class SimpeIncludeModelTest(TestCase):
def test_register_include_fields(self): def test_register_include_fields(self):
sim = SimpleIncludeModel(label="Include model", text="Looong text") sim = SimpleIncludeModel(label="Include model", text="Looong text")
sim.save() 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 # Change label, record
sim.label = "Changed label" sim.label = "Changed label"
sim.save() 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 # Change text, ignore
sim.text = "Short text" sim.text = "Short text"
sim.save() 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): class SimpeExcludeModelTest(TestCase):
@ -260,17 +346,17 @@ class SimpeExcludeModelTest(TestCase):
def test_register_exclude_fields(self): def test_register_exclude_fields(self):
sem = SimpleExcludeModel(label="Exclude model", text="Looong text") sem = SimpleExcludeModel(label="Exclude model", text="Looong text")
sem.save() 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 # Change label, ignore
sem.label = "Changed label" sem.label = "Changed label"
sem.save() 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 # Change text, record
sem.text = "Short text" sem.text = "Short text"
sem.save() 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): class SimpleMappingModelTest(TestCase):
@ -281,28 +367,32 @@ class SimpleMappingModelTest(TestCase):
sku="ASD301301A6", vtxt="2.1.5", not_mapped="Not mapped" sku="ASD301301A6", vtxt="2.1.5", not_mapped="Not mapped"
) )
smm.save() smm.save()
self.assertTrue( self.assertEqual(
smm.history.latest().changes_dict["sku"][1] == "ASD301301A6", smm.history.latest().changes_dict["sku"][1],
"ASD301301A6",
msg="The diff function retains 'sku' and can be retrieved.", msg="The diff function retains 'sku' and can be retrieved.",
) )
self.assertTrue( self.assertEqual(
smm.history.latest().changes_dict["not_mapped"][1] == "Not mapped", smm.history.latest().changes_dict["not_mapped"][1],
"Not mapped",
msg="The diff function does not map 'not_mapped' and can be retrieved.", msg="The diff function does not map 'not_mapped' and can be retrieved.",
) )
self.assertTrue( self.assertEqual(
smm.history.latest().changes_display_dict["Product No."][1] smm.history.latest().changes_display_dict["Product No."][1],
== "ASD301301A6", "ASD301301A6",
msg="The diff function maps 'sku' as 'Product No.' and can be retrieved.", msg="The diff function maps 'sku' as 'Product No.' and can be retrieved.",
) )
self.assertTrue( self.assertEqual(
smm.history.latest().changes_display_dict["Version"][1] == "2.1.5", smm.history.latest().changes_display_dict["Version"][1],
"2.1.5",
msg=( msg=(
"The diff function maps 'vtxt' as 'Version' through verbose_name" "The diff function maps 'vtxt' as 'Version' through verbose_name"
" setting on the model field and can be retrieved." " setting on the model field and can be retrieved."
), ),
) )
self.assertTrue( self.assertEqual(
smm.history.latest().changes_display_dict["not mapped"][1] == "Not mapped", smm.history.latest().changes_display_dict["not mapped"][1],
"Not mapped",
msg=( msg=(
"The diff function uses the django default verbose name for 'not_mapped'" "The diff function uses the django default verbose name for 'not_mapped'"
" and can be retrieved." " and can be retrieved."
@ -326,8 +416,8 @@ class AdditionalDataModelTest(TestCase):
label="Additional data to log entries", related=related_model label="Additional data to log entries", related=related_model
) )
obj_with_additional_data.save() obj_with_additional_data.save()
self.assertTrue( self.assertEqual(
obj_with_additional_data.history.count() == 1, msg="There is 1 log entry" obj_with_additional_data.history.count(), 1, msg="There is 1 log entry"
) )
log_entry = obj_with_additional_data.history.get() log_entry = obj_with_additional_data.history.get()
# FIXME: Work-around for the fact that additional_data isn't working # FIXME: Work-around for the fact that additional_data isn't working
@ -337,12 +427,14 @@ class AdditionalDataModelTest(TestCase):
else: else:
extra_data = log_entry.additional_data extra_data = log_entry.additional_data
self.assertIsNotNone(extra_data) self.assertIsNotNone(extra_data)
self.assertTrue( self.assertEqual(
extra_data["related_model_text"] == related_model.text, extra_data["related_model_text"],
related_model.text,
msg="Related model's text is logged", msg="Related model's text is logged",
) )
self.assertTrue( self.assertEqual(
extra_data["related_model_id"] == related_model.id, extra_data["related_model_id"],
related_model.id,
msg="Related model's id is logged", msg="Related model's id is logged",
) )
@ -365,7 +457,7 @@ class DateTimeFieldModelTest(TestCase):
naive_dt=self.now, naive_dt=self.now,
) )
dtm.save() 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 # Change timestamp to same datetime and timezone
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
@ -375,7 +467,7 @@ class DateTimeFieldModelTest(TestCase):
dtm.save() dtm.save()
# Nothing should have changed # 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): def test_model_with_different_timezone(self):
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
@ -389,7 +481,7 @@ class DateTimeFieldModelTest(TestCase):
naive_dt=self.now, naive_dt=self.now,
) )
dtm.save() 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 # Change timestamp to same datetime in another timezone
timestamp = datetime.datetime(2017, 1, 10, 13, 0, tzinfo=self.utc_plus_one) timestamp = datetime.datetime(2017, 1, 10, 13, 0, tzinfo=self.utc_plus_one)
@ -397,7 +489,7 @@ class DateTimeFieldModelTest(TestCase):
dtm.save() dtm.save()
# Nothing should have changed # 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): def test_model_with_different_datetime(self):
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
@ -411,7 +503,7 @@ class DateTimeFieldModelTest(TestCase):
naive_dt=self.now, naive_dt=self.now,
) )
dtm.save() 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 # Change timestamp to another datetime in the same timezone
timestamp = datetime.datetime(2017, 1, 10, 13, 0, tzinfo=timezone.utc) timestamp = datetime.datetime(2017, 1, 10, 13, 0, tzinfo=timezone.utc)
@ -419,7 +511,7 @@ class DateTimeFieldModelTest(TestCase):
dtm.save() dtm.save()
# The time should have changed. # 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): def test_model_with_different_date(self):
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
@ -433,7 +525,7 @@ class DateTimeFieldModelTest(TestCase):
naive_dt=self.now, naive_dt=self.now,
) )
dtm.save() 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 # Change timestamp to another datetime in the same timezone
date = datetime.datetime(2017, 1, 11) date = datetime.datetime(2017, 1, 11)
@ -441,7 +533,7 @@ class DateTimeFieldModelTest(TestCase):
dtm.save() dtm.save()
# The time should have changed. # 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): def test_model_with_different_time(self):
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
@ -455,7 +547,7 @@ class DateTimeFieldModelTest(TestCase):
naive_dt=self.now, naive_dt=self.now,
) )
dtm.save() 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 # Change timestamp to another datetime in the same timezone
time = datetime.time(6, 0) time = datetime.time(6, 0)
@ -463,7 +555,7 @@ class DateTimeFieldModelTest(TestCase):
dtm.save() dtm.save()
# The time should have changed. # 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): def test_model_with_different_time_and_timezone(self):
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
@ -477,7 +569,7 @@ class DateTimeFieldModelTest(TestCase):
naive_dt=self.now, naive_dt=self.now,
) )
dtm.save() 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 # Change timestamp to another datetime and another timezone
timestamp = datetime.datetime(2017, 1, 10, 14, 0, tzinfo=self.utc_plus_one) timestamp = datetime.datetime(2017, 1, 10, 14, 0, tzinfo=self.utc_plus_one)
@ -485,7 +577,7 @@ class DateTimeFieldModelTest(TestCase):
dtm.save() dtm.save()
# The time should have changed. # 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): def test_changes_display_dict_datetime(self):
timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc) timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc)
@ -500,9 +592,9 @@ class DateTimeFieldModelTest(TestCase):
) )
dtm.save() dtm.save()
localized_timestamp = timestamp.astimezone(gettz(settings.TIME_ZONE)) localized_timestamp = timestamp.astimezone(gettz(settings.TIME_ZONE))
self.assertTrue( self.assertEqual(
dtm.history.latest().changes_display_dict["timestamp"][1] dtm.history.latest().changes_display_dict["timestamp"][1],
== dateformat.format(localized_timestamp, settings.DATETIME_FORMAT), dateformat.format(localized_timestamp, settings.DATETIME_FORMAT),
msg=( msg=(
"The datetime should be formatted according to Django's settings for" "The datetime should be formatted according to Django's settings for"
" DATETIME_FORMAT" " DATETIME_FORMAT"
@ -512,9 +604,9 @@ class DateTimeFieldModelTest(TestCase):
dtm.timestamp = timestamp dtm.timestamp = timestamp
dtm.save() dtm.save()
localized_timestamp = timestamp.astimezone(gettz(settings.TIME_ZONE)) localized_timestamp = timestamp.astimezone(gettz(settings.TIME_ZONE))
self.assertTrue( self.assertEqual(
dtm.history.latest().changes_display_dict["timestamp"][1] dtm.history.latest().changes_display_dict["timestamp"][1],
== dateformat.format(localized_timestamp, settings.DATETIME_FORMAT), dateformat.format(localized_timestamp, settings.DATETIME_FORMAT),
msg=( msg=(
"The datetime should be formatted according to Django's settings for" "The datetime should be formatted according to Django's settings for"
" DATETIME_FORMAT" " DATETIME_FORMAT"
@ -523,9 +615,9 @@ class DateTimeFieldModelTest(TestCase):
# Change USE_L10N = True # Change USE_L10N = True
with self.settings(USE_L10N=True, LANGUAGE_CODE="en-GB"): with self.settings(USE_L10N=True, LANGUAGE_CODE="en-GB"):
self.assertTrue( self.assertEqual(
dtm.history.latest().changes_display_dict["timestamp"][1] dtm.history.latest().changes_display_dict["timestamp"][1],
== formats.localize(localized_timestamp), formats.localize(localized_timestamp),
msg=( msg=(
"The datetime should be formatted according to Django's settings for" "The datetime should be formatted according to Django's settings for"
" USE_L10N is True with a different LANGUAGE_CODE." " USE_L10N is True with a different LANGUAGE_CODE."
@ -544,9 +636,9 @@ class DateTimeFieldModelTest(TestCase):
naive_dt=self.now, naive_dt=self.now,
) )
dtm.save() dtm.save()
self.assertTrue( self.assertEqual(
dtm.history.latest().changes_display_dict["date"][1] dtm.history.latest().changes_display_dict["date"][1],
== dateformat.format(date, settings.DATE_FORMAT), dateformat.format(date, settings.DATE_FORMAT),
msg=( msg=(
"The date should be formatted according to Django's settings for" "The date should be formatted according to Django's settings for"
" DATE_FORMAT unless USE_L10N is True." " DATE_FORMAT unless USE_L10N is True."
@ -555,9 +647,9 @@ class DateTimeFieldModelTest(TestCase):
date = datetime.date(2017, 1, 11) date = datetime.date(2017, 1, 11)
dtm.date = date dtm.date = date
dtm.save() dtm.save()
self.assertTrue( self.assertEqual(
dtm.history.latest().changes_display_dict["date"][1] dtm.history.latest().changes_display_dict["date"][1],
== dateformat.format(date, settings.DATE_FORMAT), dateformat.format(date, settings.DATE_FORMAT),
msg=( msg=(
"The date should be formatted according to Django's settings for" "The date should be formatted according to Django's settings for"
" DATE_FORMAT unless USE_L10N is True." " DATE_FORMAT unless USE_L10N is True."
@ -566,9 +658,9 @@ class DateTimeFieldModelTest(TestCase):
# Change USE_L10N = True # Change USE_L10N = True
with self.settings(USE_L10N=True, LANGUAGE_CODE="en-GB"): with self.settings(USE_L10N=True, LANGUAGE_CODE="en-GB"):
self.assertTrue( self.assertEqual(
dtm.history.latest().changes_display_dict["date"][1] dtm.history.latest().changes_display_dict["date"][1],
== formats.localize(date), formats.localize(date),
msg=( msg=(
"The date should be formatted according to Django's settings for" "The date should be formatted according to Django's settings for"
" USE_L10N is True with a different LANGUAGE_CODE." " USE_L10N is True with a different LANGUAGE_CODE."
@ -587,9 +679,9 @@ class DateTimeFieldModelTest(TestCase):
naive_dt=self.now, naive_dt=self.now,
) )
dtm.save() dtm.save()
self.assertTrue( self.assertEqual(
dtm.history.latest().changes_display_dict["time"][1] dtm.history.latest().changes_display_dict["time"][1],
== dateformat.format(time, settings.TIME_FORMAT), dateformat.format(time, settings.TIME_FORMAT),
msg=( msg=(
"The time should be formatted according to Django's settings for" "The time should be formatted according to Django's settings for"
" TIME_FORMAT unless USE_L10N is True." " TIME_FORMAT unless USE_L10N is True."
@ -598,9 +690,9 @@ class DateTimeFieldModelTest(TestCase):
time = datetime.time(6, 0) time = datetime.time(6, 0)
dtm.time = time dtm.time = time
dtm.save() dtm.save()
self.assertTrue( self.assertEqual(
dtm.history.latest().changes_display_dict["time"][1] dtm.history.latest().changes_display_dict["time"][1],
== dateformat.format(time, settings.TIME_FORMAT), dateformat.format(time, settings.TIME_FORMAT),
msg=( msg=(
"The time should be formatted according to Django's settings for" "The time should be formatted according to Django's settings for"
" TIME_FORMAT unless USE_L10N is True." " TIME_FORMAT unless USE_L10N is True."
@ -609,9 +701,9 @@ class DateTimeFieldModelTest(TestCase):
# Change USE_L10N = True # Change USE_L10N = True
with self.settings(USE_L10N=True, LANGUAGE_CODE="en-GB"): with self.settings(USE_L10N=True, LANGUAGE_CODE="en-GB"):
self.assertTrue( self.assertEqual(
dtm.history.latest().changes_display_dict["time"][1] dtm.history.latest().changes_display_dict["time"][1],
== formats.localize(time), formats.localize(time),
msg=( msg=(
"The time should be formatted according to Django's settings for" "The time should be formatted according to Django's settings for"
" USE_L10N is True with a different LANGUAGE_CODE." " USE_L10N is True with a different LANGUAGE_CODE."
@ -651,7 +743,7 @@ class UnregisterTest(TestCase):
obj = self.obj obj = self.obj
# Check for log entries # 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): def test_unregister_update(self):
"""Updates are not logged after unregistering.""" """Updates are not logged after unregistering."""
@ -663,7 +755,7 @@ class UnregisterTest(TestCase):
obj.save() obj.save()
# Check for log entries # 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): def test_unregister_delete(self):
"""Deletion is not logged after unregistering.""" """Deletion is not logged after unregistering."""
@ -674,7 +766,7 @@ class UnregisterTest(TestCase):
obj.delete() obj.delete()
# Check for log entries # 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): class ChoicesFieldModelTest(TestCase):
@ -690,28 +782,30 @@ class ChoicesFieldModelTest(TestCase):
def test_changes_display_dict_single_choice(self): def test_changes_display_dict_single_choice(self):
self.assertTrue( self.assertEqual(
self.obj.history.latest().changes_display_dict["status"][1] == "Red", self.obj.history.latest().changes_display_dict["status"][1],
"Red",
msg="The human readable text 'Red' is displayed.", msg="The human readable text 'Red' is displayed.",
) )
self.obj.status = ChoicesFieldModel.GREEN self.obj.status = ChoicesFieldModel.GREEN
self.obj.save() self.obj.save()
self.assertTrue( self.assertEqual(
self.obj.history.latest().changes_display_dict["status"][1] == "Green", self.obj.history.latest().changes_display_dict["status"][1],
"Green",
msg="The human readable text 'Green' is displayed.", msg="The human readable text 'Green' is displayed.",
) )
def test_changes_display_dict_multiplechoice(self): def test_changes_display_dict_multiplechoice(self):
self.assertTrue( self.assertEqual(
self.obj.history.latest().changes_display_dict["multiplechoice"][1] self.obj.history.latest().changes_display_dict["multiplechoice"][1],
== "Red, Yellow, Green", "Red, Yellow, Green",
msg="The human readable text 'Red, Yellow, Green' is displayed.", msg="The human readable text 'Red, Yellow, Green' is displayed.",
) )
self.obj.multiplechoice = ChoicesFieldModel.RED self.obj.multiplechoice = ChoicesFieldModel.RED
self.obj.save() self.obj.save()
self.assertTrue( self.assertEqual(
self.obj.history.latest().changes_display_dict["multiplechoice"][1] self.obj.history.latest().changes_display_dict["multiplechoice"][1],
== "Red", "Red",
msg="The human readable text 'Red' is displayed.", msg="The human readable text 'Red' is displayed.",
) )
@ -726,32 +820,32 @@ class CharfieldTextfieldModelTest(TestCase):
) )
def test_changes_display_dict_longchar(self): def test_changes_display_dict_longchar(self):
self.assertTrue( self.assertEqual(
self.obj.history.latest().changes_display_dict["longchar"][1] self.obj.history.latest().changes_display_dict["longchar"][1],
== "{}...".format(self.PLACEHOLDER_LONGCHAR[:140]), "{}...".format(self.PLACEHOLDER_LONGCHAR[:140]),
msg="The string should be truncated at 140 characters with an ellipsis at the end.", msg="The string should be truncated at 140 characters with an ellipsis at the end.",
) )
SHORTENED_PLACEHOLDER = self.PLACEHOLDER_LONGCHAR[:139] SHORTENED_PLACEHOLDER = self.PLACEHOLDER_LONGCHAR[:139]
self.obj.longchar = SHORTENED_PLACEHOLDER self.obj.longchar = SHORTENED_PLACEHOLDER
self.obj.save() self.obj.save()
self.assertTrue( self.assertEqual(
self.obj.history.latest().changes_display_dict["longchar"][1] self.obj.history.latest().changes_display_dict["longchar"][1],
== SHORTENED_PLACEHOLDER, SHORTENED_PLACEHOLDER,
msg="The field should display the entire string because it is less than 140 characters", msg="The field should display the entire string because it is less than 140 characters",
) )
def test_changes_display_dict_longtextfield(self): def test_changes_display_dict_longtextfield(self):
self.assertTrue( self.assertEqual(
self.obj.history.latest().changes_display_dict["longtextfield"][1] self.obj.history.latest().changes_display_dict["longtextfield"][1],
== "{}...".format(self.PLACEHOLDER_LONGTEXTFIELD[:140]), "{}...".format(self.PLACEHOLDER_LONGTEXTFIELD[:140]),
msg="The string should be truncated at 140 characters with an ellipsis at the end.", msg="The string should be truncated at 140 characters with an ellipsis at the end.",
) )
SHORTENED_PLACEHOLDER = self.PLACEHOLDER_LONGTEXTFIELD[:139] SHORTENED_PLACEHOLDER = self.PLACEHOLDER_LONGTEXTFIELD[:139]
self.obj.longtextfield = SHORTENED_PLACEHOLDER self.obj.longtextfield = SHORTENED_PLACEHOLDER
self.obj.save() self.obj.save()
self.assertTrue( self.assertEqual(
self.obj.history.latest().changes_display_dict["longtextfield"][1] self.obj.history.latest().changes_display_dict["longtextfield"][1],
== SHORTENED_PLACEHOLDER, SHORTENED_PLACEHOLDER,
msg="The field should display the entire string because it is less than 140 characters", 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] return self.obj.history.latest().changes_display_dict["arrayfield"][1]
def test_changes_display_dict_arrayfield(self): def test_changes_display_dict_arrayfield(self):
self.assertTrue( self.assertEqual(
self.latest_array_change == "Red, Green", self.latest_array_change,
"Red, Green",
msg="The human readable text for the two choices, 'Red, Green' is displayed.", msg="The human readable text for the two choices, 'Red, Green' is displayed.",
) )
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN] self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
self.obj.save() self.obj.save()
self.assertTrue( self.assertEqual(
self.latest_array_change == "Green", self.latest_array_change,
"Green",
msg="The human readable text 'Green' is displayed.", msg="The human readable text 'Green' is displayed.",
) )
self.obj.arrayfield = [] self.obj.arrayfield = []
self.obj.save() self.obj.save()
self.assertTrue( self.assertEqual(
self.latest_array_change == "", self.latest_array_change, "", msg="The human readable text '' is displayed."
msg="The human readable text '' is displayed.",
) )
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN] self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
self.obj.save() self.obj.save()
self.assertTrue( self.assertEqual(
self.latest_array_change == "Green", self.latest_array_change,
"Green",
msg="The human readable text 'Green' is displayed.", msg="The human readable text 'Green' is displayed.",
) )

View file

@ -9,6 +9,7 @@
import os import os
import sys import sys
from datetime import date from datetime import date
from pkg_resources import get_distribution from pkg_resources import get_distribution
# -- Path setup -------------------------------------------------------------- # -- 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. # documentation root, use os.path.abspath to make it absolute, like shown here.
# Add sources folder # Add sources folder
sys.path.insert(0, os.path.abspath('../../')) sys.path.insert(0, os.path.abspath("../../"))
# Setup Django for autodoc # 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 import django
django.setup() django.setup()
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
project = 'django-auditlog' project = "django-auditlog"
author = 'Jan-Jelle Kester and contributors' author = "Jan-Jelle Kester and contributors"
copyright = f'2013-{date.today().year}, {author}' copyright = f"2013-{date.today().year}, {author}"
release = get_distribution('django-auditlog').version release = get_distribution("django-auditlog").version
# for example take major/minor # for example take major/minor
version = '.'.join(release.split('.')[:2]) version = ".".join(release.split(".")[:2])
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
@ -41,27 +43,27 @@ version = '.'.join(release.split('.')[:2])
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones. # ones.
extensions = [ extensions = [
'sphinx.ext.autodoc', "sphinx.ext.autodoc",
'sphinx.ext.viewcode', "sphinx.ext.viewcode",
] ]
# Master document that contains the root table of contents # 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. # 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 # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path. # 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 ------------------------------------------------- # -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for # The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes. # 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, # 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, # relative to this directory. They are copied after the builtin static files,

View file

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

View file

@ -16,13 +16,18 @@ setup(
"auditlog.management", "auditlog.management",
"auditlog.management.commands", "auditlog.management.commands",
], ],
url="https://github.com/jazzband/django-auditlog", url="https://github.com/MacmillanPlatform/django-auditlog/",
license="MIT", license="MIT",
author="Jan-Jelle Kester", author="Jan-Jelle Kester",
maintainer="Alieh Rymašeŭski",
description="Audit log app for Django", description="Audit log app for Django",
long_description=long_description, long_description=long_description,
long_description_content_type="text/markdown", 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, zip_safe=False,
classifiers=[ classifiers=[
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",

View file

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