Catch import error when text is used as a reason instead of path
[mediagoblin.git] / mediagoblin / db / mixin.py
CommitLineData
f42e49c3 1# GNU MediaGoblin -- federated, autonomous media hosting
7f4ebeed 2# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
f42e49c3
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
17"""
18This module contains some Mixin classes for the db objects.
19
20A bunch of functions on the db objects are really more like
21"utility functions": They could live outside the classes
22and be called "by hand" passing the appropiate reference.
23They usually only use the public API of the object and
24rarely use database related stuff.
25
26These functions now live here and get "mixed in" into the
27real objects.
28"""
29
a81082fc 30import uuid
907bba31 31import re
2d7b6bde 32from datetime import datetime
72bb46c7 33
45e687fc 34from pytz import UTC
5f8b4ae8
SS
35from werkzeug.utils import cached_property
36
58a94757 37from mediagoblin.media_types import FileTypeNotSupported
17c23e15 38from mediagoblin.tools import common, licenses
58a94757 39from mediagoblin.tools.pluginapi import hook_handle
e61ab099 40from mediagoblin.tools.text import cleaned_markdown_conversion
814334f6 41from mediagoblin.tools.url import slugify
ce46470c 42from mediagoblin.tools.translate import pass_to_ugettext as _
f42e49c3 43
64a456a4
JT
44class 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
d216d771
JT
85class 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,
64a456a4 115 id=str(uuid.uuid4()),
d216d771
JT
116 qualified=True
117 )
64a456a4 118 self.save()
d216d771 119 return self.public_id
f42e49c3
E
120
121class UserMixin(object):
0421fc5e
JT
122 object_type = "person"
123
e61ab099
E
124 @property
125 def bio_html(self):
126 return cleaned_markdown_conversion(self.bio)
127
de6a313c
BP
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',
d216d771 131
de6a313c
BP
132 user=self.username, **kwargs)
133
134
29c65044 135class GenerateSlugMixin(object):
44123853
E
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 """
814334f6 144 def generate_slug(self):
88de830f 145 """
44123853 146 Generate a unique slug for this object.
98587109 147
88de830f
CAW
148 This one does not *force* slugs, but usually it will probably result
149 in a niceish one.
150
98587109
CAW
151 The end *result* of the algorithm will result in these resolutions for
152 these situations:
88de830f
CAW
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 """
c785f3a0 167
66d9f1b2
SS
168 #Is already a slug assigned? Check if it is valid
169 if self.slug:
c785f3a0 170 slug = slugify(self.slug)
b1126f71 171
88de830f 172 # otherwise, try to use the title.
66d9f1b2 173 elif self.title:
88de830f 174 # assign slug based on title
c785f3a0 175 slug = slugify(self.title)
b1126f71 176
c785f3a0
JT
177 else:
178 # We don't have any information to set a slug
179 return
b0118957 180
c785f3a0
JT
181 # We don't want any empty string slugs
182 if slug == u"":
183 return
b1126f71 184
88de830f 185 # Otherwise, let's see if this is unique.
c785f3a0 186 if self.check_slug_used(slug):
88de830f 187 # It looks like it's being used... lame.
b1126f71 188
88de830f
CAW
189 # Can we just append the object's id to the end?
190 if self.id:
c785f3a0 191 slug_with_id = u"%s-%s" % (slug, self.id)
29c65044 192 if not self.check_slug_used(slug_with_id):
88de830f
CAW
193 self.slug = slug_with_id
194 return # success!
b1126f71 195
88de830f
CAW
196 # okay, still no success;
197 # let's whack junk on there till it's unique.
c785f3a0 198 slug += '-' + uuid.uuid4().hex[:4]
a81082fc 199 # keep going if necessary!
c785f3a0
JT
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
814334f6 206
29c65044 207
d216d771 208class MediaEntryMixin(GenerateSlugMixin, GeneratePublicIDMixin):
29c65044
E
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
0f3bf8d4 214 return check_media_slug_used(self.actor, slug, self.id)
29c65044 215
0421fc5e
JT
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
1e72e075
E
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
e77df64f
CAW
229 def get_display_media(self):
230 """Find the best media for display.
f42e49c3 231
53024776 232 We try checking self.media_manager.fetching_order if it exists to
e77df64f 233 pull down the order.
f42e49c3
E
234
235 Returns:
ddbf6af1
CAW
236 (media_size, media_path)
237 or, if not found, None.
e77df64f 238
f42e49c3 239 """
e8676fa3 240 fetch_order = self.media_manager.media_fetch_order
f42e49c3 241
ddbf6af1
CAW
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:
f42e49c3 249 if media_size in media_sizes:
ddbf6af1 250 return media_size, self.media_files[media_size]
f42e49c3
E
251
252 def main_mediafile(self):
253 pass
254
3e907d55
E
255 @property
256 def slug_or_id(self):
7de20e52
CAW
257 if self.slug:
258 return self.slug
259 else:
260 return u'id:%s' % self.id
5f8b4ae8 261
cb7ae1e4 262 def url_for_self(self, urlgen, **extra_args):
f42e49c3
E
263 """
264 Generate an appropriate url for ourselves
265
5c2b8486 266 Use a slug if we have one, else use our 'id'.
f42e49c3 267 """
0f3bf8d4 268 uploader = self.get_actor
f42e49c3 269
3e907d55
E
270 return urlgen(
271 'mediagoblin.user_pages.media_home',
272 user=uploader.username,
273 media=self.slug_or_id,
274 **extra_args)
f42e49c3 275
2e4ad359
SS
276 @property
277 def thumb_url(self):
278 """Return the thumbnail URL (for usage in templates)
279 Will return either the real thumbnail or a default fallback icon."""
280 # TODO: implement generic fallback in case MEDIA_MANAGER does
281 # not specify one?
282 if u'thumb' in self.media_files:
ddabf20f 283 thumb_url = self._app.public_store.file_url(
2e4ad359
SS
284 self.media_files[u'thumb'])
285 else:
df1c4976 286 # No thumbnail in media available. Get the media's
2e4ad359 287 # MEDIA_MANAGER for the fallback icon and return static URL
5f8b4ae8
SS
288 # Raises FileTypeNotSupported in case no such manager is enabled
289 manager = self.media_manager
ddabf20f 290 thumb_url = self._app.staticdirector(manager[u'default_thumb'])
2e4ad359
SS
291 return thumb_url
292
5b014a08
JT
293 @property
294 def original_url(self):
295 """ Returns the URL for the original image
296 will return self.thumb_url if original url doesn't exist"""
297 if u"original" not in self.media_files:
298 return self.thumb_url
ce46470c 299
ddabf20f 300 return self._app.public_store.file_url(
5b014a08
JT
301 self.media_files[u"original"]
302 )
303
654d7cf9
BB
304 @property
305 def icon_url(self):
306 '''Return the icon URL (for usage in templates) if it exists'''
307 try:
308 return self._app.staticdirector(
309 self.media_manager['type_icon'])
310 except AttributeError:
311 return None
312
5f8b4ae8
SS
313 @cached_property
314 def media_manager(self):
315 """Returns the MEDIA_MANAGER of the media's media_type
316
317 Raises FileTypeNotSupported in case no such manager is enabled
318 """
6403bc92 319 manager = hook_handle(('media_manager', self.media_type))
58a94757 320 if manager:
4259ad5b
CAW
321 return manager(self)
322
5f8b4ae8
SS
323 # Not found? Then raise an error
324 raise FileTypeNotSupported(
e6991972
RE
325 "MediaManager not in enabled types. Check media_type plugins are"
326 " enabled in config?")
5f8b4ae8 327
f42e49c3
E
328 def get_fail_exception(self):
329 """
330 Get the exception that's appropriate for this error
331 """
51eb0267 332 if self.fail_error:
7dcdc2dc
BB
333 try:
334 return common.import_component(self.fail_error)
335 except ImportError:
336 # TODO(breton): fail_error should give some hint about why it
337 # failed. fail_error is used as a path to import().
338 # Unfortunately, I didn't know about that and put general error
339 # message there. Maybe it's for the best, because for admin,
340 # we could show even some raw python things. Anyway, this
341 # should be properly resolved. Now we are in a freeze, that's
342 # why I simply catch ImportError.
343 return self.fail_error
17c23e15
AW
344
345 def get_license_data(self):
346 """Return license dict for requested license"""
138a18fd 347 return licenses.get_license_by_url(self.license or "")
feba5c52 348
5bad26bc
E
349 def exif_display_iter(self):
350 if not self.media_data:
351 return
352 exif_all = self.media_data.get("exif_all")
353
b3566e1d
GS
354 for key in exif_all:
355 label = re.sub('(.)([A-Z][a-z]+)', r'\1 \2', key)
356 yield label.replace('EXIF', '').replace('Image', ''), exif_all[key]
5bad26bc 357
420e1374
GS
358 def exif_display_data_short(self):
359 """Display a very short practical version of exif info"""
420e1374
GS
360 if not self.media_data:
361 return
907bba31 362
420e1374 363 exif_all = self.media_data.get("exif_all")
907bba31 364
14aa2eaa
JW
365 exif_short = {}
366
907bba31
JW
367 if 'Image DateTimeOriginal' in exif_all:
368 # format date taken
196cef38 369 takendate = datetime.strptime(
907bba31
JW
370 exif_all['Image DateTimeOriginal']['printable'],
371 '%Y:%m:%d %H:%M:%S').date()
372 taken = takendate.strftime('%B %d %Y')
373
14aa2eaa
JW
374 exif_short.update({'Date Taken': taken})
375
1b6a2b85 376 aperture = None
907bba31
JW
377 if 'EXIF FNumber' in exif_all:
378 fnum = str(exif_all['EXIF FNumber']['printable']).split('/')
379
1b6a2b85
JW
380 # calculate aperture
381 if len(fnum) == 2:
382 aperture = "f/%.1f" % (float(fnum[0])/float(fnum[1]))
383 elif fnum[0] != 'None':
384 aperture = "f/%s" % (fnum[0])
907bba31 385
14aa2eaa
JW
386 if aperture:
387 exif_short.update({'Aperture': aperture})
388
389 short_keys = [
390 ('Camera', 'Image Model', None),
391 ('Exposure', 'EXIF ExposureTime', lambda x: '%s sec' % x),
392 ('ISO Speed', 'EXIF ISOSpeedRatings', None),
393 ('Focal Length', 'EXIF FocalLength', lambda x: '%s mm' % x)]
394
395 for label, key, fmt_func in short_keys:
396 try:
397 val = fmt_func(exif_all[key]['printable']) if fmt_func \
398 else exif_all[key]['printable']
399 exif_short.update({label: val})
400 except KeyError:
401 pass
402
403 return exif_short
404
feba5c52 405
64a456a4 406class TextCommentMixin(GeneratePublicIDMixin):
0421fc5e
JT
407 object_type = "comment"
408
feba5c52
E
409 @property
410 def content_html(self):
411 """
412 the actual html-rendered version of the comment displayed.
413 Run through Markdown and the HTML cleaner.
414 """
415 return cleaned_markdown_conversion(self.content)
be5be115 416
dc19e98d 417 def __unicode__(self):
0f3bf8d4 418 return u'<{klass} #{id} {actor} "{comment}">'.format(
2d7b6bde
JW
419 klass=self.__class__.__name__,
420 id=self.id,
0f3bf8d4 421 actor=self.get_actor,
2d7b6bde
JW
422 comment=self.content)
423
dc19e98d 424 def __repr__(self):
0f3bf8d4 425 return '<{klass} #{id} {actor} "{comment}">'.format(
dc19e98d
TB
426 klass=self.__class__.__name__,
427 id=self.id,
0f3bf8d4 428 actor=self.get_actor,
dc19e98d
TB
429 comment=self.content)
430
d216d771 431class CollectionMixin(GenerateSlugMixin, GeneratePublicIDMixin):
0421fc5e
JT
432 object_type = "collection"
433
455fd36f 434 def check_slug_used(self, slug):
be5be115
AW
435 # import this here due to a cyclic import issue
436 # (db.models -> db.mixin -> db.util -> db.models)
437 from mediagoblin.db.util import check_collection_slug_used
438
0f3bf8d4 439 return check_collection_slug_used(self.actor, slug, self.id)
be5be115
AW
440
441 @property
442 def description_html(self):
443 """
444 Rendered version of the description, run through
445 Markdown and cleaned with our cleaning tool.
446 """
447 return cleaned_markdown_conversion(self.description)
448
449 @property
450 def slug_or_id(self):
5c2b8486 451 return (self.slug or self.id)
be5be115
AW
452
453 def url_for_self(self, urlgen, **extra_args):
454 """
455 Generate an appropriate url for ourselves
456
5c2b8486 457 Use a slug if we have one, else use our 'id'.
be5be115 458 """
0f3bf8d4 459 creator = self.get_actor
be5be115
AW
460
461 return urlgen(
256f816f 462 'mediagoblin.user_pages.user_collection',
be5be115
AW
463 user=creator.username,
464 collection=self.slug_or_id,
465 **extra_args)
466
64a456a4
JT
467 def add_to_collection(self, obj, content=None, commit=True):
468 """ Adds an object to the collection """
469 # It's here to prevent cyclic imports
470 from mediagoblin.db.models import CollectionItem
654d7cf9 471
64a456a4
JT
472 # Need the ID of this collection for this so check we've got one.
473 self.save(commit=False)
474
475 # Create the CollectionItem
476 item = CollectionItem()
477 item.collection = self.id
478 item.get_object = obj
654d7cf9 479
64a456a4
JT
480 if content is not None:
481 item.note = content
482
483 self.num_items = self.num_items + 1
654d7cf9 484
64a456a4
JT
485 # Save both!
486 self.save(commit=commit)
487 item.save(commit=commit)
654d7cf9 488 return item
6d1e55b2 489
be5be115
AW
490class CollectionItemMixin(object):
491 @property
492 def note_html(self):
493 """
494 the actual html-rendered version of the note displayed.
495 Run through Markdown and the HTML cleaner.
496 """
497 return cleaned_markdown_conversion(self.note)
ce46470c 498
d216d771 499class ActivityMixin(GeneratePublicIDMixin):
0421fc5e 500 object_type = "activity"
ce46470c
JT
501
502 VALID_VERBS = ["add", "author", "create", "delete", "dislike", "favorite",
503 "follow", "like", "post", "share", "unfavorite", "unfollow",
504 "unlike", "unshare", "update", "tag"]
505
506 def get_url(self, request):
507 return request.urlgen(
4fd52036 508 "mediagoblin.user_pages.activity_view",
ce46470c
JT
509 username=self.get_actor.username,
510 id=self.id,
511 qualified=True
512 )
513
514 def generate_content(self):
515 """ Produces a HTML content for object """
516 # some of these have simple and targetted. If self.target it set
517 # it will pick the targetted. If they DON'T have a targetted version
518 # the information in targetted won't be added to the content.
519 verb_to_content = {
520 "add": {
521 "simple" : _("{username} added {object}"),
522 "targetted": _("{username} added {object} to {target}"),
523 },
524 "author": {"simple": _("{username} authored {object}")},
525 "create": {"simple": _("{username} created {object}")},
526 "delete": {"simple": _("{username} deleted {object}")},
527 "dislike": {"simple": _("{username} disliked {object}")},
528 "favorite": {"simple": _("{username} favorited {object}")},
529 "follow": {"simple": _("{username} followed {object}")},
530 "like": {"simple": _("{username} liked {object}")},
531 "post": {
532 "simple": _("{username} posted {object}"),
2b191618 533 "targetted": _("{username} posted {object} to {target}"),
ce46470c
JT
534 },
535 "share": {"simple": _("{username} shared {object}")},
536 "unfavorite": {"simple": _("{username} unfavorited {object}")},
537 "unfollow": {"simple": _("{username} stopped following {object}")},
538 "unlike": {"simple": _("{username} unliked {object}")},
539 "unshare": {"simple": _("{username} unshared {object}")},
540 "update": {"simple": _("{username} updated {object}")},
541 "tag": {"simple": _("{username} tagged {object}")},
542 }
543
1e0c938c
JT
544 object_map = {
545 "image": _("an image"),
546 "comment": _("a comment"),
547 "collection": _("a collection"),
548 "video": _("a video"),
549 "audio": _("audio"),
550 "person": _("a person"),
551 }
b4997540
JT
552 obj = self.object()
553 target = None if self.target_id is None else self.target()
ce46470c
JT
554 actor = self.get_actor
555 content = verb_to_content.get(self.verb, None)
556
2d73983e 557 if content is None or self.object is None:
ce46470c
JT
558 return
559
1e0c938c
JT
560 # Decide what to fill the object with
561 if hasattr(obj, "title") and obj.title.strip(" "):
7eac1e6d 562 object_value = obj.title
1e0c938c 563 elif obj.object_type in object_map:
7eac1e6d 564 object_value = object_map[obj.object_type]
1e0c938c 565 else:
7eac1e6d 566 object_value = _("an object")
1e0c938c 567
7eac1e6d
JT
568 # Do we want to add a target (indirect object) to content?
569 if target is not None and "targetted" in content:
570 if hasattr(target, "title") and target.title.strip(" "):
b4997540 571 target_value = target.title
7eac1e6d
JT
572 elif target.object_type in object_map:
573 target_value = object_map[target.object_type]
574 else:
575 target_value = _("an object")
576
577 self.content = content["targetted"].format(
ce46470c 578 username=actor.username,
7eac1e6d
JT
579 object=object_value,
580 target=target_value
ce46470c
JT
581 )
582 else:
7eac1e6d 583 self.content = content["simple"].format(
ce46470c 584 username=actor.username,
7eac1e6d 585 object=object_value
ce46470c
JT
586 )
587
588 return self.content
589
590 def serialize(self, request):
9c602458 591 href = request.urlgen(
4fd52036 592 "mediagoblin.api.object",
9c602458
JT
593 object_type=self.object_type,
594 id=self.id,
595 qualified=True
596 )
45e687fc
JT
597 published = UTC.localize(self.published)
598 updated = UTC.localize(self.updated)
ce46470c 599 obj = {
9c602458 600 "id": href,
ce46470c
JT
601 "actor": self.get_actor.serialize(request),
602 "verb": self.verb,
45e687fc
JT
603 "published": published.isoformat(),
604 "updated": updated.isoformat(),
ce46470c
JT
605 "content": self.content,
606 "url": self.get_url(request),
de366f73 607 "object": self.object().serialize(request),
0421fc5e 608 "objectType": self.object_type,
35885226
JT
609 "links": {
610 "self": {
611 "href": href,
612 },
613 },
ce46470c
JT
614 }
615
616 if self.generator:
6d36f75f 617 obj["generator"] = self.get_generator.serialize(request)
ce46470c
JT
618
619 if self.title:
620 obj["title"] = self.title
621
de366f73
JT
622 if self.target_id is not None:
623 obj["target"] = self.target().serialize(request)
ce46470c
JT
624
625 return obj
626
627 def unseralize(self, data):
628 """
629 Takes data given and set it on this activity.
630
631 Several pieces of data are not written on because of security
632 reasons. For example changing the author or id of an activity.
633 """
634 if "verb" in data:
635 self.verb = data["verb"]
636
637 if "title" in data:
638 self.title = data["title"]
639
640 if "content" in data:
641 self.content = data["content"]