Merge branch 'stable'
[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 from __future__ import print_function
22
23 import logging
24 import datetime
25
26 from sqlalchemy import Column, Integer, Unicode, UnicodeText, DateTime, \
27 Boolean, ForeignKey, UniqueConstraint, PrimaryKeyConstraint, \
28 SmallInteger, Date
29 from sqlalchemy.orm import relationship, backref, with_polymorphic, validates
30 from sqlalchemy.orm.collections import attribute_mapped_collection
31 from sqlalchemy.sql.expression import desc
32 from sqlalchemy.ext.associationproxy import association_proxy
33 from sqlalchemy.util import memoized_property
34
35 from mediagoblin.db.extratypes import (PathTupleWithSlashes, JSONEncoded,
36 MutationDict)
37 from mediagoblin.db.base import Base, DictReadAttrProxy
38 from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, \
39 MediaCommentMixin, CollectionMixin, CollectionItemMixin, \
40 ActivityMixin
41 from mediagoblin.tools.files import delete_media_files
42 from mediagoblin.tools.common import import_component
43 from mediagoblin.tools.routing import extract_url_arguments
44
45 import six
46 from pytz import UTC
47
48 _log = logging.getLogger(__name__)
49
50 class Location(Base):
51 """ Represents a physical location """
52 __tablename__ = "core__locations"
53
54 id = Column(Integer, primary_key=True)
55 name = Column(Unicode)
56
57 # GPS coordinates
58 position = Column(MutationDict.as_mutable(JSONEncoded))
59 address = Column(MutationDict.as_mutable(JSONEncoded))
60
61 @classmethod
62 def create(cls, data, obj):
63 location = cls()
64 location.unserialize(data)
65 location.save()
66 obj.location = location.id
67 return location
68
69 def serialize(self, request):
70 location = {"objectType": "place"}
71
72 if self.name is not None:
73 location["displayName"] = self.name
74
75 if self.position:
76 location["position"] = self.position
77
78 if self.address:
79 location["address"] = self.address
80
81 return location
82
83 def unserialize(self, data):
84 if "displayName" in data:
85 self.name = data["displayName"]
86
87 self.position = {}
88 self.address = {}
89
90 # nicer way to do this?
91 if "position" in data:
92 # TODO: deal with ISO 9709 formatted string as position
93 if "altitude" in data["position"]:
94 self.position["altitude"] = data["position"]["altitude"]
95
96 if "direction" in data["position"]:
97 self.position["direction"] = data["position"]["direction"]
98
99 if "longitude" in data["position"]:
100 self.position["longitude"] = data["position"]["longitude"]
101
102 if "latitude" in data["position"]:
103 self.position["latitude"] = data["position"]["latitude"]
104
105 if "address" in data:
106 if "formatted" in data["address"]:
107 self.address["formatted"] = data["address"]["formatted"]
108
109 if "streetAddress" in data["address"]:
110 self.address["streetAddress"] = data["address"]["streetAddress"]
111
112 if "locality" in data["address"]:
113 self.address["locality"] = data["address"]["locality"]
114
115 if "region" in data["address"]:
116 self.address["region"] = data["address"]["region"]
117
118 if "postalCode" in data["address"]:
119 self.address["postalCode"] = data["addresss"]["postalCode"]
120
121 if "country" in data["address"]:
122 self.address["country"] = data["address"]["country"]
123
124 class User(Base, UserMixin):
125 """
126 TODO: We should consider moving some rarely used fields
127 into some sort of "shadow" table.
128 """
129 __tablename__ = "core__users"
130
131 id = Column(Integer, primary_key=True)
132 username = Column(Unicode, nullable=False, unique=True)
133 # Note: no db uniqueness constraint on email because it's not
134 # reliable (many email systems case insensitive despite against
135 # the RFC) and because it would be a mess to implement at this
136 # point.
137 email = Column(Unicode, nullable=False)
138 pw_hash = Column(Unicode)
139 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
140 # Intented to be nullable=False, but migrations would not work for it
141 # set to nullable=True implicitly.
142 wants_comment_notification = Column(Boolean, default=True)
143 wants_notifications = Column(Boolean, default=True)
144 license_preference = Column(Unicode)
145 url = Column(Unicode)
146 bio = Column(UnicodeText) # ??
147 uploaded = Column(Integer, default=0)
148 upload_limit = Column(Integer)
149 location = Column(Integer, ForeignKey("core__locations.id"))
150 get_location = relationship("Location", lazy="joined")
151
152 activity = Column(Integer, ForeignKey("core__activity_intermediators.id"))
153
154 ## TODO
155 # plugin data would be in a separate model
156
157 def __repr__(self):
158 return '<{0} #{1} {2} {3} "{4}">'.format(
159 self.__class__.__name__,
160 self.id,
161 'verified' if self.has_privilege(u'active') else 'non-verified',
162 'admin' if self.has_privilege(u'admin') else 'user',
163 self.username)
164
165 def delete(self, **kwargs):
166 """Deletes a User and all related entries/comments/files/..."""
167 # Collections get deleted by relationships.
168
169 media_entries = MediaEntry.query.filter(MediaEntry.uploader == self.id)
170 for media in media_entries:
171 # TODO: Make sure that "MediaEntry.delete()" also deletes
172 # all related files/Comments
173 media.delete(del_orphan_tags=False, commit=False)
174
175 # Delete now unused tags
176 # TODO: import here due to cyclic imports!!! This cries for refactoring
177 from mediagoblin.db.util import clean_orphan_tags
178 clean_orphan_tags(commit=False)
179
180 # Delete user, pass through commit=False/True in kwargs
181 super(User, self).delete(**kwargs)
182 _log.info('Deleted user "{0}" account'.format(self.username))
183
184 def has_privilege(self, privilege, allow_admin=True):
185 """
186 This method checks to make sure a user has all the correct privileges
187 to access a piece of content.
188
189 :param privilege A unicode object which represent the different
190 privileges which may give the user access to
191 content.
192
193 :param allow_admin If this is set to True the then if the user is
194 an admin, then this will always return True
195 even if the user hasn't been given the
196 privilege. (defaults to True)
197 """
198 priv = Privilege.query.filter_by(privilege_name=privilege).one()
199 if priv in self.all_privileges:
200 return True
201 elif allow_admin and self.has_privilege(u'admin', allow_admin=False):
202 return True
203
204 return False
205
206 def is_banned(self):
207 """
208 Checks if this user is banned.
209
210 :returns True if self is banned
211 :returns False if self is not
212 """
213 return UserBan.query.get(self.id) is not None
214
215
216 def serialize(self, request):
217 published = UTC.localize(self.created)
218 user = {
219 "id": "acct:{0}@{1}".format(self.username, request.host),
220 "published": published.isoformat(),
221 "preferredUsername": self.username,
222 "displayName": "{0}@{1}".format(self.username, request.host),
223 "objectType": self.object_type,
224 "pump_io": {
225 "shared": False,
226 "followed": False,
227 },
228 "links": {
229 "self": {
230 "href": request.urlgen(
231 "mediagoblin.api.user.profile",
232 username=self.username,
233 qualified=True
234 ),
235 },
236 "activity-inbox": {
237 "href": request.urlgen(
238 "mediagoblin.api.inbox",
239 username=self.username,
240 qualified=True
241 )
242 },
243 "activity-outbox": {
244 "href": request.urlgen(
245 "mediagoblin.api.feed",
246 username=self.username,
247 qualified=True
248 )
249 },
250 },
251 }
252
253 if self.bio:
254 user.update({"summary": self.bio})
255 if self.url:
256 user.update({"url": self.url})
257 if self.location:
258 user.update({"location": self.get_location.serialize(request)})
259
260 return user
261
262 def unserialize(self, data):
263 if "summary" in data:
264 self.bio = data["summary"]
265
266 if "location" in data:
267 Location.create(data, self)
268
269 class Client(Base):
270 """
271 Model representing a client - Used for API Auth
272 """
273 __tablename__ = "core__clients"
274
275 id = Column(Unicode, nullable=True, primary_key=True)
276 secret = Column(Unicode, nullable=False)
277 expirey = Column(DateTime, nullable=True)
278 application_type = Column(Unicode, nullable=False)
279 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
280 updated = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
281
282 # optional stuff
283 redirect_uri = Column(JSONEncoded, nullable=True)
284 logo_url = Column(Unicode, nullable=True)
285 application_name = Column(Unicode, nullable=True)
286 contacts = Column(JSONEncoded, nullable=True)
287
288 def __repr__(self):
289 if self.application_name:
290 return "<Client {0} - {1}>".format(self.application_name, self.id)
291 else:
292 return "<Client {0}>".format(self.id)
293
294 class RequestToken(Base):
295 """
296 Model for representing the request tokens
297 """
298 __tablename__ = "core__request_tokens"
299
300 token = Column(Unicode, primary_key=True)
301 secret = Column(Unicode, nullable=False)
302 client = Column(Unicode, ForeignKey(Client.id))
303 user = Column(Integer, ForeignKey(User.id), nullable=True)
304 used = Column(Boolean, default=False)
305 authenticated = Column(Boolean, default=False)
306 verifier = Column(Unicode, nullable=True)
307 callback = Column(Unicode, nullable=False, default=u"oob")
308 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
309 updated = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
310
311 get_client = relationship(Client)
312
313 class AccessToken(Base):
314 """
315 Model for representing the access tokens
316 """
317 __tablename__ = "core__access_tokens"
318
319 token = Column(Unicode, nullable=False, primary_key=True)
320 secret = Column(Unicode, nullable=False)
321 user = Column(Integer, ForeignKey(User.id))
322 request_token = Column(Unicode, ForeignKey(RequestToken.token))
323 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
324 updated = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
325
326 get_requesttoken = relationship(RequestToken)
327
328
329 class NonceTimestamp(Base):
330 """
331 A place the timestamp and nonce can be stored - this is for OAuth1
332 """
333 __tablename__ = "core__nonce_timestamps"
334
335 nonce = Column(Unicode, nullable=False, primary_key=True)
336 timestamp = Column(DateTime, nullable=False, primary_key=True)
337
338 class MediaEntry(Base, MediaEntryMixin):
339 """
340 TODO: Consider fetching the media_files using join
341 """
342 __tablename__ = "core__media_entries"
343
344 id = Column(Integer, primary_key=True)
345 uploader = Column(Integer, ForeignKey(User.id), nullable=False, index=True)
346 title = Column(Unicode, nullable=False)
347 slug = Column(Unicode)
348 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow,
349 index=True)
350 description = Column(UnicodeText) # ??
351 media_type = Column(Unicode, nullable=False)
352 state = Column(Unicode, default=u'unprocessed', nullable=False)
353 # or use sqlalchemy.types.Enum?
354 license = Column(Unicode)
355 file_size = Column(Integer, default=0)
356 location = Column(Integer, ForeignKey("core__locations.id"))
357 get_location = relationship("Location", lazy="joined")
358
359 fail_error = Column(Unicode)
360 fail_metadata = Column(JSONEncoded)
361
362 transcoding_progress = Column(SmallInteger)
363
364 queued_media_file = Column(PathTupleWithSlashes)
365
366 queued_task_id = Column(Unicode)
367
368 __table_args__ = (
369 UniqueConstraint('uploader', 'slug'),
370 {})
371
372 get_uploader = relationship(User)
373
374 media_files_helper = relationship("MediaFile",
375 collection_class=attribute_mapped_collection("name"),
376 cascade="all, delete-orphan"
377 )
378 media_files = association_proxy('media_files_helper', 'file_path',
379 creator=lambda k, v: MediaFile(name=k, file_path=v)
380 )
381
382 attachment_files_helper = relationship("MediaAttachmentFile",
383 cascade="all, delete-orphan",
384 order_by="MediaAttachmentFile.created"
385 )
386 attachment_files = association_proxy("attachment_files_helper", "dict_view",
387 creator=lambda v: MediaAttachmentFile(
388 name=v["name"], filepath=v["filepath"])
389 )
390
391 tags_helper = relationship("MediaTag",
392 cascade="all, delete-orphan" # should be automatically deleted
393 )
394 tags = association_proxy("tags_helper", "dict_view",
395 creator=lambda v: MediaTag(name=v["name"], slug=v["slug"])
396 )
397
398 collections_helper = relationship("CollectionItem",
399 cascade="all, delete-orphan"
400 )
401 collections = association_proxy("collections_helper", "in_collection")
402 media_metadata = Column(MutationDict.as_mutable(JSONEncoded),
403 default=MutationDict())
404
405 activity = Column(Integer, ForeignKey("core__activity_intermediators.id"))
406
407 ## TODO
408 # fail_error
409
410 def get_comments(self, ascending=False):
411 order_col = MediaComment.created
412 if not ascending:
413 order_col = desc(order_col)
414 return self.all_comments.order_by(order_col)
415
416 def url_to_prev(self, urlgen):
417 """get the next 'newer' entry by this user"""
418 media = MediaEntry.query.filter(
419 (MediaEntry.uploader == self.uploader)
420 & (MediaEntry.state == u'processed')
421 & (MediaEntry.id > self.id)).order_by(MediaEntry.id).first()
422
423 if media is not None:
424 return media.url_for_self(urlgen)
425
426 def url_to_next(self, urlgen):
427 """get the next 'older' entry by this user"""
428 media = MediaEntry.query.filter(
429 (MediaEntry.uploader == self.uploader)
430 & (MediaEntry.state == u'processed')
431 & (MediaEntry.id < self.id)).order_by(desc(MediaEntry.id)).first()
432
433 if media is not None:
434 return media.url_for_self(urlgen)
435
436 def get_file_metadata(self, file_key, metadata_key=None):
437 """
438 Return the file_metadata dict of a MediaFile. If metadata_key is given,
439 return the value of the key.
440 """
441 media_file = MediaFile.query.filter_by(media_entry=self.id,
442 name=six.text_type(file_key)).first()
443
444 if media_file:
445 if metadata_key:
446 return media_file.file_metadata.get(metadata_key, None)
447
448 return media_file.file_metadata
449
450 def set_file_metadata(self, file_key, **kwargs):
451 """
452 Update the file_metadata of a MediaFile.
453 """
454 media_file = MediaFile.query.filter_by(media_entry=self.id,
455 name=six.text_type(file_key)).first()
456
457 file_metadata = media_file.file_metadata or {}
458
459 for key, value in six.iteritems(kwargs):
460 file_metadata[key] = value
461
462 media_file.file_metadata = file_metadata
463 media_file.save()
464
465 @property
466 def media_data(self):
467 return getattr(self, self.media_data_ref)
468
469 def media_data_init(self, **kwargs):
470 """
471 Initialize or update the contents of a media entry's media_data row
472 """
473 media_data = self.media_data
474
475 if media_data is None:
476 # Get the correct table:
477 table = import_component(self.media_type + '.models:DATA_MODEL')
478 # No media data, so actually add a new one
479 media_data = table(**kwargs)
480 # Get the relationship set up.
481 media_data.get_media_entry = self
482 else:
483 # Update old media data
484 for field, value in six.iteritems(kwargs):
485 setattr(media_data, field, value)
486
487 @memoized_property
488 def media_data_ref(self):
489 return import_component(self.media_type + '.models:BACKREF_NAME')
490
491 def __repr__(self):
492 if six.PY2:
493 # obj.__repr__() should return a str on Python 2
494 safe_title = self.title.encode('utf-8', 'replace')
495 else:
496 safe_title = self.title
497
498 return '<{classname} {id}: {title}>'.format(
499 classname=self.__class__.__name__,
500 id=self.id,
501 title=safe_title)
502
503 def delete(self, del_orphan_tags=True, **kwargs):
504 """Delete MediaEntry and all related files/attachments/comments
505
506 This will *not* automatically delete unused collections, which
507 can remain empty...
508
509 :param del_orphan_tags: True/false if we delete unused Tags too
510 :param commit: True/False if this should end the db transaction"""
511 # User's CollectionItems are automatically deleted via "cascade".
512 # Comments on this Media are deleted by cascade, hopefully.
513
514 # Delete all related files/attachments
515 try:
516 delete_media_files(self)
517 except OSError as error:
518 # Returns list of files we failed to delete
519 _log.error('No such files from the user "{1}" to delete: '
520 '{0}'.format(str(error), self.get_uploader))
521 _log.info('Deleted Media entry id "{0}"'.format(self.id))
522 # Related MediaTag's are automatically cleaned, but we might
523 # want to clean out unused Tag's too.
524 if del_orphan_tags:
525 # TODO: Import here due to cyclic imports!!!
526 # This cries for refactoring
527 from mediagoblin.db.util import clean_orphan_tags
528 clean_orphan_tags(commit=False)
529 # pass through commit=False/True in kwargs
530 super(MediaEntry, self).delete(**kwargs)
531
532 def serialize(self, request, show_comments=True):
533 """ Unserialize MediaEntry to object """
534 href = request.urlgen(
535 "mediagoblin.api.object",
536 object_type=self.object_type,
537 id=self.id,
538 qualified=True
539 )
540 author = self.get_uploader
541 published = UTC.localize(self.created)
542 updated = UTC.localize(self.created)
543 context = {
544 "id": href,
545 "author": author.serialize(request),
546 "objectType": self.object_type,
547 "url": self.url_for_self(request.urlgen, qualified=True),
548 "image": {
549 "url": request.host_url + self.thumb_url[1:],
550 },
551 "fullImage":{
552 "url": request.host_url + self.original_url[1:],
553 },
554 "published": published.isoformat(),
555 "updated": updated.isoformat(),
556 "pump_io": {
557 "shared": False,
558 },
559 "links": {
560 "self": {
561 "href": href,
562 },
563
564 }
565 }
566
567 if self.title:
568 context["displayName"] = self.title
569
570 if self.description:
571 context["content"] = self.description
572
573 if self.license:
574 context["license"] = self.license
575
576 if self.location:
577 context["location"] = self.get_location.serialize(request)
578
579 if show_comments:
580 comments = [
581 comment.serialize(request) for comment in self.get_comments()]
582 total = len(comments)
583 context["replies"] = {
584 "totalItems": total,
585 "items": comments,
586 "url": request.urlgen(
587 "mediagoblin.api.object.comments",
588 object_type=self.object_type,
589 id=self.id,
590 qualified=True
591 ),
592 }
593
594 # Add image height and width if possible. We didn't use to store this
595 # data and we're not able (and maybe not willing) to re-process all
596 # images so it's possible this might not exist.
597 if self.get_file_metadata("thumb", "height"):
598 height = self.get_file_metadata("thumb", "height")
599 context["image"]["height"] = height
600 if self.get_file_metadata("thumb", "width"):
601 width = self.get_file_metadata("thumb", "width")
602 context["image"]["width"] = width
603 if self.get_file_metadata("original", "height"):
604 height = self.get_file_metadata("original", "height")
605 context["fullImage"]["height"] = height
606 if self.get_file_metadata("original", "height"):
607 width = self.get_file_metadata("original", "width")
608 context["fullImage"]["width"] = width
609
610 return context
611
612 def unserialize(self, data):
613 """ Takes API objects and unserializes on existing MediaEntry """
614 if "displayName" in data:
615 self.title = data["displayName"]
616
617 if "content" in data:
618 self.description = data["content"]
619
620 if "license" in data:
621 self.license = data["license"]
622
623 if "location" in data:
624 Licence.create(data["location"], self)
625
626 return True
627
628 class FileKeynames(Base):
629 """
630 keywords for various places.
631 currently the MediaFile keys
632 """
633 __tablename__ = "core__file_keynames"
634 id = Column(Integer, primary_key=True)
635 name = Column(Unicode, unique=True)
636
637 def __repr__(self):
638 return "<FileKeyname %r: %r>" % (self.id, self.name)
639
640 @classmethod
641 def find_or_new(cls, name):
642 t = cls.query.filter_by(name=name).first()
643 if t is not None:
644 return t
645 return cls(name=name)
646
647
648 class MediaFile(Base):
649 """
650 TODO: Highly consider moving "name" into a new table.
651 TODO: Consider preloading said table in software
652 """
653 __tablename__ = "core__mediafiles"
654
655 media_entry = Column(
656 Integer, ForeignKey(MediaEntry.id),
657 nullable=False)
658 name_id = Column(SmallInteger, ForeignKey(FileKeynames.id), nullable=False)
659 file_path = Column(PathTupleWithSlashes)
660 file_metadata = Column(MutationDict.as_mutable(JSONEncoded))
661
662 __table_args__ = (
663 PrimaryKeyConstraint('media_entry', 'name_id'),
664 {})
665
666 def __repr__(self):
667 return "<MediaFile %s: %r>" % (self.name, self.file_path)
668
669 name_helper = relationship(FileKeynames, lazy="joined", innerjoin=True)
670 name = association_proxy('name_helper', 'name',
671 creator=FileKeynames.find_or_new
672 )
673
674
675 class MediaAttachmentFile(Base):
676 __tablename__ = "core__attachment_files"
677
678 id = Column(Integer, primary_key=True)
679 media_entry = Column(
680 Integer, ForeignKey(MediaEntry.id),
681 nullable=False)
682 name = Column(Unicode, nullable=False)
683 filepath = Column(PathTupleWithSlashes)
684 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
685
686 @property
687 def dict_view(self):
688 """A dict like view on this object"""
689 return DictReadAttrProxy(self)
690
691
692 class Tag(Base):
693 __tablename__ = "core__tags"
694
695 id = Column(Integer, primary_key=True)
696 slug = Column(Unicode, nullable=False, unique=True)
697
698 def __repr__(self):
699 return "<Tag %r: %r>" % (self.id, self.slug)
700
701 @classmethod
702 def find_or_new(cls, slug):
703 t = cls.query.filter_by(slug=slug).first()
704 if t is not None:
705 return t
706 return cls(slug=slug)
707
708
709 class MediaTag(Base):
710 __tablename__ = "core__media_tags"
711
712 id = Column(Integer, primary_key=True)
713 media_entry = Column(
714 Integer, ForeignKey(MediaEntry.id),
715 nullable=False, index=True)
716 tag = Column(Integer, ForeignKey(Tag.id), nullable=False, index=True)
717 name = Column(Unicode)
718 # created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
719
720 __table_args__ = (
721 UniqueConstraint('tag', 'media_entry'),
722 {})
723
724 tag_helper = relationship(Tag)
725 slug = association_proxy('tag_helper', 'slug',
726 creator=Tag.find_or_new
727 )
728
729 def __init__(self, name=None, slug=None):
730 Base.__init__(self)
731 if name is not None:
732 self.name = name
733 if slug is not None:
734 self.tag_helper = Tag.find_or_new(slug)
735
736 @property
737 def dict_view(self):
738 """A dict like view on this object"""
739 return DictReadAttrProxy(self)
740
741
742 class MediaComment(Base, MediaCommentMixin):
743 __tablename__ = "core__media_comments"
744
745 id = Column(Integer, primary_key=True)
746 media_entry = Column(
747 Integer, ForeignKey(MediaEntry.id), nullable=False, index=True)
748 author = Column(Integer, ForeignKey(User.id), nullable=False)
749 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
750 content = Column(UnicodeText, nullable=False)
751 location = Column(Integer, ForeignKey("core__locations.id"))
752 get_location = relationship("Location", lazy="joined")
753
754 # Cascade: Comments are owned by their creator. So do the full thing.
755 # lazy=dynamic: People might post a *lot* of comments,
756 # so make the "posted_comments" a query-like thing.
757 get_author = relationship(User,
758 backref=backref("posted_comments",
759 lazy="dynamic",
760 cascade="all, delete-orphan"))
761 get_entry = relationship(MediaEntry,
762 backref=backref("comments",
763 lazy="dynamic",
764 cascade="all, delete-orphan"))
765
766 # Cascade: Comments are somewhat owned by their MediaEntry.
767 # So do the full thing.
768 # lazy=dynamic: MediaEntries might have many comments,
769 # so make the "all_comments" a query-like thing.
770 get_media_entry = relationship(MediaEntry,
771 backref=backref("all_comments",
772 lazy="dynamic",
773 cascade="all, delete-orphan"))
774
775
776 activity = Column(Integer, ForeignKey("core__activity_intermediators.id"))
777
778 def serialize(self, request):
779 """ Unserialize to python dictionary for API """
780 href = request.urlgen(
781 "mediagoblin.api.object",
782 object_type=self.object_type,
783 id=self.id,
784 qualified=True
785 )
786 media = MediaEntry.query.filter_by(id=self.media_entry).first()
787 author = self.get_author
788 published = UTC.localize(self.created)
789 context = {
790 "id": href,
791 "objectType": self.object_type,
792 "content": self.content,
793 "inReplyTo": media.serialize(request, show_comments=False),
794 "author": author.serialize(request),
795 "published": published.isoformat(),
796 "updated": published.isoformat(),
797 }
798
799 if self.location:
800 context["location"] = self.get_location.seralize(request)
801
802 return context
803
804 def unserialize(self, data, request):
805 """ Takes API objects and unserializes on existing comment """
806 # Handle changing the reply ID
807 if "inReplyTo" in data:
808 # Validate that the ID is correct
809 try:
810 media_id = int(extract_url_arguments(
811 url=data["inReplyTo"]["id"],
812 urlmap=request.app.url_map
813 )["id"])
814 except ValueError:
815 return False
816
817 media = MediaEntry.query.filter_by(id=media_id).first()
818 if media is None:
819 return False
820
821 self.media_entry = media.id
822
823 if "content" in data:
824 self.content = data["content"]
825
826 if "location" in data:
827 Location.create(data["location"], self)
828
829 return True
830
831
832
833 class Collection(Base, CollectionMixin):
834 """An 'album' or 'set' of media by a user.
835
836 On deletion, contained CollectionItems get automatically reaped via
837 SQL cascade"""
838 __tablename__ = "core__collections"
839
840 id = Column(Integer, primary_key=True)
841 title = Column(Unicode, nullable=False)
842 slug = Column(Unicode)
843 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow,
844 index=True)
845 description = Column(UnicodeText)
846 creator = Column(Integer, ForeignKey(User.id), nullable=False)
847 location = Column(Integer, ForeignKey("core__locations.id"))
848 get_location = relationship("Location", lazy="joined")
849
850 # TODO: No of items in Collection. Badly named, can we migrate to num_items?
851 items = Column(Integer, default=0)
852
853 # Cascade: Collections are owned by their creator. So do the full thing.
854 get_creator = relationship(User,
855 backref=backref("collections",
856 cascade="all, delete-orphan"))
857
858 activity = Column(Integer, ForeignKey("core__activity_intermediators.id"))
859
860 __table_args__ = (
861 UniqueConstraint('creator', 'slug'),
862 {})
863
864 def get_collection_items(self, ascending=False):
865 #TODO, is this still needed with self.collection_items being available?
866 order_col = CollectionItem.position
867 if not ascending:
868 order_col = desc(order_col)
869 return CollectionItem.query.filter_by(
870 collection=self.id).order_by(order_col)
871
872 def __repr__(self):
873 safe_title = self.title.encode('ascii', 'replace')
874 return '<{classname} #{id}: {title} by {creator}>'.format(
875 id=self.id,
876 classname=self.__class__.__name__,
877 creator=self.creator,
878 title=safe_title)
879
880 def serialize(self, request):
881 # Get all serialized output in a list
882 items = []
883 for item in self.get_collection_items():
884 items.append(item.serialize(request))
885
886 return {
887 "totalItems": self.items,
888 "url": self.url_for_self(request.urlgen, qualified=True),
889 "items": items,
890 }
891
892
893 class CollectionItem(Base, CollectionItemMixin):
894 __tablename__ = "core__collection_items"
895
896 id = Column(Integer, primary_key=True)
897 media_entry = Column(
898 Integer, ForeignKey(MediaEntry.id), nullable=False, index=True)
899 collection = Column(Integer, ForeignKey(Collection.id), nullable=False)
900 note = Column(UnicodeText, nullable=True)
901 added = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
902 position = Column(Integer)
903
904 # Cascade: CollectionItems are owned by their Collection. So do the full thing.
905 in_collection = relationship(Collection,
906 backref=backref(
907 "collection_items",
908 cascade="all, delete-orphan"))
909
910 get_media_entry = relationship(MediaEntry)
911
912 __table_args__ = (
913 UniqueConstraint('collection', 'media_entry'),
914 {})
915
916 @property
917 def dict_view(self):
918 """A dict like view on this object"""
919 return DictReadAttrProxy(self)
920
921 def __repr__(self):
922 return '<{classname} #{id}: Entry {entry} in {collection}>'.format(
923 id=self.id,
924 classname=self.__class__.__name__,
925 collection=self.collection,
926 entry=self.media_entry)
927
928 def serialize(self, request):
929 return self.get_media_entry.serialize(request)
930
931
932 class ProcessingMetaData(Base):
933 __tablename__ = 'core__processing_metadata'
934
935 id = Column(Integer, primary_key=True)
936 media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False,
937 index=True)
938 media_entry = relationship(MediaEntry,
939 backref=backref('processing_metadata',
940 cascade='all, delete-orphan'))
941 callback_url = Column(Unicode)
942
943 @property
944 def dict_view(self):
945 """A dict like view on this object"""
946 return DictReadAttrProxy(self)
947
948
949 class CommentSubscription(Base):
950 __tablename__ = 'core__comment_subscriptions'
951 id = Column(Integer, primary_key=True)
952
953 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
954
955 media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False)
956 media_entry = relationship(MediaEntry,
957 backref=backref('comment_subscriptions',
958 cascade='all, delete-orphan'))
959
960 user_id = Column(Integer, ForeignKey(User.id), nullable=False)
961 user = relationship(User,
962 backref=backref('comment_subscriptions',
963 cascade='all, delete-orphan'))
964
965 notify = Column(Boolean, nullable=False, default=True)
966 send_email = Column(Boolean, nullable=False, default=True)
967
968 def __repr__(self):
969 return ('<{classname} #{id}: {user} {media} notify: '
970 '{notify} email: {email}>').format(
971 id=self.id,
972 classname=self.__class__.__name__,
973 user=self.user,
974 media=self.media_entry,
975 notify=self.notify,
976 email=self.send_email)
977
978
979 class Notification(Base):
980 __tablename__ = 'core__notifications'
981 id = Column(Integer, primary_key=True)
982 type = Column(Unicode)
983
984 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
985
986 user_id = Column(Integer, ForeignKey('core__users.id'), nullable=False,
987 index=True)
988 seen = Column(Boolean, default=lambda: False, index=True)
989 user = relationship(
990 User,
991 backref=backref('notifications', cascade='all, delete-orphan'))
992
993 __mapper_args__ = {
994 'polymorphic_identity': 'notification',
995 'polymorphic_on': type
996 }
997
998 def __repr__(self):
999 return '<{klass} #{id}: {user}: {subject} ({seen})>'.format(
1000 id=self.id,
1001 klass=self.__class__.__name__,
1002 user=self.user,
1003 subject=getattr(self, 'subject', None),
1004 seen='unseen' if not self.seen else 'seen')
1005
1006 def __unicode__(self):
1007 return u'<{klass} #{id}: {user}: {subject} ({seen})>'.format(
1008 id=self.id,
1009 klass=self.__class__.__name__,
1010 user=self.user,
1011 subject=getattr(self, 'subject', None),
1012 seen='unseen' if not self.seen else 'seen')
1013
1014
1015 class CommentNotification(Notification):
1016 __tablename__ = 'core__comment_notifications'
1017 id = Column(Integer, ForeignKey(Notification.id), primary_key=True)
1018
1019 subject_id = Column(Integer, ForeignKey(MediaComment.id))
1020 subject = relationship(
1021 MediaComment,
1022 backref=backref('comment_notifications', cascade='all, delete-orphan'))
1023
1024 __mapper_args__ = {
1025 'polymorphic_identity': 'comment_notification'
1026 }
1027
1028
1029 class ProcessingNotification(Notification):
1030 __tablename__ = 'core__processing_notifications'
1031
1032 id = Column(Integer, ForeignKey(Notification.id), primary_key=True)
1033
1034 subject_id = Column(Integer, ForeignKey(MediaEntry.id))
1035 subject = relationship(
1036 MediaEntry,
1037 backref=backref('processing_notifications',
1038 cascade='all, delete-orphan'))
1039
1040 __mapper_args__ = {
1041 'polymorphic_identity': 'processing_notification'
1042 }
1043
1044 # the with_polymorphic call has been moved to the bottom above MODELS
1045 # this is because it causes conflicts with relationship calls.
1046
1047 class ReportBase(Base):
1048 """
1049 This is the basic report object which the other reports are based off of.
1050
1051 :keyword reporter_id Holds the id of the user who created
1052 the report, as an Integer column.
1053 :keyword report_content Hold the explanation left by the repor-
1054 -ter to indicate why they filed the
1055 report in the first place, as a
1056 Unicode column.
1057 :keyword reported_user_id Holds the id of the user who created
1058 the content which was reported, as
1059 an Integer column.
1060 :keyword created Holds a datetime column of when the re-
1061 -port was filed.
1062 :keyword discriminator This column distinguishes between the
1063 different types of reports.
1064 :keyword resolver_id Holds the id of the moderator/admin who
1065 resolved the report.
1066 :keyword resolved Holds the DateTime object which descri-
1067 -bes when this report was resolved
1068 :keyword result Holds the UnicodeText column of the
1069 resolver's reasons for resolving
1070 the report this way. Some of this
1071 is auto-generated
1072 """
1073 __tablename__ = 'core__reports'
1074 id = Column(Integer, primary_key=True)
1075 reporter_id = Column(Integer, ForeignKey(User.id), nullable=False)
1076 reporter = relationship(
1077 User,
1078 backref=backref("reports_filed_by",
1079 lazy="dynamic",
1080 cascade="all, delete-orphan"),
1081 primaryjoin="User.id==ReportBase.reporter_id")
1082 report_content = Column(UnicodeText)
1083 reported_user_id = Column(Integer, ForeignKey(User.id), nullable=False)
1084 reported_user = relationship(
1085 User,
1086 backref=backref("reports_filed_on",
1087 lazy="dynamic",
1088 cascade="all, delete-orphan"),
1089 primaryjoin="User.id==ReportBase.reported_user_id")
1090 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
1091 discriminator = Column('type', Unicode(50))
1092 resolver_id = Column(Integer, ForeignKey(User.id))
1093 resolver = relationship(
1094 User,
1095 backref=backref("reports_resolved_by",
1096 lazy="dynamic",
1097 cascade="all, delete-orphan"),
1098 primaryjoin="User.id==ReportBase.resolver_id")
1099
1100 resolved = Column(DateTime)
1101 result = Column(UnicodeText)
1102 __mapper_args__ = {'polymorphic_on': discriminator}
1103
1104 def is_comment_report(self):
1105 return self.discriminator=='comment_report'
1106
1107 def is_media_entry_report(self):
1108 return self.discriminator=='media_report'
1109
1110 def is_archived_report(self):
1111 return self.resolved is not None
1112
1113 def archive(self,resolver_id, resolved, result):
1114 self.resolver_id = resolver_id
1115 self.resolved = resolved
1116 self.result = result
1117
1118
1119 class CommentReport(ReportBase):
1120 """
1121 Reports that have been filed on comments.
1122 :keyword comment_id Holds the integer value of the reported
1123 comment's ID
1124 """
1125 __tablename__ = 'core__reports_on_comments'
1126 __mapper_args__ = {'polymorphic_identity': 'comment_report'}
1127
1128 id = Column('id',Integer, ForeignKey('core__reports.id'),
1129 primary_key=True)
1130 comment_id = Column(Integer, ForeignKey(MediaComment.id), nullable=True)
1131 comment = relationship(
1132 MediaComment, backref=backref("reports_filed_on",
1133 lazy="dynamic"))
1134
1135
1136 class MediaReport(ReportBase):
1137 """
1138 Reports that have been filed on media entries
1139 :keyword media_entry_id Holds the integer value of the reported
1140 media entry's ID
1141 """
1142 __tablename__ = 'core__reports_on_media'
1143 __mapper_args__ = {'polymorphic_identity': 'media_report'}
1144
1145 id = Column('id',Integer, ForeignKey('core__reports.id'),
1146 primary_key=True)
1147 media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=True)
1148 media_entry = relationship(
1149 MediaEntry,
1150 backref=backref("reports_filed_on",
1151 lazy="dynamic"))
1152
1153 class UserBan(Base):
1154 """
1155 Holds the information on a specific user's ban-state. As long as one of
1156 these is attached to a user, they are banned from accessing mediagoblin.
1157 When they try to log in, they are greeted with a page that tells them
1158 the reason why they are banned and when (if ever) the ban will be
1159 lifted
1160
1161 :keyword user_id Holds the id of the user this object is
1162 attached to. This is a one-to-one
1163 relationship.
1164 :keyword expiration_date Holds the date that the ban will be lifted.
1165 If this is null, the ban is permanent
1166 unless a moderator manually lifts it.
1167 :keyword reason Holds the reason why the user was banned.
1168 """
1169 __tablename__ = 'core__user_bans'
1170
1171 user_id = Column(Integer, ForeignKey(User.id), nullable=False,
1172 primary_key=True)
1173 expiration_date = Column(Date)
1174 reason = Column(UnicodeText, nullable=False)
1175
1176
1177 class Privilege(Base):
1178 """
1179 The Privilege table holds all of the different privileges a user can hold.
1180 If a user 'has' a privilege, the User object is in a relationship with the
1181 privilege object.
1182
1183 :keyword privilege_name Holds a unicode object that is the recognizable
1184 name of this privilege. This is the column
1185 used for identifying whether or not a user
1186 has a necessary privilege or not.
1187
1188 """
1189 __tablename__ = 'core__privileges'
1190
1191 id = Column(Integer, nullable=False, primary_key=True)
1192 privilege_name = Column(Unicode, nullable=False, unique=True)
1193 all_users = relationship(
1194 User,
1195 backref='all_privileges',
1196 secondary="core__privileges_users")
1197
1198 def __init__(self, privilege_name):
1199 '''
1200 Currently consructors are required for tables that are initialized thru
1201 the FOUNDATIONS system. This is because they need to be able to be con-
1202 -structed by a list object holding their arg*s
1203 '''
1204 self.privilege_name = privilege_name
1205
1206 def __repr__(self):
1207 return "<Privilege %s>" % (self.privilege_name)
1208
1209
1210 class PrivilegeUserAssociation(Base):
1211 '''
1212 This table holds the many-to-many relationship between User and Privilege
1213 '''
1214
1215 __tablename__ = 'core__privileges_users'
1216
1217 user = Column(
1218 "user",
1219 Integer,
1220 ForeignKey(User.id),
1221 primary_key=True)
1222 privilege = Column(
1223 "privilege",
1224 Integer,
1225 ForeignKey(Privilege.id),
1226 primary_key=True)
1227
1228 class Generator(Base):
1229 """ Information about what created an activity """
1230 __tablename__ = "core__generators"
1231
1232 id = Column(Integer, primary_key=True)
1233 name = Column(Unicode, nullable=False)
1234 published = Column(DateTime, default=datetime.datetime.utcnow)
1235 updated = Column(DateTime, default=datetime.datetime.utcnow)
1236 object_type = Column(Unicode, nullable=False)
1237
1238 def __repr__(self):
1239 return "<{klass} {name}>".format(
1240 klass=self.__class__.__name__,
1241 name=self.name
1242 )
1243
1244 def serialize(self, request):
1245 href = request.urlgen(
1246 "mediagoblin.api.object",
1247 object_type=self.object_type,
1248 id=self.id,
1249 qualified=True
1250 )
1251 published = UTC.localize(self.published)
1252 updated = UTC.localize(self.updated)
1253 return {
1254 "id": href,
1255 "displayName": self.name,
1256 "published": published.isoformat(),
1257 "updated": updated.isoformat(),
1258 "objectType": self.object_type,
1259 }
1260
1261 def unserialize(self, data):
1262 if "displayName" in data:
1263 self.name = data["displayName"]
1264
1265
1266 class ActivityIntermediator(Base):
1267 """
1268 This is used so that objects/targets can have a foreign key back to this
1269 object and activities can a foreign key to this object. This objects to be
1270 used multiple times for the activity object or target and also allows for
1271 different types of objects to be used as an Activity.
1272 """
1273 __tablename__ = "core__activity_intermediators"
1274
1275 id = Column(Integer, primary_key=True)
1276 type = Column(Unicode, nullable=False)
1277
1278 TYPES = {
1279 "user": User,
1280 "media": MediaEntry,
1281 "comment": MediaComment,
1282 "collection": Collection,
1283 }
1284
1285 def _find_model(self, obj):
1286 """ Finds the model for a given object """
1287 for key, model in self.TYPES.items():
1288 if isinstance(obj, model):
1289 return key, model
1290
1291 return None, None
1292
1293 def set(self, obj):
1294 """ This sets itself as the activity """
1295 key, model = self._find_model(obj)
1296 if key is None:
1297 raise ValueError("Invalid type of object given")
1298
1299 self.type = key
1300
1301 # We need to populate the self.id so we need to save but, we don't
1302 # want to save this AI in the database (yet) so commit=False.
1303 self.save(commit=False)
1304 obj.activity = self.id
1305 obj.save()
1306
1307 def get(self):
1308 """ Finds the object for an activity """
1309 if self.type is None:
1310 return None
1311
1312 model = self.TYPES[self.type]
1313 return model.query.filter_by(activity=self.id).first()
1314
1315 @validates("type")
1316 def validate_type(self, key, value):
1317 """ Validate that the type set is a valid type """
1318 assert value in self.TYPES
1319 return value
1320
1321 class Activity(Base, ActivityMixin):
1322 """
1323 This holds all the metadata about an activity such as uploading an image,
1324 posting a comment, etc.
1325 """
1326 __tablename__ = "core__activities"
1327
1328 id = Column(Integer, primary_key=True)
1329 actor = Column(Integer,
1330 ForeignKey("core__users.id"),
1331 nullable=False)
1332 published = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
1333 updated = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
1334 verb = Column(Unicode, nullable=False)
1335 content = Column(Unicode, nullable=True)
1336 title = Column(Unicode, nullable=True)
1337 generator = Column(Integer,
1338 ForeignKey("core__generators.id"),
1339 nullable=True)
1340 object = Column(Integer,
1341 ForeignKey("core__activity_intermediators.id"),
1342 nullable=False)
1343 target = Column(Integer,
1344 ForeignKey("core__activity_intermediators.id"),
1345 nullable=True)
1346
1347 get_actor = relationship(User,
1348 backref=backref("activities",
1349 cascade="all, delete-orphan"))
1350 get_generator = relationship(Generator)
1351
1352 def __repr__(self):
1353 if self.content is None:
1354 return "<{klass} verb:{verb}>".format(
1355 klass=self.__class__.__name__,
1356 verb=self.verb
1357 )
1358 else:
1359 return "<{klass} {content}>".format(
1360 klass=self.__class__.__name__,
1361 content=self.content
1362 )
1363
1364 @property
1365 def get_object(self):
1366 if self.object is None:
1367 return None
1368
1369 ai = ActivityIntermediator.query.filter_by(id=self.object).first()
1370 return ai.get()
1371
1372 def set_object(self, obj):
1373 self.object = self._set_model(obj)
1374
1375 @property
1376 def get_target(self):
1377 if self.target is None:
1378 return None
1379
1380 ai = ActivityIntermediator.query.filter_by(id=self.target).first()
1381 return ai.get()
1382
1383 def set_target(self, obj):
1384 self.target = self._set_model(obj)
1385
1386 def _set_model(self, obj):
1387 # Firstly can we set obj
1388 if not hasattr(obj, "activity"):
1389 raise ValueError(
1390 "{0!r} is unable to be set on activity".format(obj))
1391
1392 if obj.activity is None:
1393 # We need to create a new AI
1394 ai = ActivityIntermediator()
1395 ai.set(obj)
1396 ai.save()
1397 return ai.id
1398
1399 # Okay we should have an existing AI
1400 return ActivityIntermediator.query.filter_by(id=obj.activity).first().id
1401
1402 def save(self, set_updated=True, *args, **kwargs):
1403 if set_updated:
1404 self.updated = datetime.datetime.now()
1405 super(Activity, self).save(*args, **kwargs)
1406
1407 with_polymorphic(
1408 Notification,
1409 [ProcessingNotification, CommentNotification])
1410
1411 MODELS = [
1412 User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem,
1413 MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData,
1414 Notification, CommentNotification, ProcessingNotification, Client,
1415 CommentSubscription, ReportBase, CommentReport, MediaReport, UserBan,
1416 Privilege, PrivilegeUserAssociation,
1417 RequestToken, AccessToken, NonceTimestamp,
1418 Activity, ActivityIntermediator, Generator,
1419 Location]
1420
1421 """
1422 Foundations are the default rows that are created immediately after the tables
1423 are initialized. Each entry to this dictionary should be in the format of:
1424 ModelConstructorObject:List of Dictionaries
1425 (Each Dictionary represents a row on the Table to be created, containing each
1426 of the columns' names as a key string, and each of the columns' values as a
1427 value)
1428
1429 ex. [NOTE THIS IS NOT BASED OFF OF OUR USER TABLE]
1430 user_foundations = [{'name':u'Joanna', 'age':24},
1431 {'name':u'Andrea', 'age':41}]
1432
1433 FOUNDATIONS = {User:user_foundations}
1434 """
1435 privilege_foundations = [{'privilege_name':u'admin'},
1436 {'privilege_name':u'moderator'},
1437 {'privilege_name':u'uploader'},
1438 {'privilege_name':u'reporter'},
1439 {'privilege_name':u'commenter'},
1440 {'privilege_name':u'active'}]
1441 FOUNDATIONS = {Privilege:privilege_foundations}
1442
1443 ######################################################
1444 # Special, migrations-tracking table
1445 #
1446 # Not listed in MODELS because this is special and not
1447 # really migrated, but used for migrations (for now)
1448 ######################################################
1449
1450 class MigrationData(Base):
1451 __tablename__ = "core__migrations"
1452
1453 name = Column(Unicode, primary_key=True)
1454 version = Column(Integer, nullable=False, default=0)
1455
1456 ######################################################
1457
1458
1459 def show_table_init(engine_uri):
1460 if engine_uri is None:
1461 engine_uri = 'sqlite:///:memory:'
1462 from sqlalchemy import create_engine
1463 engine = create_engine(engine_uri, echo=True)
1464
1465 Base.metadata.create_all(engine)
1466
1467
1468 if __name__ == '__main__':
1469 from sys import argv
1470 print(repr(argv))
1471 if len(argv) == 2:
1472 uri = argv[1]
1473 else:
1474 uri = None
1475 show_table_init(uri)