diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py new file mode 100644 index 0000000..8c9e3d9 --- /dev/null +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -0,0 +1,127 @@ +"""Pricelist plugin""" + +from flask import Blueprint, jsonify, request +from http.client import NO_CONTENT + +from flaschengeist.plugins import Plugin +from flaschengeist.utils.decorators import login_required +from werkzeug.exceptions import BadRequest + +from . import models +from . import pricelist_controller, permissions + +pricelist_bp = Blueprint("pricelist", __name__, url_prefix="/pricelist") + + +class PriceListPlugin(Plugin): + models = models + + def __init__(self, cfg): + super().__init__(blueprint=pricelist_bp, permissions=permissions.permissions) + config = {"discount": 0} + config.update(cfg) + + def install(self): + from flaschengeist.database import db + + db.create_all() + + +@pricelist_bp.route("/drink-types", methods=["GET"]) +@pricelist_bp.route("/drink-types/", methods=["GET"]) +def get_drink_types(identifier=None): + if identifier: + result = pricelist_controller.get_drink_type(identifier) + else: + result = pricelist_controller.get_drink_types() + return jsonify(result) + + +@pricelist_bp.route("/drink-types", methods=["POST"]) +@login_required(permission=permissions.CREATE_TYPE) +def new_drink_type(current_session): + data = request.get_json() + if "name" not in data: + raise BadRequest + drink_type = pricelist_controller.create_drink_type(data["name"]) + return jsonify(drink_type) + + +@pricelist_bp.route("/drink-types/", methods=["PUT"]) +@login_required(permission=permissions.EDIT_TYPE) +def update_drink_type(identifier, current_session): + data = request.get_json() + if "name" not in data: + raise BadRequest + drink_type = pricelist_controller.rename_drink_type(data["id"], data["name"]) + return jsonify(drink_type) + + +@pricelist_bp.route("/drink-types/", methods=["DELETE"]) +@login_required(permission=permissions.DELETE_TYPE) +def delete_drink_type(identifier, current_session): + pricelist_controller.delete_drink_type(identifier) + return "", NO_CONTENT + + +@pricelist_bp.route("/tags", methods=["GET"]) +@pricelist_bp.route("/tags/", methods=["GET"]) +def get_tags(identifier=None): + if identifier: + result = pricelist_controller.get_tag(identifier) + else: + result = pricelist_controller.get_tags() + return jsonify(result) + + +@pricelist_bp.route("/tags", methods=["POST"]) +@login_required(permission=permissions.CREATE_TAG) +def new_tag(current_session): + data = request.get_json() + if "name" not in data: + raise BadRequest + drink_type = pricelist_controller.create_tag(data["name"]) + return jsonify(drink_type) + + +@pricelist_bp.route("/tags/", methods=["PUT"]) +@login_required(permission=permissions.EDIT_TAG) +def update_tag(identifier, current_session): + data = request.get_json() + if "name" not in data: + raise BadRequest + drink_type = pricelist_controller.rename_tag(data["name"]) + return jsonify(drink_type) + + +@pricelist_bp.route("/tags/", methods=["DELETE"]) +@login_required(permission=permissions.DELETE_TAG) +def delete_tag(identifier, current_session): + pricelist_controller.delete_tag(identifier) + return "", NO_CONTENT + + +@pricelist_bp.route("/drinks", methods=["GET"]) +@pricelist_bp.route("/drinks/", methods=["GET"]) +def get_drinks(identifier=None): + if identifier: + result = pricelist_controller.get_drink(identifier) + else: + result = pricelist_controller.get_drinks() + return jsonify(result) + + +@pricelist_bp.route("/drinks/search/", methods=["GET"]) +def search_drinks(name): + return jsonify(pricelist_controller.get_drinks(name)) + + +@pricelist_bp.route("/drinks", methods=["POST"]) +@login_required(permission=permissions.CREATE) +def create_drink(current_session): + data = request.get_json() + if not all(item in data for item in ["name", "volume", "cost_price"]) or not all( + item in data for item in ["name", "ingredients"] + ): + raise BadRequest("No correct Keys to create drink") + return jsonify(pricelist_controller.create_drink(data)) diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py new file mode 100644 index 0000000..1eb9ae6 --- /dev/null +++ b/flaschengeist/plugins/pricelist/models.py @@ -0,0 +1,90 @@ +from flaschengeist.database import db +from flaschengeist.models import ModelSerializeMixin + +from typing import Optional + +drink_tag_association = db.Table( + "drink_x_tag", + db.Column("drink_id", db.Integer, db.ForeignKey("drink.id")), + db.Column("tag_id", db.Integer, db.ForeignKey("drink_tag.id")), +) + +drink_type_association = db.Table( + "drink_x_type", + db.Column("drink_id", db.Integer, db.ForeignKey("drink.id")), + db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")), +) + + +class Tag(db.Model, ModelSerializeMixin): + """ + Tag + """ + + __tablename__ = "drink_tag" + id: int = db.Column("id", db.Integer, primary_key=True) + name: str = db.Column(db.String(30), nullable=False, unique=True) + + +class DrinkType(db.Model, ModelSerializeMixin): + """ + DrinkType + """ + + __tablename__ = "drink_type" + id: int = db.Column("id", db.Integer, primary_key=True) + name: str = db.Column(db.String(30), nullable=False, unique=True) + + +class DrinkPrice(db.Model, ModelSerializeMixin): + """ + PriceFromVolume + """ + + __tablename__ = "drink_price" + id: int = db.Column("id", db.Integer, primary_key=True) + volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) + price: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) + drink_id = db.Column("drink_id", db.Integer, db.ForeignKey("drink.id")) + drink = db.relationship("Drink", back_populates="prices") + no_auto: bool = db.Column(db.Boolean, default=False) + public: bool = db.Column(db.Boolean, default=True) + description: Optional[str] = db.Column(db.String(30)) + round_step: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False, default=0.5) + + +class Ingredient(db.Model, ModelSerializeMixin): + """ + Drink Build + """ + + __tablename__ = "drink_ingredient" + id: int = db.Column("id", db.Integer, primary_key=True) + volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) + drink_parent_id: int = db.Column("drink_parent_id", db.Integer, db.ForeignKey("drink.id")) + drink_parent = db.relationship("Drink", foreign_keys=drink_parent_id) + drink_ingredient_id: int = db.Column("drink_ingredient_id", db.Integer, db.ForeignKey("drink.id")) + drink_ingredient: "Drink" = db.relationship("Drink", foreign_keys=drink_ingredient_id) + + +class Drink(db.Model, ModelSerializeMixin): + """ + DrinkPrice + """ + + __tablename__ = "drink" + id: int = db.Column("id", db.Integer, primary_key=True) + name: str = db.Column(db.String(60), nullable=False) + volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) + cost_price: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) + discount: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False) + extra_charge: Optional[float] = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), default=0) + prices: [DrinkPrice] = db.relationship( + "DrinkPrice", back_populates="drink", cascade="all,delete,delete-orphan", order_by=[DrinkPrice.volume] + ) + ingredients: [Ingredient] = db.relationship( + "Ingredient", back_populates="drink_parent", foreign_keys=Ingredient.drink_parent_id + ) + tags: [Optional[Tag]] = db.relationship("Tag", secondary=drink_tag_association, cascade="save-update, merge") + type_id_ = db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")) + type = db.relationship("DrinkType") diff --git a/flaschengeist/plugins/pricelist/permissions.py b/flaschengeist/plugins/pricelist/permissions.py new file mode 100644 index 0000000..b92ab9a --- /dev/null +++ b/flaschengeist/plugins/pricelist/permissions.py @@ -0,0 +1,23 @@ +CREATE = "drink_create" +"""Can create drinks""" + +EDIT = "drink_edit" +"""Can edit drinks""" + +DELETE = "drink_delete" +"""Can delete drinks""" + +CREATE_TAG = "drink_tag_create" +"""Can create and edit Tags""" + +EDIT_TAG = "drink_tag_edit" + +DELETE_TAG = "drink_tag_delete" + +CREATE_TYPE = "drink_type_create" + +EDIT_TYPE = "drink_type_edit" + +DELETE_TYPE = "drink_type_delete" + +permissions = [value for key, value in globals().items() if not key.startswith("_")] diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py new file mode 100644 index 0000000..93c365c --- /dev/null +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -0,0 +1,209 @@ +from werkzeug.exceptions import BadRequest, NotFound +from sqlalchemy.exc import IntegrityError + +from flaschengeist import logger +from flaschengeist.database import db +from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType + +from math import ceil + + +def update(): + db.session.commit() + + +def get_tags(): + return Tag.query.all() + + +def get_tag(identifier): + if isinstance(identifier, int): + retVal = Tag.query.get(identifier) + elif isinstance(identifier, str): + retVal = Tag.query.filter(Tag.name == identifier).one_or_none() + else: + logger.debug("Invalid identifier type for Tag") + raise BadRequest + if not retVal: + raise NotFound + return retVal + + +def create_tag(name): + try: + tag = Tag(name=name) + db.session.add(tag) + update() + return tag + except IntegrityError: + raise BadRequest("Name already exists") + + +def rename_tag(identifier, new_name): + tag = get_tag(identifier) + tag.name = new_name + try: + update() + except IntegrityError: + raise BadRequest("Name already exists") + + +def delete_tag(identifier): + tag = get_tag(identifier) + db.session.delete(tag) + try: + update() + except IntegrityError: + raise BadRequest("Tag still in use") + + +def get_drink_types(): + return DrinkType.query.all() + + +def get_drink_type(identifier): + if isinstance(identifier, int): + retVal = DrinkType.query.get(identifier) + elif isinstance(identifier, str): + retVal = DrinkType.query.filter(Tag.name == identifier).one_or_none() + else: + logger.debug("Invalid identifier type for DrinkType") + raise BadRequest + if not retVal: + raise NotFound + return retVal + + +def create_drink_type(name): + try: + drinkType = DrinkType(name=name) + db.session.add(drinkType) + update() + return drinkType + except IntegrityError: + raise BadRequest("Name already exists") + + +def rename_drink_type(identifier, new_name): + drink_type = get_drink_type(identifier) + drink_type.name = new_name + try: + update() + except IntegrityError: + raise BadRequest("Name already exists") + return drink_type + + +def delete_drink_type(identifier): + drinkType = get_drink_type(identifier) + db.session.delete(drinkType) + try: + update() + except IntegrityError: + raise BadRequest("DrinkType still in use") + + +def round_price(price, round_step): + return round(ceil(float(price) / round_step) * round_step * 100) / 100 + + +def calc_prices(drink, prices): + retVal = [] + if len(drink.ingredients) > 0: + return calc_price_by_ingredients(drink, prices) + allowed_keys = DrinkPrice().serialize().keys() + for price in prices: + values = {key: value for key, value in price.items() if key in allowed_keys} + if values.get("no_auto"): + retVal.append(DrinkPrice(**values)) + else: + volume = float(values.pop("volume")) + if "price" in values: + values.pop("price") + _price = float(drink.cost_price) / float(drink.volume) * volume + _price += _price * float(drink.discount) + if drink.extra_charge: + _price += float(drink.extra_charge) + _price = round_price(_price, float(price.get("round_step"))) + retVal.append(DrinkPrice(volume=volume, price=_price, **values)) + return retVal + + +def calc_price_by_ingredients(drink, prices): + allowed_keys = DrinkPrice().serialize().keys() + retVal = [] + for price in prices: + values = {key: value for key, value in price.items() if key in allowed_keys} + if values.get("no_auto"): + retVal.append(DrinkPrice(**values)) + else: + volume = float(values.pop("volume")) + if "price" in values: + values.pop("price") + _price = 0 + for ingredient in drink.ingredients: + _price = ( + float(ingredient.drink_ingredient.cost_price) + / float(ingredient.drink_ingredient.volume) + * float(ingredient.volume) + ) + _price += _price * float(drink.discount) + float(drink.extra_charge) + _price = round_price(_price, price.get("round_step")) + retVal.append(DrinkPrice(volume=volume, price=_price, **values)) + return retVal + + +def get_drinks(name=None): + if name: + return Drink.query.filter(Drink.name.contains(name)).all() + return Drink.query.all() + + +def get_drink(identifier): + if isinstance(identifier, int): + retVal = Drink.query.get(identifier) + elif isinstance(identifier, str): + retVal = Drink.query.filter(Tag.name == identifier).one_or_none() + else: + logger.debug("Invalid identifier type for Drink") + raise BadRequest + if not retVal: + raise NotFound + return retVal + + +def add_prices(drink, prices): + for price in prices: + drink.prices.append(price) + + +def add_ingredients(drink, ingredients): + for identifier, volume in ingredients: + ingredient = Ingredient(volume=volume, drink_ingredient=get_drink(identifier)) + drink.ingredients.append(ingredient) + + +def create_drink(data): + allowed_keys = Drink().serialize().keys() + values = {key: value for key, value in data.items() if key in allowed_keys} + prices = values.pop("prices", []) + ingredients = values.pop("ingredients", []) + if "id" in values: + values.pop("id") + + drink = Drink(**values) + add_ingredients(drink, ingredients) + drink.prices = calc_prices(drink, prices) + db.session.add(drink) + update() + return drink + + +def delete_drink(identifier): + drink = get_drink(identifier) + for price in drink.prices: + db.session.delete(price) + for ingredient in drink.ingredients: + db.session.delete(ingredient) + db.session.delete(drink) + update() diff --git a/setup.py b/setup.py index d2c164b..5a6ecaf 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,7 @@ setup( "balance = flaschengeist.plugins.balance:BalancePlugin", "schedule = flaschengeist.plugins.schedule:SchedulePlugin", "mail = flaschengeist.plugins.message_mail:MailMessagePlugin", + "pricelist = flaschengeist.plugins.pricelist:PriceListPlugin", ], }, )