Add account deletion, admin user removal, household deletion, grayed-out other-user chips (47 tests)
This commit is contained in:
@@ -12,7 +12,17 @@ from flask import (
|
|||||||
flash,
|
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__)
|
meals_bp = Blueprint("meals", __name__)
|
||||||
|
|
||||||
@@ -64,6 +74,8 @@ def dashboard():
|
|||||||
today=date.today(),
|
today=date.today(),
|
||||||
prev_date=today - timedelta(days=1),
|
prev_date=today - timedelta(days=1),
|
||||||
next_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(),
|
today=date.today(),
|
||||||
prev_date=target_date - timedelta(days=1),
|
prev_date=target_date - timedelta(days=1),
|
||||||
next_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,
|
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/<int:user_id>", 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"))
|
||||||
|
|||||||
@@ -111,6 +111,19 @@ def count_household_users(household_id: int) -> int:
|
|||||||
).fetchone()[0]
|
).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 ─────────────────────────────────────────────────────────────────────
|
# ── Users ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_user_by_id(user_id: int) -> dict | None:
|
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)
|
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]:
|
def get_household_users(household_id: int) -> list[dict]:
|
||||||
"""Return all users in a given household."""
|
"""Return all users in a given household."""
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
|
|||||||
@@ -212,3 +212,23 @@ body {
|
|||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
|
|
||||||
.empty-state { text-align: center; color: var(--muted); margin: 40px 0; }
|
.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; }
|
||||||
|
|||||||
@@ -27,40 +27,54 @@
|
|||||||
{% for period in dashboard.periods %}
|
{% for period in dashboard.periods %}
|
||||||
<th class="meal-col">{{ period.meal_type|capitalize }}</th>
|
<th class="meal-col">{{ period.meal_type|capitalize }}</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if is_admin %}
|
||||||
|
<th class="action-col"></th>
|
||||||
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for user in dashboard.users %}
|
{% for user in dashboard.users %}
|
||||||
|
{% set is_me = user.id == current_user_id %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="user-cell">{{ user.username }}</td>
|
<td class="user-cell">
|
||||||
|
{{ user.username }}
|
||||||
|
{% if user.is_admin %}<span class="admin-tag">admin</span>{% endif %}
|
||||||
|
{% if is_me %}<span class="you-tag">you</span>{% endif %}
|
||||||
|
</td>
|
||||||
{% for period in dashboard.periods %}
|
{% for period in dashboard.periods %}
|
||||||
{% set resp = user.responses[period.meal_type] %}
|
{% set resp = user.responses[period.meal_type] %}
|
||||||
<td class="response-cell status-{{ resp.status }}{% if resp.changed_at %} changed-mind{% endif %}">
|
<td class="response-cell status-{{ resp.status }}{% if resp.changed_at %} changed-mind{% endif %}">
|
||||||
{% if viewing_date >= today or session.is_admin %}
|
{% if is_me and viewing_date >= today %}
|
||||||
<form method="POST" action="{{ url_for('meals.respond') }}" class="respond-form">
|
<form method="POST" action="{{ url_for('meals.respond') }}" class="respond-form">
|
||||||
<input type="hidden" name="date" value="{{ viewing_date.isoformat() }}">
|
<input type="hidden" name="date" value="{{ viewing_date.isoformat() }}">
|
||||||
<input type="hidden" name="meal_type" value="{{ period.meal_type }}">
|
<input type="hidden" name="meal_type" value="{{ period.meal_type }}">
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button name="status" value="yes"
|
<button name="status" value="yes"
|
||||||
class="chip chip-yes {% if resp.status == 'yes' %}active{% endif %}"
|
class="chip chip-yes {% if resp.status == 'yes' %}active{% endif %}"
|
||||||
{% if viewing_date < today and not session.is_admin %}disabled{% endif %}
|
|
||||||
>Home</button>
|
>Home</button>
|
||||||
<button name="status" value="no"
|
<button name="status" value="no"
|
||||||
class="chip chip-no {% if resp.status == 'no' %}active{% endif %}"
|
class="chip chip-no {% if resp.status == 'no' %}active{% endif %}"
|
||||||
{% if viewing_date < today and not session.is_admin %}disabled{% endif %}
|
|
||||||
>Out</button>
|
>Out</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% if resp.changed_at %}
|
{% if resp.changed_at %}
|
||||||
<div class="changed-badge" title="Changed mind at {{ resp.changed_at }}">↻ {{ resp.changed_at[11:16] }}</div>
|
<div class="changed-badge" title="Changed mind at {{ resp.changed_at }}">↻ {{ resp.changed_at[11:16] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif resp.status == 'not_answered' %}
|
|
||||||
<span class="muted">—</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge badge-{{ resp.status }}">{{ 'Home' if resp.status == 'yes' else 'Out' }}</span>
|
<span class="badge badge-{{ resp.status }}">{{ 'Home' if resp.status == 'yes' else ('Out' if resp.status == 'no' else '—') }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if is_admin and not is_me and not user.is_admin %}
|
||||||
|
<td class="action-cell">
|
||||||
|
<form method="POST" action="{{ url_for('meals.admin_remove_user', user_id=user.id) }}"
|
||||||
|
onsubmit="return confirm('Remove {{ user.username }} from this household?');">
|
||||||
|
<button class="btn btn-sm btn-danger">Remove</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{% elif is_admin %}
|
||||||
|
<td class="action-cell"></td>
|
||||||
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -68,4 +82,28 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="manage-section">
|
||||||
|
<details class="manage-details">
|
||||||
|
<summary>⚙ Account Settings</summary>
|
||||||
|
<div class="manage-forms">
|
||||||
|
<form method="POST" action="{{ url_for('meals.delete_account') }}"
|
||||||
|
onsubmit="return confirm('Delete YOUR account? This cannot be undone.');" class="manage-form">
|
||||||
|
<h4>Delete My Account</h4>
|
||||||
|
<input type="password" name="password" placeholder="Enter your password to confirm" required>
|
||||||
|
<button class="btn btn-danger">Delete My Account</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
<form method="POST" action="{{ url_for('meals.admin_delete_household') }}"
|
||||||
|
onsubmit="return confirm('Delete the ENTIRE household and ALL members? This cannot be undone.');" class="manage-form">
|
||||||
|
<h4>Delete Household</h4>
|
||||||
|
<p class="hint">This will remove all users and all data in this household.</p>
|
||||||
|
<input type="password" name="password" placeholder="Enter your password to confirm" required>
|
||||||
|
<button class="btn btn-danger">Delete Household</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -89,3 +89,100 @@ class TestHistoryRoute:
|
|||||||
resp = auth_client.get(f"/history?date={past.isoformat()}")
|
resp = auth_client.get(f"/history?date={past.isoformat()}")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"History" in resp.data
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user