Added basic collection functionality
authorAaron Williamson <aaron@copiesofcopies.org>
Fri, 17 Aug 2012 04:54:40 +0000 (00:54 -0400)
committerJoar Wandborg <git@wandborg.com>
Tue, 18 Sep 2012 16:10:36 +0000 (18:10 +0200)
18 files changed:
mediagoblin/db/mixin.py
mediagoblin/db/sql/migrations.py
mediagoblin/db/sql/models.py
mediagoblin/db/sql/util.py
mediagoblin/db/util.py
mediagoblin/decorators.py
mediagoblin/edit/forms.py
mediagoblin/edit/routing.py
mediagoblin/edit/views.py
mediagoblin/static/css/base.css
mediagoblin/submit/forms.py
mediagoblin/submit/routing.py
mediagoblin/submit/views.py
mediagoblin/templates/mediagoblin/base.html
mediagoblin/templates/mediagoblin/user_pages/media.html
mediagoblin/user_pages/forms.py
mediagoblin/user_pages/routing.py
mediagoblin/user_pages/views.py

index 9f9b8786bb80d0e3034d50dfba30a6bf63b169dd..929c1c7f77ee898cdd9336afcd496fb1b04c51dd 100644 (file)
@@ -143,3 +143,56 @@ class MediaCommentMixin(object):
         Run through Markdown and the HTML cleaner.
         """
         return cleaned_markdown_conversion(self.content)
+
+
+class CollectionMixin(object):
+    def generate_slug(self):
+        # import this here due to a cyclic import issue
+        # (db.models -> db.mixin -> db.util -> db.models)
+        from mediagoblin.db.util import check_collection_slug_used
+
+        self.slug = slugify(self.title)
+
+        duplicate = check_collection_slug_used(mg_globals.database,
+            self.creator, self.slug, self.id)
+
+        if duplicate:
+            if self.id is not None:
+                self.slug = u"%s-%s" % (self.id, self.slug)
+            else:
+                self.slug = None
+
+    @property
+    def description_html(self):
+        """
+        Rendered version of the description, run through
+        Markdown and cleaned with our cleaning tool.
+        """
+        return cleaned_markdown_conversion(self.description)
+
+    @property
+    def slug_or_id(self):
+        return (self.slug or self._id)
+
+    def url_for_self(self, urlgen, **extra_args):
+        """
+        Generate an appropriate url for ourselves
+
+        Use a slug if we have one, else use our '_id'.
+        """
+        creator = self.get_creator
+
+        return urlgen(
+            'mediagoblin.user_pages.collections_home',
+            user=creator.username,
+            collection=self.slug_or_id,
+            **extra_args)
+
+class CollectionItemMixin(object):
+    @property
+    def note_html(self):
+        """
+        the actual html-rendered version of the note displayed.
+        Run through Markdown and the HTML cleaner.
+        """
+        return cleaned_markdown_conversion(self.note)
index 49798a541d878e915f3959313c83585549144ceb..3db51ab9489200e2e1310c1ceac60f0872cac899 100644 (file)
@@ -14,7 +14,7 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sqlalchemy import MetaData, Table, Column, Boolean, SmallInteger
+from sqlalchemy import MetaData, Table, Column, Boolean, SmallInteger, Integer
 
 from mediagoblin.db.sql.util import RegisterMigration
 
@@ -59,3 +59,15 @@ def add_transcoding_progress(db_conn):
     col = Column('transcoding_progress', SmallInteger)
     col.create(media_entry)
     db_conn.commit()
+
+
+@RegisterMigration(4, MIGRATIONS)
+def add_mediaentry_collections(db_conn):
+    metadata = MetaData(bind=db_conn.bind)
+
+    media_entry = Table('core__media_entries', metadata, autoload=True,
+            autoload_with=db_conn.bind)
+
+    col = Column('collections', Integer)
+    col.create(media_entry)
+    db_conn.commit()
index 7c8c0f99be05469eea3a507964014f16e5194aa4..5862f722b2f362c6e472794b6ddd871a923b36d0 100644 (file)
@@ -33,7 +33,7 @@ from sqlalchemy.util import memoized_property
 
 from mediagoblin.db.sql.extratypes import PathTupleWithSlashes, JSONEncoded
 from mediagoblin.db.sql.base import Base, DictReadAttrProxy
-from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, MediaCommentMixin
+from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, MediaCommentMixin, CollectionMixin, CollectionItemMixin
 from mediagoblin.db.sql.base import Session
 
 # It's actually kind of annoying how sqlalchemy-migrate does this, if
@@ -103,6 +103,7 @@ class MediaEntry(Base, MediaEntryMixin):
     state = Column(Unicode, default=u'unprocessed', nullable=False)
         # or use sqlalchemy.types.Enum?
     license = Column(Unicode)
+    collected = Column(Integer, default=0)
 
     fail_error = Column(Unicode)
     fail_metadata = Column(JSONEncoded)
@@ -143,6 +144,11 @@ class MediaEntry(Base, MediaEntryMixin):
         creator=lambda v: MediaTag(name=v["name"], slug=v["slug"])
         )
 
+    collections_helper = relationship("CollectionItem",
+        cascade="all, delete-orphan"
+        )
+    collections = association_proxy("collections_helper", "in_collection")
+
     ## TODO
     # media_data
     # fail_error
@@ -348,8 +354,58 @@ class MediaComment(Base, MediaCommentMixin):
     _id = SimpleFieldAlias("id")
 
 
+class Collection(Base, CollectionMixin):
+    __tablename__ = "core__collections"
+
+    id = Column(Integer, primary_key=True)
+    title = Column(Unicode, nullable=False)
+    slug = Column(Unicode)
+    created = Column(DateTime, nullable=False, default=datetime.datetime.now,
+        index=True)
+    description = Column(UnicodeText) 
+    creator = Column(Integer, ForeignKey(User.id), nullable=False)
+    items = Column(Integer, default=0)
+
+    get_creator = relationship(User)
+    
+    def get_collection_items(self, ascending=False):
+        order_col = CollectionItem.position
+        if not ascending:
+            order_col = desc(order_col)
+        return CollectionItem.query.filter_by(
+            collection=self.id).order_by(order_col)
+
+    _id = SimpleFieldAlias("id")
+
+
+class CollectionItem(Base, CollectionItemMixin):
+    __tablename__ = "core__collection_items"
+
+    id = Column(Integer, primary_key=True)
+    media_entry = Column(
+        Integer, ForeignKey(MediaEntry.id), nullable=False, index=True)
+    collection = Column(Integer, ForeignKey(Collection.id), nullable=False)
+    note = Column(UnicodeText, nullable=True)
+    added = Column(DateTime, nullable=False, default=datetime.datetime.now)
+    position = Column(Integer)
+    in_collection = relationship("Collection")
+
+    get_media_entry = relationship(MediaEntry)
+
+    _id = SimpleFieldAlias("id")
+
+    __table_args__ = (
+        UniqueConstraint('collection', 'media_entry'),
+        {})
+
+    @property
+    def dict_view(self):
+        """A dict like view on this object"""
+        return DictReadAttrProxy(self)
+
+
 MODELS = [
-    User, MediaEntry, Tag, MediaTag, MediaComment, MediaFile, FileKeynames,
+    User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem, MediaFile, FileKeynames,
     MediaAttachmentFile]
 
 
index 53260db2d24dea6654be327640b5c004a04c08b3..74b5d73ed631685c8842260565e5d2efefa22bb6 100644 (file)
@@ -17,7 +17,7 @@
 
 import sys
 from mediagoblin.db.sql.base import Session
-from mediagoblin.db.sql.models import MediaEntry, Tag, MediaTag
+from mediagoblin.db.sql.models import MediaEntry, Tag, MediaTag, Collection
 
 from mediagoblin.tools.common import simple_printer
 
@@ -310,6 +310,15 @@ def clean_orphan_tags():
     Session.commit()
 
 
+def check_collection_slug_used(dummy_db, creator_id, slug, ignore_c_id):
+    filt = (Collection.creator == creator_id) \
+        & (Collection.slug == slug)
+    if ignore_c_id is not None:
+        filt = filt & (Collection.id != ignore_c_id)
+    does_exist = Session.query(Collection.id).filter(filt).first() is not None
+    return does_exist
+
+
 if __name__ == '__main__':
     from mediagoblin.db.sql.open import setup_connection_and_db_from_config
 
index 540a9244d578d53094f72acea45ad28d9163cd5e..a8c8c92b50aa5ee7740447631872a4dfdf9cd9ed 100644 (file)
@@ -22,7 +22,7 @@ except ImportError:
 if use_sql:
     from mediagoblin.db.sql.fake import ObjectId, InvalidId, DESCENDING
     from mediagoblin.db.sql.util import atomic_update, check_media_slug_used, \
-        media_entries_for_tag_slug
+        media_entries_for_tag_slug, check_collection_slug_used
 else:
     from mediagoblin.db.mongo.util import \
         ObjectId, InvalidId, DESCENDING, atomic_update, \
index 9961be839cea7a8a786d344fcddec03408ba3e26..9be9d4cc1246b6d8348abab0bfabb6ba3b9f672a 100644 (file)
@@ -70,6 +70,23 @@ def user_may_delete_media(controller):
     return wrapper
 
 
+def user_may_alter_collection(controller):
+    """
+    Require user ownership of the Collection to modify.
+    """
+    @wraps(controller)
+    def wrapper(request, *args, **kwargs):
+        creator_id = request.db.User.find_one(
+            {'username': request.matchdict['user']}).id
+        if not (request.user.is_admin or
+                request.user._id == creator_id):
+            return exc.HTTPForbidden()
+
+        return controller(request, *args, **kwargs)
+
+    return wrapper
+
+
 def uses_pagination(controller):
     """
     Check request GET 'page' key for wrong values
@@ -123,6 +140,59 @@ def get_user_media_entry(controller):
     return wrapper
 
 
+def get_user_collection(controller):
+    """
+    Pass in a Collection based off of a url component
+    """
+    @wraps(controller)
+    def wrapper(request, *args, **kwargs):
+        user = request.db.User.find_one(
+            {'username': request.matchdict['user']})
+
+        if not user:
+            return render_404(request)
+
+        collection = request.db.Collection.find_one(
+            {'slug': request.matchdict['collection'],
+             'creator': user._id})
+
+        # Still no collection?  Okay, 404.
+        if not collection:
+            return render_404(request)
+
+        return controller(request, collection=collection, *args, **kwargs)
+
+    return wrapper
+
+
+def get_user_collection_item(controller):
+    """
+    Pass in a CollectionItem based off of a url component
+    """
+    @wraps(controller)
+    def wrapper(request, *args, **kwargs):
+        user = request.db.User.find_one(
+            {'username': request.matchdict['user']})
+
+        if not user:
+            return render_404(request)
+
+        collection = request.db.Collection.find_one(
+            {'slug': request.matchdict['collection'],
+             'creator': user._id})
+
+        collection_item = request.db.CollectionItem.find_one(
+            {'_id': request.matchdict['collection_item'] })
+
+        # Still no collection item?  Okay, 404.
+        if not collection_item:
+            return render_404(request)
+
+        return controller(request, collection_item=collection_item, *args, **kwargs)
+
+    return wrapper
+
+
 def get_media_entry_by_id(controller):
     """
     Pass in a MediaEntry based off of a url component
index e2882adad02d41440a0c621c4edae6a19d88a3d0..856852b6d0b76e1410633254d6d9cb24976fead5 100644 (file)
@@ -77,3 +77,19 @@ class EditAttachmentsForm(wtforms.Form):
         'Title')
     attachment_file = wtforms.FileField(
         'File')
+
+class EditCollectionForm(wtforms.Form):
+    title = wtforms.TextField(
+        _('Title'),
+        [wtforms.validators.Length(min=0, max=500), wtforms.validators.Required(message=_("The title can't be empty"))])
+    description = wtforms.TextAreaField(
+        _('Description of this collection'),
+        description=_("""You can use
+                      <a href="http://daringfireball.net/projects/markdown/basics">
+                      Markdown</a> for formatting."""))
+    slug = wtforms.TextField(
+        _('Slug'),
+        [wtforms.validators.Required(message=_("The slug can't be empty"))],
+        description=_(
+            "The title part of this collection's address. "
+            "You usually don't need to change this."))
index 5bcafeac871922836811972ed9a0f8501ec3b9a2..321c1f448c0cc96e18efb0a015f8e0969fe36dbe 100644 (file)
@@ -22,5 +22,5 @@ edit_routes = [
     Route('mediagoblin.edit.profile', '/profile/',
         controller="mediagoblin.edit.views:edit_profile"),
     Route('mediagoblin.edit.account', '/account/',
-        controller="mediagoblin.edit.views:edit_account")
+        controller="mediagoblin.edit.views:edit_account"),
     ]
index 9ce702317fd8d1a8681c326c3d46ac1402dbd103..d4adcd0d3d56f198f8f827ed5bdd6d95890c2169 100644 (file)
@@ -25,13 +25,14 @@ from mediagoblin import mg_globals
 
 from mediagoblin.auth import lib as auth_lib
 from mediagoblin.edit import forms
-from mediagoblin.edit.lib import may_edit_media
-from mediagoblin.decorators import require_active_login, get_user_media_entry
+from mediagoblin.edit.lib import may_edit_media, may_edit_collection
+from mediagoblin.decorators import require_active_login, get_user_media_entry, \
+    user_may_alter_collection, get_user_collection
 from mediagoblin.tools.response import render_to_response, redirect
 from mediagoblin.tools.translate import pass_to_ugettext as _
 from mediagoblin.tools.text import (
     convert_to_tag_list_of_dicts, media_tags_as_string)
-from mediagoblin.db.util import check_media_slug_used
+from mediagoblin.db.util import check_media_slug_used, check_collection_slug_used
 
 import mimetypes
 
@@ -255,3 +256,58 @@ def edit_account(request):
         'mediagoblin/edit/edit_account.html',
         {'user': user,
          'form': form})
+
+
+@require_active_login
+@user_may_alter_collection
+@get_user_collection
+def edit_collection(request, collection):
+    defaults = dict(
+        title=collection.title,
+        slug=collection.slug,
+        description=collection.description)
+
+    form = forms.EditCollectionForm(
+        request.POST,
+        **defaults)
+
+    if request.method == 'POST' and form.validate():
+        # Make sure there isn't already a Collection with such a slug
+        # and userid.
+        slug_used = check_collection_slug_used(request.db, collection.creator,
+                request.POST['slug'], collection.id)
+        
+        # Make sure there isn't already a Collection with this title
+        existing_collection = request.db.Collection.find_one({
+                'creator': request.user._id,
+                'title':request.POST['title']})
+                
+        if existing_collection and existing_collection.id != collection.id:
+            messages.add_message(
+                request, messages.ERROR, _('You already have a collection called "%s"!' % request.POST['title']))
+        elif slug_used:
+            form.slug.errors.append(
+                _(u'A collection with that slug already exists for this user.'))
+        else:
+            collection.title = unicode(request.POST['title'])
+            collection.description = unicode(request.POST.get('description'))
+            collection.slug = unicode(request.POST['slug'])
+
+            collection.save()
+
+            return redirect(request, "mediagoblin.user_pages.user_collection",
+                            user=collection.get_creator.username,
+                            collection=collection.slug)
+
+    if request.user.is_admin \
+            and collection.creator != request.user._id \
+            and request.method != 'POST':
+        messages.add_message(
+            request, messages.WARNING,
+            _("You are editing another user's collection. Proceed with caution."))
+
+    return render_to_response(
+        request,
+        'mediagoblin/edit/edit_collection.html',
+        {'collection': collection,
+         'form': form})
index 0a14335a88a19845cbd5557dfd09f04692a35745..6bc85674f51f54c9a11d4e846f6a311f374a3557 100644 (file)
@@ -250,6 +250,17 @@ footer {
   font-family: 'Lato', sans-serif;
 }
 
+.button_collect {
+    background-image: url("../images/icon_collect.png"); 
+    background-repeat: no-repeat;
+    background-position:top center;
+    height: 30px;
+    width: 30px;
+    margin: 0px;
+    padding: 3px 3px 2px 3px;
+    position: relative;
+}
+
 .pagination {
 text-align: center;
 }
@@ -344,6 +355,10 @@ text-align: center;
   text-align: right;
 }
 
+.subform {
+  margin: 2em;
+}
+
 #password_boolean {
   margin-top: 4px;
   width: 20px;
@@ -416,6 +431,34 @@ textarea#comment_content {
   max-height: 135px;
 }
 
+/* collection media */
+
+.collection_thumbnail {
+  float: left;
+  padding: 0px;
+  width: 180px;
+  margin: 0px 4px 10px;
+  text-align: left;
+  font-size: 0.875em;
+  background-color: #222;
+  border-radius: 0 0 5px 5px;
+  padding: 0 0 6px;
+  text-overflow: ellipsis;
+}
+
+.collection_thumbnail a {
+  color: #eee;
+  text-decoration: none;
+}
+
+.collection_thumbnail a.remove {
+  color: #86D4B1;
+}
+
+.collection_thumbnail img {
+  max-height: 135px;
+}
+
 /* media detail */
 
 h2.media_title {
index 7d9e8fcf83c299e3280485723e3cee718cb279ca..bd1e904f8b3cafb0aa5d2ec7bba6ea32e3ac56dd 100644 (file)
@@ -41,3 +41,13 @@ class SubmitStartForm(wtforms.Form):
         _('License'),
         [wtforms.validators.Optional(),],
         choices=licenses_as_choices())
+
+class AddCollectionForm(wtforms.Form):
+    title = wtforms.TextField(
+        _('Title'),
+        [wtforms.validators.Length(min=0, max=500), wtforms.validators.Required()])
+    description = wtforms.TextAreaField(
+        _('Description of this collection'),
+        description=_("""You can use
+                      <a href="http://daringfireball.net/projects/markdown/basics">
+                      Markdown</a> for formatting."""))
index 8ce23cd9d1311f7ecdca0f7d4bfa5f5ec859b072..1e399d1eb111b0bb17847f931de726df1580a7cf 100644 (file)
@@ -18,4 +18,7 @@ from routes.route import Route
 
 submit_routes = [
     Route('mediagoblin.submit.start', '/',
-          controller='mediagoblin.submit.views:submit_start')]
+          controller='mediagoblin.submit.views:submit_start'),
+    Route('mediagoblin.submit.collection', '/collection',
+          controller='mediagoblin.submit.views:add_collection'),
+    ]
index 72186136998f5ea955f45c46c078031a66647e29..a9b13778f5d0959c502d1b5e0b0a9c7f7834a941 100644 (file)
@@ -14,6 +14,7 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+from mediagoblin import messages
 import mediagoblin.mg_globals as mg_globals
 import uuid
 from os.path import splitext
@@ -181,3 +182,46 @@ def submit_start(request):
         'mediagoblin/submit/start.html',
         {'submit_form': submit_form,
          'app_config': mg_globals.app_config})
+
+@require_active_login
+def add_collection(request, media=None):
+    """
+    View to create a new collection
+    """
+    submit_form = submit_forms.AddCollectionForm(request.POST)
+
+    if request.method == 'POST' and submit_form.validate():
+        try:
+            collection = request.db.Collection()
+            collection.id = ObjectId()
+
+            collection.title = unicode(request.POST['title'])
+
+            collection.description = unicode(request.POST.get('description'))
+            collection.creator = request.user._id
+            collection.generate_slug()
+
+            # Make sure this user isn't duplicating an existing collection
+            existing_collection = request.db.Collection.find_one({
+                    'creator': request.user._id,
+                    'title':collection.title})
+                
+            if existing_collection:
+                messages.add_message(
+                    request, messages.ERROR, _('You already have a collection called "%s"!' % collection.title))
+            else:
+                collection.save(validate=True)
+            
+                add_message(request, SUCCESS, _('Collection "%s" added!' % collection.title))
+
+            return redirect(request, "mediagoblin.user_pages.user_home",
+                            user=request.user.username)
+
+        except Exception as e:
+            raise
+
+    return render_to_response(
+        request,
+        'mediagoblin/submit/collection.html',
+        {'submit_form': submit_form,
+         'app_config': mg_globals.app_config})
index 5620debd5e3770a004d440741c4eba8db667300d..b6ba8818837145b52f3afa88aef7a22048d64b1b 100644 (file)
@@ -64,6 +64,7 @@
                 <div class="dropdown_items">
                   {% if request.user and request.user.status == 'active' %}
                     <a href="{{ request.urlgen('mediagoblin.submit.start') }}">{% trans %}+ Add media{% endtrans %}</a>
+                    <a href="{{ request.urlgen('mediagoblin.submit.collection') }}">{% trans %}+ Add collection{% endtrans %}</a>
                   {% endif %}
                   <a href="{{ request.urlgen('mediagoblin.user_pages.user_home', user= request.user.username) }}">{% trans %}View your profile{% endtrans %}</a>
                   <a class="button_action" href="{{ request.urlgen('mediagoblin.auth.logout') }}">{% trans %}Log out{% endtrans %}</a>
index 15ac498868cdb2888b45e257c4f0e6fce9fc3d7b..ac15dd2fa8c4b90a5981fcbfe75d0cc718648ec8 100644 (file)
       {% include "mediagoblin/utils/tags.html" %}
     {% endif %}
 
+    {% if media.collections %}
+      {% include "mediagoblin/utils/collections.html" %}
+    {% endif %}
+
     {% include "mediagoblin/utils/license.html" %}
 
     {% include "mediagoblin/utils/geolocation_map.html" %}
       </p>
     {% endif %}
 
+    {% if request.user %}
+      <p>
+        <a type="submit" href="{{ request.urlgen('mediagoblin.user_pages.media_collect', 
+                                    user=media.get_uploader.username,
+                                    media=media._id) }}" class="button_action button_collect" >
+       </a>
+      </p>
+    {% endif %}
+
     {% block mediagoblin_sidebar %}
     {% endblock %}
   </div>
index f17e6c0000622f51f83ec8da8c0c10f1dbaf2ce6..9e8ccf01e65d93678ad7767bea1cfd81e6354c66 100644 (file)
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import wtforms
-
+from wtforms.ext.sqlalchemy.fields import QuerySelectField
 from mediagoblin.tools.translate import fake_ugettext_passthrough as _
 
-
 class MediaCommentForm(wtforms.Form):
     comment_content = wtforms.TextAreaField(
         '',
         [wtforms.validators.Required()])
 
-
 class ConfirmDeleteForm(wtforms.Form):
     confirm = wtforms.BooleanField(
         _('I am sure I want to delete this'))
+
+class ConfirmCollectionItemRemoveForm(wtforms.Form):
+    confirm = wtforms.BooleanField(
+        _('I am sure I want to remove this item from the collection'))
+
+class MediaCollectForm(wtforms.Form):
+    collection = QuerySelectField(allow_blank=True, blank_text=_('-- Select --'), get_label='title',)
+    note = wtforms.TextAreaField(
+        _('Include a note'),
+        [wtforms.validators.Optional()],)
+    collection_title = wtforms.TextField(
+        _('Title'),
+        [wtforms.validators.Length(min=0, max=500)])
+    collection_description = wtforms.TextAreaField(
+        _('Description of this collection'),
+        description=_("""You can use
+                      <a href="http://daringfireball.net/projects/markdown/basics">
+                      Markdown</a> for formatting."""))
index f9780edcb4e88f0a2165749a08a9a033651a309f..1cfce2dd4c384bd7c6c2bb9449f8d159af1ad4c0 100644 (file)
@@ -40,6 +40,21 @@ user_routes = [
     Route('mediagoblin.user_pages.media_post_comment',
           '/{user}/m/{media}/comment/add/',
           controller="mediagoblin.user_pages.views:media_post_comment"),
+    Route('mediagoblin.user_pages.media_collect',
+          "/{user}/m/{media}/collect/",
+          controller="mediagoblin.user_pages.views:media_collect"),
+    Route('mediagoblin.user_pages.user_collection', "/{user}/collection/{collection}/",
+        controller="mediagoblin.user_pages.views:user_collection"),
+    Route('mediagoblin.edit.edit_collection', "/{user}/c/{collection}/edit/",
+          controller="mediagoblin.edit.views:edit_collection"),
+    Route('mediagoblin.user_pages.collection_confirm_delete',
+          "/{user}/c/{collection}/confirm-delete/",
+          controller="mediagoblin.user_pages.views:collection_confirm_delete"),
+    Route('mediagoblin.user_pages.collection_item_confirm_remove', 
+          "/{user}/collection/{collection}/{collection_item}/confirm_remove/",
+          controller="mediagoblin.user_pages.views:collection_item_confirm_remove"),
+    Route('mediagoblin.user_pages.collection_atom_feed', '/{user}/collection/{collection}/atom/',
+        controller="mediagoblin.user_pages.views:collection_atom_feed"),
     Route('mediagoblin.user_pages.processing_panel',
           '/{user}/panel/',
           controller="mediagoblin.user_pages.views:processing_panel"),
index 4c86aadcb3f713a899e0e8b6123a25e148f00f57..ee366c105f5595980fc5f7a730c7aafd95065ecc 100644 (file)
@@ -28,12 +28,13 @@ from mediagoblin.user_pages import forms as user_forms
 from mediagoblin.user_pages.lib import send_comment_email
 
 from mediagoblin.decorators import (uses_pagination, get_user_media_entry,
-    require_active_login, user_may_delete_media)
+    require_active_login, user_may_delete_media, user_may_alter_collection, 
+    get_user_collection, get_user_collection_item)
 
 from werkzeug.contrib.atom import AtomFeed
 
 from mediagoblin.media_types import get_media_manager
-
+from sqlalchemy.exc import IntegrityError
 
 _log = logging.getLogger(__name__)
 _log.setLevel(logging.DEBUG)
@@ -175,6 +176,91 @@ def media_post_comment(request, media):
         location=media.url_for_self(request.urlgen))
 
 
+@get_user_media_entry
+@require_active_login
+def media_collect(request, media):
+
+    form = user_forms.MediaCollectForm(request.POST)
+    filt = (request.db.Collection.creator == request.user.id)
+    form.collection.query = request.db.Collection.query.filter(filt).order_by(request.db.Collection.title)
+
+    if request.method == 'POST':
+        if form.validate():
+
+            collection = None
+            collection_item = request.db.CollectionItem()
+
+            # If the user is adding a new collection, use that
+            if request.POST['collection_title']:
+                collection = request.db.Collection()
+                collection.id = ObjectId()
+
+                collection.title = (
+                    unicode(request.POST['collection_title'])
+                    or unicode(splitext(filename)[0]))
+
+                collection.description = unicode(request.POST.get('collection_description'))
+                collection.creator = request.user._id
+                collection.generate_slug()
+
+                # Make sure this user isn't duplicating an existing collection
+                existing_collection = request.db.Collection.find_one({
+                                        'creator': request.user._id,
+                                        'title':collection.title})
+                
+                if existing_collection:
+                    messages.add_message(
+                        request, messages.ERROR, _('You already have a collection called "%s"!' % collection.title))
+                    
+                    return redirect(request, "mediagoblin.user_pages.media_home",
+                                    user=request.user.username,
+                                    media=media.id)
+
+                collection.save(validate=True)
+
+                collection_item.collection = collection.id
+            # Otherwise, use the collection selected from the drop-down
+            else:
+                collection = request.db.Collection.find_one({'_id': request.POST.get('collection')})
+                collection_item.collection = collection.id
+
+            # Make sure the user actually selected a collection
+            if not collection:
+                messages.add_message(
+                    request, messages.ERROR, _('You have to select or add a collection'))
+            # Check whether media already exists in collection
+            elif request.db.CollectionItem.find_one({'media_entry': media.id, 'collection': collection_item.collection}):
+                messages.add_message(
+                    request, messages.ERROR, _('"%s" already in collection "%s"' % (media.title, collection.title)))
+            else:
+                collection_item.media_entry = media.id
+                collection_item.author = request.user.id
+                collection_item.note = unicode(request.POST['note'])
+                collection_item.save(validate=True)
+
+                collection.items = collection.items + 1
+                collection.save(validate=True)
+        
+                media.collected = media.collected + 1
+                media.save()
+
+                messages.add_message(
+                    request, messages.SUCCESS, _('"%s" added to collection "%s"' % (media.title, collection.title)))
+
+            return redirect(request, "mediagoblin.user_pages.media_home",
+                            user=request.user.username,
+                            media=media.id)
+        else:
+            messages.add_message(
+                request, messages.ERROR, _('Please check your entries and try again.'))      
+
+    return render_to_response(
+        request,
+        'mediagoblin/user_pages/media_collect.html',
+        {'media': media,
+         'form': form})
+
+
 @get_user_media_entry
 @require_active_login
 @user_may_delete_media
@@ -227,6 +313,132 @@ def media_confirm_delete(request, media):
          'form': form})
 
 
+@uses_pagination
+def user_collection(request, page):
+    """A User-defined Collection"""
+    user = request.db.User.find_one({
+            'username': request.matchdict['user'],
+            'status': u'active'})
+    if not user:
+        return render_404(request)
+
+    collection = request.db.Collection.find_one(
+        {'slug': request.matchdict['collection'] })
+
+    cursor = request.db.CollectionItem.find(
+        {'collection': collection.id })
+
+    pagination = Pagination(page, cursor)
+    collection_items = pagination()
+
+    #if no data is available, return NotFound
+    if collection_items == None:
+        return render_404(request)
+
+    return render_to_response(
+        request,
+        'mediagoblin/user_pages/collection.html',
+        {'user': user,
+         'collection': collection,
+         'collection_items': collection_items,
+         'pagination': pagination})
+
+
+@get_user_collection_item
+@require_active_login
+@user_may_alter_collection
+def collection_item_confirm_remove(request, collection_item):
+
+    form = user_forms.ConfirmCollectionItemRemoveForm(request.POST)
+
+    if request.method == 'POST' and form.validate():
+        username = collection_item.in_collection.get_creator.username
+        collection = collection_item.in_collection
+
+        if form.confirm.data is True:
+            entry = collection_item.get_media_entry
+            entry.collected = entry.collected - 1
+            entry.save()
+
+            collection_item.delete()
+            collection.items = collection.items - 1;
+            collection.save()
+
+            messages.add_message(
+                request, messages.SUCCESS, _('You deleted the item from the collection.'))
+        else:
+            messages.add_message(
+                request, messages.ERROR,
+                _("The item was not removed because you didn't check that you were sure."))
+
+        return redirect(request, "mediagoblin.user_pages.user_collection",
+                        user=username,
+                        collection=collection.slug)
+
+    if ((request.user.is_admin and
+         request.user._id != collection.creator)):
+        messages.add_message(
+            request, messages.WARNING,
+            _("You are about to delete an item from another user's collection. "
+              "Proceed with caution."))
+
+    return render_to_response(
+        request,
+        'mediagoblin/user_pages/collection_item_confirm_remove.html',
+        {'collection_item': collection_item,
+         'form': form})
+
+
+@get_user_collection
+@require_active_login
+@user_may_alter_collection
+def collection_confirm_delete(request, collection):
+
+    form = user_forms.ConfirmDeleteForm(request.POST)
+
+    if request.method == 'POST' and form.validate():
+
+        username = collection.get_creator.username
+
+        if form.confirm.data is True:
+            collection_title = collection.title
+
+            # Delete all the associated collection items
+            for item in collection.get_collection_items():
+                entry = item.get_media_entry
+                entry.collected = entry.collected - 1
+                entry.save()
+                item.delete()
+
+            collection.delete()
+            messages.add_message(
+                request, messages.SUCCESS, _('You deleted the collection "%s"' % collection_title))
+
+            return redirect(request, "mediagoblin.user_pages.user_home",
+                user=username)
+        else:
+            messages.add_message(
+                request, messages.ERROR,
+                _("The collection was not deleted because you didn't check that you were sure."))
+
+            return redirect(request, "mediagoblin.user_pages.user_collection",
+                            user=username,
+                            collection=collection.slug)
+
+    if ((request.user.is_admin and
+         request.user._id != collection.creator)):
+        messages.add_message(
+            request, messages.WARNING,
+            _("You are about to delete another user's collection. "
+              "Proceed with caution."))
+
+    return render_to_response(
+        request,
+        'mediagoblin/user_pages/collection_confirm_delete.html',
+        {'collection': collection,
+         'form': form})
+
+
 ATOM_DEFAULT_NR_OF_UPDATED_ITEMS = 15
 
 
@@ -293,6 +505,74 @@ def atom_feed(request):
 
     return feed.get_response()
 
+def collection_atom_feed(request):
+    """
+    generates the atom feed with the newest images from a collection
+    """
+
+    user = request.db.User.find_one({
+               'username': request.matchdict['user'],
+               'status': u'active'})
+    if not user:
+        return render_404(request)
+
+    collection = request.db.Collection.find_one({
+               'creator': user.id,
+               'slug': request.matchdict['collection']})
+
+    cursor = request.db.CollectionItem.find({
+                 'collection': collection._id}) \
+                 .sort('added', DESCENDING) \
+                 .limit(ATOM_DEFAULT_NR_OF_UPDATED_ITEMS)
+
+    """
+    ATOM feed id is a tag URI (see http://en.wikipedia.org/wiki/Tag_URI)
+    """
+    atomlinks = [{
+           'href': request.urlgen(
+               'mediagoblin.user_pages.user_collection',
+               qualified=True, user=request.matchdict['user'], collection=collection.slug),
+           'rel': 'alternate',
+           'type': 'text/html'
+           }]
+
+    if mg_globals.app_config["push_urls"]:
+        for push_url in mg_globals.app_config["push_urls"]:
+            atomlinks.append({
+                'rel': 'hub',
+                'href': push_url})
+
+    feed = AtomFeed(
+               "MediaGoblin: Feed for %s's collection %s" % (request.matchdict['user'], collection.title),
+               feed_url=request.url,
+               id='tag:{host},{year}:collection.user-{user}.title-{title}'.format(
+                   host=request.host,
+                   year=datetime.datetime.today().strftime('%Y'),
+                   user=request.matchdict['user'],
+                   title=collection.title),
+               links=atomlinks)
+
+    for item in cursor:
+        entry = item.get_media_entry
+        feed.add(entry.get('title'),
+            item.note_html,
+            id=entry.url_for_self(request.urlgen, qualified=True),
+            content_type='html',
+            author={
+                'name': entry.get_uploader.username,
+                'uri': request.urlgen(
+                    'mediagoblin.user_pages.user_home',
+                    qualified=True, user=entry.get_uploader.username)},
+            updated=item.get('added'),
+            links=[{
+                'href': entry.url_for_self(
+                    request.urlgen,
+                    qualified=True),
+                'rel': 'alternate',
+                'type': 'text/html'}])
+
+    return feed.get_response()
+
 
 @require_active_login
 def processing_panel(request):