Просмотр исходного кода

Add AuditTrail feature to misago (#1014)

* Use runtests.py instead of  python runtests.py

* #1012: Create Audit Trail object and module

* Fix build

* Remove unused import

* Rename create_audit_trail

* Add lower-level create_user_audit_trail

* Create audit trail on user's registration

* Use audit trail for posts

* Create audit trail for attachments

* Create audit trail for poll creation/edit

* Make removeoldips also remove old audit trails
Rafał Pitoń 7 лет назад
Родитель
Сommit
eb502b1982

+ 3 - 0
misago/threads/api/attachments.py

@@ -8,6 +8,7 @@ from django.utils.translation import ugettext as _
 from misago.acl import add_acl
 from misago.acl import add_acl
 from misago.threads.models import Attachment, AttachmentType
 from misago.threads.models import Attachment, AttachmentType
 from misago.threads.serializers import AttachmentSerializer
 from misago.threads.serializers import AttachmentSerializer
+from misago.users.audittrail import create_audit_trail
 
 
 
 
 IMAGE_EXTENSIONS = ('jpg', 'jpeg', 'png', 'gif')
 IMAGE_EXTENSIONS = ('jpg', 'jpeg', 'png', 'gif')
@@ -54,6 +55,8 @@ class AttachmentViewSet(viewsets.ViewSet):
         attachment.save()
         attachment.save()
         add_acl(request.user, attachment)
         add_acl(request.user, attachment)
 
 
+        create_audit_trail(request, attachment)
+
         return Response(AttachmentSerializer(attachment, context={'user': request.user}).data)
         return Response(AttachmentSerializer(attachment, context={'user': request.user}).data)
 
 
 
 

+ 3 - 0
misago/threads/api/postingendpoint/reply.py

@@ -5,6 +5,7 @@ from django.utils.translation import ugettext_lazy
 from misago.markup import common_flavour
 from misago.markup import common_flavour
 from misago.threads.checksums import update_post_checksum
 from misago.threads.checksums import update_post_checksum
 from misago.threads.validators import validate_post, validate_post_length, validate_title
 from misago.threads.validators import validate_post, validate_post_length, validate_title
+from misago.users.audittrail import create_audit_trail
 
 
 from . import PostingEndpoint, PostingMiddleware
 from . import PostingEndpoint, PostingMiddleware
 
 
@@ -46,6 +47,8 @@ class ReplyMiddleware(PostingMiddleware):
 
 
         self.thread.save()
         self.thread.save()
 
 
+        create_audit_trail(self.request, self.post)
+
         # annotate post for future middlewares
         # annotate post for future middlewares
         self.post.parsing_result = parsing_result
         self.post.parsing_result = parsing_result
 
 

+ 20 - 17
misago/threads/api/threadpoll.py

@@ -15,6 +15,7 @@ from misago.threads.permissions import (
 from misago.threads.serializers import (
 from misago.threads.serializers import (
     EditPollSerializer, NewPollSerializer, PollSerializer, PollVoteSerializer)
     EditPollSerializer, NewPollSerializer, PollSerializer, PollVoteSerializer)
 from misago.threads.viewmodels import ForumThread
 from misago.threads.viewmodels import ForumThread
+from misago.users.audittrail import create_audit_trail
 
 
 from .pollvotecreateendpoint import poll_vote_create
 from .pollvotecreateendpoint import poll_vote_create
 
 
@@ -64,19 +65,20 @@ class ViewSet(viewsets.ViewSet):
         )
         )
 
 
         serializer = NewPollSerializer(instance, data=request.data)
         serializer = NewPollSerializer(instance, data=request.data)
-        if serializer.is_valid():
-            serializer.save()
+        serializer.is_valid(raise_exception=True)
 
 
-            add_acl(request.user, instance)
-            for choice in instance.choices:
-                choice['selected'] = False
+        serializer.save()
 
 
-            thread.has_poll = True
-            thread.save()
+        add_acl(request.user, instance)
+        for choice in instance.choices:
+            choice['selected'] = False
 
 
-            return Response(PollSerializer(instance).data)
-        else:
-            return Response(serializer.errors, status=400)
+        thread.has_poll = True
+        thread.save()
+
+        create_audit_trail(request, instance)
+
+        return Response(PollSerializer(instance).data)
 
 
     @transaction.atomic
     @transaction.atomic
     def update(self, request, thread_pk, pk=None):
     def update(self, request, thread_pk, pk=None):
@@ -86,15 +88,16 @@ class ViewSet(viewsets.ViewSet):
         allow_edit_poll(request.user, instance)
         allow_edit_poll(request.user, instance)
 
 
         serializer = EditPollSerializer(instance, data=request.data)
         serializer = EditPollSerializer(instance, data=request.data)
-        if serializer.is_valid():
-            serializer.save()
+        serializer.is_valid(raise_exception=True)
 
 
-            add_acl(request.user, instance)
-            instance.make_choices_votes_aware(request.user)
+        serializer.save()
 
 
-            return Response(PollSerializer(instance).data)
-        else:
-            return Response(serializer.errors, status=400)
+        add_acl(request.user, instance)
+        instance.make_choices_votes_aware(request.user)
+
+        create_audit_trail(request, instance)
+
+        return Response(PollSerializer(instance).data)
 
 
     @transaction.atomic
     @transaction.atomic
     def delete(self, request, thread_pk, pk=None):
     def delete(self, request, thread_pk, pk=None):

+ 8 - 0
misago/threads/tests/test_attachments_api.py

@@ -226,6 +226,8 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
         self.assertIsNone(response_json['url']['thumb'])
         self.assertIsNone(response_json['url']['thumb'])
         self.assertEqual(response_json['url']['uploader'], self.user.get_absolute_url())
         self.assertEqual(response_json['url']['uploader'], self.user.get_absolute_url())
 
 
+        self.assertEqual(self.user.audittrail_set.count(), 1)
+
         # files associated with attachment are deleted on its deletion
         # files associated with attachment are deleted on its deletion
         file_path = attachment.file.path
         file_path = attachment.file.path
         self.assertTrue(os.path.exists(file_path))
         self.assertTrue(os.path.exists(file_path))
@@ -267,6 +269,8 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
         self.assertIsNone(response_json['url']['thumb'])
         self.assertIsNone(response_json['url']['thumb'])
         self.assertEqual(response_json['url']['uploader'], self.user.get_absolute_url())
         self.assertEqual(response_json['url']['uploader'], self.user.get_absolute_url())
 
 
+        self.assertEqual(self.user.audittrail_set.count(), 1)
+
     def test_large_image_upload(self):
     def test_large_image_upload(self):
         """successful large image upload creates orphan attachment with thumbnail"""
         """successful large image upload creates orphan attachment with thumbnail"""
         self.override_acl({'max_attachment_size': 10 * 1024})
         self.override_acl({'max_attachment_size': 10 * 1024})
@@ -304,6 +308,8 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(response_json['url']['index'], attachment.get_absolute_url())
         self.assertEqual(response_json['url']['index'], attachment.get_absolute_url())
         self.assertEqual(response_json['url']['thumb'], attachment.get_thumbnail_url())
         self.assertEqual(response_json['url']['thumb'], attachment.get_thumbnail_url())
         self.assertEqual(response_json['url']['uploader'], self.user.get_absolute_url())
         self.assertEqual(response_json['url']['uploader'], self.user.get_absolute_url())
+        
+        self.assertEqual(self.user.audittrail_set.count(), 1)
 
 
         # thumbnail was scaled down
         # thumbnail was scaled down
         thumbnail = Image.open(attachment.thumbnail.path)
         thumbnail = Image.open(attachment.thumbnail.path)
@@ -357,3 +363,5 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(response_json['url']['index'], attachment.get_absolute_url())
         self.assertEqual(response_json['url']['index'], attachment.get_absolute_url())
         self.assertEqual(response_json['url']['thumb'], attachment.get_thumbnail_url())
         self.assertEqual(response_json['url']['thumb'], attachment.get_thumbnail_url())
         self.assertEqual(response_json['url']['uploader'], self.user.get_absolute_url())
         self.assertEqual(response_json['url']['uploader'], self.user.get_absolute_url())
+        
+        self.assertEqual(self.user.audittrail_set.count(), 1)

+ 2 - 0
misago/threads/tests/test_privatethread_reply_api.py

@@ -37,6 +37,8 @@ class PrivateThreadReplyApiTestCase(PrivateThreadsTestCase):
         self.assertEqual(self.user.threads, 0)
         self.assertEqual(self.user.threads, 0)
         self.assertEqual(self.user.posts, 0)
         self.assertEqual(self.user.posts, 0)
 
 
+        self.assertEqual(self.user.audittrail_set.count(), 1)
+
         # valid user was flagged to sync
         # valid user was flagged to sync
         self.assertFalse(UserModel.objects.get(pk=self.user.pk).sync_unread_private_threads)
         self.assertFalse(UserModel.objects.get(pk=self.user.pk).sync_unread_private_threads)
         self.assertTrue(UserModel.objects.get(pk=self.other_user.pk).sync_unread_private_threads)
         self.assertTrue(UserModel.objects.get(pk=self.other_user.pk).sync_unread_private_threads)

+ 2 - 0
misago/threads/tests/test_privatethread_start_api.py

@@ -351,6 +351,8 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(post.poster_id, self.user.id)
         self.assertEqual(post.poster_id, self.user.id)
         self.assertEqual(post.poster_name, self.user.username)
         self.assertEqual(post.poster_name, self.user.username)
 
 
+        self.assertEqual(self.user.audittrail_set.count(), 1)
+
         # thread has two participants
         # thread has two participants
         self.assertEqual(thread.participants.count(), 2)
         self.assertEqual(thread.participants.count(), 2)
 
 

+ 2 - 0
misago/threads/tests/test_thread_editreply_api.py

@@ -239,6 +239,8 @@ class EditReplyTests(AuthenticatedUserTestCase):
         response = self.client.get(self.thread.get_absolute_url())
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, "<p>This is test edit!</p>")
         self.assertContains(response, "<p>This is test edit!</p>")
 
 
+        self.assertEqual(self.user.audittrail_set.count(), 1)
+
         post = self.thread.post_set.order_by('id').last()
         post = self.thread.post_set.order_by('id').last()
         self.assertEqual(post.edits, 1)
         self.assertEqual(post.edits, 1)
         self.assertEqual(post.original, "This is test edit!")
         self.assertEqual(post.original, "This is test edit!")

+ 2 - 0
misago/threads/tests/test_thread_pollcreate_api.py

@@ -313,3 +313,5 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
 
 
         self.assertEqual(len(poll.choices), 3)
         self.assertEqual(len(poll.choices), 3)
         self.assertEqual(len(set([c['hash'] for c in poll.choices])), 3)
         self.assertEqual(len(set([c['hash'] for c in poll.choices])), 3)
+        
+        self.assertEqual(self.user.audittrail_set.count(), 1)

+ 8 - 0
misago/threads/tests/test_thread_polledit_api.py

@@ -335,6 +335,8 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         # votes were removed
         # votes were removed
         self.assertEqual(response_json['votes'], 0)
         self.assertEqual(response_json['votes'], 0)
         self.assertEqual(self.poll.pollvote_set.count(), 0)
         self.assertEqual(self.poll.pollvote_set.count(), 0)
+        
+        self.assertEqual(self.user.audittrail_set.count(), 1)
 
 
     def test_poll_current_choices_edited(self):
     def test_poll_current_choices_edited(self):
         """api edits current poll choices"""
         """api edits current poll choices"""
@@ -418,6 +420,8 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         # no votes were removed
         # no votes were removed
         self.assertEqual(response_json['votes'], 4)
         self.assertEqual(response_json['votes'], 4)
         self.assertEqual(self.poll.pollvote_set.count(), 4)
         self.assertEqual(self.poll.pollvote_set.count(), 4)
+        
+        self.assertEqual(self.user.audittrail_set.count(), 1)
 
 
     def test_poll_some_choices_edited(self):
     def test_poll_some_choices_edited(self):
         """api edits some poll choices"""
         """api edits some poll choices"""
@@ -491,6 +495,8 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         self.assertEqual(response_json['votes'], 1)
         self.assertEqual(response_json['votes'], 1)
         self.assertEqual(self.poll.pollvote_set.count(), 1)
         self.assertEqual(self.poll.pollvote_set.count(), 1)
 
 
+        self.assertEqual(self.user.audittrail_set.count(), 1)
+
     def test_moderate_user_poll(self):
     def test_moderate_user_poll(self):
         """api edits all poll choices out in other users poll, even if its over"""
         """api edits all poll choices out in other users poll, even if its over"""
         self.override_acl({'can_edit_polls': 2, 'poll_edit_time': 5})
         self.override_acl({'can_edit_polls': 2, 'poll_edit_time': 5})
@@ -544,3 +550,5 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         # votes were removed
         # votes were removed
         self.assertEqual(response_json['votes'], 0)
         self.assertEqual(response_json['votes'], 0)
         self.assertEqual(self.poll.pollvote_set.count(), 0)
         self.assertEqual(self.poll.pollvote_set.count(), 0)
+
+        self.assertEqual(self.user.audittrail_set.count(), 1)

+ 2 - 0
misago/threads/tests/test_thread_reply_api.py

@@ -163,6 +163,8 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(self.user.threads, 0)
         self.assertEqual(self.user.threads, 0)
         self.assertEqual(self.user.posts, 1)
         self.assertEqual(self.user.posts, 1)
 
 
+        self.assertEqual(self.user.audittrail_set.count(), 1)
+
         post = self.user.post_set.all()[:1][0]
         post = self.user.post_set.all()[:1][0]
         self.assertEqual(post.category_id, self.category.pk)
         self.assertEqual(post.category_id, self.category.pk)
         self.assertEqual(post.original, "This is test response!")
         self.assertEqual(post.original, "This is test response!")

+ 2 - 0
misago/threads/tests/test_thread_start_api.py

@@ -197,6 +197,8 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(self.user.threads, 1)
         self.assertEqual(self.user.threads, 1)
         self.assertEqual(self.user.posts, 1)
         self.assertEqual(self.user.posts, 1)
 
 
+        self.assertEqual(self.user.audittrail_set.count(), 1)
+
         self.assertEqual(thread.category_id, self.category.pk)
         self.assertEqual(thread.category_id, self.category.pk)
         self.assertEqual(thread.title, "Hello, I am test thread!")
         self.assertEqual(thread.title, "Hello, I am test thread!")
         self.assertEqual(thread.starter_id, self.user.id)
         self.assertEqual(thread.starter_id, self.user.id)

+ 1 - 0
misago/users/api/userendpoints/create.py

@@ -43,6 +43,7 @@ def create_endpoint(request):
             form.cleaned_data['username'],
             form.cleaned_data['username'],
             form.cleaned_data['email'],
             form.cleaned_data['email'],
             form.cleaned_data['password'],
             form.cleaned_data['password'],
+            create_audit_trail=True,
             joined_from_ip=request.user_ip,
             joined_from_ip=request.user_ip,
             set_default_avatar=True,
             set_default_avatar=True,
             **activation_kwargs
             **activation_kwargs

+ 19 - 0
misago/users/audittrail.py

@@ -0,0 +1,19 @@
+from django.db import models
+
+
+def create_audit_trail(request, obj):
+    return create_user_audit_trail(request.user, request.user_ip, obj)
+
+
+def create_user_audit_trail(user, ip_address, obj):
+    if not isinstance(obj, models.Model):
+        raise ValueError("obj must be a valid Django model instance")
+
+    if user.is_anonymous:
+        return None
+
+    return user.audittrail_set.create(
+        user=user,
+        ip_address=ip_address,
+        content_object=obj,
+    )

+ 33 - 0
misago/users/migrations/0012_audittrail.py

@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.13 on 2018-06-03 18:46
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('misago_users', '0011_auto_20180331_2208'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='AuditTrail',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('object_id', models.PositiveIntegerField()),
+                ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
+                ('ip_address', models.GenericIPAddressField()),
+                ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'ordering': ['-pk'],
+            },
+        ),
+    ]

+ 1 - 0
misago/users/models/__init__.py

@@ -2,5 +2,6 @@ from .rank import Rank
 from .user import AnonymousUser, Online, User, UsernameChange
 from .user import AnonymousUser, Online, User, UsernameChange
 from .activityranking import ActivityRanking
 from .activityranking import ActivityRanking
 from .avatar import Avatar
 from .avatar import Avatar
+from .audittrail import AuditTrail
 from .avatargallery import AvatarGallery
 from .avatargallery import AvatarGallery
 from .ban import Ban, BanCache
 from .ban import Ban, BanCache

+ 18 - 0
misago/users/models/audittrail.py

@@ -0,0 +1,18 @@
+from django.conf import settings
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from django.db import models
+from django.utils import timezone
+
+
+class AuditTrail(models.Model):
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
+    created_at = models.DateTimeField(db_index=True, default=timezone.now)
+    ip_address = models.GenericIPAddressField()
+
+    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
+    object_id = models.PositiveIntegerField()
+    content_object = GenericForeignKey('content_type', 'object_id')
+
+    class Meta:
+        ordering = ['-pk']

+ 6 - 2
misago/users/models/user.py

@@ -17,6 +17,7 @@ from misago.conf import settings
 from misago.core.pgutils import PgPartialIndex
 from misago.core.pgutils import PgPartialIndex
 from misago.core.utils import slugify
 from misago.core.utils import slugify
 from misago.users import avatars
 from misago.users import avatars
+from misago.users.audittrail import create_user_audit_trail
 from misago.users.signatures import is_user_signature_valid
 from misago.users.signatures import is_user_signature_valid
 from misago.users.utils import hash_email
 from misago.users.utils import hash_email
 
 
@@ -26,8 +27,8 @@ from .rank import Rank
 class UserManager(BaseUserManager):
 class UserManager(BaseUserManager):
     @transaction.atomic
     @transaction.atomic
     def create_user(
     def create_user(
-            self, username, email, password=None, set_default_avatar=False, **extra_fields
-    ):
+            self, username, email, password=None, create_audit_trail=False,
+            joined_from_ip=None, set_default_avatar=False, **extra_fields):
         from misago.users.validators import validate_email, validate_username
         from misago.users.validators import validate_email, validate_username
 
 
         email = self.normalize_email(email)
         email = self.normalize_email(email)
@@ -89,6 +90,9 @@ class UserManager(BaseUserManager):
 
 
         user.save(update_fields=['avatars', 'acl_key'])
         user.save(update_fields=['avatars', 'acl_key'])
 
 
+        if create_audit_trail:
+            create_user_audit_trail(user, user.joined_from_ip, user)
+
         # populate online tracker with default value
         # populate online tracker with default value
         Online.objects.create(
         Online.objects.create(
             user=user,
             user=user,

+ 7 - 0
misago/users/signals.py

@@ -8,6 +8,8 @@ from django.utils import timezone
 from misago.conf import settings
 from misago.conf import settings
 from misago.core.utils import ANONYMOUS_IP
 from misago.core.utils import ANONYMOUS_IP
 
 
+from .models import AuditTrail
+
 
 
 UserModel = get_user_model()
 UserModel = get_user_model()
 
 
@@ -31,3 +33,8 @@ def anonymize_old_registrations_ips(sender, **kwargs):
     queryset = UserModel.objects.exclude(ip_is_too_new | ip_is_already_anonymized)
     queryset = UserModel.objects.exclude(ip_is_too_new | ip_is_already_anonymized)
     queryset.update(joined_from_ip=ANONYMOUS_IP)
     queryset.update(joined_from_ip=ANONYMOUS_IP)
 
 
+
+@receiver(remove_old_ips)
+def remove_old_audit_trails(sender, **kwargs):
+    removal_cutoff = timezone.now() - timedelta(days=settings.MISAGO_IP_STORE_TIME)
+    AuditTrail.objects.filter(created_at__lte=removal_cutoff).delete()

+ 2 - 0
misago/users/social/pipeline.py

@@ -156,6 +156,7 @@ def create_user(strategy, details, backend, user=None, *args, **kwargs):
     new_user = UserModel.objects.create_user(
     new_user = UserModel.objects.create_user(
         username, 
         username, 
         email, 
         email, 
+        create_audit_trail=True,
         joined_from_ip=request.user_ip, 
         joined_from_ip=request.user_ip, 
         set_default_avatar=True,
         set_default_avatar=True,
         **activation_kwargs
         **activation_kwargs
@@ -197,6 +198,7 @@ def create_user_with_form(strategy, details, backend, user=None, *args, **kwargs
             new_user = UserModel.objects.create_user(
             new_user = UserModel.objects.create_user(
                 form.cleaned_data['username'],
                 form.cleaned_data['username'],
                 form.cleaned_data['email'],
                 form.cleaned_data['email'],
+                create_audit_trail=True,
                 joined_from_ip=request.user_ip,
                 joined_from_ip=request.user_ip,
                 set_default_avatar=True,
                 set_default_avatar=True,
                 **activation_kwargs
                 **activation_kwargs

+ 187 - 0
misago/users/tests/test_audittrail.py

@@ -0,0 +1,187 @@
+from datetime import timedelta
+
+from django.contrib.auth import get_user_model
+from django.utils import timezone
+
+from misago.users.audittrail import create_audit_trail, create_user_audit_trail
+from misago.users.models import AuditTrail
+from misago.users.signals import remove_old_ips
+from misago.users.testutils import UserTestCase
+
+
+UserModel = get_user_model()
+
+USER_IP = '13.41.51.41'
+
+
+class MockRequest(object):
+    user_ip = USER_IP
+
+    def __init__(self, user):
+        self.user = user
+
+
+class CreateAuditTrailTests(UserTestCase):
+    def setUp(self):
+        super(CreateAuditTrailTests, self).setUp()
+
+        self.obj = UserModel.objects.create_user('BobBoberson', 'bob@example.com')
+
+    def test_create_user_audit_require_model(self):
+        """create_audit_trail requires model instance"""
+        anonymous_user = self.get_anonymous_user()
+        request = MockRequest(anonymous_user)
+        with self.assertRaises(ValueError):
+            create_audit_trail(request, anonymous_user)
+        self.assertEqual(AuditTrail.objects.count(), 0)
+
+    def test_create_user_audit_trail_anonymous_user(self):
+        """create_audit_trail doesn't record anonymous users"""
+        user = self.get_anonymous_user()
+        request = MockRequest(user)
+        create_audit_trail(request, self.obj)
+        self.assertEqual(AuditTrail.objects.count(), 0)
+
+    def test_create_user_audit_trail(self):
+        """create_audit_trail creates new db record"""
+        user = self.get_authenticated_user()
+        request = MockRequest(user)
+        create_audit_trail(request, self.obj)
+        self.assertEqual(AuditTrail.objects.count(), 1)
+
+        audit_trail = user.audittrail_set.all()[0]
+        self.assertEqual(audit_trail.user, user)
+        self.assertEqual(audit_trail.ip_address, request.user_ip)
+        self.assertEqual(audit_trail.content_object, self.obj)
+
+    def test_delete_user_remove_audit_trail(self):
+        """audit trail is deleted together with user it belongs to"""
+        user = self.get_authenticated_user()
+        request = MockRequest(user)
+        create_audit_trail(request, self.obj)
+        self.assertEqual(AuditTrail.objects.count(), 1)
+
+        user.delete()
+        self.assertEqual(AuditTrail.objects.count(), 0)
+
+    def test_delete_obj_keep_audit_trail(self):
+        """audit trail is kept after with obj it points at is deleted"""
+        user = self.get_authenticated_user()
+        request = MockRequest(user)
+        create_audit_trail(request, self.obj)
+        self.assertEqual(AuditTrail.objects.count(), 1)
+
+        self.obj.delete()
+        self.assertEqual(AuditTrail.objects.count(), 1)
+
+        audit_trail = user.audittrail_set.all()[0]
+        self.assertEqual(audit_trail.user, user)
+        self.assertEqual(audit_trail.ip_address, request.user_ip)
+        self.assertIsNone(audit_trail.content_object)
+
+    def test_delete_audit_trail(self):
+        """audit trail deletion leaves other data untouched"""
+        user = self.get_authenticated_user()
+        request = MockRequest(user)
+        create_audit_trail(request, self.obj)
+        self.assertEqual(AuditTrail.objects.count(), 1)
+
+        audit_trail = user.audittrail_set.all()[0]
+        audit_trail.delete()
+        
+        UserModel.objects.get(id=user.id)
+        UserModel.objects.get(id=self.obj.id)
+
+
+class CreateUserAuditTrailTests(UserTestCase):
+    def setUp(self):
+        super(CreateUserAuditTrailTests, self).setUp()
+
+        self.obj = UserModel.objects.create_user('BobBoberson', 'bob@example.com')
+
+    def test_create_user_audit_require_model(self):
+        """create_user_audit_trail requires model instance"""
+        anonymous_user = self.get_anonymous_user()
+        with self.assertRaises(ValueError):
+            create_user_audit_trail(anonymous_user, USER_IP, anonymous_user)
+        self.assertEqual(AuditTrail.objects.count(), 0)
+
+    def test_create_user_audit_trail_anonymous_user(self):
+        """create_user_audit_trail doesn't record anonymous users"""
+        user = self.get_anonymous_user()
+        create_user_audit_trail(user, USER_IP, self.obj)
+        self.assertEqual(AuditTrail.objects.count(), 0)
+
+    def test_create_user_audit_trail(self):
+        """create_user_audit_trail creates new db record"""
+        user = self.get_authenticated_user()
+        create_user_audit_trail(user, USER_IP, self.obj)
+        self.assertEqual(AuditTrail.objects.count(), 1)
+
+        audit_trail = user.audittrail_set.all()[0]
+        self.assertEqual(audit_trail.user, user)
+        self.assertEqual(audit_trail.ip_address, USER_IP)
+        self.assertEqual(audit_trail.content_object, self.obj)
+
+    def test_delete_user_remove_audit_trail(self):
+        """audit trail is deleted together with user it belongs to"""
+        user = self.get_authenticated_user()
+        create_user_audit_trail(user, USER_IP, self.obj)
+        self.assertEqual(AuditTrail.objects.count(), 1)
+
+        user.delete()
+        self.assertEqual(AuditTrail.objects.count(), 0)
+
+    def test_delete_obj_keep_audit_trail(self):
+        """audit trail is kept after with obj it points at is deleted"""
+        user = self.get_authenticated_user()
+        create_user_audit_trail(user, USER_IP, self.obj)
+        self.assertEqual(AuditTrail.objects.count(), 1)
+
+        self.obj.delete()
+        self.assertEqual(AuditTrail.objects.count(), 1)
+
+        audit_trail = user.audittrail_set.all()[0]
+        self.assertEqual(audit_trail.user, user)
+        self.assertEqual(audit_trail.ip_address, USER_IP)
+        self.assertIsNone(audit_trail.content_object)
+
+    def test_delete_audit_trail(self):
+        """audit trail deletion leaves other data untouched"""
+        user = self.get_authenticated_user()
+        create_user_audit_trail(user, USER_IP, self.obj)
+        self.assertEqual(AuditTrail.objects.count(), 1)
+
+        audit_trail = user.audittrail_set.all()[0]
+        audit_trail.delete()
+        
+        UserModel.objects.get(id=user.id)
+        UserModel.objects.get(id=self.obj.id)
+
+
+class RemoveOldAuditTrailsTest(UserTestCase):
+    def setUp(self):
+        super(RemoveOldAuditTrailsTest, self).setUp()
+
+        self.obj = UserModel.objects.create_user('BobBoberson', 'bob@example.com')
+        
+    def test_recent_audit_trail_is_kept(self):
+        """remove_old_ips keeps recent audit trails"""
+        user = self.get_authenticated_user()
+        audit_trail = create_user_audit_trail(user, USER_IP, self.obj)
+
+        remove_old_ips.send(None)
+
+        self.assertEqual(user.audittrail_set.count(), 1)
+
+    def test_old_audit_trail_is_removed(self):
+        """remove_old_ips removes old audit trails"""
+        user = self.get_authenticated_user()
+        audit_trail = create_user_audit_trail(user, USER_IP, self.obj)
+
+        audit_trail.created_at = timezone.now() - timedelta(days=50)
+        audit_trail.save()
+
+        remove_old_ips.send(None)
+
+        self.assertEqual(user.audittrail_set.count(), 0)

+ 2 - 0
misago/users/tests/test_social_pipeline.py

@@ -68,6 +68,8 @@ class PipelineTestCase(UserTestCase):
         if activation == 'admin':
         if activation == 'admin':
             self.assertEqual(new_user.requires_activation, UserModel.ACTIVATION_ADMIN)
             self.assertEqual(new_user.requires_activation, UserModel.ACTIVATION_ADMIN)
 
 
+        self.assertEqual(new_user.audittrail_set.count(), 1)
+
     def assertJsonResponseEquals(self, response, value):
     def assertJsonResponseEquals(self, response, value):
         response_content = response.content.decode("utf-8")
         response_content = response.content.decode("utf-8")
         response_json = json.loads(response_content)
         response_json = json.loads(response_content)

+ 2 - 0
misago/users/tests/test_user_create_api.py

@@ -321,6 +321,8 @@ class UserCreateTests(UserTestCase):
 
 
         self.assertIn('Welcome', mail.outbox[0].subject)
         self.assertIn('Welcome', mail.outbox[0].subject)
 
 
+        self.assertEqual(test_user.audittrail_set.count(), 1)
+
     def test_registration_creates_inactive_user(self):
     def test_registration_creates_inactive_user(self):
         """api creates inactive user on POST"""
         """api creates inactive user on POST"""
         settings.override_setting('account_activation', 'user')
         settings.override_setting('account_activation', 'user')

+ 2 - 3
runtests.py

@@ -1,3 +1,4 @@
+#!/usr/bin/env python
 import os
 import os
 import pwd
 import pwd
 import shutil
 import shutil
@@ -8,7 +9,7 @@ from django import setup
 
 
 TEST_RUNNER_PATH = os.path.dirname(os.path.abspath(__file__))
 TEST_RUNNER_PATH = os.path.dirname(os.path.abspath(__file__))
 
 
-sys.path.append(TEST_RUNNER_PATH)
+sys.path.append(os.path.dirname(os.path.abspath(__file__)))
 
 
 
 
 def runtests():
 def runtests():
@@ -38,9 +39,7 @@ def parse_args():
 
 
 
 
 def setup_testproject():
 def setup_testproject():
-    TEST_RUNNER_PATH = os.path.dirname(os.path.abspath(__file__))
     project_template_path = os.path.join(TEST_RUNNER_PATH, 'misago/project_template')
     project_template_path = os.path.join(TEST_RUNNER_PATH, 'misago/project_template')
-    project_package_path = os.path.join(TEST_RUNNER_PATH, 'misago/project_template/project_name')
 
 
     test_project_path = os.path.join(TEST_RUNNER_PATH, "testproject")
     test_project_path = os.path.join(TEST_RUNNER_PATH, "testproject")
     if os.path.exists(test_project_path):
     if os.path.exists(test_project_path):