Compare commits
5 Commits
master
...
to-aiohttp
Author | SHA1 | Date | |
---|---|---|---|
65b0a25429 | |||
14cc8ec7c4 | |||
6b159b6eda | |||
fff8a4879e | |||
f2d9a01682 |
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -97,3 +97,9 @@ ENV/
|
||||||
conf/
|
conf/
|
||||||
pos.db
|
pos.db
|
||||||
pos.log*
|
pos.log*
|
||||||
|
|
||||||
|
# vim swapfiles
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
.idea
|
||||||
|
|
139
README.md
139
README.md
|
@ -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
4
cli.py
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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]
|
|
||||||
|
|
|
@ -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.')
|
||||||
|
|
|
@ -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,28 @@ 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')
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
return all([
|
||||||
|
self.is_active,
|
||||||
|
self.created_at < datetime.now(),
|
||||||
|
self.expires_at > datetime.now()
|
||||||
|
])
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
116
pos/rest.py
Normal file
116
pos/rest.py
Normal 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
8
pos/routes.py
Normal 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)
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
|
|
@ -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 %}
|
|
|
@ -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
206
web.py
Normal file → Executable 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'))
|
||||||
|
|
Loading…
Reference in New Issue
Block a user