Do not try to get private attributes for comments
[mediagoblin.git] / mediagoblin / db / models.py
CommitLineData
fbad3a9f 1# GNU MediaGoblin -- federated, autonomous media hosting
7f4ebeed 2# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
fbad3a9f
E
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU Affero General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Affero General Public License for more details.
13#
14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
eea6d276
E
17"""
18TODO: indexes on foreignkeys, where useful.
19"""
20
f6a700e8
BP
21from __future__ import print_function
22
fdc34b8b 23import logging
ccca0fbf
CAW
24import datetime
25
942084fb
JW
26from sqlalchemy import Column, Integer, Unicode, UnicodeText, DateTime, \
27 Boolean, ForeignKey, UniqueConstraint, PrimaryKeyConstraint, \
6185a4b9 28 SmallInteger, Date, types
641ae2f1
JT
29from sqlalchemy.orm import relationship, backref, with_polymorphic, validates, \
30 class_mapper
02db7e0a 31from sqlalchemy.orm.collections import attribute_mapped_collection
0f3bf8d4 32from sqlalchemy.sql import and_
c47a03b9 33from sqlalchemy.sql.expression import desc
02db7e0a 34from sqlalchemy.ext.associationproxy import association_proxy
007ac2e7 35from sqlalchemy.util import memoized_property
ccca0fbf 36
7f9d3ca7
RE
37from mediagoblin.db.extratypes import (PathTupleWithSlashes, JSONEncoded,
38 MutationDict)
64a456a4 39from mediagoblin.db.base import Base, DictReadAttrProxy, FakeCursor
2d7b6bde 40from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, \
64a456a4
JT
41 CollectionMixin, CollectionItemMixin, ActivityMixin, TextCommentMixin, \
42 CommentingMixin
fdc34b8b 43from mediagoblin.tools.files import delete_media_files
57f8d263 44from mediagoblin.tools.common import import_component
9c602458 45from mediagoblin.tools.routing import extract_url_arguments
ccca0fbf 46
386c9c7c 47import six
fb071a38 48from six.moves.urllib.parse import urljoin
45e687fc 49from pytz import UTC
386c9c7c 50
fdc34b8b
SS
51_log = logging.getLogger(__name__)
52
641ae2f1
JT
53class GenericModelReference(Base):
54 """
55 Represents a relationship to any model that is defined with a integer pk
641ae2f1
JT
56 """
57 __tablename__ = "core__generic_model_reference"
58
59 id = Column(Integer, primary_key=True)
60 obj_pk = Column(Integer, nullable=False)
61
62 # This will be the tablename of the model
63 model_type = Column(Unicode, nullable=False)
64
c1d27aa0 65 # Constrain it so obj_pk and model_type have to be unique
2d73983e
JT
66 # They should be this order as the index is generated, "model_type" will be
67 # the major order as it's put first.
c1d27aa0 68 __table_args__ = (
2d73983e 69 UniqueConstraint("model_type", "obj_pk"),
c1d27aa0
JT
70 {})
71
bfe1e8ce 72 def get_object(self):
641ae2f1
JT
73 # This can happen if it's yet to be saved
74 if self.model_type is None or self.obj_pk is None:
75 return None
76
77 model = self._get_model_from_type(self.model_type)
6185a4b9 78 return model.query.filter_by(id=self.obj_pk).first()
641ae2f1 79
bfe1e8ce 80 def set_object(self, obj):
641ae2f1
JT
81 model = obj.__class__
82
83 # Check we've been given a object
84 if not issubclass(model, Base):
c1d27aa0 85 raise ValueError("Only models can be set as using the GMR")
641ae2f1
JT
86
87 # Check that the model has an explicit __tablename__ declaration
88 if getattr(model, "__tablename__", None) is None:
89 raise ValueError("Models must have __tablename__ attribute")
90
91 # Check that it's not a composite primary key
92 primary_keys = [key.name for key in class_mapper(model).primary_key]
93 if len(primary_keys) > 1:
94 raise ValueError("Models can not have composite primary keys")
95
96 # Check that the field on the model is a an integer field
97 pk_column = getattr(model, primary_keys[0])
c1d27aa0 98 if not isinstance(pk_column.type, Integer):
641ae2f1
JT
99 raise ValueError("Only models with integer pks can be set")
100
c1d27aa0
JT
101 if getattr(obj, pk_column.key) is None:
102 obj.save(commit=False)
641ae2f1 103
c1d27aa0 104 self.obj_pk = getattr(obj, pk_column.key)
641ae2f1
JT
105 self.model_type = obj.__tablename__
106
107 def _get_model_from_type(self, model_type):
2e4782ef
JT
108 """ Gets a model from a tablename (model type) """
109 if getattr(type(self), "_TYPE_MAP", None) is None:
641ae2f1
JT
110 # We want to build on the class (not the instance) a map of all the
111 # models by the table name (type) for easy lookup, this is done on
112 # the class so it can be shared between all instances
113
114 # to prevent circular imports do import here
2d73983e 115 registry = dict(Base._decl_class_registry).values()
2e4782ef 116 self._TYPE_MAP = dict(
2d73983e 117 ((m.__tablename__, m) for m in registry if hasattr(m, "__tablename__"))
2e4782ef 118 )
2d73983e 119 setattr(type(self), "_TYPE_MAP", self._TYPE_MAP)
641ae2f1 120
6185a4b9 121 return self.__class__._TYPE_MAP[model_type]
641ae2f1 122
0b405a3e
JT
123 @classmethod
124 def find_for_obj(cls, obj):
125 """ Finds a GMR for an object or returns None """
126 # Is there one for this already.
127 model = type(obj)
128 pk = getattr(obj, "id")
129
c1d27aa0 130 gmr = cls.query.filter_by(
0b405a3e
JT
131 obj_pk=pk,
132 model_type=model.__tablename__
133 )
134
135 return gmr.first()
136
c1d27aa0
JT
137 @classmethod
138 def find_or_new(cls, obj):
139 """ Finds an existing GMR or creates a new one for the object """
2d73983e 140 gmr = cls.find_for_obj(obj)
c1d27aa0
JT
141
142 # If there isn't one already create one
2d73983e
JT
143 if gmr is None:
144 gmr = cls(
c1d27aa0
JT
145 obj_pk=obj.id,
146 model_type=type(obj).__tablename__
147 )
641ae2f1 148
2d73983e 149 return gmr
bfe1e8ce 150
c0434db4
JT
151class Location(Base):
152 """ Represents a physical location """
153 __tablename__ = "core__locations"
154
155 id = Column(Integer, primary_key=True)
156 name = Column(Unicode)
157
158 # GPS coordinates
159 position = Column(MutationDict.as_mutable(JSONEncoded))
160 address = Column(MutationDict.as_mutable(JSONEncoded))
161
162 @classmethod
163 def create(cls, data, obj):
164 location = cls()
165 location.unserialize(data)
166 location.save()
167 obj.location = location.id
168 return location
169
170 def serialize(self, request):
171 location = {"objectType": "place"}
172
173 if self.name is not None:
5b7e6bb8 174 location["displayName"] = self.name
c0434db4
JT
175
176 if self.position:
177 location["position"] = self.position
178
179 if self.address:
180 location["address"] = self.address
181
182 return location
183
184 def unserialize(self, data):
5b7e6bb8
JT
185 if "displayName" in data:
186 self.name = data["displayName"]
c0434db4
JT
187
188 self.position = {}
189 self.address = {}
190
191 # nicer way to do this?
192 if "position" in data:
193 # TODO: deal with ISO 9709 formatted string as position
194 if "altitude" in data["position"]:
195 self.position["altitude"] = data["position"]["altitude"]
196
197 if "direction" in data["position"]:
198 self.position["direction"] = data["position"]["direction"]
199
200 if "longitude" in data["position"]:
201 self.position["longitude"] = data["position"]["longitude"]
202
203 if "latitude" in data["position"]:
204 self.position["latitude"] = data["position"]["latitude"]
205
206 if "address" in data:
207 if "formatted" in data["address"]:
208 self.address["formatted"] = data["address"]["formatted"]
209
210 if "streetAddress" in data["address"]:
211 self.address["streetAddress"] = data["address"]["streetAddress"]
212
213 if "locality" in data["address"]:
214 self.address["locality"] = data["address"]["locality"]
215
216 if "region" in data["address"]:
217 self.address["region"] = data["address"]["region"]
218
219 if "postalCode" in data["address"]:
220 self.address["postalCode"] = data["addresss"]["postalCode"]
221
222 if "country" in data["address"]:
223 self.address["country"] = data["address"]["country"]
f4f84297 224
f42e49c3 225class User(Base, UserMixin):
eea6d276 226 """
aa9ba3ed
JT
227 Base user that is common amongst LocalUser and RemoteUser.
228
229 This holds all the fields which are common between both the Local and Remote
230 user models.
231
232 NB: ForeignKeys should reference this User model and NOT the LocalUser or
233 RemoteUser models.
eea6d276 234 """
2f5ce68c 235 __tablename__ = "core__users"
ccca0fbf
CAW
236
237 id = Column(Integer, primary_key=True)
aa9ba3ed
JT
238 url = Column(Unicode)
239 bio = Column(UnicodeText)
240 name = Column(Unicode)
241
283e6d8b
JT
242 # This is required for the polymorphic inheritance
243 type = Column(Unicode)
244
aa9ba3ed
JT
245 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
246 updated = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
247
248 location = Column(Integer, ForeignKey("core__locations.id"))
249
250 # Lazy getters
251 get_location = relationship("Location", lazy="joined")
252
283e6d8b
JT
253 __mapper_args__ = {
254 'polymorphic_identity': 'user',
b4997540 255 'polymorphic_on': type,
283e6d8b
JT
256 }
257
bc75a653
JT
258 deletion_mode = Base.SOFT_DELETE
259
260 def soft_delete(self, *args, **kwargs):
261 # Find all the Collections and delete those
262 for collection in Collection.query.filter_by(actor=self.id):
263 collection.delete(**kwargs)
264
265 # Find all the comments and delete those too
64a456a4 266 for comment in TextComment.query.filter_by(actor=self.id):
bc75a653
JT
267 comment.delete(**kwargs)
268
269 # Find all the activities and delete those too
270 for activity in Activity.query.filter_by(actor=self.id):
271 activity.delete(**kwargs)
30852fda 272
bc75a653
JT
273 super(User, self).soft_delete(*args, **kwargs)
274
275
276 def delete(self, *args, **kwargs):
b4997540
JT
277 """Deletes a User and all related entries/comments/files/..."""
278 # Collections get deleted by relationships.
279
0f3bf8d4 280 media_entries = MediaEntry.query.filter(MediaEntry.actor == self.id)
b4997540
JT
281 for media in media_entries:
282 # TODO: Make sure that "MediaEntry.delete()" also deletes
283 # all related files/Comments
284 media.delete(del_orphan_tags=False, commit=False)
285
286 # Delete now unused tags
287 # TODO: import here due to cyclic imports!!! This cries for refactoring
288 from mediagoblin.db.util import clean_orphan_tags
289 clean_orphan_tags(commit=False)
290
291 # Delete user, pass through commit=False/True in kwargs
292 username = self.username
bc75a653 293 super(User, self).delete(*args, **kwargs)
b4997540
JT
294 _log.info('Deleted user "{0}" account'.format(username))
295
aa9ba3ed
JT
296 def has_privilege(self, privilege, allow_admin=True):
297 """
298 This method checks to make sure a user has all the correct privileges
299 to access a piece of content.
300
301 :param privilege A unicode object which represent the different
302 privileges which may give the user access to
303 content.
304
305 :param allow_admin If this is set to True the then if the user is
306 an admin, then this will always return True
307 even if the user hasn't been given the
308 privilege. (defaults to True)
309 """
310 priv = Privilege.query.filter_by(privilege_name=privilege).one()
311 if priv in self.all_privileges:
312 return True
313 elif allow_admin and self.has_privilege(u'admin', allow_admin=False):
314 return True
315
316 return False
317
318 def is_banned(self):
319 """
320 Checks if this user is banned.
321
322 :returns True if self is banned
323 :returns False if self is not
324 """
325 return UserBan.query.get(self.id) is not None
326
327 def serialize(self, request):
328 published = UTC.localize(self.created)
329 updated = UTC.localize(self.updated)
330 user = {
331 "published": published.isoformat(),
332 "updated": updated.isoformat(),
333 "objectType": self.object_type,
334 "pump_io": {
335 "shared": False,
336 "followed": False,
337 },
338 }
339
340 if self.bio:
341 user.update({"summary": self.bio})
342 if self.url:
343 user.update({"url": self.url})
344 if self.location:
345 user.update({"location": self.get_location.serialize(request)})
346
de366f73
JT
347 return user
348
aa9ba3ed
JT
349 def unserialize(self, data):
350 if "summary" in data:
351 self.bio = data["summary"]
352
353 if "location" in data:
354 Location.create(data, self)
355
356class LocalUser(User):
357 """ This represents a user registered on this instance """
358 __tablename__ = "core__local_users"
359
360 id = Column(Integer, ForeignKey("core__users.id"), primary_key=True)
ccca0fbf 361 username = Column(Unicode, nullable=False, unique=True)
fbe8edc2
CAW
362 # Note: no db uniqueness constraint on email because it's not
363 # reliable (many email systems case insensitive despite against
364 # the RFC) and because it would be a mess to implement at this
365 # point.
ccca0fbf 366 email = Column(Unicode, nullable=False)
b56b6b1e 367 pw_hash = Column(Unicode)
aa9ba3ed 368
c4869eff
JW
369 # Intented to be nullable=False, but migrations would not work for it
370 # set to nullable=True implicitly.
371 wants_comment_notification = Column(Boolean, default=True)
93d805ad 372 wants_notifications = Column(Boolean, default=True)
dc4dfbde 373 license_preference = Column(Unicode)
bdd22421
RE
374 uploaded = Column(Integer, default=0)
375 upload_limit = Column(Integer)
ccca0fbf 376
283e6d8b 377 __mapper_args__ = {
b4997540 378 "polymorphic_identity": "user_local",
283e6d8b
JT
379 }
380
ccca0fbf
CAW
381 ## TODO
382 # plugin data would be in a separate model
383
88a9662b
JW
384 def __repr__(self):
385 return '<{0} #{1} {2} {3} "{4}">'.format(
386 self.__class__.__name__,
387 self.id,
25625107 388 'verified' if self.has_privilege(u'active') else 'non-verified',
389 'admin' if self.has_privilege(u'admin') else 'user',
88a9662b
JW
390 self.username)
391
89068c2b
JT
392 def get_public_id(self, host):
393 return "acct:{0}@{1}".format(self.username, host)
394
637b966a
JT
395 def serialize(self, request):
396 user = {
89068c2b 397 "id": self.get_public_id(request.host),
637b966a 398 "preferredUsername": self.username,
89068c2b 399 "displayName": self.get_public_id(request.host).split(":", 1)[1],
637b966a 400 "links": {
d7b3805f
JT
401 "self": {
402 "href": request.urlgen(
4fd52036 403 "mediagoblin.api.user.profile",
d7b3805f
JT
404 username=self.username,
405 qualified=True
406 ),
407 },
408 "activity-inbox": {
409 "href": request.urlgen(
4fd52036 410 "mediagoblin.api.inbox",
d7b3805f
JT
411 username=self.username,
412 qualified=True
413 )
414 },
415 "activity-outbox": {
416 "href": request.urlgen(
4fd52036 417 "mediagoblin.api.feed",
d7b3805f
JT
418 username=self.username,
419 qualified=True
420 )
421 },
637b966a
JT
422 },
423 }
c434fc31 424
aa9ba3ed 425 user.update(super(LocalUser, self).serialize(request))
d7b3805f 426 return user
637b966a 427
aa9ba3ed
JT
428class RemoteUser(User):
429 """ User that is on another (remote) instance """
430 __tablename__ = "core__remote_users"
431
432 id = Column(Integer, ForeignKey("core__users.id"), primary_key=True)
433 webfinger = Column(Unicode, unique=True)
434
283e6d8b
JT
435 __mapper_args__ = {
436 'polymorphic_identity': 'user_remote'
437 }
438
aa9ba3ed
JT
439 def __repr__(self):
440 return "<{0} #{1} {2}>".format(
441 self.__class__.__name__,
442 self.id,
443 self.webfinger
444 )
c0434db4 445
c0434db4 446
4990b47c 447class Client(Base):
448 """
449 Model representing a client - Used for API Auth
450 """
451 __tablename__ = "core__clients"
452
453 id = Column(Unicode, nullable=True, primary_key=True)
454 secret = Column(Unicode, nullable=False)
455 expirey = Column(DateTime, nullable=True)
456 application_type = Column(Unicode, nullable=False)
d705f3b7
JT
457 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
458 updated = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
4990b47c 459
460 # optional stuff
c33a34d4 461 redirect_uri = Column(JSONEncoded, nullable=True)
462 logo_url = Column(Unicode, nullable=True)
4990b47c 463 application_name = Column(Unicode, nullable=True)
c33a34d4 464 contacts = Column(JSONEncoded, nullable=True)
465
4990b47c 466 def __repr__(self):
c33a34d4 467 if self.application_name:
468 return "<Client {0} - {1}>".format(self.application_name, self.id)
469 else:
470 return "<Client {0}>".format(self.id)
4990b47c 471
d41c6a53 472class RequestToken(Base):
473 """
474 Model for representing the request tokens
475 """
476 __tablename__ = "core__request_tokens"
4990b47c 477
d41c6a53 478 token = Column(Unicode, primary_key=True)
479 secret = Column(Unicode, nullable=False)
480 client = Column(Unicode, ForeignKey(Client.id))
0f3bf8d4 481 actor = Column(Integer, ForeignKey(User.id), nullable=True)
d41c6a53 482 used = Column(Boolean, default=False)
483 authenticated = Column(Boolean, default=False)
484 verifier = Column(Unicode, nullable=True)
405aa45a 485 callback = Column(Unicode, nullable=False, default=u"oob")
d705f3b7
JT
486 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
487 updated = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
b5059525 488
7e15632b
JT
489 get_client = relationship(Client)
490
d41c6a53 491class AccessToken(Base):
492 """
493 Model for representing the access tokens
494 """
495 __tablename__ = "core__access_tokens"
496
497 token = Column(Unicode, nullable=False, primary_key=True)
498 secret = Column(Unicode, nullable=False)
0f3bf8d4 499 actor = Column(Integer, ForeignKey(User.id))
d41c6a53 500 request_token = Column(Unicode, ForeignKey(RequestToken.token))
d705f3b7
JT
501 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
502 updated = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
b5059525 503
7e15632b
JT
504 get_requesttoken = relationship(RequestToken)
505
4990b47c 506
cfe7054c 507class NonceTimestamp(Base):
508 """
509 A place the timestamp and nonce can be stored - this is for OAuth1
510 """
511 __tablename__ = "core__nonce_timestamps"
512
513 nonce = Column(Unicode, nullable=False, primary_key=True)
514 timestamp = Column(DateTime, nullable=False, primary_key=True)
515
64a456a4 516class MediaEntry(Base, MediaEntryMixin, CommentingMixin):
eea6d276
E
517 """
518 TODO: Consider fetching the media_files using join
519 """
2f5ce68c 520 __tablename__ = "core__media_entries"
ccca0fbf
CAW
521
522 id = Column(Integer, primary_key=True)
35fbad47
JT
523 public_id = Column(Unicode, unique=True, nullable=True)
524 remote = Column(Boolean, default=False)
525
0f3bf8d4 526 actor = Column(Integer, ForeignKey(User.id), nullable=False, index=True)
7c2c56a5 527 title = Column(Unicode, nullable=False)
3e907d55 528 slug = Column(Unicode)
ccca0fbf 529 description = Column(UnicodeText) # ??
ccca0fbf 530 media_type = Column(Unicode, nullable=False)
51fba991
E
531 state = Column(Unicode, default=u'unprocessed', nullable=False)
532 # or use sqlalchemy.types.Enum?
2788e6a1 533 license = Column(Unicode)
bdd22421 534 file_size = Column(Integer, default=0)
c0434db4
JT
535 location = Column(Integer, ForeignKey("core__locations.id"))
536 get_location = relationship("Location", lazy="joined")
fbad3a9f 537
35fbad47
JT
538 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow,
539 index=True)
540 updated = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
541
ccca0fbf 542 fail_error = Column(Unicode)
cf27accc 543 fail_metadata = Column(JSONEncoded)
9412fffe 544
64712915
JW
545 transcoding_progress = Column(SmallInteger)
546
02db7e0a 547 queued_media_file = Column(PathTupleWithSlashes)
ccca0fbf
CAW
548
549 queued_task_id = Column(Unicode)
550
551 __table_args__ = (
0f3bf8d4 552 UniqueConstraint('actor', 'slug'),
ccca0fbf
CAW
553 {})
554
bc75a653
JT
555 deletion_mode = Base.SOFT_DELETE
556
0f3bf8d4 557 get_actor = relationship(User)
88e90f41 558
02db7e0a
E
559 media_files_helper = relationship("MediaFile",
560 collection_class=attribute_mapped_collection("name"),
561 cascade="all, delete-orphan"
562 )
563 media_files = association_proxy('media_files_helper', 'file_path',
fbad3a9f 564 creator=lambda k, v: MediaFile(name=k, file_path=v)
02db7e0a
E
565 )
566
35029581 567 attachment_files_helper = relationship("MediaAttachmentFile",
df5b142a 568 cascade="all, delete-orphan",
35029581
E
569 order_by="MediaAttachmentFile.created"
570 )
571 attachment_files = association_proxy("attachment_files_helper", "dict_view",
572 creator=lambda v: MediaAttachmentFile(
573 name=v["name"], filepath=v["filepath"])
574 )
575
de917303 576 tags_helper = relationship("MediaTag",
fdc34b8b 577 cascade="all, delete-orphan" # should be automatically deleted
de917303
E
578 )
579 tags = association_proxy("tags_helper", "dict_view",
580 creator=lambda v: MediaTag(name=v["name"], slug=v["slug"])
581 )
582
c8abeb58 583 media_metadata = Column(MutationDict.as_mutable(JSONEncoded),
584 default=MutationDict())
be5be115 585
ccca0fbf 586 ## TODO
ccca0fbf
CAW
587 # fail_error
588
65805ffb
BB
589 @property
590 def get_uploader(self):
591 # for compatibility
592 return self.get_actor
593
594 @property
595 def uploader(self):
596 # for compatibility
597 return self.actor
598
0f3bf8d4
JT
599 @property
600 def collections(self):
601 """ Get any collections that this MediaEntry is in """
602 return list(Collection.query.join(Collection.collection_items).join(
603 CollectionItem.object_helper
604 ).filter(
605 and_(
606 GenericModelReference.model_type == self.__tablename__,
607 GenericModelReference.obj_pk == self.id
608 )
609 ))
610
02ede858 611 def get_comments(self, ascending=False):
64a456a4
JT
612 query = Comment.query.join(Comment.target_helper).filter(and_(
613 GenericModelReference.obj_pk == self.id,
614 GenericModelReference.model_type == self.__tablename__
615 ))
02ede858 616
64a456a4
JT
617 if ascending:
618 query = query.order_by(Comment.added.asc())
619 else:
d5084359 620 query = query.order_by(Comment.added.desc())
65805ffb 621
161bc6b2 622 return query
65805ffb 623
c47a03b9
E
624 def url_to_prev(self, urlgen):
625 """get the next 'newer' entry by this user"""
626 media = MediaEntry.query.filter(
0f3bf8d4 627 (MediaEntry.actor == self.actor)
5bd0adeb 628 & (MediaEntry.state == u'processed')
c47a03b9
E
629 & (MediaEntry.id > self.id)).order_by(MediaEntry.id).first()
630
631 if media is not None:
632 return media.url_for_self(urlgen)
633
634 def url_to_next(self, urlgen):
635 """get the next 'older' entry by this user"""
636 media = MediaEntry.query.filter(
0f3bf8d4 637 (MediaEntry.actor == self.actor)
5bd0adeb 638 & (MediaEntry.state == u'processed')
c47a03b9
E
639 & (MediaEntry.id < self.id)).order_by(desc(MediaEntry.id)).first()
640
641 if media is not None:
642 return media.url_for_self(urlgen)
643
e002452f
RE
644 def get_file_metadata(self, file_key, metadata_key=None):
645 """
646 Return the file_metadata dict of a MediaFile. If metadata_key is given,
647 return the value of the key.
648 """
649 media_file = MediaFile.query.filter_by(media_entry=self.id,
e49b7e02 650 name=six.text_type(file_key)).first()
e002452f
RE
651
652 if media_file:
653 if metadata_key:
654 return media_file.file_metadata.get(metadata_key, None)
655
656 return media_file.file_metadata
657
658 def set_file_metadata(self, file_key, **kwargs):
659 """
660 Update the file_metadata of a MediaFile.
661 """
662 media_file = MediaFile.query.filter_by(media_entry=self.id,
e49b7e02 663 name=six.text_type(file_key)).first()
e002452f
RE
664
665 file_metadata = media_file.file_metadata or {}
666
386c9c7c 667 for key, value in six.iteritems(kwargs):
e002452f
RE
668 file_metadata[key] = value
669
670 media_file.file_metadata = file_metadata
7f9d3ca7 671 media_file.save()
e002452f 672
5fe1fd07
E
673 @property
674 def media_data(self):
485404a9 675 return getattr(self, self.media_data_ref)
5fe1fd07 676
acb21949 677 def media_data_init(self, **kwargs):
007ac2e7
CAW
678 """
679 Initialize or update the contents of a media entry's media_data row
680 """
57f8d263 681 media_data = self.media_data
007ac2e7 682
99c2f9f0 683 if media_data is None:
139c6c09
E
684 # Get the correct table:
685 table = import_component(self.media_type + '.models:DATA_MODEL')
57f8d263 686 # No media data, so actually add a new one
139c6c09 687 media_data = table(**kwargs)
57f8d263
E
688 # Get the relationship set up.
689 media_data.get_media_entry = self
007ac2e7 690 else:
57f8d263 691 # Update old media data
386c9c7c 692 for field, value in six.iteritems(kwargs):
007ac2e7
CAW
693 setattr(media_data, field, value)
694
57f8d263
E
695 @memoized_property
696 def media_data_ref(self):
697 return import_component(self.media_type + '.models:BACKREF_NAME')
acb21949 698
64712915 699 def __repr__(self):
9d85dcdf
BP
700 if six.PY2:
701 # obj.__repr__() should return a str on Python 2
702 safe_title = self.title.encode('utf-8', 'replace')
703 else:
704 safe_title = self.title
79f28e0b 705
64712915
JW
706 return '<{classname} {id}: {title}>'.format(
707 classname=self.__class__.__name__,
708 id=self.id,
79f28e0b 709 title=safe_title)
64712915 710
bc75a653
JT
711 def soft_delete(self, *args, **kwargs):
712 # Find all of the media comments for this and delete them
64a456a4 713 for comment in self.get_comments():
bc75a653
JT
714 comment.delete(*args, **kwargs)
715
716 super(MediaEntry, self).soft_delete(*args, **kwargs)
717
fdc34b8b
SS
718 def delete(self, del_orphan_tags=True, **kwargs):
719 """Delete MediaEntry and all related files/attachments/comments
720
721 This will *not* automatically delete unused collections, which
722 can remain empty...
723
724 :param del_orphan_tags: True/false if we delete unused Tags too
725 :param commit: True/False if this should end the db transaction"""
726 # User's CollectionItems are automatically deleted via "cascade".
b98882e1 727 # Comments on this Media are deleted by cascade, hopefully.
fdc34b8b
SS
728
729 # Delete all related files/attachments
730 try:
731 delete_media_files(self)
f6a700e8 732 except OSError as error:
fdc34b8b
SS
733 # Returns list of files we failed to delete
734 _log.error('No such files from the user "{1}" to delete: '
0f3bf8d4 735 '{0}'.format(str(error), self.get_actor))
fdc34b8b
SS
736 _log.info('Deleted Media entry id "{0}"'.format(self.id))
737 # Related MediaTag's are automatically cleaned, but we might
738 # want to clean out unused Tag's too.
739 if del_orphan_tags:
740 # TODO: Import here due to cyclic imports!!!
741 # This cries for refactoring
742 from mediagoblin.db.util import clean_orphan_tags
743 clean_orphan_tags(commit=False)
744 # pass through commit=False/True in kwargs
745 super(MediaEntry, self).delete(**kwargs)
746
a840d2a8 747 def serialize(self, request, show_comments=True):
bdde87a4 748 """ Unserialize MediaEntry to object """
0f3bf8d4 749 author = self.get_actor
45e687fc 750 published = UTC.localize(self.created)
35fbad47 751 updated = UTC.localize(self.updated)
d216d771 752 public_id = self.get_public_id(request.urlgen)
bdde87a4 753 context = {
de366f73 754 "id": public_id,
bdde87a4 755 "author": author.serialize(request),
0421fc5e 756 "objectType": self.object_type,
c511fc5e 757 "url": self.url_for_self(request.urlgen, qualified=True),
5b014a08 758 "image": {
fb071a38 759 "url": urljoin(request.host_url, self.thumb_url),
5b014a08
JT
760 },
761 "fullImage":{
fb071a38 762 "url": urljoin(request.host_url, self.original_url),
98596dd0 763 },
45e687fc
JT
764 "published": published.isoformat(),
765 "updated": updated.isoformat(),
c434fc31
JT
766 "pump_io": {
767 "shared": False,
768 },
3c8bd177
JT
769 "links": {
770 "self": {
de366f73 771 "href": public_id,
3c8bd177
JT
772 },
773
774 }
bdde87a4 775 }
a840d2a8 776
51ab5192
JT
777 if self.title:
778 context["displayName"] = self.title
779
780 if self.description:
781 context["content"] = self.description
782
783 if self.license:
784 context["license"] = self.license
785
c0434db4
JT
786 if self.location:
787 context["location"] = self.get_location.serialize(request)
788
a840d2a8 789 if show_comments:
0421fc5e 790 comments = [
161bc6b2 791 l.comment().serialize(request) for l in self.get_comments()]
a840d2a8 792 total = len(comments)
7810817c 793 context["replies"] = {
794 "totalItems": total,
795 "items": comments,
796 "url": request.urlgen(
4fd52036 797 "mediagoblin.api.object.comments",
0421fc5e 798 object_type=self.object_type,
a14d90c2 799 id=self.id,
7810817c 800 qualified=True
801 ),
802 }
a840d2a8 803
4a09d595
JT
804 # Add image height and width if possible. We didn't use to store this
805 # data and we're not able (and maybe not willing) to re-process all
806 # images so it's possible this might not exist.
807 if self.get_file_metadata("thumb", "height"):
808 height = self.get_file_metadata("thumb", "height")
809 context["image"]["height"] = height
810 if self.get_file_metadata("thumb", "width"):
811 width = self.get_file_metadata("thumb", "width")
812 context["image"]["width"] = width
813 if self.get_file_metadata("original", "height"):
814 height = self.get_file_metadata("original", "height")
815 context["fullImage"]["height"] = height
816 if self.get_file_metadata("original", "height"):
817 width = self.get_file_metadata("original", "width")
818 context["fullImage"]["width"] = width
819
51ab5192 820 return context
bdde87a4 821
d8f55f2b
JT
822 def unserialize(self, data):
823 """ Takes API objects and unserializes on existing MediaEntry """
824 if "displayName" in data:
825 self.title = data["displayName"]
826
827 if "content" in data:
828 self.description = data["content"]
829
830 if "license" in data:
831 self.license = data["license"]
832
c0434db4 833 if "location" in data:
2d73983e 834 License.create(data["location"], self)
c0434db4 835
d8f55f2b 836 return True
ccca0fbf 837
a9dac7c8
E
838class FileKeynames(Base):
839 """
840 keywords for various places.
841 currently the MediaFile keys
842 """
843 __tablename__ = "core__file_keynames"
844 id = Column(Integer, primary_key=True)
845 name = Column(Unicode, unique=True)
846
847 def __repr__(self):
848 return "<FileKeyname %r: %r>" % (self.id, self.name)
849
850 @classmethod
851 def find_or_new(cls, name):
852 t = cls.query.filter_by(name=name).first()
853 if t is not None:
854 return t
855 return cls(name=name)
856
857
02db7e0a 858class MediaFile(Base):
eea6d276
E
859 """
860 TODO: Highly consider moving "name" into a new table.
861 TODO: Consider preloading said table in software
862 """
2f5ce68c 863 __tablename__ = "core__mediafiles"
02db7e0a
E
864
865 media_entry = Column(
866 Integer, ForeignKey(MediaEntry.id),
a9dac7c8
E
867 nullable=False)
868 name_id = Column(SmallInteger, ForeignKey(FileKeynames.id), nullable=False)
02db7e0a 869 file_path = Column(PathTupleWithSlashes)
42dbb26a 870 file_metadata = Column(MutationDict.as_mutable(JSONEncoded))
02db7e0a 871
a9dac7c8
E
872 __table_args__ = (
873 PrimaryKeyConstraint('media_entry', 'name_id'),
874 {})
875
02db7e0a
E
876 def __repr__(self):
877 return "<MediaFile %s: %r>" % (self.name, self.file_path)
878
a9dac7c8
E
879 name_helper = relationship(FileKeynames, lazy="joined", innerjoin=True)
880 name = association_proxy('name_helper', 'name',
881 creator=FileKeynames.find_or_new
882 )
883
02db7e0a 884
35029581
E
885class MediaAttachmentFile(Base):
886 __tablename__ = "core__attachment_files"
887
888 id = Column(Integer, primary_key=True)
889 media_entry = Column(
890 Integer, ForeignKey(MediaEntry.id),
891 nullable=False)
892 name = Column(Unicode, nullable=False)
893 filepath = Column(PathTupleWithSlashes)
d705f3b7 894 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
35029581
E
895
896 @property
897 def dict_view(self):
898 """A dict like view on this object"""
899 return DictReadAttrProxy(self)
900
901
ccca0fbf 902class Tag(Base):
2f5ce68c 903 __tablename__ = "core__tags"
ccca0fbf
CAW
904
905 id = Column(Integer, primary_key=True)
906 slug = Column(Unicode, nullable=False, unique=True)
907
de917303
E
908 def __repr__(self):
909 return "<Tag %r: %r>" % (self.id, self.slug)
910
911 @classmethod
912 def find_or_new(cls, slug):
913 t = cls.query.filter_by(slug=slug).first()
914 if t is not None:
915 return t
916 return cls(slug=slug)
917
ccca0fbf
CAW
918
919class MediaTag(Base):
2f5ce68c 920 __tablename__ = "core__media_tags"
ccca0fbf
CAW
921
922 id = Column(Integer, primary_key=True)
ccca0fbf 923 media_entry = Column(
de917303 924 Integer, ForeignKey(MediaEntry.id),
ecd538bb
E
925 nullable=False, index=True)
926 tag = Column(Integer, ForeignKey(Tag.id), nullable=False, index=True)
de917303 927 name = Column(Unicode)
d705f3b7 928 # created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
ccca0fbf
CAW
929
930 __table_args__ = (
931 UniqueConstraint('tag', 'media_entry'),
932 {})
933
de917303
E
934 tag_helper = relationship(Tag)
935 slug = association_proxy('tag_helper', 'slug',
936 creator=Tag.find_or_new
937 )
938
6456cefa 939 def __init__(self, name=None, slug=None):
de917303 940 Base.__init__(self)
6456cefa
E
941 if name is not None:
942 self.name = name
943 if slug is not None:
944 self.tag_helper = Tag.find_or_new(slug)
de917303
E
945
946 @property
947 def dict_view(self):
948 """A dict like view on this object"""
949 return DictReadAttrProxy(self)
950
64a456a4
JT
951class Comment(Base):
952 """
953 Link table between a response and another object that can have replies.
65805ffb 954
64a456a4
JT
955 This acts as a link table between an object and the comments on it, it's
956 done like this so that you can look up all the comments without knowing
957 whhich comments are on an object before hand. Any object can be a comment
958 and more or less any object can accept comments too.
959
960 Important: This is NOT the old MediaComment table.
961 """
962 __tablename__ = "core__comment_links"
963
964 id = Column(Integer, primary_key=True)
65805ffb 965
64a456a4
JT
966 # The GMR to the object the comment is on.
967 target_id = Column(
968 Integer,
969 ForeignKey(GenericModelReference.id),
970 nullable=False
971 )
972 target_helper = relationship(
973 GenericModelReference,
974 foreign_keys=[target_id]
975 )
976 target = association_proxy("target_helper", "get_object",
977 creator=GenericModelReference.find_or_new)
978
979 # The comment object
980 comment_id = Column(
981 Integer,
982 ForeignKey(GenericModelReference.id),
983 nullable=False
984 )
985 comment_helper = relationship(
986 GenericModelReference,
987 foreign_keys=[comment_id]
988 )
989 comment = association_proxy("comment_helper", "get_object",
990 creator=GenericModelReference.find_or_new)
991
992 # When it was added
993 added = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
65805ffb
BB
994
995 @property
996 def get_author(self):
997 # for compatibility
998 return self.comment().get_actor # noqa
999
1000 def __getattr__(self, attr):
cacc679a
BB
1001 if attr.startswith('_'):
1002 # if attr starts with '_', then it's probably some internal
1003 # sqlalchemy variable. Since __getattr__ is called when
1004 # non-existing attributes are being accessed, we should not try to
1005 # fetch it from self.comment()
1006 raise AttributeError
65805ffb 1007 try:
cacc679a 1008 _log.debug('Old attr is being accessed: {0}'.format(attr))
65805ffb
BB
1009 return getattr(self.comment(), attr) # noqa
1010 except Exception as e:
cacc679a 1011 _log.error(e)
65805ffb 1012 raise
ccca0fbf 1013
64a456a4
JT
1014class TextComment(Base, TextCommentMixin, CommentingMixin):
1015 """
1016 A basic text comment, this is a usually short amount of text and nothing else
1017 """
1018 # This is a legacy from when Comments where just on MediaEntry objects.
2f5ce68c 1019 __tablename__ = "core__media_comments"
fbad3a9f 1020
ccca0fbf 1021 id = Column(Integer, primary_key=True)
64a456a4 1022 public_id = Column(Unicode, unique=True)
0f3bf8d4 1023 actor = Column(Integer, ForeignKey(User.id), nullable=False)
d705f3b7 1024 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
64a456a4 1025 updated = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
ccca0fbf 1026 content = Column(UnicodeText, nullable=False)
c0434db4
JT
1027 location = Column(Integer, ForeignKey("core__locations.id"))
1028 get_location = relationship("Location", lazy="joined")
e365f980 1029
ff68ca9f 1030 # Cascade: Comments are owned by their creator. So do the full thing.
b98882e1
E
1031 # lazy=dynamic: People might post a *lot* of comments,
1032 # so make the "posted_comments" a query-like thing.
0f3bf8d4 1033 get_actor = relationship(User,
ff68ca9f
E
1034 backref=backref("posted_comments",
1035 lazy="dynamic",
1036 cascade="all, delete-orphan"))
bc75a653 1037 deletion_mode = Base.SOFT_DELETE
30852fda 1038
bdde87a4
JT
1039 def serialize(self, request):
1040 """ Unserialize to python dictionary for API """
64a456a4
JT
1041 target = self.get_reply_to()
1042 # If this is target just.. give them nothing?
1043 if target is None:
1044 target = {}
1045 else:
65805ffb 1046 target = target.serialize(request, show_comments=False)
64a456a4
JT
1047
1048
0f3bf8d4 1049 author = self.get_actor
1c8f52da 1050 published = UTC.localize(self.created)
bdde87a4 1051 context = {
64a456a4 1052 "id": self.get_public_id(request.urlgen),
0421fc5e 1053 "objectType": self.object_type,
bdde87a4 1054 "content": self.content,
64a456a4 1055 "inReplyTo": target,
1c8f52da
JT
1056 "author": author.serialize(request),
1057 "published": published.isoformat(),
1058 "updated": published.isoformat(),
bdde87a4
JT
1059 }
1060
c0434db4
JT
1061 if self.location:
1062 context["location"] = self.get_location.seralize(request)
1063
bdde87a4
JT
1064 return context
1065
9c602458 1066 def unserialize(self, data, request):
d8f55f2b 1067 """ Takes API objects and unserializes on existing comment """
64a456a4
JT
1068 if "content" in data:
1069 self.content = data["content"]
1070
1071 if "location" in data:
1072 Location.create(data["location"], self)
1073
65805ffb 1074
9e715bb0
JT
1075 # Handle changing the reply ID
1076 if "inReplyTo" in data:
1077 # Validate that the ID is correct
1078 try:
64a456a4 1079 id = extract_url_arguments(
9e715bb0
JT
1080 url=data["inReplyTo"]["id"],
1081 urlmap=request.app.url_map
64a456a4 1082 )["id"]
9e715bb0 1083 except ValueError:
64a456a4
JT
1084 raise False
1085
1086 public_id = request.urlgen(
1087 "mediagoblin.api.object",
1088 id=id,
1089 object_type=data["inReplyTo"]["objectType"],
1090 qualified=True
1091 )
d8f55f2b 1092
64a456a4 1093 media = MediaEntry.query.filter_by(public_id=public_id).first()
9e715bb0
JT
1094 if media is None:
1095 return False
9246a6ba 1096
64a456a4
JT
1097 # We need an ID for this model.
1098 self.save(commit=False)
c0434db4 1099
64a456a4
JT
1100 # Create the link
1101 link = Comment()
1102 link.target = media
1103 link.comment = self
1104 link.save()
65805ffb 1105
d8f55f2b
JT
1106 return True
1107
64a456a4 1108class Collection(Base, CollectionMixin, CommentingMixin):
0f3bf8d4
JT
1109 """A representation of a collection of objects.
1110
1111 This holds a group/collection of objects that could be a user defined album
1112 or their inbox, outbox, followers, etc. These are always ordered and accessable
1113 via the API and web.
1114
1115 The collection has a number of types which determine what kind of collection
1116 it is, for example the users inbox will be of `Collection.INBOX_TYPE` that will
1117 be stored on the `Collection.type` field. It's important to set the correct type.
242776e3
SS
1118
1119 On deletion, contained CollectionItems get automatically reaped via
1120 SQL cascade"""
be5be115
AW
1121 __tablename__ = "core__collections"
1122
1123 id = Column(Integer, primary_key=True)
d216d771 1124 public_id = Column(Unicode, unique=True)
be5be115
AW
1125 title = Column(Unicode, nullable=False)
1126 slug = Column(Unicode)
d705f3b7 1127 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow,
34d8bc98 1128 index=True)
0f3bf8d4 1129 updated = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
88a9662b 1130 description = Column(UnicodeText)
0f3bf8d4
JT
1131 actor = Column(Integer, ForeignKey(User.id), nullable=False)
1132 num_items = Column(Integer, default=0)
1133
1134 # There are lots of different special types of collections in the pump.io API
1135 # for example: followers, following, inbox, outbox, etc. See type constants
1136 # below the fields on this model.
1137 type = Column(Unicode, nullable=False)
1138
1139 # Location
c0434db4
JT
1140 location = Column(Integer, ForeignKey("core__locations.id"))
1141 get_location = relationship("Location", lazy="joined")
1142
6194344b 1143 # Cascade: Collections are owned by their creator. So do the full thing.
0f3bf8d4 1144 get_actor = relationship(User,
6194344b
E
1145 backref=backref("collections",
1146 cascade="all, delete-orphan"))
34d8bc98 1147 __table_args__ = (
30852fda 1148 UniqueConstraint("actor", "slug"),
34d8bc98
RE
1149 {})
1150
bc75a653 1151 deletion_mode = Base.SOFT_DELETE
30852fda 1152
0f3bf8d4
JT
1153 # These are the types, It's strongly suggested if new ones are invented they
1154 # are prefixed to ensure they're unique from other types. Any types used in
1155 # the main mediagoblin should be prefixed "core-"
1156 INBOX_TYPE = "core-inbox"
1157 OUTBOX_TYPE = "core-outbox"
1158 FOLLOWER_TYPE = "core-followers"
1159 FOLLOWING_TYPE = "core-following"
64a456a4 1160 COMMENT_TYPE = "core-comments"
0f3bf8d4
JT
1161 USER_DEFINED_TYPE = "core-user-defined"
1162
be5be115 1163 def get_collection_items(self, ascending=False):
242776e3 1164 #TODO, is this still needed with self.collection_items being available?
dbb86ffb 1165 order_col = MediaEntry.created
be5be115
AW
1166 if not ascending:
1167 order_col = desc(order_col)
dbb86ffb
BB
1168 return CollectionItem.query.join(MediaEntry).filter(
1169 CollectionItem.collection==self.id).order_by(order_col)
be5be115 1170
4f1a5148
OHO
1171 def __repr__(self):
1172 safe_title = self.title.encode('ascii', 'replace')
0f3bf8d4 1173 return '<{classname} #{id}: {title} by {actor}>'.format(
4f1a5148
OHO
1174 id=self.id,
1175 classname=self.__class__.__name__,
0f3bf8d4 1176 actor=self.actor,
4f1a5148
OHO
1177 title=safe_title)
1178
c511fc5e
JT
1179 def serialize(self, request):
1180 # Get all serialized output in a list
0f3bf8d4 1181 items = [i.serialize(request) for i in self.get_collection_items()]
c511fc5e 1182 return {
fd07dd6d 1183 "totalItems": self.num_items,
c511fc5e
JT
1184 "url": self.url_for_self(request.urlgen, qualified=True),
1185 "items": items,
1186 }
1187
be5be115
AW
1188
1189class CollectionItem(Base, CollectionItemMixin):
1190 __tablename__ = "core__collection_items"
1191
1192 id = Column(Integer, primary_key=True)
0f3bf8d4 1193
be5be115
AW
1194 collection = Column(Integer, ForeignKey(Collection.id), nullable=False)
1195 note = Column(UnicodeText, nullable=True)
d705f3b7 1196 added = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
be5be115 1197 position = Column(Integer)
6194344b
E
1198 # Cascade: CollectionItems are owned by their Collection. So do the full thing.
1199 in_collection = relationship(Collection,
242776e3
SS
1200 backref=backref(
1201 "collection_items",
1202 cascade="all, delete-orphan"))
be5be115 1203
0f3bf8d4
JT
1204 # Link to the object (could be anything.
1205 object_id = Column(
1206 Integer,
1207 ForeignKey(GenericModelReference.id),
1208 nullable=False,
1209 index=True
1210 )
1211 object_helper = relationship(
1212 GenericModelReference,
1213 foreign_keys=[object_id]
1214 )
1215 get_object = association_proxy(
1216 "object_helper",
1217 "get_object",
1218 creator=GenericModelReference.find_or_new
1219 )
be5be115 1220
be5be115 1221 __table_args__ = (
0f3bf8d4 1222 UniqueConstraint('collection', 'object_id'),
be5be115
AW
1223 {})
1224
1225 @property
1226 def dict_view(self):
1227 """A dict like view on this object"""
1228 return DictReadAttrProxy(self)
1229
4f1a5148 1230 def __repr__(self):
0f3bf8d4 1231 return '<{classname} #{id}: Object {obj} in {collection}>'.format(
4f1a5148
OHO
1232 id=self.id,
1233 classname=self.__class__.__name__,
1234 collection=self.collection,
0f3bf8d4
JT
1235 obj=self.get_object()
1236 )
4f1a5148 1237
c511fc5e 1238 def serialize(self, request):
0f3bf8d4 1239 return self.get_object().serialize(request)
c511fc5e 1240
be5be115 1241
5354f954
JW
1242class ProcessingMetaData(Base):
1243 __tablename__ = 'core__processing_metadata'
1244
1245 id = Column(Integer, primary_key=True)
1246 media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False,
1247 index=True)
942084fb
JW
1248 media_entry = relationship(MediaEntry,
1249 backref=backref('processing_metadata',
1250 cascade='all, delete-orphan'))
5354f954
JW
1251 callback_url = Column(Unicode)
1252
1253 @property
1254 def dict_view(self):
1255 """A dict like view on this object"""
1256 return DictReadAttrProxy(self)
1257
1258
2d7b6bde
JW
1259class CommentSubscription(Base):
1260 __tablename__ = 'core__comment_subscriptions'
1261 id = Column(Integer, primary_key=True)
1262
d705f3b7 1263 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
2d7b6bde
JW
1264
1265 media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False)
1266 media_entry = relationship(MediaEntry,
1267 backref=backref('comment_subscriptions',
1268 cascade='all, delete-orphan'))
1269
1270 user_id = Column(Integer, ForeignKey(User.id), nullable=False)
1271 user = relationship(User,
1272 backref=backref('comment_subscriptions',
1273 cascade='all, delete-orphan'))
1274
1275 notify = Column(Boolean, nullable=False, default=True)
1276 send_email = Column(Boolean, nullable=False, default=True)
1277
1278 def __repr__(self):
1279 return ('<{classname} #{id}: {user} {media} notify: '
1280 '{notify} email: {email}>').format(
1281 id=self.id,
1282 classname=self.__class__.__name__,
1283 user=self.user,
1284 media=self.media_entry,
1285 notify=self.notify,
1286 email=self.send_email)
1287
1288
1289class Notification(Base):
1290 __tablename__ = 'core__notifications'
1291 id = Column(Integer, primary_key=True)
2d7b6bde 1292
64a456a4
JT
1293 object_id = Column(Integer, ForeignKey(GenericModelReference.id))
1294 object_helper = relationship(GenericModelReference)
1295 obj = association_proxy("object_helper", "get_object",
1296 creator=GenericModelReference.find_or_new)
2d7b6bde 1297
64a456a4 1298 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
2d7b6bde
JW
1299 user_id = Column(Integer, ForeignKey('core__users.id'), nullable=False,
1300 index=True)
1301 seen = Column(Boolean, default=lambda: False, index=True)
1302 user = relationship(
1303 User,
65805ffb 1304 backref=backref('notifications', cascade='all, delete-orphan'))
2d7b6bde
JW
1305
1306 def __repr__(self):
1307 return '<{klass} #{id}: {user}: {subject} ({seen})>'.format(
1308 id=self.id,
1309 klass=self.__class__.__name__,
1310 user=self.user,
1311 subject=getattr(self, 'subject', None),
1312 seen='unseen' if not self.seen else 'seen')
1313
dc19e98d 1314 def __unicode__(self):
09bed9a7 1315 return u'<{klass} #{id}: {user}: {subject} ({seen})>'.format(
2d7b6bde
JW
1316 id=self.id,
1317 klass=self.__class__.__name__,
1318 user=self.user,
1319 subject=getattr(self, 'subject', None),
1320 seen='unseen' if not self.seen else 'seen')
1321
64a456a4 1322class Report(Base):
30a9fe7c 1323 """
64a456a4 1324 Represents a report that someone might file against Media, Comments, etc.
8e91df87 1325
1326 :keyword reporter_id Holds the id of the user who created
1327 the report, as an Integer column.
1328 :keyword report_content Hold the explanation left by the repor-
1329 -ter to indicate why they filed the
1330 report in the first place, as a
1331 Unicode column.
1332 :keyword reported_user_id Holds the id of the user who created
1333 the content which was reported, as
1334 an Integer column.
1335 :keyword created Holds a datetime column of when the re-
1336 -port was filed.
c9068870 1337 :keyword resolver_id Holds the id of the moderator/admin who
1338 resolved the report.
1339 :keyword resolved Holds the DateTime object which descri-
1340 -bes when this report was resolved
1341 :keyword result Holds the UnicodeText column of the
1342 resolver's reasons for resolving
1343 the report this way. Some of this
1344 is auto-generated
64a456a4
JT
1345 :keyword object_id Holds the ID of the GenericModelReference
1346 which points to the reported object.
30a9fe7c 1347 """
1348 __tablename__ = 'core__reports'
65805ffb 1349
30a9fe7c 1350 id = Column(Integer, primary_key=True)
1351 reporter_id = Column(Integer, ForeignKey(User.id), nullable=False)
9b8ef022 1352 reporter = relationship(
dfd66b78 1353 User,
9b8ef022 1354 backref=backref("reports_filed_by",
1355 lazy="dynamic",
3ce0c611 1356 cascade="all, delete-orphan"),
64a456a4 1357 primaryjoin="User.id==Report.reporter_id")
30a9fe7c 1358 report_content = Column(UnicodeText)
3ce0c611 1359 reported_user_id = Column(Integer, ForeignKey(User.id), nullable=False)
1360 reported_user = relationship(
dfd66b78 1361 User,
3ce0c611 1362 backref=backref("reports_filed_on",
1363 lazy="dynamic",
1364 cascade="all, delete-orphan"),
64a456a4 1365 primaryjoin="User.id==Report.reported_user_id")
d705f3b7 1366 created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
c9068870 1367 resolver_id = Column(Integer, ForeignKey(User.id))
1368 resolver = relationship(
1369 User,
1370 backref=backref("reports_resolved_by",
1371 lazy="dynamic",
1372 cascade="all, delete-orphan"),
64a456a4 1373 primaryjoin="User.id==Report.resolver_id")
c9068870 1374
1375 resolved = Column(DateTime)
1376 result = Column(UnicodeText)
65805ffb 1377
9bdb1741 1378 object_id = Column(Integer, ForeignKey(GenericModelReference.id), nullable=True)
64a456a4
JT
1379 object_helper = relationship(GenericModelReference)
1380 obj = association_proxy("object_helper", "get_object",
1381 creator=GenericModelReference.find_or_new)
1382
1383 def is_archived_report(self):
1384 return self.resolved is not None
30a9fe7c 1385
3aa3871b 1386 def is_comment_report(self):
64a456a4
JT
1387 if self.object_id is None:
1388 return False
1389 return isinstance(self.obj(), TextComment)
3aa3871b 1390
1391 def is_media_entry_report(self):
64a456a4
JT
1392 if self.object_id is None:
1393 return False
1394 return isinstance(self.obj(), MediaEntry)
c9068870 1395
1396 def archive(self,resolver_id, resolved, result):
1397 self.resolver_id = resolver_id
1398 self.resolved = resolved
1399 self.result = result
3aa3871b 1400
30a9fe7c 1401class UserBan(Base):
1402 """
dfd66b78 1403 Holds the information on a specific user's ban-state. As long as one of
1404 these is attached to a user, they are banned from accessing mediagoblin.
1405 When they try to log in, they are greeted with a page that tells them
1406 the reason why they are banned and when (if ever) the ban will be
9b8ef022 1407 lifted
1408
dfd66b78 1409 :keyword user_id Holds the id of the user this object is
1410 attached to. This is a one-to-one
9b8ef022 1411 relationship.
dfd66b78 1412 :keyword expiration_date Holds the date that the ban will be lifted.
1413 If this is null, the ban is permanent
9b8ef022 1414 unless a moderator manually lifts it.
6bba33d7 1415 :keyword reason Holds the reason why the user was banned.
30a9fe7c 1416 """
1417 __tablename__ = 'core__user_bans'
1418
dfd66b78 1419 user_id = Column(Integer, ForeignKey(User.id), nullable=False,
9b8ef022 1420 primary_key=True)
1bb367f6 1421 expiration_date = Column(Date)
30a9fe7c 1422 reason = Column(UnicodeText, nullable=False)
1423
1424
3fb96fc9 1425class Privilege(Base):
6bba33d7 1426 """
1427 The Privilege table holds all of the different privileges a user can hold.
1428 If a user 'has' a privilege, the User object is in a relationship with the
dfd66b78 1429 privilege object.
6bba33d7 1430
1431 :keyword privilege_name Holds a unicode object that is the recognizable
dfd66b78 1432 name of this privilege. This is the column
6bba33d7 1433 used for identifying whether or not a user
1434 has a necessary privilege or not.
dfd66b78 1435
6bba33d7 1436 """
3fb96fc9 1437 __tablename__ = 'core__privileges'
30a9fe7c 1438
1439 id = Column(Integer, nullable=False, primary_key=True)
3fb96fc9 1440 privilege_name = Column(Unicode, nullable=False, unique=True)
9b8ef022 1441 all_users = relationship(
dfd66b78 1442 User,
1443 backref='all_privileges',
3fb96fc9 1444 secondary="core__privileges_users")
9b8ef022 1445
3fb96fc9 1446 def __init__(self, privilege_name):
6bba33d7 1447 '''
1448 Currently consructors are required for tables that are initialized thru
1449 the FOUNDATIONS system. This is because they need to be able to be con-
1450 -structed by a list object holding their arg*s
1451 '''
3fb96fc9 1452 self.privilege_name = privilege_name
30a9fe7c 1453
1454 def __repr__(self):
3fb96fc9 1455 return "<Privilege %s>" % (self.privilege_name)
30a9fe7c 1456
6bba33d7 1457
3fb96fc9 1458class PrivilegeUserAssociation(Base):
6bba33d7 1459 '''
1460 This table holds the many-to-many relationship between User and Privilege
1461 '''
dfd66b78 1462
3fb96fc9 1463 __tablename__ = 'core__privileges_users'
30a9fe7c 1464
c56a88b4 1465 user = Column(
987a6351 1466 "user",
dfd66b78 1467 Integer,
1468 ForeignKey(User.id),
9b8ef022 1469 primary_key=True)
c56a88b4 1470 privilege = Column(
987a6351 1471 "privilege",
dfd66b78 1472 Integer,
1473 ForeignKey(Privilege.id),
9b8ef022 1474 primary_key=True)
30a9fe7c 1475
b9492011 1476class Generator(Base):
0421fc5e 1477 """ Information about what created an activity """
b9492011 1478 __tablename__ = "core__generators"
ce46470c 1479
b9492011
JT
1480 id = Column(Integer, primary_key=True)
1481 name = Column(Unicode, nullable=False)
d705f3b7
JT
1482 published = Column(DateTime, default=datetime.datetime.utcnow)
1483 updated = Column(DateTime, default=datetime.datetime.utcnow)
b9492011 1484 object_type = Column(Unicode, nullable=False)
ce46470c 1485
bc75a653 1486 deletion_mode = Base.SOFT_DELETE
30852fda 1487
2b191618
JT
1488 def __repr__(self):
1489 return "<{klass} {name}>".format(
1490 klass=self.__class__.__name__,
1491 name=self.name
1492 )
1493
b9492011 1494 def serialize(self, request):
9c602458 1495 href = request.urlgen(
4fd52036 1496 "mediagoblin.api.object",
9c602458
JT
1497 object_type=self.object_type,
1498 id=self.id,
1499 qualified=True
1500 )
45e687fc
JT
1501 published = UTC.localize(self.published)
1502 updated = UTC.localize(self.updated)
b9492011 1503 return {
9c602458 1504 "id": href,
b9492011 1505 "displayName": self.name,
45e687fc
JT
1506 "published": published.isoformat(),
1507 "updated": updated.isoformat(),
b9492011
JT
1508 "objectType": self.object_type,
1509 }
ce46470c 1510
b9492011
JT
1511 def unserialize(self, data):
1512 if "displayName" in data:
1513 self.name = data["displayName"]
b9492011 1514
ce46470c 1515class Activity(Base, ActivityMixin):
b9492011
JT
1516 """
1517 This holds all the metadata about an activity such as uploading an image,
ce46470c 1518 posting a comment, etc.
b9492011
JT
1519 """
1520 __tablename__ = "core__activities"
ce46470c 1521
b9492011 1522 id = Column(Integer, primary_key=True)
d216d771 1523 public_id = Column(Unicode, unique=True)
ce46470c 1524 actor = Column(Integer,
0421fc5e 1525 ForeignKey("core__users.id"),
ce46470c 1526 nullable=False)
d705f3b7
JT
1527 published = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
1528 updated = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
30852fda 1529
b9492011 1530 verb = Column(Unicode, nullable=False)
1c151268 1531 content = Column(Unicode, nullable=True)
b9492011 1532 title = Column(Unicode, nullable=True)
0421fc5e
JT
1533 generator = Column(Integer,
1534 ForeignKey("core__generators.id"),
1535 nullable=True)
c1d27aa0
JT
1536
1537 # Create the generic foreign keys for the object
1538 object_id = Column(Integer, ForeignKey(GenericModelReference.id), nullable=False)
2d73983e 1539 object_helper = relationship(GenericModelReference, foreign_keys=[object_id])
c1d27aa0
JT
1540 object = association_proxy("object_helper", "get_object",
1541 creator=GenericModelReference.find_or_new)
1542
1543 # Create the generic foreign Key for the target
2d73983e
JT
1544 target_id = Column(Integer, ForeignKey(GenericModelReference.id), nullable=True)
1545 target_helper = relationship(GenericModelReference, foreign_keys=[target_id])
de366f73 1546 target = association_proxy("target_helper", "get_object",
c1d27aa0 1547 creator=GenericModelReference.find_or_new)
ce46470c
JT
1548
1549 get_actor = relationship(User,
63d69537
JT
1550 backref=backref("activities",
1551 cascade="all, delete-orphan"))
1c151268 1552 get_generator = relationship(Generator)
ce46470c 1553
bc75a653 1554 deletion_mode = Base.SOFT_DELETE
30852fda 1555
2b191618
JT
1556 def __repr__(self):
1557 if self.content is None:
1558 return "<{klass} verb:{verb}>".format(
1559 klass=self.__class__.__name__,
1560 verb=self.verb
1561 )
1562 else:
1563 return "<{klass} {content}>".format(
1564 klass=self.__class__.__name__,
1565 content=self.content
1566 )
1567
ce46470c
JT
1568 def save(self, set_updated=True, *args, **kwargs):
1569 if set_updated:
1570 self.updated = datetime.datetime.now()
1571 super(Activity, self).save(*args, **kwargs)
b9492011 1572
bc75a653
JT
1573class Graveyard(Base):
1574 """ Where models come to die """
1575 __tablename__ = "core__graveyard"
1576
1577 id = Column(Integer, primary_key=True)
1578 public_id = Column(Unicode, nullable=True, unique=True)
1579
1580 deleted = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
1581 object_type = Column(Unicode, nullable=False)
1582
1583 # This could either be a deleted actor or a real actor, this must be
1584 # nullable as it we shouldn't have it set for deleted actor
1585 actor_id = Column(Integer, ForeignKey(GenericModelReference.id))
1586 actor_helper = relationship(GenericModelReference)
1587 actor = association_proxy("actor_helper", "get_object",
1588 creator=GenericModelReference.find_or_new)
1589
1590 def __repr__(self):
1591 return "<{klass} deleted {obj_type}>".format(
1592 klass=type(self).__name__,
1593 obj_type=self.object_type
1594 )
1595
1596 def serialize(self, request):
89068c2b
JT
1597 deleted = UTC.localize(self.deleted).isoformat()
1598 context = {
bc75a653
JT
1599 "id": self.public_id,
1600 "objectType": self.object_type,
89068c2b
JT
1601 "published": deleted,
1602 "updated": deleted,
1603 "deleted": deleted,
bc75a653
JT
1604 }
1605
89068c2b
JT
1606 if self.actor_id is not None:
1607 context["actor"] = self.actor().serialize(request)
1608
1609 return context
70b44584 1610MODELS = [
64a456a4 1611 LocalUser, RemoteUser, User, MediaEntry, Tag, MediaTag, Comment, TextComment,
d7f35f6f 1612 Collection, CollectionItem, MediaFile, FileKeynames, MediaAttachmentFile,
64a456a4
JT
1613 ProcessingMetaData, Notification, Client, CommentSubscription, Report,
1614 UserBan, Privilege, PrivilegeUserAssociation, RequestToken, AccessToken,
1615 NonceTimestamp, Activity, Generator, Location, GenericModelReference, Graveyard]
70b44584 1616
f2b2008d 1617"""
b5059525 1618 Foundations are the default rows that are created immediately after the tables
f2b2008d 1619 are initialized. Each entry to this dictionary should be in the format of:
1620 ModelConstructorObject:List of Dictionaries
1621 (Each Dictionary represents a row on the Table to be created, containing each
1622 of the columns' names as a key string, and each of the columns' values as a
1623 value)
1624
1625 ex. [NOTE THIS IS NOT BASED OFF OF OUR USER TABLE]
1626 user_foundations = [{'name':u'Joanna', 'age':24},
1627 {'name':u'Andrea', 'age':41}]
1628
1629 FOUNDATIONS = {User:user_foundations}
1630"""
dfd66b78 1631privilege_foundations = [{'privilege_name':u'admin'},
1632 {'privilege_name':u'moderator'},
52a355b2 1633 {'privilege_name':u'uploader'},
dfd66b78 1634 {'privilege_name':u'reporter'},
52a355b2 1635 {'privilege_name':u'commenter'},
1636 {'privilege_name':u'active'}]
3fb96fc9 1637FOUNDATIONS = {Privilege:privilege_foundations}
70b44584
CAW
1638
1639######################################################
1640# Special, migrations-tracking table
1641#
1642# Not listed in MODELS because this is special and not
1643# really migrated, but used for migrations (for now)
1644######################################################
1645
1646class MigrationData(Base):
2f5ce68c 1647 __tablename__ = "core__migrations"
70b44584 1648
bf813828 1649 name = Column(Unicode, primary_key=True)
70b44584
CAW
1650 version = Column(Integer, nullable=False, default=0)
1651
1652######################################################
1653
1654
eea6d276
E
1655def show_table_init(engine_uri):
1656 if engine_uri is None:
1657 engine_uri = 'sqlite:///:memory:'
e365f980 1658 from sqlalchemy import create_engine
eea6d276 1659 engine = create_engine(engine_uri, echo=True)
e365f980
E
1660
1661 Base.metadata.create_all(engine)
1662
1663
1664if __name__ == '__main__':
eea6d276 1665 from sys import argv
f6a700e8 1666 print(repr(argv))
eea6d276
E
1667 if len(argv) == 2:
1668 uri = argv[1]
1669 else:
1670 uri = None
1671 show_table_init(uri)