Mercurial > hg > toolbox
comparison toolbox/handlers.py @ 0:b0942f44413f
import from git://github.com/mozilla/toolbox.git
author | Jeff Hammel <k0scist@gmail.com> |
---|---|
date | Sun, 11 May 2014 09:15:35 -0700 |
parents | |
children | 201857e15b50 |
comparison
equal
deleted
inserted
replaced
-1:000000000000 | 0:b0942f44413f |
---|---|
1 """ | |
2 request handlers: | |
3 these are instantiated for every request, then called | |
4 """ | |
5 | |
6 import cgi | |
7 import os | |
8 from datetime import datetime | |
9 from pkg_resources import resource_filename | |
10 from urllib import quote as _quote | |
11 from urlparse import urlparse | |
12 from util import strsplit | |
13 from util import JSONEncoder | |
14 from webob import Response, exc | |
15 from tempita import HTMLTemplate | |
16 from time import time | |
17 | |
18 # this is necessary because WSGI stupidly follows the CGI convention wrt encoding slashes | |
19 # http://comments.gmane.org/gmane.comp.web.pylons.general/5922 | |
20 encoded_slash = '%25%32%66' | |
21 | |
22 def quote(s, safe='/'): | |
23 if isinstance(s, unicode): | |
24 s = s.encode('utf-8', 'ignore') # hope we're using utf-8! | |
25 return _quote(s, safe).replace('/', encoded_slash) | |
26 | |
27 try: | |
28 import json | |
29 except ImportError: | |
30 import simplejson as json | |
31 | |
32 class HandlerMatchException(Exception): | |
33 """the handler doesn't match the request""" | |
34 | |
35 class Handler(object): | |
36 """general purpose request handler (view)""" | |
37 | |
38 methods = set(['GET']) # methods to listen to | |
39 handler_path = [] # path elements to match | |
40 | |
41 @classmethod | |
42 def match(cls, app, request): | |
43 | |
44 # check the method | |
45 if request.method not in cls.methods: | |
46 return None | |
47 | |
48 # check the path | |
49 if request.environ['path'] != cls.handler_path: | |
50 return None | |
51 | |
52 # check the constructor | |
53 try: | |
54 return cls(app, request) | |
55 except HandlerMatchException: | |
56 return None | |
57 | |
58 def __init__(self, app, request): | |
59 self.app = app | |
60 self.request = request | |
61 self.check_json() # is this a JSON request? | |
62 | |
63 def __call__(self): | |
64 return getattr(self, self.request.method.title())() | |
65 | |
66 def link(self, path=None): | |
67 """ | |
68 link relative to the site root | |
69 """ | |
70 path_info = self.request.path_info | |
71 segments = path_info.split('/') | |
72 if segments[0]: | |
73 segments.insert(0, '') | |
74 | |
75 if len(segments) <3: | |
76 if not path or path == '/': | |
77 return './' | |
78 return path | |
79 | |
80 nlayers = len(segments[2:]) | |
81 string = '../' * nlayers | |
82 | |
83 if not path or path == '/': | |
84 return string | |
85 return string + path | |
86 | |
87 def redirect(self, location, query=None, anchor=None): | |
88 return exc.HTTPSeeOther(location=self.app.baseurl + '/' + location | |
89 + (query and self.query_string(query) or '') | |
90 + (anchor and ('#' + anchor) or '')) | |
91 | |
92 def query_string(self, query): | |
93 """ | |
94 generate a query string; query is a list of 2-tuples | |
95 """ | |
96 return '?' + '&'.join(['%s=%s' % (i,j) | |
97 for i, j in query]) | |
98 | |
99 # methods for JSON | |
100 | |
101 def check_json(self): | |
102 """check to see if the request is for JSON""" | |
103 self.json = self.request.GET.pop('format', '') == 'json' | |
104 | |
105 def post_data(self): | |
106 """python dict from POST request""" | |
107 if self.json: | |
108 return json.loads(self.request.body) | |
109 else: | |
110 retval = self.request.POST.mixed() | |
111 for key in retval: | |
112 value = retval[key] | |
113 if isinstance(value, basestring): | |
114 retval[key] = value.strip() | |
115 else: | |
116 # TODO[?]: just throw away all empty values here | |
117 retval[key] = [i.strip() for i in value] | |
118 return retval | |
119 | |
120 def get_json(self): | |
121 """JSON to serialize if requested for GET""" | |
122 raise NotImplementedError # abstract base class | |
123 | |
124 | |
125 class TempitaHandler(Handler): | |
126 """handler for tempita templates""" | |
127 | |
128 template_dirs = [ resource_filename(__name__, 'templates') ] | |
129 | |
130 template_cache = {} | |
131 | |
132 css = ['css/html5boilerplate.css'] | |
133 | |
134 less = ['css/style.less'] | |
135 | |
136 js = ['js/jquery-1.6.min.js', | |
137 'js/less-1.0.41.min.js', | |
138 'js/jquery.timeago.js', | |
139 'js/main.js'] | |
140 | |
141 def __init__(self, app, request): | |
142 Handler.__init__(self, app, request) | |
143 | |
144 # add application template_dir if specified | |
145 if app.template_dir: | |
146 self.template_dirs = self.template_dirs[:] + [app.template_dir] | |
147 | |
148 self.data = { 'request': request, | |
149 'css': self.css, | |
150 'item_name': self.app.item_name, | |
151 'item_plural': self.app.item_plural, | |
152 'less': self.less, | |
153 'js': self.js, | |
154 'site_name': app.site_name, | |
155 'title': self.__class__.__name__, | |
156 'hasAbout': bool(app.about), | |
157 'urlescape': quote, | |
158 'link': self.link} | |
159 | |
160 def find_template(self, name): | |
161 """find a template of a given name""" | |
162 # the application caches a dict of the templates if app.reload is False | |
163 if name in self.template_cache: | |
164 return self.template_cache[name] | |
165 | |
166 for d in self.template_dirs: | |
167 path = os.path.join(d, name) | |
168 if os.path.exists(path): | |
169 template = HTMLTemplate.from_filename(path) | |
170 if not self.app.reload: | |
171 self.template_cache[name] = template | |
172 return template | |
173 | |
174 def render(self, template, **data): | |
175 template = self.find_template(template) | |
176 if template is None: | |
177 raise Exception("I can't find your template") | |
178 return template.substitute(**data) | |
179 | |
180 def Get(self): | |
181 # needs to have self.template set | |
182 if self.json: | |
183 return Response(content_type='application/json', | |
184 body=json.dumps(self.get_json(), cls=JSONEncoder)) | |
185 self.data['content'] = self.render(self.template, **self.data) | |
186 return Response(content_type='text/html', | |
187 body=self.render('main.html', **self.data)) | |
188 | |
189 class ProjectsView(TempitaHandler): | |
190 """abstract base class for views of projects""" | |
191 | |
192 js = TempitaHandler.js[:] | |
193 js.extend(['js/jquery.tokeninput.js', | |
194 'js/jquery.jeditable.js', | |
195 'js/jquery.autolink.js', | |
196 'js/project.js']) | |
197 | |
198 less = TempitaHandler.less[:] | |
199 less.extend(['css/project.less']) | |
200 | |
201 css = TempitaHandler.css[:] | |
202 css.extend(['css/token-input.css', | |
203 'css/token-input-facebook.css']) | |
204 | |
205 def __init__(self, app, request): | |
206 """project views specific init""" | |
207 TempitaHandler.__init__(self, app, request) | |
208 self.data['fields'] = self.app.model.fields() | |
209 self.data['error'] = None | |
210 if not self.json: | |
211 self.data['format_date'] = self.format_date | |
212 | |
213 def get_json(self): | |
214 """JSON to serialize if requested""" | |
215 return self.data['projects'] | |
216 | |
217 def sort(self, field): | |
218 reverse = False | |
219 if field.startswith('-'): | |
220 field = field[1:] | |
221 reverse = True | |
222 if field == 'name': | |
223 self.data['projects'].sort(key=lambda value: value[field].lower(), reverse=reverse) | |
224 else: | |
225 self.data['projects'].sort(key=lambda value: value[field], reverse=reverse) | |
226 | |
227 def format_date(self, timestamp): | |
228 """return a string representation of a timestamp""" | |
229 format_string = '%Y-%m-%dT%H:%M:%SZ' | |
230 return datetime.utcfromtimestamp(timestamp).strftime(format_string) | |
231 | |
232 | |
233 class QueryView(ProjectsView): | |
234 """general index view to query projects""" | |
235 | |
236 template = 'index.html' | |
237 methods = set(['GET']) | |
238 | |
239 def __init__(self, app, request): | |
240 ProjectsView.__init__(self, app, request) | |
241 | |
242 # pop non-query parameters; | |
243 # sort is popped first so that it does go in the query | |
244 sort_type = self.request.GET.pop('sort', None) | |
245 query = self.request.GET.mixed() | |
246 self.data['query'] = query | |
247 search = query.pop('q', None) | |
248 self.data['search'] = search | |
249 | |
250 # query for tools | |
251 self.data['projects']= self.app.model.get(search, **query) | |
252 | |
253 # order the results | |
254 self.data['sort_types'] = [('name', 'name'), ('-modified', 'last updated')] | |
255 if search: | |
256 self.data['sort_types'].insert(0, ('search', 'search rank')) | |
257 if sort_type is None: | |
258 if search: | |
259 sort_type = 'search' | |
260 else: | |
261 # default | |
262 sort_type = '-modified' | |
263 self.data['sort_type'] = sort_type | |
264 if sort_type != 'search': | |
265 # preserve search order results | |
266 self.sort(sort_type) | |
267 | |
268 self.data['fields'] = self.app.model.fields() | |
269 self.data['title'] = self.app.site_name | |
270 | |
271 | |
272 class ProjectView(ProjectsView): | |
273 """view of a particular project""" | |
274 | |
275 template = 'index.html' | |
276 methods=set(['GET', 'POST']) | |
277 | |
278 @classmethod | |
279 def match(cls, app, request): | |
280 | |
281 # check the method | |
282 if request.method not in cls.methods: | |
283 return None | |
284 | |
285 # the path should match a project | |
286 if not len(request.environ['path']) == 1: | |
287 return None | |
288 | |
289 # get the project if it exists | |
290 projectname = request.environ['path'][0].replace('%2f', '/') # double de-escape slashes, see top of file | |
291 try: | |
292 # if its utf-8, we should try to keep it utf-8 | |
293 projectname = projectname.decode('utf-8') | |
294 except UnicodeDecodeError: | |
295 pass | |
296 project = app.model.project(projectname) | |
297 if not project: | |
298 return None | |
299 | |
300 # check the constructor | |
301 try: | |
302 return cls(app, request, project) | |
303 except HandlerMatchException: | |
304 return None | |
305 | |
306 def __init__(self, app, request, project): | |
307 ProjectsView.__init__(self, app, request) | |
308 self.data['fields'] = self.app.model.fields() | |
309 self.data['projects'] = [project] | |
310 self.data['title'] = project['name'] | |
311 | |
312 def get_json(self): | |
313 return self.data['projects'][0] | |
314 | |
315 def Post(self): | |
316 | |
317 # data | |
318 post_data = self.post_data() | |
319 project = self.data['projects'][0] | |
320 | |
321 # insist that you have a name | |
322 if 'name' in post_data and not post_data['name'].strip(): | |
323 self.data['title'] = 'Rename error' | |
324 self.data['error'] = 'Cannot give a project an empty name' | |
325 self.data['content'] = self.render(self.template, **self.data) | |
326 return Response(content_type='text/html', | |
327 status=403, | |
328 body=self.render('main.html', **self.data)) | |
329 | |
330 # don't allow overiding other projects with your fancy rename | |
331 if 'name' in post_data and post_data['name'] != project['name']: | |
332 if self.app.model.project(post_data['name']): | |
333 self.data['title'] = '%s -> %s: Rename error' % (project['name'], post_data['name']) | |
334 self.data['error'] = 'Cannot rename over existing project: <a href="%s">%s</a>' % (post_data['name'], post_data['name'] ) | |
335 self.data['content'] = self.render(self.template, **self.data) | |
336 return Response(content_type='text/html', | |
337 status=403, | |
338 body=self.render('main.html', **self.data)) | |
339 | |
340 # XXX for compatability with jeditable: | |
341 id = post_data.pop('id', None) | |
342 | |
343 action = post_data.pop('action', None) | |
344 old_name = project['name'] | |
345 if action == 'delete': | |
346 for field in self.app.model.fields(): | |
347 if field in post_data and field in project: | |
348 values = post_data.pop(field) | |
349 if isinstance(values, basestring): | |
350 values = [values] | |
351 for value in values: | |
352 project[field].remove(value) | |
353 if not project[field]: | |
354 project.pop(field) | |
355 else: | |
356 for field in self.app.model.required: | |
357 if field in post_data: | |
358 project[field] = post_data[field] | |
359 for field in self.app.model.fields(): | |
360 if field in post_data: | |
361 value = post_data[field] | |
362 if isinstance(value, basestring): | |
363 value = strsplit(value) | |
364 if action == 'replace': | |
365 # replace the field from the POST request | |
366 project[field] = value | |
367 else: | |
368 # append the items....the default action | |
369 project.setdefault(field, []).extend(value) | |
370 | |
371 # rename handling | |
372 if 'name' in post_data and post_data['name'] != old_name: | |
373 self.app.model.delete(old_name) | |
374 self.app.model.update(project) | |
375 return self.redirect(quote(project['name'])) | |
376 | |
377 self.app.model.update(project) | |
378 | |
379 # XXX for compatability with jeditable: | |
380 if id is not None: | |
381 return Response(content_type='text/plain', | |
382 body=cgi.escape(project['description'])) | |
383 | |
384 # XXX should redirect instead | |
385 return self.Get() | |
386 | |
387 | |
388 class FieldView(ProjectsView): | |
389 """view of projects sorted by a field""" | |
390 | |
391 template = 'fields.html' | |
392 methods=set(['GET', 'POST']) | |
393 js = TempitaHandler.js[:] + ['js/field.js'] | |
394 | |
395 @classmethod | |
396 def match(cls, app, request): | |
397 | |
398 # check the method | |
399 if request.method not in cls.methods: | |
400 return None | |
401 | |
402 # the path should match a project | |
403 if len(request.environ['path']) != 1: | |
404 return None | |
405 | |
406 # ensure the field exists | |
407 field = request.environ['path'][0] | |
408 if field not in app.model.fields(): | |
409 return None | |
410 | |
411 # check the constructor | |
412 try: | |
413 return cls(app, request, field) | |
414 except HandlerMatchException: | |
415 return None | |
416 | |
417 def __init__(self, app, request, field): | |
418 ProjectsView.__init__(self, app, request) | |
419 projects = self.app.model.field_query(field) | |
420 if projects is None: | |
421 projects = {} | |
422 self.data['field'] = field | |
423 self.data['values'] = projects | |
424 self.data['title'] = app.item_plural + ' by %s' % field | |
425 if self.request.method == 'GET': | |
426 # get project descriptions for tooltips | |
427 descriptions = {} | |
428 project_set = set() | |
429 for values in projects.values(): | |
430 project_set.update(values) | |
431 self.data['projects'] = dict([(name, self.app.model.project(name)) | |
432 for name in project_set]) | |
433 | |
434 def Post(self): | |
435 field = self.data['field'] | |
436 for key in self.request.POST.iterkeys(): | |
437 value = self.request.POST[key] | |
438 self.app.model.rename_field_value(field, key, value) | |
439 | |
440 return self.redirect(field, anchor=value) | |
441 | |
442 def get_json(self): | |
443 return self.data['values'] | |
444 | |
445 | |
446 class CreateProjectView(TempitaHandler): | |
447 """view to create a new project""" | |
448 | |
449 template = 'new.html' | |
450 methods = set(['GET', 'POST']) | |
451 handler_path = ['new'] | |
452 js = TempitaHandler.js[:] | |
453 js.extend(['js/jquery.tokeninput.js', | |
454 'js/queryString.js', | |
455 'js/new.js']) | |
456 | |
457 less = TempitaHandler.less[:] | |
458 less.extend(['css/new.less']) | |
459 | |
460 css = TempitaHandler.css[:] | |
461 css.extend(['css/token-input.css', | |
462 'css/token-input-facebook.css']) | |
463 | |
464 def __init__(self, app, request): | |
465 TempitaHandler.__init__(self, app, request) | |
466 self.data['title'] = 'Add a ' + app.item_name | |
467 self.data['fields'] = self.app.model.fields() | |
468 | |
469 def check_name(self, name): | |
470 """ | |
471 checks a project name for validity | |
472 returns None on success or an error message if invalid | |
473 """ | |
474 reserved = self.app.reserved.copy() | |
475 if name in reserved or name in self.app.model.fields(): # check application-level reserved URLS | |
476 return 'reserved' | |
477 if self.app.model.project(name): # check projects for conflict | |
478 return 'conflict' | |
479 | |
480 def Post(self): | |
481 | |
482 # get some data | |
483 required = self.app.model.required | |
484 post_data = self.post_data() | |
485 | |
486 # ensure the form isn't over 24 hours old | |
487 day = 24*3600 | |
488 form_date = post_data.pop('form-render-date', -day) | |
489 try: | |
490 form_date = float(form_date) | |
491 except ValueError: | |
492 form_date = -day | |
493 if abs(form_date - time()) > day: | |
494 # if more than a day old, don't honor the request | |
495 return Response(content_type='text/plain', | |
496 status=400, | |
497 body="Your form is over a day old or you don't have Javascript enabled") | |
498 | |
499 # build up a project dict | |
500 project = dict([(i, post_data.get(i, '').strip()) | |
501 for i in required]) | |
502 | |
503 # check for errors | |
504 errors = {} | |
505 missing = set([i for i in required if not project[i]]) | |
506 if missing: # missing required fields | |
507 errors['missing'] = missing | |
508 # TODO check for duplicate project name | |
509 # and other url namespace collisions | |
510 name_conflict = self.check_name(project['name']) | |
511 if name_conflict: | |
512 errors[name_conflict] = [project['name']] | |
513 if errors: | |
514 error_list = [] | |
515 for key in errors: | |
516 # flatten the error dict into a list | |
517 error_list.extend([(key, i) for i in errors[key]]) | |
518 return self.redirect(self.request.path_info.strip('/'), error_list) | |
519 | |
520 # add fields to the project | |
521 for field in self.app.model.fields(): | |
522 value = post_data.get(field, '').strip() | |
523 values = strsplit(value) | |
524 if not value: | |
525 continue | |
526 project[field] = values or value | |
527 | |
528 self.app.model.update(project) | |
529 return self.redirect(quote(project['name'])) | |
530 | |
531 | |
532 class DeleteProjectHandler(Handler): | |
533 | |
534 methods = set(['POST']) | |
535 handler_path = ['delete'] | |
536 | |
537 def Post(self): | |
538 post_data = self.post_data() | |
539 project = post_data.get('project') | |
540 if project: | |
541 try: | |
542 self.app.model.delete(project) | |
543 except: | |
544 pass # XXX better than internal server error | |
545 | |
546 # redirect to query view | |
547 return self.redirect('') | |
548 | |
549 | |
550 class TagsView(TempitaHandler): | |
551 """view most popular tags""" | |
552 methods = set(['GET']) | |
553 handler_path = ['tags'] | |
554 template = 'tags.html' | |
555 | |
556 def __init__(self, app, request): | |
557 TempitaHandler.__init__(self, app, request) | |
558 self.data['fields'] = self.app.model.fields() | |
559 fields = self.request.GET.getall('field') or self.data['fields'] | |
560 query = self.request.GET.get('q', '') | |
561 self.data['title'] = 'Tags' | |
562 field_tags = dict((i, {}) for i in fields) | |
563 omit = self.request.GET.getall('omit') | |
564 ommitted = dict([(field, set()) for field in fields]) | |
565 for name in omit: | |
566 project = self.app.model.project(name) | |
567 if not project: | |
568 continue | |
569 for field in fields: | |
570 ommitted[field].update(project.get(field, [])) | |
571 | |
572 for project in self.app.model.get(): | |
573 if project in omit: | |
574 continue | |
575 # TODO: cache this for speed somehow | |
576 # possibly at the model level | |
577 for field in fields: | |
578 for value in project.get(field, []): | |
579 if value in ommitted[field] or query not in value: | |
580 continue | |
581 count = field_tags[field].get(value, 0) + 1 | |
582 field_tags[field][value] = count | |
583 tags = [] | |
584 for field in field_tags: | |
585 for value, count in field_tags[field].items(): | |
586 tags.append({'field': field, 'value': value, 'count': count, 'id': value, 'name': value}) | |
587 tags.sort(key=lambda x: x['count'], reverse=True) | |
588 | |
589 self.data['tags'] = tags | |
590 | |
591 def get_json(self): | |
592 return self.data['tags'] | |
593 | |
594 | |
595 class AboutView(TempitaHandler): | |
596 """the obligatory about page""" | |
597 methods = set(['GET']) | |
598 handler_path = ['about'] | |
599 template = 'about.html' | |
600 less = TempitaHandler.less[:] + ['css/about.less'] | |
601 def __init__(self, app, request): | |
602 TempitaHandler.__init__(self, app, request) | |
603 self.data['fields'] = self.app.model.fields() | |
604 self.data['title'] = 'about:' + self.app.site_name | |
605 self.data['about'] = self.app.about | |
606 | |
607 class NotFound(TempitaHandler): | |
608 def __init__(self, app, request): | |
609 TempitaHandler.__init__(self, app, request) | |
610 self.data['fields'] = self.app.model.fields() | |
611 | |
612 def __call__(self): | |
613 self.data['content'] = '<h1 id="title">Not Found</h1>' | |
614 return Response(content_type='text/html', | |
615 status=404, | |
616 body=self.render('main.html', **self.data)) |