Browse Source

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 years ago
parent
commit
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.threads.models import Attachment, AttachmentType
 from misago.threads.serializers import AttachmentSerializer
+from misago.users.audittrail import create_audit_trail
 
 
 IMAGE_EXTENSIONS = ('jpg', 'jpeg', 'png', 'gif')
@@ -54,6 +55,8 @@ class AttachmentViewSet(viewsets.ViewSet):
         attachment.save()
         add_acl(request.user, attachment)
 
+        create_audit_trail(request, attachment)
+
         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.threads.checksums import update_post_checksum
 from misago.threads.validators import validate_post, validate_post_length, validate_title
+from misago.users.audittrail import create_audit_trail
 
 from . import PostingEndpoint, PostingMiddleware
 
@@ -46,6 +47,8 @@ class ReplyMiddleware(PostingMiddleware):
 
         self.thread.save()
 
+        create_audit_trail(self.request, self.post)
+
         # annotate post for future middlewares
         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 (
     EditPollSerializer, NewPollSerializer, PollSerializer, PollVoteSerializer)
 from misago.threads.viewmodels import ForumThread
+from misago.users.audittrail import create_audit_trail
 
 from .pollvotecreateendpoint import poll_vote_create
 
@@ -64,19 +65,20 @@ class ViewSet(viewsets.ViewSet):
         )
 
         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
     def update(self, request, thread_pk, pk=None):
@@ -86,15 +88,16 @@ class ViewSet(viewsets.ViewSet):
         allow_edit_poll(request.user, instance)
 
         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
     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.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
         file_path = attachment.file.path
         self.assertTrue(os.path.exists(file_path))
@@ -267,6 +269,8 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
         self.assertIsNone(response_json['url']['thumb'])
         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):
         """successful large image upload creates orphan attachment with thumbnail"""
         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']['thumb'], attachment.get_thumbnail_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 = 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']['thumb'], attachment.get_thumbnail_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.posts, 0)
 
+        self.assertEqual(self.user.audittrail_set.count(), 1)
+
         # valid user was flagged to sync
         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)

+ 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_name, self.user.username)
 
+        self.assertEqual(self.user.audittrail_set.count(), 1)
+
         # thread has two participants
         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())
         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()
         self.assertEqual(post.edits, 1)
         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(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
         self.assertEqual(response_json['votes'], 0)
         self.assertEqual(self.poll.pollvote_set.count(), 0)
+        
+        self.assertEqual(self.user.audittrail_set.count(), 1)
 
     def test_poll_current_choices_edited(self):
         """api edits current poll choices"""
@@ -418,6 +420,8 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         # no votes were removed
         self.assertEqual(response_json['votes'], 4)
         self.assertEqual(self.poll.pollvote_set.count(), 4)
+        
+        self.assertEqual(self.user.audittrail_set.count(), 1)
 
     def test_poll_some_choices_edited(self):
         """api edits some poll choices"""
@@ -491,6 +495,8 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         self.assertEqual(response_json['votes'], 1)
         self.assertEqual(self.poll.pollvote_set.count(), 1)
 
+        self.assertEqual(self.user.audittrail_set.count(), 1)
+
     def test_moderate_user_poll(self):
         """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})
@@ -544,3 +550,5 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         # votes were removed
         self.assertEqual(response_json['votes'], 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.posts, 1)
 
+        self.assertEqual(self.user.audittrail_set.count(), 1)
+
         post = self.user.post_set.all()[:1][0]
         self.assertEqual(post.category_id, self.category.pk)
         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.posts, 1)
 
+        self.assertEqual(self.user.audittrail_set.count(), 1)
+
         self.assertEqual(thread.category_id, self.category.pk)
         self.assertEqual(thread.title, "Hello, I am test thread!")
         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['email'],
             form.cleaned_data['password'],
+            create_audit_trail=True,
             joined_from_ip=request.user_ip,
             set_default_avatar=True,
             **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 .activityranking import ActivityRanking
 from .avatar import Avatar
+from .audittrail import AuditTrail
 from .avatargallery import AvatarGallery
 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.utils import slugify
 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.utils import hash_email
 
@@ -26,8 +27,8 @@ from .rank import Rank
 class UserManager(BaseUserManager):
     @transaction.atomic
     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
 
         email = self.normalize_email(email)
@@ -89,6 +90,9 @@ class UserManager(BaseUserManager):
 
         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
         Online.objects.create(
             user=user,

+ 7 - 0
misago/users/signals.py

@@ -8,6 +8,8 @@ from django.utils import timezone
 from misago.conf import settings
 from misago.core.utils import ANONYMOUS_IP
 
+from .models import AuditTrail
+
 
 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.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(
         username, 
         email, 
+        create_audit_trail=True,
         joined_from_ip=request.user_ip, 
         set_default_avatar=True,
         **activation_kwargs
@@ -197,6 +198,7 @@ def create_user_with_form(strategy, details, backend, user=None, *args, **kwargs
             new_user = UserModel.objects.create_user(
                 form.cleaned_data['username'],
                 form.cleaned_data['email'],
+                create_audit_trail=True,
                 joined_from_ip=request.user_ip,
                 set_default_avatar=True,
                 **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':
             self.assertEqual(new_user.requires_activation, UserModel.ACTIVATION_ADMIN)
 
+        self.assertEqual(new_user.audittrail_set.count(), 1)
+
     def assertJsonResponseEquals(self, response, value):
         response_content = response.content.decode("utf-8")
         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.assertEqual(test_user.audittrail_set.count(), 1)
+
     def test_registration_creates_inactive_user(self):
         """api creates inactive user on POST"""
         settings.override_setting('account_activation', 'user')

+ 2 - 3
runtests.py

@@ -1,3 +1,4 @@
+#!/usr/bin/env python
 import os
 import pwd
 import shutil
@@ -8,7 +9,7 @@ from django import setup
 
 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():
@@ -38,9 +39,7 @@ def parse_args():
 
 
 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_package_path = os.path.join(TEST_RUNNER_PATH, 'misago/project_template/project_name')
 
     test_project_path = os.path.join(TEST_RUNNER_PATH, "testproject")
     if os.path.exists(test_project_path):