X-Git-Url: https://vcs.fsf.org/?a=blobdiff_plain;f=mediagoblin%2Fdb%2Fmodels.py;h=281c09d96caaea9530633c8275a23553c7f70dc6;hb=c8bd2542d7b8face6033884fccfb898be1d12989;hp=aa0c54d3310672b42198abb27db5048f033c89e5;hpb=e002452f911c366756bf93e19238cc26bc835d09;p=mediagoblin.git diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index aa0c54d3..281c09d9 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -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 "" % (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