At this point, I am very close to done with this code! I made one big change at
[mediagoblin:mediagoblin.git] / mediagoblin / db / migrations.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 import datetime
18 import uuid
19
20 from sqlalchemy import (MetaData, Table, Column, Boolean, SmallInteger,
21                         Integer, Unicode, UnicodeText, DateTime,
22                         ForeignKey, Date)
23 from sqlalchemy.exc import ProgrammingError
24 from sqlalchemy.ext.declarative import declarative_base
25 from sqlalchemy.sql import and_
26 from migrate.changeset.constraint import UniqueConstraint
27
28
29 from mediagoblin.db.extratypes import JSONEncoded
30 from mediagoblin.db.migration_tools import RegisterMigration, inspect_table
31 from mediagoblin.db.models import (MediaEntry, Collection, User,
32                                    MediaComment, Privilege, ReportBase,
33                                    FOUNDATIONS)
34
35 MIGRATIONS = {}
36
37
38 @RegisterMigration(1, MIGRATIONS)
39 def ogg_to_webm_audio(db_conn):
40     metadata = MetaData(bind=db_conn.bind)
41
42     file_keynames = Table('core__file_keynames', metadata, autoload=True,
43                           autoload_with=db_conn.bind)
44
45     db_conn.execute(
46         file_keynames.update().where(file_keynames.c.name == 'ogg').
47             values(name='webm_audio')
48     )
49     db_conn.commit()
50
51
52 @RegisterMigration(2, MIGRATIONS)
53 def add_wants_notification_column(db_conn):
54     metadata = MetaData(bind=db_conn.bind)
55
56     users = Table('core__users', metadata, autoload=True,
57             autoload_with=db_conn.bind)
58
59     col = Column('wants_comment_notification', Boolean,
60             default=True, nullable=True)
61     col.create(users, populate_defaults=True)
62     db_conn.commit()
63
64
65 @RegisterMigration(3, MIGRATIONS)
66 def add_transcoding_progress(db_conn):
67     metadata = MetaData(bind=db_conn.bind)
68
69     media_entry = inspect_table(metadata, 'core__media_entries')
70
71     col = Column('transcoding_progress', SmallInteger)
72     col.create(media_entry)
73     db_conn.commit()
74
75
76 class Collection_v0(declarative_base()):
77     __tablename__ = "core__collections"
78
79     id = Column(Integer, primary_key=True)
80     title = Column(Unicode, nullable=False)
81     slug = Column(Unicode)
82     created = Column(DateTime, nullable=False, default=datetime.datetime.now,
83         index=True)
84     description = Column(UnicodeText)
85     creator = Column(Integer, ForeignKey(User.id), nullable=False)
86     items = Column(Integer, default=0)
87
88 class CollectionItem_v0(declarative_base()):
89     __tablename__ = "core__collection_items"
90
91     id = Column(Integer, primary_key=True)
92     media_entry = Column(
93         Integer, ForeignKey(MediaEntry.id), nullable=False, index=True)
94     collection = Column(Integer, ForeignKey(Collection.id), nullable=False)
95     note = Column(UnicodeText, nullable=True)
96     added = Column(DateTime, nullable=False, default=datetime.datetime.now)
97     position = Column(Integer)
98
99     ## This should be activated, normally.
100     ## But this would change the way the next migration used to work.
101     ## So it's commented for now.
102     __table_args__ = (
103         UniqueConstraint('collection', 'media_entry'),
104         {})
105
106 collectionitem_unique_constraint_done = False
107
108 @RegisterMigration(4, MIGRATIONS)
109 def add_collection_tables(db_conn):
110     Collection_v0.__table__.create(db_conn.bind)
111     CollectionItem_v0.__table__.create(db_conn.bind)
112
113     global collectionitem_unique_constraint_done
114     collectionitem_unique_constraint_done = True
115
116     db_conn.commit()
117
118
119 @RegisterMigration(5, MIGRATIONS)
120 def add_mediaentry_collected(db_conn):
121     metadata = MetaData(bind=db_conn.bind)
122
123     media_entry = inspect_table(metadata, 'core__media_entries')
124
125     col = Column('collected', Integer, default=0)
126     col.create(media_entry)
127     db_conn.commit()
128
129
130 class ProcessingMetaData_v0(declarative_base()):
131     __tablename__ = 'core__processing_metadata'
132
133     id = Column(Integer, primary_key=True)
134     media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False,
135             index=True)
136     callback_url = Column(Unicode)
137
138 @RegisterMigration(6, MIGRATIONS)
139 def create_processing_metadata_table(db):
140     ProcessingMetaData_v0.__table__.create(db.bind)
141     db.commit()
142
143
144 # Okay, problem being:
145 #  Migration #4 forgot to add the uniqueconstraint for the
146 #  new tables. While creating the tables from scratch had
147 #  the constraint enabled.
148 #
149 # So we have four situations that should end up at the same
150 # db layout:
151 #
152 # 1. Fresh install.
153 #    Well, easy. Just uses the tables in models.py
154 # 2. Fresh install using a git version just before this migration
155 #    The tables are all there, the unique constraint is also there.
156 #    This migration should do nothing.
157 #    But as we can't detect the uniqueconstraint easily,
158 #    this migration just adds the constraint again.
159 #    And possibly fails very loud. But ignores the failure.
160 # 3. old install, not using git, just releases.
161 #    This one will get the new tables in #4 (now with constraint!)
162 #    And this migration is just skipped silently.
163 # 4. old install, always on latest git.
164 #    This one has the tables, but lacks the constraint.
165 #    So this migration adds the constraint.
166 @RegisterMigration(7, MIGRATIONS)
167 def fix_CollectionItem_v0_constraint(db_conn):
168     """Add the forgotten Constraint on CollectionItem"""
169
170     global collectionitem_unique_constraint_done
171     if collectionitem_unique_constraint_done:
172         # Reset it. Maybe the whole thing gets run again
173         # For a different db?
174         collectionitem_unique_constraint_done = False
175         return
176
177     metadata = MetaData(bind=db_conn.bind)
178
179     CollectionItem_table = inspect_table(metadata, 'core__collection_items')
180
181     constraint = UniqueConstraint('collection', 'media_entry',
182         name='core__collection_items_collection_media_entry_key',
183         table=CollectionItem_table)
184
185     try:
186         constraint.create()
187     except ProgrammingError:
188         # User probably has an install that was run since the
189         # collection tables were added, so we don't need to run this migration.
190         pass
191
192     db_conn.commit()
193
194
195 @RegisterMigration(8, MIGRATIONS)
196 def add_license_preference(db):
197     metadata = MetaData(bind=db.bind)
198
199     user_table = inspect_table(metadata, 'core__users')
200
201     col = Column('license_preference', Unicode)
202     col.create(user_table)
203     db.commit()
204
205
206 @RegisterMigration(9, MIGRATIONS)
207 def mediaentry_new_slug_era(db):
208     """
209     Update for the new era for media type slugs.
210
211     Entries without slugs now display differently in the url like:
212       /u/cwebber/m/id=251/
213
214     ... because of this, we should back-convert:
215      - entries without slugs should be converted to use the id, if possible, to
216        make old urls still work
217      - slugs with = (or also : which is now also not allowed) to have those
218        stripped out (small possibility of breakage here sadly)
219     """
220
221     def slug_and_user_combo_exists(slug, uploader):
222         return db.execute(
223             media_table.select(
224                 and_(media_table.c.uploader==uploader,
225                      media_table.c.slug==slug))).first() is not None
226
227     def append_garbage_till_unique(row, new_slug):
228         """
229         Attach junk to this row until it's unique, then save it
230         """
231         if slug_and_user_combo_exists(new_slug, row.uploader):
232             # okay, still no success;
233             # let's whack junk on there till it's unique.
234             new_slug += '-' + uuid.uuid4().hex[:4]
235             # keep going if necessary!
236             while slug_and_user_combo_exists(new_slug, row.uploader):
237                 new_slug += uuid.uuid4().hex[:4]
238
239         db.execute(
240             media_table.update(). \
241             where(media_table.c.id==row.id). \
242             values(slug=new_slug))
243
244     metadata = MetaData(bind=db.bind)
245
246     media_table = inspect_table(metadata, 'core__media_entries')
247
248     for row in db.execute(media_table.select()):
249         # no slug, try setting to an id
250         if not row.slug:
251             append_garbage_till_unique(row, unicode(row.id))
252         # has "=" or ":" in it... we're getting rid of those
253         elif u"=" in row.slug or u":" in row.slug:
254             append_garbage_till_unique(
255                 row, row.slug.replace(u"=", u"-").replace(u":", u"-"))
256
257     db.commit()
258
259
260 @RegisterMigration(10, MIGRATIONS)
261 def unique_collections_slug(db):
262     """Add unique constraint to collection slug"""
263     metadata = MetaData(bind=db.bind)
264     collection_table = inspect_table(metadata, "core__collections")
265     existing_slugs = {}
266     slugs_to_change = []
267
268     for row in db.execute(collection_table.select()):
269         # if duplicate slug, generate a unique slug
270         if row.creator in existing_slugs and row.slug in \
271            existing_slugs[row.creator]:
272             slugs_to_change.append(row.id)
273         else:
274             if not row.creator in existing_slugs:
275                 existing_slugs[row.creator] = [row.slug]
276             else:
277                 existing_slugs[row.creator].append(row.slug)
278
279     for row_id in slugs_to_change:
280         new_slug = unicode(uuid.uuid4())
281         db.execute(collection_table.update().
282                    where(collection_table.c.id == row_id).
283                    values(slug=new_slug))
284     # sqlite does not like to change the schema when a transaction(update) is
285     # not yet completed
286     db.commit()
287
288     constraint = UniqueConstraint('creator', 'slug',
289                                   name='core__collection_creator_slug_key',
290                                   table=collection_table)
291     constraint.create()
292
293     db.commit()
294
295 @RegisterMigration(11, MIGRATIONS)
296 def drop_token_related_User_columns(db):
297     """
298     Drop unneeded columns from the User table after switching to using
299     itsdangerous tokens for email and forgot password verification.
300     """
301     metadata = MetaData(bind=db.bind)
302     user_table = inspect_table(metadata, 'core__users')
303
304     verification_key = user_table.columns['verification_key']
305     fp_verification_key = user_table.columns['fp_verification_key']
306     fp_token_expire = user_table.columns['fp_token_expire']
307
308     verification_key.drop()
309     fp_verification_key.drop()
310     fp_token_expire.drop()
311
312     db.commit()
313
314
315 class CommentSubscription_v0(declarative_base()):
316     __tablename__ = 'core__comment_subscriptions'
317     id = Column(Integer, primary_key=True)
318
319     created = Column(DateTime, nullable=False, default=datetime.datetime.now)
320
321     media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False)
322
323     user_id = Column(Integer, ForeignKey(User.id), nullable=False)
324
325     notify = Column(Boolean, nullable=False, default=True)
326     send_email = Column(Boolean, nullable=False, default=True)
327
328
329 class Notification_v0(declarative_base()):
330     __tablename__ = 'core__notifications'
331     id = Column(Integer, primary_key=True)
332     type = Column(Unicode)
333
334     created = Column(DateTime, nullable=False, default=datetime.datetime.now)
335
336     user_id = Column(Integer, ForeignKey(User.id), nullable=False,
337                      index=True)
338     seen = Column(Boolean, default=lambda: False, index=True)
339
340
341 class CommentNotification_v0(Notification_v0):
342     __tablename__ = 'core__comment_notifications'
343     id = Column(Integer, ForeignKey(Notification_v0.id), primary_key=True)
344
345     subject_id = Column(Integer, ForeignKey(MediaComment.id))
346
347
348 class ProcessingNotification_v0(Notification_v0):
349     __tablename__ = 'core__processing_notifications'
350
351     id = Column(Integer, ForeignKey(Notification_v0.id), primary_key=True)
352
353     subject_id = Column(Integer, ForeignKey(MediaEntry.id))
354
355
356 @RegisterMigration(12, MIGRATIONS)
357 def add_new_notification_tables(db):
358     metadata = MetaData(bind=db.bind)
359
360     user_table = inspect_table(metadata, 'core__users')
361     mediaentry_table = inspect_table(metadata, 'core__media_entries')
362     mediacomment_table = inspect_table(metadata, 'core__media_comments')
363
364     CommentSubscription_v0.__table__.create(db.bind)
365
366     Notification_v0.__table__.create(db.bind)
367     CommentNotification_v0.__table__.create(db.bind)
368     ProcessingNotification_v0.__table__.create(db.bind)
369
370     db.commit()
371
372
373 @RegisterMigration(13, MIGRATIONS)
374 def pw_hash_nullable(db):
375     """Make pw_hash column nullable"""
376     metadata = MetaData(bind=db.bind)
377     user_table = inspect_table(metadata, "core__users")
378
379     user_table.c.pw_hash.alter(nullable=True)
380
381     # sqlite+sqlalchemy seems to drop this constraint during the
382     # migration, so we add it back here for now a bit manually.
383     if db.bind.url.drivername == 'sqlite':
384         constraint = UniqueConstraint('username', table=user_table)
385         constraint.create()
386
387     db.commit()
388
389
390 # oauth1 migrations
391 class Client_v0(declarative_base()):
392     """
393         Model representing a client - Used for API Auth
394     """
395     __tablename__ = "core__clients"
396
397     id = Column(Unicode, nullable=True, primary_key=True)
398     secret = Column(Unicode, nullable=False)
399     expirey = Column(DateTime, nullable=True)
400     application_type = Column(Unicode, nullable=False)
401     created = Column(DateTime, nullable=False, default=datetime.datetime.now)
402     updated = Column(DateTime, nullable=False, default=datetime.datetime.now)
403
404     # optional stuff
405     redirect_uri = Column(JSONEncoded, nullable=True)
406     logo_url = Column(Unicode, nullable=True)
407     application_name = Column(Unicode, nullable=True)
408     contacts = Column(JSONEncoded, nullable=True)
409
410     def __repr__(self):
411         if self.application_name:
412             return "<Client {0} - {1}>".format(self.application_name, self.id)
413         else:
414             return "<Client {0}>".format(self.id)
415
416 class RequestToken_v0(declarative_base()):
417     """
418         Model for representing the request tokens
419     """
420     __tablename__ = "core__request_tokens"
421
422     token = Column(Unicode, primary_key=True)
423     secret = Column(Unicode, nullable=False)
424     client = Column(Unicode, ForeignKey(Client_v0.id))
425     user = Column(Integer, ForeignKey(User.id), nullable=True)
426     used = Column(Boolean, default=False)
427     authenticated = Column(Boolean, default=False)
428     verifier = Column(Unicode, nullable=True)
429     callback = Column(Unicode, nullable=False, default=u"oob")
430     created = Column(DateTime, nullable=False, default=datetime.datetime.now)
431     updated = Column(DateTime, nullable=False, default=datetime.datetime.now)
432
433 class AccessToken_v0(declarative_base()):
434     """
435         Model for representing the access tokens
436     """
437     __tablename__ = "core__access_tokens"
438
439     token = Column(Unicode, nullable=False, primary_key=True)
440     secret = Column(Unicode, nullable=False)
441     user = Column(Integer, ForeignKey(User.id))
442     request_token = Column(Unicode, ForeignKey(RequestToken_v0.token))
443     created = Column(DateTime, nullable=False, default=datetime.datetime.now)
444     updated = Column(DateTime, nullable=False, default=datetime.datetime.now)
445
446
447 class NonceTimestamp_v0(declarative_base()):
448     """
449         A place the timestamp and nonce can be stored - this is for OAuth1
450     """
451     __tablename__ = "core__nonce_timestamps"
452
453     nonce = Column(Unicode, nullable=False, primary_key=True)
454     timestamp = Column(DateTime, nullable=False, primary_key=True)
455
456
457 @RegisterMigration(14, MIGRATIONS)
458 def create_oauth1_tables(db):
459     """ Creates the OAuth1 tables """
460
461     Client_v0.__table__.create(db.bind)
462     RequestToken_v0.__table__.create(db.bind)
463     AccessToken_v0.__table__.create(db.bind)
464     NonceTimestamp_v0.__table__.create(db.bind)
465
466     db.commit()
467
468
469 @RegisterMigration(15, MIGRATIONS)
470 def wants_notifications(db):
471     """Add a wants_notifications field to User model"""
472     metadata = MetaData(bind=db.bind)
473     user_table = inspect_table(metadata, "core__users")
474     col = Column('wants_notifications', Boolean, default=True)
475     col.create(user_table)
476     db.commit()
477
478 class ReportBase_v0(declarative_base()):
479     __tablename__ = 'core__reports'
480     id = Column(Integer, primary_key=True)
481     reporter_id = Column(Integer, ForeignKey(User.id), nullable=False)
482     report_content = Column(UnicodeText)
483     reported_user_id = Column(Integer, ForeignKey(User.id), nullable=False)
484     created = Column(DateTime, nullable=False, default=datetime.datetime.now)
485     discriminator = Column('type', Unicode(50))
486     resolver_id = Column(Integer, ForeignKey(User.id))
487     resolved = Column(DateTime)
488     result = Column(UnicodeText)
489     __mapper_args__ = {'polymorphic_on': discriminator}
490
491 class CommentReport_v0(ReportBase_v0):
492     __tablename__ = 'core__reports_on_comments'
493     __mapper_args__ = {'polymorphic_identity': 'comment_report'}
494
495     id = Column('id',Integer, ForeignKey('core__reports.id'),
496                                                 primary_key=True)
497     comment_id = Column(Integer, ForeignKey(MediaComment.id), nullable=True)
498
499
500
501 class MediaReport_v0(ReportBase_v0):
502     __tablename__ = 'core__reports_on_media'
503     __mapper_args__ = {'polymorphic_identity': 'media_report'}
504
505     id = Column('id',Integer, ForeignKey('core__reports.id'), primary_key=True)
506     media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=True)
507
508 class UserBan_v0(declarative_base()):
509     __tablename__ = 'core__user_bans'
510     user_id = Column('id',Integer, ForeignKey(User.id), nullable=False,
511                                          primary_key=True)
512     expiration_date = Column(Date)
513     reason = Column(UnicodeText, nullable=False)
514
515 class Privilege_v0(declarative_base()):
516     __tablename__ = 'core__privileges'
517     id = Column(Integer, nullable=False, primary_key=True, unique=True)
518     privilege_name = Column(Unicode, nullable=False, unique=True)
519
520 class PrivilegeUserAssociation_v0(declarative_base()):
521     __tablename__ = 'core__privileges_users'
522     group_id = Column(
523         'core__privilege_id',
524         Integer,
525         ForeignKey(User.id),
526         primary_key=True)
527     user_id = Column(
528         'core__user_id',
529         Integer,
530         ForeignKey(Privilege.id),
531         primary_key=True)
532
533 @RegisterMigration(16, MIGRATIONS)
534 def create_moderation_tables(db):
535     ReportBase_v0.__table__.create(db.bind)
536     CommentReport_v0.__table__.create(db.bind)
537     MediaReport_v0.__table__.create(db.bind)
538     UserBan_v0.__table__.create(db.bind)
539     Privilege_v0.__table__.create(db.bind)
540     PrivilegeUserAssociation_v0.__table__.create(db.bind)
541
542     db.commit()
543
544     for parameters in FOUNDATIONS[Privilege]:
545         p = Privilege(**parameters)
546         p.save()
547
548
549 @RegisterMigration(17, MIGRATIONS)
550 def update_user_privilege_columns(db):
551     # first, create the privileges which would be created by foundations
552     default_privileges = Privilege.query.filter(
553         Privilege.privilege_name !=u'admin').filter(
554         Privilege.privilege_name !=u'moderator').filter(
555         Privilege.privilege_name !=u'active').all()
556     admin_privilege = Privilege.query.filter(
557         Privilege.privilege_name ==u'admin').first()
558     active_privilege = Privilege.query.filter(
559         Privilege.privilege_name ==u'active').first()
560     # then, assign them to the appropriate users
561     for inactive_user in User.query.filter(
562         User.status!=u'active').filter(
563         User.is_admin==False).all():
564
565         inactive_user.all_privileges = default_privileges
566         inactive_user.save()
567     for user in User.query.filter(
568         User.status==u'active').filter(
569         User.is_admin==False).all():
570
571         user.all_privileges = default_privileges + [active_privilege]
572         user.save()
573     for admin_user in User.query.filter(
574         User.is_admin==True).all():
575
576         admin_user.all_privileges = default_privileges + [
577             admin_privilege, active_privilege]
578         admin_user.save()
579
580     # and then drop the now vestigial status column
581     metadata = MetaData(bind=db.bind)
582     user_table = inspect_table(metadata, 'core__users')
583     status = user_table.columns['status']
584     status.drop()
585     db.commit()
586