diff --git a/ShyBadger/__init__.py b/ShyBadger/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ShyBadger/src/__init__.py b/ShyBadger/src/__init__.py new file mode 100644 index 0000000..9388197 --- /dev/null +++ b/ShyBadger/src/__init__.py @@ -0,0 +1,2 @@ +# left empty so the user has to import all submodules explicitly, +# this ensures clear namespace separation \ No newline at end of file diff --git a/ShyBadger/src/endpoints/BasketEndpoint.py b/ShyBadger/src/endpoints/BasketEndpoint.py new file mode 100644 index 0000000..e6a78ca --- /dev/null +++ b/ShyBadger/src/endpoints/BasketEndpoint.py @@ -0,0 +1,31 @@ +from flask_restful import Resource +from flask import session, Response, request +import uuid + +from src.services import BasketService + + +class Basket(Resource): + basket_service = BasketService() + + def get_session_id(): + # first interaction with server sets session_id + # would need to research if this is ideal + return str(uuid.uuid4()) + + def get(self): + if "session_id" not in session: + session["session_id"] = self.get_session_id() + return Response( + {"total": self.basket_service.total()}, mimetype="application/json" + ) + + def post(self): + # expects something similar: {"items": [{"item_id": abc002}]} + + if "session_id" not in session: + session["session_id"] = self.get_session_id() + jsonData = request.get_json(force=True) + for entry in jsonData["items"]: + self.basket_service.scan(entry["item_id"]) + return Response(HTTPStatus=201) diff --git a/ShyBadger/src/main.py b/ShyBadger/src/main.py new file mode 100644 index 0000000..a84bce3 --- /dev/null +++ b/ShyBadger/src/main.py @@ -0,0 +1,20 @@ +from flask import Flask, request, g, render_template, redirect +from flask_restful import Resource, reqparse +from flask_restful_swagger_3 import Api +from json import dumps +import logging + +import endpoints.BasketEndpoint as BasketEndpoint + +app = Flask(__name__) +app.secret_key = "SUPER_DUPER_BAD_SECRET_KEY" +api = Api(app) +logging.basicConfig(level=logging.DEBUG) +api.add_resource(BasketEndpoint.Basket, "/api/v1/basket/") + + +@app.route("/") +def index(): + """in lieu of a UI redirect to the API doc""" + app.logger.info(f"request from {request.remote_addr}") + return redirect("/api/doc/swagger.json", code=302) diff --git a/ShyBadger/src/models/Item.py b/ShyBadger/src/models/Item.py new file mode 100644 index 0000000..c1e0213 --- /dev/null +++ b/ShyBadger/src/models/Item.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass +class Item: + id: str + price: str diff --git a/ShyBadger/src/run.py b/ShyBadger/src/run.py new file mode 100644 index 0000000..d54fb3f --- /dev/null +++ b/ShyBadger/src/run.py @@ -0,0 +1,5 @@ +from main import app + +# disabled threading for easier session handleing in this demo project +# if threaded was True, as session store would be needed +app.run(host="0.0.0.0", port="80", debug=False, threaded=False) diff --git a/ShyBadger/src/services/BasketService.py b/ShyBadger/src/services/BasketService.py new file mode 100644 index 0000000..6171dad --- /dev/null +++ b/ShyBadger/src/services/BasketService.py @@ -0,0 +1,32 @@ +from collections import defaultdict +import math +from services.DealsService import DealsService +from services.ProductService import ProductService + + +class BasketService: + baskets = None + dealsService = None + productService = None + + def __init__(self) -> None: + self.baskets = defaultdict() + self.dealsService = DealsService() + self.productService = ProductService() + + def total(self, sessionID: str) -> int: + # this function should really be in a separate file but I am already over time + # this round up function needs to be checked with stakeholders + # as it would generate millions in profit in a big online shop, + # but could be considered unfriendly towards consumers + def _round_up(val: float): + return math.ceil(val * 100) / 100 + items = self.baskets[sessionID] + prices = self.dealsService.get_items_with_final_prices(items) + return _round_up(sum([item.price for item in prices])) + + def scan(self, session_id: str, itemId) -> None: + item = self.productService.get_item_from_id(itemId) + if session_id not in self.baskets: + self.baskets[session_id] = [] + self.baskets[session_id].append(item) diff --git a/ShyBadger/src/services/DealsService.py b/ShyBadger/src/services/DealsService.py new file mode 100644 index 0000000..d993326 --- /dev/null +++ b/ShyBadger/src/services/DealsService.py @@ -0,0 +1,78 @@ +from collections import defaultdict +import math + + +class DealsService: + # deals and promotions are global and therefor class vars instead of instance vars + deals = dict() + promotions = defaultdict(list) + + def __init__(self) -> None: + # placeholder data + # promiootion can be read dznamically from db + self.promotions["A0001"].append("two_for_one") + self.promotions["A0002"].append("ten_percent_off") + + # deal behavior needs to be programmed an can only be + # changed with the rollout of a new version, + self.deals["two_for_one"] = DealsService.two_for_one_deal + self.deals["ten_percent_off"] = DealsService.ten_percent_off + + def get_items_with_final_prices(self, items) -> list: + groups = defaultdict(list) + + for obj in items: + groups[obj.id].append(obj) + + for group in groups.values(): + # the deals do not stack, the first deal in the list of deals is taken, + # ideally for the customer all deals would be calculated and the maximum value selected + # not implemented for time reasons + + for promotion in self.promotions.get(group[0].id): + # iterate over all promotions for this item + # lookup the higher order function that is associated with this deal name + # apply the function to the item group + # if the deal was applied to the group of items break and move on to the next item group + # this avoids discout stacking + if promotion not in self.deals: + continue + if self.deals[promotion](group): + break + + return [item for group in groups.values() for item in group] + + @staticmethod + def two_for_one_deal(group): + """' applies deal onto item group in place""" + + # ideally would be an object implementing a "Deal"-interface, + # which has 2 functions, deal_is_applicable() and apply_deal() + def _round_up(val: float): + return math.ceil(val * 100) / 100 + + # could be compressed + # not done since this would be the 2 different functions in the above mentioned interface + deal_is_applicable = False + if len(group) >= 2: + deal_is_applicable = True + + for i in range(len(group) - len(group) % 2): + group[i].price = _round_up(group[i].price / 2) + return deal_is_applicable + + @staticmethod + def ten_percent_off(group): + # the percentage discount could be added to function signature + """' applies deal onto item group in place""" + + # ideally would be an object implementing a "Deal"-interface, + # which has 2 functions, deal_is_applicable() and apply_deal() + def _round_up(val: float): + return math.ceil(val * 100) / 100 + + deal_is_applicable = True + + for i in range(len(group)): + group[i].price = _round_up(group[i].price * 0.9) + return deal_is_applicable diff --git a/ShyBadger/src/services/ProductService.py b/ShyBadger/src/services/ProductService.py new file mode 100644 index 0000000..7df905d --- /dev/null +++ b/ShyBadger/src/services/ProductService.py @@ -0,0 +1,15 @@ +from models.Item import Item + + +class ProductService: + # prices are global, sheduled function to refresh prices periodically + # could save ressources under high load + prices = dict() + + def __init__(self) -> None: + # placeholder data + self.prices["A0001"] = 12.99 + self.prices["A0002"] = 3.99 + + def get_item_from_id(self, item_id: str) -> Item: + return Item(id=item_id, price=self.prices.get(item_id)) diff --git a/ShyBadger/src/services/__init__.py b/ShyBadger/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ShyBadger/src/test.py b/ShyBadger/src/test.py new file mode 100644 index 0000000..a85ebbc --- /dev/null +++ b/ShyBadger/src/test.py @@ -0,0 +1,16 @@ + + +from services import BasketService + + +def test__BasketService(): + bs = BasketService.BasketService() + bs.scan("1", "A0001") + bs.scan("1", "A0001") + bs.scan("1", "A0001") + bs.scan("1", "A0002") + bs.scan("1", "A0002") + assert bs.total("1") == 33.2 + print("total: ", bs.total("1")) + +test__BasketService() \ No newline at end of file diff --git a/ShyBadger/tests/__init__.py b/ShyBadger/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29