Add hard-to-guess public URLs (/public/<token>) 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/<token> 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)
This commit is contained in:
@@ -388,6 +388,84 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
|
|||||||
"fr": "Fuseau horaire mis à jour.",
|
"fr": "Fuseau horaire mis à jour.",
|
||||||
"de": "Zeitzone aktualisiert.",
|
"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!",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ from models import (
|
|||||||
get_all_households,
|
get_all_households,
|
||||||
get_users_with_ntfy,
|
get_users_with_ntfy,
|
||||||
update_reminder_sent,
|
update_reminder_sent,
|
||||||
|
get_household_by_public_token,
|
||||||
|
generate_public_token,
|
||||||
|
set_public_enabled,
|
||||||
)
|
)
|
||||||
from i18n import t
|
from i18n import t
|
||||||
from ntfy import send_test_notification, send_meal_reminder
|
from ntfy import send_test_notification, send_meal_reminder
|
||||||
@@ -369,6 +372,65 @@ def auto_login(token):
|
|||||||
return redirect(target)
|
return redirect(target)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public sharing ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@meals_bp.route("/public/<token>")
|
||||||
|
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) ─────────────────────────────────
|
# ── Cron tick (called by meal-cron container) ─────────────────────────────────
|
||||||
|
|
||||||
REMINDER_HOURS = {"lunch": 10, "dinner": 16}
|
REMINDER_HOURS = {"lunch": 10, "dinner": 16}
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ def init_db() -> None:
|
|||||||
db.execute("ALTER TABLE households ADD COLUMN last_lunch_reminder TEXT DEFAULT ''")
|
db.execute("ALTER TABLE households ADD COLUMN last_lunch_reminder TEXT DEFAULT ''")
|
||||||
if "last_dinner_reminder" not in hh_cols:
|
if "last_dinner_reminder" not in hh_cols:
|
||||||
db.execute("ALTER TABLE households ADD COLUMN last_dinner_reminder TEXT DEFAULT ''")
|
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 ────────────────────────────────────────────────────────────────
|
# ── Households ────────────────────────────────────────────────────────────────
|
||||||
@@ -156,6 +160,39 @@ def update_reminder_sent(household_id: int, meal_type: str, date_str: str) -> No
|
|||||||
(date_str, household_id))
|
(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 ─────────────────────────────────────────────────────────────────────
|
# ── Users ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_user_by_id(user_id: int) -> dict | None:
|
def get_user_by_id(user_id: int) -> dict | None:
|
||||||
|
|||||||
@@ -257,6 +257,36 @@ body {
|
|||||||
.lang-active { background: var(--primary); color: #fff; }
|
.lang-active { background: var(--primary); color: #fff; }
|
||||||
.lang-active:hover { background: #0d47a1; 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 ── */
|
/* ── Responsive ── */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.navbar {
|
.navbar {
|
||||||
|
|||||||
@@ -114,6 +114,27 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
|
<form method="POST" action="{{ url_for('meals.admin_public_link') }}" class="manage-form">
|
||||||
|
<h4>🔗 {{ t('Public Sharing Link') }}</h4>
|
||||||
|
<p class="hint">{{ 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.') }}</p>
|
||||||
|
{% if household.public_token and household.public_enabled %}
|
||||||
|
<div class="public-url-box">
|
||||||
|
<code>{{ request.host_url }}public/{{ household.public_token }}</code>
|
||||||
|
<button type="button" class="btn btn-sm" onclick="copyPublicUrl(this)">{{ t('Copy') }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row" style="margin-top: 8px;">
|
||||||
|
<button type="submit" name="action" value="generate" class="btn btn-sm">{{ t('Regenerate') }}</button>
|
||||||
|
<button type="submit" name="action" value="disable" class="btn btn-sm btn-danger">{{ t('Disable') }}</button>
|
||||||
|
</div>
|
||||||
|
{% elif household.public_token and not household.public_enabled %}
|
||||||
|
<p class="hint">{{ t('Public link is currently disabled.') }}</p>
|
||||||
|
<button type="submit" name="action" value="enable" class="btn btn-sm">{{ t('Enable') }}</button>
|
||||||
|
{% else %}
|
||||||
|
<p class="hint">{{ t('No public link yet. Generate one to share.') }}</p>
|
||||||
|
<button type="submit" name="action" value="generate" class="btn btn-sm">{{ t('Generate Link') }}</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('meals.admin_set_timezone') }}" class="manage-form">
|
<form method="POST" action="{{ url_for('meals.admin_set_timezone') }}" class="manage-form">
|
||||||
<h4>🕐 {{ t('Household Timezone') }}</h4>
|
<h4>🕐 {{ t('Household Timezone') }}</h4>
|
||||||
<p class="hint">{{ t('Used to send reminders at 10:00 (lunch) and 16:00 (dinner) local time.') }}</p>
|
<p class="hint">{{ t('Used to send reminders at 10:00 (lunch) and 16:00 (dinner) local time.') }}</p>
|
||||||
@@ -146,4 +167,22 @@
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyPublicUrl(btn) {
|
||||||
|
var code = btn.parentElement.querySelector('code');
|
||||||
|
if (!code) return;
|
||||||
|
navigator.clipboard.writeText(code.textContent).then(function() {
|
||||||
|
var orig = btn.textContent;
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
btn.disabled = true;
|
||||||
|
setTimeout(function() { btn.textContent = orig; btn.disabled = false; }, 2000);
|
||||||
|
}).catch(function() {
|
||||||
|
var range = document.createRange();
|
||||||
|
range.selectNode(code);
|
||||||
|
window.getSelection().removeAllRanges();
|
||||||
|
window.getSelection().addRange(range);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ t('Not Found — Meal Tracker') }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>🔗 {{ t('Link Not Found') }}</h2>
|
||||||
|
<p class="empty-state">{{ t("This sharing link is invalid or has been disabled. Please ask the household admin for a new link.") }}</p>
|
||||||
|
<p class="empty-state" style="margin-top: 2rem;"><a href="{{ url_for('auth.login') }}">{{ t('Log in') }}</a></p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="{{ lang }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>{{ household.name }} — {{ t('Meal Tracker') }}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body class="public-page">
|
||||||
|
<nav class="navbar">
|
||||||
|
<span class="brand">🍽 {{ household.name }}</span>
|
||||||
|
<span class="nav-household">{{ viewing_date.isoformat() }}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<h2 class="public-heading">{{ t("Today's Meals") }}</h2>
|
||||||
|
|
||||||
|
<div class="date-nav">
|
||||||
|
<a class="btn btn-sm" href="?date={{ prev_date.isoformat() }}">{{ t('◀ Prev') }}</a>
|
||||||
|
<span class="date-display">{{ viewing_date.isoformat() }}</span>
|
||||||
|
<a class="btn btn-sm" href="?date={{ next_date.isoformat() }}">{{ t('Next ▶') }}</a>
|
||||||
|
{% if viewing_date != today %}
|
||||||
|
<a class="btn btn-sm" href="?">Today</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not dashboard.users %}
|
||||||
|
<p class="empty-state">{{ t('No users yet.') }}</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="meal-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('User') }}</th>
|
||||||
|
{% for period in dashboard.periods %}
|
||||||
|
<th class="meal-col">{{ t(period.meal_type|capitalize) }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in dashboard.users %}
|
||||||
|
<tr>
|
||||||
|
<td class="user-cell">
|
||||||
|
{{ user.username }}
|
||||||
|
{% if user.is_admin %}<span class="admin-tag">{{ t('admin') }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
{% for period in dashboard.periods %}
|
||||||
|
{% set resp = user.responses[period.meal_type] %}
|
||||||
|
<td class="response-cell status-{{ resp.status }}{% if resp.changed_at %} changed-mind{% endif %}">
|
||||||
|
<span class="badge badge-{{ resp.status }}">{{ t('Home') if resp.status == 'yes' else (t('Out') if resp.status == 'no' else t('—')) }}</span>
|
||||||
|
{% if resp.changed_at %}
|
||||||
|
<div class="changed-badge" title="{{ t('Changed mind at {time}', time=resp.changed_at) }}">↻ {{ resp.changed_at[11:16] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p class="public-footer">
|
||||||
|
{{ t('Meal Tracker') }}
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="lang-footer">
|
||||||
|
{% for code, name in {'en': 'English', 'fr': 'Français', 'de': 'Deutsch'}.items() %}
|
||||||
|
<a href="?lang={{ code }}"
|
||||||
|
class="lang-link{% if lang == code %} lang-active{% endif %}"
|
||||||
|
aria-label="{{ name }}">{{ code.upper() }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user