Browse Source

Engine refactored, currently refactoring admin actions

Ralfp 12 years ago
parent
commit
69a82b00d8
59 changed files with 2918 additions and 84 deletions
  1. 2 2
      misago/acl/builder.py
  2. 0 0
      misago/acl/permissions/__init__.py
  3. 0 0
      misago/acl/permissions/admin.py
  4. 77 0
      misago/acl/permissions/forums.py
  5. 587 0
      misago/acl/permissions/threads.py
  6. 81 0
      misago/acl/permissions/usercp.py
  7. 60 0
      misago/acl/permissions/users.py
  8. 4 2
      misago/admin.py
  9. 3 5
      misago/auth.py
  10. 1 1
      misago/context_processors.py
  11. 0 0
      misago/core/__init__.py
  12. 0 0
      misago/core/admin/__init__.py
  13. 16 0
      misago/core/admin/home.py
  14. 15 0
      misago/core/admin/index.py
  15. 0 0
      misago/core/admin/online/__init__.py
  16. 27 0
      misago/core/admin/online/forms.py
  17. 45 0
      misago/core/admin/online/views.py
  18. 33 0
      misago/core/admin/sections/__init__.py
  19. 96 0
      misago/core/admin/sections/forums.py
  20. 73 0
      misago/core/admin/sections/overview.py
  21. 69 0
      misago/core/admin/sections/perms.py
  22. 49 0
      misago/core/admin/sections/system.py
  23. 158 0
      misago/core/admin/sections/users.py
  24. 0 0
      misago/core/admin/stats/__init__.py
  25. 28 0
      misago/core/admin/stats/forms.py
  26. 170 0
      misago/core/admin/stats/views.py
  27. 550 0
      misago/core/admin/widgets.py
  28. 0 0
      misago/core/front/__init__.py
  29. 69 0
      misago/core/front/index.py
  30. 23 0
      misago/core/front/readall.py
  31. 0 0
      misago/core/signin/__init__.py
  32. 0 0
      misago/core/signin/forms.py
  33. 2 2
      misago/core/signin/urls.py
  34. 2 3
      misago/core/signin/views.py
  35. 0 0
      misago/core/views.py
  36. 1 1
      misago/decorators.py
  37. 2 1
      misago/firewalls.py
  38. 60 0
      misago/markdown/__init__.py
  39. 0 0
      misago/markdown/extensions/__init__.py
  40. 45 0
      misago/markdown/extensions/magiclinks.py
  41. 59 0
      misago/markdown/extensions/mentions.py
  42. 58 0
      misago/markdown/extensions/quotes.py
  43. 115 0
      misago/markdown/factory.py
  44. 2 2
      misago/middleware/bruteforce.py
  45. 7 2
      misago/models/banmodel.py
  46. 1 1
      misago/models/forummodel.py
  47. 3 3
      misago/models/usermodel.py
  48. 98 0
      misago/readstrackers.py
  49. 85 0
      misago/search/__init__.py
  50. 2 2
      misago/sessions.py
  51. 19 19
      misago/settings_base.py
  52. 19 0
      misago/templatetags/datetime.py
  53. 34 0
      misago/templatetags/django2jinja.py
  54. 38 0
      misago/templatetags/markdown.py
  55. 26 25
      misago/urls.py
  56. 1 1
      misago/validators.py
  57. 3 8
      templates/admin/index.html
  58. 0 2
      templates/admin/layout_compact.html
  59. 0 2
      templates/admin/signin.html

+ 2 - 2
misago/acl/builder.py

@@ -65,10 +65,10 @@ def build_acl(request, roles):
     forum_roles = {}
 
     for role in roles:
-        perms.append(role.get_permissions())
+        perms.append(role.permissions)
 
     for role in ForumRole.objects.all():
-        forum_roles[role.pk] = role.get_permissions()
+        forum_roles[role.pk] = role.permissions
 
     for provider in settings.PERMISSION_PROVIDERS:
         app_module = import_module(provider)

+ 0 - 0
misago/front/__init__.py → misago/acl/permissions/__init__.py


+ 0 - 0
misago/admin/acl.py → misago/acl/permissions/admin.py


+ 77 - 0
misago/acl/permissions/forums.py

@@ -0,0 +1,77 @@
+from django.utils.translation import ugettext_lazy as _
+from django import forms
+from misago.acl.builder import BaseACL
+from misago.acl.exceptions import ACLError403, ACLError404
+from misago.forms import YesNoSwitch
+
+def make_forum_form(request, role, form):
+    form.base_fields['can_see_forum'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_see_forum_contents'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.layout.append((
+                        _("Forums Permissions"),
+                        (
+                         ('can_see_forum', {'label': _("Can see forum")}),
+                         ('can_see_forum_contents', {'label': _("Can see forum contents")}),
+                         ),
+                        ))
+
+
+class ForumsACL(BaseACL):
+    def known_forums(self):
+        return self.acl['can_see']
+
+    def can_see(self, forum):
+        try:
+            return forum.pk in self.acl['can_see']
+        except AttributeError:
+            return long(forum) in self.acl['can_see']
+
+    def can_browse(self, forum):
+        if self.can_see(forum):
+            try:
+                return forum.pk in self.acl['can_browse']
+            except AttributeError:
+                return long(forum) in self.acl['can_browse']
+        return False
+
+    def allow_forum_view(self, forum):
+        if not self.can_see(forum):
+            raise ACLError404()
+        if not self.can_browse(forum):
+            raise ACLError403(_("You don't have permission to browse this forum."))
+
+
+def build_forums(acl, perms, forums, forum_roles):
+    acl.forums = ForumsACL()
+    acl.forums.acl['can_see'] = []
+    acl.forums.acl['can_browse'] = []
+
+    for forum in forums:
+        for perm in perms:
+            try:
+                role = forum_roles[perm['forums'][forum.pk]]
+                if role['can_see_forum'] and forum.pk not in acl.forums.acl['can_see']:
+                    acl.forums.acl['can_see'].append(forum.pk)
+                if role['can_see_forum_contents'] and forum.pk not in acl.forums.acl['can_browse']:
+                    acl.forums.acl['can_browse'].append(forum.pk)
+            except KeyError:
+                pass
+
+
+def cleanup(acl, perms, forums):
+    for forum in forums:
+        if forum.pk in acl.forums.acl['can_browse'] and not forum.pk in acl.forums.acl['can_see']:
+            # First burp: we can read forum but we cant see forum
+            del acl.forums.acl['can_browse'][acl.forums.acl['can_browse'].index(forum.pk)]
+
+        if forum.level > 1:
+            if forum.parent_id not in acl.forums.acl['can_see'] or forum.parent_id not in acl.forums.acl['can_browse']:
+                # Second burp: we cant see or read parent forum
+                try:
+                    del acl.forums.acl['can_see'][acl.forums.acl['can_see'].index(forum.pk)]
+                except ValueError:
+                    pass
+                try:
+                    del acl.forums.acl['can_browse'][acl.forums.acl['can_browse'].index(forum.pk)]
+                except ValueError:
+                    pass

+ 587 - 0
misago/acl/permissions/threads.py

@@ -0,0 +1,587 @@
+from django import forms
+from django.db import models
+from django.db.models import Q
+from django.utils.translation import ugettext_lazy as _
+from misago.acl.builder import BaseACL
+from misago.acl.exceptions import ACLError403, ACLError404
+from misago.forms import YesNoSwitch
+
+def make_forum_form(request, role, form):
+    form.base_fields['can_read_threads'] = forms.TypedChoiceField(choices=(
+                                                                  (0, _("No")),
+                                                                  (1, _("Yes, owned")),
+                                                                  (2, _("Yes, all")),
+                                                                  ), coerce=int)
+    form.base_fields['can_start_threads'] = forms.TypedChoiceField(choices=(
+                                                                   (0, _("No")),
+                                                                   (1, _("Yes, with moderation")),
+                                                                   (2, _("Yes")),
+                                                                   ), coerce=int)
+    form.base_fields['can_edit_own_threads'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_soft_delete_own_threads'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_write_posts'] = forms.TypedChoiceField(choices=(
+                                                                 (0, _("No")),
+                                                                 (1, _("Yes, with moderation")),
+                                                                 (2, _("Yes")),
+                                                                 ), coerce=int)
+    form.base_fields['can_edit_own_posts'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_soft_delete_own_posts'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_upvote_posts'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_downvote_posts'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_see_posts_scores'] = forms.TypedChoiceField(choices=(
+                                                                      (0, _("No")),
+                                                                      (1, _("Yes, final score")),
+                                                                      (2, _("Yes, both up and down-votes")),
+                                                                      ), coerce=int)
+    form.base_fields['can_see_votes'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_make_polls'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_vote_in_polls'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_see_poll_votes'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_see_attachments'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_upload_attachments'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_download_attachments'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['attachment_size'] = forms.IntegerField(min_value=0, initial=100)
+    form.base_fields['attachment_limit'] = forms.IntegerField(min_value=0, initial=3)
+    form.base_fields['can_approve'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_edit_labels'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_see_changelog'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_pin_threads'] = forms.TypedChoiceField(choices=(
+                                                                 (0, _("No")),
+                                                                 (1, _("Yes, to stickies")),
+                                                                 (2, _("Yes, to annoucements")),
+                                                                 ), coerce=int)
+    form.base_fields['can_edit_threads_posts'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_move_threads_posts'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_close_threads'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_protect_posts'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_delete_threads'] = forms.TypedChoiceField(choices=(
+                                                                    (0, _("No")),
+                                                                    (1, _("Yes, soft-delete")),
+                                                                    (2, _("Yes, hard-delete")),
+                                                                    ), coerce=int)
+    form.base_fields['can_delete_posts'] = forms.TypedChoiceField(choices=(
+                                                                  (0, _("No")),
+                                                                  (1, _("Yes, soft-delete")),
+                                                                  (2, _("Yes, hard-delete")),
+                                                                   ), coerce=int)
+    form.base_fields['can_delete_polls'] = forms.TypedChoiceField(choices=(
+                                                                  (0, _("No")),
+                                                                  (1, _("Yes, soft-delete")),
+                                                                  (2, _("Yes, hard-delete")),
+                                                                  ), coerce=int)
+    form.base_fields['can_delete_attachments'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+
+    form.layout.append((
+                        _("Threads"),
+                        (
+                         ('can_read_threads', {'label': _("Can read threads")}),
+                         ('can_start_threads', {'label': _("Can start new threads")}),
+                         ('can_edit_own_threads', {'label': _("Can edit own threads")}),
+                         ('can_soft_delete_own_threads', {'label': _("Can soft-delete own threads")}),
+                        ),
+                       ),)
+    form.layout.append((
+                        _("Posts"),
+                        (
+                         ('can_write_posts', {'label': _("Can write posts")}),
+                         ('can_edit_own_posts', {'label': _("Can edit own posts")}),
+                         ('can_soft_delete_own_posts', {'label': _("Can soft-delete own posts")}),
+                        ),
+                       ),)
+    form.layout.append((
+                        _("Karma"),
+                        (
+                         ('can_upvote_posts', {'label': _("Can upvote posts")}),
+                         ('can_downvote_posts', {'label': _("Can downvote posts")}),
+                         ('can_see_posts_scores', {'label': _("Can see post score")}),
+                         ('can_see_votes', {'label': _("Can see who voted on post")}),
+                        ),
+                       ),)
+    form.layout.append((
+                        _("Polls"),
+                        (
+                         ('can_make_polls', {'label': _("Can make polls")}),
+                         ('can_vote_in_polls', {'label': _("Can vote in polls")}),
+                         ('can_see_poll_votes', {'label': _("Can see who voted in poll")}),
+                        ),
+                       ),)
+    form.layout.append((
+                        _("Attachments"),
+                        (
+                         ('can_see_attachments', {'label': _("Can see attachments")}),
+                         ('can_upload_attachments', {'label': _("Can upload attachments")}),
+                         ('can_download_attachments', {'label': _("Can download attachments")}),
+                         ('attachment_size', {'label': _("Max size of single attachment (in Kb)"), 'help_text': _("Enter zero for no limit.")}),
+                         ('attachment_limit', {'label': _("Max number of attachments per post"), 'help_text': _("Enter zero for no limit.")}),
+                        ),
+                       ),)
+    form.layout.append((
+                        _("Moderation"),
+                        (
+                         ('can_approve', {'label': _("Can accept threads and posts")}),
+                         ('can_edit_labels', {'label': _("Can edit thread labels")}),
+                         ('can_see_changelog', {'label': _("Can see edits history")}),
+                         ('can_make_annoucements', {'label': _("Can make annoucements")}),
+                         ('can_pin_threads', {'label': _("Can change threads weight")}),
+                         ('can_edit_threads_posts', {'label': _("Can edit threads and posts")}),
+                         ('can_move_threads_posts', {'label': _("Can move, merge and split threads and posts")}),
+                         ('can_close_threads', {'label': _("Can close threads")}),
+                         ('can_protect_posts', {'label': _("Can protect posts"), 'help_text': _("Protected posts cannot be changed by their owners.")}),
+                         ('can_delete_threads', {'label': _("Can delete threads")}),
+                         ('can_delete_posts', {'label': _("Can delete posts")}),
+                         ('can_delete_polls', {'label': _("Can delete polls")}),
+                         ('can_delete_attachments', {'label': _("Can delete attachments")}),
+                        ),
+                       ),)
+
+
+class ThreadsACL(BaseACL):
+    def get_role(self, forum):
+        try:
+            try:
+                return self.acl[forum.pk]
+            except AttributeError:
+                return self.acl[forum]
+        except KeyError:
+            return {}
+
+    def allow_thread_view(self, user, thread):
+        try:
+            forum_role = self.acl[thread.forum_id]
+            if forum_role['can_read_threads'] == 0:
+                raise ACLError403(_("You don't have permission to read threads in this forum."))
+            if forum_role['can_read_threads'] == 1 and thread.weight < 2 and thread.start_poster_id != user.id:
+                raise ACLError404()
+            if thread.moderated and not (forum_role['can_approve'] or (user.is_authenticated() and user == thread.start_poster)):
+                raise ACLError404()
+            if thread.deleted and not forum_role['can_delete_threads']:
+                raise ACLError404()
+        except KeyError:
+            raise ACLError403(_("You don't have permission to read threads in this forum."))
+
+    def allow_post_view(self, user, thread, post):
+        forum_role = self.acl[thread.forum_id]
+        if post.moderated and not (forum_role['can_approve'] or (user.is_authenticated() and user == post.user)):
+            raise ACLError404()
+        if post.deleted and not (forum_role['can_delete_posts'] or (user.is_authenticated() and user == post.user)):
+            raise ACLError404()
+
+    def get_readable_forums(self, acl):
+        readable = []
+        for forum in self.acl:
+            if acl.forums.can_browse(forum) and self.acl[forum]['can_read_threads']:
+                readable.append(forum)
+        return readable
+
+    def filter_threads(self, request, forum, queryset):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_approve']:
+                if request.user.is_authenticated():
+                    queryset = queryset.filter(Q(moderated=0) | Q(start_poster=request.user))
+                else:
+                    queryset = queryset.filter(moderated=0)
+            if forum_role['can_read_threads'] == 1:
+                queryset = queryset.filter(Q(weight=2) | Q(start_poster_id=request.user.id))
+            if not forum_role['can_delete_threads']:
+                queryset = queryset.filter(deleted=False)
+        except KeyError:
+            return False
+        return queryset
+
+    def filter_posts(self, request, thread, queryset):
+        try:
+            forum_role = self.acl[thread.forum.pk]
+            if not forum_role['can_approve']:
+                if request.user.is_authenticated():
+                    queryset = queryset.filter(Q(moderated=0) | Q(user=request.user))
+                else:
+                    queryset = queryset.filter(moderated=0)
+        except KeyError:
+            return False
+        return queryset
+
+    def can_start_threads(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            if forum_role['can_read_threads'] == 0 or forum_role['can_start_threads'] == 0:
+                return False
+            if forum.closed and forum_role['can_close_threads'] == 0:
+                return False
+            return True
+        except KeyError:
+            return False
+
+    def allow_new_threads(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            if forum_role['can_read_threads'] == 0 or forum_role['can_start_threads'] == 0:
+                raise ACLError403(_("You don't have permission to start new threads in this forum."))
+            if forum.closed and forum_role['can_close_threads'] == 0:
+                raise ACLError403(_("This forum is closed, you can't start new threads in it."))
+        except KeyError:
+            raise ACLError403(_("You don't have permission to start new threads in this forum."))
+
+    def can_edit_thread(self, user, forum, thread, post):
+        try:
+            forum_role = self.acl[thread.forum_id]
+            if forum_role['can_close_threads'] == 0 and (forum.closed or thread.closed):
+                return False
+            if forum_role['can_edit_threads_posts']:
+                return True
+            if forum_role['can_edit_own_threads'] and not post.protected and post.user_id == user.pk:
+                return True
+            return False
+        except KeyError:
+            return False
+
+    def allow_thread_edit(self, user, forum, thread, post):
+        try:
+            forum_role = self.acl[thread.forum_id]
+            if thread.deleted or post.deleted:
+                self.allow_deleted_post_view(forum)
+            if not forum_role['can_close_threads']:
+                if forum.closed:
+                    raise ACLError403(_("You can't edit threads in closed forums."))
+                if thread.closed:
+                    raise ACLError403(_("You can't edit closed threads."))
+            if not forum_role['can_edit_threads_posts']:
+                if post.user_id != user.pk:
+                    raise ACLError403(_("You can't edit other members threads."))
+                if not forum_role['can_edit_own_threads']:
+                    raise ACLError403(_("You can't edit your threads."))
+                if post.protected:
+                    raise ACLError403(_("This thread is protected, you cannot edit it."))
+        except KeyError:
+            raise ACLError403(_("You don't have permission to edit threads in this forum."))
+
+    def can_reply(self, forum, thread):
+        try:
+            forum_role = self.acl[forum.pk]
+            if forum_role['can_write_posts'] == 0:
+                return False
+            if (forum.closed or thread.closed) and forum_role['can_close_threads'] == 0:
+                return False
+            return True
+        except KeyError:
+            return False
+
+    def allow_reply(self, forum, thread):
+        try:
+            forum_role = self.acl[thread.forum.pk]
+            if forum_role['can_write_posts'] == 0:
+                raise ACLError403(_("You don't have permission to write replies in this forum."))
+            if forum_role['can_close_threads'] == 0:
+                if forum.closed:
+                    raise ACLError403(_("You can't write replies in closed forums."))
+                if thread.closed:
+                    raise ACLError403(_("You can't write replies in closed threads."))
+        except KeyError:
+            raise ACLError403(_("You don't have permission to write replies in this forum."))
+
+    def can_edit_reply(self, user, forum, thread, post):
+        try:
+            forum_role = self.acl[thread.forum_id]
+            if forum_role['can_close_threads'] == 0 and (forum.closed or thread.closed):
+                return False
+            if forum_role['can_edit_threads_posts']:
+                return True
+            if forum_role['can_edit_own_posts'] and not post.protected and post.user_id == user.pk:
+                return True
+            return False
+        except KeyError:
+            return False
+
+    def allow_reply_edit(self, user, forum, thread, post):
+        try:
+            forum_role = self.acl[thread.forum_id]
+            if thread.deleted or post.deleted:
+                self.allow_deleted_post_view(forum)
+            if not forum_role['can_close_threads']:
+                if forum.closed:
+                    raise ACLError403(_("You can't edit replies in closed forums."))
+                if thread.closed:
+                    raise ACLError403(_("You can't edit replies in closed threads."))
+            if not forum_role['can_edit_threads_posts']:
+                if post.user_id != user.pk:
+                    raise ACLError403(_("You can't edit other members replies."))
+                if not forum_role['can_edit_own_posts']:
+                    raise ACLError403(_("You can't edit your replies."))
+                if post.protected:
+                    raise ACLError403(_("This reply is protected, you cannot edit it."))
+        except KeyError:
+            raise ACLError403(_("You don't have permission to edit replies in this forum."))
+
+    def can_see_changelog(self, user, forum, post):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_see_changelog'] or user.pk == post.user_id
+        except KeyError:
+            return False
+
+    def allow_changelog_view(self, user, forum, post):
+        try:
+            forum_role = self.acl[forum.pk]
+            if post.thread.deleted or post.deleted:
+                self.allow_deleted_post_view(forum)
+            if not (forum_role['can_see_changelog'] or user.pk == post.user_id):
+                raise ACLError403(_("You don't have permission to see history of changes made to this post."))
+        except KeyError:
+            raise ACLError403(_("You don't have permission to see history of changes made to this post."))
+
+    def can_make_revert(self, forum, thread):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_close_threads'] and (forum.closed or thread.closed):
+                return False
+            return forum_role['can_edit_threads_posts']
+        except KeyError:
+            return False
+
+    def allow_revert(self, forum, thread):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_close_threads']:
+                if forum.closed:
+                    raise ACLError403(_("You can't make reverts in closed forums."))
+                if thread.closed:
+                    raise ACLError403(_("You can't make reverts in closed threads."))
+            if not forum_role['can_edit_threads_posts']:
+                raise ACLError403(_("You don't have permission to make reverts in this forum."))
+        except KeyError:
+            raise ACLError403(_("You don't have permission to make reverts in this forum."))
+
+    def can_mod_threads(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return (
+                    forum_role['can_approve']
+                    or forum_role['can_pin_threads']
+                    or forum_role['can_move_threads_posts']
+                    or forum_role['can_close_threads']
+                    or forum_role['can_delete_threads']
+                    )
+        except KeyError:
+            return False
+
+    def can_mod_posts(self, thread):
+        try:
+            forum_role = self.acl[thread.forum.pk]
+            return (
+                    forum_role['can_edit_threads_posts']
+                    or forum_role['can_move_threads_posts']
+                    or forum_role['can_close_threads']
+                    or forum_role['can_delete_threads']
+                    or forum_role['can_delete_posts']
+                    )
+        except KeyError:
+            return False
+
+    def can_approve(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_approve']
+        except KeyError:
+            return False
+
+    def can_protect(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_protect_posts']
+        except KeyError:
+            return False
+
+    def can_delete_thread(self, user, forum, thread, post):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_close_threads'] and (forum.closed or thread.closed):
+                return False
+            if post.protected and not forum_role['can_protect_posts']:
+                return False
+            if forum_role['can_delete_threads']:
+                return forum_role['can_delete_threads']
+            if thread.start_poster_id == user.pk and forum_role['can_soft_delete_own_threads']:
+                return 1
+            return False
+        except KeyError:
+            return False
+
+    def allow_delete_thread(self, user, forum, thread, post, delete=False):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_close_threads']:
+                if forum.closed:
+                    raise ACLError403(_("You don't have permission to delete threads in closed forum."))
+                if thread.closed:
+                    raise ACLError403(_("This thread is closed, you cannot delete it."))
+            if post.protected and not forum_role['can_protect_posts']:
+                raise ACLError403(_("This post is protected, you cannot delete it."))
+            if delete and forum_role['can_delete_threads'] < 2:
+                raise ACLError403(_("You cannot hard delete this thread."))
+            if not (forum_role['can_delete_threads'] or (thread.start_poster_id == user.pk and forum_role['can_soft_delete_own_threads'])):
+                raise ACLError403(_("You don't have permission to delete this thread."))
+            if thread.deleted and not delete:
+                raise ACLError403(_("This thread is already deleted."))
+        except KeyError:
+            raise ACLError403(_("You don't have permission to delete this thread."))
+
+    def can_delete_post(self, user, forum, thread, post):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_close_threads'] and (forum.closed or thread.closed):
+                return False
+            if post.protected and not forum_role['can_protect_posts']:
+                return False
+            if forum_role['can_delete_posts']:
+                return forum_role['can_delete_posts']
+            if post.user_id == user.pk and not post.protected and forum_role['can_soft_delete_own_posts']:
+                return 1
+            return False
+        except KeyError:
+            return False
+
+    def allow_delete_post(self, user, forum, thread, post, delete=False):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_close_threads']:
+                if forum.closed:
+                    raise ACLError403(_("You don't have permission to delete posts in closed forum."))
+                if thread.closed:
+                    raise ACLError403(_("This thread is closed, you cannot delete its posts."))
+            if post.protected and not forum_role['can_protect_posts']:
+                raise ACLError403(_("This post is protected, you cannot delete it."))
+            if delete and forum_role['can_delete_posts'] < 2:
+                raise ACLError403(_("You cannot hard delete this post."))
+            if not (forum_role['can_delete_posts'] or (post.user_id == user.pk and forum_role['can_soft_delete_own_posts'])):
+                raise ACLError403(_("You don't have permission to delete this post."))
+            if post.deleted and not delete:
+                raise ACLError403(_("This post is already deleted."))
+        except KeyError:
+            raise ACLError403(_("You don't have permission to delete this post."))
+
+    def can_see_deleted_threads(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_delete_threads']
+        except KeyError:
+            return False
+
+    def can_see_deleted_posts(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_delete_posts']
+        except KeyError:
+            return False
+
+    def allow_deleted_post_view(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_delete_posts']:
+                raise ACLError404()
+        except KeyError:
+            raise ACLError404()
+        
+    def can_upvote_posts(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_upvote_posts']
+        except KeyError:
+            return False
+        
+    def can_downvote_posts(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_downvote_posts']
+        except KeyError:
+            return False
+        
+    def can_see_post_score(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_see_posts_scores']
+        except KeyError:
+            return False
+        
+    def can_see_post_votes(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_see_votes']
+        except KeyError:
+            return False
+
+    def allow_post_upvote(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_upvote_posts']:
+                raise ACLError403(_("You cannot upvote posts in this forum."))
+        except KeyError:
+            raise ACLError403(_("You cannot upvote posts in this forum."))
+
+    def allow_post_downvote(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_downvote_posts']:
+                raise ACLError403(_("You cannot downvote posts in this forum."))
+        except KeyError:
+            raise ACLError403(_("You cannot downvote posts in this forum."))
+        
+    def allow_post_votes_view(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_see_votes']:
+                raise ACLError403(_("You don't have permission to see who voted on this post."))
+        except KeyError:
+            raise ACLError403(_("You don't have permission to see who voted on this post."))
+
+
+def build_forums(acl, perms, forums, forum_roles):
+    acl.threads = ThreadsACL()
+    for forum in forums:
+        forum_role = {
+                     'can_read_threads': 0,
+                     'can_start_threads': 0,
+                     'can_edit_own_threads': False,
+                     'can_soft_delete_own_threads': False,
+                     'can_write_posts': 0,
+                     'can_edit_own_posts': False,
+                     'can_soft_delete_own_posts': False,
+                     'can_upvote_posts': False,
+                     'can_downvote_posts': False,
+                     'can_see_posts_scores': 0,
+                     'can_see_votes': False,
+                     'can_make_polls': False,
+                     'can_vote_in_polls': False,
+                     'can_see_poll_votes': False,
+                     'can_see_attachments': False,
+                     'can_upload_attachments': False,
+                     'can_download_attachments': False,
+                     'attachment_size': 100,
+                     'attachment_limit': 3,
+                     'can_approve': False,
+                     'can_edit_labels': False,
+                     'can_see_changelog': False,
+                     'can_make_annoucements': False,
+                     'can_pin_threads': 0,
+                     'can_edit_threads_posts': False,
+                     'can_move_threads_posts': False,
+                     'can_close_threads': False,
+                     'can_protect_posts': False,
+                     'can_delete_threads': 0,
+                     'can_delete_posts': 0,
+                     'can_delete_polls': 0,
+                     'can_delete_attachments': False,
+                     }
+        for perm in perms:
+            try:
+                role = forum_roles[perm['forums'][forum.pk]]
+                for p in forum_role:
+                    try:
+                        if p in ['attachment_size', 'attachment_limit'] and role[p] == 0:
+                            forum_role[p] = 0
+                        elif role[p] > forum_role[p]:
+                            forum_role[p] = role[p]
+                    except KeyError:
+                        pass
+            except KeyError:
+                pass
+        acl.threads.acl[forum.pk] = forum_role

+ 81 - 0
misago/acl/permissions/usercp.py

@@ -0,0 +1,81 @@
+from datetime import timedelta
+from django import forms
+from django.utils.translation import ugettext_lazy as _
+from django.utils import timezone
+from misago.acl.builder import BaseACL
+from misago.forms import YesNoSwitch
+
+def make_form(request, role, form):
+    if role.token != 'guest':
+        form.base_fields['name_changes_allowed'] = forms.IntegerField(min_value=0, initial=1)
+        form.base_fields['changes_expire'] = forms.IntegerField(min_value=0, initial=0)
+        form.base_fields['can_use_signature'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+        form.base_fields['allow_signature_links'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+        form.base_fields['allow_signature_images'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+        form.layout.append((
+                            _("Profile Settings"),
+                            (
+                             ('name_changes_allowed', {'label': _("Allowed Username changes number"), 'help_text': _("Enter zero to don't allow users with this role to change their names.")}),
+                             ('changes_expire', {'label': _("Don't count username changes older than"), 'help_text': _("Number of days since name change that makes that change no longer count to limit. For example, if you enter 7 days and set changes limit 3, users with this rank will not be able to make more than three changes in duration of 7 days. Enter zero to make all changes count.")}),
+                             ('can_use_signature', {'label': _("Can have signature")}),
+                             ('allow_signature_links', {'label': _("Can put links in signature")}),
+                             ('allow_signature_images', {'label': _("Can put images in signature")}),
+                             ),
+                            ))
+
+
+class UserCPACL(BaseACL):
+    def show_username_change(self):
+        return self.acl['name_changes_allowed'] > 0
+
+    def changes_expire(self):
+        return self.acl['changes_expire'] > 0
+
+    def changes_left(self, user):
+        if not self.acl['name_changes_allowed']:
+            return 0
+
+        if self.acl['changes_expire']:
+            changes_left = self.acl['name_changes_allowed'] - user.namechanges.filter(
+                                                    date__gte=timezone.now() - timedelta(days=self.acl['changes_expire']),
+                                                    ).count()
+        else:
+            changes_left = self.acl['name_changes_allowed'] - user.namechanges.all().count()
+
+        if changes_left:
+            return changes_left
+        return 0
+
+    def can_use_signature(self):
+        return self.acl['signature']
+
+    def allow_signature_links(self):
+        return self.acl['signature_links']
+
+    def allow_signature_images(self):
+        return self.acl['signature_images']
+
+
+def build(acl, roles):
+    acl.usercp = UserCPACL()
+    acl.usercp.acl['name_changes_allowed'] = 0
+    acl.usercp.acl['changes_expire'] = 0
+    acl.usercp.acl['signature'] = False
+    acl.usercp.acl['signature_links'] = False
+    acl.usercp.acl['signature_images'] = False
+
+    for role in roles:
+        if 'name_changes_allowed' in role and role['name_changes_allowed'] > acl.usercp.acl['name_changes_allowed']:
+            acl.usercp.acl['name_changes_allowed'] = role['name_changes_allowed']
+
+        if 'changes_expire' in role and role['changes_expire'] > acl.usercp.acl['changes_expire']:
+            acl.usercp.acl['changes_expire'] = role['changes_expire']
+
+        if 'can_use_signature' in role and role['can_use_signature']:
+            acl.usercp.acl['signature'] = role['can_use_signature']
+
+        if 'allow_signature_links' in role and role['allow_signature_links']:
+            acl.usercp.acl['signature_links'] = role['allow_signature_links']
+
+        if 'allow_signature_images' in role and role['allow_signature_images']:
+            acl.usercp.acl['signature_images'] = role['allow_signature_images']

+ 60 - 0
misago/acl/permissions/users.py

@@ -0,0 +1,60 @@
+from django import forms
+from django.utils.translation import ugettext_lazy as _
+from misago.acl.builder import BaseACL
+from misago.acl.exceptions import ACLError404
+from misago.forms import YesNoSwitch
+
+def make_form(request, role, form):
+    form.base_fields['can_search_users'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_see_users_emails'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_see_users_trails'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    form.base_fields['can_see_hidden_users'] = forms.BooleanField(widget=YesNoSwitch, initial=False, required=False)
+    
+    form.layout.append((
+                        _("User Profiles"),
+                        (
+                         ('can_search_users', {'label': _("Can search user profiles")}),
+                         ('can_see_users_emails', {'label': _("Can see members e-mail's")}),
+                         ('can_see_users_trails', {'label': _("Can see members ip's and user-agents")}),
+                         ('can_see_hidden_users', {'label': _("Can see mebers that hide their presence")}),
+                         ),
+                        ))
+
+
+class UsersACL(BaseACL):
+    def can_search_users(self):
+        return self.acl['can_search_users']
+    
+    def can_see_users_emails(self):
+        return self.acl['can_see_users_emails']
+
+    def can_see_users_trails(self):
+        return self.acl['can_see_users_trails']
+
+    def can_see_hidden_users(self):
+        return self.acl['can_see_hidden_users']
+    
+    def allow_details_view(self):
+        if not self.acl['can_see_users_trails']:
+            raise ACLError404()
+
+
+def build(acl, roles):
+    acl.users = UsersACL()
+    acl.users.acl['can_search_users'] = False
+    acl.users.acl['can_see_users_emails'] = False
+    acl.users.acl['can_see_users_trails'] = False
+    acl.users.acl['can_see_hidden_users'] = False
+
+    for role in roles:
+        if 'can_search_users' in role and role['can_search_users']:
+            acl.users.acl['can_search_users'] = True
+
+        if 'can_see_users_emails' in role and role['can_see_users_emails']:
+            acl.users.acl['can_see_users_emails'] = True
+
+        if 'can_see_users_trails' in role and role['can_see_users_trails']:
+            acl.users.acl['can_see_users_trails'] = True
+
+        if 'can_see_hidden_users' in role and role['can_see_hidden_users']:
+            acl.users.acl['can_see_hidden_users'] = True

+ 4 - 2
misago/admin/__init__.py → misago/admin.py

@@ -147,13 +147,13 @@ class AdminSite(object):
         late_actions = []
 
         # Load default admin site
-        from misago.admin.layout.sections import ADMIN_SECTIONS
+        from misago.core.admin.sections import ADMIN_SECTIONS
         for section in ADMIN_SECTIONS:
             self.sections.append(section)
             self.sections_index[section.id] = section
 
             # Loop section actions
-            section_actions = import_module('misago.admin.layout.%s' % section.id)
+            section_actions = import_module('misago.core.admin.sections.%s' % section.id)
             for action in section_actions.ADMIN_ACTIONS:
                 self.actions_index[action.id] = action
                 if not action.after:
@@ -206,11 +206,13 @@ class AdminSite(object):
         # Return ready admin routing
         first_section = True
         for section in self.sections:
+            print section.get_routes()
             if first_section:
                 self.routes += patterns('', url('^', include(section.get_routes())))
                 first_section = False
             else:
                 self.routes += patterns('', url(('^%s/' % section.id), include(section.get_routes())))
+        
         return self.routes
 
     def get_action(self, action):

+ 3 - 5
misago/auth.py

@@ -2,10 +2,7 @@ from datetime import timedelta
 from django.conf import settings
 from django.utils import timezone
 from django.utils.translation import ugettext_lazy as _
-from misago.banning.models import check_ban
-from misago.bruteforce.models import SignInAttempt
-from misago.sessions.models import Token
-from misago.users.models import User
+from misago.models import Ban, SignInAttempt, Token, User
 
 """
 Exception constants
@@ -58,7 +55,7 @@ def auth_forum(request, email, password):
     Forum auth - check bans and if we are in maintenance - maintenance access
     """
     user = get_user(email, password)
-    user_ban = check_ban(username=user.username, email=user.email)
+    user_ban = Ban.objects.check_ban(username=user.username, email=user.email)
     if user_ban:
         if user_ban.reason_user:
             raise AuthException(BANNED, _("Your account has been banned for following reason:"), ban=user_ban)
@@ -80,6 +77,7 @@ def auth_remember(request, ip):
         cookie_token = request.COOKIES[cookie_token]
         if len(cookie_token) != 42:
             raise AuthException()
+            
         try:
             token_rk = Token.objects.select_related().get(pk=cookie_token)
         except Token.DoesNotExist:

+ 1 - 1
misago/context_processors.py

@@ -25,4 +25,4 @@ def common(request):
 
 
 def admin(request):
-    site.get_admin_navigation(request)
+    return site.get_admin_navigation(request)

+ 0 - 0
misago/shared/__init__.py → misago/core/__init__.py


+ 0 - 0
misago/shared/signin/__init__.py → misago/core/admin/__init__.py


+ 16 - 0
misago/core/admin/home.py

@@ -0,0 +1,16 @@
+from django.template import RequestContext
+from misago.models import Session
+
+def home(request):
+    print 'BAZONGA!'
+    return request.theme.render_to_response('home.html', {
+        'users': request.monitor['users'],
+        'users_inactive': request.monitor['users_inactive'],
+        'threads': request.monitor['threads'],
+        'posts': request.monitor['posts'],
+        'admins': Session.objects.filter(user__isnull=False).filter(admin=1).order_by('user__username_slug').select_related('user'),
+        }, context_instance=RequestContext(request));
+
+
+def todo(request):
+    return request.theme.render_to_response('todo.html', context_instance=RequestContext(request));

+ 15 - 0
misago/core/admin/index.py

@@ -0,0 +1,15 @@
+from django.template import RequestContext
+from misago.models import Session
+
+def index(request):
+    return request.theme.render_to_response('index.html', {
+        'users': request.monitor['users'],
+        'users_inactive': request.monitor['users_inactive'],
+        'threads': request.monitor['threads'],
+        'posts': request.monitor['posts'],
+        'admins': Session.objects.filter(user__isnull=False).filter(admin=1).order_by('user__username_slug').select_related('user'),
+        }, context_instance=RequestContext(request));
+
+
+def todo(request, *args, **kwargs):
+    return request.theme.render_to_response('todo.html', context_instance=RequestContext(request));

+ 0 - 0
misago/core/admin/online/__init__.py


+ 27 - 0
misago/core/admin/online/forms.py

@@ -0,0 +1,27 @@
+from django import forms
+from django.utils.translation import ugettext_lazy as _
+from misago.forms import Form
+
+class SearchSessionsForm(Form):
+    username = forms.CharField(max_length=255, required=False)
+    ip_address = forms.CharField(max_length=255, required=False)
+    useragent = forms.CharField(max_length=255, required=False)
+    type = forms.ChoiceField(choices=(
+                                      ('all', _("All types")),
+                                      ('registered', _("Registered Members Sessions")),
+                                      ('hidden', _("Hidden Sessions")),
+                                      ('guest', _("Guests Sessions")),
+                                      ('crawler', _("Crawler Sessions")),
+                                      ), required=False)
+
+    layout = (
+              (
+               _("Search Sessions"),
+               (
+                ('ip_address', {'label': _("IP Address"), 'attrs': {'placeholder': _("IP begins with...")}}),
+                ('username', {'label': _("Username"), 'attrs': {'placeholder': _("Username begings with...")}}),
+                ('useragent', {'label': _("User Agent"), 'attrs': {'placeholder': _("User Agent contains...")}}),
+                ('type', {'label': _("Session Type")}),
+               ),
+              ),
+             )

+ 45 - 0
misago/core/admin/online/views.py

@@ -0,0 +1,45 @@
+from django.utils.translation import ugettext as _
+from misago.admin import site
+from misago.core.admin.widgets import ListWidget
+from misago.core.admin.online.forms import SearchSessionsForm
+
+class List(ListWidget):
+    admin = site.get_action('online')
+    id = 'list'
+    columns = (
+               ('owner', _("Session Owner")),
+               ('start', _("Session Start"), 25),
+               ('last', _("Last Click"), 25),
+               )
+    default_sorting = 'start'
+    sortables = {
+                 'start': 0,
+                 'last': 0,
+                }
+    hide_actions = True
+    pagination = 50
+    search_form = SearchSessionsForm
+    empty_message = _('Looks like nobody is currently online on forums.')
+
+    def set_filters(self, model, filters):
+        if 'username' in filters:
+            model = model.filter(user__username__istartswith=filters['username'])
+        if 'ip_address' in filters:
+            model = model.filter(ip__startswith=filters['ip_address'])
+        if 'useragent' in filters:
+            model = model.filter(agent__icontains=filters['useragent'])
+        if filters['type'] == 'registered':
+            model = model.filter(user__isnull=False)
+        if filters['type'] == 'hidden':
+            model = model.filter(hidden=True)
+        if filters['type'] == 'guest':
+            model = model.filter(user__isnull=True)
+        if filters['type'] == 'crawler':
+            model = model.filter(crawler__isnull=False)
+        return model
+
+    def prefetch_related(self, items):
+        return items.prefetch_related('user')
+
+    def select_items(self, items):
+        return items.filter(matched=1).filter(admin=0)

+ 33 - 0
misago/core/admin/sections/__init__.py

@@ -0,0 +1,33 @@
+from django.utils.translation import ugettext_lazy as _
+from misago.admin import AdminSection
+
+ADMIN_SECTIONS = (
+    AdminSection(
+                 id='overview',
+                 name=_("Overview"),
+                 icon='signal',
+                 ),
+)
+
+"""
+AdminSection(
+             id='users',
+             name=_("Users"),
+             icon='user',
+             ),
+AdminSection(
+             id='forums',
+             name=_("Forums"),
+             icon='comment',
+             ),
+AdminSection(
+             id='perms',
+             name=_("Permissions"),
+             icon='adjust',
+             ),
+AdminSection(
+             id='system',
+             name=_("System"),
+             icon='cog',
+             ),
+"""

+ 96 - 0
misago/core/admin/sections/forums.py

@@ -0,0 +1,96 @@
+from django.conf.urls import patterns, include, url
+from django.utils.translation import ugettext_lazy as _
+from misago.admin import AdminAction
+from misago.models import Forum
+
+ADMIN_ACTIONS = (
+   AdminAction(
+               section='forums',
+               id='forums',
+               name=_("Forums List"),
+               help=_("Create, edit and delete forums."),
+               icon='comment',
+               model=Forum,
+               actions=[
+                        {
+                         'id': 'list',
+                         'name': _("Forums List"),
+                         'help': _("All existing forums"),
+                         'route': 'admin_forums'
+                         },
+                        {
+                         'id': 'new_category',
+                         'name': _("New Category"),
+                         'help': _("Create new category"),
+                         'route': 'admin_forums_new_category'
+                         },
+                        {
+                         'id': 'new_forum',
+                         'name': _("New Forum"),
+                         'help': _("Create new forum"),
+                         'route': 'admin_forums_new_forum'
+                         },
+                        {
+                         'id': 'new_redirect',
+                         'name': _("New Redirect"),
+                         'help': _("Create new redirect"),
+                         'route': 'admin_forums_new_redirect'
+                         },
+                        ],
+               route='admin_forums',
+               urlpatterns=patterns('misago.forums.views',
+                        url(r'^$', 'List', name='admin_forums'),
+                        url(r'^new/category/$', 'NewCategory', name='admin_forums_new_category'),
+                        url(r'^new/forum/$', 'NewForum', name='admin_forums_new_forum'),
+                        url(r'^new/redirect/$', 'NewRedirect', name='admin_forums_new_redirect'),
+                        url(r'^up/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Up', name='admin_forums_up'),
+                        url(r'^down/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Down', name='admin_forums_down'),
+                        url(r'^edit/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Edit', name='admin_forums_edit'),
+                        url(r'^delete/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Delete', name='admin_forums_delete'),
+                    ),
+               ),
+   AdminAction(
+               section='forums',
+               id='labels',
+               name=_("Thread Labels"),
+               help=_("Thread Labels allow you to group threads together within forums."),
+               icon='tags',
+               route='admin_forums_labels',
+               urlpatterns=patterns('misago.admin.views',
+                        url(r'^$', 'todo', name='admin_forums_labels'),
+                    ),
+               ),
+   AdminAction(
+               section='forums',
+               id='badwords',
+               name=_("Words Filter"),
+               help=_("Forbid usage of words in messages"),
+               icon='volume-off',
+               route='admin_forums_badwords',
+               urlpatterns=patterns('misago.admin.views',
+                        url(r'^$', 'todo', name='admin_forums_badwords'),
+                    ),
+               ),
+   AdminAction(
+               section='forums',
+               id='tests',
+               name=_("Tests"),
+               help=_("Tests that new messages have to pass"),
+               icon='filter',
+               route='admin_forums_tests',
+               urlpatterns=patterns('misago.admin.views',
+                        url(r'^$', 'todo', name='admin_forums_tests'),
+                    ),
+               ),
+   AdminAction(
+               section='forums',
+               id='attachments',
+               name=_("Attachments"),
+               help=_("Manage allowed attachment types."),
+               icon='download-alt',
+               route='admin_forums_attachments',
+               urlpatterns=patterns('misago.admin.views',
+                        url(r'^$', 'todo', name='admin_forums_attachments'),
+                    ),
+               ),
+)

+ 73 - 0
misago/core/admin/sections/overview.py

@@ -0,0 +1,73 @@
+from django.conf.urls import patterns, include, url
+from django.utils.translation import ugettext_lazy as _
+from misago.admin import AdminAction
+from misago.models import Session, User
+
+ADMIN_ACTIONS = (
+   AdminAction(
+               section='overview',
+               id='index',
+               name=_("Home"),
+               help=_("Your forums right now"),
+               icon='home',
+               route='admin_home',
+               urlpatterns=patterns('misago.core.admin.index',
+                        url(r'^$', 'index', name='admin_home'),
+                    ),
+               ),
+    AdminAction(
+               section='overview',
+               id='stats',
+               name=_("Stats"),
+               help=_("Create Statistics Reports"),
+               icon='signal',
+               route='admin_stats',
+               urlpatterns=patterns('misago.core.admin.stats.views',
+                        url(r'^$', 'form', name='admin_stats'),
+                        url(r'^(?P<model>[a-z0-9]+)/(?P<date_start>[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9])/(?P<date_end>[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9])/(?P<precision>\w+)$', 'graph', name='admin_stats_graph'),
+                    ),
+               ),
+    AdminAction(
+               section='overview',
+               id='online',
+               name=_("Online"),
+               help=_("See who is currently online on forums."),
+               icon='fire',
+               model=Session,
+               actions=[
+                        {
+                         'id': 'list',
+                         'name': _("Browse Users"),
+                         'help': _("Browse all registered user accounts"),
+                         'route': 'admin_online'
+                         },
+                        ],
+               route='admin_online',
+               urlpatterns=patterns('misago.core.admin.online.views',
+                        url(r'^$', 'List', name='admin_online'),
+                        url(r'^(?P<page>\d+)/$', 'List', name='admin_online'),
+                    ),
+               ),
+)
+"""
+AdminAction(
+           section='overview',
+           id='team',
+           name=_("Forum Team"),
+           help=_("List of all forum team members"),
+           icon='user',
+           model=User,
+           actions=[
+                    {
+                     'id': 'list',
+                     'name': _("Forum Team Members"),
+                     'help': _("List of all forum team members"),
+                     'route': 'admin_team'
+                     },
+                    ],
+           route='admin_team',
+           urlpatterns=patterns('misago.team.views',
+                    url(r'^$', 'List', name='admin_team'),
+                ),
+           ),
+"""

+ 69 - 0
misago/core/admin/sections/perms.py

@@ -0,0 +1,69 @@
+from django.conf.urls import patterns, include, url
+from django.utils.translation import ugettext_lazy as _
+from misago.admin import AdminAction
+from misago.models import ForumRole, Role
+
+
+ADMIN_ACTIONS = (
+   AdminAction(
+               section='perms',
+               id='roles',
+               name=_("User Roles"),
+               help=_("Manage User Roles"),
+               icon='th-large',
+               model=Role,
+               actions=[
+                        {
+                         'id': 'list',
+                         'name': _("Browse Roles"),
+                         'help': _("Browse all existing roles"),
+                         'route': 'admin_roles'
+                         },
+                        {
+                         'id': 'new',
+                         'name': _("Add Role"),
+                         'help': _("Create new role"),
+                         'route': 'admin_roles_new'
+                         },
+                        ],
+               route='admin_roles',
+               urlpatterns=patterns('misago.roles.views',
+                        url(r'^$', 'List', name='admin_roles'),
+                        url(r'^new/$', 'New', name='admin_roles_new'),
+                        url(r'^forums/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Forums', name='admin_roles_masks'),
+                        url(r'^acl/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'ACL', name='admin_roles_acl'),
+                        url(r'^edit/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Edit', name='admin_roles_edit'),
+                        url(r'^delete/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Delete', name='admin_roles_delete'),
+                    ),
+               ),
+   AdminAction(
+               section='perms',
+               id='roles_forums',
+               name=_("Forum Roles"),
+               help=_("Manage Forum Roles"),
+               icon='th-list',
+               model=ForumRole,
+               actions=[
+                        {
+                         'id': 'list',
+                         'name': _("Browse Roles"),
+                         'help': _("Browse all existing roles"),
+                         'route': 'admin_roles_forums'
+                         },
+                        {
+                         'id': 'new',
+                         'name': _("Add Role"),
+                         'help': _("Create new role"),
+                         'route': 'admin_roles_forums_new'
+                         },
+                        ],
+               route='admin_roles_forums',
+               urlpatterns=patterns('misago.forumroles.views',
+                        url(r'^$', 'List', name='admin_roles_forums'),
+                        url(r'^new/$', 'New', name='admin_roles_forums_new'),
+                        url(r'^acl/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'ACL', name='admin_roles_forums_acl'),
+                        url(r'^edit/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Edit', name='admin_roles_forums_edit'),
+                        url(r'^delete/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Delete', name='admin_roles_forums_delete'),
+                    ),
+               ),
+)

+ 49 - 0
misago/core/admin/sections/system.py

@@ -0,0 +1,49 @@
+from django.conf.urls import patterns, include, url
+from django.utils.translation import ugettext_lazy as _
+from misago.admin import AdminAction
+from misago.models import ThemeAdjustment
+
+ADMIN_ACTIONS = (
+   AdminAction(
+               section='system',
+               id='settings',
+               name=_("Settings"),
+               help=_("Change your forum configuration"),
+               icon='wrench',
+               route='admin_settings',
+               urlpatterns=patterns('misago.settings.views',
+                        url(r'^$', 'settings', name='admin_settings'),
+                        url(r'^search/$', 'settings_search', name='admin_settings_search'),
+                        url(r'^(?P<group_slug>([a-z0-9]|-)+)-(?P<group_id>\d+)/$', 'settings', name='admin_settings')
+                    ),
+               ),
+   AdminAction(
+               section='system',
+               id='clients',
+               name=_("Clients"),
+               help=_("Adjust presentation layer to clients"),
+               icon='tint',
+               model=ThemeAdjustment,
+               actions=[
+                        {
+                         'id': 'list',
+                         'name': _("Browse Clients"),
+                         'help': _("Browse all existing clients"),
+                         'route': 'admin_clients'
+                         },
+                        {
+                         'id': 'new',
+                         'name': _("Add New Adjustment"),
+                         'help': _("Create new client adjustment"),
+                         'route': 'admin_clients_new'
+                         },
+                        ],
+               route='admin_clients',
+               urlpatterns=patterns('misago.themes.views',
+                        url(r'^$', 'List', name='admin_clients'),
+                        url(r'^new/$', 'New', name='admin_clients_new'),
+                        url(r'^edit/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Edit', name='admin_clients_edit'),
+                        url(r'^delete/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Delete', name='admin_clients_delete'),
+                    ),
+               ),
+)

+ 158 - 0
misago/core/admin/sections/users.py

@@ -0,0 +1,158 @@
+from django.conf.urls import patterns, include, url
+from django.utils.translation import ugettext_lazy as _
+from misago.admin import AdminAction
+from misago.models import Ban, Newsletter, PruningPolicy, Rank, User
+
+ADMIN_ACTIONS = (
+   AdminAction(
+               section='users',
+               id='users',
+               name=_("Users List"),
+               help=_("Search and browse users"),
+               icon='user',
+               model=User,
+               actions=[
+                        {
+                         'id': 'list',
+                         'name': _("Browse Users"),
+                         'help': _("Browse all registered user accounts"),
+                         'route': 'admin_users'
+                         },
+                        {
+                         'id': 'new',
+                         'name': _("Add User"),
+                         'help': _("Create new user account"),
+                         'route': 'admin_users_new'
+                         },
+                        ],
+               route='admin_users',
+               urlpatterns=patterns('misago.users.views',
+                        url(r'^$', 'List', name='admin_users'),
+                        url(r'^(?P<page>\d+)/$', 'List', name='admin_users'),
+                        url(r'^inactive/$', 'inactive', name='admin_users_inactive'),
+                        url(r'^new/$', 'New', name='admin_users_new'),
+                        url(r'^edit/(?P<slug>[a-z0-9]+)-(?P<target>\d+)/$', 'Edit', name='admin_users_edit'),
+                        url(r'^delete/(?P<slug>[a-z0-9]+)-(?P<target>\d+)/$', 'Delete', name='admin_users_delete'),
+                    ),
+               ),
+   AdminAction(
+               section='users',
+               id='ranks',
+               name=_("Ranks"),
+               help=_("Administrate User Ranks"),
+               icon='star',
+               model=Rank,
+               actions=[
+                        {
+                         'id': 'list',
+                         'name': _("Browse Ranks"),
+                         'help': _("Browse all existing ranks"),
+                         'route': 'admin_ranks'
+                         },
+                        {
+                         'id': 'new',
+                         'name': _("Add Rank"),
+                         'help': _("Create new rank"),
+                         'route': 'admin_ranks_new'
+                         },
+                        ],
+               route='admin_ranks',
+               urlpatterns=patterns('misago.ranks.views',
+                        url(r'^$', 'List', name='admin_ranks'),
+                        url(r'^new/$', 'New', name='admin_ranks_new'),
+                        url(r'^edit/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Edit', name='admin_ranks_edit'),
+                        url(r'^delete/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Delete', name='admin_ranks_delete'),
+                    ),
+               ),
+   AdminAction(
+               section='users',
+               id='bans',
+               name=_("Banning"),
+               help=_("Ban or unban users from forums."),
+               icon='lock',
+               model=Ban,
+               actions=[
+                        {
+                         'id': 'list',
+                         'name': _("Browse Bans"),
+                         'help': _("Browse all existing bans"),
+                         'route': 'admin_bans'
+                         },
+                        {
+                         'id': 'new',
+                         'name': _("Set Ban"),
+                         'help': _("Set new Ban"),
+                         'route': 'admin_bans_new'
+                         },
+                        ],
+               route='admin_bans',
+               urlpatterns=patterns('misago.banning.views',
+                        url(r'^$', 'List', name='admin_bans'),
+                        url(r'^(?P<page>\d+)/$', 'List', name='admin_bans'),
+                        url(r'^new/$', 'New', name='admin_bans_new'),
+                        url(r'^edit/(?P<target>\d+)/$', 'Edit', name='admin_bans_edit'),
+                        url(r'^delete/(?P<target>\d+)/$', 'Delete', name='admin_bans_delete'),
+                    ),
+               ),
+   AdminAction(
+               section='users',
+               id='prune_users',
+               name=_("Prune Users"),
+               help=_("Delete multiple Users"),
+               icon='remove',
+               model=PruningPolicy,
+               actions=[
+                        {
+                         'id': 'list',
+                         'name': _("Pruning Policies"),
+                         'help': _("Browse all existing pruning policies"),
+                         'route': 'admin_prune_users'
+                         },
+                        {
+                         'id': 'new',
+                         'name': _("Set New Policy"),
+                         'help': _("Set new pruning policy"),
+                         'route': 'admin_prune_users_new'
+                         },
+                        ],
+               route='admin_prune_users',
+               urlpatterns=patterns('misago.prune.views',
+                        url(r'^$', 'List', name='admin_prune_users'),
+                        url(r'^new/$', 'New', name='admin_prune_users_new'),
+                        url(r'^edit/(?P<target>\d+)/$', 'Edit', name='admin_prune_users_edit'),
+                        url(r'^delete/(?P<target>\d+)/$', 'Delete', name='admin_prune_users_delete'),
+                        url(r'^apply/(?P<target>\d+)/$', 'Apply', name='admin_prune_users_apply'),
+                    ),
+               ),
+   AdminAction(
+               section='users',
+               id='newsletters',
+               name=_("Newsletters"),
+               help=_("Manage and send Newsletters"),
+               icon='envelope',
+               model=Newsletter,
+               actions=[
+                        {
+                         'id': 'list',
+                         'name': _("Browse Newsletters"),
+                         'help': _("Browse all existing Newsletters"),
+                         'route': 'admin_newsletters'
+                         },
+                        {
+                         'id': 'new',
+                         'name': _("New Newsletter"),
+                         'help': _("Create new Newsletter"),
+                         'route': 'admin_newsletters_new'
+                         },
+                        ],
+               route='admin_newsletters',
+               urlpatterns=patterns('misago.newsletters.views',
+                        url(r'^$', 'List', name='admin_newsletters'),
+                        url(r'^(?P<page>\d+)/$', 'List', name='admin_newsletters'),
+                        url(r'^new/$', 'New', name='admin_newsletters_new'),
+                        url(r'^send/(?P<target>\d+)/(?P<token>[a-zA-Z0-9]+)/$', 'send', name='admin_newsletters_send'),
+                        url(r'^edit/(?P<target>\d+)/$', 'Edit', name='admin_newsletters_edit'),
+                        url(r'^delete/(?P<target>\d+)/$', 'Delete', name='admin_newsletters_delete'),
+                    ),
+               ),
+)

+ 0 - 0
misago/core/admin/stats/__init__.py


+ 28 - 0
misago/core/admin/stats/forms.py

@@ -0,0 +1,28 @@
+from datetime import timedelta
+from django import forms
+from django.utils import timezone as tz
+from django.utils.translation import ugettext_lazy as _
+from misago.forms import Form
+
+class GenerateStatisticsForm(Form):
+    provider_model = forms.ChoiceField()
+    date_start = forms.DateField(initial=tz.now() - timedelta(days=7))
+    date_end = forms.DateField(initial=tz.now())
+    stats_precision = forms.ChoiceField(choices=(('day', _('For each day')), ('week', _('For each week')), ('month', _('For each month')), ('year', _('For each year'))))
+
+    layout = (
+              (None, (
+                        ('provider_model', {'label': _('Report Type'), 'help_text': _('Select statistics provider.')}),
+                        ('nested', (
+                            ('date_start', {'label': _('Time'), 'help_text': _('Enter start and end date for time period you want to take data from to use in graph.'), 'attrs': {'placeholder': _('Start Date: YYYY-MM-DD')}, 'width': 50}),
+                            ('date_end', {'attrs': {'placeholder': _('End Date: YYYY-MM-DD')}, 'width': 50}),
+                        )),
+                        ('stats_precision', {'label': _('Graph Precision')}),
+                      )),
+              )
+
+    def __init__(self, *args, **kwargs):
+        provider_choices = kwargs.get('provider_choices')
+        del kwargs['provider_choices']
+        super(GenerateStatisticsForm, self).__init__(*args, **kwargs)
+        self.fields['provider_model'] = forms.ChoiceField(choices=provider_choices)

+ 170 - 0
misago/core/admin/stats/views.py

@@ -0,0 +1,170 @@
+import math
+from datetime import datetime, timedelta
+from django.core.urlresolvers import reverse
+from django.db import models
+from django.shortcuts import redirect
+from django.template import RequestContext
+from django.utils import timezone
+from django.utils.translation import ugettext as _
+from misago.forms import FormLayout
+from misago.messages import Message
+from misago.core.admin.stats.forms import GenerateStatisticsForm
+from misago.core.views import error404
+
+def form(request):
+    """
+    Allow admins to generate fancy statistic graphs for different models
+    """
+    statistics_providers = []
+    models_map = {}
+    for model in models.get_models():
+        try:
+            getattr(model.objects, 'filter_stats')
+            statistics_providers.append((str(model.__name__).lower(), model.statistics_name))
+            models_map[str(model.__name__).lower()] = model
+        except AttributeError:
+            pass
+
+    if not statistics_providers:
+        """
+        Something went FUBAR - Misago ships with some stats providers out of box
+        If those providers cant be found, this means Misago filesystem is corrupted
+        """
+        return request.theme.render_to_response('stats/not_available.html',
+                                                context_instance=RequestContext(request));
+
+    message = None
+    if request.method == 'POST':
+        form = GenerateStatisticsForm(request.POST, provider_choices=statistics_providers, request=request)
+        if form.is_valid():
+            date_start = form.cleaned_data['date_start']
+            date_end = form.cleaned_data['date_end']
+            if date_start > date_end:
+                # Reverse dates if start is after end
+                date_temp = date_end
+                date_end = date_start
+                date_start = date_temp
+            # Assert that dates are correct
+            if date_end == date_start:
+                message = Message(_('Start and end date are same'), type='error')
+            elif check_dates(date_start, date_end, form.cleaned_data['stats_precision']):
+                message = check_dates(date_start, date_end, form.cleaned_data['stats_precision'])
+            else:
+                request.messages.set_flash(Message(_('Statistical report has been created.')), 'success', 'admin_stats')
+                return redirect(reverse('admin_stats_graph', kwargs={
+                                                       'model': form.cleaned_data['provider_model'],
+                                                       'date_start': date_start.strftime('%Y-%m-%d'),
+                                                       'date_end': date_end.strftime('%Y-%m-%d'),
+                                                       'precision': form.cleaned_data['stats_precision']
+                                                        }))
+        else:
+            message = Message(form.non_field_errors()[0], 'error')
+    else:
+        form = GenerateStatisticsForm(provider_choices=statistics_providers, request=request)
+
+    return request.theme.render_to_response('stats/form.html', {
+                                            'form': FormLayout(form),
+                                            'message': message,
+                                            }, context_instance=RequestContext(request));
+
+
+def graph(request, model, date_start, date_end, precision):
+    """
+    Generate fancy graph for model and stuff
+    """
+    if date_start == date_end:
+        # Bad dates
+        raise error404()
+
+    # Turn stuff into datetime's
+    date_start = datetime.strptime(date_start, '%Y-%m-%d')
+    date_end = datetime.strptime(date_end, '%Y-%m-%d')
+
+
+    statistics_providers = []
+    models_map = {}
+    for model_obj in models.get_models():
+        try:
+            getattr(model_obj.objects, 'filter_stats')
+            statistics_providers.append((str(model_obj.__name__).lower(), model_obj.statistics_name))
+            models_map[str(model_obj.__name__).lower()] = model_obj
+        except AttributeError:
+            pass
+
+    if not statistics_providers:
+        # Like before, q.q on lack of models
+        return request.theme.render_to_response('stats/not_available.html',
+                                                context_instance=RequestContext(request));
+
+    if not model in models_map or check_dates(date_start, date_end, precision):
+        # Bad model name or graph data!
+        raise error404()
+
+    form = GenerateStatisticsForm(
+                                  provider_choices=statistics_providers,
+                                  request=request,
+                                  initial={'provider_model': model, 'date_start': date_start, 'date_end': date_end, 'stats_precision': precision})
+    return request.theme.render_to_response('stats/graph.html', {
+                                            'title': models_map[model].statistics_name,
+                                            'graph': build_graph(models_map[model], date_start, date_end, precision),
+                                            'form': FormLayout(form),
+                                            'message': request.messages.get_message('admin_stats'),
+                                            }, context_instance=RequestContext(request));
+
+
+def check_dates(date_start, date_end, precision):
+    date_diff = date_end - date_start
+    date_diff = date_diff.seconds + date_diff.days * 86400
+
+    if ((precision == 'day' and date_diff / 86400 > 60)
+        or (precision == 'week' and date_diff / 604800 > 60)
+        or (precision == 'month' and date_diff / 2592000 > 60)
+        or (precision == 'year' and date_diff / 31536000 > 60)):
+        return Message(_('Too many many items to display on graph.'), 'error')
+    elif ((precision == 'day' and date_diff / 86400 < 1)
+          or (precision == 'week' and date_diff / 604800 < 1)
+          or (precision == 'month' and date_diff / 2592000 < 1)
+          or (precision == 'year' and date_diff / 31536000 < 1)):
+        return Message(_('Too few items to display on graph'), 'error')
+    return None
+
+
+def build_graph(model, date_start, date_end, precision):
+    if precision == 'day':
+        format = 'F j, Y'
+        step = 86400
+    if precision == 'week':
+        format = 'W, Y'
+        step = 604800
+    if precision == 'month':
+        format = 'F, Y'
+        step = 2592000
+    if precision == 'year':
+        format = 'Y'
+        step = 31536000
+
+    date_end = timezone.make_aware(date_end, timezone.get_current_timezone())
+    date_start = timezone.make_aware(date_start, timezone.get_current_timezone())
+
+    date_diff = date_end - date_start
+    date_diff = date_diff.seconds + date_diff.days * 86400
+    steps = int(math.ceil(float(date_diff / step))) + 1
+    timeline = [0 for i in range(0, steps)]
+    for i in range(0, steps):
+        step_date = date_end - timedelta(seconds=(i * step));
+        timeline[steps - i - 1] = step_date
+    stat = {'total': 0, 'max': 0, 'stat': [0 for i in range(0, steps)], 'timeline': timeline, 'start': date_start, 'end': date_end, 'format': format}
+
+    # Loop model items
+    for item in model.objects.filter_stats(date_start, date_end).iterator():
+        date_diff = date_end - item.get_date()
+        date_diff = date_diff.seconds + date_diff.days * 86400
+        date_diff = steps - int(math.floor(float(date_diff / step))) - 2
+        stat['stat'][date_diff] += 1
+        stat['total'] += 1
+
+    # Find max
+    for i in stat['stat']:
+        if i > stat['max']:
+            stat['max'] = i
+    return stat

+ 550 - 0
misago/core/admin/widgets.py

@@ -0,0 +1,550 @@
+from django import forms
+from django.core.exceptions import ValidationError
+from django.core.urlresolvers import reverse
+from django.shortcuts import redirect
+from django.template import RequestContext
+from django.utils.translation import ugettext_lazy as _
+from jinja2 import TemplateNotFound
+import math
+from misago.forms import Form, FormLayout, FormFields, FormFieldsets
+from misago.messages import Message
+
+"""
+Class widgets
+"""
+class BaseWidget(object):
+    """
+    Admin Widget abstract class, providing widgets with common or shared functionality
+    """
+    admin = None
+    id = None
+    fallback = None
+    name = None
+    help = None
+    notfound_message = None
+
+    def __new__(cls, request, **kwargs):
+        obj = super(BaseWidget, cls).__new__(cls)
+        if not obj.name:
+            obj.name = obj.get_name()
+        if not obj.help:
+            obj.help = obj.get_help()
+        return obj(request, **kwargs)
+
+    def get_token(self, token):
+        return '%s_%s_%s' % (self.id, token, str('%s.%s' % (self.admin.id, self.admin.model.__name__)))
+
+    def get_url(self):
+        return reverse(self.admin.get_action_attr(self.id, 'route'))
+
+    def get_name(self):
+        return self.admin.get_action_attr(self.id, 'name')
+
+    def get_help(self):
+        return self.admin.get_action_attr(self.id, 'help')
+
+    def get_id(self):
+        return 'admin_%s' % self.id
+
+    def get_template(self):
+        return ('%s/%s.html' % (self.admin.id, self.template),
+                'admin/%s.html' % self.template)
+
+    def add_template_variables(self, variables):
+        return variables
+
+    def get_fallback_url(self):
+        return reverse(self.fallback)
+
+    def get_target(self, model):
+        pass
+
+    def get_target_name(self, model):
+        try:
+            if self.translate_target_name:
+                return _(model.__dict__[self.target_name])
+            return model.__dict__[self.target_name]
+        except AttributeError:
+            return None
+
+    def get_and_validate_target(self, target):
+        try:
+            model = self.admin.model.objects.select_related().get(pk=target)
+            self.get_target(model)
+            return model
+        except self.admin.model.DoesNotExist:
+            self.request.messages.set_flash(Message(self.notfound_message), 'error', self.admin.id)
+        except ValueError as e:
+            self.request.messages.set_flash(Message(e.args[0]), 'error', self.admin.id)
+        return None
+
+
+class ListWidget(BaseWidget):
+    """
+    Items list widget
+    """
+    actions = []
+    columns = []
+    sortables = {}
+    default_sorting = None
+    search_form = None
+    is_filtering = False
+    pagination = None
+    template = 'list'
+    hide_actions = False
+    table_form_button = _('Go')
+    empty_message = _('There are no items to display')
+    empty_search_message = _('Search has returned no items')
+    nothing_checked_message = _('You have to select at least one item.')
+    prompt_select = False
+
+    def get_item_actions(self, item):
+        """
+        Provides request and item, should return list of tuples with item actions in following format:
+        (id, name, help, icon, link)
+        """
+        return []
+
+    def action(self, icon=None, name=None, url=None, post=False, prompt=None):
+        """
+        Function call to make hash with item actions
+        """
+        if prompt:
+            self.prompt_select = True
+        return {
+                'icon': icon,
+                'name': name,
+                'url': url,
+                'post': post,
+                'prompt': prompt,
+                }
+
+    def get_search_form(self):
+        """
+        Build a form object with items search
+        """
+        return self.search_form
+
+    def set_filters(self, model, filters):
+        """
+        Set filters on model using filters from session
+        """
+        return None
+
+    def get_table_form(self, page_items):
+        """
+        Build a form object with list of all items fields
+        """
+        return None
+
+    def table_action(self, page_items, cleaned_data):
+        """
+        Handle table form submission, return tuple containing message and redirect link/false
+        """
+        return None
+
+    def get_actions_form(self, page_items):
+        """
+        Build a form object with list of all items actions
+        """
+        if not self.actions:
+            return None # Dont build form
+        form_fields = {}
+        list_choices = []
+        for action in self.actions:
+            list_choices.append((action[0], action[1]))
+        form_fields['list_action'] = forms.ChoiceField(choices=list_choices)
+        list_choices = []
+        for item in page_items:
+            list_choices.append((item.pk, None))
+        form_fields['list_items'] = forms.MultipleChoiceField(choices=list_choices, widget=forms.CheckboxSelectMultiple)
+        return type('AdminListForm', (Form,), form_fields)
+
+    def get_sorting(self):
+        """
+        Return list sorting method.
+        A list with three values:
+        - Field we use to sort over
+        - Sorting direction
+        - order_by() argument
+        """
+        sorting_method = None
+        if self.request.session.get(self.get_token('sort')) and self.request.session.get(self.get_token('sort'))[0] in self.sortables:
+            sorting_method = self.request.session.get(self.get_token('sort'))
+
+        if self.request.GET.get('sort') and self.request.GET.get('sort') in self.sortables:
+            new_sorting = self.request.GET.get('sort')
+            sorting_dir = int(self.request.GET.get('dir')) == 1
+            sorting_method = [
+                    new_sorting,
+                    sorting_dir,
+                    new_sorting if sorting_dir else '-%s' % new_sorting
+                   ]
+            self.request.session[self.get_token('sort')] = sorting_method
+
+        if not sorting_method:
+            if self.sortables:
+                new_sorting = self.sortables.keys()[0]
+                if self.default_sorting in self.sortables:
+                    new_sorting = self.default_sorting
+                sorting_method = [
+                        new_sorting,
+                        self.sortables[new_sorting] == True,
+                        new_sorting if self.sortables[new_sorting] else '-%s' % new_sorting
+                       ]
+            else:
+                sorting_method = [
+                        id,
+                        True,
+                        '-id'
+                       ]
+        return sorting_method
+
+    def sort_items(self, page_items, sorting_method):
+        return page_items.order_by(sorting_method[2])
+
+    def get_pagination_url(self, page):
+        return reverse(self.admin.get_action_attr(self.id, 'route'), kwargs={'page': page})
+
+    def get_pagination(self, total, page):
+        """
+        Return list pagination.
+        A list with three values:
+        - Offset for ORM slicing
+        - Length of slice
+        - no. of prev page (or -1 for first page)
+        - no. of next page (or -1 for last page)
+        - Current page
+        - Pages total
+        """
+        if not self.pagination or total < 0:
+            # Dont do anything if we are not paging
+            return None
+
+        # Set basic pagination, use either Session cache or new page value
+        pagination = {'start': 0, 'stop': 0, 'prev':-1, 'next':-1}
+        if self.request.session.get(self.get_token('pagination')):
+            pagination['start'] = self.request.session.get(self.get_token('pagination'))
+        page = int(page)
+        if page > 0:
+            pagination['start'] = (page - 1) * self.pagination
+
+        # Set page and total stat
+        pagination['page'] = int(pagination['start'] / self.pagination) + 1
+        pagination['total'] = int(math.ceil(total / float(self.pagination)))
+
+        # Fix too large offset
+        if pagination['start'] > total:
+            pagination['start'] = 0
+
+        # Allow prev/next?
+        if total > self.pagination:
+            if pagination['page'] > 1:
+                pagination['prev'] = pagination['page'] - 1
+            if pagination['page'] < pagination['total']:
+                pagination['next'] = pagination['page'] + 1
+
+        # Set stop offset
+        pagination['stop'] = pagination['start'] + self.pagination
+        return pagination
+
+    def get_items(self):
+        if self.request.session.get(self.get_token('filter')):
+            self.is_filtering = True
+            return self.set_filters(self.admin.model.objects, self.request.session.get(self.get_token('filter')))
+        return self.admin.model.objects
+
+    def __call__(self, request, page=0):
+        """
+        Use widget as view
+        """
+        self.request = request
+
+        # Get basic list items
+        items_total = self.get_items()
+
+        # Set extra filters?
+        try:
+            items_total = self.select_items(items_total).count()
+        except AttributeError:
+            items_total = items_total.count()
+
+        # Set sorting and paginating
+        sorting_method = self.get_sorting()
+        paginating_method = self.get_pagination(items_total, page)
+
+        # List items
+        items = self.get_items()
+        if not request.session.get(self.get_token('filter')):
+            items = items.all()
+
+        # Set extra filters?
+        try:
+            items = self.select_items(items)
+        except AttributeError:
+            pass
+
+        # Sort them
+        items = self.sort_items(items, sorting_method);
+
+        # Set pagination
+        if self.pagination:
+            items = items[paginating_method['start']:paginating_method['stop']]
+
+        # Prefetch related?
+        try:
+            items = self.prefetch_related(items)
+        except AttributeError:
+            pass
+
+        # Default message
+        message = None
+
+        # See if we should make and handle search form
+        search_form = None
+        SearchForm = self.get_search_form()
+        if SearchForm:
+            if request.method == 'POST':
+                # New search
+                if request.POST.get('origin') == 'search':
+                    search_form = SearchForm(request.POST, request=request)
+                    if search_form.is_valid():
+                        search_criteria = {}
+                        for field, criteria in search_form.cleaned_data.items():
+                            if len(criteria) > 0:
+                                search_criteria[field] = criteria
+                        if not search_criteria:
+                            message = Message(_("No search criteria have been defined."))
+                        else:
+                            request.session[self.get_token('filter')] = search_criteria
+                            return redirect(self.get_url())
+                    else:
+                        message = Message(_("Search form contains errors."))
+                    message.type = 'error'
+                else:
+                    search_form = SearchForm(request=request)
+
+                # Kill search
+                if request.POST.get('origin') == 'clear' and self.is_filtering and request.csrf.request_secure(request):
+                    request.session[self.get_token('filter')] = None
+                    request.messages.set_flash(Message(_("Search criteria have been cleared.")), 'info', self.admin.id)
+                    return redirect(self.get_url())
+            else:
+                if self.is_filtering:
+                    search_form = SearchForm(request=request, initial=request.session.get(self.get_token('filter')))
+                else:
+                    search_form = SearchForm(request=request)
+
+        # See if we sould make and handle tab form
+        table_form = None
+        TableForm = self.get_table_form(items)
+        if TableForm:
+            if request.method == 'POST' and request.POST.get('origin') == 'table':
+                table_form = TableForm(request.POST, request=request)
+                if table_form.is_valid():
+                    message, redirect_url = self.table_action(items, table_form.cleaned_data)
+                    if redirect_url:
+                        request.messages.set_flash(message, message.type, self.admin.id)
+                        return redirect(redirect_url)
+                else:
+                    message = Message(table_form.non_field_errors()[0], 'error')
+            else:
+                table_form = TableForm(request=request)
+
+        # See if we should make and handle list form
+        list_form = None
+        ListForm = self.get_actions_form(items)
+        if ListForm:
+            if request.method == 'POST' and request.POST.get('origin') == 'list':
+                list_form = ListForm(request.POST, request=request)
+                if list_form.is_valid():
+                    try:
+                        form_action = getattr(self, 'action_' + list_form.cleaned_data['list_action'])
+                        message, redirect_url = form_action(items, list_form.cleaned_data['list_items'])
+                        if redirect_url:
+                            request.messages.set_flash(message, message.type, self.admin.id)
+                            return redirect(redirect_url)
+                    except AttributeError:
+                        message = Message(_("Action requested is incorrect."))
+                else:
+                    if 'list_items' in list_form.errors:
+                        message = Message(self.nothing_checked_message)
+                    elif 'list_action' in list_form.errors:
+                        message = Message(_("Action requested is incorrect."))
+                    else:
+                        message = Message(list_form.non_field_errors()[0])
+                message.type = 'error'
+            else:
+                list_form = ListForm(request=request)
+
+        # Little hax to keep counters correct 
+        items_shown = len(items)
+        if items_total < items_shown:
+            items_total = items_shown
+
+        # Render list
+        return request.theme.render_to_response(self.get_template(),
+                                                self.add_template_variables({
+                                                 'admin': self.admin,
+                                                 'action': self,
+                                                 'request': request,
+                                                 'url': self.get_url(),
+                                                 'messages_log': request.messages.get_messages(self.admin.id),
+                                                 'message': message,
+                                                 'sorting': self.sortables,
+                                                 'sorting_method': sorting_method,
+                                                 'pagination': paginating_method,
+                                                 'list_form': FormLayout(list_form) if list_form else None,
+                                                 'search_form': FormLayout(search_form) if search_form else None,
+                                                 'table_form': FormFields(table_form).fields if table_form else None,
+                                                 'items': items,
+                                                 'items_total': items_total,
+                                                 'items_shown': items_shown,
+                                                }),
+                                                context_instance=RequestContext(request));
+
+
+class FormWidget(BaseWidget):
+    """
+    Form page widget
+    """
+    template = 'form'
+    submit_button = _("Save Changes")
+    form = None
+    layout = None
+    tabbed = False
+    target_name = None
+    translate_target_name = False
+    original_name = None
+    submit_fallback = False
+
+    def get_url(self, model):
+        return reverse(self.admin.get_action_attr(self.id, 'route'))
+
+    def get_form(self, target):
+        return self.form
+
+    def get_form_instance(self, form, target, initial, post=False):
+        if post:
+            return form(self.request.POST, request=self.request, initial=self.get_initial_data(target))
+        return form(request=self.request, initial=self.get_initial_data(target))
+
+    def get_layout(self, form, model):
+        if self.layout:
+            return self.layout
+        return form.layout
+
+    def get_initial_data(self, model):
+        return {}
+
+    def submit_form(self, form, model):
+        """
+        Handle form submission, ALWAYS return tuple with model and message
+        """
+        pass
+
+    def __call__(self, request, target=None, slug=None):
+        self.request = request
+
+        # Fetch target?
+        model = None
+        if target:
+            model = self.get_and_validate_target(target)
+            self.original_name = self.get_target_name(model)
+            if not model:
+                return redirect(self.get_fallback_url())
+        original_model = model
+
+        # Get form type to instantiate
+        FormType = self.get_form(model)
+
+        #Submit form
+        message = None
+        if request.method == 'POST':
+            form = self.get_form_instance(FormType, model, self.get_initial_data(model), True)
+            if form.is_valid():
+                try:
+                    model, message = self.submit_form(form, model)
+                    if message.type != 'error':
+                        request.messages.set_flash(message, message.type, self.admin.id)
+                        # Redirect back to right page
+                        try:
+                            if 'save_new' in request.POST and self.get_new_url:
+                                return redirect(self.get_new_url(model))
+                        except AttributeError:
+                            pass
+                        try:
+                            if 'save_edit' in request.POST and self.get_edit_url:
+                                return redirect(self.get_edit_url(model))
+                        except AttributeError:
+                            pass
+                        try:
+                            if self.get_submit_url:
+                                return redirect(self.get_submit_url(model))
+                        except AttributeError:
+                            pass
+                        return redirect(self.get_fallback_url())
+                except ValidationError as e:
+                    message = Message(e.messages[0], 'error')
+            else:
+                message = Message(form.non_field_errors()[0], 'error')
+        else:
+            form = self.get_form_instance(FormType, model, self.get_initial_data(model))
+
+        # Render form
+        return request.theme.render_to_response(self.get_template(),
+                                                self.add_template_variables({
+                                                 'admin': self.admin,
+                                                 'action': self,
+                                                 'request': request,
+                                                 'url': self.get_url(model),
+                                                 'fallback': self.get_fallback_url(),
+                                                 'messages_log': request.messages.get_messages(self.admin.id),
+                                                 'message': message,
+                                                 'tabbed': self.tabbed,
+                                                 'target': self.get_target_name(original_model),
+                                                 'target_model': original_model,
+                                                 'form': FormLayout(form, self.get_layout(form, target)),
+                                                }),
+                                                context_instance=RequestContext(request));
+
+
+class ButtonWidget(BaseWidget):
+    """
+    Button Action Widget
+    This widget handles most basic and common type of admin action - button press:
+    - User presses button on list (for example "delete this user!")
+    - Widget checks if request is CSRF-valid and POST
+    - Widget optionally chcecks if target has been provided and action is allowed at all
+    - Widget does action and redirects us back to fallback url
+    """
+    def __call__(self, request, target=None, slug=None):
+        self.request = request
+
+        # Fetch target?
+        model = None
+        if target:
+            model = self.get_and_validate_target(target)
+            if not model:
+                return redirect(self.get_fallback_url())
+        original_model = model
+
+        # Crash if this is invalid request
+        if not request.csrf.request_secure(request):
+            request.messages.set_flash(Message(_("Action authorization is invalid.")), 'error', self.admin.id)
+            return redirect(self.get_fallback_url())
+
+        # Do something
+        message, url = self.action(model)
+        request.messages.set_flash(message, message.type, self.admin.id)
+        if url:
+            return redirect(url)
+        return redirect(self.get_fallback_url())
+
+    def action(self, target):
+        """
+        Action to be executed when button is pressed
+        Define custom one in your Admin action.
+        It should return response and message objects 
+        """
+        pass

+ 0 - 0
misago/core/front/__init__.py


+ 69 - 0
misago/core/front/index.py

@@ -0,0 +1,69 @@
+from datetime import timedelta
+from django.core.cache import cache
+from django.template import RequestContext
+from django.utils import timezone
+from misago.models import Forum, Post, Rank, Session, Thread
+from misago.readstrackers import ForumsTracker
+
+def index(request):
+    # Threads ranking
+    popular_threads = []
+    if request.settings['thread_ranking_size'] > 0:
+        popular_threads = cache.get('thread_ranking_%s' % request.user.make_acl_key(), 'nada')
+        if popular_threads == 'nada':
+            popular_threads = []
+            for thread in Thread.objects.filter(moderated=False).filter(deleted=False).filter(forum__in=request.acl.threads.get_readable_forums(request.acl)).prefetch_related('forum').order_by('-score')[:request.settings['thread_ranking_size']]:
+                thread.forum_name = thread.forum.name
+                thread.forum_slug = thread.forum.slug
+                popular_threads.append(thread)
+            cache.set('thread_ranking_%s' % request.user.make_acl_key(), popular_threads, 60 * request.settings['thread_ranking_refresh'])
+
+    # Ranks online
+    ranks_list = cache.get('ranks_online', 'nada')
+    if ranks_list == 'nada':
+        ranks_dict = {}
+        ranks_list = []
+        users_list = []
+        for rank in Rank.objects.filter(on_index=True).order_by('order'):
+            rank_entry = {'id':rank.id, 'name': rank.name, 'style': rank.style, 'title': rank.title, 'online': []}
+            ranks_list.append(rank_entry)
+            ranks_dict[rank.pk] = rank_entry
+        if ranks_dict:
+            for session in Session.objects.select_related('user').filter(rank__in=ranks_dict.keys()).filter(last__gte=timezone.now() - timedelta(minutes=10)).filter(user__isnull=False):
+                if not session.user_id in users_list:
+                    ranks_dict[session.user.rank_id]['online'].append(session.user)
+                    users_list.append(session.user_id)
+            # Assert we are on list
+            if (request.user.is_authenticated() and request.user.rank_id in ranks_dict.keys()
+                and not request.user.id in users_list):
+                    ranks_dict[request.user.rank_id]['online'].append(request.user)
+            del ranks_dict
+            del users_list
+        cache.set('ranks_online', ranks_list, 300)
+
+    # Users online
+    users_online = cache.get('users_online', 'nada')
+    if users_online == 'nada':
+        users_online = Session.objects.filter(matched=True).filter(crawler__isnull=True).filter(last__gte=timezone.now() - timedelta(seconds=300)).count()
+        cache.set('users_online', users_online, 300)
+    if not users_online and not request.user.is_crawler():
+        # Cheatey trick to make sure we'll never display
+        # zero users online to human client
+        users_online = 1
+
+    # Load reads tracker and build forums list
+    reads_tracker = ForumsTracker(request.user)
+    forums_list = Forum.objects.treelist(request.acl.forums, tracker=reads_tracker)
+    
+    # Whitelist ignored members
+    Forum.objects.ignored_users(request.user, forums_list)
+    
+    # Render page
+    return request.theme.render_to_response('index.html',
+                                            {
+                                             'forums_list': forums_list,
+                                             'ranks_online': ranks_list,
+                                             'users_online': users_online,
+                                             'popular_threads': popular_threads,
+                                             },
+                                            context_instance=RequestContext(request));

+ 23 - 0
misago/core/front/readall.py

@@ -0,0 +1,23 @@
+from django.core.urlresolvers import reverse
+from django.shortcuts import redirect
+from django.template import RequestContext
+from django.utils import timezone
+from django.utils.translation import ugettext as _
+from misago.decorators import block_guest, check_csrf
+from misago.messages import Message
+from misago.models import ForumRead, ThreadRead
+
+@block_guest
+@check_csrf
+def read_all(request):
+    ForumRead.objects.filter(user=request.user).delete()
+    ThreadRead.objects.filter(user=request.user).delete()
+    now = timezone.now()
+    bulk = []
+    for forum in request.acl.forums.known_forums():
+        new_record = ForumRead(user=request.user, forum_id=forum, updated=now, cleared=now)
+        bulk.append(new_record)
+    if bulk:
+        ForumRead.objects.bulk_create(bulk)
+    request.messages.set_flash(Message(_("All forums have been marked as read.")), 'success')
+    return redirect(reverse('index'))

+ 0 - 0
misago/core/signin/__init__.py


+ 0 - 0
misago/shared/signin/forms.py → misago/core/signin/forms.py


+ 2 - 2
misago/shared/signin/urls.py → misago/core/signin/urls.py

@@ -1,13 +1,13 @@
 from django.conf.urls import patterns, url
 from misago.admin import ADMIN_PATH
 
-urlpatterns = patterns('misago.shared.signin.views',
+urlpatterns = patterns('misago.core.signin.views',
     url(r'^signin/$', 'signin', name="sign_in"),
     url(r'^signout/$', 'signout', name="sign_out"),
 )
 
 # Include admin patterns
 if ADMIN_PATH:
-    urlpatterns += patterns('misago.shared.signin.views',
+    urlpatterns += patterns('misago.core.signin.views',
         url(r'^' + ADMIN_PATH + 'signout/$', 'signout', name="admin_sign_out"),
     )

+ 2 - 3
misago/shared/signin/views.py → misago/core/signin/views.py

@@ -7,13 +7,12 @@ from misago.admin import site
 from misago.forms import FormLayout
 from misago.messages import Message
 import misago.auth as auth
-from misago.auth.decorators import 
-from misago.shared.signin.forms import SignInForm
 from misago.auth import AuthException, auth_admin, auth_forum, sign_user_in
 from misago.decorators import (block_authenticated, block_banned, block_crawlers,
                             block_guest, block_jammed, check_csrf)
 from misago.models import SignInAttempt, Token
-from misago.utils import random_string
+from misago.core.signin.forms import SignInForm
+from misago.utils.strings import random_string
 
 @block_crawlers
 @block_banned

+ 0 - 0
misago/shared/views.py → misago/core/views.py


+ 1 - 1
misago/decorators.py

@@ -1,6 +1,6 @@
 from django.utils.translation import ugettext as _
 from misago.acl.exceptions import ACLError403, ACLError404
-from misago.shared.views import error403, error404, error_banned
+from misago.core.views import error403, error404, error_banned
 
 def acl_errors(f):
     def decorator(*args, **kwargs):

+ 2 - 1
misago/firewalls.py

@@ -2,7 +2,8 @@ from django.conf import settings
 from django.utils.translation import ugettext_lazy as _
 from misago.admin import ADMIN_PATH
 from misago.messages import Message
-from misago.shared.views import error403, error404, signin
+from misago.core.views import error403, error404
+from misago.core.signin.views import signin
 
 class FirewallForum(object):
     admin = False

+ 60 - 0
misago/markdown/__init__.py

@@ -0,0 +1,60 @@
+from misago.markdown.factory import *
+
+# Monkeypatch blockquote parser to handle codes
+from markdown import util
+import markdown.blockprocessors
+from markdown.extensions.fenced_code import FENCED_BLOCK_RE, CODE_WRAP, LANG_TAG
+
+class MisagoBlockQuoteProcessor(markdown.blockprocessors.BlockQuoteProcessor):
+    def run(self, parent, blocks):
+        block = blocks.pop(0)
+        m = self.RE.search(block)
+        if m:
+            before = block[:m.start()] # Lines before blockquote
+            # Pass lines before blockquote in recursively for parsing forst.
+            self.parser.parseBlocks(parent, [before])
+            # Remove ``> `` from begining of each line.
+            block = '\n'.join([self.clean(line) for line in 
+                            block[m.start():].split('\n')])
+
+        sibling = self.lastChild(parent)
+        if sibling and sibling.tag == "blockquote":
+            # Previous block was a blockquote so set that as this blocks parent
+            quote = sibling
+        else:
+            # This is a new blockquote. Create a new parent element.
+            quote = util.etree.SubElement(parent, 'blockquote')
+        # Recursively parse block with blockquote as parent.
+        # change parser state so blockquotes embedded in lists use p tags
+        self.parser.state.set('blockquote')
+        # MONKEYPATCH START
+        block = self.clear_codes(block)
+        # MONKEYPATCH END
+        self.parser.parseChunk(quote, block)
+        self.parser.state.reset()
+
+    # MONKEYPATCH START
+    def clear_codes(self, text):
+        while 1:
+            m = FENCED_BLOCK_RE.search(text)
+            if m:
+                lang = ''
+                if m.group('lang'):
+                    lang = LANG_TAG % m.group('lang')
+                code = CODE_WRAP % (lang, self._escape(m.group('code')))
+                placeholder = self.parser.markdown.htmlStash.store(code, safe=True)
+                text = '%s\n\n%s\n\n%s' % (text[:m.start()].strip(), placeholder.strip(), text[m.end():].strip())
+            else:
+                break
+        return text.strip()
+
+    def _escape(self, txt):
+        """ basic html escaping """
+        txt = txt.replace('&', '&amp;')
+        txt = txt.replace('<', '&lt;')
+        txt = txt.replace('>', '&gt;')
+        txt = txt.replace('"', '&quot;')
+        return txt.strip()
+    # MONKEYPATCH END
+
+markdown.blockprocessors.BlockQuoteProcessor = MisagoBlockQuoteProcessor

+ 0 - 0
misago/markdown/extensions/__init__.py


+ 45 - 0
misago/markdown/extensions/magiclinks.py

@@ -0,0 +1,45 @@
+#-*- coding: utf-8 -*-
+import re
+import markdown
+from markdown.inlinepatterns import LinkPattern
+from markdown.postprocessors import RawHtmlPostprocessor
+from markdown.util import etree
+
+# Global vars
+MAGICLINKS_RE = re.compile(r'(?i)\b((?:[a-z][\w-]+:(?:/{1,3}|[a-z0-9%])|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:\'".,<>?«»“”‘’]))', re.UNICODE)
+
+class MagicLinksExtension(markdown.Extension):
+    def extendMarkdown(self, md):
+        md.registerExtension(self)
+        md.treeprocessors.add('mi_magiclinks',
+                              MagicLinksTreeprocessor(md),
+                              '_end')
+
+
+class MagicLinksTreeprocessor(markdown.treeprocessors.Treeprocessor):
+    def run(self, root):
+        return self.walk_tree(root)
+
+    def walk_tree(self, node):
+        def parse_link(matchobj):
+            link = LinkPattern(MAGICLINKS_RE, self.markdown)
+            href = link.sanitize_url(link.unescape(matchobj.group(0).strip()))
+            if href:
+                href = self.escape(href)
+                return self.markdown.htmlStash.store('<a href="%(href)s">%(href)s</a>' % {'href': href}, safe=True)
+            else:
+                return matchobj.group(0)
+
+        if node.tag not in ['code', 'pre', 'a', 'img']:
+            if node.text and unicode(node.text).strip():
+                node.text = MAGICLINKS_RE.sub(parse_link, unicode(node.text))
+            if node.tail and unicode(node.tail).strip():
+                node.tail = MAGICLINKS_RE.sub(parse_link, unicode(node.tail))
+            for i in node:
+                self.walk_tree(i)
+
+    def escape(self, html):
+        html = html.replace('&', '&amp;')
+        html = html.replace('<', '&lt;')
+        html = html.replace('>', '&gt;')
+        return html.replace('"', '&quot;')

+ 59 - 0
misago/markdown/extensions/mentions.py

@@ -0,0 +1,59 @@
+import re
+import markdown
+from markdown.util import etree
+from django.core.urlresolvers import reverse
+from misago.models import User
+from misago.utils import slugify
+
+# Global vars
+MENTION_RE = re.compile(r'([^\w]?)@(?P<username>(\w)+)', re.UNICODE)
+
+
+class MentionsExtension(markdown.Extension):
+    def extendMarkdown(self, md):
+        md.mentions = {}
+        md.registerExtension(self)
+        md.preprocessors.add('mi_mentions',
+                             MentionsPreprocessor(md),
+                             '>mi_quote_title')
+        md.postprocessors.add('mi_mentions',
+                              MentionsPostprocessor(md),
+                              '>mi_quote_title')
+
+
+class MentionsPreprocessor(markdown.preprocessors.Preprocessor):
+    def __init__(self, md):
+        markdown.preprocessors.Preprocessor.__init__(self, md)
+        self.md = md
+
+    def run(self, lines):
+        def mention(match):
+            slug = slugify(match.group(0)[1:])
+            if slug in self.md.mentions:
+                user = self.md.mentions[slug]
+                return '%s[@%s](%s)' % (match.group(1), user.username, reverse('user', kwargs={
+                                                                                              'user': user.pk,
+                                                                                              'username': user.username_slug,
+                                                                                              }))
+            elif len(self.md.mentions) < 32:
+                try:
+                    user = User.objects.get(username_slug=slug)
+                    self.md.mentions[slug] = user
+                    return '%s[@%s](%s)' % (match.group(1), user.username, reverse('user', kwargs={
+                                                                                                  'user': user.pk,
+                                                                                                  'username': user.username_slug,
+                                                                                                  }))
+                except User.DoesNotExist:
+                    pass
+            return match.group(0)
+        clean = []
+        for l, line in enumerate(lines):
+            if line.strip():
+                line = MENTION_RE.sub(mention, line)
+            clean.append(line)
+        return clean
+
+
+class MentionsPostprocessor(markdown.postprocessors.Postprocessor):
+    def run(self, text):
+        return text

+ 58 - 0
misago/markdown/extensions/quotes.py

@@ -0,0 +1,58 @@
+import re
+import markdown
+from markdown.util import etree
+
+# Global vars
+QUOTE_AUTHOR_RE = re.compile(r'^(?P<arrows>(>|\s)+)?@(?P<username>(\w|\d)+)$')
+
+class QuoteTitlesExtension(markdown.Extension):
+    def extendMarkdown(self, md):
+        md.registerExtension(self)
+        md.preprocessors.add('mi_quote_title',
+                             QuoteTitlesPreprocessor(md),
+                             '>fenced_code_block')
+        md.postprocessors.add('mi_quote_title',
+                              QuoteTitlesPostprocessor(md),
+                              '_end')
+
+
+class QuoteTitlesPreprocessor(markdown.preprocessors.Preprocessor):
+    def __init__(self, md):
+        markdown.preprocessors.Preprocessor.__init__(self, md)
+
+    def run(self, lines):
+        clean = []
+        for l, line in enumerate(lines):
+            try:
+                if line.strip():
+                    at_match = QUOTE_AUTHOR_RE.match(line.strip())
+                    if at_match and lines[l + 1].strip()[0] == '>':
+                        username = '<%(token)s:quotetitle>@%(name)s</%(token)s:quotetitle>' % {'token': self.markdown.mi_token, 'name': at_match.group('username')}
+                        if at_match.group('arrows'):
+                            clean.append('> %s%s' % (at_match.group('arrows'), username))
+                        else:
+                            clean.append('> %s' % username)
+                    else:
+                        clean.append(line)
+                else:
+                    clean.append(line)
+            except IndexError:
+                clean.append(line)
+        return clean
+
+
+class QuoteTitlesPostprocessor(markdown.postprocessors.Postprocessor):
+    def run(self, text):
+        text = text.replace('&lt;%s:quotetitle&gt;' % self.markdown.mi_token, '<h3><quotetitle>')
+        text = text.replace('&lt;/%s:quotetitle&gt;' % self.markdown.mi_token, '</quotetitle></h3>')
+        lines = text.splitlines()
+        clean = []
+        for l, line in enumerate(lines):
+            clean.append(line)
+            try:
+                if line == '<blockquote>':
+                    if lines[l + 1][0:7] != '<p><h3>':
+                        clean.append('<h3><quotesingletitle></h3>')
+            except IndexError:
+                pass
+        return '\r\n'.join(clean)

+ 115 - 0
misago/markdown/factory.py

@@ -0,0 +1,115 @@
+import re
+import markdown
+from HTMLParser import HTMLParser
+from django.conf import settings
+from django.utils.importlib import import_module
+from django.utils.translation import ugettext_lazy as _
+from misago.utils.strings import random_string
+
+class ClearHTMLParser(HTMLParser):
+    def __init__(self):
+        HTMLParser.__init__(self)
+        self.clean_text = ''
+        self.lookback = []
+        
+    def handle_entityref(self, name):
+        if name == 'gt':
+            self.clean_text += '>'
+        if name == 'lt':
+            self.clean_text += '<'
+
+    def handle_starttag(self, tag, attrs):
+        self.lookback.append(tag)
+
+    def handle_endtag(self, tag):
+        try:
+            if self.lookback[-1] == tag:
+                self.lookback.pop()
+        except IndexError:
+            pass
+        
+    def handle_data(self, data):
+        # String does not repeat itself
+        if self.clean_text[-len(data):] != data:
+            # String is not "QUOTE"
+            try:
+                if self.lookback[-1] in ('strong', 'em'):
+                    self.clean_text += data
+                elif not (data == 'Quote' and self.lookback[-1] == 'h3' and self.lookback[-2] == 'blockquote'):
+                    self.clean_text += data
+            except IndexError:
+                self.clean_text += data
+
+
+def clear_markdown(text):
+    parser = ClearHTMLParser()
+    parser.feed(text)
+    return parser.clean_text
+
+
+def remove_unsupported(md):
+    # References are evil, we dont support them
+    del md.preprocessors['reference']
+    del md.inlinePatterns['reference']
+    del md.inlinePatterns['image_reference']
+    del md.inlinePatterns['short_reference']
+
+
+def signature_markdown(acl, text):
+    md = markdown.Markdown(
+                           safe_mode='escape',
+                           output_format=settings.OUTPUT_FORMAT,
+                           extensions=['nl2br'])
+
+    remove_unsupported(md)
+
+    if not acl.usercp.allow_signature_links():
+        del md.inlinePatterns['link']
+        del md.inlinePatterns['autolink']
+    if not acl.usercp.allow_signature_images():
+        del md.inlinePatterns['image_link']
+
+    del md.parser.blockprocessors['hashheader']
+    del md.parser.blockprocessors['setextheader']
+    del md.parser.blockprocessors['code']
+    del md.parser.blockprocessors['quote']
+    del md.parser.blockprocessors['hr']
+    del md.parser.blockprocessors['olist']
+    del md.parser.blockprocessors['ulist']
+    
+    return md.convert(text)
+
+
+def post_markdown(request, text):
+    md = markdown.Markdown(
+                           safe_mode='escape',
+                           output_format=settings.OUTPUT_FORMAT,
+                           extensions=['nl2br', 'fenced_code'])
+
+    remove_unsupported(md)
+    md.mi_token = random_string(16)
+    for extension in settings.MARKDOWN_EXTENSIONS:
+        module = '.'.join(extension.split('.')[:-1])
+        extension = extension.split('.')[-1]
+        module = import_module(module)
+        attr = getattr(module, extension)
+        ext = attr()
+        ext.extendMarkdown(md)
+    text = md.convert(text)
+    return tidy_markdown(md, text)
+
+
+def tidy_markdown(md, text):
+    text = text.replace('<p><h3><quotetitle>', '<h3><quotetitle>')
+    text = text.replace('</quotetitle></h3></p>', '</quotetitle></h3>')
+    text = text.replace('</quotetitle></h3><br>\r\n', '</quotetitle></h3>\r\n<p>')
+    text = text.replace('\r\n<p></p>', '')
+    return md, text
+
+
+def finalize_markdown(text):
+    def trans_quotetitle(match):
+        return _("Posted by %(user)s") % {'user': match.group('content')}
+    text = re.sub(r'<quotetitle>(?P<content>.+)</quotetitle>', trans_quotetitle, text)
+    text = re.sub(r'<quotesingletitle>', _("Quote"), text)
+    return text

+ 2 - 2
misago/middleware/bruteforce.py

@@ -4,8 +4,8 @@ from misago.models import SignInAttempt
 
 class JamCache(object):
     def __init__(self):
-        jammed = False
-        expires = timezone.now()
+        self.jammed = False
+        self.expires = timezone.now()
     
     def check_for_updates(self, request):
         if self.expires < timezone.now():

+ 7 - 2
misago/models/banmodel.py

@@ -1,4 +1,7 @@
+import re
 from django.db import models
+from django.db.models import Q
+from django.utils import timezone
 
 BAN_NAME_EMAIL = 0
 BAN_NAME = 1
@@ -39,6 +42,8 @@ class Ban(models.Model):
     reason_admin = models.TextField(null=True, blank=True)
     expires = models.DateTimeField(null=True, blank=True)
 
+    objects = BansManager()
+
     class Meta:
         app_label = 'misago'
 
@@ -58,13 +63,13 @@ class BanCache(object):
 
             # Check Ban
             if request.user.is_authenticated():
-                ban = check_ban(
+                ban = Ban.objects.check_ban(
                                 ip=request.session.get_ip(request),
                                 username=request.user.username,
                                 email=request.user.email
                                 )
             else:
-                ban = check_ban(ip=request.session.get_ip(request))
+                ban = Ban.objects.check_ban(ip=request.session.get_ip(request))
 
             # Update ban cache
             if ban:

+ 1 - 1
misago/models/forummodel.py

@@ -166,7 +166,7 @@ class Forum(MPTTModel):
         if target.pk != self.pk:
             from misago.models import Role
             for role in Role.objects.all():
-                perms = role.get_permissions()
+                perms = role.permissions
                 try:
                     perms['forums'][self.pk] = perms['forums'][target.pk]
                     role.set_permissions(perms)

+ 3 - 3
misago/models/usermodel.py

@@ -16,8 +16,8 @@ from misago.acl.builder import build_acl
 from misago.monitor import Monitor
 from misago.dbsettings import DBSettings
 from misago.signals import delete_user_content, rename_user
-from misago.utils.strings import random_string, slugify
 from misago.utils.avatars import avatar_size
+from misago.utils.strings import random_string, slugify
 from misago.validators import validate_username, validate_password, validate_email
 
 class UserManager(models.Manager):
@@ -54,7 +54,7 @@ class UserManager(models.Manager):
 
         # Get first rank
         try:
-            from misago.ranks.models import Rank
+            from misago.models import Rank
             default_rank = Rank.objects.filter(special=0).order_by('order')[0]
         except IndexError:
             default_rank = None
@@ -484,7 +484,7 @@ class User(models.Model):
         return activations[self.activation]
 
     def alert(self, message):
-        from misago.alerts.models import Alert
+        from misago.models import Alert
         self.alerts += 1
         return Alert(user=self, message=message, date=tz_util.now())
 

+ 98 - 0
misago/readstrackers.py

@@ -0,0 +1,98 @@
+from datetime import timedelta
+from django.conf import settings
+from django.utils import timezone
+from misago.models import Thread, ForumRead, ThreadRead
+
+class ForumsTracker(object):
+    def __init__(self, user):
+        self.user = user
+        self.cutoff = timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH)
+        self.forums = {}
+        if user.is_authenticated() and settings.READS_TRACKER_LENGTH > 0:
+            if user.join_date > self.cutoff:
+                self.cutoff = user.join_date
+            for forum in ForumRead.objects.filter(user=user).filter(updated__gte=self.cutoff).values('id', 'forum_id', 'updated', 'cleared'):
+                 self.forums[forum['forum_id']] = forum
+
+    def is_read(self, forum):
+        if not self.user.is_authenticated() or not forum.last_thread_date:
+            return True
+        try:
+            return forum.last_thread_date <= self.cutoff or forum.last_thread_date <= self.forums[forum.pk]['cleared']
+        except KeyError:
+            return False
+
+
+class ThreadsTracker(object):
+    def __init__(self, request, forum):
+        self.need_create = None
+        self.need_update = None
+        self.request = request
+        self.forum = forum
+        self.cutoff = timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH)
+        if request.user.is_authenticated():
+            if request.user.join_date > self.cutoff:
+                self.cutoff = request.user.join_date
+            try:
+                self.record = ForumRead.objects.get(user=request.user, forum=forum)
+                if self.record.cleared > self.cutoff:
+                    self.cutoff = self.record.cleared
+            except ForumRead.DoesNotExist:
+                self.record = ForumRead(user=request.user, forum=forum, cleared=self.cutoff)
+            self.threads = self.record.get_threads()
+
+    def get_read_date(self, thread):
+        if not self.request.user.is_authenticated():
+            return timezone.now()
+        try:
+            if self.threads[thread.pk].updated > self.cutoff:
+                return self.threads[thread.pk].updated
+        except KeyError:
+            pass
+        return self.cutoff
+
+    def is_read(self, thread):
+        if not self.request.user.is_authenticated():
+            return True
+        try:
+            return thread.last <= self.cutoff or thread.last <= self.threads[thread.pk].updated
+        except KeyError:
+            return False
+
+    def set_read(self, thread, post):
+        if self.request.user.is_authenticated() and post.date > self.cutoff:
+            try:
+                self.threads[thread.pk].updated = post.date
+                self.need_update = thread
+            except KeyError:
+                self.need_create = thread
+
+    def sync(self):
+        now = timezone.now()
+
+        if self.need_create:
+            new_record = ThreadRead(
+                                      user=self.request.user,
+                                      thread=self.need_create,
+                                      forum=self.forum,
+                                      updated=now
+                                      )
+            new_record.save(force_insert=True)
+            self.threads[new_record.thread_id] = new_record
+
+        if self.need_update:
+            self.need_update.updated = now
+            self.need_update.save(force_update=True)
+
+        if self.need_create or self.need_update:
+            unread_threads = 0
+            for thread in self.request.acl.threads.filter_threads(self.request, self.forum, self.forum.thread_set.filter(last__gte=self.record.cleared)):
+                if not self.is_read(thread):
+                    unread_threads += 1
+            if not unread_threads:
+                self.record.cleared = now
+            self.record.updated = now
+            if self.record.pk:
+                self.record.save(force_update=True)
+            else:
+                self.record.save(force_insert=True)

+ 85 - 0
misago/search/__init__.py

@@ -0,0 +1,85 @@
+from django.utils.translation import ugettext_lazy as _
+
+class SearchException(Exception):
+    def __init__(self, message):
+        self.message = message
+
+
+class SearchQuery(object):
+    def __init__(self, raw_query=None):
+        """
+        Build search query object
+        """
+        if raw_query:
+            self.parse_query(raw_query)
+        
+    def parse_query(self, raw_query):
+        """
+        Parse raw search query into dict of lists of words that should be found and cant be found in string
+        """
+        self.criteria = {'+': [], '-': []}
+        for word in unicode(raw_query).split():
+            # Trim word and skip it if its empty
+            word = unicode(word).strip().lower()
+            if len(word) == 0:
+                pass
+            
+            # Find word mode
+            mode = '+'
+            if word[0] == '-':
+                mode = '-'
+                word = unicode(word[1:]).strip()
+                
+            # Strip extra crap
+            word = ''.join(e for e in word if e.isalnum())
+            
+            # Slice word?
+            if len(word) <= 3:
+                raise SearchException(_("One or more search phrases are shorter than four characters."))
+            if mode == '+':
+                if len(word) == 5:
+                    word = word[0:-1]
+                if len(word) == 6:
+                    word = word[0:-2]
+                if len(word) > 6:
+                    word = word[0:-3]
+            self.criteria[mode].append(word)
+            
+        # Complain that there are no positive matches
+        if not self.criteria['+'] and not self.criteria['-']:
+            raise SearchException(_("Search query is invalid."))
+    
+    def search(self, value):
+        """
+        See if value meets search criteria, return True for success and False otherwhise
+        """
+        try:
+            value = unicode(value).strip().lower()
+            # Search for only
+            if self.criteria['+'] and not self.criteria['-']:
+               return self.search_for(value)
+            # Search against only
+            if self.criteria['-'] and not self.criteria['+']:
+               return self.search_against(value)
+            # Search if contains for values but not against values
+            return self.search_for(value) and not self.search_against(value)
+        except AttributeError:
+            raise SearchException(_("You have to define search query before you will be able to search."))
+        
+    def search_for(self, value):
+        """
+        See if value is required
+        """
+        for word in self.criteria['+']:
+            if value.find(word) != -1:
+                return True
+        return False
+        
+    def search_against(self, value):
+        """
+        See if value is forbidden
+        """
+        for word in self.criteria['-']:
+            if value.find(word) != -1:
+                return True
+        return False

+ 2 - 2
misago/sessions.py

@@ -5,9 +5,9 @@ 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.auth.methods import auth_remember, AuthException
+from misago.auth import auth_remember, AuthException
 from misago.models import Session, Token, Guest, User
-from misago.utils.string import random_string
+from misago.utils.strings import random_string
 
 # Assert models are loaded
 if not model_cache.loaded:

+ 19 - 19
misago/settings_base.py

@@ -86,9 +86,9 @@ JINJA2_EXTENSIONS = (
 
 # List of application middlewares
 MIDDLEWARE_CLASSES = (
-    'misago.stopwatch.middleware.StopwatchMiddleware',
+    'misago.middleware.stopwatch.StopwatchMiddleware',
     'debug_toolbar.middleware.DebugToolbarMiddleware',
-    'misago.heartbeat.middleware.HeartbeatMiddleware',
+    'misago.middleware.heartbeat.HeartbeatMiddleware',
     'misago.middleware.cookiejar.CookieJarMiddleware',
     'misago.middleware.settings.SettingsMiddleware',
     'misago.middleware.monitor.MonitorMiddleware',
@@ -100,36 +100,36 @@ MIDDLEWARE_CLASSES = (
     'misago.middleware.csrf.CSRFMiddleware',
     'misago.middleware.banning.BanningMiddleware',
     'misago.middleware.messages.MessagesMiddleware',
-    'misago.middleware.users.UserMiddleware',
+    'misago.middleware.user.UserMiddleware',
     'misago.middleware.acl.ACLMiddleware',
     'django.middleware.common.CommonMiddleware',
 )
 
 # List of application permission providers
 PERMISSION_PROVIDERS = (
-    'misago.usercp.acl',
-    'misago.users.acl',
-    'misago.admin.acl',
-    'misago.forums.acl',
-    'misago.threads.acl',
+    'misago.acl.permissions.admin',
+    'misago.acl.permissions.usercp',
+    'misago.acl.permissions.users',
+    'misago.acl.permissions.forums',
+    'misago.acl.permissions.threads',
 )
 
 # List of UserCP extensions
 USERCP_EXTENSIONS = (
-    'misago.usercp.options',
-    'misago.usercp.avatar',
-    'misago.usercp.signature',
-    'misago.usercp.credentials',
-    'misago.usercp.username',
+    'misago.core.front.usercp.options',
+    'misago.core.front.usercp.avatar',
+    'misago.core.front.usercp.signature',
+    'misago.core.front.usercp.credentials',
+    'misago.core.front.usercp.username',
 )
 
 # List of User Profile extensions
 PROFILE_EXTENSIONS = (
-    'misago.profiles.posts',
-    'misago.profiles.threads',
-    'misago.profiles.follows',
-    'misago.profiles.followers',
-    'misago.profiles.details',
+    'misago.core.front.profiles.posts',
+    'misago.core.front.profiles.threads',
+    'misago.core.front.profiles.follows',
+    'misago.core.front.profiles.followers',
+    'misago.core.front.profiles.details',
 )
 
 # List of Markdown Extensions
@@ -170,7 +170,7 @@ DEBUG_TOOLBAR_PANELS = (
     'debug_toolbar.panels.timer.TimerDebugPanel',
     'debug_toolbar.panels.sql.SQLDebugPanel',
     'debug_toolbar.panels.request_vars.RequestVarsDebugPanel',
-    #'misago.acl.panels.MisagoACLDebugPanel',
+    'misago.acl.panels.MisagoACLDebugPanel',
     'debug_toolbar.panels.headers.HeaderDebugPanel',
     'debug_toolbar.panels.template.TemplateDebugPanel',
     'debug_toolbar.panels.settings_vars.SettingsVarsDebugPanel',

+ 19 - 0
misago/templatetags/datetime.py

@@ -0,0 +1,19 @@
+from coffin.template import Library
+from misago.utils.datesformats import date, reldate, reltimesince
+
+register = Library()
+
+
+@register.filter(name='date')
+def date_filter(val, arg=""):
+    return date(val, arg)
+
+
+@register.filter(name='reldate')
+def reldate_filter(val, arg=""):
+    return reldate(val, arg)
+
+
+@register.filter(name='reltimesince')
+def reltimesince_filter(val, arg=""):
+    return reltimesince(val, arg)

+ 34 - 0
misago/templatetags/django2jinja.py

@@ -0,0 +1,34 @@
+import math
+import urllib
+from coffin.template import Library
+from django.conf import settings
+from misago.utils.strings import slugify
+
+register = Library()
+
+
+@register.object(name='widthratio')
+def widthratio(min=0, max=100, range=100):
+    return int(math.ceil(float(float(min) / float(max) * int(range))))
+
+
+@register.object(name='query')
+def query_string(**kwargs):
+    query = urllib.urlencode(kwargs)
+    return '?%s' % (query if kwargs else '')
+
+
+@register.filter(name='low')
+def low(value):
+    if not value:
+        return u''
+    try:
+        rest = value[1:]
+    except IndexError:
+        rest = ''
+    return '%s%s' % (unicode(value[0]).lower(), rest)
+
+
+@register.filter(name="slugify")
+def slugify_function(format_string):
+    return slugify(format_string)

+ 38 - 0
misago/templatetags/markdown.py

@@ -0,0 +1,38 @@
+import markdown
+from coffin.template import Library
+from django.conf import settings
+import misago.markdown
+
+register = Library()
+
+
+@register.filter(name='markdown')
+def parse_markdown(value, format=None):
+    if not format:
+        format = settings.OUTPUT_FORMAT
+    return markdown.markdown(value, safe_mode='escape', output_format=format).strip()
+
+
+@register.filter(name='markdown_short')
+def short_markdown(value, length=300):
+    value = misago.markdown.clear_markdown(value)
+
+    if len(value) <= length:
+        return ' '.join(value.splitlines())
+
+    value = ' '.join(value.splitlines())
+    value = value[0:length]
+
+    while value[-1] != ' ':
+        value = value[0:-1]
+
+    value = value.strip()
+    if value[-3:3] != '...':
+        value = '%s...' % value
+
+    return value
+
+
+@register.filter(name='markdown_final')
+def finalize_markdown(value):
+    return misago.markdown.finalize_markdown(value)

+ 26 - 25
misago/urls.py

@@ -5,31 +5,33 @@ from misago.admin import ADMIN_PATH, site
 
 # Include frontend patterns
 urlpatterns = patterns('',
-    (r'^', include('misago.shared.signin.urls')),
-    """
-    (r'^users/', include('misago.profiles.urls')),
-    (r'^usercp/', include('misago.usercp.urls')),
-    (r'^register/', include('misago.register.urls')),
-    (r'^activate/', include('misago.activation.urls')),
-    (r'^reset-password/', include('misago.resetpswd.urls')),
-    (r'^', include('misago.threads.urls')),
-    (r'^', include('misago.watcher.urls')),
-    url(r'^category/(?P<slug>(\w|-)+)-(?P<forum>\d+)/$', 'misago.views.category', name="category"),
-    url(r'^redirect/(?P<slug>(\w|-)+)-(?P<forum>\d+)/$', 'misago.views.redirection', name="redirect"),
-    url(r'^$', 'misago.views.home', name="index"),
-    url(r'^alerts/$', 'misago.alerts.views.show_alerts', name="alerts"),
-    url(r'^news/$', 'misago.newsfeed.views.newsfeed', name="newsfeed"),
-    url(r'^tos/$', 'misago.tos.views.forum_tos', name="tos"),
-    url(r'^read/$', 'misago.views.read_all', name="read_all"),
-    url(r'^forum-map/$', 'misago.views.forum_map', name="forum_map"),
-    url(r'^popular/$', 'misago.views.popular_threads', name="popular_threads"),
-    url(r'^popular/(?P<page>[0-9]+)/$', 'misago.views.popular_threads', name="popular_threads"),
-    url(r'^new/$', 'misago.views.new_threads', name="new_threads"),
-    url(r'^new/(?P<page>[0-9]+)/$', 'misago.views.new_threads', name="new_threads"),
-    """
+    url(r'^$', 'misago.core.front.index.index', name="index"),
+    url(r'^read-all/$', 'misago.core.front.readall.read_all', name="read_all"),
+    (r'^', include('misago.core.signin.urls')),
+    # Remove after ACP was refactored
+    url(r'^users/(?P<username>\w+)-(?P<user>\d+)/$', 'misago.core.admin.adminindex.todo', name="user"),    
 )
 
 """
+(r'^users/', include('misago.profiles.urls')),
+(r'^usercp/', include('misago.usercp.urls')),
+(r'^register/', include('misago.register.urls')),
+(r'^activate/', include('misago.activation.urls')),
+(r'^reset-password/', include('misago.resetpswd.urls')),
+(r'^', include('misago.threads.urls')),
+(r'^', include('misago.watcher.urls')),
+url(r'^category/(?P<slug>(\w|-)+)-(?P<forum>\d+)/$', 'misago.views.category', name="category"),
+url(r'^redirect/(?P<slug>(\w|-)+)-(?P<forum>\d+)/$', 'misago.views.redirection', name="redirect"),
+url(r'^alerts/$', 'misago.alerts.views.show_alerts', name="alerts"),
+url(r'^news/$', 'misago.newsfeed.views.newsfeed', name="newsfeed"),
+url(r'^tos/$', 'misago.tos.views.forum_tos', name="tos"),
+url(r'^forum-map/$', 'misago.views.forum_map', name="forum_map"),
+url(r'^popular/$', 'misago.views.popular_threads', name="popular_threads"),
+url(r'^popular/(?P<page>[0-9]+)/$', 'misago.views.popular_threads', name="popular_threads"),
+url(r'^new/$', 'misago.views.new_threads', name="new_threads"),
+url(r'^new/(?P<page>[0-9]+)/$', 'misago.views.new_threads', name="new_threads"),
+"""
+
 # Include admin patterns
 if ADMIN_PATH:
     urlpatterns += patterns('',
@@ -43,6 +45,5 @@ if settings.DEBUG:
     )
 
 # Set error handlers
-handler403 = 'misago.views.error403'
-handler404 = 'misago.views.error404'
-"""
+handler403 = 'misago.core.views.error403'
+handler404 = 'misago.core.views.error404'

+ 1 - 1
misago/validators.py

@@ -45,7 +45,7 @@ def validate_username(value, db_settings):
     else:
         if not re.search('^[^\W_]+$', value):
             raise ValidationError(_("Username can only contain latin alphabet letters and digits."))
-
+    
     if Ban.objects.check_ban(username=value):
         raise ValidationError(_("This username is forbidden."))
 

+ 3 - 8
templates/admin/home.html → templates/admin/index.html

@@ -25,17 +25,12 @@
 <div class="row">
   <div class="span8">
   	
-  	<h2>Administrators Online</h2>
-    <table class="table table-striped table-users list-tiny">
-      <thead>
-        <tr>
-          <th{% if admins|length > 1 %} colspan="2"{% endif %}>{% trans count=admins|length, total=admins|length|intcomma -%}
+  	<h2>{% trans count=admins|length, total=admins|length|intcomma -%}
 One Administrator Online
 {%- pluralize -%}
 {{ total }} Administrators Online
-{%- endtrans %}</th>
-        </tr>
-      </thead>
+{%- endtrans %}</h2>
+    <table class="table table-striped table-users list-tiny">
       <tbody>
         {% for session in admins %}    	
         <tr>

+ 0 - 2
templates/admin/layout_compact.html

@@ -1,6 +1,4 @@
 {% extends "admin/base.html" %}
-{% load i18n %}
-{% load url from future %}
 
 {% block body_class %} class="layout-compact"{% endblock %}
 

+ 0 - 2
templates/admin/signin.html

@@ -1,6 +1,4 @@
 {% extends "admin/layout_compact.html" %}
-{% load i18n %}
-{% load url from future %}
 {% import "_forms.html" as form_theme with context %}
 {% from "admin/macros.html" import page_title, draw_message_icon %}