diff --git a/db/models.py b/db/models.py index f6469db..7376371 100644 --- a/db/models.py +++ b/db/models.py @@ -1,28 +1,45 @@ """ -This module defines database models and utility functions for managing users and drinks in a beverage tracking application using SQLAlchemy and FastAPI. -Tables: - - users_postpaid: Stores postpaid user accounts. - - users_prepaid: Stores prepaid user accounts, linked to postpaid users. - - drinks: Records drink transactions for both postpaid and prepaid users. +Database models and operations for a drink management system using SQLAlchemy and FastAPI. + +This module provides functions to manage postpaid and prepaid users, handle drink transactions, +log money transfers, and generate statistics. It includes table creation, user CRUD operations, +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: - - create_postpaid_user(username: str) -> int: - Creates a new postpaid user with the specified username. Raises HTTPException if the user already exists or if a database error occurs. Returns the ID of the newly created user. - - get_postpaid_user(user_id: int) -> dict: - Retrieves a postpaid user's information by their user ID. Returns a dictionary with user details. Raises HTTPException if the user is not found. - - get_postpaid_user_by_username(username: str) -> dict: - Retrieves a postpaid user's information by their username. Returns a dictionary with user details. Raises HTTPException if the user is not found. - - set_postpaid_user_money(user_id: int, money: float) -> int: - Updates the 'money' balance for a postpaid user. Raises HTTPException if the user is not found. Returns the number of rows affected. - - drink_postpaid_user(user_id: int) -> int: - Deducts 100 units from the specified postpaid user's balance and records a drink entry. Raises HTTPException if the user is not found or if the drink entry could not be created. Returns the number of rows affected by the drink entry insertion. + _log_transaction(user_id, user_is_postpaid, ...): Log a user's money transaction. + create_postpaid_user(username): Create a new postpaid user. + get_postpaid_user(user_id): Retrieve a postpaid user's info by ID. + get_postpaid_user_by_username(username): Retrieve a postpaid user by username. + set_postpaid_user_money(user_id, money): Set a postpaid user's balance. + drink_postpaid_user(user_id, drink_type): Deduct drink cost and record a drink for postpaid user + toggle_activate_postpaid_user(user_id): Toggle activation status of a postpaid user. + payup_postpaid_user(current_user_id, payup_user_id, money_cent): Transfer money btw. post users. + get_prepaid_user(user_id): Retrieve a prepaid user's info by ID. + get_prepaid_user_by_username(username): Retrieve a prepaid user by username. + create_prepaid_user(prepaid_username, postpaid_user_id, start_money): Create a new prepaid user. + drink_prepaid_user(user_db_id): Process a prepaid drink transaction. + toggle_activate_prepaid_user(user_id): Toggle activation status of a prepaid user. + set_prepaid_user_money(user_id, money, postpaid_user_id): Set prepaid user's balance and link. + del_user_prepaid(user_id): Delete a prepaid user. + get_last_drink(user_id, user_is_postpaid, max_since_seconds): Get last drink within time window. + revert_last_drink(user_id, user_is_postpaid, drink_id, drink_cost): Revert a drink and refund. + update_drink_type(user_id, user_is_postpaid, drink_id, drink_type): Update drink type for drink. + get_most_used_drinks(user_id, user_is_postpaid, limit): Get most used drinks for a user. + get_stats_drink_types(): Get statistics of drink types. + + HTTPException: For database errors, not found, or forbidden actions. """ +import os import secrets import datetime import random -from sqlalchemy import create_engine, text +from sqlalchemy import create_engine, text, select from fastapi import HTTPException -import os from dotenv import load_dotenv load_dotenv() @@ -102,22 +119,30 @@ def _log_transaction( description = None ): """ - Logs a transaction for a user, recording changes in their account balance. - Depending on whether the user is postpaid or prepaid, retrieves the previous balance if not provided, - calculates the new balance and delta if necessary, and inserts a transaction record into the database. - Args: - user_id (int): The ID of the user for whom the transaction is being logged. - user_is_postpaid (bool): True if the user is postpaid, False if prepaid. - previous_money_cent (Optional[int], default=None): The user's previous balance in cents. If None, it is fetched from the database. - new_money_cent (Optional[int], default=None): The user's new balance in cents. If None, it is calculated using delta_money_cent. - delta_money_cent (Optional[int], default=None): The change in balance in cents. If None, it is calculated using new_money_cent. - description (Optional[str], default=None): A description of the transaction. - Raises: - HTTPException: If the user is not found, if both new_money_cent and delta_money_cent are missing, - or if the transaction could not be logged. - Returns: - int: The ID of the newly created transaction record. - """ + Logs a transaction for a user, recording changes in their account balance. + + Depending on whether the user is postpaid or prepaid, retrieves the previous balance if not + provided, calculates the new balance and delta if necessary, and inserts a transaction record + into the database. + + Args: + user_id (int): The ID of the user for whom the transaction is being logged. + user_is_postpaid (bool): True if the user is postpaid, False if prepaid. + previous_money_cent (Optional[int], default=None): The user's previous balance in cents. + If None, it is fetched from the database. + new_money_cent (Optional[int], default=None): The user's new balance in cents. If None, + it is calculated using delta_money_cent. + delta_money_cent (Optional[int], default=None): The change in balance in cents. If None, + it is calculated using new_money_cent. + description (Optional[str], default=None): A description of the transaction. + + Raises: + HTTPException: If the user is not found, if both new_money_cent and delta_money_cent are + missing, or if the transaction could not be logged. + + Returns: + int: The ID of the newly created transaction record. + """ if previous_money_cent is None: if user_is_postpaid: t_get_prev_money = text("SELECT money FROM users_postpaid WHERE id = :id") @@ -130,7 +155,13 @@ def _log_transaction( else: raise HTTPException(status_code=404, detail="User not found") if new_money_cent is None and delta_money_cent is None: - raise HTTPException(status_code=400, detail="Either new_money or delta_money must be provided, not both") + raise HTTPException( + status_code=400, + detail=( + "Either new_money or delta_money " + "must be provided, not both" + ) + ) if new_money_cent is None and delta_money_cent is not None: new_money_cent = previous_money_cent + delta_money_cent elif delta_money_cent is None and new_money_cent is not None: @@ -248,7 +279,12 @@ def set_postpaid_user_money(user_id: int, money: float): int: The number of rows affected by the update operation. """ - _log_transaction(user_id, user_is_postpaid=True, new_money_cent=money, description="Set money manually via Admin UI") + _log_transaction( + user_id, + user_is_postpaid=True, + new_money_cent=money, + description="Set money manually via Admin UI" + ) t = text("UPDATE users_postpaid SET money = :money WHERE id = :id") with engine.connect() as connection: result = connection.execute(t, {"id": user_id, "money": money}) @@ -301,6 +337,15 @@ def drink_postpaid_user(user_id: int, drink_type: str = ""): return result.rowcount def toggle_activate_postpaid_user(user_id: int): + """ + Toggles the 'activated' status of a postpaid user in the database. + Args: + user_id (int): The ID of the user whose activation status should be toggled. + Returns: + int: The number of rows affected by the update operation. + Raises: + HTTPException: If no user with the given ID is found (404 error). + """ prev_activated = get_postpaid_user(user_id)["activated"] t = text("UPDATE users_postpaid SET activated = :activated WHERE id = :id") with engine.connect() as connection: @@ -310,8 +355,71 @@ def toggle_activate_postpaid_user(user_id: int): connection.commit() return result.rowcount +def payup_postpaid_user(current_user_id: int, payup_user_id: int, money_cent: int): + """ + Transfers a specified amount of money (in cents) from one postpaid user to another. + Args: + current_user_id (int): ID of the user paying the money. + payup_user_id (int): ID of the user receiving the money. + money_cent (int): Amount of money to transfer, in cents. + Raises: + HTTPException: If either user is not activated or not found in the database. + """ + current_user = get_postpaid_user(current_user_id) + if not current_user["activated"]: + raise HTTPException(status_code=403, detail="Current user not activated") + payup_user = get_postpaid_user(payup_user_id) + if not payup_user["activated"]: + raise HTTPException(status_code=403, detail="Payup user not activated") + + # subtract money from current user + t = text("UPDATE users_postpaid SET money = money - :money WHERE id = :id") + with engine.connect() as connection: + result = connection.execute(t, {"id": current_user_id, "money": money_cent}) + if result.rowcount == 0: + raise HTTPException(status_code=404, detail="Current user not found") + connection.commit() + _log_transaction( + user_id=current_user_id, + user_is_postpaid=True, + previous_money_cent=current_user["money"], + delta_money_cent=-money_cent, + description=f"Payup to user {payup_user_id}" + ) + + # add money to payup user + t = text("UPDATE users_postpaid SET money = money + :money WHERE id = :id") + with engine.connect() as connection: + result = connection.execute(t, {"id": payup_user_id, "money": money_cent}) + if result.rowcount == 0: + raise HTTPException(status_code=404, detail="Payup user not found") + connection.commit() + _log_transaction( + user_id=payup_user_id, + user_is_postpaid=True, + previous_money_cent=payup_user["money"], + new_money_cent=payup_user["money"] + money_cent, + delta_money_cent=money_cent, + description=f"Received payup from user {current_user_id}" + ) def get_prepaid_user(user_id: int): + """ + Retrieve a prepaid user from the database by user ID. + Args: + user_id (int): The ID of the prepaid user to retrieve. + Returns: + dict: A dictionary containing the user's information with keys: + - "id": User's ID + - "username": User's username + - "user_key": User's unique key + - "postpaid_user_id": Linked postpaid user ID (if any) + - "money": User's prepaid balance + - "activated": Activation status + - "last_drink": Timestamp of the user's last drink + Raises: + HTTPException: If no user with the given ID is found (status code 404). + """ t = text("SELECT id, username, user_key, postpaid_user_id, money, activated, last_drink FROM users_prepaid WHERE id = :id") user_db = {} with engine.connect() as connection: @@ -330,15 +438,14 @@ def get_prepaid_user(user_id: int): def get_prepaid_user_by_username(username: str): """ - Retrieve a prepaid user from the database by their username. + Retrieve a prepaid user by username. Args: - username (str): The username of the user to retrieve. + username (str): The username to look up. Returns: - dict: A dictionary containing the user's id, username, money, activated status, and last_drink timestamp. + dict: User info (id, username, user_key, postpaid_user_id, money, activated, last_drink). Raises: - HTTPException: If no user with the given username is found, raises a 404 HTTPException. + HTTPException: If user not found (404). """ - t = text("SELECT id, username, user_key, postpaid_user_id, money, activated, last_drink FROM users_prepaid WHERE username = :username") user_db = {} with engine.connect() as connection: @@ -356,6 +463,17 @@ def get_prepaid_user_by_username(username: str): return user_db def create_prepaid_user(prepaid_username: str, postpaid_user_id: int, start_money: int = 0): + """ + Create a new prepaid user in users_prepaid. + Args: + prepaid_username (str): Username for the new prepaid user. + postpaid_user_id (int): Associated postpaid user ID. + start_money (int, optional): Initial money for the prepaid user. Defaults to 0. + Raises: + HTTPException: If username exists (400) or user creation fails (500). + Returns: + int: ID of the new prepaid user. + """ prepaid_key = secrets.token_urlsafe(6) t = text("INSERT INTO users_prepaid (username, user_key, postpaid_user_id, money) VALUES (:username, :user_key, :postpaid_user_id, :start_money)") with engine.connect() as connection: @@ -380,6 +498,24 @@ def create_prepaid_user(prepaid_username: str, postpaid_user_id: int, start_mone return result.lastrowid def drink_prepaid_user(user_db_id: int): + """ + Processes a prepaid drink transaction for a user. + This function performs the following steps: + 1. Retrieves the prepaid user's information from the database. + 2. Checks if the user is activated; raises HTTP 403 if not. + 3. Checks if the user has enough money for a drink; raises HTTP 403 if not. + 4. Logs the transaction. + 5. Deducts the drink cost from the user's balance and updates the last drink timestamp. + 6. Inserts a new entry into the drinks table for the user. + 7. Commits all changes to the database. + Args: + user_db_id (int): The database ID of the prepaid user. + Returns: + int: The number of rows affected by the drink entry insertion. + Raises: + HTTPException: If the user is not activated (403), does not have enough money (403), + is not found (404), or if the drink entry could not be created (500). + """ user_dict = get_prepaid_user(user_db_id) if not user_dict["activated"]: raise HTTPException(status_code=403, detail="User not activated") @@ -387,6 +523,7 @@ def drink_prepaid_user(user_db_id: int): prev_money = user_dict["money"] if prev_money < DRINK_COST: raise HTTPException(status_code=403, detail="Not enough money") + _log_transaction( user_id=user_db_id, user_is_postpaid=False, @@ -410,6 +547,15 @@ def drink_prepaid_user(user_db_id: int): return result.rowcount def toggle_activate_prepaid_user(user_id: int): + """ + Toggles the 'activated' status of a prepaid user in the database. + Args: + user_id (int): The ID of the user whose activation status is to be toggled. + Returns: + int: The number of rows affected by the update operation. + Raises: + HTTPException: If no user with the given ID is found (404 error). + """ prev_activated = get_prepaid_user(user_id)["activated"] t = text("UPDATE users_prepaid SET activated = :activated WHERE id = :id") with engine.connect() as connection: @@ -420,6 +566,17 @@ def toggle_activate_prepaid_user(user_id: int): return result.rowcount def set_prepaid_user_money(user_id: int, money: int, postpaid_user_id: int): + """ + Updates the prepaid user's money and associated postpaid user ID in the database. + Args: + user_id (int): The ID of the prepaid user whose information is to be updated. + money (int): The new amount of money (in cents) to set for the user. + postpaid_user_id (int): The ID of the associated postpaid user. + Raises: + HTTPException: If the user with the given user_id is not found in the database. + Returns: + int: The number of rows affected by the last update operation. + """ t1 = text("UPDATE users_prepaid SET money = :money WHERE id = :id") t2 = text("UPDATE users_prepaid SET postpaid_user_id = :postpaid_user_id WHERE id = :id") _log_transaction( @@ -439,6 +596,15 @@ def set_prepaid_user_money(user_id: int, money: int, postpaid_user_id: int): return result.rowcount def del_user_prepaid(user_id: int): + """ + Deletes a user's prepaid entry from the 'users_prepaid' table by user ID. + Args: + user_id (int): The ID of the user whose prepaid entry should be deleted. + Raises: + HTTPException: If no entry with the given user_id is found (404 Not Found). + Returns: + int: The number of rows deleted (should be 1 if successful). + """ t = text("DELETE FROM users_prepaid WHERE id = :id") with engine.connect() as connection: result = connection.execute(t, {"id": user_id}) @@ -448,6 +614,18 @@ def del_user_prepaid(user_id: int): return result.rowcount def get_last_drink(user_id: int, user_is_postpaid: bool, max_since_seconds: int = 60): + """ + Retrieve the most recent drink entry for a user within a specified time window. + + Args: + user_id (int): The ID of the user whose last drink is to be retrieved. + user_is_postpaid (bool): True if the user is postpaid, False if prepaid. + max_since_seconds (int, optional): Max seconds since last drink. Defaults to 60. + + Returns: + dict or None: Dict with 'id', 'timestamp', 'drink_type' if found within time window, + else None. + """ if user_is_postpaid: t = text("SELECT id, timestamp, drink_type FROM drinks WHERE postpaid_user_id = :user_id ORDER BY timestamp DESC LIMIT 1") else: @@ -469,8 +647,22 @@ def get_last_drink(user_id: int, user_is_postpaid: bool, max_since_seconds: int return None drink_obj = {"id": drink_id, "timestamp": timestamp, "drink_type": drink_type} return drink_obj - + def revert_last_drink(user_id: int, user_is_postpaid: bool, drink_id: int, drink_cost: int = DRINK_COST): + """ + Reverts the last drink purchase for a user and refunds the drink cost. + Args: + user_id (int): The ID of the user whose drink is to be reverted. + user_is_postpaid (bool): True if the user is postpaid, False if prepaid. + drink_id (int): The ID of the drink to revert. + drink_cost (int, optional): The cost of the drink in cents. Defaults to DRINK_COST. + Raises: + HTTPException: If the drink or user is not found in the database. + Side Effects: + - Deletes the drink record from the database. + - Refunds the drink cost to the user's balance. + - Logs the transaction. + """ if user_is_postpaid: del_t = text("DELETE FROM drinks WHERE postpaid_user_id = :user_id AND id = :drink_id") update_t = text("UPDATE users_postpaid SET money = money + :drink_cost WHERE id = :user_id") @@ -490,11 +682,11 @@ def revert_last_drink(user_id: int, user_is_postpaid: bool, drink_id: int, drink prev_money = connection.execute(money_t, {"user_id": user_id}).fetchone() if not prev_money: raise HTTPException(status_code=404, detail="User not found") - + new_money = prev_money[0] + drink_cost connection.execute(update_t, {"user_id": user_id, "drink_cost": drink_cost}) connection.commit() - + _log_transaction( user_id=user_id, user_is_postpaid=user_is_postpaid, @@ -505,6 +697,20 @@ def revert_last_drink(user_id: int, user_is_postpaid: bool, drink_id: int, drink ) def update_drink_type(user_id: int, user_is_postpaid: bool, drink_id, drink_type: str): + """ + 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` + field in the `drinks` table for the drink with the given `drink_id` and user association. + Args: + user_id (int): The ID of the user whose drink is being updated. + user_is_postpaid (bool): Indicates if the user is postpaid (True) or prepaid (False). + drink_id: The ID of the drink to update. + drink_type (str): The new type to set for the drink. + Raises: + HTTPException: If no drink is found with the given criteria (404 Not Found). + Returns: + int: The number of rows affected by the update. + """ if user_is_postpaid: t = text("UPDATE drinks SET drink_type = :drink_type WHERE postpaid_user_id = :user_id AND id = :drink_id") else: @@ -518,6 +724,15 @@ def update_drink_type(user_id: int, user_is_postpaid: bool, drink_id, drink_type return result.rowcount 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. + Args: + user_id (int): User's ID. + user_is_postpaid (bool): True if postpaid, else prepaid. + limit (int): Max drinks to return. + Returns: + 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' GROUP BY drink_type ORDER BY count DESC LIMIT :limit") else: @@ -536,3 +751,22 @@ def get_most_used_drinks(user_id: int, user_is_postpaid: bool, limit: int = 4): drinks.append({"drink_type": random_drink, "count": 0}) return drinks + +def get_stats_drink_types(): + """ + Retrieves statistics on drink types from the database. + Executes a SQL query to count the occurrences of each non-null drink type in the 'drinks' table, + grouping and ordering the results by the count in descending order. + Returns: + list[dict]: A list of dictionaries, each containing 'drink_type' (str) and 'count' (int). + Returns an empty list if no results are found. + """ + t = text("SELECT drink_type, count(drink_type) as count FROM drinks WHERE drink_type IS NOT NULL GROUP BY drink_type ORDER BY count DESC") + + with engine.connect() as connection: + result = connection.execute(t).fetchall() + if not result: + return [] + drinks = [{"drink_type": row[0], "count": row[1]} for row in result] + + return drinks diff --git a/main.py b/main.py index 6e7bec8..07952b8 100644 --- a/main.py +++ b/main.py @@ -1,36 +1,19 @@ -import random +import os +from dotenv import load_dotenv from fastapi import FastAPI, Request, Form, HTTPException from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi.responses import JSONResponse from starlette.middleware.sessions import SessionMiddleware import uvicorn from sqlalchemy import text from db.models import engine -from db.models import get_postpaid_user -from db.models import get_postpaid_user_by_username -from db.models import set_postpaid_user_money -from db.models import drink_postpaid_user -from db.models import toggle_activate_postpaid_user -from db.models import get_prepaid_user -from db.models import get_prepaid_user_by_username -from db.models import create_prepaid_user -from db.models import drink_prepaid_user -from db.models import toggle_activate_prepaid_user -from db.models import set_prepaid_user_money -from db.models import del_user_prepaid -from db.models import get_last_drink -from db.models import revert_last_drink -from db.models import update_drink_type -from db.models import get_most_used_drinks +import db.models from auth import oidc -import os -from dotenv import load_dotenv @@ -50,7 +33,6 @@ templates = Jinja2Templates(directory="templates") @app.get("/", response_class=HTMLResponse) def home(request: Request): - # Check if user is logged in and has a valid session user_db_id = request.session.get("user_db_id") user_authentik = request.session.get("user_authentik") @@ -72,7 +54,7 @@ def home(request: Request): if result: users = [] for row in result: - user_db = get_postpaid_user(row[0]) + user_db = db.models.get_postpaid_user(row[0]) if user_db: users.append(user_db) @@ -85,7 +67,7 @@ def home(request: Request): if result: db_users_prepaid = [] for row in result: - prepaid_user = get_prepaid_user(row[0]) + prepaid_user = db.models.get_prepaid_user(row[0]) if prepaid_user: db_users_prepaid.append(prepaid_user) # additionally load all prepaid users from the current user @@ -94,22 +76,22 @@ def home(request: Request): if result: prepaid_users_from_curr_user = [] for row in result: - prepaid_user = get_prepaid_user(row[0]) + prepaid_user = db.models.get_prepaid_user(row[0]) if prepaid_user: prepaid_users_from_curr_user.append(prepaid_user) # load current user from database user_is_postpaid = get_is_postpaid(user_authentik) if user_is_postpaid: - db_user = get_postpaid_user(user_db_id) + db_user = db.models.get_postpaid_user(user_db_id) else: - db_user = get_prepaid_user(user_db_id) + db_user = db.models.get_prepaid_user(user_db_id) # get last drink for current user, if not less than 60 seconds ago - last_drink = get_last_drink(user_db_id, user_is_postpaid, 60) + last_drink = db.models.get_last_drink(user_db_id, user_is_postpaid, 60) - most_used_drinks = get_most_used_drinks(user_db_id, user_is_postpaid, 3) - most_used_drinks.append({"drink_type": "Sonstiges", "count": 0}) # Ensure "Sonstiges" is always included + 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 return templates.TemplateResponse("index.html", { "request": request, @@ -132,7 +114,6 @@ def login_form(request: Request): Returns: TemplateResponse: The rendered login.html template with the request context. """ - return templates.TemplateResponse("login.html", {"request": request}) @app.post("/set_money_postpaid") @@ -155,10 +136,10 @@ def set_money_postpaid(request: Request, username = Form(...), money: float = Fo if not user_authentik or ADMIN_GROUP not in user_authentik["groups"]: raise HTTPException(status_code=403, detail="Nicht erlaubt") - user = get_postpaid_user_by_username(username) + user = db.models.get_postpaid_user_by_username(username) requested_user_id = user["id"] - set_postpaid_user_money(requested_user_id, money*100) + db.models.set_postpaid_user_money(requested_user_id, money*100) return RedirectResponse(url="/", status_code=303) @app.post("/drink") @@ -186,48 +167,93 @@ async def drink(request: Request): if not user_db_id: raise HTTPException(status_code=404, detail="User not found") - drink_postpaid_user(user_db_id) + db.models.drink_postpaid_user(user_db_id) return RedirectResponse(url="/", status_code=303) @app.post("/payup") def payup(request: Request, username: str = Form(...), money: float = Form(...)): + """ + Handles the payment process for a postpaid user. + This endpoint allows an authenticated admin user to record a payment for a specified user. + It validates the admin's permissions, checks the validity of the username and payment amount, + and updates the user's postpaid balance accordingly. + Args: + request (Request): The incoming HTTP request, containing session data. + username (str): The username of the user whose balance is to be updated (form data). + money (float): The amount of money to be paid (form data, must be between 0 and 1000). + Raises: + HTTPException: If the user is not authenticated as an admin (403). + HTTPException: If the specified user is not found (404). + HTTPException: If the payment amount is invalid (400). + HTTPException: If the current user is not found in the session (404). + Returns: + RedirectResponse: Redirects to the home page ("/") with a 303 status code upon success. + """ user_auth = request.session.get("user_authentik") if not user_auth or ADMIN_GROUP not in user_auth["groups"]: raise HTTPException(status_code=403, detail="Not allowed") - user_db_id = get_postpaid_user_by_username(username)["id"] + user_db_id = db.models.get_postpaid_user_by_username(username)["id"] if not user_db_id: raise HTTPException(status_code=404, detail="User not found") if money < 0 or money > 1000: raise HTTPException(status_code=400, detail="Money must be between 0 and 1000") - curr_user_money = get_postpaid_user(user_db_id)["money"] - set_postpaid_user_money(user_db_id, curr_user_money + money*100) - current_user_db_id = request.session.get("user_db_id") if not current_user_db_id: raise HTTPException(status_code=404, detail="Current user not found") - current_user_money = get_postpaid_user(current_user_db_id)["money"] - set_postpaid_user_money(current_user_db_id, current_user_money - money*100) + + db.models.payup_postpaid_user(current_user_db_id, user_db_id, int(money*100)) + return RedirectResponse(url="/", status_code=303) @app.post("/toggle_activated_user_postpaid") def toggle_activated_user_postpaid(request: Request, username: str = Form(...)): + """ + Toggles the activation status of a postpaid user account. + Args: + request (Request): The incoming HTTP request, containing session information. + username (str): The username of the postpaid user to toggle, provided via form data. + Raises: + HTTPException: If the user is not authenticated as an admin (status code 403). + HTTPException: If the specified user is not found (status code 404). + Returns: + RedirectResponse: Redirects to the homepage ("/") with a 303 status code after toggling. + """ user_auth = request.session.get("user_authentik") if not user_auth or ADMIN_GROUP not in user_auth["groups"]: raise HTTPException(status_code=403, detail="Not allowed") - user_db_id = get_postpaid_user_by_username(username)["id"] + user_db_id = db.models.get_postpaid_user_by_username(username)["id"] if not user_db_id: raise HTTPException(status_code=404, detail="User not found") - toggle_activate_postpaid_user(user_db_id) + db.models.toggle_activate_postpaid_user(user_db_id) return RedirectResponse(url="/", status_code=303) @app.post("/add_prepaid_user") def add_prepaid_user(request: Request, username: str = Form(...), start_money: float = Form(...)): + """ + Handles the creation of a new prepaid user account. + This endpoint validates the current user's authentication and group membership, + checks for the existence of the username among prepaid and postpaid users, + validates the input parameters, creates the prepaid user, and deducts the starting + money from the current user's postpaid balance. + Args: + request (Request): The incoming HTTP request, containing session data. + username (str): The username for the new prepaid user (form field, required). + start_money (float): The initial balance for the new prepaid user (form field, required). + Raises: + HTTPException: + - 403 if the current user is not authorized. + - 404 if the current user is not found in the session. + - 400 if the username is empty, already exists, or does not meet length requirements. + - 400 if the start_money is not between 0 and 100. + Returns: + RedirectResponse: Redirects to the homepage ("/") with status code 303 upon successful creation. + """ active_user_auth = request.session.get("user_authentik") active_user_db_id = request.session.get("user_db_id") if not active_user_auth or FS_GROUP not in active_user_auth["groups"]: @@ -239,9 +265,9 @@ def add_prepaid_user(request: Request, username: str = Form(...), start_money: f user_exists = False try: - get_postpaid_user_by_username(username) + db.models.get_postpaid_user_by_username(username) user_exists = True - get_prepaid_user_by_username(username) + db.models.get_prepaid_user_by_username(username) user_exists = True except HTTPException: pass @@ -254,15 +280,28 @@ def add_prepaid_user(request: Request, username: str = Form(...), start_money: f if len(username) < 3 or len(username) > 20: raise HTTPException(status_code=400, detail="Username must be between 3 and 20 characters") - create_prepaid_user(username, active_user_db_id, int(start_money*100)) + db.models.create_prepaid_user(username, active_user_db_id, int(start_money*100)) - prev_money = get_postpaid_user(active_user_db_id)["money"] - set_postpaid_user_money(active_user_db_id, prev_money - int(start_money*100)) + prev_money = db.models.get_postpaid_user(active_user_db_id)["money"] + db.models.set_postpaid_user_money(active_user_db_id, prev_money - int(start_money*100)) return RedirectResponse(url="/", status_code=303) @app.post("/drink_prepaid") def drink_prepaid(request: Request): + """ + Handles a prepaid drink request for a user. + This function checks if the user is authenticated and has prepaid privileges. + If the user is not found in the session or does not have prepaid access, it raises an HTTPException. + If the user is valid and has prepaid access, it records the prepaid drink for the user in the database + and redirects to the home page. + Args: + request (Request): The incoming HTTP request containing session data. + Raises: + HTTPException: If the user is not found in the session or does not have prepaid privileges. + Returns: + RedirectResponse: Redirects the user to the home page with a 303 status code. + """ user_db_id = request.session.get("user_db_id") if not user_db_id: raise HTTPException(status_code=404, detail="User not found") @@ -272,25 +311,52 @@ def drink_prepaid(request: Request): if not user_authentik["prepaid"]: raise HTTPException(status_code=403, detail="Not allowed") - drink_prepaid_user(user_db_id) + db.models.drink_prepaid_user(user_db_id) return RedirectResponse(url="/", status_code=303) @app.post("/toggle_activated_user_prepaid") def toggle_activated_user_prepaid(request: Request, username: str = Form(...)): + """ + Toggle the activation status of a prepaid user account. + This endpoint is restricted to users who are members of the ADMIN_GROUP. + It retrieves the user by username, toggles their activation status, and redirects to the homepage. + Args: + request (Request): The incoming HTTP request, containing session data. + username (str): The username of the prepaid user to toggle, provided via form data. + Raises: + HTTPException: If the user is not authenticated as an admin (403). + HTTPException: If the specified user is not found (404). + Returns: + RedirectResponse: Redirects to the homepage with a 303 status code after toggling. + """ user_auth = request.session.get("user_authentik") if not user_auth or ADMIN_GROUP not in user_auth["groups"]: raise HTTPException(status_code=403, detail="Not allowed") - user_db_id = get_prepaid_user_by_username(username)["id"] + user_db_id = db.models.get_prepaid_user_by_username(username)["id"] if not user_db_id: raise HTTPException(status_code=404, detail="User not found") - toggle_activate_prepaid_user(user_db_id) + db.models.toggle_activate_prepaid_user(user_db_id) return RedirectResponse(url="/", status_code=303) @app.post("/add_money_prepaid_user") def add_money_prepaid_user(request: Request, username: str = Form(...), money: float = Form(...)): + """ + Handles the transfer of a specified amount of money from the currently logged-in postpaid user to a prepaid user. + Args: + request (Request): The incoming HTTP request containing session data. + username (str): The username of the prepaid user to whom money will be added (provided via form data). + money (float): The amount of money to transfer (provided via form data, must be between 0 and 100). + Raises: + HTTPException: + - 403 if the current user is not authorized (not in the required group). + - 404 if the logged-in user or the prepaid user is not found. + - 400 if the money amount is not within the allowed range. + Returns: + RedirectResponse: Redirects the user to the homepage ("/") with a 303 status code after a successful transfer. + """ curr_user_auth = request.session.get("user_authentik") if not curr_user_auth or FS_GROUP not in curr_user_auth["groups"]: raise HTTPException(status_code=403, detail="Not allowed") @@ -298,7 +364,7 @@ def add_money_prepaid_user(request: Request, username: str = Form(...), money: f if not curr_user_db_id: raise HTTPException(status_code=404, detail="Logged In User not found") - prepaid_user_dict = get_prepaid_user_by_username(username) + prepaid_user_dict = db.models.get_prepaid_user_by_username(username) prepaid_user_db_id = prepaid_user_dict["id"] if not prepaid_user_db_id: raise HTTPException(status_code=404, detail="Prepaid User not found") @@ -306,31 +372,55 @@ def add_money_prepaid_user(request: Request, username: str = Form(...), money: f if money < 0 or money > 100: raise HTTPException(status_code=400, detail="Money must be between 0 and 100") - curr_user_money = get_postpaid_user(curr_user_db_id)["money"] + curr_user_money = db.models.get_postpaid_user(curr_user_db_id)["money"] prepaid_user_money = prepaid_user_dict["money"] - set_postpaid_user_money(curr_user_db_id, curr_user_money - money*100) - set_prepaid_user_money(prepaid_user_db_id, prepaid_user_money + money*100, curr_user_db_id) + db.models.set_postpaid_user_money(curr_user_db_id, curr_user_money - money*100) + db.models.set_prepaid_user_money(prepaid_user_db_id, prepaid_user_money + money*100, curr_user_db_id) return RedirectResponse(url="/", status_code=303) @app.post("/del_prepaid_user") def delete_prepaid_user(request: Request, username: str = Form(...)): + """ + Deletes a prepaid user from the system. + This endpoint allows an admin user to delete a prepaid user by their username. + It checks if the requester is part of the ADMIN_GROUP, retrieves the user to be deleted, + and removes them from the database. If the user is not found or the requester is not authorized, + an appropriate HTTPException is raised. + Args: + request (Request): The incoming HTTP request, containing session information. + username (str): The username of the prepaid user to delete, provided via form data. + Raises: + HTTPException: If the requester is not authorized (status code 403). + HTTPException: If the user to delete is not found (status code 404). + Returns: + RedirectResponse: Redirects to the homepage ("/") with status code 303 upon successful deletion. + """ # check if user is in ADMIN_GROUP user_auth = request.session.get("user_authentik") if not user_auth or ADMIN_GROUP not in user_auth["groups"]: raise HTTPException(status_code=403, detail="Nicht erlaubt") - user_to_del = get_prepaid_user_by_username(username) + user_to_del = db.models.get_prepaid_user_by_username(username) if not user_to_del["id"]: raise HTTPException(status_code=404, detail="User not found") - del_user_prepaid(user_to_del["id"]) + db.models.del_user_prepaid(user_to_del["id"]) return RedirectResponse(url="/", status_code=303) @app.post("/del_last_drink") def del_last_drink(request: Request): + """ + Handles the deletion (reversion) of the last drink entry for the currently authenticated user. + Args: + request (Request): The incoming HTTP request containing session data. + Raises: + HTTPException: If the user is not found in the session. + Returns: + RedirectResponse: Redirects to the homepage ("/") after attempting to revert the last drink. + """ user_db_id = request.session.get("user_db_id") if not user_db_id: raise HTTPException(status_code=404, detail="User not found") @@ -338,18 +428,28 @@ def del_last_drink(request: Request): if not user_authentik: raise HTTPException(status_code=404, detail="User not found") - last_drink = get_last_drink(user_db_id, True, 60) + last_drink = db.models.get_last_drink(user_db_id, True, 60) if not last_drink: return RedirectResponse(url="/", status_code=303) user_is_postpaid = get_is_postpaid(user_authentik) - revert_last_drink(user_db_id, user_is_postpaid, last_drink["id"]) + db.models.revert_last_drink(user_db_id, user_is_postpaid, last_drink["id"]) return RedirectResponse(url="/", status_code=303) @app.post("/update_drink_post") def update_drink_post(request: Request, drink_type: str = Form(...)): + """ + Handles a POST request to update the type of the user's last drink. + Args: + request (Request): The incoming HTTP request containing session data. + drink_type (str, optional): The new type of drink to set, provided via form data. + Raises: + HTTPException: If the user is not found in the session or if the drink type is empty. + Returns: + RedirectResponse: Redirects to the home page after updating the drink type, or if no last drink is found. + """ user_db_id = request.session.get("user_db_id") if not user_db_id: raise HTTPException(status_code=404, detail="User not found") @@ -358,20 +458,53 @@ def update_drink_post(request: Request, drink_type: str = Form(...)): if not user_authentik: raise HTTPException(status_code=404, detail="User not found") - last_drink = get_last_drink(user_db_id, True, 60) + last_drink = db.models.get_last_drink(user_db_id, True, 60) if not last_drink: return RedirectResponse(url="/", status_code=303) if not drink_type: raise HTTPException(status_code=400, detail="Drink type is empty") - update_drink_type(user_db_id, get_is_postpaid(user_authentik), last_drink["id"], drink_type) + db.models.update_drink_type(user_db_id, get_is_postpaid(user_authentik), last_drink["id"], drink_type) return RedirectResponse(url="/", status_code=303) +@app.get("/stats", response_class=HTMLResponse) +def stats(request: Request): + """ + Handles the statistics page request for authenticated admin users. + Args: + request (Request): The incoming HTTP request object containing session data. + Raises: + HTTPException: If the user is not authenticated as an admin (403). + HTTPException: If the user's database ID is not found in the session (404). + Returns: + TemplateResponse: Renders the "stats.html" template with user and drink type statistics. + """ + 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") + drink_types = db.models.get_stats_drink_types() -def get_is_postpaid(user_authentik) -> bool: + return templates.TemplateResponse("stats.html", { + "request": request, + "user": user_authentik, + "user_db_id": user_db_id, + "stats_drink_types": drink_types, + }) + +def get_is_postpaid(user_authentik: dict) -> bool: + """ + Determine if a user is postpaid based on their authentication information. + Args: + user_authentik (dict): A dictionary containing user authentication data, expected to have a 'prepaid' key. + Returns: + bool: True if the user is postpaid, False if the user is prepaid. If the 'prepaid' key is missing, defaults to True (postpaid). + """ try: if user_authentik["prepaid"]: user_is_postpaid = False diff --git a/templates/base.html b/templates/base.html index f6b1107..a50a345 100644 --- a/templates/base.html +++ b/templates/base.html @@ -48,171 +48,6 @@
{% block content %}{% endblock %} - {% if user %} - {% if 'Getraenkeliste Postpaid' in user.groups %} -

Du bist Teil der Fachschaft Informatik.

- {% if prepaid_users_from_curr_user %} -

Liste deiner Prepaid-User:

- - - - - - - - - - {% for prepaid_user_i in prepaid_users_from_curr_user %} - - - - - - {% endfor %} - -
UsernameKeyMoney (€)
{{ prepaid_user_i.username }}{{ prepaid_user_i.user_key }}{{ prepaid_user_i.money / 100 }}
- {% endif %} -

Füge Nutzer zur Prepaid Liste hinzu:

-
- - - - - -
-

Füge bestehendem Prepaid-User Geld hinzu:

- {% if db_users_prepaid %} -
- - - - - -
- {% else %} -

Es sind keine Prepaid-User vorhanden.

- {% endif %} - {% endif %} - {% if 'Getraenkeliste Verantwortliche' in user.groups %} -

Admin Interface

-

Ausgleichszahlung:

-

Der eingegebene Betrag wird vom aktuell eingeloggten Nutzer abgezogen und dem eingetragenem Nutzer gutgeschrieben.

-
- - - - - -
-

Postpaid Liste

-

Users in postpaid database:

- - - - - - - - - - - - {% for db_user_i in users %} - - - - - - - - {% endfor %} - -
IDUsernameMoney (€)Activatedlast drink
{{ db_user_i.id }}{{ db_user_i.username }}{{ db_user_i.money / 100 }}{{ db_user_i.activated }}{{ db_user_i.last_drink }}
-

(De-)Activate User

-
- - - -
-

Set user money:

-
- - - - - -
-

Prepaid Liste

-

Users in prepaid database:

- {% if db_users_prepaid %} - - - - - - - - - - - - - - {% for prepaid_user_i in db_users_prepaid %} - - - - - - - - - - {% endfor %} - -
IDUsernameKeyPostpaid_User IDMoney (€)Activatedlast drink
{{ prepaid_user_i.id }}{{ prepaid_user_i.username }}{{ prepaid_user_i.user_key }}{{ prepaid_user_i.postpaid_user_id }}{{ prepaid_user_i.money / 100 }}{{ prepaid_user_i.activated }}{{ prepaid_user_i.last_drink }}
-

(De-)Activate User

-
- - - -
-

Delete User

-
- - - - {% else %} - - No users in prepaid database - - {% endif %} - {% endif %} - {% endif %}
diff --git a/templates/index.html b/templates/index.html index fb25771..fc3f5e0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -164,55 +164,170 @@ content %} - +{% if user %} + {% if 'Getraenkeliste Postpaid' in user.groups %} +

Du bist Teil der Fachschaft Informatik.

+ {% if prepaid_users_from_curr_user %} +

Liste deiner Prepaid-User:

+ + + + + + + + + + {% for prepaid_user_i in prepaid_users_from_curr_user %} + + + + + + {% endfor %} + +
UsernameKeyMoney (€)
{{ prepaid_user_i.username }}{{ prepaid_user_i.user_key }}{{ prepaid_user_i.money / 100 }}
+ {% endif %} +

Füge Nutzer zur Prepaid Liste hinzu:

+ + + + + + + +

Füge bestehendem Prepaid-User Geld hinzu:

+ {% if db_users_prepaid %} +
+ + + + + +
+ {% else %} +

Es sind keine Prepaid-User vorhanden.

+ {% endif %} + {% endif %} + {% if 'Getraenkeliste Verantwortliche' in user.groups %} +

Admin Interface

+

Ausgleichszahlung:

+

Der eingegebene Betrag wird vom aktuell eingeloggten Nutzer abgezogen und dem eingetragenem Nutzer gutgeschrieben.

+
+ + + + + +
+

Postpaid Liste

+

Users in postpaid database:

+ + + + + + + + + + + + {% for db_user_i in users %} + + + + + + + + {% endfor %} + +
IDUsernameMoney (€)Activatedlast drink
{{ db_user_i.id }}{{ db_user_i.username }}{{ db_user_i.money / 100 }}{{ db_user_i.activated }}{{ db_user_i.last_drink }}
+

(De-)Activate User

+
+ + + +
+

Set user money:

+
+ + + + + +
+

Prepaid Liste

+

Users in prepaid database:

+ {% if db_users_prepaid %} + + + + + + + + + + + + + + {% for prepaid_user_i in db_users_prepaid %} + + + + + + + + + + {% endfor %} + +
IDUsernameKeyPostpaid_User IDMoney (€)Activatedlast drink
{{ prepaid_user_i.id }}{{ prepaid_user_i.username }}{{ prepaid_user_i.user_key }}{{ prepaid_user_i.postpaid_user_id }}{{ prepaid_user_i.money / 100 }}{{ prepaid_user_i.activated }}{{ prepaid_user_i.last_drink }}
+

(De-)Activate User

+
+ + + +
+

Delete User

+
+ + + + {% else %} + + No users in prepaid database + + {% endif %} + {% endif %} +{% endif %} {% endblock %} diff --git a/templates/stats.html b/templates/stats.html new file mode 100644 index 0000000..d4fa0f9 --- /dev/null +++ b/templates/stats.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}Statistik{% endblock %} +{% block content %} + + + + +

Verteilung der Getränkesorten

+ + + +{% endblock %}