Compare commits

..

7 Commits

Author SHA1 Message Date
Notisset
836186f58b Application bound address automatically replaced in dockerfile. 2017-09-27 00:38:18 +02:00
Notisset
abb76836a1 Added Docker + docker-compose configuration 2017-09-26 00:52:06 +02:00
65b0a25429 Add declarative JSON requests. 2017-09-25 21:46:23 +02:00
14cc8ec7c4 Refactor token validity check. 2017-09-25 21:22:41 +02:00
6b159b6eda Switch from Flask to aiohttp.
This repository now provides the REST API only.

Implemented:
 * Authorization

Need improvement:
 * Product listing

Missing:
 * Selling
2017-09-25 19:38:45 +02:00
fff8a4879e Clean little code 2017-09-25 15:02:14 +02:00
f2d9a01682 Add floating point input 2017-09-25 15:02:14 +02:00
20 changed files with 350 additions and 331 deletions

2
.dockerignore Normal file
View File

@ -0,0 +1,2 @@
.git
.idea

6
.gitignore vendored
View File

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

14
Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM alpine:latest
MAINTAINER Notisset <notisset@autistici.org>
#Pillow requires the following dependencies to build: musl-dev zlib-dev jpeg-dev gcc python3-dev
RUN apk update && apk add musl-dev zlib-dev jpeg-dev gcc python3 python3-dev
COPY requirements.txt .
RUN pip3 install -r requirements.txt
COPY . /var/www/site
RUN sed -i '/Address/s/= .*/= 0.0.0.0/' /var/www/site/conf/core.ini
EXPOSE 8080
ENTRYPOINT python3 /var/www/site/web.py

133
README.md
View File

@ -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
@ -45,7 +46,7 @@ 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
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
### Authentication
* **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"
```
[uwsgi]
socket = 127.0.0.1:9000
chdir = /var/www/macao-pos
wsgi-file = web.py
virtualenv = env
### Logout
master
workers = 1
max-requests = 200
harakiri = 30
die-on-term
* **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

4
cli.py
View File

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

10
docker-compose.yml Normal file
View File

@ -0,0 +1,10 @@
version: '2'
services:
app:
build: .
restart: always
ports:
- "8080:8080"
volumes:
- .:/var/www/site
working_dir: /var/www/site

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,28 @@ 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')
def is_valid(self):
return all([
self.is_active,
self.created_at < datetime.now(),
self.expires_at > datetime.now()
])

View File

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

116
pos/rest.py Normal file
View File

@ -0,0 +1,116 @@
from functools import wraps
from aiohttp.web import json_response
from pos.database import User, ProductCategory, AccessToken
def needs(*needed):
def decorator(func):
@wraps(func)
async def wrapper(request):
request_json = await request.json()
if not all(k in request_json.keys() for k in needed) or
return json_response({
'err': 'malformed_request',
'msg': 'Missing one or more keys: {}.'.format(
", ".join(needed))
}, status=400)
else:
return func(request)
return wrapper
return decorator
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_valid():
return json_response({'err': 'forbidden',
'msg': 'The token has expired.'},
status=403)
else:
return await func(request)
return wrapper
@needs('username', 'password')
async def token_create(request):
db = request.app['db']
request_json = await request.json()
username = request_json['username']
password = request_json['password']
with db.get_session() as session:
user = session.query(User) \
.filter_by(username=username) \
.one_or_none()
if not user or user.password != 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
SQLAlchemy>=1.1.0
SQLAlchemy>=1.2.0b1
sqlalchemy_utils>=0.32.00
pymysql
babel
passlib
tabulate
PyYAML
flask>=0.12.0
flask_login>=0.4.0
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
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'))