From c48cd265ca7ad8a0250aa1d85859fba4bcead711 Mon Sep 17 00:00:00 2001 From: agentbox Date: Fri, 31 Jul 2026 21:44:29 +0000 Subject: [PATCH] Add household support: multi-household isolation, each with own admin and users, 38 tests --- auth.py | 60 +++++++++++++-- meals.py | 7 +- models.py | 158 +++++++++++++++++++++++++++------------- static/style.css | 38 +++++++++- templates/base.html | 3 +- templates/register.html | 38 +++++++++- tests/conftest.py | 44 ++++++++--- tests/test_auth.py | 103 ++++++++++++++++++++++++-- tests/test_meals.py | 107 +++++++++++++++++---------- tests/test_routes.py | 27 ++++++- 10 files changed, 459 insertions(+), 126 deletions(-) diff --git a/auth.py b/auth.py index ee8fce5..eb25b20 100644 --- a/auth.py +++ b/auth.py @@ -3,7 +3,15 @@ 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 +from models import ( + get_user_by_username, + create_user, + create_household, + get_household_by_name, + get_household_by_id, + get_all_households, + count_household_users, +) auth_bp = Blueprint("auth", __name__) @@ -23,10 +31,14 @@ def login(): flash("Invalid username or password.", "error") return render_template("login.html") + household = get_household_by_id(user["household_id"]) + session.clear() session["user_id"] = user["id"] session["username"] = user["username"] session["is_admin"] = bool(user["is_admin"]) + session["household_id"] = user["household_id"] + session["household_name"] = household["name"] if household else "Unknown" return redirect(url_for("meals.dashboard")) return render_template("login.html") @@ -34,10 +46,15 @@ def login(): @auth_bp.route("/register", methods=["GET", "POST"]) def register(): + households = get_all_households() + if request.method == "POST": username = request.form.get("username", "").strip() password = request.form.get("password", "") confirm = request.form.get("confirm", "") + household_action = request.form.get("household_action", "join") # "join" or "create" + household_id = request.form.get("household_id", "").strip() + new_household_name = request.form.get("new_household_name", "").strip() errors = [] if not username or len(username) < 2: @@ -49,20 +66,47 @@ def register(): if get_user_by_username(username): errors.append("Username already taken.") + target_household_id = None + + if household_action == "create": + if not new_household_name: + errors.append("Household name is required.") + elif get_household_by_name(new_household_name): + errors.append("A household with that name already exists.") + else: + household = create_household(new_household_name) + target_household_id = household["id"] + is_admin = True # First user in a new household is admin + else: + if not household_id: + errors.append("Please select a household.") + else: + hh = get_household_by_id(int(household_id)) + if hh is None: + errors.append("Selected household does not exist.") + else: + target_household_id = hh["id"] + is_admin = False + if errors: for e in errors: flash(e, "error") - return render_template("register.html") + return render_template("register.html", households=households) - # 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) + user = create_user(username, password_hash, target_household_id, is_admin=is_admin) - flash("Account created! Please log in.", "success") - return redirect(url_for("auth.login")) + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + session["household_id"] = user["household_id"] + session["household_name"] = get_household_by_id(target_household_id)["name"] - return render_template("register.html") + flash("Account created! Welcome to your household.", "success") + return redirect(url_for("meals.dashboard")) + + return render_template("register.html", households=households) @auth_bp.route("/logout") diff --git a/meals.py b/meals.py index 8d895a6..53f524c 100644 --- a/meals.py +++ b/meals.py @@ -10,7 +10,6 @@ from flask import ( url_for, session, flash, - jsonify, ) from models import get_dashboard_data, upsert_response, ensure_meal_periods @@ -42,6 +41,7 @@ def index(): @login_required def dashboard(): today = date.today() + household_id = session["household_id"] # Allow viewing other dates via query param date_str = request.args.get("date") @@ -51,7 +51,7 @@ def dashboard(): except ValueError: flash("Invalid date format. Use YYYY-MM-DD.", "error") - data = get_dashboard_data(today) + data = get_dashboard_data(today, household_id) return render_template( "dashboard.html", dashboard=data, @@ -83,7 +83,6 @@ def respond(): 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")) @@ -107,7 +106,7 @@ def history(): except ValueError: target_date = date.today() - data = get_dashboard_data(target_date) + data = get_dashboard_data(target_date, session["household_id"]) return render_template( "dashboard.html", dashboard=data, diff --git a/models.py b/models.py index 5d6a8a3..09a1058 100644 --- a/models.py +++ b/models.py @@ -4,6 +4,7 @@ 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") @@ -19,14 +20,21 @@ def get_db() -> sqlite3.Connection: def init_db() -> None: - """Create tables if they don't exist.""" + """Create tables if they don't exist, including migration for household support.""" with get_db() as db: db.executescript(""" + CREATE TABLE IF NOT EXISTS households ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + 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, + household_id INTEGER REFERENCES households(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -54,8 +62,100 @@ def init_db() -> None: ON responses(period_id); CREATE INDEX IF NOT EXISTS idx_meal_periods_date ON meal_periods(date); + CREATE INDEX IF NOT EXISTS idx_users_household + ON users(household_id); """) + # Migration: if users table exists but lacks household_id column, add it. + cols = [r["name"] for r in db.execute("PRAGMA table_info(users)").fetchall()] + if "household_id" not in cols: + db.execute("ALTER TABLE users ADD COLUMN household_id INTEGER REFERENCES households(id)") + + +# ── Households ──────────────────────────────────────────────────────────────── + +def create_household(name: str) -> dict: + """Create a new household. Returns the row as a dict.""" + with get_db() as db: + cur = db.execute("INSERT INTO households (name) VALUES (?)", (name,)) + db.commit() + row = db.execute("SELECT * FROM households WHERE id = ?", (cur.lastrowid,)).fetchone() + return dict(row) + + +def get_household_by_id(household_id: int) -> dict | None: + """Fetch a household by ID.""" + with get_db() as db: + row = db.execute("SELECT * FROM households WHERE id = ?", (household_id,)).fetchone() + return dict(row) if row else None + + +def get_household_by_name(name: str) -> dict | None: + """Fetch a household by name.""" + with get_db() as db: + row = db.execute("SELECT * FROM households WHERE name = ?", (name,)).fetchone() + return dict(row) if row else None + + +def get_all_households() -> list[dict]: + """Return all households.""" + with get_db() as db: + rows = db.execute("SELECT * FROM households ORDER BY name").fetchall() + return [dict(r) for r in rows] + + +def count_household_users(household_id: int) -> int: + """Return number of users in a household.""" + with get_db() as db: + return db.execute( + "SELECT COUNT(*) FROM users WHERE household_id = ?", (household_id,) + ).fetchone()[0] + + +# ── Users ───────────────────────────────────────────────────────────────────── + +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, household_id: int, + is_admin: bool = False) -> dict: + """Insert a new user into a household. Returns the user row as a dict.""" + with get_db() as db: + cur = db.execute( + "INSERT INTO users (username, password_hash, is_admin, household_id) " + "VALUES (?, ?, ?, ?)", + (username, password_hash, int(is_admin), household_id), + ) + user_id = cur.lastrowid + db.commit() + return get_user_by_id(user_id) + + +def get_household_users(household_id: int) -> list[dict]: + """Return all users in a given household.""" + with get_db() as db: + rows = db.execute( + "SELECT id, username, is_admin, created_at " + "FROM users WHERE household_id = ? ORDER BY username", + (household_id,), + ).fetchall() + return [dict(r) for r in rows] + + +# ── Meal periods ────────────────────────────────────────────────────────────── def ensure_meal_periods(for_date: date) -> list[dict]: """Ensure lunch and dinner periods exist for a given date. @@ -79,51 +179,10 @@ def ensure_meal_periods(for_date: date) -> list[dict]: 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 +# ── Dashboard ───────────────────────────────────────────────────────────────── - -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. +def get_dashboard_data(for_date: date, household_id: int) -> dict: + """Return all users and their responses for a given date, scoped to a household. Returns: { @@ -143,7 +202,7 @@ def get_dashboard_data(for_date: date) -> dict: } """ periods = ensure_meal_periods(for_date) - users = get_all_users() + users = get_household_users(household_id) with get_db() as db: period_ids = [p["id"] for p in periods] @@ -158,7 +217,6 @@ def get_dashboard_data(for_date: date) -> dict: 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"])] = { @@ -186,6 +244,8 @@ def get_dashboard_data(for_date: date) -> dict: } +# ── Responses ───────────────────────────────────────────────────────────────── + def upsert_response(user_id: int, period_id: int, new_status: str) -> dict | None: """Insert or update a response. Handles changed_at logic. @@ -199,16 +259,13 @@ def upsert_response(user_id: int, period_id: int, new_status: str) -> dict | Non ).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 = ? " @@ -216,7 +273,6 @@ def upsert_response(user_id: int, period_id: int, new_status: str) -> dict | Non (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), diff --git a/static/style.css b/static/style.css index 1d188d7..096d365 100644 --- a/static/style.css +++ b/static/style.css @@ -36,6 +36,7 @@ body { } .brand { font-size: 1.2rem; font-weight: 700; } .nav-right { display: flex; align-items: center; gap: 12px; } +.nav-household { font-weight: 600; font-size: 0.85rem; color: var(--primary); background: #e3f2fd; padding: 2px 10px; border-radius: 12px; } .nav-user { font-weight: 500; color: var(--muted); } /* ── Buttons ── */ @@ -65,9 +66,10 @@ body { border: 1px solid var(--border); border-radius: var(--radius); padding: 32px; - max-width: 380px; + max-width: 420px; margin: 40px auto; } +.register-card { max-width: 480px; } .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"], @@ -81,6 +83,40 @@ body { .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); } +/* Household section */ +.household-section { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + margin: 16px 0; +} +.household-section legend { + font-weight: 600; + font-size: 0.9rem; + padding: 0 8px; +} +.radio-label { + display: flex !important; + align-items: center; + gap: 8px; + margin: 12px 0 4px !important; + cursor: pointer; + font-weight: 400 !important; +} +.radio-label input[type="radio"] { margin: 0; } +.household-block { + margin: 4px 0 12px 24px; +} +.select-input, .text-input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 4px; + font-size: 1rem; + background: var(--surface); +} +.hint { font-size: 0.8rem; color: var(--muted); margin-top: 4px; } + /* ── Flashes ── */ .flashes { margin-bottom: 16px; } .flash { diff --git a/templates/base.html b/templates/base.html index f77deb7..0b77e98 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,7 +11,8 @@ 🍽 Meal Tracker {% if session.user_id %} {% endif %} diff --git a/templates/register.html b/templates/register.html index 38cdda3..e6564b1 100644 --- a/templates/register.html +++ b/templates/register.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block title %}Register — Meal Tracker{% endblock %} {% block content %} -
+

Create Account

@@ -13,6 +13,42 @@ +
+ Household + + +
+ + {% if not households %} +

No households yet. Create one below!

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