feat: Probably functional
This commit is contained in:
13
app/db.py
13
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
191
app/routes.py
191
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/<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
164
app/static/style.css
Normal 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
40
app/templates/add.html
Normal 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>
|
||||
46
app/templates/drink_buy.html
Normal file
46
app/templates/drink_buy.html
Normal 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>
|
||||
31
app/templates/drink_list.html
Normal file
31
app/templates/drink_list.html
Normal 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>
|
||||
39
app/templates/drink_manage.html
Normal file
39
app/templates/drink_manage.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
50
app/templates/transactions.html
Normal file
50
app/templates/transactions.html
Normal 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>
|
||||
Reference in New Issue
Block a user