From 8c3c473ebf94c7e2647e2bf9a62d79d553ee4050 Mon Sep 17 00:00:00 2001 From: Diogo Diniz Date: Thu, 9 Apr 2026 14:06:19 +0100 Subject: [PATCH] feat: Probably functional --- app/db.py | 13 ++- app/models.py | 12 +- app/routes.py | 191 +++++++++++++++++++++++++++++++- app/static/style.css | 164 +++++++++++++++++++++++++++ app/templates/add.html | 40 +++++++ app/templates/drink_buy.html | 46 ++++++++ app/templates/drink_list.html | 31 ++++++ app/templates/drink_manage.html | 39 +++++++ app/templates/index.html | 7 ++ app/templates/transactions.html | 50 +++++++++ 10 files changed, 578 insertions(+), 15 deletions(-) create mode 100644 app/static/style.css create mode 100644 app/templates/add.html create mode 100644 app/templates/drink_buy.html create mode 100644 app/templates/drink_list.html create mode 100644 app/templates/drink_manage.html create mode 100644 app/templates/transactions.html diff --git a/app/db.py b/app/db.py index 940c7e7..9b44263 100644 --- a/app/db.py +++ b/app/db.py @@ -1,9 +1,11 @@ +from pathlib import Path + from flask import Flask from sqlalchemy import create_engine -from sqlalchemy.orm import DeclarativeBase, scoped_session, sessionmaker +from sqlalchemy.orm import DeclarativeBase, Session, scoped_session, sessionmaker engine = None -SessionLocal = None +SessionLocal: scoped_session[Session] class Base(DeclarativeBase): @@ -13,8 +15,13 @@ class Base(DeclarativeBase): def init_db(app: Flask) -> None: # noqa: ARG001 global engine, SessionLocal # noqa: PLW0603 + Path("./instance").mkdir(exist_ok=True) + engine = create_engine("sqlite:///instance/app.db", echo=True, future=True) - SessionLocal = scoped_session(sessionmaker(bind=engine)) + session_local = scoped_session(sessionmaker(bind=engine)) + if session_local is None: + raise RuntimeError + SessionLocal = session_local from . import models # noqa: F401, PLC0415 diff --git a/app/models.py b/app/models.py index ab0a476..52e782b 100644 --- a/app/models.py +++ b/app/models.py @@ -6,14 +6,6 @@ from sqlalchemy.orm import Mapped, mapped_column from .db import Base -class User(Base): - __tablename__ = "users" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String) - contact: Mapped[int] = mapped_column(Integer, nullable=True) - - class Drink(Base): __tablename__ = "drinks" @@ -21,7 +13,7 @@ class Drink(Base): name: Mapped[str] = mapped_column(String) price: Mapped[int] = mapped_column(Integer) stock: Mapped[int] = mapped_column(Integer) - stocked_by: Mapped[int] = mapped_column(Integer, ForeignKey(User.id)) + stocked_by: Mapped[str] = mapped_column(String) # Buys @@ -30,6 +22,6 @@ class Transaction(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) drink: Mapped[int] = mapped_column(Integer, ForeignKey(Drink.id)) - user: Mapped[int] = mapped_column(Integer, ForeignKey(User.id)) + user_name: Mapped[str] = mapped_column(String) quantity: Mapped[int] = mapped_column(Integer) timestamp: Mapped[datetime] = mapped_column(DateTime) diff --git a/app/routes.py b/app/routes.py index 5529e45..3bdfea9 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,8 +1,195 @@ -from flask import Blueprint, render_template +from datetime import datetime + +from flask import Blueprint, redirect, render_template, request, url_for +from sqlalchemy import distinct, select +from werkzeug.wrappers.response import Response + +from app.db import SessionLocal +from app.models import Drink, Transaction bp = Blueprint("main", __name__) -@bp.route("/") +@bp.get("/") def index() -> str: return render_template("index.html") + + +@bp.get("/add") +def create_drink_get() -> str: + return render_template("add.html") + + +@bp.post("/add") +def create_drink_post() -> Response | tuple[str, int]: + name = request.form.get("name") + stock_raw = request.form.get("stock") + price_raw = request.form.get("price") + contact = request.form.get("phone_number") + + if name is None or stock_raw is None or price_raw is None or contact is None: + return "Missing required fields", 400 + + name = name.strip() + contact = contact.strip() + + try: + stock = int(stock_raw) + price = float(price_raw) + except ValueError: + return "Invalid numeric input", 400 + + if stock <= 0 or price <= 0: + return "Invalid stock or price", 400 + + session = SessionLocal() + + drink = Drink(name=name, stock=stock, price=price, stocked_by=contact) + + session.add(drink) + session.commit() + + return redirect(url_for("main.list_drinks_get", id=drink.id)) + + +@bp.get("/drink//manage") +def manage_drink_get(id: int) -> str | tuple[str, int]: # noqa: A002 + session = SessionLocal() + + drink = session.get(Drink, id) + if drink is None: + return "Drink not found", 404 + + return render_template("drink_manage.html", drink=drink) + + +@bp.post("/drink//restock") +def restock_drink_post(id: int) -> Response | tuple[str, int]: # noqa: A002 + session = SessionLocal() + + drink = session.get(Drink, id) + if drink is None: + return "Drink not found", 404 + + amount_raw = request.form.get("amount") + + if amount_raw is None: + return "Missing fields", 400 + + try: + amount = int(amount_raw) + except ValueError: + return "Non numeric amount", 400 + + if amount <= 0: + return "Invalid amount", 400 + + drink.stock += amount + session.commit() + + return redirect(url_for("main.manage_drink_get", id=id)) + + +@bp.post("/drink//delete") +def delete_drink_post(id: int) -> Response | tuple[str, int]: # noqa: A002 + session = SessionLocal() + + drink = session.get(Drink, id) + if drink is None: + return "Drink not found", 404 + + session.delete(drink) + session.commit() + + return redirect(url_for("main.list_drinks_get")) + + +@bp.get("/drinks") +def list_drinks_get() -> str: + session = SessionLocal() + + stmt = select(Drink) + result = session.execute(stmt) + drinks = [row[0] for row in result if row[0]] + + return render_template("drink_list.html", drinks=drinks) + + +@bp.get("/drink/") +def buy_drink_get(id: int) -> str | tuple[str, int]: # noqa: A002 + session = SessionLocal() + + drink = session.get(Drink, id) + if drink is None: + return "Drink not found", 404 + + # Find user names + stmt = select(distinct(Transaction.user_name)).order_by(Transaction.user_name) + result = session.execute(stmt).all() + names = [row[0] for row in result if row[0]] + + return render_template("drink_buy.html", drink=drink, names=names) + + +@bp.post("/drink//buy") +def buy_drink_post(id: int) -> Response | tuple[str, int]: # noqa: A002 + session = SessionLocal() + + drink = session.get(Drink, id) + if drink is None: + return "Drink not found", 404 + + name = request.form.get("name") + qty_raw = request.form.get("quantity") + + if name is None or qty_raw is None: + return "Missing fields", 400 + + name = name.strip() + + try: + quantity = int(qty_raw) + except ValueError: + return "Invalid quantity", 400 + + if quantity <= 0: + return "Quantity must be positive", 400 + + if drink.stock < quantity: + return "Not enough stock", 400 + + # Update stock + drink.stock -= quantity + + # Create transaction + transaction = Transaction(drink=drink.id, user_name=name, quantity=quantity, timestamp=datetime.now().astimezone()) + + session.add(transaction) + session.commit() + + return redirect(url_for("main.list_drinks_get", id=id)) + + +@bp.get("/transactions") +def list_transactions_get() -> str: + session = SessionLocal() + + stmt = select(Transaction).order_by(Transaction.timestamp) + result = session.execute(stmt).all() + txs: list[Transaction] = [row[0] for row in result if row[0]] + + stmt = select(Drink) + result = session.execute(stmt).all() + drinks: dict[int, Drink] = {row[0].id: row[0] for row in result if row[0]} + + patched = [ + { + "drink_name": drinks[t.drink].name if t.drink in drinks else "", + "user_name": t.user_name, + "quantity": t.quantity, + "timestamp": t.timestamp, + } + for t in txs + ] + + return render_template("transactions.html", transactions=patched) diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..4e24c89 --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,164 @@ +/* Base reset */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* Theme */ +body { + font-family: system-ui, -apple-system, sans-serif; + background-color: #0f1115; + color: #e6e6e6; + line-height: 1.5; + padding: 16px; +} + +/* Layout container */ +.container { + max-width: 600px; + margin: 0 auto; +} + +/* Headings */ +h1, h2, h3 { + margin-bottom: 12px; + font-weight: 600; +} + +/* Text spacing */ +p { + margin-bottom: 10px; +} + +/* Links */ +a { + color: #6ab0ff; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* Cards (main building block) */ +.card { + background: #171923; + border-radius: 10px; + padding: 14px; + margin-bottom: 12px; + border: 1px solid #222633; +} + +/* Table (mobile-friendly fallback) */ +.table { + width: 100%; + border-collapse: collapse; + margin-top: 10px; +} + +.table th, +.table td { + padding: 10px; + text-align: left; + border-bottom: 1px solid #2a2f3a; +} + +.table th { + font-size: 0.9em; + color: #a0a7b5; +} + +/* Form elements */ +.form-group { + margin-bottom: 14px; +} + +label { + display: block; + margin-bottom: 6px; + font-size: 0.9em; + color: #a0a7b5; +} + +/* Inputs */ +input { + width: 100%; + padding: 10px; + border-radius: 8px; + border: 1px solid #2a2f3a; + background: #0f1115; + color: #e6e6e6; + font-size: 1em; +} + +/* Buttons */ +.button { + display: inline-block; + margin: 3px; + width: 100%; + padding: 12px; + border-radius: 8px; + border: none; + background: #2f81f7; + color: white; + font-size: 1em; + font-weight: 500; + text-align: center; + cursor: pointer; +} + +.button:hover { + background: #1f6feb; +} + +/* Secondary button */ +.button-secondary { + background: #30363d; +} + +.button-secondary:hover { + background: #3a4048; +} + +/* Danger button */ +.button-danger { + background: #da3633; +} + +.button-danger:hover { + background: #b62324; +} + +/* Inline actions */ +.actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +/* Small text */ +.muted { + color: #8b949e; + font-size: 0.85em; +} + +/* Spacing helpers */ +.mt { + margin-top: 12px; +} + +.mb { + margin-bottom: 12px; +} + +/* Divider */ +.divider { + height: 1px; + background: #2a2f3a; + margin: 16px 0; +} + +button:active { + transform: scale(0.98); +} diff --git a/app/templates/add.html b/app/templates/add.html new file mode 100644 index 0000000..5a2bbe9 --- /dev/null +++ b/app/templates/add.html @@ -0,0 +1,40 @@ + + + + Add Drink + + + + +
+

Add Drink

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ Back +
+ + diff --git a/app/templates/drink_buy.html b/app/templates/drink_buy.html new file mode 100644 index 0000000..16d259a --- /dev/null +++ b/app/templates/drink_buy.html @@ -0,0 +1,46 @@ + + + + {{ drink.name }} + + + + +
+
+

{{ drink.name }}

+

{{ "%.2f"|format(drink.price) }}€

+

Stock: {{ drink.stock }}

+

Pay to: {{ drink.phone_number }}

+
+ + {% if drink.stock > 0 %} +
+
+ + + + {% for n in names %} + + {% endfor %} + + + + + +
+
+ {% else %} +

Out of stock

+ {% endif %} + + Back to list +
+ + diff --git a/app/templates/drink_list.html b/app/templates/drink_list.html new file mode 100644 index 0000000..c9f3707 --- /dev/null +++ b/app/templates/drink_list.html @@ -0,0 +1,31 @@ + + + + Drinks Catalogue + + + + +
+

Drinks Catalogue

+ + {% if drinks %} + {% for drink in drinks %} +
+ {{ drink.name }} +

{{ "%.2f"|format(drink.price) }}€

+

Stock: {{ drink.stock }}

+ +
+ Buy + Manage +
+
+ {% endfor %} + {% else %} +

No drinks available.

+ {% endif %} + Back +
+ + diff --git a/app/templates/drink_manage.html b/app/templates/drink_manage.html new file mode 100644 index 0000000..98d123f --- /dev/null +++ b/app/templates/drink_manage.html @@ -0,0 +1,39 @@ + + + + Manage Drink + + + + +
+

Manage Drink

+ +
+

{{ drink.name }}

+

Price: {{ drink.price }}€

+

Stock: {{ drink.stock }}

+
+ +
+

Restock

+
+ + +
+
+ +
+

Danger zone

+
+ +
+
+ + Back to list +
+ + diff --git a/app/templates/index.html b/app/templates/index.html index e97672b..f4961c5 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -2,9 +2,16 @@ Fridge Tracker + + +

Fridge Tracker

Welcome. System is running.

+ Add drink + Drink catalogue + Transactions +
diff --git a/app/templates/transactions.html b/app/templates/transactions.html new file mode 100644 index 0000000..7d9e7bc --- /dev/null +++ b/app/templates/transactions.html @@ -0,0 +1,50 @@ + + + + Transactions + + + + +
+ +

Transactions

+ +
+ + {% if transactions %} + + + + + + + + + + + {% for t in transactions %} + + + + + + + {% endfor %} + +
DrinkUserQtyTime
{{ t.drink_name }}{{ t.user_name }}{{ t.quantity }} + {{ t.timestamp.strftime("%Y-%m-%d %H:%M") }} +
+ {% else %} +

No transactions yet.

+ {% endif %} + +
+ +
+ Back +
+ +
+ +