mirror of
https://github.com/jazzband/django-auditlog.git
synced 2026-03-17 06:30:27 +00:00
Compare commits
31 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
358971aafe | ||
|
|
f58b3d7685 | ||
|
|
5cd55ac38c | ||
|
|
8397754a20 | ||
|
|
da49432924 | ||
|
|
3af06e13c7 | ||
|
|
ae57b0c322 | ||
|
|
bde49bdb4f | ||
|
|
c66b36c700 | ||
|
|
2ae401a04f | ||
|
|
5ba554af56 | ||
|
|
df2bf0a05c | ||
|
|
05e6b179fd | ||
|
|
07b38a9345 | ||
|
|
5784247180 | ||
|
|
2a43cff96f | ||
|
|
b16b1a0df3 | ||
|
|
9152d225bb | ||
|
|
c53b766132 | ||
|
|
e35d0f4194 | ||
|
|
e60876ae14 | ||
|
|
5dbea8a9a1 | ||
|
|
cfbc588cc1 | ||
|
|
ee6bb33bc9 | ||
|
|
c9c97b6861 | ||
|
|
5f5cc7f7e9 | ||
|
|
a5381b6195 | ||
|
|
2dc0ac43b5 | ||
|
|
aa28009d3b | ||
|
|
03b8616dac | ||
|
|
62c1e676cc |
29 changed files with 259 additions and 227 deletions
1
LICENSE
1
LICENSE
|
|
@ -1,6 +1,7 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Jan-Jelle Kester
|
||||
Copyright (c) 2019 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
|
||||
|
|
|
|||
4
MANIFEST
4
MANIFEST
|
|
@ -3,7 +3,7 @@ setup.py
|
|||
src/auditlog/__init__.py
|
||||
src/auditlog/admin.py
|
||||
src/auditlog/apps.py
|
||||
src/auditlog/compat.py
|
||||
src/auditlog/context.py
|
||||
src/auditlog/diff.py
|
||||
src/auditlog/filters.py
|
||||
src/auditlog/middleware.py
|
||||
|
|
@ -21,4 +21,6 @@ src/auditlog/migrations/0004_logentry_detailed_object_repr.py
|
|||
src/auditlog/migrations/0005_logentry_additional_data_verbose_name.py
|
||||
src/auditlog/migrations/0006_object_pk_index.py
|
||||
src/auditlog/migrations/0007_object_pk_type.py
|
||||
src/auditlog/migrations/0008_timestamp_index.py
|
||||
src/auditlog/migrations/0009_timestamp_id_index.py
|
||||
src/auditlog/migrations/__init__.py
|
||||
|
|
|
|||
|
|
@ -60,9 +60,9 @@ copyright = u'2017, Jan-Jelle Kester and contributors'
|
|||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = '0.4'
|
||||
version = '0.6'
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = '0.4.7'
|
||||
release = '0.6.6'
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ The repository can be found at https://github.com/jjkester/django-auditlog/.
|
|||
|
||||
**Requirements**
|
||||
|
||||
- Python 2.7, 3.4 or higher
|
||||
- Django 1.8 or higher
|
||||
- Python 3.5 or higher
|
||||
- Django 1.11 or higher
|
||||
|
||||
Auditlog is currently tested with Python 2.7 and 3.4 and Django 1.8, 1.9 and 1.10. The latest test report can be found
|
||||
at https://travis-ci.org/jjkester/django-auditlog.
|
||||
|
|
|
|||
|
|
@ -5,3 +5,4 @@ tox>=1.7.0
|
|||
codecov>=2.0.0
|
||||
django-multiselectfield==0.1.8
|
||||
psycopg2-binary
|
||||
mock
|
||||
|
|
|
|||
12
setup.py
12
setup.py
|
|
@ -2,26 +2,24 @@ from distutils.core import setup
|
|||
|
||||
setup(
|
||||
name='django-auditlog',
|
||||
version='0.4.7',
|
||||
version='0.6.6',
|
||||
packages=['auditlog', 'auditlog.migrations', 'auditlog.management', 'auditlog.management.commands'],
|
||||
package_dir={'': 'src'},
|
||||
url='https://github.com/jjkester/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',
|
||||
install_requires=[
|
||||
'django-jsonfield>=1.0.0',
|
||||
'python-dateutil==2.6.0'
|
||||
'python-dateutil>=2.6.0',
|
||||
],
|
||||
zip_safe=False,
|
||||
classifiers=[
|
||||
'Programming Language :: Python :: 2',
|
||||
'Programming Language :: Python :: 2.7',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3.4',
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Programming Language :: Python :: 3.7',
|
||||
'License :: OSI Approved :: MIT License',
|
||||
],
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
default_app_config = 'auditlog.apps.AuditlogConfig'
|
||||
|
|
|
|||
|
|
@ -1,18 +1,44 @@
|
|||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.core.paginator import Paginator
|
||||
from django.db import connection, transaction, OperationalError
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from .models import LogEntry
|
||||
from .mixins import LogEntryAdminMixin
|
||||
from .filters import ResourceTypeFilter
|
||||
from .filters import ResourceTypeFilter, FieldFilter
|
||||
|
||||
|
||||
class TimeLimitedPaginator(Paginator):
|
||||
"""A PostgreSQL-specific paginator with a hard time limit for total count of pages.
|
||||
|
||||
Courtesy of https://medium.com/@hakibenita/optimizing-django-admin-paginator-53c4eb6bfca3
|
||||
"""
|
||||
DEFAULT_PAGE_COUNT = 10000
|
||||
|
||||
@cached_property
|
||||
def count(self):
|
||||
timeout = getattr(settings, 'AUDITLOG_PAGINATOR_TIMEOUT', 500) # ms
|
||||
with transaction.atomic(), connection.cursor() as cursor:
|
||||
cursor.execute('SET LOCAL statement_timeout TO %s;', (timeout,))
|
||||
try:
|
||||
return super().count
|
||||
except OperationalError:
|
||||
return self.per_page * self.DEFAULT_PAGE_COUNT
|
||||
|
||||
|
||||
class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
|
||||
list_display = ['created', 'resource_url', 'action', 'msg_short', 'user_url']
|
||||
search_fields = ['timestamp', 'object_repr', 'changes', 'actor__first_name', 'actor__last_name']
|
||||
list_filter = ['action', ResourceTypeFilter]
|
||||
list_filter = ['action', ResourceTypeFilter, FieldFilter]
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
import django
|
||||
|
||||
def is_authenticated(user):
|
||||
"""Return whether or not a User is authenticated.
|
||||
|
||||
Function provides compatibility following deprecation of method call to
|
||||
`is_authenticated()` in Django 2.0.
|
||||
|
||||
This is *only* required to support Django < v1.10 (i.e. v1.9 and earlier),
|
||||
as `is_authenticated` was introduced as a property in v1.10.s
|
||||
"""
|
||||
if not hasattr(user, 'is_authenticated'):
|
||||
return False
|
||||
if callable(user.is_authenticated):
|
||||
# Will be callable if django.version < 2.0, but is only necessary in
|
||||
# v1.9 and earlier due to change introduced in v1.10 making
|
||||
# `is_authenticated` a property instead of a callable.
|
||||
return user.is_authenticated()
|
||||
else:
|
||||
return user.is_authenticated
|
||||
55
src/auditlog/context.py
Normal file
55
src/auditlog/context.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import contextlib
|
||||
import time
|
||||
import threading
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models.signals import pre_save
|
||||
from django.utils.functional import curry
|
||||
|
||||
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 = curry(_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'])
|
||||
|
||||
|
||||
def _set_actor(user, sender, instance, signal_duid, **kwargs):
|
||||
"""Signal receiver with an extra 'user' kwarg.
|
||||
|
||||
This function becomes a valid signal receiver when it is curried with the actor.
|
||||
"""
|
||||
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']
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models import Model, NOT_PROVIDED, DateTimeField
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
from django.contrib.admin import SimpleListFilter
|
||||
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 Concat, Cast
|
||||
|
||||
from auditlog.registry import auditlog
|
||||
|
||||
|
||||
class ResourceTypeFilter(SimpleListFilter):
|
||||
|
|
@ -6,11 +13,54 @@ 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.list()
|
||||
]
|
||||
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 [(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})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
from six import moves
|
||||
|
||||
from auditlog.models import LogEntry
|
||||
|
||||
|
|
@ -11,7 +10,7 @@ class Command(BaseCommand):
|
|||
answer = None
|
||||
|
||||
while answer not in ['', 'y', 'n']:
|
||||
answer = moves.input("Are you sure? [y/N]: ").lower().strip()
|
||||
answer = input("Are you sure? [y/N]: ").lower().strip()
|
||||
|
||||
if answer == 'y':
|
||||
count = LogEntry.objects.all().count()
|
||||
|
|
|
|||
|
|
@ -1,84 +1,35 @@
|
|||
from __future__ import unicode_literals
|
||||
import contextlib
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import pre_save
|
||||
from django.utils.functional import curry
|
||||
from django.apps import apps
|
||||
from auditlog.models import LogEntry
|
||||
from auditlog.compat import is_authenticated
|
||||
|
||||
# Use MiddlewareMixin when present (Django >= 1.10)
|
||||
try:
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
except ImportError:
|
||||
MiddlewareMixin = object
|
||||
from auditlog.context import set_actor
|
||||
|
||||
|
||||
threadlocal = threading.local()
|
||||
@contextlib.contextmanager
|
||||
def nullcontext():
|
||||
"""Equivalent to contextlib.nullcontext(None) from Python 3.7."""
|
||||
yield
|
||||
|
||||
|
||||
class AuditlogMiddleware(MiddlewareMixin):
|
||||
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 is_authenticated(request.user):
|
||||
set_actor = curry(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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
from django.db import migrations
|
||||
import jsonfield.fields
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db import migrations
|
||||
import jsonfield.fields
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
|
|
|
|||
17
src/auditlog/migrations/0008_timestamp_index.py
Normal file
17
src/auditlog/migrations/0008_timestamp_index.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# -*- 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'),
|
||||
),
|
||||
]
|
||||
21
src/auditlog/migrations/0009_timestamp_id_index.py
Normal file
21
src/auditlog/migrations/0009_timestamp_id_index.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
import ast
|
||||
|
||||
|
|
@ -11,7 +9,6 @@ from django.db import models, DEFAULT_DB_ALIAS
|
|||
from django.db.models import QuerySet, Q
|
||||
from django.utils import formats, timezone
|
||||
from django.utils.encoding import python_2_unicode_compatible, smart_text
|
||||
from django.utils.six import iteritems, integer_types
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from jsonfield.fields import JSONField
|
||||
|
|
@ -43,7 +40,7 @@ class LogEntryManager(models.Manager):
|
|||
kwargs.setdefault('object_pk', pk)
|
||||
kwargs.setdefault('object_repr', smart_text(instance))
|
||||
|
||||
if isinstance(pk, integer_types):
|
||||
if isinstance(pk, int):
|
||||
kwargs.setdefault('object_id', pk)
|
||||
|
||||
get_additional_data = getattr(instance, 'get_additional_data', None)
|
||||
|
|
@ -78,7 +75,7 @@ class LogEntryManager(models.Manager):
|
|||
content_type = ContentType.objects.get_for_model(instance.__class__)
|
||||
pk = self._get_pk_value(instance)
|
||||
|
||||
if isinstance(pk, integer_types):
|
||||
if isinstance(pk, int):
|
||||
return self.filter(content_type=content_type, object_id=pk)
|
||||
else:
|
||||
return self.filter(content_type=content_type, object_pk=smart_text(pk))
|
||||
|
|
@ -98,7 +95,7 @@ class LogEntryManager(models.Manager):
|
|||
content_type = ContentType.objects.get_for_model(queryset.model)
|
||||
primary_keys = list(queryset.values_list(queryset.model._meta.pk.name, flat=True))
|
||||
|
||||
if isinstance(primary_keys[0], integer_types):
|
||||
if isinstance(primary_keys[0], int):
|
||||
return self.filter(content_type=content_type).filter(Q(object_id__in=primary_keys)).distinct()
|
||||
elif isinstance(queryset.model._meta.pk, models.UUIDField):
|
||||
primary_keys = [smart_text(pk) for pk in primary_keys]
|
||||
|
|
@ -189,6 +186,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:
|
||||
|
|
@ -226,7 +224,7 @@ class LogEntry(models.Model):
|
|||
"""
|
||||
substrings = []
|
||||
|
||||
for field, values in iteritems(self.changes_dict):
|
||||
for field, values in self.changes_dict.items():
|
||||
substring = smart_text('{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}').format(
|
||||
field_name=field,
|
||||
colon=colon,
|
||||
|
|
@ -249,7 +247,7 @@ class LogEntry(models.Model):
|
|||
model_fields = auditlog.get_model_fields(model._meta.model)
|
||||
changes_display_dict = {}
|
||||
# grab the changes_dict and iterate through
|
||||
for field_name, values in iteritems(self.changes_dict):
|
||||
for field_name, values in self.changes_dict.items():
|
||||
# try to get the field attribute on the model
|
||||
try:
|
||||
field = model._meta.get_field(field_name)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
|
||||
from auditlog.diff import model_instance_diff
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
from django.db.models.signals import pre_save, post_save, post_delete
|
||||
from django.db.models import Model
|
||||
from django.utils.six import iteritems
|
||||
|
||||
|
||||
class AuditlogModelRegistry(object):
|
||||
|
|
@ -114,6 +111,10 @@ class AuditlogModelRegistry(object):
|
|||
'mapping_fields': self._registry[model]['mapping_fields'],
|
||||
}
|
||||
|
||||
def list(self):
|
||||
"""Get a list of all registered models."""
|
||||
return list(self._registry.keys())
|
||||
|
||||
|
||||
class AuditLogModelRegistry(AuditlogModelRegistry):
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
import datetime
|
||||
import django
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.contrib.auth.models import User, AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models.signals import pre_save
|
||||
from django.http import HttpResponse
|
||||
from django.test import TestCase, RequestFactory
|
||||
from django.utils import dateformat, formats, timezone
|
||||
from dateutil.tz import gettz
|
||||
import mock
|
||||
|
||||
from auditlog.middleware import AuditlogMiddleware
|
||||
from auditlog.models import LogEntry
|
||||
|
|
@ -17,7 +14,6 @@ from auditlog_tests.models import SimpleModel, AltPrimaryKeyModel, UUIDPrimaryKe
|
|||
ProxyModel, SimpleIncludeModel, SimpleExcludeModel, SimpleMappingModel, RelatedModel, \
|
||||
ManyRelatedModel, AdditionalDataIncludedModel, DateTimeFieldModel, ChoicesFieldModel, \
|
||||
CharfieldTextfieldModel, PostgresArrayFieldModel, NoDeleteHistoryModel
|
||||
from auditlog import compat
|
||||
|
||||
|
||||
class SimpleModelTest(TestCase):
|
||||
|
|
@ -121,66 +117,64 @@ class MiddlewareTest(TestCase):
|
|||
Test the middleware responsible for connecting and disconnecting the signals used in automatic logging.
|
||||
"""
|
||||
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):
|
||||
|
|
@ -603,44 +597,6 @@ class PostgresArrayFieldModelTest(TestCase):
|
|||
msg="The human readable text 'Green' is displayed.")
|
||||
|
||||
|
||||
class CompatibilityTest(TestCase):
|
||||
"""Test case for compatibility functions."""
|
||||
|
||||
def test_is_authenticated(self):
|
||||
"""Test that the 'is_authenticated' compatibility function is working.
|
||||
|
||||
Bit of explanation: the `is_authenticated` property on request.user is
|
||||
*always* set to 'False' for AnonymousUser, and it is *always* set to
|
||||
'True' for *any* other (i.e. identified/authenticated) user.
|
||||
|
||||
So, the logic of this test is to ensure that compat.is_authenticated()
|
||||
returns the correct value based on whether or not the User is an
|
||||
anonymous user (simulating what goes on in the real request.user).
|
||||
|
||||
"""
|
||||
|
||||
# Test compat.is_authenticated for anonymous users
|
||||
self.user = auth.get_user(self.client)
|
||||
if django.VERSION < (1, 10):
|
||||
assert self.user.is_anonymous()
|
||||
else:
|
||||
assert self.user.is_anonymous
|
||||
assert not compat.is_authenticated(self.user)
|
||||
|
||||
# Setup some other user, which is *not* anonymous, and check
|
||||
# compat.is_authenticated
|
||||
self.user = User.objects.create(
|
||||
username="test.user",
|
||||
email="test.user@mail.com",
|
||||
password="auditlog"
|
||||
)
|
||||
if django.VERSION < (1, 10):
|
||||
assert not self.user.is_anonymous()
|
||||
else:
|
||||
assert not self.user.is_anonymous
|
||||
assert compat.is_authenticated(self.user)
|
||||
|
||||
|
||||
class AdminPanelTest(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
|
|
|||
6
tox.ini
6
tox.ini
|
|
@ -1,7 +1,7 @@
|
|||
[tox]
|
||||
envlist =
|
||||
{py27,py34,py35,py36,py37}-django-111
|
||||
{py34,py35,py36,py37}-django-20
|
||||
{py35,py36,py37}-django-111
|
||||
{py35,py36,py37}-django-20
|
||||
{py35,py36,py37}-django-21
|
||||
{py35,py36,py37}-django-22
|
||||
|
||||
|
|
@ -19,5 +19,3 @@ basepython =
|
|||
py37: python3.7
|
||||
py36: python3.6
|
||||
py35: python3.5
|
||||
py34: python3.4
|
||||
py27: python2.7
|
||||
|
|
|
|||
Loading…
Reference in a new issue