From 6b159b6edaed6f3d6a6a3be2f0a2f0b328b8d043 Mon Sep 17 00:00:00 2001 From: crudo Date: Sun, 24 Sep 2017 19:10:41 +0200 Subject: [PATCH] Switch from Flask to aiohttp. This repository now provides the REST API only. Implemented: * Authorization Need improvement: * Product listing Missing: * Selling --- .gitignore | 6 + README.md | 139 ++++++++++--- cli.py | 4 +- docs/config_core/core_debug_sqlite.ini | 6 +- docs/config_core/core_production_mysql.ini | 6 +- docs/config_logging/logging_debug.yaml | 9 +- docs/config_logging/logging_production.yaml | 9 +- pos/config.py | 8 +- pos/database.py | 49 ++--- pos/logging.py | 8 +- pos/rest.py | 104 ++++++++++ pos/routes.py | 8 + requirements.txt | 6 +- templates/base.html | 21 -- templates/login.html | 11 -- templates/sell.html | 36 ---- web.py | 206 ++------------------ 17 files changed, 305 insertions(+), 331 deletions(-) create mode 100644 pos/rest.py create mode 100644 pos/routes.py delete mode 100644 templates/base.html delete mode 100644 templates/login.html delete mode 100644 templates/sell.html mode change 100644 => 100755 web.py diff --git a/.gitignore b/.gitignore index 5b52655..12dad07 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,9 @@ ENV/ conf/ pos.db pos.log* + +# vim swapfiles +*.swp + +# IntelliJ +.idea diff --git a/README.md b/README.md index 54f2dd0..bc4eb3d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ examples. The default path for configurations in `conf/` but you can also use this order. For a testing environment you can just do: + ``` mkdir conf cp docs/config_core/core_debug_sqlite.ini conf/core.ini @@ -42,10 +43,10 @@ cp docs/config_logging/logging_production.yaml conf/logging.yaml and then edit the conf/core.ini file to adjust the database params. Don't forget to change the SECRET_KEY value (`openssl rand -hex 32` will help you). -If you want to change the log file path open your `conf/logging.yaml` and +If you want to change the log file path open your `conf/logging.yaml` and change the `filename` field of the `file` entry inside the `handlers` category. -## Building the database +### Building the database You also need to add some entries to the database. @@ -55,47 +56,137 @@ First of all add a new user. Get inside the virtualenv and then just do: python3 cli.py user add username password ``` +Add some categories with: + +``` +python3 cli.py category add "Birra" +python3 cli.py category add "Super" +python3 cli.py category add "Altro" +``` + Add some products with: ``` -python3 cli.py product add "Birra media" 3 -python3 cli.py product add "Birra grande" 4 -python3 cli.py product add "Cocktail" 5 -python3 cli.py product add "Vino" 4 -python3 cli.py product add "Amaro" 2 -python3 cli.py product add "Acqua" 0.5 +python3 cli.py product add -c 1 "Birra media" 3 +python3 cli.py product add -c 1 "Birra grande" 4 +python3 cli.py product add -c 2 "Vino" 4 +python3 cli.py product add -c 2 "Cocktail" 5 +python3 cli.py product add -c 2 "Amaro" 2 +python3 cli.py product add -c 3 "Acqua" 0.5 ``` And finally add and event you can play with: + ``` -python3 cli.py event add "My party" --start "2017-03-19 22:00" --end "2017-03-22 07:00" +python3 cli.py event add "My party" "2017-03-19 22:00" "2017-03-22 07:00" ``` ## Running -You can run this software with from the virtualenv with: +You can run this software within the virtualenv with: ``` python3 web.py ``` -If you want to use a read httpd you can setup uwsgi as follows: +## REST API -``` -[uwsgi] -socket = 127.0.0.1:9000 +### Authentication -chdir = /var/www/macao-pos -wsgi-file = web.py -virtualenv = env +* **URL**: `/api/token` +* **Method**: `POST` +* **Success response**: + * **Code**: 200 + * **Content**: + ``` + { + "token": "3ea90c63-4b92-465e-bee8-018a4c569252", + "created_at": "2017-09-25T18:50:38.620881", + "expires_at": "2017-09-25T18:50:38.620881" + } + ``` +* **Error response**: + * ***Malformed request*** + * **Code**: 400 + * **Content**: + ``` + { + "err": "malformed_request", + "msg": "Missing username and/or password keys." + } + ``` + * ***Invalid credentials*** + * **Code**: 400 + * **Content**: + ``` + { + "err": "invalid_credentials" + } + ``` +* **Sample call**: + ``` + curl -X POST \ + -H "Accept: application/json" \ + -d '{"username": "gino", "password": "paoli"}' \ + "http://127.0.0.1:8080/api/token" + ``` -master -workers = 1 -max-requests = 200 -harakiri = 30 -die-on-term -``` +### Logout + +* **URL**: `/api/token` +* **Method**: `DELETE` +* **Success response**: + * **Code**: 200 + * **Content**: + ``` + {} + ``` +* **Error response**: + * ***Malformed request*** + * **Code**: 400 + * **Content**: + ``` + { + "err": "malformed_request", + "msg": "Missing Authorization header." + } + ``` + * ***Unauthorizred*** + * **Code**: 401 + * **Content**: + ``` + { + "err": "unauthorized", + "msg": "The token is not valid." + } + ``` + * ***Forbidden*** + * **Code**: 403 + * **Content**: + ``` + { + "err": "forbidden", + "msg": "The token has expired." + } + ``` +* **Sample call**: + ``` + curl -X DELETE \ + -H "Authorization: 3ea90c63-4b92-465e-bee8-018a4c569252" \ + "http://127.0.0.1:8080/api/token" + ``` ## Contributing -Fork → commit → pull request +Before pushing any commit make sure flake8 doesn't complain running: + +``` +flake8 web.py cli.py pos/*.py +``` + +1. Clone this repository +2. Create a new branch +3. Make your patch. Split it in different commits if it's huge +4. Fork this repo +5. Push your branch to your fork +6. Issue a pull request diff --git a/cli.py b/cli.py index f7d9fb6..22346b8 100755 --- a/cli.py +++ b/cli.py @@ -4,14 +4,14 @@ from tabulate import tabulate from datetime import datetime from pos.config import Config -from pos.logging import init_logging, get_logger +from pos.logging import setup_logging, get_logger from pos.database import Database, User, Event, ProductCategory, Product,\ Transaction config = Config() conf_db = config.core['DATABASE'] -init_logging(config.logging) +setup_logging(config.logging) log = get_logger('cli') db = Database(**conf_db) diff --git a/docs/config_core/core_debug_sqlite.ini b/docs/config_core/core_debug_sqlite.ini index 2f847e3..2e52b01 100644 --- a/docs/config_core/core_debug_sqlite.ini +++ b/docs/config_core/core_debug_sqlite.ini @@ -1,13 +1,11 @@ [GENERAL] -Debug = True +Address = 127.0.0.1 +Port = 8080 [DATABASE] Engine = sqlite Path = pos.db -[FLASK] -SECRET_KEY = CHANGE_ME_NOW! - [PRINTER] Host = 192.168.1.100 Post = 9100 diff --git a/docs/config_core/core_production_mysql.ini b/docs/config_core/core_production_mysql.ini index 6ef2a25..9c4a923 100644 --- a/docs/config_core/core_production_mysql.ini +++ b/docs/config_core/core_production_mysql.ini @@ -1,5 +1,6 @@ [GENERAL] -Debug = False +Address = 127.0.0.1 +Port = 8080 [DATABASE] Engine = mysql+pymysql @@ -9,9 +10,6 @@ Database = pos User = pos Password = secret -[FLASK] -SECRET_KEY = CHANGE_ME_NOW! - [PRINTER] Host = 192.168.1.100 Post = 9100 diff --git a/docs/config_logging/logging_debug.yaml b/docs/config_logging/logging_debug.yaml index 4901139..9d9f5bc 100644 --- a/docs/config_logging/logging_debug.yaml +++ b/docs/config_logging/logging_debug.yaml @@ -15,9 +15,12 @@ handlers: filename: pos.log loggers: - pos: - level: DEBUG - handlers: [console,file] sqlalchemy: level: INFO handlers: [console,file] + aiohttp: + level: INFO + handlers: [console, file] + pos: + level: DEBUG + handlers: [console,file] diff --git a/docs/config_logging/logging_production.yaml b/docs/config_logging/logging_production.yaml index db6eb63..f213eb0 100644 --- a/docs/config_logging/logging_production.yaml +++ b/docs/config_logging/logging_production.yaml @@ -17,9 +17,12 @@ handlers: backupCount: 3 loggers: + sqlalchemy: + level: ERROR + handlers: [console,file] + aiohttp: + level: ERROR + handlers: [console, file] pos: level: WARNING handlers: [console,file] - sqlalchemy: - level: CRITICAL - handlers: [console,file] diff --git a/pos/config.py b/pos/config.py index 896d1c0..870f44f 100644 --- a/pos/config.py +++ b/pos/config.py @@ -3,8 +3,6 @@ from configparser import ConfigParser import yaml -APP_NAME = 'pos' - CONFIG_PATHS = ['conf/', '~/.config/pos', '/usr/local/etc/pos', '/etc/pos'] CONFIG_FILES = { 'core': 'core.ini', @@ -14,10 +12,8 @@ CONFIG_FILES = { def get_default_path(): for p in CONFIG_PATHS: - if all([ - os.path.isfile(os.path.join(p, f)) - for f in CONFIG_FILES.values() - ]): + if all([os.path.isfile(os.path.join(p, f)) + for f in CONFIG_FILES.values()]): return p else: raise Exception('Unable to find a configuration folder.') diff --git a/pos/database.py b/pos/database.py index 1f7daa0..b9aec54 100644 --- a/pos/database.py +++ b/pos/database.py @@ -1,18 +1,20 @@ -from datetime import datetime +from datetime import datetime, timedelta +from uuid import uuid4 from contextlib import contextmanager import sqlalchemy as sa import sqlalchemy.ext.declarative from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy import Table, Column, ForeignKey +from sqlalchemy import Column, ForeignKey from sqlalchemy import Integer, String, Boolean, DateTime from sqlalchemy.orm import relationship - from sqlalchemy_utils import force_auto_coercion from sqlalchemy_utils.types.password import PasswordType from pos.logging import get_logger +log = get_logger('database') + # The database URL must follow RFC 1738 in the form # dialect+driver://username:password@host:port/database @@ -23,11 +25,7 @@ ENGINE_SQLITE_MEMORY = "sqlite://" PASSWORD_SCHEMES = ['pbkdf2_sha512'] - Base = sqlalchemy.ext.declarative.declarative_base() -log = get_logger('database') - -force_auto_coercion() class Database: @@ -74,14 +72,15 @@ class Database: Base.metadata.create_all(self.engine) + force_auto_coercion() + @contextmanager def get_session(self): session = self.Session() try: yield session except SQLAlchemyError as e: - log.critical("Error performing transaction:") - log.critical(e) + log.critical("Error performing transaction: {}".format(e)) session.rollback() else: session.commit() @@ -95,12 +94,8 @@ class User(Base): username = Column(String, nullable=False, unique=True) password = Column(PasswordType(schemes=PASSWORD_SCHEMES), nullable=False) is_active = Column(Boolean, nullable=False, server_default='1') - is_authenticated = Column(Boolean, nullable=False, server_default='0') created_at = Column(DateTime, nullable=False, default=datetime.now) - def get_id(self): - return u'{}'.format(self.uid) - class Event(Base): __tablename__ = 'events' @@ -137,22 +132,14 @@ class Product(Base): category = relationship('ProductCategory', lazy='joined') -order_entry_association = Table( - 'order_entry_associations', Base.metadata, - Column('transaction_uid', Integer, ForeignKey('transactions.uid')), - Column('order_uid', Integer, ForeignKey('orders.uid')) - ) - - class Transaction(Base): __tablename__ = 'transactions' uid = Column(Integer, primary_key=True) event_uid = Column(Integer, ForeignKey('events.uid'), nullable=False) created_at = Column(DateTime, nullable=False, default=datetime.now) - event = relationship('Event') - orders = relationship('Order', lazy='joined', - secondary=order_entry_association) + event = relationship('Event', lazy='joined') + orders = relationship('Order', lazy='joined') class Order(Base): @@ -160,5 +147,21 @@ class Order(Base): uid = Column(Integer, primary_key=True) product_uid = Column(Integer, ForeignKey('products.uid'), nullable=False) quantity = Column(Integer, nullable=False) + transaction_uid = Column(Integer, ForeignKey('transactions.uid'), + nullable=False) product = relationship('Product', lazy='joined') + transaction = relationship('Transaction', lazy='joined') + + +class AccessToken(Base): + __tablename__ = 'access_tokens' + uid = Column(Integer, primary_key=True) + user_uid = Column(Integer, ForeignKey('users.uid'), nullable=False) + token = Column(String(36), nullable=False, default=str(uuid4())) + is_active = Column(Boolean, nullable=False, server_default='1') + created_at = Column(DateTime, nullable=False, default=datetime.now) + expires_at = Column(DateTime, nullable=False, + default=(datetime.now() + timedelta(days=2))) + + user = relationship('User', lazy='joined') diff --git a/pos/logging.py b/pos/logging.py index ed6eb6c..1cd0233 100644 --- a/pos/logging.py +++ b/pos/logging.py @@ -1,13 +1,11 @@ import logging import logging.config -from pos.config import APP_NAME + +root = logging.getLogger('pos') -root = logging.getLogger(APP_NAME) - - -def init_logging(config): +def setup_logging(config): logging.config.dictConfig(config) diff --git a/pos/rest.py b/pos/rest.py new file mode 100644 index 0000000..f6de7e9 --- /dev/null +++ b/pos/rest.py @@ -0,0 +1,104 @@ +from datetime import datetime +from functools import wraps +from aiohttp.web import json_response +from pos.database import User, ProductCategory, AccessToken + + +def auth_required(func): + @wraps(func) + async def wrapper(request): + db = request.app['db'] + headers = request.headers + + if 'Authorization' not in headers.keys(): + return json_response({'err': 'malformed_request', + 'msg': 'Missing Authorization header.'}, + status=400) + else: + remote_token = headers['Authorization'] + + with db.get_session() as session: + token = session.query(AccessToken) \ + .filter_by(token=remote_token) \ + .one_or_none() + + if not token: + return json_response({'err': 'unauthorized', + 'msg': 'The token is not valid.'}, + status=401) + elif ( + not token.is_active or + token.created_at > datetime.now() or + token.expires_at < datetime.now() + ): + return json_response({'err': 'forbidden', + 'msg': 'The token has expired.'}, + status=403) + else: + return await func(request) + + return wrapper + + +async def token_create(request): + db = request.app['db'] + request_json = await request.json() + + if not all(k in request_json.keys() for k in ['username', 'password']): + return json_response({'err': 'malformed_request', + 'msg': 'Missing username and/or password keys.'}, + status=400) + + with db.get_session() as session: + user = session.query(User) \ + .filter_by(username=request_json['username']) \ + .one_or_none() + + if not user or user.password != request_json['password']: + return json_response({'err': 'invalid_credentials'}, + status=400) + + with db.get_session() as session: + token = AccessToken(user=user) + session.add(token) + + return json_response({ + 'token': token.token, + 'created_at': token.created_at.isoformat(), + 'expires_at': token.created_at.isoformat() + }) + + +@auth_required +async def token_destroy(request): + db = request.app['db'] + remote_token = request.headers['Authorization'] + + with db.get_session() as session: + token = session.query(AccessToken) \ + .filter_by(token=remote_token) \ + .one_or_none() + token.is_active = False + session.add(token) + + return json_response({}, status=200) + + +@auth_required +async def product_list(request): + db = request.app['db'] + + with db.get_session() as session: + categories = session.query(ProductCategory).all() + + return json_response({ + 'categories': [{ + 'uid': c.uid, + 'name': c.name, + 'products': [{ + 'uid': p.uid, + 'name': p.name, + 'price': p.price + } for p in c.products] + } for c in categories] + }) diff --git a/pos/routes.py b/pos/routes.py new file mode 100644 index 0000000..71d7b4c --- /dev/null +++ b/pos/routes.py @@ -0,0 +1,8 @@ +from pos.rest import product_list +from pos.rest import token_create, token_destroy + + +def setup_routes(app): + app.router.add_route('POST', '/api/token', token_create) + app.router.add_route('DELETE', '/api/token', token_destroy) + app.router.add_route('GET', '/api/products', product_list) diff --git a/requirements.txt b/requirements.txt index 920c23a..1d47eea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,9 @@ +aiohttp click>=6.0 -SQLAlchemy>=1.1.0 +SQLAlchemy>=1.2.0 sqlalchemy_utils>=0.32.00 pymysql -babel passlib tabulate PyYAML -flask>=0.12.0 -flask_login>=0.4.0 python-escpos diff --git a/templates/base.html b/templates/base.html deleted file mode 100644 index 7d403e1..0000000 --- a/templates/base.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - {{ title }} - - - -
{% block main %}{% endblock %}
-{% with alerts = get_flashed_messages(with_categories=True) %} -{% if alerts %}{% for category, message in alerts %} -
{{ message }}
-{% endfor %}{% endif %} -{% endwith %} - - diff --git a/templates/login.html b/templates/login.html deleted file mode 100644 index bf5a88b..0000000 --- a/templates/login.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends 'base.html' %} -{% block main %} -

{{ title }}

-
- - - - - -
-{% endblock %} diff --git a/templates/sell.html b/templates/sell.html deleted file mode 100644 index 8b8c66f..0000000 --- a/templates/sell.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends 'base.html' %} -{% block main %} -
-{% for product in products %} - -{% endfor %} -
-
-
-
-{% for product in products %} - -{% endfor %} -

Total: € 0.00

-
- -{% endblock %} diff --git a/web.py b/web.py old mode 100644 new mode 100755 index 39bd095..5e4e5d1 --- a/web.py +++ b/web.py @@ -1,197 +1,33 @@ -from datetime import datetime -from time import sleep -import escpos.printer - -from flask import Flask, redirect, request, render_template, flash -from flask_login import LoginManager, login_user, logout_user, login_required +#!/usr/bin/env python3 +import asyncio +from aiohttp import web from pos.config import Config -from pos.logging import init_logging, get_logger -from pos.database import Database, User, Product, Event, Transaction, Order +from pos.logging import setup_logging, get_logger +from pos.database import Database +from pos.routes import setup_routes - -config = Config() -debug = config.core['GENERAL'].getboolean('Debug', False) -conf_db = config.core['DATABASE'] -conf_printer = config.core['PRINTER'] -conf_flask = config.core['FLASK'] - -init_logging(config.logging) log = get_logger('web') -printer = escpos.printer.Network( - conf_printer['Host'], - port=conf_printer.getint('Port'), - timeout=15) +def setup_app(loop, config): + app = web.Application(loop=loop) -db = Database(**conf_db) + app['config'] = config + app['db'] = Database(**config.core['DATABASE']) -app = Flask(__name__) -app.config.update( - debug=debug, - SECRET_KEY=conf_flask['SECRET_KEY'] -) + setup_routes(app) -login_manager = LoginManager() -login_manager.init_app(app) -login_manager.login_view = 'login' -login_manager.login_message_category = 'error' - - -@login_manager.user_loader -def load_user(uid): - user = None - with db.get_session() as session: - user = session.query(User).get(uid) - return user - - -@app.route('/') -def home(): - return redirect('/sell') - - -@app.route('/login', methods=['GET']) -def login_page(): - return render_template('login.html', title='Login') - - -@app.route('/login', methods=['POST']) -def login(): - username = request.form['username'] - password = request.form['password'] - - if not all([username, password]): - flash("Fill all fields.", 'error') - return login_page(), 400 - - with db.get_session() as session: - user = session.query(User)\ - .filter(User.username == username)\ - .filter(User.is_active == 1)\ - .one_or_none() - - if user is None: - flash("User not found.", 'error') - return login_page(), 400 - - if user.password == password: - login_user(user) - session.add(user) - user.is_authenticated = True - flash("Succesfully logged in.", 'success') - return redirect('/') - else: - flash("Wrong password.", 'error') - return login_page(), 400 - - -@app.route('/logout') -def logout(): - logout_user() - return redirect('/') - - -def get_products(session): - return session.query(Product)\ - .filter(Product.is_active == 1)\ - .order_by(Product.sort.asc())\ - .all() - - -def get_event(session): - return session.query(Event)\ - .filter(Event.starts_at < datetime.now())\ - .filter((Event.ends_at.is_(None)) - | (Event.ends_at > datetime.now()))\ - .one_or_none() - - -@app.route('/sell', methods=['GET']) -@login_required -def sell_page(): - with db.get_session() as session: - products = get_products(session) - event = get_event(session) - - return render_template('sell.html', - title='Sell', event=event, products=products) - - -def print_orders(transaction, cat, orders): - printer.open() - - printer.set(align='CENTER') - printer.image('static/img/macao-logo-printer.png', impl='bitImageColumn') - - printer.set(align='CENTER', text_type='B') - printer.text(transaction.event.name.upper()) - printer.text("\n\n") - - for o in orders: - printer.set(align='LEFT', width=2, height=2) - printer.text("{} x {}".format(o.quantity, o.product.name.upper())) - printer.text("\n") - printer.text("\n") - - printer.set(align='RIGHT') - printer.text("{} #{}-{}-{}" - .format(datetime.strftime(transaction.created_at, - "%Y-%m-%d %H:%M"), - transaction.event.uid, transaction.uid, cat)) - - printer.cut() - printer.close() - sleep(0.7) - - -def print_transaction(transaction): - categorized_orders = {} - - for o in transaction.orders: - uid = o.product.category_uid - - if not categorized_orders.get(uid, False): - categorized_orders[uid] = [] - - categorized_orders[uid].append(o) - - for cat, orders in categorized_orders.items(): - print_orders(transaction, cat, orders) - - -@app.route('/sell', methods=['POST']) -@login_required -def sell(): - with db.get_session() as session: - event = get_event(session) - - if event is None: - flash("Can't perform order without an ongoing event!", 'error') - return sell_page() - - quantities = request.form.values() - if not any(int(qty) > 0 for qty in quantities): - flash("Can't perform empty order!", 'error') - return sell_page() - - with db.get_session() as session: - items = request.form.items() - orders = [ - Order(product_uid=uid, quantity=qty) - for uid, qty in items - if int(qty) > 0 - ] - - transaction = Transaction(event_uid=event.uid, orders=orders) - session.add(transaction) - session.flush() - print_transaction(transaction) - - flash("Success!", 'success') - return sell_page() + return app if __name__ == '__main__': - app.run(host='0.0.0.0', port=8080, debug=debug) + config = Config() + setup_logging(config.logging) + + loop = asyncio.get_event_loop() + + app = setup_app(loop, config) + web.run_app(app, + host=config.core.get('GENERAL', 'Address'), + port=config.core.getint('GENERAL', 'Port'))