Add meal tracker app: Flask + SQLite with auth, dashboard, 31 passing tests, Docker Compose
This commit is contained in:
@@ -1 +1,4 @@
|
||||
.env
|
||||
meals.db
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM python:3.13-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
COPY app.py models.py auth.py meals.py ./
|
||||
COPY templates/ templates/
|
||||
COPY static/ static/
|
||||
|
||||
# Create persistent data directory
|
||||
RUN mkdir -p /data
|
||||
ENV DB_PATH=/data/meals.db
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
# Generate a default secret key at build time (override in prod)
|
||||
ENV SECRET_KEY=change-me-in-production
|
||||
|
||||
# Run in production mode (no debug, use a WSGI server)
|
||||
CMD ["flask", "--app", "app:create_app()", "run", "--host=0.0.0.0", "--port=5000"]
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Meal Tracker — Flask application entry point."""
|
||||
|
||||
import os
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from models import init_db
|
||||
from auth import auth_bp
|
||||
from meals import meals_bp
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get("SECRET_KEY", "dev-secret-change-me")
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(meals_bp)
|
||||
|
||||
with app.app_context():
|
||||
init_db()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = create_app()
|
||||
app.run(host="0.0.0.0", port=5000, debug=True)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Authentication blueprint — login, register, logout."""
|
||||
|
||||
from flask import Blueprint, request, render_template, redirect, url_for, session, flash
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
from models import get_user_by_username, create_user, count_users
|
||||
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
|
||||
if not username or not password:
|
||||
flash("Username and password are required.", "error")
|
||||
return render_template("login.html")
|
||||
|
||||
user = get_user_by_username(username)
|
||||
if user is None or not check_password_hash(user["password_hash"], password):
|
||||
flash("Invalid username or password.", "error")
|
||||
return render_template("login.html")
|
||||
|
||||
session.clear()
|
||||
session["user_id"] = user["id"]
|
||||
session["username"] = user["username"]
|
||||
session["is_admin"] = bool(user["is_admin"])
|
||||
return redirect(url_for("meals.dashboard"))
|
||||
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
@auth_bp.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
confirm = request.form.get("confirm", "")
|
||||
|
||||
errors = []
|
||||
if not username or len(username) < 2:
|
||||
errors.append("Username must be at least 2 characters.")
|
||||
if not password or len(password) < 4:
|
||||
errors.append("Password must be at least 4 characters.")
|
||||
if password != confirm:
|
||||
errors.append("Passwords do not match.")
|
||||
if get_user_by_username(username):
|
||||
errors.append("Username already taken.")
|
||||
|
||||
if errors:
|
||||
for e in errors:
|
||||
flash(e, "error")
|
||||
return render_template("register.html")
|
||||
|
||||
# First user to register becomes admin
|
||||
is_admin = count_users() == 0
|
||||
password_hash = generate_password_hash(password)
|
||||
create_user(username, password_hash, is_admin=is_admin)
|
||||
|
||||
flash("Account created! Please log in.", "success")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
return render_template("register.html")
|
||||
|
||||
|
||||
@auth_bp.route("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for("auth.login"))
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
meal-tracker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.meal
|
||||
ports:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
- meal-data:/data
|
||||
environment:
|
||||
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
meal-data:
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Meals blueprint — dashboard, responses, history."""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
render_template,
|
||||
request,
|
||||
redirect,
|
||||
url_for,
|
||||
session,
|
||||
flash,
|
||||
jsonify,
|
||||
)
|
||||
|
||||
from models import get_dashboard_data, upsert_response, ensure_meal_periods
|
||||
|
||||
meals_bp = Blueprint("meals", __name__)
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""Decorator: redirect to login if not authenticated."""
|
||||
from functools import wraps
|
||||
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
if "user_id" not in session:
|
||||
return redirect(url_for("auth.login"))
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@meals_bp.route("/")
|
||||
def index():
|
||||
if "user_id" in session:
|
||||
return redirect(url_for("meals.dashboard"))
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@meals_bp.route("/dashboard")
|
||||
@login_required
|
||||
def dashboard():
|
||||
today = date.today()
|
||||
|
||||
# Allow viewing other dates via query param
|
||||
date_str = request.args.get("date")
|
||||
if date_str:
|
||||
try:
|
||||
today = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
flash("Invalid date format. Use YYYY-MM-DD.", "error")
|
||||
|
||||
data = get_dashboard_data(today)
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
dashboard=data,
|
||||
viewing_date=today,
|
||||
today=date.today(),
|
||||
prev_date=today - timedelta(days=1),
|
||||
next_date=today + timedelta(days=1),
|
||||
)
|
||||
|
||||
|
||||
@meals_bp.route("/respond", methods=["POST"])
|
||||
@login_required
|
||||
def respond():
|
||||
meal_type = request.form.get("meal_type", "").strip()
|
||||
status = request.form.get("status", "").strip()
|
||||
date_str = request.form.get("date", date.today().isoformat())
|
||||
|
||||
if meal_type not in ("lunch", "dinner"):
|
||||
flash("Invalid meal type.", "error")
|
||||
return redirect(url_for("meals.dashboard"))
|
||||
|
||||
if status not in ("yes", "no"):
|
||||
flash("Invalid status.", "error")
|
||||
return redirect(url_for("meals.dashboard"))
|
||||
|
||||
try:
|
||||
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
flash("Invalid date.", "error")
|
||||
return redirect(url_for("meals.dashboard"))
|
||||
|
||||
# Only allow responding to today (and not in the past)
|
||||
if target_date < date.today():
|
||||
flash("Cannot change responses for past dates.", "error")
|
||||
return redirect(url_for("meals.dashboard"))
|
||||
|
||||
periods = ensure_meal_periods(target_date)
|
||||
period = next((p for p in periods if p["meal_type"] == meal_type), None)
|
||||
if period is None:
|
||||
flash("Meal period not found.", "error")
|
||||
return redirect(url_for("meals.dashboard"))
|
||||
|
||||
upsert_response(session["user_id"], period["id"], status)
|
||||
return redirect(url_for("meals.dashboard"))
|
||||
|
||||
|
||||
@meals_bp.route("/history")
|
||||
@login_required
|
||||
def history():
|
||||
date_str = request.args.get("date", date.today().isoformat())
|
||||
try:
|
||||
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
target_date = date.today()
|
||||
|
||||
data = get_dashboard_data(target_date)
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
dashboard=data,
|
||||
viewing_date=target_date,
|
||||
today=date.today(),
|
||||
prev_date=target_date - timedelta(days=1),
|
||||
next_date=target_date + timedelta(days=1),
|
||||
history_mode=True,
|
||||
)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Database models and helpers for the meal tracker."""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
|
||||
def _db_path() -> str:
|
||||
return os.environ.get(
|
||||
"DB_PATH", os.path.join(os.path.dirname(__file__), "meals.db")
|
||||
)
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
"""Return a connection to the SQLite database with foreign keys enabled."""
|
||||
conn = sqlite3.connect(_db_path())
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Create tables if they don't exist."""
|
||||
with get_db() as db:
|
||||
db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meal_periods (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
meal_type TEXT NOT NULL CHECK(meal_type IN ('lunch', 'dinner')),
|
||||
UNIQUE(date, meal_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS responses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
period_id INTEGER NOT NULL REFERENCES meal_periods(id),
|
||||
status TEXT NOT NULL DEFAULT 'not_answered'
|
||||
CHECK(status IN ('yes', 'no', 'not_answered')),
|
||||
changed_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, period_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_responses_user
|
||||
ON responses(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_responses_period
|
||||
ON responses(period_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_meal_periods_date
|
||||
ON meal_periods(date);
|
||||
""")
|
||||
|
||||
|
||||
def ensure_meal_periods(for_date: date) -> list[dict]:
|
||||
"""Ensure lunch and dinner periods exist for a given date.
|
||||
|
||||
Returns the two period rows (as dicts) for that date.
|
||||
"""
|
||||
date_str = for_date.isoformat()
|
||||
with get_db() as db:
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO meal_periods (date, meal_type) VALUES (?, 'lunch')",
|
||||
(date_str,),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO meal_periods (date, meal_type) VALUES (?, 'dinner')",
|
||||
(date_str,),
|
||||
)
|
||||
rows = db.execute(
|
||||
"SELECT id, date, meal_type FROM meal_periods WHERE date = ? ORDER BY meal_type",
|
||||
(date_str,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_user_by_id(user_id: int) -> dict | None:
|
||||
"""Fetch a single user by ID."""
|
||||
with get_db() as db:
|
||||
row = db.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_user_by_username(username: str) -> dict | None:
|
||||
"""Fetch a single user by username."""
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"SELECT * FROM users WHERE username = ?", (username,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def create_user(username: str, password_hash: str, is_admin: bool = False) -> dict:
|
||||
"""Insert a new user. Returns the user row as a dict."""
|
||||
with get_db() as db:
|
||||
cur = db.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)",
|
||||
(username, password_hash, int(is_admin)),
|
||||
)
|
||||
user_id = cur.lastrowid
|
||||
db.commit() # Make visible to get_user_by_id (opens a new connection)
|
||||
return get_user_by_id(user_id)
|
||||
|
||||
|
||||
def get_all_users() -> list[dict]:
|
||||
"""Return all registered users."""
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"SELECT id, username, is_admin, created_at FROM users ORDER BY username"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def count_users() -> int:
|
||||
"""Return total number of registered users."""
|
||||
with get_db() as db:
|
||||
return db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
|
||||
|
||||
def get_dashboard_data(for_date: date) -> dict:
|
||||
"""Return all users and their responses for a given date.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"date": "YYYY-MM-DD",
|
||||
"periods": [{"id": 1, "date": "...", "meal_type": "lunch"}, ...],
|
||||
"users": [
|
||||
{
|
||||
"id": 1,
|
||||
"username": "alice",
|
||||
"responses": {
|
||||
"lunch": {"status": "yes", "changed_at": None},
|
||||
"dinner": {"status": "no", "changed_at": "2025-01-15 16:30:00"},
|
||||
}
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
periods = ensure_meal_periods(for_date)
|
||||
users = get_all_users()
|
||||
|
||||
with get_db() as db:
|
||||
period_ids = [p["id"] for p in periods]
|
||||
if not period_ids:
|
||||
return {"date": for_date.isoformat(), "periods": periods, "users": []}
|
||||
|
||||
placeholders = ",".join("?" * len(period_ids))
|
||||
resp_rows = db.execute(
|
||||
f"SELECT user_id, period_id, status, changed_at "
|
||||
f"FROM responses "
|
||||
f"WHERE period_id IN ({placeholders})",
|
||||
period_ids,
|
||||
).fetchall()
|
||||
|
||||
# Build a lookup: (user_id, period_id) → response
|
||||
resp_map = {}
|
||||
for r in resp_rows:
|
||||
resp_map[(r["user_id"], r["period_id"])] = {
|
||||
"status": r["status"],
|
||||
"changed_at": r["changed_at"],
|
||||
}
|
||||
|
||||
period_map = {p["meal_type"]: p["id"] for p in periods}
|
||||
|
||||
enriched_users = []
|
||||
for u in users:
|
||||
user_responses = {}
|
||||
for meal_type, pid in period_map.items():
|
||||
key = (u["id"], pid)
|
||||
if key in resp_map:
|
||||
user_responses[meal_type] = resp_map[key]
|
||||
else:
|
||||
user_responses[meal_type] = {"status": "not_answered", "changed_at": None}
|
||||
enriched_users.append({**u, "responses": user_responses})
|
||||
|
||||
return {
|
||||
"date": for_date.isoformat(),
|
||||
"periods": periods,
|
||||
"users": enriched_users,
|
||||
}
|
||||
|
||||
|
||||
def upsert_response(user_id: int, period_id: int, new_status: str) -> dict | None:
|
||||
"""Insert or update a response. Handles changed_at logic.
|
||||
|
||||
changed_at is set when flipping between 'yes' and 'no'.
|
||||
It stays NULL on first answer or when status doesn't change.
|
||||
"""
|
||||
with get_db() as db:
|
||||
existing = db.execute(
|
||||
"SELECT status, changed_at FROM responses WHERE user_id = ? AND period_id = ?",
|
||||
(user_id, period_id),
|
||||
).fetchone()
|
||||
|
||||
if existing is None:
|
||||
# First response ever
|
||||
db.execute(
|
||||
"INSERT INTO responses (user_id, period_id, status) VALUES (?, ?, ?)",
|
||||
(user_id, period_id, new_status),
|
||||
)
|
||||
elif existing["status"] == new_status:
|
||||
# No change — nothing to update
|
||||
pass
|
||||
elif existing["status"] in ("yes", "no") and new_status in ("yes", "no"):
|
||||
# Flipping between yes/no → mind changed
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
db.execute(
|
||||
"UPDATE responses SET status = ?, changed_at = ? "
|
||||
"WHERE user_id = ? AND period_id = ?",
|
||||
(new_status, now, user_id, period_id),
|
||||
)
|
||||
else:
|
||||
# Transition from 'not_answered' → 'yes'/'no' (first answer)
|
||||
db.execute(
|
||||
"UPDATE responses SET status = ? WHERE user_id = ? AND period_id = ?",
|
||||
(new_status, user_id, period_id),
|
||||
)
|
||||
|
||||
row = db.execute(
|
||||
"SELECT * FROM responses WHERE user_id = ? AND period_id = ?",
|
||||
(user_id, period_id),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
@@ -0,0 +1,3 @@
|
||||
flask>=3.0
|
||||
Werkzeug>=3.0
|
||||
pytest>=7.0
|
||||
@@ -0,0 +1,178 @@
|
||||
/* ── Reset & Base ── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--surface: #fff;
|
||||
--text: #222;
|
||||
--muted: #888;
|
||||
--border: #ddd;
|
||||
--green: #2e7d32;
|
||||
--green-bg: #e8f5e9;
|
||||
--red: #c62828;
|
||||
--red-bg: #ffebee;
|
||||
--orange: #e65100;
|
||||
--orange-bg: #fff3e0;
|
||||
--gray-bg: #f0f0f0;
|
||||
--primary: #1565c0;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Nav ── */
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.brand { font-size: 1.2rem; font-weight: 700; }
|
||||
.nav-right { display: flex; align-items: center; gap: 12px; }
|
||||
.nav-user { font-weight: 500; color: var(--muted); }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn:hover { background: #eee; }
|
||||
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
.btn-primary:hover { background: #0d47a1; }
|
||||
.btn-sm { padding: 4px 10px; font-size: 0.8rem; }
|
||||
|
||||
/* ── Container ── */
|
||||
.container { max-width: 720px; margin: 32px auto; padding: 0 16px; }
|
||||
|
||||
/* ── Forms ── */
|
||||
.form-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 32px;
|
||||
max-width: 380px;
|
||||
margin: 40px auto;
|
||||
}
|
||||
.form-card h2 { margin-bottom: 20px; }
|
||||
.form-card label { display: block; margin: 12px 0 4px; font-weight: 500; font-size: 0.9rem; }
|
||||
.form-card input[type="text"],
|
||||
.form-card input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.form-card button[type="submit"] { margin-top: 20px; width: 100%; }
|
||||
.form-footer { margin-top: 16px; text-align: center; font-size: 0.85rem; color: var(--muted); }
|
||||
|
||||
/* ── Flashes ── */
|
||||
.flashes { margin-bottom: 16px; }
|
||||
.flash {
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.flash-error { background: #ffebee; color: #c62828; border: 1px solid #ef9a9a; }
|
||||
.flash-success { background: #e8f5e9; color: #2e7d32; border: 1px solid #a5d6a7; }
|
||||
|
||||
/* ── Dashboard ── */
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.dashboard-header h2 { font-size: 1.3rem; }
|
||||
.date-nav { display: flex; align-items: center; gap: 8px; }
|
||||
.date-display { font-weight: 600; font-size: 0.95rem; }
|
||||
|
||||
.table-wrapper {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.meal-table { width: 100%; border-collapse: collapse; }
|
||||
.meal-table th, .meal-table td {
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.meal-table th {
|
||||
background: #fafafa;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.meal-table th:first-child, .meal-table td:first-child { text-align: left; }
|
||||
.user-cell { font-weight: 600; min-width: 100px; }
|
||||
.meal-col { min-width: 180px; }
|
||||
|
||||
/* ── Response Cells ── */
|
||||
.response-cell { vertical-align: middle; }
|
||||
.response-cell.status-yes { background: var(--green-bg); }
|
||||
.response-cell.status-no { background: var(--red-bg); }
|
||||
.response-cell.status-not_answered { background: var(--gray-bg); }
|
||||
|
||||
.btn-row { display: flex; gap: 6px; justify-content: center; }
|
||||
|
||||
/* ── Chips ── */
|
||||
.chip {
|
||||
padding: 6px 18px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 20px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.chip:hover:not(:disabled) { border-color: #999; }
|
||||
.chip:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.chip-yes.active { background: var(--green); color: #fff; border-color: var(--green); }
|
||||
.chip-no.active { background: var(--red); color: #fff; border-color: var(--red); }
|
||||
|
||||
/* ── Changed-mind badge ── */
|
||||
.changed-badge {
|
||||
font-size: 0.7rem;
|
||||
color: var(--orange);
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.response-cell.changed-mind {
|
||||
box-shadow: inset 0 0 0 2px var(--orange);
|
||||
}
|
||||
|
||||
/* ── Static badge (history view for other users) ── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-yes { background: var(--green); color: #fff; }
|
||||
.badge-no { background: var(--red); color: #fff; }
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
.empty-state { text-align: center; color: var(--muted); margin: 40px 0; }
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Meal Tracker{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<span class="brand">🍽 Meal Tracker</span>
|
||||
{% if session.user_id %}
|
||||
<div class="nav-right">
|
||||
<span class="nav-user">{{ session.username }}</span>
|
||||
<a class="btn btn-sm" href="{{ url_for('auth.logout') }}">Logout</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<main class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flashes">
|
||||
{% for category, msg in messages %}
|
||||
<div class="flash flash-{{ category }}">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard — Meal Tracker{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="dashboard-header">
|
||||
<h2>
|
||||
{% if history_mode %}History — {{ viewing_date.isoformat() }}{% else %}Today's Meals{% endif %}
|
||||
</h2>
|
||||
<div class="date-nav">
|
||||
<a class="btn btn-sm" href="?date={{ prev_date.isoformat() }}">◀ Prev</a>
|
||||
<span class="date-display">{{ viewing_date.isoformat() }}</span>
|
||||
<a class="btn btn-sm" href="?date={{ next_date.isoformat() }}">Next ▶</a>
|
||||
{% if viewing_date != today %}
|
||||
<a class="btn btn-sm" href="{{ url_for('meals.dashboard') }}">Today</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not dashboard.users %}
|
||||
<p class="empty-state">No registered users yet. Ask household members to register!</p>
|
||||
{% else %}
|
||||
<div class="table-wrapper">
|
||||
<table class="meal-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
{% for period in dashboard.periods %}
|
||||
<th class="meal-col">{{ period.meal_type|capitalize }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in dashboard.users %}
|
||||
<tr>
|
||||
<td class="user-cell">{{ user.username }}</td>
|
||||
{% for period in dashboard.periods %}
|
||||
{% set resp = user.responses[period.meal_type] %}
|
||||
<td class="response-cell status-{{ resp.status }}{% if resp.changed_at %} changed-mind{% endif %}">
|
||||
{% if viewing_date >= today or session.is_admin %}
|
||||
<form method="POST" action="{{ url_for('meals.respond') }}" class="respond-form">
|
||||
<input type="hidden" name="date" value="{{ viewing_date.isoformat() }}">
|
||||
<input type="hidden" name="meal_type" value="{{ period.meal_type }}">
|
||||
<div class="btn-row">
|
||||
<button name="status" value="yes"
|
||||
class="chip chip-yes {% if resp.status == 'yes' %}active{% endif %}"
|
||||
{% if viewing_date < today and not session.is_admin %}disabled{% endif %}
|
||||
>Home</button>
|
||||
<button name="status" value="no"
|
||||
class="chip chip-no {% if resp.status == 'no' %}active{% endif %}"
|
||||
{% if viewing_date < today and not session.is_admin %}disabled{% endif %}
|
||||
>Out</button>
|
||||
</div>
|
||||
</form>
|
||||
{% if resp.changed_at %}
|
||||
<div class="changed-badge" title="Changed mind at {{ resp.changed_at }}">↻ {{ resp.changed_at[11:16] }}</div>
|
||||
{% endif %}
|
||||
{% elif resp.status == 'not_answered' %}
|
||||
<span class="muted">—</span>
|
||||
{% else %}
|
||||
<span class="badge badge-{{ resp.status }}">{{ 'Home' if resp.status == 'yes' else 'Out' }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Login — Meal Tracker{% endblock %}
|
||||
{% block content %}
|
||||
<div class="form-card">
|
||||
<h2>Log In</h2>
|
||||
<form method="POST">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Log In</button>
|
||||
</form>
|
||||
<p class="form-footer">
|
||||
Don't have an account? <a href="{{ url_for('auth.register') }}">Register</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Register — Meal Tracker{% endblock %}
|
||||
{% block content %}
|
||||
<div class="form-card">
|
||||
<h2>Create Account</h2>
|
||||
<form method="POST">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
|
||||
<label for="confirm">Confirm Password</label>
|
||||
<input type="password" id="confirm" name="confirm" required>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Register</button>
|
||||
</form>
|
||||
<p class="form-footer">
|
||||
Already have an account? <a href="{{ url_for('auth.login') }}">Log in</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Shared test fixtures for the meal tracker."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from app import create_app
|
||||
from models import init_db, get_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create a Flask app with a temporary database."""
|
||||
db_fd, db_path = tempfile.mkstemp(suffix=".db")
|
||||
os.environ["DB_PATH"] = db_path
|
||||
|
||||
app = create_app()
|
||||
app.config["SECRET_KEY"] = "test-secret"
|
||||
app.config["TESTING"] = True
|
||||
|
||||
with app.app_context():
|
||||
init_db()
|
||||
|
||||
yield app
|
||||
|
||||
os.close(db_fd)
|
||||
os.unlink(db_path)
|
||||
os.environ.pop("DB_PATH", None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Flask test client."""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(client):
|
||||
"""Test client with a logged-in regular user."""
|
||||
client.post("/register", data={
|
||||
"username": "bob",
|
||||
"password": "bobpass",
|
||||
"confirm": "bobpass",
|
||||
})
|
||||
client.get("/logout")
|
||||
client.post("/register", data={
|
||||
"username": "alice",
|
||||
"password": "alicepass",
|
||||
"confirm": "alicepass",
|
||||
})
|
||||
client.get("/logout")
|
||||
# Log in as bob (second user, not admin)
|
||||
client.post("/login", data={
|
||||
"username": "bob",
|
||||
"password": "bobpass",
|
||||
})
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(client):
|
||||
"""Test client with the admin user logged in."""
|
||||
client.post("/register", data={
|
||||
"username": "admin",
|
||||
"password": "adminpass",
|
||||
"confirm": "adminpass",
|
||||
})
|
||||
return client
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Tests for authentication: registration, login, logout."""
|
||||
|
||||
|
||||
def test_register_page(client):
|
||||
resp = client.get("/register")
|
||||
assert resp.status_code == 200
|
||||
assert b"Create Account" in resp.data
|
||||
|
||||
|
||||
def test_register_success(client):
|
||||
resp = client.post("/register", data={
|
||||
"username": "testuser",
|
||||
"password": "secret123",
|
||||
"confirm": "secret123",
|
||||
}, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"Account created" in resp.data
|
||||
|
||||
|
||||
def test_register_first_user_is_admin(client):
|
||||
"""First registered user should be admin."""
|
||||
client.post("/register", data={
|
||||
"username": "firstuser",
|
||||
"password": "secret123",
|
||||
"confirm": "secret123",
|
||||
})
|
||||
from models import get_user_by_username
|
||||
user = get_user_by_username("firstuser")
|
||||
assert user is not None
|
||||
assert user["is_admin"] == 1
|
||||
|
||||
|
||||
def test_register_duplicate_username(client):
|
||||
client.post("/register", data={
|
||||
"username": "dup",
|
||||
"password": "pass1234",
|
||||
"confirm": "pass1234",
|
||||
})
|
||||
client.get("/logout")
|
||||
resp = client.post("/register", data={
|
||||
"username": "dup",
|
||||
"password": "other5678",
|
||||
"confirm": "other5678",
|
||||
}, follow_redirects=True)
|
||||
assert b"Username already taken" in resp.data
|
||||
|
||||
|
||||
def test_register_password_mismatch(client):
|
||||
resp = client.post("/register", data={
|
||||
"username": "someone",
|
||||
"password": "abc12345",
|
||||
"confirm": "abc12346",
|
||||
}, follow_redirects=True)
|
||||
assert b"Passwords do not match" in resp.data
|
||||
|
||||
|
||||
def test_register_short_username(client):
|
||||
resp = client.post("/register", data={
|
||||
"username": "x",
|
||||
"password": "pass1234",
|
||||
"confirm": "pass1234",
|
||||
}, follow_redirects=True)
|
||||
assert b"at least 2 characters" in resp.data
|
||||
|
||||
|
||||
def test_register_short_password(client):
|
||||
resp = client.post("/register", data={
|
||||
"username": "validname",
|
||||
"password": "ab",
|
||||
"confirm": "ab",
|
||||
}, follow_redirects=True)
|
||||
assert b"at least 4 characters" in resp.data
|
||||
|
||||
|
||||
def test_login_page(client):
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
assert b"Log In" in resp.data
|
||||
|
||||
|
||||
def test_login_success(client):
|
||||
# Register first
|
||||
client.post("/register", data={
|
||||
"username": "logintest",
|
||||
"password": "mypassword",
|
||||
"confirm": "mypassword",
|
||||
})
|
||||
client.get("/logout")
|
||||
# Then login
|
||||
resp = client.post("/login", data={
|
||||
"username": "logintest",
|
||||
"password": "mypassword",
|
||||
}, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"Today" in resp.data # Dashboard
|
||||
|
||||
|
||||
def test_login_wrong_password(client):
|
||||
client.post("/register", data={
|
||||
"username": "logintest2",
|
||||
"password": "mypassword",
|
||||
"confirm": "mypassword",
|
||||
})
|
||||
client.get("/logout")
|
||||
resp = client.post("/login", data={
|
||||
"username": "logintest2",
|
||||
"password": "wrongpassword",
|
||||
}, follow_redirects=True)
|
||||
assert b"Invalid username or password" in resp.data
|
||||
|
||||
|
||||
def test_login_nonexistent_user(client):
|
||||
resp = client.post("/login", data={
|
||||
"username": "nobody",
|
||||
"password": "whatever",
|
||||
}, follow_redirects=True)
|
||||
assert b"Invalid username or password" in resp.data
|
||||
|
||||
|
||||
def test_logout(auth_client):
|
||||
resp = auth_client.get("/logout", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"Log In" in resp.data # Redirected to login
|
||||
|
||||
|
||||
def test_unauthenticated_redirect(client):
|
||||
"""Dashboard and root should redirect to login when not logged in."""
|
||||
for path in ["/", "/dashboard", "/history"]:
|
||||
resp = client.get(path, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"Log In" in resp.data
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests for meal periods, responses, and changed-mind logic."""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from models import (
|
||||
init_db,
|
||||
get_db,
|
||||
ensure_meal_periods,
|
||||
get_dashboard_data,
|
||||
upsert_response,
|
||||
get_user_by_username,
|
||||
)
|
||||
|
||||
|
||||
class TestMealPeriods:
|
||||
def test_ensure_creates_periods(self, app):
|
||||
with app.app_context():
|
||||
periods = ensure_meal_periods(date(2025, 6, 15))
|
||||
assert len(periods) == 2
|
||||
types = {p["meal_type"] for p in periods}
|
||||
assert types == {"lunch", "dinner"}
|
||||
for p in periods:
|
||||
assert p["date"] == "2025-06-15"
|
||||
|
||||
def test_ensure_is_idempotent(self, app):
|
||||
with app.app_context():
|
||||
p1 = ensure_meal_periods(date(2025, 6, 15))
|
||||
p2 = ensure_meal_periods(date(2025, 6, 15))
|
||||
assert p1[0]["id"] == p2[0]["id"]
|
||||
assert p1[1]["id"] == p2[1]["id"]
|
||||
|
||||
|
||||
class TestResponses:
|
||||
def test_first_response_no_changed_at(self, app):
|
||||
with app.app_context():
|
||||
from models import create_user
|
||||
|
||||
user = create_user("test", "hash", is_admin=False)
|
||||
periods = ensure_meal_periods(date.today())
|
||||
lunch = periods[0]
|
||||
|
||||
resp = upsert_response(user["id"], lunch["id"], "yes")
|
||||
assert resp["status"] == "yes"
|
||||
assert resp["changed_at"] is None
|
||||
|
||||
def test_flip_yes_to_no_sets_changed_at(self, app):
|
||||
with app.app_context():
|
||||
from models import create_user
|
||||
|
||||
user = create_user("flipper", "hash", is_admin=False)
|
||||
periods = ensure_meal_periods(date.today())
|
||||
lunch = periods[0]
|
||||
|
||||
upsert_response(user["id"], lunch["id"], "yes")
|
||||
resp = upsert_response(user["id"], lunch["id"], "no")
|
||||
assert resp["status"] == "no"
|
||||
assert resp["changed_at"] is not None
|
||||
|
||||
def test_flip_twice_updates_changed_at(self, app):
|
||||
with app.app_context():
|
||||
from models import create_user
|
||||
|
||||
user = create_user("flipper2", "hash", is_admin=False)
|
||||
periods = ensure_meal_periods(date.today())
|
||||
lunch = periods[0]
|
||||
|
||||
upsert_response(user["id"], lunch["id"], "yes")
|
||||
r1 = upsert_response(user["id"], lunch["id"], "no")
|
||||
r2 = upsert_response(user["id"], lunch["id"], "yes")
|
||||
|
||||
# changed_at should reflect the latest flip
|
||||
assert r2["changed_at"] is not None
|
||||
# The second flip should have a different timestamp (or at least not earlier)
|
||||
assert r2["changed_at"] >= r1["changed_at"]
|
||||
|
||||
def test_same_status_no_change(self, app):
|
||||
with app.app_context():
|
||||
from models import create_user
|
||||
|
||||
user = create_user("same", "hash", is_admin=False)
|
||||
periods = ensure_meal_periods(date.today())
|
||||
lunch = periods[0]
|
||||
|
||||
r1 = upsert_response(user["id"], lunch["id"], "yes")
|
||||
r2 = upsert_response(user["id"], lunch["id"], "yes")
|
||||
assert r1["status"] == r2["status"]
|
||||
assert r1["changed_at"] == r2["changed_at"]
|
||||
|
||||
|
||||
class TestDashboardData:
|
||||
def test_dashboard_includes_all_users(self, app):
|
||||
with app.app_context():
|
||||
from models import create_user
|
||||
|
||||
create_user("u1", "h1", is_admin=True)
|
||||
create_user("u2", "h2", is_admin=False)
|
||||
|
||||
data = get_dashboard_data(date.today())
|
||||
assert len(data["users"]) == 2
|
||||
assert data["users"][0]["username"] == "u1"
|
||||
assert data["users"][1]["username"] == "u2"
|
||||
|
||||
def test_dashboard_shows_responses(self, app):
|
||||
with app.app_context():
|
||||
from models import create_user
|
||||
|
||||
user = create_user("responder", "h", is_admin=True)
|
||||
periods = ensure_meal_periods(date.today())
|
||||
lunch = next(p for p in periods if p["meal_type"] == "lunch")
|
||||
upsert_response(user["id"], lunch["id"], "yes")
|
||||
|
||||
data = get_dashboard_data(date.today())
|
||||
resp = data["users"][0]["responses"]["lunch"]
|
||||
assert resp["status"] == "yes"
|
||||
|
||||
def test_dashboard_defaults_not_answered(self, app):
|
||||
with app.app_context():
|
||||
from models import create_user
|
||||
|
||||
create_user("silent", "h", is_admin=False)
|
||||
|
||||
data = get_dashboard_data(date.today())
|
||||
resp = data["users"][0]["responses"]["dinner"]
|
||||
assert resp["status"] == "not_answered"
|
||||
assert resp["changed_at"] is None
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Integration tests for HTTP routes."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
|
||||
class TestDashboardRoute:
|
||||
def test_dashboard_requires_login(self, client):
|
||||
resp = client.get("/dashboard", follow_redirects=True)
|
||||
assert b"Log In" in resp.data
|
||||
|
||||
def test_dashboard_shows_today(self, auth_client):
|
||||
resp = auth_client.get("/dashboard")
|
||||
assert resp.status_code == 200
|
||||
assert b"Today" in resp.data
|
||||
|
||||
def test_dashboard_renders_users(self, auth_client):
|
||||
resp = auth_client.get("/dashboard")
|
||||
# bob and alice were registered
|
||||
assert b"alice" in resp.data
|
||||
assert b"bob" in resp.data
|
||||
assert b"Lunch" in resp.data
|
||||
assert b"Dinner" in resp.data
|
||||
|
||||
|
||||
class TestRespondRoute:
|
||||
def test_respond_sets_status(self, auth_client):
|
||||
resp = auth_client.post("/respond", data={
|
||||
"meal_type": "lunch",
|
||||
"status": "yes",
|
||||
}, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
# After responding, the "Home" chip should be active
|
||||
assert b"Home" in resp.data
|
||||
|
||||
def test_respond_changes_mind(self, auth_client):
|
||||
# First respond yes
|
||||
auth_client.post("/respond", data={
|
||||
"meal_type": "dinner",
|
||||
"status": "yes",
|
||||
})
|
||||
# Then change to no
|
||||
resp = auth_client.post("/respond", data={
|
||||
"meal_type": "dinner",
|
||||
"status": "no",
|
||||
}, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_respond_invalid_meal_type(self, auth_client):
|
||||
resp = auth_client.post("/respond", data={
|
||||
"meal_type": "breakfast",
|
||||
"status": "yes",
|
||||
}, follow_redirects=True)
|
||||
assert b"Invalid meal type" in resp.data
|
||||
|
||||
def test_respond_invalid_status(self, auth_client):
|
||||
resp = auth_client.post("/respond", data={
|
||||
"meal_type": "lunch",
|
||||
"status": "maybe",
|
||||
}, follow_redirects=True)
|
||||
assert b"Invalid status" in resp.data
|
||||
|
||||
|
||||
class TestHistoryRoute:
|
||||
def test_history_requires_login(self, client):
|
||||
resp = client.get("/history", follow_redirects=True)
|
||||
assert b"Log In" in resp.data
|
||||
|
||||
def test_history_shows_past_date(self, auth_client):
|
||||
past = date.today().replace(year=date.today().year - 1)
|
||||
resp = auth_client.get(f"/history?date={past.isoformat()}")
|
||||
assert resp.status_code == 200
|
||||
assert b"History" in resp.data
|
||||
Reference in New Issue
Block a user