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