From: Eevee Date: Fri, 23 Oct 2009 02:31:14 +0000 (-0700) Subject: Merge branch 'comments' X-Git-Url: http://git.veekun.com/zzz-floof.git/commitdiff_plain/7c20a2661d532fe1e338f0f0dfb9fe42113ab103?hp=2037ab1d9057d34ccf0457e8fb5c7c34b2d89083 Merge branch 'comments' --- diff --git a/floof/config/routing.py b/floof/config/routing.py index ffce2d6..603afd0 100644 --- a/floof/config/routing.py +++ b/floof/config/routing.py @@ -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") diff --git a/floof/controllers/art.py b/floof/controllers/art.py index fea21c6..d598292 100644 --- a/floof/controllers/art.py +++ b/floof/controllers/art.py @@ -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 index 0000000..b096ba2 --- /dev/null +++ b/floof/controllers/comments.py @@ -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) diff --git a/floof/lib/base.py b/floof/lib/base.py index d05c24d..169493c 100644 --- a/floof/lib/base.py +++ b/floof/lib/base.py @@ -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']) diff --git a/floof/lib/helpers.py b/floof/lib/helpers.py index c8a1251..b4cac1f 100644 --- a/floof/lib/helpers.py +++ b/floof/lib/helpers.py @@ -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:] diff --git a/floof/model/art.py b/floof/model/art.py index c21c74a..0732684 100644 --- a/floof/model/art.py +++ b/floof/model/art.py @@ -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 index 0000000..5e57c14 --- /dev/null +++ b/floof/model/comments.py @@ -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 diff --git a/floof/public/layout.css b/floof/public/layout.css index a098b5e..b175705 100644 --- a/floof/public/layout.css +++ b/floof/public/layout.css @@ -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 ***/ diff --git a/floof/templates/art/show.mako b/floof/templates/art/show.mako index 262f1cb..cf713dc 100644 --- a/floof/templates/art/show.mako +++ b/floof/templates/art/show.mako @@ -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()} +${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 index 0000000..e822a17 --- /dev/null +++ b/floof/templates/comments/lib.mako @@ -0,0 +1,34 @@ +<%def name="comment_block(comments)"> +

${len(comments)} comments

+

View all

+

Reply

+${comment_thread(comments)} + + +<%def name="comment_thread(comments)"> +<%! from floof.model.comments import indent_comments %>\ +% for comment in indent_comments(comments): +${single_comment(comment)} +% endfor + + +<%def name="single_comment(comment)"> +% if hasattr(comment, 'indent'): +
+% else: +
+% endif +
+
${comment.user.name}
+
${comment.time}
+ +
+

${comment.text}

+
+ diff --git a/floof/templates/comments/reply.mako b/floof/templates/comments/reply.mako new file mode 100644 index 0000000..916cc91 --- /dev/null +++ b/floof/templates/comments/reply.mako @@ -0,0 +1,17 @@ +<%inherit file="/base.mako" /> +<%namespace name="comments" file="/comments/lib.mako" /> + +

Reply

+% if c.parent_comment: +${h.form(url(controller='comments', action='reply_done', id=c.parent_comment_id, owner_url=c.owner_url), method='post')} +

Replying to comment ${c.parent_comment_id}

+% else: +${h.form(url(controller='comments', action='reply_done', owner_url=c.owner_url), method='post')} +% endif +
+
Comment
+
${h.textarea('text')}
+ +
${h.submit(None, 'Post')}
+
+${h.end_form()} diff --git a/floof/templates/comments/thread.mako b/floof/templates/comments/thread.mako new file mode 100644 index 0000000..947c500 --- /dev/null +++ b/floof/templates/comments/thread.mako @@ -0,0 +1,17 @@ +<%inherit file="/base.mako" /> +<%namespace name="comments" file="/comments/lib.mako" /> + +

« Return

+ +% if c.root_comment: +${comments.single_comment(c.root_comment)} +% endif + +

${len(c.comments)} comments

+

View all

+% if c.root_comment_id: +

Reply

+% else: +

Reply

+% 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 index 0000000..908d1bc --- /dev/null +++ b/floof/tests/functional/test_comments.py @@ -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...