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")
import elixir
from floof.model.art import Art, Rating
+from floof.model.comments import Discussion
from sqlalchemy.exceptions import IntegrityError
# TODO: login required
def create(self):
c.art = Art(uploader=c.user, **request.params)
+ c.art.discussion = Discussion(count=0)
try:
elixir.session.commit()
--- /dev/null
+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)
"""
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'])
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:]
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))
uploader = ManyToOne('User', required=True)
tags = OneToMany('Tag')
+ discussion = ManyToOne('Discussion')
def set_file(self, file):
self.hash = save_file("art", file)
--- /dev/null
+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
.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 ***/
<%inherit file="/base.mako" />
+<%namespace name="comments" file="/comments/lib.mako" />
<%! from floof.model.art import Rating %>
<img class="full" src="${c.art.get_path()}">
+${comments.comment_block(c.art.discussion.comments)}
--- /dev/null
+<%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>
--- /dev/null
+<%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()}
--- /dev/null
+<%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)}
--- /dev/null
+from floof.tests import *
+
+class TestCommentsController(TestController):
+
+ def test_index(self):
+ response = self.app.get(url(controller='comments', action='index'))
+ # Test response...