diff --git a/.gitignore b/.gitignore index 11429d1..03c6d5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .env -test.db +test.db* # Created by https://www.toptal.com/developers/gitignore/api/python # Edit at https://www.toptal.com/developers/gitignore?templates=python diff --git a/db/models.py b/db/models.py index ec88e01..9239e1a 100644 --- a/db/models.py +++ b/db/models.py @@ -7,7 +7,6 @@ transaction logging, drink recording, and utility functions for drink statistics Constants: DRINK_COST (int): Cost of a drink in cents. - AVAILABLE_DRINKS (list): List of available drink types. Functions: _log_transaction(user_id, user_is_postpaid, ...): Log a user's money transaction. @@ -37,7 +36,7 @@ import os import secrets import datetime import random -from sqlalchemy import create_engine, text, select +from sqlalchemy import create_engine, text from fastapi import HTTPException from dotenv import load_dotenv @@ -49,7 +48,6 @@ DATABASE_URL = "sqlite:///" + str(DATABASE_FILE) engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) DRINK_COST = 100 # cent -AVAILABLE_DRINKS = ["Paulaner Spezi", "Mio Mate", "Club Mate", "Eistee Pfirsisch"] with engine.connect() as conn: # Create a table for postpaid users @@ -85,9 +83,10 @@ with engine.connect() as conn: postpaid_user_id INTEGER, prepaid_user_id INTEGER, timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - drink_type TEXT, + drink_type INTEGER DEFAULT 1, FOREIGN KEY (postpaid_user_id) REFERENCES users_postpaid(id), - FOREIGN KEY (prepaid_user_id) REFERENCES users_prepaid(id) + FOREIGN KEY (prepaid_user_id) REFERENCES users_prepaid(id), + FOREIGN KEY (drink_type) REFERENCES drink_types(id) ) """)) @@ -106,6 +105,27 @@ with engine.connect() as conn: FOREIGN KEY (prepaid_user_id) REFERENCES users_prepaid(id) ) """)) + + # create a table for drink types + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS drink_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + drink_name TEXT NOT NULL UNIQUE, + icon TEXT NOT NULL, + quantity INT DEFAULT 0 + ) + """)) + conn.execute(text(""" + INSERT OR IGNORE INTO drink_types (id, drink_name, icon, quantity) VALUES + (1, 'Sonstiges', 'sonstiges.png', 0), + (2, 'Paulaner Spezi', 'paulaner_spezi.png', 0), + (3, 'Paulaner Limo Orange', 'paulaner_limo_orange.png', 0), + (4, 'Paulaner Limo Zitrone', 'paulaner_limo_zitrone.png', 0), + (5, 'Mio Mate Original', 'mio_mate_original.png', 0), + (6, 'Mio Mate Ginger', 'mio_mate_ginger.png', 0), + (7, 'Mio Mate Pomegranate', 'mio_mate_pomegranate.png', 0), + (8, 'Club Mate', 'club_mate.png', 0) + """)) conn.commit() @@ -293,7 +313,7 @@ def set_postpaid_user_money(user_id: int, money: float): connection.commit() return result.rowcount -def drink_postpaid_user(user_id: int, drink_type: str = ""): +def drink_postpaid_user(user_id: int, drink_type: int = 1): """ Deducts 100 units from the specified postpaid user's balance and records a drink entry. Args: @@ -635,7 +655,7 @@ def get_last_drink(user_id: int, user_is_postpaid: bool, max_since_seconds: int result = connection.execute(t, {"user_id": user_id}).fetchone() if not result: return None - drink_id, timestamp, drink_type = result + drink_id, timestamp, drink_type_id = result if timestamp: now = datetime.datetime.now(datetime.timezone.utc) @@ -645,8 +665,13 @@ def get_last_drink(user_id: int, user_is_postpaid: bool, max_since_seconds: int last_drink_time = last_drink_time.replace(tzinfo=datetime.timezone.utc) if (now - last_drink_time).total_seconds() > max_since_seconds: return None - drink_obj = {"id": drink_id, "timestamp": timestamp, "drink_type": drink_type} + drink_obj = {"id": drink_id, "timestamp": timestamp, "drink_type_id": drink_type_id} + if drink_type_id: + drink_type_name, drink_type_icon = get_drink_type(drink_type_id) + drink_obj["drink_type_name"] = drink_type_name + drink_obj["drink_type_icon"] = drink_type_icon return drink_obj + return None def revert_last_drink(user_id: int, user_is_postpaid: bool, drink_id: int, drink_cost: int = DRINK_COST): """ @@ -696,7 +721,7 @@ def revert_last_drink(user_id: int, user_is_postpaid: bool, drink_id: int, drink description="Reverted last drink" ) -def update_drink_type(user_id: int, user_is_postpaid: bool, drink_id, drink_type: str): +def update_drink_type(user_id: int, user_is_postpaid: bool, drink_id, drink_type_id: int): """ Updates the drink type for a specific drink associated with a user. Depending on whether the user is postpaid or prepaid, the function updates the `drink_type` @@ -715,14 +740,52 @@ def update_drink_type(user_id: int, user_is_postpaid: bool, drink_id, drink_type t = text("UPDATE drinks SET drink_type = :drink_type WHERE postpaid_user_id = :user_id AND id = :drink_id") else: t = text("UPDATE drinks SET drink_type = :drink_type WHERE prepaid_user_id = :user_id AND id = :drink_id") + + t_update_quantity = text("UPDATE drink_types SET quantity = quantity - 1 WHERE id = :drink_type_id") with engine.connect() as connection: - result = connection.execute(t, {"user_id": user_id, "drink_id": drink_id, "drink_type": drink_type}) + result = connection.execute(t, {"user_id": user_id, "drink_id": drink_id, "drink_type": drink_type_id}) if result.rowcount == 0: raise HTTPException(status_code=404, detail="Drink not found") + + result_quantity = connection.execute(t_update_quantity, {"drink_type_id": drink_type_id}) + if result_quantity.rowcount != 1: + raise HTTPException(status_code=404, detail="Drink type not found") connection.commit() return result.rowcount +def get_drink_type(drink_id: int): + t = text("SELECT drink_name, icon FROM drink_types WHERE id = :drink_id") + with engine.connect() as connection: + result = connection.execute(t, {"drink_id": drink_id}).fetchone() + if not result: + raise HTTPException(status_code=404, detail="Drink type not found") + return {"drink_name": result[0], "icon": result[1]} + +def get_drink_type_by_name(drink_name: str): + t = text("SELECT id, icon FROM drink_types WHERE drink_name = :drink_name") + with engine.connect() as connection: + result = connection.execute(t, {"drink_name": drink_name}).fetchone() + if not result: + raise HTTPException(status_code=404, detail="Drink type not found") + return {"drink_type_id": result[0], "icon": result[1]} + +def add_drink_type(drink_name: str, icon: str, quantity: int = 0): + t = text("INSERT INTO drink_types (drink_name, icon, quantity) VALUES (:drink_name, :icon, :quantity)") + with engine.connect() as connection: + result = connection.execute(t, {"drink_name": drink_name, "icon": icon, "quantity": quantity}) + if result.rowcount == 0: + raise HTTPException(status_code=500, detail="Failed to add drink type") + connection.commit() + +def set_drink_type_quantity(drink_type_id: int, quantity: int): + t = text("UPDATE drink_types SET quantity = :quantity WHERE id = :drink_type_id") + with engine.connect() as connection: + result = connection.execute(t, {"drink_type_id": drink_type_id, "quantity": quantity}) + if result.rowcount == 0: + raise HTTPException(status_code=404, detail="Drink type not found") + connection.commit() + def get_most_used_drinks(user_id: int, user_is_postpaid: bool, limit: int = 4): """ Return up to `limit` most used drinks for a user, filling with random drinks if needed. @@ -734,19 +797,34 @@ def get_most_used_drinks(user_id: int, user_is_postpaid: bool, limit: int = 4): list[dict]: Each dict has 'drink_type' and 'count'. """ if user_is_postpaid: - t = text("SELECT drink_type, count(drink_type) as count FROM drinks WHERE postpaid_user_id = :user_id AND drink_type IS NOT NULL AND drink_type != 'Sonstiges' AND drink_type != 'None' GROUP BY drink_type ORDER BY count DESC LIMIT :limit") + t = text("SELECT drink_type, count(drink_type) as count FROM drinks WHERE postpaid_user_id = :user_id AND drink_type != 1 GROUP BY drink_type ORDER BY count DESC LIMIT :limit") else: - t = text("SELECT drink_type, count(drink_type) as count FROM drinks WHERE prepaid_user_id = :user_id AND drink_type IS NOT NULL AND drink_type != 'Sonstiges' AND drink_type != 'None' GROUP BY drink_type ORDER BY count DESC LIMIT :limit") + t = text("SELECT drink_type, count(drink_type) as count FROM drinks WHERE prepaid_user_id = :user_id AND drink_type != 1 GROUP BY drink_type ORDER BY count DESC LIMIT :limit") with engine.connect() as connection: result = connection.execute(t, {"user_id": user_id, "limit": limit}).fetchall() - drinks = [{"drink_type": row[0], "count": row[1]} for row in result] + drinks = [{"drink_type_id": row[0], "count": row[1]} for row in result] + + available_drink_ids_text = text("SELECT id FROM drink_types") + available_drinks = connection.execute(available_drink_ids_text).fetchall() + available_drink_ids = [row[0] for row in available_drinks] + if 1 in available_drink_ids: + available_drink_ids.remove(1) while len(drinks) < limit: - random_drink = random.choice(AVAILABLE_DRINKS) - if any(drink["drink_type"] == random_drink for drink in drinks): + if not available_drink_ids: + print("No more available drink types to fill up the drinks list") + break + random_drink = random.choice(available_drink_ids) + if any(drink["drink_type_id"] == random_drink for drink in drinks): + available_drink_ids.remove(random_drink) continue - drinks.append({"drink_type": random_drink, "count": 0}) + drinks.append({"drink_type_id": random_drink, "count": 0}) + + for drink in drinks: + drink_type_info = get_drink_type(drink["drink_type_id"]) + drink["drink_type"] = drink_type_info["drink_name"] + drink["icon"] = drink_type_info["icon"] return drinks @@ -765,6 +843,11 @@ def get_stats_drink_types(): result = connection.execute(t).fetchall() if not result: return [] - drinks = [{"drink_type": row[0], "count": row[1]} for row in result] + drinks = [{"drink_type_id": row[0], "count": row[1]} for row in result] + + for drink in drinks: + drink_type_info = get_drink_type(drink["drink_type_id"]) + drink["drink_type"] = drink_type_info["drink_name"] + drink["icon"] = drink_type_info["icon"] return drinks diff --git a/main.py b/main.py index 31c9b11..0c48aaf 100644 --- a/main.py +++ b/main.py @@ -47,6 +47,7 @@ def home(request: Request): # if user is Admin, load all postpaid users users = None db_users_prepaid = None + drink_types = [] if ADMIN_GROUP in user_authentik["groups"]: with engine.connect() as conn: t = text("SELECT id FROM users_postpaid") @@ -58,6 +59,9 @@ def home(request: Request): if user_db: users.append(user_db) + t2 = text("SELECT id, drink_name, icon, quantity FROM drink_types") + drink_types = conn.execute(t2).fetchall() + # if user is in Fachschaft, load all prepaid users prepaid_users_from_curr_user = [] if FS_GROUP in user_authentik["groups"]: @@ -91,7 +95,7 @@ def home(request: Request): last_drink = db.models.get_last_drink(user_db_id, user_is_postpaid, 60) most_used_drinks = db.models.get_most_used_drinks(user_db_id, user_is_postpaid, 3) - most_used_drinks.append({"drink_type": "Sonstiges", "count": 0}) # ensure "Sonstiges" is in + most_used_drinks.append({"drink_type_id": 1, "drink_type": "Sonstiges", "count": 0, "icon": "sonstiges.png"}) # ensure "Sonstiges" is in return templates.TemplateResponse("index.html", { "request": request, @@ -103,6 +107,7 @@ def home(request: Request): "prepaid_users_from_curr_user": prepaid_users_from_curr_user, "last_drink": last_drink, "avail_drink_types": most_used_drinks, + "drink_types": drink_types, }) @app.get("/login", response_class=HTMLResponse) @@ -465,7 +470,9 @@ def update_drink_post(request: Request, drink_type: str = Form(...)): if not drink_type: raise HTTPException(status_code=400, detail="Drink type is empty") - db.models.update_drink_type(user_db_id, get_is_postpaid(user_authentik), last_drink["id"], drink_type) + drink_type_id = db.models.get_drink_type_by_name(drink_type)["drink_type_id"] + + db.models.update_drink_type(user_db_id, get_is_postpaid(user_authentik), last_drink["id"], drink_type_id) return RedirectResponse(url="/", status_code=303) @@ -496,6 +503,50 @@ def stats(request: Request): "user_db_id": user_db_id, "stats_drink_types": drink_types, }) + +@app.post("/add_drink_type") +def add_drink_type(request: Request, drink_type: str = Form(...), icon: str = Form(...)): + user_authentik = request.session.get("user_authentik") + if not user_authentik or ADMIN_GROUP not in user_authentik["groups"]: + raise HTTPException(status_code=403, detail="Not allowed") + user_db_id = request.session.get("user_db_id") + if not user_db_id: + raise HTTPException(status_code=404, detail="User not found") + if not drink_type: + raise HTTPException(status_code=400, detail="Drink type is empty") + if not icon: + raise HTTPException(status_code=400, detail="Icon is empty") + if len(drink_type) < 3 or len(drink_type) > 20: + raise HTTPException(status_code=400, detail="Drink type must be between 3 and 20 characters") + if len(icon) < 3 or len(icon) > 20: + raise HTTPException(status_code=400, detail="Icon must be between 3 and 20 characters") + if not icon.endswith(".png"): + raise HTTPException(status_code=400, detail="Icon must be a .png file") + if db.models.get_drink_type_by_name(drink_type): + raise HTTPException(status_code=400, detail="Drink type already exists") + + db.models.add_drink_type(drink_type, icon) + + return RedirectResponse(url="/", status_code=303) + +@app.post("/set_drink_type_quantity") +def drink_type_set_quantity(request: Request, drink_type_name: str = Form(...), quantity: int = Form(...)): + user_authentik = request.session.get("user_authentik") + if not user_authentik or ADMIN_GROUP not in user_authentik["groups"]: + raise HTTPException(status_code=403, detail="Not allowed") + user_db_id = request.session.get("user_db_id") + if not user_db_id: + raise HTTPException(status_code=404, detail="User not found") + if not drink_type_name or not quantity: + raise HTTPException(status_code=400, detail="Drink type name and quantity are required") + if quantity < 0 or quantity > 10000: + raise HTTPException(status_code=400, detail="Quantity must be between 0 and 10000") + + drink_type_id = db.models.get_drink_type_by_name(drink_type_name)["drink_type_id"] + + db.models.set_drink_type_quantity(drink_type_id, quantity) + + return RedirectResponse(url="/", status_code=303) def get_is_postpaid(user_authentik: dict) -> bool: diff --git a/static/drinks/mio_mate_ginger.png b/static/drinks/mio_mate_ginger.png new file mode 100644 index 0000000..f0e6735 Binary files /dev/null and b/static/drinks/mio_mate_ginger.png differ diff --git a/static/drinks/mio_mate.png b/static/drinks/mio_mate_original.png similarity index 100% rename from static/drinks/mio_mate.png rename to static/drinks/mio_mate_original.png diff --git a/static/drinks/mio_mate_pomegranate.png b/static/drinks/mio_mate_pomegranate.png new file mode 100644 index 0000000..7b4b208 Binary files /dev/null and b/static/drinks/mio_mate_pomegranate.png differ diff --git a/static/drinks/paulaner_limo_orange.png b/static/drinks/paulaner_limo_orange.png new file mode 100644 index 0000000..ce226bb Binary files /dev/null and b/static/drinks/paulaner_limo_orange.png differ diff --git a/static/drinks/paulaner_limo_zitrone.png b/static/drinks/paulaner_limo_zitrone.png new file mode 100644 index 0000000..ca4178b Binary files /dev/null and b/static/drinks/paulaner_limo_zitrone.png differ diff --git a/static/drinks/rosbacher_iso.png b/static/drinks/rosbacher_iso.png new file mode 100644 index 0000000..428fa94 Binary files /dev/null and b/static/drinks/rosbacher_iso.png differ diff --git a/templates/index.html b/templates/index.html index fc3f5e0..01b08d5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -81,7 +81,7 @@ content %} - + {{ drink.drink_type }} {% if drink.count > 0 %} x{{ drink.count }} @@ -155,14 +155,8 @@ content %} ID {{ db_user.postpaid_user_id }} {% endif %} - - Wähle dein Getränk oder warte bitte - - - - - + + {% if user %} {% if 'Getraenkeliste Postpaid' in user.groups %} @@ -327,6 +321,53 @@ content %} No users in prepaid database {% endif %} + + Getränkesorten + Verfügbare Getränkesorten: + {% if drink_types %} + + + + ID + Name + Icon + Quantity + + + + {% for drink_type in drink_types %} + {{ drink_type[0] }} + {{ drink_type[1] }} + {{ drink_type[2] }} + {{ drink_type[3] }} + + {% endfor %} + + + {% else %} + Keine Getränkesorten vorhanden. + {% endif %} + Füge Getränkesorte hinzu: + + Name: + + Icon: + + Add Drink Type + + Setze Menge der Getränkesorte: + + Getränkesorte: + + {% for drink_type in drink_types %} + {{ drink_type[1] }} + {% endfor %} + + Menge: + + Setzen + + {% endif %} {% endif %}
Verfügbare Getränkesorten:
Keine Getränkesorten vorhanden.
Füge Getränkesorte hinzu:
Setze Menge der Getränkesorte: