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))