1 # GNU MediaGoblin -- federated, autonomous media hosting
2 # Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU Affero General Public License for more details.
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18 TODO: indexes on foreignkeys, where useful.
24 from sqlalchemy import Column, Integer, Unicode, UnicodeText, DateTime, \
25 Boolean, ForeignKey, UniqueConstraint, PrimaryKeyConstraint, \
27 from sqlalchemy.orm import relationship, backref
28 from sqlalchemy.orm.collections import attribute_mapped_collection
29 from sqlalchemy.sql.expression import desc
30 from sqlalchemy.ext.associationproxy import association_proxy
31 from sqlalchemy.util import memoized_property
32 from sqlalchemy.schema import Table
34 from mediagoblin.db.extratypes import PathTupleWithSlashes, JSONEncoded
35 from mediagoblin.db.base import Base, DictReadAttrProxy
36 from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, MediaCommentMixin, CollectionMixin, CollectionItemMixin
37 from mediagoblin.tools.files import delete_media_files
38 from mediagoblin.tools.common import import_component
40 # It's actually kind of annoying how sqlalchemy-migrate does this, if
41 # I understand it right, but whatever. Anyway, don't remove this :P
43 # We could do migration calls more manually instead of relying on
44 # this import-based meddling...
45 from migrate import changeset
47 _log = logging.getLogger(__name__)
50 class User(Base, UserMixin):
52 TODO: We should consider moving some rarely used fields
53 into some sort of "shadow" table.
55 __tablename__ = "core__users"
57 id = Column(Integer, primary_key=True)
58 username = Column(Unicode, nullable=False, unique=True)
59 # Note: no db uniqueness constraint on email because it's not
60 # reliable (many email systems case insensitive despite against
61 # the RFC) and because it would be a mess to implement at this
63 email = Column(Unicode, nullable=False)
64 created = Column(DateTime, nullable=False, default=datetime.datetime.now)
65 pw_hash = Column(Unicode, nullable=False)
66 email_verified = Column(Boolean, default=False)
67 status = Column(Unicode, default=u"needs_email_verification", nullable=False)
68 # Intented to be nullable=False, but migrations would not work for it
69 # set to nullable=True implicitly.
70 wants_comment_notification = Column(Boolean, default=True)
71 license_preference = Column(Unicode)
72 verification_key = Column(Unicode)
73 is_admin = Column(Boolean, default=False, nullable=False)
75 bio = Column(UnicodeText) # ??
76 fp_verification_key = Column(Unicode)
77 fp_token_expire = Column(DateTime)
80 # plugin data would be in a separate model
83 return '<{0} #{1} {2} {3} "{4}">'.format(
84 self.__class__.__name__,
86 'verified' if self.email_verified else 'non-verified',
87 'admin' if self.is_admin else 'user',
90 def delete(self, **kwargs):
91 """Deletes a User and all related entries/comments/files/..."""
92 # Collections get deleted by relationships.
94 media_entries = MediaEntry.query.filter(MediaEntry.uploader == self.id)
95 for media in media_entries:
96 # TODO: Make sure that "MediaEntry.delete()" also deletes
97 # all related files/Comments
98 media.delete(del_orphan_tags=False, commit=False)
100 # Delete now unused tags
101 # TODO: import here due to cyclic imports!!! This cries for refactoring
102 from mediagoblin.db.util import clean_orphan_tags
103 clean_orphan_tags(commit=False)
105 # Delete user, pass through commit=False/True in kwargs
106 super(User, self).delete(**kwargs)
107 _log.info('Deleted user "{0}" account'.format(self.username))
110 class MediaEntry(Base, MediaEntryMixin):
112 TODO: Consider fetching the media_files using join
114 __tablename__ = "core__media_entries"
116 id = Column(Integer, primary_key=True)
117 uploader = Column(Integer, ForeignKey(User.id), nullable=False, index=True)
118 title = Column(Unicode, nullable=False)
119 slug = Column(Unicode)
120 created = Column(DateTime, nullable=False, default=datetime.datetime.now,
122 description = Column(UnicodeText) # ??
123 media_type = Column(Unicode, nullable=False)
124 state = Column(Unicode, default=u'unprocessed', nullable=False)
125 # or use sqlalchemy.types.Enum?
126 license = Column(Unicode)
127 collected = Column(Integer, default=0)
129 fail_error = Column(Unicode)
130 fail_metadata = Column(JSONEncoded)
132 transcoding_progress = Column(SmallInteger)
134 queued_media_file = Column(PathTupleWithSlashes)
136 queued_task_id = Column(Unicode)
139 UniqueConstraint('uploader', 'slug'),
142 get_uploader = relationship(User)
144 media_files_helper = relationship("MediaFile",
145 collection_class=attribute_mapped_collection("name"),
146 cascade="all, delete-orphan"
148 media_files = association_proxy('media_files_helper', 'file_path',
149 creator=lambda k, v: MediaFile(name=k, file_path=v)
152 attachment_files_helper = relationship("MediaAttachmentFile",
153 cascade="all, delete-orphan",
154 order_by="MediaAttachmentFile.created"
156 attachment_files = association_proxy("attachment_files_helper", "dict_view",
157 creator=lambda v: MediaAttachmentFile(
158 name=v["name"], filepath=v["filepath"])
161 tags_helper = relationship("MediaTag",
162 cascade="all, delete-orphan" # should be automatically deleted
164 tags = association_proxy("tags_helper", "dict_view",
165 creator=lambda v: MediaTag(name=v["name"], slug=v["slug"])
168 collections_helper = relationship("CollectionItem",
169 cascade="all, delete-orphan"
171 collections = association_proxy("collections_helper", "in_collection")
176 def get_comments(self, ascending=False):
177 order_col = MediaComment.created
179 order_col = desc(order_col)
180 return self.all_comments.order_by(order_col)
182 def url_to_prev(self, urlgen):
183 """get the next 'newer' entry by this user"""
184 media = MediaEntry.query.filter(
185 (MediaEntry.uploader == self.uploader)
186 & (MediaEntry.state == u'processed')
187 & (MediaEntry.id > self.id)).order_by(MediaEntry.id).first()
189 if media is not None:
190 return media.url_for_self(urlgen)
192 def url_to_next(self, urlgen):
193 """get the next 'older' entry by this user"""
194 media = MediaEntry.query.filter(
195 (MediaEntry.uploader == self.uploader)
196 & (MediaEntry.state == u'processed')
197 & (MediaEntry.id < self.id)).order_by(desc(MediaEntry.id)).first()
199 if media is not None:
200 return media.url_for_self(urlgen)
203 def media_data(self):
204 return getattr(self, self.media_data_ref)
206 def media_data_init(self, **kwargs):
208 Initialize or update the contents of a media entry's media_data row
210 media_data = self.media_data
212 if media_data is None:
213 # Get the correct table:
214 table = import_component(self.media_type + '.models:DATA_MODEL')
215 # No media data, so actually add a new one
216 media_data = table(**kwargs)
217 # Get the relationship set up.
218 media_data.get_media_entry = self
220 # Update old media data
221 for field, value in kwargs.iteritems():
222 setattr(media_data, field, value)
225 def media_data_ref(self):
226 return import_component(self.media_type + '.models:BACKREF_NAME')
229 safe_title = self.title.encode('ascii', 'replace')
231 return '<{classname} {id}: {title}>'.format(
232 classname=self.__class__.__name__,
236 def delete(self, del_orphan_tags=True, **kwargs):
237 """Delete MediaEntry and all related files/attachments/comments
239 This will *not* automatically delete unused collections, which
242 :keyword del_orphan_tags: True/false if we delete unused Tags too
243 :keyword commit: True/False if this should end the db transaction"""
244 # User's CollectionItems are automatically deleted via "cascade".
245 # Comments on this Media are deleted by cascade, hopefully.
247 # Delete all related files/attachments
249 delete_media_files(self)
250 except OSError, error:
251 # Returns list of files we failed to delete
252 _log.error('No such files from the user "{1}" to delete: '
253 '{0}'.format(str(error), self.get_uploader))
254 _log.info('Deleted Media entry id "{0}"'.format(self.id))
255 # Related MediaTag's are automatically cleaned, but we might
256 # want to clean out unused Tag's too.
258 # TODO: Import here due to cyclic imports!!!
259 # This cries for refactoring
260 from mediagoblin.db.util import clean_orphan_tags
261 clean_orphan_tags(commit=False)
262 # pass through commit=False/True in kwargs
263 super(MediaEntry, self).delete(**kwargs)
266 class FileKeynames(Base):
268 keywords for various places.
269 currently the MediaFile keys
271 __tablename__ = "core__file_keynames"
272 id = Column(Integer, primary_key=True)
273 name = Column(Unicode, unique=True)
276 return "<FileKeyname %r: %r>" % (self.id, self.name)
279 def find_or_new(cls, name):
280 t = cls.query.filter_by(name=name).first()
283 return cls(name=name)
286 class MediaFile(Base):
288 TODO: Highly consider moving "name" into a new table.
289 TODO: Consider preloading said table in software
291 __tablename__ = "core__mediafiles"
293 media_entry = Column(
294 Integer, ForeignKey(MediaEntry.id),
296 name_id = Column(SmallInteger, ForeignKey(FileKeynames.id), nullable=False)
297 file_path = Column(PathTupleWithSlashes)
300 PrimaryKeyConstraint('media_entry', 'name_id'),
304 return "<MediaFile %s: %r>" % (self.name, self.file_path)
306 name_helper = relationship(FileKeynames, lazy="joined", innerjoin=True)
307 name = association_proxy('name_helper', 'name',
308 creator=FileKeynames.find_or_new
312 class MediaAttachmentFile(Base):
313 __tablename__ = "core__attachment_files"
315 id = Column(Integer, primary_key=True)
316 media_entry = Column(
317 Integer, ForeignKey(MediaEntry.id),
319 name = Column(Unicode, nullable=False)
320 filepath = Column(PathTupleWithSlashes)
321 created = Column(DateTime, nullable=False, default=datetime.datetime.now)
325 """A dict like view on this object"""
326 return DictReadAttrProxy(self)
330 __tablename__ = "core__tags"
332 id = Column(Integer, primary_key=True)
333 slug = Column(Unicode, nullable=False, unique=True)
336 return "<Tag %r: %r>" % (self.id, self.slug)
339 def find_or_new(cls, slug):
340 t = cls.query.filter_by(slug=slug).first()
343 return cls(slug=slug)
346 class MediaTag(Base):
347 __tablename__ = "core__media_tags"
349 id = Column(Integer, primary_key=True)
350 media_entry = Column(
351 Integer, ForeignKey(MediaEntry.id),
352 nullable=False, index=True)
353 tag = Column(Integer, ForeignKey(Tag.id), nullable=False, index=True)
354 name = Column(Unicode)
355 # created = Column(DateTime, nullable=False, default=datetime.datetime.now)
358 UniqueConstraint('tag', 'media_entry'),
361 tag_helper = relationship(Tag)
362 slug = association_proxy('tag_helper', 'slug',
363 creator=Tag.find_or_new
366 def __init__(self, name=None, slug=None):
371 self.tag_helper = Tag.find_or_new(slug)
375 """A dict like view on this object"""
376 return DictReadAttrProxy(self)
379 class MediaComment(Base, MediaCommentMixin):
380 __tablename__ = "core__media_comments"
382 id = Column(Integer, primary_key=True)
383 media_entry = Column(
384 Integer, ForeignKey(MediaEntry.id), nullable=False, index=True)
385 author = Column(Integer, ForeignKey(User.id), nullable=False)
386 created = Column(DateTime, nullable=False, default=datetime.datetime.now)
387 content = Column(UnicodeText, nullable=False)
389 # Cascade: Comments are owned by their creator. So do the full thing.
390 # lazy=dynamic: People might post a *lot* of comments,
391 # so make the "posted_comments" a query-like thing.
392 get_author = relationship(User,
393 backref=backref("posted_comments",
395 cascade="all, delete-orphan"))
397 # Cascade: Comments are somewhat owned by their MediaEntry.
398 # So do the full thing.
399 # lazy=dynamic: MediaEntries might have many comments,
400 # so make the "all_comments" a query-like thing.
401 get_media_entry = relationship(MediaEntry,
402 backref=backref("all_comments",
404 cascade="all, delete-orphan"))
407 class Collection(Base, CollectionMixin):
408 """An 'album' or 'set' of media by a user.
410 On deletion, contained CollectionItems get automatically reaped via
412 __tablename__ = "core__collections"
414 id = Column(Integer, primary_key=True)
415 title = Column(Unicode, nullable=False)
416 slug = Column(Unicode)
417 created = Column(DateTime, nullable=False, default=datetime.datetime.now,
419 description = Column(UnicodeText)
420 creator = Column(Integer, ForeignKey(User.id), nullable=False)
421 # TODO: No of items in Collection. Badly named, can we migrate to num_items?
422 items = Column(Integer, default=0)
424 # Cascade: Collections are owned by their creator. So do the full thing.
425 get_creator = relationship(User,
426 backref=backref("collections",
427 cascade="all, delete-orphan"))
430 UniqueConstraint('creator', 'slug'),
433 def get_collection_items(self, ascending=False):
434 #TODO, is this still needed with self.collection_items being available?
435 order_col = CollectionItem.position
437 order_col = desc(order_col)
438 return CollectionItem.query.filter_by(
439 collection=self.id).order_by(order_col)
442 class CollectionItem(Base, CollectionItemMixin):
443 __tablename__ = "core__collection_items"
445 id = Column(Integer, primary_key=True)
446 media_entry = Column(
447 Integer, ForeignKey(MediaEntry.id), nullable=False, index=True)
448 collection = Column(Integer, ForeignKey(Collection.id), nullable=False)
449 note = Column(UnicodeText, nullable=True)
450 added = Column(DateTime, nullable=False, default=datetime.datetime.now)
451 position = Column(Integer)
453 # Cascade: CollectionItems are owned by their Collection. So do the full thing.
454 in_collection = relationship(Collection,
457 cascade="all, delete-orphan"))
459 get_media_entry = relationship(MediaEntry)
462 UniqueConstraint('collection', 'media_entry'),
467 """A dict like view on this object"""
468 return DictReadAttrProxy(self)
471 class ProcessingMetaData(Base):
472 __tablename__ = 'core__processing_metadata'
474 id = Column(Integer, primary_key=True)
475 media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False,
477 media_entry = relationship(MediaEntry,
478 backref=backref('processing_metadata',
479 cascade='all, delete-orphan'))
480 callback_url = Column(Unicode)
484 """A dict like view on this object"""
485 return DictReadAttrProxy(self)
488 class ReportBase(Base):
490 This is the basic report table which the other reports are based off of.
492 :keyword report_content
493 :keyword reported_user_id
497 :keyword discriminator
500 __tablename__ = 'core__reports'
501 id = Column(Integer, primary_key=True)
502 reporter_id = Column(Integer, ForeignKey(User.id), nullable=False)
503 reporter = relationship(
505 backref=backref("reports_filed_by",
507 cascade="all, delete-orphan"),
508 primaryjoin="User.id==ReportBase.reporter_id")
509 report_content = Column(UnicodeText)
510 reported_user_id = Column(Integer, ForeignKey(User.id), nullable=False)
511 reported_user = relationship(
513 backref=backref("reports_filed_on",
515 cascade="all, delete-orphan"),
516 primaryjoin="User.id==ReportBase.reported_user_id")
517 created = Column(DateTime, nullable=False, default=datetime.datetime.now())
518 resolved = Column(DateTime)
519 result = Column(UnicodeText)
520 discriminator = Column('type', Unicode(50))
521 __mapper_args__ = {'polymorphic_on': discriminator}
524 class CommentReport(ReportBase):
526 A class to keep track of reports that have been filed on comments
528 __tablename__ = 'core__reports_on_comments'
529 __mapper_args__ = {'polymorphic_identity': 'comment_report'}
531 id = Column('id',Integer, ForeignKey('core__reports.id'),
533 comment_id = Column(Integer, ForeignKey(MediaComment.id), nullable=False)
534 comment = relationship(
535 MediaComment, backref=backref("reports_filed_on",
537 cascade="all, delete-orphan"))
539 class MediaReport(ReportBase):
541 A class to keep track of reports that have been filed on media entries
543 __tablename__ = 'core__reports_on_media'
544 __mapper_args__ = {'polymorphic_identity': 'media_report'}
546 id = Column('id',Integer, ForeignKey('core__reports.id'),
548 media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False)
549 media_entry = relationship(
551 backref=backref("reports_filed_on",
553 cascade="all, delete-orphan"))
557 Holds the information on a specific user's ban-state. As long as one of
558 these is attached to a user, they are banned from accessing mediagoblin.
559 When they try to log in, they are greeted with a page that tells them
560 the reason why they are banned and when (if ever) the ban will be
563 :keyword user_id Holds the id of the user this object is
564 attached to. This is a one-to-one
566 :keyword expiration_date Holds the date that the ban will be lifted.
567 If this is null, the ban is permanent
568 unless a moderator manually lifts it.
569 :keyword reason Holds the reason why the user was banned.
571 __tablename__ = 'core__user_bans'
573 user_id = Column(Integer, ForeignKey(User.id), nullable=False,
575 expiration_date = Column(DateTime)
576 reason = Column(UnicodeText, nullable=False)
579 class Privilege(Base):
581 The Privilege table holds all of the different privileges a user can hold.
582 If a user 'has' a privilege, the User object is in a relationship with the
585 :keyword privilege_name Holds a unicode object that is the recognizable
586 name of this privilege. This is the column
587 used for identifying whether or not a user
588 has a necessary privilege or not.
591 __tablename__ = 'core__privileges'
593 id = Column(Integer, nullable=False, primary_key=True)
594 privilege_name = Column(Unicode, nullable=False, unique=True)
595 all_users = relationship(
597 backref='all_privileges',
598 secondary="core__privileges_users")
600 def __init__(self, privilege_name):
602 Currently consructors are required for tables that are initialized thru
603 the FOUNDATIONS system. This is because they need to be able to be con-
604 -structed by a list object holding their arg*s
606 self.privilege_name = privilege_name
609 return "<Privilege %s>" % (self.privilege_name)
611 def is_admin_or_moderator(self):
613 This method is necessary to check if a user is able to take moderation
617 return (self.privilege_name==u'admin' or
618 self.privilege_name==u'moderator')
620 class PrivilegeUserAssociation(Base):
622 This table holds the many-to-many relationship between User and Privilege
625 __tablename__ = 'core__privileges_users'
627 privilege_id = Column(
628 'core__privilege_id',
635 ForeignKey(Privilege.id),
639 privilege_foundations = [[u'admin'], [u'moderator'], [u'uploader'],[u'reporter'], [u'commenter'] ,[u'active']]
642 User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem,
643 MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData, ReportBase,
644 CommentReport, MediaReport, UserBan, Privilege, PrivilegeUserAssociation]
646 # Foundations are the default rows that are created immediately after the tables are initialized. Each entry to
647 # this dictionary should be in the format of
648 # ModelObject:List of Rows
649 # (Each Row must be a list of parameters that can create and instance of the ModelObject)
651 FOUNDATIONS = {Privilege:privilege_foundations}
653 ######################################################
654 # Special, migrations-tracking table
656 # Not listed in MODELS because this is special and not
657 # really migrated, but used for migrations (for now)
658 ######################################################
660 class MigrationData(Base):
661 __tablename__ = "core__migrations"
663 name = Column(Unicode, primary_key=True)
664 version = Column(Integer, nullable=False, default=0)
666 ######################################################
669 def show_table_init(engine_uri):
670 if engine_uri is None:
671 engine_uri = 'sqlite:///:memory:'
672 from sqlalchemy import create_engine
673 engine = create_engine(engine_uri, echo=True)
675 Base.metadata.create_all(engine)
678 if __name__ == '__main__':