Browse Source

- Misago tracks threads reads
- Forums layouts are back
- Subforums are listed in thread view

Ralfp 12 years ago
parent
commit
0fcbbb02e6

+ 12 - 0
misago/forums/forms.py

@@ -11,6 +11,11 @@ class CategoryForm(Form):
     description = forms.CharField(widget=forms.Textarea,required=False)
     description = forms.CharField(widget=forms.Textarea,required=False)
     closed = forms.BooleanField(widget=YesNoSwitch,required=False)
     closed = forms.BooleanField(widget=YesNoSwitch,required=False)
     style = forms.CharField(max_length=255,required=False)
     style = forms.CharField(max_length=255,required=False)
+    template = forms.ChoiceField(choices=(
+                                          ('row', _('One forum per row')),
+                                          ('half', _('Two forums per row')),
+                                          ('quarter', _('Four forums per row')),
+                                          ))
     
     
     layout = (
     layout = (
               (
               (
@@ -26,6 +31,7 @@ class CategoryForm(Form):
               (
               (
                _("Display Options"),
                _("Display Options"),
                (
                (
+                ('template', {'label': _("Category Layout"), 'help_text': _('Controls how this category is displayed on forums lists.')}),
                 ('style', {'label': _("Category Style"), 'help_text': _('You can add custom CSS classess to this category, to change way it looks on board index.')}),
                 ('style', {'label': _("Category Style"), 'help_text': _('You can add custom CSS classess to this category, to change way it looks on board index.')}),
                 ),
                 ),
               ),
               ),
@@ -45,6 +51,11 @@ class ForumForm(Form):
     style = forms.CharField(max_length=255,required=False)
     style = forms.CharField(max_length=255,required=False)
     prune_start = forms.IntegerField(min_value=0,initial=0)
     prune_start = forms.IntegerField(min_value=0,initial=0)
     prune_last = forms.IntegerField(min_value=0,initial=0)
     prune_last = forms.IntegerField(min_value=0,initial=0)
+    template = forms.ChoiceField(choices=(
+                                          ('row', _('One forum per row')),
+                                          ('half', _('Two forums per row')),
+                                          ('quarter', _('Four forums per row')),
+                                          ))
     
     
     layout = (
     layout = (
               (
               (
@@ -67,6 +78,7 @@ class ForumForm(Form):
               (
               (
                _("Display Options"),
                _("Display Options"),
                (
                (
+                ('template', {'label': _("Subforums Layout"), 'help_text': _('Controls how this forum displays subforums list.')}),
                 ('style', {'label': _("Forum Style"), 'help_text': _('You can add custom CSS classess to this forum to change way it looks on forums lists.')}),
                 ('style', {'label': _("Forum Style"), 'help_text': _('You can add custom CSS classess to this forum to change way it looks on forums lists.')}),
                 ),
                 ),
               ),
               ),

+ 19 - 10
misago/forums/models.py

@@ -5,7 +5,7 @@ from mptt.models import MPTTModel,TreeForeignKey
 from misago.roles.models import Role
 from misago.roles.models import Role
 
 
 class ForumManager(models.Manager):
 class ForumManager(models.Manager):
-    def treelist(self, acl, parent=None):
+    def treelist(self, acl, parent=None, tracker=None):
         complete_list = []
         complete_list = []
         forums_list = []
         forums_list = []
         parents = {}
         parents = {}
@@ -17,6 +17,9 @@ class ForumManager(models.Manager):
             
             
         for forum in queryset:
         for forum in queryset:
             forum.subforums = []
             forum.subforums = []
+            forum.is_read = False
+            if tracker:
+                forum.is_read = tracker.is_read(forum)
             parents[forum.pk] = forum
             parents[forum.pk] = forum
             complete_list.append(forum)
             complete_list.append(forum)
             if forum.parent_id in parents:
             if forum.parent_id in parents:
@@ -31,15 +34,20 @@ class ForumManager(models.Manager):
                 parents[forum.parent_id].threads_delta += forum.threads_delta
                 parents[forum.parent_id].threads_delta += forum.threads_delta
                 parents[forum.parent_id].posts += forum.posts
                 parents[forum.parent_id].posts += forum.posts
                 parents[forum.parent_id].posts_delta += forum.posts_delta
                 parents[forum.parent_id].posts_delta += forum.posts_delta
-                if acl.can_browse(forum.pk) and forum.last_thread_date and (not parents[forum.parent_id].last_thread_date or forum.last_thread_date > parents[forum.parent_id].last_thread_date):
-                    parents[forum.parent_id].last_thread = forum.last_thread
-                    parents[forum.parent_id].last_thread_name = forum.last_thread_name
-                    parents[forum.parent_id].last_thread_slug = forum.last_thread_slug
-                    parents[forum.parent_id].last_thread_date = forum.last_thread_date
-                    parents[forum.parent_id].last_poster = forum.last_poster
-                    parents[forum.parent_id].last_poster_name = forum.last_poster_name
-                    parents[forum.parent_id].last_poster_slug = forum.last_poster_slug
-                    parents[forum.parent_id].last_poster_style = forum.last_poster_style
+                if acl.can_browse(forum.pk):
+                    # If forum is unread, make parent unread too
+                    if not forum.is_read:
+                        parents[forum.parent_id].is_read = False
+                    # Sum stats
+                    if forum.last_thread_date and (not parents[forum.parent_id].last_thread_date or forum.last_thread_date > parents[forum.parent_id].last_thread_date):
+                        parents[forum.parent_id].last_thread = forum.last_thread
+                        parents[forum.parent_id].last_thread_name = forum.last_thread_name
+                        parents[forum.parent_id].last_thread_slug = forum.last_thread_slug
+                        parents[forum.parent_id].last_thread_date = forum.last_thread_date
+                        parents[forum.parent_id].last_poster = forum.last_poster
+                        parents[forum.parent_id].last_poster_name = forum.last_poster_name
+                        parents[forum.parent_id].last_poster_slug = forum.last_poster_slug
+                        parents[forum.parent_id].last_poster_style = forum.last_poster_style
         return forums_list
         return forums_list
 
 
 
 
@@ -68,6 +76,7 @@ class Forum(MPTTModel):
     prune_start = models.PositiveIntegerField(default=0)
     prune_start = models.PositiveIntegerField(default=0)
     prune_last = models.PositiveIntegerField(default=0)
     prune_last = models.PositiveIntegerField(default=0)
     redirect = models.CharField(max_length=255,null=True,blank=True)
     redirect = models.CharField(max_length=255,null=True,blank=True)
+    template = models.CharField(max_length=255,null=True,blank=True)
     style = models.CharField(max_length=255,null=True,blank=True)
     style = models.CharField(max_length=255,null=True,blank=True)
     closed = models.BooleanField(default=False)
     closed = models.BooleanField(default=False)
     
     

+ 11 - 4
misago/forums/views.py

@@ -1,3 +1,4 @@
+import copy
 from django.core.urlresolvers import reverse as django_reverse
 from django.core.urlresolvers import reverse as django_reverse
 from django.db.models import Q
 from django.db.models import Q
 from django.utils.translation import ugettext as _
 from django.utils.translation import ugettext as _
@@ -82,6 +83,7 @@ class NewCategory(FormWidget):
     def submit_form(self, form, target):
     def submit_form(self, form, target):
         new_forum = Forum(
         new_forum = Forum(
                      name=form.cleaned_data['name'],
                      name=form.cleaned_data['name'],
+                     template=form.cleaned_data['template'],
                      slug=slugify(form.cleaned_data['name']),
                      slug=slugify(form.cleaned_data['name']),
                      type='category',
                      type='category',
                      style=form.cleaned_data['style'],
                      style=form.cleaned_data['style'],
@@ -115,6 +117,7 @@ class NewForum(FormWidget):
                      name=form.cleaned_data['name'],
                      name=form.cleaned_data['name'],
                      slug=slugify(form.cleaned_data['name']),
                      slug=slugify(form.cleaned_data['name']),
                      type='forum',
                      type='forum',
+                     template=form.cleaned_data['template'],
                      style=form.cleaned_data['style'],
                      style=form.cleaned_data['style'],
                      closed=form.cleaned_data['closed'],
                      closed=form.cleaned_data['closed'],
                      prune_start=form.cleaned_data['prune_start'],
                      prune_start=form.cleaned_data['prune_start'],
@@ -219,15 +222,16 @@ class Edit(FormWidget):
     
     
     def get_form(self, target):
     def get_form(self, target):
         if target.type == 'category':
         if target.type == 'category':
-            self.name= _("Edit Category")
+            self.name = _("Edit Category")
             self.form = CategoryForm
             self.form = CategoryForm
         if target.type == 'redirect':
         if target.type == 'redirect':
-            self.name= _("Edit Redirect")
+            self.name = _("Edit Redirect")
             self.form = RedirectForm
             self.form = RedirectForm
         
         
         # Remove invalid targets from parent select
         # Remove invalid targets from parent select
+        self.form = copy.deepcopy(self.form)
         valid_targets = Forum.tree.get(token='root').get_descendants(include_self=target.type == 'category').exclude(Q(lft__gte=target.lft) & Q(rght__lte=target.rght))
         valid_targets = Forum.tree.get(token='root').get_descendants(include_self=target.type == 'category').exclude(Q(lft__gte=target.lft) & Q(rght__lte=target.rght))
-        self.form.fields['parent'] = TreeNodeChoiceField(queryset=valid_targets,level_indicator=u'- - ')
+        self.form.base_fields['parent'] = TreeNodeChoiceField(queryset=valid_targets,level_indicator=u'- - ')
         
         
         return self.form
         return self.form
     
     
@@ -241,6 +245,7 @@ class Edit(FormWidget):
         if model.type == 'redirect':
         if model.type == 'redirect':
             initial['redirect'] = model.redirect
             initial['redirect'] = model.redirect
         else:
         else:
+            initial['template'] = model.template
             initial['style'] = model.style
             initial['style'] = model.style
             initial['closed'] = model.closed
             initial['closed'] = model.closed
             
             
@@ -256,6 +261,7 @@ class Edit(FormWidget):
         if target.type == 'redirect':
         if target.type == 'redirect':
             target.redirect = form.cleaned_data['redirect']
             target.redirect = form.cleaned_data['redirect']
         else:
         else:
+            target.template = form.cleaned_data['template']
             target.style = form.cleaned_data['style']
             target.style = form.cleaned_data['style']
             target.closed = form.cleaned_data['closed']
             target.closed = form.cleaned_data['closed']
             
             
@@ -299,8 +305,9 @@ class Delete(FormWidget):
             self.name= _("Delete Redirect")
             self.name= _("Delete Redirect")
         
         
         # Remove invalid targets from parent select
         # Remove invalid targets from parent select
+        self.form = copy.deepcopy(self.form)
         valid_targets = Forum.tree.get(token='root').get_descendants(include_self=target.type == 'category').exclude(Q(lft__gte=target.lft) & Q(rght__lte=target.rght))
         valid_targets = Forum.tree.get(token='root').get_descendants(include_self=target.type == 'category').exclude(Q(lft__gte=target.lft) & Q(rght__lte=target.rght))
-        self.form.fields['parent'] = TreeNodeChoiceField(queryset=valid_targets,required=False,empty_label=_("Remove with forum"),level_indicator=u'- - ')
+        self.form.base_fields['parent'] = TreeNodeChoiceField(queryset=valid_targets,required=False,empty_label=_("Remove with forum"),level_indicator=u'- - ')
         
         
         return self.form
         return self.form
         
         

+ 0 - 0
misago/readstracker/__init__.py


+ 25 - 0
misago/readstracker/models.py

@@ -0,0 +1,25 @@
+from django.db import models
+import base64
+try:
+    import cPickle as pickle
+except ImportError:
+    import pickle
+
+class Record(models.Model):
+    user = models.ForeignKey('users.User')
+    forum = models.ForeignKey('forums.Forum')
+    threads = models.TextField(null=True,blank=True)
+    updated = models.DateTimeField()
+    cleared = models.DateTimeField()
+    
+    def get_threads(self):
+        try:
+            return pickle.loads(base64.decodestring(self.threads))
+        except Exception:
+            # ValueError, SuspiciousOperation, unpickling exceptions. If any of
+            # these happen, just return an empty dictionary (an empty permissions list).
+            return {}
+    
+    def set_threads(self, threads):
+        self.threads = base64.encodestring(pickle.dumps(threads, pickle.HIGHEST_PROTOCOL))
+    

+ 73 - 0
misago/readstracker/trackers.py

@@ -0,0 +1,73 @@
+from datetime import timedelta
+from django.conf import settings
+from django.utils import timezone
+from misago.readstracker.models import Record
+from misago.threads.models import Thread
+
+class ForumsTracker(object):
+    def __init__(self, user):
+        self.user = user
+        self.cutoff = timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH)
+        self.forums = {}
+        if self.user.is_authenticated() and settings.READS_TRACKER_LENGTH > 0:
+            for forum in Record.objects.filter(user=user).filter(updated__gte=self.cutoff).values('id', 'forum_id', 'updated', 'cleared'):
+                 self.forums[forum['forum_id']] = forum
+        print self.forums
+                 
+    def is_read(self, forum):
+        if not self.user.is_authenticated() or not forum.last_thread_date:
+            return True
+        try:
+            return forum.last_thread_date <= self.cutoff or forum.last_thread_date <= self.forums[forum.pk]['cleared']
+        except KeyError:
+            return False
+
+
+class ThreadsTracker(object):
+    def __init__(self, user, forum):
+        self.need_sync = False
+        self.need_update = False
+        self.user = user
+        self.forum = forum
+        self.cutoff = timezone.now() - timedelta(days=settings.READS_TRACKER_LENGTH)
+        try:
+            self.record = Record.objects.get(user=user,forum=forum)
+        except Record.DoesNotExist:
+            self.record = Record(user=user,forum=forum,cleared=self.cutoff)
+        self.threads = self.record.get_threads()
+    
+    def is_read(self, thread):
+        if not self.user.is_authenticated():
+            return True
+        try:
+            if thread.last <= self.cutoff and thread.pk in self.threads:
+                del self.threads[thread.pk]
+                self.need_update = True
+            return thread.last <= self.cutoff or thread.last <= self.threads[thread.pk]
+        except KeyError:
+            return False
+        
+    def set_read(self, thread, post):
+        if self.user.is_authenticated():
+            try:
+                if self.threads[thread.pk] < post.date:
+                    self.threads[thread.pk] = post.date
+                    self.need_sync = True
+            except KeyError:
+                self.threads[thread.pk] = post.date
+                self.need_sync = True
+                        
+    def sync(self):
+        now = timezone.now()
+        if self.need_sync:
+            unread_threads = 0
+            for thread in Thread.objects.filter(last__gte=self.record.cleared).all():
+                if not self.is_read(thread):
+                    unread_threads += 1
+            if not unread_threads:
+                self.record.cleared = now
+                
+        if self.need_sync or self.need_update:
+            self.record.updated = now
+            self.record.set_threads(self.threads)
+            self.record.save(force_update=self.record.pk)

+ 7 - 0
misago/settings_base.py

@@ -31,6 +31,12 @@ LOCALE_PATHS = (
 # If DEBUG_MODE is on, all emails will be sent to this address instead of real recipient.
 # If DEBUG_MODE is on, all emails will be sent to this address instead of real recipient.
 CATCH_ALL_EMAIL_ADDRESS = ''
 CATCH_ALL_EMAIL_ADDRESS = ''
 
 
+# Forums and threads read tracker length (days
+# Enter 0 to turn tracking off
+# The bigger the number, then longer tracker keeps threads reads
+# information and the more costful it is to track reads
+READS_TRACKER_LENGTH = 7
+
 # List of finder classes that know how to find static files in
 # List of finder classes that know how to find static files in
 # various locations.
 # various locations.
 STATICFILES_FINDERS = (
 STATICFILES_FINDERS = (
@@ -150,6 +156,7 @@ INSTALLED_APPS = (
     'misago.activation', # Activate inactive User or resend activation e-mail
     'misago.activation', # Activate inactive User or resend activation e-mail
     'misago.resetpswd', # Reset User Password
     'misago.resetpswd', # Reset User Password
     'misago.threads', # Threads and Posts
     'misago.threads', # Threads and Posts
+    'misago.readstracker', # Forums and Threads reads tracker
 )
 )
 
 
 # IP's that can see debug toolbar
 # IP's that can see debug toolbar

+ 6 - 1
misago/threads/views/list.py

@@ -4,6 +4,7 @@ from django.template import RequestContext
 from django.utils.translation import ugettext as _
 from django.utils.translation import ugettext as _
 from misago.acl.utils import ACLError403, ACLError404
 from misago.acl.utils import ACLError403, ACLError404
 from misago.forums.models import Forum
 from misago.forums.models import Forum
+from misago.readstracker.trackers import ForumsTracker, ThreadsTracker
 from misago.threads.models import Thread, Post
 from misago.threads.models import Thread, Post
 from misago.threads.views.base import BaseView
 from misago.threads.views.base import BaseView
 from misago.views import error403, error404
 from misago.views import error403, error404
@@ -14,13 +15,17 @@ class ThreadsView(BaseView):
         self.forum = Forum.objects.get(pk=forum, type='forum')
         self.forum = Forum.objects.get(pk=forum, type='forum')
         self.request.acl.forums.allow_forum_view(self.forum)
         self.request.acl.forums.allow_forum_view(self.forum)
         self.parents = self.forum.get_ancestors().filter(level__gt=1)
         self.parents = self.forum.get_ancestors().filter(level__gt=1)
-        
+        self.forum.subforums = Forum.objects.treelist(self.request.acl.forums, self.forum, tracker=ForumsTracker(self.request.user))
+        self.tracker = ThreadsTracker(self.request.user, self.forum)
+                
     def fetch_threads(self, page):
     def fetch_threads(self, page):
         self.count = Thread.objects.filter(forum=self.forum).count()
         self.count = Thread.objects.filter(forum=self.forum).count()
         self.threads = Thread.objects.filter(forum=self.forum).order_by('-weight', '-last').all()
         self.threads = Thread.objects.filter(forum=self.forum).order_by('-weight', '-last').all()
         self.pagination = make_pagination(page, self.count, self.request.settings.threads_per_page)
         self.pagination = make_pagination(page, self.count, self.request.settings.threads_per_page)
         if self.request.settings.threads_per_page < self.count:
         if self.request.settings.threads_per_page < self.count:
             self.threads = self.threads[self.pagination['start']:self.pagination['stop']]
             self.threads = self.threads[self.pagination['start']:self.pagination['stop']]
+        for thread in self.threads:
+            thread.is_read = self.tracker.is_read(thread)
     
     
     def __call__(self, request, slug=None, forum=None, page=0):
     def __call__(self, request, slug=None, forum=None, page=0):
         self.request = request
         self.request = request

+ 5 - 4
misago/threads/views/posting.py

@@ -63,14 +63,15 @@ class PostingView(BaseView):
         if request.method == 'POST':
         if request.method == 'POST':
             form = self.get_form(True)
             form = self.get_form(True)
             if form.is_valid():
             if form.is_valid():
+                now = timezone.now()
                 # Get or create new thread
                 # Get or create new thread
                 if self.mode in ['new_thread', 'edit_thread']:
                 if self.mode in ['new_thread', 'edit_thread']:
                     thread = Thread.objects.create(
                     thread = Thread.objects.create(
                                                    forum=self.forum,
                                                    forum=self.forum,
                                                    name=form.cleaned_data['thread_name'],
                                                    name=form.cleaned_data['thread_name'],
                                                    slug=slugify(form.cleaned_data['thread_name']),
                                                    slug=slugify(form.cleaned_data['thread_name']),
-                                                   start=timezone.now(),
-                                                   last=timezone.now(),
+                                                   start=now,
+                                                   last=now,
                                                    )
                                                    )
                 else:
                 else:
                     thread = self.thread
                     thread = self.thread
@@ -85,7 +86,7 @@ class PostingView(BaseView):
                                            agent=request.META.get('HTTP_USER_AGENT'),
                                            agent=request.META.get('HTTP_USER_AGENT'),
                                            post=form.cleaned_data['post'],
                                            post=form.cleaned_data['post'],
                                            post_preparsed=post_markdown(request, form.cleaned_data['post']),
                                            post_preparsed=post_markdown(request, form.cleaned_data['post']),
-                                           date=timezone.now()
+                                           date=now
                                            )
                                            )
                 
                 
                 if self.mode == 'new_thread':
                 if self.mode == 'new_thread':
@@ -96,7 +97,7 @@ class PostingView(BaseView):
                     if request.user.rank and request.user.rank.style:
                     if request.user.rank and request.user.rank.style:
                         thread.start_poster_style = request.user.rank.style
                         thread.start_poster_style = request.user.rank.style
                     
                     
-                thread.last = timezone.now()
+                thread.last = now
                 thread.last_post = post
                 thread.last_post = post
                 thread.last_poster = request.user
                 thread.last_poster = request.user
                 thread.last_poster_name = request.user.username
                 thread.last_poster_name = request.user.username

+ 7 - 2
misago/threads/views/thread.py

@@ -5,6 +5,7 @@ from django.utils.translation import ugettext as _
 from misago.acl.utils import ACLError403, ACLError404
 from misago.acl.utils import ACLError403, ACLError404
 from misago.forms import FormFields
 from misago.forms import FormFields
 from misago.forums.models import Forum
 from misago.forums.models import Forum
+from misago.readstracker.trackers import ThreadsTracker
 from misago.threads.forms import QuickReplyForm
 from misago.threads.forms import QuickReplyForm
 from misago.threads.models import Thread, Post
 from misago.threads.models import Thread, Post
 from misago.threads.views.base import BaseView
 from misago.threads.views.base import BaseView
@@ -18,14 +19,18 @@ class ThreadView(BaseView):
         self.request.acl.forums.allow_forum_view(self.forum)
         self.request.acl.forums.allow_forum_view(self.forum)
         self.request.acl.threads.allow_thread_view(self.thread)
         self.request.acl.threads.allow_thread_view(self.thread)
         self.parents = self.forum.get_ancestors(include_self=True).filter(level__gt=1)
         self.parents = self.forum.get_ancestors(include_self=True).filter(level__gt=1)
+        self.tracker = ThreadsTracker(self.request.user, self.forum)
     
     
     def fetch_posts(self, page):
     def fetch_posts(self, page):
         self.count = Post.objects.filter(thread=self.thread).count()
         self.count = Post.objects.filter(thread=self.thread).count()
-        self.posts = Post.objects.filter(thread=self.thread).order_by('pk').all()
+        self.posts = Post.objects.filter(thread=self.thread).order_by('pk').all().prefetch_related('user', 'user__rank')
         self.pagination = make_pagination(page, self.count, self.request.settings.posts_per_page)
         self.pagination = make_pagination(page, self.count, self.request.settings.posts_per_page)
         if self.request.settings.threads_per_page < self.count:
         if self.request.settings.threads_per_page < self.count:
             self.posts = self.posts[self.pagination['start']:self.pagination['stop']]
             self.posts = self.posts[self.pagination['start']:self.pagination['stop']]
-        self.posts.prefetch_related('user', 'user__rank')
+        last_post = self.posts[len(self.posts) - 1]
+        if not self.tracker.is_read(self.thread):
+            self.tracker.set_read(self.thread, last_post)
+            self.tracker.sync()
         
         
     def __call__(self, request, slug=None, thread=None, page=0):
     def __call__(self, request, slug=None, thread=None, page=0):
         self.request = request
         self.request = request

+ 1 - 1
misago/users/models.py

@@ -146,7 +146,7 @@ class User(models.Model):
     following = models.PositiveIntegerField(default=0)
     following = models.PositiveIntegerField(default=0)
     followers = models.PositiveIntegerField(default=0)
     followers = models.PositiveIntegerField(default=0)
     score = models.IntegerField(default=0,db_index=True)
     score = models.IntegerField(default=0,db_index=True)
-    rank = models.ForeignKey('ranks.Rank',null=True,blank=True,db_index=True,on_delete=models.SET_NULL)
+    rank = models.ForeignKey('ranks.Rank',null=True,blank=True,on_delete=models.SET_NULL)
     last_sync = models.DateTimeField(null=True,blank=True)
     last_sync = models.DateTimeField(null=True,blank=True)
     follows = models.ManyToManyField('self',related_name='follows_set',symmetrical=False)
     follows = models.ManyToManyField('self',related_name='follows_set',symmetrical=False)
     ignores = models.ManyToManyField('self',related_name='ignores_set',symmetrical=False)
     ignores = models.ManyToManyField('self',related_name='ignores_set',symmetrical=False)

+ 5 - 3
misago/views.py

@@ -3,6 +3,7 @@ from django.shortcuts import redirect
 from django.template import RequestContext
 from django.template import RequestContext
 from django.utils.translation import ugettext as _
 from django.utils.translation import ugettext as _
 from misago.forums.models import Forum
 from misago.forums.models import Forum
+from misago.readstracker.trackers import ForumsTracker
 from misago.sessions.models import Session
 from misago.sessions.models import Session
 
 
 def home(request):
 def home(request):
@@ -12,10 +13,10 @@ def home(request):
         if session.user.pk not in team_pks:
         if session.user.pk not in team_pks:
             team_pks.append(session.user.pk)
             team_pks.append(session.user.pk)
             team_online.append(session.user)
             team_online.append(session.user)
-            
+    reads_tracker = ForumsTracker(request.user)
     return request.theme.render_to_response('index.html',
     return request.theme.render_to_response('index.html',
                                             {
                                             {
-                                             'forums_list': Forum.objects.treelist(request.acl.forums),
+                                             'forums_list': Forum.objects.treelist(request.acl.forums, tracker=reads_tracker),
                                              'team_online': team_online,
                                              'team_online': team_online,
                                              },
                                              },
                                             context_instance=RequestContext(request));
                                             context_instance=RequestContext(request));
@@ -30,11 +31,12 @@ def category(request, forum, slug):
             return error403(request, _("You don't have permission to browse this category."))
             return error403(request, _("You don't have permission to browse this category."))
     except Forum.DoesNotExist:
     except Forum.DoesNotExist:
         return error404(request)
         return error404(request)
+
+    forum.subforums = Forum.objects.treelist(request.acl.forums, forum, tracker=ForumsTracker(request.user))
     return request.theme.render_to_response('category.html',
     return request.theme.render_to_response('category.html',
                                             {
                                             {
                                              'category': forum,
                                              'category': forum,
                                              'parents': forum.get_ancestors().filter(level__gt=1),
                                              'parents': forum.get_ancestors().filter(level__gt=1),
-                                             'forums_list': Forum.objects.treelist(request.acl.forums, forum),
                                              },
                                              },
                                             context_instance=RequestContext(request));
                                             context_instance=RequestContext(request));
 
 

+ 8 - 3
static/sora/css/sora.css

@@ -943,19 +943,24 @@ td.lead-cell{color:#555555;font-weight:bold;}
 .nav-tabs .tab-search form{marging:0px;margin-bottom:-4px;}
 .nav-tabs .tab-search form{marging:0px;margin-bottom:-4px;}
 .nav-tabs .tab-search.tab-search-no-tabs{position:relative;bottom:12px;}
 .nav-tabs .tab-search.tab-search-no-tabs{position:relative;bottom:12px;}
 .nav-tabs button{padding-left:7px;padding-right:7px;}
 .nav-tabs button{padding-left:7px;padding-right:7px;}
-.forums-list{padding-top:4px;}.forums-list .category{margin-bottom:12px;}.forums-list .category h2{color:#666666;font-size:110%;margin-bottom:0px;}.forums-list .category h2 small{color:#a6a6a6;font-size:100%;}
+.forums-list{padding-top:4px;}.forums-list .category h2{color:#666666;font-size:110%;margin-bottom:0px;}.forums-list .category h2 small{color:#a6a6a6;font-size:100%;}
 .forums-list .category-important .well-forum{border:1px solid #0099e6;-webkit-box-shadow:0px 0px 0px 3px #66ccff;-moz-box-shadow:0px 0px 0px 3px #66ccff;box-shadow:0px 0px 0px 3px #66ccff;}
 .forums-list .category-important .well-forum{border:1px solid #0099e6;-webkit-box-shadow:0px 0px 0px 3px #66ccff;-moz-box-shadow:0px 0px 0px 3px #66ccff;box-shadow:0px 0px 0px 3px #66ccff;}
 .forums-list .well-forum{margin:0px -8px;margin-bottom:14px;overflow:auto;padding:12px 8px;padding-bottom:8px;}.forums-list .well-forum .row .span3{margin-left:0px;padding-left:16px;}
 .forums-list .well-forum{margin:0px -8px;margin-bottom:14px;overflow:auto;padding:12px 8px;padding-bottom:8px;}.forums-list .well-forum .row .span3{margin-left:0px;padding-left:16px;}
-.forums-list .well-forum .forum-icon{background-color:#0088cc;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;float:left;padding:3px 6px;position:relative;bottom:4px;margin-bottom:-4px;}
+.forums-list .well-forum .forum-icon{background-color:#eeeeee;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;float:left;padding:3px 6px;position:relative;bottom:4px;margin-bottom:-4px;}
+.forums-list .well-forum .forum-new{background-color:#0088cc !important;}
 .forums-list .well-forum .redirect-icon{background-color:#46a546;}
 .forums-list .well-forum .redirect-icon{background-color:#46a546;}
 .forums-list .well-forum .forum-details{margin-left:36px;overflow:auto;}
 .forums-list .well-forum .forum-details{margin-left:36px;overflow:auto;}
 .forums-list .well-forum .forum-stat{margin-left:16px;margin-right:4px;padding:8px 12px;font-size:140%;}.forums-list .well-forum .forum-stat .muted{color:#999999;}
 .forums-list .well-forum .forum-stat{margin-left:16px;margin-right:4px;padding:8px 12px;font-size:140%;}.forums-list .well-forum .forum-stat .muted{color:#999999;}
 .forums-list .well-forum .forum-stat .stag,.forums-list .well-forum .forum-stat .positive{-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;margin:-2px 0px;padding:2px 5px;text-shadow:0px 1px 0px #ffffff;}
 .forums-list .well-forum .forum-stat .stag,.forums-list .well-forum .forum-stat .positive{-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;margin:-2px 0px;padding:2px 5px;text-shadow:0px 1px 0px #ffffff;}
 .forums-list .well-forum .forum-stat .stag{background-color:#eeeeee;color:#8c8c8c;}
 .forums-list .well-forum .forum-stat .stag{background-color:#eeeeee;color:#8c8c8c;}
 .forums-list .well-forum .forum-stat .positive{background-color:#cdeacd;color:#3e933e;}
 .forums-list .well-forum .forum-stat .positive{background-color:#cdeacd;color:#3e933e;}
-.forums-list .well-forum h3{margin:0px;padding:0px;font-size:110%;line-height:100%;}.forums-list .well-forum h3 a:link,.forums-list .well-forum h3 a:active,.forums-list .well-forum h3 a:visited{color:#333333;}
+.forums-list .well-forum h3{margin:0px;padding:0px;font-size:110%;line-height:100%;}.forums-list .well-forum h3 .read-forum{color:#75a5bd;}
 .forums-list .well-forum .muted{margin-top:4px;color:#737373;}
 .forums-list .well-forum .muted{margin-top:4px;color:#737373;}
 .forum-list-side{padding-top:10px;}
 .forum-list-side{padding-top:10px;}
+.subforums-list{margin:0px;position:relative;bottom:32px;}.subforums-list .category{margin-bottom:-24px;}
+.threads-list .thread-icon{background-color:#999999;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;padding:5px 6px;}
+.threads-list .thread-closed{background-color:#9d261d;}
+.threads-list .thread-new{background-color:#0088cc;}
 .thread-info{overflow:auto;}.thread-info li{float:left;margin-right:16px;opacity:0.5;filter:alpha(opacity=50);font-weight:bold;}.thread-info li a{color:#333333;}
 .thread-info{overflow:auto;}.thread-info li{float:left;margin-right:16px;opacity:0.5;filter:alpha(opacity=50);font-weight:bold;}.thread-info li a{color:#333333;}
 .posts-list{margin-top:12px;margin-bottom:20px;}.posts-list .well-post{margin:0px;margin-bottom:16px;overflow:auto;padding:16px 0px;}.posts-list .well-post .post-author{overflow:auto;float:left;width:284px;position:relative;bottom:4px;}.posts-list .well-post .post-author .avatar-normal{float:left;margin:4px 0px;margin-left:16px;width:80px;height:80px;-webkit-box-shadow:0px 0px 4px #999999;-moz-box-shadow:0px 0px 4px #999999;box-shadow:0px 0px 4px #999999;}
 .posts-list{margin-top:12px;margin-bottom:20px;}.posts-list .well-post{margin:0px;margin-bottom:16px;overflow:auto;padding:16px 0px;}.posts-list .well-post .post-author{overflow:auto;float:left;width:284px;position:relative;bottom:4px;}.posts-list .well-post .post-author .avatar-normal{float:left;margin:4px 0px;margin-left:16px;width:80px;height:80px;-webkit-box-shadow:0px 0px 4px #999999;-moz-box-shadow:0px 0px 4px #999999;box-shadow:0px 0px 4px #999999;}
 .posts-list .well-post .post-author .post-bit{float:left;margin-left:12px;padding-top:4px;font-weight:bold;font-size:120%;}.posts-list .well-post .post-author .post-bit p{margin:0px;}
 .posts-list .well-post .post-author .post-bit{float:left;margin-left:12px;padding-top:4px;font-weight:bold;font-size:120%;}.posts-list .well-post .post-author .post-bit p{margin:0px;}

+ 8 - 6
static/sora/css/sora/forums.less

@@ -13,9 +13,7 @@
         color: lighten(@textColor, 45%);
         color: lighten(@textColor, 45%);
         font-size: 100%;
         font-size: 100%;
       }
       }
-    } 
-    
-    margin-bottom: 12px;
+    }
   }
   }
   
   
   .category-important {
   .category-important {
@@ -40,7 +38,7 @@
     }
     }
     
     
     .forum-icon {
     .forum-icon {
-      background-color: @linkColor; 
+      background-color: @grayLighter; 
       .border-radius(3px);
       .border-radius(3px);
       float: left;
       float: left;
       padding: 3px 6px;
       padding: 3px 6px;
@@ -49,6 +47,10 @@
       margin-bottom: -4px;
       margin-bottom: -4px;
     }
     }
     
     
+    .forum-new {
+      background-color: @linkColor !important; 
+    }
+    
     .redirect-icon {
     .redirect-icon {
       background-color: @green; 
       background-color: @green; 
     }
     }
@@ -95,8 +97,8 @@
       font-size: 110%;
       font-size: 110%;
       line-height: 100%;
       line-height: 100%;
       
       
-      a:link, a:active, a:visited {
-        color: @textColor;
+      .read-forum {
+        color: lighten(desaturate(@linkColor, 65%), 20%);
       }
       }
     }
     }
     
     

+ 30 - 0
static/sora/css/sora/threads.less

@@ -1,5 +1,35 @@
 // Misago Threads Styles
 // Misago Threads Styles
 // --------------------------------------------------
 // --------------------------------------------------
+.subforums-list {
+  margin: 0px;
+  position: relative;
+  bottom: 32px;
+  
+  .category {
+    margin-bottom: -24px;
+  }
+}
+
+// Threads list
+// --------------------------------------------------
+.threads-list {
+  .thread-icon {
+    background-color: @grayLight; 
+    .border-radius(3px);
+    padding: 5px 6px;
+  }
+  
+  .thread-closed {
+    background-color: @red; 
+  }
+  
+  .thread-new {
+    background-color: @linkColor; 
+  }
+}
+
+// Thread view
+// --------------------------------------------------
 .thread-info {
 .thread-info {
   overflow: auto;
   overflow: auto;
   
   

+ 4 - 3
templates/sora/category.html

@@ -14,13 +14,14 @@
 
 
 {% block content %}
 {% block content %}
 <div class="page-header">
 <div class="page-header">
+  <ul class="breadcrumb">
+    {{ self.breadcrumb() }}</li>
+  </ul>
   <h1>{{ category.name }}{% if category.description %} <small>{{ category.description }}</small>{% endif %}</h1>
   <h1>{{ category.name }}{% if category.description %} <small>{{ category.description }}</small>{% endif %}</h1>
 </div>
 </div>
 <div class="forums-list">
 <div class="forums-list">
   <div class="category{% if category.style %} {{ category.style }}{% endif %}">
   <div class="category{% if category.style %} {{ category.style }}{% endif %}">
-    {% for forum in forums_list %}
-    {{ macros.draw_forum(category, forum) }}
-    {% endfor %}
+    {{ macros.draw_forums(category, 12) }}
   </div>
   </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 0 - 3
templates/sora/forums_list.html

@@ -1,3 +0,0 @@
-{% load i18n %}
-{% load url from future %}
-

+ 1 - 3
templates/sora/index.html

@@ -16,9 +16,7 @@
       {% for category in forums_list %}{% if category.subforums %}
       {% for category in forums_list %}{% if category.subforums %}
       <div class="category{% if category.style %} {{ category.style }}{% endif %}">
       <div class="category{% if category.style %} {{ category.style }}{% endif %}">
         <h2>{{ category.name }}{% if category.description %} <small><strong>{{ category.description }}</strong></small>{% endif %}</h2>
         <h2>{{ category.name }}{% if category.description %} <small><strong>{{ category.description }}</strong></small>{% endif %}</h2>
-        {% for forum in category.subforums %}
-        {{ macros.draw_forum(category, forum) }}
-        {% endfor %}
+        {{ macros.draw_forums(category, 8) }}
       </div>{% endif %}{% endfor %}
       </div>{% endif %}{% endfor %}
     </div>
     </div>
   </div>
   </div>

+ 44 - 19
templates/sora/macros.html

@@ -26,27 +26,52 @@
   		{%- endif %} icon-white"></i></span></div>
   		{%- endif %} icon-white"></i></span></div>
 {%- endmacro %}
 {%- endmacro %}
 
 
-{# Render forum on list #}
-{% macro draw_forum(category, forum) %}
-<div class="well well-forum{% if forum.style %} {{ forum.style }}{% endif %}">
-  <div class="forum-icon{% if forum.type == 'redirect' %} redirect-icon{% endif %}"><i class="icon-{% if forum.type == 'redirect' %}circle-arrow-right{% else %}comment{% endif %} icon-white"></i></div>
-  <div class="forum-details">
-    <div class="pull-left">
-      <h3><a href="{{ forum.type|url(slug=forum.slug, forum=forum.id) }}">{{ forum.name }}</a></h3>
-      {% if forum.description %}<div class="muted">{{ forum.description }}</div>{% endif %}
-    </div>
-    {% if forum.type == 'redirect' %}
-    <div class="pull-right forum-stat stat-redirects">
-      <span class="stat {% if forum.redirects_delta > 0 %}positive{% else %}stag{% endif %}">{% if forum.redirects_delta > 0 %}+{{ forum.redirects_delta }}{% else %}{{ forum.redirects }}{% endif %}</span> <span class="muted">{% trans %}clicks{% endtrans %}</span>
-    </div>
-    {% else %}
-    <div class="pull-right forum-stat stat-posts">
-      <span class="stat {% if forum.posts_delta > 0 %}positive{% else %}stag{% endif %}">{% if forum.posts_delta > 0 %}+{{ forum.posts_delta }}{% else %}{{ forum.posts }}{% endif %}</span> <span class="muted">{% trans %}posts{% endtrans %}</span>
-    </div>
-    <div class="pull-right forum-stat stat-threads">
-      <span class="stat {% if forum.threads_delta > 0 %}positive{% else %}stag{% endif %}">{% if forum.posts_delta > 0 %}+{{ forum.threads_delta }}{% else %}{{ forum.threads }}{% endif %}</span> <span class="muted">{% trans %}threads{% endtrans %}</span>
+{# Render forums list #}
+{% macro draw_forums(category, width=12) %}
+{% if category.template != 'row' %}
+<div class="row">
+  {% for forum in category.subforums %}
+    {{ draw_forum(category, forum, width) }}
+    {% if not loop.last and ((category.template == 'half' and loop.index is even()) or (category.template == 'quad' and loop.index is divisibleby(4))) %}
     </div>
     </div>
+    <div class="row">
     {% endif %}
     {% endif %}
+  {% endfor %}
+</div>
+{% else %}
+{% for forum in category.subforums %}
+  {{ draw_forum(category, forum, width) }}
+{% endfor %}
+{% endif %}
+{% endmacro %}
+
+{# Render forum on list #}
+{% macro draw_forum(category, forum, width=12) %}
+<div{% if category.template != 'row' %} class="{% if category.template == 'half' %}span{{ widthratio(50, 100, width) }}{% elif category.template == 'quarter' %}span{{ widthratio(25, 100, width) }}{% endif %}"{% endif %}>
+  <div class="well well-forum{% if forum.style %} {{ forum.style }}{% endif %}">
+    <div class="forum-icon {% if forum.type == 'redirect' %} redirect-icon{% elif not forum.is_read %} forum-new{% endif %}"><i class="icon-{% if forum.type == 'redirect' %}circle-arrow-right{% else %}comment{% endif %} icon-white"></i></div>
+    {% if category.template != 'quarter' %}<div class="forum-details">{% endif %}
+      <div class="pull-left">
+        <h3><a href="{{ forum.type|url(slug=forum.slug, forum=forum.id) }}"{% if forum.type != 'redirect' and forum.is_read %} class="read-forum"{% endif %}>{{ forum.name }}</a></h3>
+        {% if forum.description %}<div class="muted">{{ forum.description }}</div>{% endif %}
+      </div>
+      {% if category.template != 'quarter' %}
+      {% if forum.type == 'redirect' %}
+      <div class="pull-right forum-stat stat-redirects">
+        <span class="stat {% if forum.redirects_delta > 0 %}positive{% else %}stag{% endif %}">{% if forum.redirects_delta > 0 %}+{{ forum.redirects_delta }}{% else %}{{ forum.redirects }}{% endif %}</span> <span class="muted">{% trans %}clicks{% endtrans %}</span>
+      </div>
+      {% else %}
+      <div class="pull-right forum-stat stat-posts">
+        <span class="stat {% if forum.posts_delta > 0 %}positive{% else %}stag{% endif %}">{% if forum.posts_delta > 0 %}+{{ forum.posts_delta }}{% else %}{{ forum.posts }}{% endif %}</span> <span class="muted">{% trans %}posts{% endtrans %}</span>
+      </div>
+      {% if category.template != 'half' %}
+      <div class="pull-right forum-stat stat-threads">
+        <span class="stat {% if forum.threads_delta > 0 %}positive{% else %}stag{% endif %}">{% if forum.posts_delta > 0 %}+{{ forum.threads_delta }}{% else %}{{ forum.threads }}{% endif %}</span> <span class="muted">{% trans %}threads{% endtrans %}</span>
+      </div>
+      {% endif %}
+      {% endif %}
+      {% endif %}
+    {% if category.template != 'quarter' %}</div>{% endif %}
   </div>
   </div>
 </div>
 </div>
 {% endmacro %}
 {% endmacro %}

+ 13 - 3
templates/sora/threads/list.html

@@ -19,6 +19,14 @@
   </ul>
   </ul>
   <h1>{{ forum.name }}{% if forum.description %} <small>{{ forum.description }}</small>{% endif %}</h1>
   <h1>{{ forum.name }}{% if forum.description %} <small>{{ forum.description }}</small>{% endif %}</h1>
 </div>
 </div>
+{% if forum.subforums %}
+<div class="forums-list subforums-list">
+  <h3>{% trans %}Subforums{% endtrans %}:</h3>
+  <div class="category{% if forum.style %} {{ forum.style }}{% endif %}">
+    {{ macros.draw_forums(forum, 12) }}
+  </div>
+</div>
+{% endif %}
 {% if message %}{{ macros.draw_message(message) }}{% endif %}
 {% if message %}{{ macros.draw_message(message) }}{% endif %}
 <div class="list-nav">
 <div class="list-nav">
   {{ pager() }}
   {{ pager() }}
@@ -28,7 +36,7 @@
   </ul>
   </ul>
   {% endif %}
   {% endif %}
 </div>
 </div>
-<table class="table table-striped">
+<table class="table table-striped threads-list">
   <thead>
   <thead>
     <tr>
     <tr>
       <th style="width: 1%;">&nbsp;</th>
       <th style="width: 1%;">&nbsp;</th>
@@ -41,8 +49,10 @@
   <tbody>
   <tbody>
     {% for thread in threads %}
     {% for thread in threads %}
     <tr>
     <tr>
-      <td>[ICON]</td>
-      <td><a href="{% url 'thread' thread=thread.pk, slug=thread.slug %}"><strong>{{ thread.name }}</strong></a>[LABELS][JUMP] [ICONS]</td>
+      <td><span class="thread-icon{% if not thread.is_read %} {% if thread.closed %}thread-closed{% else %}thread-new{% endif %}{% endif %}"><i class="icon-{% if thread.closed %}remove{% else %}comment{% endif %} icon-white"></i></span></td>
+      <td>
+        <a href="{% url 'thread' thread=thread.pk, slug=thread.slug %}">{% if not thread.is_read %}<strong>{{ thread.name }}</strong>{% else %}{{ thread.name }}{% endif %}</a>[LABELS][JUMP] [ICONS]
+      </td>
       <td>{% if thread.start_poster_id %}<a href="{% url 'user' user=thread.start_poster_id, username=thread.start_poster_slug %}" class="tooltip-top" title="{{ thread.start|reltimesince }}">{{ thread.start_poster_name }}</a>{% else %}<em class="tooltip-top muted" title="{{ thread.start|reltimesince }}">{{ thread.start_poster_name }}</em>{% endif %}</td>
       <td>{% if thread.start_poster_id %}<a href="{% url 'user' user=thread.start_poster_id, username=thread.start_poster_slug %}" class="tooltip-top" title="{{ thread.start|reltimesince }}">{{ thread.start_poster_name }}</a>{% else %}<em class="tooltip-top muted" title="{{ thread.start|reltimesince }}">{{ thread.start_poster_name }}</em>{% endif %}</td>
       <td>{{ thread.replies|intcomma }}</td>
       <td>{{ thread.replies|intcomma }}</td>
       <td>{% if thread.last_poster_id %}<a href="{% url 'user' user=thread.last_poster_id, username=thread.last_poster_slug %}" class="tooltip-top" title="{{ thread.last|reltimesince }}">{{ thread.last_poster_name }}</a>{% else %}<em class="tooltip-top muted" title="{{ thread.last|reltimesince }}">{{ thread.last_poster_name }}</em>{% endif %}</td>
       <td>{% if thread.last_poster_id %}<a href="{% url 'user' user=thread.last_poster_id, username=thread.last_poster_slug %}" class="tooltip-top" title="{{ thread.last|reltimesince }}">{{ thread.last_poster_name }}</a>{% else %}<em class="tooltip-top muted" title="{{ thread.last|reltimesince }}">{{ thread.last_poster_name }}</em>{% endif %}</td>