Merge branch 'master' of git@veekun.com:floof
authorEevee <git@veekun.com>
Wed, 2 Dec 2009 01:38:00 +0000 (17:38 -0800)
committerEevee <git@veekun.com>
Wed, 2 Dec 2009 01:38:00 +0000 (17:38 -0800)
18 files changed:
floof/config/routing.py
floof/controllers/art.py
floof/controllers/comments.py
floof/controllers/relation.py [new file with mode: 0644]
floof/controllers/search.py
floof/controllers/tag.py
floof/lib/search.py
floof/model/__init__.py
floof/model/art.py
floof/model/ratings.py [new file with mode: 0644]
floof/model/relations.py [new file with mode: 0644]
floof/model/search.py
floof/model/tags.py [new file with mode: 0644]
floof/model/users.py
floof/public/layout.css
floof/templates/art/new.mako
floof/templates/art/show.mako
floof/tests/functional/test_relation.py [new file with mode: 0644]

index 44462f8..f6906f4 100644 (file)
@@ -74,6 +74,10 @@ def make_map():
         sub.connect('delete_tag', '/art/{art_id}/tag/{id}')
         sub.connect('create_tag', '/art/{art_id}/tag')
 
+    with map.submapper(controller='relation') as sub:
+        sub.connect('create_relation', '/art/{art_id}/relations/{kind}/create', action="create")
+        # TODO: conditions: kind = by|for|of|character?
+
     map.resource('tag','tags', controller="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
index b8406ed..41298c8 100644 (file)
@@ -8,7 +8,8 @@ from floof.lib.base import BaseController, render
 log = logging.getLogger(__name__)
 
 import elixir
-from floof.model.art import Art, Rating
+from floof.model.users import User
+from floof.model import Art, Rating, UserRelation
 from floof.model.comments import Discussion
 from floof.model.users import User, UserRelationship
 
@@ -16,6 +17,52 @@ from sqlalchemy import func
 from sqlalchemy.exceptions import IntegrityError
 from sqlalchemy.orm.exc import NoResultFound
 
+from wtforms.validators import ValidationError
+from wtforms import *
+
+
+class ArtUploadForm(Form):
+    by = TextField('Artists')
+    file = FileField('Upload')
+    url = TextField('Link')
+
+    # TODO: make this general purpose
+    def validate_file(self, field):
+        if field.data == u'':
+            raise ValidationError('File is required')
+    
+    # Also make this into a general User List field validator
+    """ PLEASE NOTE!  I just realized that I need to have a __str__ method on User
+    to get it to write the usernames back in the form when it redisplays them, since
+    this validator turns them into user objects instead.  This fact actually sounds dangerous
+    to me in the future, since it means I proably shouldn't be changing the data input
+    by the user right here in the validator, or the user will see the post-mangled data instead
+    of what they actually typed.  Hm.
+    
+    One solution to this could be to only look up the users after normal validation is over, 
+    and then manually add validation errors to the form if that fails.  But I think that kind of
+    sucks.  Perhaps the ideology in Formish, where they keep Validation and Conversion as
+    separate tasks, is a better way of doing it?  That way there is less risk of changing the user's
+    input -- you do that at the conversiot stage -- yet it is still encapsulated in the form workflow.
+    Hm.  But that means I'd have to query for the users in the validation step and throw them away,
+    or something equally stupid.  Guess there's no perfect solution here, but I thought it was
+    worth discussing.
+    
+    Btw, this is meant to be used by a field with multi user autocompletion on it (like on stackoverflow tags),
+    so the user should never actually submit anything invalid unless they disable javascript and force it.
+    """
+    def validate_by(self, field):
+        if not field.data:
+            raise ValidationError("Needs at least one creator")
+        user_names = field.data.split()
+        users = []
+        # TODO: Could totally do a filter__in here instead of picking them out individually
+        for user_name in user_names:
+            user = User.get_by(name=user_name)
+            if not user:
+                raise ValidationError("Couldn't find user %s" % user_name)
+            users.append(user)
+        field.data = users
 
 class ArtController(BaseController):
     def __before__(self, id=None):
@@ -26,23 +73,38 @@ class ArtController(BaseController):
 
     def new(self):
         """ New Art! """
+        c.form = ArtUploadForm()
         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)
+        c.form = ArtUploadForm(request.params)
+        if c.form.validate():
 
-        try:
-            elixir.session.commit()
-            redirect(url('show_art', id=c.art.id))
-        except IntegrityError:
-            # hurr, there must be a better way to do this but I am lazy right now
-            hash = c.art.hash
-            elixir.session.rollback()
-            duplicate_art = Art.get_by(hash=hash)
-            h.flash("We already have that one.")
-            redirect(url('show_art', id=duplicate_art.id))
+            c.art = Art(uploader=c.user, **request.params)
+            c.art.discussion = Discussion(count=0)
+
+            for artist in c.form.by.data:
+                UserRelation(user=artist, kind="by", creator=c.user, art=c.art)
+
+            file = request.params['file']
+
+            try:
+                elixir.session.commit()
+                redirect(url('show_art', id=c.art.id))
+            except IntegrityError:
+                # hurr, there must be a better way to do this but I am lazy right now
+                hash = c.art.hash
+                elixir.session.rollback()
+                duplicate_art = Art.get_by(hash=hash)
+                h.flash("We already have that one.")
+                redirect(url('show_art', id=duplicate_art.id))
+
+        else:
+            ## TODO: JavaScript should be added to the upload form so that it is
+            ## impossible to submit the form when it contains any invalid users, 
+            ## so this never happens.  Only autocompled usernames should be allowed.
+            return render("/art/new.mako")
 
 
     def show(self, id):
index b096ba2..342b009 100644 (file)
@@ -6,7 +6,7 @@ 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 import Art
 from floof.model.comments import Comment
 
 log = logging.getLogger(__name__)
diff --git a/floof/controllers/relation.py b/floof/controllers/relation.py
new file mode 100644 (file)
index 0000000..48e7a46
--- /dev/null
@@ -0,0 +1,35 @@
+import logging
+
+from pylons import request, response, session, tmpl_context as c, h, url
+from pylons.controllers.util import abort, redirect
+
+from floof.lib.base import BaseController, render
+
+log = logging.getLogger(__name__)
+
+from floof.model import Art, UserRelation
+from floof.model.users import User
+import elixir
+
+# TODO!!!  Implement adding a related user the same way that it works
+# on the "add new art" page
+
+class RelationController(BaseController):
+    def create(self, art_id, kind):
+        art = h.get_object_or_404(Art, id=art_id)
+        user = h.get_object_or_404(User, name=request.params['username'])
+        ## TODO: actually, this should act like a form validation.
+
+        prior_relation = UserRelation.get_by(art=art, user=user)
+        if prior_relation:
+            abort(404) ## should be a validation error
+
+        relation = UserRelation(user=user, kind=kind, art=art, creator=c.user)
+        elixir.session.commit()
+        redirect(url('show_art', id=art_id))
+    
+    def index(self):
+        # Return a rendered template
+        #return render('/relation.mako')
+        # or, return a response
+        return 'Hello World'
index 4e92a6d..f87ddae 100644 (file)
@@ -9,8 +9,8 @@ from floof.lib.search import do_search
 
 log = logging.getLogger(__name__)
 
-from floof.model.art import Art, Tag, TagText
-from floof.model.search import SavedSearch, GalleryWidget
+from floof.model import Art, Tag, TagText
+from floof.model import SavedSearch, GalleryWidget
 import elixir
 
 class SearchController(BaseController):
index 4f98994..e5ce85b 100644 (file)
@@ -9,7 +9,7 @@ from pylons import url
 log = logging.getLogger(__name__)
 
 import elixir
-from floof.model.art import Art, Tag
+from floof.model import Art, Tag
 
 class TagController(BaseController):
 
index f7ce65b..37d762d 100644 (file)
@@ -1,4 +1,4 @@
-from floof.model.art import Art, Tag, TagText
+from floof.model import Art, Tag, TagText
 
 def do_search(query):
     tags = query.split()
index 1a81770..8368357 100644 (file)
@@ -22,7 +22,14 @@ if elixir.options_defaults.get('autoload', False) \
 
 # # import other entities here, e.g.
 # from floof.model.blog import BlogEntry, BlogComment
-from floof.model import art, users, search
+from floof.model.art import *
+from floof.model.ratings import *
+from floof.model.comments import *
+from floof.model.search import *
+from floof.model.tags import *
+from floof.model.users import *
+from floof.model.relations import *
+
 
 # Finally, call elixir to set up the tables.
 # but not if using reflected tables
index 0732684..bb68c9f 100644 (file)
@@ -14,6 +14,10 @@ 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
 
+
+# Note: Art is the most important class.  To keep its size down, and to better organize the source code,
+# other modules will mix into it automatically by adding to its __bases__.
+
 class Art(Entity):
     title = Field(Unicode(120))
     original_filename = Field(Unicode(120))
@@ -23,6 +27,9 @@ class Art(Entity):
     tags = OneToMany('Tag')
     discussion = ManyToOne('Discussion')
 
+    user_relations = OneToMany('UserRelation')
+
+
     def set_file(self, file):
         self.hash = save_file("art", file)
         self.original_filename = file.filename
@@ -33,69 +40,5 @@ class Art(Entity):
         if self.hash:
             return get_path("art", self.hash)
 
-
-    def add_tags(self, tags, user):
-        for text in tags.split():
-            if text[0] == '-':
-                # Nega-tags
-                tagtext = TagText.get_by(text=text[1:])
-                if tagtext:
-                    tag = Tag.get_by(art=self, tagger=user, tagtext=tagtext)
-                    if tag:
-                        elixir.session.delete(tag)
-
-            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...
-
-                # elixir should really have its own find_or_create.
-                tagtext = find_or_create(TagText, text=text)
-                tag     = find_or_create(Tag, art=self, tagger=user, tagtext=tagtext)
-
-
-
-
-    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:
-            return rating.score
-        return Rating.default
-
     def __unicode__(self):
         return self.get_path()
-
-
-class Tag(Entity):
-    # look into how ondelete works.  This just sets a database property.
-    art = ManyToOne('Art', ondelete='cascade')
-    tagger = ManyToOne('User', ondelete='cascade')
-    tagtext = ManyToOne('TagText')
-
-    def __unicode__(self):
-        if not self.tagtext:
-            return "(broken)"
-        return unicode(self.tagtext)
-
-
-class TagText(Entity):
-    text = Field(Unicode(50)) # gotta enforce this somehow
-    tags = OneToMany('Tag')
-
-    def __unicode__(self):
-        return self.text
-
-
-class Rating(Entity):
-    art = ManyToOne('Art', ondelete='cascade')
-    rater = ManyToOne('User', ondelete='cascade')
-    score = Field(Integer)
-
-    options = {-1:"sucks", 0:"undecided", 1:"good", 2:"great"}
-    default = 0
-
-
-Rating.reverse_options = dict (zip(Rating.options.values(), Rating.options.keys()))
diff --git a/floof/model/ratings.py b/floof/model/ratings.py
new file mode 100644 (file)
index 0000000..6b5850a
--- /dev/null
@@ -0,0 +1,26 @@
+from elixir import *
+from art import Art
+from floof.lib.dbhelpers import find_or_create, update_or_create
+
+class Rating(Entity):
+    art = ManyToOne('Art', ondelete='cascade')
+    rater = ManyToOne('User', ondelete='cascade')
+    score = Field(Integer)
+
+    options = {-1:"sucks", 0:"undecided", 1:"good", 2:"great"}
+    default = 0
+
+Rating.reverse_options = dict (zip(Rating.options.values(), Rating.options.keys()))
+
+
+class RatingMixin(object):
+    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:
+            return rating.score
+        return Rating.default
+
+Art.__bases__ += (RatingMixin,)
\ No newline at end of file
diff --git a/floof/model/relations.py b/floof/model/relations.py
new file mode 100644 (file)
index 0000000..a6fe020
--- /dev/null
@@ -0,0 +1,34 @@
+from elixir import *
+from art import Art
+
+
+class UserRelation(Entity):
+    user = ManyToOne("User")
+    art = ManyToOne("Art")
+    kind = Field(String) # by for of
+    creator = ManyToOne("User")
+    confirmed_by_related_user = Field(Boolean)
+
+    # it is useful to record which authority figure on a given artwork
+    # confirmed the validity of this relation.
+    confirmed_by_authority = ManyToOne("User")
+    
+    def __init__(self, **kwargs):
+        super(UserRelation, self).__init__(**kwargs)
+        assert self.user and self.art and self.kind and self.creator
+        
+        if self.creator == self.user:
+            self.confirmed_by_related_user = True
+        # TODO: implement authorities
+        # if self.creator in self.art.authorities
+        #     self.confirmed_by_authority = self.creator
+
+    def __unicode__(self):
+        return "%s: %s" % (self.kind, self.related_user)
+
+
+class RelationMixin(object):
+    def add_relation(creator, kind, user):
+        return UserRelation(art=self, creator=creator, kind=kind, user=user)
+
+Art.__bases__ += (RelationMixin,)
index 01d45a2..fb86f35 100644 (file)
@@ -1,17 +1,20 @@
 from elixir import *
 # from users import User
 
-from floof.lib.search import do_search
 
 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):
+        # This caused some cyclic dependencies when I tried importing it
+        # at the module level.  I wonder why that is...
+        from floof.lib.search import do_search
         return do_search(self.string)
 
 
diff --git a/floof/model/tags.py b/floof/model/tags.py
new file mode 100644 (file)
index 0000000..1a41278
--- /dev/null
@@ -0,0 +1,46 @@
+from elixir import *
+from art import Art
+from floof.lib.dbhelpers import find_or_create, update_or_create
+
+
+class Tag(Entity):
+    # look into how ondelete works.  This just sets a database property.
+    art = ManyToOne('Art', ondelete='cascade')
+    tagger = ManyToOne('User', ondelete='cascade')
+    tagtext = ManyToOne('TagText')
+
+    def __unicode__(self):
+        if not self.tagtext:
+            return "(broken)"
+        return unicode(self.tagtext)
+
+
+class TagText(Entity):
+    text = Field(Unicode(50)) # gotta enforce this somehow
+    tags = OneToMany('Tag')
+
+    def __unicode__(self):
+        return self.text
+
+
+class TagMixin(object):
+    def add_tags(self, tags, user):
+        for text in tags.split():
+            if text[0] == '-':
+                # Nega-tags
+                tagtext = TagText.get_by(text=text[1:])
+                if tagtext:
+                    tag = Tag.get_by(art=self, tagger=user, tagtext=tagtext)
+                    if tag:
+                        elixir.session.delete(tag)
+
+            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...
+
+                # elixir should really have its own find_or_create.
+                tagtext = find_or_create(TagText, text=text)
+                tag     = find_or_create(Tag, art=self, tagger=user, tagtext=tagtext)
+                
+Art.__bases__ += (TagMixin, )
index af4bfba..7601cf5 100644 (file)
@@ -22,6 +22,9 @@ class User(Entity):
 
     def __unicode__(self):
         return self.name
+    
+    def __str__(self):
+        return self.name
 
     def __init__(self, **kwargs):
         super(User, self).__init__(**kwargs)
@@ -47,10 +50,9 @@ class UserPage(Entity):
     This is so that they can keep some nice themed pages lying around for special occasions.
     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.
-
-     """
-
+    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)
 
@@ -75,3 +77,4 @@ class UserRelationship(Entity):
     user = ManyToOne('User')
     target_user = ManyToOne('User')
     type = Field(Integer)  # UserRelationshipTypes above
+
index 017379f..bc7056e 100644 (file)
@@ -37,6 +37,9 @@ 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; }
 
+.errors {color:red;}
+
+
 /* Comments */
 .comment {}
 .comment .header { background: #d8d8d8; }
index 3eda527..4204700 100644 (file)
@@ -3,7 +3,31 @@
 <h1>Add New Art</h1>
 <p>Now: Upload a file.  Later: Supply a link?  Not exclusive to uploading.</p>
 
+## Todo: write some macros to make outputting form fields easier.
+
 ${h.form(h.url('create_art'), multipart=True)}
-${h.file('file')}
+
+${normal_field(c.form.by)}
+${normal_field(c.form.file)}
+
+
+##Artist: ${h.text('artist')}
+##${h.file('file')}
 ${h.submit(None, 'Upload!')}
 ${h.end_form()}
+
+
+
+<%def name="normal_field(field)">
+<div>
+${field.label()|n}
+${field()|n} 
+%if field.errors:
+<ul class="errors">
+%for error in field.errors:
+<li>${error}
+%endfor
+</ul>
+%endif
+</div>
+</%def>
index cf713dc..05d1e42 100644 (file)
@@ -1,7 +1,7 @@
 <%inherit file="/base.mako" />
 <%namespace name="comments" file="/comments/lib.mako" />
 
-<%! from floof.model.art import Rating %>
+<%! from floof.model import Rating %>
 
 <h1>Viewing Art</h1>
 
@@ -32,6 +32,20 @@ ${h.submit('score', text)}
 ${h.end_form()}
 % endif
 
+<h2>Relations</h2>
+<ul>
+% for relation in c.art.user_relations:
+<li>${relation.kind}: ${relation.user}
+% endfor
+</ul>
+
+<h2>Add Relations</h2>
+${h.form (h.url("create_relation", kind="by", art_id=c.art.id))}
+By: ${h.text('username')}
+${h.submit('add','Add')}
+${h.end_form()}
+
+
 <img class="full" src="${c.art.get_path()}">
 
 ${comments.comment_block(c.art.discussion.comments)}
diff --git a/floof/tests/functional/test_relation.py b/floof/tests/functional/test_relation.py
new file mode 100644 (file)
index 0000000..b651acf
--- /dev/null
@@ -0,0 +1,7 @@
+from floof.tests import *
+
+class TestRelationController(TestController):
+
+    def test_index(self):
+        response = self.app.get(url(controller='relation', action='index'))
+        # Test response...