Whew. This is a big update. I did some significant keeping work. I moved all of
[mediagoblin:mediagoblin.git] / mediagoblin / db / models.py
1 # GNU MediaGoblin -- federated, autonomous media hosting
2 # Copyright (C) 2011, 2012 MediaGoblin contributors.  See AUTHORS.
3 #
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.
8 #
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.
13 #
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/>.
16
17 """
18 TODO: indexes on foreignkeys, where useful.
19 """
20
21 import logging
22 import datetime
23
24 from sqlalchemy import Column, Integer, Unicode, UnicodeText, DateTime, \
25         Boolean, ForeignKey, UniqueConstraint, PrimaryKeyConstraint, \
26         SmallInteger
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
33
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
39
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
42 #
43 # We could do migration calls more manually instead of relying on
44 # this import-based meddling...
45 from migrate import changeset
46
47 _log = logging.getLogger(__name__)
48
49
50 class User(Base, UserMixin):
51     """
52     TODO: We should consider moving some rarely used fields
53     into some sort of "shadow" table.
54     """
55     __tablename__ = "core__users"
56
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
62     # point.
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)
74     url = Column(Unicode)
75     bio = Column(UnicodeText)  # ??
76     fp_verification_key = Column(Unicode)
77     fp_token_expire = Column(DateTime)
78
79     ## TODO
80     # plugin data would be in a separate model
81
82     def __repr__(self):
83         return '<{0} #{1} {2} {3} "{4}">'.format(
84                 self.__class__.__name__,
85                 self.id,
86                 'verified' if self.email_verified else 'non-verified',
87                 'admin' if self.is_admin else 'user',
88                 self.username)
89
90     def delete(self, **kwargs):
91         """Deletes a User and all related entries/comments/files/..."""
92         # Collections get deleted by relationships.
93
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)
99
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)
104
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))
108
109
110 class MediaEntry(Base, MediaEntryMixin):
111     """
112     TODO: Consider fetching the media_files using join
113     """
114     __tablename__ = "core__media_entries"
115
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,
121         index=True)
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)
128
129     fail_error = Column(Unicode)
130     fail_metadata = Column(JSONEncoded)
131
132     transcoding_progress = Column(SmallInteger)
133
134     queued_media_file = Column(PathTupleWithSlashes)
135
136     queued_task_id = Column(Unicode)
137
138     __table_args__ = (
139         UniqueConstraint('uploader', 'slug'),
140         {})
141
142     get_uploader = relationship(User)
143
144     media_files_helper = relationship("MediaFile",
145         collection_class=attribute_mapped_collection("name"),
146         cascade="all, delete-orphan"
147         )
148     media_files = association_proxy('media_files_helper', 'file_path',
149         creator=lambda k, v: MediaFile(name=k, file_path=v)
150         )
151
152     attachment_files_helper = relationship("MediaAttachmentFile",
153         cascade="all, delete-orphan",
154         order_by="MediaAttachmentFile.created"
155         )
156     attachment_files = association_proxy("attachment_files_helper", "dict_view",
157         creator=lambda v: MediaAttachmentFile(
158             name=v["name"], filepath=v["filepath"])
159         )
160
161     tags_helper = relationship("MediaTag",
162         cascade="all, delete-orphan" # should be automatically deleted
163         )
164     tags = association_proxy("tags_helper", "dict_view",
165         creator=lambda v: MediaTag(name=v["name"], slug=v["slug"])
166         )
167
168     collections_helper = relationship("CollectionItem",
169         cascade="all, delete-orphan"
170         )
171     collections = association_proxy("collections_helper", "in_collection")
172
173     ## TODO
174     # fail_error
175
176     def get_comments(self, ascending=False):
177         order_col = MediaComment.created
178         if not ascending:
179             order_col = desc(order_col)
180         return self.all_comments.order_by(order_col)
181
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()
188
189         if media is not None:
190             return media.url_for_self(urlgen)
191
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()
198
199         if media is not None:
200             return media.url_for_self(urlgen)
201
202     @property
203     def media_data(self):
204         return getattr(self, self.media_data_ref)
205
206     def media_data_init(self, **kwargs):
207         """
208         Initialize or update the contents of a media entry's media_data row
209         """
210         media_data = self.media_data
211
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
219         else:
220             # Update old media data
221             for field, value in kwargs.iteritems():
222                 setattr(media_data, field, value)
223
224     @memoized_property
225     def media_data_ref(self):
226         return import_component(self.media_type + '.models:BACKREF_NAME')
227
228     def __repr__(self):
229         safe_title = self.title.encode('ascii', 'replace')
230
231         return '<{classname} {id}: {title}>'.format(
232                 classname=self.__class__.__name__,
233                 id=self.id,
234                 title=safe_title)
235
236     def delete(self, del_orphan_tags=True, **kwargs):
237         """Delete MediaEntry and all related files/attachments/comments
238
239         This will *not* automatically delete unused collections, which
240         can remain empty...
241
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.
246
247         # Delete all related files/attachments
248         try:
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.
257         if del_orphan_tags:
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)
264
265
266 class FileKeynames(Base):
267     """
268     keywords for various places.
269     currently the MediaFile keys
270     """
271     __tablename__ = "core__file_keynames"
272     id = Column(Integer, primary_key=True)
273     name = Column(Unicode, unique=True)
274
275     def __repr__(self):
276         return "<FileKeyname %r: %r>" % (self.id, self.name)
277
278     @classmethod
279     def find_or_new(cls, name):
280         t = cls.query.filter_by(name=name).first()
281         if t is not None:
282             return t
283         return cls(name=name)
284
285
286 class MediaFile(Base):
287     """
288     TODO: Highly consider moving "name" into a new table.
289     TODO: Consider preloading said table in software
290     """
291     __tablename__ = "core__mediafiles"
292
293     media_entry = Column(
294         Integer, ForeignKey(MediaEntry.id),
295         nullable=False)
296     name_id = Column(SmallInteger, ForeignKey(FileKeynames.id), nullable=False)
297     file_path = Column(PathTupleWithSlashes)
298
299     __table_args__ = (
300         PrimaryKeyConstraint('media_entry', 'name_id'),
301         {})
302
303     def __repr__(self):
304         return "<MediaFile %s: %r>" % (self.name, self.file_path)
305
306     name_helper = relationship(FileKeynames, lazy="joined", innerjoin=True)
307     name = association_proxy('name_helper', 'name',
308         creator=FileKeynames.find_or_new
309         )
310
311
312 class MediaAttachmentFile(Base):
313     __tablename__ = "core__attachment_files"
314
315     id = Column(Integer, primary_key=True)
316     media_entry = Column(
317         Integer, ForeignKey(MediaEntry.id),
318         nullable=False)
319     name = Column(Unicode, nullable=False)
320     filepath = Column(PathTupleWithSlashes)
321     created = Column(DateTime, nullable=False, default=datetime.datetime.now)
322
323     @property
324     def dict_view(self):
325         """A dict like view on this object"""
326         return DictReadAttrProxy(self)
327
328
329 class Tag(Base):
330     __tablename__ = "core__tags"
331
332     id = Column(Integer, primary_key=True)
333     slug = Column(Unicode, nullable=False, unique=True)
334
335     def __repr__(self):
336         return "<Tag %r: %r>" % (self.id, self.slug)
337
338     @classmethod
339     def find_or_new(cls, slug):
340         t = cls.query.filter_by(slug=slug).first()
341         if t is not None:
342             return t
343         return cls(slug=slug)
344
345
346 class MediaTag(Base):
347     __tablename__ = "core__media_tags"
348
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)
356
357     __table_args__ = (
358         UniqueConstraint('tag', 'media_entry'),
359         {})
360
361     tag_helper = relationship(Tag)
362     slug = association_proxy('tag_helper', 'slug',
363         creator=Tag.find_or_new
364         )
365
366     def __init__(self, name=None, slug=None):
367         Base.__init__(self)
368         if name is not None:
369             self.name = name
370         if slug is not None:
371             self.tag_helper = Tag.find_or_new(slug)
372
373     @property
374     def dict_view(self):
375         """A dict like view on this object"""
376         return DictReadAttrProxy(self)
377
378
379 class MediaComment(Base, MediaCommentMixin):
380     __tablename__ = "core__media_comments"
381
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)
388
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",
394                                               lazy="dynamic",
395                                               cascade="all, delete-orphan"))
396
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",
403                                                    lazy="dynamic",
404                                                    cascade="all, delete-orphan"))
405
406
407 class Collection(Base, CollectionMixin):
408     """An 'album' or 'set' of media by a user.
409
410     On deletion, contained CollectionItems get automatically reaped via
411     SQL cascade"""
412     __tablename__ = "core__collections"
413
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,
418                      index=True)
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)
423
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"))
428
429     __table_args__ = (
430         UniqueConstraint('creator', 'slug'),
431         {})
432
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
436         if not ascending:
437             order_col = desc(order_col)
438         return CollectionItem.query.filter_by(
439             collection=self.id).order_by(order_col)
440
441
442 class CollectionItem(Base, CollectionItemMixin):
443     __tablename__ = "core__collection_items"
444
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)
452
453     # Cascade: CollectionItems are owned by their Collection. So do the full thing.
454     in_collection = relationship(Collection,
455                                  backref=backref(
456                                      "collection_items",
457                                      cascade="all, delete-orphan"))
458
459     get_media_entry = relationship(MediaEntry)
460
461     __table_args__ = (
462         UniqueConstraint('collection', 'media_entry'),
463         {})
464
465     @property
466     def dict_view(self):
467         """A dict like view on this object"""
468         return DictReadAttrProxy(self)
469
470
471 class ProcessingMetaData(Base):
472     __tablename__ = 'core__processing_metadata'
473
474     id = Column(Integer, primary_key=True)
475     media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False,
476             index=True)
477     media_entry = relationship(MediaEntry,
478             backref=backref('processing_metadata',
479                 cascade='all, delete-orphan'))
480     callback_url = Column(Unicode)
481
482     @property
483     def dict_view(self):
484         """A dict like view on this object"""
485         return DictReadAttrProxy(self)
486
487
488 class ReportBase(Base):
489     """
490     This is the basic report table which the other reports are based off of.
491         :keyword reporter_id
492         :keyword report_content
493         :keyword reported_user_id
494         :keyword created
495         :keyword resolved
496         :keyword result
497         :keyword discriminator
498
499     """
500     __tablename__ = 'core__reports'
501     id = Column(Integer, primary_key=True)
502     reporter_id = Column(Integer, ForeignKey(User.id), nullable=False)
503     reporter =  relationship(
504         User, 
505         backref=backref("reports_filed_by",
506             lazy="dynamic",
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(
512         User, 
513         backref=backref("reports_filed_on",
514             lazy="dynamic",
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}
522
523
524 class CommentReport(ReportBase):
525     """
526     A class to keep track of reports that have been filed on comments
527     """
528     __tablename__ = 'core__reports_on_comments'
529     __mapper_args__ = {'polymorphic_identity': 'comment_report'}
530
531     id = Column('id',Integer, ForeignKey('core__reports.id'),
532                                                 primary_key=True)
533     comment_id = Column(Integer, ForeignKey(MediaComment.id), nullable=False)
534     comment = relationship(
535         MediaComment, backref=backref("reports_filed_on",
536             lazy="dynamic",
537             cascade="all, delete-orphan"))
538
539 class MediaReport(ReportBase):
540     """
541     A class to keep track of reports that have been filed on media entries
542     """
543     __tablename__ = 'core__reports_on_media'
544     __mapper_args__ = {'polymorphic_identity': 'media_report'}
545
546     id = Column('id',Integer, ForeignKey('core__reports.id'),
547                                                 primary_key=True)
548     media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False)
549     media_entry = relationship(
550         MediaEntry, 
551         backref=backref("reports_filed_on",
552             lazy="dynamic",
553             cascade="all, delete-orphan"))
554
555 class UserBan(Base):
556     """
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 
561         lifted
562
563         :keyword user_id          Holds the id of the user this object is 
564                                     attached to. This is a one-to-one 
565                                     relationship.
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.
570     """
571     __tablename__ = 'core__user_bans'
572
573     user_id = Column(Integer, ForeignKey(User.id), nullable=False, 
574                                                         primary_key=True)
575     expiration_date = Column(DateTime)
576     reason = Column(UnicodeText, nullable=False)
577
578
579 class Privilege(Base):
580     """
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
583     privilege object. 
584
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.
589                                 
590     """
591     __tablename__ = 'core__privileges'
592
593     id = Column(Integer, nullable=False, primary_key=True)
594     privilege_name = Column(Unicode, nullable=False, unique=True)
595     all_users = relationship(
596         User, 
597         backref='all_privileges', 
598         secondary="core__privileges_users")
599
600     def __init__(self, privilege_name):
601         '''
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
605         '''
606         self.privilege_name = privilege_name
607
608     def __repr__(self):
609         return "<Privilege %s>" % (self.privilege_name)
610
611     def is_admin_or_moderator(self):
612         '''
613         This method is necessary to check if a user is able to take moderation
614         actions.
615         '''
616         
617         return (self.privilege_name==u'admin' or 
618                 self.privilege_name==u'moderator')
619
620 class PrivilegeUserAssociation(Base):
621     '''
622     This table holds the many-to-many relationship between User and Privilege
623     '''
624     
625     __tablename__ = 'core__privileges_users'
626
627     privilege_id = Column(
628         'core__privilege_id', 
629         Integer, 
630         ForeignKey(User.id), 
631         primary_key=True)
632     user_id = Column(
633         'core__user_id', 
634         Integer, 
635         ForeignKey(Privilege.id), 
636         primary_key=True)
637
638
639 privilege_foundations = [[u'admin'], [u'moderator'], [u'uploader'],[u'reporter'], [u'commenter'] ,[u'active']]
640
641 MODELS = [
642     User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem, 
643     MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData, ReportBase,
644     CommentReport, MediaReport, UserBan, Privilege, PrivilegeUserAssociation]
645
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)
650 #   
651 FOUNDATIONS = {Privilege:privilege_foundations}
652
653 ######################################################
654 # Special, migrations-tracking table
655 #
656 # Not listed in MODELS because this is special and not
657 # really migrated, but used for migrations (for now)
658 ######################################################
659
660 class MigrationData(Base):
661     __tablename__ = "core__migrations"
662
663     name = Column(Unicode, primary_key=True)
664     version = Column(Integer, nullable=False, default=0)
665
666 ######################################################
667
668
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)
674
675     Base.metadata.create_all(engine)
676
677
678 if __name__ == '__main__':
679     from sys import argv
680     print repr(argv)
681     if len(argv) == 2:
682         uri = argv[1]
683     else:
684         uri = None
685     show_table_init(uri)