Add household support: multi-household isolation, each with own admin and users, 38 tests
This commit is contained in:
@@ -3,7 +3,15 @@
|
|||||||
from flask import Blueprint, request, render_template, redirect, url_for, session, flash
|
from flask import Blueprint, request, render_template, redirect, url_for, session, flash
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
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__)
|
auth_bp = Blueprint("auth", __name__)
|
||||||
|
|
||||||
@@ -23,10 +31,14 @@ def login():
|
|||||||
flash("Invalid username or password.", "error")
|
flash("Invalid username or password.", "error")
|
||||||
return render_template("login.html")
|
return render_template("login.html")
|
||||||
|
|
||||||
|
household = get_household_by_id(user["household_id"])
|
||||||
|
|
||||||
session.clear()
|
session.clear()
|
||||||
session["user_id"] = user["id"]
|
session["user_id"] = user["id"]
|
||||||
session["username"] = user["username"]
|
session["username"] = user["username"]
|
||||||
session["is_admin"] = bool(user["is_admin"])
|
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 redirect(url_for("meals.dashboard"))
|
||||||
|
|
||||||
return render_template("login.html")
|
return render_template("login.html")
|
||||||
@@ -34,10 +46,15 @@ def login():
|
|||||||
|
|
||||||
@auth_bp.route("/register", methods=["GET", "POST"])
|
@auth_bp.route("/register", methods=["GET", "POST"])
|
||||||
def register():
|
def register():
|
||||||
|
households = get_all_households()
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username", "").strip()
|
username = request.form.get("username", "").strip()
|
||||||
password = request.form.get("password", "")
|
password = request.form.get("password", "")
|
||||||
confirm = request.form.get("confirm", "")
|
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 = []
|
errors = []
|
||||||
if not username or len(username) < 2:
|
if not username or len(username) < 2:
|
||||||
@@ -49,20 +66,47 @@ def register():
|
|||||||
if get_user_by_username(username):
|
if get_user_by_username(username):
|
||||||
errors.append("Username already taken.")
|
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:
|
if errors:
|
||||||
for e in errors:
|
for e in errors:
|
||||||
flash(e, "error")
|
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)
|
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")
|
session.clear()
|
||||||
return redirect(url_for("auth.login"))
|
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")
|
@auth_bp.route("/logout")
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from flask import (
|
|||||||
url_for,
|
url_for,
|
||||||
session,
|
session,
|
||||||
flash,
|
flash,
|
||||||
jsonify,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from models import get_dashboard_data, upsert_response, ensure_meal_periods
|
from models import get_dashboard_data, upsert_response, ensure_meal_periods
|
||||||
@@ -42,6 +41,7 @@ def index():
|
|||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
today = date.today()
|
today = date.today()
|
||||||
|
household_id = session["household_id"]
|
||||||
|
|
||||||
# Allow viewing other dates via query param
|
# Allow viewing other dates via query param
|
||||||
date_str = request.args.get("date")
|
date_str = request.args.get("date")
|
||||||
@@ -51,7 +51,7 @@ def dashboard():
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
flash("Invalid date format. Use YYYY-MM-DD.", "error")
|
flash("Invalid date format. Use YYYY-MM-DD.", "error")
|
||||||
|
|
||||||
data = get_dashboard_data(today)
|
data = get_dashboard_data(today, household_id)
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
dashboard=data,
|
dashboard=data,
|
||||||
@@ -83,7 +83,6 @@ def respond():
|
|||||||
flash("Invalid date.", "error")
|
flash("Invalid date.", "error")
|
||||||
return redirect(url_for("meals.dashboard"))
|
return redirect(url_for("meals.dashboard"))
|
||||||
|
|
||||||
# Only allow responding to today (and not in the past)
|
|
||||||
if target_date < date.today():
|
if target_date < date.today():
|
||||||
flash("Cannot change responses for past dates.", "error")
|
flash("Cannot change responses for past dates.", "error")
|
||||||
return redirect(url_for("meals.dashboard"))
|
return redirect(url_for("meals.dashboard"))
|
||||||
@@ -107,7 +106,7 @@ def history():
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
target_date = date.today()
|
target_date = date.today()
|
||||||
|
|
||||||
data = get_dashboard_data(target_date)
|
data = get_dashboard_data(target_date, session["household_id"])
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
dashboard=data,
|
dashboard=data,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import sqlite3
|
|||||||
import os
|
import os
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
|
||||||
def _db_path() -> str:
|
def _db_path() -> str:
|
||||||
return os.environ.get(
|
return os.environ.get(
|
||||||
"DB_PATH", os.path.join(os.path.dirname(__file__), "meals.db")
|
"DB_PATH", os.path.join(os.path.dirname(__file__), "meals.db")
|
||||||
@@ -19,14 +20,21 @@ def get_db() -> sqlite3.Connection:
|
|||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
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:
|
with get_db() as db:
|
||||||
db.executescript("""
|
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 (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
household_id INTEGER REFERENCES households(id),
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -54,8 +62,100 @@ def init_db() -> None:
|
|||||||
ON responses(period_id);
|
ON responses(period_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_meal_periods_date
|
CREATE INDEX IF NOT EXISTS idx_meal_periods_date
|
||||||
ON 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]:
|
def ensure_meal_periods(for_date: date) -> list[dict]:
|
||||||
"""Ensure lunch and dinner periods exist for a given date.
|
"""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]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def get_user_by_id(user_id: int) -> dict | None:
|
# ── Dashboard ─────────────────────────────────────────────────────────────────
|
||||||
"""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_dashboard_data(for_date: date, household_id: int) -> dict:
|
||||||
def get_user_by_username(username: str) -> dict | None:
|
"""Return all users and their responses for a given date, scoped to a household.
|
||||||
"""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:
|
Returns:
|
||||||
{
|
{
|
||||||
@@ -143,7 +202,7 @@ def get_dashboard_data(for_date: date) -> dict:
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
periods = ensure_meal_periods(for_date)
|
periods = ensure_meal_periods(for_date)
|
||||||
users = get_all_users()
|
users = get_household_users(household_id)
|
||||||
|
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
period_ids = [p["id"] for p in periods]
|
period_ids = [p["id"] for p in periods]
|
||||||
@@ -158,7 +217,6 @@ def get_dashboard_data(for_date: date) -> dict:
|
|||||||
period_ids,
|
period_ids,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
# Build a lookup: (user_id, period_id) → response
|
|
||||||
resp_map = {}
|
resp_map = {}
|
||||||
for r in resp_rows:
|
for r in resp_rows:
|
||||||
resp_map[(r["user_id"], r["period_id"])] = {
|
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:
|
def upsert_response(user_id: int, period_id: int, new_status: str) -> dict | None:
|
||||||
"""Insert or update a response. Handles changed_at logic.
|
"""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()
|
).fetchone()
|
||||||
|
|
||||||
if existing is None:
|
if existing is None:
|
||||||
# First response ever
|
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO responses (user_id, period_id, status) VALUES (?, ?, ?)",
|
"INSERT INTO responses (user_id, period_id, status) VALUES (?, ?, ?)",
|
||||||
(user_id, period_id, new_status),
|
(user_id, period_id, new_status),
|
||||||
)
|
)
|
||||||
elif existing["status"] == new_status:
|
elif existing["status"] == new_status:
|
||||||
# No change — nothing to update
|
|
||||||
pass
|
pass
|
||||||
elif existing["status"] in ("yes", "no") and new_status in ("yes", "no"):
|
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")
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE responses SET status = ?, changed_at = ? "
|
"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),
|
(new_status, now, user_id, period_id),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Transition from 'not_answered' → 'yes'/'no' (first answer)
|
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE responses SET status = ? WHERE user_id = ? AND period_id = ?",
|
"UPDATE responses SET status = ? WHERE user_id = ? AND period_id = ?",
|
||||||
(new_status, user_id, period_id),
|
(new_status, user_id, period_id),
|
||||||
|
|||||||
+37
-1
@@ -36,6 +36,7 @@ body {
|
|||||||
}
|
}
|
||||||
.brand { font-size: 1.2rem; font-weight: 700; }
|
.brand { font-size: 1.2rem; font-weight: 700; }
|
||||||
.nav-right { display: flex; align-items: center; gap: 12px; }
|
.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); }
|
.nav-user { font-weight: 500; color: var(--muted); }
|
||||||
|
|
||||||
/* ── Buttons ── */
|
/* ── Buttons ── */
|
||||||
@@ -65,9 +66,10 @@ body {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
max-width: 380px;
|
max-width: 420px;
|
||||||
margin: 40px auto;
|
margin: 40px auto;
|
||||||
}
|
}
|
||||||
|
.register-card { max-width: 480px; }
|
||||||
.form-card h2 { margin-bottom: 20px; }
|
.form-card h2 { margin-bottom: 20px; }
|
||||||
.form-card label { display: block; margin: 12px 0 4px; font-weight: 500; font-size: 0.9rem; }
|
.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="text"],
|
||||||
@@ -81,6 +83,40 @@ body {
|
|||||||
.form-card button[type="submit"] { margin-top: 20px; width: 100%; }
|
.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); }
|
.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 ── */
|
||||||
.flashes { margin-bottom: 16px; }
|
.flashes { margin-bottom: 16px; }
|
||||||
.flash {
|
.flash {
|
||||||
|
|||||||
+2
-1
@@ -11,7 +11,8 @@
|
|||||||
<span class="brand">🍽 Meal Tracker</span>
|
<span class="brand">🍽 Meal Tracker</span>
|
||||||
{% if session.user_id %}
|
{% if session.user_id %}
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
<span class="nav-user">{{ session.username }}</span>
|
<span class="nav-household">{{ session.household_name }}</span>
|
||||||
|
<span class="nav-user">{{ session.username }}{% if session.is_admin %} (admin){% endif %}</span>
|
||||||
<a class="btn btn-sm" href="{{ url_for('auth.logout') }}">Logout</a>
|
<a class="btn btn-sm" href="{{ url_for('auth.logout') }}">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
+37
-1
@@ -1,7 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Register — Meal Tracker{% endblock %}
|
{% block title %}Register — Meal Tracker{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="form-card">
|
<div class="form-card register-card">
|
||||||
<h2>Create Account</h2>
|
<h2>Create Account</h2>
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
@@ -13,6 +13,42 @@
|
|||||||
<label for="confirm">Confirm Password</label>
|
<label for="confirm">Confirm Password</label>
|
||||||
<input type="password" id="confirm" name="confirm" required>
|
<input type="password" id="confirm" name="confirm" required>
|
||||||
|
|
||||||
|
<fieldset class="household-section">
|
||||||
|
<legend>Household</legend>
|
||||||
|
|
||||||
|
<label class="radio-label">
|
||||||
|
<input type="radio" name="household_action" value="join" checked
|
||||||
|
onchange="document.getElementById('join-block').hidden=false;
|
||||||
|
document.getElementById('create-block').hidden=true;">
|
||||||
|
Join an existing household
|
||||||
|
</label>
|
||||||
|
<div id="join-block" class="household-block">
|
||||||
|
<select name="household_id" class="select-input">
|
||||||
|
<option value="">— Select —</option>
|
||||||
|
{% for h in households %}
|
||||||
|
<option value="{{ h.id }}">{{ h.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if not households %}
|
||||||
|
<p class="hint">No households yet. Create one below!</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="radio-label">
|
||||||
|
<input type="radio" name="household_action" value="create"
|
||||||
|
onchange="document.getElementById('join-block').hidden=true;
|
||||||
|
document.getElementById('create-block').hidden=false;"
|
||||||
|
{% if not households %}checked{% endif %}>
|
||||||
|
Create a new household
|
||||||
|
</label>
|
||||||
|
<div id="create-block" class="household-block" hidden>
|
||||||
|
<input type="text" name="new_household_name"
|
||||||
|
placeholder="Household name (e.g. The Smiths)"
|
||||||
|
class="text-input">
|
||||||
|
<p class="hint">You'll be the admin of this household.</p>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Register</button>
|
<button type="submit" class="btn btn-primary">Register</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="form-footer">
|
<p class="form-footer">
|
||||||
|
|||||||
+32
-12
@@ -6,7 +6,7 @@ import tempfile
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
from models import init_db, get_db
|
from models import init_db
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -35,35 +35,55 @@ def client(app):
|
|||||||
return app.test_client()
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_and_login(client, username, password, household_action="create",
|
||||||
|
household_name=None, join_household_id=None):
|
||||||
|
"""Helper: register (and auto-login) a user."""
|
||||||
|
if not household_name:
|
||||||
|
household_name = f"{username}-house"
|
||||||
|
resp = client.post("/register", data={
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"confirm": password,
|
||||||
|
"household_action": household_action,
|
||||||
|
"new_household_name": household_name,
|
||||||
|
"household_id": str(join_household_id) if join_household_id else "",
|
||||||
|
}, follow_redirects=True)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def auth_client(client):
|
def auth_client(client):
|
||||||
"""Test client with a logged-in regular user."""
|
"""Test client with a logged-in regular user in a 2-person household."""
|
||||||
client.post("/register", data={
|
# Admin creates household
|
||||||
"username": "bob",
|
|
||||||
"password": "bobpass",
|
|
||||||
"confirm": "bobpass",
|
|
||||||
})
|
|
||||||
client.get("/logout")
|
|
||||||
client.post("/register", data={
|
client.post("/register", data={
|
||||||
"username": "alice",
|
"username": "alice",
|
||||||
"password": "alicepass",
|
"password": "alicepass",
|
||||||
"confirm": "alicepass",
|
"confirm": "alicepass",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "Test Household",
|
||||||
})
|
})
|
||||||
client.get("/logout")
|
client.get("/logout")
|
||||||
# Log in as bob (second user, not admin)
|
# Bob joins the same household
|
||||||
client.post("/login", data={
|
from models import get_household_by_name
|
||||||
|
hh = get_household_by_name("Test Household")
|
||||||
|
client.post("/register", data={
|
||||||
"username": "bob",
|
"username": "bob",
|
||||||
"password": "bobpass",
|
"password": "bobpass",
|
||||||
|
"confirm": "bobpass",
|
||||||
|
"household_action": "join",
|
||||||
|
"household_id": str(hh["id"]),
|
||||||
})
|
})
|
||||||
return client
|
return client # Logged in as bob (regular user)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def admin_client(client):
|
def admin_client(client):
|
||||||
"""Test client with the admin user logged in."""
|
"""Test client with the admin user logged in (created their own household)."""
|
||||||
client.post("/register", data={
|
client.post("/register", data={
|
||||||
"username": "admin",
|
"username": "admin",
|
||||||
"password": "adminpass",
|
"password": "adminpass",
|
||||||
"confirm": "adminpass",
|
"confirm": "adminpass",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "Admin Household",
|
||||||
})
|
})
|
||||||
return client
|
return client
|
||||||
|
|||||||
+96
-7
@@ -5,24 +5,31 @@ def test_register_page(client):
|
|||||||
resp = client.get("/register")
|
resp = client.get("/register")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"Create Account" in resp.data
|
assert b"Create Account" in resp.data
|
||||||
|
assert b"Household" in resp.data
|
||||||
|
|
||||||
|
|
||||||
def test_register_success(client):
|
def test_register_create_household(client):
|
||||||
|
"""Registering with 'create household' should succeed and make admin."""
|
||||||
resp = client.post("/register", data={
|
resp = client.post("/register", data={
|
||||||
"username": "testuser",
|
"username": "testuser",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"confirm": "secret123",
|
"confirm": "secret123",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "My House",
|
||||||
}, follow_redirects=True)
|
}, follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"Account created" in resp.data
|
assert b"Account created" in resp.data
|
||||||
|
assert b"My House" in resp.data # Shows in nav
|
||||||
|
|
||||||
|
|
||||||
def test_register_first_user_is_admin(client):
|
def test_register_first_in_household_is_admin(client):
|
||||||
"""First registered user should be admin."""
|
"""First user in a new household should be admin."""
|
||||||
client.post("/register", data={
|
client.post("/register", data={
|
||||||
"username": "firstuser",
|
"username": "firstuser",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"confirm": "secret123",
|
"confirm": "secret123",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "First House",
|
||||||
})
|
})
|
||||||
from models import get_user_by_username
|
from models import get_user_by_username
|
||||||
user = get_user_by_username("firstuser")
|
user = get_user_by_username("firstuser")
|
||||||
@@ -30,17 +37,91 @@ def test_register_first_user_is_admin(client):
|
|||||||
assert user["is_admin"] == 1
|
assert user["is_admin"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_join_household_not_admin(client):
|
||||||
|
"""Joining an existing household makes you a non-admin."""
|
||||||
|
# First user creates household
|
||||||
|
client.post("/register", data={
|
||||||
|
"username": "creator",
|
||||||
|
"password": "secret123",
|
||||||
|
"confirm": "secret123",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "Shared Home",
|
||||||
|
})
|
||||||
|
client.get("/logout")
|
||||||
|
# Second user joins
|
||||||
|
client.post("/register", data={
|
||||||
|
"username": "joiner",
|
||||||
|
"password": "pass4321",
|
||||||
|
"confirm": "pass4321",
|
||||||
|
"household_action": "join",
|
||||||
|
"household_id": "1",
|
||||||
|
})
|
||||||
|
from models import get_user_by_username
|
||||||
|
joiner = get_user_by_username("joiner")
|
||||||
|
assert joiner is not None
|
||||||
|
assert joiner["is_admin"] == 0
|
||||||
|
assert joiner["household_id"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_create_duplicate_household_name(client):
|
||||||
|
"""Creating a household with an existing name should fail."""
|
||||||
|
client.post("/register", data={
|
||||||
|
"username": "u1",
|
||||||
|
"password": "pass1234",
|
||||||
|
"confirm": "pass1234",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "DupHouse",
|
||||||
|
})
|
||||||
|
client.get("/logout")
|
||||||
|
resp = client.post("/register", data={
|
||||||
|
"username": "u2",
|
||||||
|
"password": "pass5678",
|
||||||
|
"confirm": "pass5678",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "DupHouse",
|
||||||
|
}, follow_redirects=True)
|
||||||
|
assert b"already exists" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_missing_household_name(client):
|
||||||
|
"""Creating a household without a name should fail."""
|
||||||
|
resp = client.post("/register", data={
|
||||||
|
"username": "u3",
|
||||||
|
"password": "pass1234",
|
||||||
|
"confirm": "pass1234",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "",
|
||||||
|
}, follow_redirects=True)
|
||||||
|
assert b"Household name is required" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_no_household_selected(client):
|
||||||
|
"""Joining without selecting a household should fail."""
|
||||||
|
resp = client.post("/register", data={
|
||||||
|
"username": "u4",
|
||||||
|
"password": "pass1234",
|
||||||
|
"confirm": "pass1234",
|
||||||
|
"household_action": "join",
|
||||||
|
"household_id": "",
|
||||||
|
}, follow_redirects=True)
|
||||||
|
assert b"Please select a household" in resp.data
|
||||||
|
|
||||||
|
|
||||||
def test_register_duplicate_username(client):
|
def test_register_duplicate_username(client):
|
||||||
client.post("/register", data={
|
client.post("/register", data={
|
||||||
"username": "dup",
|
"username": "dup",
|
||||||
"password": "pass1234",
|
"password": "pass1234",
|
||||||
"confirm": "pass1234",
|
"confirm": "pass1234",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "DupHouse",
|
||||||
})
|
})
|
||||||
client.get("/logout")
|
client.get("/logout")
|
||||||
resp = client.post("/register", data={
|
resp = client.post("/register", data={
|
||||||
"username": "dup",
|
"username": "dup",
|
||||||
"password": "other5678",
|
"password": "other5678",
|
||||||
"confirm": "other5678",
|
"confirm": "other5678",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "OtherHouse",
|
||||||
}, follow_redirects=True)
|
}, follow_redirects=True)
|
||||||
assert b"Username already taken" in resp.data
|
assert b"Username already taken" in resp.data
|
||||||
|
|
||||||
@@ -50,6 +131,8 @@ def test_register_password_mismatch(client):
|
|||||||
"username": "someone",
|
"username": "someone",
|
||||||
"password": "abc12345",
|
"password": "abc12345",
|
||||||
"confirm": "abc12346",
|
"confirm": "abc12346",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "AnyHouse",
|
||||||
}, follow_redirects=True)
|
}, follow_redirects=True)
|
||||||
assert b"Passwords do not match" in resp.data
|
assert b"Passwords do not match" in resp.data
|
||||||
|
|
||||||
@@ -59,6 +142,8 @@ def test_register_short_username(client):
|
|||||||
"username": "x",
|
"username": "x",
|
||||||
"password": "pass1234",
|
"password": "pass1234",
|
||||||
"confirm": "pass1234",
|
"confirm": "pass1234",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "AnyHouse",
|
||||||
}, follow_redirects=True)
|
}, follow_redirects=True)
|
||||||
assert b"at least 2 characters" in resp.data
|
assert b"at least 2 characters" in resp.data
|
||||||
|
|
||||||
@@ -68,6 +153,8 @@ def test_register_short_password(client):
|
|||||||
"username": "validname",
|
"username": "validname",
|
||||||
"password": "ab",
|
"password": "ab",
|
||||||
"confirm": "ab",
|
"confirm": "ab",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "AnyHouse",
|
||||||
}, follow_redirects=True)
|
}, follow_redirects=True)
|
||||||
assert b"at least 4 characters" in resp.data
|
assert b"at least 4 characters" in resp.data
|
||||||
|
|
||||||
@@ -79,20 +166,20 @@ def test_login_page(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_login_success(client):
|
def test_login_success(client):
|
||||||
# Register first
|
|
||||||
client.post("/register", data={
|
client.post("/register", data={
|
||||||
"username": "logintest",
|
"username": "logintest",
|
||||||
"password": "mypassword",
|
"password": "mypassword",
|
||||||
"confirm": "mypassword",
|
"confirm": "mypassword",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "LoginHouse",
|
||||||
})
|
})
|
||||||
client.get("/logout")
|
client.get("/logout")
|
||||||
# Then login
|
|
||||||
resp = client.post("/login", data={
|
resp = client.post("/login", data={
|
||||||
"username": "logintest",
|
"username": "logintest",
|
||||||
"password": "mypassword",
|
"password": "mypassword",
|
||||||
}, follow_redirects=True)
|
}, follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"Today" in resp.data # Dashboard
|
assert b"Today" in resp.data
|
||||||
|
|
||||||
|
|
||||||
def test_login_wrong_password(client):
|
def test_login_wrong_password(client):
|
||||||
@@ -100,6 +187,8 @@ def test_login_wrong_password(client):
|
|||||||
"username": "logintest2",
|
"username": "logintest2",
|
||||||
"password": "mypassword",
|
"password": "mypassword",
|
||||||
"confirm": "mypassword",
|
"confirm": "mypassword",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "LoginHouse2",
|
||||||
})
|
})
|
||||||
client.get("/logout")
|
client.get("/logout")
|
||||||
resp = client.post("/login", data={
|
resp = client.post("/login", data={
|
||||||
@@ -120,7 +209,7 @@ def test_login_nonexistent_user(client):
|
|||||||
def test_logout(auth_client):
|
def test_logout(auth_client):
|
||||||
resp = auth_client.get("/logout", follow_redirects=True)
|
resp = auth_client.get("/logout", follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"Log In" in resp.data # Redirected to login
|
assert b"Log In" in resp.data
|
||||||
|
|
||||||
|
|
||||||
def test_unauthenticated_redirect(client):
|
def test_unauthenticated_redirect(client):
|
||||||
|
|||||||
+70
-37
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for meal periods, responses, and changed-mind logic."""
|
"""Tests for meal periods, responses, changed-mind logic, and household scoping."""
|
||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
@@ -10,10 +10,25 @@ from models import (
|
|||||||
ensure_meal_periods,
|
ensure_meal_periods,
|
||||||
get_dashboard_data,
|
get_dashboard_data,
|
||||||
upsert_response,
|
upsert_response,
|
||||||
get_user_by_username,
|
create_user,
|
||||||
|
create_household,
|
||||||
|
get_all_households,
|
||||||
|
get_household_users,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _setup_household_and_user(app, username="test", household_name="Test House"):
|
||||||
|
"""Create a household + user. Returns (household, user)."""
|
||||||
|
with app.app_context():
|
||||||
|
hh = create_household(household_name)
|
||||||
|
user = create_user(username, "hash", hh["id"], is_admin=True)
|
||||||
|
return hh, user
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TestMealPeriods:
|
class TestMealPeriods:
|
||||||
def test_ensure_creates_periods(self, app):
|
def test_ensure_creates_periods(self, app):
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@@ -34,94 +49,112 @@ class TestMealPeriods:
|
|||||||
|
|
||||||
class TestResponses:
|
class TestResponses:
|
||||||
def test_first_response_no_changed_at(self, app):
|
def test_first_response_no_changed_at(self, app):
|
||||||
|
hh, user = _setup_household_and_user(app)
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from models import create_user
|
|
||||||
|
|
||||||
user = create_user("test", "hash", is_admin=False)
|
|
||||||
periods = ensure_meal_periods(date.today())
|
periods = ensure_meal_periods(date.today())
|
||||||
lunch = periods[0]
|
lunch = periods[0]
|
||||||
|
|
||||||
resp = upsert_response(user["id"], lunch["id"], "yes")
|
resp = upsert_response(user["id"], lunch["id"], "yes")
|
||||||
assert resp["status"] == "yes"
|
assert resp["status"] == "yes"
|
||||||
assert resp["changed_at"] is None
|
assert resp["changed_at"] is None
|
||||||
|
|
||||||
def test_flip_yes_to_no_sets_changed_at(self, app):
|
def test_flip_yes_to_no_sets_changed_at(self, app):
|
||||||
|
hh, user = _setup_household_and_user(app)
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from models import create_user
|
|
||||||
|
|
||||||
user = create_user("flipper", "hash", is_admin=False)
|
|
||||||
periods = ensure_meal_periods(date.today())
|
periods = ensure_meal_periods(date.today())
|
||||||
lunch = periods[0]
|
lunch = periods[0]
|
||||||
|
|
||||||
upsert_response(user["id"], lunch["id"], "yes")
|
upsert_response(user["id"], lunch["id"], "yes")
|
||||||
resp = upsert_response(user["id"], lunch["id"], "no")
|
resp = upsert_response(user["id"], lunch["id"], "no")
|
||||||
assert resp["status"] == "no"
|
assert resp["status"] == "no"
|
||||||
assert resp["changed_at"] is not None
|
assert resp["changed_at"] is not None
|
||||||
|
|
||||||
def test_flip_twice_updates_changed_at(self, app):
|
def test_flip_twice_updates_changed_at(self, app):
|
||||||
|
hh, user = _setup_household_and_user(app, "flipper")
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from models import create_user
|
|
||||||
|
|
||||||
user = create_user("flipper2", "hash", is_admin=False)
|
|
||||||
periods = ensure_meal_periods(date.today())
|
periods = ensure_meal_periods(date.today())
|
||||||
lunch = periods[0]
|
lunch = periods[0]
|
||||||
|
|
||||||
upsert_response(user["id"], lunch["id"], "yes")
|
upsert_response(user["id"], lunch["id"], "yes")
|
||||||
r1 = upsert_response(user["id"], lunch["id"], "no")
|
r1 = upsert_response(user["id"], lunch["id"], "no")
|
||||||
r2 = upsert_response(user["id"], lunch["id"], "yes")
|
r2 = upsert_response(user["id"], lunch["id"], "yes")
|
||||||
|
|
||||||
# changed_at should reflect the latest flip
|
|
||||||
assert r2["changed_at"] is not None
|
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"]
|
assert r2["changed_at"] >= r1["changed_at"]
|
||||||
|
|
||||||
def test_same_status_no_change(self, app):
|
def test_same_status_no_change(self, app):
|
||||||
|
hh, user = _setup_household_and_user(app, "same")
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from models import create_user
|
|
||||||
|
|
||||||
user = create_user("same", "hash", is_admin=False)
|
|
||||||
periods = ensure_meal_periods(date.today())
|
periods = ensure_meal_periods(date.today())
|
||||||
lunch = periods[0]
|
lunch = periods[0]
|
||||||
|
|
||||||
r1 = upsert_response(user["id"], lunch["id"], "yes")
|
r1 = upsert_response(user["id"], lunch["id"], "yes")
|
||||||
r2 = upsert_response(user["id"], lunch["id"], "yes")
|
r2 = upsert_response(user["id"], lunch["id"], "yes")
|
||||||
assert r1["status"] == r2["status"]
|
assert r1["status"] == r2["status"]
|
||||||
assert r1["changed_at"] == r2["changed_at"]
|
assert r1["changed_at"] == r2["changed_at"]
|
||||||
|
|
||||||
|
|
||||||
class TestDashboardData:
|
class TestHouseholdScoping:
|
||||||
def test_dashboard_includes_all_users(self, app):
|
def test_users_isolation(self, app):
|
||||||
|
"""Users in different households should not see each other."""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from models import create_user
|
hh1 = create_household("House Alpha")
|
||||||
|
hh2 = create_household("House Beta")
|
||||||
|
u1 = create_user("alice_a", "h", hh1["id"], is_admin=True)
|
||||||
|
u2 = create_user("bob_b", "h", hh2["id"], is_admin=True)
|
||||||
|
|
||||||
create_user("u1", "h1", is_admin=True)
|
data1 = get_dashboard_data(date.today(), hh1["id"])
|
||||||
create_user("u2", "h2", is_admin=False)
|
data2 = get_dashboard_data(date.today(), hh2["id"])
|
||||||
|
|
||||||
data = get_dashboard_data(date.today())
|
assert len(data1["users"]) == 1
|
||||||
|
assert data1["users"][0]["username"] == "alice_a"
|
||||||
|
assert len(data2["users"]) == 1
|
||||||
|
assert data2["users"][0]["username"] == "bob_b"
|
||||||
|
|
||||||
|
def test_join_household_starts_fresh(self, app):
|
||||||
|
"""New joiner sees household members but no prior responses."""
|
||||||
|
with app.app_context():
|
||||||
|
hh = create_household("Shared")
|
||||||
|
u1 = create_user("alice", "h", hh["id"], is_admin=True)
|
||||||
|
# Alice responds
|
||||||
|
periods = ensure_meal_periods(date.today())
|
||||||
|
lunch = next(p for p in periods if p["meal_type"] == "lunch")
|
||||||
|
upsert_response(u1["id"], lunch["id"], "yes")
|
||||||
|
|
||||||
|
# Bob joins
|
||||||
|
u2 = create_user("bob", "h", hh["id"], is_admin=False)
|
||||||
|
data = get_dashboard_data(date.today(), hh["id"])
|
||||||
assert len(data["users"]) == 2
|
assert len(data["users"]) == 2
|
||||||
assert data["users"][0]["username"] == "u1"
|
# Bob's default status
|
||||||
assert data["users"][1]["username"] == "u2"
|
bob_resp = [u for u in data["users"] if u["username"] == "bob"][0]
|
||||||
|
assert bob_resp["responses"]["lunch"]["status"] == "not_answered"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDashboardData:
|
||||||
|
def test_dashboard_includes_all_household_users(self, app):
|
||||||
|
with app.app_context():
|
||||||
|
hh = create_household("DashHouse")
|
||||||
|
create_user("u1", "h1", hh["id"], is_admin=True)
|
||||||
|
create_user("u2", "h2", hh["id"], is_admin=False)
|
||||||
|
|
||||||
|
data = get_dashboard_data(date.today(), hh["id"])
|
||||||
|
assert len(data["users"]) == 2
|
||||||
|
usernames = {u["username"] for u in data["users"]}
|
||||||
|
assert usernames == {"u1", "u2"}
|
||||||
|
|
||||||
def test_dashboard_shows_responses(self, app):
|
def test_dashboard_shows_responses(self, app):
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from models import create_user
|
hh = create_household("RespHouse")
|
||||||
|
user = create_user("responder", "h", hh["id"], is_admin=True)
|
||||||
user = create_user("responder", "h", is_admin=True)
|
|
||||||
periods = ensure_meal_periods(date.today())
|
periods = ensure_meal_periods(date.today())
|
||||||
lunch = next(p for p in periods if p["meal_type"] == "lunch")
|
lunch = next(p for p in periods if p["meal_type"] == "lunch")
|
||||||
upsert_response(user["id"], lunch["id"], "yes")
|
upsert_response(user["id"], lunch["id"], "yes")
|
||||||
|
|
||||||
data = get_dashboard_data(date.today())
|
data = get_dashboard_data(date.today(), hh["id"])
|
||||||
resp = data["users"][0]["responses"]["lunch"]
|
resp = data["users"][0]["responses"]["lunch"]
|
||||||
assert resp["status"] == "yes"
|
assert resp["status"] == "yes"
|
||||||
|
|
||||||
def test_dashboard_defaults_not_answered(self, app):
|
def test_dashboard_defaults_not_answered(self, app):
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from models import create_user
|
hh = create_household("SilentHouse")
|
||||||
|
create_user("silent", "h", hh["id"], is_admin=False)
|
||||||
|
|
||||||
create_user("silent", "h", is_admin=False)
|
data = get_dashboard_data(date.today(), hh["id"])
|
||||||
|
|
||||||
data = get_dashboard_data(date.today())
|
|
||||||
resp = data["users"][0]["responses"]["dinner"]
|
resp = data["users"][0]["responses"]["dinner"]
|
||||||
assert resp["status"] == "not_answered"
|
assert resp["status"] == "not_answered"
|
||||||
assert resp["changed_at"] is None
|
assert resp["changed_at"] is None
|
||||||
|
|||||||
+23
-4
@@ -15,12 +15,34 @@ class TestDashboardRoute:
|
|||||||
|
|
||||||
def test_dashboard_renders_users(self, auth_client):
|
def test_dashboard_renders_users(self, auth_client):
|
||||||
resp = auth_client.get("/dashboard")
|
resp = auth_client.get("/dashboard")
|
||||||
# bob and alice were registered
|
# bob and alice were registered in the same household
|
||||||
assert b"alice" in resp.data
|
assert b"alice" in resp.data
|
||||||
assert b"bob" in resp.data
|
assert b"bob" in resp.data
|
||||||
assert b"Lunch" in resp.data
|
assert b"Lunch" in resp.data
|
||||||
assert b"Dinner" in resp.data
|
assert b"Dinner" in resp.data
|
||||||
|
|
||||||
|
def test_dashboard_hides_other_households(self, auth_client):
|
||||||
|
"""Bob should NOT see users from other households."""
|
||||||
|
# Register another user in a different household
|
||||||
|
auth_client.get("/logout")
|
||||||
|
auth_client.post("/register", data={
|
||||||
|
"username": "stranger",
|
||||||
|
"password": "stranger1",
|
||||||
|
"confirm": "stranger1",
|
||||||
|
"household_action": "create",
|
||||||
|
"new_household_name": "Stranger House",
|
||||||
|
})
|
||||||
|
# Log back in as bob (Test Household)
|
||||||
|
auth_client.get("/logout")
|
||||||
|
auth_client.post("/login", data={
|
||||||
|
"username": "bob",
|
||||||
|
"password": "bobpass",
|
||||||
|
})
|
||||||
|
resp = auth_client.get("/dashboard")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"stranger" not in resp.data
|
||||||
|
assert b"Stranger House" not in resp.data
|
||||||
|
|
||||||
|
|
||||||
class TestRespondRoute:
|
class TestRespondRoute:
|
||||||
def test_respond_sets_status(self, auth_client):
|
def test_respond_sets_status(self, auth_client):
|
||||||
@@ -29,16 +51,13 @@ class TestRespondRoute:
|
|||||||
"status": "yes",
|
"status": "yes",
|
||||||
}, follow_redirects=True)
|
}, follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
# After responding, the "Home" chip should be active
|
|
||||||
assert b"Home" in resp.data
|
assert b"Home" in resp.data
|
||||||
|
|
||||||
def test_respond_changes_mind(self, auth_client):
|
def test_respond_changes_mind(self, auth_client):
|
||||||
# First respond yes
|
|
||||||
auth_client.post("/respond", data={
|
auth_client.post("/respond", data={
|
||||||
"meal_type": "dinner",
|
"meal_type": "dinner",
|
||||||
"status": "yes",
|
"status": "yes",
|
||||||
})
|
})
|
||||||
# Then change to no
|
|
||||||
resp = auth_client.post("/respond", data={
|
resp = auth_client.post("/respond", data={
|
||||||
"meal_type": "dinner",
|
"meal_type": "dinner",
|
||||||
"status": "no",
|
"status": "no",
|
||||||
|
|||||||
Reference in New Issue
Block a user