More thorough support for comment threading.
authorEevee <git@veekun.com>
Tue, 20 Oct 2009 03:13:12 +0000 (20:13 -0700)
committerEevee <git@veekun.com>
Tue, 20 Oct 2009 03:13:12 +0000 (20:13 -0700)
Comment threads are indented correctly.

Viewing a comment subthread works.

Replying to an arbitrarily deeply-nested comment works.

Creating a nested comment updates the rest of the discussion correctly.

floof/config/routing.py
floof/controllers/comments.py
floof/lib/helpers.py
floof/model/comments.py
floof/public/layout.css
floof/templates/comments/lib.mako
floof/templates/comments/reply.mako
floof/templates/comments/thread.mako

index 5f729e6..603afd0 100644 (file)
@@ -49,8 +49,11 @@ def make_map():
     # Comments
     with map.submapper(controller='comments') as sub:
         sub.connect('/*owner_url/comments', action='thread')
+        sub.connect('/*owner_url/comments/{id}', action='thread')
         sub.connect('/*owner_url/comments/reply', action='reply')
+        sub.connect('/*owner_url/comments/{id}/reply', action='reply')
         sub.connect('/*owner_url/comments/reply_done', action='reply_done', **require_POST)
+        sub.connect('/*owner_url/comments/{id}/reply_done', action='reply_done', **require_POST)
 
     with map.submapper(controller="art") as sub:
         sub.connect('new_art',      '/art/new',         action="new")
index 70c9073..b096ba2 100644 (file)
@@ -3,6 +3,7 @@ import logging
 import elixir
 from pylons import config, request, response, session, tmpl_context as c
 from pylons.controllers.util import abort, redirect, redirect_to
+from sqlalchemy import and_
 
 from floof.lib.base import BaseController, render
 from floof.model.art import Art
@@ -33,28 +34,59 @@ def find_owner(owner_url):
 
 class CommentsController(BaseController):
 
-    def thread(self, owner_url):
+    def thread(self, owner_url, id=None):
         """View a thread of comments, either attached to an item or starting
         from a parent comment belonging to that item.
         """
         owner_object = find_owner(owner_url)
-        c.comments = owner_object.discussion.comments
+        c.owner_url = owner_url
+        c.root_comment_id = id
+
+        if id:
+            # Get a thread starting from a certain point
+            c.root_comment = Comment.query.get(id)
+            if c.root_comment.discussion != owner_object.discussion:
+                abort(404)
+
+            c.comments = Comment.query.filter(and_(
+                Comment.discussion_id == owner_object.discussion_id,
+                Comment.left > c.root_comment.left,
+                Comment.right < c.root_comment.right
+            )).all()
+        else:
+            # Get everything
+            c.root_comment = None
+            c.comments = owner_object.discussion.comments
+
         return render('/comments/thread.mako')
 
-    def reply(self, owner_url):
+    def reply(self, owner_url, id=None):
         """Reply to a comment or discussion."""
+
+        c.parent_comment_id = id
+        if id:
+            c.parent_comment = Comment.query.get(id)
+        else:
+            c.parent_comment = None
+
         return render('/comments/reply.mako')
 
-    def reply_done(self, owner_url):
+    def reply_done(self, owner_url, id=None):
         """Finish replying to a comment or discussion."""
         # XXX form validation woo
 
         owner_object = find_owner(owner_url)
 
+        if id:
+            parent_comment = Comment.query.get(id)
+        else:
+            parent_comment = None
+
         new_comment = Comment(
             text=request.params['text'],
             user=c.user,
             discussion=owner_object.discussion,
+            parent=parent_comment,
         )
         elixir.session.commit()
 
index 1c2f0c1..b4cac1f 100644 (file)
@@ -35,5 +35,9 @@ def get_comment_owner_url(**route):
     """Given a view route, returns the owner_url route parameter for generating
     comment URLs.
     """ 
+    if 'owner_url' in route:
+        # We're already in a comments page.  Reuse the existing owner URL
+        return route['owner_url']
+
     # url() returns URLs beginning with a slash.  We just need to strip it.
     return url(**route)[1:]
index e1ad150..bcfee9f 100644 (file)
@@ -3,6 +3,47 @@ import datetime
 from elixir import *
 from sqlalchemy import func
 
+### Utility function(s)
+
+def indent_comments(comments):
+    """Given a list of comment objects, returns the same comments (and changes
+    them in-place) with an `indent` property set on each comment.  This
+    indicates how deeply nested each comment is, relative to the first comment.
+    The first comment's indent level is 0.
+
+    The comments must be a complete subtree, ordered by their `left` property.
+    """
+
+    last_comment = None
+    indent = 0 
+    right_ancestry = []
+    for comment in comments:
+        # If this comment is a child of the last, bump the nesting level
+        if last_comment and comment.left < last_comment.right:
+            indent = indent + 1 
+            # Remember current ancestory relevant to the root
+            right_ancestry.append(last_comment)
+
+        # On the other hand, for every nesting level this comment may have just
+        # broken out of, back out a level
+        for i in xrange(len(right_ancestry) - 1, -1, -1):
+            if comment.left > right_ancestry[i].right:
+                indent = indent - 1 
+                right_ancestry.pop(i)
+
+        # Cache parent comment
+        if len(right_ancestry):
+            comment._parent = right_ancestry[-1]
+
+        comment.indent = indent
+
+        last_comment = comment
+
+    return comments
+
+
+### Usual database classes
+
 class Discussion(Entity):
     """Represents a collection of comments attached to some other object."""
     count = Field(Integer)
@@ -34,19 +75,20 @@ class Comment(Entity):
         self.discussion.count += 1
 
         if parent:
-            # Parent comment given.  Add this comment just before the parent's
-            # right side...
-            self.left = parent.right
-            self.right = parent.right + 1
-
-            # ...then adjust all rightward comments accordingly
+            # Parent comment given.
+            parent_right = parent.right
+            # Shift every left/right ahead by two to make room for the new
+            # comment's left/right
+            Comment.query.filter(Comment.discussion == self.discussion) \
+                         .filter(Comment.left >= parent_right) \
+                         .update({ Comment.left: Comment.left + 2 })
             Comment.query.filter(Comment.discussion == self.discussion) \
-                         .filter(Comment.right > parent.right) \
-                         .update({ Comment.left:  Comment.left + 2,
-                                   Comment.right: Comment.right + 2 })
+                         .filter(Comment.right >= parent_right) \
+                         .update({ Comment.right: Comment.right + 2 })
 
-            # And, finally, update the parent's right endpoint
-            parent.right += 2
+            # Then stick the new comment in the right place
+            self.left = parent_right
+            self.right = parent_right + 1
 
         else:
             query = session.query(func.max(Comment.right)) \
@@ -60,3 +102,4 @@ class Comment(Entity):
             else:
                 self.left = max_right + 1
                 self.right = max_right + 2
+
index a098b5e..b175705 100644 (file)
@@ -14,13 +14,19 @@ body { font-family: sans-serif; font-size: 12px; }
 .artwork-grid li {display:inline;}
 
 /*** Common bits and pieces ***/
-/* General form layout */
 a {color:blue; text-decoration:none; pointer:cursor;} /* Who needs visited links */
 
+p { margin: 0.25em 0 1em 0; }
+
+/* General form layout */
 dl.form { margin: 1em 0; padding-left: 1em; border-left: 0.5em solid gray; }
 dl.form dt { padding-bottom: 0.25em; font-style: italic; }
 dl.form dd { margin-bottom: 0.5em; }
 
+/* Comments */
+.comment {}
+.comment .header { background: #d8d8d8; }
+
 
 
 /*** Individual page layout ***/
index 38e6534..0c8ebfb 100644 (file)
@@ -1,17 +1,24 @@
 <%def name="comment_block(comments)">
 <h1>${len(comments)} comments</h1>
-## XXX make sure these do the right thing when this is a subtree
 <p><a href="${url(controller='comments', action='thread', owner_url=h.get_comment_owner_url(**c.route))}">View all</a></p>
 <p><a href="${url(controller='comments', action='reply', owner_url=h.get_comment_owner_url(**c.route))}">Reply</a></p>
 ${comment_thread(comments)}
 </%def>
 
 <%def name="comment_thread(comments)">
-% for comment in comments:
-<div class="comment">
-    <div class="user">${comment.user.name}</div>
-    <div class="time">${comment.time}</div>
+<%! from floof.model.comments import indent_comments %>\
+% for comment in indent_comments(comments):
+${single_comment(comment)}
+% endfor
+</%def>
+
+<%def name="single_comment(comment)">
+<div class="comment" style="margin-left: ${comment.indent}em;">
+    <div class="header">
+        <div class="user">${comment.user.name}</div>
+        <div class="time">${comment.time}</div>
+        <div class="link"><a href="${url(controller='comments', action='thread', id=comment.id, owner_url=h.get_comment_owner_url(**c.route))}">Link</a></div>
+    </div>
     <p>${comment.text}</p>
 </div>
-% endfor
 </%def>
index 9941433..916cc91 100644 (file)
@@ -2,7 +2,12 @@
 <%namespace name="comments" file="/comments/lib.mako" />
 
 <h1>Reply</h1>
+% if c.parent_comment:
+${h.form(url(controller='comments', action='reply_done', id=c.parent_comment_id, owner_url=c.owner_url), method='post')}
+<p>Replying to comment ${c.parent_comment_id}</p>
+% else:
 ${h.form(url(controller='comments', action='reply_done', owner_url=c.owner_url), method='post')}
+% endif
 <dl class="form">
     <dt>Comment</dt>
     <dd>${h.textarea('text')}</dd>
index 7b3580a..359c296 100644 (file)
@@ -1,4 +1,15 @@
 <%inherit file="/base.mako" />
 <%namespace name="comments" file="/comments/lib.mako" />
 
-${comments.comment_block(c.comments)}
+% if c.root_comment:
+${comments.single_comment(c.root_comment)}
+% endif
+
+<h1>${len(c.comments)} comments</h1>
+<p><a href="${url(controller='comments', action='thread', owner_url=c.owner_url)}">View all</a></p>
+% if c.root_comment_id:
+<p><a href="${url(controller='comments', action='reply', id=c.root_comment_id, owner_url=c.owner_url)}">Reply</a></p>
+% else:
+<p><a href="${url(controller='comments', action='reply', owner_url=c.owner_url)}">Reply</a></p>
+% endif
+${comments.comment_thread(c.comments)}