From 188aaf66a093691bbc15cac8f336246bd54a4d24 Mon Sep 17 00:00:00 2001 From: agentbox Date: Fri, 31 Jul 2026 22:12:13 +0000 Subject: [PATCH 1/3] Add account deletion, admin user removal, household deletion, grayed-out other-user chips (47 tests) --- meals.py | 91 ++++++++++++++++++++++++++++++++++++- models.py | 20 +++++++++ static/style.css | 20 +++++++++ templates/dashboard.html | 52 ++++++++++++++++++--- tests/test_routes.py | 97 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 272 insertions(+), 8 deletions(-) diff --git a/meals.py b/meals.py index 77e98bc..2a601ab 100644 --- a/meals.py +++ b/meals.py @@ -12,7 +12,17 @@ from flask import ( flash, ) -from models import get_dashboard_data, upsert_response, ensure_meal_periods +from werkzeug.security import check_password_hash + +from models import ( + get_dashboard_data, + upsert_response, + ensure_meal_periods, + get_user_by_id, + delete_user, + delete_household, + count_household_users, +) meals_bp = Blueprint("meals", __name__) @@ -64,6 +74,8 @@ def dashboard(): today=date.today(), prev_date=today - timedelta(days=1), next_date=today + timedelta(days=1), + current_user_id=session["user_id"], + is_admin=session["is_admin"], ) @@ -119,5 +131,82 @@ def history(): today=date.today(), prev_date=target_date - timedelta(days=1), next_date=target_date + timedelta(days=1), + current_user_id=session["user_id"], + is_admin=session["is_admin"], history_mode=True, ) + + +# ── Account / household management ──────────────────────────────────────────── + +@meals_bp.route("/delete-account", methods=["POST"]) +@login_required +def delete_account(): + """Delete the currently logged-in user's own account.""" + password = request.form.get("password", "") + user = get_user_by_id(session["user_id"]) + + if not check_password_hash(user["password_hash"], password): + flash("Incorrect password. Account not deleted.", "error") + return redirect(url_for("meals.dashboard")) + + household_id = session["household_id"] + is_admin = session["is_admin"] + user_count = count_household_users(household_id) + + # If admin is the last user, delete the household too + if is_admin and user_count == 1: + delete_household(household_id) + flash("Your account and household have been deleted.", "success") + else: + delete_user(session["user_id"]) + flash("Your account has been deleted.", "success") + + # Clear auth keys but keep the session so flash survives the redirect + for key in ("user_id", "username", "is_admin", "household_id", "household_name"): + session.pop(key, None) + return redirect(url_for("auth.login")) + + +@meals_bp.route("/admin/remove-user/", methods=["POST"]) +@login_required +def admin_remove_user(user_id): + """Admin removes a user from the household.""" + if not session["is_admin"]: + flash("Only admins can remove users.", "error") + return redirect(url_for("meals.dashboard")) + + user = get_user_by_id(user_id) + if user is None or user["household_id"] != session["household_id"]: + flash("User not found in your household.", "error") + return redirect(url_for("meals.dashboard")) + + if user["is_admin"]: + flash("Cannot remove the admin. Transfer admin role first or delete the household.", "error") + return redirect(url_for("meals.dashboard")) + + delete_user(user_id) + flash(f"User '{user['username']}' has been removed.", "success") + return redirect(url_for("meals.dashboard")) + + +@meals_bp.route("/admin/delete-household", methods=["POST"]) +@login_required +def admin_delete_household(): + """Admin deletes the entire household.""" + if not session["is_admin"]: + flash("Only admins can delete the household.", "error") + return redirect(url_for("meals.dashboard")) + + password = request.form.get("password", "") + user = get_user_by_id(session["user_id"]) + + if not check_password_hash(user["password_hash"], password): + flash("Incorrect password. Household not deleted.", "error") + return redirect(url_for("meals.dashboard")) + + delete_household(session["household_id"]) + flash("Household and all members have been deleted.", "success") + for key in ("user_id", "username", "is_admin", "household_id", "household_name"): + session.pop(key, None) + return redirect(url_for("auth.login")) diff --git a/models.py b/models.py index d56e173..d463358 100644 --- a/models.py +++ b/models.py @@ -111,6 +111,19 @@ def count_household_users(household_id: int) -> int: ).fetchone()[0] +def delete_household(household_id: int) -> None: + """Delete a household, all its users, and all their responses.""" + with get_db() as db: + # Delete responses for all users in the household + db.execute( + "DELETE FROM responses WHERE user_id IN " + "(SELECT id FROM users WHERE household_id = ?)", + (household_id,), + ) + db.execute("DELETE FROM users WHERE household_id = ?", (household_id,)) + db.execute("DELETE FROM households WHERE id = ?", (household_id,)) + + # ── Users ───────────────────────────────────────────────────────────────────── def get_user_by_id(user_id: int) -> dict | None: @@ -143,6 +156,13 @@ def create_user(username: str, password_hash: str, household_id: int, return get_user_by_id(user_id) +def delete_user(user_id: int) -> None: + """Delete a user and all their responses.""" + with get_db() as db: + db.execute("DELETE FROM responses WHERE user_id = ?", (user_id,)) + db.execute("DELETE FROM users WHERE id = ?", (user_id,)) + + def get_household_users(household_id: int) -> list[dict]: """Return all users in a given household.""" with get_db() as db: diff --git a/static/style.css b/static/style.css index 096d365..243ea42 100644 --- a/static/style.css +++ b/static/style.css @@ -212,3 +212,23 @@ body { .muted { color: var(--muted); } .empty-state { text-align: center; color: var(--muted); margin: 40px 0; } + +/* ── User tags ── */ +.admin-tag { font-size: 0.65rem; font-weight: 600; color: var(--orange); background: var(--orange-bg); padding: 1px 6px; border-radius: 8px; margin-left: 4px; vertical-align: middle; } +.you-tag { font-size: 0.65rem; font-weight: 600; color: var(--primary); background: #e3f2fd; padding: 1px 6px; border-radius: 8px; margin-left: 4px; vertical-align: middle; } + +/* ── Action column ── */ +.action-col { width: 80px; } +.action-cell { vertical-align: middle; } +.btn-danger { background: var(--red); color: #fff; border-color: var(--red); font-size: 0.8rem; padding: 4px 10px; } +.btn-danger:hover { background: #b71c1c; } + +/* ── Manage section ── */ +.manage-section { margin-top: 24px; } +.manage-details { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; } +.manage-details summary { cursor: pointer; font-weight: 600; font-size: 0.9rem; } +.manage-forms { display: flex; gap: 24px; flex-wrap: wrap; margin-top: 12px; } +.manage-form { flex: 1; min-width: 220px; padding: 12px; border: 1px solid var(--border); border-radius: var(--radius); } +.manage-form h4 { margin-bottom: 8px; font-size: 0.9rem; } +.manage-form .hint { margin-bottom: 8px; } +.manage-form input[type="password"] { width: 100%; padding: 6px 10px; border: 1px solid var(--border); border-radius: 4px; margin-bottom: 8px; } diff --git a/templates/dashboard.html b/templates/dashboard.html index 5c35145..99fb1b0 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -27,40 +27,54 @@ {% for period in dashboard.periods %} {{ period.meal_type|capitalize }} {% endfor %} + {% if is_admin %} + + {% endif %} {% for user in dashboard.users %} + {% set is_me = user.id == current_user_id %} - {{ user.username }} + + {{ user.username }} + {% if user.is_admin %}admin{% endif %} + {% if is_me %}you{% endif %} + {% for period in dashboard.periods %} {% set resp = user.responses[period.meal_type] %} - {% if viewing_date >= today or session.is_admin %} + {% if is_me and viewing_date >= today %}
{% if resp.changed_at %}
↻ {{ resp.changed_at[11:16] }}
{% endif %} - {% elif resp.status == 'not_answered' %} - {% else %} - {{ 'Home' if resp.status == 'yes' else 'Out' }} + {{ 'Home' if resp.status == 'yes' else ('Out' if resp.status == 'no' else '—') }} {% endif %} {% endfor %} + {% if is_admin and not is_me and not user.is_admin %} + +
+ +
+ + {% elif is_admin %} + + {% endif %} {% endfor %} @@ -68,4 +82,28 @@ {% endif %} +
+
+ ⚙ Account Settings +
+
+

Delete My Account

+ + +
+ + {% if is_admin %} +
+

Delete Household

+

This will remove all users and all data in this household.

+ + +
+ {% endif %} +
+
+
+ {% endblock %} diff --git a/tests/test_routes.py b/tests/test_routes.py index cb60c60..aff92ac 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -89,3 +89,100 @@ class TestHistoryRoute: resp = auth_client.get(f"/history?date={past.isoformat()}") assert resp.status_code == 200 assert b"History" in resp.data + + +class TestDeleteAccount: + def test_delete_own_account(self, auth_client): + """Bob deletes his own account.""" + resp = auth_client.post("/delete-account", data={ + "password": "bobpass", + }, follow_redirects=True) + assert resp.status_code == 200 + assert b"has been deleted" in resp.data + # Bob should be logged out + resp2 = auth_client.get("/dashboard", follow_redirects=True) + assert b"Log In" in resp2.data + + def test_delete_own_account_wrong_password(self, auth_client): + """Delete fails with wrong password.""" + resp = auth_client.post("/delete-account", data={ + "password": "wrongpass", + }, follow_redirects=True) + assert b"Incorrect password" in resp.data + # Still logged in + resp2 = auth_client.get("/dashboard") + assert resp2.status_code == 200 + + +class TestAdminRemoveUser: + def test_admin_removes_user(self, admin_client): + """Admin removes a regular user.""" + # admin_client has admin as sole user — need another user to remove + admin_client.get("/logout") + # Register another user in admin's household + from models import get_household_by_name + hh = get_household_by_name("Admin Household") + admin_client.post("/register", data={ + "username": "extra", + "password": "extrapass", + "confirm": "extrapass", + "household_action": "join", + "household_id": str(hh["id"]), + }) + # admin_client logs back in as admin + admin_client.get("/logout") + admin_client.post("/login", data={ + "username": "admin", "password": "adminpass", + }) + # Remove the extra user + from models import get_user_by_username + extra = get_user_by_username("extra") + resp = admin_client.post(f"/admin/remove-user/{extra['id']}", follow_redirects=True) + assert b"has been removed" in resp.data + # Extra user should be gone + admin_client.get("/dashboard") + assert get_user_by_username("extra") is None + + def test_non_admin_cannot_remove(self, auth_client): + """Regular user (bob) cannot remove others.""" + resp = auth_client.post("/admin/remove-user/1", follow_redirects=True) + assert b"Only admins" in resp.data + + def test_admin_cannot_remove_admin(self, admin_client): + """Admin cannot remove themselves via remove-user.""" + resp = admin_client.post("/admin/remove-user/1", follow_redirects=True) + assert b"Cannot remove the admin" in resp.data + + +class TestAdminDeleteHousehold: + def test_admin_deletes_household(self, admin_client): + """Admin deletes entire household.""" + resp = admin_client.post("/admin/delete-household", data={ + "password": "adminpass", + }, follow_redirects=True) + assert resp.status_code == 200 + assert b"have been deleted" in resp.data + + def test_admin_delete_household_wrong_password(self, admin_client): + resp = admin_client.post("/admin/delete-household", data={ + "password": "wrong", + }, follow_redirects=True) + assert b"Incorrect password" in resp.data + + def test_non_admin_cannot_delete_household(self, auth_client): + resp = auth_client.post("/admin/delete-household", data={ + "password": "bobpass", + }, follow_redirects=True) + assert b"Only admins" in resp.data + + +class TestDashboardOtherUsersGrayedOut: + def test_other_users_buttons_are_disabled(self, auth_client): + """Bob sees alice's status as badges, not clickable buttons.""" + # Bob responds first so we can see his buttons + auth_client.post("/respond", data={ + "meal_type": "lunch", "status": "yes", + }, follow_redirects=True) + resp = auth_client.get("/dashboard") + # Alice's status should show as a badge (not a form button) + assert b"badge-yes" in resp.data or b"badge-not_answered" in resp.data From fd934c006d154908e76dcf79e10a7ff33e36b744 Mon Sep 17 00:00:00 2001 From: agentbox Date: Fri, 31 Jul 2026 22:14:55 +0000 Subject: [PATCH 2/3] Add Gitea Actions CI: run tests, package artifact zip on push --- .gitea/workflows/ci.yml | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..5bbc40f --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main] + +jobs: + test-and-package: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run tests + run: python -m pytest tests/ -v + + - name: Package artifact + run: | + zip -r meal-tracker.zip . \ + -x ".git/*" \ + -x ".gitea/*" \ + -x ".env" \ + -x "docker-compose.yml" \ + -x ".pytest_cache/*" \ + -x "__pycache__/*" \ + -x "meals.db" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: meal-tracker + path: meal-tracker.zip From bbd0f6a4c183d2422d01bece8dbf30f58728a00e Mon Sep 17 00:00:00 2001 From: agentbox Date: Fri, 31 Jul 2026 22:16:56 +0000 Subject: [PATCH 3/3] Fix CI: downgrade actions to v3-compatible versions for Gitea --- .gitea/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5bbc40f..8c9379a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -9,10 +9,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v3 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v4 with: python-version: "3.13" @@ -34,7 +34,7 @@ jobs: -x "meals.db" - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v3 with: name: meal-tracker path: meal-tracker.zip