From ce46470c02371ff92db2c0412af97dfef33e58ee Mon Sep 17 00:00:00 2001 From: Jessica Tallon Date: Wed, 27 Aug 2014 14:34:07 +0100 Subject: [PATCH] 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 | 330 ++++++++++++++++------------------- 3 files changed, 364 insertions(+), 220 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(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() - + 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 set_object(self, *args, **kwargs): + if self.object is None: + ai = ActivityIntermediator() + ai.set_object(*args, **kwargs) + ai.save() + self.object = ai.id + return + + self.object.set_object(*args, **kwargs) + self.object.save() + + @property 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 - + 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): - """ 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 + if self.target is None: 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: - 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) + + 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 -- 2.25.1