CI / test-and-package (push) Successful in 40s
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)
548 lines
18 KiB
Python
548 lines
18 KiB
Python
"""Lightweight i18n — French and German translations, browser-language detection."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from flask import request, session, g
|
|
|
|
# ── Supported languages ───────────────────────────────────────────────────────
|
|
LANGUAGES = {
|
|
"en": "English",
|
|
"fr": "Français",
|
|
"de": "Deutsch",
|
|
}
|
|
|
|
FALLBACK = "en"
|
|
|
|
|
|
# ── Translations ──────────────────────────────────────────────────────────────
|
|
# Keys are English strings; values are dicts of language → translated string.
|
|
# Only non-English translations are stored; English is the key itself.
|
|
|
|
TRANSLATIONS: dict[str, dict[str, str]] = {
|
|
# ── base.html ──
|
|
"Meal Tracker": {
|
|
"fr": "Suivi des Repas",
|
|
"de": "Mahlzeiten-Tracker",
|
|
},
|
|
"admin": {
|
|
"fr": "administrateur",
|
|
"de": "Administrator",
|
|
},
|
|
"Logout": {
|
|
"fr": "Déconnexion",
|
|
"de": "Abmelden",
|
|
},
|
|
|
|
# ── login.html ──
|
|
"Log In": {
|
|
"fr": "Se connecter",
|
|
"de": "Anmelden",
|
|
},
|
|
"Username": {
|
|
"fr": "Nom d'utilisateur",
|
|
"de": "Benutzername",
|
|
},
|
|
"Password": {
|
|
"fr": "Mot de passe",
|
|
"de": "Passwort",
|
|
},
|
|
"Don't have an account?": {
|
|
"fr": "Pas de compte ?",
|
|
"de": "Kein Konto?",
|
|
},
|
|
"Register": {
|
|
"fr": "S'inscrire",
|
|
"de": "Registrieren",
|
|
},
|
|
|
|
# ── register.html ──
|
|
"Create Account": {
|
|
"fr": "Créer un compte",
|
|
"de": "Konto erstellen",
|
|
},
|
|
"Confirm Password": {
|
|
"fr": "Confirmer le mot de passe",
|
|
"de": "Passwort bestätigen",
|
|
},
|
|
"Household": {
|
|
"fr": "Foyer",
|
|
"de": "Haushalt",
|
|
},
|
|
"Join an existing household": {
|
|
"fr": "Rejoindre un foyer existant",
|
|
"de": "Einem bestehenden Haushalt beitreten",
|
|
},
|
|
"— Select —": {
|
|
"fr": "— Sélectionner —",
|
|
"de": "— Auswählen —",
|
|
},
|
|
"No households yet. Create one below!": {
|
|
"fr": "Pas encore de foyer. Créez-en un ci-dessous !",
|
|
"de": "Noch keine Haushalte. Erstellen Sie unten einen!",
|
|
},
|
|
"Create a new household": {
|
|
"fr": "Créer un nouveau foyer",
|
|
"de": "Neuen Haushalt erstellen",
|
|
},
|
|
"Household name (e.g. The Smiths)": {
|
|
"fr": "Nom du foyer (ex. Les Dupont)",
|
|
"de": "Haushaltsname (z.B. Familie Müller)",
|
|
},
|
|
"You'll be the admin of this household.": {
|
|
"fr": "Vous serez l'administrateur de ce foyer.",
|
|
"de": "Sie werden der Administrator dieses Haushalts sein.",
|
|
},
|
|
"Already have an account?": {
|
|
"fr": "Vous avez déjà un compte ?",
|
|
"de": "Haben Sie bereits ein Konto?",
|
|
},
|
|
"Log in": {
|
|
"fr": "Se connecter",
|
|
"de": "Anmelden",
|
|
},
|
|
|
|
# ── dashboard.html ──
|
|
"Today's Meals": {
|
|
"fr": "Repas du jour",
|
|
"de": "Heutige Mahlzeiten",
|
|
},
|
|
"History — {date}": {
|
|
"fr": "Historique — {date}",
|
|
"de": "Verlauf — {date}",
|
|
},
|
|
"◀ Prev": {
|
|
"fr": "◀ Préc.",
|
|
"de": "◀ Zurück",
|
|
},
|
|
"Next ▶": {
|
|
"fr": "Suiv. ▶",
|
|
"de": "Weiter ▶",
|
|
},
|
|
"Today": {
|
|
"fr": "Aujourd'hui",
|
|
"de": "Heute",
|
|
},
|
|
"No registered users yet. Ask household members to register!": {
|
|
"fr": "Pas encore d'utilisateurs. Invitez les membres du foyer à s'inscrire !",
|
|
"de": "Noch keine registrierten Benutzer. Bitten Sie Haushaltsmitglieder, sich zu registrieren!",
|
|
},
|
|
"User": {
|
|
"fr": "Utilisateur",
|
|
"de": "Benutzer",
|
|
},
|
|
"Lunch": {
|
|
"fr": "Déjeuner",
|
|
"de": "Mittagessen",
|
|
},
|
|
"Dinner": {
|
|
"fr": "Dîner",
|
|
"de": "Abendessen",
|
|
},
|
|
"you": {
|
|
"fr": "vous",
|
|
"de": "du",
|
|
},
|
|
"Home": {
|
|
"fr": "Présent",
|
|
"de": "Zuhause",
|
|
},
|
|
"Out": {
|
|
"fr": "Absent",
|
|
"de": "Auswärts",
|
|
},
|
|
"—": {
|
|
"fr": "—",
|
|
"de": "—",
|
|
},
|
|
"Remove": {
|
|
"fr": "Retirer",
|
|
"de": "Entfernen",
|
|
},
|
|
"Changed mind at {time}": {
|
|
"fr": "A changé d'avis à {time}",
|
|
"de": "Meinung geändert um {time}",
|
|
},
|
|
"⚙ Account Settings": {
|
|
"fr": "⚙ Paramètres du compte",
|
|
"de": "⚙ Kontoeinstellungen",
|
|
},
|
|
"Delete My Account": {
|
|
"fr": "Supprimer mon compte",
|
|
"de": "Mein Konto löschen",
|
|
},
|
|
"Enter your password to confirm": {
|
|
"fr": "Entrez votre mot de passe pour confirmer",
|
|
"de": "Geben Sie Ihr Passwort zur Bestätigung ein",
|
|
},
|
|
"Delete Household": {
|
|
"fr": "Supprimer le foyer",
|
|
"de": "Haushalt löschen",
|
|
},
|
|
"This will remove all users and all data in this household.": {
|
|
"fr": "Cela supprimera tous les utilisateurs et toutes les données de ce foyer.",
|
|
"de": "Dies entfernt alle Benutzer und alle Daten in diesem Haushalt.",
|
|
},
|
|
|
|
# ── JS confirm strings ──
|
|
"Delete YOUR account? This cannot be undone.": {
|
|
"fr": "Supprimer VOTRE compte ? Cette action est irréversible.",
|
|
"de": "IHR Konto löschen? Dies kann nicht rückgängig gemacht werden.",
|
|
},
|
|
"Remove {name} from this household?": {
|
|
"fr": "Retirer {name} de ce foyer ?",
|
|
"de": "{name} aus diesem Haushalt entfernen?",
|
|
},
|
|
"Delete the ENTIRE household and ALL members? This cannot be undone.": {
|
|
"fr": "Supprimer le foyer ENTIER et TOUS ses membres ? Cette action est irréversible.",
|
|
"de": "Den GESAMTEN Haushalt und ALLE Mitglieder löschen? Dies kann nicht rückgängig gemacht werden.",
|
|
},
|
|
|
|
# ── Page titles ──
|
|
"Login — Meal Tracker": {
|
|
"fr": "Connexion — Suivi des Repas",
|
|
"de": "Anmeldung — Mahlzeiten-Tracker",
|
|
},
|
|
"Register — Meal Tracker": {
|
|
"fr": "Inscription — Suivi des Repas",
|
|
"de": "Registrierung — Mahlzeiten-Tracker",
|
|
},
|
|
"Dashboard — Meal Tracker": {
|
|
"fr": "Tableau de bord — Suivi des Repas",
|
|
"de": "Übersicht — Mahlzeiten-Tracker",
|
|
},
|
|
|
|
# ── Flash / error messages ──
|
|
"Username and password are required.": {
|
|
"fr": "Le nom d'utilisateur et le mot de passe sont requis.",
|
|
"de": "Benutzername und Passwort sind erforderlich.",
|
|
},
|
|
"Invalid username or password.": {
|
|
"fr": "Nom d'utilisateur ou mot de passe invalide.",
|
|
"de": "Ungültiger Benutzername oder Passwort.",
|
|
},
|
|
"Username must be at least 2 characters.": {
|
|
"fr": "Le nom d'utilisateur doit comporter au moins 2 caractères.",
|
|
"de": "Der Benutzername muss mindestens 2 Zeichen lang sein.",
|
|
},
|
|
"Password must be at least 4 characters.": {
|
|
"fr": "Le mot de passe doit comporter au moins 4 caractères.",
|
|
"de": "Das Passwort muss mindestens 4 Zeichen lang sein.",
|
|
},
|
|
"Passwords do not match.": {
|
|
"fr": "Les mots de passe ne correspondent pas.",
|
|
"de": "Die Passwörter stimmen nicht überein.",
|
|
},
|
|
"Username already taken.": {
|
|
"fr": "Ce nom d'utilisateur est déjà pris.",
|
|
"de": "Dieser Benutzername ist bereits vergeben.",
|
|
},
|
|
"Household name is required.": {
|
|
"fr": "Le nom du foyer est requis.",
|
|
"de": "Der Haushaltsname ist erforderlich.",
|
|
},
|
|
"A household with that name already exists.": {
|
|
"fr": "Un foyer avec ce nom existe déjà.",
|
|
"de": "Ein Haushalt mit diesem Namen existiert bereits.",
|
|
},
|
|
"Please select a household.": {
|
|
"fr": "Veuillez sélectionner un foyer.",
|
|
"de": "Bitte wählen Sie einen Haushalt aus.",
|
|
},
|
|
"Selected household does not exist.": {
|
|
"fr": "Le foyer sélectionné n'existe pas.",
|
|
"de": "Der ausgewählte Haushalt existiert nicht.",
|
|
},
|
|
"Account created! Welcome to your household.": {
|
|
"fr": "Compte créé ! Bienvenue dans votre foyer.",
|
|
"de": "Konto erstellt! Willkommen in Ihrem Haushalt.",
|
|
},
|
|
"Invalid meal type.": {
|
|
"fr": "Type de repas invalide.",
|
|
"de": "Ungültige Mahlzeitart.",
|
|
},
|
|
"Invalid status.": {
|
|
"fr": "Statut invalide.",
|
|
"de": "Ungültiger Status.",
|
|
},
|
|
"Invalid date.": {
|
|
"fr": "Date invalide.",
|
|
"de": "Ungültiges Datum.",
|
|
},
|
|
"Cannot change responses for past dates.": {
|
|
"fr": "Impossible de modifier les réponses pour les dates passées.",
|
|
"de": "Antworten für vergangene Daten können nicht geändert werden.",
|
|
},
|
|
"Meal period not found.": {
|
|
"fr": "Période de repas introuvable.",
|
|
"de": "Mahlzeitenzeitraum nicht gefunden.",
|
|
},
|
|
"Invalid date format. Use YYYY-MM-DD.": {
|
|
"fr": "Format de date invalide. Utilisez AAAA-MM-JJ.",
|
|
"de": "Ungültiges Datumsformat. Verwenden Sie JJJJ-MM-TT.",
|
|
},
|
|
"Incorrect password. Account not deleted.": {
|
|
"fr": "Mot de passe incorrect. Compte non supprimé.",
|
|
"de": "Falsches Passwort. Konto nicht gelöscht.",
|
|
},
|
|
"Your account and household have been deleted.": {
|
|
"fr": "Votre compte et votre foyer ont été supprimés.",
|
|
"de": "Ihr Konto und Ihr Haushalt wurden gelöscht.",
|
|
},
|
|
"Your account has been deleted.": {
|
|
"fr": "Votre compte a été supprimé.",
|
|
"de": "Ihr Konto wurde gelöscht.",
|
|
},
|
|
"Only admins can remove users.": {
|
|
"fr": "Seuls les administrateurs peuvent retirer des utilisateurs.",
|
|
"de": "Nur Administratoren können Benutzer entfernen.",
|
|
},
|
|
"User not found in your household.": {
|
|
"fr": "Utilisateur introuvable dans votre foyer.",
|
|
"de": "Benutzer nicht in Ihrem Haushalt gefunden.",
|
|
},
|
|
"Cannot remove the admin. Transfer admin role first or delete the household.": {
|
|
"fr": "Impossible de retirer l'administrateur. Transférez d'abord le rôle admin ou supprimez le foyer.",
|
|
"de": "Der Administrator kann nicht entfernt werden. Übertragen Sie zuerst die Admin-Rolle oder löschen Sie den Haushalt.",
|
|
},
|
|
"User '{name}' has been removed.": {
|
|
"fr": "L'utilisateur '{name}' a été retiré.",
|
|
"de": "Benutzer '{name}' wurde entfernt.",
|
|
},
|
|
"Only admins can delete the household.": {
|
|
"fr": "Seuls les administrateurs peuvent supprimer le foyer.",
|
|
"de": "Nur Administratoren können den Haushalt löschen.",
|
|
},
|
|
"Incorrect password. Household not deleted.": {
|
|
"fr": "Mot de passe incorrect. Foyer non supprimé.",
|
|
"de": "Falsches Passwort. Haushalt nicht gelöscht.",
|
|
},
|
|
"Household and all members have been deleted.": {
|
|
"fr": "Le foyer et tous ses membres ont été supprimés.",
|
|
"de": "Haushalt und alle Mitglieder wurden gelöscht.",
|
|
},
|
|
|
|
# ── Ntfy notifications ──
|
|
"Ntfy Notifications": {
|
|
"fr": "Notifications Ntfy",
|
|
"de": "Ntfy-Benachrichtigungen",
|
|
},
|
|
"Get meal reminders on your phone via ntfy.": {
|
|
"fr": "Recevez des rappels de repas sur votre téléphone via ntfy.",
|
|
"de": "Erhalten Sie Essenserinnerungen auf Ihrem Handy via ntfy.",
|
|
},
|
|
"Ntfy Topic URL": {
|
|
"fr": "URL du sujet Ntfy",
|
|
"de": "Ntfy-Themen-URL",
|
|
},
|
|
"Access Token": {
|
|
"fr": "Jeton d'accès",
|
|
"de": "Zugriffstoken",
|
|
},
|
|
"(optional)": {
|
|
"fr": "(optionnel)",
|
|
"de": "(optional)",
|
|
},
|
|
"Save": {
|
|
"fr": "Enregistrer",
|
|
"de": "Speichern",
|
|
},
|
|
"Send Test": {
|
|
"fr": "Tester",
|
|
"de": "Test senden",
|
|
},
|
|
"Ntfy settings saved.": {
|
|
"fr": "Paramètres Ntfy enregistrés.",
|
|
"de": "Ntfy-Einstellungen gespeichert.",
|
|
},
|
|
"Please enter an ntfy URL first.": {
|
|
"fr": "Veuillez d'abord entrer une URL ntfy.",
|
|
"de": "Bitte geben Sie zuerst eine ntfy-URL ein.",
|
|
},
|
|
"Test notification sent! Check your device.": {
|
|
"fr": "Notification de test envoyée ! Vérifiez votre appareil.",
|
|
"de": "Testbenachrichtigung gesendet! Überprüfen Sie Ihr Gerät.",
|
|
},
|
|
"Failed to send test notification. Check your ntfy URL and token.": {
|
|
"fr": "Échec de l'envoi du test. Vérifiez votre URL ntfy et votre jeton.",
|
|
"de": "Testbenachrichtigung fehlgeschlagen. Überprüfen Sie Ihre ntfy-URL und Ihr Token.",
|
|
},
|
|
|
|
# ── Timezone ──
|
|
"Household Timezone": {
|
|
"fr": "Fuseau horaire du foyer",
|
|
"de": "Zeitzone des Haushalts",
|
|
},
|
|
"Used to send reminders at 10:00 (lunch) and 16:00 (dinner) local time.": {
|
|
"fr": "Utilisé pour envoyer les rappels à 10h00 (déjeuner) et 16h00 (dîner) heure locale.",
|
|
"de": "Wird verwendet, um Erinnerungen um 10:00 (Mittagessen) und 16:00 (Abendessen) Ortszeit zu senden.",
|
|
},
|
|
"Only admins can change the timezone.": {
|
|
"fr": "Seuls les administrateurs peuvent changer le fuseau horaire.",
|
|
"de": "Nur Administratoren können die Zeitzone ändern.",
|
|
},
|
|
"Please select a timezone.": {
|
|
"fr": "Veuillez sélectionner un fuseau horaire.",
|
|
"de": "Bitte wählen Sie eine Zeitzone aus.",
|
|
},
|
|
"Timezone updated.": {
|
|
"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!",
|
|
},
|
|
}
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
def parse_accept_language(header: str | None) -> str:
|
|
"""Parse Accept-Language header and return the best supported language code."""
|
|
if not header:
|
|
return FALLBACK
|
|
|
|
best = FALLBACK
|
|
best_quality = 0.0
|
|
|
|
for part in header.split(","):
|
|
chunk = part.strip().split(";")
|
|
tag = chunk[0].strip().split("-")[0].lower() # "fr-FR" → "fr"
|
|
quality = 1.0
|
|
if len(chunk) > 1 and chunk[1].strip().startswith("q="):
|
|
try:
|
|
quality = float(chunk[1].strip()[2:])
|
|
except ValueError:
|
|
pass
|
|
|
|
if tag in LANGUAGES and quality > best_quality:
|
|
best_quality = quality
|
|
best = tag
|
|
|
|
return best
|
|
|
|
|
|
def get_lang() -> str:
|
|
"""Return current language code (from session, header, or fallback)."""
|
|
# Session override (user manually selected a language)
|
|
lang = session.get("lang")
|
|
if lang and lang in LANGUAGES:
|
|
return lang
|
|
|
|
# Parse Accept-Language header
|
|
lang = parse_accept_language(request.headers.get("Accept-Language", ""))
|
|
session["lang"] = lang
|
|
return lang
|
|
|
|
|
|
def t(key: str, **kwargs) -> str:
|
|
"""Translate a key into the current language.
|
|
|
|
Usage in templates: {{ t('Log In') }}
|
|
With placeholders: {{ t("User '{name}' has been removed.", name=username) }}
|
|
"""
|
|
lang = g.get("lang") or session.get("lang") or FALLBACK
|
|
|
|
if lang == FALLBACK:
|
|
result = key
|
|
else:
|
|
result = TRANSLATIONS.get(key, {}).get(lang, key)
|
|
|
|
if kwargs:
|
|
result = result.format(**kwargs)
|
|
return result
|
|
|
|
|
|
def init_i18n(app):
|
|
"""Register before_request handler and template globals on a Flask app."""
|
|
|
|
@app.before_request
|
|
def _set_lang():
|
|
# Allow ?lang= override in query string
|
|
qs_lang = request.args.get("lang")
|
|
if qs_lang and qs_lang in LANGUAGES:
|
|
session["lang"] = qs_lang
|
|
g.lang = get_lang()
|
|
|
|
@app.context_processor
|
|
def _inject_i18n():
|
|
return {
|
|
"t": t,
|
|
"lang": g.get("lang", FALLBACK),
|
|
"lang_name": LANGUAGES.get(g.get("lang", FALLBACK), "English"),
|
|
}
|