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.fields import ForumChoiceField
 from misago.core.forms.forms import Form
 from misago.core.forms.forms import Form
 from misago.core.forms.layouts import FormLayout, FormFields, FormFieldsets
 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 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):
 class ForumChoiceField(TreeNodeChoiceField):
     """
     """
@@ -11,4 +13,16 @@ class ForumChoiceField(TreeNodeChoiceField):
 
 
     def _get_level_indicator(self, obj):
     def _get_level_indicator(self, obj):
         level = getattr(obj, obj._mptt_meta.level_attr)
         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 import forms
 from django.utils.translation import ugettext_lazy as _
 from django.utils.translation import ugettext_lazy as _
-from recaptcha.client.captcha import submit as recaptcha_submit
 
 
 class Form(forms.Form):
 class Form(forms.Form):
     """
     """

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

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

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

@@ -1,7 +1,8 @@
 from django import forms
 from django import forms
 
 
+class ReCaptchaWidget(forms.TextInput):
+    pass
+
+
 class YesNoSwitch(forms.CheckboxInput):
 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.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.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.settingmodel import Setting
 from misago.models.settingsgroupmodel import SettingsGroup
 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.conf import settings
 from django.core.cache import cache
 from django.core.cache import cache
 from django.db import models
 from django.db import models
 from django.utils.translation import ugettext_lazy as _
 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.forums.signals import move_forum_content, delete_forum_content
-from misago.roles.models import Role
 from misago.users.signals import rename_user
 from misago.users.signals import rename_user
 
 
 class ForumManager(models.Manager):
 class ForumManager(models.Manager):
@@ -127,11 +126,11 @@ class Forum(MPTTModel):
     posts_delta = models.IntegerField(default=0)
     posts_delta = models.IntegerField(default=0)
     redirects = models.PositiveIntegerField(default=0)
     redirects = models.PositiveIntegerField(default=0)
     redirects_delta = models.IntegerField(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_name = models.CharField(max_length=255, null=True, blank=True)
     last_thread_slug = models.SlugField(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_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_name = models.CharField(max_length=255, null=True, blank=True)
     last_poster_slug = models.SlugField(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)
     last_poster_style = models.CharField(max_length=255, null=True, blank=True)
@@ -166,6 +165,7 @@ class Forum(MPTTModel):
 
 
     def copy_permissions(self, target):
     def copy_permissions(self, target):
         if target.pk != self.pk:
         if target.pk != self.pk:
+            from misago.models import Role
             for role in Role.objects.all():
             for role in Role.objects.all():
                 perms = role.get_permissions()
                 perms = role.get_permissions()
                 try:
                 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.static',
     'django.core.context_processors.tz',
     'django.core.context_processors.tz',
     'django.contrib.messages.context_processors.messages',
     'django.contrib.messages.context_processors.messages',
-    'misago.context_processors.core',
+    'misago.context_processors.misago',
     'misago.admin.context_processors.admin',
     'misago.admin.context_processors.admin',
     'misago.banning.context_processors.banning',
     'misago.banning.context_processors.banning',
     'misago.messages.context_processors.messages',
     'misago.messages.context_processors.messages',
@@ -94,16 +94,16 @@ JINJA2_EXTENSIONS = (
 
 
 # List of application middlewares
 # List of application middlewares
 MIDDLEWARE_CLASSES = (
 MIDDLEWARE_CLASSES = (
-    #'misago.stopwatch.middleware.StopwatchMiddleware',
+    'misago.stopwatch.middleware.StopwatchMiddleware',
     'debug_toolbar.middleware.DebugToolbarMiddleware',
     'debug_toolbar.middleware.DebugToolbarMiddleware',
     #'misago.heartbeat.middleware.HeartbeatMiddleware',
     #'misago.heartbeat.middleware.HeartbeatMiddleware',
-    #'misago.cookie_jar.middleware.CookieJarMiddleware',
+    'misago.middleware.cookiejar.CookieJarMiddleware',
     'misago.middleware.settings.SettingsMiddleware',
     'misago.middleware.settings.SettingsMiddleware',
     'misago.middleware.monitor.MonitorMiddleware',
     'misago.middleware.monitor.MonitorMiddleware',
     #'misago.themes.middleware.ThemeMiddleware',
     #'misago.themes.middleware.ThemeMiddleware',
     #'misago.firewalls.middleware.FirewallMiddleware',
     #'misago.firewalls.middleware.FirewallMiddleware',
     #'misago.crawlers.middleware.DetectCrawlerMiddleware',
     #'misago.crawlers.middleware.DetectCrawlerMiddleware',
-    #'misago.sessions.middleware.SessionMiddleware',
+    'misago.middleware.session.SessionMiddleware',
     #'misago.bruteforce.middleware.JamMiddleware',
     #'misago.bruteforce.middleware.JamMiddleware',
     #'misago.csrf.middleware.CSRFMiddleware',
     #'misago.csrf.middleware.CSRFMiddleware',
     #'misago.banning.middleware.BanningMiddleware',
     #'misago.banning.middleware.BanningMiddleware',

+ 1 - 1
misago/utils/strings.py

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