Merge remote-tracking branch 'refs/remotes/origin/main'
CI / test-and-package (push) Successful in 34s

This commit is contained in:
Romane
2026-08-01 00:29:56 +02:00
6 changed files with 312 additions and 8 deletions
+40
View File
@@ -0,0 +1,40 @@
name: CI
on:
push:
branches: [main]
jobs:
test-and-package:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v4
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@v3
with:
name: meal-tracker
path: meal-tracker.zip
+90 -1
View File
@@ -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"))
+20
View File
@@ -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:
+20
View File
@@ -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; }
+45 -7
View File
@@ -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 %}
+97
View File
@@ -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