From b949201152b2ff3a5b072107ae903ddac309a530 Mon Sep 17 00:00:00 2001 From: Jessica Tallon Date: Fri, 22 Aug 2014 18:53:29 +0100 Subject: [PATCH 1/9] 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)) From 1c15126819ed42b6572de8023962d7149fae6f03 Mon Sep 17 00:00:00 2001 From: Jessica Tallon Date: Tue, 26 Aug 2014 08:53:28 +0100 Subject: [PATCH 2/9] Add better support for targets on Activities This adds betters upport for targets in the content generation and on the model itself. Adding getters for properties which would otherwise require looking up e.g. get_author. --- mediagoblin/db/models.py | 125 +++++++++++++++++++++----------- mediagoblin/user_pages/views.py | 2 +- 2 files changed, 82 insertions(+), 45 deletions(-) diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index 89dc2de7..aadd3fea 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -610,6 +610,8 @@ class MediaTag(Base): creator=Tag.find_or_new ) + objectType = "tag" + def __init__(self, name=None, slug=None): Base.__init__(self) if name is not None: @@ -724,6 +726,8 @@ class Collection(Base, CollectionMixin): UniqueConstraint('creator', 'slug'), {}) + objectType = "collection" + def get_collection_items(self, ascending=False): #TODO, is this still needed with self.collection_items being available? order_col = CollectionItem.position @@ -1099,7 +1103,7 @@ class Activity(Base): 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) + content = Column(Unicode, nullable=True) title = Column(Unicode, nullable=True) target = Column(Integer, ForeignKey(User.id), nullable=True) generator = Column(Integer, ForeignKey(Generator.id), nullable=True) @@ -1111,12 +1115,20 @@ class Activity(Base): object_media = Column(Integer, ForeignKey(MediaEntry.id), nullable=True) object_user = Column(Integer, ForeignKey(User.id), nullable=True) + # The target could also be several things + target_comment = Column(Integer, ForeignKey(MediaComment.id), nullable=True) + target_collection = Column(Integer, ForeignKey(Collection.id), nullable=True) + target_media = Column(Integer, ForeignKey(MediaEntry.id), nullable=True) + target_user = Column(Integer, ForeignKey(User.id), nullable=True) + + get_actor = relationship(User, foreign_keys="Activity.actor") + get_generator = relationship(Generator) + VALID_VERBS = ["add", "author", "create", "delete", "dislike", "favorite", "follow", "like", "post", "share", "unfavorite", "unfollow", "unlike", "unshare", "update", "tag"] - @property - def object(self): + def get_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: @@ -1131,12 +1143,33 @@ class Activity(Base): 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 + # Shouldn't happen but in case it does return None self._cached_object = obj return obj + def get_target(self): + """ This represents the target given on the activity (if any) """ + if getattr(self, "_cached_target", None) is not None: + return self._cached_target + + if self.target_comment is not None: + target = MediaComment.query.filter_by(id=self.target_comment).first() + elif self.target_collection is not None: + target = Collection.query.filter_by(id=self.target_collection).first() + elif self.target_media is not None: + target = MediaEntry.query.filter_by(id=self.target_media).first() + elif self.target_user is not None: + target = User.query.filter_by(id=self.target_user).first() + else: + # Shouldn't happen but in case it does + return None + + self._cached_target = target + return self._cached_target + + def url(self, request): actor = User.query.filter_by(id=self.actor).first() return request.urlgen( @@ -1151,60 +1184,66 @@ class Activity(Base): Produces a HTML content for object TODO: Can this be moved to a mixin? """ + # some of these have simple and targetted. If self.target it set + # it will pick the targetted. If they DON'T have a targetted version + # the information in targetted won't be added to the content. 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}"), + "add": { + "simple" : _("{username} added {object}"), + "targetted": _("{username} added {object} to {target}"), + }, + "author": {"simple": _("{username} authored {object}")}, + "create": {"simple": _("{username} created {object}")}, + "delete": {"simple": _("{username} deleted {object}")}, + "dislike": {"simple": _("{username} disliked {object}")}, + "favorite": {"simple": _("{username} favorited {object}")}, + "follow": {"simple": _("{username} followed {object}")}, + "like": {"simple": _("{username} liked {object}")}, + "post": { + "simple": _("{username} posted {object}"), + "targetted": _("{username} posted {object} to {targetted}"), + }, + "share": {"simple": _("{username} shared {object}")}, + "unfavorite": {"simple": _("{username} unfavorited {object}")}, + "unfollow": {"simple": _("{username} stopped following {object}")}, + "unlike": {"simple": _("{username} unliked {object}")}, + "unshare": {"simple": _("{username} unshared {object}")}, + "update": {"simple": _("{username} updated {object}")}, + "tag": {"simple": _("{username} tagged {object}")}, } + + obj = self.get_object() + target = self.get_target() + actor = self.get_actor + content = verb_to_content.get(self.verb, None) - actor = User.query.filter_by(id=self.actor).first() + if content is None or obj is None: + return - 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( + if target is None or "targetted" not in content: + self.content = content["simple"].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 + object=obj.objectType ) else: - return + self.content = content["targetted"].format( + username=actor.username, + object=obj.objectType, + target=target.objectType, + ) 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), + "actor": self.get_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) + "object": self.get_object().serialize(request) } if self.generator: @@ -1213,8 +1252,8 @@ class Activity(Base): if self.title: obj["title"] = self.title - if self.target: - target = User.query.filter_by(id=self.target).first() + target = self.get_target() + if target is not None: obj["target"] = target.seralize(request) return obj @@ -1237,8 +1276,6 @@ class Activity(Base): 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 = [ diff --git a/mediagoblin/user_pages/views.py b/mediagoblin/user_pages/views.py index 8203cfa7..cd0e843d 100644 --- a/mediagoblin/user_pages/views.py +++ b/mediagoblin/user_pages/views.py @@ -289,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) + create_activity("add", media, target=collection) messages.add_message(request, messages.SUCCESS, _('"%s" added to collection "%s"') % (media.title, collection.title)) From ce46470c02371ff92db2c0412af97dfef33e58ee Mon Sep 17 00:00:00 2001 From: Jessica Tallon Date: Wed, 27 Aug 2014 14:34:07 +0100 Subject: [PATCH 3/9] Add ActivityIntermediator table and refactor some of Activity model - This has introduced a intermediatory table between object/target and the activity. This allows for multiple activities to be associated with one object/target. - This moves some of the methods off Activity model into a mixin which didn't need to interact with database things. - This also cleaned up the migrations as well as adding retroactive creation of activities for collection creation. --- mediagoblin/db/migrations.py | 145 +++++++++++---- mediagoblin/db/mixin.py | 109 +++++++++++- mediagoblin/db/models.py | 334 ++++++++++++++++------------------- 3 files changed, 366 insertions(+), 222 deletions(-) diff --git a/mediagoblin/db/migrations.py b/mediagoblin/db/migrations.py index 72f85369..f467253f 100644 --- a/mediagoblin/db/migrations.py +++ b/mediagoblin/db/migrations.py @@ -29,7 +29,7 @@ from mediagoblin.db.extratypes import JSONEncoded, MutationDict from mediagoblin.db.migration_tools import ( RegisterMigration, inspect_table, replace_table_hack) from mediagoblin.db.models import (MediaEntry, Collection, MediaComment, User, - Privilege) + Privilege, Generator) from mediagoblin.db.extratypes import JSONEncoded, MutationDict @@ -578,30 +578,6 @@ PRIVILEGE_FOUNDATIONS_v0 = [{'privilege_name':u'admin'}, {'privilege_name':u'commenter'}, {'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. @@ -914,17 +890,91 @@ def revert_username_index(db): db.commit() +class Generator_R0(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) + +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) + generator = Column(Integer, ForeignKey(Generator.id), nullable=True) + +class ActivityIntermediator_R0(declarative_base()): + __tablename__ = "core__acitivity_intermediators" + id = Column(Integer, primary_key=True) + type = Column(Integer, nullable=False) + @RegisterMigration(24, MIGRATIONS) -def create_activity_table(db): - """ This will create the activity table """ +def activity_migration(db): + """ + Creates everything to create activities in GMG + - Adds Activity, ActivityIntermediator and Generator table + - Creates GMG service generator for activities produced by the server + - Adds the activity_as_object and activity_as_target to objects/targets + - Retroactively adds activities for what we can acurately work out + """ + # Set constants we'll use later + FOREIGN_KEY = "core__acitivity_intermediators.id" + + + # Create the new tables. Activity_R0.__table__.create(db.bind) Generator_R0.__table__.create(db.bind) + ActivityIntermediator_R0.__table__.create(db.bind) db.commit() - - # Create the GNU MediaGoblin generator - gmg_generator = Generator(name="GNU MediaGoblin", object_type="service") - gmg_generator.save() - + + + # Initiate the tables we want to use later + metadata = MetaData(bind=db.bind) + user_table = inspect_table(metadata, "core__users") + generator_table = inspect_table(metadata, "core__generators") + collection_table = inspect_table(metadata, "core__collections") + media_entry_table = inspect_table(metadata, "core__media_entries") + media_comments_table = inspect_table(metadata, "core__media_comments") + + + # Create the foundations for Generator + db.execute(generator_table.insert().values( + name="GNU Mediagoblin", + object_type="service" + )) + db.commit() + + + # Now we want to modify the tables which MAY have an activity at some point + as_object = Column("activity_as_object", Integer, ForeignKey(FOREIGN_KEY)) + as_object.create(media_entry_table) + as_target = Column("activity_as_target", Integer, ForeignKey(FOREIGN_KEY)) + as_target.create(media_entry_table) + + as_object = Column("activity_as_object", Integer, ForeignKey(FOREIGN_KEY)) + as_object.create(user_table) + as_target = Column("activity_as_target", Integer, ForeignKey(FOREIGN_KEY)) + as_target.create(user_table) + + as_object = Column("activity_as_object", Integer, ForeignKey(FOREIGN_KEY)) + as_object.create(media_comments_table) + as_target = Column("activity_as_target", Integer, ForeignKey(FOREIGN_KEY)) + as_target.create(media_comments_table) + + as_object = Column("activity_as_object", Integer, ForeignKey(FOREIGN_KEY)) + as_object.create(collection_table) + as_target = Column("activity_as_target", Integer, ForeignKey(FOREIGN_KEY)) + as_target.create(collection_table) + db.commit() + + # 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(): @@ -932,19 +982,40 @@ def create_activity_table(db): verb="create", actor=media.uploader, published=media.created, - object_media=media.id, + updated=media.created, + generator=gmg_generator.id ) activity.generate_content() - activity.save() - + activity.save(set_updated=False) + activity.set_object(media) + media.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, + updated=comment.created, + generator=gmg_generator.id ) activity.generate_content() - activity.save() - + activity.save(set_updated=False) + activity.set_object(comment) + comment.save() + + # Create 'create' activities for all collections + for collection in Collection.query.all(): + activity = Activity_R0( + verb="create", + actor=collection.creator, + published=collection.created, + updated=collection.created, + generator=gmg_generator.id + ) + activity.generate_content() + activity.save(set_updated=False) + activity.set_object(collection) + collection.save() + db.commit() diff --git a/mediagoblin/db/mixin.py b/mediagoblin/db/mixin.py index 1f2e7ec3..bc3a3bd2 100644 --- a/mediagoblin/db/mixin.py +++ b/mediagoblin/db/mixin.py @@ -39,6 +39,7 @@ from mediagoblin.tools import common, licenses from mediagoblin.tools.pluginapi import hook_handle from mediagoblin.tools.text import cleaned_markdown_conversion from mediagoblin.tools.url import slugify +from mediagoblin.tools.translate import pass_to_ugettext as _ class UserMixin(object): @@ -208,7 +209,7 @@ class MediaEntryMixin(GenerateSlugMixin): will return self.thumb_url if original url doesn't exist""" if u"original" not in self.media_files: return self.thumb_url - + return mg_globals.app.public_store.file_url( self.media_files[u"original"] ) @@ -363,3 +364,109 @@ class CollectionItemMixin(object): Run through Markdown and the HTML cleaner. """ return cleaned_markdown_conversion(self.note) + +class ActivityMixin(object): + + VALID_VERBS = ["add", "author", "create", "delete", "dislike", "favorite", + "follow", "like", "post", "share", "unfavorite", "unfollow", + "unlike", "unshare", "update", "tag"] + + def get_url(self, request): + return request.urlgen( + "mediagoblin.federation.activity_view", + username=self.get_actor.username, + id=self.id, + qualified=True + ) + + def generate_content(self): + """ Produces a HTML content for object """ + # some of these have simple and targetted. If self.target it set + # it will pick the targetted. If they DON'T have a targetted version + # the information in targetted won't be added to the content. + verb_to_content = { + "add": { + "simple" : _("{username} added {object}"), + "targetted": _("{username} added {object} to {target}"), + }, + "author": {"simple": _("{username} authored {object}")}, + "create": {"simple": _("{username} created {object}")}, + "delete": {"simple": _("{username} deleted {object}")}, + "dislike": {"simple": _("{username} disliked {object}")}, + "favorite": {"simple": _("{username} favorited {object}")}, + "follow": {"simple": _("{username} followed {object}")}, + "like": {"simple": _("{username} liked {object}")}, + "post": { + "simple": _("{username} posted {object}"), + "targetted": _("{username} posted {object} to {targetted}"), + }, + "share": {"simple": _("{username} shared {object}")}, + "unfavorite": {"simple": _("{username} unfavorited {object}")}, + "unfollow": {"simple": _("{username} stopped following {object}")}, + "unlike": {"simple": _("{username} unliked {object}")}, + "unshare": {"simple": _("{username} unshared {object}")}, + "update": {"simple": _("{username} updated {object}")}, + "tag": {"simple": _("{username} tagged {object}")}, + } + + obj = self.get_object() + target = self.get_target() + actor = self.get_actor + content = verb_to_content.get(self.verb, None) + + if content is None or obj is None: + return + + if target is None or "targetted" not in content: + self.content = content["simple"].format( + username=actor.username, + object=obj.objectType + ) + else: + self.content = content["targetted"].format( + username=actor.username, + object=obj.objectType, + target=target.objectType, + ) + + return self.content + + def serialize(self, request): + obj = { + "id": self.id, + "actor": self.get_actor.serialize(request), + "verb": self.verb, + "published": self.published.isoformat(), + "updated": self.updated.isoformat(), + "content": self.content, + "url": self.get_url(request), + "object": self.get_object().serialize(request) + } + + if self.generator: + obj["generator"] = self.get_generator.seralize(request) + + if self.title: + obj["title"] = self.title + + target = self.get_target() + if target is not None: + 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"] diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index aadd3fea..6004e97d 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -35,9 +35,9 @@ from mediagoblin.db.extratypes import (PathTupleWithSlashes, JSONEncoded, MutationDict) from mediagoblin.db.base import Base, DictReadAttrProxy from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, \ - MediaCommentMixin, CollectionMixin, CollectionItemMixin + MediaCommentMixin, CollectionMixin, CollectionItemMixin, \ + ActivityMixin 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 @@ -77,6 +77,11 @@ class User(Base, UserMixin): uploaded = Column(Integer, default=0) upload_limit = Column(Integer) + activity_as_object = Column(Integer, + ForeignKey("core__acitivity_intermediators.id")) + activity_as_target = Column(Integer, + ForeignKey("core__acitivity_intermediators.id")) + ## TODO # plugin data would be in a separate model @@ -313,6 +318,11 @@ class MediaEntry(Base, MediaEntryMixin): media_metadata = Column(MutationDict.as_mutable(JSONEncoded), default=MutationDict()) + activity_as_object = Column(Integer, + ForeignKey("core__acitivity_intermediators.id")) + activity_as_target = Column(Integer, + ForeignKey("core__acitivity_intermediators.id")) + ## TODO # fail_error @@ -656,8 +666,14 @@ class MediaComment(Base, MediaCommentMixin): lazy="dynamic", cascade="all, delete-orphan")) + + activity_as_object = Column(Integer, + ForeignKey("core__acitivity_intermediators.id")) + activity_as_target = Column(Integer, + ForeignKey("core__acitivity_intermediators.id")) + objectType = "comment" - + def serialize(self, request): """ Unserialize to python dictionary for API """ media = MediaEntry.query.filter_by(id=self.media_entry).first() @@ -722,6 +738,11 @@ class Collection(Base, CollectionMixin): backref=backref("collections", cascade="all, delete-orphan")) + activity_as_object = Column(Integer, + ForeignKey("core__acitivity_intermediators.id")) + activity_as_target = Column(Integer, + ForeignKey("core__acitivity_intermediators.id")) + __table_args__ = ( UniqueConstraint('creator', 'slug'), {}) @@ -1069,13 +1090,13 @@ class Generator(Base): 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) + published = Column(DateTime, default=datetime.datetime.now) + updated = Column(DateTime, default=datetime.datetime.now) object_type = Column(Unicode, nullable=False) - + def serialize(self, request): return { "id": self.id, @@ -1084,199 +1105,144 @@ class Generator(Base): "updated": self.updated.isoformat(), "objectType": self.object_type, } - + def unserialize(self, data): if "displayName" in data: self.name = data["displayName"] - - -class Activity(Base): + +class ActivityIntermediator(Base): + """ + This is used so that objects/targets can have a foreign key back to this + object and activities can a foreign key to this object. This objects to be + used multiple times for the activity object or target and also allows for + different types of objects to be used as an Activity. + """ + __tablename__ = "core__acitivity_intermediators" + + id = Column(Integer, primary_key=True) + type = Column(Integer, nullable=False) + + TYPES = { + 0: User, + 1: MediaEntry, + 2: MediaComment, + 3: Collection, + } + + def _find_model(self, obj): + """ Finds the model for a given object """ + for key, model in self.TYPES.items(): + if isinstance(obj, model): + return key, model + + return None, None + + def set_object(self, obj): + """ This sets itself as the object for an activity """ + key, model = self._find_model(obj) + if key is None: + raise ValueError("Invalid type of object given") + + # First set self as activity + obj.activity_as_object = self.id + self.type = key + + @property + def get_object(self): + """ Finds the object for an activity """ + if self.type is None: + return None + + model = self.TYPES[self.type] + return model.query.filter_by(activity_as_object=self.id).first() + + def set_target(self, obj): + """ This sets itself as the target for an activity """ + key, model = self._find_model(obj) + if key is None: + raise ValueError("Invalid type of object given") + + obj.activity_as_target = self.id + self.type = key + + @property + def get_target(self): + """ Gets the target for an activity """ + if self.type is None: + return None + + model = self.TYPES[self.type] + return model.query.filter_by(activity_as_target=self.id).first() + + def save(self, *args, **kwargs): + if self.type not in self.TYPES.keys(): + raise ValueError("Invalid type set") + Base.save(self, *args, **kwargs) + +class Activity(Base, ActivityMixin): """ This holds all the metadata about an activity such as uploading an image, - posting a comment, etc. + posting a comment, etc. """ __tablename__ = "core__activities" - + id = Column(Integer, primary_key=True) - actor = Column(Integer, ForeignKey(User.id), nullable=False) + actor = Column(Integer, + ForeignKey(User.id, use_alter=True, name="actor"), + 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=True) 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) - - # The target could also be several things - target_comment = Column(Integer, ForeignKey(MediaComment.id), nullable=True) - target_collection = Column(Integer, ForeignKey(Collection.id), nullable=True) - target_media = Column(Integer, ForeignKey(MediaEntry.id), nullable=True) - target_user = Column(Integer, ForeignKey(User.id), nullable=True) - - get_actor = relationship(User, foreign_keys="Activity.actor") + object = Column(Integer, + ForeignKey(ActivityIntermediator.id), nullable=False) + target = Column(Integer, + ForeignKey(ActivityIntermediator.id), nullable=True) + + get_actor = relationship(User, + foreign_keys="Activity.actor", post_update=True) get_generator = relationship(Generator) - - VALID_VERBS = ["add", "author", "create", "delete", "dislike", "favorite", - "follow", "like", "post", "share", "unfavorite", "unfollow", - "unlike", "unshare", "update", "tag"] - - def get_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 in case it does - return None - - self._cached_object = obj - return obj - - def get_target(self): - """ This represents the target given on the activity (if any) """ - if getattr(self, "_cached_target", None) is not None: - return self._cached_target - - if self.target_comment is not None: - target = MediaComment.query.filter_by(id=self.target_comment).first() - elif self.target_collection is not None: - target = Collection.query.filter_by(id=self.target_collection).first() - elif self.target_media is not None: - target = MediaEntry.query.filter_by(id=self.target_media).first() - elif self.target_user is not None: - target = User.query.filter_by(id=self.target_user).first() - else: - # Shouldn't happen but in case it does - return None - - self._cached_target = target - return self._cached_target - - - 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? - """ - # some of these have simple and targetted. If self.target it set - # it will pick the targetted. If they DON'T have a targetted version - # the information in targetted won't be added to the content. - verb_to_content = { - "add": { - "simple" : _("{username} added {object}"), - "targetted": _("{username} added {object} to {target}"), - }, - "author": {"simple": _("{username} authored {object}")}, - "create": {"simple": _("{username} created {object}")}, - "delete": {"simple": _("{username} deleted {object}")}, - "dislike": {"simple": _("{username} disliked {object}")}, - "favorite": {"simple": _("{username} favorited {object}")}, - "follow": {"simple": _("{username} followed {object}")}, - "like": {"simple": _("{username} liked {object}")}, - "post": { - "simple": _("{username} posted {object}"), - "targetted": _("{username} posted {object} to {targetted}"), - }, - "share": {"simple": _("{username} shared {object}")}, - "unfavorite": {"simple": _("{username} unfavorited {object}")}, - "unfollow": {"simple": _("{username} stopped following {object}")}, - "unlike": {"simple": _("{username} unliked {object}")}, - "unshare": {"simple": _("{username} unshared {object}")}, - "update": {"simple": _("{username} updated {object}")}, - "tag": {"simple": _("{username} tagged {object}")}, - } - - obj = self.get_object() - target = self.get_target() - actor = self.get_actor - content = verb_to_content.get(self.verb, None) - - if content is None or obj is None: + + def set_object(self, *args, **kwargs): + if self.object is None: + ai = ActivityIntermediator() + ai.set_object(*args, **kwargs) + ai.save() + self.object = ai.id return - - if target is None or "targetted" not in content: - self.content = content["simple"].format( - username=actor.username, - object=obj.objectType - ) - else: - self.content = content["targetted"].format( - username=actor.username, - object=obj.objectType, - target=target.objectType, - ) - - return self.content - - def serialize(self, request): - obj = { - "id": self.id, - "actor": self.get_actor.serialize(request), - "verb": self.verb, - "published": self.published.isoformat(), - "updated": self.updated.isoformat(), - "content": self.content, - "url": self.url(request), - "object": self.get_object().serialize(request) - } - - if self.generator: - obj["generator"] = generator.seralize(request) - - if self.title: - obj["title"] = self.title - - target = self.get_target() - if target is not None: - 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() - super(Activity, self).save(*args, **kwargs) + + self.object.set_object(*args, **kwargs) + self.object.save() + + @property + def get_object(self): + return self.object.get_object + + def set_target(self, *args, **kwargs): + if self.target is None: + ai = ActivityIntermediator() + ai.set_target(*args, **kwargs) + ai.save() + self.object = ai.id + return + + self.target.set_object(*args, **kwargs) + self.targt.save() + + @property + def get_target(self): + if self.target is None: + return None + + return self.target.get_target + + def save(self, set_updated=True, *args, **kwargs): + if set_updated: + self.updated = datetime.datetime.now() + super(Activity, self).save(*args, **kwargs) MODELS = [ User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem, @@ -1285,7 +1251,7 @@ MODELS = [ CommentSubscription, ReportBase, CommentReport, MediaReport, UserBan, Privilege, PrivilegeUserAssociation, RequestToken, AccessToken, NonceTimestamp, - Activity, Generator] + Activity, ActivityIntermediator, Generator] """ Foundations are the default rows that are created immediately after the tables From 23bf7f3b02b5b96fade3d8b1dac2ef43a3dc59b9 Mon Sep 17 00:00:00 2001 From: Jessica Tallon Date: Wed, 27 Aug 2014 15:32:59 +0100 Subject: [PATCH 4/9] Oops forgot to add medigoblin.tools.federation --- mediagoblin/tools/federation.py | 61 +++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 mediagoblin/tools/federation.py diff --git a/mediagoblin/tools/federation.py b/mediagoblin/tools/federation.py new file mode 100644 index 00000000..573b059a --- /dev/null +++ b/mediagoblin/tools/federation.py @@ -0,0 +1,61 @@ +# 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 . + +from mediagoblin.db.models import Activity, Generator, User, MediaEntry, \ + MediaComment, Collection + +def create_activity(verb, obj, target=None, actor=None): + """ + This will create an Activity object which for the obj if possible + and save it. The verb should be one of the following: + add, author, create, delete, dislike, favorite, follow + like, post, share, unfollow, unfavorite, unlike, unshare, + update, tag. + + If none of those fit you might not want/need to create an activity for + the object. The list is in mediagoblin.db.models.Activity.VALID_VERBS + + If no actor is supplied it'll take the actor/author/uploader/etc. from + the object if possible, else raise a ValueError + """ + # exception when we try and generate an activity with an unknow verb + # could change later to allow arbitrary verbs but at the moment we'll play + # it safe. + + if verb not in Activity.VALID_VERBS: + raise ValueError("A invalid verb type has been supplied.") + + # This should exist as we're creating it by the migration for Generator + generator = Generator.query.filter_by(name="GNU MediaGoblin").first() + activity = Activity(verb=verb) + activity.set_object(obj) + + if target is not None: + activity.set_target(target) + + if actor is not None: + # If they've set it override the actor from the obj. + activity.actor = actor.id if isinstance(actor, User) else actor + + activity.generator = generator.id + activity.save() + + # Sigh want to do this prior to save but I can't figure a way to get + # around relationship() not looking up object when model isn't saved. + if activity.generate_content(): + activity.save() + + return activity From 0421fc5ee8e10606426a803b51bfe4333d3ab406 Mon Sep 17 00:00:00 2001 From: Jessica Tallon Date: Fri, 29 Aug 2014 13:49:48 +0100 Subject: [PATCH 5/9] Fix migrations and refactor object_type - Make changes to objectType to be more pythonic "object_type" - Move object_type to mixins rather than be on the models - Convert migrations to sqlalchemy core rather than ORM (fix) - Change TYPES to use descriptive strings rather than numbers --- mediagoblin/db/migrations.py | 148 +++++++++++++++++++++--------- mediagoblin/db/mixin.py | 15 ++- mediagoblin/db/models.py | 81 ++++++++-------- mediagoblin/federation/routing.py | 4 +- mediagoblin/federation/views.py | 31 ++++--- 5 files changed, 174 insertions(+), 105 deletions(-) diff --git a/mediagoblin/db/migrations.py b/mediagoblin/db/migrations.py index f467253f..aab01c12 100644 --- a/mediagoblin/db/migrations.py +++ b/mediagoblin/db/migrations.py @@ -905,15 +905,21 @@ class Activity_R0(declarative_base()): 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) + content = Column(Unicode, nullable=True) title = Column(Unicode, nullable=True) target = Column(Integer, ForeignKey(User.id), nullable=True) generator = Column(Integer, ForeignKey(Generator.id), nullable=True) + object = Column(Integer, + ForeignKey("core__activity_intermediators.id"), + nullable=False) + target = Column(Integer, + ForeignKey("core__activity_intermediators.id"), + nullable=True) class ActivityIntermediator_R0(declarative_base()): - __tablename__ = "core__acitivity_intermediators" + __tablename__ = "core__activity_intermediators" id = Column(Integer, primary_key=True) - type = Column(Integer, nullable=False) + type = Column(Unicode, nullable=False) @RegisterMigration(24, MIGRATIONS) def activity_migration(db): @@ -925,32 +931,40 @@ def activity_migration(db): - Retroactively adds activities for what we can acurately work out """ # Set constants we'll use later - FOREIGN_KEY = "core__acitivity_intermediators.id" + FOREIGN_KEY = "core__activity_intermediators.id" # Create the new tables. - Activity_R0.__table__.create(db.bind) - Generator_R0.__table__.create(db.bind) ActivityIntermediator_R0.__table__.create(db.bind) + Generator_R0.__table__.create(db.bind) + Activity_R0.__table__.create(db.bind) db.commit() # Initiate the tables we want to use later metadata = MetaData(bind=db.bind) user_table = inspect_table(metadata, "core__users") + activity_table = inspect_table(metadata, "core__activities") generator_table = inspect_table(metadata, "core__generators") collection_table = inspect_table(metadata, "core__collections") media_entry_table = inspect_table(metadata, "core__media_entries") media_comments_table = inspect_table(metadata, "core__media_comments") + ai_table = inspect_table(metadata, "core__activity_intermediators") # Create the foundations for Generator db.execute(generator_table.insert().values( name="GNU Mediagoblin", - object_type="service" + object_type="service", + published=datetime.datetime.now(), + updated=datetime.datetime.now() )) db.commit() + # Get the ID of that generator + gmg_generator = db.execute(generator_table.select( + generator_table.c.name==u"GNU Mediagoblin")).first() + # Now we want to modify the tables which MAY have an activity at some point as_object = Column("activity_as_object", Integer, ForeignKey(FOREIGN_KEY)) @@ -977,45 +991,93 @@ def activity_migration(db): # 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, - updated=media.created, - generator=gmg_generator.id - ) - activity.generate_content() - activity.save(set_updated=False) - activity.set_object(media) - media.save() + # these can't have content as it's not fesible to get the + # correct content strings. + for media in db.execute(media_entry_table.select()): + # Now we want to create the intermedaitory + db_ai = db.execute(ai_table.insert().values( + type="media", + )) + db_ai = db.execute(ai_table.select( + ai_table.c.id==db_ai.inserted_primary_key[0] + )).first() + + # Add the activity + activity = { + "verb": "create", + "actor": media.uploader, + "published": media.created, + "updated": media.created, + "generator": gmg_generator.id, + "object": db_ai.id + } + db.execute(activity_table.insert().values(**activity)) + + # Add the AI to the media. + db.execute(media_entry_table.update().values( + activity_as_object=db_ai.id + ).where(id=media.id)) # 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, - updated=comment.created, - generator=gmg_generator.id - ) - activity.generate_content() - activity.save(set_updated=False) - activity.set_object(comment) - comment.save() + for comment in db.execute(media_comments_table.select()): + # Get the MediaEntry for the comment + media_entry = db.execute( + media_entry_table.select(id=comment.media_entry_id)) + + # Create an AI for target + db_ai_media = db.execute(ai_table.insert().values( + type="media" + )) + db_ai_media = db.execute(ai_table.select( + ai_table.c.id==db_ai_media.inserted_primary_key[0] + )) + + db.execute( + media_entry_table.update().values( + activity_as_target=db_ai_media.id + ).where(id=media_entry.id)) + + # Now create the AI for the comment + db_ai_comment = db.execute(ai_table.insert().values( + type="comment" + )) + + activity = { + "verb": "comment", + "actor": comment.author, + "published": comment.created, + "updated": comment.created, + "generator": gmg_generator.id, + "object": db_ai_comment.id, + "target": db_ai_media.id, + } + + # Now add the comment object + db.execute(media_comments_table.insert().values(**activity)) # Create 'create' activities for all collections - for collection in Collection.query.all(): - activity = Activity_R0( - verb="create", - actor=collection.creator, - published=collection.created, - updated=collection.created, - generator=gmg_generator.id - ) - activity.generate_content() - activity.save(set_updated=False) - activity.set_object(collection) - collection.save() + for collection in db.execute(collection_table.select()): + # create AI + db_ai = db.execute(ai_table.insert().values( + type="collection" + )) + + # Now add link the collection to the AI + db.execute(collection_table.update().values( + activity_as_object=db_ai.id + ).where(id=collection.id)) + + activity = { + "verb": "create", + "actor": collection.creator, + "published": collection.created, + "updated": collection.created, + "generator": gmg_generator.id, + "object": db_ai.id, + } + + db.execute(activity_table.insert().values(**activity)) + + db.commit() diff --git a/mediagoblin/db/mixin.py b/mediagoblin/db/mixin.py index bc3a3bd2..6a733510 100644 --- a/mediagoblin/db/mixin.py +++ b/mediagoblin/db/mixin.py @@ -43,6 +43,8 @@ from mediagoblin.tools.translate import pass_to_ugettext as _ class UserMixin(object): + object_type = "person" + @property def bio_html(self): return cleaned_markdown_conversion(self.bio) @@ -131,6 +133,11 @@ class MediaEntryMixin(GenerateSlugMixin): return check_media_slug_used(self.uploader, slug, self.id) + @property + def object_type(self): + """ Converts media_type to pump-like type - don't use internally """ + return self.media_type.split(".")[-1] + @property def description_html(self): """ @@ -298,6 +305,8 @@ class MediaEntryMixin(GenerateSlugMixin): class MediaCommentMixin(object): + object_type = "comment" + @property def content_html(self): """ @@ -322,6 +331,8 @@ class MediaCommentMixin(object): class CollectionMixin(GenerateSlugMixin): + object_type = "collection" + def check_slug_used(self, slug): # import this here due to a cyclic import issue # (db.models -> db.mixin -> db.util -> db.models) @@ -366,6 +377,7 @@ class CollectionItemMixin(object): return cleaned_markdown_conversion(self.note) class ActivityMixin(object): + object_type = "activity" VALID_VERBS = ["add", "author", "create", "delete", "dislike", "favorite", "follow", "like", "post", "share", "unfavorite", "unfollow", @@ -440,7 +452,8 @@ class ActivityMixin(object): "updated": self.updated.isoformat(), "content": self.content, "url": self.get_url(request), - "object": self.get_object().serialize(request) + "object": self.get_object().serialize(request), + "objectType": self.object_type, } if self.generator: diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index 6004e97d..5bd40c2a 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -78,15 +78,13 @@ class User(Base, UserMixin): upload_limit = Column(Integer) activity_as_object = Column(Integer, - ForeignKey("core__acitivity_intermediators.id")) + ForeignKey("core__activity_intermediators.id")) activity_as_target = Column(Integer, - ForeignKey("core__acitivity_intermediators.id")) + ForeignKey("core__activity_intermediators.id")) ## TODO # plugin data would be in a separate model - objectType = "person" - def __repr__(self): return '<{0} #{1} {2} {3} "{4}">'.format( self.__class__.__name__, @@ -151,7 +149,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": self.objectType, + "objectType": self.object_type, "pump_io": { "shared": False, "followed": False, @@ -319,9 +317,9 @@ class MediaEntry(Base, MediaEntryMixin): default=MutationDict()) activity_as_object = Column(Integer, - ForeignKey("core__acitivity_intermediators.id")) + ForeignKey("core__activity_intermediators.id")) activity_as_target = Column(Integer, - ForeignKey("core__acitivity_intermediators.id")) + ForeignKey("core__activity_intermediators.id")) ## TODO # fail_error @@ -444,18 +442,13 @@ class MediaEntry(Base, MediaEntryMixin): # pass through commit=False/True in kwargs super(MediaEntry, self).delete(**kwargs) - @property - def objectType(self): - """ Converts media_type to pump-like type - don't use internally """ - return self.media_type.split(".")[-1] - def serialize(self, request, show_comments=True): """ Unserialize MediaEntry to object """ author = self.get_uploader context = { "id": self.id, "author": author.serialize(request), - "objectType": self.objectType, + "objectType": self.object_type, "url": self.url_for_self(request.urlgen), "image": { "url": request.host_url + self.thumb_url[1:], @@ -472,7 +465,7 @@ class MediaEntry(Base, MediaEntryMixin): "self": { "href": request.urlgen( "mediagoblin.federation.object", - objectType=self.objectType, + object_type=self.objectType, id=self.id, qualified=True ), @@ -491,14 +484,15 @@ class MediaEntry(Base, MediaEntryMixin): context["license"] = self.license if show_comments: - comments = [comment.serialize(request) for comment in self.get_comments()] + comments = [ + comment.serialize(request) for comment in self.get_comments()] total = len(comments) context["replies"] = { "totalItems": total, "items": comments, "url": request.urlgen( "mediagoblin.federation.object.comments", - objectType=self.objectType, + object_type=self.object_type, id=self.id, qualified=True ), @@ -620,8 +614,6 @@ class MediaTag(Base): creator=Tag.find_or_new ) - objectType = "tag" - def __init__(self, name=None, slug=None): Base.__init__(self) if name is not None: @@ -668,11 +660,9 @@ class MediaComment(Base, MediaCommentMixin): activity_as_object = Column(Integer, - ForeignKey("core__acitivity_intermediators.id")) + ForeignKey("core__activity_intermediators.id")) activity_as_target = Column(Integer, - ForeignKey("core__acitivity_intermediators.id")) - - objectType = "comment" + ForeignKey("core__activity_intermediators.id")) def serialize(self, request): """ Unserialize to python dictionary for API """ @@ -680,7 +670,7 @@ class MediaComment(Base, MediaCommentMixin): author = self.get_author context = { "id": self.id, - "objectType": self.objectType, + "objectType": self.object_type, "content": self.content, "inReplyTo": media.serialize(request, show_comments=False), "author": author.serialize(request) @@ -739,16 +729,14 @@ class Collection(Base, CollectionMixin): cascade="all, delete-orphan")) activity_as_object = Column(Integer, - ForeignKey("core__acitivity_intermediators.id")) + ForeignKey("core__activity_intermediators.id")) activity_as_target = Column(Integer, - ForeignKey("core__acitivity_intermediators.id")) + ForeignKey("core__activity_intermediators.id")) __table_args__ = ( UniqueConstraint('creator', 'slug'), {}) - objectType = "collection" - def get_collection_items(self, ascending=False): #TODO, is this still needed with self.collection_items being available? order_col = CollectionItem.position @@ -1085,10 +1073,7 @@ class PrivilegeUserAssociation(Base): primary_key=True) class Generator(Base): - """ - This holds the information about the software used to create - objects for the pump.io APIs. - """ + """ Information about what created an activity """ __tablename__ = "core__generators" id = Column(Integer, primary_key=True) @@ -1118,16 +1103,16 @@ class ActivityIntermediator(Base): used multiple times for the activity object or target and also allows for different types of objects to be used as an Activity. """ - __tablename__ = "core__acitivity_intermediators" + __tablename__ = "core__activity_intermediators" id = Column(Integer, primary_key=True) - type = Column(Integer, nullable=False) + type = Column(Unicode, nullable=False) TYPES = { - 0: User, - 1: MediaEntry, - 2: MediaComment, - 3: Collection, + "user": User, + "media": MediaEntry, + "comment": MediaComment, + "collection": Collection, } def _find_model(self, obj): @@ -1189,18 +1174,22 @@ class Activity(Base, ActivityMixin): id = Column(Integer, primary_key=True) actor = Column(Integer, - ForeignKey(User.id, use_alter=True, name="actor"), + ForeignKey("core__users.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=True) title = Column(Unicode, nullable=True) - generator = Column(Integer, ForeignKey(Generator.id), nullable=True) + generator = Column(Integer, + ForeignKey("core__generators.id"), + nullable=True) object = Column(Integer, - ForeignKey(ActivityIntermediator.id), nullable=False) + ForeignKey("core__activity_intermediators.id"), + nullable=False) target = Column(Integer, - ForeignKey(ActivityIntermediator.id), nullable=True) + ForeignKey("core__activity_intermediators.id"), + nullable=True) get_actor = relationship(User, foreign_keys="Activity.actor", post_update=True) @@ -1214,8 +1203,9 @@ class Activity(Base, ActivityMixin): self.object = ai.id return - self.object.set_object(*args, **kwargs) - self.object.save() + ai = ActivityIntermediator.query.filter_by(id=self.object).first() + ai.set_object(*args, **kwargs) + ai.save() @property def get_object(self): @@ -1229,8 +1219,9 @@ class Activity(Base, ActivityMixin): self.object = ai.id return - self.target.set_object(*args, **kwargs) - self.targt.save() + ai = ActivityIntermediator.query.filter_by(id=self.target).first() + ai.set_object(*args, **kwargs) + ai.save() @property def get_target(self): diff --git a/mediagoblin/federation/routing.py b/mediagoblin/federation/routing.py index 0b0fbaf1..44a04bdb 100644 --- a/mediagoblin/federation/routing.py +++ b/mediagoblin/federation/routing.py @@ -51,12 +51,12 @@ add_route( # object endpoints add_route( "mediagoblin.federation.object", - "/api//", + "/api//", "mediagoblin.federation.views:object_endpoint" ) add_route( "mediagoblin.federation.object.comments", - "/api///comments", + "/api///comments", "mediagoblin.federation.views:object_comments" ) diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py index 7d02d02e..60054b2e 100644 --- a/mediagoblin/federation/views.py +++ b/mediagoblin/federation/views.py @@ -71,14 +71,14 @@ def profile_endpoint(request): def user_endpoint(request): """ This is /api/user/ - This will get the user """ user, user_profile = get_profile(request) - + if user is None: username = request.matchdict["username"] return json_error( "No such 'user' with username '{0}'".format(username), status=404 ) - + return json_response({ "nickname": user.username, "updated": user.created.isoformat(), @@ -350,7 +350,7 @@ def feed_endpoint(request): @oauth_required def object_endpoint(request): """ Lookup for a object type """ - object_type = request.matchdict["objectType"] + object_type = request.matchdict["object_type"] try: object_id = int(request.matchdict["id"]) except ValueError: @@ -382,17 +382,17 @@ def object_comments(request): media = MediaEntry.query.filter_by(id=request.matchdict["id"]).first() if media is None: return json_error("Can't find '{0}' with ID '{1}'".format( - request.matchdict["objectType"], + request.matchdict["object_type"], request.matchdict["id"] ), 404) - comments = response.serialize(request) + comments = media.serialize(request) comments = comments.get("replies", { "totalItems": 0, "items": [], "url": request.urlgen( "mediagoblin.federation.object.comments", - objectType=media.objectType, + object_type=media.object_type, id=media.id, qualified=True ) @@ -459,27 +459,30 @@ def whoami(request): @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() + + activity = Activity.query.filter_by( + id=activity_id, + author=user.id + ).first() if activity is None: return render_404(request) - + return render_to_response( request, "mediagoblin/federation/activity.html", {"activity": activity} ) - - + + From b61519ce53d9839c4277132967b2073ad6299daf Mon Sep 17 00:00:00 2001 From: Jessica Tallon Date: Wed, 3 Sep 2014 15:58:40 +0100 Subject: [PATCH 6/9] Only have Model.activity for activity compatable objects/targets --- mediagoblin/db/migrations.py | 30 ++++------- mediagoblin/db/models.py | 96 +++++++++++++----------------------- 2 files changed, 44 insertions(+), 82 deletions(-) diff --git a/mediagoblin/db/migrations.py b/mediagoblin/db/migrations.py index aab01c12..e4e5d9b8 100644 --- a/mediagoblin/db/migrations.py +++ b/mediagoblin/db/migrations.py @@ -901,14 +901,13 @@ class Generator_R0(declarative_base()): class Activity_R0(declarative_base()): __tablename__ = "core__activities" id = Column(Integer, primary_key=True) - actor = Column(Integer, ForeignKey(User.id), nullable=False) + actor = Column(Integer, ForeignKey("core__users.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=True) title = Column(Unicode, nullable=True) - target = Column(Integer, ForeignKey(User.id), nullable=True) - generator = Column(Integer, ForeignKey(Generator.id), nullable=True) + generator = Column(Integer, ForeignKey("core__generators.id"), nullable=True) object = Column(Integer, ForeignKey("core__activity_intermediators.id"), nullable=False) @@ -933,7 +932,6 @@ def activity_migration(db): # Set constants we'll use later FOREIGN_KEY = "core__activity_intermediators.id" - # Create the new tables. ActivityIntermediator_R0.__table__.create(db.bind) Generator_R0.__table__.create(db.bind) @@ -967,25 +965,17 @@ def activity_migration(db): # Now we want to modify the tables which MAY have an activity at some point - as_object = Column("activity_as_object", Integer, ForeignKey(FOREIGN_KEY)) - as_object.create(media_entry_table) - as_target = Column("activity_as_target", Integer, ForeignKey(FOREIGN_KEY)) - as_target.create(media_entry_table) + media_col = Column("activity", Integer, ForeignKey(FOREIGN_KEY)) + media_col.create(media_entry_table) - as_object = Column("activity_as_object", Integer, ForeignKey(FOREIGN_KEY)) - as_object.create(user_table) - as_target = Column("activity_as_target", Integer, ForeignKey(FOREIGN_KEY)) - as_target.create(user_table) + user_col = Column("activity", Integer, ForeignKey(FOREIGN_KEY)) + user_col.create(user_table) - as_object = Column("activity_as_object", Integer, ForeignKey(FOREIGN_KEY)) - as_object.create(media_comments_table) - as_target = Column("activity_as_target", Integer, ForeignKey(FOREIGN_KEY)) - as_target.create(media_comments_table) + comments_col = Column("activity", Integer, ForeignKey(FOREIGN_KEY)) + comments_col.create(media_comments_table) - as_object = Column("activity_as_object", Integer, ForeignKey(FOREIGN_KEY)) - as_object.create(collection_table) - as_target = Column("activity_as_target", Integer, ForeignKey(FOREIGN_KEY)) - as_target.create(collection_table) + collection_col = Column("activity", Integer, ForeignKey(FOREIGN_KEY)) + collection_col.create(collection_table) db.commit() diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index 5bd40c2a..65096500 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -77,10 +77,7 @@ class User(Base, UserMixin): uploaded = Column(Integer, default=0) upload_limit = Column(Integer) - activity_as_object = Column(Integer, - ForeignKey("core__activity_intermediators.id")) - activity_as_target = Column(Integer, - ForeignKey("core__activity_intermediators.id")) + activity = Column(Integer, ForeignKey("core__activity_intermediators.id")) ## TODO # plugin data would be in a separate model @@ -316,10 +313,7 @@ class MediaEntry(Base, MediaEntryMixin): media_metadata = Column(MutationDict.as_mutable(JSONEncoded), default=MutationDict()) - activity_as_object = Column(Integer, - ForeignKey("core__activity_intermediators.id")) - activity_as_target = Column(Integer, - ForeignKey("core__activity_intermediators.id")) + activity = Column(Integer, ForeignKey("core__activity_intermediators.id")) ## TODO # fail_error @@ -659,10 +653,7 @@ class MediaComment(Base, MediaCommentMixin): cascade="all, delete-orphan")) - activity_as_object = Column(Integer, - ForeignKey("core__activity_intermediators.id")) - activity_as_target = Column(Integer, - ForeignKey("core__activity_intermediators.id")) + activity = Column(Integer, ForeignKey("core__activity_intermediators.id")) def serialize(self, request): """ Unserialize to python dictionary for API """ @@ -728,10 +719,7 @@ class Collection(Base, CollectionMixin): backref=backref("collections", cascade="all, delete-orphan")) - activity_as_object = Column(Integer, - ForeignKey("core__activity_intermediators.id")) - activity_as_target = Column(Integer, - ForeignKey("core__activity_intermediators.id")) + activity = Column(Integer, ForeignKey("core__activity_intermediators.id")) __table_args__ = ( UniqueConstraint('creator', 'slug'), @@ -1123,8 +1111,8 @@ class ActivityIntermediator(Base): return None, None - def set_object(self, obj): - """ This sets itself as the object for an activity """ + def set(self, obj): + """ This sets itself as the activity """ key, model = self._find_model(obj) if key is None: raise ValueError("Invalid type of object given") @@ -1133,8 +1121,7 @@ class ActivityIntermediator(Base): obj.activity_as_object = self.id self.type = key - @property - def get_object(self): + def get(self): """ Finds the object for an activity """ if self.type is None: return None @@ -1142,24 +1129,6 @@ class ActivityIntermediator(Base): model = self.TYPES[self.type] return model.query.filter_by(activity_as_object=self.id).first() - def set_target(self, obj): - """ This sets itself as the target for an activity """ - key, model = self._find_model(obj) - if key is None: - raise ValueError("Invalid type of object given") - - obj.activity_as_target = self.id - self.type = key - - @property - def get_target(self): - """ Gets the target for an activity """ - if self.type is None: - return None - - model = self.TYPES[self.type] - return model.query.filter_by(activity_as_target=self.id).first() - def save(self, *args, **kwargs): if self.type not in self.TYPES.keys(): raise ValueError("Invalid type set") @@ -1195,40 +1164,43 @@ class Activity(Base, ActivityMixin): foreign_keys="Activity.actor", post_update=True) get_generator = relationship(Generator) - def set_object(self, *args, **kwargs): - if self.object is None: - ai = ActivityIntermediator() - ai.set_object(*args, **kwargs) - ai.save() - self.object = ai.id - return - - ai = ActivityIntermediator.query.filter_by(id=self.object).first() - ai.set_object(*args, **kwargs) - ai.save() - @property def get_object(self): - return self.object.get_object + if self.object is None: + return None - def set_target(self, *args, **kwargs): - if self.target is None: - ai = ActivityIntermediator() - ai.set_target(*args, **kwargs) - ai.save() - self.object = ai.id - return + ai = ActivityIntermediator.query.filter_by(id=self.object).first() + return ai.get() - ai = ActivityIntermediator.query.filter_by(id=self.target).first() - ai.set_object(*args, **kwargs) - ai.save() + def set_object(self, obj): + self.object = self._set_model(obj) @property def get_target(self): if self.target is None: return None - return self.target.get_target + ai = ActivityIntermediator.query.filter_by(id=self.target).first() + return ai.get() + + def set_target(self, obj): + self.target = self._set_model(obj) + + def _set_model(self, obj): + # Firstly can we set obj + if not hasattr(obj, "activity"): + raise ValueError( + "{0!r} is unable to be set on activity".format(obj)) + + if obj.activity is None: + # We need to create a new AI + ai = ActivityIntermediator() + ai.set(obj) + ai.save() + return ai.id + + # Okay we should have an existing AI + return ActivityIntermediator.query.filter_by(id=obj.activity).first().id def save(self, set_updated=True, *args, **kwargs): if set_updated: From 6d36f75f845759da50b81f9cdeb9769fe97c2281 Mon Sep 17 00:00:00 2001 From: Jessica Tallon Date: Thu, 4 Sep 2014 19:12:48 +0100 Subject: [PATCH 7/9] Fix all the unit tests and clean up code --- mediagoblin/db/migrations.py | 74 +++++++++++-------- mediagoblin/db/mixin.py | 18 ++--- mediagoblin/db/models.py | 4 +- mediagoblin/submit/lib.py | 10 ++- .../mediagoblin/federation/activity.html | 4 +- mediagoblin/tools/federation.py | 17 +++-- mediagoblin/user_pages/views.py | 6 +- 7 files changed, 74 insertions(+), 59 deletions(-) diff --git a/mediagoblin/db/migrations.py b/mediagoblin/db/migrations.py index e4e5d9b8..c1a73795 100644 --- a/mediagoblin/db/migrations.py +++ b/mediagoblin/db/migrations.py @@ -898,28 +898,28 @@ class Generator_R0(declarative_base()): updated = Column(DateTime, nullable=False, default=datetime.datetime.now) object_type = Column(Unicode, nullable=False) +class ActivityIntermediator_R0(declarative_base()): + __tablename__ = "core__activity_intermediators" + id = Column(Integer, primary_key=True) + type = Column(Unicode, nullable=False) + class Activity_R0(declarative_base()): __tablename__ = "core__activities" id = Column(Integer, primary_key=True) - actor = Column(Integer, ForeignKey("core__users.id"), nullable=False) + 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=True) title = Column(Unicode, nullable=True) - generator = Column(Integer, ForeignKey("core__generators.id"), nullable=True) + generator = Column(Integer, ForeignKey(Generator_R0.id), nullable=True) object = Column(Integer, - ForeignKey("core__activity_intermediators.id"), + ForeignKey(ActivityIntermediator_R0.id), nullable=False) target = Column(Integer, - ForeignKey("core__activity_intermediators.id"), + ForeignKey(ActivityIntermediator_R0.id), nullable=True) -class ActivityIntermediator_R0(declarative_base()): - __tablename__ = "core__activity_intermediators" - id = Column(Integer, primary_key=True) - type = Column(Unicode, nullable=False) - @RegisterMigration(24, MIGRATIONS) def activity_migration(db): """ @@ -931,6 +931,7 @@ def activity_migration(db): """ # Set constants we'll use later FOREIGN_KEY = "core__activity_intermediators.id" + ACTIVITY_COLUMN = "activity" # Create the new tables. ActivityIntermediator_R0.__table__.create(db.bind) @@ -938,7 +939,6 @@ def activity_migration(db): Activity_R0.__table__.create(db.bind) db.commit() - # Initiate the tables we want to use later metadata = MetaData(bind=db.bind) user_table = inspect_table(metadata, "core__users") @@ -965,16 +965,16 @@ def activity_migration(db): # Now we want to modify the tables which MAY have an activity at some point - media_col = Column("activity", Integer, ForeignKey(FOREIGN_KEY)) + media_col = Column(ACTIVITY_COLUMN, Integer, ForeignKey(FOREIGN_KEY)) media_col.create(media_entry_table) - user_col = Column("activity", Integer, ForeignKey(FOREIGN_KEY)) + user_col = Column(ACTIVITY_COLUMN, Integer, ForeignKey(FOREIGN_KEY)) user_col.create(user_table) - comments_col = Column("activity", Integer, ForeignKey(FOREIGN_KEY)) + comments_col = Column(ACTIVITY_COLUMN, Integer, ForeignKey(FOREIGN_KEY)) comments_col.create(media_comments_table) - collection_col = Column("activity", Integer, ForeignKey(FOREIGN_KEY)) + collection_col = Column(ACTIVITY_COLUMN, Integer, ForeignKey(FOREIGN_KEY)) collection_col.create(collection_table) db.commit() @@ -1005,32 +1005,31 @@ def activity_migration(db): # Add the AI to the media. db.execute(media_entry_table.update().values( - activity_as_object=db_ai.id - ).where(id=media.id)) + activity=db_ai.id + ).where(media_entry_table.c.id==media.id)) # Now we want to add all the comments people made for comment in db.execute(media_comments_table.select()): # Get the MediaEntry for the comment media_entry = db.execute( - media_entry_table.select(id=comment.media_entry_id)) + media_entry_table.select( + media_entry_table.c.id==comment.media_entry + )).first() # Create an AI for target - db_ai_media = db.execute(ai_table.insert().values( - type="media" - )) db_ai_media = db.execute(ai_table.select( - ai_table.c.id==db_ai_media.inserted_primary_key[0] - )) + ai_table.c.id==media_entry.activity + )).first().id db.execute( - media_entry_table.update().values( - activity_as_target=db_ai_media.id - ).where(id=media_entry.id)) + media_comments_table.update().values( + activity=db_ai_media + ).where(media_comments_table.c.id==media_entry.id)) # Now create the AI for the comment db_ai_comment = db.execute(ai_table.insert().values( type="comment" - )) + )).inserted_primary_key[0] activity = { "verb": "comment", @@ -1038,12 +1037,17 @@ def activity_migration(db): "published": comment.created, "updated": comment.created, "generator": gmg_generator.id, - "object": db_ai_comment.id, - "target": db_ai_media.id, + "object": db_ai_comment, + "target": db_ai_media, } # Now add the comment object - db.execute(media_comments_table.insert().values(**activity)) + db.execute(activity_table.insert().values(**activity)) + + # Now add activity to comment + db.execute(media_comments_table.update().values( + activity=db_ai_comment + ).where(media_comments_table.c.id==comment.id)) # Create 'create' activities for all collections for collection in db.execute(collection_table.select()): @@ -1051,11 +1055,14 @@ def activity_migration(db): db_ai = db.execute(ai_table.insert().values( type="collection" )) + db_ai = db.execute(ai_table.select( + ai_table.c.id==db_ai.inserted_primary_key[0] + )).first() # Now add link the collection to the AI db.execute(collection_table.update().values( - activity_as_object=db_ai.id - ).where(id=collection.id)) + activity=db_ai.id + ).where(collection_table.c.id==collection.id)) activity = { "verb": "create", @@ -1068,6 +1075,9 @@ def activity_migration(db): db.execute(activity_table.insert().values(**activity)) - + # Now add the activity to the collection + db.execute(collection_table.update().values( + activity=db_ai.id + ).where(collection_table.c.id==collection.id)) db.commit() diff --git a/mediagoblin/db/mixin.py b/mediagoblin/db/mixin.py index 6a733510..aec35fc7 100644 --- a/mediagoblin/db/mixin.py +++ b/mediagoblin/db/mixin.py @@ -421,8 +421,8 @@ class ActivityMixin(object): "tag": {"simple": _("{username} tagged {object}")}, } - obj = self.get_object() - target = self.get_target() + obj = self.get_object + target = self.get_target actor = self.get_actor content = verb_to_content.get(self.verb, None) @@ -432,13 +432,13 @@ class ActivityMixin(object): if target is None or "targetted" not in content: self.content = content["simple"].format( username=actor.username, - object=obj.objectType + object=obj.object_type ) else: self.content = content["targetted"].format( username=actor.username, - object=obj.objectType, - target=target.objectType, + object=obj.object_type, + target=target.object_type, ) return self.content @@ -452,19 +452,19 @@ class ActivityMixin(object): "updated": self.updated.isoformat(), "content": self.content, "url": self.get_url(request), - "object": self.get_object().serialize(request), + "object": self.get_object.serialize(request), "objectType": self.object_type, } if self.generator: - obj["generator"] = self.get_generator.seralize(request) + obj["generator"] = self.get_generator.serialize(request) if self.title: obj["title"] = self.title - target = self.get_target() + target = self.get_target if target is not None: - obj["target"] = target.seralize(request) + obj["target"] = target.serialize(request) return obj diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index 65096500..5f78275b 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -459,7 +459,7 @@ class MediaEntry(Base, MediaEntryMixin): "self": { "href": request.urlgen( "mediagoblin.federation.object", - object_type=self.objectType, + object_type=self.object_type, id=self.id, qualified=True ), @@ -1127,7 +1127,7 @@ class ActivityIntermediator(Base): return None model = self.TYPES[self.type] - return model.query.filter_by(activity_as_object=self.id).first() + return model.query.filter_by(activity=self.id).first() def save(self, *args, **kwargs): if self.type not in self.TYPES.keys(): diff --git a/mediagoblin/submit/lib.py b/mediagoblin/submit/lib.py index af25bfb7..347eff81 100644 --- a/mediagoblin/submit/lib.py +++ b/mediagoblin/submit/lib.py @@ -200,9 +200,10 @@ 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) + entry.activity = create_activity("post", entry, entry.uploader).id + entry.save() return entry @@ -293,8 +294,9 @@ 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) + entry.activity = create_activity("post", entry, entry.uploader).id + entry.save() return json_response(entry.serialize(request)) diff --git a/mediagoblin/templates/mediagoblin/federation/activity.html b/mediagoblin/templates/mediagoblin/federation/activity.html index f380fd5f..14377a48 100644 --- a/mediagoblin/templates/mediagoblin/federation/activity.html +++ b/mediagoblin/templates/mediagoblin/federation/activity.html @@ -29,13 +29,13 @@ {% autoescape False %}

{{ activity.content }}

{% endautoescape %} - +
{% block mediagoblin_after_added_sidebar %} - View {{ activity.object.objectType }} + View {{ activity.get_object.object_type }} {% endblock %}
diff --git a/mediagoblin/tools/federation.py b/mediagoblin/tools/federation.py index 573b059a..281994cc 100644 --- a/mediagoblin/tools/federation.py +++ b/mediagoblin/tools/federation.py @@ -17,7 +17,7 @@ from mediagoblin.db.models import Activity, Generator, User, MediaEntry, \ MediaComment, Collection -def create_activity(verb, obj, target=None, actor=None): +def create_activity(verb, obj, actor, target=None): """ This will create an Activity object which for the obj if possible and save it. The verb should be one of the following: @@ -27,9 +27,6 @@ def create_activity(verb, obj, target=None, actor=None): If none of those fit you might not want/need to create an activity for the object. The list is in mediagoblin.db.models.Activity.VALID_VERBS - - If no actor is supplied it'll take the actor/author/uploader/etc. from - the object if possible, else raise a ValueError """ # exception when we try and generate an activity with an unknow verb # could change later to allow arbitrary verbs but at the moment we'll play @@ -40,15 +37,21 @@ def create_activity(verb, obj, target=None, actor=None): # This should exist as we're creating it by the migration for Generator generator = Generator.query.filter_by(name="GNU MediaGoblin").first() + if generator is None: + generator = Generator( + name="GNU MediaGoblin", + object_type="service" + ) + generator.save() + activity = Activity(verb=verb) activity.set_object(obj) if target is not None: activity.set_target(target) - if actor is not None: - # If they've set it override the actor from the obj. - activity.actor = actor.id if isinstance(actor, User) else actor + # If they've set it override the actor from the obj. + activity.actor = actor.id if isinstance(actor, User) else actor activity.generator = generator.id activity.save() diff --git a/mediagoblin/user_pages/views.py b/mediagoblin/user_pages/views.py index cd0e843d..0d0f3e7b 100644 --- a/mediagoblin/user_pages/views.py +++ b/mediagoblin/user_pages/views.py @@ -200,7 +200,7 @@ def media_post_comment(request, media): _('Your comment has been posted!')) trigger_notification(comment, media, request) - create_activity("post", comment) + create_activity("post", comment, comment.author) add_comment_subscription(request.user, media) return redirect_obj(request, media) @@ -262,7 +262,7 @@ def media_collect(request, media): collection.creator = request.user.id collection.generate_slug() collection.save() - create_activity("create", collection) + create_activity("create", collection, collection.creator) # Otherwise, use the collection selected from the drop-down else: @@ -289,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, target=collection) + create_activity("add", media, request.user, target=collection) messages.add_message(request, messages.SUCCESS, _('"%s" added to collection "%s"') % (media.title, collection.title)) From 240e9870162706228e1e1822de14d11cc4fd19ce Mon Sep 17 00:00:00 2001 From: Jessica Tallon Date: Sat, 4 Oct 2014 11:26:48 +0100 Subject: [PATCH 8/9] Fix bug where activity.content was not populated --- mediagoblin/db/models.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index 5f78275b..4d7830d2 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -1117,9 +1117,13 @@ class ActivityIntermediator(Base): if key is None: raise ValueError("Invalid type of object given") - # First set self as activity - obj.activity_as_object = self.id + # We need to save so that self.id is populated self.type = key + self.save() + + # First set self as activity + obj.activity = self.id + obj.save() def get(self): """ Finds the object for an activity """ From 2b1916182d36043c2dbce7c2718e8e1d1a976429 Mon Sep 17 00:00:00 2001 From: Jessica Tallon Date: Tue, 7 Oct 2014 08:54:26 +0100 Subject: [PATCH 9/9] Add __repr__ to Activity and Generator class --- mediagoblin/db/mixin.py | 2 +- mediagoblin/db/models.py | 18 ++++++++++++++++++ mediagoblin/tools/federation.py | 3 +-- mediagoblin/user_pages/views.py | 2 +- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/mediagoblin/db/mixin.py b/mediagoblin/db/mixin.py index aec35fc7..39690cfc 100644 --- a/mediagoblin/db/mixin.py +++ b/mediagoblin/db/mixin.py @@ -410,7 +410,7 @@ class ActivityMixin(object): "like": {"simple": _("{username} liked {object}")}, "post": { "simple": _("{username} posted {object}"), - "targetted": _("{username} posted {object} to {targetted}"), + "targetted": _("{username} posted {object} to {target}"), }, "share": {"simple": _("{username} shared {object}")}, "unfavorite": {"simple": _("{username} unfavorited {object}")}, diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index 4d7830d2..ffd7b4f1 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -1070,6 +1070,12 @@ class Generator(Base): updated = Column(DateTime, default=datetime.datetime.now) object_type = Column(Unicode, nullable=False) + def __repr__(self): + return "<{klass} {name}>".format( + klass=self.__class__.__name__, + name=self.name + ) + def serialize(self, request): return { "id": self.id, @@ -1168,6 +1174,18 @@ class Activity(Base, ActivityMixin): foreign_keys="Activity.actor", post_update=True) get_generator = relationship(Generator) + def __repr__(self): + if self.content is None: + return "<{klass} verb:{verb}>".format( + klass=self.__class__.__name__, + verb=self.verb + ) + else: + return "<{klass} {content}>".format( + klass=self.__class__.__name__, + content=self.content + ) + @property def get_object(self): if self.object is None: diff --git a/mediagoblin/tools/federation.py b/mediagoblin/tools/federation.py index 281994cc..890e8801 100644 --- a/mediagoblin/tools/federation.py +++ b/mediagoblin/tools/federation.py @@ -14,8 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from mediagoblin.db.models import Activity, Generator, User, MediaEntry, \ - MediaComment, Collection +from mediagoblin.db.models import Activity, Generator, User def create_activity(verb, obj, actor, target=None): """ diff --git a/mediagoblin/user_pages/views.py b/mediagoblin/user_pages/views.py index 0d0f3e7b..d0ebdd70 100644 --- a/mediagoblin/user_pages/views.py +++ b/mediagoblin/user_pages/views.py @@ -200,7 +200,7 @@ def media_post_comment(request, media): _('Your comment has been posted!')) trigger_notification(comment, media, request) - create_activity("post", comment, comment.author) + create_activity("post", comment, comment.author, target=media) add_comment_subscription(request.user, media) return redirect_obj(request, media)