Files
agentbox bda54eda3a
CI / test-and-package (push) Successful in 43s
CI / build-android (push) Successful in 1m10s
feat: password change + split account/household settings UI
- New /change-password route: validates current password,
  enforces min 4 chars, requires confirmation match
- update_password() helper in models.py
- Dashboard UI split into two collapsible sections:
  👤 Account Settings (password, ntfy, delete account)
  🏠 Household Settings (admin only: public link, timezone,
     delete household)
- 14 new i18n keys with fr/de translations
- Restored accidentally dropped 'Delete My Account' key
2026-08-01 12:23:30 +00:00

594 lines
20 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 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!",
},
# ── Password change ──
"👤 Account Settings": {
"fr": "👤 Paramètres du compte",
"de": "👤 Kontoeinstellungen",
},
"🏠 Household Settings": {
"fr": "🏠 Paramètres du foyer",
"de": "🏠 Haushaltseinstellungen",
},
"Change Password": {
"fr": "Changer le mot de passe",
"de": "Passwort ändern",
},
"Current password": {
"fr": "Mot de passe actuel",
"de": "Aktuelles Passwort",
},
"New password": {
"fr": "Nouveau mot de passe",
"de": "Neues Passwort",
},
"Confirm new password": {
"fr": "Confirmer le nouveau mot de passe",
"de": "Neues Passwort bestätigen",
},
"Password changed successfully.": {
"fr": "Mot de passe changé avec succès.",
"de": "Passwort erfolgreich geändert.",
},
"Current password is incorrect.": {
"fr": "Le mot de passe actuel est incorrect.",
"de": "Das aktuelle Passwort ist falsch.",
},
"New password must be at least 4 characters.": {
"fr": "Le nouveau mot de passe doit comporter au moins 4 caractères.",
"de": "Das neue Passwort muss mindestens 4 Zeichen lang sein.",
},
"New passwords do not match.": {
"fr": "Les nouveaux mots de passe ne correspondent pas.",
"de": "Die neuen Passwörter stimmen nicht überein.",
},
"This will permanently delete your account and all your responses.": {
"fr": "Cela supprimera définitivement votre compte et toutes vos réponses.",
"de": "Dies löscht Ihr Konto und alle Ihre Antworten dauerhaft.",
},
"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",
},
}
# ── 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"),
}