forked from crudo/macao-pos
commit
e7874a10ee
17 changed files with 1127 additions and 0 deletions
@ -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 |
@ -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 |
@ -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() |
@ -0,0 +1,9 @@ |
|||
[GENERAL] |
|||
Debug = True |
|||
|
|||
[DATABASE] |
|||
Engine = sqlite |
|||
Path = pos.db |
|||
|
|||
[FLASK] |
|||
SECRET_KEY = CHANGE_ME_NOW! |
@ -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! |
@ -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] |
@ -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] |
@ -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')) |
@ -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') |
@ -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) |
@ -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 |
@ -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; |
|||
} |
@ -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; |
|||
} |
@ -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> |
@ -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 %} |
@ -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 %} |
@ -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