From b949201152b2ff3a5b072107ae903ddac309a530 Mon Sep 17 00:00:00 2001 From: Jessica Tallon Date: Fri, 22 Aug 2014 18:53:29 +0100 Subject: [PATCH] Create activity model and add activity creation 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 | 58 ++++++ mediagoblin/db/models.py | 194 +++++++++++++++++- mediagoblin/federation/routing.py | 6 + mediagoblin/federation/views.py | 52 +++-- mediagoblin/submit/lib.py | 8 + .../mediagoblin/federation/activity.html | 42 ++++ mediagoblin/user_pages/views.py | 6 +- 7 files changed, 343 insertions(+), 23 deletions(-) create mode 100644 mediagoblin/templates/mediagoblin/federation/activity.html diff --git a/mediagoblin/db/migrations.py b/mediagoblin/db/migrations.py index 04588ad1..72f85369 100644 --- a/mediagoblin/db/migrations.py +++ b/mediagoblin/db/migrations.py @@ -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() diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index b910e522..89dc2de7 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -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 diff --git a/mediagoblin/federation/routing.py b/mediagoblin/federation/routing.py index c1c5a264..0b0fbaf1 100644 --- a/mediagoblin/federation/routing.py +++ b/mediagoblin/federation/routing.py @@ -77,3 +77,9 @@ add_route( "/api/whoami", "mediagoblin.federation.views:whoami" ) + +add_route( + "mediagoblin.federation.activity_view", + "//activity/", + "mediagoblin.federation.views:activity_view" +) \ No newline at end of file diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index 3d6953a7..7d02d02e 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -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): + """ //activity/ - 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} + ) + + diff --git a/mediagoblin/submit/lib.py b/mediagoblin/submit/lib.py index aaa90ea0..af25bfb7 100644 --- a/mediagoblin/submit/lib.py +++ b/mediagoblin/submit/lib.py @@ -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 index 00000000..f380fd5f --- /dev/null +++ b/mediagoblin/templates/mediagoblin/federation/activity.html @@ -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 . +#} +{%- extends "mediagoblin/base.html" %} + +{% block mediagoblin_head %} + {% template_hook("media_head") %} +{% endblock mediagoblin_head %} + +{% block mediagoblin_content %} +
+

+ {% if activity.title %}{{ activity.title }}{% endif %} +

+ {% autoescape False %} +

{{ activity.content }}

+ {% endautoescape %} + +
+ {% block mediagoblin_after_added_sidebar %} + + View {{ activity.object.objectType }} + + {% endblock %} +
+{% endblock %} \ No newline at end of file diff --git a/mediagoblin/user_pages/views.py b/mediagoblin/user_pages/views.py index 78751a28..8203cfa7 100644 --- a/mediagoblin/user_pages/views.py +++ b/mediagoblin/user_pages/views.py @@ -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)) -- 2.25.1