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')
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')
import elixir
from floof.model.art import Art, Rating
+from floof.model.comments import Discussion
from sqlalchemy.exceptions import IntegrityError
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")
# TODO: login required
def create(self):
c.art = Art(uploader=c.user, **request.params)
+ c.art.discussion = Discussion(count=0)
try:
elixir.session.commit()
if c.user:
c.your_score = c.art.user_score(c.user)
return render("/art/show.mako")
-
+
# TODO: login required
def rate(self, id):
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))
--- /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)
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', '')
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
+
+
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)
"""
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'])
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:
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')
-
- # 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)
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...
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:
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)"
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")
# class CharacterRelation(Entity):
-# pass
\ No newline at end of file
+# pass
--- /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
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)
# 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
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)
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')
.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)}
% 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')}
<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">
--- /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...
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']),