forked from crudo/macao-pos
Import.
This commit is contained in:
commit
e7874a10ee
98
.gitignore
vendored
Normal file
98
.gitignore
vendored
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*,cover
|
||||||
|
.hypothesis/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# celery beat schedule file
|
||||||
|
celerybeat-schedule
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# dotenv
|
||||||
|
.env
|
||||||
|
|
||||||
|
# virtualenv
|
||||||
|
.venv
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# macao-pos files
|
||||||
|
pos.db
|
||||||
|
pos.log
|
101
README.md
Normal file
101
README.md
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
# macao-pos
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
The only requirements are Python 3 and virtualenv as you're going to install all
|
||||||
|
the modules through pip.
|
||||||
|
|
||||||
|
If you're using MySQL or PostgreSQL create now a new user and database. You can
|
||||||
|
also use SQLite for testing purposes.
|
||||||
|
|
||||||
|
```
|
||||||
|
cd /var/www
|
||||||
|
git clone ...
|
||||||
|
cd macao-pos
|
||||||
|
virtualenv -p python3 env
|
||||||
|
source env/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you need to configure this software. Inside `doc/` you'll find some
|
||||||
|
examples. The default path for configurations in `conf/` but you can also use
|
||||||
|
`~/.config/pos`, `/usr/local/etc/pos` and `/etc/pos` that will be checked in
|
||||||
|
this order.
|
||||||
|
|
||||||
|
For a testing environment you can just do:
|
||||||
|
```
|
||||||
|
mkdir conf
|
||||||
|
cp docs/config_core/core_debug_sqlite.ini conf/core.ini
|
||||||
|
cp docs/config_logging/logging_debug.yaml conf/logging.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
and you're ready to go.
|
||||||
|
|
||||||
|
For a production environment:
|
||||||
|
```
|
||||||
|
cd /var/www/macao-pos
|
||||||
|
mkdir conf
|
||||||
|
cp docs/config_core/core_production_mysql.ini conf/core.ini
|
||||||
|
cp docs/config_logging/logging_production.yaml conf/logging.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
and then edit the conf/core.ini file to adjust the database params. Don't forget
|
||||||
|
to change the SECRET_KEY value (`openssl rand -hex 32` will help you).
|
||||||
|
|
||||||
|
If you want to change the log file path open your `conf/logging.yaml` and
|
||||||
|
change the `filename` field of the `file` entry inside the `handlers` category.
|
||||||
|
|
||||||
|
## Building the database
|
||||||
|
|
||||||
|
You also need to add some entries to the database.
|
||||||
|
|
||||||
|
First of all add a new user. Get inside the virtualenv and then just do:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 cli.py user add username password
|
||||||
|
```
|
||||||
|
|
||||||
|
Add some products with:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 cli.py product add "Birra media" 300
|
||||||
|
python3 cli.py product add "Birra grande" 400
|
||||||
|
python3 cli.py product add "Cocktail" 500
|
||||||
|
python3 cli.py product add "Vino" 400
|
||||||
|
python3 cli.py product add "Amaro" 200
|
||||||
|
python3 cli.py product add "Acqua" 100
|
||||||
|
```
|
||||||
|
|
||||||
|
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"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
You can run this software with from the virtualenv with:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 web.py
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to use a read httpd you can setup uwsgi as follows:
|
||||||
|
|
||||||
|
```
|
||||||
|
[uwsgi]
|
||||||
|
socket = 127.0.0.1:9000
|
||||||
|
|
||||||
|
chdir = /var/www/macao-pos
|
||||||
|
wsgi-file = web.py
|
||||||
|
virtualenv = env
|
||||||
|
|
||||||
|
master
|
||||||
|
workers = 1
|
||||||
|
max-requests = 200
|
||||||
|
harakiri = 30
|
||||||
|
die-on-term
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Fork → commit → pull request
|
165
cli.py
Normal file
165
cli.py
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
import click
|
||||||
|
from tabulate import tabulate
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pos.config import Config
|
||||||
|
from pos.logging import init_logging, get_logger
|
||||||
|
from pos.database import Database, User, Event, Product, Order
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
conf_db = config.core['DATABASE']
|
||||||
|
|
||||||
|
init_logging(config.logging)
|
||||||
|
log = get_logger('cli')
|
||||||
|
|
||||||
|
db = Database(**conf_db)
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def cli():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group('user')
|
||||||
|
def user():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def tabulate_users(users):
|
||||||
|
tab = [["UID", "Username", "Enabled", "Created at"]]
|
||||||
|
for u in users:
|
||||||
|
tab.append([u.uid, u.username, u.is_active, u.created_at])
|
||||||
|
return tabulate(tab, headers='firstrow')
|
||||||
|
|
||||||
|
|
||||||
|
@user.command('add')
|
||||||
|
@click.argument('username')
|
||||||
|
@click.argument('password')
|
||||||
|
def user_add(username, password):
|
||||||
|
user = User(username=username, password=password)
|
||||||
|
with db.get_session() as session:
|
||||||
|
session.add(user)
|
||||||
|
print("User succesfully added.")
|
||||||
|
|
||||||
|
|
||||||
|
@user.command('list')
|
||||||
|
def user_list():
|
||||||
|
users = None
|
||||||
|
with db.get_session() as session:
|
||||||
|
users = session.query(User).all()
|
||||||
|
if len(users) == 0:
|
||||||
|
print("No users found.")
|
||||||
|
return
|
||||||
|
print(tabulate_users(users))
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group('event')
|
||||||
|
def event():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def tabulate_events(events):
|
||||||
|
tab = [["UID", "Name", "Starts at", "Ends at", "Income", "Created at"]]
|
||||||
|
for e in events:
|
||||||
|
sum = 0
|
||||||
|
for order in e.orders:
|
||||||
|
for entry in order.entries:
|
||||||
|
sum += entry.product.price * entry.quantity
|
||||||
|
tab.append([e.uid, e.name, e.starts_at, e.ends_at, sum, e.created_at])
|
||||||
|
return tabulate(tab, headers='firstrow')
|
||||||
|
|
||||||
|
|
||||||
|
@event.command('add')
|
||||||
|
@click.argument('name')
|
||||||
|
@click.option('--start')
|
||||||
|
@click.option('--end')
|
||||||
|
def event_add(name, start, end):
|
||||||
|
start = datetime.strptime(start, "%Y-%m-%d %H:%M")
|
||||||
|
end = datetime.strptime(end, "%Y-%m-%d %H:%M")
|
||||||
|
event = Event(name=name, starts_at=start, ends_at=end)
|
||||||
|
with db.get_session() as session:
|
||||||
|
session.add(event)
|
||||||
|
print("Event succesfully added.")
|
||||||
|
|
||||||
|
|
||||||
|
@event.command('list')
|
||||||
|
def event_list():
|
||||||
|
events = None
|
||||||
|
with db.get_session() as session:
|
||||||
|
events = session.query(Event).all()
|
||||||
|
if len(events) == 0:
|
||||||
|
print("No events found.")
|
||||||
|
return
|
||||||
|
print(tabulate_events(events))
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group('product')
|
||||||
|
def product():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def tabulate_products(products):
|
||||||
|
tab = [["UID", "Name", "Price", "Currency", "Enabled", "Created at"]]
|
||||||
|
for p in products:
|
||||||
|
tab.append([p.uid, p.name, p.price, p.currency,
|
||||||
|
p.is_active, p.created_at])
|
||||||
|
return tabulate(tab, headers='firstrow')
|
||||||
|
|
||||||
|
|
||||||
|
@product.command('add')
|
||||||
|
@click.argument('name')
|
||||||
|
@click.argument('price')
|
||||||
|
@click.option('--currency')
|
||||||
|
def product_add(name, price, currency):
|
||||||
|
product = Product(name=name, currency=currency, price=price)
|
||||||
|
with db.get_session() as session:
|
||||||
|
session.add(product)
|
||||||
|
print("Product succesfully added.")
|
||||||
|
|
||||||
|
|
||||||
|
@product.command('list')
|
||||||
|
def product_list():
|
||||||
|
products = None
|
||||||
|
with db.get_session() as session:
|
||||||
|
products = session.query(Product).all()
|
||||||
|
if len(products) == 0:
|
||||||
|
print("No products found.")
|
||||||
|
return
|
||||||
|
print(tabulate_products(products))
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group('order')
|
||||||
|
def order():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def tabulate_orders(orders):
|
||||||
|
text = []
|
||||||
|
for o in orders:
|
||||||
|
text.append("Listing order #{} ({}):".format(o.uid, o.created_at))
|
||||||
|
tab = [["Product", "Quantity", "Total"]]
|
||||||
|
total = 0
|
||||||
|
for e in o.entries:
|
||||||
|
if e.quantity != 0:
|
||||||
|
tab.append([e.product.name, e.quantity,
|
||||||
|
e.product.price * e.quantity])
|
||||||
|
total += e.product.price * e.quantity
|
||||||
|
text.append(tabulate(tab, headers='firstrow'))
|
||||||
|
text.append("Total: {}".format(total))
|
||||||
|
text.append('\n')
|
||||||
|
return '\n'.join(text)
|
||||||
|
|
||||||
|
|
||||||
|
@order.command('list')
|
||||||
|
def order_list():
|
||||||
|
orders = None
|
||||||
|
with db.get_session() as session:
|
||||||
|
orders = session.query(Order).all()
|
||||||
|
if len(orders) == 0:
|
||||||
|
print("No orders found.")
|
||||||
|
return
|
||||||
|
print(tabulate_orders(orders))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
cli()
|
9
docs/config_core/core_debug_sqlite.ini
Normal file
9
docs/config_core/core_debug_sqlite.ini
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
[GENERAL]
|
||||||
|
Debug = True
|
||||||
|
|
||||||
|
[DATABASE]
|
||||||
|
Engine = sqlite
|
||||||
|
Path = pos.db
|
||||||
|
|
||||||
|
[FLASK]
|
||||||
|
SECRET_KEY = CHANGE_ME_NOW!
|
13
docs/config_core/core_production_mysql.ini
Normal file
13
docs/config_core/core_production_mysql.ini
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
[GENERAL]
|
||||||
|
Debug = False
|
||||||
|
|
||||||
|
[DATABASE]
|
||||||
|
Engine = mysql+pymysql
|
||||||
|
Host = 127.0.0.1
|
||||||
|
Port = 3306
|
||||||
|
Database = pos
|
||||||
|
User = pos
|
||||||
|
Password = secret
|
||||||
|
|
||||||
|
[FLASK]
|
||||||
|
SECRET_KEY = CHANGE_ME_NOW!
|
23
docs/config_logging/logging_debug.yaml
Normal file
23
docs/config_logging/logging_debug.yaml
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
default:
|
||||||
|
format: '[%(name)s %(levelname)s] %(message)s'
|
||||||
|
|
||||||
|
handlers:
|
||||||
|
console:
|
||||||
|
class: logging.StreamHandler
|
||||||
|
formatter: default
|
||||||
|
stream: ext://sys.stdout
|
||||||
|
file:
|
||||||
|
class: logging.FileHandler
|
||||||
|
formatter: default
|
||||||
|
filename: pos.log
|
||||||
|
|
||||||
|
loggers:
|
||||||
|
pos:
|
||||||
|
level: DEBUG
|
||||||
|
handlers: [console,file]
|
||||||
|
sqlalchemy:
|
||||||
|
level: INFO
|
||||||
|
handlers: [console,file]
|
25
docs/config_logging/logging_production.yaml
Normal file
25
docs/config_logging/logging_production.yaml
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
default:
|
||||||
|
format: '%(asctime)s %(levelname)-8s %(name)-30s %(message)s'
|
||||||
|
|
||||||
|
handlers:
|
||||||
|
console:
|
||||||
|
class: logging.StreamHandler
|
||||||
|
formatter: default
|
||||||
|
stream: ext://sys.stdout
|
||||||
|
file:
|
||||||
|
class: logging.handlers.RotatingFileHandler
|
||||||
|
formatter: default
|
||||||
|
filename: pos.log
|
||||||
|
maxBytes: 1024
|
||||||
|
backupCount: 3
|
||||||
|
|
||||||
|
loggers:
|
||||||
|
pos:
|
||||||
|
level: WARNING
|
||||||
|
handlers: [console,file]
|
||||||
|
sqlalchemy:
|
||||||
|
level: CRITICAL
|
||||||
|
handlers: [console,file]
|
39
pos/config.py
Normal file
39
pos/config.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import os.path
|
||||||
|
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',
|
||||||
|
'logging': 'logging.yaml'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
]):
|
||||||
|
return p
|
||||||
|
else:
|
||||||
|
raise Exception('Unable to find a configuration folder.')
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
def __init__(self):
|
||||||
|
self.basedir = get_default_path()
|
||||||
|
self.path_core = os.path.join(self.basedir, CONFIG_FILES['core'])
|
||||||
|
self.path_logging = os.path.join(self.basedir, CONFIG_FILES['logging'])
|
||||||
|
|
||||||
|
self.core = ConfigParser()
|
||||||
|
self.logging = None
|
||||||
|
|
||||||
|
self.read()
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
self.core.read(self.path_core)
|
||||||
|
self.logging = yaml.load(open(self.path_logging, 'r'))
|
152
pos/database.py
Normal file
152
pos/database.py
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
from datetime import datetime
|
||||||
|
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 Integer, String, Boolean, DateTime
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from sqlalchemy_utils import force_auto_coercion
|
||||||
|
from sqlalchemy_utils.types.password import PasswordType
|
||||||
|
from sqlalchemy_utils.types.currency import CurrencyType
|
||||||
|
|
||||||
|
from pos.logging import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
# The database URL must follow RFC 1738 in the form
|
||||||
|
# dialect+driver://username:password@host:port/database
|
||||||
|
ENGINE_GENERIC = "{engine}://{user}:{password}@{host}:{port}/{database}"\
|
||||||
|
"?charset=utf8"
|
||||||
|
ENGINE_SQLITE = "sqlite:///{path}"
|
||||||
|
ENGINE_SQLITE_MEMORY = "sqlite://"
|
||||||
|
|
||||||
|
PASSWORD_SCHEMES = ['pbkdf2_sha512']
|
||||||
|
DEFAULT_CURRENCY = 'EUR'
|
||||||
|
|
||||||
|
|
||||||
|
Base = sqlalchemy.ext.declarative.declarative_base()
|
||||||
|
log = get_logger('database')
|
||||||
|
|
||||||
|
force_auto_coercion()
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
"""
|
||||||
|
Handle database operations."
|
||||||
|
"""
|
||||||
|
|
||||||
|
Session = None
|
||||||
|
engine = None
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize database connection.
|
||||||
|
|
||||||
|
:param engine: The SQLAlchemy database backend in the form
|
||||||
|
dialect+driver where dialect is the name of a SQLAlchemy
|
||||||
|
dialect (sqlite, mysql, postgresql, oracle or mssql) and
|
||||||
|
driver is the name of the DBAPI in all lowercase
|
||||||
|
letters. If driver is not specified the default DBAPI
|
||||||
|
will be imported if available.
|
||||||
|
:param path: Only for SQLite. Path to database. If not specified the
|
||||||
|
database will be kept in memory (should be used only for
|
||||||
|
testing).
|
||||||
|
:param host:
|
||||||
|
:param port:
|
||||||
|
:param database:
|
||||||
|
:param user:
|
||||||
|
:param password:
|
||||||
|
"""
|
||||||
|
|
||||||
|
if kwargs['engine'] == 'sqlite':
|
||||||
|
if 'path' in kwargs:
|
||||||
|
url = ENGINE_SQLITE.format(path=kwargs['path'])
|
||||||
|
else:
|
||||||
|
url = ENGINE_SQLITE_MEMORY
|
||||||
|
else:
|
||||||
|
url = ENGINE_GENERIC.format(**kwargs)
|
||||||
|
|
||||||
|
self.engine = sa.create_engine(url)
|
||||||
|
self.Session = sa.orm.sessionmaker(
|
||||||
|
bind=self.engine,
|
||||||
|
expire_on_commit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_session(self):
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
log.critical("Error performing transaction:")
|
||||||
|
log.critical(e)
|
||||||
|
session.rollback()
|
||||||
|
else:
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = 'users'
|
||||||
|
uid = Column(Integer, primary_key=True)
|
||||||
|
username = Column(String, nullable=False, unique=True)
|
||||||
|
password = Column(PasswordType(schemes=PASSWORD_SCHEMES), nullable=False)
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||||
|
is_active = Column(Boolean, nullable=False, server_default='1')
|
||||||
|
is_authenticated = Column(Boolean, nullable=False, server_default='0')
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
return u'{}'.format(self.uid)
|
||||||
|
|
||||||
|
|
||||||
|
class Event(Base):
|
||||||
|
__tablename__ = 'events'
|
||||||
|
uid = Column(Integer, primary_key=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||||
|
starts_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||||
|
ends_at = Column(DateTime)
|
||||||
|
|
||||||
|
orders = relationship('Order', lazy='joined')
|
||||||
|
|
||||||
|
|
||||||
|
class Product(Base):
|
||||||
|
__tablename__ = 'products'
|
||||||
|
uid = Column(Integer, primary_key=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
currency = Column(CurrencyType, nullable=False, default=DEFAULT_CURRENCY)
|
||||||
|
price = Column(Integer, nullable=False)
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||||
|
is_active = Column(Boolean, nullable=False, server_default='1')
|
||||||
|
|
||||||
|
|
||||||
|
order_entry_association = Table(
|
||||||
|
'order_entry_associations', Base.metadata,
|
||||||
|
Column('order_uid', Integer, ForeignKey('orders.uid')),
|
||||||
|
Column('order_entry_uid', Integer, ForeignKey('order_entries.uid'))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Order(Base):
|
||||||
|
__tablename__ = 'orders'
|
||||||
|
uid = Column(Integer, primary_key=True)
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||||
|
event_uid = Column(Integer, ForeignKey('events.uid'), nullable=False)
|
||||||
|
|
||||||
|
event = relationship('Event')
|
||||||
|
entries = relationship('OrderEntry', lazy='joined',
|
||||||
|
secondary=order_entry_association)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderEntry(Base):
|
||||||
|
__tablename__ = 'order_entries'
|
||||||
|
uid = Column(Integer, primary_key=True)
|
||||||
|
product_uid = Column(Integer, ForeignKey('products.uid'), nullable=False)
|
||||||
|
quantity = Column(Integer, nullable=False)
|
||||||
|
|
||||||
|
product = relationship('Product', lazy='joined')
|
15
pos/logging.py
Normal file
15
pos/logging.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import logging
|
||||||
|
import logging.config
|
||||||
|
|
||||||
|
from pos.config import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
root = logging.getLogger(APP_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def init_logging(config):
|
||||||
|
logging.config.dictConfig(config)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name):
|
||||||
|
return root.getChild(name)
|
10
requirements.txt
Normal file
10
requirements.txt
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
click>=6.0
|
||||||
|
SQLAlchemy>=1.1.0
|
||||||
|
sqlalchemy_utils>=0.32.00
|
||||||
|
pymysql
|
||||||
|
babel
|
||||||
|
passlib
|
||||||
|
tabulate
|
||||||
|
PyYAML
|
||||||
|
flask>=0.12.0
|
||||||
|
flask_login>=0.4.0
|
55
static/style/reset.css
Normal file
55
static/style/reset.css
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
/* http://meyerweb.com/eric/tools/css/reset/
|
||||||
|
v2.0 | 20110126
|
||||||
|
License: none (public domain)
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
html, body, div, span, applet, object, iframe,
|
||||||
|
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
|
||||||
|
a, abbr, acronym, address, big, cite, code,
|
||||||
|
del, dfn, em, img, ins, kbd, q, s, samp,
|
||||||
|
small, strike, strong, sub, sup, tt, var,
|
||||||
|
b, u, i, center,
|
||||||
|
dl, dt, dd, ol, ul, li,
|
||||||
|
fieldset, form, label, legend,
|
||||||
|
table, caption, tbody, tfoot, thead, tr, th, td,
|
||||||
|
article, aside, canvas, details, embed,
|
||||||
|
figure, figcaption, footer, header, hgroup,
|
||||||
|
menu, nav, output, ruby, section, summary,
|
||||||
|
time, mark, audio, video {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
font-size: 100%;
|
||||||
|
font: inherit;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HTML5 display-role reset for older browsers */
|
||||||
|
article, aside, details, figcaption, figure,
|
||||||
|
footer, header, hgroup, menu, nav, section {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol, ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote, q {
|
||||||
|
quotes: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote:before, blockquote:after,
|
||||||
|
q:before, q:after {
|
||||||
|
content: '';
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
170
static/style/screen.css
Normal file
170
static/style/screen.css
Normal file
|
@ -0,0 +1,170 @@
|
||||||
|
html {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
main {
|
||||||
|
margin: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
max-width: 20rem;
|
||||||
|
margin: 1rem auto;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.success {
|
||||||
|
background-color: #60B044;
|
||||||
|
border-color: #5CA941;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.error {
|
||||||
|
background-color: #E74C3C;
|
||||||
|
border-color: #C0392B;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 2.5em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
form#login {
|
||||||
|
max-width: 16rem;
|
||||||
|
margin: 5rem auto;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
border: 1px solid #CCC;
|
||||||
|
padding: 2rem 2.5rem 1.5rem 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form#login > label {
|
||||||
|
display: block;
|
||||||
|
margin: 1rem 0 0.25rem 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
form#login > input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #CCC;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
background-color: #FAFAFA;
|
||||||
|
box-shadow: inset 0 1px 3px #DDD;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form#login > input:focus {
|
||||||
|
border-color: #51A7E8;
|
||||||
|
background-color: #FFF;
|
||||||
|
outline: 0;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075) inset,
|
||||||
|
0 0 5px rgba(81, 167, 232, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
form#login > button {
|
||||||
|
display: block;
|
||||||
|
margin: 2rem 0 0.5rem 0;
|
||||||
|
border: 1px solid #5CA941;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
background-color: #60B044;
|
||||||
|
background-image: linear-gradient(#8ADD6D, #60B044);
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
form#login > button:focus,
|
||||||
|
form#login > button:hover {
|
||||||
|
border-color: #4A993E;
|
||||||
|
background-color: #569E3D;
|
||||||
|
background-image: linear-gradient(#79D858, #569E3D);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#products {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 4rem auto;
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#products > button {
|
||||||
|
display: inline-block;
|
||||||
|
width: 15rem;
|
||||||
|
height: 7rem;
|
||||||
|
margin: 0 1rem 1rem 0;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#products > button > span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#basket {
|
||||||
|
display: inline-block;
|
||||||
|
float: right;
|
||||||
|
margin: 4rem auto;
|
||||||
|
min-width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#basket > button {
|
||||||
|
display: block;
|
||||||
|
width: 15rem;
|
||||||
|
height: 3rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sell {
|
||||||
|
display: inline-block;
|
||||||
|
float: right;
|
||||||
|
margin: auto;
|
||||||
|
min-width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sell > button {
|
||||||
|
display: block;
|
||||||
|
width: 15rem;
|
||||||
|
height: 3rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
21
templates/base.html
Normal file
21
templates/base.html
Normal 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
11
templates/login.html
Normal 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 %}
|
76
templates/sell.html
Normal file
76
templates/sell.html
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block main %}
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
{% if event %}
|
||||||
|
<h2>{{ event.name }}</h2>
|
||||||
|
<h3>{{ event.starts_at }} → {{ event.ends_at }}</h3>
|
||||||
|
{% else %}
|
||||||
|
<h2>No ongoing event!</h2>
|
||||||
|
{% endif %}
|
||||||
|
<div id="products">
|
||||||
|
{% for product in products %}
|
||||||
|
<button type="button"
|
||||||
|
onclick="addProduct({{ product.uid }})">
|
||||||
|
<span class="name">{{ product.name }}</span>
|
||||||
|
<span class="price">{{ product.price }}</span>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<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="{{ product.price }}"
|
||||||
|
value="0">
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit">Print</input>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
function addProduct(uid) {
|
||||||
|
// This is the hidden input element inside the form. We'll use this DOM
|
||||||
|
// element to keep informations about the product, such as the name,
|
||||||
|
// the price and the value inside the 'data-' tag.
|
||||||
|
form_el = document.getElementById('prod_' + uid)
|
||||||
|
basket = document.getElementById('basket')
|
||||||
|
basket_el = document.getElementById('basketitem_' + uid)
|
||||||
|
|
||||||
|
form_el.value = parseInt(form_el.value) + 1
|
||||||
|
|
||||||
|
// If there is not a button for the given product inside the basket
|
||||||
|
// div we'll create it.
|
||||||
|
if (basket_el === null) {
|
||||||
|
new_basket_el =
|
||||||
|
'<button id="basketitem_' + uid + '"' +
|
||||||
|
'onclick="delProduct(' + uid + ')">' +
|
||||||
|
'1 x ' + form_el.dataset.name +
|
||||||
|
'</button>'
|
||||||
|
basket.innerHTML += new_basket_el
|
||||||
|
} else {
|
||||||
|
// Otherwise we'll just update it.
|
||||||
|
console.log(form_el.value)
|
||||||
|
new_text = form_el.value + ' x ' + form_el.dataset.name
|
||||||
|
basket_el.innerText = new_text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function delProduct(uid) {
|
||||||
|
form_el = document.getElementById('prod_' + uid)
|
||||||
|
basket = document.getElementById('basket')
|
||||||
|
basket_el = document.getElementById('basketitem_' + uid)
|
||||||
|
|
||||||
|
form_el.value = parseInt(form_el.value) - 1
|
||||||
|
|
||||||
|
if (form_el.value == 0) {
|
||||||
|
basket.removeChild(basket_el)
|
||||||
|
} else {
|
||||||
|
new_text = form_el.value + ' x ' + form_el.dataset.name
|
||||||
|
basket_el.innerText = new_text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
144
web.py
Normal file
144
web.py
Normal file
|
@ -0,0 +1,144 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
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 init_logging, get_logger
|
||||||
|
from pos.database import Database, User, Product, Event, Order, OrderEntry
|
||||||
|
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
debug = config.core['GENERAL'].getboolean('Debug', False)
|
||||||
|
conf_db = config.core['DATABASE']
|
||||||
|
conf_flask = config.core['FLASK']
|
||||||
|
|
||||||
|
init_logging(config.logging)
|
||||||
|
log = get_logger('web')
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.update(
|
||||||
|
debug=debug,
|
||||||
|
SECRET_KEY=conf_flask['SECRET_KEY']
|
||||||
|
)
|
||||||
|
|
||||||
|
login_manager = LoginManager()
|
||||||
|
login_manager.init_app(app)
|
||||||
|
login_manager.login_view = 'login'
|
||||||
|
login_manager.login_message_category = 'error'
|
||||||
|
|
||||||
|
db = Database(**conf_db)
|
||||||
|
|
||||||
|
|
||||||
|
@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)\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
|
||||||
|
def get_event(session):
|
||||||
|
return session.query(Event)\
|
||||||
|
.filter(Event.starts_at < datetime.now())\
|
||||||
|
.filter((Event.ends_at > datetime.now()) |
|
||||||
|
(Event.ends_at is None))\
|
||||||
|
.one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/sell', methods=['GET'])
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/sell', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def sell():
|
||||||
|
app.logger.info(str(request.form))
|
||||||
|
|
||||||
|
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()
|
||||||
|
entries = [
|
||||||
|
OrderEntry(product_uid=uid, quantity=qty)
|
||||||
|
for uid, qty in items
|
||||||
|
if int(qty) > 0
|
||||||
|
]
|
||||||
|
|
||||||
|
order = Order(event_uid=event.uid, entries=entries)
|
||||||
|
session.add(order)
|
||||||
|
|
||||||
|
flash("Success!", 'success')
|
||||||
|
return sell_page()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='0.0.0.0', port=8080, debug=debug)
|
Loading…
Reference in New Issue
Block a user