Browse Source

#38: User warnings

Rafał Pitoń 10 years ago
parent
commit
154ae88287

+ 3 - 4
docs/developers/acls.rst

@@ -67,15 +67,14 @@ Required. This function is called when change permissions form for role is being
 Required. Is used in process of building new ACL. Its supplied dict with incomplete ACL, list of user roles and name of key under which its permissions values are stored in roles ``permissions`` attributes. Its expected to access roles ``permissions`` attributes which are dicts of values coming from permission change forms and return updated ``acl`` dict.
 
 
-.. function:: add_acl_to_target(user, acl, target)
+.. function:: add_acl_to_target(user, target)
 
-Optional. Is called when Misago is trying to make ``target`` aware of its ACLs. Its provided with three arguments:
+Optional. Is called when Misago is trying to make ``target`` aware of its ACLs. Its called with two arguments:
 
 * **user** - user asking to make target aware of its ACL's
-* **acl** - user ACLs
 * **target** - target instance, guaranteed to be an single object, not list or other iterable (like queryset)
 
-Value of ``target`` argument has ``acl`` attribute which is dict with incomplete ACL that function can change and update with new keys.
+``target`` has ``acl`` attribute which is dict with incomplete ACL that function can change and update with new keys.
 
 .. note::
    This will not work for instances of User model, that already reserve ``acl`` attribute for their own acls. Instead add_acl_to_target for User instances will add acl's to `acl_` attribute.

+ 2 - 2
misago/acl/api.py

@@ -51,7 +51,7 @@ def add_acl(user, target):
     """
     try:
         for item in target:
-            _add_acl_to_target(user, target)
+            _add_acl_to_target(user, item)
     except TypeError:
         _add_acl_to_target(user, target)
 
@@ -67,4 +67,4 @@ def _add_acl_to_target(user, target):
 
     for extension, module in providers.list():
         if hasattr(module, 'add_acl_to_target'):
-            module.add_acl_to_target(user, user.acl, target)
+            module.add_acl_to_target(user, target)

+ 2 - 2
misago/acl/decorators.py

@@ -4,9 +4,9 @@ from django.http import Http404
 
 def require_target_type(supported_type):
     def wrap(f):
-        def decorator(user, acl, target):
+        def decorator(user, target):
             if isinstance(target, supported_type):
-                return f(user, acl, target)
+                return f(user, target)
             else:
                 return None
         return decorator

+ 25 - 0
misago/acl/migrations/0003_default_roles.py

@@ -99,6 +99,21 @@ def create_default_roles(apps, schema_editor):
                 'can_see_hidden_users': 1,
             },
 
+            # warnings perms
+            'misago.users.permissions.warnings': {
+                'can_see_other_users_warnings': 1,
+                'can_warn_users': 1,
+                'can_cancel_warnings': 1,
+                'can_be_warned': 0,
+            },
+
+            # moderation perms
+            'misago.users.permissions.moderation': {
+                'can_warn_users': 1,
+                'can_moderate_avatars': 1,
+                'can_moderate_signatures': 1,
+            },
+
             # delete users perms
             'misago.users.permissions.delete': {
                 'can_delete_users_newer_than': 0,
@@ -107,6 +122,16 @@ def create_default_roles(apps, schema_editor):
         })
     role.save()
 
+    role = Role(name=_("See warnings"))
+    pickle_permissions(role,
+        {
+            # warnings perms
+            'misago.users.permissions.warnings': {
+                'can_see_other_users_warnings': 1,
+            },
+        })
+    role.save()
+
     role = Role(name=_("Renaming users"))
     pickle_permissions(role,
         {

+ 1 - 0
misago/conf/defaults.py

@@ -154,6 +154,7 @@ TEMPLATE_CONTEXT_PROCESSORS = (
 MISAGO_ACL_EXTENSIONS = (
     'misago.users.permissions.account',
     'misago.users.permissions.profiles',
+    'misago.users.permissions.warnings',
     'misago.users.permissions.moderation',
     'misago.users.permissions.delete',
     'misago.forums.permissions',

+ 46 - 1
misago/core/utils.py

@@ -3,7 +3,9 @@ from datetime import timedelta
 import bleach
 from markdown import Markdown
 from unidecode import unidecode
-from django.core.urlresolvers import reverse
+
+from django.http import Http404
+from django.core.urlresolvers import resolve, reverse
 from django.template.defaultfilters import slugify as django_slugify
 from django.utils.translation import ugettext_lazy as _, ungettext_lazy
 
@@ -15,6 +17,49 @@ def slugify(string):
 
 
 """
+Return path utility
+"""
+def clean_return_path(request):
+    post_return_path = _get_return_path_from_post(request)
+    if not post_return_path:
+        return _get_return_path_from_referer(request)
+    else:
+        return post_return_path
+
+
+def _get_return_path_from_post(request):
+    return_path = request.POST.get('return_path')
+    try:
+        if not return_path:
+            raise ValueError()
+        if not return_path.startswith('/'):
+            raise ValueError()
+        resolve(return_path)
+        return return_path
+    except (Http404, ValueError):
+        return None
+
+
+def _get_return_path_from_referer(request):
+    referer = request.META.get('HTTP_REFERER')
+    try:
+        if not referer:
+            raise ValueError()
+        if not referer.startswith(request.scheme):
+            raise ValueError()
+        referer = referer[len(request.scheme) + 3:]
+        if not referer.startswith(request.META['HTTP_HOST']):
+            raise ValueError()
+        referer = referer[len(request.META['HTTP_HOST']):]
+        if not referer.startswith('/'):
+            raise ValueError()
+        resolve(referer)
+        return referer
+    except (Http404, KeyError, ValueError):
+        return None
+
+
+"""
 Utils for resolving requests destination
 """
 def _is_request_path_under_misago(request):

+ 5 - 0
misago/static/misago/css/misago/forms.less

@@ -161,6 +161,11 @@
         padding-top: 0px;
         padding-bottom: 0px;
       }
+
+      .extra-padding {
+        padding: @form-panel-padding;
+        padding-top: 0px;
+      }
     }
 
     &.no-fieldsets {

+ 1 - 0
misago/static/misago/css/misago/misago.less

@@ -24,4 +24,5 @@
 @import "errorpages.less";
 @import "profile.less";
 @import "signin.less";
+@import "warnings.less";
 @import "usercp.less";

+ 90 - 0
misago/static/misago/css/misago/warnings.less

@@ -0,0 +1,90 @@
+//
+// User Warnings
+// --------------------------------------------------
+
+
+// Warning summary
+//
+//==
+.warning-summary {
+  p.lead {
+    margin-bottom: @line-height-computed / 3;
+  }
+
+  .progress {
+    border-radius: @border-radius-small;
+    height: 8px;
+
+    .progress-bar {
+      .box-shadow(none);
+      height: 8px;
+    }
+  }
+}
+
+
+// Warnings list
+//
+//==
+.warnings-list {
+  .panel-body {
+    p {
+      margin: 0px;
+    }
+  }
+
+  .panel-footer {
+    overflow: auto;
+
+    font-size: @font-size-small;
+
+    ul {
+      margin: 0px;
+
+      li {
+        float: left;
+        margin: 0px;
+
+        .state-valid {
+          color: @brand-danger;
+        }
+
+        .state-canceled {
+          color: @brand-warning;
+        }
+
+        .state-expired {
+          color: fadeOut(@text-color, 40);
+        }
+
+        img {
+          border-radius: @border-radius-base;
+          margin-top: -3px;
+          width: 18px;
+        }
+
+        form {
+          margin: -3px 0px;
+          padding: 0px;
+
+          .btn-default {
+            padding: 2px 5px;
+            .box-shadow(none);
+          }
+        }
+      }
+
+      &.pull-left {
+        li {
+          margin-right: @line-height-computed;
+        }
+      }
+
+      &.pull-right {
+        li {
+          margin-left: @line-height-computed / 2;
+        }
+      }
+    }
+  }
+}

+ 3 - 2
misago/templates/misago/modusers/mod_options.html

@@ -6,13 +6,14 @@
     {% trans "Moderation" %} <span class="glyphicon glyphicon-chevron-down"></span>
   </button>
   <ul class="dropdown-menu" role="menu">
+    {% if profile.acl_.can_warn %}
     <li>
-      <a href="#">
+      <a href="{% url 'misago:warn_user' user_slug=profile.slug user_id=profile.pk %}">
         <span class="fa fa-exclamation-triangle"></span>
         {% trans "Warn user" %}
       </a>
     </li>
-    <li class="divider"></li>
+    {% endif %}
     {% if profile.acl_.can_rename %}
     <li>
       <a href="{% url 'misago:rename_user' user_slug=profile.slug user_id=profile.pk %}">

+ 123 - 0
misago/templates/misago/modusers/warn.html

@@ -0,0 +1,123 @@
+{% extends "misago/modusers/base.html" %}
+{% load i18n misago_capture misago_forms %}
+
+
+{% block title %}
+{{ profile.username }}: {% trans "Warn" %} | {{ block.super }}
+{% endblock title %}
+
+
+{% block action-name %}
+{% trans "Warn" %}
+{% endblock action-name %}
+
+
+{% block action-content %}
+<div class="row warning-form">
+  <div class="col-md-8">
+
+    <div class="form-panel">
+      <form method="POST" role="form">
+        {% csrf_token %}
+        <input type="hidden" name="return_path" value="{{ return_path }}">
+
+        <div class="form-header">
+          <h2>
+            {{ form.reason.label }}
+          </h2>
+        </div>
+
+        {% include "misago/form_errors.html" %}
+
+        <div class="form-body">
+          <fieldset>
+            <legend>{{ form.reason.help_text }}</legend>
+
+            <div class="extra-padding">
+              {% form_input form.reason %}
+            </div>
+
+          </fieldset>
+        </div>
+
+        <div class="form-footer">
+
+          <button class="btn btn-primary">{% trans "Warn user" %}</button>
+          <a href="{{ return_path }}" class="btn btn-default">
+            {% trans "Cancel" %}
+          </a>
+
+        </div>
+
+      </form>
+    </div>
+
+  </div>
+  <div class="col-md-4">
+
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <h3 class="panel-title">
+          {% trans "Current warning level" %}
+        </h3>
+      </div>
+      <div class="panel-body">
+
+        {% if current_level %}
+          <h4>{{ current_level.name }}</h4>
+
+          {% if current_level.is_replying_disallowed %}
+          <p class="text-error">{% trans "Replying are forbidden." %}</p>
+          {% elif current_level.is_replying_moderated %}
+          <p class="text-danger">{% trans "New replies are moderated." %}</p>
+          {% else %}
+          <p class="test-success">{% trans "No restrictions on posting replies." %}</p>
+          {% endif %}
+
+          {% if current_level.is_starting_threads_disallowed %}
+          <p class="text-error">{% trans "Starting threads is forbidden." %}</p>
+          {% elif current_level.is_starting_threads_moderated %}
+          <p class="text-danger">{% trans "New threads are moderated." %}</p>
+          {% else %}
+          <p class="test-success">{% trans "No restrictions on starting threads." %}</p>
+          {% endif %}
+        {% else %}
+        <h4>{% trans "None" %}</h4>
+        <p>{% trans "No warning level is set." %}</p>
+        {% endif %}
+
+      </div>
+    </div>
+
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <h3 class="panel-title">
+          {% trans "Next level" %}
+        </h3>
+      </div>
+      <div class="panel-body">
+
+        <h4>{{ next_level.name }}</h4>
+
+        {% if next_level.is_replying_disallowed %}
+        <p>{% trans "Replying are forbidden." %}</p>
+        {% elif next_level.is_replying_moderated %}
+        <p>{% trans "New replies are moderated." %}</p>
+        {% else %}
+        <p>{% trans "No restrictions on posting replies." %}</p>
+        {% endif %}
+
+        {% if next_level.is_starting_threads_disallowed %}
+        <p class="text-error">{% trans "Starting threads is forbidden." %}</p>
+        {% elif next_level.is_starting_threads_moderated %}
+        <p class="text-danger">{% trans "New threads are moderated." %}</p>
+        {% else %}
+        <p>{% trans "No restrictions on starting threads." %}</p>
+        {% endif %}
+
+      </div>
+    </div>
+
+  </div>
+</div>
+{% endblock action-content %}

+ 74 - 74
misago/templates/misago/profile/name_history.html

@@ -4,88 +4,88 @@
 
 {% block page %}
 {% if name_changes.object_list %}
-<div class="username-history">
-  {% for change in name_changes.object_list %}
-  {% capture trimmed as old_username %}
-    <strong>{{ change.old_username }}</strong>
-  {% endcapture %}
-  {% capture trimmed as new_username %}
-    <strong>{{ change.new_username }}</strong>
-  {% endcapture %}
-  <p>
-    {% if not change.changed_by or change.changed_by_id != profile.pk %}
-      {% if change.changed_by %}
-      <a href="{% url USER_PROFILE_URL user_slug=change.changed_by_slug user_id=change.changed_by_id %}">
-        <img src="{{ change.changed_by_id|avatar:22 }}" class="tooltip-top" title="{{ change.changed_by_username }}">
-      </a>
-      {% else %}
-      <img src="{% blankavatar 22 %}" class="tooltip-top" title="{{ change.changed_by_username }}">
-      {% endif %}
-      {% capture trimmed as changed_by %}
+  <div class="username-history">
+    {% for change in name_changes.object_list %}
+    {% capture trimmed as old_username %}
+      <strong>{{ change.old_username }}</strong>
+    {% endcapture %}
+    {% capture trimmed as new_username %}
+      <strong>{{ change.new_username }}</strong>
+    {% endcapture %}
+    <p>
+      {% if not change.changed_by or change.changed_by_id != profile.pk %}
         {% if change.changed_by %}
-        <a href="{% url USER_PROFILE_URL user_slug=change.changed_by_slug user_id=change.changed_by_id %}">{{ change.changed_by_username }}</a>
+        <a href="{% url USER_PROFILE_URL user_slug=change.changed_by_slug user_id=change.changed_by_id %}">
+          <img src="{{ change.changed_by_id|avatar:22 }}" class="tooltip-top" title="{{ change.changed_by_username }}">
+        </a>
         {% else %}
-        {{ change.changed_by_username }}
+        <img src="{% blankavatar 22 %}" class="tooltip-top" title="{{ change.changed_by_username }}">
         {% endif %}
-      {% endcapture %}
-      {% blocktrans trimmed with changed_by=changed_by|safe old_username=old_username|safe new_username=new_username|safe %}
-      {{ changed_by }} changed {{ old_username }}'s name to {{ new_username }}
-      {% endblocktrans %}
-    {% else %}
-      <img src="{{ profile.pk|avatar:22 }}" class="tooltip-top" title="{{ profile.username }}">
-      {% blocktrans trimmed with old_username=old_username|safe new_username=new_username|safe %}
-      {{ old_username }} changed name to {{ new_username }}
-      {% endblocktrans %}
-    {% endif %}
-    <abbr class="pull-right text-muted tooltip-top dynamic time-ago" title="{{ change.changed_on }}" data-timestamp="{{ change.changed_on|date:"c" }}">
-      {{ change.changed_on|date }}
-    </abbr>
-  </p>
-  <hr>
-  {% endfor%}
-</div>
+        {% capture trimmed as changed_by %}
+          {% if change.changed_by %}
+          <a href="{% url USER_PROFILE_URL user_slug=change.changed_by_slug user_id=change.changed_by_id %}">{{ change.changed_by_username }}</a>
+          {% else %}
+          {{ change.changed_by_username }}
+          {% endif %}
+        {% endcapture %}
+        {% blocktrans trimmed with changed_by=changed_by|safe old_username=old_username|safe new_username=new_username|safe %}
+        {{ changed_by }} changed {{ old_username }}'s name to {{ new_username }}
+        {% endblocktrans %}
+      {% else %}
+        <img src="{{ profile.pk|avatar:22 }}" class="tooltip-top" title="{{ profile.username }}">
+        {% blocktrans trimmed with old_username=old_username|safe new_username=new_username|safe %}
+        {{ old_username }} changed name to {{ new_username }}
+        {% endblocktrans %}
+      {% endif %}
+      <abbr class="pull-right text-muted tooltip-top dynamic time-ago" title="{{ change.changed_on }}" data-timestamp="{{ change.changed_on|date:"c" }}">
+        {{ change.changed_on|date }}
+      </abbr>
+    </p>
+    <hr>
+    {% endfor%}
+  </div>
 
-{% if name_changes.paginator.num_pages > 1 %}
-<ul class="pager pager-wide">
-  {% if name_changes.has_previous %}
-    <li class="pull-left">
-      <a href="{% url 'misago:user_name_history' user_slug=profile.slug user_id=profile.pk %}" class="tooltip-top" title="{% trans "Go to first page" %}">
-        {% trans "Latest" %}
-      </a>
-    </li>
-    {% if name_changes.number > 2 %}
-    <li class="pull-left">
-      <a href="{% url 'misago:user_name_history' user_slug=profile.slug user_id=profile.pk page=name_changes.previous_page_number %}" class="tooltip-top" title="{% trans "Go to previous page" %}">
-        {% trans "Later" %}
-      </a>
-    </li>
+  {% if name_changes.paginator.num_pages > 1 %}
+  <ul class="pager pager-wide">
+    {% if name_changes.has_previous %}
+      <li class="pull-left">
+        <a href="{% url 'misago:user_name_history' user_slug=profile.slug user_id=profile.pk %}" class="tooltip-top" title="{% trans "Go to first page" %}">
+          {% trans "Latest" %}
+        </a>
+      </li>
+      {% if name_changes.number > 2 %}
+      <li class="pull-left">
+        <a href="{% url 'misago:user_name_history' user_slug=profile.slug user_id=profile.pk page=name_changes.previous_page_number %}" class="tooltip-top" title="{% trans "Go to previous page" %}">
+          {% trans "Later" %}
+        </a>
+      </li>
+      {% endif %}
     {% endif %}
-  {% endif %}
-  {% if name_changes.has_next %}
-    <li class="pull-right">
-      <a href="{% url 'misago:user_name_history' user_slug=profile.slug user_id=profile.pk page=name_changes.paginator.num_pages %}" class="tooltip-top" title="{% trans "Go to last page" %}">
-        {% trans "Oldest" %}
-      </a>
-    </li>
-    {% if name_changes.next_page_number < name_changes.paginator.num_pages %}
-    <li class="pull-right">
-      <a href="{% url 'misago:user_name_history' user_slug=profile.slug user_id=profile.pk page=name_changes.next_page_number %}" class="tooltip-top" title="{% trans "Go to next page" %}">
-        {% trans "Older" %}
-      </a>
+    {% if name_changes.has_next %}
+      <li class="pull-right">
+        <a href="{% url 'misago:user_name_history' user_slug=profile.slug user_id=profile.pk page=name_changes.paginator.num_pages %}" class="tooltip-top" title="{% trans "Go to last page" %}">
+          {% trans "Oldest" %}
+        </a>
+      </li>
+      {% if name_changes.next_page_number < name_changes.paginator.num_pages %}
+      <li class="pull-right">
+        <a href="{% url 'misago:user_name_history' user_slug=profile.slug user_id=profile.pk page=name_changes.next_page_number %}" class="tooltip-top" title="{% trans "Go to next page" %}">
+          {% trans "Older" %}
+        </a>
+      </li>
+      {% endif %}
+    {% endif %}
+    {% if name_changes.has_next %}
+    <li class="page text-muted">
+      {% blocktrans trimmed count left=items_left %}
+      There is {{ left }} more changes
+      {% plural %}
+      There are {{ left }} more changes
+      {% endblocktrans %}
     </li>
     {% endif %}
+  </ul>
   {% endif %}
-  {% if name_changes.has_next %}
-  <li class="page text-muted">
-    {% blocktrans trimmed count left=items_left %}
-    There is {{ left }} more changes
-    {% plural %}
-    There are {{ left }} more changes
-    {% endblocktrans %}
-  </li>
-  {% endif %}
-</ul>
-{% endif %}
 {% else %}
 <p class="lead">
   {% if is_authenticated_user %}

+ 229 - 0
misago/templates/misago/profile/warnings.html

@@ -0,0 +1,229 @@
+{% extends "misago/profile/base.html" %}
+{% load i18n misago_avatars misago_capture %}
+
+
+{% block page %}
+{% if warnings.object_list %}
+  <div class="warning-summary">
+    {% if warning_level %}
+      <p class="lead">
+        <span class="fa fa-exclamation-triangle"></span>
+        {{ warning_level.name }}
+      </p>
+
+      {% if warning_level.has_restrictions %}
+      <p>
+        {% if warning_level.is_replying_disallowed %}
+          {% trans "Replying are forbidden." %}
+        {% elif warning_level.is_replying_moderated %}
+          {% trans "New replies are moderated." %}
+        {% endif %}
+        {% if warning_level.is_starting_threads_disallowed %}
+          {% trans "Starting threads is forbidden." %}
+        {% elif warning_level.is_starting_threads_moderated %}
+          {% trans "New threads are moderated." %}
+        {% endif %}
+        {% if warning_level.length_in_minutes %}
+          {% blocktrans trimmed with length=warning_level.length %}
+          This warning level lasts {{ length }}.
+          {% endblocktrans %}
+        {% endif %}
+      </p>
+      {% endif %}
+    {% else %}
+    <p class="lead">
+      <span class="fa fa-check"></span>
+      {% trans "No warnings are active." %}
+    </p>
+    {% endif %}
+    <div class="progress">
+      <div class="progress-bar progress-bar-{% if warning_progress > 66 %}success{% elif warning_progress > 33 %}warning{% else %}danger{% endif %}" role="progressbar" aria-valuenow="{{ warning_progress }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ warning_progress }}%;">
+        <span class="sr-only">
+          {% blocktrans trimmed with level=warning_level.level %}
+          Warning level is {{ level }}
+          {% endblocktrans %}
+        </span>
+      </div>
+    </div>
+  </div>
+
+  <div class="warnings-list">
+    {% for warning in warnings.object_list %}
+    <div class="panel panel-default">
+      <div class="panel-body">
+
+        {% if warning.reason %}
+        <p>
+          {{ warning.reason|escape|urlize|linebreaks }}
+        </p>
+        {% else %}
+        <p>
+          <em>{% trans "No warning reason was provided." %}</em>
+        </p>
+        {% endif %}
+
+      </div>
+      <div class="panel-footer">
+        <ul class="list-unstyled pull-left">
+          <li>
+            {% if warning.canceled %}
+            <strong class="state-canceled">
+              <span class="fa fa-ban fa-fw"></span>
+              {% trans "Canceled" %}
+            </strong>
+            {% elif warning.is_active %}
+            <strong class="state-valid">
+              <span class="fa fa-exclamation-triangle fa-fw"></span>
+              {% trans "Active" %}
+            </strong>
+            {% else %}
+            <strong class="state-expired">
+              <span class="fa fa-times fa-fw"></span>
+              {% trans "Expired" %}
+            </strong>
+            {% endif %}
+          </li>
+          <li>
+            <span class="glyphicon glyphicon-time"></span>
+            <abbr class="tooltip-top dynamic time-ago" title="{{ warning.given_on }}" data-timestamp="{{ warning.given_on|date:"c" }}">
+              {{ warning.given_on|date }}
+            </abbr>
+          </li>
+          <li>
+            {% if warning.giver_id %}
+            <img src="{{ warning.giver_id|avatar:30 }}" alt="{% trans "Avatar" %}">
+            <strong>{{ warning.giver_username }}</strong>
+            {% else %}
+            <img src="{% blankavatar 30 %}" alt="{% trans "Avatar" %}">
+            <a href="{% url USER_PROFILE_URL user_slug=warning.giver_slug user_id=warning.giver_id %}">
+              {{ warning.giver_username }}
+            </a>
+            {% endif %}
+          </li>
+          {% if warning.canceled %}
+          <li>
+            <span class="fa fa-ban"></span>
+            {% capture trimmed as canceler %}
+            {% if warning.canceler_id %}
+            <strong>{{ warning.canceler_username }}</strong>
+            {% else %}
+            <a href="{% url USER_PROFILE_URL user_slug=warning.canceler_slug user_id=warning.canceler_id %}">
+              {{ warning.canceler_username }}
+            </a>
+            {% endif %}
+            {% endcapture %}
+            {% capture trimmed as canceled %}
+            <abbr class="tooltip-top dynamic time-ago" title="{{ warning.canceled_on }}" data-timestamp="{{ warning.canceled_on|date:"c" }}">
+              {{ warning.canceled_on|date }}
+            </abbr>
+            {% endcapture %}
+            {% blocktrans trimmed with user=canceler|safe canceled=canceled|safe %}
+            Canceled by {{user}} {{ canceled }}.
+            {% endblocktrans %}
+          </li>
+          {% endif %}
+        </ul>
+        {% if warning.acl.can_moderate %}
+        <ul class="list-unstyled pull-right">
+          {% if warning.acl.can_cancel %}
+          <li>
+            <form action="{% url 'misago:cancel_warning' user_slug=profile.slug user_id=profile.pk warning_id=warning.pk %}" method="POST" class="cancel-warning-prompt">
+              {% csrf_token %}
+              <button type="submit" class="btn btn-default btn-sm">
+                {% trans "Cancel" %}
+              </button>
+            </form>
+          </li>
+          {% endif %}
+          {% if warning.acl.can_delete %}
+          <li>
+            <form action="{% url 'misago:delete_warning' user_slug=profile.slug user_id=profile.pk warning_id=warning.pk %}" method="POST" class="delete-warning-prompt">
+              {% csrf_token %}
+              <button type="submit" class="btn btn-default btn-sm">
+                {% trans "Delete" %}
+              </button>
+            </form>
+          </li>
+          {% endif %}
+        </ul>
+        {% endif %}
+      </div>
+    </div>
+    {% endfor %}
+  </div>
+
+  {% if warnings.paginator.num_pages > 1 %}
+  <ul class="pager pager-wide">
+    {% if warnings.has_previous %}
+      <li class="pull-left">
+        <a href="{% url 'misago:user_warnings' user_slug=profile.slug user_id=profile.pk %}" class="tooltip-top" title="{% trans "Go to first page" %}">
+          {% trans "Latest" %}
+        </a>
+      </li>
+      {% if warnings.number > 2 %}
+      <li class="pull-left">
+        <a href="{% url 'misago:user_warnings' user_slug=profile.slug user_id=profile.pk page=warnings.previous_page_number %}" class="tooltip-top" title="{% trans "Go to previous page" %}">
+          {% trans "Later" %}
+        </a>
+      </li>
+      {% endif %}
+    {% endif %}
+    {% if warnings.has_next %}
+      <li class="pull-right">
+        <a href="{% url 'misago:user_warnings' user_slug=profile.slug user_id=profile.pk page=warnings.paginator.num_pages %}" class="tooltip-top" title="{% trans "Go to last page" %}">
+          {% trans "Oldest" %}
+        </a>
+      </li>
+      {% if warnings.next_page_number < warnings.paginator.num_pages %}
+      <li class="pull-right">
+        <a href="{% url 'misago:user_warnings' user_slug=profile.slug user_id=profile.pk page=warnings.next_page_number %}" class="tooltip-top" title="{% trans "Go to next page" %}">
+          {% trans "Older" %}
+        </a>
+      </li>
+      {% endif %}
+    {% endif %}
+    {% if warnings.has_next %}
+    <li class="page text-muted">
+      {% blocktrans trimmed count left=items_left %}
+      There is {{ left }} more warning
+      {% plural %}
+      There are {{ left }} more warnings
+      {% endblocktrans %}
+    </li>
+    {% endif %}
+  </ul>
+  {% endif %}
+{% else %}
+<p class="lead">
+  {% if is_authenticated_user %}
+    {% blocktrans trimmed with user=profile.username %}
+    Your have no warnings, {{ user }}.
+    {% endblocktrans %}
+  {% else %}
+    {% blocktrans trimmed with user=profile.username %}
+    {{ user }} has no warnings.
+    {% endblocktrans %}
+  {% endif %}
+</p>
+{% endif %}
+{% endblock page %}
+
+
+{% block javascripts %}
+<script type="text/javascript">
+  $(function() {
+    {% if user.is_authenticated and user.acl.can_cancel_warnings %}
+    $('.cancel-warning-prompt').submit(function() {
+      var decision = confirm("{% trans "Are you sure you want to cancel this warning?" %}");
+      return decision;
+    });
+    {% endif %}
+    {% if user.is_authenticated and user.acl.can_delete_warnings %}
+    $('.delete-warning-prompt').submit(function() {
+      var decision = confirm("{% trans "Are you sure you want to delete this warning?" %}");
+      return decision;
+    });
+    {% endif %}
+  });
+</script>
+{% endblock javascripts %}

+ 11 - 0
misago/users/apps.py

@@ -51,6 +51,14 @@ class MisagoUsersConfig(AppConfig):
                 return is_account_owner or has_permission
             else:
                 return False
+        def can_see_warnings(request, profile):
+            if request.user.is_authenticated():
+                is_account_owner = profile.pk == request.user.pk
+                user_acl = request.user.acl
+                has_permission = user_acl['can_see_other_users_warnings']
+                return is_account_owner or has_permission
+            else:
+                return False
         def can_see_ban_details(request, profile):
             if request.user.is_authenticated():
                 if request.user.acl['can_see_ban_details']:
@@ -70,6 +78,9 @@ class MisagoUsersConfig(AppConfig):
         user_profile.add_page(link='misago:user_name_history',
                               name=_("Name history"),
                               visible_if=can_see_names_history)
+        user_profile.add_page(link='misago:user_warnings',
+                              name=_("Warnings"),
+                              visible_if=can_see_warnings)
         user_profile.add_page(link='misago:user_ban',
                               name=_("Ban"),
                               visible_if=can_see_ban_details)

+ 51 - 36
misago/users/forms/modusers.py

@@ -6,50 +6,26 @@ from django.utils import timezone
 
 from misago.conf import settings
 from misago.core import forms
+from misago.core.utils import clean_return_path
 
 from misago.users.forms.admin import BanUsersForm
 from misago.users.models import Ban, BAN_EMAIL, BAN_USERNAME
 
 
-class BanForm(BanUsersForm):
-    def __init__(self, *args, **kwargs):
-        self.user = kwargs.pop('user')
-        super(BanForm, self).__init__(*args, **kwargs)
-
-        if self.user.acl_['max_ban_length']:
-            message = ungettext(
-                "Required. Can't be longer than %(days)s day.",
-                "Required. Can't be longer than %(days)s days.",
-                self.user.acl_['max_ban_length'])
-            message = message % {'days': self.user.acl_['max_ban_length']}
-            self['valid_until'].field.help_text = message
-
-    def clean_valid_until(self):
-        data = self.cleaned_data['valid_until']
-
-        if self.user.acl_['max_ban_length']:
-            max_ban_length = timedelta(days=self.user.acl_['max_ban_length'])
-            if not data or data > (timezone.now() + max_ban_length).date():
-                message = ungettext(
-                    "You can't set bans longer than %(days)s day.",
-                    "You can't set bans longer than %(days)s days.",
-                    self.user.acl_['max_ban_length'])
-                message = message % {'days': self.user.acl_['max_ban_length']}
-                raise forms.ValidationError(message)
-        elif data and data < timezone.now().date():
-            raise forms.ValidationError(_("Expiration date is in past."))
+class WarnUserForm(forms.Form):
+    reason = forms.CharField(label=_("Warning Reason"),
+                             help_text=_("Optional message explaining why "
+                                         "this warning was given."),
+                             widget=forms.Textarea(attrs={'rows': 8}),
+                             required=False)
 
+    def clean_reason(self):
+        data = self.cleaned_data['reason']
+        if len(data) > 2000:
+            message = _("Warning reason can't be longer than 2000 characters.")
+            raise forms.ValidationError(message)
         return data
 
-    def ban_user(self):
-        new_ban = Ban(banned_value=self.user.username,
-                      user_message=self.cleaned_data['user_message'],
-                      staff_message=self.cleaned_data['staff_message'],
-                      valid_until=self.cleaned_data['valid_until'])
-        new_ban.save()
-
-        Ban.objects.invalidate_cache()
-
 
 class ModerateAvatarForm(forms.ModelForm):
     is_avatar_locked = forms.YesNoSwitch(
@@ -122,3 +98,42 @@ class ModerateSignatureForm(forms.ModelForm):
 
         return data
 
+
+class BanForm(BanUsersForm):
+    def __init__(self, *args, **kwargs):
+        self.user = kwargs.pop('user')
+        super(BanForm, self).__init__(*args, **kwargs)
+
+        if self.user.acl_['max_ban_length']:
+            message = ungettext(
+                "Required. Can't be longer than %(days)s day.",
+                "Required. Can't be longer than %(days)s days.",
+                self.user.acl_['max_ban_length'])
+            message = message % {'days': self.user.acl_['max_ban_length']}
+            self['valid_until'].field.help_text = message
+
+    def clean_valid_until(self):
+        data = self.cleaned_data['valid_until']
+
+        if self.user.acl_['max_ban_length']:
+            max_ban_length = timedelta(days=self.user.acl_['max_ban_length'])
+            if not data or data > (timezone.now() + max_ban_length).date():
+                message = ungettext(
+                    "You can't set bans longer than %(days)s day.",
+                    "You can't set bans longer than %(days)s days.",
+                    self.user.acl_['max_ban_length'])
+                message = message % {'days': self.user.acl_['max_ban_length']}
+                raise forms.ValidationError(message)
+        elif data and data < timezone.now().date():
+            raise forms.ValidationError(_("Expiration date is in past."))
+
+        return data
+
+    def ban_user(self):
+        new_ban = Ban(banned_value=self.user.username,
+                      user_message=self.cleaned_data['user_message'],
+                      staff_message=self.cleaned_data['staff_message'],
+                      valid_until=self.cleaned_data['valid_until'])
+        new_ban.save()
+
+        Ban.objects.invalidate_cache()

+ 1 - 0
misago/users/middleware.py

@@ -33,6 +33,7 @@ class UserMiddleware(object):
         elif not request.user.is_superuser:
             if get_request_ip_ban(request) or get_user_ban(request.user):
                 logout(request)
+        request.user.ip = request._misago_real_ip
 
 
 class OnlineTrackerMiddleware(object):

+ 20 - 0
misago/users/migrations/0001_initial.py

@@ -176,4 +176,24 @@ class Migration(migrations.Migration):
             },
             bases=(models.Model,),
         ),
+        migrations.CreateModel(
+            name='UserWarning',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('reason', models.TextField(null=True, blank=True)),
+                ('given_on', models.DateTimeField(default=django.utils.timezone.now)),
+                ('giver_username', models.CharField(max_length=255)),
+                ('giver_slug', models.CharField(max_length=255)),
+                ('canceled', models.BooleanField(default=False)),
+                ('canceled_on', models.DateTimeField(null=True, blank=True)),
+                ('canceler_username', models.CharField(max_length=255)),
+                ('canceler_slug', models.CharField(max_length=255)),
+                ('canceler', models.ForeignKey(on_delete=django.db.models.deletion.SET_NULL, blank=True, to=settings.AUTH_USER_MODEL, null=True)),
+                ('giver', models.ForeignKey(on_delete=django.db.models.deletion.SET_NULL, blank=True, to=settings.AUTH_USER_MODEL, null=True)),
+                ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+            },
+            bases=(models.Model,),
+        ),
     ]

+ 5 - 3
misago/users/models/user.py

@@ -350,15 +350,17 @@ class User(AbstractBaseUser, PermissionsMixin):
 
 
 class Online(models.Model):
-    user = models.OneToOneField(User, primary_key=True,
+    user = models.OneToOneField(settings.AUTH_USER_MODEL, primary_key=True,
                                 related_name='online_tracker')
     current_ip = models.GenericIPAddressField()
     last_click = models.DateTimeField(default=timezone.now)
 
 
 class UsernameChange(models.Model):
-    user = models.ForeignKey(User, related_name='namechanges')
-    changed_by = models.ForeignKey(User, null=True, blank=True,
+    user = models.ForeignKey(settings.AUTH_USER_MODEL,
+                             related_name='namechanges')
+    changed_by = models.ForeignKey(settings.AUTH_USER_MODEL,
+                                   null=True, blank=True,
                                    related_name='user_renames',
                                    on_delete=models.SET_NULL)
     changed_by_username = models.CharField(max_length=30)

+ 98 - 3
misago/users/models/warnings.py

@@ -1,16 +1,25 @@
+from collections import OrderedDict
+from datetime import timedelta
+
+from django.conf import settings
+from django.contrib.auth import get_user_model
 from django.db import models
+from django.utils import timezone
 from django.utils.translation import ugettext_lazy as _
 
+from misago.core import threadstore
 from misago.core.cache import cache
 from misago.core.utils import time_amount
 
 
 __all__ = [
     'RESTRICT_NO', 'RESTRICT_MODERATOR_REVIEW', 'RESTRICT_DISALLOW',
-    'RESTRICTIONS_CHOICES', 'WarningLevel'
+    'RESTRICTIONS_CHOICES', 'WarningLevel', 'UserWarning'
 ]
 
 
+CACHE_NAME = 'misago_warning_levels'
+
 RESTRICT_NO = 0
 RESTRICT_MODERATOR_REVIEW = 1
 RESTRICT_DISALLOW = 2
@@ -23,6 +32,31 @@ RESTRICTIONS_CHOICES = (
 )
 
 
+class WarningLevelManager(models.Manager):
+    def dict(self):
+        return self.get_levels_from_threadstore()
+
+    def get_levels_from_threadstore(self):
+        levels = threadstore.get(CACHE_NAME, 'nada')
+        if levels == 'nada':
+            levels = self.get_levels_from_cache()
+            threadstore.set(CACHE_NAME, levels)
+        return levels
+
+    def get_levels_from_cache(self):
+        levels = cache.get(CACHE_NAME, 'nada')
+        if levels == 'nada':
+            levels = self.get_levels_from_database()
+            cache.set(CACHE_NAME, levels)
+        return levels
+
+    def get_levels_from_database(self):
+        levels = [(0, None)]
+        for level, obj in enumerate(self.order_by('level')):
+            levels.append((level + 1, obj))
+        return OrderedDict(levels)
+
+
 class WarningLevel(models.Model):
     name = models.CharField(max_length=255)
     description = models.TextField(null=True, blank=True)
@@ -33,16 +67,18 @@ class WarningLevel(models.Model):
     restricts_posting_threads = models.PositiveIntegerField(
         default=RESTRICT_NO)
 
+    objects = WarningLevelManager()
+
     def save(self, *args, **kwargs):
         if not self.pk:
             self.set_level()
 
         super(WarningLevel, self).save(*args, **kwargs)
-        cache.delete('warning_levels')
+        cache.delete(CACHE_NAME)
 
     def delete(self, *args, **kwargs):
         super(WarningLevel, self).delete(*args, **kwargs)
-        cache.delete('warning_levels')
+        cache.delete(CACHE_NAME)
 
     @property
     def length(self):
@@ -51,8 +87,67 @@ class WarningLevel(models.Model):
         else:
             return _("permanent")
 
+    @property
+    def has_restrictions(self):
+        return self.restricts_posting_replies or self.restricts_posting_threads
+
+    @property
+    def is_replying_moderated(self):
+        return self.restricts_posting_replies == RESTRICT_MODERATOR_REVIEW
+
+    @property
+    def is_replying_disallowed(self):
+        return self.restricts_posting_replies == RESTRICT_DISALLOW
+
+    @property
+    def is_starting_threads_moderated(self):
+        return self.restricts_posting_threads == RESTRICT_MODERATOR_REVIEW
+
+    @property
+    def is_starting_threads_disallowed(self):
+        return self.restricts_posting_threads == RESTRICT_DISALLOW
+
     def set_level(self):
         try:
             self.level = WarningLevel.objects.latest('level').level + 1
         except WarningLevel.DoesNotExist:
             self.level = 1
+
+
+class UserWarning(models.Model):
+    user = models.ForeignKey(settings.AUTH_USER_MODEL,
+                             related_name="warnings")
+    reason = models.TextField(null=True, blank=True)
+    given_on = models.DateTimeField(default=timezone.now)
+    giver = models.ForeignKey(settings.AUTH_USER_MODEL,
+                                 null=True, blank=True,
+                                 on_delete=models.SET_NULL,
+                                 related_name="+")
+    giver_username = models.CharField(max_length=255)
+    giver_slug = models.CharField(max_length=255)
+    canceled = models.BooleanField(default=False)
+    canceled_on = models.DateTimeField(null=True, blank=True)
+    canceler = models.ForeignKey(settings.AUTH_USER_MODEL,
+                                    null=True, blank=True,
+                                    on_delete=models.SET_NULL,
+                                    related_name="+")
+    canceler_username = models.CharField(max_length=255)
+    canceler_slug = models.CharField(max_length=255)
+
+    def cancel(self, canceler):
+        self.canceled = True
+        self.canceled_on = timezone.now()
+        self.canceler = canceler
+        self.canceler_username = canceler.username
+        self.canceler_slug = canceler.slug
+
+        self.save(update_fields=(
+            'canceled',
+            'canceled_on',
+            'canceler',
+            'canceler_username',
+            'canceler_slug',
+        ))
+
+    def is_expired(self, valid_for):
+        return timezone.now() > self.given_on + timedelta(minutes=valid_for)

+ 1 - 1
misago/users/permissions/delete.py

@@ -57,7 +57,7 @@ def build_acl(acl, roles, key_name):
 ACL's for targets
 """
 @require_target_type(get_user_model())
-def add_acl_to_target(user, acl, target):
+def add_acl_to_target(user, target):
     target.acl_['can_delete'] = can_delete_user(user, target)
     if target.acl_['can_delete']:
         target.acl_['can_moderate'] = True

+ 1 - 1
misago/users/permissions/moderation.py

@@ -76,7 +76,7 @@ def build_acl(acl, roles, key_name):
 ACL's for targets
 """
 @require_target_type(get_user_model())
-def add_acl_to_target(user, acl, target):
+def add_acl_to_target(user, target):
     target_acl = target.acl_
 
     target_acl['can_rename'] = can_rename_user(user, target)

+ 143 - 0
misago/users/permissions/warnings.py

@@ -0,0 +1,143 @@
+from django.contrib.auth import get_user_model
+from django.core.exceptions import PermissionDenied
+from django.utils.translation import ugettext_lazy as _
+
+from misago.acl import algebra
+from misago.acl.decorators import return_boolean
+from misago.acl.models import Role
+from misago.core import forms
+
+from misago.users.models import UserWarning
+
+
+"""
+Admin Permissions Form
+"""
+NO_OWNED_ALL = ((0, _("No")), (1, _("Owned")), (2, _("All")))
+
+
+class PermissionsForm(forms.Form):
+    legend = _("Warnings")
+
+    can_see_other_users_warnings = forms.YesNoSwitch(
+        label=_("Can see other users warnings"))
+    can_warn_users = forms.YesNoSwitch(label=_("Can warn users"))
+    can_be_warned = forms.YesNoSwitch(label=_("Can be warned"), initial=False)
+    can_cancel_warnings = forms.TypedChoiceField(
+        label=_("Can cancel warnings"),
+        coerce=int,
+        choices=NO_OWNED_ALL,
+        initial=0)
+    can_delete_warnings = forms.TypedChoiceField(
+        label=_("Can delete warnings"),
+        coerce=int,
+        choices=NO_OWNED_ALL,
+        initial=0)
+
+
+def change_permissions_form(role):
+    if isinstance(role, Role) and role.special_role != 'anonymous':
+        return PermissionsForm
+    else:
+        return None
+
+
+"""
+ACL Builder
+"""
+def build_acl(acl, roles, key_name):
+    new_acl = {
+        'can_see_other_users_warnings': 0,
+        'can_warn_users': 0,
+        'can_cancel_warnings': 0,
+        'can_delete_warnings': 0,
+        'can_be_warned': 1,
+    }
+    new_acl.update(acl)
+
+    return algebra.sum_acls(
+            new_acl, roles=roles, key=key_name,
+            can_see_other_users_warnings=algebra.greater,
+            can_warn_users=algebra.greater,
+            can_cancel_warnings=algebra.greater,
+            can_delete_warnings=algebra.greater,
+            can_be_warned=algebra.lower
+            )
+
+
+"""
+ACL's for targets
+"""
+def add_acl_to_target(user, target):
+    if isinstance(target, get_user_model()):
+        add_acl_to_user(user, target)
+    elif isinstance(target, UserWarning):
+        add_acl_to_warning(user, target)
+
+
+def add_acl_to_user(user, target):
+    target_acl = target.acl_
+
+    target_acl['can_see_warnings'] = can_see_warnings(user, target)
+    target_acl['can_warn'] = can_warn_user(user, target)
+    target_acl['can_cancel_warnings'] = False
+    target_acl['can_delete_warnings'] = False
+
+    mod_permissions = (
+        'can_warn',
+    )
+
+    if target_acl['can_warn']:
+        target_acl['can_moderate'] = True
+
+
+def add_acl_to_warning(user, target):
+    target.acl['can_cancel'] = can_cancel_warning(user, target)
+    target.acl['can_delete'] = can_delete_warning(user, target)
+
+    can_moderate = target.acl['can_cancel'] or target.acl['can_delete']
+    target.acl['can_moderate'] = can_moderate
+
+
+"""
+ACL tests
+"""
+def allow_see_warnings(user, target):
+    if user.is_authenticated() and user.pk == target.pk:
+        return None
+    if not user.acl['can_see_other_users_warnings']:
+        raise PermissionDenied(_("You can't see other users warnings."))
+can_see_warnings = return_boolean(allow_see_warnings)
+
+
+def allow_warn_user(user, target):
+    if not user.acl['can_warn_users']:
+        raise PermissionDenied(_("You can't warn users."))
+    if not user.is_superuser and (target.is_staff or target.is_superuser):
+        raise PermissionDenied(_("You can't warn administrators."))
+    if not target.acl['can_be_warned']:
+        message = _("%(username)s can't be warned.")
+        raise PermissionDenied(message % {'username': target.username})
+can_warn_user = return_boolean(allow_warn_user)
+
+
+def allow_cancel_warning(user, target):
+    if user.is_anonymous() or not user.acl['can_cancel_warnings']:
+        raise PermissionDenied(_("You can't cancel warnings."))
+    if user.acl['can_cancel_warnings'] == 1:
+        if target.giver_id != user.pk:
+            message = _("You can't cancel warnings issued by other users.")
+            raise PermissionDenied(message)
+    if target.canceled:
+        raise PermissionDenied(_("This warning is already canceled."))
+can_cancel_warning = return_boolean(allow_cancel_warning)
+
+
+def allow_delete_warning(user, target):
+    if user.is_anonymous() or not user.acl['can_delete_warnings']:
+        raise PermissionDenied(_("You can't delete warnings."))
+    if user.acl['can_delete_warnings'] == 1:
+        if target.giver_id != user.pk:
+            message = _("You can't delete warnings issued by other users.")
+            raise PermissionDenied(message)
+can_delete_warning = return_boolean(allow_delete_warning)

+ 72 - 0
misago/users/tests/test_warnings.py

@@ -0,0 +1,72 @@
+from datetime import timedelta
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from django.utils import timezone
+
+from misago.core import threadstore
+from misago.core.cache import cache
+
+from misago.users import warnings
+from misago.users.models import WarningLevel, UserWarning
+
+
+class WarningsTests(TestCase):
+    def setUp(self):
+        User = get_user_model()
+        self.test_mod = User.objects.create_user('Modo', 'mod@mod.com',
+                                                 'Pass.123')
+        self.test_user = User.objects.create_user('Bob', 'bob@bob.com',
+                                                  'Pass.123')
+
+    def test_warnings(self):
+        """user warning levels is obtained"""
+        threadstore.clear()
+        cache.clear()
+
+        self.assertTrue(warnings.is_user_warning_level_max(self.test_user))
+
+        levels = (
+            WarningLevel.objects.create(name="Level 1"),
+            WarningLevel.objects.create(name="Level 2"),
+            WarningLevel.objects.create(name="Level 3"),
+            WarningLevel.objects.create(name="Level 4"),
+            WarningLevel.objects.create(name="Level 5"),
+            WarningLevel.objects.create(name="Level 6"),
+            WarningLevel.objects.create(name="Level 7"),
+            WarningLevel.objects.create(name="Level 8")
+        )
+
+        self.assertEqual(WarningLevel.objects.count(), 8)
+
+        threadstore.clear()
+        cache.clear()
+
+        for level, warning in enumerate(levels):
+            warnings.warn_user(self.test_mod, self.test_user, "bawww")
+
+            user_level = warnings.get_user_warning_level(self.test_user)
+            user_level_obj = warnings.get_user_warning_obj(self.test_user)
+
+            self.assertEqual(user_level, level + 1)
+            self.assertEqual(user_level_obj.name, levels[level].name)
+            self.assertEqual(self.test_user.warning_level, level + 1)
+
+        self.assertTrue(warnings.is_user_warning_level_max(self.test_user))
+
+        previous_level = user_level
+        for warning in self.test_user.warnings.all():
+            warnings.cancel_warning(self.test_mod, self.test_user, warning)
+            user_level = warnings.get_user_warning_level(self.test_user)
+            self.assertEqual(user_level + 1, previous_level)
+            previous_level = user_level
+
+        self.assertEqual(0, warnings.get_user_warning_level(self.test_user))
+
+
+class WarningModelTests(TestCase):
+    def test_warning_is_expired(self):
+        """warning knows wheter or not its expired"""
+        warning = UserWarning(given_on=timezone.now() - timedelta(days=6))
+        self.assertTrue(warning.is_expired(60))
+        self.assertFalse(warning.is_expired(14 * 24 * 3600))

+ 306 - 0
misago/users/tests/test_warnings_views.py

@@ -0,0 +1,306 @@
+from django.contrib.auth import get_user_model
+from django.core.urlresolvers import reverse
+
+from misago.acl.testutils import override_acl
+from misago.admin.testutils import AdminTestCase
+from misago.core import threadstore
+from misago.core.cache import cache
+
+from misago.users.warnings import warn_user
+from misago.users.models import WarningLevel
+
+
+class WarningTestCase(AdminTestCase):
+    def setUp(self):
+        super(WarningTestCase, self).setUp()
+        self.test_user = get_user_model().objects.create_user(
+            "Bob", "bob@bob.com", "Pass.123")
+        self.link_kwargs = {'user_slug': 'bob', 'user_id': self.test_user.pk}
+
+        self.warning_levels = (
+            WarningLevel.objects.create(name='Lvl 1'),
+            WarningLevel.objects.create(name='Lvl 2'),
+            WarningLevel.objects.create(name='Lvl 3'),
+            WarningLevel.objects.create(name='Lvl 4'),
+        )
+
+        cache.clear()
+        threadstore.clear()
+
+    def warn_user(self, reason):
+        response = self.client.post(
+            reverse('misago:warn_user', kwargs=self.link_kwargs),
+            data={'reason': reason})
+
+
+class WarnUserTests(WarningTestCase):
+    def test_no_permission(self):
+        """fail to warn due to permissions"""
+        override_acl(self.test_admin, {
+            'misago.users.permissions.warnings': {
+                'can_warn_users': 0,
+            }
+        })
+
+        override_acl(self.test_user, {
+            'misago.users.permissions.warnings': {
+                'can_be_warned': 1,
+            }
+        })
+
+        response = self.client.get(reverse('misago:warn_user',
+                                           kwargs=self.link_kwargs))
+        self.assertEqual(response.status_code, 403)
+
+    def test_protected_user(self):
+        """fail to warn due to user's can_be_warned"""
+        override_acl(self.test_admin, {
+            'misago.users.permissions.warnings': {
+                'can_warn_users': 1,
+            }
+        })
+
+        override_acl(self.test_user, {
+            'misago.users.permissions.warnings': {
+                'can_be_warned': 0,
+            }
+        })
+
+        response = self.client.get(reverse('misago:warn_user',
+                                           kwargs=self.link_kwargs))
+        self.assertEqual(response.status_code, 403)
+
+    def test_warn_user(self):
+        """can warn user to the roof"""
+        override_acl(self.test_admin, {
+            'misago.users.permissions.warnings': {
+                'can_warn_users': 1,
+            }
+        })
+
+        override_acl(self.test_user, {
+            'misago.users.permissions.warnings': {
+                'can_be_warned': 1,
+            }
+        })
+
+        for level in self.warning_levels:
+            response = self.client.get(reverse('misago:warn_user',
+                                               kwargs=self.link_kwargs))
+            self.assertEqual(response.status_code, 200)
+
+            response = self.client.post(
+                reverse('misago:warn_user', kwargs=self.link_kwargs),
+                data={'reason': 'Warning %s' % level.name})
+            self.assertEqual(response.status_code, 302)
+
+        self.assertEqual(self.test_user.warnings.count(), 4)
+
+
+class UserWarningsListTests(WarningTestCase):
+    def test_no_permission(self):
+        """can't see other user warnings"""
+        override_acl(self.test_admin, {
+            'misago.users.permissions.warnings': {
+                'can_warn_users': 1,
+                'can_see_other_users_warnings': 0,
+            }
+        })
+
+        self.warn_user('Test Warning!')
+        response = self.client.get(reverse('misago:user_warnings',
+                                           kwargs=self.link_kwargs))
+        self.assertEqual(response.status_code, 404)
+
+    def test_see_user_warnings(self):
+        """can see user warnings"""
+        override_acl(self.test_admin, {
+            'misago.users.permissions.warnings': {
+                'can_warn_users': 1,
+                'can_see_other_users_warnings': 1,
+            }
+        })
+
+        override_acl(self.test_user, {
+            'misago.users.permissions.warnings': {
+                'can_be_warned': 1,
+            }
+        })
+
+        response = self.client.get(reverse('misago:user_warnings',
+                                           kwargs=self.link_kwargs))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Bob has no warnings', response.content)
+
+        self.warn_user('Test Warning!')
+
+        response = self.client.get(reverse('misago:user_warnings',
+                                           kwargs=self.link_kwargs))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Test Warning!', response.content)
+
+
+class CancelWarningTests(WarningTestCase):
+    def test_no_permission(self):
+        """can't cancel warnings"""
+        override_acl(self.test_admin, {
+            'misago.users.permissions.warnings': {
+                'can_warn_users': 1,
+                'can_see_other_users_warnings': 1,
+                'can_cancel_warnings': 0,
+            }
+        })
+
+        warning = warn_user(self.test_admin, self.test_user)
+        response = self.client.post(
+            reverse('misago:cancel_warning', kwargs={
+                'user_slug': 'bob',
+                'user_id': self.test_user.pk,
+                'warning_id': warning.pk
+            }))
+        self.assertEqual(response.status_code, 403)
+        self.assertFalse(self.test_user.warnings.get(id=warning.pk).canceled)
+
+    def test_no_permission_other(self):
+        """can't cancel other mod warnings"""
+        override_acl(self.test_admin, {
+            'misago.users.permissions.warnings': {
+                'can_warn_users': 1,
+                'can_see_other_users_warnings': 1,
+                'can_cancel_warnings': 1,
+            }
+        })
+
+        warning = warn_user(self.test_user, self.test_user)
+        response = self.client.post(
+            reverse('misago:cancel_warning', kwargs={
+                'user_slug': 'bob',
+                'user_id': self.test_user.pk,
+                'warning_id': warning.pk
+            }))
+        self.assertEqual(response.status_code, 403)
+        self.assertFalse(self.test_user.warnings.get(id=warning.pk).canceled)
+
+        warning = warn_user(self.test_admin, self.test_user)
+        response = self.client.post(
+            reverse('misago:cancel_warning', kwargs={
+                'user_slug': 'bob',
+                'user_id': self.test_user.pk,
+                'warning_id': warning.pk
+            }))
+        self.assertEqual(response.status_code, 302)
+
+        warning = self.test_user.warnings.get(id=warning.pk)
+        self.assertTrue(self.test_user.warnings.get(id=warning.pk).canceled)
+
+    def test_cancel_other_and_owned_warnings(self):
+        """cancel everyone's warnings"""
+        override_acl(self.test_admin, {
+            'misago.users.permissions.warnings': {
+                'can_warn_users': 1,
+                'can_see_other_users_warnings': 1,
+                'can_cancel_warnings': 2,
+            }
+        })
+
+        warning = warn_user(self.test_user, self.test_user)
+        response = self.client.post(
+            reverse('misago:cancel_warning', kwargs={
+                'user_slug': 'bob',
+                'user_id': self.test_user.pk,
+                'warning_id': warning.pk
+            }))
+        self.assertEqual(response.status_code, 302)
+        self.assertTrue(self.test_user.warnings.get(id=warning.pk).canceled)
+
+        warning = warn_user(self.test_admin, self.test_user)
+        response = self.client.post(
+            reverse('misago:cancel_warning', kwargs={
+                'user_slug': 'bob',
+                'user_id': self.test_user.pk,
+                'warning_id': warning.pk
+            }))
+        self.assertEqual(response.status_code, 302)
+        self.assertTrue(self.test_user.warnings.get(id=warning.pk).canceled)
+
+
+class DeleteWarningTests(WarningTestCase):
+    def test_no_permission(self):
+        """can't delete warnings"""
+        override_acl(self.test_admin, {
+            'misago.users.permissions.warnings': {
+                'can_warn_users': 1,
+                'can_see_other_users_warnings': 1,
+                'can_delete_warnings': 0,
+            }
+        })
+
+        warning = warn_user(self.test_admin, self.test_user)
+        response = self.client.post(
+            reverse('misago:delete_warning', kwargs={
+                'user_slug': 'bob',
+                'user_id': self.test_user.pk,
+                'warning_id': warning.pk
+            }))
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(self.test_user.warnings.count(), 1)
+
+    def test_no_permission_other(self):
+        """can't delete other mod warnings"""
+        override_acl(self.test_admin, {
+            'misago.users.permissions.warnings': {
+                'can_warn_users': 1,
+                'can_see_other_users_warnings': 1,
+                'can_delete_warnings': 1,
+            }
+        })
+
+        warning = warn_user(self.test_user, self.test_user)
+        response = self.client.post(
+            reverse('misago:delete_warning', kwargs={
+                'user_slug': 'bob',
+                'user_id': self.test_user.pk,
+                'warning_id': warning.pk
+            }))
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(self.test_user.warnings.count(), 1)
+
+        warning = warn_user(self.test_admin, self.test_user)
+        response = self.client.post(
+            reverse('misago:delete_warning', kwargs={
+                'user_slug': 'bob',
+                'user_id': self.test_user.pk,
+                'warning_id': warning.pk
+            }))
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(self.test_user.warnings.count(), 1)
+
+    def test_cancel_other_and_owned_warnings(self):
+        """delete everyone's warnings"""
+        override_acl(self.test_admin, {
+            'misago.users.permissions.warnings': {
+                'can_warn_users': 1,
+                'can_see_other_users_warnings': 1,
+                'can_delete_warnings': 2,
+            }
+        })
+
+        warning = warn_user(self.test_user, self.test_user)
+        response = self.client.post(
+            reverse('misago:delete_warning', kwargs={
+                'user_slug': 'bob',
+                'user_id': self.test_user.pk,
+                'warning_id': warning.pk
+            }))
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(self.test_user.warnings.count(), 0)
+
+        warning = warn_user(self.test_admin, self.test_user)
+        response = self.client.post(
+            reverse('misago:delete_warning', kwargs={
+                'user_slug': 'bob',
+                'user_id': self.test_user.pk,
+                'warning_id': warning.pk
+            }))
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(self.test_user.warnings.count(), 0)

+ 5 - 0
misago/users/urls.py

@@ -58,6 +58,8 @@ urlpatterns += patterns('',
         url(r'^threads/$', 'user_threads', name="user_threads"),
         url(r'^name-history/$', 'name_history', name="user_name_history"),
         url(r'^name-history/(?P<page>\d+)/$', 'name_history', name="user_name_history"),
+        url(r'^warnings/$', 'warnings', name="user_warnings"),
+        url(r'^warnings/(?P<page>\d+)/$', 'warnings', name="user_warnings"),
         url(r'^ban-details/$', 'user_ban', name="user_ban"),
     )))
 )
@@ -65,6 +67,9 @@ urlpatterns += patterns('',
 
 urlpatterns += patterns('',
     url(r'^mod-user/(?P<user_slug>[a-zA-Z0-9]+)-(?P<user_id>\d+)/', include(patterns('misago.users.views.moderation',
+        url(r'^warn/$', 'warn', name='warn_user'),
+        url(r'^warn/(?P<warning_id>\d+)/cancel/$', 'cancel_warning', name='cancel_warning'),
+        url(r'^warn/(?P<warning_id>\d+)/delete/$', 'delete_warning', name='delete_warning'),
         url(r'^rename/$', 'rename', name='rename_user'),
         url(r'^avatar/$', 'moderate_avatar', name='moderate_avatar'),
         url(r'^signature/$', 'moderate_signature', name='moderate_signature'),

+ 102 - 7
misago/users/views/moderation.py

@@ -1,3 +1,4 @@
+from django.core.urlresolvers import reverse
 from django.contrib import messages
 from django.contrib.auth import get_user_model
 from django.db import IntegrityError, transaction
@@ -7,20 +8,26 @@ from django.utils.translation import ugettext as _
 from misago.acl import add_acl
 from misago.core.decorators import require_POST
 from misago.core.shortcuts import get_object_or_404, validate_slug
+from misago.core.utils import clean_return_path
 from misago.markup import Editor
 
 from misago.users.avatars.dynamic import set_avatar as set_dynamic_avatar
+from misago.users import warnings
 from misago.users.bans import get_user_ban
 from misago.users.decorators import deny_guests
 from misago.users.forms.rename import ChangeUsernameForm
 from misago.users.forms.modusers import (BanForm, ModerateAvatarForm,
-                                         ModerateSignatureForm)
+                                         ModerateSignatureForm, WarnUserForm)
 from misago.users.models import Ban
 from misago.users.permissions.moderation import (allow_rename_user,
                                                  allow_moderate_avatar,
                                                  allow_moderate_signature,
                                                  allow_ban_user,
                                                  allow_lift_ban)
+from misago.users.permissions.warnings import (allow_warn_user,
+                                               allow_see_warnings,
+                                               allow_cancel_warning,
+                                               allow_delete_warning)
 from misago.users.permissions.delete import allow_delete_user
 from misago.users.signatures import set_user_signature
 from misago.users.sites import user_profile
@@ -46,6 +53,95 @@ def user_moderation_view(required_permission=None):
     return wrap
 
 
+def moderation_return_path(request, user):
+    return_path = clean_return_path(request)
+    if not return_path:
+        return reverse(user_profile.get_default_link(),
+                       kwargs={'user_slug': user.slug, 'user_id': user.pk})
+    return return_path
+
+
+@user_moderation_view(allow_warn_user)
+def warn(request, user, reason=None):
+    return_path = moderation_return_path(request, user)
+
+    if warnings.is_user_warning_level_max(user):
+        message = _("%(username)s has maximum warning "
+                    "level and can't be warned.")
+        message = message % {'username': user.username}
+        messages.info(request, message)
+
+        return redirect(return_path)
+
+    form = WarnUserForm(initial={'reason': reason})
+    if request.method == 'POST':
+        form = WarnUserForm(request.POST)
+        if form.is_valid():
+            warnings.warn_user(request.user, user, form.cleaned_data['reason'])
+
+            message = _("%(username)s has been warned.")
+            message = message % {'username': user.username}
+            messages.success(request, message)
+
+            return redirect(return_path)
+
+    warning_levels = warnings.get_warning_levels()
+    current_level = warning_levels[user.warning_level]
+    next_level = warning_levels[user.warning_level + 1]
+
+    return render(request, 'misago/modusers/warn.html', {
+        'profile': user,
+        'form': form,
+        'return_path': return_path,
+        'current_level': current_level,
+        'next_level': next_level
+    })
+
+
+def warning_moderation_view(required_permission=None):
+    def wrap(f):
+        @deny_guests
+        @transaction.atomic
+        def decorator(request, *args, **kwargs):
+            queryset = kwargs['user'].warnings
+            warning_id = kwargs.pop('warning_id')
+
+            kwargs['warning'] = get_object_or_404(queryset, id=warning_id)
+            add_acl(request.user, kwargs['warning'])
+
+            required_permission(request.user, kwargs['warning'])
+
+            response = f(request, *args, **kwargs)
+
+            if response:
+                return response
+            else:
+                return_path = moderation_return_path(request, kwargs['user'])
+                return redirect(return_path)
+        return decorator
+    return wrap
+
+
+@user_moderation_view(allow_see_warnings)
+@warning_moderation_view(allow_cancel_warning)
+def cancel_warning(request, user, warning):
+    warnings.cancel_warning(request.user, user, warning)
+
+    message = _("%(username)s's warning has been canceled.")
+    message = message % {'username': user.username}
+    messages.success(request, message)
+
+
+@user_moderation_view(allow_see_warnings)
+@warning_moderation_view(allow_delete_warning)
+def delete_warning(request, user, warning):
+    warnings.delete_warning(request.user, user, warning)
+
+    message = _("%(username)s's warning has been deleted.")
+    message = message % {'username': user.username}
+    messages.success(request, message)
+
+
 @user_moderation_view(allow_rename_user)
 def rename(request, user):
     form = ChangeUsernameForm(user=user)
@@ -60,7 +156,7 @@ def rename(request, user):
                 messages.success(request, message)
 
                 return redirect(user_profile.get_default_link(),
-                                **{'user_slug': user.slug, 'user_id': user.pk})
+                                user_slug=user.slug, user_id=user.pk)
             except IntegrityError:
                 message = _("Error changing username. Please try again.")
                 messages.error(request, message)
@@ -92,7 +188,7 @@ def moderate_avatar(request, user):
 
             if 'stay' not in request.POST:
                 return redirect(user_profile.get_default_link(),
-                                **{'user_slug': user.slug, 'user_id': user.pk})
+                                user_slug=user.slug, user_id=user.pk)
 
     return render(request, 'misago/modusers/avatar.html',
                   {'profile': user, 'form': form})
@@ -121,7 +217,7 @@ def moderate_signature(request, user):
 
             if 'stay' not in request.POST:
                 return redirect(user_profile.get_default_link(),
-                                **{'user_slug': user.slug, 'user_id': user.pk})
+                                user_slug=user.slug, user_id=user.pk)
 
     acl = user.acl
     editor = Editor(form['signature'],
@@ -145,7 +241,7 @@ def ban_user(request, user):
             messages.success(request, message % {'username': user.username})
 
             return redirect(user_profile.get_default_link(),
-                            **{'user_slug': user.slug, 'user_id': user.pk})
+                            user_slug=user.slug, user_id=user.pk)
 
     return render(request, 'misago/modusers/ban.html',
                   {'profile': user, 'form': form})
@@ -164,8 +260,7 @@ def lift_user_ban(request, user):
     messages.success(request, message % {'username': user.username})
 
     return redirect(user_profile.get_default_link(),
-                    **{'user_slug': user.slug, 'user_id': user.pk})
-
+                    user_slug=user.slug, user_id=user.pk)
 
 
 @require_POST

+ 42 - 2
misago/users/views/profile.py

@@ -8,6 +8,8 @@ from misago.core.shortcuts import get_object_or_404, paginate, validate_slug
 from misago.users import online
 from misago.users.bans import get_user_ban
 from misago.users.sites import user_profile
+from misago.users.warnings import (get_warning_levels, get_user_warning_level,
+                                   get_user_warning_obj)
 
 
 def profile_view(f):
@@ -76,9 +78,47 @@ def user_threads(request, profile=None, page=0):
 
 
 @profile_view_restricted_visibility
+def warnings(request, profile=None, page=0):
+    warnings_qs = profile.warnings.order_by('-id')
+    warnings = paginate(warnings_qs, page, 5, 2)
+    items_left = warnings.paginator.count - warnings.end_index()
+
+    add_acl(request.user, warnings.object_list)
+
+    warning_level = get_user_warning_level(profile)
+    warning_level_obj = get_user_warning_obj(profile)
+
+    active_warnings = warning_level - warnings.start_index() + 1
+    for warning in warnings.object_list:
+        if warning.canceled:
+            warning.is_active = False
+        else:
+            warning.is_active = active_warnings > 0
+            active_warnings -= 1
+
+    levels_total = len(get_warning_levels()) - 1
+    if levels_total and warning_level:
+        warning_progress = 100 - warning_level * 100 / levels_total
+    else:
+        warning_progress = 100
+
+    if warning_level:
+        warning_level_obj.level = warning_level
+
+    return render(request, 'misago/profile/warnings.html', {
+        'profile': profile,
+        'warnings': warnings,
+        'warning_level': warning_level_obj,
+        'warning_progress': warning_progress,
+        'page_number': warnings.number,
+        'items_left': items_left
+    })
+
+
+@profile_view_restricted_visibility
 def name_history(request, profile=None, page=0):
-    name_changes_sq = profile.namechanges.all().order_by('-id')
-    name_changes = paginate(name_changes_sq, page, 12, 4)
+    name_changes_qs = profile.namechanges.all().order_by('-id')
+    name_changes = paginate(name_changes_qs, page, 12, 4)
     items_left = name_changes.paginator.count - name_changes.end_index()
 
     return render(request, 'misago/profile/name_history.html', {

+ 105 - 0
misago/users/warnings.py

@@ -0,0 +1,105 @@
+from datetime import timedelta
+
+from django.utils import timezone
+
+from misago.users.models import WarningLevel
+
+
+def get_warning_levels():
+    return WarningLevel.objects.dict()
+
+
+def fetch_user_valid_warnings(user):
+    levels = get_warning_levels()
+    max_level = len(levels) - 1
+
+    if not max_level:
+        return []
+
+    # build initial list of valid exceptions
+    queryset = user.warnings.exclude(canceled=True)
+    warnings = [w for w in queryset.order_by('-id')[:max_level]]
+
+    if not warnings:
+        return []
+
+    # expire levels
+    active_warnings = []
+    for length, level in enumerate(levels.values()[1:]):
+        length += 1
+        level_warnings = []
+        if level.length_in_minutes:
+            cutoff_date = timezone.now()
+            cutoff_date -= timedelta(minutes=level.length_in_minutes)
+            for warning in warnings:
+                if warning.given_on >= cutoff_date:
+                    level_warnings.append(warning)
+            if len(level_warnings) == length:
+                active_warnings = level_warnings[:length]
+        else:
+            active_warnings = warnings[:length]
+    return active_warnings
+
+
+
+def update_user_warning_level(user):
+    warnings = fetch_user_valid_warnings(user)
+    user.warning_level = len(warnings)
+
+    levels = get_warning_levels()
+
+    if user.warning_level and levels[user.warning_level].length_in_minutes:
+        level_length = levels[user.warning_level].length_in_minutes
+        next_check_date = warnings[-1].given_on
+        next_check_date += timedelta(minutes=level_length)
+        user.warning_level_update_on = next_check_date
+    else:
+        user.warning_level_update_on = None
+
+    user.save(update_fields=('warning_level', 'warning_level_update_on'))
+
+
+def get_user_warning_level(user):
+    warning_level_expiration = user.warning_level_update_on
+    if warning_level_expiration and warning_level_expiration < timezone.now():
+        update_user_warning_level(user)
+    return user.warning_level
+
+
+def get_user_warning_obj(user):
+    warning_level = get_user_warning_level(user)
+    if warning_level:
+        return get_warning_levels()[warning_level]
+    else:
+        return None
+
+
+def is_user_warning_level_max(user):
+    user_level = get_user_warning_level(user)
+    levels = len(get_warning_levels())
+    return user_level == levels - 1
+
+
+def warn_user(moderator, user, reason=''):
+    warning = user.warnings.create(reason=reason,
+                                   giver=moderator,
+                                   giver_username=moderator.username,
+                                   giver_slug=moderator.slug)
+
+    user.warning_level_update_on = timezone.now()
+    update_user_warning_level(user)
+    return warning
+
+
+def cancel_warning(moderator, user, warning):
+    warning.cancel(moderator)
+
+    user.warning_level_update_on = timezone.now()
+    update_user_warning_level(user)
+
+
+def delete_warning(moderator, user, warning):
+    warning.delete()
+
+    user.warning_level_update_on = timezone.now()
+    update_user_warning_level(user)