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

New/unread threads lists, uiserver tweaks

Rafał Pitoń 10 лет назад
Родитель
Сommit
d9cabcca79
32 измененных файлов с 885 добавлено и 88 удалено
  1. 6 0
      docs/developers/settings.rst
  2. 15 0
      docs/developers/template_tags.rst
  3. 6 0
      misago/conf/defaults.py
  4. 14 0
      misago/core/templatetags/misago_shorthands.py
  5. 1 1
      misago/readtracker/forumstracker.py
  6. 9 0
      misago/readtracker/signals.py
  7. 2 0
      misago/readtracker/threadstracker.py
  8. 34 1
      misago/static/misago/css/misago/threadslists.less
  9. 0 17
      misago/static/misago/js/misago-notifications.js
  10. 9 4
      misago/static/misago/js/misago-uiserver.js
  11. 33 0
      misago/static/misago/js/misago-user-nav.js
  12. 11 2
      misago/templates/misago/threads/base.html
  13. 1 0
      misago/templates/misago/threads/forum.html
  14. 39 0
      misago/templates/misago/threads/new.html
  15. 39 0
      misago/templates/misago/threads/unread.html
  16. 31 17
      misago/templates/misago/user_nav.html
  17. 69 0
      misago/threads/counts.py
  18. 15 0
      misago/threads/middleware.py
  19. 1 1
      misago/threads/migrations/0001_initial.py
  20. 1 1
      misago/threads/models/thread.py
  21. 58 4
      misago/threads/permissions.py
  22. 90 0
      misago/threads/tests/test_counters.py
  23. 58 0
      misago/threads/tests/test_newthreads_views.py
  24. 47 0
      misago/threads/tests/test_unreadthreads_view.py
  25. 21 0
      misago/threads/urls.py
  26. 8 28
      misago/threads/views/generic/forum/threads.py
  27. 2 1
      misago/threads/views/generic/forum/view.py
  28. 59 1
      misago/threads/views/generic/threads/threads.py
  29. 63 10
      misago/threads/views/generic/threads/view.py
  30. 5 0
      misago/threads/views/moderatedthreads.py
  31. 68 0
      misago/threads/views/newthreads.py
  32. 70 0
      misago/threads/views/unreadthreads.py

+ 6 - 0
docs/developers/settings.rst

@@ -217,6 +217,12 @@ Date format used by Misago ``compact_date`` filter for dates in past years.
 Expects standard Django date format, documented `here <https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date>`_
 
 
+MISAGO_CONTENT_COUNTING_FREQUENCY
+---------------------------------
+
+Maximum allowed age of content counts cache in minutes. The lower the number, the more accurate will be numbers of new and unread threads in navbar, but greater the stress on database.
+
+
 MISAGO_DYNAMIC_AVATAR_DRAWER
 ----------------------------
 

+ 15 - 0
docs/developers/template_tags.rst

@@ -102,6 +102,21 @@ Takes form field as its first argument and renders field complete with label, he
 Takes form field as its only argument and renders it's input.
 
 
+misago_shorthands
+==============
+
+``iftrue`` filter
+-----------------
+
+Shorthand for simple if clauses: ``{{ "fade in"|iftrue:thread.is_closed }}`` will render ``"fade in"`` in template if ``thread.is_closed`` is true.
+
+
+``iffalse`` filter
+-----------------
+
+Opposite filter for ``iftrue``.
+
+
 misago_pagination
 =================
 

+ 6 - 0
misago/conf/defaults.py

@@ -66,6 +66,7 @@ PIPELINE_JS = {
             'misago/js/misago-modal.js',
             'misago/js/misago-scrolling.js',
             'misago/js/misago-uiserver.js',
+            'misago/js/misago-user-nav.js',
             'misago/js/misago-notifications.js',
             'misago/js/misago-threads-lists.js',
         ),
@@ -147,6 +148,7 @@ MIDDLEWARE_CLASSES = (
     'misago.core.middleware.exceptionhandler.ExceptionHandlerMiddleware',
     'misago.users.middleware.OnlineTrackerMiddleware',
     'misago.admin.middleware.AdminAuthMiddleware',
+    'misago.threads.middleware.UnreadThreadsCountMiddleware',
     'misago.core.middleware.threadstore.ThreadStoreMiddleware',
 )
 
@@ -293,6 +295,10 @@ MISAGO_RANKING_SIZE = 30
 # there will be confidered fresh for "Threads with unread replies" list
 MISAGO_FRESH_CONTENT_PERIOD = 40
 
+# Number of minutes between updates of new content counts like new threads,
+# unread replies or moderated threads/posts
+MISAGO_CONTENT_COUNTING_FREQUENCY = 5
+
 
 # X-Sendfile
 # X-Sendfile is feature provided by Http servers that allows web apps to

+ 14 - 0
misago/core/templatetags/misago_shorthands.py

@@ -0,0 +1,14 @@
+from django import template
+
+
+register = template.Library()
+
+
+@register.filter
+def iftrue(value, test):
+    return value if test else ""
+
+
+@register.filter
+def iffalse(value, test):
+    return "" if test else value

+ 1 - 1
misago/readtracker/forumstracker.py

@@ -33,7 +33,7 @@ def make_read(forums):
 
 def sync_record(user, forum):
     recorded_threads = forum.thread_set.filter(last_post_on__gt=user.joined_on)
-    recorded_threads = exclude_invisible_threads(user, forum, recorded_threads)
+    recorded_threads = exclude_invisible_threads(recorded_threads, user, forum)
 
     all_threads_count = recorded_threads.count()
 

+ 9 - 0
misago/readtracker/signals.py

@@ -20,3 +20,12 @@ def delete_forum_tracker(sender, **kwargs):
 @receiver(move_thread)
 def delete_thread_tracker(sender, **kwargs):
     sender.threadread_set.all().delete()
+
+
+@receiver(thread_read)
+def decrease_unread_count(sender, **kwargs):
+    user = sender
+    thread = kwargs['thread']
+
+    if thread.is_new:
+        user.new_threads.decrease()

+ 2 - 0
misago/readtracker/threadstracker.py

@@ -50,10 +50,12 @@ def make_thread_read_aware(user, thread):
         try:
             record = user.threadread_set.filter(thread=thread).all()[0]
             thread.last_read_on = record.last_read_on
+            thread.is_new = False
             thread.is_read = thread.last_post_on <= record.last_read_on
             thread.read_record = record
         except IndexError:
             thread.read_record = None
+            thread.is_new = True
             thread.is_read = False
             thread.last_read_on = user.joined_on
 

+ 34 - 1
misago/static/misago/css/misago/threadslists.less

@@ -27,7 +27,7 @@
           float: left;
           position: relative;
           right: 5px;
-          top: 5px;
+          top: 6px;
 
           color: @state-default;
         }
@@ -121,6 +121,39 @@
             }
           }
 
+          .thread-location {
+            display: inline-block;
+            margin-right: @font-size-large * 1.5;
+            overflow: hidden;
+            position: relative;
+            top: 5px;
+            width: @font-size-large * 4.5;
+
+            font-size: @font-size-small;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+
+            span {
+              position: relative;
+              bottom: 2px;
+
+              color: @state-default;
+              font-size: @font-size-base * 0.75;
+            }
+
+            a:link, a:visited {
+              color: @state-default;
+            }
+
+            a:hover, a:focus {
+              color: @state-hover;
+            }
+
+            a:active {
+              color: @state-clicked;
+            }
+          }
+
           .thread-author {
             margin-right: @font-size-large / 2;
 

+ 0 - 17
misago/static/misago/js/misago-notifications.js

@@ -5,23 +5,6 @@ $(function() {
   var $link = $container.children('a');
 
   function notifications_handler(data) {
-    var $badge = $link.children('.badge');
-
-    if (data.count > 0) {
-      if ($badge.length == 0) {
-        $badge = $('<span class="badge">' + data.count + '</span>');
-        $badge.hide();
-        $link.append($badge);
-        $badge.fadeIn();
-      } else {
-        $badge.text(data.count);
-      }
-    } else if ($badge.length > 0) {
-        $badge.fadeOut();
-    }
-    $link.attr("title", data.message);
-    $.misago_dom().changed();
-
     if (ajax_cache != null && data.count != ajax_cache.count) {
       ajax_cache = null;
       if ($container.hasClass('open')) {

+ 9 - 4
misago/static/misago/js/misago-uiserver.js

@@ -12,8 +12,11 @@
 
     this.ui_observers = [];
     this.observer = function(name, callback) {
-      this.ui_observers.push({name: name, callback: callback});
-      this.query_server();
+      if (callback == undefined) {
+        this.ui_observers.push({callback: name});
+      } else {
+        this.ui_observers.push({name: name, callback: callback});
+      }
     };
 
     this.query_server = function(poll) {
@@ -24,7 +27,9 @@
       ui_observers = this.ui_observers
       $.get(uiserver_url, function(data) {
         $.each(ui_observers, function(i, observer) {
-          if (typeof data[observer.name] !== "undefined") {
+          if (observer.name == undefined) {
+            observer.callback(data);
+          } else if (typeof data[observer.name] !== "undefined") {
             observer.callback(data[observer.name]);
           }
         });
@@ -39,7 +44,7 @@
 
     window.setTimeout(function() {
       query_server(frequency);
-    }, frequency);
+    }, 2000); // First request after 2 seconds
 
     // Return object
     return this;

+ 33 - 0
misago/static/misago/js/misago-user-nav.js

@@ -0,0 +1,33 @@
+$(function() {
+  var $nav = $('#main-navbar');
+
+
+  if (is_authenticated) {
+    // keep badges updated
+    $.misago_ui().observer(function(data) {
+      $nav.find('.badge').each(function() {
+        var binding_name = $(this).data('badge-binding');
+        if (binding_name != undefined && data[binding_name].count != undefined) {
+          var count = data[binding_name].count;
+          $(this).text(count);
+          if (count > 0) {
+            $(this).addClass("in");
+          } else {
+            $(this).removeClass("in");
+          }
+        }
+      });
+    });
+
+    // keep tooltips updated
+    $.misago_ui().observer(function(data) {
+      $nav.find('.tooltip-bottom').each(function() {
+        var binding_name = $(this).data('tooltip-binding');
+        if (binding_name != undefined && data[binding_name].message != undefined) {
+          $(this).attr("title", data[binding_name].message);
+        }
+      });
+      $.misago_dom().changed();
+    });
+  }
+});

+ 11 - 2
misago/templates/misago/threads/base.html

@@ -1,5 +1,5 @@
 {% extends "misago/base.html" %}
-{% load humanize i18n misago_avatars misago_dates %}
+{% load humanize i18n misago_avatars misago_dates misago_shorthands %}
 
 {% block content %}
 <div class="container">
@@ -9,7 +9,7 @@
     <div class="table-panel">
       <ul class="list-group">
         {% for thread in threads %}
-        <li class="list-group-item{% if not thread.is_read %} new{% endif %}">
+        <li class="list-group-item {{ "new"|iffalse:thread.is_read }}">
           <div class="row">
 
             <div class="col-md-7">
@@ -35,10 +35,12 @@
             </div>
             {% block thread-extra %}
             <div class="col-md-5 thread-stats">
+              {% if list_actions %}
               <a href="#" class="thread-check">
                 <span class="fa fa-check"></span>
                 <input type="checkbox" form="threads-actions" name="thread" value="{{ thread.pk }}"{% if thread.pk in selected_threads %}checked="checked"{% endif %}>
               </a>
+              {% endif %}
 
               {% block thread-stats %}
               <a href="#" class="last-post">
@@ -77,6 +79,13 @@
               </div>
               {% endif %}
 
+              {% if show_threads_locations %}
+              <div class="thread-location tooltip-top" title="{% trans "Thread location" %}">
+                <span class="fa fa-reorder fa-lg"></span>
+                <a href="{{ thread.forum.get_absolute_url }}">{{ thread.forum }}</a>
+              </div>
+              {% endif %}
+
               <ul class="list-unstyled thread-flags">
                 {% if thread.has_reported_posts %}
                 <li class="tooltip-top" title="{% trans "Reported posts" %}">

+ 1 - 0
misago/templates/misago/threads/forum.html

@@ -26,6 +26,7 @@
     </div>
   </div>
   {{ block.super }}
+</div>
 {% endblock content %}
 
 

+ 39 - 0
misago/templates/misago/threads/new.html

@@ -0,0 +1,39 @@
+{% extends "misago/threads/base.html" %}
+{% load i18n misago_stringutils %}
+
+
+{% block title %}{% trans "New threads" %}{% if page.number > 1 %} ({% blocktrans with page=page.number %}Page {{ page }}{% endblocktrans %}){% endif %} | {{ block.super }}{% endblock title %}
+
+
+{% block content %}
+<div class="page-header">
+  <div class="container">
+    <h1>{% trans "New threads" %}</h1>
+  </div>
+</div>
+{{ block.super }}
+{% endblock content %}
+
+
+{% block threads-panel %}
+<div class="table-actions">
+  {% include "misago/threads/paginator.html" %}
+
+  {% include "misago/threads/sort.html" %}
+</div>
+
+{{ block.super }}
+
+<div class="table-actions">
+  {% include "misago/threads/paginator.html" %}
+</div>
+{% endblock threads-panel %}
+
+
+{% block no-threads %}
+{% blocktrans trimmed count days=fresh_period %}
+There are no threads from last {{ days }} day that you have never read.
+{% plural %}
+There are no threads from last {{ days }} days that you have you have never read.
+{% endblocktrans %}
+{% endblock no-threads %}

+ 39 - 0
misago/templates/misago/threads/unread.html

@@ -0,0 +1,39 @@
+{% extends "misago/threads/base.html" %}
+{% load i18n misago_stringutils %}
+
+
+{% block title %}{% trans "Unread threads" %}{% if page.number > 1 %} ({% blocktrans with page=page.number %}Page {{ page }}{% endblocktrans %}){% endif %} | {{ block.super }}{% endblock title %}
+
+
+{% block content %}
+<div class="page-header">
+  <div class="container">
+    <h1>{% trans "Unread threads" %}</h1>
+  </div>
+</div>
+{{ block.super }}
+{% endblock content %}
+
+
+{% block threads-panel %}
+<div class="table-actions">
+  {% include "misago/threads/paginator.html" %}
+
+  {% include "misago/threads/sort.html" %}
+</div>
+
+{{ block.super }}
+
+<div class="table-actions">
+  {% include "misago/threads/paginator.html" %}
+</div>
+{% endblock threads-panel %}
+
+
+{% block no-threads %}
+{% blocktrans trimmed count days=fresh_period %}
+There are no threads from last {{ days }} day that have unread replies.
+{% plural %}
+There are no threads from last {{ days }} days that have unread replies.
+{% endblocktrans %}
+{% endblock no-threads %}

+ 31 - 17
misago/templates/misago/user_nav.html

@@ -1,4 +1,4 @@
-{% load i18n misago_avatars %}
+{% load i18n misago_avatars misago_shorthands %}
 <ul class="nav navbar-nav navbar-nav-user navbar-right">
   <li class="dropdown">
     <a href="{% url USER_PROFILE_URL user_slug=user.slug user_id=user.id %}" class="dropdown-toggle user-toggle" data-toggle="dropdown">
@@ -21,18 +21,24 @@
       </li>
       <li>
         <a href="{% url 'misago:notifications' %}">
-          {% if user.new_notifications %}
-          <span class="badge pull-right">42</span>
-          {% endif %}
+          <span class="badge fade {{ "in"|iftrue:user.new_notifications }} pull-right" data-badge-binding="misago_notifications">{{ user.new_notifications }}</span>
           <span class="fa fa-bell-o"></span>
           {% trans "See all notifications" %}
         </a>
       </li>
       <li class="divider"></li>
       <li>
-        <a href="#">
-          <span class="fa fa-plane"></span>
-          Separated link
+        <a href="{% url 'misago:new_threads' %}">
+          <span class="badge fade {{ "in"|iftrue:user.new_threads }} pull-right" data-badge-binding="misago_new_threads">{{ user.new_threads }}</span>
+          <span class="fa fa-plus-circle"></span>
+          {% trans "New threads" %}
+        </a>
+      </li>
+      <li>
+        <a href="{% url 'misago:unread_threads' %}">
+          <span class="badge fade {{ "in"|iftrue:user.unread_threads }} pull-right" data-badge-binding="misago_unread_threads">{{ user.unread_threads }}</span>
+          <span class="fa fa-signal"></span>
+          {% trans "Unread threads" %}
         </a>
       </li>
       <li class="divider"></li>
@@ -51,15 +57,25 @@
 
 <ul class="nav navbar-nav navbar-nav-primary navbar-right">
   <li>
-    <a href="#" class="tooltip-bottom" title="{% trans "New threads" %}">
-      <span class="fa fa-circle-o fa-fw"></span>
-      <span class="badge">31</span>
+    <a href="{% url 'misago:new_threads' %}" class="tooltip-bottom" data-tooltip-binding="misago_new_threads"
+        {% if user.new_threads %}
+        title="{% blocktrans count threads=user.new_threads %}{{ threads }} new thread{% plural %}{{ threads }} new threads{% endblocktrans %}"
+        {% else %}
+        title="{% trans "New threads" %}"
+        {% endif %}>
+      <span class="fa fa-plus-circle fa-fw"></span>
+      <span class="badge fade {{ "in"|iftrue:user.new_threads }}" data-badge-binding="misago_new_threads">{{ user.new_threads }}</span>
     </a>
   </li>
   <li>
-    <a href="#" class="tooltip-bottom" title="{% trans "Unread threads" %}">
-      <span class="fa fa-reply-all fa-fw"></span>
-      <span class="badge">23</span>
+    <a href="{% url 'misago:unread_threads' %}" class="tooltip-bottom" data-tooltip-binding="misago_unread_threads"
+        {% if user.unread_threads %}
+        title="{% blocktrans count threads=user.unread_threads %}{{ threads }} unread thread{% plural %}{{ threads }} unread threads{% endblocktrans %}"
+        {% else %}
+        title="{% trans "Unread threads" %}"
+        {% endif %}>
+      <span class="fa fa-signal fa-fw"></span>
+      <span class="badge fade {{ "in"|iftrue:user.unread_threads }}" data-badge-binding="misago_unread_threads">{{ user.unread_threads }}</span>
     </a>
   </li>
   <li>
@@ -81,7 +97,7 @@
     </a>
   </li>
   <li class="user-notifications-nav dropdown">
-    <a href="{% url 'misago:notifications' %}" class="dropdown-toggle tooltip-bottom"
+    <a href="{% url 'misago:notifications' %}" class="dropdown-toggle tooltip-bottom" data-tooltip-binding="misago_notifications"
         {% if user.new_notifications %}
         title="{% blocktrans with notifications=user.new_notifications count counter=user.new_notifications %}{{ notifications }} new notification{% plural %}{{ notifications }} new notifications{% endblocktrans %}"
         {% else %}
@@ -89,9 +105,7 @@
         {% endif %}
         data-toggle="dropdown">
       <span class="fa fa-bell-o fa-fw"></span>
-      {% if user.new_notifications %}
-      <span class="badge">{{ user.new_notifications }}</span>
-      {% endif %}
+      <span class="badge fade {{ "in"|iftrue:user.new_notifications }}" data-badge-binding="misago_notifications">{{ user.new_notifications }}</span>
     </a>
     <div class="dropdown-menu">
       <div class="display"></div>

+ 69 - 0
misago/threads/counts.py

@@ -0,0 +1,69 @@
+from time import time
+
+from django.conf import settings
+
+from misago.threads.views.newthreads import NewThreads
+from misago.threads.views.unreadthreads import UnreadThreads
+
+
+class BaseCounter(object):
+    Threads = None
+    name = None
+
+    def __init__(self, user, session):
+        self.user = user
+        self.session = session
+        self.count = self.get_cached_count()
+
+    def __int__(self):
+        return self.count
+
+    def __unicode__(self):
+        return unicode(self.count)
+
+    def __nonzero__( self) :
+        return bool(self.count)
+
+    def get_cached_count(self):
+        count = self.session.get(self.name, None)
+        if not count or not self.is_cache_valid(count):
+            count = self.get_real_count()
+            self.session[self.name] = count
+        return count['threads']
+
+    def is_cache_valid(self, cache):
+        return cache.get('expires', 0) > time()
+
+    def get_expiration_timestamp(self):
+        return time() + settings.MISAGO_CONTENT_COUNTING_FREQUENCY * 60
+
+    def get_real_count(self):
+        return {
+            'threads': self.Threads(self.user).get_queryset().count(),
+            'expires': self.get_expiration_timestamp()
+        }
+
+    def set(self, count):
+        self.count = count
+        self.session[self.name] = {
+            'threads': count,
+            'expires': self.get_expiration_timestamp()
+        }
+
+    def decrease(self):
+        if self.count > 0:
+            self.count -= 1
+            self.session[self.name] = {
+                'threads': self.count,
+                'expires': self.session[self.name]['expires']
+            }
+
+
+class NewThreadsCount(BaseCounter):
+    Threads = NewThreads
+    name = 'new_threads'
+
+
+class UnreadThreadsCount(BaseCounter):
+    Threads = UnreadThreads
+    name = 'unread_threads'

+ 15 - 0
misago/threads/middleware.py

@@ -0,0 +1,15 @@
+from time import time
+
+from django.conf import settings
+
+from misago.threads.counts import NewThreadsCount, UnreadThreadsCount
+
+
+class UnreadThreadsCountMiddleware(object):
+    def process_request(self, request):
+        if request.user.is_authenticated():
+            request.user.new_threads = NewThreadsCount(
+                request.user, request.session)
+            request.user.unread_threads = UnreadThreadsCount(
+                request.user, request.session)
+

+ 1 - 1
misago/threads/migrations/0001_initial.py

@@ -79,7 +79,7 @@ class Migration(migrations.Migration):
                 ('has_moderated_posts', models.BooleanField(default=False)),
                 ('has_hidden_posts', models.BooleanField(default=False)),
                 ('has_events', models.BooleanField(default=False)),
-                ('started_on', models.DateTimeField()),
+                ('started_on', models.DateTimeField(db_index=True)),
                 ('starter_name', models.CharField(max_length=255)),
                 ('starter_slug', models.CharField(max_length=255)),
                 ('last_post_on', models.DateTimeField(db_index=True)),

+ 1 - 1
misago/threads/models/thread.py

@@ -22,7 +22,7 @@ class Thread(models.Model):
     has_moderated_posts = models.BooleanField(default=False)
     has_hidden_posts = models.BooleanField(default=False)
     has_events = models.BooleanField(default=False)
-    started_on = models.DateTimeField()
+    started_on = models.DateTimeField(db_index=True)
     first_post = models.ForeignKey('misago_threads.Post', related_name='+',
                                    null=True, blank=True,
                                    on_delete=models.SET_NULL)

+ 58 - 4
misago/threads/permissions.py

@@ -3,7 +3,7 @@ from django.db.models import Q
 from django.http import Http404
 from django.utils.translation import ugettext_lazy as _
 
-from misago.acl import algebra
+from misago.acl import add_acl, algebra
 from misago.acl.decorators import return_boolean
 from misago.core import forms
 from misago.forums.models import Forum, RoleForumACL, ForumRole
@@ -331,7 +331,14 @@ can_start_thread = return_boolean(allow_start_thread)
 """
 Queryset helpers
 """
-def exclude_invisible_threads(user, forum, queryset):
+def exclude_invisible_threads(queryset, user, forum=None):
+    if forum:
+        return exclude_invisible_forum_threads(queryset, user, forum)
+    else:
+        return exclude_all_invisible_threads(queryset, user)
+
+
+def exclude_invisible_forum_threads(queryset, user, forum):
     if user.is_authenticated():
         condition_author = Q(starter_id=user.id)
 
@@ -356,13 +363,60 @@ def exclude_invisible_threads(user, forum, queryset):
     return queryset
 
 
-def exclude_invisible_posts(user, forum, queryset):
+def exclude_all_invisible_threads(queryset, user):
+    forums_in = []
+    conditions = None
+
+    for forum in Forum.objects.all_forums():
+        add_acl(user, forum)
+
+        condition_forum = Q(forum=forum)
+        condition_author = Q(starter_id=user.id)
+
+        # can see all threads?
+        if forum.acl['can_see_all_threads']:
+            can_mod = forum.acl['can_review_moderated_content']
+            can_hide = forum.acl['can_hide_threads']
+
+            if not can_mod or not can_hide:
+                if not can_mod and not can_hide:
+                    condition = Q(is_moderated=False) & Q(is_hidden=False)
+                elif not can_mod:
+                    condition = Q(is_moderated=False)
+                elif not can_hide:
+                    condition = Q(is_hidden=False)
+                visibility_condition = condition_author | condition
+                visibility_condition = condition_forum & visibility_condition
+            else:
+                # user can see everything so don't bother with rest of routine
+                forums_in.append(forum.pk)
+                continue
+        else:
+            # show all threads in forum made by user
+            visibility_condition = condition_forum & condition_author
+
+        if conditions:
+            conditions = conditions | visibility_condition
+        else:
+            conditions = visibility_condition
+
+    if conditions and forums_in:
+        return queryset.filter(Q(forum_id__in=forums_in) | conditions)
+    elif conditions:
+        return queryset.filter(conditions)
+    elif forums_in:
+        return queryset.filter(forum_id__in=forums_in)
+    else:
+        return Thread.objects.none()
+
+
+def exclude_invisible_posts(queryset, user, forum):
     if user.is_authenticated():
         if not forum.acl['can_review_moderated_content']:
             condition_author = Q(starter_id=user.id)
             condition = Q(is_moderated=False)
             queryset = queryset.filter(condition_author | condition)
     elif not forum.acl['can_review_moderated_content']:
-            queryset = queryset.filter(is_moderated=False)
+        queryset = queryset.filter(is_moderated=False)
 
     return queryset

+ 90 - 0
misago/threads/tests/test_counters.py

@@ -0,0 +1,90 @@
+from time import time
+
+from django.core.urlresolvers import reverse
+from django.utils.translation import ugettext as _
+
+from misago.forums.models import Forum
+from misago.users.testutils import AuthenticatedUserTestCase
+
+from misago.threads.counts import NewThreadsCount, UnreadThreadsCount
+from misago.threads import testutils
+
+
+class TestNewThreadsCount(AuthenticatedUserTestCase):
+    def setUp(self):
+        super(TestNewThreadsCount, self).setUp()
+
+        self.forum = Forum.objects.all_forums().filter(role="forum")[:1][0]
+
+    def test_cast_to_int(self):
+        """counter is castable to int"""
+        counter = NewThreadsCount(self.user, {})
+        self.assertEqual(int(counter), 0)
+
+        threads = [testutils.post_thread(self.forum) for t in xrange(42)]
+        counter = NewThreadsCount(self.user, {})
+        self.assertEqual(int(counter), 42)
+
+    def test_cast_to_bool(self):
+        """counter is castable to bool"""
+        counter = NewThreadsCount(self.user, {})
+        self.assertFalse(counter)
+
+        threads = [testutils.post_thread(self.forum) for t in xrange(42)]
+        counter = NewThreadsCount(self.user, {})
+        self.assertTrue(counter)
+
+    def test_is_cache_valid(self):
+        """is_cache_valid returns valid value for different caches"""
+        counter = NewThreadsCount(self.user, {})
+
+        self.assertTrue(counter.is_cache_valid({'expires': time() + 15}))
+        self.assertFalse(counter.is_cache_valid({'expires': time() - 15}))
+
+    def test_get_expiration_timestamp(self):
+        """get_expiration_timestamp returns greater time than current one"""
+        counter = NewThreadsCount(self.user, {})
+        self.assertTrue(counter.get_expiration_timestamp() > time())
+
+    def test_get_real_count(self):
+        """get_real_count returns valid count of new threads"""
+        counter = NewThreadsCount(self.user, {})
+        self.assertEqual(counter.count, 0)
+        self.assertEqual(counter.get_real_count()['threads'], 0)
+
+        # create 10 new threads
+        threads = [testutils.post_thread(self.forum) for t in xrange(10)]
+        self.assertEqual(counter.get_real_count()['threads'], 10)
+
+        # create new counter
+        counter = NewThreadsCount(self.user, {})
+        self.assertEqual(counter.count, 10)
+        self.assertEqual(counter.get_real_count()['threads'], 10)
+
+    def test_set(self):
+        """set allows for changing count of threads"""
+        session = {}
+        counter = NewThreadsCount(self.user, session)
+        counter.set(128)
+
+        self.assertEqual(int(counter), 128)
+        self.assertEqual(session[counter.name]['threads'], 128)
+
+    def test_decrease(self):
+        """decrease is not allowing for negative threads counts"""
+        session = {}
+        counter = NewThreadsCount(self.user, session)
+        counter.set(128)
+        counter.decrease()
+
+        self.assertEqual(int(counter), 127)
+        self.assertEqual(session[counter.name]['threads'], 127)
+
+    def test_decrease_zero(self):
+        """decrease is not allowing for negative threads counts"""
+        session = {}
+        counter = NewThreadsCount(self.user, session)
+        counter.decrease()
+
+        self.assertEqual(int(counter), 0)
+        self.assertEqual(session[counter.name]['threads'], 0)

+ 58 - 0
misago/threads/tests/test_newthreads_views.py

@@ -0,0 +1,58 @@
+from django.core.urlresolvers import reverse
+from django.utils.translation import ugettext as _
+
+from misago.forums.models import Forum
+from misago.users.testutils import UserTestCase, AuthenticatedUserTestCase
+
+from misago.threads import testutils
+
+
+class AuthenticatedTests(AuthenticatedUserTestCase):
+    def test_empty_threads_list(self):
+        """empty threads list is rendered"""
+        response = self.client.get(reverse('misago:new_threads'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("There are no threads from last", response.content)
+
+    def test_single_page_threads_list(self):
+        """filled threads list is rendered"""
+        forum = Forum.objects.all_forums().filter(role="forum")[:1][0]
+        threads = [testutils.post_thread(forum) for t in xrange(10)]
+
+        response = self.client.get(reverse('misago:new_threads'))
+        self.assertEqual(response.status_code, 200)
+        for thread in threads:
+            self.assertIn(thread.get_absolute_url(), response.content)
+
+        # read half of threads
+        for thread in threads[5:]:
+            response = self.client.get(thread.get_absolute_url())
+
+        # assert first half is no longer shown on list
+        response = self.client.get(reverse('misago:new_threads'))
+        for thread in threads[5:]:
+            self.assertNotIn(thread.get_absolute_url(), response.content)
+        for thread in threads[:5]:
+            self.assertIn(thread.get_absolute_url(), response.content)
+
+    def test_multipage_threads_list(self):
+        """multipage threads list is rendered"""
+        forum = Forum.objects.all_forums().filter(role="forum")[:1][0]
+        threads = [testutils.post_thread(forum) for t in xrange(80)]
+
+        response = self.client.get(reverse('misago:new_threads'))
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.get(reverse('misago:new_threads',
+                                           kwargs={'page': 2}))
+        self.assertEqual(response.status_code, 200)
+
+
+class AnonymousTests(UserTestCase):
+    def test_anon_access_to_view(self):
+        """anonymous user has no access to new threads list"""
+        response = self.client.get(reverse('misago:new_threads'))
+        self.assertEqual(response.status_code, 403)
+        self.assertIn(
+            _("You have to sign in to see your list of new threads."),
+            response.content)

+ 47 - 0
misago/threads/tests/test_unreadthreads_view.py

@@ -0,0 +1,47 @@
+from django.core.urlresolvers import reverse
+from django.utils.translation import ugettext as _
+
+from misago.users.testutils import UserTestCase, AuthenticatedUserTestCase
+
+from misago.threads import testutils
+
+
+class AuthenticatedTests(AuthenticatedUserTestCase):
+    def test_empty_threads_list(self):
+        """empty threads list is rendered"""
+        response = self.client.get(reverse('misago:unread_threads'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("There are no threads from last", response.content)
+
+    def test_filled_threads_list(self):
+        """filled threads list is rendered"""
+        forum = Forum.objects.all_forums().filter(role="forum")[:1][0]
+        threads = [testutils.post_thread(forum) for t in xrange(10)]
+
+        # only unread tracker threads are shown on unread list
+        response = self.client.get(reverse('misago:unread_threads'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("There are no threads from last", response.content)
+
+        # we'll read and reply to first five threads
+        for thread in threads[5:]:
+            response = self.client.get(thread.get_absolute_url())
+            testutils.reply_thread(thread)
+
+        # assert that replied threads show on list
+        response = self.client.get(reverse('misago:unread_threads'))
+        self.assertEqual(response.status_code, 200)
+        for thread in threads[5:]:
+            self.assertIn(thread.get_absolute_url(), response.content)
+        for thread in threads[:5]:
+            self.assertNotIn(thread.get_absolute_url(), response.content)
+
+
+class AnonymousTests(UserTestCase):
+    def test_anon_access_to_view(self):
+        """anonymous user has no access to unread threads list"""
+        response = self.client.get(reverse('misago:unread_threads'))
+        self.assertEqual(response.status_code, 403)
+        self.assertIn(_("You have to sign in to see your list of "
+                        "threads with unread replies."),
+                      response.content)

+ 21 - 0
misago/threads/urls.py

@@ -16,7 +16,28 @@ urlpatterns = patterns('',
     url(r'^forum/(?P<forum_slug>[\w\d-]+)-(?P<forum_id>\d+)/start-thread/$', StartThreadView.as_view(), name='start_thread'),
 )
 
+
 urlpatterns += patterns('',
     url(r'^thread/(?P<thread_slug>[\w\d-]+)-(?P<thread_id>\d+)/$', ThreadView.as_view(), name='thread'),
     url(r'^thread/(?P<thread_slug>[\w\d-]+)-(?P<thread_id>\d+)/(?P<page>\d+)/$', ThreadView.as_view(), name='thread'),
 )
+
+
+# new threads lists
+from misago.threads.views.newthreads import NewThreadsView
+urlpatterns += patterns('',
+    url(r'^new-threads/$', NewThreadsView.as_view(), name='new_threads'),
+    url(r'^new-threads/(?P<page>\d+)/$', NewThreadsView.as_view(), name='new_threads'),
+    url(r'^new-threads/sort-(?P<sort>[\w-]+)$', NewThreadsView.as_view(), name='new_threads'),
+    url(r'^new-threads/sort-(?P<sort>[\w-]+)(?P<page>\d+)/$', NewThreadsView.as_view(), name='new_threads'),
+)
+
+
+# unread threads lists
+from misago.threads.views.unreadthreads import UnreadThreadsView
+urlpatterns += patterns('',
+    url(r'^unread-threads/$', UnreadThreadsView.as_view(), name='unread_threads'),
+    url(r'^unread-threads/(?P<page>\d+)/$', UnreadThreadsView.as_view(), name='unread_threads'),
+    url(r'^unread-threads/sort-(?P<sort>[\w-]+)$', UnreadThreadsView.as_view(), name='unread_threads'),
+    url(r'^unread-threads/sort-(?P<sort>[\w-]+)(?P<page>\d+)/$', UnreadThreadsView.as_view(), name='unread_threads'),
+)

+ 8 - 28
misago/threads/views/generic/forum/threads.py

@@ -9,18 +9,14 @@ __all__ = ['ForumThreads']
 
 class ForumThreads(Threads):
     def __init__(self, user, forum):
+        self.pinned_count = 0
+
         self.user = user
         self.forum = forum
 
         self.filter_by = None
         self.sort_by = '-last_post_on'
 
-    def filter(self, filter_by):
-        self.filter_by = filter_by
-
-    def sort(self, sort_by):
-        self.sort_by = sort_by
-
     def list(self, page=0):
         queryset = self.get_queryset()
         queryset = queryset.order_by(self.sort_by)
@@ -34,6 +30,7 @@ class ForumThreads(Threads):
         threads = []
         for thread in pinned_qs:
             threads.append(thread)
+            self.pinned_count += 1
         for thread in self._page.object_list:
             threads.append(thread)
 
@@ -45,6 +42,11 @@ class ForumThreads(Threads):
 
         return threads
 
+    def get_queryset(self):
+        queryset = exclude_invisible_threads(
+            self.forum.thread_set, self.user, self.forum)
+        return self.filter_threads(queryset)
+
     def filter_threads(self, queryset):
         if self.filter_by == 'my-threads':
             return queryset.filter(starter_id=self.user.id)
@@ -66,25 +68,3 @@ class ForumThreads(Threads):
                         return queryset.filter(label_id=label.pk)
                 else:
                     return queryset
-
-    def get_queryset(self):
-        queryset = exclude_invisible_threads(
-            self.user, self.forum, self.forum.thread_set)
-        return self.filter_threads(queryset)
-
-    error_message = ("threads list has to be loaded via call to list() before "
-                     "pagination data will be available")
-
-    @property
-    def paginator(self):
-        try:
-            return self._paginator
-        except AttributeError:
-            raise AttributeError(self.error_message)
-
-    @property
-    def page(self):
-        try:
-            return self._page
-        except AttributeError:
-            raise AttributeError(self.error_message)

+ 2 - 1
misago/threads/views/generic/forum/view.py

@@ -42,7 +42,7 @@ class ForumView(ThreadsView):
         cleaned_kwargs = filtering.clean_kwargs(cleaned_kwargs)
 
         if cleaned_kwargs != kwargs:
-            return redirect('misago:forum', **cleaned_kwargs)
+            return redirect(self.link_name, **cleaned_kwargs)
 
         threads = self.Threads(request.user, forum)
         sorting.sort(threads)
@@ -63,6 +63,7 @@ class ForumView(ThreadsView):
             'path': get_forum_path(forum),
 
             'threads': threads.list(page_number),
+            'threads_count': threads.count(),
             'page': threads.page,
             'paginator': threads.paginator,
 

+ 59 - 1
misago/threads/views/generic/threads/threads.py

@@ -1,3 +1,4 @@
+from misago.core.shortcuts import paginate
 from misago.readtracker import threadstracker
 
 from misago.threads.models import Label
@@ -8,10 +9,47 @@ __all__ = ['Threads']
 
 class Threads(object):
     def __init__(self, user):
+        self.pinned_count = 0
+
         self.user = user
 
+        self.filter_by = None
+        self.sort_by = '-last_post_on'
+
+    def filter(self, filter_by):
+        self.filter_by = filter_by
+
     def sort(self, sort_by):
-        self.queryset = self.queryset.order_by(sort_by)
+        self.sort_by = sort_by
+
+    def list(self, page=0):
+        queryset = self.get_queryset()
+        queryset = queryset.order_by(self.sort_by)
+
+        pinned_qs = queryset.filter(is_pinned=True)
+        threads_qs = queryset.filter(is_pinned=False)
+
+        self._page = paginate(threads_qs, page, 20, 10)
+        self._paginator = self._page.paginator
+
+        threads = []
+        for thread in pinned_qs:
+            threads.append(thread)
+            self.pinned_count += 1
+        for thread in self._page.object_list:
+            threads.append(thread)
+
+        self.label_threads(threads, Label.objects.get_cached_labels())
+        self.make_threads_read_aware(threads)
+
+        return threads
+
+    def get_queryset(self):
+        queryset = exclude_invisible_threads(self.forum.thread_set, self.user)
+        return self.filter_threads(queryset)
+
+    def filter_threads(self, queryset):
+        return queryset
 
     def label_threads(self, threads, labels=None):
         if labels:
@@ -24,3 +62,23 @@ class Threads(object):
 
     def make_threads_read_aware(self, threads):
         threadstracker.make_read_aware(self.user, threads)
+
+    error_message = ("threads list has to be loaded via call to list() before "
+                     "pagination data will be available")
+
+    @property
+    def paginator(self):
+        try:
+            return self._paginator
+        except AttributeError:
+            raise AttributeError(self.error_message)
+
+    @property
+    def page(self):
+        try:
+            return self._page
+        except AttributeError:
+            raise AttributeError(self.error_message)
+
+    def count(self):
+        return self.pinned_count + self.paginator.count

+ 63 - 10
misago/threads/views/generic/threads/view.py

@@ -1,23 +1,21 @@
 from misago.core.shortcuts import paginate
 
 from misago.threads.views.generic.base import ViewBase
+from misago.threads.views.generic.threads.sorting import Sorting
 
 
 __all__ = ['ThreadsView']
 
 
 class ThreadsView(ViewBase):
-    def get_threads(self, request, kwargs):
-        queryset = self.get_threads_queryset(request, forum)
-        queryset = threads_qs.order_by('-last_post_id')
+    """
+    Basic view for generic threads lists
+    """
 
-        page = paginate(threads_qs, kwargs.get('page', 0), 30, 10)
-        threads = [thread for thread in page.object_list]
-
-        return page, threads
-
-    def get_threads_queryset(self, request):
-        return forum.thread_set.all().order_by('-last_post_id')
+    Threads = None
+    Sorting = Sorting
+    Filtering = None
+    Actions = None
 
     def clean_kwargs(self, request, kwargs):
         cleaned_kwargs = kwargs.copy()
@@ -26,3 +24,58 @@ class ThreadsView(ViewBase):
             cleaned_kwargs.pop('sort', None)
             cleaned_kwargs.pop('show', None)
         return cleaned_kwargs
+
+    def dispatch(self, request, *args, **kwargs):
+        page_number = kwargs.pop('page', None)
+        cleaned_kwargs = self.clean_kwargs(request, kwargs)
+
+        if self.Sorting:
+            sorting = self.Sorting(self.link_name, cleaned_kwargs)
+            cleaned_kwargs = sorting.clean_kwargs(cleaned_kwargs)
+
+        if self.Filtering:
+            filtering = self.Filtering(self.link_name, cleaned_kwargs)
+            cleaned_kwargs = filtering.clean_kwargs(cleaned_kwargs)
+
+        if cleaned_kwargs != kwargs:
+            return redirect(self.link_name, **cleaned_kwargs)
+
+        threads = self.Threads(request.user)
+
+        if self.Sorting:
+            sorting.sort(threads)
+        if self.Filtering:
+            filtering.filter(threads)
+
+        if self.Actions:
+            actions = self.Actions(user=request.user)
+            if request.method == 'POST':
+                # see if we can delegate anything to actions manager
+                response = actions.handle_post(request, threads.get_queryset())
+                if response:
+                    return response
+
+        # build template context
+        context = {
+            'link_name': self.link_name,
+            'links_params': cleaned_kwargs,
+
+            'threads': threads.list(page_number),
+            'threads_count': threads.count(),
+            'page': threads.page,
+            'paginator': threads.paginator,
+        }
+
+        if self.Sorting:
+            context.update({'sorting': sorting})
+
+        if self.Filtering:
+            context.update({'filtering': filtering})
+
+        if self.Actions:
+            context.update({
+                'list_actions': actions.get_list(),
+                'selected_threads': actions.get_selected_ids(),
+            })
+
+        return self.render(request, context)

+ 5 - 0
misago/threads/views/moderatedthreads.py

@@ -0,0 +1,5 @@
+from misago.threads.generic import threads
+
+
+class ModeratedThreadsView(threads.ThreadsView):
+    pass

+ 68 - 0
misago/threads/views/newthreads.py

@@ -0,0 +1,68 @@
+from datetime import timedelta
+
+from django.conf import settings
+from django.core.exceptions import PermissionDenied
+from misago.core.uiviews import uiview
+from misago.users.decorators import deny_guests
+from django.utils import timezone
+from django.utils.translation import ungettext, ugettext as _
+
+from misago.threads.models import Thread
+from misago.threads.permissions import exclude_invisible_threads
+from misago.threads.views.generic.threads import Threads, ThreadsView
+
+
+class NewThreads(Threads):
+    def get_queryset(self):
+        cutoff_days = settings.MISAGO_FRESH_CONTENT_PERIOD
+        cutoff_date = timezone.now() - timedelta(days=cutoff_days)
+        if cutoff_date < self.user.joined_on:
+            cutoff_date = self.user.joined_on
+
+        queryset = Thread.objects.filter(started_on__gte=cutoff_date)
+        queryset = queryset.select_related('forum')
+
+        tracked_threads = self.user.threadread_set.all()
+        queryset = queryset.exclude(id__in=tracked_threads.values('thread_id'))
+        queryset = exclude_invisible_threads(queryset, self.user)
+        return queryset
+
+
+class NewThreadsView(ThreadsView):
+    link_name = 'misago:new_threads'
+    template = 'misago/threads/new.html'
+
+    Threads = NewThreads
+
+    def process_context(self, request, context):
+        context['show_threads_locations'] = True
+        context['fresh_period'] = settings.MISAGO_FRESH_CONTENT_PERIOD
+
+        if request.user.new_threads != context['threads_count']:
+            request.user.new_threads.set(context['threads_count'])
+        return context
+
+    def dispatch(self, request, *args, **kwargs):
+        if request.user.is_anonymous():
+            message = _("You have to sign in to see your list of new threads.")
+            raise PermissionDenied(message)
+        else:
+            return super(NewThreadsView, self).dispatch(
+                request, *args, **kwargs)
+
+
+@uiview("misago_new_threads")
+@deny_guests
+def event_sender(request, resolver_match):
+    if request.user.new_threads:
+        message = ungettext("%(threads)s new thread",
+                            "%(threads)s new threads",
+                            request.user.new_threads)
+        message = message % {'threads': request.user.new_threads}
+    else:
+        message = _("New threads")
+
+    return {
+        'count': int(request.user.new_threads),
+        'message': message,
+    }

+ 70 - 0
misago/threads/views/unreadthreads.py

@@ -0,0 +1,70 @@
+from datetime import timedelta
+
+from django.conf import settings
+from django.core.exceptions import PermissionDenied
+from misago.core.uiviews import uiview
+from misago.users.decorators import deny_guests
+from django.db.models import F
+from django.utils import timezone
+from django.utils.translation import ungettext, ugettext as _
+
+from misago.threads.models import Thread
+from misago.threads.permissions import exclude_invisible_threads
+from misago.threads.views.generic.threads import Threads, ThreadsView
+
+
+class UnreadThreads(Threads):
+    def get_queryset(self):
+        cutoff_days = settings.MISAGO_FRESH_CONTENT_PERIOD
+        cutoff_date = timezone.now() - timedelta(days=cutoff_days)
+        if cutoff_date < self.user.joined_on:
+            cutoff_date = self.user.joined_on
+
+        queryset = Thread.objects.filter(last_post_on__gte=cutoff_date)
+        queryset = queryset.select_related('forum')
+        queryset = queryset.filter(threadread__user=self.user)
+        queryset = queryset.filter(
+            threadread__last_read_on__lt=F('last_post_on'))
+        queryset = exclude_invisible_threads(queryset, self.user)
+        return queryset
+
+
+class UnreadThreadsView(ThreadsView):
+    link_name = 'misago:unread_threads'
+    template = 'misago/threads/unread.html'
+
+    Threads = UnreadThreads
+
+    def process_context(self, request, context):
+        context['show_threads_locations'] = True
+        context['fresh_period'] = settings.MISAGO_FRESH_CONTENT_PERIOD
+
+        if request.user.unread_threads != context['threads_count']:
+            request.user.unread_threads.set(context['threads_count'])
+        return context
+
+    def dispatch(self, request, *args, **kwargs):
+        if request.user.is_anonymous():
+            message = _("You have to sign in to see your list of "
+                        "threads with unread replies.")
+            raise PermissionDenied(message)
+        else:
+            return super(UnreadThreadsView, self).dispatch(
+                request, *args, **kwargs)
+
+
+@uiview("misago_unread_threads")
+@deny_guests
+def event_sender(request, resolver_match):
+    if request.user.unread_threads:
+        message = ungettext("%(threads)s unread thread",
+                            "%(threads)s unread threads",
+                            request.user.unread_threads)
+        message = message % {'threads': request.user.unread_threads}
+    else:
+        message = _("Unread threads")
+
+    return {
+        'count': int(request.user.unread_threads),
+        'message': message,
+    }