From 30673a1f682d7756f8793608b2d899f6387cbaee Mon Sep 17 00:00:00 2001 From: Davide Alberani Date: Mon, 2 Jan 2017 21:52:33 +0100 Subject: [PATCH] initial commit --- README.md | 18 + config/dev.env.js | 6 + config/index.js | 32 ++ config/prod.env.js | 3 + ibt2.py | 705 +++++++++++++++++++++++++++++++++++++++ index.html | 12 + monco.py | 285 ++++++++++++++++ package.json | 59 ++++ src/App.vue | 133 ++++++++ src/assets/logo.png | Bin 0 -> 6849 bytes src/components/Hello.vue | 53 +++ src/main.js | 22 ++ static/.gitkeep | 0 tests/ibt2_tests.py | 136 ++++++++ tests/monco.py | 1 + tests/monco.pyc | Bin 0 -> 10549 bytes utils.py | 61 ++++ 17 files changed, 1526 insertions(+) create mode 100644 README.md create mode 100644 config/dev.env.js create mode 100644 config/index.js create mode 100644 config/prod.env.js create mode 100755 ibt2.py create mode 100644 index.html create mode 100644 monco.py create mode 100644 package.json create mode 100644 src/App.vue create mode 100644 src/assets/logo.png create mode 100644 src/components/Hello.vue create mode 100644 src/main.js create mode 100644 static/.gitkeep create mode 100755 tests/ibt2_tests.py create mode 120000 tests/monco.py create mode 100644 tests/monco.pyc create mode 100644 utils.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..624ecd4 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# ibt2 + +> I'll be there, 2 + +## Build Setup + +``` bash +# install dependencies +npm install + +# serve with hot reload at localhost:8080 +npm run dev + +# build for production with minification +npm run build +``` + +For detailed explanation on how things work, checkout the [guide](http://vuejs-templates.github.io/webpack/) and [docs for vue-loader](http://vuejs.github.io/vue-loader). diff --git a/config/dev.env.js b/config/dev.env.js new file mode 100644 index 0000000..efead7c --- /dev/null +++ b/config/dev.env.js @@ -0,0 +1,6 @@ +var merge = require('webpack-merge') +var prodEnv = require('./prod.env') + +module.exports = merge(prodEnv, { + NODE_ENV: '"development"' +}) diff --git a/config/index.js b/config/index.js new file mode 100644 index 0000000..207dfbd --- /dev/null +++ b/config/index.js @@ -0,0 +1,32 @@ +// see http://vuejs-templates.github.io/webpack for documentation. +var path = require('path') + +module.exports = { + build: { + env: require('./prod.env'), + index: path.resolve(__dirname, '../dist/index.html'), + assetsRoot: path.resolve(__dirname, '../dist'), + assetsSubDirectory: 'static', + assetsPublicPath: '/', + productionSourceMap: true, + // Gzip off by default as many popular static hosts such as + // Surge or Netlify already gzip all static assets for you. + // Before setting to `true`, make sure to: + // npm install --save-dev compression-webpack-plugin + productionGzip: false, + productionGzipExtensions: ['js', 'css'] + }, + dev: { + env: require('./dev.env'), + port: 8080, + assetsSubDirectory: 'static', + assetsPublicPath: '/', + proxyTable: {}, + // CSS Sourcemaps off by default because relative paths are "buggy" + // with this option, according to the CSS-Loader README + // (https://github.com/webpack/css-loader#sourcemaps) + // In our experience, they generally work as expected, + // just be aware of this issue when enabling this option. + cssSourceMap: false + } +} diff --git a/config/prod.env.js b/config/prod.env.js new file mode 100644 index 0000000..773d263 --- /dev/null +++ b/config/prod.env.js @@ -0,0 +1,3 @@ +module.exports = { + NODE_ENV: '"production"' +} diff --git a/ibt2.py b/ibt2.py new file mode 100755 index 0000000..ce6f688 --- /dev/null +++ b/ibt2.py @@ -0,0 +1,705 @@ +#!/usr/bin/env python +"""I'll Be There 2 (ibt2) - an oversimplified attendees registration system. + +Copyright 2016 Davide Alberani + RaspiBO + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import os +import re +import string +import logging +import datetime +from operator import itemgetter +import itertools + +import tornado.httpserver +import tornado.ioloop +import tornado.options +from tornado.options import define, options +import tornado.web +from tornado import gen, escape + +import utils +import monco + +ENCODING = 'utf-8' +PROCESS_TIMEOUT = 60 + +API_VERSION = '1.0' + +re_env_key = re.compile('[^A-Z_]+') +re_slashes = re.compile(r'//+') + + +class BaseException(Exception): + """Base class for ibt2 custom exceptions. + + :param message: text message + :type message: str + :param status: numeric http status code + :type status: int""" + def __init__(self, message, status=400): + super(BaseException, self).__init__(message) + self.message = message + self.status = status + + +class InputException(BaseException): + """Exception raised by errors in input handling.""" + pass + + +class BaseHandler(tornado.web.RequestHandler): + """Base class for request handlers.""" + permissions = { + 'day|read': True, + 'day:groups|read': True, + 'day:groups|create': True, + 'day:groups|update': True, + 'day:groups-all|read': True, + 'day:groups-all|create': True, + 'days|read': True, + 'days|create': True, + 'users|create': True + } + + # Cache currently connected users. + _users_cache = {} + + # A property to access the first value of each argument. + arguments = property(lambda self: dict([(k, v[0]) + for k, v in self.request.arguments.iteritems()])) + + _bool_convert = { + '0': False, + 'n': False, + 'f': False, + 'no': False, + 'off': False, + 'false': False, + '1': True, + 'y': True, + 't': True, + 'on': True, + 'yes': True, + 'true': True + } + + _re_split_salt = re.compile(r'\$(?P.+)\$(?P.+)') + + def write_error(self, status_code, **kwargs): + """Default error handler.""" + if isinstance(kwargs.get('exc_info', (None, None))[1], BaseException): + exc = kwargs['exc_info'][1] + status_code = exc.status + message = exc.message + else: + message = 'internal error' + self.build_error(message, status=status_code) + + def is_api(self): + """Return True if the path is from an API call.""" + return self.request.path.startswith('/v%s' % API_VERSION) + + def tobool(self, obj): + """Convert some textual values to boolean.""" + if isinstance(obj, (list, tuple)): + obj = obj[0] + if isinstance(obj, (str, unicode)): + obj = obj.lower() + return self._bool_convert.get(obj, obj) + + def arguments_tobool(self): + """Return a dictionary of arguments, converted to booleans where possible.""" + return dict([(k, self.tobool(v)) for k, v in self.arguments.iteritems()]) + + def initialize(self, **kwargs): + """Add every passed (key, value) as attributes of the instance.""" + for key, value in kwargs.iteritems(): + setattr(self, key, value) + + @property + def current_user(self): + """Retrieve current user name from the secure cookie.""" + return self.get_secure_cookie("user") + + @property + def current_user_info(self): + """Information about the current user, including their permissions.""" + current_user = self.current_user + if current_user in self._users_cache: + return self._users_cache[current_user] + permissions = set([k for (k, v) in self.permissions.iteritems() if v is True]) + user_info = {'permissions': permissions} + if current_user: + user_info['username'] = current_user + res = self.db.query('users', {'username': current_user}) + if res: + user = res[0] + user_info = user + permissions.update(set(user.get('permissions') or [])) + user_info['permissions'] = permissions + self._users_cache[current_user] = user_info + return user_info + + def has_permission(self, permission): + """Check permissions of the current user. + + :param permission: the permission to check + :type permission: str + + :returns: True if the user is allowed to perform the action or False + :rtype: bool + """ + user_info = self.current_user_info or {} + user_permissions = user_info.get('permissions') or [] + global_permission = '%s|all' % permission.split('|')[0] + if 'admin|all' in user_permissions or global_permission in user_permissions or permission in user_permissions: + return True + collection_permission = self.permissions.get(permission) + if isinstance(collection_permission, bool): + return collection_permission + if callable(collection_permission): + return collection_permission(permission) + return False + + def user_authorized(self, username, password): + """Check if a combination of username/password is valid. + + :param username: username or email + :type username: str + :param password: password + :type password: str + + :returns: tuple like (bool_user_is_authorized, dict_user_info) + :rtype: dict""" + query = [{'username': username}, {'email': username}] + res = self.db.query('users', query) + if not res: + return (False, {}) + user = res[0] + db_password = user.get('password') or '' + if not db_password: + return (False, {}) + match = self._re_split_salt.match(db_password) + if not match: + return (False, {}) + salt = match.group('salt') + if utils.hash_password(password, salt=salt) == db_password: + return (True, user) + return (False, {}) + + def build_error(self, message='', status=400): + """Build and write an error message. + + :param message: textual message + :type message: str + :param status: HTTP status code + :type status: int + """ + self.set_status(status) + self.write({'error': True, 'message': message}) + + def logout(self): + """Remove the secure cookie used fro authentication.""" + if self.current_user in self._users_cache: + del self._users_cache[self.current_user] + self.clear_cookie("user") + + +class RootHandler(BaseHandler): + """Handler for the / path.""" + app_path = os.path.join(os.path.dirname(__file__), "dist") + + @gen.coroutine + def get(self, *args, **kwargs): + # serve the ./app/index.html file + with open(self.app_path + "/index.html", 'r') as fd: + self.write(fd.read()) + + +class CollectionHandler(BaseHandler): + """Base class for handlers that need to interact with the database backend. + + Introduce basic CRUD operations.""" + # set of documents we're managing (a collection in MongoDB or a table in a SQL database) + document = None + collection = None + + # set of documents used to store incremental sequences + counters_collection = 'counters' + + _id_chars = string.ascii_lowercase + string.digits + + def _filter_results(self, results, params): + """Filter a list using keys and values from a dictionary. + + :param results: the list to be filtered + :type results: list + :param params: a dictionary of items that must all be present in an original list item to be included in the return + :type params: dict + + :returns: list of items that have all the keys with the same values as params + :rtype: list""" + if not params: + return results + params = monco.convert(params) + filtered = [] + for result in results: + add = True + for key, value in params.iteritems(): + if key not in result or result[key] != value: + add = False + break + if add: + filtered.append(result) + return filtered + + def _clean_dict(self, data): + """Filter a dictionary (in place) to remove unwanted keywords in db queries. + + :param data: dictionary to clean + :type data: dict""" + if isinstance(data, dict): + for key in data.keys(): + if isinstance(key, (str, unicode)) and key.startswith('$'): + del data[key] + return data + + def apply_filter(self, data, filter_name): + """Apply a filter to the data. + + :param data: the data to filter + :returns: the modified (possibly also in place) data + """ + filter_method = getattr(self, 'filter_%s' % filter_name, None) + if filter_method is not None: + data = filter_method(data) + return data + + @gen.coroutine + def get(self, id_=None, resource=None, resource_id=None, acl=True, **kwargs): + if resource: + # Handle access to sub-resources. + permission = '%s:%s%s|read' % (self.document, resource, '-all' if resource_id is None else '') + if acl and not self.has_permission(permission): + return self.build_error(status=401, message='insufficient permissions: %s' % permission) + handler = getattr(self, 'handle_get_%s' % resource, None) + if callable(handler): + output = handler(id_, resource_id, **kwargs) or {} + output = self.apply_filter(output, 'get_%s' % resource) + self.write(output) + return + return self.build_error(status=404, message='unable to access resource: %s' % resource) + if id_ is not None: + # read a single document + permission = '%s|read' % self.document + if acl and not self.has_permission(permission): + return self.build_error(status=401, message='insufficient permissions: %s' % permission) + handler = getattr(self, 'handle_get', None) + if callable(handler): + output = handler(id_, **kwargs) or {} + else: + output = self.db.get(self.collection, id_) + output = self.apply_filter(output, 'get') + self.write(output) + else: + # return an object containing the list of all objects in the collection. + # Please, never return JSON lists that are not encapsulated into an object, + # to avoid XSS vulnerabilities. + permission = '%s|read' % self.collection + if acl and not self.has_permission(permission): + return self.build_error(status=401, message='insufficient permissions: %s' % permission) + output = {self.collection: self.db.query(self.collection, self.arguments)} + output = self.apply_filter(output, 'get_all') + self.write(output) + + @gen.coroutine + def post(self, id_=None, resource=None, resource_id=None, **kwargs): + data = escape.json_decode(self.request.body or '{}') + self._clean_dict(data) + method = self.request.method.lower() + crud_method = 'create' if method == 'post' else 'update' + now = datetime.datetime.now() + user_info = self.current_user_info + user_id = user_info.get('_id') + if crud_method == 'create': + data['created_by'] = user_id + data['created_at'] = now + data['updated_by'] = user_id + data['updated_at'] = now + if resource: + permission = '%s:%s%s|%s' % (self.document, resource, '-all' if resource_id is None else '', crud_method) + if not self.has_permission(permission): + return self.build_error(status=401, message='insufficient permissions: %s' % permission) + # Handle access to sub-resources. + handler = getattr(self, 'handle_%s_%s' % (method, resource), None) + if handler and callable(handler): + data = self.apply_filter(data, 'input_%s_%s' % (method, resource)) + output = handler(id_, resource_id, data, **kwargs) + output = self.apply_filter(output, 'get_%s' % resource) + self.write(output) + return + return self.build_error(status=404, message='unable to access resource: %s' % resource) + if id_ is not None: + permission = '%s|%s' % (self.document, crud_method) + if not self.has_permission(permission): + return self.build_error(status=401, message='insufficient permissions: %s' % permission) + data = self.apply_filter(data, 'input_%s' % method) + merged, newData = self.db.update(self.collection, id_, data) + newData = self.apply_filter(newData, method) + else: + permission = '%s|%s' % (self.collection, crud_method) + if not self.has_permission(permission): + return self.build_error(status=401, message='insufficient permissions: %s' % permission) + data = self.apply_filter(data, 'input_%s_all' % method) + newData = self.db.add(self.collection, data) + newData = self.apply_filter(newData, '%s_all' % method) + self.write(newData) + + # PUT (update an existing document) is handled by the POST (create a new document) method; + # in subclasses you can always separate sub-resources handlers like handle_post_tickets and handle_put_tickets + put = post + + @gen.coroutine + def delete(self, id_=None, resource=None, resource_id=None, **kwargs): + if resource: + # Handle access to sub-resources. + permission = '%s:%s%s|delete' % (self.document, resource, '-all' if resource_id is None else '') + if not self.has_permission(permission): + return self.build_error(status=401, message='insufficient permissions: %s' % permission) + method = getattr(self, 'handle_delete_%s' % resource, None) + if method and callable(method): + output = method(id_, resource_id, **kwargs) + self.write(output) + return + return self.build_error(status=404, message='unable to access resource: %s' % resource) + if id_ is not None: + permission = '%s|delete' % self.document + if not self.has_permission(permission): + return self.build_error(status=401, message='insufficient permissions: %s' % permission) + howMany = self.db.delete(self.collection, id_) + else: + self.write({'success': False}) + self.write({'success': True}) + + +class AttendeesHandler(CollectionHandler): + document = 'attendee' + collection = 'attendees' + + @gen.coroutine + def get(self, id_=None, **kwargs): + if id_: + output = self.db.getOne(self.collection, {'_id': id_}) + else: + output = {self.collection: self.db.query(self.collection, self.arguments)} + self.write(output) + + @gen.coroutine + def post(self, **kwargs): + data = escape.json_decode(self.request.body or '{}') + self._clean_dict(data) + user_info = self.current_user_info + user_id = user_info.get('_id') + now = datetime.datetime.now() + data['created_by'] = user_id + data['created_at'] = now + data['updated_by'] = user_id + data['updated_at'] = now + doc = self.db.add(self.collection, data) + doc = self.apply_filter(doc, 'create') + self.write(doc) + + @gen.coroutine + def put(self, id_, **kwargs): + data = escape.json_decode(self.request.body or '{}') + self._clean_dict(data) + user_info = self.current_user_info + user_id = user_info.get('_id') + now = datetime.datetime.now() + data['updated_by'] = user_id + data['updated_at'] = now + merged, doc = self.db.update(self.collection, {'_id': id_}, data) + doc = self.apply_filter(doc, 'update') + self.write(doc) + + @gen.coroutine + def delete(self, id_=None, **kwargs): + if id_ is not None: + howMany = self.db.delete(self.collection, id_) + self.write({'success': True, 'deleted entries': howMany.get('n')}) + else: + self.write({'success': False}) + + +class DaysHandler(CollectionHandler): + """Handle requests for Days.""" + + def _summarize(self, days): + res = [] + for day in days: + print day['day'], [x['group'] for x in day.get('groups')] + res.append({'day': day['day'], 'groups_count': len(day.get('groups', []))}) + return res + + @gen.coroutine + def get(self, day=None, **kwargs): + params = self.arguments + summary = params.get('summary', False) + if summary: + del params['summary'] + start = params.get('start') + if start: + del params['start'] + end = params.get('end') + if end: + del params['end'] + if day: + params['day'] = day + else: + if start: + params['day'] = {'$gte': start} + if end: + if 'day' not in params: + params['day'] = {} + if end.count('-') == 0: + end += '-13' + elif end.count('-') == 1: + end += '-31' + params['day'].update({'$lte': end}) + res = self.db.query('attendees', params) + days = [] + for d, dayItems in itertools.groupby(sorted(res, key=itemgetter('day')), key=itemgetter('day')): + dayData = {'day': d, 'groups': []} + for group, attendees in itertools.groupby(sorted(dayItems, key=itemgetter('group')), key=itemgetter('group')): + attendees = sorted(attendees, key=itemgetter('_id')) + dayData['groups'].append({'group': group, 'attendees': attendees}) + days.append(dayData) + if summary: + days = self._summarize(days) + if not day: + self.write({'days': days}) + elif days: + self.write(days[0]) + else: + self.write({}) + + +class UsersHandler(CollectionHandler): + """Handle requests for Users.""" + document = 'user' + collection = 'users' + + def filter_get(self, data): + if 'password' in data: + del data['password'] + return data + + def filter_get_all(self, data): + if 'users' not in data: + return data + for user in data['users']: + if 'password' in user: + del user['password'] + return data + + @gen.coroutine + def get(self, id_=None, resource=None, resource_id=None, acl=True, **kwargs): + if id_ is not None: + if (self.has_permission('user|read') or str(self.current_user_info.get('_id')) == id_): + acl = False + super(UsersHandler, self).get(id_, resource, resource_id, acl=acl, **kwargs) + + def filter_input_post_all(self, data): + username = (data.get('username') or '').strip() + password = (data.get('password') or '').strip() + email = (data.get('email') or '').strip() + if not (username and password): + raise InputException('missing username or password') + res = self.db.query('users', {'username': username}) + if res: + raise InputException('username already exists') + return {'username': username, 'password': utils.hash_password(password), + 'email': email} + + def filter_input_put(self, data): + old_pwd = data.get('old_password') + new_pwd = data.get('new_password') + if old_pwd is not None: + del data['old_password'] + if new_pwd is not None: + del data['new_password'] + authorized, user = self.user_authorized(data['username'], old_pwd) + if not (authorized and self.current_user == data['username']): + raise InputException('not authorized to change password') + data['password'] = utils.hash_password(new_pwd) + if '_id' in data: + # Avoid overriding _id + del data['_id'] + return data + + @gen.coroutine + def put(self, id_=None, resource=None, resource_id=None, **kwargs): + if id_ is None: + return self.build_error(status=404, message='unable to access the resource') + if str(self.current_user_info.get('_id')) != id_: + return self.build_error(status=401, message='insufficient permissions: user|update or current user') + super(UsersHandler, self).put(id_, resource, resource_id, **kwargs) + + +class SettingsHandler(BaseHandler): + """Handle requests for Settings.""" + @gen.coroutine + def get(self, **kwargs): + query = self.arguments_tobool() + settings = self.db.query('settings', query) + self.write({'settings': settings}) + + +class LoginHandler(RootHandler): + """Handle user authentication requests.""" + + @gen.coroutine + def get(self, **kwargs): + # show the login page + if self.is_api(): + self.set_status(401) + self.write({'error': True, + 'message': 'authentication required'}) + + @gen.coroutine + def post(self, *args, **kwargs): + # authenticate a user + try: + password = self.get_body_argument('password') + username = self.get_body_argument('username') + except tornado.web.MissingArgumentError: + data = escape.json_decode(self.request.body or '{}') + username = data.get('username') + password = data.get('password') + if not (username and password): + self.set_status(401) + self.write({'error': True, 'message': 'missing username or password'}) + return + authorized, user = self.user_authorized(username, password) + if authorized and user.get('username'): + username = user['username'] + logging.info('successful login for user %s' % username) + self.set_secure_cookie("user", username) + self.write({'error': False, 'message': 'successful login'}) + return + logging.info('login failed for user %s' % username) + self.set_status(401) + self.write({'error': True, 'message': 'wrong username and password'}) + + +class LogoutHandler(BaseHandler): + """Handle user logout requests.""" + @gen.coroutine + def get(self, **kwargs): + # log the user out + logging.info('logout') + self.logout() + self.write({'error': False, 'message': 'logged out'}) + + +def run(): + """Run the Tornado web application.""" + # command line arguments; can also be written in a configuration file, + # specified with the --config argument. + define("port", default=3000, help="run on the given port", type=int) + define("address", default='', help="bind the server at the given address", type=str) + define("data_dir", default=os.path.join(os.path.dirname(__file__), "data"), + help="specify the directory used to store the data") + define("ssl_cert", default=os.path.join(os.path.dirname(__file__), 'ssl', 'ibt2_cert.pem'), + help="specify the SSL certificate to use for secure connections") + define("ssl_key", default=os.path.join(os.path.dirname(__file__), 'ssl', 'ibt2_key.pem'), + help="specify the SSL private key to use for secure connections") + define("mongo_url", default=None, + help="URL to MongoDB server", type=str) + define("db_name", default='ibt2', + help="Name of the MongoDB database to use", type=str) + define("authentication", default=False, help="if set to true, authentication is required") + define("debug", default=False, help="run in debug mode") + define("config", help="read configuration file", + callback=lambda path: tornado.options.parse_config_file(path, final=False)) + tornado.options.parse_command_line() + + logger = logging.getLogger() + logger.setLevel(logging.INFO) + if options.debug: + logger.setLevel(logging.DEBUG) + + ssl_options = {} + if os.path.isfile(options.ssl_key) and os.path.isfile(options.ssl_cert): + ssl_options = dict(certfile=options.ssl_cert, keyfile=options.ssl_key) + + # database backend connector + db_connector = monco.Monco(url=options.mongo_url, dbName=options.db_name) + init_params = dict(db=db_connector, data_dir=options.data_dir, listen_port=options.port, + authentication=options.authentication, logger=logger, ssl_options=ssl_options) + + # If not present, we store a user 'admin' with password 'ibt2' into the database. + if not db_connector.query('users', {'username': 'admin'}): + db_connector.add('users', + {'username': 'admin', 'password': utils.hash_password('ibt2'), + 'isAdmin': True}) + + # If present, use the cookie_secret stored into the database. + cookie_secret = db_connector.query('settings', {'setting': 'server_cookie_secret'}) + if cookie_secret: + cookie_secret = cookie_secret[0]['cookie_secret'] + else: + # the salt guarantees its uniqueness + cookie_secret = utils.hash_password('__COOKIE_SECRET__') + db_connector.add('settings', + {'setting': 'server_cookie_secret', 'cookie_secret': cookie_secret}) + + _days_path = r"/days/?(?P[\d_-]+)?" + _attendees_path = r"/days/(?P[\d_-]+)/groups/(?P[\w\d_\ -]+)/attendees/?(?P[\w\d_\ -]+)?" + _users_path = r"/users/?(?P[\w\d_-]+)?/?(?P[\w\d_-]+)?/?(?P[\w\d_-]+)?" + _attendees_path = r"/attendees/?(?P[\w\d_-]+)?" + application = tornado.web.Application([ + (_attendees_path, AttendeesHandler, init_params), + (r'/v%s%s' % (API_VERSION, _attendees_path), AttendeesHandler, init_params), + (_days_path, DaysHandler, init_params), + (r'/v%s%s' % (API_VERSION, _days_path), DaysHandler, init_params), + (_users_path, UsersHandler, init_params), + (r'/v%s%s' % (API_VERSION, _users_path), UsersHandler, init_params), + (r"/(?:index.html)?", RootHandler, init_params), + (r"/settings", SettingsHandler, init_params), + (r'/login', LoginHandler, init_params), + (r'/v%s/login' % API_VERSION, LoginHandler, init_params), + (r'/logout', LogoutHandler), + (r'/v%s/logout' % API_VERSION, LogoutHandler), + (r'/?(.*)', tornado.web.StaticFileHandler, {"path": "dist"}) + ], + static_path=os.path.join(os.path.dirname(__file__), "dist/static"), + cookie_secret='__COOKIE_SECRET__', + login_url='/login', + debug=options.debug) + http_server = tornado.httpserver.HTTPServer(application, ssl_options=ssl_options or None) + logger.info('Start serving on %s://%s:%d', 'https' if ssl_options else 'http', + options.address if options.address else '127.0.0.1', + options.port) + http_server.listen(options.port, options.address) + tornado.ioloop.IOLoop.instance().start() + + +if __name__ == '__main__': + try: + run() + except KeyboardInterrupt: + print('Stop server') diff --git a/index.html b/index.html new file mode 100644 index 0000000..a2f6a13 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + ibt2 + + + +
+ + + diff --git a/monco.py b/monco.py new file mode 100644 index 0000000..34c11ba --- /dev/null +++ b/monco.py @@ -0,0 +1,285 @@ +"""Monco: a MongoDB database backend + +Classes and functions used to issue queries to a MongoDB database. + +Copyright 2016 Davide Alberani + RaspiBO + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import re +import pymongo +from bson.objectid import ObjectId + +re_objectid = re.compile(r'[0-9a-f]{24}') + +_force_conversion = { + '_id': ObjectId +} + + +def convert_obj(obj): + """Convert an object in a format suitable to be stored in MongoDB. + + :param obj: object to convert + + :returns: object that can be stored in MongoDB. + """ + if obj is None: + return None + if isinstance(obj, bool): + return obj + try: + if re_objectid.match(obj): + return ObjectId(obj) + except: + pass + return obj + + +def convert(seq): + """Convert an object to a format suitable to be stored in MongoDB, + descending lists, tuples and dictionaries (a copy is returned). + + :param seq: sequence or object to convert + + :returns: object that can be stored in MongoDB. + """ + if isinstance(seq, dict): + d = {} + for key, item in seq.iteritems(): + if key in _force_conversion: + try: + d[key] = _force_conversion[key](item) + except: + d[key] = item + else: + d[key] = convert(item) + return d + if isinstance(seq, (list, tuple)): + return [convert(x) for x in seq] + return convert_obj(seq) + + +class MoncoError(Exception): + """Base class for Monco exceptions.""" + pass + + +class MoncoConnection(MoncoError): + """Monco exceptions raise when a connection problem occurs.""" + pass + + +class Monco(object): + """MongoDB connector.""" + db = None + connection = None + + # map operations on lists of items. + _operations = { + 'update': '$set', + 'append': '$push', + 'appendUnique': '$addToSet', + 'delete': '$pull', + 'increment': '$inc' + } + + def __init__(self, dbName, url=None): + """Initialize the instance, connecting to the database. + + :param dbName: name of the database + :type dbName: str (or None to use the dbName passed at initialization) + :param url: URL of the database + :type url: str (or None to connect to localhost) + """ + self._url = url + self._dbName = dbName + self.connect(url) + + def connect(self, dbName=None, url=None): + """Connect to the database. + + :param dbName: name of the database + :type dbName: str (or None to use the dbName passed at initialization) + :param url: URL of the database + :type url: str (or None to connect to localhost) + + :returns: the database we're connected to + :rtype: :class:`~pymongo.database.Database` + """ + if self.db is not None: + return self.db + if url: + self._url = url + if dbName: + self._dbName = dbName + if not self._dbName: + raise MoncoConnection('no database name specified') + self.connection = pymongo.MongoClient(self._url) + self.db = self.connection[self._dbName] + return self.db + + def getOne(self, collection, query=None): + """Get a single document with the specified `query`. + + :param collection: search the document in this collection + :type collection: str + :param query: query to filter the documents + :type query: dict or None + + :returns: the first document matching the query + :rtype: dict + """ + results = self.query(collection, convert(query)) + return results and results[0] or {} + + def get(self, collection, _id): + """Get a single document with the specified `_id`. + + :param collection: search the document in this collection + :type collection: str + :param _id: unique ID of the document + :type _id: str or :class:`~bson.objectid.ObjectId` + + :returns: the document with the given `_id` + :rtype: dict + """ + return self.getOne(collection, {'_id': _id}) + + def query(self, collection, query=None, condition='or'): + """Get multiple documents matching a query. + + :param collection: search for documents in this collection + :type collection: str + :param query: search for documents with those attributes + :type query: dict or None + + :returns: list of matching documents + :rtype: list + """ + db = self.connect() + query = convert(query or {}) + if isinstance(query, (list, tuple)): + query = {'$%s' % condition: query} + return list(db[collection].find(query)) + + def add(self, collection, data, _id=None): + """Insert a new document. + + :param collection: insert the document in this collection + :type collection: str + :param data: the document to store + :type data: dict + :param _id: the _id of the document to store; if None, it's generated + :type _id: object + + :returns: the document, as created in the database + :rtype: dict + """ + db = self.connect() + data = convert(data) + if _id is not None: + data['_id'] = _id + _id = db[collection].insert(data) + return self.get(collection, _id) + + def insertOne(self, collection, data): + """Insert a document, avoiding duplicates. + + :param collection: update a document in this collection + :type collection: str + :param data: the document information to store + :type data: dict + + :returns: True if the document was already present + :rtype: bool + """ + db = self.connect() + data = convert(data) + ret = db[collection].update(data, {'$set': data}, upsert=True) + return ret['updatedExisting'] + + def _buildSearchPattern(self, data, searchBy): + """Return an OR condition.""" + _or = [] + for searchPattern in searchBy: + try: + _or.append(dict([(k, data[k]) for k in searchPattern if k in data])) + except KeyError: + continue + return _or + + def update(self, collection, _id_or_query, data, operation='update', + updateList=None, create=True): + """Update an existing document or create it, if requested. + _id_or_query can be an ID, a dict representing a query or a list of tuples. + In the latter case, the tuples are put in OR; a tuple match if all of its + items from 'data' are contained in the document. + + :param collection: update a document in this collection + :type collection: str + :param _id_or_query: ID of the document to be updated, or a query or a list of attributes in the data that must match + :type _id_or_query: str or :class:`~bson.objectid.ObjectId` or iterable + :param data: the updated information to store + :type data: dict + :param operation: operation used to update the document or a portion of it, like a list (update, append, appendUnique, delete, increment) + :type operation: str + :param updateList: if set, it's considered the name of a list (the first matching element will be updated) + :type updateList: str + :param create: if True, the document is created if no document matches + :type create: bool + + :returns: a boolean (True if an existing document was updated) and the document after the update + :rtype: tuple of (bool, dict) + """ + db = self.connect() + data = convert(data or {}) + _id_or_query = convert(_id_or_query) + if isinstance(_id_or_query, (list, tuple)): + _id_or_query = {'$or': self._buildSearchPattern(data, _id_or_query)} + elif not isinstance(_id_or_query, dict): + _id_or_query = {'_id': _id_or_query} + if '_id' in data: + del data['_id'] + operator = self._operations.get(operation) + if updateList: + newData = {} + for key, value in data.iteritems(): + newData['%s.$.%s' % (updateList, key)] = value + data = newData + res = db[collection].find_and_modify(query=_id_or_query, + update={operator: data}, full_response=True, new=True, upsert=create) + lastErrorObject = res.get('lastErrorObject') or {} + return lastErrorObject.get('updatedExisting', False), res.get('value') or {} + + def delete(self, collection, _id_or_query=None, force=False): + """Remove one or more documents from a collection. + + :param collection: search the documents in this collection + :type collection: str + :param _id_or_query: unique ID of the document or query to match multiple documents + :type _id_or_query: str or :class:`~bson.objectid.ObjectId` or dict + :param force: force the deletion of all documents, when `_id_or_query` is empty + :type force: bool + + :returns: how many documents were removed + :rtype: int + """ + if not _id_or_query and not force: + return + db = self.connect() + if not isinstance(_id_or_query, dict): + _id_or_query = {'_id': _id_or_query} + _id_or_query = convert(_id_or_query) + return db[collection].remove(_id_or_query) + diff --git a/package.json b/package.json new file mode 100644 index 0000000..c5ae8a6 --- /dev/null +++ b/package.json @@ -0,0 +1,59 @@ +{ + "name": "ibt2", + "version": "1.0.0", + "description": "I'll be there, 2", + "author": "Davide Alberani ", + "private": true, + "scripts": { + "dev": "node build/dev-server.js", + "pydev": "python ibt2.py --debug --db_name=ibt2_test", + "watch": "watch 'npm run build' src/", + "build": "node build/build.js" + }, + "dependencies": { + "babel": "^6.5.2", + "jquery": "^3.1.1", + "material-ui-vue": "^0.1.4", + "materialize-css": "^0.97.8", + "vue": "^2.1.6", + "vue-loader": "^10.0.2", + "vue-resource": "^1.0.3", + "vuejs-datepicker": "^0.4.27" + }, + "devDependencies": { + "autoprefixer": "^6.4.0", + "babel-core": "^6.0.0", + "babel-loader": "^6.0.0", + "babel-plugin-transform-runtime": "^6.0.0", + "babel-preset-es2015": "^6.0.0", + "babel-preset-stage-2": "^6.0.0", + "babel-register": "^6.0.0", + "chalk": "^1.1.3", + "connect-history-api-fallback": "^1.1.0", + "css-loader": "^0.25.0", + "eventsource-polyfill": "^0.9.6", + "express": "^4.13.3", + "extract-text-webpack-plugin": "^1.0.1", + "file-loader": "^0.9.0", + "function-bind": "^1.0.2", + "html-webpack-plugin": "^2.8.1", + "http-proxy-middleware": "^0.17.2", + "json-loader": "^0.5.4", + "semver": "^5.3.0", + "opn": "^4.0.2", + "ora": "^0.3.0", + "shelljs": "^0.7.4", + "url-loader": "^0.5.7", + "vue-loader": "^10.0.0", + "vue-style-loader": "^1.0.0", + "vue-template-compiler": "^2.1.0", + "webpack": "^1.13.2", + "webpack-dev-middleware": "^1.8.3", + "webpack-hot-middleware": "^2.12.2", + "webpack-merge": "^0.14.1" + }, + "engines": { + "node": ">= 4.0.0", + "npm": ">= 3.0.0" + } +} diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..0726de4 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/src/assets/logo.png b/src/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f3d2503fc2a44b5053b0837ebea6e87a2d339a43 GIT binary patch literal 6849 zcmaKRcUV(fvo}bjDT-7nLI_nlK}sT_69H+`qzVWDA|yaU?}j417wLi^B1KB1SLsC& zL0ag7$U(XW5YR7p&Ux?sP$d4lvMt8C^+TcQu4F zQqv!UF!I+kw)c0jhd6+g6oCr9P?7)?!qX1ui*iL{p}sKCAGuJ{{W)0z1pLF|=>h}& zt(2Lr0Z`2ig8<5i%Zk}cO5Fm=LByqGWaS`oqChZdEFmc`0hSb#gg|Aap^{+WKOYcj zHjINK)KDG%&s?Mt4CL(T=?;~U@bU2x_mLKN!#GJuK_CzbNw5SMEJorG!}_5;?R>@1 zSl)jns3WlU7^J%=(hUtfmuUCU&C3%8B5C^f5>W2Cy8jW3#{Od{lF1}|?c61##3dzA zsPlFG;l_FzBK}8>|H_Ru_H#!_7$UH4UKo3lKOA}g1(R&|e@}GINYVzX?q=_WLZCgh z)L|eJMce`D0EIwgRaNETDsr+?vQknSGAi=7H00r`QnI%oQnFxm`G2umXso9l+8*&Q z7WqF|$p49js$mdzo^BXpH#gURy=UO;=IMrYc5?@+sR4y_?d*~0^YP7d+y0{}0)zBM zIKVM(DBvICK#~7N0a+PY6)7;u=dutmNqK3AlsrUU9U`d;msiucB_|8|2kY=(7XA;G zwDA8AR)VCA#JOkxm#6oHNS^YVuOU;8p$N)2{`;oF|rQ?B~K$%rHDxXs+_G zF5|-uqHZvSzq}L;5Kcy_P+x0${33}Ofb6+TX&=y;;PkEOpz%+_bCw_{<&~ zeLV|!bP%l1qxywfVr9Z9JI+++EO^x>ZuCK);=$VIG1`kxK8F2M8AdC$iOe3cj1fo(ce4l-9 z7*zKy3={MixvUk=enQE;ED~7tv%qh&3lR<0m??@w{ILF|e#QOyPkFYK!&Up7xWNtL zOW%1QMC<3o;G9_S1;NkPB6bqbCOjeztEc6TsBM<(q9((JKiH{01+Ud=uw9B@{;(JJ z-DxI2*{pMq`q1RQc;V8@gYAY44Z!%#W~M9pRxI(R?SJ7sy7em=Z5DbuDlr@*q|25V)($-f}9c#?D%dU^RS<(wz?{P zFFHtCab*!rl(~j@0(Nadvwg8q|4!}L^>d?0al6}Rrv9$0M#^&@zjbfJy_n!%mVHK4 z6pLRIQ^Uq~dnyy$`ay51Us6WaP%&O;@49m&{G3z7xV3dLtt1VTOMYl3UW~Rm{Eq4m zF?Zl_v;?7EFx1_+#WFUXxcK78IV)FO>42@cm@}2I%pVbZqQ}3;p;sDIm&knay03a^ zn$5}Q$G!@fTwD$e(x-~aWP0h+4NRz$KlnO_H2c< z(XX#lPuW_%H#Q+c&(nRyX1-IadKR-%$4FYC0fsCmL9ky3 zKpxyjd^JFR+vg2!=HWf}2Z?@Td`0EG`kU?{8zKrvtsm)|7>pPk9nu@2^z96aU2<#` z2QhvH5w&V;wER?mopu+nqu*n8p~(%QkwSs&*0eJwa zMXR05`OSFpfyRb!Y_+H@O%Y z0=K^y6B8Gcbl?SA)qMP3Z+=C(?8zL@=74R=EVnE?vY!1BQy2@q*RUgRx4yJ$k}MnL zs!?74QciNb-LcG*&o<9=DSL>1n}ZNd)w1z3-0Pd^4ED1{qd=9|!!N?xnXjM!EuylY z5=!H>&hSofh8V?Jofyd!h`xDI1fYAuV(sZwwN~{$a}MX^=+0TH*SFp$vyxmUv7C*W zv^3Gl0+eTFgBi3FVD;$nhcp)ka*4gSskYIqQ&+M}xP9yLAkWzBI^I%zR^l1e?bW_6 zIn{mo{dD=)9@V?s^fa55jh78rP*Ze<3`tRCN4*mpO$@7a^*2B*7N_|A(Ve2VB|)_o z$=#_=aBkhe(ifX}MLT()@5?OV+~7cXC3r!%{QJxriXo9I%*3q4KT4Xxzyd{ z9;_%=W%q!Vw$Z7F3lUnY+1HZ*lO;4;VR2+i4+D(m#01OYq|L_fbnT;KN<^dkkCwtd zF7n+O7KvAw8c`JUh6LmeIrk4`F3o|AagKSMK3))_5Cv~y2Bb2!Ibg9BO7Vkz?pAYX zoI=B}+$R22&IL`NCYUYjrdhwjnMx_v=-Qcx-jmtN>!Zqf|n1^SWrHy zK|MwJ?Z#^>)rfT5YSY{qjZ&`Fjd;^vv&gF-Yj6$9-Dy$<6zeP4s+78gS2|t%Z309b z0^fp~ue_}i`U9j!<|qF92_3oB09NqgAoehQ`)<)dSfKoJl_A6Ec#*Mx9Cpd-p#$Ez z={AM*r-bQs6*z$!*VA4|QE7bf@-4vb?Q+pPKLkY2{yKsw{&udv_2v8{Dbd zm~8VAv!G~s)`O3|Q6vFUV%8%+?ZSVUa(;fhPNg#vab@J*9XE4#D%)$UU-T5`fwjz! z6&gA^`OGu6aUk{l*h9eB?opVdrHK>Q@U>&JQ_2pR%}TyOXGq_6s56_`U(WoOaAb+K zXQr#6H}>a-GYs9^bGP2Y&hSP5gEtW+GVC4=wy0wQk=~%CSXj=GH6q z-T#s!BV`xZVxm{~jr_ezYRpqqIcXC=Oq`b{lu`Rt(IYr4B91hhVC?yg{ol4WUr3v9 zOAk2LG>CIECZ-WIs0$N}F#eoIUEtZudc7DPYIjzGqDLWk_A4#(LgacooD z2K4IWs@N`Bddm-{%oy}!k0^i6Yh)uJ1S*90>|bm3TOZxcV|ywHUb(+CeX-o1|LTZM zwU>dY3R&U)T(}5#Neh?-CWT~@{6Ke@sI)uSuzoah8COy)w)B)aslJmp`WUcjdia-0 zl2Y}&L~XfA`uYQboAJ1;J{XLhYjH){cObH3FDva+^8ioOQy%Z=xyjGLmWMrzfFoH; zEi3AG`_v+%)&lDJE;iJWJDI@-X9K5O)LD~j*PBe(wu+|%ar~C+LK1+-+lK=t# z+Xc+J7qp~5q=B~rD!x78)?1+KUIbYr^5rcl&tB-cTtj+e%{gpZZ4G~6r15+d|J(ky zjg@@UzMW0k9@S#W(1H{u;Nq(7llJbq;;4t$awM;l&(2s+$l!Ay9^Ge|34CVhr7|BG z?dAR83smef^frq9V(OH+a+ki#q&-7TkWfFM=5bsGbU(8mC;>QTCWL5ydz9s6k@?+V zcjiH`VI=59P-(-DWXZ~5DH>B^_H~;4$)KUhnmGo*G!Tq8^LjfUDO)lASN*=#AY_yS zqW9UX(VOCO&p@kHdUUgsBO0KhXxn1sprK5h8}+>IhX(nSXZKwlNsjk^M|RAaqmCZB zHBolOHYBas@&{PT=R+?d8pZu zUHfyucQ`(umXSW7o?HQ3H21M`ZJal+%*)SH1B1j6rxTlG3hx1IGJN^M7{$j(9V;MZ zRKybgVuxKo#XVM+?*yTy{W+XHaU5Jbt-UG33x{u(N-2wmw;zzPH&4DE103HV@ER86 z|FZEmQb|&1s5#`$4!Cm}&`^{(4V}OP$bk`}v6q6rm;P!H)W|2i^e{7lTk2W@jo_9q z*aw|U7#+g59Fv(5qI`#O-qPj#@_P>PC#I(GSp3DLv7x-dmYK=C7lPF8a)bxb=@)B1 zUZ`EqpXV2dR}B&r`uM}N(TS99ZT0UB%IN|0H%DcVO#T%L_chrgn#m6%x4KE*IMfjX zJ%4veCEqbXZ`H`F_+fELMC@wuy_ch%t*+Z+1I}wN#C+dRrf2X{1C8=yZ_%Pt6wL_~ zZ2NN-hXOT4P4n$QFO7yYHS-4wF1Xfr-meG9Pn;uK51?hfel`d38k{W)F*|gJLT2#T z<~>spMu4(mul-8Q3*pf=N4DcI)zzjqAgbE2eOT7~&f1W3VsdD44Ffe;3mJp-V@8UC z)|qnPc12o~$X-+U@L_lWqv-RtvB~%hLF($%Ew5w>^NR82qC_0FB z)=hP1-OEx?lLi#jnLzH}a;Nvr@JDO-zQWd}#k^an$Kwml;MrD&)sC5b`s0ZkVyPkb zt}-jOq^%_9>YZe7Y}PhW{a)c39G`kg(P4@kxjcYfgB4XOOcmezdUI7j-!gs7oAo2o zx(Ph{G+YZ`a%~kzK!HTAA5NXE-7vOFRr5oqY$rH>WI6SFvWmahFav!CfRMM3%8J&c z*p+%|-fNS_@QrFr(at!JY9jCg9F-%5{nb5Bo~z@Y9m&SHYV`49GAJjA5h~h4(G!Se zZmK{Bo7ivCfvl}@A-ptkFGcWXAzj3xfl{evi-OG(TaCn1FAHxRc{}B|x+Ua1D=I6M z!C^ZIvK6aS_c&(=OQDZfm>O`Nxsw{ta&yiYPA~@e#c%N>>#rq)k6Aru-qD4(D^v)y z*>Rs;YUbD1S8^D(ps6Jbj0K3wJw>L4m)0e(6Pee3Y?gy9i0^bZO?$*sv+xKV?WBlh zAp*;v6w!a8;A7sLB*g-^<$Z4L7|5jXxxP1}hQZ<55f9<^KJ>^mKlWSGaLcO0=$jem zWyZkRwe~u{{tU63DlCaS9$Y4CP4f?+wwa(&1ou)b>72ydrFvm`Rj-0`kBJgK@nd(*Eh!(NC{F-@=FnF&Y!q`7){YsLLHf0_B6aHc# z>WIuHTyJwIH{BJ4)2RtEauC7Yq7Cytc|S)4^*t8Va3HR zg=~sN^tp9re@w=GTx$;zOWMjcg-7X3Wk^N$n;&Kf1RgVG2}2L-(0o)54C509C&77i zrjSi{X*WV=%C17((N^6R4Ya*4#6s_L99RtQ>m(%#nQ#wrRC8Y%yxkH;d!MdY+Tw@r zjpSnK`;C-U{ATcgaxoEpP0Gf+tx);buOMlK=01D|J+ROu37qc*rD(w`#O=3*O*w9?biwNoq3WN1`&Wp8TvKj3C z3HR9ssH7a&Vr<6waJrU zdLg!ieYz%U^bmpn%;(V%%ugMk92&?_XX1K@mwnVSE6!&%P%Wdi7_h`CpScvspMx?N zQUR>oadnG17#hNc$pkTp+9lW+MBKHRZ~74XWUryd)4yd zj98$%XmIL4(9OnoeO5Fnyn&fpQ9b0h4e6EHHw*l68j;>(ya`g^S&y2{O8U>1*>4zR zq*WSI_2o$CHQ?x0!wl9bpx|Cm2+kFMR)oMud1%n2=qn5nE&t@Fgr#=Zv2?}wtEz^T z9rrj=?IH*qI5{G@Rn&}^Z{+TW}mQeb9=8b<_a`&Cm#n%n~ zU47MvCBsdXFB1+adOO)03+nczfWa#vwk#r{o{dF)QWya9v2nv43Zp3%Ps}($lA02*_g25t;|T{A5snSY?3A zrRQ~(Ygh_ebltHo1VCbJb*eOAr;4cnlXLvI>*$-#AVsGg6B1r7@;g^L zFlJ_th0vxO7;-opU@WAFe;<}?!2q?RBrFK5U{*ai@NLKZ^};Ul}beukveh?TQn;$%9=R+DX07m82gP$=}Uo_%&ngV`}Hyv8g{u z3SWzTGV|cwQuFIs7ZDOqO_fGf8Q`8MwL}eUp>q?4eqCmOTcwQuXtQckPy|4F1on8l zP*h>d+cH#XQf|+6c|S{7SF(Lg>bR~l(0uY?O{OEVlaxa5@e%T&xju=o1`=OD#qc16 zSvyH*my(dcp6~VqR;o(#@m44Lug@~_qw+HA=mS#Z^4reBy8iV?H~I;{LQWk3aKK8$bLRyt$g?- +
+

{{ msg }}

+

Essential Links

+ +

Ecosystem

+ +
+ + + + + + diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..a9d813a --- /dev/null +++ b/src/main.js @@ -0,0 +1,22 @@ +// The Vue build version to load with the `import` command +// (runtime-only or standalone) has been set in webpack.base.conf with an alias. +import Vue from 'vue' +import App from './App' +require("vue-resource") +/* +import 'jquery/dist/jquery.min.js' +import 'materialize-css/bin/materialize.css' +import 'materialize-css/bin/materialize.js' +require("material-ui-vue") +*/ +var VueResource = require("vue-resource"); +require("jquery"); + +Vue.use(VueResource); + +/* eslint-disable no-new */ +new Vue({ + el: '#app', + template: '', + components: { App } +}) diff --git a/static/.gitkeep b/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/ibt2_tests.py b/tests/ibt2_tests.py new file mode 100755 index 0000000..f08000d --- /dev/null +++ b/tests/ibt2_tests.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python +"""I'll Be There 2 (ibt2) - tests + +Copyright 2016 Davide Alberani + RaspiBO + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import unittest +import requests +import monco + +BASE_URL = 'http://localhost:3000/v1.0/' +DB_NAME = 'ibt2_test' + +def dictInDict(d, dContainer): + for k, v in d.viewitems(): + if k not in dContainer: + return False + if v != dContainer[k]: + return False + return True + + +class Ibt2Tests(unittest.TestCase): + #@classmethod + #def setUpClass(cls): + def setUp(self): + self.monco_conn = monco.Monco(dbName=DB_NAME) + self.connection = self.monco_conn.connection + self.db = self.monco_conn.db + self.connection.drop_database(DB_NAME) + + def tearDown(self): + self.add_attendee({'day': '2017-01-15', 'name': 'A name', 'group': 'group A'}) + self.add_attendee({'day': '2017-01-16', 'name': 'A new name', 'group': 'group C'}) + self.add_attendee({'day': '2017-01-15', 'name': 'Another name', 'group': 'group A'}) + self.add_attendee({'day': '2017-01-15', 'name': 'Yet another name', 'group': 'group B'}) + + def add_attendee(self, attendee): + r = requests.post('%sattendees' % BASE_URL, json=attendee) + r.raise_for_status() + return r + + def test_add_attendee(self): + # POST /attendees/ {name: 'A Name', day: '2017-01-15', group: 'A group'} + # GET /attendees/:id + attendee = {'name': 'A Name', 'day': '2017-01-15', 'group': 'A group'} + r = self.add_attendee(attendee) + rj = r.json() + id_ = rj.get('_id') + self.assertTrue(dictInDict(attendee, rj)) + r = requests.get(BASE_URL + 'attendees/' + id_) + r.raise_for_status() + rj = r.json() + self.assertTrue(dictInDict(attendee, rj)) + + def test_put_attendee(self): + # POST /attendees/ {name: 'A Name', day: '2017-01-15', group: 'A group'} + # GET /attendees/:id + attendee = {'name': 'A Name', 'day': '2017-01-15', 'group': 'A group'} + r = self.add_attendee(attendee) + update = {'notes': 'A note'} + r = requests.post(BASE_URL + 'attendees', json=attendee) + r.raise_for_status() + id_ = r.json().get('_id') + r = requests.put(BASE_URL + 'attendees/' + id_, json=update) + r.raise_for_status() + r = requests.get('%s%s/%s' % (BASE_URL, 'attendees', id_)) + r.raise_for_status() + rj = r.json() + final = attendee.copy() + final.update(update) + self.assertTrue(dictInDict(final, rj)) + + def test_delete_attendee(self): + # POST /attendees/ {name: 'A Name', day: '2017-01-15', group: 'A group'} + # GET /attendees/:id + attendee = {'name': 'A Name', 'day': '2017-01-15', 'group': 'A group'} + r = self.add_attendee(attendee) + id_ = r.json().get('_id') + r = requests.delete(BASE_URL + 'attendees/' + id_) + r.raise_for_status() + self.assertTrue(r.json().get('success')) + + def test_get_days(self): + self.add_attendee({'day': '2017-01-15', 'name': 'A name', 'group': 'group A'}) + self.add_attendee({'day': '2017-01-16', 'name': 'A new name', 'group': 'group C'}) + self.add_attendee({'day': '2017-01-15', 'name': 'Another name', 'group': 'group A'}) + self.add_attendee({'day': '2017-01-15', 'name': 'Yet another name', 'group': 'group B'}) + r = requests.get(BASE_URL + 'days') + r.raise_for_status() + rj = r.json() + self.assertEqual([x.get('day') for x in rj['days']], ['2017-01-15', '2017-01-16']) + self.assertEqual([x.get('group') for x in rj['days'][0]['groups']], ['group A', 'group B']) + self.assertTrue(len(rj['days'][0]['groups'][0]['attendees']) == 2) + self.assertTrue(len(rj['days'][0]['groups'][1]['attendees']) == 1) + self.assertEqual([x.get('group') for x in rj['days'][1]['groups']], ['group C']) + self.assertTrue(len(rj['days'][1]['groups'][0]['attendees']) == 1) + + def test_get_days_summary(self): + self.add_attendee({'day': '2017-01-15', 'name': 'A name', 'group': 'group A'}) + self.add_attendee({'day': '2017-01-16', 'name': 'A new name', 'group': 'group C'}) + self.add_attendee({'day': '2017-01-15', 'name': 'Another name', 'group': 'group A'}) + self.add_attendee({'day': '2017-01-15', 'name': 'Yet another name', 'group': 'group B'}) + r = requests.get(BASE_URL + 'days?summary=1') + r.raise_for_status() + rj = r.json() + self.assertEqual(rj, + {"days": [{"groups_count": 2, "day": "2017-01-15"}, {"groups_count": 1, "day": "2017-01-16"}]}) + + def _test_post_day_group(self): + # POST /days/ {day: '2017-01-04'} + # GET /days/2017-01-04 + day = '2017-01-15' + query = {'day': day, 'groups': [{'name': 'group1'}]} + r = requests.post(BASE_URL + 'days', json=query) + r.raise_for_status() + rj = r.json() + self.assertTrue(dictInDict(query, rj)) + r = requests.get('%s%s/%s' % (BASE_URL, 'days', day)) + r.raise_for_status() + rj = r.json() + self.assertTrue(dictInDict(query, rj)) + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/monco.py b/tests/monco.py new file mode 120000 index 0000000..f5bb920 --- /dev/null +++ b/tests/monco.py @@ -0,0 +1 @@ +../monco.py \ No newline at end of file diff --git a/tests/monco.pyc b/tests/monco.pyc new file mode 100644 index 0000000000000000000000000000000000000000..acebfd8e3ed92b0637ea82c9c13f089a59ece3f0 GIT binary patch literal 10549 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 literal 0 HcmV?d00001 diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..0bdb26c --- /dev/null +++ b/utils.py @@ -0,0 +1,61 @@ +"""ibt2 utils + +Miscellaneous utilities. + +Copyright 2016 Davide Alberani + RaspiBO + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import json +import string +import random +import hashlib +import datetime +import StringIO +from bson.objectid import ObjectId + + +def hash_password(password, salt=None): + """Hash a password. + + :param password: the cleartext password + :type password: str + :param salt: the optional salt (randomly generated, if None) + :type salt: str + + :returns: the hashed password + :rtype: str""" + if salt is None: + salt_pool = string.ascii_letters + string.digits + salt = ''.join(random.choice(salt_pool) for x in xrange(32)) + hash_ = hashlib.sha512('%s%s' % (salt, password)) + return '$%s$%s' % (salt, hash_.hexdigest()) + + +class ImprovedEncoder(json.JSONEncoder): + """Enhance the default JSON encoder to serialize datetime and ObjectId instances.""" + def default(self, o): + if isinstance(o, (datetime.datetime, datetime.date, + datetime.time, datetime.timedelta, ObjectId)): + try: + return str(o) + except: + pass + elif isinstance(o, set): + return list(o) + return json.JSONEncoder.default(self, o) + + +# Inject our class as the default encoder. +json._default_encoder = ImprovedEncoder() +