Switch from Flask to aiohttp.

This repository now provides the REST API only.

Implemented:
 * Authorization

Need improvement:
 * Product listing

Missing:
 * Selling
This commit is contained in:
crudo 2017-09-24 19:10:41 +02:00
parent fff8a4879e
commit 6b159b6eda
17 changed files with 305 additions and 331 deletions

6
.gitignore vendored
View File

@ -97,3 +97,9 @@ ENV/
conf/ conf/
pos.db pos.db
pos.log* pos.log*
# vim swapfiles
*.swp
# IntelliJ
.idea

139
README.md
View File

@ -23,6 +23,7 @@ examples. The default path for configurations in `conf/` but you can also use
this order. this order.
For a testing environment you can just do: For a testing environment you can just do:
``` ```
mkdir conf mkdir conf
cp docs/config_core/core_debug_sqlite.ini conf/core.ini 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 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). 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. 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. 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 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: Add some products with:
``` ```
python3 cli.py product add "Birra media" 3 python3 cli.py product add -c 1 "Birra media" 3
python3 cli.py product add "Birra grande" 4 python3 cli.py product add -c 1 "Birra grande" 4
python3 cli.py product add "Cocktail" 5 python3 cli.py product add -c 2 "Vino" 4
python3 cli.py product add "Vino" 4 python3 cli.py product add -c 2 "Cocktail" 5
python3 cli.py product add "Amaro" 2 python3 cli.py product add -c 2 "Amaro" 2
python3 cli.py product add "Acqua" 0.5 python3 cli.py product add -c 3 "Acqua" 0.5
``` ```
And finally add and event you can play with: 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 ## Running
You can run this software with from the virtualenv with: You can run this software within the virtualenv with:
``` ```
python3 web.py python3 web.py
``` ```
If you want to use a read httpd you can setup uwsgi as follows: ## REST API
``` ### Authentication
[uwsgi]
socket = 127.0.0.1:9000
chdir = /var/www/macao-pos * **URL**: `/api/token`
wsgi-file = web.py * **Method**: `POST`
virtualenv = env * **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 ### Logout
workers = 1
max-requests = 200 * **URL**: `/api/token`
harakiri = 30 * **Method**: `DELETE`
die-on-term * **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 ## 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

4
cli.py
View File

@ -4,14 +4,14 @@ from tabulate import tabulate
from datetime import datetime from datetime import datetime
from pos.config import Config 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,\ from pos.database import Database, User, Event, ProductCategory, Product,\
Transaction Transaction
config = Config() config = Config()
conf_db = config.core['DATABASE'] conf_db = config.core['DATABASE']
init_logging(config.logging) setup_logging(config.logging)
log = get_logger('cli') log = get_logger('cli')
db = Database(**conf_db) db = Database(**conf_db)

View File

@ -1,13 +1,11 @@
[GENERAL] [GENERAL]
Debug = True Address = 127.0.0.1
Port = 8080
[DATABASE] [DATABASE]
Engine = sqlite Engine = sqlite
Path = pos.db Path = pos.db
[FLASK]
SECRET_KEY = CHANGE_ME_NOW!
[PRINTER] [PRINTER]
Host = 192.168.1.100 Host = 192.168.1.100
Post = 9100 Post = 9100

View File

@ -1,5 +1,6 @@
[GENERAL] [GENERAL]
Debug = False Address = 127.0.0.1
Port = 8080
[DATABASE] [DATABASE]
Engine = mysql+pymysql Engine = mysql+pymysql
@ -9,9 +10,6 @@ Database = pos
User = pos User = pos
Password = secret Password = secret
[FLASK]
SECRET_KEY = CHANGE_ME_NOW!
[PRINTER] [PRINTER]
Host = 192.168.1.100 Host = 192.168.1.100
Post = 9100 Post = 9100

View File

@ -15,9 +15,12 @@ handlers:
filename: pos.log filename: pos.log
loggers: loggers:
pos:
level: DEBUG
handlers: [console,file]
sqlalchemy: sqlalchemy:
level: INFO level: INFO
handlers: [console,file] handlers: [console,file]
aiohttp:
level: INFO
handlers: [console, file]
pos:
level: DEBUG
handlers: [console,file]

View File

@ -17,9 +17,12 @@ handlers:
backupCount: 3 backupCount: 3
loggers: loggers:
sqlalchemy:
level: ERROR
handlers: [console,file]
aiohttp:
level: ERROR
handlers: [console, file]
pos: pos:
level: WARNING level: WARNING
handlers: [console,file] handlers: [console,file]
sqlalchemy:
level: CRITICAL
handlers: [console,file]

View File

@ -3,8 +3,6 @@ from configparser import ConfigParser
import yaml import yaml
APP_NAME = 'pos'
CONFIG_PATHS = ['conf/', '~/.config/pos', '/usr/local/etc/pos', '/etc/pos'] CONFIG_PATHS = ['conf/', '~/.config/pos', '/usr/local/etc/pos', '/etc/pos']
CONFIG_FILES = { CONFIG_FILES = {
'core': 'core.ini', 'core': 'core.ini',
@ -14,10 +12,8 @@ CONFIG_FILES = {
def get_default_path(): def get_default_path():
for p in CONFIG_PATHS: for p in CONFIG_PATHS:
if all([ if all([os.path.isfile(os.path.join(p, f))
os.path.isfile(os.path.join(p, f)) for f in CONFIG_FILES.values()]):
for f in CONFIG_FILES.values()
]):
return p return p
else: else:
raise Exception('Unable to find a configuration folder.') raise Exception('Unable to find a configuration folder.')

View File

@ -1,18 +1,20 @@
from datetime import datetime from datetime import datetime, timedelta
from uuid import uuid4
from contextlib import contextmanager from contextlib import contextmanager
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.ext.declarative import sqlalchemy.ext.declarative
from sqlalchemy.exc import SQLAlchemyError 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 import Integer, String, Boolean, DateTime
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy_utils import force_auto_coercion from sqlalchemy_utils import force_auto_coercion
from sqlalchemy_utils.types.password import PasswordType from sqlalchemy_utils.types.password import PasswordType
from pos.logging import get_logger from pos.logging import get_logger
log = get_logger('database')
# The database URL must follow RFC 1738 in the form # The database URL must follow RFC 1738 in the form
# dialect+driver://username:password@host:port/database # dialect+driver://username:password@host:port/database
@ -23,11 +25,7 @@ ENGINE_SQLITE_MEMORY = "sqlite://"
PASSWORD_SCHEMES = ['pbkdf2_sha512'] PASSWORD_SCHEMES = ['pbkdf2_sha512']
Base = sqlalchemy.ext.declarative.declarative_base() Base = sqlalchemy.ext.declarative.declarative_base()
log = get_logger('database')
force_auto_coercion()
class Database: class Database:
@ -74,14 +72,15 @@ class Database:
Base.metadata.create_all(self.engine) Base.metadata.create_all(self.engine)
force_auto_coercion()
@contextmanager @contextmanager
def get_session(self): def get_session(self):
session = self.Session() session = self.Session()
try: try:
yield session yield session
except SQLAlchemyError as e: except SQLAlchemyError as e:
log.critical("Error performing transaction:") log.critical("Error performing transaction: {}".format(e))
log.critical(e)
session.rollback() session.rollback()
else: else:
session.commit() session.commit()
@ -95,12 +94,8 @@ class User(Base):
username = Column(String, nullable=False, unique=True) username = Column(String, nullable=False, unique=True)
password = Column(PasswordType(schemes=PASSWORD_SCHEMES), nullable=False) password = Column(PasswordType(schemes=PASSWORD_SCHEMES), nullable=False)
is_active = Column(Boolean, nullable=False, server_default='1') 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) created_at = Column(DateTime, nullable=False, default=datetime.now)
def get_id(self):
return u'{}'.format(self.uid)
class Event(Base): class Event(Base):
__tablename__ = 'events' __tablename__ = 'events'
@ -137,22 +132,14 @@ class Product(Base):
category = relationship('ProductCategory', lazy='joined') 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): class Transaction(Base):
__tablename__ = 'transactions' __tablename__ = 'transactions'
uid = Column(Integer, primary_key=True) uid = Column(Integer, primary_key=True)
event_uid = Column(Integer, ForeignKey('events.uid'), nullable=False) event_uid = Column(Integer, ForeignKey('events.uid'), nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.now) created_at = Column(DateTime, nullable=False, default=datetime.now)
event = relationship('Event') event = relationship('Event', lazy='joined')
orders = relationship('Order', lazy='joined', orders = relationship('Order', lazy='joined')
secondary=order_entry_association)
class Order(Base): class Order(Base):
@ -160,5 +147,21 @@ class Order(Base):
uid = Column(Integer, primary_key=True) uid = Column(Integer, primary_key=True)
product_uid = Column(Integer, ForeignKey('products.uid'), nullable=False) product_uid = Column(Integer, ForeignKey('products.uid'), nullable=False)
quantity = Column(Integer, nullable=False) quantity = Column(Integer, nullable=False)
transaction_uid = Column(Integer, ForeignKey('transactions.uid'),
nullable=False)
product = relationship('Product', lazy='joined') 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')

View File

@ -1,13 +1,11 @@
import logging import logging
import logging.config import logging.config
from pos.config import APP_NAME
root = logging.getLogger('pos')
root = logging.getLogger(APP_NAME) def setup_logging(config):
def init_logging(config):
logging.config.dictConfig(config) logging.config.dictConfig(config)

104
pos/rest.py Normal file
View File

@ -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]
})

8
pos/routes.py Normal file
View File

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

View File

@ -1,11 +1,9 @@
aiohttp
click>=6.0 click>=6.0
SQLAlchemy>=1.1.0 SQLAlchemy>=1.2.0
sqlalchemy_utils>=0.32.00 sqlalchemy_utils>=0.32.00
pymysql pymysql
babel
passlib passlib
tabulate tabulate
PyYAML PyYAML
flask>=0.12.0
flask_login>=0.4.0
python-escpos python-escpos

View File

@ -1,21 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet"
href="{{ url_for('static', filename='style/reset.css') }}">
<link rel="stylesheet"
href="{{ url_for('static', filename='style/screen.css') }}">
<title>{{ title }}</title>
</head>
<body>
<main>{% block main %}{% endblock %}</main>
{% with alerts = get_flashed_messages(with_categories=True) %}
{% if alerts %}{% for category, message in alerts %}
<div class="alert {{ category }}">{{ message }}</div>
{% endfor %}{% endif %}
{% endwith %}
</body>
</html>

View File

@ -1,11 +0,0 @@
{% extends 'base.html' %}
{% block main %}
<h1>{{ title }}</h1>
<form id="login" action="/login" method="post">
<label for="username">Username</label>
<input id="username" name="username" type="text" required autofocus>
<label for="password">Password</label>
<input id="password" name="password" type="password" required>
<button type="submit">Login</button>
</form>
{% endblock %}

View File

@ -1,36 +0,0 @@
{% extends 'base.html' %}
{% block main %}
<div id="products">
{% for product in products %}
<button type="button"
onclick="addProduct({{ product.uid }})">
<span class="name">{{ product.name }}</span>
<span class="price">
€ {{ '{0:.02f}'.format(product.price / 100.0) }}
</span>
</button>
{% endfor %}
</div>
<div id="summary">
<div id="basket"></div>
<form id="sell" action="/sell" method="POST">
{% for product in products %}
<input type="hidden"
id="prod_{{ product.uid }}"
name="{{ product.uid }}"
data-name="{{ product.name }}"
data-price="{{ '{0:.02f}'.format(product.price / 100.0) }}"
value="0">
{% endfor %}
<p>Total: € <span id="total">0.00</span></p>
<button type="submit">Print</input>
</form>
</div>
<script type="text/javascript" src="{{ url_for('static', filename='js/sell.js') }}">
<script type="text/javascript">
{%- for product in products %}
document.getElementById('prod_{{ product.uid }}').value = 0
{%- endfor %}
</script>
{% endblock %}

206
web.py Normal file → Executable file
View File

@ -1,197 +1,33 @@
from datetime import datetime #!/usr/bin/env python3
from time import sleep import asyncio
import escpos.printer from aiohttp import web
from flask import Flask, redirect, request, render_template, flash
from flask_login import LoginManager, login_user, logout_user, login_required
from pos.config import Config 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, Product, Event, Transaction, Order 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') 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__) setup_routes(app)
app.config.update(
debug=debug,
SECRET_KEY=conf_flask['SECRET_KEY']
)
login_manager = LoginManager() return app
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()
if __name__ == '__main__': 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'))