1 # GNU MediaGoblin -- federated, autonomous media hosting
2 # Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU Affero General Public License for more details.
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18 This module contains some Mixin classes for the db objects.
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.
26 These functions now live here and get "mixed in" into the
32 from datetime
import datetime
35 from werkzeug
.utils
import cached_property
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 _
44 class CommentingMixin(object):
46 Mixin that gives classes methods to get and add the comments on/to it
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.
53 NB: This is NOT the mixin for the Comment Model, this is for
54 other models which support commenting.
57 def get_comment_link(self
):
58 # Import here to avoid cyclic imports
59 from mediagoblin
.db
.models
import Comment
, GenericModelReference
61 gmr
= GenericModelReference
.query
.filter_by(
63 model_type
=self
.__tablename
__
69 link
= Comment
.query
.filter_by(comment_id
=gmr
.id).first()
72 def get_reply_to(self
):
73 link
= self
.get_comment_link()
74 if link
is None or link
.target_id
is None:
79 def soft_delete(self
, *args
, **kwargs
):
80 link
= self
.get_comment_link()
83 super(CommentingMixin
, self
).soft_delete(*args
, **kwargs
)
85 class GeneratePublicIDMixin(object):
87 Mixin that ensures that a the public_id field is populated.
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.
97 This requires a the urlgen off the request object (`request.urlgen`) to be
98 provided as it's the ID is a URL.
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")
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)
112 self
.public_id
= urlgen(
113 "mediagoblin.api.object",
114 object_type
=self
.object_type
,
115 id=str(uuid
.uuid4()),
119 return self
.public_id
121 class UserMixin(object):
122 object_type
= "person"
126 return cleaned_markdown_conversion(self
.bio
)
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',
132 user
=self
.username
, **kwargs
)
135 class GenerateSlugMixin(object):
137 Mixin to add a generate_slug method to objects.
142 - self.check_slug_used(new_slug)
144 def generate_slug(self
):
146 Generate a unique slug for this object.
148 This one does not *force* slugs, but usually it will probably result
151 The end *result* of the algorithm will result in these resolutions for
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.
168 #Is already a slug assigned? Check if it is valid
170 slug
= slugify(self
.slug
)
172 # otherwise, try to use the title.
174 # assign slug based on title
175 slug
= slugify(self
.title
)
178 # We don't have any information to set a slug
181 # We don't want any empty string slugs
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.
189 # Can we just append the object's id to the end?
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
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]
203 # self.check_slug_used(slug) must be False now so we have a slug that
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
214 return check_media_slug_used(self
.actor
, slug
, self
.id)
217 def object_type(self
):
218 """ Converts media_type to pump-like type - don't use internally """
219 return self
.media_type
.split(".")[-1]
222 def description_html(self
):
224 Rendered version of the description, run through
225 Markdown and cleaned with our cleaning tool.
227 return cleaned_markdown_conversion(self
.description
)
229 def get_display_media(self
):
230 """Find the best media for display.
232 We try checking self.media_manager.fetching_order if it exists to
236 (media_size, media_path)
237 or, if not found, None.
240 fetch_order
= self
.media_manager
.media_fetch_order
242 # No fetching order found? well, give up!
246 media_sizes
= self
.media_files
.keys()
248 for media_size
in fetch_order
:
249 if media_size
in media_sizes
:
250 return media_size
, self
.media_files
[media_size
]
252 def get_all_media(self
):
254 Returns all available qualties of a media
256 fetch_order
= self
.media_manager
.media_fetch_order
258 # No fetching order found? well, give up!
262 media_sizes
= self
.media_files
.keys()
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
]))
274 sall_media_path
.append(('default', size
,
275 self
.media_files
[media_size
]))
277 return all_media_path
279 def main_mediafile(self
):
283 def slug_or_id(self
):
287 return u
'id:%s' % self
.id
289 def url_for_self(self
, urlgen
, **extra_args
):
291 Generate an appropriate url for ourselves
293 Use a slug if we have one, else use our 'id'.
295 uploader
= self
.get_actor
298 'mediagoblin.user_pages.media_home',
299 user
=uploader
.username
,
300 media
=self
.slug_or_id
,
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
309 if u
'thumb' in self
.media_files
:
310 thumb_url
= self
._app
.public_store
.file_url(
311 self
.media_files
[u
'thumb'])
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'])
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
327 return self
._app
.public_store
.file_url(
328 self
.media_files
[u
"original"]
333 '''Return the icon URL (for usage in templates) if it exists'''
335 return self
._app
.staticdirector(
336 self
.media_manager
['type_icon'])
337 except AttributeError:
341 def media_manager(self
):
342 """Returns the MEDIA_MANAGER of the media's media_type
344 Raises FileTypeNotSupported in case no such manager is enabled
346 manager
= hook_handle(('media_manager', self
.media_type
))
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?")
355 def get_fail_exception(self
):
357 Get the exception that's appropriate for this error
361 return common
.import_component(self
.fail_error
)
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.
372 def get_license_data(self
):
373 """Return license dict for requested license"""
374 return licenses
.get_license_by_url(self
.license
or "")
376 def exif_display_iter(self
):
377 if not self
.media_data
:
379 exif_all
= self
.media_data
.get("exif_all")
382 label
= re
.sub('(.)([A-Z][a-z]+)', r
'\1 \2', key
)
383 yield label
.replace('EXIF', '').replace('Image', ''), exif_all
[key
]
385 def exif_display_data_short(self
):
386 """Display a very short practical version of exif info"""
387 if not self
.media_data
:
390 exif_all
= self
.media_data
.get("exif_all")
394 if 'Image DateTimeOriginal' in exif_all
:
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')
401 exif_short
.update({'Date Taken': taken
})
404 if 'EXIF FNumber' in exif_all
:
405 fnum
= str(exif_all
['EXIF FNumber']['printable']).split('/')
409 aperture
= "f/%.1f" % (float(fnum
[0])/float(fnum
[1]))
410 elif fnum
[0] != 'None':
411 aperture
= "f/%s" % (fnum
[0])
414 exif_short
.update({'Aperture': aperture
})
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
)]
422 for label
, key
, fmt_func
in short_keys
:
424 val
= fmt_func(exif_all
[key
]['printable']) if fmt_func \
425 else exif_all
[key
]['printable']
426 exif_short
.update({label
: val
})
433 class TextCommentMixin(GeneratePublicIDMixin
):
434 object_type
= "comment"
437 def content_html(self
):
439 the actual html-rendered version of the comment displayed.
440 Run through Markdown and the HTML cleaner.
442 return cleaned_markdown_conversion(self
.content
)
444 def __unicode__(self
):
445 return u
'<{klass} #{id} {actor} "{comment}">'.format(
446 klass
=self
.__class
__.__name
__,
448 actor
=self
.get_actor
,
449 comment
=self
.content
)
452 return '<{klass} #{id} {actor} "{comment}">'.format(
453 klass
=self
.__class
__.__name
__,
455 actor
=self
.get_actor
,
456 comment
=self
.content
)
458 class CollectionMixin(GenerateSlugMixin
, GeneratePublicIDMixin
):
459 object_type
= "collection"
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
466 return check_collection_slug_used(self
.actor
, slug
, self
.id)
469 def description_html(self
):
471 Rendered version of the description, run through
472 Markdown and cleaned with our cleaning tool.
474 return cleaned_markdown_conversion(self
.description
)
477 def slug_or_id(self
):
478 return (self
.slug
or self
.id)
480 def url_for_self(self
, urlgen
, **extra_args
):
482 Generate an appropriate url for ourselves
484 Use a slug if we have one, else use our 'id'.
486 creator
= self
.get_actor
489 'mediagoblin.user_pages.user_collection',
490 user
=creator
.username
,
491 collection
=self
.slug_or_id
,
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
499 # Need the ID of this collection for this so check we've got one.
500 self
.save(commit
=False)
502 # Create the CollectionItem
503 item
= CollectionItem()
504 item
.collection
= self
.id
505 item
.get_object
= obj
507 if content
is not None:
510 self
.num_items
= self
.num_items
+ 1
513 self
.save(commit
=commit
)
514 item
.save(commit
=commit
)
517 class CollectionItemMixin(object):
521 the actual html-rendered version of the note displayed.
522 Run through Markdown and the HTML cleaner.
524 return cleaned_markdown_conversion(self
.note
)
526 class ActivityMixin(GeneratePublicIDMixin
):
527 object_type
= "activity"
529 VALID_VERBS
= ["add", "author", "create", "delete", "dislike", "favorite",
530 "follow", "like", "post", "share", "unfavorite", "unfollow",
531 "unlike", "unshare", "update", "tag"]
533 def get_url(self
, request
):
534 return request
.urlgen(
535 "mediagoblin.user_pages.activity_view",
536 username
=self
.get_actor
.username
,
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.
548 "simple" : _("{username} added {object}"),
549 "targetted": _("{username} added {object} to {target}"),
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}")},
559 "simple": _("{username} posted {object}"),
560 "targetted": _("{username} posted {object} to {target}"),
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}")},
572 "image": _("an image"),
573 "comment": _("a comment"),
574 "collection": _("a collection"),
575 "video": _("a video"),
577 "person": _("a person"),
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)
584 if content
is None or self
.object is None:
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
]
593 object_value
= _("an object")
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
]
602 target_value
= _("an object")
604 self
.content
= content
["targetted"].format(
605 username
=actor
.username
,
610 self
.content
= content
["simple"].format(
611 username
=actor
.username
,
617 def serialize(self
, request
):
618 href
= request
.urlgen(
619 "mediagoblin.api.object",
620 object_type
=self
.object_type
,
624 published
= UTC
.localize(self
.published
)
625 updated
= UTC
.localize(self
.updated
)
628 "actor": self
.get_actor
.serialize(request
),
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
,
644 obj
["generator"] = self
.get_generator
.serialize(request
)
647 obj
["title"] = self
.title
649 if self
.target_id
is not None:
650 obj
["target"] = self
.target().serialize(request
)
654 def unseralize(self
, data
):
656 Takes data given and set it on this activity.
658 Several pieces of data are not written on because of security
659 reasons. For example changing the author or id of an activity.
662 self
.verb
= data
["verb"]
665 self
.title
= data
["title"]
667 if "content" in data
:
668 self
.content
= data
["content"]