Browse Source

Moved more code around

Ralfp 12 years ago
parent
commit
6fc9914ea4

+ 41 - 0
misago/core/cookiejar.py

@@ -0,0 +1,41 @@
+from datetime import datetime, timedelta
+from django.conf import settings
+
+class CookieJar(object):
+    def __init__(self):
+        self._set_cookies = []
+        self._delete_cookies = []
+
+    def set(self, cookie, value, permanent=False):
+        if permanent:
+            # 360 days
+            max_age = 31104000
+        else:
+            # 48 hours
+            max_age = 172800
+        self._set_cookies.append({
+                                  'name': cookie,
+                                  'value': value,
+                                  'max_age': max_age,
+                                  })
+
+    def delete(self, cookie):
+        self._delete_cookies.append(cookie)
+
+    def flush(self, response):
+        for cookie in self._set_cookies:
+            response.set_cookie(
+                                settings.COOKIES_PREFIX + cookie['name'],
+                                cookie['value'],
+                                max_age=cookie['max_age'],
+                                path=settings.COOKIES_PATH,
+                                domain=settings.COOKIES_DOMAIN,
+                                secure=settings.COOKIES_SECURE
+                                )
+
+        for cookie in self._delete_cookies:
+            response.delete_cookie(
+                                   settings.COOKIES_PREFIX + cookie,
+                                   path=settings.COOKIES_PATH,
+                                   domain=settings.COOKIES_DOMAIN,
+                                   )

+ 1 - 1
misago/core/forms/__init__.py

@@ -1,4 +1,4 @@
 from misago.core.forms.fields import ForumChoiceField
 from misago.core.forms.forms import Form
 from misago.core.forms.layouts import FormLayout, FormFields, FormFieldsets
-from misago.core.forms.widgets import YesNoSwitch
+from misago.core.forms.widgets import ReCaptchaWidget, YesNoSwitch

+ 18 - 0
misago/core/forms/captcha.py

@@ -0,0 +1,18 @@
+from recaptcha.client.captcha import API_SSL_SERVER, API_SERVER, VERIFY_SERVER
+from django.forms.fields import CharField
+from django.forms.widgets import TextInput
+
+class ReCaptchaWidget(TextInput):
+    pass
+
+
+class ReCaptchaField(CharField):
+    widget = ReCaptchaWidget # Fakey widget for FormLayout
+    api_error = None # Api error
+    def __init__(self, label=_("Verification Code"), *args, **kwargs):
+        kwargs['label'], kwargs['required'] = label, False
+        super(ReCaptchaField, self).__init__(*args, **kwargs)
+
+
+class QACaptchaField(CharField):
+    pass

+ 16 - 2
misago/core/forms/fields.py

@@ -1,5 +1,7 @@
-from django.utils.html import conditional_escape, mark_safe
 from mptt.forms import TreeNodeChoiceField
+from django.utils.html import conditional_escape, mark_safe
+from django.utils.translation import ugettext_lazy as _
+from misago.core.forms import ReCaptchaWidget
 
 class ForumChoiceField(TreeNodeChoiceField):
     """
@@ -11,4 +13,16 @@ class ForumChoiceField(TreeNodeChoiceField):
 
     def _get_level_indicator(self, obj):
         level = getattr(obj, obj._mptt_meta.level_attr)
-        return mark_safe(conditional_escape(self.level_indicator) * (level - 1))
+        return mark_safe(conditional_escape(self.level_indicator) * (level - 1))
+
+
+class ReCaptchaField(CharField):
+    widget = ReCaptchaWidget
+    api_error = None
+    def __init__(self, label=_("Verification Code"), *args, **kwargs):
+        kwargs['label'], kwargs['required'] = label, False
+        super(ReCaptchaField, self).__init__(*args, **kwargs)
+
+
+class QACaptchaField(CharField):
+    pass

+ 1 - 1
misago/core/forms/forms.py

@@ -1,6 +1,6 @@
+from recaptcha.client.captcha import submit as recaptcha_submit
 from django import forms
 from django.utils.translation import ugettext_lazy as _
-from recaptcha.client.captcha import submit as recaptcha_submit
 
 class Form(forms.Form):
     """

+ 1 - 1
misago/core/forms/layouts.py

@@ -1,4 +1,5 @@
 from UserDict import IterableUserDict
+from recaptcha.client.captcha import displayhtml
 from django.utils import formats
 
 class FormLayout(object):
@@ -102,7 +103,6 @@ class FormFields(object):
 
             # ReCaptcha      
             if widget_name == 'ReCaptchaWidget':
-                from recaptcha.client.captcha import displayhtml
                 blueprint['widget'] = 'recaptcha'
                 blueprint['attrs'] = {'html': displayhtml(
                                                           form.request.settings['recaptcha_public'],

+ 5 - 4
misago/core/forms/widgets.py

@@ -1,7 +1,8 @@
 from django import forms
 
+class ReCaptchaWidget(forms.TextInput):
+    pass
+
+
 class YesNoSwitch(forms.CheckboxInput):
-    """
-    Custom Yes-No switch as fancier alternative to checkboxes
-    """
-    pass
+    pass

+ 248 - 0
misago/core/sessions.py

@@ -0,0 +1,248 @@
+from datetime import timedelta
+from django.conf import settings
+from django.contrib.sessions.backends.base import SessionBase, CreateError
+from django.db.models.loading import cache as model_cache
+from django.utils import timezone
+from django.utils.crypto import salted_hmac
+from django.utils.encoding import force_unicode
+from misago.authn.methods import auth_remember, AuthException
+from misago.models import Session, Token, Guest, User
+from misago.utils.string import random_string
+
+# Assert models are loaded
+if not model_cache.loaded:
+    model_cache.get_models()
+
+
+class IncorrectSessionException(Exception):
+    pass
+
+
+class MisagoSession(SessionBase):
+    """
+    Abstract class for sessions to inherit and extend
+    """
+    def _get_new_session_key(self):
+        return random_string(42)
+
+    def _get_session(self):
+        try:
+            return self._session_cache
+        except AttributeError:
+            self._session_cache = self.load()
+        return self._session_cache
+
+    def _hash(self, value):
+        key_salt = "misago.sessions" + self.__class__.__name__
+        return salted_hmac(key_salt, value).hexdigest()
+
+    def delete(self):
+        """We use sessions to track onlines so sorry, only sessions cleaner may delete sessions"""
+        pass
+
+    def flush(self):
+        """We use sessions to track onlines so sorry, only sessions cleaner may delete sessions"""
+        pass
+
+    def load(self):
+        return self.decode(force_unicode(self._session_rk.data))
+
+    def session_expired(self):
+        return False
+
+    def get_hidden(self):
+        return False
+
+    def set_hidden(self, hidden=False):
+        pass
+
+    def get_ip(self, request):
+        return request.META.get('HTTP_X_FORWARDED_FOR', '') or request.META.get('REMOTE_ADDR')
+
+    def set_user(self, user=None):
+        pass
+
+    def get_ban(self):
+        return False
+
+    def set_ban(self, ban):
+        return False
+
+    def save(self):
+        self._session_rk.data = self.encode(self._get_session())
+        self._session_rk.last = timezone.now()
+        self._session_rk.save(force_update=True)
+
+
+class CrawlerSession(MisagoSession):
+    """
+    Crawler Session controller
+    """
+    def __init__(self, request):
+        self.hidden = False
+        self.team = False
+        self._ip = self.get_ip(request)
+        try:
+            self._session_rk = Session.objects.get(crawler=request.user.username, ip=self._ip)
+            self._session_key = self._session_rk.id
+        except Session.DoesNotExist:
+            self.create(request)
+
+    def create(self, request):
+        while True:
+            try:
+                self._session_key = self._get_new_session_key()
+                self._session_rk = Session(
+                                         id=self._session_key,
+                                         data=self.encode({}),
+                                         crawler=request.user.username,
+                                         ip=self._ip,
+                                         agent=request.META.get('HTTP_USER_AGENT', ''),
+                                         start=timezone.now(),
+                                         last=timezone.now(),
+                                         matched=True
+                                         )
+                self._session_rk.save(force_insert=True)
+                break
+            except CreateError:
+                # Key wasn't unique. Try again.
+                continue
+
+    def human_session(self):
+        return False
+
+
+class HumanSession(MisagoSession):
+    """
+    Human Session controller
+    """
+    def __init__(self, request):
+        self.expired = False
+        self.hidden = False
+        self.team = False
+        self.rank = None
+        self.remember_me = None
+        self._user = None
+        self._ip = self.get_ip(request)
+        self._session_token = None
+        if request.firewall.admin:
+            self._cookie_sid = settings.COOKIES_PREFIX + 'ASID'
+        else:
+            self._cookie_sid = settings.COOKIES_PREFIX + 'SID'
+        try:
+            # Do we have correct session ID?
+            if self._cookie_sid not in request.COOKIES or len(request.COOKIES[self._cookie_sid]) != 42:
+                raise IncorrectSessionException()
+            self._session_key = request.COOKIES[self._cookie_sid]
+            self._session_rk = Session.objects.select_related().get(
+                                                                    pk=self._session_key,
+                                                                    admin=request.firewall.admin
+                                                                    )
+            # IP invalid
+            if request.settings.sessions_validate_ip and self._session_rk.ip != self._ip:
+                raise IncorrectSessionException()
+            
+            # Session expired
+            if timezone.now() - self._session_rk.last > timedelta(seconds=settings.SESSION_LIFETIME):
+                self.expired = True
+                raise IncorrectSessionException()
+            
+            # Change session to matched and extract session user and hidden flag
+            self._session_rk.matched = True
+            self._user = self._session_rk.user
+            self.hidden = self._session_rk.hidden
+            self.team = self._session_rk.team
+        except (Session.DoesNotExist, IncorrectSessionException):
+            # Attempt autolog
+            try:
+                self.remember_me = auth_remember(request, self.get_ip(request))
+                self.create(request, user=self.remember_me.user)
+            except AuthException as e:
+                # Autolog failed
+                self.create(request)
+        self.id = self._session_rk.id
+
+        # Make cookie live longer
+        if request.firewall.admin:
+            request.cookiejar.set('ASID', self._session_rk.id)
+        else:
+            request.cookiejar.set('SID', self._session_rk.id)
+
+    def create(self, request, user=None):
+        self._user = user
+        while True:
+            try:
+                self._session_key = self._get_new_session_key()
+                self._session_rk = Session(
+                                         id=self._session_key,
+                                         data=self.encode({}),
+                                         user=self._user,
+                                         ip=self._ip,
+                                         agent=request.META.get('HTTP_USER_AGENT', ''),
+                                         start=timezone.now(),
+                                         last=timezone.now(),
+                                         admin=request.firewall.admin,
+                                         )
+                self._session_rk.save(force_insert=True)
+                if user:
+                    # Update user data
+                    user.set_last_visit(
+                                        self.get_ip(request),
+                                        request.META.get('HTTP_USER_AGENT', ''),
+                                        hidden=self.hidden
+                                        )
+                    user.save(force_update=True)
+                break
+            except CreateError:
+                # Key wasn't unique. Try again.
+                continue
+
+    def save(self):
+        self._session_rk.user = self._user
+        self._session_rk.hidden = self.hidden
+        self._session_rk.team = self.team
+        self._session_rk.rank_id = self.rank
+        super(HumanSession, self).save()
+
+    def human_session(self):
+        return True
+
+    def session_expired(self):
+        return self.expired
+
+    def get_user(self):
+        if self._user == None:
+            return Guest()
+        return self._user
+
+    def set_user(self, user=None):
+        self._user = user
+
+    def sign_out(self, request):
+        try:
+            if self._user.is_authenticated():
+                if not request.firewall.admin:
+                    cookie_token = settings.COOKIES_PREFIX + 'TOKEN'
+                    if cookie_token in request.COOKIES:
+                        if len(request.COOKIES[cookie_token]) > 0:
+                            Token.objects.filter(id=request.COOKIES[cookie_token]).delete()
+                        request.cookiejar.delete('TOKEN')
+                self.hidden = False
+                self._user = None
+                request.user = Guest()
+        except AttributeError:
+            pass
+
+    def get_hidden(self):
+        return self.hidden
+
+    def set_hidden(self, hidden=False):
+        self.hidden = hidden
+
+
+class SessionMock(object):
+    def get_ip(self, request):
+        try:
+            return self.ip
+        except AttributeError:
+            return '127.0.0.1'

+ 67 - 0
misago/fixtures/captchasettings.py

@@ -0,0 +1,67 @@
+from misago.utils.fixtures import load_settings_fixture, update_settings_fixture
+from misago.utils.translation import ugettext_lazy as _
+
+settings_fixture = (
+   # Spam Countermeasures
+   ('spam', {
+        'name': _("Spam Countermeasures"),
+        'description': _("Those settings allow you to combat automatic registrations and spam messages on your forum."),
+        'settings': (
+            ('bots_registration', {
+                'type':         "string",
+                'input':        "choice",
+                'extra':        {'choices': [('', _("No protection")), ('recaptcha', _("reCaptcha")), ('qa', _("Question & Answer"))]},
+                'separator':    _("Spambots Registrations"),
+                'name':         _("CAPTCHA type"),
+                'description':  _('CAPTCHA stands for "Completely Automated Public Turing test to tell Computers and Humans Apart". Its type of test developed on purpose of blocking automatic registrations.'),
+            }),
+            ('recaptcha_public', {
+                'type':         "string",
+                'input':        "text",
+                'separator':    _("reCaptcha"),
+                'name':         _("Public Key"),
+                'description':  _("Enter public API key that you have received from reCaptcha."),
+            }),
+            ('recaptcha_private', {
+                'type':         "string",
+                'input':        "text",
+                'name':         _("Private Key"),
+                'description':  _("Enter private API key that you have received from reCaptcha."),
+            }),
+            ('recaptcha_ssl', {
+                'value':        False,
+                'type':         "boolean",
+                'input':        "yesno",
+                'name':         _("Use SSL in reCaptcha"),
+                'description':  _("Do you want forum to use SSL when making requests to reCaptha servers?"),
+            }),
+            ('qa_test', {
+                'type':         "string",
+                'input':        "text",
+                'separator':    _("Question and Answer Test"),
+                'name':         _("Question"),
+                'description':  _("Question visible to your users."),
+            }),
+            ('qa_test_help', {
+                'type':         "string",
+                'input':        "text",
+                'name':         _("Help Message"),
+                'description':  _("Optional help message displayed on form."),
+            }),
+            ('qa_test_answers', {
+                'type':         "string",
+                'input':        "textarea",
+                'name':         _("Answers"),
+                'description':  _("Enter allowed answers to this question, each in new line. Test is case-insensitive."),
+            }),
+        ),
+    }),
+)
+
+
+def load():
+    load_settings_fixture(settings_fixture)
+
+
+def update():
+    update_settings_fixture(settings_fixture)

+ 14 - 0
misago/management/commands/clearsessions.py

@@ -0,0 +1,14 @@
+from datetime import timedelta
+from django.conf import settings
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+from misago.models import Session
+
+class Command(BaseCommand):
+    """
+    This command is intended to work as CRON job fired every few hours to keep sessions table reasonable 
+    """
+    help = 'Clears users sessions'
+    def handle(self, *args, **options):
+        Session.objects.filter(last__lte=timezone.now() - timedelta(seconds=settings.SESSION_LIFETIME)).delete()
+        self.stdout.write('\nSessions have been cleared.\n')

+ 13 - 0
misago/management/commands/cleartokens.py

@@ -0,0 +1,13 @@
+from datetime import timedelta
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+from misago.models import Token
+
+class Command(BaseCommand):
+    """
+    This command is intended to work as CRON job fired every few days to remove unused tokens 
+    """
+    help = 'Clears "Remember Me" tokens'
+    def handle(self, *args, **options):
+        Token.objects.filter(accessed__lte=timezone.now() - timedelta(days=5)).delete()
+        self.stdout.write('\nSessions tokens have been cleared.\n')

+ 12 - 0
misago/middleware/cookiejar.py

@@ -0,0 +1,12 @@
+from misago.core.cookiejar import CookieJar
+
+class CookieJarMiddleware(object):
+    def process_request(self, request):
+        request.cookiejar = CookieJar()
+
+    def process_response(self, request, response):
+        try:
+            request.cookiejar.flush(response)
+        except AttributeError:
+            pass
+        return response

+ 30 - 0
misago/middleware/session.py

@@ -0,0 +1,30 @@
+from django.utils import timezone
+from misago.core.sessions import CrawlerSession, HumanSession
+
+class SessionMiddleware(object):
+    def process_request(self, request):
+        try:
+            if request.user.is_crawler():
+                # Crawler Session
+                request.session = CrawlerSession(request)
+        except AttributeError:
+            # Human Session
+            request.session = HumanSession(request)
+            request.user = request.session.get_user()
+
+            if request.user.is_authenticated():
+                request.session.set_hidden(request.user.hide_activity > 0)
+
+    def process_response(self, request, response):
+        try:
+            # Sync last visit date
+            if request.user.is_authenticated():
+                visit_sync = request.session.get('visit_sync')
+                if not visit_sync or (timezone.now() - visit_sync).seconds >= 900:
+                    request.session['visit_sync'] = timezone.now()
+                    request.user.last_date = timezone.now()
+                    request.user.save(force_update=True)
+            request.session.save()
+        except AttributeError:
+            pass
+        return response

+ 21 - 4
misago/models/__init__.py

@@ -1,8 +1,25 @@
-#from misago.models.alertmodel import Alert
-#from misago.models.banmodel import Ban
+from misago.models.alertmodel import Alert
+from misago.models.banmodel import Ban
+from misago.models.changemodel import Change
+from misago.models.checkpointmodel import Checkpoint
 from misago.models.fixturemodel import Fixture
-#from misago.models.forumrolemodel import ForumRole
+from misago.models.forummodel import Forum
+from misago.models.forumreadmodel import ForumRead
+from misago.models.forumrolemodel import ForumRole
+from misago.models.karmamodel import Karma
 from misago.models.monitoritemmodel import MonitorItem
+from misago.models.newslettermodel import Newsletter
+from misago.models.postmodel import Post
+from misago.models.prunepolicymodel import PrunePolicy
+from misago.models.rankmodel import Rank
+from misago.models.rolemodel import Role
+from misago.models.sessionmodel import Session
 from misago.models.settingmodel import Setting
 from misago.models.settingsgroupmodel import SettingsGroup
-#from misago.models.signinattemptmodel import SignInAttempt
+from misago.models.signinattemptmodel import SignInAttempt
+from misago.models.themeadjustmentmodel import ThemeAdjustment
+from misago.models.threadmodel import Thread
+from misago.models.threadreadmodel import ThreadRead
+from misago.models.tokenmodel import Token
+from misago.models.usermodel import User, Guest, Crawler
+from misago.models.usernamechangemodel import UsernameChange

+ 62 - 0
misago/models/changemodel.py

@@ -0,0 +1,62 @@
+from django.db import models
+from misago.forums.signals import move_forum_content
+from misago.threads.signals import move_thread, merge_thread, move_post, merge_post
+from misago.users.signals import rename_user
+
+class Change(models.Model):
+    forum = models.ForeignKey('Forum')
+    thread = models.ForeignKey('Thread')
+    post = models.ForeignKey('Post')
+    user = models.ForeignKey('User', null=True, blank=True, on_delete=models.SET_NULL)
+    user_name = models.CharField(max_length=255)
+    user_slug = models.CharField(max_length=255)
+    date = models.DateTimeField()
+    ip = models.GenericIPAddressField()
+    agent = models.CharField(max_length=255)
+    reason = models.CharField(max_length=255, null=True, blank=True)
+    thread_name_new = models.CharField(max_length=255, null=True, blank=True)
+    thread_name_old = models.CharField(max_length=255, null=True, blank=True)
+    post_content = models.TextField()
+    size = models.IntegerField(default=0)
+    change = models.IntegerField(default=0)
+
+    class Meta:
+        app_label = 'misago'
+
+
+def rename_user_handler(sender, **kwargs):
+    Change.objects.filter(user=sender).update(
+                                              user_name=sender.username,
+                                              user_slug=sender.username_slug,
+                                              )
+rename_user.connect(rename_user_handler, dispatch_uid="rename_user_changes")
+
+
+def move_forum_content_handler(sender, **kwargs):
+    Change.objects.filter(forum=sender).update(forum=kwargs['move_to'])
+
+move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_changes")
+
+
+def move_thread_handler(sender, **kwargs):
+    Change.objects.filter(thread=sender).update(forum=kwargs['move_to'])
+
+move_thread.connect(move_thread_handler, dispatch_uid="move_thread_changes")
+
+
+def merge_thread_handler(sender, **kwargs):
+    Change.objects.filter(thread=sender).update(thread=kwargs['new_thread'])
+
+merge_thread.connect(merge_thread_handler, dispatch_uid="merge_threads_changes")
+
+
+def move_posts_handler(sender, **kwargs):
+    Change.objects.filter(post=sender).update(forum=kwargs['move_to'].forum, thread=kwargs['move_to'])
+
+move_post.connect(move_posts_handler, dispatch_uid="move_posts_changes")
+
+
+def merge_posts_handler(sender, **kwargs):
+    Change.objects.filter(post=sender).update(post=kwargs['new_post'])
+
+merge_post.connect(merge_posts_handler, dispatch_uid="merge_posts_changes")

+ 65 - 0
misago/models/checkpointmodel.py

@@ -0,0 +1,65 @@
+from django.db import models
+from misago.forums.signals import move_forum_content
+from misago.threads.signals import move_thread, merge_thread, move_post, merge_post
+from misago.users.signals import rename_user
+
+class Checkpoint(models.Model):
+    forum = models.ForeignKey('Forum')
+    thread = models.ForeignKey('Thread')
+    post = models.ForeignKey('Post')
+    action = models.CharField(max_length=255)
+    user = models.ForeignKey('User', null=True, blank=True, on_delete=models.SET_NULL)
+    user_name = models.CharField(max_length=255)
+    user_slug = models.CharField(max_length=255)
+    date = models.DateTimeField()
+    ip = models.GenericIPAddressField()
+    agent = models.CharField(max_length=255)
+
+    class Meta:
+        app_label = 'misago'
+
+
+def rename_user_handler(sender, **kwargs):
+    Checkpoint.objects.filter(user=sender).update(
+                                                  user_name=sender.username,
+                                                  user_slug=sender.username_slug,
+                                                  )
+
+rename_user.connect(rename_user_handler, dispatch_uid="rename_user_checkpoints")
+
+
+def move_forum_content_handler(sender, **kwargs):
+    Checkpoint.objects.filter(forum=sender).update(forum=kwargs['move_to'])
+
+move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_checkpoints")
+
+
+def move_thread_handler(sender, **kwargs):
+    Checkpoint.objects.filter(thread=sender).update(forum=kwargs['move_to'])
+
+move_thread.connect(move_thread_handler, dispatch_uid="move_thread_checkpoints")
+
+
+def merge_thread_handler(sender, **kwargs):
+    Checkpoint.objects.filter(thread=sender).delete()
+
+merge_thread.connect(merge_thread_handler, dispatch_uid="merge_threads_checkpoints")
+
+
+def move_posts_handler(sender, **kwargs):
+    if sender.checkpoints:
+        prev_post = Post.objects.filter(thread=sender.thread_id).filter(merge__lte=sender.merge).exclude(id=sender.pk).order_by('merge', '-id')[:1][0]
+        Checkpoint.objects.filter(post=sender).update(post=prev_post)
+        prev_post.checkpoints = True
+        prev_post.save(force_update=True)
+    sender.checkpoints = False
+
+move_post.connect(move_posts_handler, dispatch_uid="move_posts_checkpoints")
+
+
+def merge_posts_handler(sender, **kwargs):
+    Checkpoint.objects.filter(post=sender).update(post=kwargs['new_post'])
+    if sender.checkpoints:
+        kwargs['new_post'].checkpoints = True
+
+merge_post.connect(merge_posts_handler, dispatch_uid="merge_posts_checkpoints")

+ 4 - 4
misago/models/forummodel.py

@@ -1,10 +1,9 @@
+from mptt.models import MPTTModel, TreeForeignKey
 from django.conf import settings
 from django.core.cache import cache
 from django.db import models
 from django.utils.translation import ugettext_lazy as _
-from mptt.models import MPTTModel, TreeForeignKey
 from misago.forums.signals import move_forum_content, delete_forum_content
-from misago.roles.models import Role
 from misago.users.signals import rename_user
 
 class ForumManager(models.Manager):
@@ -127,11 +126,11 @@ class Forum(MPTTModel):
     posts_delta = models.IntegerField(default=0)
     redirects = models.PositiveIntegerField(default=0)
     redirects_delta = models.IntegerField(default=0)
-    last_thread = models.ForeignKey('threads.Thread', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
+    last_thread = models.ForeignKey('Thread', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
     last_thread_name = models.CharField(max_length=255, null=True, blank=True)
     last_thread_slug = models.SlugField(max_length=255, null=True, blank=True)
     last_thread_date = models.DateTimeField(null=True, blank=True)
-    last_poster = models.ForeignKey('users.User', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
+    last_poster = models.ForeignKey('User', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
     last_poster_name = models.CharField(max_length=255, null=True, blank=True)
     last_poster_slug = models.SlugField(max_length=255, null=True, blank=True)
     last_poster_style = models.CharField(max_length=255, null=True, blank=True)
@@ -166,6 +165,7 @@ class Forum(MPTTModel):
 
     def copy_permissions(self, target):
         if target.pk != self.pk:
+            from misago.models import Role
             for role in Role.objects.all():
                 perms = role.get_permissions()
                 try:

+ 28 - 0
misago/models/forumreadmodel.py

@@ -0,0 +1,28 @@
+from datetime import timedelta
+from django.conf import settings
+from django.db import models
+from django.utils import timezone
+from misago.forums.signals import move_forum_content
+from misago.threads.signals import move_thread
+
+class ForumRead(models.Model):
+    user = models.ForeignKey('User')
+    forum = models.ForeignKey('Forum')
+    updated = models.DateTimeField()
+    cleared = models.DateTimeField()
+    
+    class Meta:
+        app_label = 'misago'
+
+    def get_threads(self):
+        threads = {}
+        for thread in ThreadRecord.objects.filter(user=self.user, forum=self.forum, updated__gte=(timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH))):
+            threads[thread.thread_id] = thread
+        return threads
+
+
+def move_forum_content_handler(sender, **kwargs):
+    ForumRead.objects.filter(forum=sender).delete()
+    ForumRead.objects.filter(forum=kwargs['move_to']).delete()
+
+move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_reads")

+ 61 - 0
misago/models/karmamodel.py

@@ -0,0 +1,61 @@
+from django.db import models
+from misago.forums.signals import move_forum_content
+from misago.threads.signals import move_thread, merge_thread, move_post, merge_post
+from misago.users.signals import rename_user
+
+class Karma(models.Model):
+    forum = models.ForeignKey('Forum')
+    thread = models.ForeignKey('Thread')
+    post = models.ForeignKey('Post')
+    user = models.ForeignKey('User', null=True, blank=True, on_delete=models.SET_NULL)
+    user_name = models.CharField(max_length=255)
+    user_slug = models.CharField(max_length=255)
+    date = models.DateTimeField()
+    ip = models.GenericIPAddressField()
+    agent = models.CharField(max_length=255)
+    score = models.IntegerField(default=0)
+
+    class Meta:
+        app_label = 'misago'
+
+
+def rename_user_handler(sender, **kwargs):
+    Karma.objects.filter(user=sender).update(
+                                             user_name=sender.username,
+                                             user_slug=sender.username_slug,
+                                             )
+
+rename_user.connect(rename_user_handler, dispatch_uid="rename_user_karmas")
+
+
+def move_forum_content_handler(sender, **kwargs):
+    Karma.objects.filter(forum=sender).update(forum=kwargs['move_to'])
+
+move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_karmas")
+
+
+def move_thread_handler(sender, **kwargs):
+    Karma.objects.filter(thread=sender).update(forum=kwargs['move_to'])
+
+move_thread.connect(move_thread_handler, dispatch_uid="move_thread_karmas")
+
+
+def merge_thread_handler(sender, **kwargs):
+    Karma.objects.filter(thread=sender).update(thread=kwargs['new_thread'])
+
+merge_thread.connect(merge_thread_handler, dispatch_uid="merge_threads_karmas")
+
+
+def move_posts_handler(sender, **kwargs):
+    Karma.objects.filter(post=sender).update(forum=kwargs['move_to'].forum, thread=kwargs['move_to'])
+
+move_post.connect(move_posts_handler, dispatch_uid="move_posts_karmas")
+
+
+def merge_posts_handler(sender, **kwargs):
+    Karma.objects.filter(post=sender).update(post=kwargs['new_post'])
+    kwargs['new_post'].upvotes += self.upvotes
+    kwargs['new_post'].downvotes += self.downvotes
+    kwargs['new_post'].score += self.score
+
+merge_post.connect(merge_posts_handler, dispatch_uid="merge_posts_karmas")

+ 36 - 0
misago/models/newslettermodel.py

@@ -0,0 +1,36 @@
+from django.db import models
+from misago.utils.string import random_string
+
+class Newsletter(models.Model):
+    name = models.CharField(max_length=255)
+    token = models.CharField(max_length=32)
+    step_size = models.PositiveIntegerField(default=0)
+    progress = models.PositiveIntegerField(default=0)
+    content_html = models.TextField(null=True, blank=True)
+    content_plain = models.TextField(null=True, blank=True)
+    ignore_subscriptions = models.BooleanField(default=False)
+    ranks = models.ManyToManyField('Rank')
+
+    class Meta:
+        app_label = 'misago'
+
+    def generate_token(self):
+        self.token = random_string(32)
+
+    def parse_name(self, tokens):
+        name = self.name
+        for key in tokens:
+            name = name.replace(key, tokens[key])
+        return name
+
+    def parse_html(self, tokens):
+        content_html = self.content_html
+        for key in tokens:
+            content_html = content_html.replace(key, tokens[key])
+        return content_html
+
+    def parse_plain(self, tokens):
+        content_plain = self.content_plain
+        for key in tokens:
+            content_plain = content_plain.replace(key, tokens[key])
+        return content_plain

+ 154 - 0
misago/models/postmodel.py

@@ -0,0 +1,154 @@
+from django.db import models
+from django.db.models import F
+from django.utils import timezone
+from django.utils.translation import ugettext_lazy as _
+from misago.forums.signals import move_forum_content
+from misago.threads.signals import move_thread, merge_thread, move_post, merge_post
+from misago.users.signals import delete_user_content, rename_user
+
+class PostManager(models.Manager):
+    def filter_stats(self, start, end):
+        return self.filter(date__gte=start).filter(date__lte=end)
+
+
+class Post(models.Model):
+    forum = models.ForeignKey('Forum')
+    thread = models.ForeignKey('Thread')
+    merge = models.PositiveIntegerField(default=0)
+    user = models.ForeignKey('User', null=True, blank=True, on_delete=models.SET_NULL)
+    user_name = models.CharField(max_length=255)
+    ip = models.GenericIPAddressField()
+    agent = models.CharField(max_length=255)
+    post = models.TextField()
+    post_preparsed = models.TextField()
+    upvotes = models.PositiveIntegerField(default=0)
+    downvotes = models.PositiveIntegerField(default=0)
+    mentions = models.ManyToManyField('User', related_name="mention_set")
+    checkpoints = models.BooleanField(default=False)
+    date = models.DateTimeField()
+    edits = models.PositiveIntegerField(default=0)
+    edit_date = models.DateTimeField(null=True, blank=True)
+    edit_reason = models.CharField(max_length=255, null=True, blank=True)
+    edit_user = models.ForeignKey('User', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
+    edit_user_name = models.CharField(max_length=255, null=True, blank=True)
+    edit_user_slug = models.SlugField(max_length=255, null=True, blank=True)
+    reported = models.BooleanField(default=False)
+    moderated = models.BooleanField(default=False)
+    deleted = models.BooleanField(default=False)
+    protected = models.BooleanField(default=False)
+
+    objects = PostManager()
+
+    statistics_name = _('New Posts')
+
+    class Meta:
+        app_label = 'misago'
+
+    def get_date(self):
+        return self.date
+
+    def move_to(self, thread):
+        move_post.send(sender=self, move_to=thread)
+        self.thread = thread
+        self.forum = thread.forum
+        
+    def merge_with(self, post):
+        post.post = '%s\n- - -\n%s' % (post.post, self.post)
+        merge_post.send(sender=self, new_post=post)
+
+    def set_checkpoint(self, request, action):
+        if request.user.is_authenticated():
+            self.checkpoints = True
+            self.checkpoint_set.create(
+                                       forum=self.forum,
+                                       thread=self.thread,
+                                       post=self,
+                                       action=action,
+                                       user=request.user,
+                                       user_name=request.user.username,
+                                       user_slug=request.user.username_slug,
+                                       date=timezone.now(),
+                                       ip=request.session.get_ip(request),
+                                       agent=request.META.get('HTTP_USER_AGENT'),
+                                       )
+            
+    def notify_mentioned(self, request, users):
+        from misago.acl.builder import get_acl
+        from misago.acl.utils import ACLError403, ACLError404
+        
+        mentioned = self.mentions.all()
+        for slug, user in users.items():
+            if user.pk != request.user.pk and user not in mentioned:
+                self.mentions.add(user)
+                try:                    
+                    acl = get_acl(request, user)
+                    acl.forums.allow_forum_view(self.forum)
+                    acl.threads.allow_thread_view(user, self.thread)
+                    acl.threads.allow_post_view(user, self.thread, self)
+                    if not user.is_ignoring(request.user):
+                        alert = user.alert(ugettext_lazy("%(username)s has mentioned you in his reply in thread %(thread)s").message)
+                        alert.profile('username', request.user)
+                        alert.post('thread', self.thread, self)
+                        alert.save_all()
+                except (ACLError403, ACLError404):
+                    pass
+
+
+def rename_user_handler(sender, **kwargs):
+    Post.objects.filter(user=sender).update(
+                                            user_name=sender.username,
+                                            )
+    Post.objects.filter(edit_user=sender).update(
+                                                 edit_user_name=sender.username,
+                                                 edit_user_slug=sender.username_slug,
+                                                 )
+
+rename_user.connect(rename_user_handler, dispatch_uid="rename_user_posts")
+
+
+def delete_user_content_handler(sender, **kwargs):
+    from misago.models import Thread
+
+    threads = []
+    prev_posts = []
+
+    for post in sender.post_set.filter(checkpoints=True):
+        threads.append(post.thread_id)
+        prev_post = Post.objects.filter(thread=post.thread_id).exclude(merge__gt=post.merge).exclude(user=sender).order_by('merge', '-id')[:1][0]
+        post.checkpoint_set.update(post=prev_post)
+        if not prev_post.pk in prev_posts:
+            prev_posts.append(prev_post.pk)
+
+    sender.post_set.all().delete()
+    Post.objects.filter(id__in=prev_posts).update(checkpoints=True)
+
+    for post in sender.post_set.distinct().values('thread_id').iterator():
+        if not post['thread_id'] in threads:
+            threads.append(post['thread_id'])
+
+    for post in Post.objects.filter(user=sender):
+        post.delete()
+
+    for thread in Thread.objects.filter(id__in=threads):
+        thread.sync()
+        thread.save(force_update=True)
+
+delete_user_content.connect(delete_user_content_handler, dispatch_uid="delete_user_posts")
+
+
+def move_forum_content_handler(sender, **kwargs):
+    Post.objects.filter(forum=sender).update(forum=kwargs['move_to'])
+
+move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_posts")
+
+
+def move_thread_handler(sender, **kwargs):
+    Post.objects.filter(thread=sender).update(forum=kwargs['move_to'])
+
+move_thread.connect(move_thread_handler, dispatch_uid="move_thread_posts")
+
+
+def merge_thread_handler(sender, **kwargs):
+    Post.objects.filter(thread=sender).update(thread=kwargs['new_thread'], merge=F('merge') + kwargs['merge'])
+
+merge_thread.connect(merge_thread_handler, dispatch_uid="merge_threads_posts")

+ 52 - 0
misago/models/pruningpolicymodel.py

@@ -0,0 +1,52 @@
+from datetime import timedelta
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.db.models import Q
+from django.utils import timezone
+from django.utils.translation import ugettext_lazy as _
+
+class PruningPolicy(models.Model):
+    name = models.CharField(max_length=255)
+    email = models.CharField(max_length=255, null=True, blank=True)
+    posts = models.PositiveIntegerField(default=0)
+    registered = models.PositiveIntegerField(default=0)
+    last_visit = models.PositiveIntegerField(default=0)
+
+    class Meta:
+        app_label = 'misago'
+
+    def clean(self):
+        if not (self.email and self.posts and self.registered and self.last_visit):
+            raise ValidationError(_("Pruning policy must have at least one pruning criteria set to be valid."))
+
+    def make_queryset(self):
+        from misago.models import User
+        queryset = User.objects
+
+        if self.email:
+            if ',' in self.email:
+                qs = None
+                for name in self.email.split(','):
+                    name = name.strip().lower()
+                    if name:
+                        if qs:
+                            qs = qs | Q(email__iendswith=name)
+                        else:
+                            qs = Q(email__iendswith=name)
+                if qs:
+                    queryset = queryset.filter(qs)
+            else:
+                queryset = queryset.filter(email__iendswith=self.email)
+
+        if self.posts:
+            queryset = queryset.filter(posts__lt=self.posts)
+
+        if self.registered:
+            date = timezone.now() - timedelta(days=self.registered)
+            queryset = queryset.filter(join_date__gte=date)
+
+        if self.last_visit:
+            date = timezone.now() - timedelta(days=self.last_visit)
+            queryset = queryset.filter(last_date__gte=date)
+
+        return queryset

+ 96 - 0
misago/models/rankmodel.py

@@ -0,0 +1,96 @@
+import math
+from django.conf import settings
+from django.db import models, connection, transaction
+from django.utils.translation import ugettext_lazy as _
+
+class Rank(models.Model):
+    """
+    Misago User Rank
+    Ranks are ready style/title pairs that are assigned to users either by admin (special ranks) or as result of user activity.
+    """
+    name = models.CharField(max_length=255)
+    name_slug = models.CharField(max_length=255, null=True, blank=True)
+    description = models.TextField(null=True, blank=True)
+    style = models.CharField(max_length=255, null=True, blank=True)
+    title = models.CharField(max_length=255, null=True, blank=True)
+    special = models.BooleanField(default=False)
+    as_tab = models.BooleanField(default=False)
+    on_index = models.BooleanField(default=False)
+    order = models.IntegerField(default=0)
+    criteria = models.CharField(max_length=255, null=True, blank=True)
+
+    class Meta:
+        app_label = 'misago'
+
+    def __unicode__(self):
+        return unicode(_(self.name))
+
+    def assign_rank(self, users=0, special_ranks=None):
+        if not self.criteria or self.special or users == 0:
+            # Rank cant be rolled in
+            return False
+
+        if self.criteria == "0":
+            # Just update all fellows
+            User.objects.exclude(rank__in=special_ranks).update(rank=self)
+        else:
+            # Count number of users to update
+            if self.criteria[-1] == '%':
+                criteria = int(self.criteria[0:-1])
+                criteria = int(math.ceil(float(users / 100.0) * criteria))
+            else:
+                criteria = int(self.criteria)
+
+            # Join special ranks
+            if special_ranks:
+                special_ranks = ','.join(special_ranks)
+
+            # Run raw query
+            cursor = connection.cursor()
+            try:
+                # Postgresql
+                if (settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql_psycopg2'
+                    or settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql'):
+                    if special_ranks:
+                        cursor.execute('''UPDATE users_user
+                            FROM (
+                                SELECT id
+                                FROM users_user
+                                WHERE rank_id NOT IN (%s)
+                                ORDER BY score DESC LIMIT %s
+                                ) AS updateable
+                            SET rank_id=%s
+                            WHERE id = updateable.id
+                            RETURNING *''' % (self.id, special_ranks, criteria))
+                    else:
+                        cursor.execute('''UPDATE users_user
+                            FROM (
+                                SELECT id
+                                FROM users_user
+                                ORDER BY score DESC LIMIT %s
+                                ) AS updateable
+                            SET rank_id=%s
+                            WHERE id = updateable.id
+                            RETURNING *''', [self.id, criteria])
+
+                # MySQL, SQLite and Oracle
+                if (settings.DATABASES['default']['ENGINE'] == 'django.db.backends.mysql'
+                    or settings.DATABASES['default']['ENGINE'] == 'django.db.backends.sqlite3'
+                    or settings.DATABASES['default']['ENGINE'] == 'django.db.backends.oracle'):
+                    if special_ranks:
+                        cursor.execute('''UPDATE users_user
+                            SET rank_id=%s
+                            WHERE rank_id NOT IN (%s)
+                            ORDER BY score DESC
+                            LIMIT %s''' % (self.id, special_ranks, criteria))
+                    else:
+                        cursor.execute('''UPDATE users_user
+                        SET rank_id=%s
+                        ORDER BY score DESC
+                        LIMIT %s''', [self.id, criteria])
+            except Exception as e:
+                print 'Error updating users ranking: %s' % e
+
+            transaction.commit_unless_managed()
+
+        return True

+ 45 - 0
misago/models/rolemodel.py

@@ -0,0 +1,45 @@
+from django.db import models
+from django.utils.translation import ugettext as _
+import base64
+try:
+    import cPickle as pickle
+except ImportError:
+    import pickle
+
+class Role(models.Model):
+    """
+    Misago User Role model
+    """
+    name = models.CharField(max_length=255)
+    token = models.CharField(max_length=255,null=True,blank=True)
+    protected = models.BooleanField(default=False)
+    _permissions = models.TextField(db_column = 'permissions', null=True, blank=True)
+    permissions_cache = {}
+
+    class Meta:
+        app_label = 'misago'
+    
+    def __unicode__(self):
+        return unicode(_(self.name))
+    
+    def is_special(self):
+        return token
+
+    @property
+    def permissions(self):
+        if self.permissions_cache:
+            return self.permissions_cache
+
+        try:
+            self.permissions_cache = pickle.loads(base64.decodestring(self._permissions))
+        except Exception:
+            # ValueError, SuspiciousOperation, unpickling exceptions. If any of
+            # these happen, just return an empty dictionary (an empty permissions list).
+            self.permissions_cache = {}
+
+        return self.permissions_cache
+
+    @permissions.setter
+    def permissions(self, permissions):
+        self.permissions_cache = permissions
+        self._permissions = base64.encodestring(pickle.dumps(permissions, pickle.HIGHEST_PROTOCOL))

+ 19 - 0
misago/models/sessionmodel.py

@@ -0,0 +1,19 @@
+from django.db import models
+
+class Session(models.Model):
+    id = models.CharField(max_length=42, primary_key=True)
+    data = models.TextField(db_column="session_data")
+    user = models.ForeignKey('User', related_name='sessions', null=True, on_delete=models.SET_NULL)
+    crawler = models.CharField(max_length=255, blank=True, null=True)
+    ip = models.GenericIPAddressField()
+    agent = models.CharField(max_length=255)
+    start = models.DateTimeField()
+    last = models.DateTimeField()
+    team = models.BooleanField(default=False)
+    rank = models.ForeignKey('Rank', related_name='sessions', null=True, on_delete=models.SET_NULL)
+    admin = models.BooleanField(default=False)
+    matched = models.BooleanField(default=False)
+    hidden = models.BooleanField(default=False)
+
+    class Meta:
+        app_label = 'misago'

+ 25 - 0
misago/models/themeadjustmentmodel.py

@@ -0,0 +1,25 @@
+from django.core.cache import cache
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+
+class ThemeAdjustment(models.Model):
+    theme = models.CharField(max_length=255, unique=True,
+                             error_messages={'unique': _("User agents for this theme are already defined.")})
+    useragents = models.TextField(null=True, blank=True)
+    
+    class Meta:
+        app_label = 'misago'
+
+    def adjust_theme(self, useragent):
+        for string in self.useragents.splitlines():
+            if string in useragent:
+                return True
+        return False
+    
+    def save(self, *args, **kwargs):
+        cache.delete('client_adjustments')
+        super(ThemeAdjustment, self).save(*args, **kwargs)
+
+    def delete(self, *args, **kwargs):
+        cache.delete('client_adjustments')
+        super(ThemeAdjustment, self).delete(*args, **kwargs)

+ 178 - 0
misago/models/threadmodel.py

@@ -0,0 +1,178 @@
+from datetime import timedelta
+from django.conf import settings
+from django.db import models
+from django.utils import timezone
+from django.utils.translation import ugettext_lazy as _
+from misago.forums.signals import move_forum_content
+from misago.threads.signals import move_thread, merge_thread
+from misago.users.signals import delete_user_content, rename_user
+from misago.utils.strings import slugify
+
+class ThreadManager(models.Manager):
+    def filter_stats(self, start, end):
+        return self.filter(start__gte=start).filter(start__lte=end)
+
+    def with_reads(self, queryset, user):
+        from misago.models import ForumRead, ThreadRead
+
+        threads = []
+        threads_dict = {}
+        forum_reads = {}
+        cutoff = timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH)
+
+        if user.is_authenticated() and user.join_date > cutoff:
+            cutoff = user.join_date
+            for row in ForumRead.objects.filter(user=user).values('forum_id', 'cleared'):
+                forum_reads[row['forum_id']] = row['cleared']
+
+        for thread in queryset:
+            thread.is_read = True
+            if user.is_authenticated() and thread.last > cutoff:
+                try:
+                    thread.is_read = thread.last <= forum_reads[thread.forum_id]
+                except KeyError:
+                    pass
+
+            threads.append(thread)
+            threads_dict[thread.pk] = thread
+
+        if user.is_authenticated():
+            for read in ThreadRead.objects.filter(user=user).filter(thread__in=threads_dict.keys()):
+                try:
+                    threads_dict[read.thread_id].is_read = (threads_dict[read.thread_id].last <= cutoff or 
+                                                            threads_dict[read.thread_id].last <= read.updated or
+                                                            threads_dict[read.thread_id].last <= forum_reads[read.forum_id])
+                except KeyError:
+                    pass
+
+        return threads
+
+
+class Thread(models.Model):
+    forum = models.ForeignKey('Forum')
+    weight = models.PositiveIntegerField(default=0)
+    name = models.CharField(max_length=255)
+    slug = models.SlugField(max_length=255)
+    replies = models.PositiveIntegerField(default=0)
+    replies_reported = models.PositiveIntegerField(default=0)
+    replies_moderated = models.PositiveIntegerField(default=0)
+    replies_deleted = models.PositiveIntegerField(default=0)
+    merges = models.PositiveIntegerField(default=0)
+    score = models.PositiveIntegerField(default=30)
+    upvotes = models.PositiveIntegerField(default=0)
+    downvotes = models.PositiveIntegerField(default=0)
+    start = models.DateTimeField()
+    start_post = models.ForeignKey('Post', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
+    start_poster = models.ForeignKey('User', null=True, blank=True, on_delete=models.SET_NULL)
+    start_poster_name = models.CharField(max_length=255)
+    start_poster_slug = models.SlugField(max_length=255)
+    start_poster_style = models.CharField(max_length=255, null=True, blank=True)
+    last = models.DateTimeField()
+    last_post = models.ForeignKey('Post', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
+    last_poster = models.ForeignKey('User', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
+    last_poster_name = models.CharField(max_length=255, null=True, blank=True)
+    last_poster_slug = models.SlugField(max_length=255, null=True, blank=True)
+    last_poster_style = models.CharField(max_length=255, null=True, blank=True)
+    moderated = models.BooleanField(default=False)
+    deleted = models.BooleanField(default=False)
+    closed = models.BooleanField(default=False)
+
+    objects = ThreadManager()
+
+    statistics_name = _('New Threads')
+
+    class Meta:
+        app_label = 'misago'
+
+    def get_date(self):
+        return self.start
+
+    def move_to(self, move_to):
+        move_thread.send(sender=self, move_to=move_to)
+        self.forum = move_to
+
+    def merge_with(self, thread, merge):
+        merge_thread.send(sender=self, new_thread=thread, merge=merge)
+
+    def sync(self):
+        # Counters
+        self.replies = self.post_set.filter(moderated=False).count() - 1
+        if self.replies < 0:
+            self.replies = 0
+        self.replies_reported = self.post_set.filter(reported=True).count()
+        self.replies_moderated = self.post_set.filter(moderated=True).count()
+        self.replies_deleted = self.post_set.filter(deleted=True).count()
+        # First post
+        start_post = self.post_set.order_by('merge', 'id')[0:][0]
+        self.start = start_post.date
+        self.start_post = start_post
+        self.start_poster = start_post.user
+        self.start_poster_name = start_post.user_name
+        self.start_poster_slug = slugify(start_post.user_name)
+        self.start_poster_style = start_post.user.rank.style if start_post.user else ''
+        self.upvotes = start_post.upvotes
+        self.downvotes = start_post.downvotes
+        # Last visible post
+        if self.replies > 0:
+            last_post = self.post_set.order_by('-merge', '-id').filter(moderated=False)[0:][0]
+        else:
+            last_post = start_post
+        self.last = last_post.date
+        self.last_post = last_post
+        self.last_poster = last_post.user
+        self.last_poster_name = last_post.user_name
+        self.last_poster_slug = slugify(last_post.user_name)
+        self.last_poster_style = last_post.user.rank.style if last_post.user else ''
+        # Flags
+        self.moderated = start_post.moderated
+        self.deleted = start_post.deleted
+        self.merges = last_post.merge
+        
+    def email_watchers(self, request, post):
+        from misago.acl.builder import get_acl
+        from misago.acl.utils import ACLError403, ACLError404
+        from misago.models import ThreadRead
+
+        for watch in ThreadWatch.objects.filter(thread=self).filter(email=True).filter(last_read__gte=self.previous_last):
+            user = watch.user
+            if user.pk != request.user.pk:
+                try:
+                    acl = get_acl(request, user)
+                    acl.forums.allow_forum_view(self.forum)
+                    acl.threads.allow_thread_view(user, self)
+                    acl.threads.allow_post_view(user, self, post)
+                    if not user.is_ignoring(request.user):
+                        user.email_user(
+                            request,
+                            'post_notification',
+                            _('New reply in thread "%(thread)s"') % {'thread': self.name},
+                            {'author': request.user, 'post': post, 'thread': self}
+                            )
+                except (ACLError403, ACLError404):
+                    pass
+
+
+def rename_user_handler(sender, **kwargs):
+    Thread.objects.filter(start_poster=sender).update(
+                                                     start_poster_name=sender.username,
+                                                     start_poster_slug=sender.username_slug,
+                                                     )
+    Thread.objects.filter(last_poster=sender).update(
+                                                     last_poster_name=sender.username,
+                                                     last_poster_slug=sender.username_slug,
+                                                     )
+
+rename_user.connect(rename_user_handler, dispatch_uid="rename_user_threads")
+
+
+def delete_user_content_handler(sender, **kwargs):
+    for thread in Thread.objects.filter(start_poster=sender):
+        thread.delete()
+
+delete_user_content.connect(delete_user_content_handler, dispatch_uid="delete_user_threads")
+
+
+def move_forum_content_handler(sender, **kwargs):
+    Thread.objects.filter(forum=sender).update(forum=kwargs['move_to'])
+
+move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_threads")

+ 24 - 0
misago/models/threadreadmodel.py

@@ -0,0 +1,24 @@
+from django.db import models
+from misago.forums.signals import move_forum_content
+from misago.threads.signals import move_thread
+
+class ThreadRecord(models.Model):
+    user = models.ForeignKey('User')
+    forum = models.ForeignKey('Forum')
+    thread = models.ForeignKey('Thread')
+    updated = models.DateTimeField()
+
+    class Meta:
+        app_label = 'misago'
+
+
+def move_forum_content_handler(sender, **kwargs):
+    ThreadRecord.objects.filter(forum=sender).update(forum=kwargs['move_to'])
+
+move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_threads_reads")
+
+
+def move_thread_handler(sender, **kwargs):
+    ThreadRecord.objects.filter(thread=sender).delete()
+
+move_thread.connect(move_thread_handler, dispatch_uid="move_thread_reads")

+ 10 - 0
misago/models/tokenmodel.py

@@ -0,0 +1,10 @@
+from django.db import models
+
+class Token(models.Model):
+    id = models.CharField(max_length=42, primary_key=True)
+    user = models.ForeignKey('User', related_name='signin_tokens')
+    created = models.DateTimeField()
+    accessed = models.DateTimeField()
+
+    class Meta:
+        app_label = 'misago'

+ 537 - 0
misago/models/usermodel.py

@@ -0,0 +1,537 @@
+import hashlib
+import math
+from random import choice
+from path import path
+from django.conf import settings
+from django.contrib.auth.hashers import (
+    check_password, make_password, is_password_usable, UNUSABLE_PASSWORD)
+from django.core.cache import cache, InvalidCacheBackendError
+from django.core.exceptions import ValidationError
+from django.core.mail import EmailMultiAlternatives
+from django.db import models
+from django.template import RequestContext
+from django.utils import timezone as tz_util
+from django.utils.translation import ugettext_lazy as _
+from misago.acl.builder import build_acl
+from misago.monitor.monitor import Monitor
+from misago.core.settings import DBSettings
+from misago.users.signals import delete_user_content, rename_user
+from misago.users.validators import validate_username, validate_password, validate_email
+from misago.utils.strings import random_string, slugify
+from misago.utils.avatars import avatar_size
+
+class UserManager(models.Manager):
+    """
+    User Manager provides us with some additional methods for users
+    """
+    def get_blank_user(self):
+        blank_user = User(
+                        join_date=tz_util.now(),
+                        join_ip='127.0.0.1'
+                        )
+        return blank_user
+
+    def resync_monitor(self, monitor):
+        monitor['users'] = self.filter(activation=0).count()
+        monitor['users_inactive'] = self.filter(activation__gt=0).count()
+        last_user = self.filter(activation=0).latest('id')
+        monitor['last_user'] = last_user.pk
+        monitor['last_user_name'] = last_user.username
+        monitor['last_user_slug'] = last_user.username_slug
+
+    def create_user(self, username, email, password, timezone=False, ip='127.0.0.1', agent='', no_roles=False, activation=0, request=False):
+        token = ''
+        if activation > 0:
+            token = random_string(12)
+
+        try:
+            db_settings = request.settings
+        except AttributeError:
+            db_settings = DBSettings()
+
+        if timezone == False:
+            timezone = db_settings['default_timezone']
+
+        # Get first rank
+        try:
+            from misago.ranks.models import Rank
+            default_rank = Rank.objects.filter(special=0).order_by('order')[0]
+        except IndexError:
+            default_rank = None
+
+        # Store user in database
+        new_user = User(
+                        last_sync=tz_util.now(),
+                        join_date=tz_util.now(),
+                        join_ip=ip,
+                        join_agent=agent,
+                        activation=activation,
+                        token=token,
+                        timezone=timezone,
+                        rank=default_rank,
+                        subscribe_start=db_settings['subscribe_start'],
+                        subscribe_reply=db_settings['subscribe_reply'],
+                        )
+
+        validate_username(username, db_settings)
+        validate_password(password, db_settings)
+        new_user.set_username(username)
+        new_user.set_email(email)
+        new_user.set_password(password)
+        new_user.full_clean()
+        new_user.default_avatar(db_settings)
+        new_user.save(force_insert=True)
+
+        # Set user roles?
+        if not no_roles:
+            from misago.models import Role
+            new_user.roles.add(Role.objects.get(token='registered'))
+            new_user.make_acl_key()
+            new_user.save(force_update=True)
+
+        # Load monitor
+        try:
+            monitor = request.monitor
+        except AttributeError:
+            monitor = Monitor()
+
+        # Update forum stats
+        if activation == 0:
+            monitor['users'] = int(monitor['users']) + 1
+            monitor['last_user'] = new_user.pk
+            monitor['last_user_name'] = new_user.username
+            monitor['last_user_slug'] = new_user.username_slug
+        else:
+            monitor['users_inactive'] = int(monitor['users_inactive']) + 1
+
+        # Return new user
+        return new_user
+
+    def get_by_email(self, email):
+        return self.get(email_hash=hashlib.md5(email).hexdigest())
+
+    def filter_stats(self, start, end):
+        return self.filter(join_date__gte=start).filter(join_date__lte=end)
+
+
+class User(models.Model):
+    """
+    Misago User model
+    """
+    username = models.CharField(max_length=255)
+    username_slug = models.SlugField(max_length=255, unique=True,
+                                     error_messages={'unique': _("This user name is already in use by another user.")})
+    email = models.EmailField(max_length=255, validators=[validate_email])
+    email_hash = models.CharField(max_length=32, unique=True,
+                                     error_messages={'unique': _("This email address is already in use by another user.")})
+    password = models.CharField(max_length=255)
+    password_date = models.DateTimeField()
+    avatar_type = models.CharField(max_length=10, null=True, blank=True)
+    avatar_image = models.CharField(max_length=255, null=True, blank=True)
+    avatar_original = models.CharField(max_length=255, null=True, blank=True)
+    avatar_temp = models.CharField(max_length=255, null=True, blank=True)
+    signature = models.TextField(null=True, blank=True)
+    signature_preparsed = models.TextField(null=True, blank=True)
+    join_date = models.DateTimeField()
+    join_ip = models.GenericIPAddressField()
+    join_agent = models.TextField(null=True, blank=True)
+    last_date = models.DateTimeField(null=True, blank=True)
+    last_ip = models.GenericIPAddressField(null=True, blank=True)
+    last_agent = models.TextField(null=True, blank=True)
+    hide_activity = models.PositiveIntegerField(default=0)
+    allow_pms = models.PositiveIntegerField(default=0)
+    subscribe_start = models.PositiveIntegerField(default=0)
+    subscribe_reply = models.PositiveIntegerField(default=0)
+    receive_newsletters = models.BooleanField(default=True)
+    threads = models.PositiveIntegerField(default=0)
+    posts = models.PositiveIntegerField(default=0)
+    votes = models.PositiveIntegerField(default=0)
+    karma_given_p = models.PositiveIntegerField(default=0)
+    karma_given_n = models.PositiveIntegerField(default=0)
+    karma_p = models.PositiveIntegerField(default=0)
+    karma_n = models.PositiveIntegerField(default=0)
+    following = models.PositiveIntegerField(default=0)
+    followers = models.PositiveIntegerField(default=0)
+    score = models.IntegerField(default=0)
+    ranking = models.PositiveIntegerField(default=0)
+    rank = models.ForeignKey('Rank', null=True, blank=True, on_delete=models.SET_NULL)
+    last_sync = models.DateTimeField(null=True, blank=True)
+    follows = models.ManyToManyField('self', related_name='follows_set', symmetrical=False)
+    ignores = models.ManyToManyField('self', related_name='ignores_set', symmetrical=False)
+    title = models.CharField(max_length=255, null=True, blank=True)
+    last_post = models.DateTimeField(null=True, blank=True)
+    last_search = models.DateTimeField(null=True, blank=True)
+    alerts = models.PositiveIntegerField(default=0)
+    alerts_date = models.DateTimeField(null=True, blank=True)
+    activation = models.IntegerField(default=0)
+    token = models.CharField(max_length=12, null=True, blank=True)
+    avatar_ban = models.BooleanField(default=False)
+    avatar_ban_reason_user = models.TextField(null=True, blank=True)
+    avatar_ban_reason_admin = models.TextField(null=True, blank=True)
+    signature_ban = models.BooleanField(default=False)
+    signature_ban_reason_user = models.TextField(null=True, blank=True)
+    signature_ban_reason_admin = models.TextField(null=True, blank=True)
+    timezone = models.CharField(max_length=255, default='utc')
+    roles = models.ManyToManyField('Role')
+    is_team = models.BooleanField(default=False)
+    acl_key = models.CharField(max_length=12, null=True, blank=True)
+
+    objects = UserManager()
+
+    ACTIVATION_NONE = 0
+    ACTIVATION_USER = 1
+    ACTIVATION_ADMIN = 2
+    ACTIVATION_CREDENTIALS = 3
+
+    statistics_name = _('Users Registrations')
+
+    def is_god(self):
+        try:
+            return self.is_god_cache
+        except AttributeError:
+            for user in settings.ADMINS:
+                if user[1].lower() == self.email:
+                    self.is_god_cache = True
+                    return True
+            self.is_god_cache = False
+            return False
+
+    def is_anonymous(self):
+        return False
+
+    def is_authenticated(self):
+        return True
+
+    def is_crawler(self):
+        return False
+
+    def is_protected(self):
+        for role in self.roles.all():
+            if role.protected:
+                return True
+        return False
+
+    def lock_avatar(self):
+        # Kill existing avatar and lock our ability to change it
+        self.delete_avatar()
+        self.avatar_ban = True
+
+        # Pick new one from _locked gallery
+        galleries = path(settings.STATICFILES_DIRS[0]).joinpath('avatars').joinpath('_locked')
+        avatars_list = galleries.files('*.gif')
+        avatars_list += galleries.files('*.jpg')
+        avatars_list += galleries.files('*.jpeg')
+        avatars_list += galleries.files('*.png')
+        self.avatar_type = 'gallery'
+        self.avatar_image = '/'.join(path(choice(avatars_list)).splitall()[-2:])
+
+    def default_avatar(self, db_settings):
+        if db_settings['default_avatar'] == 'gallery':
+            try:
+                avatars_list = []
+                try:
+                    # First try, _default path
+                    galleries = path(settings.STATICFILES_DIRS[0]).joinpath('avatars').joinpath('_default')
+                    avatars_list += galleries.files('*.gif')
+                    avatars_list += galleries.files('*.jpg')
+                    avatars_list += galleries.files('*.jpeg')
+                    avatars_list += galleries.files('*.png')
+                except Exception as e:
+                    pass
+                # Second try, all paths
+                if not avatars_list:
+                    avatars_list = []
+                    for directory in path(settings.STATICFILES_DIRS[0]).joinpath('avatars').dirs():
+                        if not directory[-7:] == '_locked' and not directory[-7:] == '_thumbs':
+                            avatars_list += directory.files('*.gif')
+                            avatars_list += directory.files('*.jpg')
+                            avatars_list += directory.files('*.jpeg')
+                            avatars_list += directory.files('*.png')
+                if avatars_list:
+                    # Pick random avatar from list
+                    self.avatar_type = 'gallery'
+                    self.avatar_image = '/'.join(path(choice(avatars_list)).splitall()[-2:])
+                    return True
+            except Exception as e:
+                pass
+
+        self.avatar_type = 'gravatar'
+        self.avatar_image = None
+        return True
+
+    def delete_avatar_temp(self):
+        if self.avatar_temp:
+            try:
+                av_file = path(settings.MEDIA_ROOT + 'avatars/' + self.avatar_temp)
+                if not av_file.isdir():
+                    av_file.remove()
+            except Exception:
+                pass
+
+        self.avatar_temp = None
+
+    def delete_avatar_original(self):
+        if self.avatar_original:
+            try:
+                av_file = path(settings.MEDIA_ROOT + 'avatars/' + self.avatar_original)
+                if not av_file.isdir():
+                    av_file.remove()
+            except Exception:
+                pass
+
+        self.avatar_original = None
+
+    def delete_avatar_image(self):
+        if self.avatar_image:
+            for size in settings.AVATAR_SIZES[1:]:
+                try:
+                    av_file = path(settings.MEDIA_ROOT + 'avatars/' + str(size) + '_' + self.avatar_image)
+                    if not av_file.isdir():
+                        av_file.remove()
+                except Exception:
+                    pass
+            try:
+                av_file = path(settings.MEDIA_ROOT + 'avatars/' + self.avatar_image)
+                if not av_file.isdir():
+                    av_file.remove()
+            except Exception:
+                pass
+
+        self.avatar_image = None
+
+    def delete_avatar(self):
+        self.delete_avatar_temp()
+        self.delete_avatar_original()
+        self.delete_avatar_image()
+
+    def delete_content(self):
+        delete_user_content.send(sender=self)
+
+    def delete(self, *args, **kwargs):
+        self.delete_avatar()
+        super(User, self).delete(*args, **kwargs)
+
+    def set_username(self, username):
+        self.username = username.strip()
+        self.username_slug = slugify(username)
+
+    def sync_username(self):
+        print 'SYNCING NAME CACHES!'
+        rename_user.send(sender=self)
+
+    def is_username_valid(self, e):
+        try:
+            raise ValidationError(e.message_dict['username'])
+        except KeyError:
+            pass
+        try:
+            raise ValidationError(e.message_dict['username_slug'])
+        except KeyError:
+            pass
+
+    def is_email_valid(self, e):
+        try:
+            raise ValidationError(e.message_dict['email'])
+        except KeyError:
+            pass
+        try:
+            raise ValidationError(e.message_dict['email_hash'])
+        except KeyError:
+            pass
+
+    def is_password_valid(self, e):
+        try:
+            raise ValidationError(e.message_dict['password'])
+        except KeyError:
+            pass
+
+    def set_email(self, email):
+        self.email = email.strip().lower()
+        self.email_hash = hashlib.md5(self.email).hexdigest()
+
+    def set_password(self, raw_password):
+        self.password_date = tz_util.now()
+        self.password = make_password(raw_password.strip())
+
+    def set_last_visit(self, ip, agent, hidden=False):
+        self.last_date = tz_util.now()
+        self.last_ip = ip
+        self.last_agent = agent
+        self.last_hide = hidden
+
+    def check_password(self, raw_password, mobile=False):
+        """
+        Returns a boolean of whether the raw_password was correct. Handles
+        hashing formats behind the scenes.
+        """
+        def setter(raw_password):
+            self.set_password(raw_password)
+            self.save()
+
+        # Is standard password allright?
+        if check_password(raw_password, self.password, setter):
+            return True
+
+        # Check mobile password?
+        if mobile:
+            raw_password = raw_password[:1].lower() + raw_password[1:]
+        else:
+            password_reversed = u''
+            for c in raw_password:
+                r = c.upper()
+                if r == c:
+                    r = c.lower()
+                password_reversed += r
+            raw_password = password_reversed
+        return check_password(raw_password, self.password, setter)
+
+    def is_following(self, user):
+        try:
+            return self.follows.filter(id=user.pk).count() > 0
+        except AttributeError:
+            return self.follows.filter(id=user).count() > 0
+
+    def is_ignoring(self, user):
+        try:
+            return self.ignores.filter(id=user.pk).count() > 0
+        except AttributeError:
+            return self.ignores.filter(id=user).count() > 0
+        
+    def ignored_users(self):
+        return [item['id'] for item in self.ignores.values('id')]
+
+    def get_roles(self):
+        return self.roles.all()
+
+    def make_acl_key(self, force=False):
+        if not force and self.acl_key:
+            return self.acl_key
+        roles_ids = []
+        for role in self.roles.all():
+            roles_ids.append(str(role.pk))
+        self.acl_key = 'acl_%s' % hashlib.md5('_'.join(roles_ids)).hexdigest()[0:8]
+        return self.acl_key
+
+    def get_acl(self, request):
+        try:
+            acl = cache.get(self.acl_key)
+            if acl.version != request.monitor.acl_version:
+                raise InvalidCacheBackendError()
+        except AttributeError, InvalidCacheBackendError:
+            # build acl cache
+            acl = build_acl(request, self.get_roles())
+            cache.set(self.acl_key, acl, 2592000)
+        return acl
+
+    def get_avatar(self, size=None):
+        image_size = avatar_size(size) if size else None
+
+        # Get uploaded avatar
+        if self.avatar_type == 'upload':
+            image_prefix = '%s_' % image_size if image_size else ''
+            return settings.MEDIA_URL + 'avatars/' + image_prefix + self.avatar_image
+
+        # Get gallery avatar
+        if self.avatar_type == 'gallery':
+            image_prefix = '_thumbs/%s/' % image_size if image_size else ''
+            return settings.STATIC_URL + 'avatars/' + image_prefix + self.avatar_image
+
+        # No avatar found, get gravatar
+        if not image_size:
+            image_size = settings.AVATAR_SIZES[0]
+        return 'http://www.gravatar.com/avatar/%s?s=%s' % (hashlib.md5(self.email).hexdigest(), image_size)
+
+    def get_ranking(self):
+        if not self.ranking:
+            self.ranking = User.objects.filter(score__gt=self.score).count() + 1
+            self.save(force_update=True)
+        return self.ranking
+
+    def get_title(self):
+        if self.title:
+            return self.title
+        if self.rank:
+            return self.rank.title
+        return None
+
+    def get_style(self):
+        if self.rank:
+            return self.rank.style
+        return ''
+
+    def email_user(self, request, template, subject, context={}):
+        templates = request.theme.get_email_templates(template)
+        context = RequestContext(request, context)
+        context['author'] = context['user']
+        context['user'] = self
+
+        # Set message recipient
+        if settings.DEBUG and settings.CATCH_ALL_EMAIL_ADDRESS:
+            recipient = settings.CATCH_ALL_EMAIL_ADDRESS
+        else:
+            recipient = self.email
+
+        # Build and send message
+        email = EmailMultiAlternatives(subject, templates[0].render(context), settings.EMAIL_HOST_USER, [recipient])
+        email.attach_alternative(templates[1].render(context), "text/html")
+        email.send()
+
+    def get_activation(self):
+        activations = ['none', 'user', 'admin', 'credentials']
+        return activations[self.activation]
+
+    def alert(self, message):
+        from misago.alerts.models import Alert
+        self.alerts += 1
+        return Alert(user=self, message=message, date=tz_util.now())
+
+    def get_date(self):
+        return self.join_date
+
+    def sync_user(self):
+        pass
+
+
+class Guest(object):
+    """
+    Misago Guest dummy
+    """
+    id = -1
+    pk = -1
+    is_team = False
+
+    def is_anonymous(self):
+        return True
+
+    def is_authenticated(self):
+        return False
+
+    def is_crawler(self):
+        return False
+
+    def get_roles(self):
+        from misago.models import Role
+        return Role.objects.filter(token='guest')
+
+    def make_acl_key(self):
+        return 'acl_guest'
+
+
+class Crawler(Guest):
+    """
+    Misago Crawler dummy
+    """
+    is_team = False
+
+    def __init__(self, username):
+        self.username = username
+
+    def is_anonymous(self):
+        return True
+
+    def is_authenticated(self):
+        return False
+
+    def is_crawler(self):
+        return True
+

+ 9 - 0
misago/models/usernamechangemodel.py

@@ -0,0 +1,9 @@
+from django.db import models
+
+class UsernameChange(models.Model):
+    user = models.ForeignKey('User', related_name='namechanges')
+    date = models.DateTimeField()
+    old_username = models.CharField(max_length=255)
+
+    class Meta:
+        app_label = 'misago'

+ 36 - 0
misago/models/watchedthreadmodel.py

@@ -0,0 +1,36 @@
+from django.db import models
+from misago.forums.signals import move_forum_content
+from misago.threads.signals import move_thread, merge_thread
+
+class WatchedThread(models.Model):
+    user = models.ForeignKey('User')
+    forum = models.ForeignKey('Forum')
+    thread = models.ForeignKey('Thread')
+    last_read = models.DateTimeField()
+    email = models.BooleanField(default=False)
+    deleted = False
+
+    class Meta:
+        app_label = 'misago'
+    
+    def save(self, *args, **kwargs):
+        if not self.deleted:
+            super(WatchedThread, self).save(*args, **kwargs)
+            
+
+def move_forum_content_handler(sender, **kwargs):
+    WatchedThread.objects.filter(forum=sender).update(forum=kwargs['move_to'])
+
+move_forum_content.connect(move_forum_content_handler, dispatch_uid="move_forum_threads_watchers")
+
+
+def move_thread_handler(sender, **kwargs):
+    WatchedThread.objects.filter(forum=sender.forum_id).update(forum=kwargs['move_to'])
+
+move_thread.connect(move_thread_handler, dispatch_uid="move_thread_watchers")
+
+
+def merge_thread_handler(sender, **kwargs):
+    WatchedThread.objects.filter(thread=sender).delete()
+
+merge_thread.connect(merge_thread_handler, dispatch_uid="merge_threads_watchers")

+ 4 - 4
misago/settings_base.py

@@ -75,7 +75,7 @@ TEMPLATE_CONTEXT_PROCESSORS = (
     'django.core.context_processors.static',
     'django.core.context_processors.tz',
     'django.contrib.messages.context_processors.messages',
-    'misago.context_processors.core',
+    'misago.context_processors.misago',
     'misago.admin.context_processors.admin',
     'misago.banning.context_processors.banning',
     'misago.messages.context_processors.messages',
@@ -94,16 +94,16 @@ JINJA2_EXTENSIONS = (
 
 # List of application middlewares
 MIDDLEWARE_CLASSES = (
-    #'misago.stopwatch.middleware.StopwatchMiddleware',
+    'misago.stopwatch.middleware.StopwatchMiddleware',
     'debug_toolbar.middleware.DebugToolbarMiddleware',
     #'misago.heartbeat.middleware.HeartbeatMiddleware',
-    #'misago.cookie_jar.middleware.CookieJarMiddleware',
+    'misago.middleware.cookiejar.CookieJarMiddleware',
     'misago.middleware.settings.SettingsMiddleware',
     'misago.middleware.monitor.MonitorMiddleware',
     #'misago.themes.middleware.ThemeMiddleware',
     #'misago.firewalls.middleware.FirewallMiddleware',
     #'misago.crawlers.middleware.DetectCrawlerMiddleware',
-    #'misago.sessions.middleware.SessionMiddleware',
+    'misago.middleware.session.SessionMiddleware',
     #'misago.bruteforce.middleware.JamMiddleware',
     #'misago.csrf.middleware.CSRFMiddleware',
     #'misago.banning.middleware.BanningMiddleware',

+ 1 - 1
misago/utils/strings.py

@@ -12,5 +12,5 @@ def slugify(string):
     return django.template.defaultfilters.slugify(string)
 
 
-def get_random_string(length):
+def random_string(length):
     return crypto.get_random_string(length, "1234567890qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM")

+ 33 - 4
refactoring.md

@@ -14,11 +14,13 @@ misago/                        App root structure
     +-forms.py                 Custom form base class
     +-layouts.py               Forums layouts (wrapper around Django forms that enables templating)
     +-widgets.py               Custom widgets
+  +-cookiejar.py               CookieJar allows for easy setting and removing cookies without direct access to response object
   +-monitor.py                 Monitor controller that tracks forum stats
   +-settings.py                DB based settings controller
   +-stopwatch.py               Stopwatch controller for measuring request processing time
 +-fixtures/                    Starting data
   +-basicsettings.py           "Basic Settings" group fixture
+  +-captchasettings.py         "Captcha Settings" group fixture
   +-usersmonitor.py            Users Monitor fixture
 +-front/                       Frontend apps
 +-management/                  manage.py commands
@@ -40,7 +42,9 @@ misago/                        App root structure
 +-__init__.py                  Misago init, contains Misago version
 +-context_processors.py        Misago context processors
 +-settingsbase.py              Base configuration
++-signals.py                   Misago's signals
 +-urls.py                      Default urls
++-validators.py                Misago's validators
 ```
 
 
@@ -66,18 +70,23 @@ Authn
 
 Banning
 -------
+* Moved Ban model to models package.
 
 
 Bruteforce
 ----------
+* Moved SignInAttempt model to models package.
 
 
 Captcha
 -------
+* Turned into module and moved to core.forms package
 
 
-Cookiejar
+CookieJar
 ---------
+* Moved controller to core package.
+* Moved middleware to middleware package.
 
 
 Crawlers
@@ -98,12 +107,14 @@ Forms
 * Split __init__.py into three modules.
 
 
-Forumroles
+ForumRoles
 ----------
+* Moved ForumRole model to models package.
 
 
 Forums
 ------
+* Moved Forum model to models package.
 
 
 Heartbeat
@@ -133,6 +144,7 @@ Newsfeed
 
 Newsletters
 -----------
+* Moved Newsletter model to models package.
 
 
 Profiles
@@ -141,14 +153,18 @@ Profiles
 
 Prune
 -----
+* Renamed "Policy" model to "PruningPolicy" and moved to models package.
 
 
 Ranks
 -----
+* Moved Rank model to models package.
 
 
 Readstracker
 ------------
+* Renamed "ForumRecord" model to "ForumRead" and moved to models package.
+* Renamed "ThreadRecord" model to "ThreadRead" and moved to models package.
 
 
 Register
@@ -161,6 +177,7 @@ ResetPswd
 
 Roles
 -----
+* Moved Role model to models package.
 
 
 Search
@@ -169,6 +186,7 @@ Search
 
 Sessions
 --------
+* Moved Session model to models package.
 
 
 Settings
@@ -180,7 +198,7 @@ Settings
 * Moved Setting model to models package.
 * Renamed "type" attribute on Setting model to "normalizes_to".
 * Renamed "input" attribute on Setting model to "field".
-* Renamed model Group to SettingsGroup and moved it to models package.
+* Renamed model "Group" to "SettingsGroup" and moved it to models package.
 
 
 Setup
@@ -217,6 +235,11 @@ Themes
 
 Threads
 -------
+* Moved Thread model to models package.
+* Moved Post model to models package.
+* Moved Karma model to models package.
+* Moved Change model to models package.
+* Moved Checkpoint model to models package.
 
 
 Timezones
@@ -230,16 +253,22 @@ ToS
 
 UserCP
 ------
+* Moved UsernameChange model to models package.
 
 
 Users
 -----
+* Moved User model to models package.
+* Moved Guest model to models package.
+* Moved Crawler model to models package.
 
 
 Utils
 -----
 * Split __init__.py module into datesformat, pagination, translation and strings modules.
+* Renamed "get_random_string" function to "random_String" in strings module.
 
 
 Watcher
--------
+-------
+* Renamed "ThreadWatch" model to "WatchedThread" and moved to models package.