Fixes where User id in API would return url rather than host
[mediagoblin.git] / mediagoblin / db / models.py
index aa0c54d3310672b42198abb27db5048f033c89e5..281c09d96caaea9530633c8275a23553c7f70dc6 100644 (file)
@@ -20,18 +20,19 @@ TODO: indexes on foreignkeys, where useful.
 
 import logging
 import datetime
+import base64
 
 from sqlalchemy import Column, Integer, Unicode, UnicodeText, DateTime, \
         Boolean, ForeignKey, UniqueConstraint, PrimaryKeyConstraint, \
-        SmallInteger
+        SmallInteger, Date
 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.extratypes import (PathTupleWithSlashes, JSONEncoded,
+                                       MutationDict)
 from mediagoblin.db.base import Base, DictReadAttrProxy
 from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, \
         MediaCommentMixin, CollectionMixin, CollectionItemMixin
@@ -48,6 +49,7 @@ from migrate import changeset
 _log = logging.getLogger(__name__)
 
 
+
 class User(Base, UserMixin):
     """
     TODO: We should consider moving some rarely used fields
@@ -56,23 +58,23 @@ class User(Base, UserMixin):
     __tablename__ = "core__users"
 
     id = Column(Integer, primary_key=True)
-    username = Column(Unicode, nullable=False, unique=True)
+    username = Column(Unicode, nullable=False, unique=True, index=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)
     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)
+    wants_notifications = Column(Boolean, default=True)
     license_preference = Column(Unicode)
-    is_admin = Column(Boolean, default=False, nullable=False)
     url = Column(Unicode)
     bio = Column(UnicodeText)  # ??
+    uploaded = Column(Integer, default=0)
+    upload_limit = Column(Integer)
 
     ## TODO
     # plugin data would be in a separate model
@@ -81,8 +83,8 @@ class User(Base, UserMixin):
         return '<{0} #{1} {2} {3} "{4}">'.format(
                 self.__class__.__name__,
                 self.id,
-                'verified' if self.email_verified else 'non-verified',
-                'admin' if self.is_admin else 'user',
+                'verified' if self.has_privilege(u'active') else 'non-verified',
+                'admin' if self.has_privilege(u'admin') else 'user',
                 self.username)
 
     def delete(self, **kwargs):
@@ -104,6 +106,70 @@ class User(Base, UserMixin):
         super(User, self).delete(**kwargs)
         _log.info('Deleted user "{0}" account'.format(self.username))
 
+    def has_privilege(self,*priv_names):
+        """
+        This method checks to make sure a user has all the correct privileges
+        to access a piece of content.
+
+        :param  priv_names      A variable number of unicode objects which rep-
+                                -resent the different privileges which may give
+                                the user access to this content. If you pass
+                                multiple arguments, the user will be granted
+                                access if they have ANY of the privileges
+                                passed.
+        """
+        if len(priv_names) == 1:
+            priv = Privilege.query.filter(
+                Privilege.privilege_name==priv_names[0]).one()
+            return (priv in self.all_privileges)
+        elif len(priv_names) > 1:
+            return self.has_privilege(priv_names[0]) or \
+                self.has_privilege(*priv_names[1:])
+        return False
+
+    def is_banned(self):
+        """
+        Checks if this user is banned.
+
+            :returns                True if self is banned
+            :returns                False if self is not
+        """
+        return UserBan.query.get(self.id) is not None
+
+
+    def serialize(self, request):
+        user = {
+            "id": "acct:{0}@{1}".format(self.username, request.host),
+            "preferredUsername": self.username,
+            "displayName": "{0}@{1}".format(self.username, request.url),
+            "objectType": "person",
+            "url": self.url,
+            "summary": self.bio,
+            "links": {
+                "self": {
+                    "href": request.urlgen(
+                            "mediagoblin.federation.profile",
+                             username=self.username,
+                             qualified=True
+                             ),
+                },
+                "activity-inbox": {
+                    "href": request.urlgen(
+                            "mediagoblin.federation.inbox",
+                            username=self.username,
+                            qualified=True
+                            )
+                },
+                "activity-outbox": {
+                    "href": request.urlgen(
+                            "mediagoblin.federation.feed",
+                            username=self.username,
+                            qualified=True
+                            )
+                },
+            },
+        }
+        return user
 
 class Client(Base):
     """
@@ -171,6 +237,10 @@ class NonceTimestamp(Base):
     timestamp = Column(DateTime, nullable=False, primary_key=True)
 
 
+def create_uuid():
+    """ Creates a new uuid which is suitable for use in a URL """
+    return base64.urlsafe_b64encode(uuid.uuid4().bytes).strip("=")
+
 class MediaEntry(Base, MediaEntryMixin):
     """
     TODO: Consider fetching the media_files using join
@@ -188,7 +258,7 @@ class MediaEntry(Base, MediaEntryMixin):
     state = Column(Unicode, default=u'unprocessed', nullable=False)
         # or use sqlalchemy.types.Enum?
     license = Column(Unicode)
-    collected = Column(Integer, default=0)
+    file_size = Column(Integer, default=0)
 
     fail_error = Column(Unicode)
     fail_metadata = Column(JSONEncoded)
@@ -233,6 +303,8 @@ class MediaEntry(Base, MediaEntryMixin):
         cascade="all, delete-orphan"
         )
     collections = association_proxy("collections_helper", "in_collection")
+    media_metadata = Column(MutationDict.as_mutable(JSONEncoded),
+        default=MutationDict())
 
     ## TODO
     # fail_error
@@ -269,7 +341,7 @@ class MediaEntry(Base, MediaEntryMixin):
         return the value of the key.
         """
         media_file = MediaFile.query.filter_by(media_entry=self.id,
-                                               name=file_key).first()
+                                               name=unicode(file_key)).first()
 
         if media_file:
             if metadata_key:
@@ -282,7 +354,7 @@ class MediaEntry(Base, MediaEntryMixin):
         Update the file_metadata of a MediaFile.
         """
         media_file = MediaFile.query.filter_by(media_entry=self.id,
-                                               name=file_key).first()
+                                               name=unicode(file_key)).first()
 
         file_metadata = media_file.file_metadata or {}
 
@@ -290,6 +362,7 @@ class MediaEntry(Base, MediaEntryMixin):
             file_metadata[key] = value
 
         media_file.file_metadata = file_metadata
+        media_file.save()
 
     @property
     def media_data(self):
@@ -354,6 +427,47 @@ 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
+        url = request.urlgen(
+            "mediagoblin.user_pages.media_home",
+            user=author.username,
+            media=self.slug,
+            qualified=True
+            )
+
+        id = request.urlgen(
+            "mediagoblin.federation.object",
+            objectType=self.objectType,
+            uuid=self.uuid,
+            qualified=True
+            )
+
+        context = {
+            "id": id, 
+            "author": author.serialize(request),
+            "displayName": self.title,
+            "objectType": self.objectType,
+            "url": url,
+        }
+
+        if show_comments:
+            comments = [comment.serialize(request) for comment in self.get_comments()]
+            total = len(comments)
+            if total > 0:
+                # we only want to include replies if there are any.
+                context["replies"] = {
+                    "totalItems": total,
+                    "items": comments
+                }
+
+        return context 
 
 class FileKeynames(Base):
     """
@@ -387,7 +501,7 @@ class MediaFile(Base):
         nullable=False)
     name_id = Column(SmallInteger, ForeignKey(FileKeynames.id), nullable=False)
     file_path = Column(PathTupleWithSlashes)
-    file_metadata = Column(JSONEncoded)
+    file_metadata = Column(MutationDict.as_mutable(JSONEncoded))
 
     __table_args__ = (
         PrimaryKeyConstraint('media_entry', 'name_id'),
@@ -500,6 +614,18 @@ class MediaComment(Base, MediaCommentMixin):
                                                    lazy="dynamic",
                                                    cascade="all, delete-orphan"))
 
+    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 = {
+            "objectType": "comment",
+            "content": self.content,
+            "inReplyTo": media.serialize(request, show_comments=False),
+            "author": author.serialize(request)
+        }
+
+        return context
 
 class Collection(Base, CollectionMixin):
     """An 'album' or 'set' of media by a user.
@@ -632,7 +758,7 @@ class Notification(Base):
     }
 
     def __repr__(self):
-        return '<{klass} #{id}: {user}: {subject} ({seen})>'.format(
+        return u'<{klass} #{id}: {user}: {subject} ({seen})>'.format(
             id=self.id,
             klass=self.__class__.__name__,
             user=self.user,
@@ -669,16 +795,198 @@ class ProcessingNotification(Notification):
         'polymorphic_identity': 'processing_notification'
     }
 
-
 with_polymorphic(
     Notification,
     [ProcessingNotification, CommentNotification])
 
+class ReportBase(Base):
+    """
+    This is the basic report object which the other reports are based off of.
+
+        :keyword    reporter_id         Holds the id of the user who created
+                                            the report, as an Integer column.
+        :keyword    report_content      Hold the explanation left by the repor-
+                                            -ter to indicate why they filed the
+                                            report in the first place, as a
+                                            Unicode column.
+        :keyword    reported_user_id    Holds the id of the user who created
+                                            the content which was reported, as
+                                            an Integer column.
+        :keyword    created             Holds a datetime column of when the re-
+                                            -port was filed.
+        :keyword    discriminator       This column distinguishes between the
+                                            different types of reports.
+        :keyword    resolver_id         Holds the id of the moderator/admin who
+                                            resolved the report.
+        :keyword    resolved            Holds the DateTime object which descri-
+                                            -bes when this report was resolved
+        :keyword    result              Holds the UnicodeText column of the
+                                            resolver's reasons for resolving
+                                            the report this way. Some of this
+                                            is auto-generated
+    """
+    __tablename__ = 'core__reports'
+    id = Column(Integer, primary_key=True)
+    reporter_id = Column(Integer, ForeignKey(User.id), nullable=False)
+    reporter =  relationship(
+        User,
+        backref=backref("reports_filed_by",
+            lazy="dynamic",
+            cascade="all, delete-orphan"),
+        primaryjoin="User.id==ReportBase.reporter_id")
+    report_content = Column(UnicodeText)
+    reported_user_id = Column(Integer, ForeignKey(User.id), nullable=False)
+    reported_user = relationship(
+        User,
+        backref=backref("reports_filed_on",
+            lazy="dynamic",
+            cascade="all, delete-orphan"),
+        primaryjoin="User.id==ReportBase.reported_user_id")
+    created = Column(DateTime, nullable=False, default=datetime.datetime.now())
+    discriminator = Column('type', Unicode(50))
+    resolver_id = Column(Integer, ForeignKey(User.id))
+    resolver = relationship(
+        User,
+        backref=backref("reports_resolved_by",
+            lazy="dynamic",
+            cascade="all, delete-orphan"),
+        primaryjoin="User.id==ReportBase.resolver_id")
+
+    resolved = Column(DateTime)
+    result = Column(UnicodeText)
+    __mapper_args__ = {'polymorphic_on': discriminator}
+
+    def is_comment_report(self):
+        return self.discriminator=='comment_report'
+
+    def is_media_entry_report(self):
+        return self.discriminator=='media_report'
+
+    def is_archived_report(self):
+        return self.resolved is not None
+
+    def archive(self,resolver_id, resolved, result):
+        self.resolver_id   = resolver_id
+        self.resolved   = resolved
+        self.result     = result
+
+
+class CommentReport(ReportBase):
+    """
+    Reports that have been filed on comments.
+        :keyword    comment_id          Holds the integer value of the reported
+                                            comment's ID
+    """
+    __tablename__ = 'core__reports_on_comments'
+    __mapper_args__ = {'polymorphic_identity': 'comment_report'}
+
+    id = Column('id',Integer, ForeignKey('core__reports.id'),
+                                                primary_key=True)
+    comment_id = Column(Integer, ForeignKey(MediaComment.id), nullable=True)
+    comment = relationship(
+        MediaComment, backref=backref("reports_filed_on",
+            lazy="dynamic"))
+
+
+class MediaReport(ReportBase):
+    """
+    Reports that have been filed on media entries
+        :keyword    media_entry_id      Holds the integer value of the reported
+                                            media entry's ID
+    """
+    __tablename__ = 'core__reports_on_media'
+    __mapper_args__ = {'polymorphic_identity': 'media_report'}
+
+    id = Column('id',Integer, ForeignKey('core__reports.id'),
+                                                primary_key=True)
+    media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=True)
+    media_entry = relationship(
+        MediaEntry,
+        backref=backref("reports_filed_on",
+            lazy="dynamic"))
+
+class UserBan(Base):
+    """
+    Holds the information on a specific user's ban-state. As long as one of
+        these is attached to a user, they are banned from accessing mediagoblin.
+        When they try to log in, they are greeted with a page that tells them
+        the reason why they are banned and when (if ever) the ban will be
+        lifted
+
+        :keyword user_id          Holds the id of the user this object is
+                                    attached to. This is a one-to-one
+                                    relationship.
+        :keyword expiration_date  Holds the date that the ban will be lifted.
+                                    If this is null, the ban is permanent
+                                    unless a moderator manually lifts it.
+        :keyword reason           Holds the reason why the user was banned.
+    """
+    __tablename__ = 'core__user_bans'
+
+    user_id = Column(Integer, ForeignKey(User.id), nullable=False,
+                                                        primary_key=True)
+    expiration_date = Column(Date)
+    reason = Column(UnicodeText, nullable=False)
+
+
+class Privilege(Base):
+    """
+    The Privilege table holds all of the different privileges a user can hold.
+    If a user 'has' a privilege, the User object is in a relationship with the
+    privilege object.
+
+        :keyword privilege_name   Holds a unicode object that is the recognizable
+                                    name of this privilege. This is the column
+                                    used for identifying whether or not a user
+                                    has a necessary privilege or not.
+
+    """
+    __tablename__ = 'core__privileges'
+
+    id = Column(Integer, nullable=False, primary_key=True)
+    privilege_name = Column(Unicode, nullable=False, unique=True)
+    all_users = relationship(
+        User,
+        backref='all_privileges',
+        secondary="core__privileges_users")
+
+    def __init__(self, privilege_name):
+        '''
+        Currently consructors are required for tables that are initialized thru
+        the FOUNDATIONS system. This is because they need to be able to be con-
+        -structed by a list object holding their arg*s
+        '''
+        self.privilege_name = privilege_name
+
+    def __repr__(self):
+        return "<Privilege %s>" % (self.privilege_name)
+
+
+class PrivilegeUserAssociation(Base):
+    '''
+    This table holds the many-to-many relationship between User and Privilege
+    '''
+
+    __tablename__ = 'core__privileges_users'
+
+    user = Column(
+        "user",
+        Integer,
+        ForeignKey(User.id),
+        primary_key=True)
+    privilege = Column(
+        "privilege",
+        Integer,
+        ForeignKey(Privilege.id),
+        primary_key=True)
+
 MODELS = [
-    User, Client, RequestToken, AccessToken, NonceTimestamp, MediaEntry, Tag,
-    MediaTag, MediaComment, Collection, CollectionItem, MediaFile, FileKeynames,
-    MediaAttachmentFile, ProcessingMetaData, Notification, CommentNotification,
-    ProcessingNotification, CommentSubscription]
+    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]
 
 """
  Foundations are the default rows that are created immediately after the tables
@@ -694,7 +1002,13 @@ MODELS = [
 
     FOUNDATIONS = {User:user_foundations}
 """
-FOUNDATIONS = {}
+privilege_foundations = [{'privilege_name':u'admin'},
+                                               {'privilege_name':u'moderator'},
+                                               {'privilege_name':u'uploader'},
+                                               {'privilege_name':u'reporter'},
+                                               {'privilege_name':u'commenter'},
+                                               {'privilege_name':u'active'}]
+FOUNDATIONS = {Privilege:privilege_foundations}
 
 ######################################################
 # Special, migrations-tracking table