Create activity model and add activity creation
authorJessica Tallon <jessica@megworld.co.uk>
Fri, 22 Aug 2014 17:53:29 +0000 (18:53 +0100)
committerJessica Tallon <jessica@megworld.co.uk>
Fri, 22 Aug 2014 22:18:01 +0000 (23:18 +0100)
This creates the Activity and Genrator models from the Activity
Streams spec and. I then created a migration which retro-actively
create activities for media uploaded and comments created. Through
out the code I've added so automatically activties are created when
a user peforms an action (uploading media, commenting, etc.).

mediagoblin/db/migrations.py
mediagoblin/db/models.py
mediagoblin/federation/routing.py
mediagoblin/federation/views.py
mediagoblin/submit/lib.py
mediagoblin/templates/mediagoblin/federation/activity.html [new file with mode: 0644]
mediagoblin/user_pages/views.py

index 04588ad1462ebd136a6b73e332b81fa7d42124b9..72f853697f13762493cfae86c72e10bcbd56b944 100644 (file)
@@ -579,6 +579,29 @@ PRIVILEGE_FOUNDATIONS_v0 = [{'privilege_name':u'admin'},
                             {'privilege_name':u'active'}]
 
 
+class Activity_R0(declarative_base()):
+    __tablename__ = "core__activities"
+    id = Column(Integer, primary_key=True)
+    actor = Column(Integer, ForeignKey(User.id), nullable=False)
+    published = Column(DateTime, nullable=False, default=datetime.datetime.now)
+    updated = Column(DateTime, nullable=False, default=datetime.datetime.now)
+    verb = Column(Unicode, nullable=False)
+    content = Column(Unicode, nullable=False)
+    title = Column(Unicode, nullable=True)
+    target = Column(Integer, ForeignKey(User.id), nullable=True)
+    object_comment = Column(Integer, ForeignKey(MediaComment.id), nullable=True)
+    object_collection = Column(Integer, ForeignKey(Collection.id), nullable=True)
+    object_media = Column(Integer, ForeignKey(MediaEntry.id), nullable=True)
+    object_user = Column(Integer, ForeignKey(User.id), nullable=True)
+
+class Generator(declarative_base()):
+    __tablename__ = "core__generators"
+    id = Column(Integer, primary_key=True)
+    name = Column(Unicode, nullable=False)
+    published = Column(DateTime, nullable=False, default=datetime.datetime.now)
+    updated = Column(DateTime, nullable=False, default=datetime.datetime.now)
+    object_type = Column(Unicode, nullable=False)
+
 # vR1 stands for "version Rename 1".  This only exists because we need
 # to deal with dropping some booleans and it's otherwise impossible
 # with sqlite.
@@ -890,3 +913,38 @@ def revert_username_index(db):
             db.rollback()
 
     db.commit()
+
+@RegisterMigration(24, MIGRATIONS)
+def create_activity_table(db):
+    """ This will create the activity table """
+    Activity_R0.__table__.create(db.bind)
+    Generator_R0.__table__.create(db.bind)
+    db.commit()
+    
+    # Create the GNU MediaGoblin generator
+    gmg_generator = Generator(name="GNU MediaGoblin", object_type="service")
+    gmg_generator.save()
+    
+    # Now we want to retroactively add what activities we can
+    # first we'll add activities when people uploaded media.
+    for media in MediaEntry.query.all():
+        activity = Activity_R0(
+            verb="create",
+            actor=media.uploader,
+            published=media.created,
+            object_media=media.id,
+        )
+        activity.generate_content()
+        activity.save()
+    
+    # Now we want to add all the comments people made
+    for comment in MediaComment.query.all():
+        activity = Activity_R0(
+            verb="comment",
+            actor=comment.author,
+            published=comment.created,
+        )
+        activity.generate_content()
+        activity.save()
+    
+    db.commit()
index b910e522658dcf8b0c621176cc9cf68425fe9264..89dc2de71a71930b3358a4e0b75b3a8f003a9fdd 100644 (file)
@@ -37,6 +37,7 @@ from mediagoblin.db.base import Base, DictReadAttrProxy
 from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, \
         MediaCommentMixin, CollectionMixin, CollectionItemMixin
 from mediagoblin.tools.files import delete_media_files
+from mediagoblin.tools.translate import pass_to_ugettext as _
 from mediagoblin.tools.common import import_component
 
 # It's actually kind of annoying how sqlalchemy-migrate does this, if
@@ -79,6 +80,8 @@ class User(Base, UserMixin):
     ## TODO
     # plugin data would be in a separate model
 
+    objectType = "person"
+
     def __repr__(self):
         return '<{0} #{1} {2} {3} "{4}">'.format(
                 self.__class__.__name__,
@@ -143,7 +146,7 @@ class User(Base, UserMixin):
             "id": "acct:{0}@{1}".format(self.username, request.host),
             "preferredUsername": self.username,
             "displayName": "{0}@{1}".format(self.username, request.host),
-            "objectType": "person",
+            "objectType": self.objectType,
             "pump_io": {
                 "shared": False,
                 "followed": False,
@@ -651,13 +654,15 @@ class MediaComment(Base, MediaCommentMixin):
                                                    lazy="dynamic",
                                                    cascade="all, delete-orphan"))
 
+    objectType = "comment"
+    
     def serialize(self, request):
         """ Unserialize to python dictionary for API """
         media = MediaEntry.query.filter_by(id=self.media_entry).first()
         author = self.get_author
         context = {
             "id": self.id,
-            "objectType": "comment",
+            "objectType": self.objectType,
             "content": self.content,
             "inReplyTo": media.serialize(request, show_comments=False),
             "author": author.serialize(request)
@@ -1054,13 +1059,196 @@ class PrivilegeUserAssociation(Base):
         ForeignKey(Privilege.id),
         primary_key=True)
 
+class Generator(Base):
+    """
+    This holds the information about the software used to create
+    objects for the pump.io APIs.
+    """
+    __tablename__ = "core__generators"
+    
+    id = Column(Integer, primary_key=True)
+    name = Column(Unicode, nullable=False)
+    published = Column(DateTime, nullable=False, default=datetime.datetime.now)
+    updated = Column(DateTime, nullable=False, default=datetime.datetime.now)
+    object_type = Column(Unicode, nullable=False)
+    
+    def serialize(self, request):
+        return {
+            "id": self.id,
+            "displayName": self.name,
+            "published": self.published.isoformat(),
+            "updated": self.updated.isoformat(),
+            "objectType": self.object_type,
+        }
+    
+    def unserialize(self, data):
+        if "displayName" in data:
+            self.name = data["displayName"]
+        
+    
+
+class Activity(Base):
+    """
+    This holds all the metadata about an activity such as uploading an image,
+    posting a comment, etc. 
+    """
+    __tablename__ = "core__activities"
+    
+    id = Column(Integer, primary_key=True)
+    actor = Column(Integer, ForeignKey(User.id), nullable=False)
+    published = Column(DateTime, nullable=False, default=datetime.datetime.now)
+    updated = Column(DateTime, nullable=False, default=datetime.datetime.now)
+    verb = Column(Unicode, nullable=False)
+    content = Column(Unicode, nullable=False)
+    title = Column(Unicode, nullable=True)
+    target = Column(Integer, ForeignKey(User.id), nullable=True)
+    generator = Column(Integer, ForeignKey(Generator.id), nullable=True)
+    
+    
+    # Links to other models (only one of these should have a value).
+    object_comment = Column(Integer, ForeignKey(MediaComment.id), nullable=True)
+    object_collection = Column(Integer, ForeignKey(Collection.id), nullable=True)
+    object_media = Column(Integer, ForeignKey(MediaEntry.id), nullable=True)
+    object_user = Column(Integer, ForeignKey(User.id), nullable=True)
+    
+    VALID_VERBS = ["add", "author", "create", "delete", "dislike", "favorite", 
+                   "follow", "like", "post", "share", "unfavorite", "unfollow",
+                   "unlike", "unshare", "update", "tag"]
+    
+    @property
+    def object(self):
+        """ This represents the object that is given to the activity """
+        # Do we have a cached version
+        if getattr(self, "_cached_object", None) is not None:
+            return self._cached_object
+        
+        if self.object_comment is not None:
+            obj = MediaComment.query.filter_by(id=self.object_comment).first()
+        elif self.object_collection is not None:
+            obj = Collection.query.filter_by(id=self.object_collection).first()
+        elif self.object_media is not None:
+            obj = MediaEntry.query.filter_by(id=self.object_media).first()
+        elif self.object_user is not None:
+            obj = User.query.filter_by(id=self.object_user).first()
+        else:
+            # Shouldn't happen but incase it does
+            return None
+        
+        self._cached_object = obj
+        return obj
+    
+    def url(self, request):
+        actor = User.query.filter_by(id=self.actor).first() 
+        return request.urlgen(
+            "mediagoblin.federation.activity_view",
+            username=actor.username,
+            id=self.id,
+            qualified=True
+        )
+    
+    def generate_content(self):
+        """
+        Produces a HTML content for object
+        TODO: Can this be moved to a mixin?
+        """
+        verb_to_content = {
+            "add": _("{username} added {object} to {destination}"),
+            "author": _("{username} authored {object}"),
+            "create": _("{username} created {object}"),
+            "delete": _("{username} deleted {object}"),
+            "dislike": _("{username} disliked {object}"),
+            "favorite": _("{username} favorited {object}"),
+            "follow": _("{username} followed {object}"),
+            "like": _("{username} liked {object}"),
+            "post": _("{username} posted {object}"),
+            "share": _("{username} shared {object}"),
+            "unfavorite": _("{username} unfavorited {object}"),
+            "unfollow": _("{username} stopped following {object}"),
+            "unlike": _("{username} unliked {object}"),
+            "unshare": _("{username} unshared {object}"),
+            "update": _("{username} updated {object}"),
+            "tag": _("{username} tagged {object}"), 
+        }
+        
+        actor = User.query.filter_by(id=self.actor).first()
+        
+        if self.verb == "add" and self.object.objectType == "collection":
+            media = MediaEntry.query.filter_by(id=self.object.media_entry)
+            content = verb_to_content[self.verb]
+            self.content = content.format(
+                username=actor.username,
+                object=media.objectType,
+                destination=self.object.objectType,
+            )
+        elif self.verb in verb_to_content:
+            content = verb_to_content[self.verb]
+            self.content = content.format(
+                username=actor.username,
+                object=self.object.objectType
+            )
+        else:
+            return
+        
+        return self.content
+    
+    def serialize(self, request):
+        # Lookup models
+        actor = User.query.filter_by(id=self.actor).first()
+        generator = Generator.query.filter_by(id=self.generator).first()
+        
+        obj = {
+            "id": self.id,
+            "actor": actor.serialize(request),
+            "verb": self.verb,
+            "published": self.published.isoformat(),
+            "updated": self.updated.isoformat(),
+            "content": self.content,
+            "url": self.url(request),
+            "object": self.object.serialize(request)
+        }
+        
+        if self.generator:
+            obj["generator"] = generator.seralize(request)
+        
+        if self.title:
+            obj["title"] = self.title
+        
+        if self.target:
+            target = User.query.filter_by(id=self.target).first()
+            obj["target"] = target.seralize(request)
+        
+        return obj
+    
+    def unseralize(self, data):
+        """
+        Takes data given and set it on this activity.
+        
+        Several pieces of data are not written on because of security
+        reasons. For example changing the author or id of an activity.
+        """
+        if "verb" in data:
+            self.verb = data["verb"]
+        
+        if "title" in data:
+            self.title = data["title"]
+        
+        if "content" in data:
+            self.content = data["content"]
+    
+    def save(self, *args, **kwargs):
+        self.updated = datetime.datetime.now()
+        if self.content is None:
+            self.generate_content()
+        super(Activity, self).save(*args, **kwargs)    
+
 MODELS = [
     User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem,
     MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData,
     Notification, CommentNotification, ProcessingNotification, Client,
     CommentSubscription, ReportBase, CommentReport, MediaReport, UserBan,
        Privilege, PrivilegeUserAssociation,
-    RequestToken, AccessToken, NonceTimestamp]
+    RequestToken, AccessToken, NonceTimestamp,
+    Activity, Generator]
 
 """
  Foundations are the default rows that are created immediately after the tables
index c1c5a2644057838c7ff459cb576fa162da1f49f5..0b0fbaf17a5702dcdc7f6457ed6d68c29f2f8b39 100644 (file)
@@ -77,3 +77,9 @@ add_route(
     "/api/whoami",
     "mediagoblin.federation.views:whoami"
 )
+
+add_route(
+    "mediagoblin.federation.activity_view",
+    "/<string:username>/activity/<string:id>",
+    "mediagoblin.federation.views:activity_view"
+)
\ No newline at end of file
index 3d6953a7c3642f124c2cd0c4013923b40e6a5a5b..7d02d02ec800d322026d3724ef1895876e051ef7 100644 (file)
@@ -20,10 +20,11 @@ import mimetypes
 
 from werkzeug.datastructures import FileStorage
 
-from mediagoblin.decorators import oauth_required
+from mediagoblin.decorators import oauth_required, require_active_login
 from mediagoblin.federation.decorators import user_has_privilege
-from mediagoblin.db.models import User, MediaEntry, MediaComment
-from mediagoblin.tools.response import redirect, json_response, json_error
+from mediagoblin.db.models import User, MediaEntry, MediaComment, Activity
+from mediagoblin.tools.response import redirect, json_response, json_error, \
+                                       render_404, render_to_response
 from mediagoblin.meddleware.csrf import csrf_exempt
 from mediagoblin.submit.lib import new_upload_entry, api_upload_request, \
                                     api_add_to_feed
@@ -340,21 +341,8 @@ def feed_endpoint(request):
         "items": [],
     }
 
-
-    # Look up all the media to put in the feed (this will be changed
-    # when we get real feeds/inboxes/outboxes/activites)
-    for media in MediaEntry.query.all():
-        item = {
-            "verb": "post",
-            "object": media.serialize(request),
-            "actor": media.get_uploader.serialize(request),
-            "content": "{0} posted a picture".format(request.user.username),
-            "id": media.id,
-        }
-        item["updated"] = item["object"]["updated"]
-        item["published"] = item["object"]["published"]
-        item["url"] = item["object"]["url"]
-        feed["items"].append(item)
+    for activity in Activity.query.filter_by(actor=request.user.id):
+        feed["items"].append(activity.serialize(request))
     feed["totalItems"] = len(feed["items"])
 
     return json_response(feed)
@@ -467,3 +455,31 @@ def whoami(request):
     )
 
     return redirect(request, location=profile)
+
+@require_active_login
+def activity_view(request):
+    """ /<username>/activity/<id> - Display activity
+    
+    This should display a HTML presentation of the activity
+    this is NOT an API endpoint.
+    """
+    # Get the user object.
+    username = request.matchdict["username"]
+    user = User.query.filter_by(username=username).first()
+    
+    activity_id = request.matchdict["id"]
+    
+    if request.user is None:
+        return render_404(request)
+    
+    activity = Activity.query.filter_by(id=activity_id).first()
+    if activity is None:
+        return render_404(request)
+    
+    return render_to_response(
+        request,
+        "mediagoblin/federation/activity.html",
+        {"activity": activity}
+    )
+    
+    
index aaa90ea05d1c50d88954b7b65d231a666aded5ba..af25bfb7a8c743cb7389f701812e2e9a63dc8cee 100644 (file)
@@ -24,6 +24,7 @@ from werkzeug.datastructures import FileStorage
 from mediagoblin import mg_globals
 from mediagoblin.tools.response import json_response
 from mediagoblin.tools.text import convert_to_tag_list_of_dicts
+from mediagoblin.tools.federation import create_activity
 from mediagoblin.db.models import MediaEntry, ProcessingMetaData
 from mediagoblin.processing import mark_entry_failed
 from mediagoblin.processing.task import ProcessMedia
@@ -199,6 +200,9 @@ def submit_media(mg_app, user, submitted_file, filename,
     run_process_media(entry, feed_url)
 
     add_comment_subscription(user, entry)
+    
+    # Create activity
+    create_activity("post", entry)
 
     return entry
 
@@ -289,4 +293,8 @@ def api_add_to_feed(request, entry):
 
     run_process_media(entry, feed_url)
     add_comment_subscription(request.user, entry)
+    
+    # Create activity
+    create_activity("post", entry)
+
     return json_response(entry.serialize(request))
diff --git a/mediagoblin/templates/mediagoblin/federation/activity.html b/mediagoblin/templates/mediagoblin/federation/activity.html
new file mode 100644 (file)
index 0000000..f380fd5
--- /dev/null
@@ -0,0 +1,42 @@
+{#
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2014 MediaGoblin contributors.  See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# 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/>.
+#}
+{%- extends "mediagoblin/base.html" %}
+
+{% block mediagoblin_head %}
+       {% template_hook("media_head") %}
+{% endblock mediagoblin_head %}
+
+{% block mediagoblin_content %}
+<div class="media_pane eleven columns">
+  <h2 class="media_title">
+    {% if activity.title %}{{ activity.title }}{% endif %}
+  </h2>
+  {% autoescape False %}
+    <p> {{ activity.content }} </p>
+  {% endautoescape %}
+  
+  <div class="media_sidebar">
+    {% block mediagoblin_after_added_sidebar %}
+      <a href="{{ activity.url(request) }}"
+         class="button_action"
+         id="button_reportmedia">
+            View {{ activity.object.objectType }}
+      </a>
+    {% endblock %}
+  </div>
+{% endblock %}
\ No newline at end of file
index 78751a28f85ef0d2ccc794618c2ba0b71b7033c0..8203cfa7d5c791bf6182699eddc0c838947535c4 100644 (file)
@@ -26,6 +26,7 @@ from mediagoblin.tools.response import render_to_response, render_404, \
 from mediagoblin.tools.text import cleaned_markdown_conversion
 from mediagoblin.tools.translate import pass_to_ugettext as _
 from mediagoblin.tools.pagination import Pagination
+from mediagoblin.tools.federation import create_activity
 from mediagoblin.user_pages import forms as user_forms
 from mediagoblin.user_pages.lib import (send_comment_email,
        add_media_to_collection, build_report_object)
@@ -199,7 +200,7 @@ def media_post_comment(request, media):
             _('Your comment has been posted!'))
 
         trigger_notification(comment, media, request)
-
+        create_activity("post", comment)
         add_comment_subscription(request.user, media)
 
     return redirect_obj(request, media)
@@ -261,6 +262,7 @@ def media_collect(request, media):
         collection.creator = request.user.id
         collection.generate_slug()
         collection.save()
+        create_activity("create", collection)
 
     # Otherwise, use the collection selected from the drop-down
     else:
@@ -287,7 +289,7 @@ def media_collect(request, media):
                              % (media.title, collection.title))
     else: # Add item to collection
         add_media_to_collection(collection, media, form.note.data)
-
+        create_activity("add", media)
         messages.add_message(request, messages.SUCCESS,
                              _('"%s" added to collection "%s"')
                              % (media.title, collection.title))