commit e7874a10eeebbdb772bb31eb290b14ef5753f6b6 Author: crudo Date: Tue Mar 21 11:26:25 2017 +0100 Import. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f69314b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b795efd --- /dev/null +++ b/README.md @@ -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 diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..b835c5b --- /dev/null +++ b/cli.py @@ -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() diff --git a/docs/config_core/core_debug_sqlite.ini b/docs/config_core/core_debug_sqlite.ini new file mode 100644 index 0000000..43f365e --- /dev/null +++ b/docs/config_core/core_debug_sqlite.ini @@ -0,0 +1,9 @@ +[GENERAL] +Debug = True + +[DATABASE] +Engine = sqlite +Path = pos.db + +[FLASK] +SECRET_KEY = CHANGE_ME_NOW! diff --git a/docs/config_core/core_production_mysql.ini b/docs/config_core/core_production_mysql.ini new file mode 100644 index 0000000..af4207c --- /dev/null +++ b/docs/config_core/core_production_mysql.ini @@ -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! diff --git a/docs/config_logging/logging_debug.yaml b/docs/config_logging/logging_debug.yaml new file mode 100644 index 0000000..4901139 --- /dev/null +++ b/docs/config_logging/logging_debug.yaml @@ -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] diff --git a/docs/config_logging/logging_production.yaml b/docs/config_logging/logging_production.yaml new file mode 100644 index 0000000..db6eb63 --- /dev/null +++ b/docs/config_logging/logging_production.yaml @@ -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] diff --git a/pos/config.py b/pos/config.py new file mode 100644 index 0000000..896d1c0 --- /dev/null +++ b/pos/config.py @@ -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')) diff --git a/pos/database.py b/pos/database.py new file mode 100644 index 0000000..8992bf0 --- /dev/null +++ b/pos/database.py @@ -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') diff --git a/pos/logging.py b/pos/logging.py new file mode 100644 index 0000000..ed6eb6c --- /dev/null +++ b/pos/logging.py @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..66f9508 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/static/style/reset.css b/static/style/reset.css new file mode 100644 index 0000000..8de0fa8 --- /dev/null +++ b/static/style/reset.css @@ -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; +} diff --git a/static/style/screen.css b/static/style/screen.css new file mode 100644 index 0000000..dc03d02 --- /dev/null +++ b/static/style/screen.css @@ -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; +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..7d403e1 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,21 @@ + + + + + + + + {{ title }} + + + +
{% block main %}{% endblock %}
+{% with alerts = get_flashed_messages(with_categories=True) %} +{% if alerts %}{% for category, message in alerts %} +
{{ message }}
+{% endfor %}{% endif %} +{% endwith %} + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..bf5a88b --- /dev/null +++ b/templates/login.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% block main %} +

{{ title }}

+
+ + + + + +
+{% endblock %} diff --git a/templates/sell.html b/templates/sell.html new file mode 100644 index 0000000..8445dfb --- /dev/null +++ b/templates/sell.html @@ -0,0 +1,76 @@ +{% extends 'base.html' %} +{% block main %} +

{{ title }}

+{% if event %} +

{{ event.name }}

+

{{ event.starts_at }} → {{ event.ends_at }}

+{% else %} +

No ongoing event!

+{% endif %} +
+{% for product in products %} + +{% endfor %} +
+
+
+
+{% for product in products %} + +{% endfor %} +