Merge branch 'comments'
authorEevee <git@veekun.com>
Fri, 23 Oct 2009 02:31:14 +0000 (19:31 -0700)
committerEevee <git@veekun.com>
Fri, 23 Oct 2009 02:31:14 +0000 (19:31 -0700)
13 files changed:
floof/config/routing.py
floof/controllers/art.py
floof/controllers/comments.py [new file with mode: 0644]
floof/lib/base.py
floof/lib/helpers.py
floof/model/art.py
floof/model/comments.py [new file with mode: 0644]
floof/public/layout.css
floof/templates/art/show.mako
floof/templates/comments/lib.mako [new file with mode: 0644]
floof/templates/comments/reply.mako [new file with mode: 0644]
floof/templates/comments/thread.mako [new file with mode: 0644]
floof/tests/functional/test_comments.py [new file with mode: 0644]

index ffce2d6..603afd0 100644 (file)
@@ -46,6 +46,14 @@ def make_map():
     map.connect('/users', controller='users', action='list')
     map.connect('user_page', '/users/{name}', controller='users', action='view')
 
+    # 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 fea21c6..d598292 100644 (file)
@@ -9,6 +9,7 @@ log = logging.getLogger(__name__)
 
 import elixir
 from floof.model.art import Art, Rating
+from floof.model.comments import Discussion
 
 from sqlalchemy.exceptions import IntegrityError
 
@@ -27,6 +28,7 @@ class ArtController(BaseController):
     # TODO: login required
     def create(self):
         c.art = Art(uploader=c.user, **request.params)
+        c.art.discussion = Discussion(count=0)
 
         try:
             elixir.session.commit()
diff --git a/floof/controllers/comments.py b/floof/controllers/comments.py
new file mode 100644 (file)
index 0000000..b096ba2
--- /dev/null
@@ -0,0 +1,93 @@
+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
+from floof.model.comments import Comment
+
+log = logging.getLogger(__name__)
+
+def find_owner(owner_url):
+    """Returns whatever thing owns a group of comments."""
+
+    # Need to prepend a slash to make this an absolute URL
+    route = config['routes.map'].match('/' + owner_url)
+
+    if route['action'] not in ('show', 'view'):
+        abort(404)
+
+    if route['controller'] == 'art':
+        model = Art
+    else:
+        abort(404)
+
+    owner = model.query.get(route['id'])
+    if not owner:
+        abort(404)
+
+    return owner
+
+
+class CommentsController(BaseController):
+
+    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.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, 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, 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()
+
+        return redirect('/' + owner_url, code=301)
index d05c24d..169493c 100644 (file)
@@ -4,16 +4,18 @@ Provides the BaseController class for subclassing.
 """
 from pylons.controllers import WSGIController
 from pylons.templating import render_mako as render
-from pylons import config
-from floof import model
+from pylons import config, session, tmpl_context as c
+from routes import request_config
 
-from pylons import session, tmpl_context as c
+from floof import model
 from floof.model.users import User
 
 class BaseController(WSGIController):
 
     # NOTE: This could have been implemented as a middleware =]
     def __before__(self):
+        c.route = request_config().mapper_dict
+
         # Fetch current user object
         try:
             c.user = User.query.get(session['user_id'])
index c8a1251..b4cac1f 100644 (file)
@@ -31,3 +31,13 @@ def get_object_or_404(model, **kw):
         abort(404)
     return obj
 
+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 c21c74a..0732684 100644 (file)
@@ -12,6 +12,7 @@ from pylons import config
 
 from floof.lib.file_storage import get_path, save_file
 from floof.lib.dbhelpers import find_or_create, update_or_create
+import floof.model.comments
 
 class Art(Entity):
     title = Field(Unicode(120))
@@ -20,6 +21,7 @@ class Art(Entity):
 
     uploader = ManyToOne('User', required=True)
     tags = OneToMany('Tag')
+    discussion = ManyToOne('Discussion')
 
     def set_file(self, file):
         self.hash = save_file("art", file)
diff --git a/floof/model/comments.py b/floof/model/comments.py
new file mode 100644 (file)
index 0000000..5e57c14
--- /dev/null
@@ -0,0 +1,120 @@
+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.
+
+    This function will also cache the `parent` property for each comment.
+    """
+
+    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)
+    comments = OneToMany('Comment', order_by='left')
+
+class Comment(Entity):
+    time = Field(DateTime, default=datetime.datetime.now)
+    text = Field(Unicode(65536))
+
+    # Comments are a tree, and are stored as a nested set, because:
+    # - It's easy to get a subtree with a single query.
+    # - It's easy to get a comment's depth.
+    # - It's easy to sort comments without recursion.
+    # The only real disadvantage is that adding a comment requires a quick
+    # update of all the following comments (in post-order), but that's rare
+    # enough that it shouldn't be a problem.
+    left = Field(Integer, index=True)
+    right = Field(Integer)
+
+    discussion = ManyToOne('Discussion')
+    user = ManyToOne('User')
+
+    def __init__(self, parent=None, **kwargs):
+        """Constructor override to set left/right correctly on a new comment.
+        """
+        super(Comment, self).__init__(**kwargs)
+
+        # Keep the comment count updated
+        self.discussion.count += 1
+
+        if parent:
+            # 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.right: Comment.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)) \
+                           .filter(Comment.discussion == self.discussion)
+            (max_right,) = query.one()
+
+            if not max_right:
+                # No comments yet.  Use 1 and 2
+                self.left = 1
+                self.right = 2
+            else:
+                self.left = max_right + 1
+                self.right = max_right + 2
+
+    @property
+    def parent(self):
+        """Returns this comment's parent.  This is cached, hence its being a
+        property and not a method.
+        """
+        if not hasattr(self, '_parent'):
+            self._parent = Comment.query \
+                .filter(Comment.discussion_id == self.discussion_id) \
+                .filter(Comment.left < self.left) \
+                .filter(Comment.right > self.right) \
+                .order_by(Comment.left.desc()) \
+                .first()
+        return self._parent
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 262f1cb..cf713dc 100644 (file)
@@ -1,4 +1,5 @@
 <%inherit file="/base.mako" />
+<%namespace name="comments" file="/comments/lib.mako" />
 
 <%! from floof.model.art import Rating %>
 
@@ -33,3 +34,4 @@ ${h.end_form()}
 
 <img class="full" src="${c.art.get_path()}">
 
+${comments.comment_block(c.art.discussion.comments)}
diff --git a/floof/templates/comments/lib.mako b/floof/templates/comments/lib.mako
new file mode 100644 (file)
index 0000000..e822a17
--- /dev/null
@@ -0,0 +1,34 @@
+<%def name="comment_block(comments)">
+<h1>${len(comments)} comments</h1>
+<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)">
+<%! from floof.model.comments import indent_comments %>\
+% for comment in indent_comments(comments):
+${single_comment(comment)}
+% endfor
+</%def>
+
+<%def name="single_comment(comment)">
+% if hasattr(comment, 'indent'):
+<div class="comment" style="margin-left: ${comment.indent}em;">
+% else:
+<div class="comment">
+% endif
+    <div class="header">
+        <div class="user">${comment.user.name}</div>
+        <div class="time">${comment.time}</div>
+        <div class="links">
+            <a href="${url(controller='comments', action='thread', id=comment.id, owner_url=h.get_comment_owner_url(**c.route))}">Link</a>
+            <a href="${url(controller='comments', action='reply', id=comment.id, owner_url=h.get_comment_owner_url(**c.route))}">Reply</a>
+            % if comment.parent:
+            <a href="${url(controller='comments', action='thread', id=comment.parent.id, owner_url=h.get_comment_owner_url(**c.route))}">Parent</a>
+            % endif
+        </div>
+    </div>
+    <p>${comment.text}</p>
+</div>
+</%def>
diff --git a/floof/templates/comments/reply.mako b/floof/templates/comments/reply.mako
new file mode 100644 (file)
index 0000000..916cc91
--- /dev/null
@@ -0,0 +1,17 @@
+<%inherit file="/base.mako" />
+<%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>
+
+    <dd>${h.submit(None, 'Post')}</dd>
+</dl>
+${h.end_form()}
diff --git a/floof/templates/comments/thread.mako b/floof/templates/comments/thread.mako
new file mode 100644 (file)
index 0000000..947c500
--- /dev/null
@@ -0,0 +1,17 @@
+<%inherit file="/base.mako" />
+<%namespace name="comments" file="/comments/lib.mako" />
+
+<p><a href="/${c.owner_url}">« Return</a></p>
+
+% 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)}
diff --git a/floof/tests/functional/test_comments.py b/floof/tests/functional/test_comments.py
new file mode 100644 (file)
index 0000000..908d1bc
--- /dev/null
@@ -0,0 +1,7 @@
+from floof.tests import *
+
+class TestCommentsController(TestController):
+
+    def test_index(self):
+        response = self.app.get(url(controller='comments', action='index'))
+        # Test response...