Attach the MediaGoblinApp to the engine, and provide a way for models to access
[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
814334f6 37from mediagoblin import mg_globals
58a94757 38from mediagoblin.media_types import FileTypeNotSupported
17c23e15 39from mediagoblin.tools import common, licenses
58a94757 40from mediagoblin.tools.pluginapi import hook_handle
e61ab099 41from mediagoblin.tools.text import cleaned_markdown_conversion
814334f6 42from mediagoblin.tools.url import slugify
ce46470c 43from mediagoblin.tools.translate import pass_to_ugettext as _
f42e49c3
E
44
45
46class UserMixin(object):
0421fc5e
JT
47 object_type = "person"
48
e61ab099
E
49 @property
50 def bio_html(self):
51 return cleaned_markdown_conversion(self.bio)
52
de6a313c
BP
53 def url_for_self(self, urlgen, **kwargs):
54 """Generate a URL for this User's home page."""
55 return urlgen('mediagoblin.user_pages.user_home',
56 user=self.username, **kwargs)
57
58
29c65044 59class GenerateSlugMixin(object):
44123853
E
60 """
61 Mixin to add a generate_slug method to objects.
62
63 Depends on:
64 - self.slug
65 - self.title
66 - self.check_slug_used(new_slug)
67 """
814334f6 68 def generate_slug(self):
88de830f 69 """
44123853 70 Generate a unique slug for this object.
98587109 71
88de830f
CAW
72 This one does not *force* slugs, but usually it will probably result
73 in a niceish one.
74
98587109
CAW
75 The end *result* of the algorithm will result in these resolutions for
76 these situations:
88de830f
CAW
77 - If we have a slug, make sure it's clean and sanitized, and if it's
78 unique, we'll use that.
79 - If we have a title, slugify it, and if it's unique, we'll use that.
80 - If we can't get any sort of thing that looks like it'll be a useful
81 slug out of a title or an existing slug, bail, and don't set the
82 slug at all. Don't try to create something just because. Make
83 sure we have a reasonable basis for a slug first.
84 - If we have a reasonable basis for a slug (either based on existing
85 slug or slugified title) but it's not unique, first try appending
86 the entry's id, if that exists
87 - If that doesn't result in something unique, tack on some randomly
88 generated bits until it's unique. That'll be a little bit of junk,
89 but at least it has the basis of a nice slug.
90 """
c785f3a0 91
66d9f1b2
SS
92 #Is already a slug assigned? Check if it is valid
93 if self.slug:
c785f3a0 94 slug = slugify(self.slug)
b1126f71 95
88de830f 96 # otherwise, try to use the title.
66d9f1b2 97 elif self.title:
88de830f 98 # assign slug based on title
c785f3a0 99 slug = slugify(self.title)
b1126f71 100
c785f3a0
JT
101 else:
102 # We don't have any information to set a slug
103 return
b0118957 104
c785f3a0
JT
105 # We don't want any empty string slugs
106 if slug == u"":
107 return
b1126f71 108
88de830f 109 # Otherwise, let's see if this is unique.
c785f3a0 110 if self.check_slug_used(slug):
88de830f 111 # It looks like it's being used... lame.
b1126f71 112
88de830f
CAW
113 # Can we just append the object's id to the end?
114 if self.id:
c785f3a0 115 slug_with_id = u"%s-%s" % (slug, self.id)
29c65044 116 if not self.check_slug_used(slug_with_id):
88de830f
CAW
117 self.slug = slug_with_id
118 return # success!
b1126f71 119
88de830f
CAW
120 # okay, still no success;
121 # let's whack junk on there till it's unique.
c785f3a0 122 slug += '-' + uuid.uuid4().hex[:4]
a81082fc 123 # keep going if necessary!
c785f3a0
JT
124 while self.check_slug_used(slug):
125 slug += uuid.uuid4().hex[:4]
126
127 # self.check_slug_used(slug) must be False now so we have a slug that
128 # we can use now.
129 self.slug = slug
814334f6 130
29c65044
E
131
132class MediaEntryMixin(GenerateSlugMixin):
133 def check_slug_used(self, slug):
134 # import this here due to a cyclic import issue
135 # (db.models -> db.mixin -> db.util -> db.models)
136 from mediagoblin.db.util import check_media_slug_used
137
138 return check_media_slug_used(self.uploader, slug, self.id)
139
0421fc5e
JT
140 @property
141 def object_type(self):
142 """ Converts media_type to pump-like type - don't use internally """
143 return self.media_type.split(".")[-1]
144
1e72e075
E
145 @property
146 def description_html(self):
147 """
148 Rendered version of the description, run through
149 Markdown and cleaned with our cleaning tool.
150 """
151 return cleaned_markdown_conversion(self.description)
152
e77df64f
CAW
153 def get_display_media(self):
154 """Find the best media for display.
f42e49c3 155
53024776 156 We try checking self.media_manager.fetching_order if it exists to
e77df64f 157 pull down the order.
f42e49c3
E
158
159 Returns:
ddbf6af1
CAW
160 (media_size, media_path)
161 or, if not found, None.
e77df64f 162
f42e49c3 163 """
e8676fa3 164 fetch_order = self.media_manager.media_fetch_order
f42e49c3 165
ddbf6af1
CAW
166 # No fetching order found? well, give up!
167 if not fetch_order:
168 return None
169
170 media_sizes = self.media_files.keys()
171
172 for media_size in fetch_order:
f42e49c3 173 if media_size in media_sizes:
ddbf6af1 174 return media_size, self.media_files[media_size]
f42e49c3
E
175
176 def main_mediafile(self):
177 pass
178
3e907d55
E
179 @property
180 def slug_or_id(self):
7de20e52
CAW
181 if self.slug:
182 return self.slug
183 else:
184 return u'id:%s' % self.id
5f8b4ae8 185
cb7ae1e4 186 def url_for_self(self, urlgen, **extra_args):
f42e49c3
E
187 """
188 Generate an appropriate url for ourselves
189
5c2b8486 190 Use a slug if we have one, else use our 'id'.
f42e49c3
E
191 """
192 uploader = self.get_uploader
193
3e907d55
E
194 return urlgen(
195 'mediagoblin.user_pages.media_home',
196 user=uploader.username,
197 media=self.slug_or_id,
198 **extra_args)
f42e49c3 199
2e4ad359
SS
200 @property
201 def thumb_url(self):
202 """Return the thumbnail URL (for usage in templates)
203 Will return either the real thumbnail or a default fallback icon."""
204 # TODO: implement generic fallback in case MEDIA_MANAGER does
205 # not specify one?
206 if u'thumb' in self.media_files:
207 thumb_url = mg_globals.app.public_store.file_url(
208 self.media_files[u'thumb'])
209 else:
df1c4976 210 # No thumbnail in media available. Get the media's
2e4ad359 211 # MEDIA_MANAGER for the fallback icon and return static URL
5f8b4ae8
SS
212 # Raises FileTypeNotSupported in case no such manager is enabled
213 manager = self.media_manager
df1c4976 214 thumb_url = mg_globals.app.staticdirector(manager[u'default_thumb'])
2e4ad359
SS
215 return thumb_url
216
5b014a08
JT
217 @property
218 def original_url(self):
219 """ Returns the URL for the original image
220 will return self.thumb_url if original url doesn't exist"""
221 if u"original" not in self.media_files:
222 return self.thumb_url
ce46470c 223
5b014a08
JT
224 return mg_globals.app.public_store.file_url(
225 self.media_files[u"original"]
226 )
227
5f8b4ae8
SS
228 @cached_property
229 def media_manager(self):
230 """Returns the MEDIA_MANAGER of the media's media_type
231
232 Raises FileTypeNotSupported in case no such manager is enabled
233 """
6403bc92 234 manager = hook_handle(('media_manager', self.media_type))
58a94757 235 if manager:
4259ad5b
CAW
236 return manager(self)
237
5f8b4ae8
SS
238 # Not found? Then raise an error
239 raise FileTypeNotSupported(
e6991972
RE
240 "MediaManager not in enabled types. Check media_type plugins are"
241 " enabled in config?")
5f8b4ae8 242
f42e49c3
E
243 def get_fail_exception(self):
244 """
245 Get the exception that's appropriate for this error
246 """
51eb0267
JW
247 if self.fail_error:
248 return common.import_component(self.fail_error)
17c23e15
AW
249
250 def get_license_data(self):
251 """Return license dict for requested license"""
138a18fd 252 return licenses.get_license_by_url(self.license or "")
feba5c52 253
5bad26bc
E
254 def exif_display_iter(self):
255 if not self.media_data:
256 return
257 exif_all = self.media_data.get("exif_all")
258
b3566e1d
GS
259 for key in exif_all:
260 label = re.sub('(.)([A-Z][a-z]+)', r'\1 \2', key)
261 yield label.replace('EXIF', '').replace('Image', ''), exif_all[key]
5bad26bc 262
420e1374
GS
263 def exif_display_data_short(self):
264 """Display a very short practical version of exif info"""
420e1374
GS
265 if not self.media_data:
266 return
907bba31 267
420e1374 268 exif_all = self.media_data.get("exif_all")
907bba31 269
14aa2eaa
JW
270 exif_short = {}
271
907bba31
JW
272 if 'Image DateTimeOriginal' in exif_all:
273 # format date taken
196cef38 274 takendate = datetime.strptime(
907bba31
JW
275 exif_all['Image DateTimeOriginal']['printable'],
276 '%Y:%m:%d %H:%M:%S').date()
277 taken = takendate.strftime('%B %d %Y')
278
14aa2eaa
JW
279 exif_short.update({'Date Taken': taken})
280
1b6a2b85 281 aperture = None
907bba31
JW
282 if 'EXIF FNumber' in exif_all:
283 fnum = str(exif_all['EXIF FNumber']['printable']).split('/')
284
1b6a2b85
JW
285 # calculate aperture
286 if len(fnum) == 2:
287 aperture = "f/%.1f" % (float(fnum[0])/float(fnum[1]))
288 elif fnum[0] != 'None':
289 aperture = "f/%s" % (fnum[0])
907bba31 290
14aa2eaa
JW
291 if aperture:
292 exif_short.update({'Aperture': aperture})
293
294 short_keys = [
295 ('Camera', 'Image Model', None),
296 ('Exposure', 'EXIF ExposureTime', lambda x: '%s sec' % x),
297 ('ISO Speed', 'EXIF ISOSpeedRatings', None),
298 ('Focal Length', 'EXIF FocalLength', lambda x: '%s mm' % x)]
299
300 for label, key, fmt_func in short_keys:
301 try:
302 val = fmt_func(exif_all[key]['printable']) if fmt_func \
303 else exif_all[key]['printable']
304 exif_short.update({label: val})
305 except KeyError:
306 pass
307
308 return exif_short
309
feba5c52
E
310
311class MediaCommentMixin(object):
0421fc5e
JT
312 object_type = "comment"
313
feba5c52
E
314 @property
315 def content_html(self):
316 """
317 the actual html-rendered version of the comment displayed.
318 Run through Markdown and the HTML cleaner.
319 """
320 return cleaned_markdown_conversion(self.content)
be5be115 321
dc19e98d 322 def __unicode__(self):
09bed9a7 323 return u'<{klass} #{id} {author} "{comment}">'.format(
2d7b6bde
JW
324 klass=self.__class__.__name__,
325 id=self.id,
326 author=self.get_author,
327 comment=self.content)
328
dc19e98d
TB
329 def __repr__(self):
330 return '<{klass} #{id} {author} "{comment}">'.format(
331 klass=self.__class__.__name__,
332 id=self.id,
333 author=self.get_author,
334 comment=self.content)
335
be5be115 336
455fd36f 337class CollectionMixin(GenerateSlugMixin):
0421fc5e
JT
338 object_type = "collection"
339
455fd36f 340 def check_slug_used(self, slug):
be5be115
AW
341 # import this here due to a cyclic import issue
342 # (db.models -> db.mixin -> db.util -> db.models)
343 from mediagoblin.db.util import check_collection_slug_used
344
455fd36f 345 return check_collection_slug_used(self.creator, slug, self.id)
be5be115
AW
346
347 @property
348 def description_html(self):
349 """
350 Rendered version of the description, run through
351 Markdown and cleaned with our cleaning tool.
352 """
353 return cleaned_markdown_conversion(self.description)
354
355 @property
356 def slug_or_id(self):
5c2b8486 357 return (self.slug or self.id)
be5be115
AW
358
359 def url_for_self(self, urlgen, **extra_args):
360 """
361 Generate an appropriate url for ourselves
362
5c2b8486 363 Use a slug if we have one, else use our 'id'.
be5be115
AW
364 """
365 creator = self.get_creator
366
367 return urlgen(
256f816f 368 'mediagoblin.user_pages.user_collection',
be5be115
AW
369 user=creator.username,
370 collection=self.slug_or_id,
371 **extra_args)
372
6d1e55b2 373
be5be115
AW
374class CollectionItemMixin(object):
375 @property
376 def note_html(self):
377 """
378 the actual html-rendered version of the note displayed.
379 Run through Markdown and the HTML cleaner.
380 """
381 return cleaned_markdown_conversion(self.note)
ce46470c
JT
382
383class ActivityMixin(object):
0421fc5e 384 object_type = "activity"
ce46470c
JT
385
386 VALID_VERBS = ["add", "author", "create", "delete", "dislike", "favorite",
387 "follow", "like", "post", "share", "unfavorite", "unfollow",
388 "unlike", "unshare", "update", "tag"]
389
390 def get_url(self, request):
391 return request.urlgen(
392 "mediagoblin.federation.activity_view",
393 username=self.get_actor.username,
394 id=self.id,
395 qualified=True
396 )
397
398 def generate_content(self):
399 """ Produces a HTML content for object """
400 # some of these have simple and targetted. If self.target it set
401 # it will pick the targetted. If they DON'T have a targetted version
402 # the information in targetted won't be added to the content.
403 verb_to_content = {
404 "add": {
405 "simple" : _("{username} added {object}"),
406 "targetted": _("{username} added {object} to {target}"),
407 },
408 "author": {"simple": _("{username} authored {object}")},
409 "create": {"simple": _("{username} created {object}")},
410 "delete": {"simple": _("{username} deleted {object}")},
411 "dislike": {"simple": _("{username} disliked {object}")},
412 "favorite": {"simple": _("{username} favorited {object}")},
413 "follow": {"simple": _("{username} followed {object}")},
414 "like": {"simple": _("{username} liked {object}")},
415 "post": {
416 "simple": _("{username} posted {object}"),
2b191618 417 "targetted": _("{username} posted {object} to {target}"),
ce46470c
JT
418 },
419 "share": {"simple": _("{username} shared {object}")},
420 "unfavorite": {"simple": _("{username} unfavorited {object}")},
421 "unfollow": {"simple": _("{username} stopped following {object}")},
422 "unlike": {"simple": _("{username} unliked {object}")},
423 "unshare": {"simple": _("{username} unshared {object}")},
424 "update": {"simple": _("{username} updated {object}")},
425 "tag": {"simple": _("{username} tagged {object}")},
426 }
427
6d36f75f
JT
428 obj = self.get_object
429 target = self.get_target
ce46470c
JT
430 actor = self.get_actor
431 content = verb_to_content.get(self.verb, None)
432
433 if content is None or obj is None:
434 return
435
436 if target is None or "targetted" not in content:
437 self.content = content["simple"].format(
438 username=actor.username,
6d36f75f 439 object=obj.object_type
ce46470c
JT
440 )
441 else:
442 self.content = content["targetted"].format(
443 username=actor.username,
6d36f75f
JT
444 object=obj.object_type,
445 target=target.object_type,
ce46470c
JT
446 )
447
448 return self.content
449
450 def serialize(self, request):
9c602458
JT
451 href = request.urlgen(
452 "mediagoblin.federation.object",
453 object_type=self.object_type,
454 id=self.id,
455 qualified=True
456 )
45e687fc
JT
457 published = UTC.localize(self.published)
458 updated = UTC.localize(self.updated)
ce46470c 459 obj = {
9c602458 460 "id": href,
ce46470c
JT
461 "actor": self.get_actor.serialize(request),
462 "verb": self.verb,
45e687fc
JT
463 "published": published.isoformat(),
464 "updated": updated.isoformat(),
ce46470c
JT
465 "content": self.content,
466 "url": self.get_url(request),
6d36f75f 467 "object": self.get_object.serialize(request),
0421fc5e 468 "objectType": self.object_type,
ce46470c
JT
469 }
470
471 if self.generator:
6d36f75f 472 obj["generator"] = self.get_generator.serialize(request)
ce46470c
JT
473
474 if self.title:
475 obj["title"] = self.title
476
6d36f75f 477 target = self.get_target
ce46470c 478 if target is not None:
6d36f75f 479 obj["target"] = target.serialize(request)
ce46470c
JT
480
481 return obj
482
483 def unseralize(self, data):
484 """
485 Takes data given and set it on this activity.
486
487 Several pieces of data are not written on because of security
488 reasons. For example changing the author or id of an activity.
489 """
490 if "verb" in data:
491 self.verb = data["verb"]
492
493 if "title" in data:
494 self.title = data["title"]
495
496 if "content" in data:
497 self.content = data["content"]