Add get_all_media() in MediaEntryMixin
[mediagoblin.git] / mediagoblin / db / mixin.py
1 # GNU MediaGoblin -- federated, autonomous media hosting
2 # Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17 """
18 This module contains some Mixin classes for the db objects.
19
20 A bunch of functions on the db objects are really more like
21 "utility functions": They could live outside the classes
22 and be called "by hand" passing the appropiate reference.
23 They usually only use the public API of the object and
24 rarely use database related stuff.
25
26 These functions now live here and get "mixed in" into the
27 real objects.
28 """
29
30 import uuid
31 import re
32 from datetime import datetime
33
34 from pytz import UTC
35 from werkzeug.utils import cached_property
36
37 from mediagoblin.media_types import FileTypeNotSupported
38 from mediagoblin.tools import common, licenses
39 from mediagoblin.tools.pluginapi import hook_handle
40 from mediagoblin.tools.text import cleaned_markdown_conversion
41 from mediagoblin.tools.url import slugify
42 from mediagoblin.tools.translate import pass_to_ugettext as _
43
44 class CommentingMixin(object):
45 """
46 Mixin that gives classes methods to get and add the comments on/to it
47
48 This assumes the model has a "comments" class which is a ForeignKey to the
49 Collection model. This will hold a Collection of comments which are
50 associated to this model. It also assumes the model has an "actor"
51 ForeignKey which points to the creator/publisher/etc. of the model.
52
53 NB: This is NOT the mixin for the Comment Model, this is for
54 other models which support commenting.
55 """
56
57 def get_comment_link(self):
58 # Import here to avoid cyclic imports
59 from mediagoblin.db.models import Comment, GenericModelReference
60
61 gmr = GenericModelReference.query.filter_by(
62 obj_pk=self.id,
63 model_type=self.__tablename__
64 ).first()
65
66 if gmr is None:
67 return None
68
69 link = Comment.query.filter_by(comment_id=gmr.id).first()
70 return link
71
72 def get_reply_to(self):
73 link = self.get_comment_link()
74 if link is None or link.target_id is None:
75 return None
76
77 return link.target()
78
79 def soft_delete(self, *args, **kwargs):
80 link = self.get_comment_link()
81 if link is not None:
82 link.delete()
83 super(CommentingMixin, self).soft_delete(*args, **kwargs)
84
85 class GeneratePublicIDMixin(object):
86 """
87 Mixin that ensures that a the public_id field is populated.
88
89 The public_id is the ID that is used in the API, this must be globally
90 unique and dereferencable. This will be the URL for the API view of the
91 object. It's used in several places, not only is it used to give out via
92 the API but it's also vital information stored when a soft_deletion occurs
93 on the `Graveyard.public_id` field, this is needed to follow the spec which
94 says we have to be able to provide a shell of an object and return a 410
95 (rather than a 404) when a deleted object has been deleted.
96
97 This requires a the urlgen off the request object (`request.urlgen`) to be
98 provided as it's the ID is a URL.
99 """
100
101 def get_public_id(self, urlgen):
102 # Verify that the class this is on actually has a public_id field...
103 if "public_id" not in self.__table__.columns.keys():
104 raise Exception("Model has no public_id field")
105
106 # Great! the model has a public id, if it's None, let's create one!
107 if self.public_id is None:
108 # We need the internal ID for this so ensure we've been saved.
109 self.save(commit=False)
110
111 # Create the URL
112 self.public_id = urlgen(
113 "mediagoblin.api.object",
114 object_type=self.object_type,
115 id=str(uuid.uuid4()),
116 qualified=True
117 )
118 self.save()
119 return self.public_id
120
121 class UserMixin(object):
122 object_type = "person"
123
124 @property
125 def bio_html(self):
126 return cleaned_markdown_conversion(self.bio)
127
128 def url_for_self(self, urlgen, **kwargs):
129 """Generate a URL for this User's home page."""
130 return urlgen('mediagoblin.user_pages.user_home',
131
132 user=self.username, **kwargs)
133
134
135 class GenerateSlugMixin(object):
136 """
137 Mixin to add a generate_slug method to objects.
138
139 Depends on:
140 - self.slug
141 - self.title
142 - self.check_slug_used(new_slug)
143 """
144 def generate_slug(self):
145 """
146 Generate a unique slug for this object.
147
148 This one does not *force* slugs, but usually it will probably result
149 in a niceish one.
150
151 The end *result* of the algorithm will result in these resolutions for
152 these situations:
153 - If we have a slug, make sure it's clean and sanitized, and if it's
154 unique, we'll use that.
155 - If we have a title, slugify it, and if it's unique, we'll use that.
156 - If we can't get any sort of thing that looks like it'll be a useful
157 slug out of a title or an existing slug, bail, and don't set the
158 slug at all. Don't try to create something just because. Make
159 sure we have a reasonable basis for a slug first.
160 - If we have a reasonable basis for a slug (either based on existing
161 slug or slugified title) but it's not unique, first try appending
162 the entry's id, if that exists
163 - If that doesn't result in something unique, tack on some randomly
164 generated bits until it's unique. That'll be a little bit of junk,
165 but at least it has the basis of a nice slug.
166 """
167
168 #Is already a slug assigned? Check if it is valid
169 if self.slug:
170 slug = slugify(self.slug)
171
172 # otherwise, try to use the title.
173 elif self.title:
174 # assign slug based on title
175 slug = slugify(self.title)
176
177 else:
178 # We don't have any information to set a slug
179 return
180
181 # We don't want any empty string slugs
182 if slug == u"":
183 return
184
185 # Otherwise, let's see if this is unique.
186 if self.check_slug_used(slug):
187 # It looks like it's being used... lame.
188
189 # Can we just append the object's id to the end?
190 if self.id:
191 slug_with_id = u"%s-%s" % (slug, self.id)
192 if not self.check_slug_used(slug_with_id):
193 self.slug = slug_with_id
194 return # success!
195
196 # okay, still no success;
197 # let's whack junk on there till it's unique.
198 slug += '-' + uuid.uuid4().hex[:4]
199 # keep going if necessary!
200 while self.check_slug_used(slug):
201 slug += uuid.uuid4().hex[:4]
202
203 # self.check_slug_used(slug) must be False now so we have a slug that
204 # we can use now.
205 self.slug = slug
206
207
208 class MediaEntryMixin(GenerateSlugMixin, GeneratePublicIDMixin):
209 def check_slug_used(self, slug):
210 # import this here due to a cyclic import issue
211 # (db.models -> db.mixin -> db.util -> db.models)
212 from mediagoblin.db.util import check_media_slug_used
213
214 return check_media_slug_used(self.actor, slug, self.id)
215
216 @property
217 def object_type(self):
218 """ Converts media_type to pump-like type - don't use internally """
219 return self.media_type.split(".")[-1]
220
221 @property
222 def description_html(self):
223 """
224 Rendered version of the description, run through
225 Markdown and cleaned with our cleaning tool.
226 """
227 return cleaned_markdown_conversion(self.description)
228
229 def get_display_media(self):
230 """Find the best media for display.
231
232 We try checking self.media_manager.fetching_order if it exists to
233 pull down the order.
234
235 Returns:
236 (media_size, media_path)
237 or, if not found, None.
238
239 """
240 fetch_order = self.media_manager.media_fetch_order
241
242 # No fetching order found? well, give up!
243 if not fetch_order:
244 return None
245
246 media_sizes = self.media_files.keys()
247
248 for media_size in fetch_order:
249 if media_size in media_sizes:
250 return media_size, self.media_files[media_size]
251
252 def get_all_media(self):
253 """
254 Returns all available qualties of a media
255 """
256 fetch_order = self.media_manager.media_fetch_order
257
258 # No fetching order found? well, give up!
259 if not fetch_order:
260 return None
261
262 media_sizes = self.media_files.keys()
263
264 all_media_path = []
265
266 for media_size in fetch_order:
267 if media_size in media_sizes:
268 file_metadata = self.get_file_metadata(media_size)
269 size = file_metadata['medium_size']
270 if media_size != 'webm':
271 all_media_path.append((media_size[5:], size,
272 self.media_files[media_size]))
273 else:
274 sall_media_path.append(('default', size,
275 self.media_files[media_size]))
276
277 return all_media_path
278
279 def main_mediafile(self):
280 pass
281
282 @property
283 def slug_or_id(self):
284 if self.slug:
285 return self.slug
286 else:
287 return u'id:%s' % self.id
288
289 def url_for_self(self, urlgen, **extra_args):
290 """
291 Generate an appropriate url for ourselves
292
293 Use a slug if we have one, else use our 'id'.
294 """
295 uploader = self.get_actor
296
297 return urlgen(
298 'mediagoblin.user_pages.media_home',
299 user=uploader.username,
300 media=self.slug_or_id,
301 **extra_args)
302
303 @property
304 def thumb_url(self):
305 """Return the thumbnail URL (for usage in templates)
306 Will return either the real thumbnail or a default fallback icon."""
307 # TODO: implement generic fallback in case MEDIA_MANAGER does
308 # not specify one?
309 if u'thumb' in self.media_files:
310 thumb_url = self._app.public_store.file_url(
311 self.media_files[u'thumb'])
312 else:
313 # No thumbnail in media available. Get the media's
314 # MEDIA_MANAGER for the fallback icon and return static URL
315 # Raises FileTypeNotSupported in case no such manager is enabled
316 manager = self.media_manager
317 thumb_url = self._app.staticdirector(manager[u'default_thumb'])
318 return thumb_url
319
320 @property
321 def original_url(self):
322 """ Returns the URL for the original image
323 will return self.thumb_url if original url doesn't exist"""
324 if u"original" not in self.media_files:
325 return self.thumb_url
326
327 return self._app.public_store.file_url(
328 self.media_files[u"original"]
329 )
330
331 @property
332 def icon_url(self):
333 '''Return the icon URL (for usage in templates) if it exists'''
334 try:
335 return self._app.staticdirector(
336 self.media_manager['type_icon'])
337 except AttributeError:
338 return None
339
340 @cached_property
341 def media_manager(self):
342 """Returns the MEDIA_MANAGER of the media's media_type
343
344 Raises FileTypeNotSupported in case no such manager is enabled
345 """
346 manager = hook_handle(('media_manager', self.media_type))
347 if manager:
348 return manager(self)
349
350 # Not found? Then raise an error
351 raise FileTypeNotSupported(
352 "MediaManager not in enabled types. Check media_type plugins are"
353 " enabled in config?")
354
355 def get_fail_exception(self):
356 """
357 Get the exception that's appropriate for this error
358 """
359 if self.fail_error:
360 try:
361 return common.import_component(self.fail_error)
362 except ImportError:
363 # TODO(breton): fail_error should give some hint about why it
364 # failed. fail_error is used as a path to import().
365 # Unfortunately, I didn't know about that and put general error
366 # message there. Maybe it's for the best, because for admin,
367 # we could show even some raw python things. Anyway, this
368 # should be properly resolved. Now we are in a freeze, that's
369 # why I simply catch ImportError.
370 return None
371
372 def get_license_data(self):
373 """Return license dict for requested license"""
374 return licenses.get_license_by_url(self.license or "")
375
376 def exif_display_iter(self):
377 if not self.media_data:
378 return
379 exif_all = self.media_data.get("exif_all")
380
381 for key in exif_all:
382 label = re.sub('(.)([A-Z][a-z]+)', r'\1 \2', key)
383 yield label.replace('EXIF', '').replace('Image', ''), exif_all[key]
384
385 def exif_display_data_short(self):
386 """Display a very short practical version of exif info"""
387 if not self.media_data:
388 return
389
390 exif_all = self.media_data.get("exif_all")
391
392 exif_short = {}
393
394 if 'Image DateTimeOriginal' in exif_all:
395 # format date taken
396 takendate = datetime.strptime(
397 exif_all['Image DateTimeOriginal']['printable'],
398 '%Y:%m:%d %H:%M:%S').date()
399 taken = takendate.strftime('%B %d %Y')
400
401 exif_short.update({'Date Taken': taken})
402
403 aperture = None
404 if 'EXIF FNumber' in exif_all:
405 fnum = str(exif_all['EXIF FNumber']['printable']).split('/')
406
407 # calculate aperture
408 if len(fnum) == 2:
409 aperture = "f/%.1f" % (float(fnum[0])/float(fnum[1]))
410 elif fnum[0] != 'None':
411 aperture = "f/%s" % (fnum[0])
412
413 if aperture:
414 exif_short.update({'Aperture': aperture})
415
416 short_keys = [
417 ('Camera', 'Image Model', None),
418 ('Exposure', 'EXIF ExposureTime', lambda x: '%s sec' % x),
419 ('ISO Speed', 'EXIF ISOSpeedRatings', None),
420 ('Focal Length', 'EXIF FocalLength', lambda x: '%s mm' % x)]
421
422 for label, key, fmt_func in short_keys:
423 try:
424 val = fmt_func(exif_all[key]['printable']) if fmt_func \
425 else exif_all[key]['printable']
426 exif_short.update({label: val})
427 except KeyError:
428 pass
429
430 return exif_short
431
432
433 class TextCommentMixin(GeneratePublicIDMixin):
434 object_type = "comment"
435
436 @property
437 def content_html(self):
438 """
439 the actual html-rendered version of the comment displayed.
440 Run through Markdown and the HTML cleaner.
441 """
442 return cleaned_markdown_conversion(self.content)
443
444 def __unicode__(self):
445 return u'<{klass} #{id} {actor} "{comment}">'.format(
446 klass=self.__class__.__name__,
447 id=self.id,
448 actor=self.get_actor,
449 comment=self.content)
450
451 def __repr__(self):
452 return '<{klass} #{id} {actor} "{comment}">'.format(
453 klass=self.__class__.__name__,
454 id=self.id,
455 actor=self.get_actor,
456 comment=self.content)
457
458 class CollectionMixin(GenerateSlugMixin, GeneratePublicIDMixin):
459 object_type = "collection"
460
461 def check_slug_used(self, slug):
462 # import this here due to a cyclic import issue
463 # (db.models -> db.mixin -> db.util -> db.models)
464 from mediagoblin.db.util import check_collection_slug_used
465
466 return check_collection_slug_used(self.actor, slug, self.id)
467
468 @property
469 def description_html(self):
470 """
471 Rendered version of the description, run through
472 Markdown and cleaned with our cleaning tool.
473 """
474 return cleaned_markdown_conversion(self.description)
475
476 @property
477 def slug_or_id(self):
478 return (self.slug or self.id)
479
480 def url_for_self(self, urlgen, **extra_args):
481 """
482 Generate an appropriate url for ourselves
483
484 Use a slug if we have one, else use our 'id'.
485 """
486 creator = self.get_actor
487
488 return urlgen(
489 'mediagoblin.user_pages.user_collection',
490 user=creator.username,
491 collection=self.slug_or_id,
492 **extra_args)
493
494 def add_to_collection(self, obj, content=None, commit=True):
495 """ Adds an object to the collection """
496 # It's here to prevent cyclic imports
497 from mediagoblin.db.models import CollectionItem
498
499 # Need the ID of this collection for this so check we've got one.
500 self.save(commit=False)
501
502 # Create the CollectionItem
503 item = CollectionItem()
504 item.collection = self.id
505 item.get_object = obj
506
507 if content is not None:
508 item.note = content
509
510 self.num_items = self.num_items + 1
511
512 # Save both!
513 self.save(commit=commit)
514 item.save(commit=commit)
515 return item
516
517 class CollectionItemMixin(object):
518 @property
519 def note_html(self):
520 """
521 the actual html-rendered version of the note displayed.
522 Run through Markdown and the HTML cleaner.
523 """
524 return cleaned_markdown_conversion(self.note)
525
526 class ActivityMixin(GeneratePublicIDMixin):
527 object_type = "activity"
528
529 VALID_VERBS = ["add", "author", "create", "delete", "dislike", "favorite",
530 "follow", "like", "post", "share", "unfavorite", "unfollow",
531 "unlike", "unshare", "update", "tag"]
532
533 def get_url(self, request):
534 return request.urlgen(
535 "mediagoblin.user_pages.activity_view",
536 username=self.get_actor.username,
537 id=self.id,
538 qualified=True
539 )
540
541 def generate_content(self):
542 """ Produces a HTML content for object """
543 # some of these have simple and targetted. If self.target it set
544 # it will pick the targetted. If they DON'T have a targetted version
545 # the information in targetted won't be added to the content.
546 verb_to_content = {
547 "add": {
548 "simple" : _("{username} added {object}"),
549 "targetted": _("{username} added {object} to {target}"),
550 },
551 "author": {"simple": _("{username} authored {object}")},
552 "create": {"simple": _("{username} created {object}")},
553 "delete": {"simple": _("{username} deleted {object}")},
554 "dislike": {"simple": _("{username} disliked {object}")},
555 "favorite": {"simple": _("{username} favorited {object}")},
556 "follow": {"simple": _("{username} followed {object}")},
557 "like": {"simple": _("{username} liked {object}")},
558 "post": {
559 "simple": _("{username} posted {object}"),
560 "targetted": _("{username} posted {object} to {target}"),
561 },
562 "share": {"simple": _("{username} shared {object}")},
563 "unfavorite": {"simple": _("{username} unfavorited {object}")},
564 "unfollow": {"simple": _("{username} stopped following {object}")},
565 "unlike": {"simple": _("{username} unliked {object}")},
566 "unshare": {"simple": _("{username} unshared {object}")},
567 "update": {"simple": _("{username} updated {object}")},
568 "tag": {"simple": _("{username} tagged {object}")},
569 }
570
571 object_map = {
572 "image": _("an image"),
573 "comment": _("a comment"),
574 "collection": _("a collection"),
575 "video": _("a video"),
576 "audio": _("audio"),
577 "person": _("a person"),
578 }
579 obj = self.object()
580 target = None if self.target_id is None else self.target()
581 actor = self.get_actor
582 content = verb_to_content.get(self.verb, None)
583
584 if content is None or self.object is None:
585 return
586
587 # Decide what to fill the object with
588 if hasattr(obj, "title") and obj.title.strip(" "):
589 object_value = obj.title
590 elif obj.object_type in object_map:
591 object_value = object_map[obj.object_type]
592 else:
593 object_value = _("an object")
594
595 # Do we want to add a target (indirect object) to content?
596 if target is not None and "targetted" in content:
597 if hasattr(target, "title") and target.title.strip(" "):
598 target_value = target.title
599 elif target.object_type in object_map:
600 target_value = object_map[target.object_type]
601 else:
602 target_value = _("an object")
603
604 self.content = content["targetted"].format(
605 username=actor.username,
606 object=object_value,
607 target=target_value
608 )
609 else:
610 self.content = content["simple"].format(
611 username=actor.username,
612 object=object_value
613 )
614
615 return self.content
616
617 def serialize(self, request):
618 href = request.urlgen(
619 "mediagoblin.api.object",
620 object_type=self.object_type,
621 id=self.id,
622 qualified=True
623 )
624 published = UTC.localize(self.published)
625 updated = UTC.localize(self.updated)
626 obj = {
627 "id": href,
628 "actor": self.get_actor.serialize(request),
629 "verb": self.verb,
630 "published": published.isoformat(),
631 "updated": updated.isoformat(),
632 "content": self.content,
633 "url": self.get_url(request),
634 "object": self.object().serialize(request),
635 "objectType": self.object_type,
636 "links": {
637 "self": {
638 "href": href,
639 },
640 },
641 }
642
643 if self.generator:
644 obj["generator"] = self.get_generator.serialize(request)
645
646 if self.title:
647 obj["title"] = self.title
648
649 if self.target_id is not None:
650 obj["target"] = self.target().serialize(request)
651
652 return obj
653
654 def unseralize(self, data):
655 """
656 Takes data given and set it on this activity.
657
658 Several pieces of data are not written on because of security
659 reasons. For example changing the author or id of an activity.
660 """
661 if "verb" in data:
662 self.verb = data["verb"]
663
664 if "title" in data:
665 self.title = data["title"]
666
667 if "content" in data:
668 self.content = data["content"]