merged in comments
authorNick Retallack <nickretallack@gmil.com>
Sat, 24 Oct 2009 05:00:00 +0000 (22:00 -0700)
committerNick Retallack <nickretallack@gmil.com>
Sat, 24 Oct 2009 05:00:00 +0000 (22:00 -0700)
20 files changed:
floof/config/routing.py
floof/controllers/art.py
floof/controllers/comments.py [new file with mode: 0644]
floof/controllers/search.py
floof/controllers/tag.py
floof/lib/base.py
floof/lib/dbhelpers.py
floof/lib/helpers.py
floof/model/art.py
floof/model/comments.py [new file with mode: 0644]
floof/model/search.py
floof/model/users.py
floof/public/layout.css
floof/templates/art/show.mako
floof/templates/base.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]
setup.py

index c9b4975..645d138 100644 (file)
@@ -46,13 +46,21 @@ 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/reply', action='reply')
+        sub.connect('/*owner_url/comments/reply_done', action='reply_done', **require_POST)
+        sub.connect('/*owner_url/comments/{id}', action='thread')
+        sub.connect('/*owner_url/comments/{id}/reply', action='reply')
+        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")
         sub.connect('create_art',   '/art/create',      action="create")
         sub.connect('rate_art',     '/art/{id}/rate',   action="rate")
         sub.connect('show_art',     '/art/{id}',        action="show")
-        
+
     with map.submapper(controller='tag') as sub:
         sub.connect('delete_tag', '/art/{art_id}/tag/{id}')
         sub.connect('create_tag', '/art/{art_id}/tag')
@@ -61,14 +69,14 @@ def make_map():
         parent_resource=dict(member_name='art', collection_name='art'))
         # Yeah, parent resources are specified kinda dumb-ly.  Would be better if you could pass in the
         # real parent resource instead of mocking it up with a silly dict.  We should file a feature request.
-        
+
     # I think resources is the right way to go for most things.  It ensures all of our actions have the right
     # methods on them, at least.  It does require the use of silly _method="delete" post parameters though.
-    
+
     # One sticking point though is, it'll happily allow you to add any formatting string you want, like art/1.json
     # I wonder if there's a way to place requirements on that, or disable it until we actually have formats.
     # It just serves the same action as usual but with a format argument in the context.
-            
+
     # map.connect('/art/new', controller='art', action='new')
     # map.connect('/art/upload', controller='art', action='upload')
     # map.connect('show_art', '/art/{id}', controller='art', action='show')
index 5186daa..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
 
@@ -20,10 +21,6 @@ class ArtController(BaseController):
         if id:
             c.art = h.get_object_or_404(Art, id=id)
 
-    # def index():
-    #     c.artwork = Art.query.order_by(Art.id.desc()).all()
-    #     return render
-
     def new(self):
         """ New Art! """
         return render("/art/new.mako")
@@ -31,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()
@@ -49,7 +47,7 @@ class ArtController(BaseController):
         if c.user:
             c.your_score = c.art.user_score(c.user)
         return render("/art/show.mako")
-        
+
 
     # TODO: login required
     def rate(self, id):
@@ -59,8 +57,8 @@ class ArtController(BaseController):
             score = int(score)
         else:
             score = Rating.reverse_options.get(score)
-        
+
         c.art.rate(score, c.user)
         elixir.session.commit()
-            
+
         redirect(url('show_art', id=c.art.id))
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 57ff4d2..4e92a6d 100644 (file)
@@ -18,11 +18,11 @@ class SearchController(BaseController):
     def index(self):
         if request.params.get('button') == 'Save':
             return self.save()
-        
+
         c.query = request.params.get('query', '')
         c.artwork = do_search(c.query)
         return render('/index.mako')
-        
+
     # TODO: login required
     def save(self):
         c.query = request.params.get('query', '')
@@ -30,19 +30,18 @@ class SearchController(BaseController):
         elixir.session.commit()
         redirect(url('saved_searches'))
         # TODO: do something better than this.
-        
-    
+
+
     # TODO: login required
     def list(self):
         c.searches = c.user.searches
         return render('/searches.mako')
-    
+
     # TODO: login required
     def display(self, id):
         c.search = h.get_object_or_404(SavedSearch, id=id)
         c.gallery = GalleryWidget(search=c.search, page=c.user.primary_page)
         elixir.session.commit()
         redirect(url(controller="users", action="view", name=c.user.name))
-        
-        
-        
\ No newline at end of file
+
+
index 874e605..4f98994 100644 (file)
@@ -19,7 +19,7 @@ class TagController(BaseController):
         elixir.session.delete(tag)
         elixir.session.commit()
         redirect(url('show_art', id=art_id))
-        
+
     # TODO: login required
     def create(self, art_id):
         c.art = h.get_object_or_404(Art, id=art_id)
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 0da968b..23fd3b4 100644 (file)
@@ -3,7 +3,7 @@ def find_or_create(model, **kwargs):
     if not instance:
         instance = model(**kwargs)
     return instance
-    
+
 def update_or_create(model, get_by, update_with):
     instance = model.get_by(**get_by)
     if instance:
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 544a830..8b328d8 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,16 +21,7 @@ class Art(Entity):
 
     uploader = ManyToOne('User', required=True)
     tags = OneToMany('Tag')
-
-    # def __init__(self, **kwargs):
-    #     # I wanted to check for the existence of the file, but...
-    #     # for some reason this FieldStorage object always conditions as falsey.
-    #     # self.hash = save_file("art", kwargs.pop('file'))
-    #     super(Art, self).__init__(**kwargs)
-    #     # this is what super is doing, pretty much.
-    #     # for key, value in kwargs.items():
-    #     #     setattr(self, key, value)
-    # left for posterity.
+    discussion = ManyToOne('Discussion')
 
     def set_file(self, file):
         self.hash = save_file("art", file)
@@ -52,7 +44,7 @@ class Art(Entity):
                     if tag:
                         elixir.session.delete(tag)
 
-            else: 
+            else:
                 if len(text) > 50:
                     raise "Long Tag!" # can we handle this more gracefully?
                 # sqlite seems happy to store strings much longer than the supplied limit...
@@ -66,7 +58,7 @@ class Art(Entity):
 
     def rate(self, score, user):
         return update_or_create(Rating, {"rater":user, "art":self}, {"score":score})
-        
+
     def user_score(self, user):
         rating = Rating.get_by(rater=user, art=self)
         if rating:
@@ -83,15 +75,6 @@ class Tag(Entity):
     tagger = ManyToOne('User', ondelete='cascade')
     tagtext = ManyToOne('TagText')
 
-    # this text setter is no longer useful since I changed the way Art#add_tags works
-    # but I'll leave it in here just for several minutes nostalgia.
-    # def set_text(self, text):
-    #     self.tagtext = TagText.get_by(text=text)
-    #     if not self.tagtext:
-    #         self.tagtext = TagText(text=text)
-    #
-    # text = property(lambda self: self.tagtext.text, set_text)
-
     def __unicode__(self):
         if not self.tagtext:
             return "(broken)"
@@ -104,26 +87,20 @@ class TagText(Entity):
 
     def __unicode__(self):
         return self.text
-        
+
 
 class Rating(Entity):
     art = ManyToOne('Art', ondelete='cascade')
     rater = ManyToOne('User', ondelete='cascade')
     score = Field(Integer)
-    
-    # @score.setter
-    # def score(self, value):    
-        
+
     options = {-1:"sucks", 0:"undecided", 1:"good", 2:"great"}
     default = 0
-    # options = ["sucks","neutral","good","great"]
-    
 
 Rating.reverse_options = dict (zip(Rating.options.values(), Rating.options.keys()))
 
 
 
-
 class UserRelation(Entity):
     related = ManyToOne("User")
     art = ManyToOne("Art")
@@ -131,4 +108,4 @@ class UserRelation(Entity):
     
     
 # class CharacterRelation(Entity):
-#     pass
\ No newline at end of file
+#     pass
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 a413eb6..2296491 100644 (file)
@@ -7,10 +7,10 @@ class SavedSearch(Entity):
     string = Field(Unicode) # I tried calling this query, but it broke elixir
     author = ManyToOne('User')
     fork = ManyToOne("SavedSearch")
-    
+
     def __unicode__(self):
         return self.string
-        
+
     @property
     def results(self):
         return do_search(self.string)
@@ -24,19 +24,18 @@ class GalleryWidget(Entity):
     # NOTE: no longer needed now that we have pages, I guess.
     # displayer = ManyToOne('User') # determines whose page should it should show up on
     #                             # Could be no-ones, if it's just a template.
-    
+
     # Needs some fields for position on your page
 
     @property
     def string(self):
         return self.search
-    
+
     @string.setter
     def string(self, value):
         # TODO: should we delete the possibly orphaned saved search?
         # if not self.displayer:
         #     # TODO: may have to refactor this into an init if the key ordering is inconvenienc
         #     raise "Oh no!  This gallery needs a displayer to set on the saved search."
-        
+
         self.search = SavedSearch(author=getattr(self,"author",None), string=value)
-        
\ No newline at end of file
index 2775ab7..c38197c 100644 (file)
@@ -17,18 +17,18 @@ class User(Entity):
     pages = OneToMany('UserPage', inverse="owner")
     primary_page = OneToOne('UserPage', inverse="owner")
 
-    
+
     def __unicode__(self):
         return self.name
 
     def __init__(self, **kwargs):
         super(User, self).__init__(**kwargs)
-        
-        
-        
+
+
+
         # TODO: have this clone a standard starter page
         self.primary_page = UserPage(owner=self, title="default", visible=True)
-        
+
         # a starter gallery, just for fun
         gallery = GalleryWidget(owner=self, string="awesome")
         self.primary_page.galleries.append(gallery)
@@ -46,12 +46,11 @@ class UserPage(Entity):
     Page templates that provide familiar interfaces will also be UserPage records.  Users will
     see a panel full of them, and they can choose to clone those template pages to their own page list.
     If more than one is set to visible, there would be tabs.  The primary page is indicated in the user model.
-    
     """
     
     owner = ManyToOne('User', inverse="pages")
     title = Field(String)
-    
+
     visible = Field(Boolean)
     galleries = OneToMany('GalleryWidget')
     
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)}
index 73f069c..e4acc6f 100644 (file)
@@ -17,8 +17,8 @@
 % endif
 
 ${h.form(h.url_for('search'), method='GET')}
-${h.text('query', c.query)} 
-${h.submit('button', 'Search')} 
+${h.text('query', c.query)}
+${h.submit('button', 'Search')}
 
 % if c.user:
 ${h.submit('button', 'Save')}
@@ -32,7 +32,7 @@ ${h.end_form()}
     <div id="user">
         % if c.user:
         <form action="${url(controller='account', action='logout')}" method="POST">
-        <p>Logged in as <a href="${h.url('user_page', name=c.user.name)}">${c.user.name}</a>.  ${h.submit(None, 'Log out')}</p>
+        <p>Logged in as <a href="${h.url('user_page', name=c.user.name.lower())}">${c.user.name}</a>.  ${h.submit(None, 'Log out')}</p>
         </form>
         % else:
         <form action="${url(controller='account', action='login_begin')}" method="POST">
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...
index 30bf697..ee04f36 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -13,10 +13,12 @@ setup(
     author_email='',
     url='',
     install_requires=[
+        'routes>=1.11',     # for submapper
         "Pylons>=0.9.7",
         "SQLAlchemy>=0.5",
         "elixir>=0.6",
         'python-openid',
+        'shabti',
     ],
     setup_requires=["PasteScript>=1.6.3"],
     packages=find_packages(exclude=['ez_setup']),