1 # GNU MediaGoblin -- federated, autonomous media hosting
2 # Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU Affero General Public License for more details.
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18 TODO: indexes on foreignkeys, where useful.
21 from __future__
import print_function
26 from sqlalchemy
import Column
, Integer
, Unicode
, UnicodeText
, DateTime
, \
27 Boolean
, ForeignKey
, UniqueConstraint
, PrimaryKeyConstraint
, \
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
35 from mediagoblin
.db
.extratypes
import (PathTupleWithSlashes
, JSONEncoded
,
37 from mediagoblin
.db
.base
import Base
, DictReadAttrProxy
38 from mediagoblin
.db
.mixin
import UserMixin
, MediaEntryMixin
, \
39 MediaCommentMixin
, CollectionMixin
, CollectionItemMixin
, \
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
48 _log
= logging
.getLogger(__name__
)
51 """ Represents a physical location """
52 __tablename__
= "core__locations"
54 id = Column(Integer
, primary_key
=True)
55 name
= Column(Unicode
)
58 position
= Column(MutationDict
.as_mutable(JSONEncoded
))
59 address
= Column(MutationDict
.as_mutable(JSONEncoded
))
62 def create(cls
, data
, obj
):
64 location
.unserialize(data
)
66 obj
.location
= location
.id
69 def serialize(self
, request
):
70 location
= {"objectType": "place"}
72 if self
.name
is not None:
73 location
["displayName"] = self
.name
76 location
["position"] = self
.position
79 location
["address"] = self
.address
83 def unserialize(self
, data
):
84 if "displayName" in data
:
85 self
.name
= data
["displayName"]
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"]
96 if "direction" in data
["position"]:
97 self
.position
["direction"] = data
["position"]["direction"]
99 if "longitude" in data
["position"]:
100 self
.position
["longitude"] = data
["position"]["longitude"]
102 if "latitude" in data
["position"]:
103 self
.position
["latitude"] = data
["position"]["latitude"]
105 if "address" in data
:
106 if "formatted" in data
["address"]:
107 self
.address
["formatted"] = data
["address"]["formatted"]
109 if "streetAddress" in data
["address"]:
110 self
.address
["streetAddress"] = data
["address"]["streetAddress"]
112 if "locality" in data
["address"]:
113 self
.address
["locality"] = data
["address"]["locality"]
115 if "region" in data
["address"]:
116 self
.address
["region"] = data
["address"]["region"]
118 if "postalCode" in data
["address"]:
119 self
.address
["postalCode"] = data
["addresss"]["postalCode"]
121 if "country" in data
["address"]:
122 self
.address
["country"] = data
["address"]["country"]
124 class User(Base
, UserMixin
):
126 TODO: We should consider moving some rarely used fields
127 into some sort of "shadow" table.
129 __tablename__
= "core__users"
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
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")
152 activity
= Column(Integer
, ForeignKey("core__activity_intermediators.id"))
155 # plugin data would be in a separate model
158 return '<{0} #{1} {2} {3} "{4}">'.format(
159 self
.__class
__.__name
__,
161 'verified' if self
.has_privilege(u
'active') else 'non-verified',
162 'admin' if self
.has_privilege(u
'admin') else 'user',
165 def delete(self
, **kwargs
):
166 """Deletes a User and all related entries/comments/files/..."""
167 # Collections get deleted by relationships.
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)
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)
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
))
184 def has_privilege(self
, privilege
, allow_admin
=True):
186 This method checks to make sure a user has all the correct privileges
187 to access a piece of content.
189 :param privilege A unicode object which represent the different
190 privileges which may give the user access to
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)
198 priv
= Privilege
.query
.filter_by(privilege_name
=privilege
).one()
199 if priv
in self
.all_privileges
:
201 elif allow_admin
and self
.has_privilege(u
'admin', allow_admin
=False):
208 Checks if this user is banned.
210 :returns True if self is banned
211 :returns False if self is not
213 return UserBan
.query
.get(self
.id) is not None
216 def serialize(self
, request
):
217 published
= UTC
.localize(self
.created
)
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
,
230 "href": request
.urlgen(
231 "mediagoblin.api.user.profile",
232 username
=self
.username
,
237 "href": request
.urlgen(
238 "mediagoblin.api.inbox",
239 username
=self
.username
,
244 "href": request
.urlgen(
245 "mediagoblin.api.feed",
246 username
=self
.username
,
254 user
.update({"summary": self
.bio
})
256 user
.update({"url": self
.url
})
258 user
.update({"location": self
.get_location
.serialize(request
)})
262 def unserialize(self
, data
):
263 if "summary" in data
:
264 self
.bio
= data
["summary"]
266 if "location" in data
:
267 Location
.create(data
, self
)
271 Model representing a client - Used for API Auth
273 __tablename__
= "core__clients"
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
)
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)
289 if self
.application_name
:
290 return "<Client {0} - {1}>".format(self
.application_name
, self
.id)
292 return "<Client {0}>".format(self
.id)
294 class RequestToken(Base
):
296 Model for representing the request tokens
298 __tablename__
= "core__request_tokens"
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
)
311 get_client
= relationship(Client
)
313 class AccessToken(Base
):
315 Model for representing the access tokens
317 __tablename__
= "core__access_tokens"
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
)
326 get_requesttoken
= relationship(RequestToken
)
329 class NonceTimestamp(Base
):
331 A place the timestamp and nonce can be stored - this is for OAuth1
333 __tablename__
= "core__nonce_timestamps"
335 nonce
= Column(Unicode
, nullable
=False, primary_key
=True)
336 timestamp
= Column(DateTime
, nullable
=False, primary_key
=True)
338 class MediaEntry(Base
, MediaEntryMixin
):
340 TODO: Consider fetching the media_files using join
342 __tablename__
= "core__media_entries"
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
,
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")
359 fail_error
= Column(Unicode
)
360 fail_metadata
= Column(JSONEncoded
)
362 transcoding_progress
= Column(SmallInteger
)
364 queued_media_file
= Column(PathTupleWithSlashes
)
366 queued_task_id
= Column(Unicode
)
369 UniqueConstraint('uploader', 'slug'),
372 get_uploader
= relationship(User
)
374 media_files_helper
= relationship("MediaFile",
375 collection_class
=attribute_mapped_collection("name"),
376 cascade
="all, delete-orphan"
378 media_files
= association_proxy('media_files_helper', 'file_path',
379 creator
=lambda k
, v
: MediaFile(name
=k
, file_path
=v
)
382 attachment_files_helper
= relationship("MediaAttachmentFile",
383 cascade
="all, delete-orphan",
384 order_by
="MediaAttachmentFile.created"
386 attachment_files
= association_proxy("attachment_files_helper", "dict_view",
387 creator
=lambda v
: MediaAttachmentFile(
388 name
=v
["name"], filepath
=v
["filepath"])
391 tags_helper
= relationship("MediaTag",
392 cascade
="all, delete-orphan" # should be automatically deleted
394 tags
= association_proxy("tags_helper", "dict_view",
395 creator
=lambda v
: MediaTag(name
=v
["name"], slug
=v
["slug"])
398 collections_helper
= relationship("CollectionItem",
399 cascade
="all, delete-orphan"
401 collections
= association_proxy("collections_helper", "in_collection")
402 media_metadata
= Column(MutationDict
.as_mutable(JSONEncoded
),
403 default
=MutationDict())
405 activity
= Column(Integer
, ForeignKey("core__activity_intermediators.id"))
410 def get_comments(self
, ascending
=False):
411 order_col
= MediaComment
.created
413 order_col
= desc(order_col
)
414 return self
.all_comments
.order_by(order_col
)
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()
423 if media
is not None:
424 return media
.url_for_self(urlgen
)
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()
433 if media
is not None:
434 return media
.url_for_self(urlgen
)
436 def get_file_metadata(self
, file_key
, metadata_key
=None):
438 Return the file_metadata dict of a MediaFile. If metadata_key is given,
439 return the value of the key.
441 media_file
= MediaFile
.query
.filter_by(media_entry
=self
.id,
442 name
=six
.text_type(file_key
)).first()
446 return media_file
.file_metadata
.get(metadata_key
, None)
448 return media_file
.file_metadata
450 def set_file_metadata(self
, file_key
, **kwargs
):
452 Update the file_metadata of a MediaFile.
454 media_file
= MediaFile
.query
.filter_by(media_entry
=self
.id,
455 name
=six
.text_type(file_key
)).first()
457 file_metadata
= media_file
.file_metadata
or {}
459 for key
, value
in six
.iteritems(kwargs
):
460 file_metadata
[key
] = value
462 media_file
.file_metadata
= file_metadata
466 def media_data(self
):
467 return getattr(self
, self
.media_data_ref
)
469 def media_data_init(self
, **kwargs
):
471 Initialize or update the contents of a media entry's media_data row
473 media_data
= self
.media_data
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
483 # Update old media data
484 for field
, value
in six
.iteritems(kwargs
):
485 setattr(media_data
, field
, value
)
488 def media_data_ref(self
):
489 return import_component(self
.media_type
+ '.models:BACKREF_NAME')
493 # obj.__repr__() should return a str on Python 2
494 safe_title
= self
.title
.encode('utf-8', 'replace')
496 safe_title
= self
.title
498 return '<{classname} {id}: {title}>'.format(
499 classname
=self
.__class
__.__name
__,
503 def delete(self
, del_orphan_tags
=True, **kwargs
):
504 """Delete MediaEntry and all related files/attachments/comments
506 This will *not* automatically delete unused collections, which
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.
514 # Delete all related files/attachments
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.
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
)
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
,
540 author
= self
.get_uploader
541 published
= UTC
.localize(self
.created
)
542 updated
= UTC
.localize(self
.created
)
545 "author": author
.serialize(request
),
546 "objectType": self
.object_type
,
547 "url": self
.url_for_self(request
.urlgen
, qualified
=True),
549 "url": request
.host_url
+ self
.thumb_url
[1:],
552 "url": request
.host_url
+ self
.original_url
[1:],
554 "published": published
.isoformat(),
555 "updated": updated
.isoformat(),
568 context
["displayName"] = self
.title
571 context
["content"] = self
.description
574 context
["license"] = self
.license
577 context
["location"] = self
.get_location
.serialize(request
)
581 comment
.serialize(request
) for comment
in self
.get_comments()]
582 total
= len(comments
)
583 context
["replies"] = {
586 "url": request
.urlgen(
587 "mediagoblin.api.object.comments",
588 object_type
=self
.object_type
,
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
612 def unserialize(self
, data
):
613 """ Takes API objects and unserializes on existing MediaEntry """
614 if "displayName" in data
:
615 self
.title
= data
["displayName"]
617 if "content" in data
:
618 self
.description
= data
["content"]
620 if "license" in data
:
621 self
.license
= data
["license"]
623 if "location" in data
:
624 Licence
.create(data
["location"], self
)
628 class FileKeynames(Base
):
630 keywords for various places.
631 currently the MediaFile keys
633 __tablename__
= "core__file_keynames"
634 id = Column(Integer
, primary_key
=True)
635 name
= Column(Unicode
, unique
=True)
638 return "<FileKeyname %r: %r>" % (self
.id, self
.name
)
641 def find_or_new(cls
, name
):
642 t
= cls
.query
.filter_by(name
=name
).first()
645 return cls(name
=name
)
648 class MediaFile(Base
):
650 TODO: Highly consider moving "name" into a new table.
651 TODO: Consider preloading said table in software
653 __tablename__
= "core__mediafiles"
655 media_entry
= Column(
656 Integer
, ForeignKey(MediaEntry
.id),
658 name_id
= Column(SmallInteger
, ForeignKey(FileKeynames
.id), nullable
=False)
659 file_path
= Column(PathTupleWithSlashes
)
660 file_metadata
= Column(MutationDict
.as_mutable(JSONEncoded
))
663 PrimaryKeyConstraint('media_entry', 'name_id'),
667 return "<MediaFile %s: %r>" % (self
.name
, self
.file_path
)
669 name_helper
= relationship(FileKeynames
, lazy
="joined", innerjoin
=True)
670 name
= association_proxy('name_helper', 'name',
671 creator
=FileKeynames
.find_or_new
675 class MediaAttachmentFile(Base
):
676 __tablename__
= "core__attachment_files"
678 id = Column(Integer
, primary_key
=True)
679 media_entry
= Column(
680 Integer
, ForeignKey(MediaEntry
.id),
682 name
= Column(Unicode
, nullable
=False)
683 filepath
= Column(PathTupleWithSlashes
)
684 created
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
688 """A dict like view on this object"""
689 return DictReadAttrProxy(self
)
693 __tablename__
= "core__tags"
695 id = Column(Integer
, primary_key
=True)
696 slug
= Column(Unicode
, nullable
=False, unique
=True)
699 return "<Tag %r: %r>" % (self
.id, self
.slug
)
702 def find_or_new(cls
, slug
):
703 t
= cls
.query
.filter_by(slug
=slug
).first()
706 return cls(slug
=slug
)
709 class MediaTag(Base
):
710 __tablename__
= "core__media_tags"
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)
721 UniqueConstraint('tag', 'media_entry'),
724 tag_helper
= relationship(Tag
)
725 slug
= association_proxy('tag_helper', 'slug',
726 creator
=Tag
.find_or_new
729 def __init__(self
, name
=None, slug
=None):
734 self
.tag_helper
= Tag
.find_or_new(slug
)
738 """A dict like view on this object"""
739 return DictReadAttrProxy(self
)
742 class MediaComment(Base
, MediaCommentMixin
):
743 __tablename__
= "core__media_comments"
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")
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",
760 cascade
="all, delete-orphan"))
761 get_entry
= relationship(MediaEntry
,
762 backref
=backref("comments",
764 cascade
="all, delete-orphan"))
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",
773 cascade
="all, delete-orphan"))
776 activity
= Column(Integer
, ForeignKey("core__activity_intermediators.id"))
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
,
786 media
= MediaEntry
.query
.filter_by(id=self
.media_entry
).first()
787 author
= self
.get_author
788 published
= UTC
.localize(self
.created
)
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(),
800 context
["location"] = self
.get_location
.seralize(request
)
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
810 media_id
= int(extract_url_arguments(
811 url
=data
["inReplyTo"]["id"],
812 urlmap
=request
.app
.url_map
817 media
= MediaEntry
.query
.filter_by(id=media_id
).first()
821 self
.media_entry
= media
.id
823 if "content" in data
:
824 self
.content
= data
["content"]
826 if "location" in data
:
827 Location
.create(data
["location"], self
)
833 class Collection(Base
, CollectionMixin
):
834 """An 'album' or 'set' of media by a user.
836 On deletion, contained CollectionItems get automatically reaped via
838 __tablename__
= "core__collections"
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
,
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")
850 # TODO: No of items in Collection. Badly named, can we migrate to num_items?
851 items
= Column(Integer
, default
=0)
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"))
858 activity
= Column(Integer
, ForeignKey("core__activity_intermediators.id"))
861 UniqueConstraint('creator', 'slug'),
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
868 order_col
= desc(order_col
)
869 return CollectionItem
.query
.filter_by(
870 collection
=self
.id).order_by(order_col
)
873 safe_title
= self
.title
.encode('ascii', 'replace')
874 return '<{classname} #{id}: {title} by {creator}>'.format(
876 classname
=self
.__class
__.__name
__,
877 creator
=self
.creator
,
880 def serialize(self
, request
):
881 # Get all serialized output in a list
883 for item
in self
.get_collection_items():
884 items
.append(item
.serialize(request
))
887 "totalItems": self
.items
,
888 "url": self
.url_for_self(request
.urlgen
, qualified
=True),
893 class CollectionItem(Base
, CollectionItemMixin
):
894 __tablename__
= "core__collection_items"
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
)
904 # Cascade: CollectionItems are owned by their Collection. So do the full thing.
905 in_collection
= relationship(Collection
,
908 cascade
="all, delete-orphan"))
910 get_media_entry
= relationship(MediaEntry
)
913 UniqueConstraint('collection', 'media_entry'),
918 """A dict like view on this object"""
919 return DictReadAttrProxy(self
)
922 return '<{classname} #{id}: Entry {entry} in {collection}>'.format(
924 classname
=self
.__class
__.__name
__,
925 collection
=self
.collection
,
926 entry
=self
.media_entry
)
928 def serialize(self
, request
):
929 return self
.get_media_entry
.serialize(request
)
932 class ProcessingMetaData(Base
):
933 __tablename__
= 'core__processing_metadata'
935 id = Column(Integer
, primary_key
=True)
936 media_entry_id
= Column(Integer
, ForeignKey(MediaEntry
.id), nullable
=False,
938 media_entry
= relationship(MediaEntry
,
939 backref
=backref('processing_metadata',
940 cascade
='all, delete-orphan'))
941 callback_url
= Column(Unicode
)
945 """A dict like view on this object"""
946 return DictReadAttrProxy(self
)
949 class CommentSubscription(Base
):
950 __tablename__
= 'core__comment_subscriptions'
951 id = Column(Integer
, primary_key
=True)
953 created
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
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'))
960 user_id
= Column(Integer
, ForeignKey(User
.id), nullable
=False)
961 user
= relationship(User
,
962 backref
=backref('comment_subscriptions',
963 cascade
='all, delete-orphan'))
965 notify
= Column(Boolean
, nullable
=False, default
=True)
966 send_email
= Column(Boolean
, nullable
=False, default
=True)
969 return ('<{classname} #{id}: {user} {media} notify: '
970 '{notify} email: {email}>').format(
972 classname
=self
.__class
__.__name
__,
974 media
=self
.media_entry
,
976 email
=self
.send_email
)
979 class Notification(Base
):
980 __tablename__
= 'core__notifications'
981 id = Column(Integer
, primary_key
=True)
982 type = Column(Unicode
)
984 created
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
986 user_id
= Column(Integer
, ForeignKey('core__users.id'), nullable
=False,
988 seen
= Column(Boolean
, default
=lambda: False, index
=True)
991 backref
=backref('notifications', cascade
='all, delete-orphan'))
994 'polymorphic_identity': 'notification',
995 'polymorphic_on': type
999 return '<{klass} #{id}: {user}: {subject} ({seen})>'.format(
1001 klass
=self
.__class
__.__name
__,
1003 subject
=getattr(self
, 'subject', None),
1004 seen
='unseen' if not self
.seen
else 'seen')
1006 def __unicode__(self
):
1007 return u
'<{klass} #{id}: {user}: {subject} ({seen})>'.format(
1009 klass
=self
.__class
__.__name
__,
1011 subject
=getattr(self
, 'subject', None),
1012 seen
='unseen' if not self
.seen
else 'seen')
1015 class CommentNotification(Notification
):
1016 __tablename__
= 'core__comment_notifications'
1017 id = Column(Integer
, ForeignKey(Notification
.id), primary_key
=True)
1019 subject_id
= Column(Integer
, ForeignKey(MediaComment
.id))
1020 subject
= relationship(
1022 backref
=backref('comment_notifications', cascade
='all, delete-orphan'))
1025 'polymorphic_identity': 'comment_notification'
1029 class ProcessingNotification(Notification
):
1030 __tablename__
= 'core__processing_notifications'
1032 id = Column(Integer
, ForeignKey(Notification
.id), primary_key
=True)
1034 subject_id
= Column(Integer
, ForeignKey(MediaEntry
.id))
1035 subject
= relationship(
1037 backref
=backref('processing_notifications',
1038 cascade
='all, delete-orphan'))
1041 'polymorphic_identity': 'processing_notification'
1044 # the with_polymorphic call has been moved to the bottom above MODELS
1045 # this is because it causes conflicts with relationship calls.
1047 class ReportBase(Base
):
1049 This is the basic report object which the other reports are based off of.
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
1057 :keyword reported_user_id Holds the id of the user who created
1058 the content which was reported, as
1060 :keyword created Holds a datetime column of when the re-
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
1073 __tablename__
= 'core__reports'
1074 id = Column(Integer
, primary_key
=True)
1075 reporter_id
= Column(Integer
, ForeignKey(User
.id), nullable
=False)
1076 reporter
= relationship(
1078 backref
=backref("reports_filed_by",
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(
1086 backref
=backref("reports_filed_on",
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(
1095 backref
=backref("reports_resolved_by",
1097 cascade
="all, delete-orphan"),
1098 primaryjoin
="User.id==ReportBase.resolver_id")
1100 resolved
= Column(DateTime
)
1101 result
= Column(UnicodeText
)
1102 __mapper_args__
= {'polymorphic_on': discriminator
}
1104 def is_comment_report(self
):
1105 return self
.discriminator
=='comment_report'
1107 def is_media_entry_report(self
):
1108 return self
.discriminator
=='media_report'
1110 def is_archived_report(self
):
1111 return self
.resolved
is not None
1113 def archive(self
,resolver_id
, resolved
, result
):
1114 self
.resolver_id
= resolver_id
1115 self
.resolved
= resolved
1116 self
.result
= result
1119 class CommentReport(ReportBase
):
1121 Reports that have been filed on comments.
1122 :keyword comment_id Holds the integer value of the reported
1125 __tablename__
= 'core__reports_on_comments'
1126 __mapper_args__
= {'polymorphic_identity': 'comment_report'}
1128 id = Column('id',Integer
, ForeignKey('core__reports.id'),
1130 comment_id
= Column(Integer
, ForeignKey(MediaComment
.id), nullable
=True)
1131 comment
= relationship(
1132 MediaComment
, backref
=backref("reports_filed_on",
1136 class MediaReport(ReportBase
):
1138 Reports that have been filed on media entries
1139 :keyword media_entry_id Holds the integer value of the reported
1142 __tablename__
= 'core__reports_on_media'
1143 __mapper_args__
= {'polymorphic_identity': 'media_report'}
1145 id = Column('id',Integer
, ForeignKey('core__reports.id'),
1147 media_entry_id
= Column(Integer
, ForeignKey(MediaEntry
.id), nullable
=True)
1148 media_entry
= relationship(
1150 backref
=backref("reports_filed_on",
1153 class UserBan(Base
):
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
1161 :keyword user_id Holds the id of the user this object is
1162 attached to. This is a one-to-one
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.
1169 __tablename__
= 'core__user_bans'
1171 user_id
= Column(Integer
, ForeignKey(User
.id), nullable
=False,
1173 expiration_date
= Column(Date
)
1174 reason
= Column(UnicodeText
, nullable
=False)
1177 class Privilege(Base
):
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
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.
1189 __tablename__
= 'core__privileges'
1191 id = Column(Integer
, nullable
=False, primary_key
=True)
1192 privilege_name
= Column(Unicode
, nullable
=False, unique
=True)
1193 all_users
= relationship(
1195 backref
='all_privileges',
1196 secondary
="core__privileges_users")
1198 def __init__(self
, privilege_name
):
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
1204 self
.privilege_name
= privilege_name
1207 return "<Privilege %s>" % (self
.privilege_name
)
1210 class PrivilegeUserAssociation(Base
):
1212 This table holds the many-to-many relationship between User and Privilege
1215 __tablename__
= 'core__privileges_users'
1220 ForeignKey(User
.id),
1225 ForeignKey(Privilege
.id),
1228 class Generator(Base
):
1229 """ Information about what created an activity """
1230 __tablename__
= "core__generators"
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)
1239 return "<{klass} {name}>".format(
1240 klass
=self
.__class
__.__name
__,
1244 def serialize(self
, request
):
1245 href
= request
.urlgen(
1246 "mediagoblin.api.object",
1247 object_type
=self
.object_type
,
1251 published
= UTC
.localize(self
.published
)
1252 updated
= UTC
.localize(self
.updated
)
1255 "displayName": self
.name
,
1256 "published": published
.isoformat(),
1257 "updated": updated
.isoformat(),
1258 "objectType": self
.object_type
,
1261 def unserialize(self
, data
):
1262 if "displayName" in data
:
1263 self
.name
= data
["displayName"]
1266 class ActivityIntermediator(Base
):
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.
1273 __tablename__
= "core__activity_intermediators"
1275 id = Column(Integer
, primary_key
=True)
1276 type = Column(Unicode
, nullable
=False)
1280 "media": MediaEntry
,
1281 "comment": MediaComment
,
1282 "collection": Collection
,
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
):
1294 """ This sets itself as the activity """
1295 key
, model
= self
._find
_model
(obj
)
1297 raise ValueError("Invalid type of object given")
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
1308 """ Finds the object for an activity """
1309 if self
.type is None:
1312 model
= self
.TYPES
[self
.type]
1313 return model
.query
.filter_by(activity
=self
.id).first()
1316 def validate_type(self
, key
, value
):
1317 """ Validate that the type set is a valid type """
1318 assert value
in self
.TYPES
1321 class Activity(Base
, ActivityMixin
):
1323 This holds all the metadata about an activity such as uploading an image,
1324 posting a comment, etc.
1326 __tablename__
= "core__activities"
1328 id = Column(Integer
, primary_key
=True)
1329 actor
= Column(Integer
,
1330 ForeignKey("core__users.id"),
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"),
1340 object = Column(Integer
,
1341 ForeignKey("core__activity_intermediators.id"),
1343 target
= Column(Integer
,
1344 ForeignKey("core__activity_intermediators.id"),
1347 get_actor
= relationship(User
,
1348 backref
=backref("activities",
1349 cascade
="all, delete-orphan"))
1350 get_generator
= relationship(Generator
)
1353 if self
.content
is None:
1354 return "<{klass} verb:{verb}>".format(
1355 klass
=self
.__class
__.__name
__,
1359 return "<{klass} {content}>".format(
1360 klass
=self
.__class
__.__name
__,
1361 content
=self
.content
1365 def get_object(self
):
1366 if self
.object is None:
1369 ai
= ActivityIntermediator
.query
.filter_by(id=self
.object).first()
1372 def set_object(self
, obj
):
1373 self
.object = self
._set
_model
(obj
)
1376 def get_target(self
):
1377 if self
.target
is None:
1380 ai
= ActivityIntermediator
.query
.filter_by(id=self
.target
).first()
1383 def set_target(self
, obj
):
1384 self
.target
= self
._set
_model
(obj
)
1386 def _set_model(self
, obj
):
1387 # Firstly can we set obj
1388 if not hasattr(obj
, "activity"):
1390 "{0!r} is unable to be set on activity".format(obj
))
1392 if obj
.activity
is None:
1393 # We need to create a new AI
1394 ai
= ActivityIntermediator()
1399 # Okay we should have an existing AI
1400 return ActivityIntermediator
.query
.filter_by(id=obj
.activity
).first().id
1402 def save(self
, set_updated
=True, *args
, **kwargs
):
1404 self
.updated
= datetime
.datetime
.now()
1405 super(Activity
, self
).save(*args
, **kwargs
)
1409 [ProcessingNotification
, CommentNotification
])
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
,
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
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}]
1433 FOUNDATIONS = {User:user_foundations}
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
}
1443 ######################################################
1444 # Special, migrations-tracking table
1446 # Not listed in MODELS because this is special and not
1447 # really migrated, but used for migrations (for now)
1448 ######################################################
1450 class MigrationData(Base
):
1451 __tablename__
= "core__migrations"
1453 name
= Column(Unicode
, primary_key
=True)
1454 version
= Column(Integer
, nullable
=False, default
=0)
1456 ######################################################
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)
1465 Base
.metadata
.create_all(engine
)
1468 if __name__
== '__main__':
1469 from sys
import argv
1475 show_table_init(uri
)