changeset 113:d97ceb95ce02

Forums: ForumLastVisit logic in place. Need to add code for topics and posts next.
author Brian Neal <bgneal@gmail.com>
date Sun, 11 Oct 2009 19:10:54 +0000
parents d1b0b86441c0
children 535d02d1c017
files gpp/forums/admin.py gpp/forums/models.py gpp/forums/unread.py gpp/forums/urls.py gpp/forums/views.py gpp/templates/forums/display_post.html gpp/templates/forums/forum_index.html gpp/templates/forums/index.html media/icons/new.png
diffstat 9 files changed, 257 insertions(+), 10 deletions(-) [+]
line wrap: on
line diff
--- a/gpp/forums/admin.py	Wed Sep 30 00:42:13 2009 +0000
+++ b/gpp/forums/admin.py	Sun Oct 11 19:10:54 2009 +0000
@@ -8,6 +8,7 @@
 from forums.models import Topic
 from forums.models import Post
 from forums.models import FlaggedPost
+from forums.models import TopicLastVisit
 
 
 class CategoryAdmin(admin.ModelAdmin):
@@ -48,8 +49,14 @@
     list_display = ('__unicode__', 'flag_date', 'get_post_url')
 
 
+class TopicLastVisitAdmin(admin.ModelAdmin):
+    raw_id_fields = ('user', 'topic')
+    list_display = ('user', 'topic', 'last_visit')
+
+
 admin.site.register(Category, CategoryAdmin)
 admin.site.register(Forum, ForumAdmin)
 admin.site.register(Topic, TopicAdmin)
 admin.site.register(Post, PostAdmin)
 admin.site.register(FlaggedPost, FlaggedPostAdmin)
+admin.site.register(TopicLastVisit, TopicLastVisitAdmin)
--- a/gpp/forums/models.py	Wed Sep 30 00:42:13 2009 +0000
+++ b/gpp/forums/models.py	Sun Oct 11 19:10:54 2009 +0000
@@ -9,6 +9,9 @@
 from django.template.loader import render_to_string
 
 
+POST_EDIT_DELTA = datetime.timedelta(seconds=3)
+
+
 class Category(models.Model):
     """
     Forums belong to a category, whose access may be assigned to groups.
@@ -128,6 +131,23 @@
         except Post.DoesNotExist:
             self.last_post = None
 
+    def catchup(self, user, flv=None):
+        """
+        Call to mark this forum all caught up for the given user (i.e. mark all topics
+        read for this user).
+        """
+        TopicLastVisit.objects.filter(user=user, topic__forum=self).delete()
+        if flv is None:
+            try:
+                flv = ForumLastVisit.objects.get(user=user, forum=self)
+            except ForumLastVisit.DoesNotExist:
+                flv = ForumLastVisit(user=user, forum=self)
+
+        now = datetime.datetime.now()
+        flv.begin_date = now
+        flv.end_date = now
+        flv.save()
+
 
 class Topic(models.Model):
     """
@@ -241,6 +261,9 @@
         if self.id == first_post_id:
             self.topic.delete()
 
+    def has_been_edited(self):
+        return (self.update_date - self.creation_date) > POST_EDIT_DELTA
+
 
 class FlaggedPost(models.Model):
     """This model represents a user flagging a post as inappropriate."""
@@ -258,4 +281,55 @@
         return '<a href="%s">Post</a>' % self.post.get_absolute_url()
     get_post_url.allow_tags = True
 
-# TODO: A "read" table
+
+class ForumLastVisit(models.Model):
+    """
+    This model records the last time a user visited a forum.
+    It is used to compute if a user has unread topics in a forum.
+    We keep track of a window of time, delimited by begin_date and end_date.
+    Topics updated within this window are tracked, and may have TopicLastVisit
+    objects.
+    Marking a forum as all read sets the begin_date equal to the end_date.
+    """
+    user = models.ForeignKey(User)
+    forum = models.ForeignKey(Forum)
+    begin_date = models.DateTimeField()
+    end_date = models.DateTimeField()
+
+    class Meta:
+        unique_together = ('user', 'forum')
+        ordering = ('-end_date', )
+
+    def __unicode__(self):
+        return u'Forum: %d User: %d Date: %s' % (self.forum.id, self.user.id,
+                self.end_date.strftime('%Y-%m-%d %H:%M:%S'))
+
+    def is_caught_up(self):
+        return self.begin_date == self.end_date
+
+
+class TopicLastVisit(models.Model):
+    """
+    This model records the last time a user read a topic.
+    Objects of this class exist for the window specified in the
+    corresponding ForumLastVisit object.
+    """
+    user = models.ForeignKey(User)
+    topic = models.ForeignKey(Topic)
+    last_visit = models.DateTimeField()
+
+    class Meta:
+        unique_together = ('user', 'topic')
+        ordering = ('-last_visit', )
+
+    def __unicode__(self):
+        return u'Topic: %d User: %d Date: %s' % (self.topic.id, self.user.id,
+                self.last_visit.strftime('%Y-%m-%d %H:%M:%S'))
+
+    def save(self, *args, **kwargs):
+        if self.id is None:
+            self.touch()
+        super(TopicLastVisit, self).save(*args, **kwargs)
+        
+    def touch(self):
+        self.last_visit = datetime.datetime.now()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/forums/unread.py	Sun Oct 11 19:10:54 2009 +0000
@@ -0,0 +1,99 @@
+"""
+This file contains routines for implementing the "has unread" feature. 
+Forums, topics, and posts are displayed with a visual indication if they have
+been read or not.
+"""
+import datetime
+
+from forums.models import ForumLastVisit, TopicLastVisit, Topic
+
+
+THRESHOLD = datetime.timedelta(days=7)
+
+
+def get_forum_unread_status(qs, user):
+    if not user.is_authenticated():
+        for forum in qs:
+            forum.has_unread = False
+        return
+
+    now = datetime.datetime.now()
+    min_date = now - THRESHOLD
+
+    # retrieve ForumLastVisit records in one SQL query
+    forum_ids = [forum.id for forum in qs]
+    flvs = ForumLastVisit.objects.filter(user=user, 
+            forum__in=forum_ids).select_related()
+    flvs = dict([(flv.forum.id, flv) for flv in flvs])
+
+    for forum in qs:
+        # Edge case: forum has no posts
+        if forum.last_post is None:
+            forum.has_unread = False
+            continue
+
+        # Get the ForumLastVisit record
+        if forum.id in flvs:
+            flv = flvs[forum.id]
+        else:
+            # One doesn't exist, create a default one for next time,
+            # mark it as having no unread topics, and bail.
+            flv = ForumLastVisit(user=user, forum=forum)
+            flv.begin_date = now
+            flv.end_date = now
+            flv.save()
+            forum.has_unread = False
+            continue
+
+        # If the last visit record was too far in the past,
+        # catch that user up and mark as no unreads.
+        if now - flv.end_date > THRESHOLD:
+            forum.catchup(user, flv)
+            forum.has_unread = False
+            continue
+
+        # Check the easy cases first. Check the last_post in the
+        # forum. If created after the end_date in our window, there
+        # are new posts. Likewise, if before the begin_date in our window,
+        # there are no new posts.
+        if forum.last_post.creation_date > flv.end_date:
+            forum.has_unread = True
+        elif forum.last_post.creation_date < flv.begin_date:
+            if not flv.is_caught_up():
+                forum.catchup(user, flv)
+            forum.has_unread = False
+        else:
+            # Going to have to examine the topics in our window.
+            # First adjust our window if it is too old.
+            if now - flv.begin_date > THRESHOLD:
+                flv.begin_date = min_date
+                flv.save()
+                TopicLastVisit.objects.filter(user=user, topic__forum=forum,
+                        last_visit__lt=min_date).delete()
+
+            topics = Topic.objects.filter(creation_date__gt=flv.begin_date)
+            tracked_topics = TopicLastVisit.objects.filter(user=user,
+                    topic__forum=forum, last_visit__gt=flv.begin_date)
+
+            # If the number of topics created since our window was started 
+            # is greater than the tracked topic records, then there are new
+            # posts.
+            if topics.count() > tracked_topics.count():
+                forum.has_unread = True
+                continue
+
+            tracked_dict = dict([(t.id, t) for t in tracked_topics])
+
+            for topic in topics:
+                if topic.id in tracked_dict:
+                    if topic.update_date > tracked_dict[topic.id].last_visit:
+                        forum.has_unread = True
+                        continue
+                else:
+                    forum.has_unread = True
+                    continue
+
+            # If we made it through the above loop without continuing, then
+            # we are all caught up.
+            forum.catchup(user, flv)
+            forum.has_unread = False
--- a/gpp/forums/urls.py	Wed Sep 30 00:42:13 2009 +0000
+++ b/gpp/forums/urls.py	Sun Oct 11 19:10:54 2009 +0000
@@ -11,6 +11,7 @@
     url(r'^edit/(?P<id>\d+)/$', 'edit_post', name='forums-edit_post'),
     url(r'^flag-post/$', 'flag_post', name='forums-flag_post'),
     url(r'^forum/(?P<slug>[\w\d-]+)/$', 'forum_index', name='forums-forum_index'),
+    url(r'^forum/(?P<slug>[\w\d-]+)/catchup/$', 'forum_catchup', name='forums-catchup'),
     url(r'^forum/(?P<slug>[\w\d-]+)/new-topic/$', 'new_topic', name='forums-new_topic'),
     url(r'^mod/forum/(?P<slug>[\w\d-]+)/$', 'mod_forum', name='forums-mod_forum'),
     url(r'^mod/topic/delete/(\d+)/$', 'mod_topic_delete', name='forums-mod_topic_delete'),
--- a/gpp/forums/views.py	Wed Sep 30 00:42:13 2009 +0000
+++ b/gpp/forums/views.py	Sun Oct 11 19:10:54 2009 +0000
@@ -1,6 +1,8 @@
 """
 Views for the forums application.
 """
+import datetime
+
 from django.contrib.auth.decorators import login_required
 from django.http import Http404
 from django.http import HttpResponse
@@ -18,16 +20,15 @@
 
 from core.paginator import DiggPaginator
 from core.functions import email_admins
-from forums.models import Forum
-from forums.models import Topic
-from forums.models import Post
-from forums.models import FlaggedPost
+from forums.models import Forum, Topic, Post, FlaggedPost, TopicLastVisit, \
+        ForumLastVisit
 from forums.forms import NewTopicForm, NewPostForm, PostForm, MoveTopicForm
+from forums.unread import get_forum_unread_status
 
 #######################################################################
 
 TOPICS_PER_PAGE = 50
-POSTS_PER_PAGE = 2
+POSTS_PER_PAGE = 20
 
 def create_topic_paginator(topics):
    return DiggPaginator(topics, TOPICS_PER_PAGE, body=5, tail=2, margin=3, padding=2)
@@ -42,6 +43,7 @@
     This view displays all the forums available, ordered in each category.
     """
     forums = Forum.objects.forums_for_user(request.user)
+    get_forum_unread_status(forums, request.user)
     cats = {}
     for forum in forums:
         cat = cats.setdefault(forum.category.id, {
@@ -112,6 +114,9 @@
 
     last_page = page_num == paginator.num_pages
 
+    if request.user.is_authenticated() and last_page:
+        _update_last_visit(request.user, topic)
+
     # we do this for the template since it is rendered twice
     page_nav = render_to_string('forums/pagination.html', {'page': page})
 
@@ -191,8 +196,10 @@
 
         post = form.save(request.user, request.META.get("REMOTE_ADDR", ""))
         _bump_post_count(request.user)
+        _update_last_visit(request.user, form.topic)
         return render_to_response('forums/display_post.html', {
             'post': post,
+            'can_moderate': _can_moderate(form.topic.forum, request.user),
             },
             context_instance=RequestContext(request))
 
@@ -368,6 +375,7 @@
                 post.user_ip = request.META.get("REMOTE_ADDR", "")
                 post.save()
                 _bump_post_count(request.user)
+                _update_last_visit(request.user, topic)
                 return HttpResponseRedirect(post.get_absolute_url())
         else:
             quote_id = request.GET.get('quote')
@@ -515,6 +523,21 @@
         context_instance=RequestContext(request))
 
 
+@login_required
+@require_POST
+def forum_catchup(request, slug):
+    """
+    This view marks all the topics in the forum as being read.
+    """
+    forum = get_object_or_404(Forum.objects.select_related(), slug=slug)
+
+    if not forum.category.can_access(request.user):
+        return HttpResponseForbidden()
+
+    forum.catchup(request.user)
+    return HttpResponseRedirect(forum.get_absolute_url())
+
+
 def _can_moderate(forum, user):
     """
     Determines if a user has permission to moderate a given forum.
@@ -605,3 +628,28 @@
         if topic.forum == old_forum:
             _move_topic(topic, old_forum, new_forum)
 
+
+def _update_last_visit(user, topic):
+    """
+    Does the bookkeeping for the last visit status for the user to the
+    topic/forum.
+    """
+    now = datetime.datetime.now()
+    try:
+        flv = ForumLastVisit.objects.get(user=user, forum=topic.forum)
+    except ForumLastVisit.DoesNotExist:
+        flv = ForumLastVisit(user=user, forum=topic.forum)
+        flv.begin_date = now
+
+    flv.end_date = now
+    flv.save()
+
+    if topic.update_date > flv.begin_date:
+        try:
+            tlv = TopicLastVisit.objects.get(user=user, topic=topic)
+        except TopicLastVisit.DoesNotExist:
+            tlv = TopicLastVisit(user=user, topic=topic)
+
+        tlv.touch()
+        tlv.save()
+
--- a/gpp/templates/forums/display_post.html	Wed Sep 30 00:42:13 2009 +0000
+++ b/gpp/templates/forums/display_post.html	Sun Oct 11 19:10:54 2009 +0000
@@ -17,9 +17,10 @@
       </div>
       <div class="forum-post-body">
          {{ post.html|safe }}
-         {% ifnotequal post.creation_date post.update_date %}
+         {% if post.has_been_edited %}
+         <p>{{ post.creation_date|date:"M d, Y H:i:s" }} {{ post.update_date|date:"M d, Y H:i:s" }}</p>
          <p class="small quiet">Last edited: {{ post.update_date|date:"M d, Y H:i:s" }}</p>
-         {% endifnotequal %}
+         {% endif %}
       </div>
       <div class="forum-post-info-tools">
       {% if can_reply %}
--- a/gpp/templates/forums/forum_index.html	Wed Sep 30 00:42:13 2009 +0000
+++ b/gpp/templates/forums/forum_index.html	Sun Oct 11 19:10:54 2009 +0000
@@ -10,7 +10,12 @@
 </h3>
 
 <div class="forum-block">
-<a href="{% url forums-new_topic slug=forum.slug %}">New Topic</a>
+{% if user.is_authenticated %}
+<a href="{% url forums-new_topic slug=forum.slug %}">New Topic</a> &bull;
+<form action="{% url forums-catchup slug=forum.slug %}" method="post" style="display:inline">
+   <input type="submit" value="Mark All Topics Read" />
+</form>
+{% endif %}
 {{ page_nav }}
 <table class="forum-index-table">
    <thead>
@@ -46,6 +51,14 @@
    </tbody>
 </table>
 {{ page_nav }}
+{% if user.is_authenticated %}
+<a href="{% url forums-new_topic slug=forum.slug %}">New Topic</a> &bull;
+<form action="{% url forums-catchup slug=forum.slug %}" method="post" style="display:inline">
+   <input type="submit" value="Mark All Topics Read" />
+</form>
+{% endif %}
+{% if can_moderate %}
 <p><a href="{% url forums-mod_forum slug=forum.slug %}">Moderate this forum</a></p>
+{% endif %}
 </div>
 {% endblock %}
--- a/gpp/templates/forums/index.html	Wed Sep 30 00:42:13 2009 +0000
+++ b/gpp/templates/forums/index.html	Sun Oct 11 19:10:54 2009 +0000
@@ -19,7 +19,11 @@
       <tbody>
          {% for forum in iter.forums %}
             <tr class="{% cycle 'odd' 'even' %}">
-               <td><h4><a href="{{ forum.get_absolute_url }}">{{ forum.name }}</a></h4>
+               <td>
+                  {% if forum.has_unread %}
+                  <img src="{{ MEDIA_URL }}icons/new.png" alt="New Posts" title="New Posts" class="forums-topic-icon" />
+                  {% endif %}
+                  <h4><a href="{{ forum.get_absolute_url }}">{{ forum.name }}</a></h4>
                   <p>{{ forum.description }}</p></td>
                <td class="forum-topics">{{ forum.topic_count }}</td>
                <td class="forum-posts">{{ forum.post_count }}</td>
Binary file media/icons/new.png has changed