Add a new migration which removes/fixes the old migration
[mediagoblin.git] / mediagoblin / db / models.py
CommitLineData
fbad3a9f 1# GNU MediaGoblin -- federated, autonomous media hosting
7f4ebeed 2# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
fbad3a9f
E
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
eea6d276
E
17"""
18TODO: indexes on foreignkeys, where useful.
19"""
20
fdc34b8b 21import logging
ccca0fbf 22import datetime
e590179a 23import base64
ccca0fbf 24
942084fb
JW
25from sqlalchemy import Column, Integer, Unicode, UnicodeText, DateTime, \
26 Boolean, ForeignKey, UniqueConstraint, PrimaryKeyConstraint, \
1bb367f6 27 SmallInteger, Date
2d7b6bde 28from sqlalchemy.orm import relationship, backref, with_polymorphic
02db7e0a 29from sqlalchemy.orm.collections import attribute_mapped_collection
c47a03b9 30from sqlalchemy.sql.expression import desc
02db7e0a 31from sqlalchemy.ext.associationproxy import association_proxy
007ac2e7 32from sqlalchemy.util import memoized_property
ccca0fbf 33
7f9d3ca7
RE
34from mediagoblin.db.extratypes import (PathTupleWithSlashes, JSONEncoded,
35 MutationDict)
57f8d263 36from mediagoblin.db.base import Base, DictReadAttrProxy
2d7b6bde
JW
37from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, \
38 MediaCommentMixin, CollectionMixin, CollectionItemMixin
fdc34b8b 39from mediagoblin.tools.files import delete_media_files
57f8d263 40from mediagoblin.tools.common import import_component
ccca0fbf 41
780fdd7b
CAW
42# It's actually kind of annoying how sqlalchemy-migrate does this, if
43# I understand it right, but whatever. Anyway, don't remove this :P
c4869eff 44#
780fdd7b
CAW
45# We could do migration calls more manually instead of relying on
46# this import-based meddling...
47from migrate import changeset
48
fdc34b8b
SS
49_log = logging.getLogger(__name__)
50
f4f84297 51
7b194a79 52
f42e49c3 53class User(Base, UserMixin):
eea6d276
E
54 """
55 TODO: We should consider moving some rarely used fields
56 into some sort of "shadow" table.
57 """
2f5ce68c 58 __tablename__ = "core__users"
ccca0fbf
CAW
59
60 id = Column(Integer, primary_key=True)
bb12fb80 61 username = Column(Unicode, nullable=False, unique=True)
fbe8edc2
CAW
62 # Note: no db uniqueness constraint on email because it's not
63 # reliable (many email systems case insensitive despite against
64 # the RFC) and because it would be a mess to implement at this
65 # point.
ccca0fbf 66 email = Column(Unicode, nullable=False)
b56b6b1e 67 pw_hash = Column(Unicode)
2d7b6bde 68 created = Column(DateTime, nullable=False, default=datetime.datetime.now)
c4869eff
JW
69 # Intented to be nullable=False, but migrations would not work for it
70 # set to nullable=True implicitly.
71 wants_comment_notification = Column(Boolean, default=True)
93d805ad 72 wants_notifications = Column(Boolean, default=True)
dc4dfbde 73 license_preference = Column(Unicode)
ccca0fbf 74 url = Column(Unicode)
fbad3a9f 75 bio = Column(UnicodeText) # ??
bdd22421
RE
76 uploaded = Column(Integer, default=0)
77 upload_limit = Column(Integer)
ccca0fbf
CAW
78
79 ## TODO
80 # plugin data would be in a separate model
81
88a9662b
JW
82 def __repr__(self):
83 return '<{0} #{1} {2} {3} "{4}">'.format(
84 self.__class__.__name__,
85 self.id,
25625107 86 'verified' if self.has_privilege(u'active') else 'non-verified',
87 'admin' if self.has_privilege(u'admin') else 'user',
88a9662b
JW
88 self.username)
89
03b4fc50
SS
90 def delete(self, **kwargs):
91 """Deletes a User and all related entries/comments/files/..."""
6194344b 92 # Collections get deleted by relationships.
03b4fc50
SS
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
3809a8b8 102 from mediagoblin.db.util import clean_orphan_tags
03b4fc50
SS
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
7bfc81b2 109 def has_privilege(self, privilege, allow_admin=True):
8e91df87 110 """
111 This method checks to make sure a user has all the correct privileges
112 to access a piece of content.
113
7bfc81b2
JT
114 :param privilege A unicode object which represent the different
115 privileges which may give the user access to
116 content.
117
118 :param allow_admin If this is set to True the then if the user is
119 an admin, then this will always return True
120 even if the user hasn't been given the
121 privilege. (defaults to True)
8e91df87 122 """
7bfc81b2
JT
123 priv = Privilege.query.filter_by(privilege_name=privilege).one()
124 if priv in self.all_privileges:
125 return True
126 elif allow_admin and self.has_privilege(u'admin', allow_admin=False):
127 return True
128
8394febb 129 return False
130
8e91df87 131 def is_banned(self):
132 """
133 Checks if this user is banned.
134
135 :returns True if self is banned
136 :returns False if self is not
137 """
138 return UserBan.query.get(self.id) is not None
139
ccca0fbf 140
637b966a
JT
141 def serialize(self, request):
142 user = {
c8bd2542 143 "id": "acct:{0}@{1}".format(self.username, request.host),
637b966a 144 "preferredUsername": self.username,
37f070b0 145 "displayName": "{0}@{1}".format(self.username, request.host),
637b966a 146 "objectType": "person",
c434fc31
JT
147 "pump_io": {
148 "shared": False,
149 "followed": False,
150 },
637b966a 151 "links": {
d7b3805f
JT
152 "self": {
153 "href": request.urlgen(
a5682e89 154 "mediagoblin.federation.user.profile",
d7b3805f
JT
155 username=self.username,
156 qualified=True
157 ),
158 },
159 "activity-inbox": {
160 "href": request.urlgen(
161 "mediagoblin.federation.inbox",
162 username=self.username,
163 qualified=True
164 )
165 },
166 "activity-outbox": {
167 "href": request.urlgen(
168 "mediagoblin.federation.feed",
169 username=self.username,
170 qualified=True
171 )
172 },
637b966a
JT
173 },
174 }
c434fc31
JT
175
176 if self.bio:
177 user.update({"summary": self.bio})
178 if self.url:
179 user.update({"url": self.url})
180
d7b3805f 181 return user
637b966a 182
4990b47c 183class Client(Base):
184 """
185 Model representing a client - Used for API Auth
186 """
187 __tablename__ = "core__clients"
188
189 id = Column(Unicode, nullable=True, primary_key=True)
190 secret = Column(Unicode, nullable=False)
191 expirey = Column(DateTime, nullable=True)
192 application_type = Column(Unicode, nullable=False)
193 created = Column(DateTime, nullable=False, default=datetime.datetime.now)
194 updated = Column(DateTime, nullable=False, default=datetime.datetime.now)
195
196 # optional stuff
c33a34d4 197 redirect_uri = Column(JSONEncoded, nullable=True)
198 logo_url = Column(Unicode, nullable=True)
4990b47c 199 application_name = Column(Unicode, nullable=True)
c33a34d4 200 contacts = Column(JSONEncoded, nullable=True)
201
4990b47c 202 def __repr__(self):
c33a34d4 203 if self.application_name:
204 return "<Client {0} - {1}>".format(self.application_name, self.id)
205 else:
206 return "<Client {0}>".format(self.id)
4990b47c 207
d41c6a53 208class RequestToken(Base):
209 """
210 Model for representing the request tokens
211 """
212 __tablename__ = "core__request_tokens"
4990b47c 213
d41c6a53 214 token = Column(Unicode, primary_key=True)
215 secret = Column(Unicode, nullable=False)
216 client = Column(Unicode, ForeignKey(Client.id))
217 user = Column(Integer, ForeignKey(User.id), nullable=True)
218 used = Column(Boolean, default=False)
219 authenticated = Column(Boolean, default=False)
220 verifier = Column(Unicode, nullable=True)
405aa45a 221 callback = Column(Unicode, nullable=False, default=u"oob")
d41c6a53 222 created = Column(DateTime, nullable=False, default=datetime.datetime.now)
223 updated = Column(DateTime, nullable=False, default=datetime.datetime.now)
b5059525 224
d41c6a53 225class AccessToken(Base):
226 """
227 Model for representing the access tokens
228 """
229 __tablename__ = "core__access_tokens"
230
231 token = Column(Unicode, nullable=False, primary_key=True)
232 secret = Column(Unicode, nullable=False)
233 user = Column(Integer, ForeignKey(User.id))
234 request_token = Column(Unicode, ForeignKey(RequestToken.token))
235 created = Column(DateTime, nullable=False, default=datetime.datetime.now)
236 updated = Column(DateTime, nullable=False, default=datetime.datetime.now)
b5059525 237
4990b47c 238
cfe7054c 239class NonceTimestamp(Base):
240 """
241 A place the timestamp and nonce can be stored - this is for OAuth1
242 """
243 __tablename__ = "core__nonce_timestamps"
244
245 nonce = Column(Unicode, nullable=False, primary_key=True)
246 timestamp = Column(DateTime, nullable=False, primary_key=True)
247
f42e49c3 248class MediaEntry(Base, MediaEntryMixin):
eea6d276
E
249 """
250 TODO: Consider fetching the media_files using join
251 """
2f5ce68c 252 __tablename__ = "core__media_entries"
ccca0fbf
CAW
253
254 id = Column(Integer, primary_key=True)
ecd538bb 255 uploader = Column(Integer, ForeignKey(User.id), nullable=False, index=True)
7c2c56a5 256 title = Column(Unicode, nullable=False)
3e907d55 257 slug = Column(Unicode)
ecd538bb
E
258 created = Column(DateTime, nullable=False, default=datetime.datetime.now,
259 index=True)
ccca0fbf 260 description = Column(UnicodeText) # ??
ccca0fbf 261 media_type = Column(Unicode, nullable=False)
51fba991
E
262 state = Column(Unicode, default=u'unprocessed', nullable=False)
263 # or use sqlalchemy.types.Enum?
2788e6a1 264 license = Column(Unicode)
bdd22421 265 file_size = Column(Integer, default=0)
fbad3a9f 266
ccca0fbf 267 fail_error = Column(Unicode)
cf27accc 268 fail_metadata = Column(JSONEncoded)
9412fffe 269
64712915
JW
270 transcoding_progress = Column(SmallInteger)
271
02db7e0a 272 queued_media_file = Column(PathTupleWithSlashes)
ccca0fbf
CAW
273
274 queued_task_id = Column(Unicode)
275
276 __table_args__ = (
277 UniqueConstraint('uploader', 'slug'),
278 {})
279
88e90f41
E
280 get_uploader = relationship(User)
281
02db7e0a
E
282 media_files_helper = relationship("MediaFile",
283 collection_class=attribute_mapped_collection("name"),
284 cascade="all, delete-orphan"
285 )
286 media_files = association_proxy('media_files_helper', 'file_path',
fbad3a9f 287 creator=lambda k, v: MediaFile(name=k, file_path=v)
02db7e0a
E
288 )
289
35029581 290 attachment_files_helper = relationship("MediaAttachmentFile",
df5b142a 291 cascade="all, delete-orphan",
35029581
E
292 order_by="MediaAttachmentFile.created"
293 )
294 attachment_files = association_proxy("attachment_files_helper", "dict_view",
295 creator=lambda v: MediaAttachmentFile(
296 name=v["name"], filepath=v["filepath"])
297 )
298
de917303 299 tags_helper = relationship("MediaTag",
fdc34b8b 300 cascade="all, delete-orphan" # should be automatically deleted
de917303
E
301 )
302 tags = association_proxy("tags_helper", "dict_view",
303 creator=lambda v: MediaTag(name=v["name"], slug=v["slug"])
304 )
305
be5be115
AW
306 collections_helper = relationship("CollectionItem",
307 cascade="all, delete-orphan"
308 )
309 collections = association_proxy("collections_helper", "in_collection")
c8abeb58 310 media_metadata = Column(MutationDict.as_mutable(JSONEncoded),
311 default=MutationDict())
be5be115 312
ccca0fbf 313 ## TODO
ccca0fbf
CAW
314 # fail_error
315
02ede858
E
316 def get_comments(self, ascending=False):
317 order_col = MediaComment.created
318 if not ascending:
319 order_col = desc(order_col)
b98882e1 320 return self.all_comments.order_by(order_col)
02ede858 321
c47a03b9
E
322 def url_to_prev(self, urlgen):
323 """get the next 'newer' entry by this user"""
324 media = MediaEntry.query.filter(
325 (MediaEntry.uploader == self.uploader)
5bd0adeb 326 & (MediaEntry.state == u'processed')
c47a03b9
E
327 & (MediaEntry.id > self.id)).order_by(MediaEntry.id).first()
328
329 if media is not None:
330 return media.url_for_self(urlgen)
331
332 def url_to_next(self, urlgen):
333 """get the next 'older' entry by this user"""
334 media = MediaEntry.query.filter(
335 (MediaEntry.uploader == self.uploader)
5bd0adeb 336 & (MediaEntry.state == u'processed')
c47a03b9
E
337 & (MediaEntry.id < self.id)).order_by(desc(MediaEntry.id)).first()
338
339 if media is not None:
340 return media.url_for_self(urlgen)
341
e002452f
RE
342 def get_file_metadata(self, file_key, metadata_key=None):
343 """
344 Return the file_metadata dict of a MediaFile. If metadata_key is given,
345 return the value of the key.
346 """
347 media_file = MediaFile.query.filter_by(media_entry=self.id,
e2b56345 348 name=unicode(file_key)).first()
e002452f
RE
349
350 if media_file:
351 if metadata_key:
352 return media_file.file_metadata.get(metadata_key, None)
353
354 return media_file.file_metadata
355
356 def set_file_metadata(self, file_key, **kwargs):
357 """
358 Update the file_metadata of a MediaFile.
359 """
360 media_file = MediaFile.query.filter_by(media_entry=self.id,
e2b56345 361 name=unicode(file_key)).first()
e002452f
RE
362
363 file_metadata = media_file.file_metadata or {}
364
365 for key, value in kwargs.iteritems():
366 file_metadata[key] = value
367
368 media_file.file_metadata = file_metadata
7f9d3ca7 369 media_file.save()
e002452f 370
5fe1fd07
E
371 @property
372 def media_data(self):
485404a9 373 return getattr(self, self.media_data_ref)
5fe1fd07 374
acb21949 375 def media_data_init(self, **kwargs):
007ac2e7
CAW
376 """
377 Initialize or update the contents of a media entry's media_data row
378 """
57f8d263 379 media_data = self.media_data
007ac2e7 380
99c2f9f0 381 if media_data is None:
139c6c09
E
382 # Get the correct table:
383 table = import_component(self.media_type + '.models:DATA_MODEL')
57f8d263 384 # No media data, so actually add a new one
139c6c09 385 media_data = table(**kwargs)
57f8d263
E
386 # Get the relationship set up.
387 media_data.get_media_entry = self
007ac2e7 388 else:
57f8d263 389 # Update old media data
007ac2e7
CAW
390 for field, value in kwargs.iteritems():
391 setattr(media_data, field, value)
392
57f8d263
E
393 @memoized_property
394 def media_data_ref(self):
395 return import_component(self.media_type + '.models:BACKREF_NAME')
acb21949 396
64712915 397 def __repr__(self):
79f28e0b
JW
398 safe_title = self.title.encode('ascii', 'replace')
399
64712915
JW
400 return '<{classname} {id}: {title}>'.format(
401 classname=self.__class__.__name__,
402 id=self.id,
79f28e0b 403 title=safe_title)
64712915 404
fdc34b8b
SS
405 def delete(self, del_orphan_tags=True, **kwargs):
406 """Delete MediaEntry and all related files/attachments/comments
407
408 This will *not* automatically delete unused collections, which
409 can remain empty...
410
411 :param del_orphan_tags: True/false if we delete unused Tags too
412 :param commit: True/False if this should end the db transaction"""
413 # User's CollectionItems are automatically deleted via "cascade".
b98882e1 414 # Comments on this Media are deleted by cascade, hopefully.
fdc34b8b
SS
415
416 # Delete all related files/attachments
417 try:
418 delete_media_files(self)
419 except OSError, error:
420 # Returns list of files we failed to delete
421 _log.error('No such files from the user "{1}" to delete: '
422 '{0}'.format(str(error), self.get_uploader))
423 _log.info('Deleted Media entry id "{0}"'.format(self.id))
424 # Related MediaTag's are automatically cleaned, but we might
425 # want to clean out unused Tag's too.
426 if del_orphan_tags:
427 # TODO: Import here due to cyclic imports!!!
428 # This cries for refactoring
429 from mediagoblin.db.util import clean_orphan_tags
430 clean_orphan_tags(commit=False)
431 # pass through commit=False/True in kwargs
432 super(MediaEntry, self).delete(**kwargs)
433
bdde87a4
JT
434 @property
435 def objectType(self):
436 """ Converts media_type to pump-like type - don't use internally """
437 return self.media_type.split(".")[-1]
438
a840d2a8 439 def serialize(self, request, show_comments=True):
bdde87a4
JT
440 """ Unserialize MediaEntry to object """
441 author = self.get_uploader
442 url = request.urlgen(
443 "mediagoblin.user_pages.media_home",
444 user=author.username,
445 media=self.slug,
446 qualified=True
447 )
448
bdde87a4 449 context = {
51ab5192 450 "id": self.id,
bdde87a4 451 "author": author.serialize(request),
bdde87a4
JT
452 "objectType": self.objectType,
453 "url": url,
5b014a08
JT
454 "image": {
455 "url": request.host_url + self.thumb_url[1:],
456 },
457 "fullImage":{
458 "url": request.host_url + self.original_url[1:],
98596dd0 459 },
460 "published": self.created.isoformat(),
461 "updated": self.created.isoformat(),
c434fc31
JT
462 "pump_io": {
463 "shared": False,
464 },
3c8bd177
JT
465 "links": {
466 "self": {
467 "href": request.urlgen(
468 "mediagoblin.federation.object",
469 objectType=self.objectType,
a14d90c2 470 id=self.id,
3c8bd177
JT
471 qualified=True
472 ),
473 },
474
475 }
bdde87a4 476 }
a840d2a8 477
51ab5192
JT
478 if self.title:
479 context["displayName"] = self.title
480
481 if self.description:
482 context["content"] = self.description
483
484 if self.license:
485 context["license"] = self.license
486
a840d2a8
JT
487 if show_comments:
488 comments = [comment.serialize(request) for comment in self.get_comments()]
489 total = len(comments)
7810817c 490 context["replies"] = {
491 "totalItems": total,
492 "items": comments,
493 "url": request.urlgen(
494 "mediagoblin.federation.object.comments",
495 objectType=self.objectType,
a14d90c2 496 id=self.id,
7810817c 497 qualified=True
498 ),
499 }
a840d2a8 500
51ab5192 501 return context
bdde87a4 502
d8f55f2b
JT
503 def unserialize(self, data):
504 """ Takes API objects and unserializes on existing MediaEntry """
505 if "displayName" in data:
506 self.title = data["displayName"]
507
508 if "content" in data:
509 self.description = data["content"]
510
511 if "license" in data:
512 self.license = data["license"]
513
514 return True
515
a9dac7c8
E
516class FileKeynames(Base):
517 """
518 keywords for various places.
519 currently the MediaFile keys
520 """
521 __tablename__ = "core__file_keynames"
522 id = Column(Integer, primary_key=True)
523 name = Column(Unicode, unique=True)
524
525 def __repr__(self):
526 return "<FileKeyname %r: %r>" % (self.id, self.name)
527
528 @classmethod
529 def find_or_new(cls, name):
530 t = cls.query.filter_by(name=name).first()
531 if t is not None:
532 return t
533 return cls(name=name)
534
535
02db7e0a 536class MediaFile(Base):
eea6d276
E
537 """
538 TODO: Highly consider moving "name" into a new table.
539 TODO: Consider preloading said table in software
540 """
2f5ce68c 541 __tablename__ = "core__mediafiles"
02db7e0a
E
542
543 media_entry = Column(
544 Integer, ForeignKey(MediaEntry.id),
a9dac7c8
E
545 nullable=False)
546 name_id = Column(SmallInteger, ForeignKey(FileKeynames.id), nullable=False)
02db7e0a 547 file_path = Column(PathTupleWithSlashes)
42dbb26a 548 file_metadata = Column(MutationDict.as_mutable(JSONEncoded))
02db7e0a 549
a9dac7c8
E
550 __table_args__ = (
551 PrimaryKeyConstraint('media_entry', 'name_id'),
552 {})
553
02db7e0a
E
554 def __repr__(self):
555 return "<MediaFile %s: %r>" % (self.name, self.file_path)
556
a9dac7c8
E
557 name_helper = relationship(FileKeynames, lazy="joined", innerjoin=True)
558 name = association_proxy('name_helper', 'name',
559 creator=FileKeynames.find_or_new
560 )
561
02db7e0a 562
35029581
E
563class MediaAttachmentFile(Base):
564 __tablename__ = "core__attachment_files"
565
566 id = Column(Integer, primary_key=True)
567 media_entry = Column(
568 Integer, ForeignKey(MediaEntry.id),
569 nullable=False)
570 name = Column(Unicode, nullable=False)
571 filepath = Column(PathTupleWithSlashes)
572 created = Column(DateTime, nullable=False, default=datetime.datetime.now)
573
574 @property
575 def dict_view(self):
576 """A dict like view on this object"""
577 return DictReadAttrProxy(self)
578
579
ccca0fbf 580class Tag(Base):
2f5ce68c 581 __tablename__ = "core__tags"
ccca0fbf
CAW
582
583 id = Column(Integer, primary_key=True)
584 slug = Column(Unicode, nullable=False, unique=True)
585
de917303
E
586 def __repr__(self):
587 return "<Tag %r: %r>" % (self.id, self.slug)
588
589 @classmethod
590 def find_or_new(cls, slug):
591 t = cls.query.filter_by(slug=slug).first()
592 if t is not None:
593 return t
594 return cls(slug=slug)
595
ccca0fbf
CAW
596
597class MediaTag(Base):
2f5ce68c 598 __tablename__ = "core__media_tags"
ccca0fbf
CAW
599
600 id = Column(Integer, primary_key=True)
ccca0fbf 601 media_entry = Column(
de917303 602 Integer, ForeignKey(MediaEntry.id),
ecd538bb
E
603 nullable=False, index=True)
604 tag = Column(Integer, ForeignKey(Tag.id), nullable=False, index=True)
de917303 605 name = Column(Unicode)
ccca0fbf
CAW
606 # created = Column(DateTime, nullable=False, default=datetime.datetime.now)
607
608 __table_args__ = (
609 UniqueConstraint('tag', 'media_entry'),
610 {})
611
de917303
E
612 tag_helper = relationship(Tag)
613 slug = association_proxy('tag_helper', 'slug',
614 creator=Tag.find_or_new
615 )
616
6456cefa 617 def __init__(self, name=None, slug=None):
de917303 618 Base.__init__(self)
6456cefa
E
619 if name is not None:
620 self.name = name
621 if slug is not None:
622 self.tag_helper = Tag.find_or_new(slug)
de917303
E
623
624 @property
625 def dict_view(self):
626 """A dict like view on this object"""
627 return DictReadAttrProxy(self)
628
ccca0fbf 629
feba5c52 630class MediaComment(Base, MediaCommentMixin):
2f5ce68c 631 __tablename__ = "core__media_comments"
fbad3a9f 632
ccca0fbf
CAW
633 id = Column(Integer, primary_key=True)
634 media_entry = Column(
ecd538bb
E
635 Integer, ForeignKey(MediaEntry.id), nullable=False, index=True)
636 author = Column(Integer, ForeignKey(User.id), nullable=False)
ccca0fbf
CAW
637 created = Column(DateTime, nullable=False, default=datetime.datetime.now)
638 content = Column(UnicodeText, nullable=False)
e365f980 639
ff68ca9f 640 # Cascade: Comments are owned by their creator. So do the full thing.
b98882e1
E
641 # lazy=dynamic: People might post a *lot* of comments,
642 # so make the "posted_comments" a query-like thing.
ff68ca9f
E
643 get_author = relationship(User,
644 backref=backref("posted_comments",
645 lazy="dynamic",
646 cascade="all, delete-orphan"))
2d7b6bde
JW
647 get_entry = relationship(MediaEntry,
648 backref=backref("comments",
649 lazy="dynamic",
650 cascade="all, delete-orphan"))
88e90f41 651
b98882e1
E
652 # Cascade: Comments are somewhat owned by their MediaEntry.
653 # So do the full thing.
654 # lazy=dynamic: MediaEntries might have many comments,
655 # so make the "all_comments" a query-like thing.
656 get_media_entry = relationship(MediaEntry,
657 backref=backref("all_comments",
658 lazy="dynamic",
659 cascade="all, delete-orphan"))
660
bdde87a4
JT
661 def serialize(self, request):
662 """ Unserialize to python dictionary for API """
a840d2a8
JT
663 media = MediaEntry.query.filter_by(id=self.media_entry).first()
664 author = self.get_author
bdde87a4 665 context = {
51ab5192 666 "id": self.id,
bdde87a4
JT
667 "objectType": "comment",
668 "content": self.content,
a840d2a8
JT
669 "inReplyTo": media.serialize(request, show_comments=False),
670 "author": author.serialize(request)
bdde87a4
JT
671 }
672
673 return context
674
d8f55f2b
JT
675 def unserialize(self, data):
676 """ Takes API objects and unserializes on existing comment """
677 # Do initial checks to verify the object is correct
678 required_attributes = ["content", "inReplyTo"]
679 for attr in required_attributes:
680 if attr not in data:
681 return False
682
683 # Validate inReplyTo has ID
684 if "id" not in data["inReplyTo"]:
685 return False
5e5d4458
JT
686
687 # Validate that the ID is correct
688 try:
689 media_id = int(data["inReplyTo"]["id"])
690 except ValueError:
691 return False
692
693 media = MediaEntry.query.filter_by(id=media_id).first()
694 if media is None:
695 return False
d8f55f2b 696
5e5d4458 697 self.media_entry = media.id
d8f55f2b
JT
698 self.content = data["content"]
699 return True
700
701
702
be5be115 703class Collection(Base, CollectionMixin):
242776e3
SS
704 """An 'album' or 'set' of media by a user.
705
706 On deletion, contained CollectionItems get automatically reaped via
707 SQL cascade"""
be5be115
AW
708 __tablename__ = "core__collections"
709
710 id = Column(Integer, primary_key=True)
711 title = Column(Unicode, nullable=False)
712 slug = Column(Unicode)
713 created = Column(DateTime, nullable=False, default=datetime.datetime.now,
34d8bc98 714 index=True)
88a9662b 715 description = Column(UnicodeText)
be5be115 716 creator = Column(Integer, ForeignKey(User.id), nullable=False)
242776e3 717 # TODO: No of items in Collection. Badly named, can we migrate to num_items?
be5be115
AW
718 items = Column(Integer, default=0)
719
6194344b
E
720 # Cascade: Collections are owned by their creator. So do the full thing.
721 get_creator = relationship(User,
722 backref=backref("collections",
723 cascade="all, delete-orphan"))
88a9662b 724
34d8bc98
RE
725 __table_args__ = (
726 UniqueConstraint('creator', 'slug'),
727 {})
728
be5be115 729 def get_collection_items(self, ascending=False):
242776e3 730 #TODO, is this still needed with self.collection_items being available?
be5be115
AW
731 order_col = CollectionItem.position
732 if not ascending:
733 order_col = desc(order_col)
734 return CollectionItem.query.filter_by(
735 collection=self.id).order_by(order_col)
736
be5be115
AW
737
738class CollectionItem(Base, CollectionItemMixin):
739 __tablename__ = "core__collection_items"
740
741 id = Column(Integer, primary_key=True)
742 media_entry = Column(
743 Integer, ForeignKey(MediaEntry.id), nullable=False, index=True)
744 collection = Column(Integer, ForeignKey(Collection.id), nullable=False)
745 note = Column(UnicodeText, nullable=True)
746 added = Column(DateTime, nullable=False, default=datetime.datetime.now)
747 position = Column(Integer)
6194344b
E
748
749 # Cascade: CollectionItems are owned by their Collection. So do the full thing.
750 in_collection = relationship(Collection,
242776e3
SS
751 backref=backref(
752 "collection_items",
753 cascade="all, delete-orphan"))
be5be115
AW
754
755 get_media_entry = relationship(MediaEntry)
756
be5be115
AW
757 __table_args__ = (
758 UniqueConstraint('collection', 'media_entry'),
759 {})
760
761 @property
762 def dict_view(self):
763 """A dict like view on this object"""
764 return DictReadAttrProxy(self)
765
766
5354f954
JW
767class ProcessingMetaData(Base):
768 __tablename__ = 'core__processing_metadata'
769
770 id = Column(Integer, primary_key=True)
771 media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False,
772 index=True)
942084fb
JW
773 media_entry = relationship(MediaEntry,
774 backref=backref('processing_metadata',
775 cascade='all, delete-orphan'))
5354f954
JW
776 callback_url = Column(Unicode)
777
778 @property
779 def dict_view(self):
780 """A dict like view on this object"""
781 return DictReadAttrProxy(self)
782
783
2d7b6bde
JW
784class CommentSubscription(Base):
785 __tablename__ = 'core__comment_subscriptions'
786 id = Column(Integer, primary_key=True)
787
788 created = Column(DateTime, nullable=False, default=datetime.datetime.now)
789
790 media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False)
791 media_entry = relationship(MediaEntry,
792 backref=backref('comment_subscriptions',
793 cascade='all, delete-orphan'))
794
795 user_id = Column(Integer, ForeignKey(User.id), nullable=False)
796 user = relationship(User,
797 backref=backref('comment_subscriptions',
798 cascade='all, delete-orphan'))
799
800 notify = Column(Boolean, nullable=False, default=True)
801 send_email = Column(Boolean, nullable=False, default=True)
802
803 def __repr__(self):
804 return ('<{classname} #{id}: {user} {media} notify: '
805 '{notify} email: {email}>').format(
806 id=self.id,
807 classname=self.__class__.__name__,
808 user=self.user,
809 media=self.media_entry,
810 notify=self.notify,
811 email=self.send_email)
812
813
814class Notification(Base):
815 __tablename__ = 'core__notifications'
816 id = Column(Integer, primary_key=True)
817 type = Column(Unicode)
818
819 created = Column(DateTime, nullable=False, default=datetime.datetime.now)
820
821 user_id = Column(Integer, ForeignKey('core__users.id'), nullable=False,
822 index=True)
823 seen = Column(Boolean, default=lambda: False, index=True)
824 user = relationship(
825 User,
826 backref=backref('notifications', cascade='all, delete-orphan'))
827
828 __mapper_args__ = {
829 'polymorphic_identity': 'notification',
830 'polymorphic_on': type
831 }
832
833 def __repr__(self):
dc19e98d
TB
834 return '<{klass} #{id}: {user}: {subject} ({seen})>'.format(
835 id=self.id,
836 klass=self.__class__.__name__,
837 user=self.user,
838 subject=getattr(self, 'subject', None),
839 seen='unseen' if not self.seen else 'seen')
840
841 def __unicode__(self):
09bed9a7 842 return u'<{klass} #{id}: {user}: {subject} ({seen})>'.format(
2d7b6bde
JW
843 id=self.id,
844 klass=self.__class__.__name__,
845 user=self.user,
846 subject=getattr(self, 'subject', None),
847 seen='unseen' if not self.seen else 'seen')
848
849
850class CommentNotification(Notification):
851 __tablename__ = 'core__comment_notifications'
852 id = Column(Integer, ForeignKey(Notification.id), primary_key=True)
853
854 subject_id = Column(Integer, ForeignKey(MediaComment.id))
855 subject = relationship(
856 MediaComment,
857 backref=backref('comment_notifications', cascade='all, delete-orphan'))
858
859 __mapper_args__ = {
860 'polymorphic_identity': 'comment_notification'
861 }
862
863
864class ProcessingNotification(Notification):
865 __tablename__ = 'core__processing_notifications'
866
867 id = Column(Integer, ForeignKey(Notification.id), primary_key=True)
868
869 subject_id = Column(Integer, ForeignKey(MediaEntry.id))
870 subject = relationship(
871 MediaEntry,
872 backref=backref('processing_notifications',
873 cascade='all, delete-orphan'))
874
875 __mapper_args__ = {
876 'polymorphic_identity': 'processing_notification'
877 }
878
2d7b6bde
JW
879with_polymorphic(
880 Notification,
881 [ProcessingNotification, CommentNotification])
882
30a9fe7c 883class ReportBase(Base):
884 """
8e91df87 885 This is the basic report object which the other reports are based off of.
886
887 :keyword reporter_id Holds the id of the user who created
888 the report, as an Integer column.
889 :keyword report_content Hold the explanation left by the repor-
890 -ter to indicate why they filed the
891 report in the first place, as a
892 Unicode column.
893 :keyword reported_user_id Holds the id of the user who created
894 the content which was reported, as
895 an Integer column.
896 :keyword created Holds a datetime column of when the re-
897 -port was filed.
898 :keyword discriminator This column distinguishes between the
899 different types of reports.
c9068870 900 :keyword resolver_id Holds the id of the moderator/admin who
901 resolved the report.
902 :keyword resolved Holds the DateTime object which descri-
903 -bes when this report was resolved
904 :keyword result Holds the UnicodeText column of the
905 resolver's reasons for resolving
906 the report this way. Some of this
907 is auto-generated
30a9fe7c 908 """
909 __tablename__ = 'core__reports'
910 id = Column(Integer, primary_key=True)
911 reporter_id = Column(Integer, ForeignKey(User.id), nullable=False)
9b8ef022 912 reporter = relationship(
dfd66b78 913 User,
9b8ef022 914 backref=backref("reports_filed_by",
915 lazy="dynamic",
3ce0c611 916 cascade="all, delete-orphan"),
917 primaryjoin="User.id==ReportBase.reporter_id")
30a9fe7c 918 report_content = Column(UnicodeText)
3ce0c611 919 reported_user_id = Column(Integer, ForeignKey(User.id), nullable=False)
920 reported_user = relationship(
dfd66b78 921 User,
3ce0c611 922 backref=backref("reports_filed_on",
923 lazy="dynamic",
924 cascade="all, delete-orphan"),
925 primaryjoin="User.id==ReportBase.reported_user_id")
9b8ef022 926 created = Column(DateTime, nullable=False, default=datetime.datetime.now())
30a9fe7c 927 discriminator = Column('type', Unicode(50))
c9068870 928 resolver_id = Column(Integer, ForeignKey(User.id))
929 resolver = relationship(
930 User,
931 backref=backref("reports_resolved_by",
932 lazy="dynamic",
933 cascade="all, delete-orphan"),
934 primaryjoin="User.id==ReportBase.resolver_id")
935
936 resolved = Column(DateTime)
937 result = Column(UnicodeText)
30a9fe7c 938 __mapper_args__ = {'polymorphic_on': discriminator}
939
3aa3871b 940 def is_comment_report(self):
941 return self.discriminator=='comment_report'
942
943 def is_media_entry_report(self):
944 return self.discriminator=='media_report'
945
946 def is_archived_report(self):
c9068870 947 return self.resolved is not None
948
949 def archive(self,resolver_id, resolved, result):
950 self.resolver_id = resolver_id
951 self.resolved = resolved
952 self.result = result
3aa3871b 953
30a9fe7c 954
955class CommentReport(ReportBase):
956 """
8e91df87 957 Reports that have been filed on comments.
958 :keyword comment_id Holds the integer value of the reported
959 comment's ID
30a9fe7c 960 """
961 __tablename__ = 'core__reports_on_comments'
962 __mapper_args__ = {'polymorphic_identity': 'comment_report'}
963
964 id = Column('id',Integer, ForeignKey('core__reports.id'),
965 primary_key=True)
c9068870 966 comment_id = Column(Integer, ForeignKey(MediaComment.id), nullable=True)
9b8ef022 967 comment = relationship(
968 MediaComment, backref=backref("reports_filed_on",
6483b370 969 lazy="dynamic"))
30a9fe7c 970
c9068870 971
30a9fe7c 972class MediaReport(ReportBase):
973 """
8e91df87 974 Reports that have been filed on media entries
975 :keyword media_entry_id Holds the integer value of the reported
976 media entry's ID
30a9fe7c 977 """
978 __tablename__ = 'core__reports_on_media'
979 __mapper_args__ = {'polymorphic_identity': 'media_report'}
980
981 id = Column('id',Integer, ForeignKey('core__reports.id'),
982 primary_key=True)
c9068870 983 media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=True)
9b8ef022 984 media_entry = relationship(
dfd66b78 985 MediaEntry,
6483b370 986 backref=backref("reports_filed_on",
987 lazy="dynamic"))
30a9fe7c 988
989class UserBan(Base):
990 """
dfd66b78 991 Holds the information on a specific user's ban-state. As long as one of
992 these is attached to a user, they are banned from accessing mediagoblin.
993 When they try to log in, they are greeted with a page that tells them
994 the reason why they are banned and when (if ever) the ban will be
9b8ef022 995 lifted
996
dfd66b78 997 :keyword user_id Holds the id of the user this object is
998 attached to. This is a one-to-one
9b8ef022 999 relationship.
dfd66b78 1000 :keyword expiration_date Holds the date that the ban will be lifted.
1001 If this is null, the ban is permanent
9b8ef022 1002 unless a moderator manually lifts it.
6bba33d7 1003 :keyword reason Holds the reason why the user was banned.
30a9fe7c 1004 """
1005 __tablename__ = 'core__user_bans'
1006
dfd66b78 1007 user_id = Column(Integer, ForeignKey(User.id), nullable=False,
9b8ef022 1008 primary_key=True)
1bb367f6 1009 expiration_date = Column(Date)
30a9fe7c 1010 reason = Column(UnicodeText, nullable=False)
1011
1012
3fb96fc9 1013class Privilege(Base):
6bba33d7 1014 """
1015 The Privilege table holds all of the different privileges a user can hold.
1016 If a user 'has' a privilege, the User object is in a relationship with the
dfd66b78 1017 privilege object.
6bba33d7 1018
1019 :keyword privilege_name Holds a unicode object that is the recognizable
dfd66b78 1020 name of this privilege. This is the column
6bba33d7 1021 used for identifying whether or not a user
1022 has a necessary privilege or not.
dfd66b78 1023
6bba33d7 1024 """
3fb96fc9 1025 __tablename__ = 'core__privileges'
30a9fe7c 1026
1027 id = Column(Integer, nullable=False, primary_key=True)
3fb96fc9 1028 privilege_name = Column(Unicode, nullable=False, unique=True)
9b8ef022 1029 all_users = relationship(
dfd66b78 1030 User,
1031 backref='all_privileges',
3fb96fc9 1032 secondary="core__privileges_users")
9b8ef022 1033
3fb96fc9 1034 def __init__(self, privilege_name):
6bba33d7 1035 '''
1036 Currently consructors are required for tables that are initialized thru
1037 the FOUNDATIONS system. This is because they need to be able to be con-
1038 -structed by a list object holding their arg*s
1039 '''
3fb96fc9 1040 self.privilege_name = privilege_name
30a9fe7c 1041
1042 def __repr__(self):
3fb96fc9 1043 return "<Privilege %s>" % (self.privilege_name)
30a9fe7c 1044
6bba33d7 1045
3fb96fc9 1046class PrivilegeUserAssociation(Base):
6bba33d7 1047 '''
1048 This table holds the many-to-many relationship between User and Privilege
1049 '''
dfd66b78 1050
3fb96fc9 1051 __tablename__ = 'core__privileges_users'
30a9fe7c 1052
c56a88b4 1053 user = Column(
987a6351 1054 "user",
dfd66b78 1055 Integer,
1056 ForeignKey(User.id),
9b8ef022 1057 primary_key=True)
c56a88b4 1058 privilege = Column(
987a6351 1059 "privilege",
dfd66b78 1060 Integer,
1061 ForeignKey(Privilege.id),
9b8ef022 1062 primary_key=True)
30a9fe7c 1063
70b44584 1064MODELS = [
9e204e49 1065 User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem,
1066 MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData,
2c901db0 1067 Notification, CommentNotification, ProcessingNotification, Client,
dfd66b78 1068 CommentSubscription, ReportBase, CommentReport, MediaReport, UserBan,
c9068870 1069 Privilege, PrivilegeUserAssociation,
2c901db0 1070 RequestToken, AccessToken, NonceTimestamp]
70b44584 1071
f2b2008d 1072"""
b5059525 1073 Foundations are the default rows that are created immediately after the tables
f2b2008d 1074 are initialized. Each entry to this dictionary should be in the format of:
1075 ModelConstructorObject:List of Dictionaries
1076 (Each Dictionary represents a row on the Table to be created, containing each
1077 of the columns' names as a key string, and each of the columns' values as a
1078 value)
1079
1080 ex. [NOTE THIS IS NOT BASED OFF OF OUR USER TABLE]
1081 user_foundations = [{'name':u'Joanna', 'age':24},
1082 {'name':u'Andrea', 'age':41}]
1083
1084 FOUNDATIONS = {User:user_foundations}
1085"""
dfd66b78 1086privilege_foundations = [{'privilege_name':u'admin'},
1087 {'privilege_name':u'moderator'},
52a355b2 1088 {'privilege_name':u'uploader'},
dfd66b78 1089 {'privilege_name':u'reporter'},
52a355b2 1090 {'privilege_name':u'commenter'},
1091 {'privilege_name':u'active'}]
3fb96fc9 1092FOUNDATIONS = {Privilege:privilege_foundations}
70b44584
CAW
1093
1094######################################################
1095# Special, migrations-tracking table
1096#
1097# Not listed in MODELS because this is special and not
1098# really migrated, but used for migrations (for now)
1099######################################################
1100
1101class MigrationData(Base):
2f5ce68c 1102 __tablename__ = "core__migrations"
70b44584 1103
bf813828 1104 name = Column(Unicode, primary_key=True)
70b44584
CAW
1105 version = Column(Integer, nullable=False, default=0)
1106
1107######################################################
1108
1109
eea6d276
E
1110def show_table_init(engine_uri):
1111 if engine_uri is None:
1112 engine_uri = 'sqlite:///:memory:'
e365f980 1113 from sqlalchemy import create_engine
eea6d276 1114 engine = create_engine(engine_uri, echo=True)
e365f980
E
1115
1116 Base.metadata.create_all(engine)
1117
1118
1119if __name__ == '__main__':
eea6d276
E
1120 from sys import argv
1121 print repr(argv)
1122 if len(argv) == 2:
1123 uri = argv[1]
1124 else:
1125 uri = None
1126 show_table_init(uri)