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 main_mediafile(self
):
256 def slug_or_id(self
):
260 return u
'id:%s' % self
.id
262 def url_for_self(self
, urlgen
, **extra_args
):
264 Generate an appropriate url for ourselves
266 Use a slug if we have one, else use our 'id'.
268 uploader
= self
.get_actor
271 'mediagoblin.user_pages.media_home',
272 user
=uploader
.username
,
273 media
=self
.slug_or_id
,
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
282 if u
'thumb' in self
.media_files
:
283 thumb_url
= self
._app
.public_store
.file_url(
284 self
.media_files
[u
'thumb'])
286 # No thumbnail in media available. Get the media's
287 # MEDIA_MANAGER for the fallback icon and return static URL
288 # Raises FileTypeNotSupported in case no such manager is enabled
289 manager
= self
.media_manager
290 thumb_url
= self
._app
.staticdirector(manager
[u
'default_thumb'])
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
300 return self
._app
.public_store
.file_url(
301 self
.media_files
[u
"original"]
305 def media_manager(self
):
306 """Returns the MEDIA_MANAGER of the media's media_type
308 Raises FileTypeNotSupported in case no such manager is enabled
310 manager
= hook_handle(('media_manager', self
.media_type
))
314 # Not found? Then raise an error
315 raise FileTypeNotSupported(
316 "MediaManager not in enabled types. Check media_type plugins are"
317 " enabled in config?")
319 def get_fail_exception(self
):
321 Get the exception that's appropriate for this error
324 return common
.import_component(self
.fail_error
)
326 def get_license_data(self
):
327 """Return license dict for requested license"""
328 return licenses
.get_license_by_url(self
.license
or "")
330 def exif_display_iter(self
):
331 if not self
.media_data
:
333 exif_all
= self
.media_data
.get("exif_all")
336 label
= re
.sub('(.)([A-Z][a-z]+)', r
'\1 \2', key
)
337 yield label
.replace('EXIF', '').replace('Image', ''), exif_all
[key
]
339 def exif_display_data_short(self
):
340 """Display a very short practical version of exif info"""
341 if not self
.media_data
:
344 exif_all
= self
.media_data
.get("exif_all")
348 if 'Image DateTimeOriginal' in exif_all
:
350 takendate
= datetime
.strptime(
351 exif_all
['Image DateTimeOriginal']['printable'],
352 '%Y:%m:%d %H:%M:%S').date()
353 taken
= takendate
.strftime('%B %d %Y')
355 exif_short
.update({'Date Taken': taken
})
358 if 'EXIF FNumber' in exif_all
:
359 fnum
= str(exif_all
['EXIF FNumber']['printable']).split('/')
363 aperture
= "f/%.1f" % (float(fnum
[0])/float(fnum
[1]))
364 elif fnum
[0] != 'None':
365 aperture
= "f/%s" % (fnum
[0])
368 exif_short
.update({'Aperture': aperture
})
371 ('Camera', 'Image Model', None),
372 ('Exposure', 'EXIF ExposureTime', lambda x
: '%s sec' % x
),
373 ('ISO Speed', 'EXIF ISOSpeedRatings', None),
374 ('Focal Length', 'EXIF FocalLength', lambda x
: '%s mm' % x
)]
376 for label
, key
, fmt_func
in short_keys
:
378 val
= fmt_func(exif_all
[key
]['printable']) if fmt_func \
379 else exif_all
[key
]['printable']
380 exif_short
.update({label
: val
})
387 class TextCommentMixin(GeneratePublicIDMixin
):
388 object_type
= "comment"
391 def content_html(self
):
393 the actual html-rendered version of the comment displayed.
394 Run through Markdown and the HTML cleaner.
396 return cleaned_markdown_conversion(self
.content
)
398 def __unicode__(self
):
399 return u
'<{klass} #{id} {actor} "{comment}">'.format(
400 klass
=self
.__class
__.__name
__,
402 actor
=self
.get_actor
,
403 comment
=self
.content
)
406 return '<{klass} #{id} {actor} "{comment}">'.format(
407 klass
=self
.__class
__.__name
__,
409 actor
=self
.get_actor
,
410 comment
=self
.content
)
412 class CollectionMixin(GenerateSlugMixin
, GeneratePublicIDMixin
):
413 object_type
= "collection"
415 def check_slug_used(self
, slug
):
416 # import this here due to a cyclic import issue
417 # (db.models -> db.mixin -> db.util -> db.models)
418 from mediagoblin
.db
.util
import check_collection_slug_used
420 return check_collection_slug_used(self
.actor
, slug
, self
.id)
423 def description_html(self
):
425 Rendered version of the description, run through
426 Markdown and cleaned with our cleaning tool.
428 return cleaned_markdown_conversion(self
.description
)
431 def slug_or_id(self
):
432 return (self
.slug
or self
.id)
434 def url_for_self(self
, urlgen
, **extra_args
):
436 Generate an appropriate url for ourselves
438 Use a slug if we have one, else use our 'id'.
440 creator
= self
.get_actor
443 'mediagoblin.user_pages.user_collection',
444 user
=creator
.username
,
445 collection
=self
.slug_or_id
,
448 def add_to_collection(self
, obj
, content
=None, commit
=True):
449 """ Adds an object to the collection """
450 # It's here to prevent cyclic imports
451 from mediagoblin
.db
.models
import CollectionItem
453 # Need the ID of this collection for this so check we've got one.
454 self
.save(commit
=False)
456 # Create the CollectionItem
457 item
= CollectionItem()
458 item
.collection
= self
.id
459 item
.get_object
= obj
461 if content
is not None:
464 self
.num_items
= self
.num_items
+ 1
467 self
.save(commit
=commit
)
468 item
.save(commit
=commit
)
471 class CollectionItemMixin(object):
475 the actual html-rendered version of the note displayed.
476 Run through Markdown and the HTML cleaner.
478 return cleaned_markdown_conversion(self
.note
)
480 class ActivityMixin(GeneratePublicIDMixin
):
481 object_type
= "activity"
483 VALID_VERBS
= ["add", "author", "create", "delete", "dislike", "favorite",
484 "follow", "like", "post", "share", "unfavorite", "unfollow",
485 "unlike", "unshare", "update", "tag"]
487 def get_url(self
, request
):
488 return request
.urlgen(
489 "mediagoblin.user_pages.activity_view",
490 username
=self
.get_actor
.username
,
495 def generate_content(self
):
496 """ Produces a HTML content for object """
497 # some of these have simple and targetted. If self.target it set
498 # it will pick the targetted. If they DON'T have a targetted version
499 # the information in targetted won't be added to the content.
502 "simple" : _("{username} added {object}"),
503 "targetted": _("{username} added {object} to {target}"),
505 "author": {"simple": _("{username} authored {object}")},
506 "create": {"simple": _("{username} created {object}")},
507 "delete": {"simple": _("{username} deleted {object}")},
508 "dislike": {"simple": _("{username} disliked {object}")},
509 "favorite": {"simple": _("{username} favorited {object}")},
510 "follow": {"simple": _("{username} followed {object}")},
511 "like": {"simple": _("{username} liked {object}")},
513 "simple": _("{username} posted {object}"),
514 "targetted": _("{username} posted {object} to {target}"),
516 "share": {"simple": _("{username} shared {object}")},
517 "unfavorite": {"simple": _("{username} unfavorited {object}")},
518 "unfollow": {"simple": _("{username} stopped following {object}")},
519 "unlike": {"simple": _("{username} unliked {object}")},
520 "unshare": {"simple": _("{username} unshared {object}")},
521 "update": {"simple": _("{username} updated {object}")},
522 "tag": {"simple": _("{username} tagged {object}")},
526 "image": _("an image"),
527 "comment": _("a comment"),
528 "collection": _("a collection"),
529 "video": _("a video"),
531 "person": _("a person"),
534 target
= None if self
.target_id
is None else self
.target()
535 actor
= self
.get_actor
536 content
= verb_to_content
.get(self
.verb
, None)
538 if content
is None or self
.object is None:
541 # Decide what to fill the object with
542 if hasattr(obj
, "title") and obj
.title
.strip(" "):
543 object_value
= obj
.title
544 elif obj
.object_type
in object_map
:
545 object_value
= object_map
[obj
.object_type
]
547 object_value
= _("an object")
549 # Do we want to add a target (indirect object) to content?
550 if target
is not None and "targetted" in content
:
551 if hasattr(target
, "title") and target
.title
.strip(" "):
552 target_value
= target
.title
553 elif target
.object_type
in object_map
:
554 target_value
= object_map
[target
.object_type
]
556 target_value
= _("an object")
558 self
.content
= content
["targetted"].format(
559 username
=actor
.username
,
564 self
.content
= content
["simple"].format(
565 username
=actor
.username
,
571 def serialize(self
, request
):
572 href
= request
.urlgen(
573 "mediagoblin.api.object",
574 object_type
=self
.object_type
,
578 published
= UTC
.localize(self
.published
)
579 updated
= UTC
.localize(self
.updated
)
582 "actor": self
.get_actor
.serialize(request
),
584 "published": published
.isoformat(),
585 "updated": updated
.isoformat(),
586 "content": self
.content
,
587 "url": self
.get_url(request
),
588 "object": self
.object().serialize(request
),
589 "objectType": self
.object_type
,
598 obj
["generator"] = self
.get_generator
.serialize(request
)
601 obj
["title"] = self
.title
603 if self
.target_id
is not None:
604 obj
["target"] = self
.target().serialize(request
)
608 def unseralize(self
, data
):
610 Takes data given and set it on this activity.
612 Several pieces of data are not written on because of security
613 reasons. For example changing the author or id of an activity.
616 self
.verb
= data
["verb"]
619 self
.title
= data
["title"]
621 if "content" in data
:
622 self
.content
= data
["content"]