Compare commits

..

3 Commits

Author SHA1 Message Date
crudo
68b59ce478 Merge branch 'master' of subnixr/macao-pos into master 2017-03-26 21:25:55 +02:00
1b3fa43a23 Clean little code 2017-03-26 21:17:07 +02:00
257eba61b0 Add floating point input 2017-03-26 21:09:46 +02:00
17 changed files with 331 additions and 324 deletions

6
.gitignore vendored
View File

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

137
README.md
View File

@ -23,7 +23,6 @@ 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
@ -46,7 +45,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.
@ -56,137 +55,47 @@ 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 -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
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
```
And finally add and event you can play with:
```
python3 cli.py event add "My party" "2017-03-19 22:00" "2017-03-22 07:00"
python3 cli.py event add "My party" --start "2017-03-19 22:00" --end "2017-03-22 07:00"
```
## Running
You can run this software within the virtualenv with:
You can run this software with from the virtualenv with:
```
python3 web.py
```
## REST API
If you want to use a read httpd you can setup uwsgi as follows:
### Authentication
```
[uwsgi]
socket = 127.0.0.1:9000
* **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"
```
chdir = /var/www/macao-pos
wsgi-file = web.py
virtualenv = env
### 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"
```
master
workers = 1
max-requests = 200
harakiri = 30
die-on-term
```
## Contributing
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
Fork → commit → 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 setup_logging, get_logger
from pos.logging import init_logging, get_logger
from pos.database import Database, User, Event, ProductCategory, Product,\
Transaction
config = Config()
conf_db = config.core['DATABASE']
setup_logging(config.logging)
init_logging(config.logging)
log = get_logger('cli')
db = Database(**conf_db)

View File

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

View File

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

View File

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

View File

@ -17,12 +17,9 @@ 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,6 +3,8 @@ 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',
@ -12,8 +14,10 @@ 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,20 +1,18 @@
from datetime import datetime, timedelta
from uuid import uuid4
from datetime import datetime
from contextlib import contextmanager
import sqlalchemy as sa
import sqlalchemy.ext.declarative
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import Column, ForeignKey
from sqlalchemy import Table, 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
@ -25,7 +23,11 @@ ENGINE_SQLITE_MEMORY = "sqlite://"
PASSWORD_SCHEMES = ['pbkdf2_sha512']
Base = sqlalchemy.ext.declarative.declarative_base()
log = get_logger('database')
force_auto_coercion()
class Database:
@ -72,15 +74,14 @@ 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: {}".format(e))
log.critical("Error performing transaction:")
log.critical(e)
session.rollback()
else:
session.commit()
@ -94,8 +95,12 @@ 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'
@ -132,14 +137,22 @@ 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', lazy='joined')
orders = relationship('Order', lazy='joined')
event = relationship('Event')
orders = relationship('Order', lazy='joined',
secondary=order_entry_association)
class Order(Base):
@ -147,28 +160,5 @@ 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,11 +1,13 @@
import logging
import logging.config
root = logging.getLogger('pos')
from pos.config import APP_NAME
def setup_logging(config):
root = logging.getLogger(APP_NAME)
def init_logging(config):
logging.config.dictConfig(config)

View File

@ -1,116 +0,0 @@
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]
})

View File

@ -1,8 +0,0 @@
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,9 +1,11 @@
aiohttp
click>=6.0
SQLAlchemy>=1.2.0
SQLAlchemy>=1.1.0
sqlalchemy_utils>=0.32.00
pymysql
babel
passlib
tabulate
PyYAML
flask>=0.12.0
flask_login>=0.4.0
python-escpos

21
templates/base.html Normal file
View File

@ -0,0 +1,21 @@
<!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>

11
templates/login.html Normal file
View File

@ -0,0 +1,11 @@
{% 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 %}

36
templates/sell.html Normal file
View File

@ -0,0 +1,36 @@
{% 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 Executable file → Normal file
View File

@ -1,33 +1,197 @@
#!/usr/bin/env python3
import asyncio
from aiohttp import web
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
from pos.config import Config
from pos.logging import setup_logging, get_logger
from pos.database import Database
from pos.routes import setup_routes
from pos.logging import init_logging, get_logger
from pos.database import Database, User, Product, Event, Transaction, Order
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)
app['config'] = config
app['db'] = Database(**config.core['DATABASE'])
db = Database(**conf_db)
setup_routes(app)
app = Flask(__name__)
app.config.update(
debug=debug,
SECRET_KEY=conf_flask['SECRET_KEY']
)
return 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()
if __name__ == '__main__':
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'))
app.run(host='0.0.0.0', port=8080, debug=debug)