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

Merge pull request #290 from rafalp/warnings

Merged in warnings feature branch
Rafał Pitoń 11 лет назад
Родитель
Сommit
ddb713e728
78 измененных файлов с 2136 добавлено и 143 удалено
  1. 1 0
      .gitignore
  2. 2 2
      docs/conf.py
  3. 2 12
      docs/index.rst
  4. 3 3
      misago/acl/builder.py
  5. 2 2
      misago/acl/panels.py
  6. 117 3
      misago/acl/permissions/warnings.py
  7. 2 3
      misago/apps/activation/forms.py
  8. 7 2
      misago/apps/admin/prefixes/forms.py
  9. 6 0
      misago/apps/admin/prefixes/views.py
  10. 7 2
      misago/apps/admin/ranks/views.py
  11. 30 1
      misago/apps/admin/sections/users.py
  12. 1 1
      misago/apps/admin/users/views.py
  13. 0 0
      misago/apps/admin/warninglevels/__init__.py
  14. 34 0
      misago/apps/admin/warninglevels/forms.py
  15. 165 0
      misago/apps/admin/warninglevels/views.py
  16. 1 2
      misago/apps/newthreads.py
  17. 6 2
      misago/apps/profiles/decorators.py
  18. 1 1
      misago/apps/profiles/details/profile.py
  19. 1 1
      misago/apps/profiles/followers/profile.py
  20. 1 1
      misago/apps/profiles/follows/profile.py
  21. 1 1
      misago/apps/profiles/posts/profile.py
  22. 3 3
      misago/apps/profiles/template.py
  23. 1 1
      misago/apps/profiles/threads/profile.py
  24. 0 0
      misago/apps/profiles/warnings/__init__.py
  25. 5 0
      misago/apps/profiles/warnings/profile.py
  26. 20 0
      misago/apps/profiles/warnings/urls.py
  27. 89 0
      misago/apps/profiles/warnings/views.py
  28. 21 0
      misago/apps/profiles/warnings/warningstracker.py
  29. 9 1
      misago/apps/register/forms.py
  30. 2 3
      misago/apps/resetpswd/forms.py
  31. 1 0
      misago/apps/search/views.py
  32. 16 1
      misago/apps/threads/posting.py
  33. 6 6
      misago/apps/threadtype/delete.py
  34. 6 0
      misago/apps/threadtype/list/moderation.py
  35. 17 12
      misago/apps/threadtype/posting/base.py
  36. 1 1
      misago/apps/threadtype/posting/editthread.py
  37. 12 3
      misago/apps/threadtype/posting/newreply.py
  38. 12 3
      misago/apps/threadtype/posting/newthread.py
  39. 17 2
      misago/apps/threadtype/thread/moderation/posts.py
  40. 6 0
      misago/apps/threadtype/thread/moderation/thread.py
  41. 0 0
      misago/apps/warnuser/__init__.py
  42. 13 0
      misago/apps/warnuser/alerts.py
  43. 9 0
      misago/apps/warnuser/forms.py
  44. 97 0
      misago/apps/warnuser/views.py
  45. 2 2
      misago/auth.py
  46. 6 3
      misago/conf.py
  47. 12 0
      misago/fixtures/userroles.py
  48. 1 0
      misago/forms/fields.py
  49. 20 0
      misago/markdown/factory.py
  50. 3 3
      misago/middleware/acl.py
  51. 561 0
      misago/migrations/0035_auto__add_warn__add_warnlevel__add_field_user_warning_level__add_field.py
  52. 2 0
      misago/models/__init__.py
  53. 2 2
      misago/models/forummodel.py
  54. 1 2
      misago/models/postmodel.py
  55. 1 2
      misago/models/threadmodel.py
  56. 120 5
      misago/models/usermodel.py
  57. 59 0
      misago/models/warnlevelmodel.py
  58. 37 0
      misago/models/warnmodel.py
  59. 15 11
      misago/settings_base.py
  60. 6 2
      misago/templatetags/datetime.py
  61. 1 0
      misago/urls.py
  62. 60 11
      misago/utils/datesformats.py
  63. 1 1
      requirements.txt
  64. 14 0
      static/cranefly/css/cranefly.css
  65. 111 1
      static/cranefly/css/cranefly/profiles.less
  66. 11 6
      static/cranefly/js/cranefly.js
  67. 16 0
      templates/admin/warning_levels/form.html
  68. 17 0
      templates/admin/warning_levels/list.html
  69. 3 1
      templates/cranefly/editor.html
  70. 12 7
      templates/cranefly/private_threads/thread.html
  71. 12 0
      templates/cranefly/profiles/profile.html
  72. 3 3
      templates/cranefly/profiles/threads.html
  73. 147 0
      templates/cranefly/profiles/warnings.html
  74. 13 4
      templates/cranefly/threads/thread.html
  75. 2 1
      templates/cranefly/usercp/signature.html
  76. 85 0
      templates/cranefly/warn_user/form.html
  77. 26 0
      templates/cranefly/warn_user/max_level.html
  78. 1 1
      templates/forms.html

+ 1 - 0
.gitignore

@@ -173,6 +173,7 @@ debug_toolbar/**
 dev/**
 django/**
 django_jinja/**
+docs/_build/**
 floppyforms/**
 haystack/**
 jinja2/**

+ 2 - 2
docs/conf.py

@@ -51,9 +51,9 @@ copyright = u'2013, Rafał Pitoń'
 # built documents.
 #
 # The short X.Y version.
-version = '0.4'
+version = '0.5'
 # The full version, including alpha/beta/rc tags.
-release = '0.4'
+release = '0.5'
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.

+ 2 - 12
docs/index.rst

@@ -6,17 +6,7 @@
 Welcome to Misago's documentation!
 ==================================
 
-Contents:
+This documentation is only placeholder.
 
-.. toctree::
-   :maxdepth: 2
-
-
-
-Indices and tables
-==================
-
-* :ref:`genindex`
-* :ref:`modindex`
-* :ref:`search`
+Real documentation is being writen on "cleanup" branch as progress is made in Misago's refactoring.
 

+ 3 - 3
misago/acl/builder.py

@@ -53,19 +53,19 @@ class ACL(object):
                 yield self.__dict__[attr]
 
 
-def acl(request, user):
+def acl(user):
     acl_key = user.make_acl_key()
     try:
         user_acl = cache.get(acl_key)
         if user_acl.version != monitor['acl_version']:
             raise InvalidCacheBackendError()
     except (AttributeError, InvalidCacheBackendError):
-        user_acl = build_acl(request, user.get_roles())
+        user_acl = build_acl(user.get_roles())
         cache.set(acl_key, user_acl, 2592000)
     return user_acl
 
 
-def build_acl(request, roles):
+def build_acl(roles):
     new_acl = ACL(monitor['acl_version'])
     forums = Forum.objects.get(special='root').get_descendants().order_by('lft')
     perms = []

+ 2 - 2
misago/acl/panels.py

@@ -1,8 +1,8 @@
-from debug_toolbar.panels import DebugPanel
+from debug_toolbar.panels import Panel
 from django.template.loader import render_to_string
 from django.utils.translation import ugettext_lazy as _
 
-class MisagoACLDebugPanel(DebugPanel):
+class MisagoACLDebugPanel(Panel):
     name = 'MisagoACL'
     has_content = True
 

+ 117 - 3
misago/acl/permissions/warnings.py

@@ -1,31 +1,145 @@
+from django.utils import timezone
 from django.utils.translation import ugettext_lazy as _
 import floppyforms as forms
 from misago.acl.builder import BaseACL
+from misago.acl.exceptions import ACLError403, ACLError404
 from misago.forms import YesNoSwitch
 
 def make_form(request, role, form):
     if role.special != 'guest':
-        form.base_fields['can_warn_members'] = forms.BooleanField(label=_("Can warn members"),
+        form.base_fields['can_warn_members'] = forms.BooleanField(label=_("Can warn other members"),
                                                                   widget=YesNoSwitch, initial=False, required=False)
+        form.base_fields['can_see_other_members_warns'] = forms.BooleanField(label=_("Can see other members warnings"),
+                                                                             widget=YesNoSwitch, initial=False, required=False)
+        form.base_fields['can_cancel_warnings'] = forms.TypedChoiceField(label=_("Can cancel warnings"),
+                                                                         widget=forms.Select, initial=0, coerce=int,
+                                                                         choices=(
+                                                                            (0, _("No")),
+                                                                            (1, _("If is warning giver")),
+                                                                            (2, _("Yes, all warnings")),
+                                                                         ))
+        form.base_fields['can_cancel_warnings_newer_than'] = forms.IntegerField(label=_("Maximum age of warning that can be canceled (in minutes)"),
+                                                                                help_text=_("Enter zero to disable this limitation."),
+                                                                                min_value=0, initial=15)
+        form.base_fields['can_delete_warnings'] = forms.BooleanField(label=_("Can delete warnings"),
+                                                                     widget=YesNoSwitch, initial=False, required=False)
+        form.base_fields['can_be_warned'] = forms.BooleanField(label=_("Can be warned"),
+                                                               widget=YesNoSwitch, initial=False, required=False)
 
         form.fieldsets.append((
                                _("Warning Members"),
-                               ('can_warn_members',)
+                               ('can_warn_members', 'can_see_other_members_warns',
+                                'can_cancel_warnings', 'can_cancel_warnings_newer_than',
+                                'can_delete_warnings', 'can_be_warned',)
                               ))
 
 
 class WarningsACL(BaseACL):
+    def allow_warning_members(self):
+        if not self.acl['can_warn_members']:
+            raise ACLError403(_("You can't warn other members."))
+
     def can_warn_members(self):
-        return self.acl['can_warn_members']
+        try:
+            self.allow_warning_members()
+            return True
+        except ACLError403:
+            return False
+
+    def allow_member_warns_view(self, user, other_user):
+        try:
+            if user.pk == other_user.pk:
+                return
+        except AttributeError:
+            pass
+        if not self.acl['can_see_other_members_warns']:
+            raise ACLError403(_("You don't have permission to see this member warnings."))
+
+    def can_see_member_warns(self, user, other_user):
+        try:
+            self.allow_member_warns_view(user, other_user)
+            return True
+        except ACLError403:
+            return False
+
+    def allow_warning(self):
+        if not self.acl['can_be_warned']:
+            raise ACLError403(_("This member can't be warned."))
+
+    def can_be_warned(self):
+        try:
+            self.allow_warning()
+            return True
+        except ACLError403:
+            return False
+
+    def allow_cancel_warning(self, user, owner, warning):
+        if not self.acl['can_cancel_warnings']:
+            raise ACLError403(_("You can't cancel warnings."))
+
+        if warning.canceled:
+            raise ACLError403(_("This warning is already canceled."))
+
+        if not owner.is_warning_active(warning):
+            raise ACLError403(_("This warning is no longer in effect."))
+
+        try:
+            if (self.acl['can_cancel_warnings'] == 1 and
+                    user.id != warning.giver_id):
+                raise ACLError403(_("You can't cancel other moderators warnings."))
+        except AttributeError:
+            pass
+
+        warning_age = timezone.now() - warning.given_on
+        warning_age = warning_age.seconds + warning_age.days * 86400
+        warning_age /= 60
+
+        if (self.acl['can_cancel_warnings_newer_than'] > 0 and
+                self.acl['can_cancel_warnings_newer_than'] < warning_age):
+            raise ACLError403(_("This warning can no longer be canceled."))
+
+    def can_cancel_warning(self, user, owner, warning):
+        try:
+            self.allow_cancel_warning(user, owner, warning)
+            return True
+        except ACLError403:
+            return False
+
+    def allow_delete_warning(self):
+        if not self.acl['can_delete_warnings']:
+            raise ACLError403(_("You can't delete user warnings."))
+
+    def can_delete_warnings(self):
+        try:
+            self.allow_delete_warning()
+            return True
+        except ACLError403:
+            return False
 
 
 def build(acl, roles):
     acl.warnings = WarningsACL()
     acl.warnings.acl['can_warn_members'] = False
+    acl.warnings.acl['can_see_other_members_warns'] = False
+    acl.warnings.acl['can_be_warned'] = True
+    acl.warnings.acl['can_cancel_warnings'] = 0
+    acl.warnings.acl['can_cancel_warnings_newer_than'] = 5
+    acl.warnings.acl['can_delete_warnings'] = False
 
     for role in roles:
         try:
             if role['can_warn_members']:
                 acl.warnings.acl['can_warn_members'] = True
+            if role['can_see_other_members_warns']:
+                acl.warnings.acl['can_see_other_members_warns'] = True
+            if role['can_be_warned']:
+                acl.warnings.acl['can_be_warned'] = True
+            if role['can_cancel_warnings'] > acl.warnings.acl['can_cancel_warnings']:
+                acl.warnings.acl['can_cancel_warnings'] = role['can_cancel_warnings']
+            if (role['can_cancel_warnings_newer_than'] == 0
+                    or role['can_cancel_warnings_newer_than'] > acl.warnings.acl['can_cancel_warnings_newer_than']):
+                acl.warnings.acl['can_cancel_warnings_newer_than'] = role['can_cancel_warnings_newer_than']
+            if role['can_delete_warnings']:
+                acl.warnings.acl['can_delete_warnings'] = True
         except KeyError:
             pass

+ 2 - 3
misago/apps/activation/forms.py

@@ -15,9 +15,8 @@ class UserSendActivationMailForm(Form):
 
     def clean_email(self):
         try:
-            email = self.cleaned_data['email'].lower()
-            email_hash = hashlib.md5(email).hexdigest()
-            self.found_user = User.objects.get(email_hash=email_hash)
+            self.found_user = User.objects.get_by_email(
+                self.cleaned_data['email'])
         except User.DoesNotExist:
             raise ValidationError(_("There is no user with such e-mail address."))
         return email

+ 7 - 2
misago/apps/admin/prefixes/forms.py

@@ -1,10 +1,10 @@
 from django.utils.translation import ugettext_lazy as _
 import floppyforms as forms
 from misago.forms import Form, ForumMultipleChoiceField
-from misago.models import Role, Forum
+from misago.models import Forum
 from misago.validators import validate_sluggable
 
-class PrefixForm(Form):
+class PrefixFormBase(Form):
     name = forms.CharField(label=_("Prefix Name"),
                            max_length=16, validators=[validate_sluggable(
                                                                           _("Prefix must contain alphanumeric characters."),
@@ -13,7 +13,12 @@ class PrefixForm(Form):
     style = forms.CharField(label=_("Prefix CSS Class"),
                             help_text=_("CSS class that will be used to style this thread prefix."),
                             max_length=255, required=False)
+
+
+def PrefixForm(*args, **kwargs):
     forums = ForumMultipleChoiceField(label=_("Prefix Forums"),
                                       help_text=_("Select forums in which this prefix will be available."),
                                       level_indicator=u'- - ',
                                       queryset=Forum.objects.get(special='root').get_descendants())
+
+    return type('FinalPrefixForm', (PrefixFormBase,), {'forums': forums})

+ 6 - 0
misago/apps/admin/prefixes/views.py

@@ -57,6 +57,9 @@ class New(FormWidget):
     def get_edit_link(self, model):
         return reverse('admin_threads_prefixes_edit', model)
 
+    def get_form(self, target):
+        return self.form()
+
     def submit_form(self, form, target):
         new_prefix = ThreadPrefix(
                                   name=form.cleaned_data['name'],
@@ -93,6 +96,9 @@ class Edit(FormWidget):
                 'forums': model.forums.all(),
                 }
 
+    def get_form(self, target):
+        return self.form()
+
     def submit_form(self, form, target):
         target.name = form.cleaned_data['name']
         target.slug = slugify(form.cleaned_data['name'])

+ 7 - 2
misago/apps/admin/ranks/views.py

@@ -82,7 +82,12 @@ class New(FormWidget):
 
     def submit_form(self, form, target):
         position = 0
-        last_rank = Rank.objects.latest('order')
+        last_rank = Rank.objects.order_by('-order')[:1]
+        if last_rank:
+            new_last_order = last_rank[0].order + 1
+        else:
+            new_last_order = 0
+
         new_rank = Rank(
                         name=form.cleaned_data['name'],
                         slug=slugify(form.cleaned_data['name']),
@@ -92,7 +97,7 @@ class New(FormWidget):
                         special=form.cleaned_data['special'],
                         as_tab=form.cleaned_data['as_tab'],
                         on_index=form.cleaned_data['on_index'],
-                        order=(last_rank.order + 1 if last_rank else 0),
+                        order=new_last_order,
                         criteria=form.cleaned_data['criteria']
                         )
         new_rank.save(force_insert=True)

+ 30 - 1
misago/apps/admin/sections/users.py

@@ -1,7 +1,7 @@
 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
+from misago.models import Ban, Newsletter, PruningPolicy, Rank, User, WarnLevel
 
 ADMIN_ACTIONS = (
     AdminAction(
@@ -66,6 +66,35 @@ ADMIN_ACTIONS = (
                 ),
     AdminAction(
                 section='users',
+                id='warning_levels',
+                name=_("Warning Levels"),
+                help=_("Define penalties for different warning levels."),
+                icon='exclamation-sign',
+                model=WarnLevel,
+                actions=[
+                         {
+                          'id': 'list',
+                          'name': _("Browse Warning Levels"),
+                          'help': _("Browse all existing warning levels"),
+                          'link': 'admin_warning_levels'
+                          },
+                         {
+                          'id': 'new',
+                          'name': _("Set New Warning Level"),
+                          'help': None,
+                          'link': 'admin_warning_levels_new'
+                          },
+                         ],
+                link='admin_warning_levels',
+                urlpatterns=patterns('misago.apps.admin.warninglevels.views',
+                         url(r'^$', 'List', name='admin_warning_levels'),
+                         url(r'^new/$', 'New', name='admin_warning_levels_new'),
+                         url(r'^edit/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Edit', name='admin_warning_levels_edit'),
+                         url(r'^delete/(?P<slug>([a-z0-9]|-)+)-(?P<target>\d+)/$', 'Delete', name='admin_warning_levels_delete'),
+                     ),
+                ),
+    AdminAction(
+                section='users',
                 id='bans',
                 name=_("Bans"),
                 help=_("Ban or unban users from forums."),

+ 1 - 1
misago/apps/admin/users/views.py

@@ -65,7 +65,7 @@ class List(ListWidget):
                 if qs:
                     model = model.filter(qs)
             else:
-                model = model.filter(username_slug__contains=filters['username'])
+                model = model.filter(username_slug__contains=filters['username'].lower())
         if 'email' in filters:
             if ',' in filters['email']:
                 qs = None

+ 0 - 0
misago/apps/admin/warninglevels/__init__.py


+ 34 - 0
misago/apps/admin/warninglevels/forms.py

@@ -0,0 +1,34 @@
+from django.utils.translation import ugettext_lazy as _
+import floppyforms as forms
+from misago.forms import Form, YesNoSwitch
+from misago.models import WarnLevel
+from misago.validators import validate_sluggable
+
+class WarnLevelForm(Form):
+    name = forms.CharField(label=_("Warning Level Name"),
+                           max_length=255, validators=[validate_sluggable(
+                                                                          _("Warning level name must contain alphanumeric characters."),
+                                                                          _("Warning level name is too long.")
+                                                                          )])
+    description = forms.CharField(label=_("Warning Level Description"),
+                                  help_text=_("Optional message displayed to members with this warning level."),
+                                  widget=forms.Textarea, required=False)
+    expires_after_minutes = forms.IntegerField(label=_("Warning Level Expiration"),
+                                               help_text=_("Enter number of minutes since this warning level was imposed on member until it's reduced and lower level is imposed, or 0 to make this warning level permanent."),
+                                               initial=0, min_value=0)
+    restrict_posting_replies = forms.TypedChoiceField(
+        label=_("Restrict Replies Posting"),
+        choices=(
+           (WarnLevel.RESTRICT_NO, _("No restrictions")),
+           (WarnLevel.RESTRICT_MODERATOR_REVIEW, _("Review by moderator")),
+           (WarnLevel.RESTRICT_DISALLOW, _("Disallowed")),
+        ),
+        coerce=int, initial=0)
+    restrict_posting_threads = forms.TypedChoiceField(
+        label=_("Restrict Threads Posting"),
+        choices=(
+           (WarnLevel.RESTRICT_NO, _("No restrictions")),
+           (WarnLevel.RESTRICT_MODERATOR_REVIEW, _("Review by moderator")),
+           (WarnLevel.RESTRICT_DISALLOW, _("Disallowed")),
+        ),
+        coerce=int, initial=0)

+ 165 - 0
misago/apps/admin/warninglevels/views.py

@@ -0,0 +1,165 @@
+from django.core.urlresolvers import reverse as django_reverse
+from django.utils.translation import ugettext as _
+import floppyforms as forms
+from misago import messages
+from misago.admin import site
+from misago.apps.admin.widgets import *
+from misago.forms import Form
+from misago.models import WarnLevel
+from misago.utils.strings import slugify
+from misago.apps.admin.warninglevels.forms import WarnLevelForm
+
+def reverse(route, target=None):
+    if target:
+        return django_reverse(route, kwargs={'target': target.pk, 'slug': slugify(target.name)})
+    return django_reverse(route)
+
+
+"""
+Views
+"""
+class List(ListWidget):
+    admin = site.get_action('warning_levels')
+    id = 'list'
+    columns = (
+               ('name', _("Level Name")),
+               )
+    table_form_button = _('Change Warning Levels')
+    nothing_checked_message = _('You have to check at least one warning level.')
+    actions = (
+               ('delete', _("Delete selected levels"), _("Are you sure you want to delete selected warning levels?")),
+               )
+
+    def get_table_form(self, page_items):
+        order_form = {}
+
+        # Build choices list
+        choices = []
+        for i in range(0, len(page_items)):
+           choices.append([str(i), i + 1])
+
+        # Build selectors list
+        position = 0
+        for item in page_items:
+            order_form['pos_' + str(item.pk)] = forms.ChoiceField(choices=choices, initial=str(position))
+            position += 1
+
+        # Turn dict into object
+        return type('OrderWarningLevelsForm', (Form,), order_form)
+
+    def table_action(self, page_items, cleaned_data):
+        for item in page_items:
+            item.warning_level = cleaned_data['pos_' + str(item.pk)]
+            item.save(force_update=True)
+        return Message(_('Warning levels have been changed'), messages.SUCCESS), reverse('admin_warning_levels')
+
+    def sort_items(self, page_items, sorting_method):
+        return page_items.order_by('warning_level')
+
+    def get_item_actions(self, item):
+        return (
+                self.action('pencil', _("Edit Level"), reverse('admin_warning_levels_edit', item)),
+                self.action('remove', _("Delete Level"), reverse('admin_warning_levels_delete', item), post=True, prompt=_("Are you sure you want to delete this warning level?")),
+                )
+
+    def action_delete(self, items, checked):
+        WarnLevel.objects.filter(id__in=checked).delete()
+
+        levels_counter = 1
+        for level in WarnLevel.objects.order_by('warning_level').iterator():
+            if level.warning_level != levels_counter:
+                level.warning_level = levels_counter
+                level.save(force_update=True)
+            levels_counter += 1
+
+        return Message(_('Selected warning levels have been deleted successfully.'), messages.SUCCESS), reverse('admin_warning_levels')
+
+
+class New(FormWidget):
+    admin = site.get_action('warning_levels')
+    id = 'new'
+    fallback = 'admin_warning_levels'
+    form = WarnLevelForm
+    submit_button = _("Save Warning Level")
+
+    def get_new_link(self, model):
+        return reverse('admin_warning_levels_new')
+
+    def get_edit_link(self, model):
+        return reverse('admin_warning_levels_edit', model)
+
+    def submit_form(self, form, target):
+        top_level = WarnLevel.objects.order_by('-warning_level')[:1]
+        if top_level:
+            new_warning_level = top_level[0].warning_level + 1
+        else:
+            new_warning_level = 1
+
+        new_level = WarnLevel(
+                              name=form.cleaned_data['name'],
+                              slug=slugify(form.cleaned_data['name']),
+                              description=form.cleaned_data['description'],
+                              warning_level=new_warning_level,
+                              expires_after_minutes=form.cleaned_data['expires_after_minutes'],
+                              restrict_posting_replies=form.cleaned_data['restrict_posting_replies'],
+                              restrict_posting_threads=form.cleaned_data['restrict_posting_threads']
+                              )
+        new_level.save(force_insert=True)
+        return new_level, Message(_('New warning level has been defined.'), messages.SUCCESS)
+
+
+class Edit(FormWidget):
+    admin = site.get_action('warning_levels')
+    id = 'edit'
+    name = _("Edit Warning Level")
+    fallback = 'admin_warning_levels'
+    form = WarnLevelForm
+    target_name = 'name'
+    notfound_message = _('Requested warning level could not be found.')
+    translate_target_name = False
+    submit_fallback = True
+
+    def get_link(self, model):
+        return reverse('admin_warning_levels_edit', model)
+
+    def get_edit_link(self, model):
+        return self.get_link(model)
+
+    def get_initial_data(self, model):
+        return {
+                'name': model.name,
+                'description': model.description,
+                'expires_after_minutes': model.expires_after_minutes,
+                'restrict_posting_replies': model.restrict_posting_replies,
+                'restrict_posting_threads': model.restrict_posting_threads,
+                }
+
+    def submit_form(self, form, target):
+        target.name = form.cleaned_data['name']
+        target.slug = slugify(form.cleaned_data['name'])
+        target.description = form.cleaned_data['description']
+        target.expires_after_minutes = form.cleaned_data['expires_after_minutes']
+        target.restrict_posting_replies = form.cleaned_data['restrict_posting_replies']
+        target.restrict_posting_threads = form.cleaned_data['restrict_posting_threads']
+        target.save(force_update=True)
+
+        return target, Message(_('Changes in warning level "%(name)s" have been saved.') % {'name': self.original_name}, messages.SUCCESS)
+
+
+class Delete(ButtonWidget):
+    admin = site.get_action('warning_levels')
+    id = 'delete'
+    fallback = 'admin_warning_levels'
+    notfound_message = _('Requested warning level could not be found.')
+
+    def action(self, target):
+        target.delete()
+
+        levels_counter = 1
+        for level in WarnLevel.objects.order_by('warning_level').iterator():
+            if level.warning_level != levels_counter:
+                level.warning_level = levels_counter
+                level.save(force_update=True)
+            levels_counter += 1
+
+        return Message(_('Warning level "%(name)s" has been deleted.') % {'name': _(target.name)}, messages.SUCCESS), False

+ 1 - 2
misago/apps/newthreads.py

@@ -1,5 +1,4 @@
 from datetime import timedelta
-from django.core.urlresolvers import reverse
 from django.http import Http404
 from django.shortcuts import redirect
 from django.template import RequestContext
@@ -17,7 +16,7 @@ def new_threads(request, page=0):
     try:
         pagination = make_pagination(page, items_total, settings.threads_per_page)
     except Http404:
-        return redirect(reverse('new_threads'))
+        return redirect('new_threads')
 
     queryset = queryset.order_by('-start').prefetch_related('forum')[pagination['start']:pagination['stop']];
     if settings.avatars_on_threads_list:

+ 6 - 2
misago/apps/profiles/decorators.py

@@ -2,7 +2,8 @@ from functools import wraps
 from django.conf import settings
 from django.core.urlresolvers import reverse
 from django.shortcuts import redirect
-from misago.apps.errors import error404
+from misago.acl.exceptions import ACLError403, ACLError404
+from misago.apps.errors import error403, error404
 from misago.models import User
 from misago.utils.strings import slugify
 
@@ -23,7 +24,10 @@ def profile_view(fallback='user'):
                 return f(request, user, *args, **kwargs)
             except User.DoesNotExist:
                 return error404(request)
-    
+            except ACLError404:
+                return error404(request)
+            except ACLError403 as e:
+                return error403(request, e.message)
         return wraps(f)(inner_decorator)
     return outer_decorator
 

+ 1 - 1
misago/apps/profiles/details/profile.py

@@ -1,4 +1,4 @@
 from django.utils.translation import ugettext_lazy as _
 
-def register_profile_extension(request):
+def register_profile_extension(request, user):
     return (('user_details', _('Profile Details')),)

+ 1 - 1
misago/apps/profiles/followers/profile.py

@@ -1,4 +1,4 @@
 from django.utils.translation import ugettext_lazy as _
 
-def register_profile_extension(request):
+def register_profile_extension(request, user):
     return (('user_followers', _('Followers')),)

+ 1 - 1
misago/apps/profiles/follows/profile.py

@@ -1,4 +1,4 @@
 from django.utils.translation import ugettext_lazy as _
 
-def register_profile_extension(request):
+def register_profile_extension(request, user):
     return (('user_follows', _('Follows')),)

+ 1 - 1
misago/apps/profiles/posts/profile.py

@@ -1,4 +1,4 @@
 from django.utils.translation import ugettext_lazy as _
 
-def register_profile_extension(request):
+def register_profile_extension(request, user):
     return (('user_posts', _('Posts')),)

+ 3 - 3
misago/apps/profiles/template.py

@@ -9,14 +9,14 @@ def RequestContext(request, context=None):
     if not context:
         context = {}
     context['fallback'] = request.path
-        
+
     # Find out if we ignore or follow this user
     context['follows'] = False
     context['ignores'] = False
     if request.user.is_authenticated() and request.user.pk != context['profile'].pk:
         context['follows'] = request.user.is_following(context['profile'])
         context['ignores'] = request.user.is_ignoring(context['profile'])
-    
+
     # Find out if this user allows us to see his activity
     if request.user.pk != context['profile'].pk:
         if context['profile'].hide_activity == 2:
@@ -44,7 +44,7 @@ def RequestContext(request, context=None):
     for extension in settings.PROFILE_EXTENSIONS:
         profile_module = import_module(extension + '.profile')
         try:
-            append_links = profile_module.register_profile_extension(request)
+            append_links = profile_module.register_profile_extension(request, context['profile'])
             if append_links:
                 for link in append_links:
                     link = list(link)

+ 1 - 1
misago/apps/profiles/threads/profile.py

@@ -1,4 +1,4 @@
 from django.utils.translation import ugettext_lazy as _
 
-def register_profile_extension(request):
+def register_profile_extension(request, user):
     return (('user_threads', _('Threads')),)

+ 0 - 0
misago/apps/profiles/warnings/__init__.py


+ 5 - 0
misago/apps/profiles/warnings/profile.py

@@ -0,0 +1,5 @@
+from django.utils.translation import ugettext_lazy as _
+
+def register_profile_extension(request, user):
+    if request.acl.warnings.can_see_member_warns(request.user, user):
+        return (('user_warnings', _('Warnings')),)

+ 20 - 0
misago/apps/profiles/warnings/urls.py

@@ -0,0 +1,20 @@
+from django.conf.urls import patterns, url
+
+def register_profile_urls(first=False):
+    urlpatterns = []
+    if first:
+        urlpatterns += patterns('misago.apps.profiles.warnings.views',
+            url(r'^$', 'warnings', name="user"),
+            url(r'^$', 'warnings', name="user_warnings"),
+            url(r'^(?P<page>[1-9]([0-9]+)?)/$', 'warnings', name="user_warnings"),
+            url(r'^(?P<warning>\d+)/cancel/$', 'cancel_warning', name="user_warnings_cancel"),
+            url(r'^(?P<warning>\d+)/delete/$', 'delete_warning', name="user_warnings_delete"),
+        )
+    else:
+        urlpatterns += patterns('misago.apps.profiles.warnings.views',
+            url(r'^warnings/$', 'warnings', name="user_warnings"),
+            url(r'^warnings/(?P<page>[1-9]([0-9]+)?)/$', 'warnings', name="user_warnings"),
+            url(r'^warnings/(?P<warning>\d+)/cancel/$', 'cancel_warning', name="user_warnings_cancel"),
+            url(r'^warnings/(?P<warning>\d+)/delete/$', 'delete_warning', name="user_warnings_delete"),
+        )
+    return urlpatterns

+ 89 - 0
misago/apps/profiles/warnings/views.py

@@ -0,0 +1,89 @@
+from datetime import timedelta
+from django.core.cache import cache
+from django.core.urlresolvers import reverse
+from django.http import Http404
+from django.shortcuts import redirect
+from django.utils import timezone
+from django.utils.translation import ugettext as _
+from misago import messages
+from misago.apps.errors import error404
+from misago.apps.profiles.decorators import profile_view
+from misago.apps.profiles.template import RequestContext
+from misago.apps.profiles.warnings.warningstracker import WarningsTracker
+from misago.apps.warnuser import alerts
+from misago.decorators import block_guest, check_csrf
+from misago.models import Warn
+from misago.shortcuts import render_to_response
+from misago.utils.pagination import make_pagination
+
+@profile_view('user_warnings')
+def warnings(request, user, page=0):
+    request.acl.warnings.allow_member_warns_view(request.user, user)
+
+    queryset = user.warning_set
+    count = queryset.count()
+    try:
+        pagination = make_pagination(page, count, 12)
+    except Http404:
+        return redirect(reverse('user_warnings', kwargs={'user': user.id, 'username': user.username_slug}))
+
+    return render_to_response('profiles/warnings.html',
+                              context_instance=RequestContext(request, {
+                                  'profile': user,
+                                  'tab': 'warnings',
+                                  'items_total': count,
+                                  'warning_level': user.get_current_warning_level(),
+                                  'warnings_tracker': WarningsTracker(user.warning_level - pagination['start']),
+                                  'items': queryset.order_by('-id')[pagination['start']:pagination['stop']],
+                                  'pagination': pagination,
+                                  }));
+
+def warning_decorator(f):
+    def decorator(*args, **kwargs):
+        request, user = args
+        request.acl.warnings.allow_member_warns_view(request.user, user)
+        warning_pk = kwargs['warning']
+        try:
+            warning = user.warning_set.get(pk=warning_pk)
+            f(request, user, warning)
+            return redirect('user_warnings', username=user.username_slug, user=user.pk)
+        except Warn.DoesNotExist:
+            return error404(request, _("Requested warning could not be found."))
+    return decorator
+
+
+@block_guest
+@check_csrf
+@profile_view('user_warnings_cancel')
+@warning_decorator
+def cancel_warning(request, user, warning):
+    request.acl.warnings.allow_cancel_warning(
+        request.user, user, warning)
+
+    user.decrease_warning_level()
+    warning.canceled = True
+    warning.canceled_on = timezone.now()
+    warning.canceler = request.user
+    warning.canceler_username = request.user.username
+    warning.canceler_slug = request.user.username_slug
+    warning.canceler_ip = request.session.get_ip(request)
+    warning.canceler_agent = request.META.get('HTTP_USER_AGENT')
+    warning.save(force_update=True)
+
+    alerts.your_warn_has_been_canceled(request.user, user)
+    messages.success(request, _("Selected warning has been canceled."))
+
+
+@block_guest
+@check_csrf
+@profile_view('user_warnings_delete')
+@warning_decorator
+def delete_warning(request, user, warning):
+    request.acl.warnings.allow_delete_warning()
+
+    if user.is_warning_active(warning):
+        alerts.your_warn_has_been_canceled(request.user, user)
+        user.decrease_warning_level()
+    warning.delete()
+
+    messages.success(request, _("Selected warning has been deleted."))

+ 21 - 0
misago/apps/profiles/warnings/warningstracker.py

@@ -0,0 +1,21 @@
+class WarningsTracker(object):
+    def __init__(self, warning_level):
+        self.warning_level = warning_level
+        self._checked = {}
+
+    def check_warning(self, warning):
+        if self.warning_level > 0 and not warning.canceled:
+            self.warning_level -= 1
+            return True
+        else:
+            return False
+
+    def is_warning_active(self, warning):
+        try:
+            return self._checked[warning.pk]
+        except KeyError:
+            self._checked[warning.pk] = self.check_warning(warning)
+            return self._checked[warning.pk]
+
+    def is_warning_expired(self, warning):
+        return not self.is_warning_active(warning)

+ 9 - 1
misago/apps/register/forms.py

@@ -9,7 +9,6 @@ from misago.validators import validate_username, validate_password, validate_ema
 
 class UserRegisterForm(Form):
     username = forms.CharField(label=_('Username'),
-                               help_text=_("Your displayed username. Between %(min)s and %(max)s characters, only letters and digits are allowed.") % {'min': settings.username_length_min, 'max': settings.username_length_max},
                                max_length=15)
     email = forms.EmailField(label=_('E-mail address'),
                              help_text=_("Working e-mail inbox is required to maintain control over your forum account."),
@@ -33,6 +32,15 @@ class UserRegisterForm(Form):
                        'different': _("Entered passwords do not match."),
                        }]
 
+    def __init__(self, *args, **kwargs):
+        super(UserRegisterForm, self).__init__(*args, **kwargs)
+        help_text_formats = {
+                             'min': settings_lazy.username_length_min,
+                             'max': settings_lazy.username_length_max,
+                            }
+        self.fields['username'].help_text = _(
+            "Your displayed username. Between %(min)s and %(max)s characters, only letters and digits are allowed.") % help_text_formats
+
     def finalize_form(self):
         if not settings.tos_url and not settings.tos_content:
             del self.fields['accept_tos']

+ 2 - 3
misago/apps/resetpswd/forms.py

@@ -15,9 +15,8 @@ class UserResetPasswordForm(Form):
 
     def clean_email(self):
         try:
-            email = self.cleaned_data['email'].lower()
-            email_hash = hashlib.md5(email).hexdigest()
-            self.found_user = User.objects.get(email_hash=email_hash)
+            self.found_user = User.objects.get_by_email(
+                self.cleaned_data['email'])
         except User.DoesNotExist:
             raise ValidationError(_("There is no user with such e-mail address."))
         return email

+ 1 - 0
misago/apps/search/views.py

@@ -34,6 +34,7 @@ class ViewBase(object):
             else:
                 if search_data.get('search_forums'):
                     if search_data.get('search_forums_childs'):
+                        Forum.objects.populate_tree()
                         forums_tree = Forum.objects.forums_tree
                         readable_forums = Forum.objects.readable_forums(self.request.acl)
 

+ 16 - 1
misago/apps/threads/posting.py

@@ -3,9 +3,10 @@ from django.shortcuts import redirect
 from django.utils import timezone
 from django.utils.translation import ugettext as _
 from misago import messages
+from misago.acl.exceptions import ACLError403
 from misago.apps.threads.forms import NewThreadForm, EditThreadForm
 from misago.apps.threadtype.posting import NewThreadBaseView, EditThreadBaseView, NewReplyBaseView, EditReplyBaseView
-from misago.models import Forum, Thread, Post, Poll, PollOption
+from misago.models import Forum, Thread, Post, Poll, PollOption, WarnLevel
 from misago.apps.threads.mixins import TypeMixin
 
 
@@ -86,6 +87,13 @@ class NewThreadView(NewThreadBaseView, TypeMixin, PollFormMixin, PrefixFormMixin
     def set_forum_context(self):
         self.forum = Forum.objects.get(pk=self.kwargs.get('forum'), type='forum')
 
+    def check_permissions(self):
+        if self.request.user.warning_level_disallows_writing_threads:
+            raise ACLError403(_("You can't start new threads due to your warning level."))
+
+    def force_moderation(self):
+        return self.request.user.warning_level_moderate_new_threads
+
     def after_form(self, form):
         if form.cleaned_data.get('poll_question'):
             self.create_poll(form)
@@ -123,6 +131,13 @@ class EditThreadView(EditThreadBaseView, TypeMixin, PollFormMixin, PrefixFormMix
 
 
 class NewReplyView(NewReplyBaseView, TypeMixin):
+    def check_permissions(self):
+        if self.request.user.warning_level_disallows_writing_replies:
+            raise ACLError403(_("You can't reply to threads due to your warning level."))
+
+    def force_moderation(self):
+        return self.request.user.warning_level_moderate_new_replies
+
     def response(self):
         if self.post.moderated:
             messages.success(self.request, _("Your reply has been posted. It will be hidden from other members until moderator reviews it."), 'threads_%s' % self.post.pk)

+ 6 - 6
misago/apps/threadtype/delete.py

@@ -158,9 +158,9 @@ class HideReplyBaseView(DeleteHideBaseView):
     def delete(self):
         self.post.delete_date = timezone.now()
         self.post.deleted = True
-        self.thread.start_post.edit_user = self.request.user
-        self.thread.start_post.edit_user_name = self.request.user.username
-        self.thread.start_post.edit_user_slug = self.request.user.username_slug
+        self.post.edit_user = self.request.user
+        self.post.edit_user_name = self.request.user.username
+        self.post.edit_user_slug = self.request.user.username_slug
         self.post.save(force_update=True)
         self.thread.sync()
         self.thread.save(force_update=True)
@@ -184,9 +184,9 @@ class ShowReplyBaseView(DeleteHideBaseView):
 
     def delete(self):
         self.post.deleted = False
-        self.thread.start_post.edit_user = self.request.user
-        self.thread.start_post.edit_user_name = self.request.user.username
-        self.thread.start_post.edit_user_slug = self.request.user.username_slug
+        self.post.edit_user = self.request.user
+        self.post.edit_user_name = self.request.user.username
+        self.post.edit_user_slug = self.request.user.username_slug
         self.post.save(force_update=True)
         self.thread.sync()
         self.thread.save(force_update=True)

+ 6 - 0
misago/apps/threadtype/list/moderation.py

@@ -251,6 +251,9 @@ class ThreadsListModeration(object):
             if thread.pk in ids and thread.deleted:
                 undeleted.append(thread.pk)
                 thread.start_post.deleted = False
+                thread.start_post.edit_user = self.request.user
+                thread.start_post.edit_user_name = self.request.user.username
+                thread.start_post.edit_user_slug = self.request.user.username_slug
                 thread.start_post.save(force_update=True)
                 thread.sync()
                 thread.save(force_update=True)
@@ -273,6 +276,9 @@ class ThreadsListModeration(object):
             if thread.pk in ids and not thread.deleted:
                 deleted.append(thread.pk)
                 thread.start_post.deleted = True
+                thread.start_post.edit_user = self.request.user
+                thread.start_post.edit_user_name = self.request.user.username
+                thread.start_post.edit_user_slug = self.request.user.username_slug
                 thread.start_post.save(force_update=True)
                 thread.sync()
                 thread.save(force_update=True)

+ 17 - 12
misago/apps/threadtype/posting/base.py

@@ -25,6 +25,9 @@ class PostingBaseView(ViewBase):
         if self.forum.level:
             self.parents = Forum.objects.forum_parents(self.forum.pk)
 
+    def force_moderation(self):
+        return False
+
     def record_edit(self, form, old_name, old_post):
         self.post.edits += 1
         self.post.edit_user = self.request.user
@@ -84,18 +87,20 @@ class PostingBaseView(ViewBase):
         self.email_watchers(notified_users)
 
     def watch_thread(self):
-        if self.request.user.subscribe_start:
-            try:
-                WatchedThread.objects.get(user=self.request.user, thread=self.thread)
-            except WatchedThread.DoesNotExist:
-                WatchedThread.objects.create(
-                                           user=self.request.user,
-                                           forum=self.forum,
-                                           thread=self.thread,
-                                           starter_id=self.thread.start_poster_id,
-                                           last_read=timezone.now(),
-                                           email=(self.request.user.subscribe_start == 2),
-                                           )
+        pass
+
+    def start_watching_thread(self, email_notifications=False):
+        try:
+            WatchedThread.objects.get(user=self.request.user, thread=self.thread)
+        except WatchedThread.DoesNotExist:
+            WatchedThread.objects.create(
+                                       user=self.request.user,
+                                       forum=self.forum,
+                                       thread=self.thread,
+                                       starter_id=self.thread.start_poster_id,
+                                       last_read=timezone.now(),
+                                       email=email_notifications,
+                                       )
 
     def make_attachments_token(self):
         if self.post:

+ 1 - 1
misago/apps/threadtype/posting/editthread.py

@@ -14,7 +14,7 @@ class EditThreadBaseView(PostingBaseView):
         self.post = self.thread.start_post
         self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.post)
         self.request.acl.threads.allow_thread_edit(self.request.user, self.proxy, self.thread, self.post)
-        
+
     def form_initial_data(self):
         return {
                 'thread_name': self.thread.name,

+ 12 - 3
misago/apps/threadtype/posting/newreply.py

@@ -29,8 +29,12 @@ class NewReplyBaseView(PostingBaseView):
 
     def post_form(self, form):
         now = timezone.now()
-        moderation = (not self.request.acl.threads.acl[self.forum.pk]['can_approve']
-                      and self.request.acl.threads.acl[self.forum.pk]['can_start_threads'] == 1)
+
+        if self.force_moderation():
+            moderation = True
+        else:
+            moderation = (not self.request.acl.threads.acl[self.forum.pk]['can_approve']
+                          and self.request.acl.threads.acl[self.forum.pk]['can_start_threads'] == 1)
 
         self.thread.previous_last = self.thread.last_post
         self.md, post_preparsed = post_markdown(form.cleaned_data['post'])
@@ -135,4 +139,9 @@ class NewReplyBaseView(PostingBaseView):
                     alert = user.alert(ugettext_lazy("%(username)s has replied to thread %(thread)s that you are watching").message)
                 alert.profile('username', self.request.user)
                 alert.post('thread', self.type_prefix, self.thread, self.post)
-                alert.save_all()
+                alert.save_all()
+
+    def watch_thread(self):
+        if self.request.user.subscribe_reply:
+            self.start_watching_thread(
+                self.request.user.subscribe_reply == 2)

+ 12 - 3
misago/apps/threadtype/posting/newthread.py

@@ -20,8 +20,12 @@ class NewThreadBaseView(PostingBaseView):
 
     def post_form(self, form):
         now = timezone.now()
-        moderation = (not self.request.acl.threads.acl[self.forum.pk]['can_approve']
-                      and self.request.acl.threads.acl[self.forum.pk]['can_start_threads'] == 1)
+
+        if self.force_moderation():
+            moderation = True
+        else:
+            moderation = (not self.request.acl.threads.acl[self.forum.pk]['can_approve']
+                          and self.request.acl.threads.acl[self.forum.pk]['can_start_threads'] == 1)
 
         # Create empty thread
         self.thread = Thread.objects.create(
@@ -82,4 +86,9 @@ class NewThreadBaseView(PostingBaseView):
             self.request.user.threads += 1
             self.request.user.posts += 1
         self.request.user.last_post = now
-        self.request.user.save(force_update=True)
+        self.request.user.save(force_update=True)
+
+    def watch_thread(self):
+        if self.request.user.subscribe_start:
+            self.start_watching_thread(
+                self.request.user.subscribe_start == 2)

+ 17 - 2
misago/apps/threadtype/thread/moderation/posts.py

@@ -169,7 +169,14 @@ class PostsModeration(object):
             if post.pk in ids and post.deleted:
                 undeleted.append(post.pk)
         if undeleted:
-            self.thread.post_set.filter(id__in=undeleted).update(deleted=False, current_date=timezone.now())
+            update_kwargs = {
+                'deleted': False,
+                'current_date': timezone.now(),
+                'edit_user': self.request.user,
+                'edit_user_name': self.request.user.username,
+                'edit_user_slug': self.request.user.username_slug,
+            }
+            self.thread.post_set.filter(id__in=undeleted).update(**update_kwargs)
             self.thread.sync()
             self.thread.save(force_update=True)
             self.forum.sync()
@@ -186,7 +193,15 @@ class PostsModeration(object):
                     raise forms.ValidationError(_("You cannot delete first post of thread using this action. If you want to delete thread, use thread moderation instead."))
                 deleted.append(post.pk)
         if deleted:
-            self.thread.post_set.filter(id__in=deleted).update(deleted=True, current_date=timezone.now(), delete_date=timezone.now())
+            update_kwargs = {
+                'deleted': True,
+                'current_date': timezone.now(),
+                'delete_date': timezone.now(),
+                'edit_user': self.request.user,
+                'edit_user_name': self.request.user.username,
+                'edit_user_slug': self.request.user.username_slug,
+            }
+            self.thread.post_set.filter(id__in=deleted).update(**update_kwargs)
             self.thread.sync()
             self.thread.save(force_update=True)
             self.forum.sync()

+ 6 - 0
misago/apps/threadtype/thread/moderation/thread.py

@@ -108,6 +108,9 @@ class ThreadModeration(object):
     def thread_action_undelete(self):
         # Update first post in thread
         self.thread.start_post.deleted = False
+        self.thread.start_post.edit_user = self.request.user
+        self.thread.start_post.edit_user_name = self.request.user.username
+        self.thread.start_post.edit_user_slug = self.request.user.username_slug
         self.thread.start_post.save(force_update=True)
         # Update thread
         self.thread.sync()
@@ -129,6 +132,9 @@ class ThreadModeration(object):
     def thread_action_soft(self):
         # Update first post in thread
         self.thread.start_post.deleted = True
+        self.thread.start_post.edit_user = self.request.user
+        self.thread.start_post.edit_user_name = self.request.user.username
+        self.thread.start_post.edit_user_slug = self.request.user.username_slug
         self.thread.start_post.save(force_update=True)
         # Update thread
         self.thread.sync()

+ 0 - 0
misago/apps/warnuser/__init__.py


+ 13 - 0
misago/apps/warnuser/alerts.py

@@ -0,0 +1,13 @@
+from misago.utils.translation import ugettext_lazy
+
+
+def you_have_been_warned(giver, receiver, warning):
+    alert = receiver.alert(ugettext_lazy("%(username)s has increased your warning level.").message)
+    alert.profile('username', giver)
+    alert.save_all()
+
+
+def your_warn_has_been_canceled(canceler, receiver):
+    alert = receiver.alert(ugettext_lazy("%(username)s has lowered your warning level.").message)
+    alert.profile('username', canceler)
+    alert.save_all()

+ 9 - 0
misago/apps/warnuser/forms.py

@@ -0,0 +1,9 @@
+from django.utils.translation import ugettext_lazy as _
+import floppyforms as forms
+from misago.forms import Form
+
+class WarnMemberForm(Form):
+    reason = forms.CharField(label=_("Warning Reason"), widget=forms.Textarea,
+                             required=False, max_length=2048,
+                             error_messages={
+                                'max_length': _("Warn reason is too long.")})

+ 97 - 0
misago/apps/warnuser/views.py

@@ -0,0 +1,97 @@
+from datetime import timedelta
+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 import messages
+from misago.acl.exceptions import ACLError403
+from misago.apps.errors import error403, error404
+from misago.apps.warnuser import alerts
+from misago.apps.warnuser.forms import WarnMemberForm
+from misago.decorators import block_guest, check_csrf
+from misago.markdown.factory import basic_markdown
+from misago.models import User, Warn, WarnLevel
+from misago.shortcuts import render_to_response
+
+@block_guest
+@check_csrf
+def warn_user(request, user, slug):
+    try:
+        user = User.objects.get(pk=user)
+    except User.DoesNotExist:
+        return error404(request, _("Requested user could not be found."))
+
+    try:
+        request.acl.warnings.allow_warning_members()
+        user.acl().warnings.allow_warning()
+    except ACLError403 as e:
+        return error403(request, e)
+
+    if not WarnLevel.objects.get_level(1):
+        messages.error(request, _("No warning levels have been defined."))
+        return redirect(request.POST.get('retreat',
+            reverse('user', kwargs={
+                'user': user.pk,
+                'username': user.username_slug,
+                })))
+
+    current_warning_level = user.get_current_warning_level()
+    next_warning_level = WarnLevel.objects.get_level(user.warning_level + 1)
+
+    if not next_warning_level:
+        return render_to_response('warn_user/max_level.html',
+                                  {
+                                   'warned_user': user,
+                                   'retreat': request.POST.get('retreat'),
+                                  },
+                                  context_instance=RequestContext(request))
+
+    form = WarnMemberForm(initial={'reason': request.POST.get('reason')})
+    if ('origin' in request.POST
+            and request.POST.get('origin') == 'warning-form'):
+        form = WarnMemberForm(request.POST, request=request)
+        if form.is_valid():
+            user.warning_level += 1
+            if next_warning_level.expires_after_minutes:
+                user.warning_level_update_on = timezone.now()
+                user.warning_level_update_on += timedelta(
+                    minutes=next_warning_level.expires_after_minutes)
+            else:
+                user.warning_level_update_on = None
+            user.save(force_update=True)
+
+            reason_preparsed = None
+            if form.cleaned_data['reason']:
+                reason_preparsed = basic_markdown(form.cleaned_data['reason'])
+
+            warning = Warn.objects.create(
+                user=user,
+                reason=form.cleaned_data['reason'],
+                reason_preparsed=reason_preparsed,
+                given_on=timezone.now(),
+                giver=request.user,
+                giver_username=request.user.username,
+                giver_slug=request.user.username_slug,
+                giver_ip=request.session.get_ip(request),
+                giver_agent=request.META.get('HTTP_USER_AGENT'))
+
+            alerts.you_have_been_warned(request.user, user, warning)
+            messages.success(request,
+                _("%(user)s warning level has been increased.") % {
+                    'user': user.username})
+            return redirect(request.POST.get('retreat',
+                reverse('user', kwargs={
+                    'user': user.pk,
+                    'username': user.username_slug,
+                    })))
+
+    return render_to_response('warn_user/form.html',
+                              {
+                               'warned_user': user,
+                               'current_warning_level': current_warning_level,
+                               'next_warning_level': next_warning_level,
+                               'form': form,
+                               'retreat': request.POST.get('retreat'),
+                              },
+                              context_instance=RequestContext(request))

+ 2 - 2
misago/auth.py

@@ -77,7 +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:
@@ -108,7 +108,7 @@ def auth_admin(request, email, password):
     Admin auth - check ACP permissions
     """
     user = get_user(email, password, True)
-    if not user.is_god() and not user.acl(request).special.is_admin():
+    if not user.is_god() and not user.acl().acp.is_admin():
         raise AuthException(NOT_ADMIN, _("Your account does not have admin privileges."))
     return user;
 

+ 6 - 3
misago/conf.py

@@ -1,6 +1,5 @@
 from django.conf import settings as dj_settings
 from django.core.cache import cache
-from misago.models import Setting
 from misago.thread import local
 
 _thread_local = local()
@@ -8,6 +7,7 @@ _thread_local = local()
 def load_settings():
     settings = cache.get('settings', {})
     if not settings:
+        from misago.models import Setting
         for i in Setting.objects.all():
             settings[i.pk] = i.value
         cache.set('settings', settings)
@@ -29,12 +29,15 @@ class MisagoSettings(object):
     def setting(self, key):
         try:
             try:
-                return self.settings()[key]
-            except KeyError:
                 if self.is_safe:
                     return getattr(dj_settings, key)
                 else:
                     raise AttributeError()
+            except AttributeError:
+                try:
+                    return self.settings()[key]
+                except KeyError:
+                    raise AttributeError()
         except AttributeError:
             raise Exception(u"Requested setting \"%s\" could not be found." % key)
 

+ 12 - 0
misago/fixtures/userroles.py

@@ -31,6 +31,12 @@ def load():
                         'forums': {3: 1, 5: 1, 6: 1},
                         'can_destroy_user_newer_than': 14,
                         'can_destroy_users_with_less_posts_than': 10,
+                        'can_warn_members': True,
+                        'can_see_other_members_warns': True,
+                        'can_cancel_warnings': 2,
+                        'can_cancel_warnings_newer_than': 0,
+                        'can_delete_warnings': True,
+                        'can_be_warned': False,
                        }
     role.save(force_insert=True)
 
@@ -59,6 +65,12 @@ def load():
                         'forums': {3: 1, 5: 1, 6: 1},
                         'can_destroy_user_newer_than': 5,
                         'can_destroy_users_with_less_posts_than': 10,
+                        'can_warn_members': True,
+                        'can_see_other_members_warns': True,
+                        'can_cancel_warnings': 1,
+                        'can_cancel_warnings_newer_than': 30,
+                        'can_delete_warnings': False,
+                        'can_be_warned': False,
                        }
     role.save(force_insert=True)
 

+ 1 - 0
misago/forms/fields.py

@@ -30,6 +30,7 @@ class ForumMultipleChoiceField(TreeNodeMultipleChoiceField):
         level = getattr(obj, obj._mptt_meta.level_attr)
         return mark_safe(conditional_escape(self.level_indicator) * (level - 1))
 
+
 class ReCaptchaField(fields.CharField):
     widget = ReCaptchaWidget
     api_error = None

+ 20 - 0
misago/markdown/factory.py

@@ -22,6 +22,26 @@ def remove_unsupported(md):
     del md.inlinePatterns['short_reference']
 
 
+def basic_markdown(text):
+    md = markdown.Markdown(
+                           safe_mode='escape',
+                           output_format=settings.OUTPUT_FORMAT,
+                           extensions=['nl2br'])
+    remove_unsupported(md)
+    cleanlinks = CleanLinksExtension()
+    cleanlinks.extendMarkdown(md)
+
+    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 signature_markdown(acl, text):
     md = markdown.Markdown(
                            safe_mode='escape',

+ 3 - 3
misago/middleware/acl.py

@@ -2,13 +2,13 @@ from misago.acl.builder import acl
 
 class ACLMiddleware(object):
     def process_request(self, request):
-        request.acl = acl(request, request.user)
-        
+        request.acl = acl(request.user)
+
         if (request.user.is_authenticated() and
             (request.acl.team or request.user.is_god()) != request.user.is_team):
             request.user.is_team = (request.acl.team or request.user.is_god())
             request.user.save(force_update=True)
-            
+
         if request.session.team != request.user.is_team:
             request.session.team = request.user.is_team
             request.session.save()

+ 561 - 0
misago/migrations/0035_auto__add_warn__add_warnlevel__add_field_user_warning_level__add_field.py

@@ -0,0 +1,561 @@
+# -*- coding: utf-8 -*-
+from south.utils import datetime_utils as datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        # Adding model 'Warn'
+        db.create_table(u'misago_warn', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='warning_set', to=orm['misago.User'])),
+            ('reason', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('reason_preparsed', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('given_on', self.gf('django.db.models.fields.DateTimeField')()),
+            ('giver', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='warnings_given_set', null=True, on_delete=models.SET_NULL, to=orm['misago.User'])),
+            ('giver_username', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('giver_slug', self.gf('django.db.models.fields.SlugField')(max_length=255)),
+            ('giver_ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),
+            ('giver_agent', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('canceled', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('canceled_on', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
+            ('canceler', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='warnings_canceled_set', null=True, on_delete=models.SET_NULL, to=orm['misago.User'])),
+            ('canceler_username', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+            ('canceler_slug', self.gf('django.db.models.fields.SlugField')(max_length=255, null=True, blank=True)),
+            ('canceler_ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39, null=True, blank=True)),
+            ('canceler_agent', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)),
+        ))
+        db.send_create_signal('misago', ['Warn'])
+
+        # Adding model 'WarnLevel'
+        db.create_table(u'misago_warnlevel', (
+            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('slug', self.gf('django.db.models.fields.SlugField')(max_length=255)),
+            ('description', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+            ('warning_level', self.gf('django.db.models.fields.PositiveIntegerField')(default=1, db_index=True)),
+            ('expires_after_minutes', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('restrict_posting_replies', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+            ('restrict_posting_threads', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
+        ))
+        db.send_create_signal('misago', ['WarnLevel'])
+
+        # Adding field 'User.warning_level'
+        db.add_column(u'misago_user', 'warning_level',
+                      self.gf('django.db.models.fields.PositiveIntegerField')(default=0),
+                      keep_default=False)
+
+        # Adding field 'User.warning_level_update_on'
+        db.add_column(u'misago_user', 'warning_level_update_on',
+                      self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True),
+                      keep_default=False)
+
+
+    def backwards(self, orm):
+        # Deleting model 'Warn'
+        db.delete_table(u'misago_warn')
+
+        # Deleting model 'WarnLevel'
+        db.delete_table(u'misago_warnlevel')
+
+        # Deleting field 'User.warning_level'
+        db.delete_column(u'misago_user', 'warning_level')
+
+        # Deleting field 'User.warning_level_update_on'
+        db.delete_column(u'misago_user', 'warning_level_update_on')
+
+
+    models = {
+        'misago.alert': {
+            'Meta': {'object_name': 'Alert'},
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'message': ('django.db.models.fields.TextField', [], {}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']"}),
+            'variables': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
+        },
+        'misago.attachment': {
+            'Meta': {'object_name': 'Attachment'},
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'content_type': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'filetype': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.AttachmentType']"}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'hash_id': ('django.db.models.fields.CharField', [], {'max_length': '8', 'db_index': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'path': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'post': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Post']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'session': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            'size': ('django.db.models.fields.PositiveIntegerField', [], {'max_length': '255'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'user_name_slug': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.attachmenttype': {
+            'Meta': {'object_name': 'AttachmentType'},
+            'extensions': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['misago.Role']", 'symmetrical': 'False'}),
+            'size_limit': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.ban': {
+            'Meta': {'object_name': 'Ban'},
+            'ban': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'expires': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'test': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.change': {
+            'Meta': {'object_name': 'Change'},
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'change': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'post': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Post']"}),
+            'post_content': ('django.db.models.fields.TextField', [], {}),
+            'reason': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'size': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'thread_name_new': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'thread_name_old': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'user_slug': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.checkpoint': {
+            'Meta': {'object_name': 'Checkpoint'},
+            'action': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'extra': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'old_forum': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'to': "orm['misago.Forum']"}),
+            'old_forum_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'old_forum_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'target_user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"}),
+            'target_user_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'target_user_slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'user_slug': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.fixture': {
+            'Meta': {'object_name': 'Fixture'},
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.forum': {
+            'Meta': {'object_name': 'Forum'},
+            'attrs': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'description_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"}),
+            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_thread': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.Thread']"}),
+            'last_thread_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'last_thread_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_thread_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'parent': ('mptt.fields.TreeForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['misago.Forum']"}),
+            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'posts_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'prune_last': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'prune_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'pruned_archive': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.Forum']"}),
+            'redirect': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'redirects': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'redirects_delta': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'show_details': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
+            'special': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'threads_delta': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'type': ('django.db.models.fields.CharField', [], {'max_length': '12'})
+        },
+        'misago.forumread': {
+            'Meta': {'object_name': 'ForumRead'},
+            'cleared': ('django.db.models.fields.DateTimeField', [], {}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']"})
+        },
+        'misago.forumrole': {
+            'Meta': {'object_name': 'ForumRole'},
+            '_permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'db_column': "'permissions'", 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.karma': {
+            'Meta': {'object_name': 'Karma'},
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'post': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Post']"}),
+            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'user_slug': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.monitoritem': {
+            'Meta': {'object_name': 'MonitorItem'},
+            '_value': ('django.db.models.fields.TextField', [], {'null': 'True', 'db_column': "'value'", 'blank': 'True'}),
+            'id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'primary_key': 'True'}),
+            'type': ('django.db.models.fields.CharField', [], {'default': "'int'", 'max_length': '255'}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'})
+        },
+        'misago.newsletter': {
+            'Meta': {'object_name': 'Newsletter'},
+            'content_html': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'content_plain': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ignore_subscriptions': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'progress': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'ranks': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['misago.Rank']", 'symmetrical': 'False'}),
+            'step_size': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'token': ('django.db.models.fields.CharField', [], {'max_length': '32'})
+        },
+        'misago.poll': {
+            'Meta': {'object_name': 'Poll'},
+            '_choices_cache': ('django.db.models.fields.TextField', [], {'db_column': "'choices_cache'"}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            'length': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'max_choices': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'public': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'question': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'start_date': ('django.db.models.fields.DateTimeField', [], {}),
+            'thread': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'poll_of'", 'unique': 'True', 'primary_key': 'True', 'to': "orm['misago.Thread']"}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'user_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'vote_changing': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.polloption': {
+            'Meta': {'object_name': 'PollOption'},
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'poll': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'option_set'", 'to': "orm['misago.Poll']"}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.pollvote': {
+            'Meta': {'object_name': 'PollVote'},
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'option': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.PollOption']", 'null': 'True', 'blank': 'True'}),
+            'poll': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'vote_set'", 'to': "orm['misago.Poll']"}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'user_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
+        },
+        'misago.post': {
+            'Meta': {'object_name': 'Post'},
+            '_attachments': ('django.db.models.fields.TextField', [], {'null': 'True', 'db_column': "'attachments'", 'blank': 'True'}),
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'current_date': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            'delete_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'edit_reason': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'edit_user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"}),
+            'edit_user_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'edit_user_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'edits': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            'has_attachments': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'mentions': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'mention_set'", 'symmetrical': 'False', 'to': "orm['misago.User']"}),
+            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'post': ('django.db.models.fields.TextField', [], {}),
+            'post_preparsed': ('django.db.models.fields.TextField', [], {}),
+            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'reported': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_index': 'True'}),
+            'reports': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'user_name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.pruningpolicy': {
+            'Meta': {'object_name': 'PruningPolicy'},
+            'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_visit': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'registered': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.rank': {
+            'Meta': {'object_name': 'Rank'},
+            'as_tab': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'criteria': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'on_index': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'order': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['misago.Role']", 'symmetrical': 'False'}),
+            'slug': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'special': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
+        },
+        'misago.role': {
+            'Meta': {'object_name': 'Role'},
+            '_permissions': ('django.db.models.fields.TextField', [], {'null': 'True', 'db_column': "'permissions'", 'blank': 'True'}),
+            '_special': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_column': "'special'", 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'protected': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
+        },
+        'misago.session': {
+            'Meta': {'object_name': 'Session'},
+            'admin': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'crawler': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'data': ('django.db.models.fields.TextField', [], {'db_column': "'session_data'"}),
+            'id': ('django.db.models.fields.CharField', [], {'max_length': '42', 'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'last': ('django.db.models.fields.DateTimeField', [], {}),
+            'matched': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'rank': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sessions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.Rank']"}),
+            'start': ('django.db.models.fields.DateTimeField', [], {}),
+            'team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sessions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"})
+        },
+        'misago.setting': {
+            'Meta': {'object_name': 'Setting'},
+            '_value': ('django.db.models.fields.TextField', [], {'null': 'True', 'db_column': "'value'", 'blank': 'True'}),
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'extra': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'field': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'group': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.SettingsGroup']", 'to_field': "'key'"}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'normalize_to': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'position': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'separator': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'setting': ('django.db.models.fields.CharField', [], {'max_length': '255', 'primary_key': 'True'}),
+            'value_default': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
+        },
+        'misago.settingsgroup': {
+            'Meta': {'object_name': 'SettingsGroup'},
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.signinattempt': {
+            'Meta': {'object_name': 'SignInAttempt'},
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'})
+        },
+        'misago.thread': {
+            'Meta': {'object_name': 'Thread'},
+            'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'downvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            'has_poll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last': ('django.db.models.fields.DateTimeField', [], {}),
+            'last_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.Post']"}),
+            'last_poster': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"}),
+            'last_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'last_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'moderated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'participants': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'private_thread_set'", 'symmetrical': 'False', 'to': "orm['misago.User']"}),
+            'prefix': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.ThreadPrefix']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'replies': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'replies_deleted': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'replies_moderated': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'replies_reported': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'report_for': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'report_set'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.Post']"}),
+            'score': ('django.db.models.fields.PositiveIntegerField', [], {'default': '30'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
+            'start': ('django.db.models.fields.DateTimeField', [], {}),
+            'start_post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.Post']"}),
+            'start_poster': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'start_poster_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'start_poster_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
+            'start_poster_style': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'upvotes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'weight': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+        },
+        'misago.threadprefix': {
+            'Meta': {'object_name': 'ThreadPrefix'},
+            'forums': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['misago.Forum']", 'symmetrical': 'False'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
+            'style': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'misago.threadread': {
+            'Meta': {'object_name': 'ThreadRead'},
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']"})
+        },
+        'misago.token': {
+            'Meta': {'object_name': 'Token'},
+            'accessed': ('django.db.models.fields.DateTimeField', [], {}),
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'id': ('django.db.models.fields.CharField', [], {'max_length': '42', 'primary_key': 'True'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'signin_tokens'", 'to': "orm['misago.User']"})
+        },
+        'misago.user': {
+            'Meta': {'object_name': 'User'},
+            '_avatar_crop': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'db_column': "'avatar_crop'", 'blank': 'True'}),
+            'acl_key': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
+            'activation': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'alerts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'alerts_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'allow_pds': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'avatar_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'avatar_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'avatar_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'avatar_image': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'avatar_original': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'avatar_temp': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'avatar_type': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '255'}),
+            'email_hash': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
+            'followers': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'following': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'follows': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'follows_set'", 'symmetrical': 'False', 'to': "orm['misago.User']"}),
+            'hide_activity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ignores': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'ignores_set'", 'symmetrical': 'False', 'to': "orm['misago.User']"}),
+            'is_team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'join_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'join_date': ('django.db.models.fields.DateTimeField', [], {}),
+            'join_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'karma_given_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'karma_given_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'karma_n': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'karma_p': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'last_agent': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'last_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'last_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39', 'null': 'True', 'blank': 'True'}),
+            'last_post': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'last_search': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'last_sync': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'last_vote': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'password_date': ('django.db.models.fields.DateTimeField', [], {}),
+            'posts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'rank': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Rank']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'ranking': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'receive_newsletters': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'roles': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['misago.Role']", 'symmetrical': 'False'}),
+            'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'signature': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'signature_ban': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'signature_ban_reason_admin': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'signature_ban_reason_user': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'signature_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'subscribe_reply': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'subscribe_start': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'sync_pds': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'timezone': ('django.db.models.fields.CharField', [], {'default': "'utc'", 'max_length': '255'}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'token': ('django.db.models.fields.CharField', [], {'max_length': '12', 'null': 'True', 'blank': 'True'}),
+            'unread_pds': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'username_slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255'}),
+            'votes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'warning_level': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'warning_level_update_on': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'})
+        },
+        'misago.usernamechange': {
+            'Meta': {'object_name': 'UsernameChange'},
+            'date': ('django.db.models.fields.DateTimeField', [], {}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'old_username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'namechanges'", 'to': "orm['misago.User']"})
+        },
+        'misago.warn': {
+            'Meta': {'object_name': 'Warn'},
+            'canceled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'canceled_on': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'canceler': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'warnings_canceled_set'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"}),
+            'canceler_agent': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'canceler_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39', 'null': 'True', 'blank': 'True'}),
+            'canceler_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'canceler_username': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'given_on': ('django.db.models.fields.DateTimeField', [], {}),
+            'giver': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'warnings_given_set'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['misago.User']"}),
+            'giver_agent': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'giver_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'giver_slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
+            'giver_username': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'reason': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'reason_preparsed': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'warning_set'", 'to': "orm['misago.User']"})
+        },
+        'misago.warnlevel': {
+            'Meta': {'object_name': 'WarnLevel'},
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'expires_after_minutes': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'restrict_posting_replies': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'restrict_posting_threads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255'}),
+            'warning_level': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'})
+        },
+        'misago.watchedthread': {
+            'Meta': {'object_name': 'WatchedThread'},
+            'email': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'forum': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Forum']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_read': ('django.db.models.fields.DateTimeField', [], {}),
+            'starter': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'to': "orm['misago.User']"}),
+            'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.Thread']"}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['misago.User']"})
+        }
+    }
+
+    complete_apps = ['misago']

+ 2 - 0
misago/models/__init__.py

@@ -28,4 +28,6 @@ from misago.models.threadreadmodel import ThreadRead
 from misago.models.tokenmodel import Token
 from misago.models.usermodel import User, Guest, Crawler
 from misago.models.usernamechangemodel import UsernameChange
+from misago.models.warnmodel import Warn
+from misago.models.warnlevelmodel import WarnLevel
 from misago.models.watchedthreadmodel import WatchedThread

+ 2 - 2
misago/models/forummodel.py

@@ -35,8 +35,8 @@ class ForumManager(TreeManager):
 
     def populate_tree(self, force=False):
         if not self.forums_tree:
-            self.forums_tree = cache.get('forums_tree')
-        if not self.forums_tree or force:
+            self.forums_tree = cache.get('forums_tree', 'nada')
+        if self.forums_tree == 'nada' or force:
             self.forums_tree = {}
             for forum in Forum.objects.order_by('lft'):
                 self.forums_tree[forum.pk] = forum

+ 1 - 2
misago/models/postmodel.py

@@ -143,7 +143,6 @@ class Post(models.Model):
         merge_post.send(sender=self, new_post=post)
 
     def notify_mentioned(self, request, thread_type, users):
-        from misago.acl.builder import acl as build_acl
         from misago.acl.exceptions import ACLError403, ACLError404
 
         mentioned = self.mentions.all()
@@ -151,7 +150,7 @@ class Post(models.Model):
             if user.pk != request.user.pk and user not in mentioned:
                 self.mentions.add(user)
                 try:
-                    user_acl = build_acl(request, user)
+                    user_acl = user.acl()
                     user_acl.forums.allow_forum_view(self.forum)
                     user_acl.threads.allow_thread_view(user, self.thread)
                     user_acl.threads.allow_post_view(user, self.thread, self)

+ 1 - 2
misago/models/threadmodel.py

@@ -213,7 +213,6 @@ class Thread(models.Model):
         self.deleted = start_post.deleted
 
     def email_watchers(self, request, thread_type, post):
-        from misago.acl.builder import acl
         from misago.acl.exceptions import ACLError403, ACLError404
         from misago.models import ThreadRead, WatchedThread
 
@@ -222,7 +221,7 @@ class Thread(models.Model):
             user = watch.user
             if user.pk != request.user.pk:
                 try:
-                    user_acl = acl(request, user)
+                    user_acl = user.acl()
                     user_acl.forums.allow_forum_view(self.forum)
                     user_acl.threads.allow_thread_view(user, self)
                     user_acl.threads.allow_post_view(user, self, post)

+ 120 - 5
misago/models/usermodel.py

@@ -13,6 +13,7 @@ from django.template import RequestContext
 from django.utils import timezone as tz_util
 from django.utils.translation import ugettext_lazy as _
 from misago.acl.builder import acl
+from misago.apps.profiles.warnings.warningstracker import WarningsTracker
 from misago.conf import settings
 from misago.monitor import monitor, UpdatingMonitor
 from misago.signals import delete_user_content, rename_user, sync_user_profile
@@ -99,7 +100,7 @@ class UserManager(models.Manager):
         return new_user
 
     def get_by_email(self, email):
-        return self.get(email_hash=hashlib.md5(email).hexdigest())
+        return self.get(email_hash=hashlib.md5(email.lower()).hexdigest())
 
     def filter_stats(self, start, end):
         return self.filter(join_date__gte=start).filter(join_date__lte=end)
@@ -173,6 +174,8 @@ class User(models.Model):
     roles = models.ManyToManyField('Role')
     is_team = models.BooleanField(default=False)
     acl_key = models.CharField(max_length=12, null=True, blank=True)
+    warning_level = models.PositiveIntegerField(default=0)
+    warning_level_update_on = models.DateTimeField(null=True, blank=True)
 
     objects = UserManager()
 
@@ -431,8 +434,8 @@ class User(models.Model):
         self.save(update_fields=('acl_key',))
         return self.acl_key
 
-    def acl(self, request):
-        return acl(request, self)
+    def acl(self):
+        return acl(self)
 
     @property
     def avatar_crop(self):
@@ -504,9 +507,9 @@ class User(models.Model):
 
         # Set message author
         if settings.board_name:
-            sender = '%s <%s>' % (settings.board_name.replace("<", "(").replace(">", ")"), settings.EMAIL_HOST_USER)
+            sender = '%s <%s>' % (settings.board_name.replace("<", "(").replace(">", ")"), settings.DEFAULT_FROM_EMAIL)
         else:
-            sender = settings.EMAIL_HOST_USER
+            sender = settings.DEFAULT_FROM_EMAIL
 
         # Build message and add it to queue
         email = EmailMultiAlternatives(subject, email_text, sender, [recipient])
@@ -537,6 +540,118 @@ class User(models.Model):
             return True
         return False
 
+    def is_warning_level_expired(self):
+        if self.warning_level and self.warning_level_update_on:
+            return tz_util.now() > self.warning_level_update_on
+        return False
+
+    def update_expired_warning_level(self):
+        self.warning_level -= 1
+
+        try:
+            from misago.models import WarnLevel
+            warning_levels = WarnLevel.objects.get_levels()
+            new_warning_level = warning_levels[self.warning_level]
+            if new_warning_level.expires_after_minutes:
+                self.warning_level_update_on -= timedelta(
+                    minutes=new_warning_level.expires_after_minutes)
+            else:
+                self.warning_level_update_on = None
+        except KeyError:
+            # Break expiration chain so infinite loop won't happen
+            # This should only happen if your warning level is 0, but
+            # will also keep app responsive if data corruption happens
+            self.warning_level_update_on = None
+
+    def get_warning_level(self):
+        if self.warning_level:
+            from misago.models import WarnLevel
+            return WarnLevel.objects.get_level(
+                self.warning_level)
+        else:
+            return None
+
+    def get_current_warning_level(self):
+        if self.is_warning_level_expired():
+            while self.update_expired_warning_level():
+                self.update_warning_level()
+            self.save(force_update=True)
+
+        return self.get_warning_level()
+
+    def get_latest_activte_warning(self):
+        return self.warning_set.filter(canceled=False).order_by('-id')[:1][0]
+
+    def freeze_warning_level(self):
+        self.warning_level_update_on = tz_util.now() + timedelta(days=1)
+
+    def set_warning_level_update_date(self, warning, warning_level):
+        if warning_level.expires_after_minutes:
+            self.warning_level_update_on = warning.given_on + timedelta(
+                minutes=warning_level.expires_after_minutes)
+        else:
+            self.warning_level_update_on = None
+
+    def decrease_warning_level(self):
+        if self.get_current_warning_level():
+            self.warning_level -= 1
+            if self.warning_level:
+                self.freeze_warning_level()
+                latest_warning = self.get_latest_activte_warning()
+                new_warning_level = self.get_current_warning_level()
+                self.set_warning_level_update_date(
+                    latest_warning, new_warning_level)
+                self.get_current_warning_level()
+            else:
+                self.warning_level_update_on = None
+            self.save(force_update=True)
+
+    def is_warning_active(self, warning):
+        warning_level = self.get_warning_level()
+        warnings_tracker = WarningsTracker(self.warning_level)
+
+        for db_warning in self.warning_set.order_by('-pk').iterator():
+            if warnings_tracker.is_warning_active(db_warning):
+                if warning.pk == db_warning.pk:
+                    return True
+        return False
+
+    @property
+    def warning_level_moderate_new_threads(self):
+        warning_level = self.get_current_warning_level()
+        if warning_level:
+            restriction_level = warning_level.restrict_posting_threads
+            return restriction_level == warning_level.RESTRICT_MODERATOR_REVIEW
+        else:
+            return False
+
+    @property
+    def warning_level_disallows_writing_threads(self):
+        warning_level = self.get_current_warning_level()
+        if warning_level:
+            restriction_level = warning_level.restrict_posting_threads
+            return restriction_level == warning_level.RESTRICT_DISALLOW
+        else:
+            return False
+
+    @property
+    def warning_level_moderate_new_replies(self):
+        warning_level = self.get_current_warning_level()
+        if warning_level:
+            restriction_level = warning_level.restrict_posting_replies
+            return restriction_level == warning_level.RESTRICT_MODERATOR_REVIEW
+        else:
+            return False
+
+    @property
+    def warning_level_disallows_writing_replies(self):
+        warning_level = self.get_current_warning_level()
+        if warning_level:
+            restriction_level = warning_level.restrict_posting_replies
+            return restriction_level == warning_level.RESTRICT_DISALLOW
+        else:
+            return False
+
     def timeline(self, qs, length=100):
         posts = {}
         now = tz_util.now()

+ 59 - 0
misago/models/warnlevelmodel.py

@@ -0,0 +1,59 @@
+from django.core.cache import cache
+from django.db import models
+from django.utils.datastructures import SortedDict
+from misago.thread import local
+
+_thread_local = local()
+
+class WarnLevelManager(models.Manager):
+    def get_levels(self):
+        try:
+            return _thread_local._misago_warning_levels
+        except AttributeError:
+            _thread_local._misago_warning_levels = self.fetch_levels()
+            return _thread_local._misago_warning_levels
+
+    def get_level(self, level):
+        return self.get_levels().get(level)
+
+    def fetch_levels(self):
+        from_cache = cache.get('warning_levels', 'nada')
+        if from_cache != 'nada':
+            return from_cache
+
+        from_db = self.fetch_levels_from_db()
+        cache.set('warning_levels', from_db)
+        return from_db
+
+    def fetch_levels_from_db(self):
+        fetched_levels = SortedDict()
+        for level in self.order_by('warning_level').iterator():
+            fetched_levels[level.warning_level] = level
+        return fetched_levels
+
+
+class WarnLevel(models.Model):
+    RESTRICT_NO = 0
+    RESTRICT_MODERATOR_REVIEW = 1
+    RESTRICT_DISALLOW = 2
+
+    name = models.CharField(max_length=255)
+    slug = models.SlugField(max_length=255)
+    description = models.TextField(null=True, blank=True)
+    warning_level = models.PositiveIntegerField(default=1, db_index=True)
+    expires_after_minutes = models.PositiveIntegerField(default=0)
+    restrict_posting_replies = models.PositiveIntegerField(default=RESTRICT_NO)
+    restrict_posting_threads = models.PositiveIntegerField(default=RESTRICT_NO)
+
+    objects = WarnLevelManager()
+
+    class Meta:
+        app_label = 'misago'
+
+    def save(self, *args, **kwargs):
+        super(WarnLevel, self).save(*args, **kwargs)
+        cache.delete('warning_levels')
+
+    def delete(self, *args, **kwargs):
+        super(WarnLevel, self).delete(*args, **kwargs)
+        cache.delete('warning_levels')

+ 37 - 0
misago/models/warnmodel.py

@@ -0,0 +1,37 @@
+from django.db import models
+from misago.signals import rename_user
+
+class Warn(models.Model):
+    user = models.ForeignKey('User', related_name="warning_set")
+    reason = models.TextField(null=True, blank=True)
+    reason_preparsed = models.TextField(null=True, blank=True)
+    given_on = models.DateTimeField()
+    giver = models.ForeignKey('User', null=True, blank=True,
+        on_delete=models.SET_NULL, related_name="warnings_given_set")
+    giver_username = models.CharField(max_length=255)
+    giver_slug = models.SlugField(max_length=255)
+    giver_ip = models.GenericIPAddressField()
+    giver_agent = models.CharField(max_length=255)
+    canceled = models.BooleanField(default=False)
+    canceled_on = models.DateTimeField(null=True, blank=True)
+    canceler = models.ForeignKey('User', null=True, blank=True,
+        on_delete=models.SET_NULL, related_name="warnings_canceled_set")
+    canceler_username = models.CharField(max_length=255, null=True, blank=True)
+    canceler_slug = models.SlugField(max_length=255, null=True, blank=True)
+    canceler_ip = models.GenericIPAddressField(null=True, blank=True)
+    canceler_agent = models.CharField(max_length=255, null=True, blank=True)
+
+    class Meta:
+        app_label = 'misago'
+
+
+def rename_user_handler(sender, **kwargs):
+    Warn.objects.filter(giver=sender).update(
+                                             giver_username=sender.username,
+                                             giver_slug=sender.username_slug,
+                                             )
+    Warn.objects.filter(canceler=sender).update(
+                                             canceler_username=sender.username,
+                                             canceler_slug=sender.username_slug,
+                                             )
+rename_user.connect(rename_user_handler, dispatch_uid="rename_user_warnings")

+ 15 - 11
misago/settings_base.py

@@ -165,6 +165,7 @@ PROFILE_EXTENSIONS = (
     'misago.apps.profiles.threads',
     'misago.apps.profiles.follows',
     'misago.apps.profiles.followers',
+    'misago.apps.profiles.warnings',
     'misago.apps.profiles.details',
 )
 
@@ -218,18 +219,21 @@ DEBUG_TOOLBAR_CONFIG = {
 }
 
 # List panels displayed by toolbar
-DEBUG_TOOLBAR_PANELS = (
-    'debug_toolbar.panels.timer.TimerDebugPanel',
-    'debug_toolbar.panels.sql.SQLDebugPanel',
-    'debug_toolbar.panels.request_vars.RequestVarsDebugPanel',
+DEBUG_TOOLBAR_PANELS = [
+    'debug_toolbar.panels.versions.VersionsPanel',
+    'debug_toolbar.panels.timer.TimerPanel',
+    'debug_toolbar.panels.settings.SettingsPanel',
+    'debug_toolbar.panels.headers.HeadersPanel',
+    'debug_toolbar.panels.request.RequestPanel',
+    'debug_toolbar.panels.sql.SQLPanel',
+    'debug_toolbar.panels.staticfiles.StaticFilesPanel',
+    'debug_toolbar.panels.templates.TemplatesPanel',
+    'debug_toolbar.panels.cache.CachePanel',
     'misago.acl.panels.MisagoACLDebugPanel',
-    'debug_toolbar.panels.headers.HeaderDebugPanel',
-    'debug_toolbar.panels.template.TemplateDebugPanel',
-    'debug_toolbar.panels.settings_vars.SettingsVarsDebugPanel',
-    'debug_toolbar.panels.signals.SignalDebugPanel',
-    'debug_toolbar.panels.logger.LoggingPanel',
-    'debug_toolbar.panels.version.VersionDebugPanel',
-)
+    'debug_toolbar.panels.signals.SignalsPanel',
+    'debug_toolbar.panels.logging.LoggingPanel',
+    'debug_toolbar.panels.redirects.RedirectsPanel',
+]
 
 # Turn off caching
 CACHES = {

+ 6 - 2
misago/templatetags/datetime.py

@@ -1,5 +1,5 @@
 from django_jinja.library import Library
-from misago.utils.datesformats import date, reldate, reltimesince, compact, relcompact
+from misago.utils.datesformats import date, reldate, reltimesince, compact, relcompact, timeamount
 
 register = Library()
 
@@ -26,4 +26,8 @@ def compact_filter(val):
 
 @register.filter(name='relcompact')
 def relcompact_filter(val):
-    return relcompact(val)
+    return relcompact(val)
+
+@register.filter(name='timeamount')
+def timeamount_filter(val, unit='minutes'):
+    return timeamount(val, unit)

+ 1 - 0
misago/urls.py

@@ -22,6 +22,7 @@ urlpatterns = patterns('misago.apps',
     url(r'^popular/(?P<page>[1-9]([0-9]+)?)/$', 'popularthreads.popular_threads', name="popular_threads"),
     url(r'^new/$', 'newthreads.new_threads', name="new_threads"),
     url(r'^new/(?P<page>[1-9]([0-9]+)?)/$', 'newthreads.new_threads', name="new_threads"),
+    url(r'^warn-user/(?P<slug>\w+)-(?P<user>\d+)/', 'warnuser.views.warn_user', name="warn_user"),
 )
 
 urlpatterns += patterns('',

+ 60 - 11
misago/utils/datesformats.py

@@ -19,7 +19,7 @@ formats = {
 
 for key in formats:
     formats[key] = get_format(key).replace('P', 'g:i a')
-    
+
 def date(val, arg=""):
     if not val:
         return _("Never")
@@ -41,22 +41,22 @@ def reldate(val, arg=""):
     # Today check
     if format(local, 'Y-z') == format(local_now, 'Y-z'):
         return _("Today, %(hour)s") % {'hour': time_format(local, formats['TIME_FORMAT'])}
-        
+
     # Yesteday check
     yesterday = localtime(now - timedelta(days=1))
     if format(local, 'Y-z') == format(yesterday, 'Y-z'):
         return _("Yesterday, %(hour)s") % {'hour': time_format(local, formats['TIME_FORMAT'])}
-        
+
     # Tomorrow Check
     tomorrow = localtime(now + timedelta(days=1))
     if format(local, 'Y-z') == format(tomorrow, 'Y-z'):
         return _("Tomorrow, %(hour)s") % {'hour': time_format(local, formats['TIME_FORMAT'])}
-        
+
     # Day of Week check
     if format(local, 'D') != format(local_now, 'D') and diff.days > -7 and diff.days < 7:
         return _("%(day)s, %(hour)s") % {'day': format(local, 'l'), 'hour': time_format(local, formats['TIME_FORMAT'])}
 
-    # Fallback to date      
+    # Fallback to date
     return date(val, arg)
 
 
@@ -75,7 +75,7 @@ def reltimesince(val, arg=""):
     if diff.seconds >= 0:
         if diff.seconds <= 5:
             return _("Just now")
-                        
+
         if diff.seconds < 3540:
             minutes = int(math.ceil(diff.seconds / 60.0))
             return ungettext(
@@ -85,11 +85,11 @@ def reltimesince(val, arg=""):
 
         if diff.seconds < 3660:
             return _("Hour ago")
-        
+
         if diff.seconds < 10800:
             hours = int(math.floor(diff.seconds / 3600.0))
             minutes = (diff.seconds - (hours * 3600)) / 60
-            
+
             if minutes > 0:
                 return ungettext(
                     "Hour and %(minutes)s ago",
@@ -98,7 +98,7 @@ def reltimesince(val, arg=""):
                         "%(minutes)s minute",
                         "%(minutes)s minutes",
                     minutes) % {'minutes': minutes}}
-                
+
             return ungettext(
                     "Hour ago",
                     "%(hours)s hours ago",
@@ -114,7 +114,7 @@ def compact(val):
     now = datetime.now(utc if is_aware(val) else None)
     local = localtime(val)
 
-    if now.year == local.year:        
+    if now.year == local.year:
         return format(localtime(val), _('j M'))
     return format(localtime(val), _('j M y'))
 
@@ -140,4 +140,53 @@ def relcompact(val):
             hours = int(math.ceil(diff.seconds / 3600.0))
             return pgettext("number of hours", "%(hour)sh") % {'hour': hours}
 
-    return compact(val)
+    return compact(val)
+
+
+def timeamount(val, unit='minutes'):
+    if unit == 'hours':
+        seconds = val * 3600
+    elif unit == 'minutes':
+        seconds = val * 60
+    elif unit == 'seconds':
+        seconds = val
+    else:
+        raise ValueError('Only hours, minutes and seconds are supported')
+
+    segments = []
+
+    if seconds >= 86400:
+        days = int(seconds / 86400)
+        seconds -= days * 86400
+        segments.append(ungettext(
+                "day",
+                "%(days)s days",
+            days) % {'days': days})
+
+    if seconds >= 3600:
+        hours = int(seconds / 3600)
+        seconds -= hours * 3600
+        segments.append(ungettext(
+                "hour",
+                "%(hours)s hours",
+            hours) % {'hours': hours})
+
+    if seconds >= 60:
+        minutes = int(seconds / 60)
+        seconds -= minutes * 60
+        segments.append(ungettext(
+                "minute",
+                "%(minutes)s minutes",
+            minutes) % {'minutes': minutes})
+
+    if seconds > 0:
+        segments.append(ungettext(
+                "second",
+                "%(seconds)s seconds",
+            seconds) % {'seconds': seconds})
+
+    if len(segments) > 1:
+        humane = ', '.join(segments[:-1])
+        return _("%(segments)s and %(last)s") % {'segments': humane, 'last': segments[-1]}
+    return segments[0]
+

+ 1 - 1
requirements.txt

@@ -1,5 +1,5 @@
 django<1.6
-django-debug-toolbar
+django-debug-toolbar==1
 django-floppyforms
 django-haystack
 django-jinja

+ 14 - 0
static/cranefly/css/cranefly.css

@@ -978,9 +978,23 @@ a.btn-link:hover,a.btn-link:active,a.btn-link:focus{color:#333;text-decoration:n
 .user-profile .user-details .label{font-size:14px}
 .user-profile .user-list .user-cell{overflow:auto}.user-profile .user-list .user-cell .user-avatar{float:left}.user-profile .user-list .user-cell .user-avatar img{border-radius:3px;width:36px;height:36px}
 .user-profile .user-list .user-cell .user-name:link,.user-profile .user-list .user-cell .user-name:active,.user-profile .user-list .user-cell .user-name:visited,.user-profile .user-list .user-cell .user-name:hover{display:block;margin:0;margin-left:43px;margin-top:9.1px;color:#333;font-size:23.8px}
+.user-profile .warning-level{border:1px solid #555;border-radius:5px;margin-bottom:20px;padding:7px 14px;overflow:auto}.user-profile .warning-level h3,.user-profile .warning-level .lead{margin:0}
+.user-profile .warning-level.warning-active{border-color:#cf402e;color:#cf402e}.user-profile .warning-level.warning-active h3{font-size:21px}
+.user-profile .warning-level.warning-active .lead{font-size:17.5px}
+.user-profile .warning-level.warning-active .warning-expiration{clear:both;margin-top:10px;padding:0;font-size:11.9px}
+.user-profile .warning-level.warning-clean{border-color:#46a546;color:#46a546}.user-profile .warning-level.warning-clean h3{float:left;margin:0;margin-right:14px;padding:2.8px 14px}
+.user-profile .warning-level.warning-clean .lead{float:left;margin:0;padding:7px 0}
 .user-profile .content-list .media{overflow:auto}.user-profile .content-list .media .media-object{border-radius:3px;width:52px;height:52px}
 .user-profile .content-list .media .media-body{margin-left:66px}.user-profile .content-list .media .media-body .post-preview:link,.user-profile .content-list .media .media-body .post-preview:active,.user-profile .content-list .media .media-body .post-preview:visited,.user-profile .content-list .media .media-body .post-preview:hover{display:block;margin-top:7px;color:#333;font-size:16.8px;text-decoration:none}
 .user-profile .content-list .media .media-body .media-footer{margin:0;color:#999;font-size:10.5px;font-weight:normal}.user-profile .content-list .media .media-body .media-footer a{color:#555}
+.user-profile .content-list.user-warnings .media{margin-bottom:-6px}.user-profile .content-list.user-warnings .media .warning-icon{width:60px;font-size:35px;text-align:center}.user-profile .content-list.user-warnings .media .warning-icon .warning-active,.user-profile .content-list.user-warnings .media .warning-icon .warning-expired,.user-profile .content-list.user-warnings .media .warning-icon .warning-canceled{margin-bottom:-3px;position:relative;top:3px}
+.user-profile .content-list.user-warnings .media .warning-icon .warning-active{color:#cf402e}
+.user-profile .content-list.user-warnings .media .warning-icon .warning-expired{color:#999}
+.user-profile .content-list.user-warnings .media .warning-icon .warning-canceled{color:#999}
+.user-profile .content-list.user-warnings .media .media-body .warning-reason>:first-child{margin-top:0;padding-top:0}
+.user-profile .content-list.user-warnings .media .media-body .warning-reason>:last-child{margin-bottom:0;padding-bottom:0}
+.user-profile .content-list.user-warnings .media .media-footer form,.user-profile .content-list.user-warnings .media .media-footer p{float:left;margin:0}
+.user-profile .content-list.user-warnings .media .media-footer form{padding:0;margin-right:14px}
 .forum-subforums-list{background-color:#fff;border:1px solid #d5d5d5;border-radius:2px;-webkit-box-shadow:0 0 0 3px #eee;-moz-box-shadow:0 0 0 3px #eee;box-shadow:0 0 0 3px #eee;margin-bottom:20px}.forum-subforums-list .header{background-color:#fbfbfb;border:1px solid #d5d5d5;border-radius:2px 2px 0 0;margin:-1px;margin-bottom:0;padding:3.966666666666667px 9.9px}.forum-subforums-list .header h2{margin:0;padding:0;color:#333;font-size:11.9px;font-weight:bold;line-height:20px;text-align:left}.forum-subforums-list .header h2 small{margin-left:7px;color:#999;font-size:11.9px}
 .forum-subforums-list .forum{border-bottom:1px solid #d5d5d5;height:21px;overflow:visible;padding:14.75px 9.9px}.forum-subforums-list .forum.last{border-bottom:none}
 .forum-subforums-list .forum .forum-icon{float:left;position:relative;bottom:1px;width:36px;color:#d5d5d5;font-size:24px;text-align:center}.forum-subforums-list .forum .forum-icon.forum-icon-new{color:#cf402e}

+ 111 - 1
static/cranefly/css/cranefly/profiles.less

@@ -65,7 +65,7 @@
       svg {
         position: relative;
         left: 1px;
-      	height: 40px;
+        height: 40px;
         width: 100%;
       }
     }
@@ -123,6 +123,59 @@
     }
   }
 
+  .warning-level {
+    border: 1px solid @gray;
+    border-radius: @borderRadiusLarge;
+    margin-bottom: @baseLineHeight;
+    padding: (@baseFontSize / 2) @baseFontSize;
+    overflow: auto;
+
+    h3, .lead {
+      margin: 0px;
+    }
+
+    &.warning-active {
+      border-color: @red;
+
+      color: @red;
+
+      h3 {
+        font-size: @fontSizeLarge * 1.2;
+      }
+
+      .lead {
+        font-size: @fontSizeLarge;
+      }
+
+      .warning-expiration {
+        clear: both;
+        margin-top: @baseLineHeight / 2;
+        padding: 0px;
+
+        font-size: @fontSizeSmall;
+      }
+    }
+
+    &.warning-clean {
+      border-color: @green;
+
+      color: @green;
+
+      h3 {
+        float: left;
+        margin: 0px;
+        margin-right: @baseFontSize;
+        padding: (@baseFontSize / 5) @baseFontSize;
+      }
+
+      .lead {
+        float: left;
+        margin: 0px;
+        padding: (@baseFontSize / 2) 0px;
+      }
+    }
+  }
+
   .content-list {
     .media {
       overflow: auto;
@@ -160,5 +213,62 @@
         }
       }
     }
+
+    &.user-warnings {
+      .media {
+        margin-bottom: -6px;
+
+        .warning-icon {
+          width: 60px;
+
+          font-size: @fontSizeLarge * 2;
+          text-align: center;
+
+          .warning-active, .warning-expired, .warning-canceled {
+            margin-bottom: -3px;
+            position: relative;
+            top: 3px;
+          }
+
+          .warning-active {
+            color: @red;
+          }
+
+          .warning-expired {
+            color: @grayLight;
+          }
+
+          .warning-canceled {
+            color: @grayLight;
+          }
+        }
+
+        .media-body {
+          .warning-reason {
+            &>:first-child {
+              margin-top: 0px;
+              padding-top: 0px;
+            }
+
+            &>:last-child {
+              margin-bottom: 0px;
+              padding-bottom: 0px;
+            }
+          }
+        }
+
+        .media-footer {
+          form, p {
+            float: left;
+            margin: 0px;
+          }
+
+          form {
+            padding: 0px;
+            margin-right: @baseFontSize;
+          }
+        }
+      }
+    }
   }
 }

+ 11 - 6
static/cranefly/js/cranefly.js

@@ -164,7 +164,7 @@ function link2player(element, link_href) {
   var re = /vimeo.com\/([0-9]+)/;
   if (re.test(link_href)) {
     media_url = link_href.match(re);
-    return $(element).replaceWith('<iframe src="http://player.vimeo.com/video/' + media_url[1] + '?color=CF402E" width="500" height="281" frameborder="0" webkitAllowFullScreen mozallowfullscreen allowFullScreen></iframe>');
+    return $(element).replaceWith('<iframe src="//player.vimeo.com/video/' + media_url[1] + '?color=CF402E" width="500" height="281" frameborder="0" webkitAllowFullScreen mozallowfullscreen allowFullScreen></iframe>');
   }
 
   // No link
@@ -174,20 +174,20 @@ function link2player(element, link_href) {
 // Youtube player
 function youtube_player(element, movie_id, startfrom) {
   if (typeof startfrom != 'undefined') {
-    player_url = 'http://www.youtube.com/embed/' + movie_id + '?start=' + startfrom + '&amp;autoplay=1';
+    player_url = '//www.youtube.com/embed/' + movie_id + '?start=' + startfrom + '&amp;autoplay=1';
   } else {
-    player_url = 'http://www.youtube.com/embed/' + movie_id + '?autoplay=1';
+    player_url = '//www.youtube.com/embed/' + movie_id + '?autoplay=1';
   }
 
   // Replace link with fancy image
-  var media_element = $('<div><div class="media-border youtube-player" data-movieid="' + movie_id + '"><div class="media-thumbnail" style="background-image: url(\'http://img.youtube.com/vi/' + movie_id + '/0.jpg\');"><a href="' + $.trim($(element).text()) + '" class="play-link" data-playerurl="' + player_url + '"><i class="icon-youtube-sign"></i><strong>' + l_play_media_msg + '</strong></a></div></div></div>');
+  var media_element = $('<div><div class="media-border youtube-player" data-movieid="' + movie_id + '"><div class="media-thumbnail" style="background-image: url(\'//img.youtube.com/vi/' + movie_id + '/0.jpg\');"><a href="' + $.trim($(element).text()) + '" class="play-link" data-playerurl="' + player_url + '"><i class="icon-youtube-sign"></i><strong>' + l_play_media_msg + '</strong></a></div></div></div>');
   $(media_element).find('.play-link').click(function() {
     $(this).parent().replaceWith('<iframe width="853" height="480" src="' + $(this).data('playerurl') + '" frameborder="0" allowfullscreen></iframe>');
     return false;
   });
   $(element).replaceWith(media_element);
   // Fetch title, author name and thumbnail
-  $.getJSON("https://gdata.youtube.com/feeds/api/videos/" + movie_id + "?v=2&alt=json",
+  $.getJSON("//gdata.youtube.com/feeds/api/videos/" + movie_id + "?v=2&alt=json",
             function(data, textStatus, jqXHR) {
               // Movie details
               var movie_title = data.entry.title.$t;
@@ -196,7 +196,7 @@ function youtube_player(element, movie_id, startfrom) {
               $(media_element).find('.play-link strong').text(movie_title);
               $(media_element).find('.play-link').append(l_play_media_author.replace('{author}', movie_author));
               // Movie thumbnail
-              var thumb = {height: 90, url: 'http://img.youtube.com/vi/' + movie_id + '/0.jpg'};
+              var thumb = {height: 90, url: '//img.youtube.com/vi/' + movie_id + '/0.jpg'};
               console.log(data.entry['media$group']['media$thumbnail']);
               $(data.entry['media$group']['media$thumbnail']).each(function(key, yt_image) {
                 if (thumb.height < yt_image.height) {
@@ -333,6 +333,11 @@ $(function() {
     var csrf_token = $(this).find('input[name="_csrf_token"]').val();
     var button = $(this).find('button');
     $(this).submit(function() {
+      var decision = confirm(l_report_sure);
+      if (!decision) {
+        return false;
+      }
+
       var form = this;
       $.post(form.action, {'_csrf_token': csrf_token}, "json").done(function(data, textStatus, jqXHR) {
         $(button).text(l_post_reported);

+ 16 - 0
templates/admin/warning_levels/form.html

@@ -0,0 +1,16 @@
+{% extends "admin/admin/form.html" %}
+{% import "forms.html" as form_theme with context %}
+
+{% block form %}
+<fieldset>
+  <legend>{% trans %}Basic Warning Level Options{% endtrans %}</legend>
+  {{ form_theme.row(form.name, attrs={'class': 'span12'}) }}
+  {{ form_theme.row(form.description, attrs={'class': 'span12', 'rows': 3}) }}
+  {{ form_theme.row(form.expires_after_minutes, attrs={'class': 'span12'}) }}
+</fieldset>
+<fieldset>
+  <legend>{% trans %}Restrictions{% endtrans %}</legend>
+  {{ form_theme.row(form.restrict_posting_replies, attrs={'class': 'span12'}) }}
+  {{ form_theme.row(form.restrict_posting_threads, attrs={'class': 'span12'}) }}
+</fieldset>
+{% endblock %}

+ 17 - 0
templates/admin/warning_levels/list.html

@@ -0,0 +1,17 @@
+{% extends "admin/admin/list.html" %}
+{% import "forms.html" as form_theme with context %}
+
+{% block table_head scoped %}
+  {{ super() }}
+  <th>{% trans %}Warning Level{% endtrans %}</th>
+{% endblock %}
+
+{% block table_row scoped %}
+  <td>
+    <strong>{{ item.name }}</strong>
+    {% if item.description %}<div class="muted">{{ item.description }}</div>{% endif %}
+  </td>
+  <td class="span2">
+    {{ form_theme.field(table_form['pos_' + item.pk|string], attrs={'form': 'table_form', 'class': 'span2'}) }}
+  </td>
+{% endblock%}

+ 3 - 1
templates/cranefly/editor.html

@@ -1,4 +1,4 @@
-{% macro editor(field, submit_button, placeholder=None, rows=4, hide_links=False, hide_images=False, hide_hr=False, zen=False, extra=None) %}
+{% macro editor(field, submit_button, placeholder=None, rows=4, hide_links=False, hide_images=False, hide_hr=False, hide_attachments=False, zen=False, extra=None) %}
 <div class="editor editor-editable">
   {% if field.errors %}
   <div class="editor-error">
@@ -30,7 +30,9 @@
     <button name="save" type="submit" class="btn btn-primary pull-right">{{ submit_button }}</button>
     {% if extra %}{{ extra }}{% endif %}
   </div>
+  {% if not hide_attachments %}
   {{ attachments_editor() }}
+  {% endif %}
 </div>
 {% endmacro %}
 

+ 12 - 7
templates/cranefly/private_threads/thread.html

@@ -181,11 +181,7 @@
                 <label class="checkbox post-checkbox"><input form="posts_form" name="{{ posts_form['list_items']['html_name'] }}" type="checkbox" class="checkbox-member" value="{{ post.pk }}"{% if posts_form['list_items']['has_value'] and ('' ~ post.pk) in posts_form['list_items']['value'] %} checked="checked"{% endif %}></label>
                 {% endif %}
 
-                <a href="{% if pagination['page'] > 1 -%}
-                {{ url('private_thread', thread=thread.pk, slug=thread.slug, page=pagination['page']) }}
-                {%- else -%}
-                {{ url('private_thread', thread=thread.pk, slug=thread.slug) }}
-                {%- endif %}#post-{{ post.pk }}" class="post-perma tooltip-left" title="{% trans %}Direct link to this post{% endtrans %}">#{{ pagination['start'] + loop.index }}</a>
+                <a href="{{ url('private_thread_find', thread=thread.pk, slug=thread.slug, post=post.pk) }}" class="post-perma tooltip-left" title="{% trans %}Direct link to this post{% endtrans %}">#{{ pagination['start'] + loop.index }}</a>
 
                 <div class="post-extra">
                   {% if post.protected and acl.threads.can_protect(forum) %}
@@ -281,6 +277,14 @@
               </div>
               <div class="post-footer">{% filter trim %}
                 <div class="post-actions">
+                  {% if user.pk != post.user_id and post.user_id and acl.warnings.can_warn_members() %}
+                  <form action="{{ url('warn_user', user=post.user.pk, slug=post.user.username_slug) }}" class="form-inline" method="post" autocomplete="off">
+                    <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+                    <input type="hidden" name="retreat" value="{{ request_path }}">
+                    <input type="hidden" name="reason" value="{% trans message=board_address ~ url('private_thread_find', thread=thread.pk, slug=thread.slug, post=post.pk), thread=thread.name %}Your message in private thread &quot;[{{ thread }}]({{ message }})&quot; was found to violate community guidelines.{% endtrans %}">
+                    <button type="submit" class="btn btn-link tooltip-top" title="{% trans %}Warn user for this post.{% endtrans %}">{% trans %}Warn{% endtrans %}</button>
+                  </form>
+                  {% endif %}
                   {% if acl.users.can_see_users_trails() -%}
                   <a href="{{ url('private_post_info', thread=thread.pk, slug=thread.slug, post=post.pk) }}" class="post-trail">{% trans %}Info{% endtrans %}</a>
                   {% endif %}
@@ -485,7 +489,7 @@
 
         <h4>{% trans %}Invite User{% endtrans %}</h4>
         <div class="invite-participant">
-          <form class="form-inline" action="{{ url('private_thread_invite_user', thread=thread.pk, slug=thread.slug) }}" method="post">
+          <form class="form-inline" action="{{ url('private_thread_invite_user', thread=thread.pk, slug=thread.slug) }}"{% if acl.threads.can_upload_attachments(forum) %} enctype="multipart/form-data"{% endif %} method="post">
             <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
             <input type="hidden" name="retreat" value="{{ request_path }}">
             {{ form_theme.field(invite_form.username, attrs={'class': 'span2', 'placeholder': lang_user_to_invite()}) }}
@@ -508,7 +512,8 @@
   {{ super() }}
   <script src="{{ STATIC_URL }}cranefly/highlight/highlight.pack.js"></script>
   <script type="text/javascript">
-    var l_post_reported = "{{ _('Reported!') }}";
+    var l_report_sure = "{% trans %}Are you sure you want to bring this message to moderator attention?{% endtrans %}";
+    var l_post_reported = "{% trans %}Reported!{% endtrans %}";
     hljs.tabReplace = '    ';
     hljs.initHighlightingOnLoad();
     EnhancePostsMD();

+ 12 - 0
templates/cranefly/profiles/profile.html

@@ -76,6 +76,18 @@
               </form>
             </li>
             {% endif %}
+            {% if acl.warnings.can_warn_members() %}
+            <li class="pull-right">
+              <form class="form-inline" action="{{ url('warn_user', user=profile.pk, slug=profile.username_slug) }}" method="post">
+                <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+                <input type="hidden" name="retreat" value="{{ request_path }}">
+                <input type="hidden" name="reason" value="{% trans %}Your profile contents were found to violate community guidelines.{% endtrans %}">
+                <button type="submit" class="btn btn-icon tooltip-top" title="{% trans user=profile.username %}Increase {{ user }}'s warning level.{% endtrans %}">
+                  <i class="icon-warning-sign"></i>
+                </button>
+              </form>
+            </li>
+            {% endif %}
             {% endif %}
           </ul>
         </div>

+ 3 - 3
templates/cranefly/profiles/threads.html

@@ -44,9 +44,9 @@
 <div class="pagination">
   <ul>
     <li class="count">{{ macros.pager_label(pagination) }}</li>
-    {%- if pagination['prev'] > 1 %}<li><a href="{{ url('user_threads', user=profile.id, username=profile.username_slug) }}" class="tooltip-top" title="{% trans %}Lastest Posts{% endtrans %}"><i class="icon-chevron-left"></i> {% trans %}Latest{% endtrans %}</a></li>{% endif -%}
-    {%- if pagination['prev'] > 0 %}<li><a href="{%- if pagination['prev'] > 1 %}{{ url('user_threads', user=profile.id, username=profile.username_slug, page=pagination['prev']) }}{% else %}{{ url('user_threads', user=profile.id, username=profile.username_slug) }}{% endif %}" class="tooltip-top" title="{% trans %}Newer Posts{% endtrans %}"><i class="icon-chevron-left"></i></a></li>{% endif -%}
-    {%- if pagination['next'] > 0 %}<li><a href="{{ url('user_threads', user=profile.id, username=profile.username_slug, page=pagination['next']) }}" class="tooltip-top" title="{% trans %}Older Posts{% endtrans %}"><i class="icon-chevron-right"></i></a></li>{% endif -%}
+    {%- if pagination['prev'] > 1 %}<li><a href="{{ url('user_threads', user=profile.id, username=profile.username_slug) }}" class="tooltip-top" title="{% trans %}Lastest Threads{% endtrans %}"><i class="icon-chevron-left"></i> {% trans %}Latest{% endtrans %}</a></li>{% endif -%}
+    {%- if pagination['prev'] > 0 %}<li><a href="{%- if pagination['prev'] > 1 %}{{ url('user_threads', user=profile.id, username=profile.username_slug, page=pagination['prev']) }}{% else %}{{ url('user_threads', user=profile.id, username=profile.username_slug) }}{% endif %}" class="tooltip-top" title="{% trans %}Newer Threads{% endtrans %}"><i class="icon-chevron-left"></i></a></li>{% endif -%}
+    {%- if pagination['next'] > 0 %}<li><a href="{{ url('user_threads', user=profile.id, username=profile.username_slug, page=pagination['next']) }}" class="tooltip-top" title="{% trans %}Older Threads{% endtrans %}"><i class="icon-chevron-right"></i></a></li>{% endif -%}
   </ul>
 </div>
 {% endif %}

+ 147 - 0
templates/cranefly/profiles/warnings.html

@@ -0,0 +1,147 @@
+{% extends "cranefly/profiles/profile.html" %}
+{% import "cranefly/macros.html" as macros with context %}
+
+{% block title %}{{ macros.page_title(_('Warnings'), profile.username) }}{% endblock %}
+
+{% block tab %}
+{% if warning_level %}
+<div class="warning-level warning-active">
+  <h3><i class="icon-warning-sign"></i> {{ warning_level.name }}</h3>
+  {% if warning_level.description %}
+  <p class="lead">{{ warning_level.description }}</p>
+  {% endif %}
+  {% if profile.warning_level_update_on %}
+  <p class="warning-expiration">{% trans expires=format_warning_expiration(profile.warning_level_update_on|reldate|lower) %}This warning level will expire {{ expires }}.{% endtrans %}</p>
+  {% else %}
+  <p class="warning-expiration">{% trans %}This warning level will not expire and will have to be decreased manually by forum team member.{% endtrans %}</p>
+  {% endif %}
+</div>
+{% else %}
+<div class="warning-level warning-clean">
+  {% if user.is_authenticated() and user.pk == profile.pk %}
+  <p class="lead"><i class="icon-ok"></i> {% trans username=profile.username %}{{ username }}, your account has no warning level set. Good job!{% endtrans %}</p>
+  {% else %}
+  <p class="lead"><i class="icon-ok"></i> {% trans username=profile.username %}{{ username }}'s account has no warning level set.{% endtrans %}</p>
+  {% endif %}
+</div>
+{% endif %}
+
+<h2>{% if items_total -%}
+    {%- trans count=items_total, total=items_total|intcomma, username=profile.username -%}
+    {{ username }} received one warning
+    {%- pluralize -%}
+    {{ username }} received {{ total }} warnings
+    {%- endtrans -%}
+    {%- else -%}
+    {% trans username=profile.username %}{{ username }} received no warnings{% endtrans %}
+    {%- endif %}</h2>
+
+{% if items_total %}
+<div class="content-list user-warnings">
+  {% for item in items %}
+  <div class="media">
+    <div class="pull-left">
+      <div class="warning-icon ">
+        {% if item.canceled %}
+        <i class="icon-ban-circle warning-canceled tooltip-top" title="{% trans %}This warning has been canceled.{% endtrans %}"></i>
+        {% elif warnings_tracker.is_warning_expired(item) %}
+        <i class="icon-warning-sign warning-expired tooltip-top" title="{% trans %}This warning has expired.{% endtrans %}"></i>
+        {% else %}
+        <i class="icon-warning-sign warning-active tooltip-top" title="{% trans %}This warning is in effect.{% endtrans %}"></i>
+        {% endif %}
+      </div>
+    </div>
+    <div class="media-body">
+      <div class="warning-reason">
+        {% if item.reason %}
+        {{ item.reason_preparsed|safe }}
+        {% else %}
+        <em>{% trans %}No warning reason was provided by warning giver.{% endtrans %}</em>
+        {% endif %}
+      </div>
+      <div class="media-footer">
+        {% if acl.warnings.can_cancel_warning(user, profile, item) and not warnings_tracker.is_warning_expired(item) %}
+        <form action="{{ url('user_warnings_cancel', user=profile.pk, username=profile.username_slug, warning=item.pk) }}" class="confirm-cancel" method="post">
+          <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+          <button type="submit" class="btn btn-mini"><i class="icon-ban-circle"></i> {% trans %}Cancel{% endtrans %}</button>
+        </form>
+        {% endif %}
+        {% if acl.warnings.can_delete_warnings() %}
+        <form action="{{ url('user_warnings_delete', user=profile.pk, username=profile.username_slug, warning=item.pk) }}" class="confirm-delete" method="post">
+          <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+          <button type="submit" class="btn btn-mini"><i class="icon-remove"></i> {% trans %}Delete{% endtrans %}</button>
+        </form>
+        {% endif %}
+        <p>
+        {% if acl.users.can_see_users_trails() %}
+        {% trans user=warning_giver(item), date=item.given_on|reltimesince|low, ip=item.giver_ip %}Given by {{ user }} {{ date }} from ip {{ ip }}.{% endtrans %}
+        {% else %}
+        {% trans user=warning_giver(item), date=item.given_on|reltimesince|low %}Given by {{ user }} {{ date }}.{% endtrans %}
+        {% endif %}
+        {% if item.canceled %}
+          {% if acl.users.can_see_users_trails() %}
+          {% trans user=warning_canceler(item), date=item.canceled_on|reltimesince|low, ip=item.canceler_ip %}Canceled by {{ user }} {{ date }} from ip {{ ip }}.{% endtrans %}
+          {% else %}
+          {% trans user=warning_canceler(item), date=item.canceled_on|reltimesince|low %}Canceled by {{ user }} {{ date }}.{% endtrans %}
+          {% endif %}
+        {% endif %}
+        </p>
+      </div>
+    </div>
+  </div>
+  <hr>
+  {% endfor %}
+  {{ pager() }}
+</div>
+{% endif %}
+{% endblock %}
+
+
+{% block javascripts -%}{{ super() }}
+<script type="text/javascript">
+  $(function() {
+    $('.confirm-cancel').submit(function() {
+      var decision = confirm("{% trans %}Are you sure you want to cancel this warning?{% endtrans %}");
+      return decision;
+    });
+    $('.confirm-delete').submit(function() {
+      var decision = confirm("{% trans %}Are you sure you want to delete this warning?{% endtrans %}");
+      return decision;
+    });
+  });
+</script>
+{%- endblock %}
+
+
+{% macro format_warning_expiration(expires) %}
+<strong>{{ expires }}</strong>
+{% endmacro %}
+
+{% macro warning_giver(item) -%}
+{% if item.giver_id %}
+<a href="{{ url('user', user=item.giver_id, username=item.giver_slug) }}">{{ item.giver_username }}</a>
+{% else %}
+<strong>{{ item.giver_username }}</strong>
+{% endif %}
+{%- endmacro %}
+
+{% macro warning_canceler(item) -%}
+{% if item.canceler_id %}
+<a href="{{ url('user', user=item.canceler_id, username=item.canceler_slug) }}">{{ item.canceler_username }}</a>
+{% else %}
+<strong>{{ item.canceler_username }}</strong>
+{% endif %}
+{%- endmacro %}
+
+{% macro pager() -%}
+{% if pagination['total'] > 1 %}
+<div class="pagination">
+  <ul>
+    <li class="count">{{ macros.pager_label(pagination) }}</li>
+    {%- if pagination['prev'] > 1 %}<li><a href="{{ url('user_warnings', user=profile.id, username=profile.username_slug) }}" class="tooltip-top" title="{% trans %}Lastest Warnings{% endtrans %}"><i class="icon-chevron-left"></i> {% trans %}Latest{% endtrans %}</a></li>{% endif -%}
+    {%- if pagination['prev'] > 0 %}<li><a href="{%- if pagination['prev'] > 1 %}{{ url('user_warnings', user=profile.id, username=profile.username_slug, page=pagination['prev']) }}{% else %}{{ url('user_warnings', user=profile.id, username=profile.username_slug) }}{% endif %}" class="tooltip-top" title="{% trans %}Newer Warnings{% endtrans %}"><i class="icon-chevron-left"></i></a></li>{% endif -%}
+    {%- if pagination['next'] > 0 %}<li><a href="{{ url('user_warnings', user=profile.id, username=profile.username_slug, page=pagination['next']) }}" class="tooltip-top" title="{% trans %}Older Warnings{% endtrans %}"><i class="icon-chevron-right"></i></a></li>{% endif -%}
+  </ul>
+</div>
+{% endif %}
+{%- endmacro %}

+ 13 - 4
templates/cranefly/threads/thread.html

@@ -386,6 +386,14 @@
 
             {% if user.is_authenticated() %}
             <div class="post-actions">
+              {% if user.pk != post.user_id and post.user_id and acl.warnings.can_warn_members() %}
+              <form action="{{ url('warn_user', user=post.user.pk, slug=post.user.username_slug) }}" class="form-inline" method="post" autocomplete="off">
+                <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+                <input type="hidden" name="retreat" value="{{ request_path }}">
+                <input type="hidden" name="reason" value="{% trans message=board_address ~ url('thread_find', thread=thread.pk, slug=thread.slug, post=post.pk), thread=thread.name %}Your message in thread &quot;[{{ thread }}]({{ message }})&quot; was found to violate community guidelines.{% endtrans %}">
+                <button type="submit" class="btn btn-link tooltip-top" title="{% trans %}Warn user for this post.{% endtrans %}">{% trans %}Warn{% endtrans %}</button>
+              </form>
+              {% endif %}
               {% if acl.users.can_see_users_trails() -%}
               <a href="{{ url('post_info', thread=thread.pk, slug=thread.slug, post=post.pk) }}" class="post-trail">{% trans %}Info{% endtrans %}</a>
               {% endif %}
@@ -548,9 +556,9 @@
     {% endif %}
   </div>
 
-  {% if user.is_authenticated() and acl.threads.can_reply(forum, thread) %}
+  {% if user.is_authenticated() and acl.threads.can_reply(forum, thread) and not user.warning_level_disallows_writing_replies %}
   <div class="thread-quick-reply">
-    <form action="{{ url('thread_reply', thread=thread.pk, slug=thread.slug) }}" method="post">
+    <form action="{{ url('thread_reply', thread=thread.pk, slug=thread.slug) }}"{% if acl.threads.can_upload_attachments(forum) %} enctype="multipart/form-data"{% endif %} method="post">
       <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
       <input type="hidden" name="quick_reply" value="1">
       <img src="{{ user.get_avatar(100) }}" alt="{% trans %}Your Avatar{% endtrans %}" class="user-avatar">
@@ -571,7 +579,8 @@
   {{ super() }}
   <script src="{{ STATIC_URL }}cranefly/highlight/highlight.pack.js"></script>
   <script type="text/javascript">
-    var l_post_reported = "{{ _('Reported!') }}";
+    var l_report_sure = "{% trans %}Are you sure you want to bring this message to moderator attention?{% endtrans %}";
+    var l_post_reported = "{% trans %}Reported!{% endtrans %}";
     hljs.tabReplace = '    ';
     hljs.initHighlightingOnLoad();
     EnhancePostsMD();
@@ -618,7 +627,7 @@
     });
     {% endif %}
   </script>
-  {% if user.is_authenticated() and acl.threads.can_reply(forum, thread) %}
+  {% if user.is_authenticated() and acl.threads.can_reply(forum, thread) and not user.warning_level_disallows_writing_replies %}
   {{ editor.js() }}
   {% endif %}
 {%- endblock %}

+ 2 - 1
templates/cranefly/usercp/signature.html

@@ -30,7 +30,8 @@
     {{ editor.editor(form.signature, lang_save_signature(),
       hide_links=(not acl.usercp.allow_signature_links()),
       hide_images=(not acl.usercp.allow_signature_images()),
-      hide_hr=True) }}
+      hide_hr=True,
+      hide_attachments=True,) }}
   </form>
 
 </div>

+ 85 - 0
templates/cranefly/warn_user/form.html

@@ -0,0 +1,85 @@
+{% extends "cranefly/layout.html" %}
+{% import "forms.html" as form_theme with context %}
+{% import "cranefly/macros.html" as macros with context %}
+
+{% block title %}{{ macros.page_title(title=warned_user.username, parent=_('Warn User')) }}{% endblock %}
+
+{% block content %}
+<div class="row">
+  <div class="span6 offset3">
+    <div class="form-container container-horizontal">
+
+      <div class="form-header">
+        <h1>{% trans user=warned_user.username %}Warn User: {{ user }}{% endtrans %}</h1>
+      </div>
+
+      <form action="{{ url('warn_user', user=warned_user.pk, slug=warned_user.username_slug) }}" method="post">
+        <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+        <input type="hidden" name="origin" value="warning-form">
+        {% if retreat %}
+        <input type="hidden" name="retreat" value="{{ retreat }}">
+        {% endif %}
+        <div class="form-fields">
+
+          <fieldset>
+            {{ form_theme.row(form.reason, attrs={'class': 'span6', 'rows': 4}) }}
+          </fieldset>
+
+          <hr>
+
+          <h4>{% trans next_level_name=wrap_level_name(next_warning_level) %}Next Warning Level: {{ next_level_name }}{% endtrans %}</h4>
+          {% if next_warning_level.description %}
+          <p>{{ next_warning_level.description }}</p>
+          {% endif %}
+
+          <table class="table">
+            <tr>
+              <th class="text-right">{% trans %}Expires After:{% endtrans %}</th>
+              <td>{{ level_restriction_expires(next_warning_level.expires_after_minutes) }}</td>
+            </tr>
+            <tr>
+              <th class="text-right">{% trans %}Posting Replies Restriction:{% endtrans %}</th>
+              <td>{{ level_restriction_legend(next_warning_level.restrict_posting_replies) }}</td>
+            </tr>
+            <tr>
+              <th class="text-right">{% trans %}Posting Threads Restriction:{% endtrans %}</th>
+              <td>{{ level_restriction_legend(next_warning_level.restrict_posting_threads) }}</td>
+            </tr>
+          </table>
+
+        </div>
+        <div class="form-actions">
+          <button type="submit" class="btn btn-primary">{% trans %}Warn User{% endtrans %}</button>
+          {% if retreat %}
+          <a href="{{ retreat }}" class="btn">{% trans %}Cancel{% endtrans %}</a>
+          {% endif %}
+        </div>
+      </form>
+
+    </div>
+  </div>
+</div>
+{% endblock %}
+
+{# Macros #}
+{% macro wrap_level_name(warning_level) -%}
+<strong>{{ warning_level.name }}</strong>
+{%- endmacro %}
+
+{% macro level_restriction_expires(level_expires) -%}
+<span class="icon-clock"></span> {% if level_expires > 0 %}
+{{ level_expires|timeamount|capfirst }}
+{% else %}
+{% trans %}Never{% endtrans %}
+{% endif %}
+{%- endmacro %}
+
+{% macro level_restriction_legend(restriction) -%}
+{% if restriction == 0 %}
+<div class="text-success"><span class="icon-ok"></span> {% trans %}No{% endtrans %}</div>
+{% elif restriction == 1 %}
+<div class="text-info"><span class="icon-eye-open"></span> {% trans %}Moderator Review{% endtrans %}</div>
+{% elif restriction == 2 %}
+<div class="text-error"><span class="icon-remove"></span> {% trans %}Forbidden{% endtrans %}</div>
+{% endif %}
+{%- endmacro %}

+ 26 - 0
templates/cranefly/warn_user/max_level.html

@@ -0,0 +1,26 @@
+{% extends "cranefly/layout.html" %}
+{% import "forms.html" as form_theme with context %}
+{% import "cranefly/macros.html" as macros with context %}
+
+{% block title %}{{ macros.page_title(title=warned_user.username, parent=_('Warn User')) }}{% endblock %}
+
+{% block content %}
+<div class="row">
+  <div class="span6 offset3">
+    <div class="form-container container-horizontal">
+
+      <div class="form-header">
+        <h1>{% trans user=warned_user.username %}Warn User: {{ user }}{% endtrans %}</h1>
+      </div>
+
+      <div class="form-fields">
+        <p>{% trans %}This user warning level is already at maximum and can not be increased any further.{% endtrans %}</p>
+        {% if retreat %}
+        <p><a href="{{ retreat }}">{% trans %}Go Back{% endtrans %}</a></p>
+        {% endif %}
+      </div>
+
+    </div>
+  </div>
+</div>
+{% endblock %}

+ 1 - 1
templates/forms.html

@@ -9,7 +9,7 @@
 
 {% macro row(_field, label=None, help_text=None, attrs=None) -%}
   <div id="{{ _field.html_name }}-control-group" class="control-group{% if _field.errors %} error{% endif %}">
-    <label class="control-label" for="id_{{ _field.html_name }}">{% if label %}{{ label }}{% elif _field.label %}{{ _field.label }}{% else %}{{ _field.html_name }}{% endif %}</label>
+    <label class="control-label" for="id_{{ _field.html_name }}">{% if label %}{{ label }}{% elif _field.label %}{{ _field.label }}{% else %}{{ _field.html_name }}{% endif %}:</label>
     <div class="controls">
       {% if attrs == None %}{% set attrs = {} %}{% endif %}
       {% if _field.field.widget.__class__.__name__ == 'ForumTOS' %}{% do attrs.update({'inline': make_tos_label()}) %}{% endif %}