From ae95841b5bb602831d788cf1a8eb021d576ff375 Mon Sep 17 00:00:00 2001 From: agentbox Date: Sat, 1 Aug 2026 10:12:21 +0000 Subject: [PATCH] feat: public sharing links for households MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add hard-to-guess public URLs (/public/) that show a read-only meal status dashboard for each household. Only people with the link can see the data — no login required. - models.py: public_token + public_enabled columns on households, get_household_by_public_token(), generate_public_token(), set_public_enabled() - meals.py: /public/ route, /admin/public-link for generate/disable/enable/regenerate - templates/public.html: clean read-only public dashboard - templates/public-not-found.html: 404 for invalid/disabled links - templates/dashboard.html: admin UI with copy/regenerate/disable - static/style.css: public page and URL box styles - i18n.py: 18 new translation keys (en/fr/de) --- i18n.py | 78 +++++++++++++++++++++++++++++++++ meals.py | 62 ++++++++++++++++++++++++++ models.py | 37 ++++++++++++++++ static/style.css | 30 +++++++++++++ templates/dashboard.html | 39 +++++++++++++++++ templates/public-not-found.html | 7 +++ templates/public.html | 77 ++++++++++++++++++++++++++++++++ 7 files changed, 330 insertions(+) create mode 100644 templates/public-not-found.html create mode 100644 templates/public.html diff --git a/i18n.py b/i18n.py index 3f0b4ac..5150420 100644 --- a/i18n.py +++ b/i18n.py @@ -388,6 +388,84 @@ TRANSLATIONS: dict[str, dict[str, str]] = { "fr": "Fuseau horaire mis à jour.", "de": "Zeitzone aktualisiert.", }, + + # ── Public sharing ── + "Public Sharing Link": { + "fr": "Lien de partage public", + "de": "Öffentlicher Freigabelink", + }, + "Share a read-only view of your household's meal status. The link is hard to guess — only people with it can see the data.": { + "fr": "Partagez une vue en lecture seule du statut des repas de votre foyer. Le lien est difficile à deviner — seules les personnes qui le possèdent peuvent voir les données.", + "de": "Teilen Sie eine schreibgeschützte Ansicht des Essensstatus Ihres Haushalts. Der Link ist schwer zu erraten — nur Personen mit dem Link können die Daten sehen.", + }, + "Copy": { + "fr": "Copier", + "de": "Kopieren", + }, + "Regenerate": { + "fr": "Régénérer", + "de": "Neu generieren", + }, + "Disable": { + "fr": "Désactiver", + "de": "Deaktivieren", + }, + "Enable": { + "fr": "Activer", + "de": "Aktivieren", + }, + "Public link is currently disabled.": { + "fr": "Le lien public est actuellement désactivé.", + "de": "Der öffentliche Link ist derzeit deaktiviert.", + }, + "No public link yet. Generate one to share.": { + "fr": "Pas encore de lien public. Générez-en un pour partager.", + "de": "Noch kein öffentlicher Link. Generieren Sie einen zum Teilen.", + }, + "Generate Link": { + "fr": "Générer un lien", + "de": "Link generieren", + }, + "Public link generated!": { + "fr": "Lien public généré !", + "de": "Öffentlicher Link generiert!", + }, + "Public link disabled.": { + "fr": "Lien public désactivé.", + "de": "Öffentlicher Link deaktiviert.", + }, + "Public link enabled.": { + "fr": "Lien public activé.", + "de": "Öffentlicher Link aktiviert.", + }, + "Only admins can manage the public link.": { + "fr": "Seuls les administrateurs peuvent gérer le lien public.", + "de": "Nur Administratoren können den öffentlichen Link verwalten.", + }, + "Unknown action.": { + "fr": "Action inconnue.", + "de": "Unbekannte Aktion.", + }, + "Link Not Found": { + "fr": "Lien introuvable", + "de": "Link nicht gefunden", + }, + "This sharing link is invalid or has been disabled. Please ask the household admin for a new link.": { + "fr": "Ce lien de partage est invalide ou a été désactivé. Veuillez demander un nouveau lien à l'administrateur du foyer.", + "de": "Dieser Freigabelink ist ungültig oder wurde deaktiviert. Bitte fragen Sie den Haushaltsadministrator nach einem neuen Link.", + }, + "Not Found — Meal Tracker": { + "fr": "Introuvable — Suivi des Repas", + "de": "Nicht gefunden — Mahlzeiten-Tracker", + }, + "No users yet.": { + "fr": "Pas encore d'utilisateurs.", + "de": "Noch keine Benutzer.", + }, + "Copied!": { + "fr": "Copié !", + "de": "Kopiert!", + }, } diff --git a/meals.py b/meals.py index 482c38a..88fed15 100644 --- a/meals.py +++ b/meals.py @@ -30,6 +30,9 @@ from models import ( get_all_households, get_users_with_ntfy, update_reminder_sent, + get_household_by_public_token, + generate_public_token, + set_public_enabled, ) from i18n import t from ntfy import send_test_notification, send_meal_reminder @@ -369,6 +372,65 @@ def auto_login(token): return redirect(target) +# ── Public sharing ─────────────────────────────────────────────────────────── + +@meals_bp.route("/public/") +def public_dashboard(token): + """Public, read-only dashboard showing the household's meal status. + + No login required — the token is a hard-to-guess secret. Query params: + - date: YYYY-MM-DD (default: today) + """ + household = get_household_by_public_token(token) + if household is None: + return render_template("public-not-found.html"), 404 + + today = date.today() + date_str = request.args.get("date") + if date_str: + try: + today = datetime.strptime(date_str, "%Y-%m-%d").date() + except ValueError: + pass + + data = get_dashboard_data(today, household["id"]) + return render_template( + "public.html", + dashboard=data, + household=household, + viewing_date=today, + today=date.today(), + prev_date=today - timedelta(days=1), + next_date=today + timedelta(days=1), + ) + + +@meals_bp.route("/admin/public-link", methods=["POST"]) +@login_required +def admin_public_link(): + """Admin enables/disables or regenerates the public sharing link.""" + if not session["is_admin"]: + flash(t("Only admins can manage the public link."), "error") + return redirect(url_for("meals.dashboard")) + + action = request.form.get("action", "").strip() + household_id = session["household_id"] + + if action == "generate": + token = generate_public_token(household_id) + flash(t("Public link generated!"), "success") + elif action == "disable": + set_public_enabled(household_id, False) + flash(t("Public link disabled."), "success") + elif action == "enable": + set_public_enabled(household_id, True) + flash(t("Public link enabled."), "success") + else: + flash(t("Unknown action."), "error") + + return redirect(url_for("meals.dashboard")) + + # ── Cron tick (called by meal-cron container) ───────────────────────────────── REMINDER_HOURS = {"lunch": 10, "dinner": 16} diff --git a/models.py b/models.py index 0c0e538..b50568d 100644 --- a/models.py +++ b/models.py @@ -86,6 +86,10 @@ def init_db() -> None: db.execute("ALTER TABLE households ADD COLUMN last_lunch_reminder TEXT DEFAULT ''") if "last_dinner_reminder" not in hh_cols: db.execute("ALTER TABLE households ADD COLUMN last_dinner_reminder TEXT DEFAULT ''") + if "public_token" not in hh_cols: + db.execute("ALTER TABLE households ADD COLUMN public_token TEXT DEFAULT ''") + if "public_enabled" not in hh_cols: + db.execute("ALTER TABLE households ADD COLUMN public_enabled INTEGER DEFAULT 1") # ── Households ──────────────────────────────────────────────────────────────── @@ -156,6 +160,39 @@ def update_reminder_sent(household_id: int, meal_type: str, date_str: str) -> No (date_str, household_id)) +# ── Public sharing ─────────────────────────────────────────────────────────── + +def get_household_by_public_token(token: str) -> dict | None: + """Look up a household by its public sharing token.""" + with get_db() as db: + row = db.execute( + "SELECT * FROM households WHERE public_token = ? AND public_enabled = 1", + (token,), + ).fetchone() + return dict(row) if row else None + + +def generate_public_token(household_id: int) -> str: + """Generate a new hard-to-guess public token for a household.""" + import secrets + token = secrets.token_urlsafe(32) + with get_db() as db: + db.execute( + "UPDATE households SET public_token = ?, public_enabled = 1 WHERE id = ?", + (token, household_id), + ) + return token + + +def set_public_enabled(household_id: int, enabled: bool) -> None: + """Enable or disable the public sharing link for a household.""" + with get_db() as db: + db.execute( + "UPDATE households SET public_enabled = ? WHERE id = ?", + (int(enabled), household_id), + ) + + # ── Users ───────────────────────────────────────────────────────────────────── def get_user_by_id(user_id: int) -> dict | None: diff --git a/static/style.css b/static/style.css index d1f7abf..0169e2d 100644 --- a/static/style.css +++ b/static/style.css @@ -257,6 +257,36 @@ body { .lang-active { background: var(--primary); color: #fff; } .lang-active:hover { background: #0d47a1; color: #fff; } +/* ── Public page ── */ +.public-page .navbar { + justify-content: space-between; +} +.public-heading { + margin-bottom: 16px; +} +.public-footer { + text-align: center; + color: var(--muted); + font-size: 0.8rem; + margin-top: 32px; +} +.public-url-box { + display: flex; + align-items: center; + gap: 8px; + background: #f5f5f5; + border: 1px solid var(--border); + border-radius: 4px; + padding: 6px 10px; + margin-top: 8px; + word-break: break-all; +} +.public-url-box code { + flex: 1; + font-size: 0.8rem; + color: var(--text); +} + /* ── Responsive ── */ @media (max-width: 600px) { .navbar { diff --git a/templates/dashboard.html b/templates/dashboard.html index d6afcc6..adef25a 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -114,6 +114,27 @@ {% if is_admin %} +
+

🔗 {{ t('Public Sharing Link') }}

+

{{ t('Share a read-only view of your household\'s meal status. The link is hard to guess — only people with it can see the data.') }}

+ {% if household.public_token and household.public_enabled %} +
+ {{ request.host_url }}public/{{ household.public_token }} + +
+
+ + +
+ {% elif household.public_token and not household.public_enabled %} +

{{ t('Public link is currently disabled.') }}

+ + {% else %} +

{{ t('No public link yet. Generate one to share.') }}

+ + {% endif %} +
+

🕐 {{ t('Household Timezone') }}

{{ t('Used to send reminders at 10:00 (lunch) and 16:00 (dinner) local time.') }}

@@ -146,4 +167,22 @@ + + {% endblock %} diff --git a/templates/public-not-found.html b/templates/public-not-found.html new file mode 100644 index 0000000..16279cb --- /dev/null +++ b/templates/public-not-found.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block title %}{{ t('Not Found — Meal Tracker') }}{% endblock %} +{% block content %} +

🔗 {{ t('Link Not Found') }}

+

{{ t("This sharing link is invalid or has been disabled. Please ask the household admin for a new link.") }}

+

{{ t('Log in') }}

+{% endblock %} diff --git a/templates/public.html b/templates/public.html new file mode 100644 index 0000000..8dcd2ae --- /dev/null +++ b/templates/public.html @@ -0,0 +1,77 @@ + + + + + + + {{ household.name }} — {{ t('Meal Tracker') }} + + + + + +
+

{{ t("Today's Meals") }}

+ +
+ {{ t('◀ Prev') }} + {{ viewing_date.isoformat() }} + {{ t('Next ▶') }} + {% if viewing_date != today %} + Today + {% endif %} +
+ + {% if not dashboard.users %} +

{{ t('No users yet.') }}

+ {% else %} +
+ + + + + {% for period in dashboard.periods %} + + {% endfor %} + + + + {% for user in dashboard.users %} + + + {% for period in dashboard.periods %} + {% set resp = user.responses[period.meal_type] %} + + {% endfor %} + + {% endfor %} + +
{{ t('User') }}{{ t(period.meal_type|capitalize) }}
+ {{ user.username }} + {% if user.is_admin %}{{ t('admin') }}{% endif %} + + {{ t('Home') if resp.status == 'yes' else (t('Out') if resp.status == 'no' else t('—')) }} + {% if resp.changed_at %} +
↻ {{ resp.changed_at[11:16] }}
+ {% endif %} +
+
+ {% endif %} + + +
+ + + +