From 7004713412b51ec58cf01149ba736fadf769acaf Mon Sep 17 00:00:00 2001 From: Benjamin Renard Date: Sun, 12 Jan 2014 01:33:07 +0100 Subject: [PATCH] Initial commit --- .gitignore | 7 ++ Makefile | 10 ++ README | 24 ++++ development.ini | 53 +++++++++ mycoserver/__init__.py | 0 mycoserver/application.py | 57 +++++++++ mycoserver/configuration.py | 30 +++++ mycoserver/context.py | 24 ++++ mycoserver/controllers.py | 70 +++++++++++ mycoserver/conv.py | 23 ++++ mycoserver/db/__init__.py | 84 +++++++++++++ mycoserver/router.py | 50 ++++++++ mycoserver/templates/__init__.py | 44 +++++++ mycoserver/templates/helpers.py | 14 +++ mycoserver/templates/home.mako | 13 ++ mycoserver/templates/http-error.mako | 23 ++++ mycoserver/templates/site.mako | 36 ++++++ mycoserver/wsgi_helpers.py | 170 +++++++++++++++++++++++++++ setup.py | 40 +++++++ 19 files changed, 772 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README create mode 100644 development.ini create mode 100644 mycoserver/__init__.py create mode 100644 mycoserver/application.py create mode 100644 mycoserver/configuration.py create mode 100644 mycoserver/context.py create mode 100644 mycoserver/controllers.py create mode 100644 mycoserver/conv.py create mode 100644 mycoserver/db/__init__.py create mode 100644 mycoserver/router.py create mode 100644 mycoserver/templates/__init__.py create mode 100644 mycoserver/templates/helpers.py create mode 100644 mycoserver/templates/home.mako create mode 100644 mycoserver/templates/http-error.mako create mode 100644 mycoserver/templates/site.mako create mode 100644 mycoserver/wsgi_helpers.py create mode 100755 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdba8dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.pyc +*.pyo +*~ +.*.swp +/*.egg-info +/cache/* +/data/* diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a9c038e --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: clean flake8 + +all: clean flake8 + +clean: + find -name "*.pyc" | xargs rm -f + rm -rf cache/* + +flake8: + flake8 --ignore=E123,E128 --max-line-length=120 mycoserver diff --git a/README b/README new file mode 100644 index 0000000..af50a3b --- /dev/null +++ b/README @@ -0,0 +1,24 @@ +Install +======= + +Debian dependencies: + +$ aptitude install python python-mako python-markupsafe python-paste python-pastedeploy python-pastescript \ + python-weberror python-webhelpers python-webob + +Non-Debian dependencies: + +$ git clone git://gitorious.org/biryani/biryani.git biryani1 +$ cd biryani1 +$ git checkout biryani1 +$ python setup.py develop --no-deps --user + +Install mycoserver Python egg (from mycoserver root directory): + +$ python setup.py develop --no-deps --user + + +Start server +============ + +$ paster serve --reload development.ini diff --git a/development.ini b/development.ini new file mode 100644 index 0000000..b61b9e4 --- /dev/null +++ b/development.ini @@ -0,0 +1,53 @@ +# EesyVPN Web - Development environment configuration +# +# The %(here)s variable will be replaced with the parent directory of this file. + +[DEFAULT] +debug = true +# Uncomment and replace with the address which should receive any error reports +#email_to = you@yourdomain.com +smtp_server = localhost +from_address = myco-server@localhost +dbpass = myP@ssw0rd + +[server:main] +use = egg:Paste#http +host = 0.0.0.0 +port = 8765 + +[app:main] +use = egg:MyCoServer + + +# Logging configuration +[loggers] +keys = root, mycoserver, mycoserver_router + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = DEBUG +handlers = console + +[logger_mycoserver] +level = DEBUG +handlers = +qualname = mycoserver + +[logger_mycoserver_router] +level = DEBUG +handlers = +qualname = mycoserver.router + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +formatter = generic + +[formatter_generic] +format = %(asctime)s,%(msecs)03d %(levelname)-5.5s [%(name)s:%(funcName)s line %(lineno)d] %(message)s +datefmt = %H:%M:%S diff --git a/mycoserver/__init__.py b/mycoserver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mycoserver/application.py b/mycoserver/application.py new file mode 100644 index 0000000..a3b25fd --- /dev/null +++ b/mycoserver/application.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- + + +"""Middleware initialization""" + + +import logging.config +import os + +from paste.cascade import Cascade +from paste.urlparser import StaticURLParser +from weberror.errormiddleware import ErrorMiddleware + +from . import configuration, context, controllers, templates + +import db + + +def make_app(global_conf, **app_conf): + """Create a WSGI application and return it + + ``global_conf`` + The inherited configuration for this application. Normally from + the [DEFAULT] section of the Paste ini file. + + ``app_conf`` + The application's local configuration. Normally specified in + the [app:] section of the Paste ini file (where + defaults to main). + """ + logging.config.fileConfig(global_conf['__file__']) + app_ctx = context.Context() + app_ctx.conf = configuration.load_configuration(global_conf, app_conf) + app_ctx.templates = templates.load_templates(app_ctx) + app_ctx.db = db.DB( + app_ctx.conf.get('dbhost','localhost'), + app_ctx.conf.get('dbuser','myco'), + app_ctx.conf.get('dbpass','password'), + app_ctx.conf.get('dbname','myco'), + ) + if not app_ctx.db.connect(): + logging.error('Failed to connect DB') + app = controllers.make_router() + app = context.make_add_context_to_request(app, app_ctx) + if not app_ctx.conf['debug']: + app = ErrorMiddleware( + app, + error_email=app_ctx.conf['email_to'], + error_log=app_ctx.conf.get('error_log', None), + error_message=app_ctx.conf.get('error_message', 'An internal server error occurred'), + error_subject_prefix=app_ctx.conf.get('error_subject_prefix', 'Web application error: '), + from_address=app_ctx.conf['from_address'], + smtp_server=app_ctx.conf.get('smtp_server', 'localhost'), + ) + app = Cascade([StaticURLParser(os.path.join(app_ctx.conf['app_dir'], 'static')), app]) + app.ctx = app_ctx + return app diff --git a/mycoserver/configuration.py b/mycoserver/configuration.py new file mode 100644 index 0000000..96a1bb2 --- /dev/null +++ b/mycoserver/configuration.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- + + +"""Paste INI configuration""" + + +import os + +from biryani1 import strings +from biryani1.baseconv import (check, default, guess_bool, pipe, struct) + + +def load_configuration(global_conf, app_conf): + """Build the application configuration dict.""" + app_dir = os.path.dirname(os.path.abspath(__file__)) + conf = {} + conf.update(strings.deep_decode(global_conf)) + conf.update(strings.deep_decode(app_conf)) + conf.update(check(struct( + { + 'app_conf': default(app_conf), + 'app_dir': default(app_dir), + 'cache_dir': default(os.path.join(os.path.dirname(app_dir), 'cache')), + 'debug': pipe(guess_bool, default(False)), + 'global_conf': default(global_conf), + }, + default='drop', + drop_none_values=False, + ))(conf)) + return conf diff --git a/mycoserver/context.py b/mycoserver/context.py new file mode 100644 index 0000000..c11e379 --- /dev/null +++ b/mycoserver/context.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- + + +"""Context loaded and saved in WSGI requests""" + + +from webob.dec import wsgify + + +def make_add_context_to_request(app, app_ctx): + """Return a WSGI middleware that adds context to requests.""" + @wsgify + def add_context_to_request(req): + req.ctx = app_ctx + req.ctx.req = req + return req.get_response(app) + return add_context_to_request + + +class Context(object): + _ = lambda self, message: message + conf = None + templates = None + db = None diff --git a/mycoserver/controllers.py b/mycoserver/controllers.py new file mode 100644 index 0000000..b1e5b8c --- /dev/null +++ b/mycoserver/controllers.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- + + +import logging + +from webob.dec import wsgify + +from . import conv, router, templates, wsgi_helpers + +import json + +log = logging.getLogger(__name__) + +@wsgify +def home(req): + return templates.render(req.ctx, '/home.mako', data={}) + +@wsgify +def login(req): + params = req.params + log.debug(u'params = {}'.format(params)) + inputs = { + 'email': params.get('email'), + 'password': params.get('password'), + } + log.debug(u'inputs = {}'.format(inputs)) + data, errors = conv.inputs_to_login_data(inputs) + if errors is not None: + return wsgi_helpers.bad_request(req.ctx, comment=errors) + + log.debug(u'data = {}'.format(data)) + + login_data=req.ctx.db.login(data['email'],data['password']) + return wsgi_helpers.respond_json(req.ctx,login_data,headers=[('Access-Control-Allow-Origin','*')]) + +@wsgify +def sync(req): + params = req.params + log.debug(u'params = {}'.format(params)) + inputs = { + 'email': params.get('email'), + 'password': params.get('password'), + 'groups': params.get('groups') + } + log.debug(u'inputs = {}'.format(inputs)) + data, errors = conv.inputs_to_sync_data(inputs) + if errors is not None or data['groups'] is None: + return wsgi_helpers.bad_request(req.ctx, comment=errors) + + data['groups']=json.loads(data['groups']) + + log.debug(u'data = {}'.format(data)) + + login_data=req.ctx.db.login(data['email'],data['password']) + if 'email' in login_data: + ret=req.ctx.db.sync_group(data['email'],data['groups']) + return wsgi_helpers.respond_json(req.ctx,ret,headers=[('Access-Control-Allow-Origin','*')]) + else: + return wsgi_helpers.respond_json( + req.ctx, + login_data, + headers=[('Access-Control-Allow-Origin','*')] + ) + +def make_router(): + return router.make_router( + ('GET', '^/$', home), + ('GET', '^/login$', login), + ('GET', '^/sync$', sync), + ) diff --git a/mycoserver/conv.py b/mycoserver/conv.py new file mode 100644 index 0000000..8518b6f --- /dev/null +++ b/mycoserver/conv.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + + +from biryani1.baseconv import cleanup_line, empty_to_none, not_none, pipe, struct + +inputs_to_login_data = struct( + { + 'email': pipe(cleanup_line, empty_to_none), + 'password': pipe(cleanup_line, empty_to_none), + }, + default='drop', + drop_none_values=False, + ) + +inputs_to_sync_data = struct( + { + 'email': pipe(cleanup_line, empty_to_none), + 'password': pipe(cleanup_line, empty_to_none), + 'groups': pipe(empty_to_none), + }, + default='drop', + drop_none_values=False, + ) diff --git a/mycoserver/db/__init__.py b/mycoserver/db/__init__.py new file mode 100644 index 0000000..9017456 --- /dev/null +++ b/mycoserver/db/__init__.py @@ -0,0 +1,84 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +import json +import logging +log = logging.getLogger(__name__) +import MySQLdb + +class DB(object): + + def __init__(self,host,user,pwd,db): + self.host = host + self.user = user + self.pwd = pwd + self.db = db + self.con = 0 + + def connect(self): + if self.con == 0: + try: + con = MySQLdb.connect(self.host,self.user,self.pwd,self.db) + self.con = con + return True + except Exception, e: + log.fatal('Error connecting to database : %s' % e) + return + + def do_sql(self,sql): + try: + c=self.con.cursor() + c.execute(sql) + self.con.commit() + return c + except Exception,e: + log.error('Error executing request %s : %s' % (sql,e)) + return False + + def select(self,sql): + ret=self.do_sql(sql) + if ret!=False: + return ret.fetchall() + return ret + + def login(self,email,password): + ret=self.select("SELECT email,name,password FROM users WHERE email='%s' AND password='%s'" % (email,password)) + log.debug(ret) + if ret: + if len(ret)==1: + return { + 'email': ret[0][0], + 'name': ret[0][1] + } + elif len(ret)>=1: + log.warning('Duplicate user %s in database' % email) + elif ret==(): + return { 'loginerror': 'Utilisateur inconnu' } + return { 'loginerror': 'Erreur inconnu' } + + def sync_group(self,email,groups): + db_groups=self.get_group(email) + json_group=json.dumps(groups) + if db_groups!=False: + if db_groups=={}: + if groups=={}: + return {'groups': {}} + else: + if self.do_sql("INSERT INTO groups (email,groups) VALUES ('%s','%s')" % (email,json_group)): + return {'groups': groups} + elif groups=={}: + return {'groups': db_groups} + else: + if self.do_sql("UPDATE groups SET groups='%s' WHERE email='%s'" % (json_group,email)): + return {'groups': groups} + return {'syncerror': 'Erreur inconnu'} + + def get_group(self,email): + ret=self.select("SELECT groups FROM groups WHERE email='%s'" % email) + if ret!=False: + if len(ret)==1: + return json.loads(ret[0][0]) + else: + return {} + else: + return False diff --git a/mycoserver/router.py b/mycoserver/router.py new file mode 100644 index 0000000..c9272ca --- /dev/null +++ b/mycoserver/router.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- + + +"""Helpers for URLs""" + + +import logging +import re + +from webob.dec import wsgify + +from . import wsgi_helpers + + +log = logging.getLogger(__name__) + + +def make_router(*routings): + """Return a WSGI application that dispatches requests to controllers.""" + routes = [] + for routing in routings: + methods, regex, app = routing[:3] + if isinstance(methods, basestring): + methods = (methods,) + vars = routing[3] if len(routing) >= 4 else {} + routes.append((methods, re.compile(regex), app, vars)) + + @wsgify + def router(req): + """Dispatch request to controllers.""" + split_path_info = req.path_info.split('/') + assert not split_path_info[0], split_path_info + for methods, regex, app, vars in routes: + if methods is None or req.method in methods: + match = regex.match(req.path_info) + if match is not None: + log.debug(u'URL path = {path} matched controller {controller}'.format( + controller=app, path=req.path_info)) + if getattr(req, 'urlvars', None) is None: + req.urlvars = {} + req.urlvars.update(dict( + (name, value.decode('utf-8') if value is not None else None) + for name, value in match.groupdict().iteritems() + )) + req.urlvars.update(vars) + req.script_name += req.path_info[:match.end()] + req.path_info = req.path_info[match.end():] + return req.get_response(app) + return wsgi_helpers.not_found(req.ctx) + return router diff --git a/mycoserver/templates/__init__.py b/mycoserver/templates/__init__.py new file mode 100644 index 0000000..61fd172 --- /dev/null +++ b/mycoserver/templates/__init__.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + + +"""Mako templates rendering""" + + +import json +import mako.lookup +import os + +from . import helpers + + +js = lambda x: json.dumps(x, encoding='utf-8', ensure_ascii=False) + + +def load_templates(ctx): + # Create the Mako TemplateLookup, with the default auto-escaping. + return mako.lookup.TemplateLookup( + default_filters=['h'], + directories=[os.path.join(ctx.conf['app_dir'], 'templates')], + input_encoding='utf-8', + module_directory=os.path.join(ctx.conf['cache_dir'], 'templates'), + ) + + +def render(ctx, template_path, **kw): + return ctx.templates.get_template(template_path).render_unicode( + ctx=ctx, + helpers=helpers, + js=js, + N_=lambda message: message, + req=ctx.req, + **kw).strip() + + +def render_def(ctx, template_path, def_name, **kw): + return ctx.templates.get_template(template_path).get_def(def_name).render_unicode( + _=ctx.translator.ugettext, + ctx=ctx, + js=js, + N_=lambda message: message, + req=ctx.req, + **kw).strip() diff --git a/mycoserver/templates/helpers.py b/mycoserver/templates/helpers.py new file mode 100644 index 0000000..a02bcd0 --- /dev/null +++ b/mycoserver/templates/helpers.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- + + +import os +import random +import datetime + + +def random_sequence(length): + return [random.random() for idx in xrange(0, length)] + + +def relative_path(ctx, abs_path): + return os.path.relpath(abs_path, ctx.req.path) diff --git a/mycoserver/templates/home.mako b/mycoserver/templates/home.mako new file mode 100644 index 0000000..bc3c36d --- /dev/null +++ b/mycoserver/templates/home.mako @@ -0,0 +1,13 @@ +## -*- coding: utf-8 -*- + + +<%inherit file="/site.mako"/> + +<%block name="title_content">MyCo + +<%block name="body_content"> +
+

MyCo Gérer vos déponses communes

+

Application mobile de gestion de vos dépenses communes.

+
+ diff --git a/mycoserver/templates/http-error.mako b/mycoserver/templates/http-error.mako new file mode 100644 index 0000000..4742a58 --- /dev/null +++ b/mycoserver/templates/http-error.mako @@ -0,0 +1,23 @@ +## -*- coding: utf-8 -*- + + +<%inherit file="/site.mako"/> + + +<%block name="body_content"> +
+

Error « ${title} »

+

${explanation}

+% if comment: +

${comment}

+% endif +% if message: +

${message}

+% endif +
+ + + +<%block name="title_content"> +${title} - ${parent.title_content()} + diff --git a/mycoserver/templates/site.mako b/mycoserver/templates/site.mako new file mode 100644 index 0000000..cdc65c7 --- /dev/null +++ b/mycoserver/templates/site.mako @@ -0,0 +1,36 @@ + + + + + + <%block name="title_content">MyCo</%block> + + + + + + + + + + +
+ +<%block name="body_content"/> + +
+ + + + + + diff --git a/mycoserver/wsgi_helpers.py b/mycoserver/wsgi_helpers.py new file mode 100644 index 0000000..906965f --- /dev/null +++ b/mycoserver/wsgi_helpers.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- + + +import collections +import json + +from markupsafe import Markup +from webhelpers.html import tags +import webob.dec +import webob.exc + +from . import templates + + +N_ = lambda message: message + + +errors_explanation = { + 400: N_("Request is faulty"), + 401: N_("Access is restricted to authorized persons."), + 403: N_("Access is forbidden."), + 404: N_("The requested page was not found."), + } +errors_message = { + 401: N_("You must login to access this page."), + } +errors_title = { + 400: N_("Unable to Access"), + 401: N_("Access Denied"), + 403: N_("Access Denied"), + 404: N_("Unable to Access"), + } + + +def bad_request(ctx, **kw): + return error(ctx, 400, **kw) + + +def discard_empty_items(data): + if isinstance(data, collections.Mapping): + # Use type(data) to keep OrderedDicts. + data = type(data)( + (name, discard_empty_items(value)) + for name, value in data.iteritems() + if value is not None + ) + return data + + +def error(ctx, code, **kw): + response = webob.exc.status_map[code](headers=kw.pop('headers', None)) + if code != 204: # No content + body = kw.pop('body', None) + if body is None: + template_path = kw.pop('template_path', '/http-error.mako') + explanation = kw.pop('explanation', None) + if explanation is None: + explanation = errors_explanation.get(code) + explanation = ctx._(explanation) if explanation is not None else response.explanation + message = kw.pop('message', None) + if message is None: + message = errors_message.get(code) + if message is not None: + message = ctx._(message) + comment = kw.pop('comment', None) + if isinstance(comment, dict): + comment = tags.ul(u'{0} : {1}'.format(key, value) for key, value in comment.iteritems()) + elif isinstance(comment, list): + comment = tags.ul(comment) + title = kw.pop('title', None) + if title is None: + title = errors_title.get(code) + title = ctx._(title) if title is not None else response.status + body = templates.render(ctx, template_path, + comment=comment, + explanation=explanation, + message=message, + response=response, + title=title, + **kw) + response.body = body.encode('utf-8') if isinstance(body, unicode) else body + return response + + +def forbidden(ctx, **kw): + return error(ctx, 403, **kw) + + +def method_not_allowed(ctx, **kw): + return error(ctx, 405, **kw) + + +def no_content(ctx, headers=None): + return error(ctx, 204, headers=headers) + + +def not_found(ctx, **kw): + return error(ctx, 404, **kw) + + +def redirect(ctx, code=302, location=None, **kw): + assert location is not None + location_str = location.encode('utf-8') if isinstance(location, unicode) else location + response = webob.exc.status_map[code](headers=kw.pop('headers', None), location=location_str) + body = kw.pop('body', None) + if body is None: + template_path = kw.pop('template_path', '/http-error.mako') + explanation = kw.pop('explanation', None) + if explanation is None: + explanation = Markup(u'{0} {1}.').format(ctx._(u"You'll be redirected to page"), location) + message = kw.pop('message', None) + if message is None: + message = errors_message.get(code) + if message is not None: + message = ctx._(message) + title = kw.pop('title', None) + if title is None: + title = ctx._("Redirection in progress...") + body = templates.render(ctx, template_path, + comment=kw.pop('comment', None), + explanation=explanation, + message=message, + response=response, + title=title, + **kw) + response.body = body.encode('utf-8') if isinstance(body, unicode) else body + return response + + +def respond_json(ctx, data, code=None, headers=None, jsonp=None): + """Return a JSON response. + + This function is optimized for JSON following + `Google JSON Style Guide `_, but will handle + any JSON except for HTTP errors. + """ + if isinstance(data, collections.Mapping): + # Remove null properties as recommended by Google JSON Style Guide. + data = discard_empty_items(data) + error = data.get('error') + else: + error = None + if headers is None: + headers = [] + if jsonp: + headers.append(('Content-Type', 'application/javascript; charset=utf-8')) + else: + headers.append(('Content-Type', 'application/json; charset=utf-8')) + if error: + code = code or error['code'] + assert isinstance(code, int) + response = webob.exc.status_map[code](headers=headers) + if error.get('code') is None: + error['code'] = code + if error.get('message') is None: + error['message'] = response.title + else: + response = ctx.req.response + if code is not None: + response.status = code + response.headers.update(headers) + text = unicode(json.dumps(data, encoding='utf-8', ensure_ascii=False, indent=2, sort_keys=True)) + if jsonp: + text = u'{0}({1})'.format(jsonp, text) + response.text = text + return response + + +def unauthorized(ctx, **kw): + return error(ctx, 401, **kw) diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..8ac663a --- /dev/null +++ b/setup.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +"""MyCO Server web application.""" + + +from setuptools import setup, find_packages + + +doc_lines = __doc__.split('\n') + + +setup( + author=u'Benjamin Renard', + author_email=u'brenard@zionetrix.net', + description=doc_lines[0], + entry_points=""" + [paste.app_factory] + main = mycoserver.application:make_app + """, + include_package_data=True, + install_requires=[ + 'Biryani1 >= 0.9dev', + 'MarkupSafe >= 0.15', + 'WebError >= 0.10', + 'WebHelpers >= 1.3', + 'WebOb >= 1.1', + ], +# keywords='', +# license=u'http://www.fsf.org/licensing/licenses/agpl-3.0.html', + long_description='\n'.join(doc_lines[2:]), + name=u'MyCoServer', + packages=find_packages(), + paster_plugins=['PasteScript'], + setup_requires=['PasteScript >= 1.6.3'], +# url=u'', + version='0.1', + zip_safe=False, + )