diff 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
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/toolbox/handlers.py	Sun May 11 09:15:35 2014 -0700
@@ -0,0 +1,616 @@
+"""
+request handlers:
+these are instantiated for every request, then called
+"""
+
+import cgi
+import os
+from datetime import datetime
+from pkg_resources import resource_filename
+from urllib import quote as _quote
+from urlparse import urlparse
+from util import strsplit
+from util import JSONEncoder
+from webob import Response, exc
+from tempita import HTMLTemplate
+from time import time
+
+# this is necessary because WSGI stupidly follows the CGI convention wrt encoding slashes
+# http://comments.gmane.org/gmane.comp.web.pylons.general/5922
+encoded_slash = '%25%32%66'
+
+def quote(s, safe='/'):
+    if isinstance(s, unicode):
+        s = s.encode('utf-8', 'ignore') # hope we're using utf-8!
+    return _quote(s, safe).replace('/', encoded_slash)
+
+try:
+    import json
+except ImportError:
+    import simplejson as json
+
+class HandlerMatchException(Exception):
+    """the handler doesn't match the request"""
+
+class Handler(object):
+    """general purpose request handler (view)"""
+
+    methods = set(['GET']) # methods to listen to
+    handler_path = [] # path elements to match
+
+    @classmethod
+    def match(cls, app, request):
+
+        # check the method
+        if request.method not in cls.methods:
+            return None
+
+        # check the path
+        if request.environ['path'] != cls.handler_path:
+            return None
+
+        # check the constructor
+        try:
+            return cls(app, request)
+        except HandlerMatchException:
+            return None
+    
+    def __init__(self, app, request):
+        self.app = app
+        self.request = request
+        self.check_json() # is this a JSON request?
+
+    def __call__(self):
+        return getattr(self, self.request.method.title())()
+
+    def link(self, path=None):
+        """
+        link relative to the site root
+        """
+        path_info = self.request.path_info
+        segments = path_info.split('/')
+        if segments[0]:
+            segments.insert(0, '')
+
+        if len(segments) <3:
+            if not path or path == '/':
+                return './'
+            return path
+
+        nlayers = len(segments[2:])
+        string = '../' * nlayers
+
+        if not path or path == '/':
+            return string
+        return string + path
+
+    def redirect(self, location, query=None, anchor=None):
+        return exc.HTTPSeeOther(location=self.app.baseurl + '/' + location
+                                + (query and self.query_string(query) or '')
+                                + (anchor and ('#' + anchor) or ''))
+
+    def query_string(self, query):
+        """
+        generate a query string; query is a list of 2-tuples
+        """
+        return '?' + '&'.join(['%s=%s' % (i,j)
+                               for i, j in query])
+
+    # methods for JSON
+
+    def check_json(self):
+        """check to see if the request is for JSON"""
+        self.json = self.request.GET.pop('format', '') == 'json'
+
+    def post_data(self):
+        """python dict from POST request"""
+        if self.json:
+            return json.loads(self.request.body)
+        else:
+            retval = self.request.POST.mixed()
+            for key in retval:
+                value = retval[key]
+                if isinstance(value, basestring):
+                    retval[key] = value.strip()
+                else:
+                    # TODO[?]: just throw away all empty values here
+                    retval[key] = [i.strip() for i in value]
+            return retval
+
+    def get_json(self):
+        """JSON to serialize if requested for GET"""
+        raise NotImplementedError # abstract base class
+
+
+class TempitaHandler(Handler):
+    """handler for tempita templates"""
+
+    template_dirs = [ resource_filename(__name__, 'templates') ]
+
+    template_cache = {}
+
+    css = ['css/html5boilerplate.css']
+
+    less = ['css/style.less']
+
+    js = ['js/jquery-1.6.min.js',
+          'js/less-1.0.41.min.js',
+          'js/jquery.timeago.js',
+          'js/main.js']
+    
+    def __init__(self, app, request):
+        Handler.__init__(self, app, request)
+
+        # add application template_dir if specified
+        if app.template_dir:
+            self.template_dirs = self.template_dirs[:] + [app.template_dir]
+            
+        self.data = { 'request': request,
+                      'css': self.css,
+                      'item_name': self.app.item_name,
+                      'item_plural': self.app.item_plural,
+                      'less': self.less,
+                      'js':  self.js,
+                      'site_name': app.site_name,
+                      'title': self.__class__.__name__,
+                      'hasAbout': bool(app.about),
+                      'urlescape': quote,
+                      'link': self.link}
+
+    def find_template(self, name):
+        """find a template of a given name"""
+        # the application caches a dict of the templates if app.reload is False
+        if name in self.template_cache:
+            return self.template_cache[name]
+        
+        for d in self.template_dirs:
+            path = os.path.join(d, name)
+            if os.path.exists(path):
+                template = HTMLTemplate.from_filename(path)
+                if not self.app.reload:
+                    self.template_cache[name] = template
+                return template
+
+    def render(self, template, **data):
+        template = self.find_template(template)
+        if template is None:
+            raise Exception("I can't find your template")
+        return template.substitute(**data)
+
+    def Get(self):
+        # needs to have self.template set
+        if self.json:
+            return Response(content_type='application/json',
+                            body=json.dumps(self.get_json(), cls=JSONEncoder))
+        self.data['content'] = self.render(self.template, **self.data)
+        return Response(content_type='text/html',
+                        body=self.render('main.html', **self.data))
+
+class ProjectsView(TempitaHandler):
+    """abstract base class for views of projects"""
+
+    js = TempitaHandler.js[:]
+    js.extend(['js/jquery.tokeninput.js',
+               'js/jquery.jeditable.js',
+               'js/jquery.autolink.js',
+               'js/project.js'])
+               
+    less = TempitaHandler.less[:]
+    less.extend(['css/project.less'])
+
+    css = TempitaHandler.css[:]
+    css.extend(['css/token-input.css',
+                'css/token-input-facebook.css'])
+
+    def __init__(self, app, request):
+        """project views specific init"""
+        TempitaHandler.__init__(self, app, request)
+        self.data['fields'] = self.app.model.fields()
+        self.data['error'] = None
+        if not self.json:
+            self.data['format_date'] = self.format_date
+
+    def get_json(self):
+        """JSON to serialize if requested"""
+        return self.data['projects']
+
+    def sort(self, field):
+        reverse = False
+        if field.startswith('-'):
+            field = field[1:]
+            reverse = True
+        if field == 'name':
+            self.data['projects'].sort(key=lambda value: value[field].lower(), reverse=reverse)
+        else:
+            self.data['projects'].sort(key=lambda value: value[field], reverse=reverse)
+
+    def format_date(self, timestamp):
+        """return a string representation of a timestamp"""
+        format_string = '%Y-%m-%dT%H:%M:%SZ'
+        return datetime.utcfromtimestamp(timestamp).strftime(format_string)
+
+
+class QueryView(ProjectsView):
+    """general index view to query projects"""
+    
+    template = 'index.html'
+    methods = set(['GET'])
+
+    def __init__(self, app, request):
+        ProjectsView.__init__(self, app, request)
+
+        # pop non-query parameters;
+        # sort is popped first so that it does go in the query
+        sort_type = self.request.GET.pop('sort', None)
+        query = self.request.GET.mixed()
+        self.data['query'] = query
+        search = query.pop('q', None)
+        self.data['search'] = search
+
+        # query for tools
+        self.data['projects']= self.app.model.get(search, **query)
+
+        # order the results
+        self.data['sort_types'] = [('name', 'name'), ('-modified', 'last updated')]
+        if search:
+            self.data['sort_types'].insert(0, ('search', 'search rank'))
+        if sort_type is None:
+            if search:
+                sort_type = 'search'
+            else:
+                # default
+                sort_type = '-modified'
+        self.data['sort_type'] = sort_type
+        if sort_type != 'search':
+            # preserve search order results 
+            self.sort(sort_type)
+            
+        self.data['fields'] = self.app.model.fields()
+        self.data['title'] = self.app.site_name
+
+
+class ProjectView(ProjectsView):
+    """view of a particular project"""
+
+    template = 'index.html'
+    methods=set(['GET', 'POST'])
+
+    @classmethod
+    def match(cls, app, request):
+
+        # check the method
+        if request.method not in cls.methods:
+            return None
+
+        # the path should match a project
+        if not len(request.environ['path']) == 1:
+            return None
+
+        # get the project if it exists
+        projectname = request.environ['path'][0].replace('%2f', '/')  # double de-escape slashes, see top of file
+        try:
+            # if its utf-8, we should try to keep it utf-8
+            projectname = projectname.decode('utf-8')
+        except UnicodeDecodeError:
+            pass
+        project = app.model.project(projectname)
+        if not project:
+            return None
+
+        # check the constructor
+        try:
+            return cls(app, request, project)
+        except HandlerMatchException:
+            return None
+
+    def __init__(self, app, request, project):
+        ProjectsView.__init__(self, app, request)
+        self.data['fields'] = self.app.model.fields()
+        self.data['projects'] = [project]
+        self.data['title'] = project['name']
+
+    def get_json(self):
+        return self.data['projects'][0]
+
+    def Post(self):
+
+        # data
+        post_data = self.post_data()
+        project = self.data['projects'][0]
+
+        # insist that you have a name
+        if 'name' in post_data and not post_data['name'].strip():
+            self.data['title'] = 'Rename error'
+            self.data['error'] = 'Cannot give a project an empty name'
+            self.data['content'] = self.render(self.template, **self.data)
+            return Response(content_type='text/html',
+                            status=403,
+                            body=self.render('main.html', **self.data))
+
+        # don't allow overiding other projects with your fancy rename
+        if 'name' in post_data and post_data['name'] != project['name']:
+            if self.app.model.project(post_data['name']):
+                self.data['title'] = '%s -> %s: Rename error' % (project['name'], post_data['name'])
+                self.data['error'] = 'Cannot rename over existing project: <a href="%s">%s</a>' % (post_data['name'], post_data['name'] )
+                self.data['content'] = self.render(self.template, **self.data)
+                return Response(content_type='text/html',
+                                status=403,
+                                body=self.render('main.html', **self.data))
+
+        # XXX for compatability with jeditable:
+        id = post_data.pop('id', None)
+
+        action = post_data.pop('action', None)
+        old_name = project['name']
+        if action == 'delete':
+            for field in self.app.model.fields():
+                if field in post_data and field in project:
+                    values = post_data.pop(field)
+                    if isinstance(values, basestring):
+                        values = [values]
+                    for value in values:
+                        project[field].remove(value)
+                    if not project[field]:
+                        project.pop(field)
+        else:
+            for field in self.app.model.required:
+                if field in post_data:
+                    project[field] = post_data[field]
+            for field in self.app.model.fields():
+                if field in post_data:
+                    value = post_data[field]
+                    if isinstance(value, basestring):
+                        value = strsplit(value)
+                    if action == 'replace':
+                        # replace the field from the POST request
+                        project[field] = value
+                    else:
+                        # append the items....the default action
+                        project.setdefault(field, []).extend(value)
+
+        # rename handling
+        if 'name' in post_data and post_data['name'] != old_name:
+            self.app.model.delete(old_name)
+            self.app.model.update(project)
+            return self.redirect(quote(project['name']))
+
+        self.app.model.update(project)
+
+        # XXX for compatability with jeditable:
+        if id is not None:
+            return Response(content_type='text/plain',
+                            body=cgi.escape(project['description']))
+
+        # XXX should redirect instead
+        return self.Get()
+
+
+class FieldView(ProjectsView):
+    """view of projects sorted by a field"""
+
+    template = 'fields.html'
+    methods=set(['GET', 'POST'])
+    js = TempitaHandler.js[:] + ['js/field.js']
+
+    @classmethod
+    def match(cls, app, request):
+
+        # check the method
+        if request.method not in cls.methods:
+            return None
+
+        # the path should match a project
+        if len(request.environ['path']) != 1:
+            return None
+
+        # ensure the field exists
+        field = request.environ['path'][0]
+        if field not in app.model.fields():
+            return None
+
+        # check the constructor
+        try:
+            return cls(app, request, field)
+        except HandlerMatchException:
+            return None
+
+    def __init__(self, app, request, field):
+        ProjectsView.__init__(self, app, request)
+        projects = self.app.model.field_query(field)
+        if projects is None:
+            projects = {}
+        self.data['field'] = field
+        self.data['values'] = projects
+        self.data['title'] = app.item_plural + ' by %s' % field
+        if self.request.method == 'GET':
+            # get project descriptions for tooltips
+            descriptions = {}
+            project_set = set()
+            for values in projects.values():
+                project_set.update(values)
+            self.data['projects'] = dict([(name, self.app.model.project(name))
+                                          for name in project_set])
+
+    def Post(self):
+        field = self.data['field']
+        for key in self.request.POST.iterkeys():
+            value = self.request.POST[key]
+            self.app.model.rename_field_value(field, key, value)
+        
+        return self.redirect(field, anchor=value)
+        
+    def get_json(self):
+        return self.data['values']
+
+        
+class CreateProjectView(TempitaHandler):
+    """view to create a new project"""
+
+    template = 'new.html'
+    methods = set(['GET', 'POST'])
+    handler_path = ['new']
+    js = TempitaHandler.js[:]
+    js.extend(['js/jquery.tokeninput.js',
+               'js/queryString.js',
+               'js/new.js'])
+               
+    less = TempitaHandler.less[:]
+    less.extend(['css/new.less'])
+    
+    css = TempitaHandler.css[:]
+    css.extend(['css/token-input.css',
+                'css/token-input-facebook.css'])
+
+    def __init__(self, app, request):
+        TempitaHandler.__init__(self, app, request)
+        self.data['title'] = 'Add a ' + app.item_name
+        self.data['fields'] = self.app.model.fields()
+
+    def check_name(self, name):
+        """
+        checks a project name for validity
+        returns None on success or an error message if invalid
+        """
+        reserved = self.app.reserved.copy()
+        if name in reserved or name in self.app.model.fields(): # check application-level reserved URLS
+            return 'reserved'
+        if self.app.model.project(name): # check projects for conflict
+            return 'conflict'
+
+    def Post(self):
+
+        # get some data
+        required = self.app.model.required
+        post_data = self.post_data()
+
+        # ensure the form isn't over 24 hours old
+        day = 24*3600
+        form_date = post_data.pop('form-render-date', -day)
+        try:
+            form_date = float(form_date)
+        except ValueError:
+            form_date = -day
+        if abs(form_date - time()) > day:
+            # if more than a day old, don't honor the request
+            return Response(content_type='text/plain',
+                            status=400,
+                            body="Your form is over a day old or you don't have Javascript enabled")
+
+        # build up a project dict
+        project = dict([(i, post_data.get(i, '').strip())
+                        for i in required])
+
+        # check for errors
+        errors = {}
+        missing = set([i for i in required if not project[i]])
+        if missing: # missing required fields
+            errors['missing'] = missing
+        # TODO check for duplicate project name
+        # and other url namespace collisions
+        name_conflict = self.check_name(project['name'])
+        if name_conflict:
+            errors[name_conflict] = [project['name']]
+        if errors:
+            error_list = []
+            for key in errors:
+                # flatten the error dict into a list
+                error_list.extend([(key, i) for i in errors[key]])
+            return self.redirect(self.request.path_info.strip('/'), error_list)
+
+        # add fields to the project
+        for field in self.app.model.fields():
+            value = post_data.get(field, '').strip()
+            values = strsplit(value)
+            if not value:
+                continue
+            project[field] = values or value
+
+        self.app.model.update(project)
+        return self.redirect(quote(project['name']))
+
+
+class DeleteProjectHandler(Handler):
+
+    methods = set(['POST'])
+    handler_path = ['delete']
+
+    def Post(self):        
+        post_data = self.post_data()
+        project = post_data.get('project')
+        if project:
+            try:
+                self.app.model.delete(project)
+            except:
+                pass # XXX better than internal server error
+
+        # redirect to query view
+        return self.redirect('')
+
+
+class TagsView(TempitaHandler):
+    """view most popular tags"""
+    methods = set(['GET'])
+    handler_path = ['tags']
+    template = 'tags.html'
+
+    def __init__(self, app, request):
+        TempitaHandler.__init__(self, app, request)
+        self.data['fields'] = self.app.model.fields()
+        fields = self.request.GET.getall('field') or self.data['fields']
+        query = self.request.GET.get('q', '')
+        self.data['title'] = 'Tags'
+        field_tags = dict((i, {}) for i in fields)
+        omit = self.request.GET.getall('omit')
+        ommitted = dict([(field, set()) for field in fields])
+        for name in omit:
+            project = self.app.model.project(name)
+            if not project:
+                continue
+            for field in fields:
+                ommitted[field].update(project.get(field, []))
+            
+        for project in self.app.model.get():
+            if project in omit:
+                continue
+            # TODO: cache this for speed somehow
+            # possibly at the model level
+            for field in fields:
+                for value in project.get(field, []):
+                    if value in ommitted[field] or query not in value:
+                        continue
+                    count = field_tags[field].get(value, 0) + 1
+                    field_tags[field][value] = count
+        tags = []
+        for field in field_tags:
+            for value, count in field_tags[field].items():
+                tags.append({'field': field, 'value': value, 'count': count, 'id': value, 'name': value})
+        tags.sort(key=lambda x: x['count'], reverse=True)
+
+        self.data['tags'] = tags
+
+    def get_json(self):
+        return self.data['tags']
+
+
+class AboutView(TempitaHandler):
+    """the obligatory about page"""
+    methods = set(['GET'])
+    handler_path = ['about']
+    template = 'about.html'
+    less = TempitaHandler.less[:] + ['css/about.less']
+    def __init__(self, app, request):
+        TempitaHandler.__init__(self, app, request)
+        self.data['fields'] = self.app.model.fields()
+        self.data['title'] = 'about:' + self.app.site_name
+        self.data['about'] = self.app.about
+
+class NotFound(TempitaHandler):
+    def __init__(self, app, request):
+        TempitaHandler.__init__(self, app, request)
+        self.data['fields'] = self.app.model.fields()
+
+    def __call__(self):
+        self.data['content'] = '<h1 id="title">Not Found</h1>'
+        return Response(content_type='text/html',
+                        status=404,
+                        body=self.render('main.html', **self.data))