Browse Source

Merge pull request #290 from rafalp/warnings

Merged in warnings feature branch
Rafał Pitoń 11 years ago
parent
commit
ddb713e728
78 changed files with 2136 additions and 143 deletions
  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/**
 dev/**
 django/**
 django/**
 django_jinja/**
 django_jinja/**
+docs/_build/**
 floppyforms/**
 floppyforms/**
 haystack/**
 haystack/**
 jinja2/**
 jinja2/**

+ 2 - 2
docs/conf.py

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

+ 2 - 12
docs/index.rst

@@ -6,17 +6,7 @@
 Welcome to Misago's documentation!
 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]
                 yield self.__dict__[attr]
 
 
 
 
-def acl(request, user):
+def acl(user):
     acl_key = user.make_acl_key()
     acl_key = user.make_acl_key()
     try:
     try:
         user_acl = cache.get(acl_key)
         user_acl = cache.get(acl_key)
         if user_acl.version != monitor['acl_version']:
         if user_acl.version != monitor['acl_version']:
             raise InvalidCacheBackendError()
             raise InvalidCacheBackendError()
     except (AttributeError, 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)
         cache.set(acl_key, user_acl, 2592000)
     return user_acl
     return user_acl
 
 
 
 
-def build_acl(request, roles):
+def build_acl(roles):
     new_acl = ACL(monitor['acl_version'])
     new_acl = ACL(monitor['acl_version'])
     forums = Forum.objects.get(special='root').get_descendants().order_by('lft')
     forums = Forum.objects.get(special='root').get_descendants().order_by('lft')
     perms = []
     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.template.loader import render_to_string
 from django.utils.translation import ugettext_lazy as _
 from django.utils.translation import ugettext_lazy as _
 
 
-class MisagoACLDebugPanel(DebugPanel):
+class MisagoACLDebugPanel(Panel):
     name = 'MisagoACL'
     name = 'MisagoACL'
     has_content = True
     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 _
 from django.utils.translation import ugettext_lazy as _
 import floppyforms as forms
 import floppyforms as forms
 from misago.acl.builder import BaseACL
 from misago.acl.builder import BaseACL
+from misago.acl.exceptions import ACLError403, ACLError404
 from misago.forms import YesNoSwitch
 from misago.forms import YesNoSwitch
 
 
 def make_form(request, role, form):
 def make_form(request, role, form):
     if role.special != 'guest':
     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)
                                                                   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((
         form.fieldsets.append((
                                _("Warning Members"),
                                _("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):
 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):
     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):
 def build(acl, roles):
     acl.warnings = WarningsACL()
     acl.warnings = WarningsACL()
     acl.warnings.acl['can_warn_members'] = False
     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:
     for role in roles:
         try:
         try:
             if role['can_warn_members']:
             if role['can_warn_members']:
                 acl.warnings.acl['can_warn_members'] = True
                 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:
         except KeyError:
             pass
             pass

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

@@ -15,9 +15,8 @@ class UserSendActivationMailForm(Form):
 
 
     def clean_email(self):
     def clean_email(self):
         try:
         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:
         except User.DoesNotExist:
             raise ValidationError(_("There is no user with such e-mail address."))
             raise ValidationError(_("There is no user with such e-mail address."))
         return email
         return email

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

@@ -1,10 +1,10 @@
 from django.utils.translation import ugettext_lazy as _
 from django.utils.translation import ugettext_lazy as _
 import floppyforms as forms
 import floppyforms as forms
 from misago.forms import Form, ForumMultipleChoiceField
 from misago.forms import Form, ForumMultipleChoiceField
-from misago.models import Role, Forum
+from misago.models import Forum
 from misago.validators import validate_sluggable
 from misago.validators import validate_sluggable
 
 
-class PrefixForm(Form):
+class PrefixFormBase(Form):
     name = forms.CharField(label=_("Prefix Name"),
     name = forms.CharField(label=_("Prefix Name"),
                            max_length=16, validators=[validate_sluggable(
                            max_length=16, validators=[validate_sluggable(
                                                                           _("Prefix must contain alphanumeric characters."),
                                                                           _("Prefix must contain alphanumeric characters."),
@@ -13,7 +13,12 @@ class PrefixForm(Form):
     style = forms.CharField(label=_("Prefix CSS Class"),
     style = forms.CharField(label=_("Prefix CSS Class"),
                             help_text=_("CSS class that will be used to style this thread prefix."),
                             help_text=_("CSS class that will be used to style this thread prefix."),
                             max_length=255, required=False)
                             max_length=255, required=False)
+
+
+def PrefixForm(*args, **kwargs):
     forums = ForumMultipleChoiceField(label=_("Prefix Forums"),
     forums = ForumMultipleChoiceField(label=_("Prefix Forums"),
                                       help_text=_("Select forums in which this prefix will be available."),
                                       help_text=_("Select forums in which this prefix will be available."),
                                       level_indicator=u'- - ',
                                       level_indicator=u'- - ',
                                       queryset=Forum.objects.get(special='root').get_descendants())
                                       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):
     def get_edit_link(self, model):
         return reverse('admin_threads_prefixes_edit', model)
         return reverse('admin_threads_prefixes_edit', model)
 
 
+    def get_form(self, target):
+        return self.form()
+
     def submit_form(self, form, target):
     def submit_form(self, form, target):
         new_prefix = ThreadPrefix(
         new_prefix = ThreadPrefix(
                                   name=form.cleaned_data['name'],
                                   name=form.cleaned_data['name'],
@@ -93,6 +96,9 @@ class Edit(FormWidget):
                 'forums': model.forums.all(),
                 'forums': model.forums.all(),
                 }
                 }
 
 
+    def get_form(self, target):
+        return self.form()
+
     def submit_form(self, form, target):
     def submit_form(self, form, target):
         target.name = form.cleaned_data['name']
         target.name = form.cleaned_data['name']
         target.slug = slugify(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):
     def submit_form(self, form, target):
         position = 0
         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(
         new_rank = Rank(
                         name=form.cleaned_data['name'],
                         name=form.cleaned_data['name'],
                         slug=slugify(form.cleaned_data['name']),
                         slug=slugify(form.cleaned_data['name']),
@@ -92,7 +97,7 @@ class New(FormWidget):
                         special=form.cleaned_data['special'],
                         special=form.cleaned_data['special'],
                         as_tab=form.cleaned_data['as_tab'],
                         as_tab=form.cleaned_data['as_tab'],
                         on_index=form.cleaned_data['on_index'],
                         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']
                         criteria=form.cleaned_data['criteria']
                         )
                         )
         new_rank.save(force_insert=True)
         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.conf.urls import patterns, include, url
 from django.utils.translation import ugettext_lazy as _
 from django.utils.translation import ugettext_lazy as _
 from misago.admin import AdminAction
 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 = (
 ADMIN_ACTIONS = (
     AdminAction(
     AdminAction(
@@ -66,6 +66,35 @@ ADMIN_ACTIONS = (
                 ),
                 ),
     AdminAction(
     AdminAction(
                 section='users',
                 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',
                 id='bans',
                 name=_("Bans"),
                 name=_("Bans"),
                 help=_("Ban or unban users from forums."),
                 help=_("Ban or unban users from forums."),

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

@@ -65,7 +65,7 @@ class List(ListWidget):
                 if qs:
                 if qs:
                     model = model.filter(qs)
                     model = model.filter(qs)
             else:
             else:
-                model = model.filter(username_slug__contains=filters['username'])
+                model = model.filter(username_slug__contains=filters['username'].lower())
         if 'email' in filters:
         if 'email' in filters:
             if ',' in filters['email']:
             if ',' in filters['email']:
                 qs = None
                 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 datetime import timedelta
-from django.core.urlresolvers import reverse
 from django.http import Http404
 from django.http import Http404
 from django.shortcuts import redirect
 from django.shortcuts import redirect
 from django.template import RequestContext
 from django.template import RequestContext
@@ -17,7 +16,7 @@ def new_threads(request, page=0):
     try:
     try:
         pagination = make_pagination(page, items_total, settings.threads_per_page)
         pagination = make_pagination(page, items_total, settings.threads_per_page)
     except Http404:
     except Http404:
-        return redirect(reverse('new_threads'))
+        return redirect('new_threads')
 
 
     queryset = queryset.order_by('-start').prefetch_related('forum')[pagination['start']:pagination['stop']];
     queryset = queryset.order_by('-start').prefetch_related('forum')[pagination['start']:pagination['stop']];
     if settings.avatars_on_threads_list:
     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.conf import settings
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
 from django.shortcuts import redirect
 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.models import User
 from misago.utils.strings import slugify
 from misago.utils.strings import slugify
 
 
@@ -23,7 +24,10 @@ def profile_view(fallback='user'):
                 return f(request, user, *args, **kwargs)
                 return f(request, user, *args, **kwargs)
             except User.DoesNotExist:
             except User.DoesNotExist:
                 return error404(request)
                 return error404(request)
-    
+            except ACLError404:
+                return error404(request)
+            except ACLError403 as e:
+                return error403(request, e.message)
         return wraps(f)(inner_decorator)
         return wraps(f)(inner_decorator)
     return outer_decorator
     return outer_decorator
 
 

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

@@ -1,4 +1,4 @@
 from django.utils.translation import ugettext_lazy as _
 from django.utils.translation import ugettext_lazy as _
 
 
-def register_profile_extension(request):
+def register_profile_extension(request, user):
     return (('user_details', _('Profile Details')),)
     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 _
 from django.utils.translation import ugettext_lazy as _
 
 
-def register_profile_extension(request):
+def register_profile_extension(request, user):
     return (('user_followers', _('Followers')),)
     return (('user_followers', _('Followers')),)

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

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

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

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

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

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

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

@@ -1,4 +1,4 @@
 from django.utils.translation import ugettext_lazy as _
 from django.utils.translation import ugettext_lazy as _
 
 
-def register_profile_extension(request):
+def register_profile_extension(request, user):
     return (('user_threads', _('Threads')),)
     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):
 class UserRegisterForm(Form):
     username = forms.CharField(label=_('Username'),
     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)
                                max_length=15)
     email = forms.EmailField(label=_('E-mail address'),
     email = forms.EmailField(label=_('E-mail address'),
                              help_text=_("Working e-mail inbox is required to maintain control over your forum account."),
                              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."),
                        '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):
     def finalize_form(self):
         if not settings.tos_url and not settings.tos_content:
         if not settings.tos_url and not settings.tos_content:
             del self.fields['accept_tos']
             del self.fields['accept_tos']

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

@@ -15,9 +15,8 @@ class UserResetPasswordForm(Form):
 
 
     def clean_email(self):
     def clean_email(self):
         try:
         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:
         except User.DoesNotExist:
             raise ValidationError(_("There is no user with such e-mail address."))
             raise ValidationError(_("There is no user with such e-mail address."))
         return email
         return email

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

@@ -34,6 +34,7 @@ class ViewBase(object):
             else:
             else:
                 if search_data.get('search_forums'):
                 if search_data.get('search_forums'):
                     if search_data.get('search_forums_childs'):
                     if search_data.get('search_forums_childs'):
+                        Forum.objects.populate_tree()
                         forums_tree = Forum.objects.forums_tree
                         forums_tree = Forum.objects.forums_tree
                         readable_forums = Forum.objects.readable_forums(self.request.acl)
                         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 import timezone
 from django.utils.translation import ugettext as _
 from django.utils.translation import ugettext as _
 from misago import messages
 from misago import messages
+from misago.acl.exceptions import ACLError403
 from misago.apps.threads.forms import NewThreadForm, EditThreadForm
 from misago.apps.threads.forms import NewThreadForm, EditThreadForm
 from misago.apps.threadtype.posting import NewThreadBaseView, EditThreadBaseView, NewReplyBaseView, EditReplyBaseView
 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
 from misago.apps.threads.mixins import TypeMixin
 
 
 
 
@@ -86,6 +87,13 @@ class NewThreadView(NewThreadBaseView, TypeMixin, PollFormMixin, PrefixFormMixin
     def set_forum_context(self):
     def set_forum_context(self):
         self.forum = Forum.objects.get(pk=self.kwargs.get('forum'), type='forum')
         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):
     def after_form(self, form):
         if form.cleaned_data.get('poll_question'):
         if form.cleaned_data.get('poll_question'):
             self.create_poll(form)
             self.create_poll(form)
@@ -123,6 +131,13 @@ class EditThreadView(EditThreadBaseView, TypeMixin, PollFormMixin, PrefixFormMix
 
 
 
 
 class NewReplyView(NewReplyBaseView, TypeMixin):
 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):
     def response(self):
         if self.post.moderated:
         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)
             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):
     def delete(self):
         self.post.delete_date = timezone.now()
         self.post.delete_date = timezone.now()
         self.post.deleted = True
         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.post.save(force_update=True)
         self.thread.sync()
         self.thread.sync()
         self.thread.save(force_update=True)
         self.thread.save(force_update=True)
@@ -184,9 +184,9 @@ class ShowReplyBaseView(DeleteHideBaseView):
 
 
     def delete(self):
     def delete(self):
         self.post.deleted = False
         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.post.save(force_update=True)
         self.thread.sync()
         self.thread.sync()
         self.thread.save(force_update=True)
         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:
             if thread.pk in ids and thread.deleted:
                 undeleted.append(thread.pk)
                 undeleted.append(thread.pk)
                 thread.start_post.deleted = False
                 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.start_post.save(force_update=True)
                 thread.sync()
                 thread.sync()
                 thread.save(force_update=True)
                 thread.save(force_update=True)
@@ -273,6 +276,9 @@ class ThreadsListModeration(object):
             if thread.pk in ids and not thread.deleted:
             if thread.pk in ids and not thread.deleted:
                 deleted.append(thread.pk)
                 deleted.append(thread.pk)
                 thread.start_post.deleted = True
                 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.start_post.save(force_update=True)
                 thread.sync()
                 thread.sync()
                 thread.save(force_update=True)
                 thread.save(force_update=True)

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

@@ -25,6 +25,9 @@ class PostingBaseView(ViewBase):
         if self.forum.level:
         if self.forum.level:
             self.parents = Forum.objects.forum_parents(self.forum.pk)
             self.parents = Forum.objects.forum_parents(self.forum.pk)
 
 
+    def force_moderation(self):
+        return False
+
     def record_edit(self, form, old_name, old_post):
     def record_edit(self, form, old_name, old_post):
         self.post.edits += 1
         self.post.edits += 1
         self.post.edit_user = self.request.user
         self.post.edit_user = self.request.user
@@ -84,18 +87,20 @@ class PostingBaseView(ViewBase):
         self.email_watchers(notified_users)
         self.email_watchers(notified_users)
 
 
     def watch_thread(self):
     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):
     def make_attachments_token(self):
         if self.post:
         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.post = self.thread.start_post
         self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.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)
         self.request.acl.threads.allow_thread_edit(self.request.user, self.proxy, self.thread, self.post)
-        
+
     def form_initial_data(self):
     def form_initial_data(self):
         return {
         return {
                 'thread_name': self.thread.name,
                 '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):
     def post_form(self, form):
         now = timezone.now()
         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.thread.previous_last = self.thread.last_post
         self.md, post_preparsed = post_markdown(form.cleaned_data['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 = user.alert(ugettext_lazy("%(username)s has replied to thread %(thread)s that you are watching").message)
                 alert.profile('username', self.request.user)
                 alert.profile('username', self.request.user)
                 alert.post('thread', self.type_prefix, self.thread, self.post)
                 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):
     def post_form(self, form):
         now = timezone.now()
         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
         # Create empty thread
         self.thread = Thread.objects.create(
         self.thread = Thread.objects.create(
@@ -82,4 +86,9 @@ class NewThreadBaseView(PostingBaseView):
             self.request.user.threads += 1
             self.request.user.threads += 1
             self.request.user.posts += 1
             self.request.user.posts += 1
         self.request.user.last_post = now
         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:
             if post.pk in ids and post.deleted:
                 undeleted.append(post.pk)
                 undeleted.append(post.pk)
         if undeleted:
         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.sync()
             self.thread.save(force_update=True)
             self.thread.save(force_update=True)
             self.forum.sync()
             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."))
                     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)
                 deleted.append(post.pk)
         if deleted:
         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.sync()
             self.thread.save(force_update=True)
             self.thread.save(force_update=True)
             self.forum.sync()
             self.forum.sync()

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

@@ -108,6 +108,9 @@ class ThreadModeration(object):
     def thread_action_undelete(self):
     def thread_action_undelete(self):
         # Update first post in thread
         # Update first post in thread
         self.thread.start_post.deleted = False
         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)
         self.thread.start_post.save(force_update=True)
         # Update thread
         # Update thread
         self.thread.sync()
         self.thread.sync()
@@ -129,6 +132,9 @@ class ThreadModeration(object):
     def thread_action_soft(self):
     def thread_action_soft(self):
         # Update first post in thread
         # Update first post in thread
         self.thread.start_post.deleted = True
         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)
         self.thread.start_post.save(force_update=True)
         # Update thread
         # Update thread
         self.thread.sync()
         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]
         cookie_token = request.COOKIES[cookie_token]
         if len(cookie_token) != 42:
         if len(cookie_token) != 42:
             raise AuthException()
             raise AuthException()
-            
+
         try:
         try:
             token_rk = Token.objects.select_related().get(pk=cookie_token)
             token_rk = Token.objects.select_related().get(pk=cookie_token)
         except Token.DoesNotExist:
         except Token.DoesNotExist:
@@ -108,7 +108,7 @@ def auth_admin(request, email, password):
     Admin auth - check ACP permissions
     Admin auth - check ACP permissions
     """
     """
     user = get_user(email, password, True)
     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."))
         raise AuthException(NOT_ADMIN, _("Your account does not have admin privileges."))
     return user;
     return user;
 
 

+ 6 - 3
misago/conf.py

@@ -1,6 +1,5 @@
 from django.conf import settings as dj_settings
 from django.conf import settings as dj_settings
 from django.core.cache import cache
 from django.core.cache import cache
-from misago.models import Setting
 from misago.thread import local
 from misago.thread import local
 
 
 _thread_local = local()
 _thread_local = local()
@@ -8,6 +7,7 @@ _thread_local = local()
 def load_settings():
 def load_settings():
     settings = cache.get('settings', {})
     settings = cache.get('settings', {})
     if not settings:
     if not settings:
+        from misago.models import Setting
         for i in Setting.objects.all():
         for i in Setting.objects.all():
             settings[i.pk] = i.value
             settings[i.pk] = i.value
         cache.set('settings', settings)
         cache.set('settings', settings)
@@ -29,12 +29,15 @@ class MisagoSettings(object):
     def setting(self, key):
     def setting(self, key):
         try:
         try:
             try:
             try:
-                return self.settings()[key]
-            except KeyError:
                 if self.is_safe:
                 if self.is_safe:
                     return getattr(dj_settings, key)
                     return getattr(dj_settings, key)
                 else:
                 else:
                     raise AttributeError()
                     raise AttributeError()
+            except AttributeError:
+                try:
+                    return self.settings()[key]
+                except KeyError:
+                    raise AttributeError()
         except AttributeError:
         except AttributeError:
             raise Exception(u"Requested setting \"%s\" could not be found." % key)
             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},
                         'forums': {3: 1, 5: 1, 6: 1},
                         'can_destroy_user_newer_than': 14,
                         'can_destroy_user_newer_than': 14,
                         'can_destroy_users_with_less_posts_than': 10,
                         '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)
     role.save(force_insert=True)
 
 
@@ -59,6 +65,12 @@ def load():
                         'forums': {3: 1, 5: 1, 6: 1},
                         'forums': {3: 1, 5: 1, 6: 1},
                         'can_destroy_user_newer_than': 5,
                         'can_destroy_user_newer_than': 5,
                         'can_destroy_users_with_less_posts_than': 10,
                         '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)
     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)
         level = getattr(obj, obj._mptt_meta.level_attr)
         return mark_safe(conditional_escape(self.level_indicator) * (level - 1))
         return mark_safe(conditional_escape(self.level_indicator) * (level - 1))
 
 
+
 class ReCaptchaField(fields.CharField):
 class ReCaptchaField(fields.CharField):
     widget = ReCaptchaWidget
     widget = ReCaptchaWidget
     api_error = None
     api_error = None

+ 20 - 0
misago/markdown/factory.py

@@ -22,6 +22,26 @@ def remove_unsupported(md):
     del md.inlinePatterns['short_reference']
     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):
 def signature_markdown(acl, text):
     md = markdown.Markdown(
     md = markdown.Markdown(
                            safe_mode='escape',
                            safe_mode='escape',

+ 3 - 3
misago/middleware/acl.py

@@ -2,13 +2,13 @@ from misago.acl.builder import acl
 
 
 class ACLMiddleware(object):
 class ACLMiddleware(object):
     def process_request(self, request):
     def process_request(self, request):
-        request.acl = acl(request, request.user)
-        
+        request.acl = acl(request.user)
+
         if (request.user.is_authenticated() and
         if (request.user.is_authenticated() and
             (request.acl.team or request.user.is_god()) != request.user.is_team):
             (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.is_team = (request.acl.team or request.user.is_god())
             request.user.save(force_update=True)
             request.user.save(force_update=True)
-            
+
         if request.session.team != request.user.is_team:
         if request.session.team != request.user.is_team:
             request.session.team = request.user.is_team
             request.session.team = request.user.is_team
             request.session.save()
             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.tokenmodel import Token
 from misago.models.usermodel import User, Guest, Crawler
 from misago.models.usermodel import User, Guest, Crawler
 from misago.models.usernamechangemodel import UsernameChange
 from misago.models.usernamechangemodel import UsernameChange
+from misago.models.warnmodel import Warn
+from misago.models.warnlevelmodel import WarnLevel
 from misago.models.watchedthreadmodel import WatchedThread
 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):
     def populate_tree(self, force=False):
         if not self.forums_tree:
         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 = {}
             self.forums_tree = {}
             for forum in Forum.objects.order_by('lft'):
             for forum in Forum.objects.order_by('lft'):
                 self.forums_tree[forum.pk] = forum
                 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)
         merge_post.send(sender=self, new_post=post)
 
 
     def notify_mentioned(self, request, thread_type, users):
     def notify_mentioned(self, request, thread_type, users):
-        from misago.acl.builder import acl as build_acl
         from misago.acl.exceptions import ACLError403, ACLError404
         from misago.acl.exceptions import ACLError403, ACLError404
 
 
         mentioned = self.mentions.all()
         mentioned = self.mentions.all()
@@ -151,7 +150,7 @@ class Post(models.Model):
             if user.pk != request.user.pk and user not in mentioned:
             if user.pk != request.user.pk and user not in mentioned:
                 self.mentions.add(user)
                 self.mentions.add(user)
                 try:
                 try:
-                    user_acl = build_acl(request, user)
+                    user_acl = user.acl()
                     user_acl.forums.allow_forum_view(self.forum)
                     user_acl.forums.allow_forum_view(self.forum)
                     user_acl.threads.allow_thread_view(user, self.thread)
                     user_acl.threads.allow_thread_view(user, self.thread)
                     user_acl.threads.allow_post_view(user, self.thread, self)
                     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
         self.deleted = start_post.deleted
 
 
     def email_watchers(self, request, thread_type, post):
     def email_watchers(self, request, thread_type, post):
-        from misago.acl.builder import acl
         from misago.acl.exceptions import ACLError403, ACLError404
         from misago.acl.exceptions import ACLError403, ACLError404
         from misago.models import ThreadRead, WatchedThread
         from misago.models import ThreadRead, WatchedThread
 
 
@@ -222,7 +221,7 @@ class Thread(models.Model):
             user = watch.user
             user = watch.user
             if user.pk != request.user.pk:
             if user.pk != request.user.pk:
                 try:
                 try:
-                    user_acl = acl(request, user)
+                    user_acl = user.acl()
                     user_acl.forums.allow_forum_view(self.forum)
                     user_acl.forums.allow_forum_view(self.forum)
                     user_acl.threads.allow_thread_view(user, self)
                     user_acl.threads.allow_thread_view(user, self)
                     user_acl.threads.allow_post_view(user, self, post)
                     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 import timezone as tz_util
 from django.utils.translation import ugettext_lazy as _
 from django.utils.translation import ugettext_lazy as _
 from misago.acl.builder import acl
 from misago.acl.builder import acl
+from misago.apps.profiles.warnings.warningstracker import WarningsTracker
 from misago.conf import settings
 from misago.conf import settings
 from misago.monitor import monitor, UpdatingMonitor
 from misago.monitor import monitor, UpdatingMonitor
 from misago.signals import delete_user_content, rename_user, sync_user_profile
 from misago.signals import delete_user_content, rename_user, sync_user_profile
@@ -99,7 +100,7 @@ class UserManager(models.Manager):
         return new_user
         return new_user
 
 
     def get_by_email(self, email):
     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):
     def filter_stats(self, start, end):
         return self.filter(join_date__gte=start).filter(join_date__lte=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')
     roles = models.ManyToManyField('Role')
     is_team = models.BooleanField(default=False)
     is_team = models.BooleanField(default=False)
     acl_key = models.CharField(max_length=12, null=True, blank=True)
     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()
     objects = UserManager()
 
 
@@ -431,8 +434,8 @@ class User(models.Model):
         self.save(update_fields=('acl_key',))
         self.save(update_fields=('acl_key',))
         return self.acl_key
         return self.acl_key
 
 
-    def acl(self, request):
-        return acl(request, self)
+    def acl(self):
+        return acl(self)
 
 
     @property
     @property
     def avatar_crop(self):
     def avatar_crop(self):
@@ -504,9 +507,9 @@ class User(models.Model):
 
 
         # Set message author
         # Set message author
         if settings.board_name:
         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:
         else:
-            sender = settings.EMAIL_HOST_USER
+            sender = settings.DEFAULT_FROM_EMAIL
 
 
         # Build message and add it to queue
         # Build message and add it to queue
         email = EmailMultiAlternatives(subject, email_text, sender, [recipient])
         email = EmailMultiAlternatives(subject, email_text, sender, [recipient])
@@ -537,6 +540,118 @@ class User(models.Model):
             return True
             return True
         return False
         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):
     def timeline(self, qs, length=100):
         posts = {}
         posts = {}
         now = tz_util.now()
         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.threads',
     'misago.apps.profiles.follows',
     'misago.apps.profiles.follows',
     'misago.apps.profiles.followers',
     'misago.apps.profiles.followers',
+    'misago.apps.profiles.warnings',
     'misago.apps.profiles.details',
     'misago.apps.profiles.details',
 )
 )
 
 
@@ -218,18 +219,21 @@ DEBUG_TOOLBAR_CONFIG = {
 }
 }
 
 
 # List panels displayed by toolbar
 # 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',
     '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
 # Turn off caching
 CACHES = {
 CACHES = {

+ 6 - 2
misago/templatetags/datetime.py

@@ -1,5 +1,5 @@
 from django_jinja.library import Library
 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()
 register = Library()
 
 
@@ -26,4 +26,8 @@ def compact_filter(val):
 
 
 @register.filter(name='relcompact')
 @register.filter(name='relcompact')
 def relcompact_filter(val):
 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'^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/$', 'newthreads.new_threads', name="new_threads"),
     url(r'^new/(?P<page>[1-9]([0-9]+)?)/$', '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('',
 urlpatterns += patterns('',

+ 60 - 11
misago/utils/datesformats.py

@@ -19,7 +19,7 @@ formats = {
 
 
 for key in formats:
 for key in formats:
     formats[key] = get_format(key).replace('P', 'g:i a')
     formats[key] = get_format(key).replace('P', 'g:i a')
-    
+
 def date(val, arg=""):
 def date(val, arg=""):
     if not val:
     if not val:
         return _("Never")
         return _("Never")
@@ -41,22 +41,22 @@ def reldate(val, arg=""):
     # Today check
     # Today check
     if format(local, 'Y-z') == format(local_now, 'Y-z'):
     if format(local, 'Y-z') == format(local_now, 'Y-z'):
         return _("Today, %(hour)s") % {'hour': time_format(local, formats['TIME_FORMAT'])}
         return _("Today, %(hour)s") % {'hour': time_format(local, formats['TIME_FORMAT'])}
-        
+
     # Yesteday check
     # Yesteday check
     yesterday = localtime(now - timedelta(days=1))
     yesterday = localtime(now - timedelta(days=1))
     if format(local, 'Y-z') == format(yesterday, 'Y-z'):
     if format(local, 'Y-z') == format(yesterday, 'Y-z'):
         return _("Yesterday, %(hour)s") % {'hour': time_format(local, formats['TIME_FORMAT'])}
         return _("Yesterday, %(hour)s") % {'hour': time_format(local, formats['TIME_FORMAT'])}
-        
+
     # Tomorrow Check
     # Tomorrow Check
     tomorrow = localtime(now + timedelta(days=1))
     tomorrow = localtime(now + timedelta(days=1))
     if format(local, 'Y-z') == format(tomorrow, 'Y-z'):
     if format(local, 'Y-z') == format(tomorrow, 'Y-z'):
         return _("Tomorrow, %(hour)s") % {'hour': time_format(local, formats['TIME_FORMAT'])}
         return _("Tomorrow, %(hour)s") % {'hour': time_format(local, formats['TIME_FORMAT'])}
-        
+
     # Day of Week check
     # Day of Week check
     if format(local, 'D') != format(local_now, 'D') and diff.days > -7 and diff.days < 7:
     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'])}
         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)
     return date(val, arg)
 
 
 
 
@@ -75,7 +75,7 @@ def reltimesince(val, arg=""):
     if diff.seconds >= 0:
     if diff.seconds >= 0:
         if diff.seconds <= 5:
         if diff.seconds <= 5:
             return _("Just now")
             return _("Just now")
-                        
+
         if diff.seconds < 3540:
         if diff.seconds < 3540:
             minutes = int(math.ceil(diff.seconds / 60.0))
             minutes = int(math.ceil(diff.seconds / 60.0))
             return ungettext(
             return ungettext(
@@ -85,11 +85,11 @@ def reltimesince(val, arg=""):
 
 
         if diff.seconds < 3660:
         if diff.seconds < 3660:
             return _("Hour ago")
             return _("Hour ago")
-        
+
         if diff.seconds < 10800:
         if diff.seconds < 10800:
             hours = int(math.floor(diff.seconds / 3600.0))
             hours = int(math.floor(diff.seconds / 3600.0))
             minutes = (diff.seconds - (hours * 3600)) / 60
             minutes = (diff.seconds - (hours * 3600)) / 60
-            
+
             if minutes > 0:
             if minutes > 0:
                 return ungettext(
                 return ungettext(
                     "Hour and %(minutes)s ago",
                     "Hour and %(minutes)s ago",
@@ -98,7 +98,7 @@ def reltimesince(val, arg=""):
                         "%(minutes)s minute",
                         "%(minutes)s minute",
                         "%(minutes)s minutes",
                         "%(minutes)s minutes",
                     minutes) % {'minutes': minutes}}
                     minutes) % {'minutes': minutes}}
-                
+
             return ungettext(
             return ungettext(
                     "Hour ago",
                     "Hour ago",
                     "%(hours)s hours ago",
                     "%(hours)s hours ago",
@@ -114,7 +114,7 @@ def compact(val):
     now = datetime.now(utc if is_aware(val) else None)
     now = datetime.now(utc if is_aware(val) else None)
     local = localtime(val)
     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'))
     return format(localtime(val), _('j M y'))
     return format(localtime(val), _('j M y'))
 
 
@@ -140,4 +140,53 @@ def relcompact(val):
             hours = int(math.ceil(diff.seconds / 3600.0))
             hours = int(math.ceil(diff.seconds / 3600.0))
             return pgettext("number of hours", "%(hour)sh") % {'hour': hours}
             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<1.6
-django-debug-toolbar
+django-debug-toolbar==1
 django-floppyforms
 django-floppyforms
 django-haystack
 django-haystack
 django-jinja
 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-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{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 .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{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{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 .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{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{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}
 .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 {
       svg {
         position: relative;
         position: relative;
         left: 1px;
         left: 1px;
-      	height: 40px;
+        height: 40px;
         width: 100%;
         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 {
   .content-list {
     .media {
     .media {
       overflow: auto;
       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]+)/;
   var re = /vimeo.com\/([0-9]+)/;
   if (re.test(link_href)) {
   if (re.test(link_href)) {
     media_url = link_href.match(re);
     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
   // No link
@@ -174,20 +174,20 @@ function link2player(element, link_href) {
 // Youtube player
 // Youtube player
 function youtube_player(element, movie_id, startfrom) {
 function youtube_player(element, movie_id, startfrom) {
   if (typeof startfrom != 'undefined') {
   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 {
   } 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
   // 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() {
   $(media_element).find('.play-link').click(function() {
     $(this).parent().replaceWith('<iframe width="853" height="480" src="' + $(this).data('playerurl') + '" frameborder="0" allowfullscreen></iframe>');
     $(this).parent().replaceWith('<iframe width="853" height="480" src="' + $(this).data('playerurl') + '" frameborder="0" allowfullscreen></iframe>');
     return false;
     return false;
   });
   });
   $(element).replaceWith(media_element);
   $(element).replaceWith(media_element);
   // Fetch title, author name and thumbnail
   // 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) {
             function(data, textStatus, jqXHR) {
               // Movie details
               // Movie details
               var movie_title = data.entry.title.$t;
               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 strong').text(movie_title);
               $(media_element).find('.play-link').append(l_play_media_author.replace('{author}', movie_author));
               $(media_element).find('.play-link').append(l_play_media_author.replace('{author}', movie_author));
               // Movie thumbnail
               // 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']);
               console.log(data.entry['media$group']['media$thumbnail']);
               $(data.entry['media$group']['media$thumbnail']).each(function(key, yt_image) {
               $(data.entry['media$group']['media$thumbnail']).each(function(key, yt_image) {
                 if (thumb.height < yt_image.height) {
                 if (thumb.height < yt_image.height) {
@@ -333,6 +333,11 @@ $(function() {
     var csrf_token = $(this).find('input[name="_csrf_token"]').val();
     var csrf_token = $(this).find('input[name="_csrf_token"]').val();
     var button = $(this).find('button');
     var button = $(this).find('button');
     $(this).submit(function() {
     $(this).submit(function() {
+      var decision = confirm(l_report_sure);
+      if (!decision) {
+        return false;
+      }
+
       var form = this;
       var form = this;
       $.post(form.action, {'_csrf_token': csrf_token}, "json").done(function(data, textStatus, jqXHR) {
       $.post(form.action, {'_csrf_token': csrf_token}, "json").done(function(data, textStatus, jqXHR) {
         $(button).text(l_post_reported);
         $(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">
 <div class="editor editor-editable">
   {% if field.errors %}
   {% if field.errors %}
   <div class="editor-error">
   <div class="editor-error">
@@ -30,7 +30,9 @@
     <button name="save" type="submit" class="btn btn-primary pull-right">{{ submit_button }}</button>
     <button name="save" type="submit" class="btn btn-primary pull-right">{{ submit_button }}</button>
     {% if extra %}{{ extra }}{% endif %}
     {% if extra %}{{ extra }}{% endif %}
   </div>
   </div>
+  {% if not hide_attachments %}
   {{ attachments_editor() }}
   {{ attachments_editor() }}
+  {% endif %}
 </div>
 </div>
 {% endmacro %}
 {% 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>
                 <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 %}
                 {% 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">
                 <div class="post-extra">
                   {% if post.protected and acl.threads.can_protect(forum) %}
                   {% if post.protected and acl.threads.can_protect(forum) %}
@@ -281,6 +277,14 @@
               </div>
               </div>
               <div class="post-footer">{% filter trim %}
               <div class="post-footer">{% filter trim %}
                 <div class="post-actions">
                 <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() -%}
                   {% 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>
                   <a href="{{ url('private_post_info', thread=thread.pk, slug=thread.slug, post=post.pk) }}" class="post-trail">{% trans %}Info{% endtrans %}</a>
                   {% endif %}
                   {% endif %}
@@ -485,7 +489,7 @@
 
 
         <h4>{% trans %}Invite User{% endtrans %}</h4>
         <h4>{% trans %}Invite User{% endtrans %}</h4>
         <div class="invite-participant">
         <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="{{ csrf_id }}" value="{{ csrf_token }}">
             <input type="hidden" name="retreat" value="{{ request_path }}">
             <input type="hidden" name="retreat" value="{{ request_path }}">
             {{ form_theme.field(invite_form.username, attrs={'class': 'span2', 'placeholder': lang_user_to_invite()}) }}
             {{ form_theme.field(invite_form.username, attrs={'class': 'span2', 'placeholder': lang_user_to_invite()}) }}
@@ -508,7 +512,8 @@
   {{ super() }}
   {{ super() }}
   <script src="{{ STATIC_URL }}cranefly/highlight/highlight.pack.js"></script>
   <script src="{{ STATIC_URL }}cranefly/highlight/highlight.pack.js"></script>
   <script type="text/javascript">
   <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.tabReplace = '    ';
     hljs.initHighlightingOnLoad();
     hljs.initHighlightingOnLoad();
     EnhancePostsMD();
     EnhancePostsMD();

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

@@ -76,6 +76,18 @@
               </form>
               </form>
             </li>
             </li>
             {% endif %}
             {% 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 %}
             {% endif %}
           </ul>
           </ul>
         </div>
         </div>

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

@@ -44,9 +44,9 @@
 <div class="pagination">
 <div class="pagination">
   <ul>
   <ul>
     <li class="count">{{ macros.pager_label(pagination) }}</li>
     <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>
   </ul>
 </div>
 </div>
 {% endif %}
 {% 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() %}
             {% if user.is_authenticated() %}
             <div class="post-actions">
             <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() -%}
               {% 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>
               <a href="{{ url('post_info', thread=thread.pk, slug=thread.slug, post=post.pk) }}" class="post-trail">{% trans %}Info{% endtrans %}</a>
               {% endif %}
               {% endif %}
@@ -548,9 +556,9 @@
     {% endif %}
     {% endif %}
   </div>
   </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">
   <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="{{ csrf_id }}" value="{{ csrf_token }}">
       <input type="hidden" name="quick_reply" value="1">
       <input type="hidden" name="quick_reply" value="1">
       <img src="{{ user.get_avatar(100) }}" alt="{% trans %}Your Avatar{% endtrans %}" class="user-avatar">
       <img src="{{ user.get_avatar(100) }}" alt="{% trans %}Your Avatar{% endtrans %}" class="user-avatar">
@@ -571,7 +579,8 @@
   {{ super() }}
   {{ super() }}
   <script src="{{ STATIC_URL }}cranefly/highlight/highlight.pack.js"></script>
   <script src="{{ STATIC_URL }}cranefly/highlight/highlight.pack.js"></script>
   <script type="text/javascript">
   <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.tabReplace = '    ';
     hljs.initHighlightingOnLoad();
     hljs.initHighlightingOnLoad();
     EnhancePostsMD();
     EnhancePostsMD();
@@ -618,7 +627,7 @@
     });
     });
     {% endif %}
     {% endif %}
   </script>
   </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() }}
   {{ editor.js() }}
   {% endif %}
   {% endif %}
 {%- endblock %}
 {%- endblock %}

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

@@ -30,7 +30,8 @@
     {{ editor.editor(form.signature, lang_save_signature(),
     {{ editor.editor(form.signature, lang_save_signature(),
       hide_links=(not acl.usercp.allow_signature_links()),
       hide_links=(not acl.usercp.allow_signature_links()),
       hide_images=(not acl.usercp.allow_signature_images()),
       hide_images=(not acl.usercp.allow_signature_images()),
-      hide_hr=True) }}
+      hide_hr=True,
+      hide_attachments=True,) }}
   </form>
   </form>
 
 
 </div>
 </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) -%}
 {% 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 %}">
   <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">
     <div class="controls">
       {% if attrs == None %}{% set attrs = {} %}{% endif %}
       {% if attrs == None %}{% set attrs = {} %}{% endif %}
       {% if _field.field.widget.__class__.__name__ == 'ForumTOS' %}{% do attrs.update({'inline': make_tos_label()}) %}{% endif %}
       {% if _field.field.widget.__class__.__name__ == 'ForumTOS' %}{% do attrs.update({'inline': make_tos_label()}) %}{% endif %}