feat: Probably functional

This commit is contained in:
2026-04-09 14:06:19 +01:00
parent 5cf9895c79
commit 8c3c473ebf
10 changed files with 578 additions and 15 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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/<id>/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/<id>/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/<id>/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/<id>")
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/<id>/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 "<removed drink>",
"user_name": t.user_name,
"quantity": t.quantity,
"timestamp": t.timestamp,
}
for t in txs
]
return render_template("transactions.html", transactions=patched)

164
app/static/style.css Normal file
View File

@@ -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);
}

40
app/templates/add.html Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html>
<head>
<title>Add Drink</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div class="container">
<h1>Add Drink</h1>
<form method="POST" action="/add">
<div class="form-group">
<label>Drink name</label>
<input type="text" name="name" required>
</div>
<div class="form-group">
<label>Quantity</label>
<input type="number" name="stock" min="1" required>
</div>
<div class="form-group">
<label>Price (€):</label>
<input type="number" step="0.01" name="price" required>
</div>
<div class="form-group">
<label>Name + Phone number</label>
<input type="text" name="phone_number" placeholder="Name +351..." required>
</div>
<button class="button" type="submit">Create drink</button>
</form>
<br>
<a href="/">Back</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ drink.name }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div class="container">
<div class="card">
<h1>{{ drink.name }}</h1>
<p><strong>{{ "%.2f"|format(drink.price) }}€</strong></p>
<p class="muted">Stock: {{ drink.stock }}</p>
<p><strong>Pay to:</strong> {{ drink.phone_number }}</p>
</div>
{% if drink.stock > 0 %}
<div class="card">
<form method="POST" action="/drink/{{ drink.id }}/buy">
<label>
Your name:
<input list="names" name="name" required>
</label>
<datalist id="names">
{% for n in names %}
<option value="{{ n[0] }}"></option>
{% endfor %}
</datalist>
<label>
Quantity:
<input type="number" name="quantity" value="1" min="1" required>
</label>
<button class="button" type="submit">Take drink</button>
</form>
</div>
{% else %}
<p><strong>Out of stock</strong></p>
{% endif %}
<a class="button" href="/drinks">Back to list</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<title>Drinks Catalogue</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div class="container">
<h1>Drinks Catalogue</h1>
{% if drinks %}
{% for drink in drinks %}
<div class="card">
<strong>{{ drink.name }}</strong>
<p class="muted">{{ "%.2f"|format(drink.price) }}€</p>
<p>Stock: {{ drink.stock }}</p>
<div class="actions">
<a class="button" href="/drink/{{ drink.id }}">Buy</a>
<a class="button button-secondary" href="/drink/{{ drink.id }}/manage">Manage</a>
</div>
</div>
{% endfor %}
{% else %}
<p>No drinks available.</p>
{% endif %}
<a href="/">Back</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<title>Manage Drink</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div class="container">
<h1>Manage Drink</h1>
<div class="card">
<h2>{{ drink.name }}</h2>
<p>Price: {{ drink.price }}€</p>
<p>Stock: {{ drink.stock }}</p>
</div>
<div class="card">
<h3>Restock</h3>
<form method="POST" action="/drink/{{ drink.id }}/restock">
<label>
Add quantity:
<input type="number" name="amount" min="1" required>
</label>
<button class="button" type="submit">Increase stock</button>
</form>
</div>
<div class="card">
<h3>Danger zone</h3>
<form method="POST" action="/drink/{{ drink.id }}/delete" onsubmit="return confirm('Delete this drink?');">
<button class="button" type="submit" style="background: red;">Delete drink</button>
</form>
</div>
<a class="button" href="/drinks">Back to list</a>
</div>
</body>
</html>

View File

@@ -2,9 +2,16 @@
<html>
<head>
<title>Fridge Tracker</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div class="container">
<h1>Fridge Tracker</h1>
<p>Welcome. System is running.</p>
<a class="button" href="/add">Add drink</a>
<a class="button" href="/drinks">Drink catalogue</a>
<a class="button" href="/transactions">Transactions</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html>
<head>
<title>Transactions</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div class="container">
<h1 class="mb">Transactions</h1>
<div class="card">
{% if transactions %}
<table class="table">
<thead>
<tr>
<th>Drink</th>
<th>User</th>
<th>Qty</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for t in transactions %}
<tr>
<td>{{ t.drink_name }}</td>
<td>{{ t.user_name }}</td>
<td>{{ t.quantity }}</td>
<td class="muted">
{{ t.timestamp.strftime("%Y-%m-%d %H:%M") }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No transactions yet.</p>
{% endif %}
</div>
<div class="mt">
<a class="button button-secondary" href="/">Back</a>
</div>
</div>
</body>
</html>