Merge branch 'release-0.4.1'
[mediagoblin.git] / mediagoblin / db / models.py
index aeec8aead3034f6ff19a0c099d2ea3eb662aa5c5..826d47baa0a36b47aaf8699c23027d4b3afa9d4d 100644 (file)
@@ -20,21 +20,23 @@ TODO: indexes on foreignkeys, where useful.
 
 import logging
 import datetime
-import sys
 
 from sqlalchemy import Column, Integer, Unicode, UnicodeText, DateTime, \
         Boolean, ForeignKey, UniqueConstraint, PrimaryKeyConstraint, \
         SmallInteger
-from sqlalchemy.orm import relationship, backref
+from sqlalchemy.orm import relationship, backref, with_polymorphic
 from sqlalchemy.orm.collections import attribute_mapped_collection
 from sqlalchemy.sql.expression import desc
 from sqlalchemy.ext.associationproxy import association_proxy
 from sqlalchemy.util import memoized_property
 
+
 from mediagoblin.db.extratypes import PathTupleWithSlashes, JSONEncoded
-from mediagoblin.db.base import Base, DictReadAttrProxy, Session
-from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, MediaCommentMixin, CollectionMixin, CollectionItemMixin
+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.common import import_component
 
 # It's actually kind of annoying how sqlalchemy-migrate does this, if
 # I understand it right, but whatever.  Anyway, don't remove this :P
@@ -55,20 +57,22 @@ class User(Base, UserMixin):
 
     id = Column(Integer, primary_key=True)
     username = Column(Unicode, nullable=False, unique=True)
+    # Note: no db uniqueness constraint on email because it's not
+    # reliable (many email systems case insensitive despite against
+    # the RFC) and because it would be a mess to implement at this
+    # point.
     email = Column(Unicode, nullable=False)
-    created = Column(DateTime, nullable=False, default=datetime.datetime.now)
-    pw_hash = Column(Unicode, nullable=False)
+    pw_hash = Column(Unicode)
     email_verified = Column(Boolean, default=False)
+    created = Column(DateTime, nullable=False, default=datetime.datetime.now)
     status = Column(Unicode, default=u"needs_email_verification", nullable=False)
     # Intented to be nullable=False, but migrations would not work for it
     # set to nullable=True implicitly.
     wants_comment_notification = Column(Boolean, default=True)
-    verification_key = Column(Unicode)
+    license_preference = Column(Unicode)
     is_admin = Column(Boolean, default=False, nullable=False)
     url = Column(Unicode)
     bio = Column(UnicodeText)  # ??
-    fp_verification_key = Column(Unicode)
-    fp_token_expire = Column(DateTime)
 
     ## TODO
     # plugin data would be in a separate model
@@ -81,6 +85,25 @@ class User(Base, UserMixin):
                 'admin' if self.is_admin else 'user',
                 self.username)
 
+    def delete(self, **kwargs):
+        """Deletes a User and all related entries/comments/files/..."""
+        # Collections get deleted by relationships.
+
+        media_entries = MediaEntry.query.filter(MediaEntry.uploader == self.id)
+        for media in media_entries:
+            # TODO: Make sure that "MediaEntry.delete()" also deletes
+            # all related files/Comments
+            media.delete(del_orphan_tags=False, commit=False)
+
+        # Delete now unused tags
+        # TODO: import here due to cyclic imports!!! This cries for refactoring
+        from mediagoblin.db.util import clean_orphan_tags
+        clean_orphan_tags(commit=False)
+
+        # Delete user, pass through commit=False/True in kwargs
+        super(User, self).delete(**kwargs)
+        _log.info('Deleted user "{0}" account'.format(self.username))
+
 
 class MediaEntry(Base, MediaEntryMixin):
     """
@@ -125,6 +148,7 @@ class MediaEntry(Base, MediaEntryMixin):
         )
 
     attachment_files_helper = relationship("MediaAttachmentFile",
+        cascade="all, delete-orphan",
         order_by="MediaAttachmentFile.created"
         )
     attachment_files = association_proxy("attachment_files_helper", "dict_view",
@@ -145,15 +169,13 @@ class MediaEntry(Base, MediaEntryMixin):
     collections = association_proxy("collections_helper", "in_collection")
 
     ## TODO
-    # media_data
     # fail_error
 
     def get_comments(self, ascending=False):
         order_col = MediaComment.created
         if not ascending:
             order_col = desc(order_col)
-        return MediaComment.query.filter_by(
-            media_entry=self.id).order_by(order_col)
+        return self.all_comments.order_by(order_col)
 
     def url_to_prev(self, urlgen):
         """get the next 'newer' entry by this user"""
@@ -175,40 +197,31 @@ class MediaEntry(Base, MediaEntryMixin):
         if media is not None:
             return media.url_for_self(urlgen)
 
-    #@memoized_property
     @property
     def media_data(self):
-        session = Session()
-
-        return session.query(self.media_data_table).filter_by(
-            media_entry=self.id).first()
+        return getattr(self, self.media_data_ref)
 
     def media_data_init(self, **kwargs):
         """
         Initialize or update the contents of a media entry's media_data row
         """
-        session = Session()
-
-        media_data = session.query(self.media_data_table).filter_by(
-            media_entry=self.id).first()
+        media_data = self.media_data
 
-        # No media data, so actually add a new one
         if media_data is None:
-            media_data = self.media_data_table(
-                media_entry=self.id,
-                **kwargs)
-            session.add(media_data)
-        # Update old media data
+            # Get the correct table:
+            table = import_component(self.media_type + '.models:DATA_MODEL')
+            # No media data, so actually add a new one
+            media_data = table(**kwargs)
+            # Get the relationship set up.
+            media_data.get_media_entry = self
         else:
+            # Update old media data
             for field, value in kwargs.iteritems():
                 setattr(media_data, field, value)
 
     @memoized_property
-    def media_data_table(self):
-        # TODO: memoize this
-        models_module = self.media_type + '.models'
-        __import__(models_module)
-        return sys.modules[models_module].DATA_MODEL
+    def media_data_ref(self):
+        return import_component(self.media_type + '.models:BACKREF_NAME')
 
     def __repr__(self):
         safe_title = self.title.encode('ascii', 'replace')
@@ -227,9 +240,7 @@ class MediaEntry(Base, MediaEntryMixin):
         :param del_orphan_tags: True/false if we delete unused Tags too
         :param commit: True/False if this should end the db transaction"""
         # User's CollectionItems are automatically deleted via "cascade".
-        # Delete all the associated comments
-        for comment in self.get_comments():
-            comment.delete(commit=False)
+        # Comments on this Media are deleted by cascade, hopefully.
 
         # Delete all related files/attachments
         try:
@@ -373,24 +384,56 @@ class MediaComment(Base, MediaCommentMixin):
     created = Column(DateTime, nullable=False, default=datetime.datetime.now)
     content = Column(UnicodeText, nullable=False)
 
-    get_author = relationship(User)
+    # Cascade: Comments are owned by their creator. So do the full thing.
+    # lazy=dynamic: People might post a *lot* of comments,
+    #     so make the "posted_comments" a query-like thing.
+    get_author = relationship(User,
+                              backref=backref("posted_comments",
+                                              lazy="dynamic",
+                                              cascade="all, delete-orphan"))
+    get_entry = relationship(MediaEntry,
+                             backref=backref("comments",
+                                             lazy="dynamic",
+                                             cascade="all, delete-orphan"))
+
+    # Cascade: Comments are somewhat owned by their MediaEntry.
+    #     So do the full thing.
+    # lazy=dynamic: MediaEntries might have many comments,
+    #     so make the "all_comments" a query-like thing.
+    get_media_entry = relationship(MediaEntry,
+                                   backref=backref("all_comments",
+                                                   lazy="dynamic",
+                                                   cascade="all, delete-orphan"))
 
 
 class Collection(Base, CollectionMixin):
+    """An 'album' or 'set' of media by a user.
+
+    On deletion, contained CollectionItems get automatically reaped via
+    SQL cascade"""
     __tablename__ = "core__collections"
 
     id = Column(Integer, primary_key=True)
     title = Column(Unicode, nullable=False)
     slug = Column(Unicode)
     created = Column(DateTime, nullable=False, default=datetime.datetime.now,
-        index=True)
+                     index=True)
     description = Column(UnicodeText)
     creator = Column(Integer, ForeignKey(User.id), nullable=False)
+    # TODO: No of items in Collection. Badly named, can we migrate to num_items?
     items = Column(Integer, default=0)
 
-    get_creator = relationship(User)
+    # Cascade: Collections are owned by their creator. So do the full thing.
+    get_creator = relationship(User,
+                               backref=backref("collections",
+                                               cascade="all, delete-orphan"))
+
+    __table_args__ = (
+        UniqueConstraint('creator', 'slug'),
+        {})
 
     def get_collection_items(self, ascending=False):
+        #TODO, is this still needed with self.collection_items being available?
         order_col = CollectionItem.position
         if not ascending:
             order_col = desc(order_col)
@@ -408,7 +451,12 @@ class CollectionItem(Base, CollectionItemMixin):
     note = Column(UnicodeText, nullable=True)
     added = Column(DateTime, nullable=False, default=datetime.datetime.now)
     position = Column(Integer)
-    in_collection = relationship("Collection")
+
+    # Cascade: CollectionItems are owned by their Collection. So do the full thing.
+    in_collection = relationship(Collection,
+                                 backref=backref(
+                                     "collection_items",
+                                     cascade="all, delete-orphan"))
 
     get_media_entry = relationship(MediaEntry)
 
@@ -439,9 +487,103 @@ class ProcessingMetaData(Base):
         return DictReadAttrProxy(self)
 
 
+class CommentSubscription(Base):
+    __tablename__ = 'core__comment_subscriptions'
+    id = Column(Integer, primary_key=True)
+
+    created = Column(DateTime, nullable=False, default=datetime.datetime.now)
+
+    media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False)
+    media_entry = relationship(MediaEntry,
+                        backref=backref('comment_subscriptions',
+                                        cascade='all, delete-orphan'))
+
+    user_id = Column(Integer, ForeignKey(User.id), nullable=False)
+    user = relationship(User,
+                        backref=backref('comment_subscriptions',
+                                        cascade='all, delete-orphan'))
+
+    notify = Column(Boolean, nullable=False, default=True)
+    send_email = Column(Boolean, nullable=False, default=True)
+
+    def __repr__(self):
+        return ('<{classname} #{id}: {user} {media} notify: '
+                '{notify} email: {email}>').format(
+            id=self.id,
+            classname=self.__class__.__name__,
+            user=self.user,
+            media=self.media_entry,
+            notify=self.notify,
+            email=self.send_email)
+
+
+class Notification(Base):
+    __tablename__ = 'core__notifications'
+    id = Column(Integer, primary_key=True)
+    type = Column(Unicode)
+
+    created = Column(DateTime, nullable=False, default=datetime.datetime.now)
+
+    user_id = Column(Integer, ForeignKey('core__users.id'), nullable=False,
+                     index=True)
+    seen = Column(Boolean, default=lambda: False, index=True)
+    user = relationship(
+        User,
+        backref=backref('notifications', cascade='all, delete-orphan'))
+
+    __mapper_args__ = {
+        'polymorphic_identity': 'notification',
+        'polymorphic_on': type
+    }
+
+    def __repr__(self):
+        return '<{klass} #{id}: {user}: {subject} ({seen})>'.format(
+            id=self.id,
+            klass=self.__class__.__name__,
+            user=self.user,
+            subject=getattr(self, 'subject', None),
+            seen='unseen' if not self.seen else 'seen')
+
+
+class CommentNotification(Notification):
+    __tablename__ = 'core__comment_notifications'
+    id = Column(Integer, ForeignKey(Notification.id), primary_key=True)
+
+    subject_id = Column(Integer, ForeignKey(MediaComment.id))
+    subject = relationship(
+        MediaComment,
+        backref=backref('comment_notifications', cascade='all, delete-orphan'))
+
+    __mapper_args__ = {
+        'polymorphic_identity': 'comment_notification'
+    }
+
+
+class ProcessingNotification(Notification):
+    __tablename__ = 'core__processing_notifications'
+
+    id = Column(Integer, ForeignKey(Notification.id), primary_key=True)
+
+    subject_id = Column(Integer, ForeignKey(MediaEntry.id))
+    subject = relationship(
+        MediaEntry,
+        backref=backref('processing_notifications',
+                        cascade='all, delete-orphan'))
+
+    __mapper_args__ = {
+        'polymorphic_identity': 'processing_notification'
+    }
+
+
+with_polymorphic(
+    Notification,
+    [ProcessingNotification, CommentNotification])
+
 MODELS = [
-    User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem, MediaFile, FileKeynames,
-    MediaAttachmentFile, ProcessingMetaData]
+    User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem,
+    MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData,
+    Notification, CommentNotification, ProcessingNotification,
+    CommentSubscription]
 
 
 ######################################################