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
, \
31 from sqlalchemy
.orm
.collections
import attribute_mapped_collection
32 from sqlalchemy
.sql
.expression
import desc
33 from sqlalchemy
.ext
.associationproxy
import association_proxy
34 from sqlalchemy
.util
import memoized_property
36 from mediagoblin
.db
.extratypes
import (PathTupleWithSlashes
, JSONEncoded
,
38 from mediagoblin
.db
.base
import Base
, DictReadAttrProxy
39 from mediagoblin
.db
.mixin
import UserMixin
, MediaEntryMixin
, \
40 MediaCommentMixin
, CollectionMixin
, CollectionItemMixin
, \
42 from mediagoblin
.tools
.files
import delete_media_files
43 from mediagoblin
.tools
.common
import import_component
44 from mediagoblin
.tools
.routing
import extract_url_arguments
49 _log
= logging
.getLogger(__name__
)
51 class GenericModelReference(Base
):
53 Represents a relationship to any model that is defined with a integer pk
55 NB: This model should not be used directly but through the GenericForeignKey
58 __tablename__
= "core__generic_model_reference"
60 id = Column(Integer
, primary_key
=True)
61 obj_pk
= Column(Integer
, nullable
=False)
63 # This will be the tablename of the model
64 model_type
= Column(Unicode
, nullable
=False)
67 # This can happen if it's yet to be saved
68 if self
.model_type
is None or self
.obj_pk
is None:
71 model
= self
._get
_model
_from
_type
(self
.model_type
)
72 return model
.query
.filter_by(id=self
.obj_pk
)
74 def set_object(self
, obj
):
77 # Check we've been given a object
78 if not issubclass(model
, Base
):
79 raise ValueError("Only models can be set as GenericForeignKeys")
81 # Check that the model has an explicit __tablename__ declaration
82 if getattr(model
, "__tablename__", None) is None:
83 raise ValueError("Models must have __tablename__ attribute")
85 # Check that it's not a composite primary key
86 primary_keys
= [key
.name
for key
in class_mapper(model
).primary_key
]
87 if len(primary_keys
) > 1:
88 raise ValueError("Models can not have composite primary keys")
90 # Check that the field on the model is a an integer field
91 pk_column
= getattr(model
, primary_keys
[0])
92 if issubclass(Integer
, pk_column
):
93 raise ValueError("Only models with integer pks can be set")
95 # Ensure that everything has it's ID set
96 obj
.save(commit
=False)
99 self
.model_type
= obj
.__tablename
__
101 def _get_model_from_type(self
, model_type
):
102 """ Gets a model from a tablename (model type) """
103 if getattr(self
, "_TYPE_MAP", None) is None:
104 # We want to build on the class (not the instance) a map of all the
105 # models by the table name (type) for easy lookup, this is done on
106 # the class so it can be shared between all instances
108 # to prevent circular imports do import here
109 self
._TYPE
_MAP
= dict(((m
.__tablename
__, m
) for m
in MODELS
))
110 setattr(self
.__class
__._TYPE
_MAP
, self
._TYPE
_MAP
)
112 return self
._TYPE
_MAP
[model_type
]
115 class GenericForeignKey(ForeignKey
):
117 def __init__(self
, *args
, **kwargs
):
118 super(GenericForeignKey
, self
).__init
__(
119 GenericModelReference
.id,
124 def __get__(self
, *args
, **kwargs
):
125 """ Looks up GenericModelReference and model for field """
126 # Find the value of the foreign key.
127 ref
= super(self
, GenericForeignKey
).__get
__(*args
, **kwargs
)
129 # If this hasn't been set yet return None
133 # Look up the GenericModelReference for this.
134 gmr
= GenericModelReference
.query
.filter_by(id=ref
).first()
136 # If it's set to something invalid (i.e. no GMR exists return None)
140 # Ask the GMR for the corresponding model
141 return gmr
.get_object()
144 class Location(Base
):
145 """ Represents a physical location """
146 __tablename__
= "core__locations"
148 id = Column(Integer
, primary_key
=True)
149 name
= Column(Unicode
)
152 position
= Column(MutationDict
.as_mutable(JSONEncoded
))
153 address
= Column(MutationDict
.as_mutable(JSONEncoded
))
156 def create(cls
, data
, obj
):
158 location
.unserialize(data
)
160 obj
.location
= location
.id
163 def serialize(self
, request
):
164 location
= {"objectType": "place"}
166 if self
.name
is not None:
167 location
["displayName"] = self
.name
170 location
["position"] = self
.position
173 location
["address"] = self
.address
177 def unserialize(self
, data
):
178 if "displayName" in data
:
179 self
.name
= data
["displayName"]
184 # nicer way to do this?
185 if "position" in data
:
186 # TODO: deal with ISO 9709 formatted string as position
187 if "altitude" in data
["position"]:
188 self
.position
["altitude"] = data
["position"]["altitude"]
190 if "direction" in data
["position"]:
191 self
.position
["direction"] = data
["position"]["direction"]
193 if "longitude" in data
["position"]:
194 self
.position
["longitude"] = data
["position"]["longitude"]
196 if "latitude" in data
["position"]:
197 self
.position
["latitude"] = data
["position"]["latitude"]
199 if "address" in data
:
200 if "formatted" in data
["address"]:
201 self
.address
["formatted"] = data
["address"]["formatted"]
203 if "streetAddress" in data
["address"]:
204 self
.address
["streetAddress"] = data
["address"]["streetAddress"]
206 if "locality" in data
["address"]:
207 self
.address
["locality"] = data
["address"]["locality"]
209 if "region" in data
["address"]:
210 self
.address
["region"] = data
["address"]["region"]
212 if "postalCode" in data
["address"]:
213 self
.address
["postalCode"] = data
["addresss"]["postalCode"]
215 if "country" in data
["address"]:
216 self
.address
["country"] = data
["address"]["country"]
218 class User(Base
, UserMixin
):
220 TODO: We should consider moving some rarely used fields
221 into some sort of "shadow" table.
223 __tablename__
= "core__users"
225 id = Column(Integer
, primary_key
=True)
226 username
= Column(Unicode
, nullable
=False, unique
=True)
227 # Note: no db uniqueness constraint on email because it's not
228 # reliable (many email systems case insensitive despite against
229 # the RFC) and because it would be a mess to implement at this
231 email
= Column(Unicode
, nullable
=False)
232 pw_hash
= Column(Unicode
)
233 created
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
234 # Intented to be nullable=False, but migrations would not work for it
235 # set to nullable=True implicitly.
236 wants_comment_notification
= Column(Boolean
, default
=True)
237 wants_notifications
= Column(Boolean
, default
=True)
238 license_preference
= Column(Unicode
)
239 url
= Column(Unicode
)
240 bio
= Column(UnicodeText
) # ??
241 uploaded
= Column(Integer
, default
=0)
242 upload_limit
= Column(Integer
)
243 location
= Column(Integer
, ForeignKey("core__locations.id"))
244 get_location
= relationship("Location", lazy
="joined")
246 activity
= Column(Integer
, ForeignKey("core__activity_intermediators.id"))
249 # plugin data would be in a separate model
252 return '<{0} #{1} {2} {3} "{4}">'.format(
253 self
.__class
__.__name
__,
255 'verified' if self
.has_privilege(u
'active') else 'non-verified',
256 'admin' if self
.has_privilege(u
'admin') else 'user',
259 def delete(self
, **kwargs
):
260 """Deletes a User and all related entries/comments/files/..."""
261 # Collections get deleted by relationships.
263 media_entries
= MediaEntry
.query
.filter(MediaEntry
.uploader
== self
.id)
264 for media
in media_entries
:
265 # TODO: Make sure that "MediaEntry.delete()" also deletes
266 # all related files/Comments
267 media
.delete(del_orphan_tags
=False, commit
=False)
269 # Delete now unused tags
270 # TODO: import here due to cyclic imports!!! This cries for refactoring
271 from mediagoblin
.db
.util
import clean_orphan_tags
272 clean_orphan_tags(commit
=False)
274 # Delete user, pass through commit=False/True in kwargs
275 super(User
, self
).delete(**kwargs
)
276 _log
.info('Deleted user "{0}" account'.format(self
.username
))
278 def has_privilege(self
, privilege
, allow_admin
=True):
280 This method checks to make sure a user has all the correct privileges
281 to access a piece of content.
283 :param privilege A unicode object which represent the different
284 privileges which may give the user access to
287 :param allow_admin If this is set to True the then if the user is
288 an admin, then this will always return True
289 even if the user hasn't been given the
290 privilege. (defaults to True)
292 priv
= Privilege
.query
.filter_by(privilege_name
=privilege
).one()
293 if priv
in self
.all_privileges
:
295 elif allow_admin
and self
.has_privilege(u
'admin', allow_admin
=False):
302 Checks if this user is banned.
304 :returns True if self is banned
305 :returns False if self is not
307 return UserBan
.query
.get(self
.id) is not None
310 def serialize(self
, request
):
311 published
= UTC
.localize(self
.created
)
313 "id": "acct:{0}@{1}".format(self
.username
, request
.host
),
314 "published": published
.isoformat(),
315 "preferredUsername": self
.username
,
316 "displayName": "{0}@{1}".format(self
.username
, request
.host
),
317 "objectType": self
.object_type
,
324 "href": request
.urlgen(
325 "mediagoblin.api.user.profile",
326 username
=self
.username
,
331 "href": request
.urlgen(
332 "mediagoblin.api.inbox",
333 username
=self
.username
,
338 "href": request
.urlgen(
339 "mediagoblin.api.feed",
340 username
=self
.username
,
348 user
.update({"summary": self
.bio
})
350 user
.update({"url": self
.url
})
352 user
.update({"location": self
.get_location
.serialize(request
)})
356 def unserialize(self
, data
):
357 if "summary" in data
:
358 self
.bio
= data
["summary"]
360 if "location" in data
:
361 Location
.create(data
, self
)
365 Model representing a client - Used for API Auth
367 __tablename__
= "core__clients"
369 id = Column(Unicode
, nullable
=True, primary_key
=True)
370 secret
= Column(Unicode
, nullable
=False)
371 expirey
= Column(DateTime
, nullable
=True)
372 application_type
= Column(Unicode
, nullable
=False)
373 created
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
374 updated
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
377 redirect_uri
= Column(JSONEncoded
, nullable
=True)
378 logo_url
= Column(Unicode
, nullable
=True)
379 application_name
= Column(Unicode
, nullable
=True)
380 contacts
= Column(JSONEncoded
, nullable
=True)
383 if self
.application_name
:
384 return "<Client {0} - {1}>".format(self
.application_name
, self
.id)
386 return "<Client {0}>".format(self
.id)
388 class RequestToken(Base
):
390 Model for representing the request tokens
392 __tablename__
= "core__request_tokens"
394 token
= Column(Unicode
, primary_key
=True)
395 secret
= Column(Unicode
, nullable
=False)
396 client
= Column(Unicode
, ForeignKey(Client
.id))
397 user
= Column(Integer
, ForeignKey(User
.id), nullable
=True)
398 used
= Column(Boolean
, default
=False)
399 authenticated
= Column(Boolean
, default
=False)
400 verifier
= Column(Unicode
, nullable
=True)
401 callback
= Column(Unicode
, nullable
=False, default
=u
"oob")
402 created
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
403 updated
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
405 get_client
= relationship(Client
)
407 class AccessToken(Base
):
409 Model for representing the access tokens
411 __tablename__
= "core__access_tokens"
413 token
= Column(Unicode
, nullable
=False, primary_key
=True)
414 secret
= Column(Unicode
, nullable
=False)
415 user
= Column(Integer
, ForeignKey(User
.id))
416 request_token
= Column(Unicode
, ForeignKey(RequestToken
.token
))
417 created
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
418 updated
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
420 get_requesttoken
= relationship(RequestToken
)
423 class NonceTimestamp(Base
):
425 A place the timestamp and nonce can be stored - this is for OAuth1
427 __tablename__
= "core__nonce_timestamps"
429 nonce
= Column(Unicode
, nullable
=False, primary_key
=True)
430 timestamp
= Column(DateTime
, nullable
=False, primary_key
=True)
432 class MediaEntry(Base
, MediaEntryMixin
):
434 TODO: Consider fetching the media_files using join
436 __tablename__
= "core__media_entries"
438 id = Column(Integer
, primary_key
=True)
439 uploader
= Column(Integer
, ForeignKey(User
.id), nullable
=False, index
=True)
440 title
= Column(Unicode
, nullable
=False)
441 slug
= Column(Unicode
)
442 created
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
,
444 description
= Column(UnicodeText
) # ??
445 media_type
= Column(Unicode
, nullable
=False)
446 state
= Column(Unicode
, default
=u
'unprocessed', nullable
=False)
447 # or use sqlalchemy.types.Enum?
448 license
= Column(Unicode
)
449 file_size
= Column(Integer
, default
=0)
450 location
= Column(Integer
, ForeignKey("core__locations.id"))
451 get_location
= relationship("Location", lazy
="joined")
453 fail_error
= Column(Unicode
)
454 fail_metadata
= Column(JSONEncoded
)
456 transcoding_progress
= Column(SmallInteger
)
458 queued_media_file
= Column(PathTupleWithSlashes
)
460 queued_task_id
= Column(Unicode
)
463 UniqueConstraint('uploader', 'slug'),
466 get_uploader
= relationship(User
)
468 media_files_helper
= relationship("MediaFile",
469 collection_class
=attribute_mapped_collection("name"),
470 cascade
="all, delete-orphan"
472 media_files
= association_proxy('media_files_helper', 'file_path',
473 creator
=lambda k
, v
: MediaFile(name
=k
, file_path
=v
)
476 attachment_files_helper
= relationship("MediaAttachmentFile",
477 cascade
="all, delete-orphan",
478 order_by
="MediaAttachmentFile.created"
480 attachment_files
= association_proxy("attachment_files_helper", "dict_view",
481 creator
=lambda v
: MediaAttachmentFile(
482 name
=v
["name"], filepath
=v
["filepath"])
485 tags_helper
= relationship("MediaTag",
486 cascade
="all, delete-orphan" # should be automatically deleted
488 tags
= association_proxy("tags_helper", "dict_view",
489 creator
=lambda v
: MediaTag(name
=v
["name"], slug
=v
["slug"])
492 collections_helper
= relationship("CollectionItem",
493 cascade
="all, delete-orphan"
495 collections
= association_proxy("collections_helper", "in_collection")
496 media_metadata
= Column(MutationDict
.as_mutable(JSONEncoded
),
497 default
=MutationDict())
499 activity
= Column(Integer
, ForeignKey("core__activity_intermediators.id"))
504 def get_comments(self
, ascending
=False):
505 order_col
= MediaComment
.created
507 order_col
= desc(order_col
)
508 return self
.all_comments
.order_by(order_col
)
510 def url_to_prev(self
, urlgen
):
511 """get the next 'newer' entry by this user"""
512 media
= MediaEntry
.query
.filter(
513 (MediaEntry
.uploader
== self
.uploader
)
514 & (MediaEntry
.state
== u
'processed')
515 & (MediaEntry
.id > self
.id)).order_by(MediaEntry
.id).first()
517 if media
is not None:
518 return media
.url_for_self(urlgen
)
520 def url_to_next(self
, urlgen
):
521 """get the next 'older' entry by this user"""
522 media
= MediaEntry
.query
.filter(
523 (MediaEntry
.uploader
== self
.uploader
)
524 & (MediaEntry
.state
== u
'processed')
525 & (MediaEntry
.id < self
.id)).order_by(desc(MediaEntry
.id)).first()
527 if media
is not None:
528 return media
.url_for_self(urlgen
)
530 def get_file_metadata(self
, file_key
, metadata_key
=None):
532 Return the file_metadata dict of a MediaFile. If metadata_key is given,
533 return the value of the key.
535 media_file
= MediaFile
.query
.filter_by(media_entry
=self
.id,
536 name
=six
.text_type(file_key
)).first()
540 return media_file
.file_metadata
.get(metadata_key
, None)
542 return media_file
.file_metadata
544 def set_file_metadata(self
, file_key
, **kwargs
):
546 Update the file_metadata of a MediaFile.
548 media_file
= MediaFile
.query
.filter_by(media_entry
=self
.id,
549 name
=six
.text_type(file_key
)).first()
551 file_metadata
= media_file
.file_metadata
or {}
553 for key
, value
in six
.iteritems(kwargs
):
554 file_metadata
[key
] = value
556 media_file
.file_metadata
= file_metadata
560 def media_data(self
):
561 return getattr(self
, self
.media_data_ref
)
563 def media_data_init(self
, **kwargs
):
565 Initialize or update the contents of a media entry's media_data row
567 media_data
= self
.media_data
569 if media_data
is None:
570 # Get the correct table:
571 table
= import_component(self
.media_type
+ '.models:DATA_MODEL')
572 # No media data, so actually add a new one
573 media_data
= table(**kwargs
)
574 # Get the relationship set up.
575 media_data
.get_media_entry
= self
577 # Update old media data
578 for field
, value
in six
.iteritems(kwargs
):
579 setattr(media_data
, field
, value
)
582 def media_data_ref(self
):
583 return import_component(self
.media_type
+ '.models:BACKREF_NAME')
587 # obj.__repr__() should return a str on Python 2
588 safe_title
= self
.title
.encode('utf-8', 'replace')
590 safe_title
= self
.title
592 return '<{classname} {id}: {title}>'.format(
593 classname
=self
.__class
__.__name
__,
597 def delete(self
, del_orphan_tags
=True, **kwargs
):
598 """Delete MediaEntry and all related files/attachments/comments
600 This will *not* automatically delete unused collections, which
603 :param del_orphan_tags: True/false if we delete unused Tags too
604 :param commit: True/False if this should end the db transaction"""
605 # User's CollectionItems are automatically deleted via "cascade".
606 # Comments on this Media are deleted by cascade, hopefully.
608 # Delete all related files/attachments
610 delete_media_files(self
)
611 except OSError as error
:
612 # Returns list of files we failed to delete
613 _log
.error('No such files from the user "{1}" to delete: '
614 '{0}'.format(str(error
), self
.get_uploader
))
615 _log
.info('Deleted Media entry id "{0}"'.format(self
.id))
616 # Related MediaTag's are automatically cleaned, but we might
617 # want to clean out unused Tag's too.
619 # TODO: Import here due to cyclic imports!!!
620 # This cries for refactoring
621 from mediagoblin
.db
.util
import clean_orphan_tags
622 clean_orphan_tags(commit
=False)
623 # pass through commit=False/True in kwargs
624 super(MediaEntry
, self
).delete(**kwargs
)
626 def serialize(self
, request
, show_comments
=True):
627 """ Unserialize MediaEntry to object """
628 href
= request
.urlgen(
629 "mediagoblin.api.object",
630 object_type
=self
.object_type
,
634 author
= self
.get_uploader
635 published
= UTC
.localize(self
.created
)
636 updated
= UTC
.localize(self
.created
)
639 "author": author
.serialize(request
),
640 "objectType": self
.object_type
,
641 "url": self
.url_for_self(request
.urlgen
, qualified
=True),
643 "url": request
.host_url
+ self
.thumb_url
[1:],
646 "url": request
.host_url
+ self
.original_url
[1:],
648 "published": published
.isoformat(),
649 "updated": updated
.isoformat(),
662 context
["displayName"] = self
.title
665 context
["content"] = self
.description
668 context
["license"] = self
.license
671 context
["location"] = self
.get_location
.serialize(request
)
675 comment
.serialize(request
) for comment
in self
.get_comments()]
676 total
= len(comments
)
677 context
["replies"] = {
680 "url": request
.urlgen(
681 "mediagoblin.api.object.comments",
682 object_type
=self
.object_type
,
688 # Add image height and width if possible. We didn't use to store this
689 # data and we're not able (and maybe not willing) to re-process all
690 # images so it's possible this might not exist.
691 if self
.get_file_metadata("thumb", "height"):
692 height
= self
.get_file_metadata("thumb", "height")
693 context
["image"]["height"] = height
694 if self
.get_file_metadata("thumb", "width"):
695 width
= self
.get_file_metadata("thumb", "width")
696 context
["image"]["width"] = width
697 if self
.get_file_metadata("original", "height"):
698 height
= self
.get_file_metadata("original", "height")
699 context
["fullImage"]["height"] = height
700 if self
.get_file_metadata("original", "height"):
701 width
= self
.get_file_metadata("original", "width")
702 context
["fullImage"]["width"] = width
706 def unserialize(self
, data
):
707 """ Takes API objects and unserializes on existing MediaEntry """
708 if "displayName" in data
:
709 self
.title
= data
["displayName"]
711 if "content" in data
:
712 self
.description
= data
["content"]
714 if "license" in data
:
715 self
.license
= data
["license"]
717 if "location" in data
:
718 Licence
.create(data
["location"], self
)
722 class FileKeynames(Base
):
724 keywords for various places.
725 currently the MediaFile keys
727 __tablename__
= "core__file_keynames"
728 id = Column(Integer
, primary_key
=True)
729 name
= Column(Unicode
, unique
=True)
732 return "<FileKeyname %r: %r>" % (self
.id, self
.name
)
735 def find_or_new(cls
, name
):
736 t
= cls
.query
.filter_by(name
=name
).first()
739 return cls(name
=name
)
742 class MediaFile(Base
):
744 TODO: Highly consider moving "name" into a new table.
745 TODO: Consider preloading said table in software
747 __tablename__
= "core__mediafiles"
749 media_entry
= Column(
750 Integer
, ForeignKey(MediaEntry
.id),
752 name_id
= Column(SmallInteger
, ForeignKey(FileKeynames
.id), nullable
=False)
753 file_path
= Column(PathTupleWithSlashes
)
754 file_metadata
= Column(MutationDict
.as_mutable(JSONEncoded
))
757 PrimaryKeyConstraint('media_entry', 'name_id'),
761 return "<MediaFile %s: %r>" % (self
.name
, self
.file_path
)
763 name_helper
= relationship(FileKeynames
, lazy
="joined", innerjoin
=True)
764 name
= association_proxy('name_helper', 'name',
765 creator
=FileKeynames
.find_or_new
769 class MediaAttachmentFile(Base
):
770 __tablename__
= "core__attachment_files"
772 id = Column(Integer
, primary_key
=True)
773 media_entry
= Column(
774 Integer
, ForeignKey(MediaEntry
.id),
776 name
= Column(Unicode
, nullable
=False)
777 filepath
= Column(PathTupleWithSlashes
)
778 created
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
782 """A dict like view on this object"""
783 return DictReadAttrProxy(self
)
787 __tablename__
= "core__tags"
789 id = Column(Integer
, primary_key
=True)
790 slug
= Column(Unicode
, nullable
=False, unique
=True)
793 return "<Tag %r: %r>" % (self
.id, self
.slug
)
796 def find_or_new(cls
, slug
):
797 t
= cls
.query
.filter_by(slug
=slug
).first()
800 return cls(slug
=slug
)
803 class MediaTag(Base
):
804 __tablename__
= "core__media_tags"
806 id = Column(Integer
, primary_key
=True)
807 media_entry
= Column(
808 Integer
, ForeignKey(MediaEntry
.id),
809 nullable
=False, index
=True)
810 tag
= Column(Integer
, ForeignKey(Tag
.id), nullable
=False, index
=True)
811 name
= Column(Unicode
)
812 # created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
815 UniqueConstraint('tag', 'media_entry'),
818 tag_helper
= relationship(Tag
)
819 slug
= association_proxy('tag_helper', 'slug',
820 creator
=Tag
.find_or_new
823 def __init__(self
, name
=None, slug
=None):
828 self
.tag_helper
= Tag
.find_or_new(slug
)
832 """A dict like view on this object"""
833 return DictReadAttrProxy(self
)
836 class MediaComment(Base
, MediaCommentMixin
):
837 __tablename__
= "core__media_comments"
839 id = Column(Integer
, primary_key
=True)
840 media_entry
= Column(
841 Integer
, ForeignKey(MediaEntry
.id), nullable
=False, index
=True)
842 author
= Column(Integer
, ForeignKey(User
.id), nullable
=False)
843 created
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
844 content
= Column(UnicodeText
, nullable
=False)
845 location
= Column(Integer
, ForeignKey("core__locations.id"))
846 get_location
= relationship("Location", lazy
="joined")
848 # Cascade: Comments are owned by their creator. So do the full thing.
849 # lazy=dynamic: People might post a *lot* of comments,
850 # so make the "posted_comments" a query-like thing.
851 get_author
= relationship(User
,
852 backref
=backref("posted_comments",
854 cascade
="all, delete-orphan"))
855 get_entry
= relationship(MediaEntry
,
856 backref
=backref("comments",
858 cascade
="all, delete-orphan"))
860 # Cascade: Comments are somewhat owned by their MediaEntry.
861 # So do the full thing.
862 # lazy=dynamic: MediaEntries might have many comments,
863 # so make the "all_comments" a query-like thing.
864 get_media_entry
= relationship(MediaEntry
,
865 backref
=backref("all_comments",
867 cascade
="all, delete-orphan"))
870 activity
= Column(Integer
, ForeignKey("core__activity_intermediators.id"))
872 def serialize(self
, request
):
873 """ Unserialize to python dictionary for API """
874 href
= request
.urlgen(
875 "mediagoblin.api.object",
876 object_type
=self
.object_type
,
880 media
= MediaEntry
.query
.filter_by(id=self
.media_entry
).first()
881 author
= self
.get_author
882 published
= UTC
.localize(self
.created
)
885 "objectType": self
.object_type
,
886 "content": self
.content
,
887 "inReplyTo": media
.serialize(request
, show_comments
=False),
888 "author": author
.serialize(request
),
889 "published": published
.isoformat(),
890 "updated": published
.isoformat(),
894 context
["location"] = self
.get_location
.seralize(request
)
898 def unserialize(self
, data
, request
):
899 """ Takes API objects and unserializes on existing comment """
900 # Handle changing the reply ID
901 if "inReplyTo" in data
:
902 # Validate that the ID is correct
904 media_id
= int(extract_url_arguments(
905 url
=data
["inReplyTo"]["id"],
906 urlmap
=request
.app
.url_map
911 media
= MediaEntry
.query
.filter_by(id=media_id
).first()
915 self
.media_entry
= media
.id
917 if "content" in data
:
918 self
.content
= data
["content"]
920 if "location" in data
:
921 Location
.create(data
["location"], self
)
927 class Collection(Base
, CollectionMixin
):
928 """An 'album' or 'set' of media by a user.
930 On deletion, contained CollectionItems get automatically reaped via
932 __tablename__
= "core__collections"
934 id = Column(Integer
, primary_key
=True)
935 title
= Column(Unicode
, nullable
=False)
936 slug
= Column(Unicode
)
937 created
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
,
939 description
= Column(UnicodeText
)
940 creator
= Column(Integer
, ForeignKey(User
.id), nullable
=False)
941 location
= Column(Integer
, ForeignKey("core__locations.id"))
942 get_location
= relationship("Location", lazy
="joined")
944 # TODO: No of items in Collection. Badly named, can we migrate to num_items?
945 items
= Column(Integer
, default
=0)
947 # Cascade: Collections are owned by their creator. So do the full thing.
948 get_creator
= relationship(User
,
949 backref
=backref("collections",
950 cascade
="all, delete-orphan"))
952 activity
= Column(Integer
, ForeignKey("core__activity_intermediators.id"))
955 UniqueConstraint('creator', 'slug'),
958 def get_collection_items(self
, ascending
=False):
959 #TODO, is this still needed with self.collection_items being available?
960 order_col
= CollectionItem
.position
962 order_col
= desc(order_col
)
963 return CollectionItem
.query
.filter_by(
964 collection
=self
.id).order_by(order_col
)
967 safe_title
= self
.title
.encode('ascii', 'replace')
968 return '<{classname} #{id}: {title} by {creator}>'.format(
970 classname
=self
.__class
__.__name
__,
971 creator
=self
.creator
,
974 def serialize(self
, request
):
975 # Get all serialized output in a list
977 for item
in self
.get_collection_items():
978 items
.append(item
.serialize(request
))
981 "totalItems": self
.items
,
982 "url": self
.url_for_self(request
.urlgen
, qualified
=True),
987 class CollectionItem(Base
, CollectionItemMixin
):
988 __tablename__
= "core__collection_items"
990 id = Column(Integer
, primary_key
=True)
991 media_entry
= Column(
992 Integer
, ForeignKey(MediaEntry
.id), nullable
=False, index
=True)
993 collection
= Column(Integer
, ForeignKey(Collection
.id), nullable
=False)
994 note
= Column(UnicodeText
, nullable
=True)
995 added
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
996 position
= Column(Integer
)
998 # Cascade: CollectionItems are owned by their Collection. So do the full thing.
999 in_collection
= relationship(Collection
,
1002 cascade
="all, delete-orphan"))
1004 get_media_entry
= relationship(MediaEntry
)
1007 UniqueConstraint('collection', 'media_entry'),
1011 def dict_view(self
):
1012 """A dict like view on this object"""
1013 return DictReadAttrProxy(self
)
1016 return '<{classname} #{id}: Entry {entry} in {collection}>'.format(
1018 classname
=self
.__class
__.__name
__,
1019 collection
=self
.collection
,
1020 entry
=self
.media_entry
)
1022 def serialize(self
, request
):
1023 return self
.get_media_entry
.serialize(request
)
1026 class ProcessingMetaData(Base
):
1027 __tablename__
= 'core__processing_metadata'
1029 id = Column(Integer
, primary_key
=True)
1030 media_entry_id
= Column(Integer
, ForeignKey(MediaEntry
.id), nullable
=False,
1032 media_entry
= relationship(MediaEntry
,
1033 backref
=backref('processing_metadata',
1034 cascade
='all, delete-orphan'))
1035 callback_url
= Column(Unicode
)
1038 def dict_view(self
):
1039 """A dict like view on this object"""
1040 return DictReadAttrProxy(self
)
1043 class CommentSubscription(Base
):
1044 __tablename__
= 'core__comment_subscriptions'
1045 id = Column(Integer
, primary_key
=True)
1047 created
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
1049 media_entry_id
= Column(Integer
, ForeignKey(MediaEntry
.id), nullable
=False)
1050 media_entry
= relationship(MediaEntry
,
1051 backref
=backref('comment_subscriptions',
1052 cascade
='all, delete-orphan'))
1054 user_id
= Column(Integer
, ForeignKey(User
.id), nullable
=False)
1055 user
= relationship(User
,
1056 backref
=backref('comment_subscriptions',
1057 cascade
='all, delete-orphan'))
1059 notify
= Column(Boolean
, nullable
=False, default
=True)
1060 send_email
= Column(Boolean
, nullable
=False, default
=True)
1063 return ('<{classname} #{id}: {user} {media} notify: '
1064 '{notify} email: {email}>').format(
1066 classname
=self
.__class
__.__name
__,
1068 media
=self
.media_entry
,
1070 email
=self
.send_email
)
1073 class Notification(Base
):
1074 __tablename__
= 'core__notifications'
1075 id = Column(Integer
, primary_key
=True)
1076 type = Column(Unicode
)
1078 created
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
1080 user_id
= Column(Integer
, ForeignKey('core__users.id'), nullable
=False,
1082 seen
= Column(Boolean
, default
=lambda: False, index
=True)
1083 user
= relationship(
1085 backref
=backref('notifications', cascade
='all, delete-orphan'))
1088 'polymorphic_identity': 'notification',
1089 'polymorphic_on': type
1093 return '<{klass} #{id}: {user}: {subject} ({seen})>'.format(
1095 klass
=self
.__class
__.__name
__,
1097 subject
=getattr(self
, 'subject', None),
1098 seen
='unseen' if not self
.seen
else 'seen')
1100 def __unicode__(self
):
1101 return u
'<{klass} #{id}: {user}: {subject} ({seen})>'.format(
1103 klass
=self
.__class
__.__name
__,
1105 subject
=getattr(self
, 'subject', None),
1106 seen
='unseen' if not self
.seen
else 'seen')
1109 class CommentNotification(Notification
):
1110 __tablename__
= 'core__comment_notifications'
1111 id = Column(Integer
, ForeignKey(Notification
.id), primary_key
=True)
1113 subject_id
= Column(Integer
, ForeignKey(MediaComment
.id))
1114 subject
= relationship(
1116 backref
=backref('comment_notifications', cascade
='all, delete-orphan'))
1119 'polymorphic_identity': 'comment_notification'
1123 class ProcessingNotification(Notification
):
1124 __tablename__
= 'core__processing_notifications'
1126 id = Column(Integer
, ForeignKey(Notification
.id), primary_key
=True)
1128 subject_id
= Column(Integer
, ForeignKey(MediaEntry
.id))
1129 subject
= relationship(
1131 backref
=backref('processing_notifications',
1132 cascade
='all, delete-orphan'))
1135 'polymorphic_identity': 'processing_notification'
1138 # the with_polymorphic call has been moved to the bottom above MODELS
1139 # this is because it causes conflicts with relationship calls.
1141 class ReportBase(Base
):
1143 This is the basic report object which the other reports are based off of.
1145 :keyword reporter_id Holds the id of the user who created
1146 the report, as an Integer column.
1147 :keyword report_content Hold the explanation left by the repor-
1148 -ter to indicate why they filed the
1149 report in the first place, as a
1151 :keyword reported_user_id Holds the id of the user who created
1152 the content which was reported, as
1154 :keyword created Holds a datetime column of when the re-
1156 :keyword discriminator This column distinguishes between the
1157 different types of reports.
1158 :keyword resolver_id Holds the id of the moderator/admin who
1159 resolved the report.
1160 :keyword resolved Holds the DateTime object which descri-
1161 -bes when this report was resolved
1162 :keyword result Holds the UnicodeText column of the
1163 resolver's reasons for resolving
1164 the report this way. Some of this
1167 __tablename__
= 'core__reports'
1168 id = Column(Integer
, primary_key
=True)
1169 reporter_id
= Column(Integer
, ForeignKey(User
.id), nullable
=False)
1170 reporter
= relationship(
1172 backref
=backref("reports_filed_by",
1174 cascade
="all, delete-orphan"),
1175 primaryjoin
="User.id==ReportBase.reporter_id")
1176 report_content
= Column(UnicodeText
)
1177 reported_user_id
= Column(Integer
, ForeignKey(User
.id), nullable
=False)
1178 reported_user
= relationship(
1180 backref
=backref("reports_filed_on",
1182 cascade
="all, delete-orphan"),
1183 primaryjoin
="User.id==ReportBase.reported_user_id")
1184 created
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
1185 discriminator
= Column('type', Unicode(50))
1186 resolver_id
= Column(Integer
, ForeignKey(User
.id))
1187 resolver
= relationship(
1189 backref
=backref("reports_resolved_by",
1191 cascade
="all, delete-orphan"),
1192 primaryjoin
="User.id==ReportBase.resolver_id")
1194 resolved
= Column(DateTime
)
1195 result
= Column(UnicodeText
)
1196 __mapper_args__
= {'polymorphic_on': discriminator
}
1198 def is_comment_report(self
):
1199 return self
.discriminator
=='comment_report'
1201 def is_media_entry_report(self
):
1202 return self
.discriminator
=='media_report'
1204 def is_archived_report(self
):
1205 return self
.resolved
is not None
1207 def archive(self
,resolver_id
, resolved
, result
):
1208 self
.resolver_id
= resolver_id
1209 self
.resolved
= resolved
1210 self
.result
= result
1213 class CommentReport(ReportBase
):
1215 Reports that have been filed on comments.
1216 :keyword comment_id Holds the integer value of the reported
1219 __tablename__
= 'core__reports_on_comments'
1220 __mapper_args__
= {'polymorphic_identity': 'comment_report'}
1222 id = Column('id',Integer
, ForeignKey('core__reports.id'),
1224 comment_id
= Column(Integer
, ForeignKey(MediaComment
.id), nullable
=True)
1225 comment
= relationship(
1226 MediaComment
, backref
=backref("reports_filed_on",
1230 class MediaReport(ReportBase
):
1232 Reports that have been filed on media entries
1233 :keyword media_entry_id Holds the integer value of the reported
1236 __tablename__
= 'core__reports_on_media'
1237 __mapper_args__
= {'polymorphic_identity': 'media_report'}
1239 id = Column('id',Integer
, ForeignKey('core__reports.id'),
1241 media_entry_id
= Column(Integer
, ForeignKey(MediaEntry
.id), nullable
=True)
1242 media_entry
= relationship(
1244 backref
=backref("reports_filed_on",
1247 class UserBan(Base
):
1249 Holds the information on a specific user's ban-state. As long as one of
1250 these is attached to a user, they are banned from accessing mediagoblin.
1251 When they try to log in, they are greeted with a page that tells them
1252 the reason why they are banned and when (if ever) the ban will be
1255 :keyword user_id Holds the id of the user this object is
1256 attached to. This is a one-to-one
1258 :keyword expiration_date Holds the date that the ban will be lifted.
1259 If this is null, the ban is permanent
1260 unless a moderator manually lifts it.
1261 :keyword reason Holds the reason why the user was banned.
1263 __tablename__
= 'core__user_bans'
1265 user_id
= Column(Integer
, ForeignKey(User
.id), nullable
=False,
1267 expiration_date
= Column(Date
)
1268 reason
= Column(UnicodeText
, nullable
=False)
1271 class Privilege(Base
):
1273 The Privilege table holds all of the different privileges a user can hold.
1274 If a user 'has' a privilege, the User object is in a relationship with the
1277 :keyword privilege_name Holds a unicode object that is the recognizable
1278 name of this privilege. This is the column
1279 used for identifying whether or not a user
1280 has a necessary privilege or not.
1283 __tablename__
= 'core__privileges'
1285 id = Column(Integer
, nullable
=False, primary_key
=True)
1286 privilege_name
= Column(Unicode
, nullable
=False, unique
=True)
1287 all_users
= relationship(
1289 backref
='all_privileges',
1290 secondary
="core__privileges_users")
1292 def __init__(self
, privilege_name
):
1294 Currently consructors are required for tables that are initialized thru
1295 the FOUNDATIONS system. This is because they need to be able to be con-
1296 -structed by a list object holding their arg*s
1298 self
.privilege_name
= privilege_name
1301 return "<Privilege %s>" % (self
.privilege_name
)
1304 class PrivilegeUserAssociation(Base
):
1306 This table holds the many-to-many relationship between User and Privilege
1309 __tablename__
= 'core__privileges_users'
1314 ForeignKey(User
.id),
1319 ForeignKey(Privilege
.id),
1322 class Generator(Base
):
1323 """ Information about what created an activity """
1324 __tablename__
= "core__generators"
1326 id = Column(Integer
, primary_key
=True)
1327 name
= Column(Unicode
, nullable
=False)
1328 published
= Column(DateTime
, default
=datetime
.datetime
.utcnow
)
1329 updated
= Column(DateTime
, default
=datetime
.datetime
.utcnow
)
1330 object_type
= Column(Unicode
, nullable
=False)
1333 return "<{klass} {name}>".format(
1334 klass
=self
.__class
__.__name
__,
1338 def serialize(self
, request
):
1339 href
= request
.urlgen(
1340 "mediagoblin.api.object",
1341 object_type
=self
.object_type
,
1345 published
= UTC
.localize(self
.published
)
1346 updated
= UTC
.localize(self
.updated
)
1349 "displayName": self
.name
,
1350 "published": published
.isoformat(),
1351 "updated": updated
.isoformat(),
1352 "objectType": self
.object_type
,
1355 def unserialize(self
, data
):
1356 if "displayName" in data
:
1357 self
.name
= data
["displayName"]
1360 class ActivityIntermediator(Base
):
1362 This is used so that objects/targets can have a foreign key back to this
1363 object and activities can a foreign key to this object. This objects to be
1364 used multiple times for the activity object or target and also allows for
1365 different types of objects to be used as an Activity.
1367 __tablename__
= "core__activity_intermediators"
1369 id = Column(Integer
, primary_key
=True)
1370 type = Column(Unicode
, nullable
=False)
1374 "media": MediaEntry
,
1375 "comment": MediaComment
,
1376 "collection": Collection
,
1379 def _find_model(self
, obj
):
1380 """ Finds the model for a given object """
1381 for key
, model
in self
.TYPES
.items():
1382 if isinstance(obj
, model
):
1388 """ This sets itself as the activity """
1389 key
, model
= self
._find
_model
(obj
)
1391 raise ValueError("Invalid type of object given")
1395 # We need to populate the self.id so we need to save but, we don't
1396 # want to save this AI in the database (yet) so commit=False.
1397 self
.save(commit
=False)
1398 obj
.activity
= self
.id
1402 """ Finds the object for an activity """
1403 if self
.type is None:
1406 model
= self
.TYPES
[self
.type]
1407 return model
.query
.filter_by(activity
=self
.id).first()
1410 def validate_type(self
, key
, value
):
1411 """ Validate that the type set is a valid type """
1412 assert value
in self
.TYPES
1415 class Activity(Base
, ActivityMixin
):
1417 This holds all the metadata about an activity such as uploading an image,
1418 posting a comment, etc.
1420 __tablename__
= "core__activities"
1422 id = Column(Integer
, primary_key
=True)
1423 actor
= Column(Integer
,
1424 ForeignKey("core__users.id"),
1426 published
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
1427 updated
= Column(DateTime
, nullable
=False, default
=datetime
.datetime
.utcnow
)
1428 verb
= Column(Unicode
, nullable
=False)
1429 content
= Column(Unicode
, nullable
=True)
1430 title
= Column(Unicode
, nullable
=True)
1431 generator
= Column(Integer
,
1432 ForeignKey("core__generators.id"),
1434 object = Column(Integer
,
1435 GenericForeignKey(),
1437 target
= Column(Integer
,
1438 GenericForeignKey(),
1441 get_actor
= relationship(User
,
1442 backref
=backref("activities",
1443 cascade
="all, delete-orphan"))
1444 get_generator
= relationship(Generator
)
1447 if self
.content
is None:
1448 return "<{klass} verb:{verb}>".format(
1449 klass
=self
.__class
__.__name
__,
1453 return "<{klass} {content}>".format(
1454 klass
=self
.__class
__.__name
__,
1455 content
=self
.content
1458 def save(self
, set_updated
=True, *args
, **kwargs
):
1460 self
.updated
= datetime
.datetime
.now()
1461 super(Activity
, self
).save(*args
, **kwargs
)
1465 [ProcessingNotification
, CommentNotification
])
1468 User
, MediaEntry
, Tag
, MediaTag
, MediaComment
, Collection
, CollectionItem
,
1469 MediaFile
, FileKeynames
, MediaAttachmentFile
, ProcessingMetaData
,
1470 Notification
, CommentNotification
, ProcessingNotification
, Client
,
1471 CommentSubscription
, ReportBase
, CommentReport
, MediaReport
, UserBan
,
1472 Privilege
, PrivilegeUserAssociation
,
1473 RequestToken
, AccessToken
, NonceTimestamp
,
1474 Activity
, ActivityIntermediator
, Generator
,
1475 Location
, GenericModelReference
]
1478 Foundations are the default rows that are created immediately after the tables
1479 are initialized. Each entry to this dictionary should be in the format of:
1480 ModelConstructorObject:List of Dictionaries
1481 (Each Dictionary represents a row on the Table to be created, containing each
1482 of the columns' names as a key string, and each of the columns' values as a
1485 ex. [NOTE THIS IS NOT BASED OFF OF OUR USER TABLE]
1486 user_foundations = [{'name':u'Joanna', 'age':24},
1487 {'name':u'Andrea', 'age':41}]
1489 FOUNDATIONS = {User:user_foundations}
1491 privilege_foundations
= [{'privilege_name':u
'admin'},
1492 {'privilege_name':u
'moderator'},
1493 {'privilege_name':u
'uploader'},
1494 {'privilege_name':u
'reporter'},
1495 {'privilege_name':u
'commenter'},
1496 {'privilege_name':u
'active'}]
1497 FOUNDATIONS
= {Privilege
:privilege_foundations
}
1499 ######################################################
1500 # Special, migrations-tracking table
1502 # Not listed in MODELS because this is special and not
1503 # really migrated, but used for migrations (for now)
1504 ######################################################
1506 class MigrationData(Base
):
1507 __tablename__
= "core__migrations"
1509 name
= Column(Unicode
, primary_key
=True)
1510 version
= Column(Integer
, nullable
=False, default
=0)
1512 ######################################################
1515 def show_table_init(engine_uri
):
1516 if engine_uri
is None:
1517 engine_uri
= 'sqlite:///:memory:'
1518 from sqlalchemy
import create_engine
1519 engine
= create_engine(engine_uri
, echo
=True)
1521 Base
.metadata
.create_all(engine
)
1524 if __name__
== '__main__':
1525 from sys
import argv
1531 show_table_init(uri
)