1 # GNU MediaGoblin -- federated, autonomous media hosting
2 # Copyright (C) 2011 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/>.
17 from __future__
import division
19 from email
.MIMEText
import MIMEText
26 from math
import ceil
, floor
30 from babel
.localedata
import exists
31 from babel
.support
import LazyProxy
34 from webob
import Response
, exc
35 from lxml
.html
.clean
import Cleaner
37 from wtforms
.form
import Form
39 from mediagoblin
import mg_globals
40 from mediagoblin
import messages
41 from mediagoblin
.db
.util
import ObjectId
43 from itertools
import izip
, count
45 DISPLAY_IMAGE_FETCHING_ORDER
= [u
'medium', u
'original', u
'thumb']
50 def _activate_testing():
52 Call this to activate testing in util.py
58 def clear_test_buckets():
60 We store some things for testing purposes that should be cleared
61 when we want a "clean slate" of information for our next round of
62 tests. Call this function to wipe all that stuff clean.
64 Also wipes out some other things we might redefine during testing,
67 global SETUP_JINJA_ENVS
70 global EMAIL_TEST_INBOX
71 global EMAIL_TEST_MBOX_INBOX
73 EMAIL_TEST_MBOX_INBOX
= []
75 clear_test_template_context()
81 def get_jinja_env(template_loader
, locale
):
83 Set up the Jinja environment,
85 (In the future we may have another system for providing theming;
86 for now this is good enough.)
90 # If we have a jinja environment set up with this locale, just
92 if locale
in SETUP_JINJA_ENVS
:
93 return SETUP_JINJA_ENVS
[locale
]
95 template_env
= jinja2
.Environment(
96 loader
=template_loader
, autoescape
=True,
97 extensions
=['jinja2.ext.i18n', 'jinja2.ext.autoescape'])
99 template_env
.install_gettext_callables(
100 mg_globals
.translations
.ugettext
,
101 mg_globals
.translations
.ungettext
)
103 # All templates will know how to ...
104 # ... fetch all waiting messages and remove them from the queue
105 # ... construct a grid of thumbnails or other media
106 template_env
.globals['fetch_messages'] = messages
.fetch_messages
107 template_env
.globals['gridify_list'] = gridify_list
108 template_env
.globals['gridify_cursor'] = gridify_cursor
111 SETUP_JINJA_ENVS
[locale
] = template_env
116 # We'll store context information here when doing unit tests
117 TEMPLATE_TEST_CONTEXT
= {}
120 def render_template(request
, template_path
, context
):
122 Render a template with context.
124 Always inserts the request into the context, so you don't have to.
125 Also stores the context if we're doing unit tests. Helpful!
127 template
= request
.template_env
.get_template(
129 context
['request'] = request
130 rendered
= template
.render(context
)
133 TEMPLATE_TEST_CONTEXT
[template_path
] = context
138 def clear_test_template_context():
139 global TEMPLATE_TEST_CONTEXT
140 TEMPLATE_TEST_CONTEXT
= {}
143 def render_to_response(request
, template
, context
, status
=200):
144 """Much like Django's shortcut.render()"""
146 render_template(request
, template
, context
),
150 def redirect(request
, *args
, **kwargs
):
151 """Returns a HTTPFound(), takes a request and then urlgen params"""
154 if kwargs
.get('querystring'):
155 querystring
= kwargs
.get('querystring')
156 del kwargs
['querystring']
158 return exc
.HTTPFound(
160 request
.urlgen(*args
, **kwargs
),
161 querystring
if querystring
else '']))
164 def setup_user_in_request(request
):
166 Examine a request and tack on a request.user parameter if that's
169 if not 'user_id' in request
.session
:
174 user
= request
.app
.db
.User
.one(
175 {'_id': ObjectId(request
.session
['user_id'])})
178 # Something's wrong... this user doesn't exist? Invalidate
180 request
.session
.invalidate()
185 def import_component(import_string
):
187 Import a module component defined by STRING. Probably a method,
188 class, or global variable.
191 - import_string: a string that defines what to import. Written
192 in the format of "module1.module2:component"
194 module_name
, func_name
= import_string
.split(':', 1)
195 __import__(module_name
)
196 module
= sys
.modules
[module_name
]
197 func
= getattr(module
, func_name
)
200 _punct_re
= re
.compile(r
'[\t !"#$%&\'()*\
-/<=>?
@\
[\\\
]^_`
{|
},.]+')
203 def slugify(text, delim=u'-'):
205 Generates an ASCII-only slug. Taken from http://flask.pocoo.org/snippets/5/
208 for word in _punct_re.split(text.lower()):
209 word = word.encode('translit
/long')
212 return unicode(delim.join(result))
214 ### ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
215 ### Special email test stuff begins HERE
216 ### ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
218 # We have two "test inboxes" here:
222 # If you're writing test views
, you
'll probably want to check this.
223 # It contains a list of MIMEText messages.
225 # EMAIL_TEST_MBOX_INBOX:
226 # ----------------------
227 # This collects the messages from the FakeMhost inbox. It's reslly
228 # just here for testing the send_email method itself.
230 # Anyway this contains:
232 # - to: a list of email recipient addresses
233 # - message: not just the body, but the whole message, including
238 # Before running tests that call functions which send email, you should
239 # always call _clear_test_inboxes() to "wipe" the inboxes clean.
241 EMAIL_TEST_INBOX
= []
242 EMAIL_TEST_MBOX_INBOX
= []
245 class FakeMhost(object):
247 Just a fake mail host so we can capture and test messages
250 def login(self
, *args
, **kwargs
):
253 def sendmail(self
, from_addr
, to_addrs
, message
):
254 EMAIL_TEST_MBOX_INBOX
.append(
260 def _clear_test_inboxes():
261 global EMAIL_TEST_INBOX
262 global EMAIL_TEST_MBOX_INBOX
263 EMAIL_TEST_INBOX
= []
264 EMAIL_TEST_MBOX_INBOX
= []
266 ### ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
267 ### </Special email test stuff>
268 ### ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
271 def send_email(from_addr
, to_addrs
, subject
, message_body
):
273 Simple email sending wrapper, use this so we can capture messages
274 for unit testing purposes.
277 - from_addr: address you're sending the email from
278 - to_addrs: list of recipient email addresses
279 - subject: subject of the email
280 - message_body: email body text
282 if TESTS_ENABLED
or mg_globals
.app_config
['email_debug_mode']:
284 elif not mg_globals
.app_config
['email_debug_mode']:
285 mhost
= smtplib
.SMTP(
286 mg_globals
.app_config
['email_smtp_host'],
287 mg_globals
.app_config
['email_smtp_port'])
289 # SMTP.__init__ Issues SMTP.connect implicitly if host
290 if not mg_globals
.app_config
['email_smtp_host']: # e.g. host = ''
291 mhost
.connect() # We SMTP.connect explicitly
293 if mg_globals
.app_config
['email_smtp_user'] \
294 or mg_globals
.app_config
['email_smtp_pass']:
296 mg_globals
.app_config
['email_smtp_user'],
297 mg_globals
.app_config
['email_smtp_pass'])
299 message
= MIMEText(message_body
.encode('utf-8'), 'plain', 'utf-8')
300 message
['Subject'] = subject
301 message
['From'] = from_addr
302 message
['To'] = ', '.join(to_addrs
)
305 EMAIL_TEST_INBOX
.append(message
)
307 if mg_globals
.app_config
['email_debug_mode']:
308 print u
"===== Email ====="
309 print u
"From address: %s" % message
['From']
310 print u
"To addresses: %s" % message
['To']
311 print u
"Subject: %s" % message
['Subject']
313 print message
.get_payload(decode
=True)
315 return mhost
.sendmail(from_addr
, to_addrs
, message
.as_string())
323 TRANSLATIONS_PATH
= pkg_resources
.resource_filename(
324 'mediagoblin', 'i18n')
327 def locale_to_lower_upper(locale
):
329 Take a locale, regardless of style, and format it like "en-us"
332 lang
, country
= locale
.split('-', 1)
333 return '%s_%s' % (lang
.lower(), country
.upper())
335 lang
, country
= locale
.split('_', 1)
336 return '%s_%s' % (lang
.lower(), country
.upper())
338 return locale
.lower()
341 def locale_to_lower_lower(locale
):
343 Take a locale, regardless of style, and format it like "en_US"
346 lang
, country
= locale
.split('_', 1)
347 return '%s-%s' % (lang
.lower(), country
.lower())
349 return locale
.lower()
352 def get_locale_from_request(request
):
354 Figure out what target language is most appropriate based on the
357 request_form
= request
.GET
or request
.POST
359 if 'lang' in request_form
:
360 return locale_to_lower_upper(request_form
['lang'])
362 accept_lang_matches
= request
.accept_language
.best_matches()
364 # Your routing can explicitly specify a target language
365 matchdict
= request
.matchdict
or {}
367 if 'locale' in matchdict
:
368 target_lang
= matchdict
['locale']
369 elif 'target_lang' in request
.session
:
370 target_lang
= request
.session
['target_lang']
371 # Pull the first acceptable language
372 elif accept_lang_matches
:
373 target_lang
= accept_lang_matches
[0]
374 # Fall back to English
378 return locale_to_lower_upper(target_lang
)
381 # A super strict version of the lxml.html cleaner class
382 HTML_CLEANER
= Cleaner(
389 processing_instructions
=True,
395 'div', 'b', 'i', 'em', 'strong', 'p', 'ul', 'ol', 'li', 'a', 'br'],
396 remove_unknown_tags
=False, # can't be used with allow_tags
397 safe_attrs_only
=True,
398 add_nofollow
=True, # for now
400 whitelist_tags
=set([]))
403 def clean_html(html
):
404 # clean_html barfs on an empty string
408 return HTML_CLEANER
.clean_html(html
)
411 def convert_to_tag_list_of_dicts(tag_string
):
413 Filter input from incoming string containing user tags,
415 Strips trailing, leading, and internal whitespace, and also converts
416 the "tags" text into an array of tags
421 # Strip out internal, trailing, and leading whitespace
422 stripped_tag_string
= u
' '.join(tag_string
.strip().split())
424 # Split the tag string into a list of tags
425 for tag
in stripped_tag_string
.split(
426 mg_globals
.app_config
['tags_delimiter']):
428 # Ignore empty or duplicate tags
429 if tag
.strip() and tag
.strip() not in [t
['name'] for t
in taglist
]:
431 taglist
.append({'name': tag
.strip(),
432 'slug': slugify(tag
.strip())})
436 def media_tags_as_string(media_entry_tags
):
438 Generate a string from a media item's tags, stored as a list of dicts
440 This is the opposite of convert_to_tag_list_of_dicts
442 media_tag_string
= ''
444 media_tag_string
= mg_globals
.app_config
['tags_delimiter'].join(
445 [tag
['name'] for tag
in media_entry_tags
])
446 return media_tag_string
448 TOO_LONG_TAG_WARNING
= \
449 u
'Tags must be shorter than %s characters. Tags that are too long: %s'
452 def tag_length_validator(form
, field
):
454 Make sure tags do not exceed the maximum tag length.
456 tags
= convert_to_tag_list_of_dicts(field
.data
)
458 tag
['name'] for tag
in tags
459 if len(tag
['name']) > mg_globals
.app_config
['tags_max_length']]
462 raise wtforms
.ValidationError(
463 TOO_LONG_TAG_WARNING
% (mg_globals
.app_config
['tags_max_length'], \
464 ', '.join(too_long_tags
)))
467 MARKDOWN_INSTANCE
= markdown
.Markdown(safe_mode
='escape')
470 def cleaned_markdown_conversion(text
):
472 Take a block of text, run it through MarkDown, and clean its HTML.
474 # Markdown will do nothing with and clean_html can do nothing with
479 return clean_html(MARKDOWN_INSTANCE
.convert(text
))
485 def setup_gettext(locale
):
487 Setup the gettext instance based on this locale
489 # Later on when we have plugins we may want to enable the
490 # multi-translations system they have so we can handle plugin
493 # TODO: fallback nicely on translations from pt_PT to pt if not
495 if locale
in SETUP_GETTEXTS
:
496 this_gettext
= SETUP_GETTEXTS
[locale
]
498 this_gettext
= gettext
.translation(
499 'mediagoblin', TRANSLATIONS_PATH
, [locale
], fallback
=True)
501 SETUP_GETTEXTS
[locale
] = this_gettext
503 mg_globals
.setup_globals(
504 translations
=this_gettext
)
507 # Force en to be setup before anything else so that
508 # mg_globals.translations is never None
512 def pass_to_ugettext(*args
, **kwargs
):
514 Pass a translation on to the appropriate ugettext method.
516 The reason we can't have a global ugettext method is because
517 mg_globals gets swapped out by the application per-request.
519 return mg_globals
.translations
.ugettext(
523 def lazy_pass_to_ugettext(*args
, **kwargs
):
525 Lazily pass to ugettext.
527 This is useful if you have to define a translation on a module
528 level but you need it to not translate until the time that it's
531 return LazyProxy(pass_to_ugettext
, *args
, **kwargs
)
534 def pass_to_ngettext(*args
, **kwargs
):
536 Pass a translation on to the appropriate ngettext method.
538 The reason we can't have a global ngettext method is because
539 mg_globals gets swapped out by the application per-request.
541 return mg_globals
.translations
.ngettext(
545 def lazy_pass_to_ngettext(*args
, **kwargs
):
547 Lazily pass to ngettext.
549 This is useful if you have to define a translation on a module
550 level but you need it to not translate until the time that it's
553 return LazyProxy(pass_to_ngettext
, *args
, **kwargs
)
556 def fake_ugettext_passthrough(string
):
558 Fake a ugettext call for extraction's sake ;)
560 In wtforms there's a separate way to define a method to translate
561 things... so we just need to mark up the text so that it can be
562 extracted, not so that it's actually run through gettext.
567 PAGINATION_DEFAULT_PER_PAGE
= 30
570 class Pagination(object):
572 Pagination class for mongodb queries.
574 Initialization through __init__(self, cursor, page=1, per_page=2),
575 get actual data slice through __call__().
578 def __init__(self
, page
, cursor
, per_page
=PAGINATION_DEFAULT_PER_PAGE
,
581 Initializes Pagination
584 - page: requested page
585 - per_page: number of objects per page
587 - jump_to_id: ObjectId, sets the page to the page containing the
588 object with _id == jump_to_id.
591 self
.per_page
= per_page
593 self
.total_count
= self
.cursor
.count()
594 self
.active_id
= None
597 cursor
= copy
.copy(self
.cursor
)
599 for (doc
, increment
) in izip(cursor
, count(0)):
600 if doc
['_id'] == jump_to_id
:
601 self
.page
= 1 + int(floor(increment
/ self
.per_page
))
603 self
.active_id
= jump_to_id
608 Returns slice of objects for the requested page
610 return self
.cursor
.skip(
611 (self
.page
- 1) * self
.per_page
).limit(self
.per_page
)
615 return int(ceil(self
.total_count
/ float(self
.per_page
)))
623 return self
.page
< self
.pages
625 def iter_pages(self
, left_edge
=2, left_current
=2,
626 right_current
=5, right_edge
=2):
628 for num
in xrange(1, self
.pages
+ 1):
629 if num
<= left_edge
or \
630 (num
> self
.page
- left_current
- 1 and \
631 num
< self
.page
+ right_current
) or \
632 num
> self
.pages
- right_edge
:
638 def get_page_url_explicit(self
, base_url
, get_params
, page_no
):
639 """Get a page url by adding a page= parameter to the base url
641 new_get_params
= copy
.copy(get_params
or {})
642 new_get_params
['page'] = page_no
644 base_url
, urllib
.urlencode(new_get_params
))
646 def get_page_url(self
, request
, page_no
):
647 """Get a new page url based of the request, and the new page number.
649 This is a nice wrapper around get_page_url_explicit()
651 return self
.get_page_url_explicit(
652 request
.path_info
, request
.GET
, page_no
)
655 def gridify_list(this_list
, num_cols
=5):
657 Generates a list of lists where each sub-list's length depends on
658 the number of columns in the list
662 # Figure out how many rows we should have
663 num_rows
= int(ceil(float(len(this_list
)) / num_cols
))
665 for row_num
in range(num_rows
):
666 slice_min
= row_num
* num_cols
667 slice_max
= (row_num
+ 1) * num_cols
669 row
= this_list
[slice_min
:slice_max
]
676 def gridify_cursor(this_cursor
, num_cols
=5):
678 Generates a list of lists where each sub-list's length depends on
679 the number of columns in the list
681 return gridify_list(list(this_cursor
), num_cols
)
684 def render_404(request
):
688 return render_to_response(
689 request
, 'mediagoblin/404.html', {}, status
=400)
692 def delete_media_files(media
):
694 Delete all files associated with a MediaEntry
697 - media: A MediaEntry document
699 for listpath
in media
['media_files'].itervalues():
700 mg_globals
.public_store
.delete_file(
703 for attachment
in media
['attachment_files']:
704 mg_globals
.public_store
.delete_file(
705 attachment
['filepath'])