Document the db submodule a bit
[mediagoblin.git] / mediagoblin / util.py
CommitLineData
8e1e744d 1# GNU MediaGoblin -- federated, autonomous media hosting
e5572c60
ML
2# Copyright (C) 2011 Free Software Foundation, Inc
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
4d4f6050 17from email.MIMEText import MIMEText
b77eec65
CAW
18import gettext
19import pkg_resources
4d4f6050 20import smtplib
cb8ea0fe 21import sys
0546833c 22import re
c5678c1a
CAW
23import urllib
24from math import ceil
25import copy
26
f99f61c6 27from babel.localedata import exists
31a8ff42 28import jinja2
0546833c 29import translitcodec
9150244a 30from webob import Response, exc
a68ee555 31from lxml.html.clean import Cleaner
4bf8e888 32import markdown
31a8ff42 33
6e7ce8d1 34from mediagoblin import mg_globals
22646703 35from mediagoblin import messages
c5678c1a 36from mediagoblin.db.util import ObjectId
29f3fb70 37
4d4f6050
CAW
38TESTS_ENABLED = False
39def _activate_testing():
40 """
41 Call this to activate testing in util.py
42 """
43 global TESTS_ENABLED
44 TESTS_ENABLED = True
45
46
66471f0e
CAW
47def clear_test_buckets():
48 """
49 We store some things for testing purposes that should be cleared
50 when we want a "clean slate" of information for our next round of
51 tests. Call this function to wipe all that stuff clean.
52
53 Also wipes out some other things we might redefine during testing,
54 like the jinja envs.
55 """
56 global SETUP_JINJA_ENVS
57 SETUP_JINJA_ENVS = {}
58
59 global EMAIL_TEST_INBOX
60 global EMAIL_TEST_MBOX_INBOX
61 EMAIL_TEST_INBOX = []
62 EMAIL_TEST_MBOX_INBOX = []
63
64 clear_test_template_context()
65
66
0e0e3d9a 67def get_jinja_loader(user_template_path=None):
904f61c2 68 """
0e0e3d9a 69 Set up the Jinja template loaders, possibly allowing for user
904f61c2
CAW
70 overridden templates.
71
72 (In the future we may have another system for providing theming;
73 for now this is good enough.)
74 """
31a8ff42 75 if user_template_path:
0e0e3d9a 76 return jinja2.ChoiceLoader(
31a8ff42
CAW
77 [jinja2.FileSystemLoader(user_template_path),
78 jinja2.PackageLoader('mediagoblin', 'templates')])
79 else:
0e0e3d9a 80 return jinja2.PackageLoader('mediagoblin', 'templates')
31a8ff42 81
0e0e3d9a 82
f99f61c6
CAW
83SETUP_JINJA_ENVS = {}
84
85
0e0e3d9a
CAW
86def get_jinja_env(template_loader, locale):
87 """
88 Set up the Jinja environment,
89
90 (In the future we may have another system for providing theming;
91 for now this is good enough.)
92 """
b77eec65
CAW
93 setup_gettext(locale)
94
f99f61c6
CAW
95 # If we have a jinja environment set up with this locale, just
96 # return that one.
97 if SETUP_JINJA_ENVS.has_key(locale):
98 return SETUP_JINJA_ENVS[locale]
99
b77eec65 100 template_env = jinja2.Environment(
0e0e3d9a 101 loader=template_loader, autoescape=True,
44e2da2f 102 extensions=['jinja2.ext.i18n', 'jinja2.ext.autoescape'])
58dec5ef 103
b77eec65 104 template_env.install_gettext_callables(
6e7ce8d1
CAW
105 mg_globals.translations.gettext,
106 mg_globals.translations.ngettext)
b77eec65 107
22646703 108 # All templates will know how to ...
22646703
CFD
109 # ... fetch all waiting messages and remove them from the queue
110 template_env.globals['fetch_messages'] = messages.fetch_messages
111
f99f61c6
CAW
112 if exists(locale):
113 SETUP_JINJA_ENVS[locale] = template_env
114
b77eec65
CAW
115 return template_env
116
58dec5ef 117
e9279f21
CAW
118# We'll store context information here when doing unit tests
119TEMPLATE_TEST_CONTEXT = {}
120
121
67e8c45d 122def render_template(request, template_path, context):
e9279f21
CAW
123 """
124 Render a template with context.
125
126 Always inserts the request into the context, so you don't have to.
127 Also stores the context if we're doing unit tests. Helpful!
128 """
129 template = request.template_env.get_template(
67e8c45d 130 template_path)
e9279f21
CAW
131 context['request'] = request
132 rendered = template.render(context)
133
134 if TESTS_ENABLED:
67e8c45d 135 TEMPLATE_TEST_CONTEXT[template_path] = context
e9279f21
CAW
136
137 return rendered
138
139
140def clear_test_template_context():
141 global TEMPLATE_TEST_CONTEXT
142 TEMPLATE_TEST_CONTEXT = {}
143
144
1c63ad5d
E
145def render_to_response(request, template, context):
146 """Much like Django's shortcut.render()"""
147 return Response(render_template(request, template, context))
148
149
9150244a
E
150def redirect(request, *args, **kwargs):
151 """Returns a HTTPFound(), takes a request and then urlgen params"""
152 return exc.HTTPFound(location=request.urlgen(*args, **kwargs))
153
154
58dec5ef
CAW
155def setup_user_in_request(request):
156 """
157 Examine a request and tack on a request.user parameter if that's
158 appropriate.
159 """
160 if not request.session.has_key('user_id'):
59dd5c7e 161 request.user = None
58dec5ef
CAW
162 return
163
5d6840a0 164 user = None
6648c52b 165 user = request.app.db.User.one(
254bc431 166 {'_id': ObjectId(request.session['user_id'])})
5d6840a0 167
c74e1462
CAW
168 if not user:
169 # Something's wrong... this user doesn't exist? Invalidate
170 # this session.
58dec5ef 171 request.session.invalidate()
5d6840a0
CAW
172
173 request.user = user
cb8ea0fe
CAW
174
175
176def import_component(import_string):
177 """
178 Import a module component defined by STRING. Probably a method,
179 class, or global variable.
180
181 Args:
182 - import_string: a string that defines what to import. Written
183 in the format of "module1.module2:component"
184 """
185 module_name, func_name = import_string.split(':', 1)
186 __import__(module_name)
187 module = sys.modules[module_name]
188 func = getattr(module, func_name)
189 return func
4d4f6050 190
0546833c
AW
191_punct_re = re.compile(r'[\t !"#$%&\'()*\-/<=>?@\[\\\]^_`{|},.]+')
192
193def slugify(text, delim=u'-'):
194 """
195 Generates an ASCII-only slug. Taken from http://flask.pocoo.org/snippets/5/
196 """
197 result = []
198 for word in _punct_re.split(text.lower()):
199 word = word.encode('translit/long')
200 if word:
201 result.append(word)
202 return unicode(delim.join(result))
4d4f6050
CAW
203
204### ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
205### Special email test stuff begins HERE
206### ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
207
208# We have two "test inboxes" here:
209#
210# EMAIL_TEST_INBOX:
211# ----------------
212# If you're writing test views, you'll probably want to check this.
213# It contains a list of MIMEText messages.
214#
215# EMAIL_TEST_MBOX_INBOX:
216# ----------------------
217# This collects the messages from the FakeMhost inbox. It's reslly
218# just here for testing the send_email method itself.
219#
220# Anyway this contains:
221# - from
222# - to: a list of email recipient addresses
223# - message: not just the body, but the whole message, including
224# headers, etc.
225#
226# ***IMPORTANT!***
227# ----------------
228# Before running tests that call functions which send email, you should
229# always call _clear_test_inboxes() to "wipe" the inboxes clean.
230
231EMAIL_TEST_INBOX = []
232EMAIL_TEST_MBOX_INBOX = []
233
234
235class FakeMhost(object):
236 """
237 Just a fake mail host so we can capture and test messages
238 from send_email
239 """
240 def connect(self):
241 pass
242
243 def sendmail(self, from_addr, to_addrs, message):
244 EMAIL_TEST_MBOX_INBOX.append(
245 {'from': from_addr,
246 'to': to_addrs,
247 'message': message})
248
249def _clear_test_inboxes():
250 global EMAIL_TEST_INBOX
251 global EMAIL_TEST_MBOX_INBOX
252 EMAIL_TEST_INBOX = []
253 EMAIL_TEST_MBOX_INBOX = []
254
255### ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
256### </Special email test stuff>
257### ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
258
259def send_email(from_addr, to_addrs, subject, message_body):
61ec968b
CAW
260 """
261 Simple email sending wrapper, use this so we can capture messages
262 for unit testing purposes.
263
264 Args:
265 - from_addr: address you're sending the email from
266 - to_addrs: list of recipient email addresses
267 - subject: subject of the email
268 - message_body: email body text
269 """
4d4f6050 270 # TODO: make a mock mhost if testing is enabled
6e7ce8d1 271 if TESTS_ENABLED or mg_globals.email_debug_mode:
4d4f6050 272 mhost = FakeMhost()
6e7ce8d1 273 elif not mg_globals.email_debug_mode:
4d4f6050
CAW
274 mhost = smtplib.SMTP()
275
276 mhost.connect()
277
278 message = MIMEText(message_body.encode('utf-8'), 'plain', 'utf-8')
279 message['Subject'] = subject
280 message['From'] = from_addr
281 message['To'] = ', '.join(to_addrs)
282
283 if TESTS_ENABLED:
284 EMAIL_TEST_INBOX.append(message)
285
6e7ce8d1 286 if getattr(mg_globals, 'email_debug_mode', False):
29f3fb70
CAW
287 print u"===== Email ====="
288 print u"From address: %s" % message['From']
289 print u"To addresses: %s" % message['To']
290 print u"Subject: %s" % message['Subject']
291 print u"-- Body: --"
292 print message.get_payload(decode=True)
293
21919313 294 return mhost.sendmail(from_addr, to_addrs, message.as_string())
20c834ff 295
8b28bee4
CAW
296
297###################
298# Translation tools
299###################
300
301
b77eec65
CAW
302TRANSLATIONS_PATH = pkg_resources.resource_filename(
303 'mediagoblin', 'translations')
304
305
8b28bee4
CAW
306def locale_to_lower_upper(locale):
307 """
308 Take a locale, regardless of style, and format it like "en-us"
309 """
310 if '-' in locale:
311 lang, country = locale.split('-', 1)
312 return '%s_%s' % (lang.lower(), country.upper())
313 elif '_' in locale:
314 lang, country = locale.split('_', 1)
315 return '%s_%s' % (lang.lower(), country.upper())
316 else:
317 return locale.lower()
318
319
320def locale_to_lower_lower(locale):
321 """
322 Take a locale, regardless of style, and format it like "en_US"
323 """
324 if '_' in locale:
325 lang, country = locale.split('_', 1)
326 return '%s-%s' % (lang.lower(), country.lower())
327 else:
328 return locale.lower()
329
330
331def get_locale_from_request(request):
332 """
333 Figure out what target language is most appropriate based on the
334 request
335 """
336 request_form = request.GET or request.POST
337
338 if request_form.has_key('lang'):
339 return locale_to_lower_upper(request_form['lang'])
340
341 accept_lang_matches = request.accept_language.best_matches()
342
343 # Your routing can explicitly specify a target language
376e6ef2
CAW
344 if request.matchdict.has_key('locale'):
345 target_lang = request.matchdict['locale']
8b28bee4
CAW
346 elif request.session.has_key('target_lang'):
347 target_lang = request.session['target_lang']
348 # Pull the first acceptable language
349 elif accept_lang_matches:
350 target_lang = accept_lang_matches[0]
351 # Fall back to English
352 else:
353 target_lang = 'en'
354
0e0e3d9a 355 return locale_to_lower_upper(target_lang)
b77eec65
CAW
356
357
a68ee555
CAW
358# A super strict version of the lxml.html cleaner class
359HTML_CLEANER = Cleaner(
360 scripts=True,
361 javascript=True,
362 comments=True,
363 style=True,
364 links=True,
365 page_structure=True,
366 processing_instructions=True,
367 embedded=True,
368 frames=True,
369 forms=True,
370 annoying_tags=True,
371 allow_tags=[
372 'div', 'b', 'i', 'em', 'strong', 'p', 'ul', 'ol', 'li', 'a', 'br'],
373 remove_unknown_tags=False, # can't be used with allow_tags
374 safe_attrs_only=True,
375 add_nofollow=True, # for now
376 host_whitelist=(),
377 whitelist_tags=set([]))
378
379
380def clean_html(html):
4fd18da0
CAW
381 # clean_html barfs on an empty string
382 if not html:
383 return u''
384
a68ee555
CAW
385 return HTML_CLEANER.clean_html(html)
386
387
4bf8e888
CAW
388MARKDOWN_INSTANCE = markdown.Markdown(safe_mode='escape')
389
390
391def cleaned_markdown_conversion(text):
392 """
393 Take a block of text, run it through MarkDown, and clean its HTML.
394 """
82688846
CAW
395 # Markdown will do nothing with and clean_html can do nothing with
396 # an empty string :)
397 if not text:
398 return u''
399
4bf8e888
CAW
400 return clean_html(MARKDOWN_INSTANCE.convert(text))
401
402
f99f61c6
CAW
403SETUP_GETTEXTS = {}
404
b77eec65
CAW
405def setup_gettext(locale):
406 """
407 Setup the gettext instance based on this locale
408 """
409 # Later on when we have plugins we may want to enable the
410 # multi-translations system they have so we can handle plugin
411 # translations too
412
413 # TODO: fallback nicely on translations from pt_PT to pt if not
414 # available, etc.
f99f61c6
CAW
415 if SETUP_GETTEXTS.has_key(locale):
416 this_gettext = SETUP_GETTEXTS[locale]
417 else:
418 this_gettext = gettext.translation(
419 'mediagoblin', TRANSLATIONS_PATH, [locale], fallback=True)
420 if exists(locale):
421 SETUP_GETTEXTS[locale] = this_gettext
b77eec65 422
6e7ce8d1 423 mg_globals.setup_globals(
b77eec65 424 translations=this_gettext)
ae85ed0f
BK
425
426
b9e9610b
CAW
427PAGINATION_DEFAULT_PER_PAGE = 30
428
ae85ed0f
BK
429class Pagination(object):
430 """
dffa0b09
CAW
431 Pagination class for mongodb queries.
432
433 Initialization through __init__(self, cursor, page=1, per_page=2),
434 get actual data slice through __call__().
ae85ed0f 435 """
ca3ca51c 436
b9e9610b 437 def __init__(self, page, cursor, per_page=PAGINATION_DEFAULT_PER_PAGE):
44e3e917 438 """
a98d5254
CAW
439 Initializes Pagination
440
441 Args:
442 - page: requested page
443 - per_page: number of objects per page
444 - cursor: db cursor
44e3e917
BK
445 """
446 self.page = page
ca3ca51c
BK
447 self.per_page = per_page
448 self.cursor = cursor
ca3ca51c
BK
449 self.total_count = self.cursor.count()
450
451 def __call__(self):
44e3e917 452 """
a98d5254 453 Returns slice of objects for the requested page
44e3e917 454 """
140e2102
CAW
455 return self.cursor.skip(
456 (self.page - 1) * self.per_page).limit(self.per_page)
ae85ed0f
BK
457
458 @property
459 def pages(self):
460 return int(ceil(self.total_count / float(self.per_page)))
461
462 @property
463 def has_prev(self):
464 return self.page > 1
465
466 @property
467 def has_next(self):
468 return self.page < self.pages
469
470 def iter_pages(self, left_edge=2, left_current=2,
471 right_current=5, right_edge=2):
472 last = 0
473 for num in xrange(1, self.pages + 1):
474 if num <= left_edge or \
475 (num > self.page - left_current - 1 and \
476 num < self.page + right_current) or \
477 num > self.pages - right_edge:
478 if last + 1 != num:
479 yield None
480 yield num
481 last = num
44e3e917 482
50c880ac 483 def get_page_url_explicit(self, base_url, get_params, page_no):
44e3e917 484 """
50c880ac 485 Get a page url by adding a page= parameter to the base url
44e3e917
BK
486 """
487 new_get_params = copy.copy(get_params or {})
488 new_get_params['page'] = page_no
489 return "%s?%s" % (
50c880ac
CAW
490 base_url, urllib.urlencode(new_get_params))
491
492 def get_page_url(self, request, page_no):
493 """
494 Get a new page url based of the request, and the new page number.
495
496 This is a nice wrapper around get_page_url_explicit()
497 """
498 return self.get_page_url_explicit(
499 request.path_info, request.GET, page_no)