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

Followers lists, improved ajax handling

Rafał Pitoń 10 лет назад
Родитель
Сommit
1923a6fc40
34 измененных файлов с 818 добавлено и 157 удалено
  1. 13 0
      misago/acl/migrations/0003_default_roles.py
  2. 3 0
      misago/acl/testutils.py
  3. 1 0
      misago/conf/defaults.py
  4. 24 5
      misago/core/errorpages.py
  5. 43 0
      misago/faker/management/commands/createfakefollowers.py
  6. 6 1
      misago/static/misago/css/misago/buttons.less
  7. 9 0
      misago/static/misago/css/misago/header.less
  8. 1 0
      misago/static/misago/css/misago/misago.less
  9. 18 1
      misago/static/misago/css/misago/navs.less
  10. 0 2
      misago/static/misago/css/misago/panels.less
  11. 63 64
      misago/static/misago/css/misago/profile.less
  12. 60 0
      misago/static/misago/css/misago/userslists.less
  13. 14 0
      misago/static/misago/css/misago/variables.less
  14. 15 0
      misago/static/misago/css/ranks.less
  15. 3 0
      misago/static/misago/css/style.less
  16. 20 0
      misago/static/misago/js/misago-ajax.js
  17. 5 0
      misago/templates/misago/base.html
  18. 2 2
      misago/templates/misago/modusers/mod_options.html
  19. 37 31
      misago/templates/misago/profile/base.html
  20. 22 0
      misago/templates/misago/profile/followers.html
  21. 22 0
      misago/templates/misago/profile/follows.html
  22. 53 0
      misago/templates/misago/profile/header.html
  23. 12 28
      misago/templates/misago/profile/side.html
  24. 1 0
      misago/templates/misago/profile/warnings.html
  25. 23 0
      misago/templates/misago/users_cards.html
  26. 10 0
      misago/users/apps.py
  27. 12 0
      misago/users/migrations/0001_initial.py
  28. 23 0
      misago/users/models/user.py
  29. 22 0
      misago/users/permissions/decorators.py
  30. 86 11
      misago/users/permissions/profiles.py
  31. 13 3
      misago/users/permissions/warnings.py
  32. 44 0
      misago/users/tests/test_profile_views.py
  33. 9 2
      misago/users/urls.py
  34. 129 7
      misago/users/views/profile.py

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

@@ -29,6 +29,8 @@ def create_default_roles(apps, schema_editor):
             # profiles perms
             'misago.users.permissions.profiles': {
                 'can_search_users': 1,
+                'can_follow_users': 1,
+                'can_be_blocked': 1,
                 'can_see_users_name_history': 0,
                 'can_see_users_emails': 0,
                 'can_see_users_ips': 0,
@@ -87,6 +89,7 @@ def create_default_roles(apps, schema_editor):
             # profiles perms
             'misago.users.permissions.profiles': {
                 'can_search_users': 1,
+                'can_be_blocked': 0,
                 'can_see_users_name_history': 1,
                 'can_see_ban_details': 1,
                 'can_see_users_emails': 1,
@@ -165,6 +168,16 @@ def create_default_roles(apps, schema_editor):
         })
     role.save()
 
+    role = Role(name=_("Can't be blocked"))
+    pickle_permissions(role,
+        {
+            # profiles perms
+            'misago.users.permissions.profiles': {
+                'can_be_blocked': 0,
+            },
+        })
+    role.save()
+
 
 class Migration(migrations.Migration):
 

+ 3 - 0
misago/acl/testutils.py

@@ -37,3 +37,6 @@ def override_acl(user, new_acl):
     user.roles.add(test_role)
     user.acl_key = md5(unicode(time())).hexdigest()[:8]
     user.save()
+
+    threadstore.clear()
+    cache.clear()

+ 1 - 0
misago/conf/defaults.py

@@ -56,6 +56,7 @@ PIPELINE_JS = {
             'misago/js/bootstrap.js',
             'misago/js/moment.min.js',
             'misago/js/misago-alerts.js',
+            'misago/js/misago-ajax.js',
             'misago/js/misago-timestamps.js',
             'misago/js/misago-tooltips.js',
             'misago/js/misago-yesnoswitch.js',

+ 24 - 5
misago/core/errorpages.py

@@ -1,8 +1,18 @@
+from django.http import JsonResponse
 from django.shortcuts import render
+from django.utils.translation import ugettext as _
+
 from misago.core.utils import is_request_to_misago
 from misago.admin.views.errorpages import admin_error_page, admin_csrf_failure
 
 
+def _ajax_error(code=406, message=None):
+    response_dict = {'is_error': True}
+    if message:
+        response_dict['message'] = message
+    return JsonResponse(response_dict, status=code)
+
+
 @admin_error_page
 def _error_page(request, code, message=None):
     response = render(request,
@@ -13,18 +23,27 @@ def _error_page(request, code, message=None):
 
 
 def permission_denied(request, message=None):
-    return _error_page(request, 403, message)
+    if request.is_ajax():
+        return _ajax_error(403, message)
+    else:
+        return _error_page(request, 403, message)
 
 
 def page_not_found(request):
-    return _error_page(request, 404)
+    if request.is_ajax():
+        return _ajax_error(404, _("Invalid API link."))
+    else:
+        return _error_page(request, 404)
 
 
 @admin_csrf_failure
 def csrf_failure(request, reason=""):
-    response = render(request, 'misago/errorpages/csrf_failure.html')
-    response.status_code = 403
-    return response
+    if request.is_ajax():
+        return _ajax_error(403, _("Request authentication is invalid."))
+    else:
+        response = render(request, 'misago/errorpages/csrf_failure.html')
+        response.status_code = 403
+        return response
 
 
 # Decorators for custom error page handlers

+ 43 - 0
misago/faker/management/commands/createfakefollowers.py

@@ -0,0 +1,43 @@
+import random
+
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ValidationError
+from django.core.management.base import BaseCommand
+from django.db import IntegrityError
+
+from misago.core.management.progressbar import show_progress
+from misago.users.models import Rank
+
+
+class Command(BaseCommand):
+    help = 'Adds random followers for testing purposes'
+
+    def handle(self, *args, **options):
+        User = get_user_model()
+        total_users = User.objects.count()
+
+        message = 'Adding fake followers to %s users...\n'
+        self.stdout.write(message % total_users)
+
+        message = '\n\nSuccessfully added %s fake users'
+
+        total_followers = 0
+        processed_count = 0
+
+        show_progress(self, processed_count, total_users)
+        for user in User.objects.iterator():
+            user.followed_by.clear()
+
+            users_to_add = random.randint(1, total_users - 1)
+            random_queryset =  User.objects.exclude(id=user.id).order_by('?')
+            while users_to_add > 0:
+                new_follower = random_queryset[:1][0]
+                if not new_follower.is_following(user):
+                    user.followed_by.add(new_follower)
+                    users_to_add -= 1
+                    total_followers += 1
+
+            processed_count += 1
+            show_progress(self, processed_count, total_users)
+
+        self.stdout.write(message % total_followers)

+ 6 - 1
misago/static/misago/css/misago/buttons.less

@@ -7,11 +7,11 @@
   background: darken(@btn-bg, 15%);
   border-color: darken(@btn-bg, 15%);
   box-shadow: none;
+  outline: none;
   position: relative;
   top: 2px;
 
   color: darken(@btn-color, 10%);
-  outline: none;
 }
 
 
@@ -26,10 +26,15 @@
     background: darken(@btn-bg, 10%);
     border-color: darken(@btn-bg, 10%);
     box-shadow: 0px 2px 0px darken(@btn-border, 10%);
+    transition-duration: 0.5s;
 
     color: @btn-color;
   }
 
+  &:active {
+    transition-duration: 0.05s;
+  }
+
   &.active, &:active {
     .misago-button-active-flavour(@btn-color, @btn-bg, @btn-border);
   }

+ 9 - 0
misago/static/misago/css/misago/header.less

@@ -98,6 +98,10 @@
     .page-actions {
       float: right;
 
+      .pull-left {
+        margin-left: @line-height-computed / 2;
+      }
+
       &.path-fix {
         margin: -2px 0px;
         margin-bottom: -3px;
@@ -128,6 +132,11 @@
         clear: both;
         float: none;
         margin-top: @line-height-computed;
+
+        .pull-left {
+          margin-left: 0px;
+          margin-right: @line-height-computed / 2;
+        }
       }
     }
   }

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

@@ -21,6 +21,7 @@
 @import "footer.less";
 
 @import "forms.less";
+@import "userslists.less";
 
 // Pages
 @import "errorpages.less";

+ 18 - 1
misago/static/misago/css/misago/navs.less

@@ -146,6 +146,23 @@
         }
       }
     }
+
+    .badge {
+      float: right;
+    }
+  }
+
+  &.large {
+    a {
+      font-size: @font-size-large;
+    }
+
+    .badge {
+      position: relative;
+      top: 2px;
+
+      font-size: @font-size-base;
+    }
   }
 }
 
@@ -193,7 +210,7 @@
 
 
 /* Label */
-.nav-pills, .nav-tabs {
+.nav-pills, .nav-tabs, .nav-side {
   &>li {
     .label {
       padding: 2px 3px;

+ 0 - 2
misago/static/misago/css/misago/panels.less

@@ -102,7 +102,6 @@
           &:hover {
             background-color: darken(@panel-footer-bg, 5%);
             transition-timing-function: ease;
-            transition-duration: 0.5s;
 
             color: @state-hover;
           }
@@ -110,7 +109,6 @@
           &:active {
             background-color: darken(@panel-footer-bg, 5%);
             outline: none;
-            transition-duration: 0.07s;
 
             color: @state-clicked;
           }

+ 63 - 64
misago/static/misago/css/misago/profile.less

@@ -6,33 +6,29 @@
 // Profile header
 //
 //==
-.user-profile-header {
-  padding: 0px;
-  padding-top: @line-height-computed;
-  border-bottom-width: 3px;
-
-  .nav-tabs {
-    border-bottom: none;
-
-    &>li{
-      &>a {
-        .label {
-          background-color: darken(@body-bg, 5%);
-          padding-left: 5px;
-          padding-right: 5px;
-          position: relative;
-          top: -1px;
-
-          color: fadeOut(@text-color, 60%);
-          font-size: @font-size-small;
+.user-profile {
+  .profile-header {
+    padding: 0px;
+    padding-top: @line-height-computed;
+    border-bottom-width: 1px;
+
+    .page-actions {
+      .btn {
+        .btn-sm();
+
+        &.btn-follow {
+          color: @brand-follow;
+
+          &.active {
+            .misago-button-active-flavour(#fff, @brand-follow, darken(@brand-follow, 13%));
+          }
         }
-      }
 
-      &.active {
-        &>a {
-          .label {
-            background-color: fadeOut(@text-color, 40%);
-            color: @body-bg;
+        &.btn-ban {
+          color: @brand-danger;
+
+          &.active {
+            .misago-button-active-flavour(#fff, @brand-danger, darken(@brand-danger, 13%));
           }
         }
       }
@@ -41,7 +37,7 @@
 }
 
 
-// Side avatar
+// Avatar
 //
 //==
 .user-profile {
@@ -51,54 +47,69 @@
       border-radius: @border-radius-large;
       .box-shadow(0px 0px 0px 2px darken(@body-bg, 5%));
       padding: 4px;
-      margin-top: (@line-height-computed * 3.5) * -1;
+      margin-top: @line-height-computed;
+      margin-bottom: @line-height-computed;
     }
   }
 }
 
 
-// Side username
-//
-//==
-.user-profile {
-  .profile-side {
-    .user-name {
-      margin-bottom: @line-height-computed;
+/* Big displays */
+@media (min-width: @screen-sm-min) {
+  .user-profile {
+    .profile-side {
+      .user-avatar {
+        margin-top: (@line-height-computed * 4.75) * -1;
+      }
+    }
+  }
+}
 
-      font-size: @font-size-base * 1.8;
 
-      .user-title {
-        display: block;
-        clear: both;
+// Username
+//
+//==
+/* Big displays */
+@media (min-width: @screen-sm-min) {
+  .user-profile {
+    .profile-header {
+      .user-name {
+        float: left;
       }
     }
   }
 }
 
 
-// Side details
+// Details
 //
 //==
 .user-profile {
-  .profile-side {
+  .profile-header {
     .user-details {
-      li {
-        padding-bottom: @line-height-computed / 3;
+      padding-top: @line-height-computed / 3;
+      clear: both;
+
+      ul {
+        overflow: auto;
 
-        color: fadeOut(@text-color, 40%);
-        font-size: @font-size-large;
+        li {
+          margin-right: @line-height-computed;
 
-        a:link, a:visited {
           color: fadeOut(@text-color, 40%);
-        }
 
-        a:hover {
-          color: fadeOut(@text-color, 15%);
-          text-decoration: none;
-        }
+          a:link, a:visited {
+            color: fadeOut(@text-color, 40%);
+          }
+
+          a:hover {
+            color: @state-hover;
+            text-decoration: none;
+          }
 
-        a:active {
-          color: @text-color;
+          a:active {
+            color: @state-clicked;
+          }
         }
       }
     }
@@ -106,18 +117,6 @@
 }
 
 
-// Flavours
-//
-//==
-.user-profile {
-  &.profile-team {
-    .user-title {
-      color: @brand-accent;
-    }
-  }
-}
-
-
 // Username history
 //
 //==

+ 60 - 0
misago/static/misago/css/misago/userslists.less

@@ -0,0 +1,60 @@
+//
+// Users Lists
+// --------------------------------------------------
+
+
+// Fancy Web 9000.0 users cards list
+//
+//==
+.users-cards {
+  margin-bottom: @line-height-computed * -1;
+
+  .row {
+    margin-bottom: @line-height-computed;
+
+    .user-card {
+      background: @user-card-bg;
+      border-radius: @border-radius-large;
+      .box-shadow(@user-card-shadow 0px 0px 0px 2px);
+      display: block;
+      overflow: hidden;
+      padding: 3px;
+      position: relative;
+
+      color: @text-color;
+
+      img {
+        border-radius: @border-radius-base;
+        width: 100%;
+      }
+
+      .card-footer {
+        background: fadeOut(@user-card-bg, 15%);
+        position: absolute;
+        bottom: 0px;
+        padding: @padding-base-vertical @padding-base-horizontal;
+        width: 100%;
+
+        small {
+          display: block;
+          margin-top: @line-height-computed / -3;
+          margin-bottom: @line-height-computed / 3;
+
+          color: @text-color;
+          font-size: @font-size-base;
+        }
+      }
+    }
+  }
+
+  a.user-card {
+    &:hover {
+      .box-shadow(@user-card-hover-shadow 0px 0px 0px 2px);
+
+    }
+
+    &:active {
+      .box-shadow(@user-card-active-shadow 0px 0px 0px 2px);
+    }
+  }
+}

+ 14 - 0
misago/static/misago/css/misago/variables.less

@@ -17,6 +17,9 @@
 @brand-warning:         #f0ad4e;
 @brand-danger:          #e74c3c;
 
+@brand-follow:          #EB5BCC;
+@brand-block:           @brand-danger;
+
 
 // Background color for `<body>`.
 @body-bg:               #fff;
@@ -278,6 +281,17 @@
 
 @panel-default-border:        darken(@body-bg, 15%);
 
+//** User cards
+@user-card-bg:                         @body-bg;
+@user-card-border:                     darken(@body-bg, 15%);
+@user-card-shadow:                     darken(@body-bg, 15%);
+
+@user-card-hover-border:               fadeOut(@state-hover, 50%);
+@user-card-hover-shadow:               fadeOut(@state-hover, 50%);
+
+@user-card-active-border:              @state-clicked;
+@user-card-active-shadow:              @state-clicked;
+
 
 //== Form states and alerts
 //

+ 15 - 0
misago/static/misago/css/ranks.less

@@ -0,0 +1,15 @@
+//
+// Ranks flavours
+// --------------------------------------------------
+
+
+// Forum team
+//
+//==
+.user-profile {
+  &.profile-team {
+    .user-title {
+      color: @brand-accent;
+    }
+  }
+}

+ 3 - 0
misago/static/misago/css/style.less

@@ -20,3 +20,6 @@
 
 // Bootstrap 3rd party libs
 @import "bootstrap-datetimepicker.less";
+
+// Rank overrides
+@import "ranks.less";

+ 20 - 0
misago/static/misago/js/misago-ajax.js

@@ -0,0 +1,20 @@
+// Misago ajax error handler
+$(function() {
+  function handleAjaxError(event, jqxhr, settings, thrownError) {
+    console.log(jqxhr);
+    if (jqxhr.responseJSON) {
+      var error_message = jqxhr.responseJSON.message
+    } else if (thrownError == "NOT FOUND") {
+      var error_message = ajax_errors.not_found;
+    } else if (thrownError == "timeout") {
+      var error_message = ajax_errors.timeout;
+    } else {
+      var error_message = ajax_errors.generic;
+    }
+    console.log(thrownError);
+    if (thrownError != "") {
+      $.misago_alerts().error(error_message);
+    }
+  }
+  $(document).ajaxError(handleAjaxError);
+});

+ 5 - 0
misago/templates/misago/base.html

@@ -33,6 +33,11 @@
     <script lang="JavaScript">
       var lang_yes = "{% trans "Yes" %}";
       var lang_no = "{% trans "No" %}";
+      var ajax_errors = {
+        generic: "{% trans "Server returned unspecified error. Refresh page and try again." %}",
+        not_found: "{% trans "API link is invalid." %}",
+        timeout: "{% trans "Request has timed out." %}"
+      };
     </script>
     {% compressed_js 'misago' %}
     <script lang="JavaScript">

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

@@ -1,7 +1,7 @@
 {% load i18n %}
 
-{% if profile.acl_.can_moderate %}
-<div class="btn-group">
+{% if user.is_authenticated and profile.acl_.can_moderate %}
+<div class="btn-group pull-left">
   <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
     {% trans "Moderation" %} <span class="glyphicon glyphicon-chevron-down"></span>
   </button>

+ 37 - 31
misago/templates/misago/profile/base.html

@@ -8,44 +8,31 @@
 
 
 {% block content %}
-<div class="page-header user-profile-header">
-  <div class="container">
-    <div class="row">
-      <div class="col-md-9 col-md-offset-3">
-
-        <ul class="nav nav-tabs">
-          {% for page in pages %}
-          <li{% if page.is_active %} class="active"{% endif %}>
-            <a href="{% url page.link user_slug=profile.slug user_id=profile.pk %}">
-              {{ page.name }}
-              {% if page.badge != None %}
-              <span class="label label-default">{{ page.badge }}</span>
-              {% endif %}
-            </a>
-          </li>
-          {% endfor %}
-        </ul>
-
-        {% if user.is_authenticated %}
-        <div class="page-actions middle-fix">
-          {% include "misago/modusers/mod_options.html" %}
-        </div>
-        {% endif %}
+<div class="user-profile {% if profile.rank.css_class %}profile-{{ profile.rank.css_class }}{% endif %}">
+  <div class="page-header profile-header">
+    <div class="container">
+      <div class="row">
+        <div class="col-md-9 col-md-offset-3">
+
+          {% include "misago/profile/header.html" %}
 
+        </div>
       </div>
     </div>
   </div>
-</div>
 
-<div class="container user-profile {% if profile.rank.css_class %}profile-{{ profile.rank.css_class }}{% endif %}">
-  <div class="row">
-    <div class="col-md-3 profile-side">
+  <div class="container">
+    <div class="row">
+      <div class="col-md-3 profile-side">
 
-      {% include "misago/profile/side.html" %}
+        {% include "misago/profile/side.html" %}
 
-    </div>
-    <div class="col-md-9">
-      {% block page %}{% endblock page %}
+      </div>
+      <div class="col-md-9">
+
+        {% block page %}{% endblock page %}
+
+      </div>
     </div>
   </div>
 </div>
@@ -53,4 +40,23 @@
 
 {% block javascripts %}
 {% include "misago/modusers/mod_js.html" %}
+{% if profile.acl_.can_have_attitude %}
+<script type="text/javascript">
+  $(function() {
+    $('.dynamic-button').submit(function() {
+      var $form = $(this);
+      var $button = $form.find('button');
+      $.post($form.attr('action'), $form.serialize(), function(data) {
+        $.misago_alerts().success(data.message);
+        if (data.is_following) {
+          $button.addClass('active');
+        } else {
+          $button.removeClass('active');
+        }
+      });
+      return false;
+    });
+  });
+</script>
+{% endif %}
 {% endblock javascripts %}

+ 22 - 0
misago/templates/misago/profile/followers.html

@@ -0,0 +1,22 @@
+{% extends "misago/profile/base.html" %}
+{% load i18n misago_avatars misago_pagination %}
+
+
+{% block page %}
+{% if followers.object_list %}
+  {% include "misago/users_cards.html" with cards=followers.object_list %}
+  {% pagination followers "misago/profile/pagination.html" 'misago:user_followers' user_slug=profile.slug user_id=profile.pk %}
+{% else %}
+<p class="lead">
+  {% if is_authenticated_user %}
+    {% blocktrans trimmed with user=profile.username %}
+    No users are following you, {{ user }}.
+    {% endblocktrans %}
+  {% else %}
+    {% blocktrans trimmed with user=profile.username %}
+    {{ user }} has no followers.
+    {% endblocktrans %}
+  {% endif %}
+</p>
+{% endif %}
+{% endblock page %}

+ 22 - 0
misago/templates/misago/profile/follows.html

@@ -0,0 +1,22 @@
+{% extends "misago/profile/base.html" %}
+{% load i18n misago_avatars misago_pagination %}
+
+
+{% block page %}
+{% if followers.object_list %}
+  {% include "misago/users_cards.html" with cards=followers.object_list %}
+  {% pagination followers "misago/profile/pagination.html" 'misago:user_follows' user_slug=profile.slug user_id=profile.pk %}
+{% else %}
+<p class="lead">
+  {% if is_authenticated_user %}
+    {% blocktrans trimmed with user=profile.username %}
+    Your are not following any users, {{ user }}.
+    {% endblocktrans %}
+  {% else %}
+    {% blocktrans trimmed with user=profile.username %}
+    {{ user }} follows no users.
+    {% endblocktrans %}
+  {% endif %}
+</p>
+{% endif %}
+{% endblock page %}

+ 53 - 0
misago/templates/misago/profile/header.html

@@ -0,0 +1,53 @@
+{% load i18n %}
+<h1 class="user-name">
+  {{ profile.username }}
+  {% if profile.full_title %}
+  <small class="user-title">{{ profile.full_title }}</small>
+  {% endif %}
+</h1>
+
+{% if user.is_authenticated %}
+<div class="page-actions">
+  {% if profile.acl_.can_follow %}
+  <form action="{% url 'misago:follow_user' user_slug=profile.slug user_id=profile.id %}" method="POST" class="pull-left dynamic-button">
+    {% csrf_token %}
+    <button class="btn btn-default btn-follow {% if profile.is_followed %}active{% endif %}">
+      <span class="fa fa-heart"></span>
+      {% trans "Follow" %}
+    </button>
+  </form>
+  {% endif %}
+  {% if profile.acl_.can_block %}
+  <form action="{% url 'misago:block_user' user_slug=profile.slug user_id=profile.id %}" method="POST" class="pull-left dynamic-button">
+    {% csrf_token %}
+    <button class="btn btn-default btn-ban {% if profile.is_blocked %}active{% endif %}">
+      <span class="fa fa-ban"></span>
+      {% trans "Block" %}
+    </button>
+  </form>
+  {% endif %}
+  {% include "misago/modusers/mod_options.html" %}
+</div>
+{% endif %}
+
+<div class="user-details">
+  <ul class="list-unstyled">
+    <li class="user-active pull-left">
+      {% include "misago/profile/state.html" %}
+    </li>
+    <li class="user-joined-on pull-left">
+      <span class="tooltip-top" title="{% trans "Joined on" %}">
+        <span class="fa fa-clock-o fa-fw"></span>
+        {{ profile.joined_on|date }}
+      </span>
+    </li>
+    {% if show_email %}
+    <li class="user-email pull-left">
+      <a href="mailto:{{ profile.email }}" class="tooltip-top"title="{% trans "E-mail address" %}">
+        <span class="fa fa-envelope-o fa-fw"></span>
+        {{ profile.email }}
+      </a>
+    </li>
+    {% endif %}
+  </ul>
+</div>

+ 12 - 28
misago/templates/misago/profile/side.html

@@ -10,31 +10,15 @@
   {% endif %}
 </div>
 
-<h1 class="user-name">
-  {{ profile.username }}
-  {% if profile.full_title %}
-  <small class="user-title">{{ profile.full_title }}</small>
-  {% endif %}
-</h1>
-
-<div class="user-details">
-  <ul class="list-unstyled">
-    <li class="user-active">
-      {% include "misago/profile/state.html" %}
-    </li>
-    <li class="user-joined-on">
-      <span class="tooltip-top" title="{% trans "Joined on" %}">
-        <span class="fa fa-clock-o fa-fw"></span>
-        {{ profile.joined_on|date }}
-      </span>
-    </li>
-    {% if show_email %}
-    <li class="user-email">
-      <a href="mailto:{{ profile.email }}" class="tooltip-top"title="{% trans "E-mail address" %}">
-        <span class="fa fa-envelope-o fa-fw"></span>
-        {{ profile.email }}
-      </a>
-    </li>
-    {% endif %}
-  </ul>
-</div>
+<ul class="nav nav-side large">
+  {% for page in pages %}
+  <li{% if page.is_active %} class="active"{% endif %}>
+    <a href="{% url page.link user_slug=profile.slug user_id=profile.pk %}">
+      {{ page.name }}
+      {% if page.badge != None %}
+      <span class="badge">{{ page.badge }}</span>
+      {% endif %}
+    </a>
+  </li>
+  {% endfor %}
+</ul>

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

@@ -182,6 +182,7 @@
 
 
 {% block javascripts %}
+{{ block.super }}
 <script type="text/javascript">
   $(function() {
     {% if user.is_authenticated and user.acl.can_cancel_warnings %}

+ 23 - 0
misago/templates/misago/users_cards.html

@@ -0,0 +1,23 @@
+{% load i18n misago_avatars misago_batch %}
+
+<div class="users-cards">
+  {% for row in cards|batch:4 %}
+  <div class="row">
+    {% for card in row %}
+    <div class="col-md-3">
+
+      <a href="{% url USER_PROFILE_URL user_slug=card.slug user_id=card.id %}" class="user-card {% if card.rank.css_class %}card-{{ card.rank.css_class }}{% endif %}">
+        <img src="{{ card|avatar:400 }}" alt="{% trans "Avatar" %}">
+        <div class="card-footer">
+          <h4 class="user-name">{{ card.username }}</h4>
+          {% if card.full_title %}
+          <small class="user-title">{{ card.full_title }}</small>
+          {% endif %}
+        </div>
+      </a>
+
+    </div>
+    {% endfor %}
+  </div>
+  {% endfor %}
+</div>

+ 10 - 0
misago/users/apps.py

@@ -44,6 +44,10 @@ class MisagoUsersConfig(AppConfig):
             return profile.posts
         def threads_badge(request, profile):
             return profile.threads
+        def followers_badge(request, profile):
+            return profile.followers
+        def following_badge(request, profile):
+            return profile.following
         def can_see_names_history(request, profile):
             if request.user.is_authenticated():
                 is_account_owner = profile.pk == request.user.pk
@@ -75,6 +79,12 @@ class MisagoUsersConfig(AppConfig):
         user_profile.add_page(link='misago:user_threads',
                               name=_("Threads"),
                               badge=threads_badge)
+        user_profile.add_page(link='misago:user_followers',
+                              name=_("Followers"),
+                              badge=followers_badge)
+        user_profile.add_page(link='misago:user_follows',
+                              name=_("Follows"),
+                              badge=following_badge)
         user_profile.add_page(link='misago:user_name_history',
                               name=_("Name history"),
                               visible_if=can_see_names_history)

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

@@ -134,6 +134,18 @@ class Migration(migrations.Migration):
             field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to_field='id', blank=True, to='misago_users.Rank', null=True),
             preserve_default=True,
         ),
+        migrations.AddField(
+            model_name='user',
+            name='follows',
+            field=models.ManyToManyField(to=settings.AUTH_USER_MODEL),
+            preserve_default=True,
+        ),
+        migrations.AddField(
+            model_name='user',
+            name='blocks',
+            field=models.ManyToManyField(to=settings.AUTH_USER_MODEL),
+            preserve_default=True,
+        ),
         migrations.CreateModel(
             name='Ban',
             fields=[

+ 23 - 0
misago/users/models/user.py

@@ -193,6 +193,11 @@ class User(AbstractBaseUser, PermissionsMixin):
     following = models.PositiveIntegerField(default=0)
     followers = models.PositiveIntegerField(default=0)
 
+    follows = models.ManyToManyField(
+        'self', related_name='followed_by', symmetrical=False)
+    blocks = models.ManyToManyField(
+        'self', related_name='blocked_by', symmetrical=False)
+
     new_alerts = models.PositiveIntegerField(default=0)
 
     limit_private_thread_invites = models.PositiveIntegerField(default=0)
@@ -217,6 +222,10 @@ class User(AbstractBaseUser, PermissionsMixin):
 
     objects = UserManager()
 
+    def lock(self):
+        """Locks user in DB"""
+        return User.objects.select_for_update().get(id=self.id)
+
     def delete(self, *args, **kwargs):
         if kwargs.pop('delete_content', False):
             self.delete_content()
@@ -348,6 +357,20 @@ class User(AbstractBaseUser, PermissionsMixin):
         """
         send_mail(subject, message, from_email, [self.email], **kwargs)
 
+    def is_following(self, user):
+        try:
+            self.follows.get(id=user.pk)
+            return True
+        except User.DoesNotExist:
+            return False
+
+    def is_blocking(self, user):
+        try:
+            self.blocks.get(id=user.pk)
+            return True
+        except User.DoesNotExist:
+            return False
+
 
 class Online(models.Model):
     user = models.OneToOneField(settings.AUTH_USER_MODEL, primary_key=True,

+ 22 - 0
misago/users/permissions/decorators.py

@@ -0,0 +1,22 @@
+from django.core.exceptions import PermissionDenied
+from django.utils.translation import ugettext_lazy as _
+
+
+def authenticated_only(f):
+    def perm_decorator(user, target):
+        if user.is_authenticated():
+            return f(user, target)
+        else:
+            messsage = _("You have to sig in to perform this action.")
+            raise PermissionDenied(messsage)
+    return perm_decorator
+
+
+def anonymous_only(f):
+    def perm_decorator(user, target):
+        if user.is_anonymous():
+            return f(user, target)
+        else:
+            messsage = _("Only guests can perform this action.")
+            raise PermissionDenied(messsage)
+    return perm_decorator

+ 86 - 11
misago/users/permissions/profiles.py

@@ -1,25 +1,46 @@
+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 require_target_type, return_boolean
 from misago.acl.models import Role
 from misago.core import forms
 
+from misago.users.permissions.decorators import authenticated_only
+
 
 """
 Admin Permissions Form
 """
-class PermissionsForm(forms.Form):
+CAN_SEARCH_USERS = forms.YesNoSwitch(
+    label=_("Can search user profiles"),
+    initial=1)
+CAN_SEE_USER_NAME_HISTORY = forms.YesNoSwitch(
+    label=_("Can see other members name history"))
+CAN_SEE_BAN_DETAILS = forms.YesNoSwitch(
+    label=_("Can see members bans details"),
+    help_text=_("Allows users with this permission to see user and "
+                "staff ban messages."))
+
+
+class LimitedPermissionsForm(forms.Form):
     legend = _("User profiles")
+    can_search_users = CAN_SEARCH_USERS
+    can_see_users_name_history = CAN_SEE_USER_NAME_HISTORY
+    can_see_ban_details = CAN_SEE_BAN_DETAILS
 
-    can_search_users = forms.YesNoSwitch(
-        label=_("Can search user profiles"),
+
+class PermissionsForm(LimitedPermissionsForm):
+    can_search_users = CAN_SEARCH_USERS
+    can_follow_users = forms.YesNoSwitch(
+        label=_("Can follow other users"),
         initial=1)
-    can_see_users_name_history = forms.YesNoSwitch(
-        label=_("Can see other members name history"))
-    can_see_ban_details = forms.YesNoSwitch(
-        label=_("Can see members bans details"),
-        help_text=_("Allows users with this permission to see user and "
-                    "staff ban messages."))
+    can_be_blocked = forms.YesNoSwitch(
+        label=_("Can be blocked by other users"),
+        initial=0)
+    can_see_users_name_history = CAN_SEE_USER_NAME_HISTORY
+    can_see_ban_details = CAN_SEE_BAN_DETAILS
     can_see_users_emails = forms.YesNoSwitch(
         label=_("Can see members e-mails"))
     can_see_users_ips = forms.YesNoSwitch(
@@ -30,7 +51,10 @@ class PermissionsForm(forms.Form):
 
 def change_permissions_form(role):
     if isinstance(role, Role):
-        return PermissionsForm
+        if role.special_role == 'anonymous':
+            return LimitedPermissionsForm
+        else:
+            return PermissionsForm
     else:
         return None
 
@@ -41,6 +65,8 @@ ACL Builder
 def build_acl(acl, roles, key_name):
     new_acl = {
         'can_search_users': 0,
+        'can_follow_users': 1,
+        'can_be_blocked': 1,
         'can_see_users_name_history': 0,
         'can_see_ban_details': 0,
         'can_see_users_emails': 0,
@@ -51,10 +77,59 @@ def build_acl(acl, roles, key_name):
 
     return algebra.sum_acls(
             new_acl, roles=roles, key=key_name,
+            can_search_users=algebra.greater,
+            can_follow_users=algebra.greater,
+            can_be_blocked=algebra.lower,
             can_see_users_name_history=algebra.greater,
             can_see_ban_details=algebra.greater,
-            can_search_users=algebra.greater,
             can_see_users_emails=algebra.greater,
             can_see_users_ips=algebra.greater,
             can_see_hidden_users=algebra.greater
             )
+
+
+"""
+ACL's for targets
+"""
+@require_target_type(get_user_model())
+def add_acl_to_target(user, target):
+    target_acl = target.acl_
+
+    target_acl['can_have_attitude'] = False
+    target_acl['can_follow'] = can_follow_user(user, target)
+    target_acl['can_block'] = can_block_user(user, target)
+
+    mod_permissions = (
+        'can_have_attitude',
+        'can_follow',
+        'can_block',
+    )
+
+    for permission in mod_permissions:
+        if target_acl[permission]:
+            target_acl['can_have_attitude'] = True
+            break
+
+
+"""
+ACL tests
+"""
+@authenticated_only
+def allow_follow_user(user, target):
+    if not user.acl['can_follow_users']:
+        raise PermissionDenied(_("You can't follow other users."))
+    if user.pk == target.pk:
+        raise PermissionDenied(_("You can't add yourself to followed."))
+can_follow_user = return_boolean(allow_follow_user)
+
+
+@authenticated_only
+def allow_block_user(user, target):
+    if target.is_staff or target.is_superuser:
+        raise PermissionDenied(_("You can't block administrators."))
+    if user.pk == target.pk:
+        raise PermissionDenied(_("You can't block yourself."))
+    if not target.acl['can_be_blocked'] or target.is_superuser:
+        message = _("%(user)s can't be blocked.") % {'user': target.username}
+        raise PermissionDenied(message)
+can_block_user = return_boolean(allow_block_user)

+ 13 - 3
misago/users/permissions/warnings.py

@@ -8,6 +8,7 @@ from misago.acl.models import Role
 from misago.core import forms
 
 from misago.users.models import UserWarning
+from misago.users.permissions.decorators import authenticated_only
 
 
 """
@@ -16,11 +17,14 @@ Admin Permissions Form
 NO_OWNED_ALL = ((0, _("No")), (1, _("Owned")), (2, _("All")))
 
 
-class PermissionsForm(forms.Form):
+class LimitedPermissionsForm(forms.Form):
     legend = _("Warnings")
 
     can_see_other_users_warnings = forms.YesNoSwitch(
         label=_("Can see other users warnings"))
+
+
+class PermissionsForm(LimitedPermissionsForm):
     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(
@@ -36,8 +40,11 @@ class PermissionsForm(forms.Form):
 
 
 def change_permissions_form(role):
-    if isinstance(role, Role) and role.special_role != 'anonymous':
-        return PermissionsForm
+    if isinstance(role, Role):
+        if role.special_role == 'anonymous':
+            return LimitedPermissionsForm
+        else:
+            return PermissionsForm
     else:
         return None
 
@@ -110,6 +117,7 @@ def allow_see_warnings(user, target):
 can_see_warnings = return_boolean(allow_see_warnings)
 
 
+@authenticated_only
 def allow_warn_user(user, target):
     if not user.acl['can_warn_users']:
         raise PermissionDenied(_("You can't warn users."))
@@ -121,6 +129,7 @@ def allow_warn_user(user, target):
 can_warn_user = return_boolean(allow_warn_user)
 
 
+@authenticated_only
 def allow_cancel_warning(user, target):
     if user.is_anonymous() or not user.acl['can_cancel_warnings']:
         raise PermissionDenied(_("You can't cancel warnings."))
@@ -133,6 +142,7 @@ def allow_cancel_warning(user, target):
 can_cancel_warning = return_boolean(allow_cancel_warning)
 
 
+@authenticated_only
 def allow_delete_warning(user, target):
     if user.is_anonymous() or not user.acl['can_delete_warnings']:
         raise PermissionDenied(_("You can't delete warnings."))

+ 44 - 0
misago/users/tests/test_profile_views.py

@@ -39,6 +39,50 @@ class UserProfileViewsTests(AdminTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertIn('started no threads', response.content)
 
+    def test_user_followers(self):
+        """user profile followers list has no showstoppers"""
+        User = get_user_model()
+
+        response = self.client.get(reverse('misago:user_followers',
+                                           kwargs=self.link_kwargs))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('No users are following you', response.content)
+
+        followers = []
+        for i in xrange(10):
+            user_data = ("Follower%s" % i, "foll%s@test.com" % i, "Pass.123")
+            followers.append(User.objects.create_user(*user_data))
+            self.test_admin.followed_by.add(followers[-1])
+
+        response = self.client.get(reverse('misago:user_followers',
+                                           kwargs=self.link_kwargs))
+        self.assertEqual(response.status_code, 200)
+        for i in xrange(10):
+            self.assertIn("Follower%s" % i, response.content)
+
+    def test_user_follows(self):
+        """user profile follows list has no showstoppers"""
+        User = get_user_model()
+
+        response = self.client.get(reverse('misago:user_follows',
+                                           kwargs=self.link_kwargs))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Your are not following any users', response.content)
+
+        followers = []
+        for i in xrange(10):
+            user_data = ("Follower%s" % i, "foll%s@test.com" % i, "Pass.123")
+            followers.append(User.objects.create_user(*user_data))
+            followers[-1].followed_by.add(self.test_admin)
+
+        response = self.client.get(reverse('misago:user_follows',
+                                           kwargs=self.link_kwargs))
+        self.assertEqual(response.status_code, 200)
+        for i in xrange(10):
+            self.assertIn("Follower%s" % i, response.content)
+
     def test_user_name_history_list(self):
         """user name changes history list has no showstoppers"""
         response = self.client.get(reverse('misago:user_name_history',

+ 9 - 2
misago/users/urls.py

@@ -54,13 +54,20 @@ urlpatterns += patterns('misago.users.views.usercp',
 
 urlpatterns += patterns('',
     url(r'^user/(?P<user_slug>[a-zA-Z0-9]+)-(?P<user_id>\d+)/', include(patterns('misago.users.views.profile',
-        url(r'^$', 'user_posts', name="user_posts"),
-        url(r'^threads/$', 'user_threads', name="user_threads"),
+        url(r'^$', 'posts', name="user_posts"),
+        url(r'^threads/$', 'threads', name="user_threads"),
+        url(r'^followers/$', 'followers', name="user_followers"),
+        url(r'^followers/(?P<page>\d+)/$', 'followers', name="user_followers"),
+        url(r'^follows/$', 'follows', name="user_follows"),
+        url(r'^follows/(?P<page>\d+)/$', 'follows', name="user_follows"),
         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"),
+
+        url(r'^follow/$', 'follow_user', name="follow_user"),
+        url(r'^block/$', 'block_user', name="block_user"),
     )))
 )
 

+ 129 - 7
misago/users/views/profile.py

@@ -1,12 +1,20 @@
+from django.contrib import messages
 from django.contrib.auth import get_user_model
-from django.http import Http404
+from django.db.transaction import atomic
+from django.http import Http404, JsonResponse
 from django.shortcuts import redirect, render as django_render
+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, paginate, validate_slug
+from misago.core.utils import clean_return_path
 
 from misago.users import online
 from misago.users.bans import get_user_ban
+from misago.users.decorators import deny_guests
+from misago.users.permissions.profiles import (allow_follow_user,
+                                               allow_block_user)
 from misago.users.sites import user_profile
 from misago.users.warnings import (get_warning_levels, get_user_warning_level,
                                    get_user_warning_obj)
@@ -14,8 +22,10 @@ from misago.users.warnings import (get_warning_levels, get_user_warning_level,
 
 def profile_view(f):
     def decorator(request, *args, **kwargs):
+        User = get_user_model()
+
         relations = ('rank', 'online_tracker', 'ban_cache')
-        queryset = get_user_model().objects.select_related(*relations)
+        queryset = User.objects.select_related(*relations)
         profile = get_object_or_404(queryset, id=kwargs.pop('user_id'))
 
         validate_slug(profile, kwargs.pop('user_slug'))
@@ -23,6 +33,16 @@ def profile_view(f):
 
         add_acl(request.user, profile)
 
+        if profile.acl_['can_follow']:
+            profile.is_followed = request.user.is_following(profile)
+        else:
+            profile.is_followed = False
+
+        if profile.acl_['can_block'] and request.user.is_authenticated():
+            profile.is_blocked = request.user.is_blocking(profile)
+        else:
+            profile.is_blocked = False
+
         return f(request, *args, **kwargs)
     return decorator
 
@@ -68,17 +88,43 @@ def render(request, template, context):
 
 
 @profile_view
-def user_posts(request, profile=None, page=0):
+def posts(request, profile, page=0):
     return render(request, 'misago/profile/posts.html', {'profile': profile})
 
 
 @profile_view
-def user_threads(request, profile=None, page=0):
+def threads(request, profile, page=0):
     return render(request, 'misago/profile/threads.html', {'profile': profile})
 
 
+@profile_view
+def followers(request, profile, page=0):
+    followers_qs = profile.followed_by.order_by('slug').select_related('rank')
+    followers = paginate(followers_qs, page, 12, 2)
+    items_left = followers.paginator.count - followers.end_index()
+
+    return render(request, 'misago/profile/followers.html', {
+        'profile': profile,
+        'followers': followers,
+        'items_left': items_left,
+    })
+
+
+@profile_view
+def follows(request, profile, page=0):
+    followers_qs = profile.follows.order_by('slug').select_related('rank')
+    followers = paginate(followers_qs, page, 12, 2)
+    items_left = followers.paginator.count - followers.end_index()
+
+    return render(request, 'misago/profile/follows.html', {
+        'profile': profile,
+        'followers': followers,
+        'items_left': items_left,
+    })
+
+
 @profile_view_restricted_visibility
-def warnings(request, profile=None, page=0):
+def warnings(request, profile, page=0):
     warnings_qs = profile.warnings.order_by('-id')
     warnings = paginate(warnings_qs, page, 5, 2)
     items_left = warnings.paginator.count - warnings.end_index()
@@ -116,7 +162,7 @@ def warnings(request, profile=None, page=0):
 
 
 @profile_view_restricted_visibility
-def name_history(request, profile=None, page=0):
+def name_history(request, profile, page=0):
     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()
@@ -130,7 +176,7 @@ def name_history(request, profile=None, page=0):
 
 
 @profile_view_restricted_visibility
-def user_ban(request, profile=None):
+def user_ban(request, profile):
     ban = get_user_ban(profile)
     if not ban:
         raise Http404()
@@ -139,3 +185,79 @@ def user_ban(request, profile=None):
         'profile': profile,
         'ban': ban
     })
+
+
+"""
+Profile actions
+"""
+def action_view(f):
+    @deny_guests
+    @require_POST
+    @profile_view
+    @atomic
+    def decorator(request, profile):
+        response = f(request, profile.lock())
+        if request.is_ajax():
+            response['is_error'] = False
+            return JsonResponse(response)
+        else:
+            messages.success(request, response['message'])
+            return_path = clean_return_path(request)
+            if return_path:
+                return redirect(return_path)
+            else:
+                return redirect(user_profile.get_default_link(),
+                                user_slug=profile.slug, user_id=profile.id)
+    return decorator
+
+
+@action_view
+def follow_user(request, profile):
+    user_locked = request.user.lock()
+
+    if request.user.is_following(profile):
+        request.user.follows.remove(profile)
+        followed = False
+    else:
+        followed = True
+        request.user.follows.add(profile)
+
+    profile.followers = profile.followed_by.count()
+    profile.save(update_fields=['followers'])
+
+    user_locked.following = user_locked.follows.count()
+    user_locked.save(update_fields=['following'])
+
+    if followed:
+        message = _("You are now following %(user)s.")
+    else:
+        message = _("You have stopped following %(user)s.")
+    message = message % {'user': profile.username}
+
+    if request.is_ajax:
+        return {'is_following': followed, 'message': message}
+    else:
+        messages.success(request, message)
+
+
+@action_view
+def block_user(request, profile):
+    user_locked = request.user.lock()
+
+    if request.user.is_blocking(profile):
+        request.user.blocks.remove(profile)
+        blocked = False
+    else:
+        blocked = True
+        request.user.blocks.add(profile)
+
+    if blocked:
+        message = _("You are now blocking %(user)s.")
+    else:
+        message = _("You have stopped blocking %(user)s.")
+    message = message % {'user': profile.username}
+
+    if request.is_ajax:
+        return {'is_blocking': blocked, 'message': message}
+    else:
+        messages.success(request, message)