diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..42afd39 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# Projektvorgaben + +- Bei allen Änderungen Deutsch und Englisch berücksichtigen. Neue oder geänderte Oberflächentexte über die vorhandene Übersetzungsfunktion und `app/locales/en.json` pflegen und beide Sprachen prüfen. diff --git a/app/i18n.py b/app/i18n.py new file mode 100644 index 0000000..f5ea036 --- /dev/null +++ b/app/i18n.py @@ -0,0 +1,46 @@ +"""Request-local UI translations. User content and stored identifiers stay unchanged.""" + +import json +from contextvars import ContextVar +from pathlib import Path +from urllib.parse import urlencode, urlsplit + + +LANGUAGE_COOKIE = "metalcircle_language" +current_language = ContextVar("current_language", default="de") +current_page = ContextVar("current_page", default="/") +ENGLISH = json.loads((Path(__file__).parent / "locales" / "en.json").read_text()) + + +def gettext(message): + if not isinstance(message, str): + return message + return ENGLISH.get(message, message) if current_language.get() == "en" else message + + +def format_time(value): + """Format display times without changing stored or form-submitted values.""" + if current_language.get() == "en": + period = "AM" if value.hour < 12 else "PM" + return f"{value.hour % 12 or 12}:{value.minute:02d} {period}" + return value.strftime("%H:%M") + + +def format_datetime(value): + return f"{value.strftime('%d.%m.%Y')} {format_time(value)}" + + +def safe_return_path(value): + """Only allow local absolute paths, including their query string and fragment.""" + if not value or not value.startswith("/") or value.startswith("//"): + return "/" + if "\\" in value or any(ord(char) < 32 or ord(char) == 127 for char in value): + return "/" + parsed = urlsplit(value) + if parsed.scheme or parsed.netloc or parsed.path.startswith("/language/"): + return "/" + return value + + +def language_url(language): + return f"/language/{language}?{urlencode({'next': current_page.get()})}" diff --git a/app/locales/en.json b/app/locales/en.json new file mode 100644 index 0000000..15ca356 --- /dev/null +++ b/app/locales/en.json @@ -0,0 +1,463 @@ +{ + "Adminbereiche": "Administration sections", + "👥 Benutzer": "👥 Users", + "📍 Veranstaltungsorte": "📍 Venues", + "🧵 Patch-Bilder": "🧵 Patch images", + "📊 Nutzungsstatistik": "📊 Usage statistics", + "neue Nachricht(en) oder Anfrage(n)": "new message(s) or request(s)", + "Neue Benachrichtigungen": "New notifications", + "Mein Profil": "My profile", + "Nachrichten": "Messages", + "Neue Nachrichten oder Anfragen": "New messages or requests", + "Konzerttagebuch": "Concert diary", + "Gefolgte Bands & Locations": "Followed bands & venues", + "⚙️ Verwaltung": "⚙️ Administration", + "Datenschutz": "Privacy policy", + "Impressum": "Legal notice", + "Abmelden": "Log out", + "Account-Link · MetalCircle": "Account link · MetalCircle", + "Passwort zurücksetzen": "Reset password", + "Reset-Link für @": "Reset link for @", + "Diesen persönlichen Link sicher an den User senden. Er ist zwei Stunden gültig und nur einmal verwendbar.": "Send this personal link securely to the user. It is valid for two hours and can only be used once.", + "Zurück": "Back", + "Das Bild konnte nicht optimiert werden. Bitte ein kleineres JPG, PNG oder WebP wählen.": "The image could not be optimized. Please choose a smaller JPG, PNG or WebP.", + "Patch-Bilder · MetalCircle": "Patch images · MetalCircle", + "Grafiken für die Patches auf der virtuellen Kutte verwalten.": "Manage images for the patches on the virtual battle vest.", + "Das Bild wird vor dem Upload automatisch optimiert.": "The image is automatically optimized before upload.", + "Bild ersetzen": "Replace image", + "Bild hochladen": "Upload image", + "Bild entfernen": "Remove image", + "Benutzer": "User", + "Veranstaltungen": "Events", + "Bevorstehende Veranstaltungen": "Upcoming events", + "Kommentare": "Comments", + "Teilnahmen": "Attendance", + "Freundschaften": "Friendships", + "Nutzungsstatistik · MetalCircle": "Usage statistics · MetalCircle", + "Aktuelle Gesamtzahlen der Plattform.": "Current platform totals.", + "Benutzerverwaltung · MetalCircle": "User management · MetalCircle", + "Benutzerkonten und Einladungen verwalten.": "Manage user accounts and invitations.", + "Einladung erstellen": "Create invitation", + "Der Link kann einmalig verwendet werden.": "The link can only be used once.", + "+ Einladungslink erzeugen": "+ Generate invitation link", + "· seit": "· since", + "🏴‍☠️ Gründer · Admin (fest)": "🏴‍☠️ Founder · Admin (permanent)", + "Rolle für": "Role for", + "Speichern": "Save", + "Passwort-Reset": "Password reset", + "Löschen": "Delete", + "Veranstaltungsorte · MetalCircle": "Venues · MetalCircle", + "Orte korrigieren, bestätigen, zusammenführen oder entfernen.": "Correct, verify, merge or remove venues.", + "✓ bestätigt": "✓ verified", + "● unbestätigt": "● unverified", + "Veranstaltung(en) · ID": "event(s) · ID", + "Straße": "Street", + "PLZ": "Postal code", + "Ort": "City", + "Land": "Country", + "Aliase, durch Komma getrennt": "Aliases, separated by commas", + "Aliase": "Aliases", + "bestätigt": "verified", + "Quelle:": "Source:", + "Konzert(e) · ID": "concert(s) · ID", + "Mit anderem Ort zusammenführen …": "Merge with another venue …", + "Zusammenführen": "Merge", + "Der Ort ist mit": "This venue is linked to", + "Konzert(en) verknüpft. Diese Konzerte haben danach keinen Veranstaltungsort mehr.": "concert(s). These concerts will no longer have a venue afterwards.", + "Ort löschen": "Delete venue", + "Verknüpfung(en))": "link(s))", + "Noch keine Veranstaltungsorte vorhanden.": "No venues yet.", + "Deutschland": "Germany", + "← Zurück zum Kalender": "← Back to calendar", + "Quelle": "Source", + "Extern eingebundener Flyer. Rechte liegen beim jeweiligen Veranstalter bzw. Rechteinhaber. Hinweise zur Entfernung bitte an die Administration.": "Externally embedded flyer. Rights belong to the respective organizer or rights holder. Please contact the administrators with removal requests.", + "🔒 Nur Freunde": "🔒 Friends only", + "🔐 Privat / eingeladen": "🔐 Private / invited", + "gefolgt": "followed", + "folgen": "follow", + "★ Location gefolgt": "★ Following venue", + "☆ Location folgen": "☆ Follow venue", + "Meine gefolgten Inhalte": "My followed content", + "Uhr": " ", + "bis": "to", + "Gehört zu:": "Part of:", + "Über die Veranstaltung": "About the event", + "📅 In Kalender speichern (.ics)": "📅 Save to calendar (.ics)", + "Veranstaltung verwalten": "Manage event", + "✏️ Veranstaltung bearbeiten": "✏️ Edit event", + "🗑️ Veranstaltung löschen": "🗑️ Delete event", + "🐧 Wer ist dabei?": "🐧 Who is going?", + "Wähle, wie es bei dir aussieht. Deine Auswahl kann jederzeit geändert werden.": "Choose your status. You can change your selection at any time.", + "✓ Zugesagt": "✓ Going", + "? Vielleicht": "? Maybe", + "🔎 Suche Ticket": "🔎 Looking for a ticket", + "🎟️ Biete Ticket": "🎟️ Offering a ticket", + "Zusage": "going", + "bietet Ticket": "offering a ticket", + "Noch niemand hat zugesagt.": "Nobody is going yet.", + "Noch keine Vielleicht-Angaben.": "No maybes yet.", + "🔎 Sucht ein Ticket": "🔎 Looking for a ticket", + "Aktuell sucht niemand ein Ticket.": "Nobody is looking for a ticket right now.", + "🎟️ Bietet ein Ticket": "🎟️ Offering a ticket", + "Aktuell bietet niemand ein Ticket an.": "Nobody is offering a ticket right now.", + "📓 Mein Konzerttagebuch": "📓 My concert diary", + "Der private Eintrag wurde automatisch angelegt.": "The private entry was created automatically.", + "Im Konzerttagebuch bearbeiten": "Edit in concert diary", + "Ein Tagebucheintrag wird automatisch angelegt, sobald du das vergangene Konzert als „Zugesagt“ markierst.": "A diary entry is created automatically when you mark a past concert as “Going”.", + "💬 Kommentare": "💬 Comments", + "Mit anderen Konzertbesuchern austauschen": "Chat with other concertgoers", + "Schreib einen Kommentar …": "Write a comment …", + "Kommentar senden": "Send comment", + "Noch keine Kommentare. Mach den Anfang!": "No comments yet. Be the first!", + "Datenschutz · MetalCircle": "Privacy policy · MetalCircle", + "Hinweise zur Verarbeitung deiner Daten": "Information about how your data is processed", + "Verantwortlicher": "Data controller", + "Welche Daten werden gespeichert?": "What data is stored?", + "Für die Nutzung werden insbesondere Benutzername, E-Mail-Adresse, Passwort-Hash, Anzeigename, optionale Profil- und Instagram-Angaben, Profilbild, Freundschaften, Nachrichten, Veranstaltungsteilnahmen, gefolgte Bands und Locations, private Konzerttagebuch-Einträge, Kommentare, Fotos, Patches sowie von dir angelegte Veranstaltungen gespeichert.": "Using the platform involves storing your username, email address, password hash, display name, optional profile and Instagram details, profile picture, friendships, messages, event attendance, followed bands and venues, private concert diary entries, comments, photos, patches and events you create.", + "Wofür werden sie verwendet?": "What is it used for?", + "Die Daten werden ausschließlich für Anmeldung, Kontoverwaltung, Veranstaltungsfunktionen, Freundschaften, Nachrichten, Benachrichtigungen und die von dir gewählten Sichtbarkeitseinstellungen verarbeitet.": "The data is processed exclusively for login, account management, event features, friendships, messages, notifications and your chosen visibility settings.", + "Rechtsgrundlage und Speicherdauer": "Legal basis and retention period", + "Die Verarbeitung erfolgt im geschlossenen Projektbetrieb zur Bereitstellung der gewünschten Funktionen und – soweit erforderlich – auf Grundlage deiner Einwilligung. Daten bleiben gespeichert, solange dein Konto besteht oder gesetzliche Aufbewahrungspflichten gelten. Nicht mehr benötigte Daten werden gelöscht.": "Data is processed within this closed project to provide the requested features and, where required, on the basis of your consent. Data remains stored for as long as your account exists or statutory retention requirements apply. Data that is no longer needed is deleted.", + "Weitergabe und externe Dienste": "Sharing and external services", + "Es werden keine Werbe- oder Trackingdienste eingesetzt. Bei der Veranstaltungsortsuche können Suchanfragen an einen externen Geocoding-Dienst übermittelt werden. Externe Flyer- und Instagram-Links werden beim Aufruf direkt von deinem Browser geladen; dafür gelten die Datenschutzbestimmungen des jeweiligen Anbieters.": "No advertising or tracking services are used. Venue searches may send search queries to an external geocoding service. External flyer and Instagram links are loaded directly by your browser when opened; the respective provider’s privacy policy applies.", + "Deine Rechte": "Your rights", + "Du kannst Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und – soweit anwendbar – Datenübertragbarkeit verlangen. Einen Export deiner gespeicherten Anwendungsdaten findest du klein am Ende des eigenen Profilbereichs. Anfragen bitte an": "You may request access, rectification, erasure, restriction of processing and, where applicable, data portability. You can find an export of your stored application data at the bottom of your own profile. Please send requests to", + "Cookies und Sicherheit": "Cookies and security", + "Für die Anmeldung wird ausschließlich ein technisch notwendiges, HttpOnly-Sitzungscookie verwendet. Im späteren HTTPS-Betrieb wird es zusätzlich mit dem Secure-Flag gesetzt. Passwörter werden nicht im Klartext gespeichert.": "Login uses only a technically necessary HttpOnly session cookie. When HTTPS is enabled, the Secure flag is also set. Passwords are not stored in plain text.", + "Diese Information ist eine technische Projektgrundlage und ersetzt keine individuelle rechtliche Prüfung. Vor einer öffentlichen Veröffentlichung sollten Hostinganbieter, Auftragsverarbeiter, Löschfristen und der externe Geocoding-Dienst konkret ergänzt und geprüft werden.": "This information provides a technical basis for the project and does not replace an individual legal review. Before public release, hosting providers, data processors, deletion periods and the external geocoding service should be specified and reviewed.", + "Konzerttagebuch · MetalCircle": "Concert diary · MetalCircle", + "📓 Konzerttagebuch": "📓 Concert diary", + "Besuchte Konzerte werden automatisch eingetragen. Deine Erinnerungen bleiben privat.": "Attended concerts are added automatically. Your memories stay private.", + "von 5 Sternen": "out of 5 stars", + "Noch nicht bewertet.": "Not rated yet.", + "🎵 Lieblingssong:": "🎵 Favorite song:", + "Bildergalerie zu": "Image gallery for", + "Erinnerungsfoto": "Memory photo", + "zu": "for", + "Bild": "Image", + "entfernen": "remove", + "Hier erhalten ·": "Earned here ·", + "Eintrag bearbeiten": "Edit entry", + "Bewertung": "Rating", + "Noch nicht bewertet": "Not rated yet", + "Lieblingssong": "Favorite song", + "Erinnerungen": "Memories", + "Was ist dir von diesem Abend geblieben?": "What do you remember about this night?", + "Galeriebilder": "Gallery images", + "Bis zu drei Bilder; große Bilder werden automatisch optimiert.": "Up to three images; large images are automatically optimized.", + "Eintrag speichern": "Save entry", + "Konzerteintrag löschen": "Delete concert entry", + "Noch keine Einträge. Vergangene zugesagte Konzerte erscheinen hier automatisch.": "No entries yet. Past concerts marked as going appear here automatically.", + "bearbeiten · MetalCircle": "edit · MetalCircle", + "Änderungen werden direkt bei der Veranstaltung gespeichert.": "Changes are saved directly to the event.", + "Kategorie": "Category", + "Konzert": "Concert", + "Sonstiges": "Other", + "Gehört zu": "Part of", + "Keine Verknüpfung – eigenständige Karte": "No link – standalone event", + "Sichtbarkeit": "Visibility", + "Öffentlich": "Public", + "Nur Freunde": "Friends only", + "Privat – nur eingeladene Mitglieder": "Private – invited members only", + "Mitglieder einladen": "Invite members", + "Noch keine weiteren Mitglieder vorhanden.": "No other members yet.", + "Titel / Künstler / Band": "Title / artist / band", + "Bands / Line-up": "Bands / line-up", + "(eine Band pro Zeile)": "(one band per line)", + "Diese Namen werden einzeln für die Folgen-Funktion verwendet.": "These names are used individually for the follow feature.", + "Veranstaltungsort": "Venue", + "Name des Veranstaltungsorts": "Venue name", + "PLZ und Ort": "Postal code and city", + "Beginn": "Start", + "Ende": "End", + "Beschreibung": "Description", + "Ticket-Link": "Ticket link", + "Ticketpreis": "Ticket price", + "Flyer extern verlinken": "Link an external flyer", + "(bevorzugt)": "(preferred)", + "Bitte möglichst die offizielle Quelle verwenden. Ein externer Link wird gegenüber einem Upload bevorzugt und unter dem Bild genannt. Feld leeren, um wieder den Upload zu verwenden.": "Please use the official source if possible. An external link takes priority over an upload and is shown below the image. Clear the field to use the upload again.", + "Neuen Flyer hochladen": "Upload new flyer", + "Ohne neue Datei bleibt der bisherige Flyer erhalten.": "The existing flyer is kept unless you upload a new file.", + "Aktueller Flyer": "Current flyer", + "💾 Änderungen speichern": "💾 Save changes", + "Abbrechen": "Cancel", + "Gefolgt · MetalCircle": "Following · MetalCircle", + "⭐ Gefolgt": "⭐ Following", + "Bands, Locations und passende kommende Veranstaltungen.": "Bands, venues and matching upcoming events.", + "Nicht mehr folgen": "Unfollow", + "nicht mehr folgen": "unfollow", + "Noch keine Band gefolgt.": "No bands followed yet.", + "Locations": "Venues", + "Noch keiner Location gefolgt.": "No venues followed yet.", + "Kommende Treffer": "Upcoming matches", + "und": "and", + "Location": "Venue", + "Momentan gibt es keine kommenden Treffer.": "There are no upcoming matches right now.", + "Impressum · MetalCircle": "Legal notice · MetalCircle", + "Anbieterkennzeichnung": "Provider information", + "Verantwortlich für den Inhalt": "Responsible for the content", + "E-Mail:": "Email:", + "Dieses private Projekt befindet sich derzeit im geschlossenen Betrieb. Vor einer Veröffentlichung werden die Angaben und rechtlichen Anforderungen nochmals geprüft.": "This private project is currently closed to the public. The information and legal requirements will be reviewed again before public release.", + "· ✉ Eingeladen": "· ✉ Invited", + "· 🔒 Nur Freunde": "· 🔒 Friends only", + "· 🔐 Privat": "· 🔐 Private", + "um": "at", + "☠ Vergangene Veranstaltungen": "☠ Past events", + "⚡ Bevorstehend": "⚡ Upcoming", + "☠ Vergangene": "☠ Past", + "+ Veranstaltung hinzufügen": "+ Add event", + "Suche": "Search", + "Veranstaltungen, Orte oder User suchen …": "Search events, venues or users …", + "Veranstaltungen oder User suchen": "Search events or users", + "Suchen": "Search", + "Weitere Filter": "More filters", + "· aktiv": "· active", + "Von Datum": "From date", + "Bis Datum": "To date", + "Ort / Location": "City / venue", + "z. B. Düsseldorf": "e.g. Düsseldorf", + "z. B. Deutschland": "e.g. Germany", + "Alle Kategorien": "All categories", + "Filter löschen": "Clear filters", + "User zu „": "Users matching “", + "Keine passenden User gefunden.": "No matching users found.", + "Veranstaltungen zu „": "Events matching “", + "Archiv": "Archive", + "Noch keine vergangenen Veranstaltungen ☠": "No past events yet ☠", + "Noch keine Veranstaltungen 🎸": "No events yet 🎸", + "Hier erscheinen abgeschlossene Veranstaltungen.": "Completed events appear here.", + "Leg die erste Veranstaltung an!": "Create the first event!", + "Anmelden · MetalCircle": "Log in · MetalCircle", + "Anmelden": "Log in", + "Melde dich an, um Konzerte hinzuzufügen oder zu bearbeiten.": "Log in to add or edit concerts.", + "Benutzername oder E-Mail": "Username or email", + "Passwort": "Password", + "Passwort vergessen? Bitte einen Admin um einen zeitlich begrenzten Reset-Link.": "Forgot your password? Ask an admin for a temporary reset link.", + "Nachrichten · MetalCircle": "Messages · MetalCircle", + "Noch keine Nachrichten. Schreib die erste!": "No messages yet. Write the first one!", + "Nachricht schreiben …": "Write a message …", + "Nachricht": "Message", + "Senden": "Send", + "← Alle Nachrichten": "← All messages", + "🤘 Nachrichten": "🤘 Messages", + "Private Chats mit deinen Freunden.": "Private chats with your friends.", + "Neue Anfragen": "New requests", + "möchte sich mit dir befreunden.": "would like to be your friend.", + "Annehmen": "Accept", + "Ablehnen": "Decline", + "– Einladung von": "– Invited by", + "Ansehen →": "View →", + "Unterhaltungen": "Conversations", + "Du:": "You:", + "Chat beginnen": "Start chat", + "Noch keine Chats": "No chats yet", + "Chats sind mit bestätigten Freunden und mit Admins verfügbar.": "Chats are available with confirmed friends and admins.", + "Bitte JPG, PNG oder WEBP auswählen.": "Please choose JPG, PNG or WEBP.", + "Der Flyer darf maximal 10 MB groß sein.": "The flyer must not exceed 10 MB.", + "⚠️ Möglicherweise existiert diese Veranstaltung bereits:": "⚠️ This event may already exist:", + "Am selben Tag gibt es bereits eine Veranstaltung mit einem sehr ähnlichen Bandnamen. Trotzdem speichern?": "An event with a very similar band name already exists on the same day. Save anyway?", + "Veranstaltung hinzufügen · MetalCircle": "Add event · MetalCircle", + "🎤 Veranstaltung hinzufügen": "🎤 Add event", + "Trag ein, worauf wir uns freuen.": "Add something to look forward to.", + "Zum Beispiel ein gemeinsames Vortrinken vor einem Konzert.": "For example, meeting for drinks before a concert.", + "Öffentlich – alle können die Veranstaltung sehen und teilnehmen": "Public – everyone can see and attend the event", + "Nur Freunde – nur bestätigte Freunde können sie sehen und teilnehmen": "Friends only – only confirmed friends can see and attend it", + "Privat – nur gezielt eingeladene Mitglieder": "Private – specifically invited members only", + "Eingeladene erhalten eine Benachrichtigung und sehen die Veranstaltung in ihrer Übersicht.": "Invitees receive a notification and see the event in their overview.", + "z. B. Iron Maiden": "e.g. Iron Maiden", + "dArtagnan\nSupportband": "dArtagnan\nSupport band", + "Der Veranstaltungstitel bleibt frei. Diese Namen werden einzeln für „Band folgen“ verwendet.": "The event title is up to you. These names are used individually for “Follow band”.", + "z. B. Turbinenhalle": "e.g. Turbinenhalle", + "Optional für eine bessere Zuordnung:": "Optional details for a better match:", + "Ort / Stadt": "Town / city", + "Datum und Uhrzeit": "Date and time", + "Enddatum und Uhrzeit": "End date and time", + "Optional: Infos zur Veranstaltung, Tour, Supportbands usw.": "Optional: event details, tour, support bands, etc.", + "z. B. 59.90": "e.g. 59.90", + "🔗 Flyer extern verlinken": "🔗 Link an external flyer", + "Bitte möglichst den offiziellen Flyer des Veranstalters oder der Band verlinken. Das Bild wird nicht auf MetalCircle kopiert; der Link wird als Quelle ausgewiesen.": "Please link the official flyer from the organizer or band if possible. The image is not copied to MetalCircle; the link is shown as the source.", + "🎨 Veranstaltungs-Flyer": "🎨 Event flyer", + "Alternative, falls kein offizieller externer Link verfügbar ist: JPG, PNG oder WEBP · maximal 10 MB. Ist ein Link angegeben, wird er bevorzugt dargestellt.": "Alternative if no official external link is available: JPG, PNG or WEBP · maximum 10 MB. If a link is provided, it takes priority.", + "Flyer Vorschau": "Flyer preview", + "💾 Veranstaltung speichern": "💾 Save event", + "Passwort zurücksetzen · MetalCircle": "Reset password · MetalCircle", + "Neues Passwort": "New password", + "Passwort wiederholen": "Repeat password", + "Passwort speichern": "Save password", + "Das Bild konnte nicht verarbeitet werden. Bitte ein kleineres JPG, PNG oder WebP wählen.": "The image could not be processed. Please choose a smaller JPG, PNG or WebP.", + "Profil": "Profile", + "Deine Konzertgeschichte und deine Kutte.": "Your concert history and your battle vest.", + "Profilbild von": "Profile picture of", + "Kein Profilbild": "No profile picture", + "Dabei seit": "Joined", + "besuchte Konzerte": "concerts attended", + "Nachricht senden": "Send message", + "Freundschaft anfragen": "Send friend request", + "Freundschaft beenden": "Unfriend", + "Anfrage zurückziehen": "Cancel request", + "Blockierung aufheben": "Unblock", + "Nutzer blockieren": "Block user", + "Interaktionen mit diesem Nutzer sind nicht verfügbar.": "Interactions with this user are unavailable.", + "Weitere Profildetails sind nur für Freunde sichtbar.": "Further profile details are only visible to friends.", + "🥋 Virtuelle Kutte": "🥋 Virtual battle vest", + "Deine freigeschalteten Patches. Besuchs-Patches werden jeweils durch die höchste Stufe ersetzt.": "Your unlocked patches. Attendance patches are replaced by the highest level you have earned.", + "Details zu": "Show details for", + "anzeigen": " ", + "Schließen": "Close", + "Erhalten am:": "Earned on:", + "Ausgelöst durch:": "Triggered by:", + "Grund:": "Reason:", + "Noch keine Patches – dein erstes Konzert wartet!": "No patches yet – your first concert awaits!", + "🤝 Freunde": "🤝 Friends", + "Offene Anfragen": "Pending requests", + "Deine Freunde": "Your friends", + "Entfernen": "Remove", + "gesendete Anfrage(n) warten noch auf Antwort.": "sent request(s) are still awaiting a reply.", + "Noch keine Verbindungen. Finde andere User über die Suche auf der Startseite.": "No connections yet. Find other users through the search on the home page.", + "Blockierte Nutzer": "Blocked users", + "Freigeben": "Unblock", + "Keine blockierten Nutzer.": "No blocked users.", + "Profil bearbeiten": "Edit profile", + "Anzeigename": "Display name", + "Instagram-Profil": "Instagram profile", + "@name oder https://instagram.com/name": "@name or https://instagram.com/name", + "Aktuell verknüpft: @": "Currently linked: @", + "Wer darf alle Profildetails sehen?": "Who can see all profile details?", + "Alle User": "All users", + "Jeder angemeldete User sieht auch Instagram und Beitrittsdatum.": "Every logged-in user can also see your Instagram and join date.", + "Username, Profilbild, Konzertanzahl und Kutte bleiben immer sichtbar.": "Username, profile picture, concert count and battle vest are always visible.", + "Niemand": "Nobody", + "Zusätzliche Details siehst nur du; Admins behalten administrativen Zugriff.": "Only you can see additional details; admins retain administrative access.", + "Profilbild": "Profile picture", + "Große Bilder werden vor dem Upload automatisch optimiert.": "Large images are automatically optimized before upload.", + "Profil speichern": "Save profile", + "Account löschen": "Delete account", + "Diese Aktion löscht dein Konto dauerhaft und kann nicht rückgängig gemacht werden.": "This action permanently deletes your account and cannot be undone.", + "Account dauerhaft löschen": "Permanently delete account", + "Meine gespeicherten Daten exportieren (JSON)": "Export my stored data (JSON)", + "MetalCircle - Registrierung": "MetalCircle - Registration", + "Einladung angenommen": "Invitation accepted", + "Erstelle deinen Account für MetalCircle.": "Create your MetalCircle account.", + "Benutzername": "Username", + "E-Mail": "Email", + "Account erstellen": "Create account", + "🎸 Konzert-Meilensteine": "🎸 Concert milestones", + "Gründer": "Founder", + "Verantwortung für MetalCircle": "Responsibility for MetalCircle", + "Die treue Gefährtin des Captains": "The Captain’s loyal companion", + "Dein erstes besuchtes Konzert": "Your first concert attended", + "Erstes Konzert im Ausland": "First concert abroad", + "Konzerte in 5 Ländern": "Concerts in 5 countries", + "Ungültige Veranstaltungskategorie.": "Invalid event category.", + "Eine Veranstaltung kann nicht mit sich selbst verknüpft werden.": "An event cannot be linked to itself.", + "Die Hauptveranstaltung muss ein Konzert oder Festival sein.": "The parent event must be a concert or festival.", + "Die Hauptveranstaltung ist bereits beendet und kann nicht mehr verknüpft werden.": "The parent event has already ended and can no longer be linked.", + "Bitte einen gültigen Instagram-Profillink eingeben.": "Please enter a valid Instagram profile link.", + "

Nicht erlaubt

": "

Not allowed

", + "

Ein Ort kann nicht mit sich selbst zusammengeführt werden.

": "

A venue cannot be merged with itself.

", + "

Bitte ein Bild auswählen.

": "

Please select an image.

", + "

Ungültige Rolle

": "

Invalid role

", + "

Die eigenen Adminrechte können nicht entfernt werden.

": "

You cannot remove your own admin rights.

", + "

Der eigene Account kann nicht gelöscht werden.

": "

You cannot delete your own account.

", + "

Ungültiger Linktyp

": "

Invalid link type

", + "

Benutzer nicht gefunden

": "

User not found

", + "

Reset-Link ungültig oder abgelaufen.

": "

Reset link invalid or expired.

", + "

Fehler

Der Benutzername muss mindestens 3 Zeichen lang sein.

": "

Error

The username must contain at least 3 characters.

", + "

Fehler

Das Passwort muss mindestens 10 Zeichen lang sein.

": "

Error

The password must contain at least 10 characters.

", + "

Ungültige Einladung

": "

Invalid invitation

", + "Instagram verknüpft": "Instagram linked", + "Ungültige Profilsichtbarkeit.": "Invalid profile visibility.", + "Benutzer nicht gefunden.": "User not found.", + "Ungültige Aktion.": "Invalid action.", + "Eine Nachricht muss zwischen 1 und 2000 Zeichen lang sein.": "A message must contain between 1 and 2000 characters.", + "Veranstaltung nicht gefunden.": "Event not found.", + "Diese Veranstaltung hat keine zugeordnete Location.": "This event has no assigned venue.", + "Das Tagebuch ist nur für vergangene Konzerte mit einer optionalen Bewertung von 1 bis 5 verfügbar.": "The diary is only available for past concerts, with an optional rating from 1 to 5.", + "Veranstaltung nicht gefunden": "Event not found", + "

Veranstaltung nicht gefunden

": "

Event not found

", + "

Der Titel oder Künstlername muss zwischen 2 und 255 Zeichen lang sein.

": "

The title or artist name must contain between 2 and 255 characters.

", + "

Ungültige Veranstaltungskategorie.

": "

Invalid event category.

", + "

Ungültige Sichtbarkeit.

": "

Invalid visibility.

", + "

Das Enddatum darf nicht vor dem Beginn liegen.

": "

The end date must not be before the start.

", + "

Vergangene Veranstaltungen dürfen nur Admins bearbeiten.

": "

Only admins may edit past events.

", + "

Ungültige Auswahl

": "

Invalid selection

", + "CSRF-Prüfung fehlgeschlagen.": "CSRF check failed.", + "Ungültige Hauptveranstaltung.": "Invalid parent event.", + "Ungültiges Bildformat. Erlaubt sind JPG, JPEG, PNG und WEBP.": "Invalid image format. JPG, JPEG, PNG and WEBP are allowed.", + "Die Datei darf maximal 10 MB groß sein.": "The file must not exceed 10 MB.", + "Die sichere Bildprüfung ist noch nicht installiert. Bitte den Web-Container neu bauen.": "Secure image validation is not installed yet. Please rebuild the web container.", + "Passwörter müssen übereinstimmen und mindestens 10 Zeichen lang sein.": "Passwords must match and contain at least 10 characters.", + "

Mögliche doppelte Veranstaltung

Am selben Tag existiert bereits eine Veranstaltung mit einem sehr ähnlichen Künstlernamen.

Please go back, check the match and confirm the warning in the form if you still want to save.

", + "Zu viele Anfragen. Bitte später erneut versuchen.": "Too many requests. Please try again later.", + "Bildauflösung zu groß": "Image resolution too large", + "Die Datei ist kein gültiges oder unterstütztes Bild.": "The file is not a valid or supported image.", + "

Der Gründer Kai muss Admin bleiben.

": "

Founder Kai must remain an admin.

", + "

Der Gründer Kai kann nicht gelöscht werden.

": "

Founder Kai cannot be deleted.

", + "

Fehler

Dieser Benutzername ist bereits vergeben.

": "

Error

This username is already taken.

", + "

Fehler

Diese E-Mail-Adresse ist bereits registriert.

": "

Error

This email address is already registered.", + "Freundschaftsanfrage wegen einer Blockierung nicht möglich.": "A friend request is not possible because of a block.", + "Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar.": "Chat not allowed. It is available for friends and conversations with admins.", + "Ein Tagebucheintrag ist nur für besuchte Konzerte möglich.": "Diary entries are only available for attended concerts.", + "Pro Tagebucheintrag sind maximal drei Bilder möglich.": "Each diary entry can have a maximum of three images.", + "Ein Mitglied": "A member", + "Von Anfang an dabei und MetalCircle aufgebaut": "Here from the start and helped build MetalCircle", + "In der Beta dabei": "Joined during the beta", + "Erster Gig": "First gig", + "Stammgast": "Regular", + "5 Konzerte am selben Veranstaltungsort besucht": "Attended 5 concerts at the same venue", + "10 besuchte Konzerte": "10 concerts attended", + "25 besuchte Konzerte": "25 concerts attended", + "50 besuchte Konzerte": "50 concerts attended", + "100 besuchte Konzerte": "100 concerts attended", + "Zwei Konzerte an zwei aufeinanderfolgenden Tagen": "Two concerts on two consecutive days", + "Drei Konzerte innerhalb eines Wochenendes": "Three concerts in one weekend", + "⚔️ Spezial-Patches": "⚔️ Special patches", + "🤘 Stammorte": "🤘 Regular venues", + "🌍 Unterwegs": "🌍 On the road", + "⚡ Tourmodus": "⚡ Tour mode", + "Anfrage aus fremder Quelle abgelehnt.": "Request from an external source rejected.", + "

Der Name darf nicht leer sein.

": "

The name must not be empty.

", + "

Veranstaltungsort nicht gefunden.

": "

Venue not found.

", + "

Patch nicht gefunden

": "

Patch not found

", + "

Diese Einladung wurde bereits verwendet.

": "

This invitation has already been used.

", + "

Diese Einladung ist abgelaufen.

": "

This invitation has expired.

", + "Benutzername oder Passwort ist falsch.": "Incorrect username or password.", + "

Profil konnte nicht gespeichert werden.

": "

Profile could not be saved.

", + "Du kannst dir nicht selbst eine Anfrage schicken.": "You cannot send yourself a friend request.", + "Du kannst dich nicht selbst blockieren.": "You cannot block yourself.", + "Freundschaft nicht gefunden.": "Friendship not found.", + "Diese Anfrage kann nicht angenommen werden.": "This request cannot be accepted.", + "Diese Anfrage kann nicht abgelehnt werden.": "This request cannot be declined.", + "Band nicht gefunden.": "Band not found.", + "Bild nicht gefunden.": "Image not found.", + "Tagebucheintrag ist zu lang.": "Diary entry is too long.", + "

Bei Festivals ist ein Enddatum erforderlich.

": "

Festivals require an end date.

", + "Keine passenden Treffer – Eingabe trotzdem speichern": "No matching results – save your entry anyway", + "Als eigenen Veranstaltungsort anlegen": "Create as a new venue", + "✏️ „{name}“ verwenden": "✏️ Use “{name}”", + "Du kannst noch {count} Bild(er) hinzufügen.": "You can add {count} more image(s).", + "Bitte für {field} eine vollständige HTTP- oder HTTPS-Adresse eingeben.": "Please enter a complete HTTP or HTTPS address for {field}.", + "Sprache": "Language", + "Sprache wechseln": "Switch language", + "Ein technisch notwendiges Cookie speichert außerdem deine gewählte Sprache (Deutsch oder Englisch) für ein Jahr.": "A technically necessary cookie also stores your chosen language (German or English) for one year.", + "Ungültige Sprache.": "Invalid language.", + "den Flyer": "the flyer", + "Zugesagt": "Going", + "

Kommentar ist zu lang (maximal 2000 Zeichen).

": "

Comment is too long (maximum 2000 characters).

", + "Profil gespeichert": "Profile saved", + "Profilbild aktualisiert": "Profile picture updated", + "Zusagen": "going", + "Vielleicht": "Maybe", + "sucht Ticket": "looking for a ticket", + "Ort endgültig löschen?": "Permanently delete this venue?", + "Der Ort ist mit {count} Konzert(en) verknüpft. Diese Konzerte haben danach keinen Veranstaltungsort mehr. Ort endgültig löschen?": "This venue is linked to {count} concert(s). These concerts will no longer have a venue afterwards. Permanently delete this venue?", + "Patch-Bild entfernen und wieder das Symbol verwenden?": "Remove the patch image and use the symbol again?", + "Diese Veranstaltung wirklich löschen?": "Really delete this event?", + "Alle Konzerte werden dem Zielort zugeordnet und dieser Eintrag wird gelöscht. Fortfahren?": "All concerts will be assigned to the target venue and this entry will be deleted. Continue?", + "Diesen Benutzer wirklich löschen?": "Really delete this user?", + "Dieses Bild entfernen?": "Remove this image?", + "Wirklich unwiderruflich löschen? Die Veranstaltung kann nicht mehr eingetragen werden.": "Really delete permanently? This event cannot be added again.", + "Diesen Nutzer wirklich blockieren?": "Really block this user?", + "Account wirklich dauerhaft löschen?": "Really delete your account permanently?" +} diff --git a/app/main.py b/app/main.py index ffe6638..870f88a 100644 --- a/app/main.py +++ b/app/main.py @@ -30,6 +30,10 @@ from fastapi.encoders import jsonable_encoder from fastapi.staticfiles import StaticFiles from jinja2 import Environment, FileSystemLoader, select_autoescape +from i18n import ( + LANGUAGE_COOKIE, current_language, current_page, gettext as _, + language_url, safe_return_path, format_time, format_datetime, +) DATABASE_URL = os.environ["DATABASE_URL"] @@ -436,6 +440,14 @@ def ensure_schema(): ) """, """ + CREATE TABLE IF NOT EXISTS concert_diary_exclusions ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, concert_id) + ) + """, + """ ALTER TABLE concert_diary ADD COLUMN IF NOT EXISTS photo_path TEXT """, """ @@ -531,9 +543,9 @@ async def security_controls(request: Request, call_next): referer = request.headers.get("referer") referer_host = urlparse(referer).netloc if referer else None if fetch_site == "cross-site" or (origin_host and origin_host != expected_host) or (referer_host and referer_host != expected_host): - return HTMLResponse("Anfrage aus fremder Quelle abgelehnt.", status_code=403) + return HTMLResponse(_("Anfrage aus fremder Quelle abgelehnt."), status_code=403) if request.url.path not in {"/login", "/register"} and request.url.path.startswith("/password-reset") is False and not origin and not referer: - return HTMLResponse("CSRF-Prüfung fehlgeschlagen.", status_code=403) + return HTMLResponse(_("CSRF-Prüfung fehlgeschlagen."), status_code=403) path = request.url.path if path == "/login": @@ -554,7 +566,7 @@ async def security_controls(request: Request, call_next): while bucket and bucket[0] <= now - window: bucket.popleft() if len(bucket) >= limit: - return HTMLResponse("Zu viele Anfragen. Bitte später erneut versuchen.", status_code=429, + return HTMLResponse(_("Zu viele Anfragen. Bitte später erneut versuchen."), status_code=429, headers={"Retry-After": str(window)}) bucket.append(now) @@ -576,6 +588,7 @@ async def require_login(request: Request, call_next): or request.url.path.startswith("/register/") or request.url.path.startswith("/password-reset") or request.url.path in {"/impressum", "/datenschutz"} + or request.url.path.startswith("/language/") or request.url.path == "/profile/export" or request.url.path.startswith("/static/") ): @@ -587,6 +600,39 @@ async def require_login(request: Request, call_next): return login_redirect("/") +@app.middleware("http") +async def localize_request(request: Request, call_next): + language = request.cookies.get(LANGUAGE_COOKIE, "de") + language = language if language in {"de", "en"} else "de" + language_token = current_language.set(language) + page_token = current_page.set(safe_return_path( + request.url.path + ("?" + request.url.query if request.url.query else "") + )) + try: + response = await call_next(request) + response.headers["Content-Language"] = language + vary = response.headers.get("Vary", "") + if "cookie" not in {item.strip().lower() for item in vary.split(",")}: + response.headers["Vary"] = (vary + ", Cookie").lstrip(", ") + return response + finally: + current_page.reset(page_token) + current_language.reset(language_token) + + +@app.get("/language/{language}") +def change_language(language: str, next: str = "/"): + if language not in {"de", "en"}: + return HTMLResponse(_("Ungültige Sprache."), status_code=400) + response = RedirectResponse(safe_return_path(next), status_code=303) + response.set_cookie( + LANGUAGE_COOKIE, language, max_age=365 * 24 * 60 * 60, + httponly=True, secure=COOKIE_SECURE, samesite="lax", path="/", + ) + response.headers["Cache-Control"] = "no-store" + return response + + # ============================================================ # Templates # ============================================================ @@ -597,6 +643,8 @@ templates = Environment( ), autoescape=select_autoescape(["html"]) ) +templates.globals.update(_=_, language=current_language.get, language_url=language_url) +templates.filters["t"] = _ # ============================================================ @@ -786,16 +834,16 @@ def get_linkable_events(exclude_id: int | None = None): def resolve_event_relationship(cursor, event_type: str, parent_event_id: str, event_id=None): if event_type not in EVENT_TYPES: - raise ValueError("Ungültige Veranstaltungskategorie.") + raise ValueError(_("Ungültige Veranstaltungskategorie.")) if event_type != "other" or not parent_event_id: return None try: parent_id = int(parent_event_id) except ValueError as error: - raise ValueError("Ungültige Hauptveranstaltung.") from error + raise ValueError(_("Ungültige Hauptveranstaltung.")) from error if event_id is not None and parent_id == event_id: - raise ValueError("Eine Veranstaltung kann nicht mit sich selbst verknüpft werden.") + raise ValueError(_("Eine Veranstaltung kann nicht mit sich selbst verknüpft werden.")) cursor.execute( """ @@ -815,9 +863,9 @@ def resolve_event_relationship(cursor, event_type: str, parent_event_id: str, ev ) parent = cursor.fetchone() if not parent or parent[0] not in {"concert", "festival"}: - raise ValueError("Die Hauptveranstaltung muss ein Konzert oder Festival sein.") + raise ValueError(_("Die Hauptveranstaltung muss ein Konzert oder Festival sein.")) if not parent[1]: - raise ValueError("Die Hauptveranstaltung ist bereits beendet und kann nicht mehr verknüpft werden.") + raise ValueError(_("Die Hauptveranstaltung ist bereits beendet und kann nicht mehr verknüpft werden.")) return parent_id @@ -882,10 +930,10 @@ def load_concert(concert_id: int): "created_by": row[8], "is_past": is_past, "date": start_datetime.strftime("%d.%m.%Y"), - "time": start_datetime.strftime("%H:%M"), + "time": format_time(start_datetime), "start_local": start_datetime.strftime("%Y-%m-%dT%H:%M"), "end_date": end_datetime.strftime("%d.%m.%Y") if end_datetime else None, - "end_time": end_datetime.strftime("%H:%M") if end_datetime else None, + "end_time": format_time(end_datetime) if end_datetime else None, "end_local": end_datetime.strftime("%Y-%m-%dT%H:%M") if end_datetime else "", "event_type": row[17], "event_type_label": EVENT_TYPES[row[17]], @@ -950,7 +998,7 @@ def serialize_concert_card(row): "id": concert_id, "artist": artist, "date": start_datetime.strftime("%d.%m.%Y"), - "time": start_datetime.strftime("%H:%M"), + "time": format_time(start_datetime), "end_date": end_datetime.strftime("%d.%m.%Y") if end_datetime else None, "venue": venue_text, "city": city or "", @@ -1124,10 +1172,10 @@ def normalize_instagram_url(value: str): candidate = value if "://" in value else f"https://{value}" parsed = urlparse(candidate) if (parsed.hostname or "").lower() not in {"instagram.com", "www.instagram.com"}: - raise ValueError("Bitte einen gültigen Instagram-Profillink eingeben.") + raise ValueError(_("Bitte einen gültigen Instagram-Profillink eingeben.")) username = parsed.path.strip("/").split("/", 1)[0] if not re.fullmatch(r"[A-Za-z0-9._]{1,30}", username): - raise ValueError("Bitte einen gültigen Instagram-Profillink eingeben.") + raise ValueError(_("Bitte einen gültigen Instagram-Profillink eingeben.")) return f"https://www.instagram.com/{username}/" @@ -1137,7 +1185,7 @@ def normalize_external_url(value: str, field_name: str): return None parsed = urlparse(value) if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password: - raise ValueError(f"Bitte für {field_name} eine vollständige HTTP- oder HTTPS-Adresse eingeben.") + raise ValueError(_("Bitte für {field} eine vollständige HTTP- oder HTTPS-Adresse eingeben.").format(field=_(field_name))) return value @@ -1248,7 +1296,7 @@ def find_duplicate_concerts(user, artist: str, start_date: str, band_names: str "id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y"), - "time": row[2].strftime("%H:%M"), + "time": format_time(row[2]), "event_type": row[3], "venue": ", ".join(part for part in (row[4], row[5]) if part), } @@ -1269,7 +1317,7 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str): if extension not in ALLOWED_IMAGE_EXTENSIONS: return None, HTMLResponse( - "Ungültiges Bildformat. Erlaubt sind JPG, JPEG, PNG und WEBP.", + _("Ungültiges Bildformat. Erlaubt sind JPG, JPEG, PNG und WEBP."), status_code=400, ) @@ -1277,13 +1325,13 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str): if len(contents) > MAX_IMAGE_BYTES: return None, HTMLResponse( - "Die Datei darf maximal 10 MB groß sein.", + _("Die Datei darf maximal 10 MB groß sein."), status_code=400, ) if Image is None: return None, HTMLResponse( - "Die sichere Bildprüfung ist noch nicht installiert. Bitte den Web-Container neu bauen.", + _("Die sichere Bildprüfung ist noch nicht installiert. Bitte den Web-Container neu bauen."), status_code=503, ) @@ -1294,7 +1342,7 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str): with Image.open(BytesIO(contents)) as candidate: image = ImageOps.exif_transpose(candidate) if image.width * image.height > MAX_IMAGE_PIXELS: - raise ValueError("Bildauflösung zu groß") + raise ValueError(_("Bildauflösung zu groß")) image.thumbnail((2400, 2400)) has_alpha = image.mode in {"RGBA", "LA"} or ( image.mode == "P" and "transparency" in image.info @@ -1313,7 +1361,7 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str): safe_contents = output.getvalue() except (ValueError, OSError, UnidentifiedImageError, Image.DecompressionBombError): return None, HTMLResponse( - "Die Datei ist kein gültiges oder unterstütztes Bild.", status_code=400 + _("Die Datei ist kein gültiges oder unterstütztes Bild."), status_code=400 ) filename = str(uuid.uuid4()) + (".png" if output_format == "PNG" else ".jpg") @@ -1706,7 +1754,7 @@ def get_admin_venues(): @app.get("/admin") def admin_page(request: Request): if not require_admin(request): - return HTMLResponse("

Nicht erlaubt

", status_code=403) + return HTMLResponse(_("

Nicht erlaubt

"), status_code=403) return RedirectResponse("/admin/users", status_code=303) @@ -1715,7 +1763,7 @@ def admin_users_page(request: Request): user = require_admin(request) if not user: - return HTMLResponse("

Nicht erlaubt

", status_code=403) + return HTMLResponse(_("

Nicht erlaubt

"), status_code=403) with get_db_connection() as connection: with connection.cursor() as cursor: @@ -1759,7 +1807,7 @@ def admin_users_page(request: Request): def admin_venues_page(request: Request): user = require_admin(request) if not user: - return HTMLResponse("

Nicht erlaubt

", status_code=403) + return HTMLResponse(_("

Nicht erlaubt

"), status_code=403) template = templates.get_template("admin_venues.html") return template.render(user=user, venues=get_admin_venues()) @@ -1768,7 +1816,7 @@ def admin_venues_page(request: Request): def admin_patches_page(request: Request): user = require_admin(request) if not user: - return HTMLResponse("

Nicht erlaubt

", status_code=403) + return HTMLResponse(_("

Nicht erlaubt

"), status_code=403) assets = load_badge_assets() patches = [ { @@ -1798,7 +1846,7 @@ def admin_patches_page(request: Request): def admin_statistics_page(request: Request): user = require_admin(request) if not user: - return HTMLResponse("

Nicht erlaubt

", status_code=403) + return HTMLResponse(_("

Nicht erlaubt

"), status_code=403) with get_db_connection() as connection: with connection.cursor() as cursor: queries = { @@ -1827,7 +1875,7 @@ def create_invite(request: Request): user = require_admin(request) if not user: - return HTMLResponse("

Nicht erlaubt

", status_code=403) + return HTMLResponse(_("

Nicht erlaubt

"), status_code=403) token = secrets.token_urlsafe(32) @@ -1902,11 +1950,11 @@ def update_venue( is_verified: str = Form(""), ): if not require_admin(request): - return HTMLResponse("

Nicht erlaubt

", status_code=403) + return HTMLResponse(_("

Nicht erlaubt

"), status_code=403) name = name.strip() if not name: - return HTMLResponse("

Der Name darf nicht leer sein.

", status_code=400) + return HTMLResponse(_("

Der Name darf nicht leer sein.

"), status_code=400) normalized_aliases = sorted({ alias.strip() @@ -1950,15 +1998,15 @@ def merge_venue( target_venue_id: int = Form(...), ): if not require_admin(request): - return HTMLResponse("

Nicht erlaubt

", status_code=403) + return HTMLResponse(_("

Nicht erlaubt

"), status_code=403) if venue_id == target_venue_id: - return HTMLResponse("

Ein Ort kann nicht mit sich selbst zusammengeführt werden.

", status_code=400) + return HTMLResponse(_("

Ein Ort kann nicht mit sich selbst zusammengeführt werden.

"), status_code=400) with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute("SELECT id FROM venues WHERE id IN (%s, %s)", (venue_id, target_venue_id)) if len(cursor.fetchall()) != 2: - return HTMLResponse("

Veranstaltungsort nicht gefunden.

", status_code=404) + return HTMLResponse(_("

Veranstaltungsort nicht gefunden.

"), status_code=404) cursor.execute( """ INSERT INTO venue_aliases (venue_id, alias) @@ -1988,7 +2036,7 @@ def merge_venue( @app.post("/admin/venues/{venue_id}/delete") def delete_venue(request: Request, venue_id: int): if not require_admin(request): - return HTMLResponse("

Nicht erlaubt

", status_code=403) + return HTMLResponse(_("

Nicht erlaubt

"), status_code=403) with get_db_connection() as connection: with connection.cursor() as cursor: @@ -2011,15 +2059,15 @@ async def update_patch_image( user = require_admin(request) valid_codes = {definition[0] for definition in BADGE_DEFINITIONS} if not user: - return HTMLResponse("

Nicht erlaubt

", status_code=403) + return HTMLResponse(_("

Nicht erlaubt

"), status_code=403) if badge_code not in valid_codes: - return HTMLResponse("

Patch nicht gefunden

", status_code=404) + return HTMLResponse(_("

Patch nicht gefunden

"), status_code=404) image_path, error = save_image(image, PATCH_DIR, "/static/uploads/patches/") if error: return error if not image_path: - return HTMLResponse("

Bitte ein Bild auswählen.

", status_code=400) + return HTMLResponse(_("

Bitte ein Bild auswählen.

"), status_code=400) with get_db_connection() as connection: with connection.cursor() as cursor: @@ -2045,9 +2093,9 @@ async def update_patch_image( @app.post("/admin/patches/{badge_code}/delete") def delete_patch_image(request: Request, badge_code: str): if not require_admin(request): - return HTMLResponse("

Nicht erlaubt

", status_code=403) + return HTMLResponse(_("

Nicht erlaubt

"), status_code=403) if badge_code not in {definition[0] for definition in BADGE_DEFINITIONS}: - return HTMLResponse("

Patch nicht gefunden

", status_code=404) + return HTMLResponse(_("

Patch nicht gefunden

"), status_code=404) with get_db_connection() as connection: with connection.cursor() as cursor: @@ -2069,12 +2117,12 @@ def update_user_role( user = require_admin(request) if not user: - return HTMLResponse("

Nicht erlaubt

", status_code=403) + return HTMLResponse(_("

Nicht erlaubt

"), status_code=403) if role not in {"admin", "user"}: - return HTMLResponse("

Ungültige Rolle

", status_code=400) + return HTMLResponse(_("

Ungültige Rolle

"), status_code=400) if user_id == user["id"] and role != "admin": return HTMLResponse( - "

Die eigenen Adminrechte können nicht entfernt werden.

", + _("

Die eigenen Adminrechte können nicht entfernt werden.

"), status_code=400, ) @@ -2084,7 +2132,7 @@ def update_user_role( target_user = cursor.fetchone() if target_user and (target_user[0] or "").casefold() == "kai" and role != "admin": return HTMLResponse( - "

Der Gründer Kai muss Admin bleiben.

", + _("

Der Gründer Kai muss Admin bleiben.

"), status_code=400, ) cursor.execute( @@ -2101,10 +2149,10 @@ def delete_user(request: Request, user_id: int): user = require_admin(request) if not user: - return HTMLResponse("

Nicht erlaubt

", status_code=403) + return HTMLResponse(_("

Nicht erlaubt

"), status_code=403) if user_id == user["id"]: return HTMLResponse( - "

Der eigene Account kann nicht gelöscht werden.

", + _("

Der eigene Account kann nicht gelöscht werden.

"), status_code=400, ) @@ -2114,7 +2162,7 @@ def delete_user(request: Request, user_id: int): target_user = cursor.fetchone() if target_user and (target_user[0] or "").casefold() == "kai": return HTMLResponse( - "

Der Gründer Kai kann nicht gelöscht werden.

", + _("

Der Gründer Kai kann nicht gelöscht werden.

"), status_code=400, ) cursor.execute("SELECT path FROM concert_photos WHERE user_id = %s", (user_id,)) @@ -2134,15 +2182,15 @@ def delete_user(request: Request, user_id: int): def create_user_account_link(request: Request, user_id: int, purpose: str = Form(...)): user = require_admin(request) if not user: - return HTMLResponse("

Nicht erlaubt

", status_code=403) + return HTMLResponse(_("

Nicht erlaubt

"), status_code=403) if purpose != "password_reset": - return HTMLResponse("

Ungültiger Linktyp

", status_code=400) + return HTMLResponse(_("

Ungültiger Linktyp

"), status_code=400) with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute("SELECT username, email FROM users WHERE id = %s", (user_id,)) target = cursor.fetchone() if not target: - return HTMLResponse("

Benutzer nicht gefunden

", status_code=404) + return HTMLResponse(_("

Benutzer nicht gefunden

"), status_code=404) token = create_account_token(user_id, purpose, 2) path = f"/password-reset/{token}" template = templates.get_template("account_link.html") @@ -2166,7 +2214,7 @@ def password_reset_page(token: str): ) valid = cursor.fetchone() if not valid: - return HTMLResponse("

Reset-Link ungültig oder abgelaufen.

", status_code=410) + return HTMLResponse(_("

Reset-Link ungültig oder abgelaufen.

"), status_code=410) return templates.get_template("password_reset.html").render(token=token, error=None) @@ -2174,7 +2222,7 @@ def password_reset_page(token: str): def password_reset(token: str, password: str = Form(...), password_repeat: str = Form(...)): if len(password) < 10 or password != password_repeat: return templates.get_template("password_reset.html").render( - token=token, error="Passwörter müssen übereinstimmen und mindestens 10 Zeichen lang sein." + token=token, error=_("Passwörter müssen übereinstimmen und mindestens 10 Zeichen lang sein.") ) with get_db_connection() as connection: with connection.cursor() as cursor: @@ -2188,7 +2236,7 @@ def password_reset(token: str, password: str = Form(...), password_repeat: str = ) account_token = cursor.fetchone() if not account_token: - return HTMLResponse("

Reset-Link ungültig oder abgelaufen.

", status_code=410) + return HTMLResponse(_("

Reset-Link ungültig oder abgelaufen.

"), status_code=410) password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") cursor.execute("UPDATE users SET password_hash = %s WHERE id = %s", (password_hash, account_token[1])) cursor.execute("UPDATE account_tokens SET used_at = CURRENT_TIMESTAMP WHERE id = %s", (account_token[0],)) @@ -2211,13 +2259,13 @@ def register_user( if len(username) < 3: return HTMLResponse( - "

Fehler

Der Benutzername muss mindestens 3 Zeichen lang sein.

", + _("

Fehler

Der Benutzername muss mindestens 3 Zeichen lang sein.

"), status_code=400 ) if len(password) < 10: return HTMLResponse( - "

Fehler

Das Passwort muss mindestens 10 Zeichen lang sein.

", + _("

Fehler

Das Passwort muss mindestens 10 Zeichen lang sein.

"), status_code=400 ) @@ -2241,7 +2289,7 @@ def register_user( if not invite: return HTMLResponse( - "

Ungültige Einladung

", + _("

Ungültige Einladung

"), status_code=404 ) @@ -2249,13 +2297,13 @@ def register_user( if used_at: return HTMLResponse( - "

Diese Einladung wurde bereits verwendet.

", + _("

Diese Einladung wurde bereits verwendet.

"), status_code=410 ) if expires_at and datetime.now() > expires_at: return HTMLResponse( - "

Diese Einladung ist abgelaufen.

", + _("

Diese Einladung ist abgelaufen.

"), status_code=410 ) @@ -2270,7 +2318,7 @@ def register_user( if cursor.fetchone(): return HTMLResponse( - "

Fehler

Dieser Benutzername ist bereits vergeben.

", + _("

Fehler

Dieser Benutzername ist bereits vergeben.

"), status_code=400 ) @@ -2285,7 +2333,7 @@ def register_user( if cursor.fetchone(): return HTMLResponse( - "

Fehler

Diese E-Mail-Adresse ist bereits registriert.

", + _("

Fehler

Diese E-Mail-Adresse ist bereits registriert.

"), status_code=400 ) @@ -2370,7 +2418,7 @@ def register_page(token: str): if not invite: return HTMLResponse( - "

Ungültige Einladung

", + _("

Ungültige Einladung

"), status_code=404 ) @@ -2378,7 +2426,7 @@ def register_page(token: str): if used_at: return HTMLResponse( - "

Diese Einladung wurde bereits verwendet.

", + _("

Diese Einladung wurde bereits verwendet.

"), status_code=410 ) @@ -2387,7 +2435,7 @@ def register_page(token: str): if datetime.now() > expires_at: return HTMLResponse( - "

Diese Einladung ist abgelaufen.

", + _("

Diese Einladung ist abgelaufen.

"), status_code=410 ) @@ -2455,7 +2503,7 @@ def login( return HTMLResponse( template.render( next_path=next_path, - error="Benutzername oder Passwort ist falsch.", + error=_("Benutzername oder Passwort ist falsch."), ), status_code=401, ) @@ -2536,7 +2584,7 @@ def render_profile( profile = load_profile(username) if not profile: - return HTMLResponse("

Benutzer nicht gefunden

", status_code=404) + return HTMLResponse(_("

Benutzer nicht gefunden

"), status_code=404) is_own_profile = bool(viewer and viewer["id"] == profile["id"]) friendship = None @@ -2689,11 +2737,11 @@ def own_profile(request: Request, saved: str = ""): saved_items = set(saved.split(",")) messages = [] if "profile" in saved_items: - messages.append("Profil gespeichert") + messages.append(_("Profil gespeichert")) if "avatar" in saved_items: - messages.append("Profilbild aktualisiert") + messages.append(_("Profilbild aktualisiert")) if "instagram" in saved_items: - messages.append("Instagram verknüpft") + messages.append(_("Instagram verknüpft")) return render_profile( request, user["username"], @@ -2920,7 +2968,7 @@ async def update_profile( status_code=400, ) if profile_visibility not in {"public", "friends", "nobody"}: - return HTMLResponse("Ungültige Profilsichtbarkeit.", status_code=400) + return HTMLResponse(_("Ungültige Profilsichtbarkeit."), status_code=400) avatar_path, error = save_image( avatar, AVATAR_DIR, @@ -2952,7 +3000,7 @@ async def update_profile( if not saved_profile: remove_uploaded_file(avatar_path, AVATAR_DIR, "/static/uploads/avatars/") - return HTMLResponse("

Profil konnte nicht gespeichert werden.

", status_code=500) + return HTMLResponse(_("

Profil konnte nicht gespeichert werden.

"), status_code=500) if avatar_path and previous_profile and previous_profile[0] != avatar_path: remove_uploaded_file(previous_profile[0], AVATAR_DIR, "/static/uploads/avatars/") saved_parts = ["profile"] @@ -2971,9 +3019,9 @@ def send_friend_request(request: Request, username: str): user = get_current_user(request) profile = load_profile(username) if not profile: - return HTMLResponse("Benutzer nicht gefunden.", status_code=404) + return HTMLResponse(_("Benutzer nicht gefunden."), status_code=404) if profile["id"] == user["id"]: - return HTMLResponse("Du kannst dir nicht selbst eine Anfrage schicken.", status_code=400) + return HTMLResponse(_("Du kannst dir nicht selbst eine Anfrage schicken."), status_code=400) with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( @@ -2985,7 +3033,7 @@ def send_friend_request(request: Request, username: str): (user["id"], profile["id"], profile["id"], user["id"]), ) if cursor.fetchone(): - return HTMLResponse("Freundschaftsanfrage wegen einer Blockierung nicht möglich.", status_code=403) + return HTMLResponse(_("Freundschaftsanfrage wegen einer Blockierung nicht möglich."), status_code=403) cursor.execute( """ INSERT INTO friendships (requester_id, addressee_id) @@ -3003,9 +3051,9 @@ def block_user(request: Request, username: str): user = get_current_user(request) profile = load_profile(username) if not profile: - return HTMLResponse("Benutzer nicht gefunden.", status_code=404) + return HTMLResponse(_("Benutzer nicht gefunden."), status_code=404) if profile["id"] == user["id"]: - return HTMLResponse("Du kannst dich nicht selbst blockieren.", status_code=400) + return HTMLResponse(_("Du kannst dich nicht selbst blockieren."), status_code=400) with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( @@ -3032,7 +3080,7 @@ def unblock_user(request: Request, username: str): user = get_current_user(request) profile = load_profile(username) if not profile: - return HTMLResponse("Benutzer nicht gefunden.", status_code=404) + return HTMLResponse(_("Benutzer nicht gefunden."), status_code=404) with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( @@ -3047,7 +3095,7 @@ def unblock_user(request: Request, username: str): def manage_friendship(request: Request, friendship_id: int, action: str, return_to: str = Form("")): user = get_current_user(request) if action not in {"accept", "decline", "remove"}: - return HTMLResponse("Ungültige Aktion.", status_code=400) + return HTMLResponse(_("Ungültige Aktion."), status_code=400) with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( @@ -3056,17 +3104,17 @@ def manage_friendship(request: Request, friendship_id: int, action: str, return_ ) friendship = cursor.fetchone() if not friendship or user["id"] not in friendship[:2]: - return HTMLResponse("Freundschaft nicht gefunden.", status_code=404) + return HTMLResponse(_("Freundschaft nicht gefunden."), status_code=404) if action == "accept": if friendship[1] != user["id"] or friendship[2] != "pending": - return HTMLResponse("Diese Anfrage kann nicht angenommen werden.", status_code=403) + return HTMLResponse(_("Diese Anfrage kann nicht angenommen werden."), status_code=403) cursor.execute( "UPDATE friendships SET status = 'accepted', updated_at = CURRENT_TIMESTAMP WHERE id = %s", (friendship_id,), ) else: if action == "decline" and (friendship[1] != user["id"] or friendship[2] != "pending"): - return HTMLResponse("Diese Anfrage kann nicht abgelehnt werden.", status_code=403) + return HTMLResponse(_("Diese Anfrage kann nicht abgelehnt werden."), status_code=403) cursor.execute("DELETE FROM friendships WHERE id = %s", (friendship_id,)) connection.commit() other_id = friendship[1] if friendship[0] == user["id"] else friendship[0] @@ -3131,7 +3179,7 @@ def message_inbox(request: Request): ) friend_requests = [ {"id": row[0], "username": row[1], "display_name": row[2], - "created_at": row[3].strftime("%d.%m.%Y %H:%M")} + "created_at": format_datetime(row[3])} for row in cursor.fetchall() ] cursor.execute( @@ -3148,7 +3196,7 @@ def message_inbox(request: Request): ) event_invitations = [ {"concert_id": row[0], "artist": row[1], - "date": row[2].strftime("%d.%m.%Y %H:%M"), "invited_by": row[3] or "Ein Mitglied"} + "date": format_datetime(row[2]), "invited_by": row[3] or _("Ein Mitglied")} for row in cursor.fetchall() ] cursor.execute( @@ -3188,7 +3236,7 @@ def message_inbox(request: Request): conversations.append({ "id": row[0], "username": row[1], "display_name": row[2], "avatar_path": row[3], "last_message": latest[0] if latest else None, - "last_at": latest[1].strftime("%d.%m.%Y %H:%M") if latest else None, + "last_at": format_datetime(latest[1]) if latest else None, "last_at_raw": latest[1] if latest else None, "last_from_me": bool(latest and latest[2] == user["id"]), "unread_count": latest[3] if latest else 0, @@ -3206,7 +3254,7 @@ def message_conversation(request: Request, username: str): with connection.cursor() as cursor: partner = load_chat_partner(cursor, user, username) if not partner: - return HTMLResponse("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar.", status_code=403) + return HTMLResponse(_("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar."), status_code=403) cursor.execute( """ UPDATE direct_messages SET read_at = CURRENT_TIMESTAMP @@ -3227,7 +3275,7 @@ def message_conversation(request: Request, username: str): ) messages = [ {"from_me": row[0] == user["id"], "body": row[1], - "created_at": row[2].strftime("%d.%m.%Y %H:%M")} + "created_at": format_datetime(row[2])} for row in cursor.fetchall() ] connection.commit() @@ -3240,12 +3288,12 @@ def send_message(request: Request, username: str, body: str = Form(...)): user = get_current_user(request) body = body.strip() if not body or len(body) > 2000: - return HTMLResponse("Eine Nachricht muss zwischen 1 und 2000 Zeichen lang sein.", status_code=400) + return HTMLResponse(_("Eine Nachricht muss zwischen 1 und 2000 Zeichen lang sein."), status_code=400) with get_db_connection() as connection: with connection.cursor() as cursor: partner = load_chat_partner(cursor, user, username) if not partner: - return HTMLResponse("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar.", status_code=403) + return HTMLResponse(_("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar."), status_code=403) cursor.execute( "INSERT INTO direct_messages (sender_id, recipient_id, body) VALUES (%s, %s, %s)", (user["id"], partner["id"], body), @@ -3331,7 +3379,7 @@ def following_page(request: Request): events = [ { "id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y"), - "time": row[2].strftime("%H:%M"), "venue": ", ".join(filter(None, (row[4], row[5]))), + "time": format_time(row[2]), "venue": ", ".join(filter(None, (row[4], row[5]))), "matched_band": any( artist_names_similar(event_band["name"], followed_band) for event_band in (candidate_bands.get(row[0]) or parse_band_names("", row[1], row[8])) @@ -3376,11 +3424,11 @@ def follow_band(request: Request, concert_id: int, band_key: str = Form(...), ac user = get_current_user(request) concert = load_concert(concert_id) if not concert or not can_view_event(user, concert): - return HTMLResponse("Veranstaltung nicht gefunden.", status_code=404) + return HTMLResponse(_("Veranstaltung nicht gefunden."), status_code=404) bands = load_concert_bands(concert_id, concert["artist"], concert["event_type"]) selected_band = next((band for band in bands if band["key"] == band_key), None) if not selected_band: - return HTMLResponse("Band nicht gefunden.", status_code=404) + return HTMLResponse(_("Band nicht gefunden."), status_code=404) with get_db_connection() as connection: with connection.cursor() as cursor: if action == "unfollow": @@ -3391,7 +3439,7 @@ def follow_band(request: Request, concert_id: int, band_key: str = Form(...), ac (user["id"], band_key, selected_band["name"]), ) else: - return HTMLResponse("Ungültige Aktion.", status_code=400) + return HTMLResponse(_("Ungültige Aktion."), status_code=400) connection.commit() return RedirectResponse(f"/concerts/{concert_id}#following", status_code=303) @@ -3401,10 +3449,10 @@ def follow_venue(request: Request, concert_id: int, action: str = Form("follow") user = get_current_user(request) concert = load_concert(concert_id) if not concert or not can_view_event(user, concert): - return HTMLResponse("Veranstaltung nicht gefunden.", status_code=404) + return HTMLResponse(_("Veranstaltung nicht gefunden."), status_code=404) venue_id = concert["venue"]["id"] if not venue_id: - return HTMLResponse("Diese Veranstaltung hat keine zugeordnete Location.", status_code=400) + return HTMLResponse(_("Diese Veranstaltung hat keine zugeordnete Location."), status_code=400) with get_db_connection() as connection: with connection.cursor() as cursor: if action == "unfollow": @@ -3412,7 +3460,7 @@ def follow_venue(request: Request, concert_id: int, action: str = Form("follow") elif action == "follow": cursor.execute("INSERT INTO followed_venues (user_id, venue_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", (user["id"], venue_id)) else: - return HTMLResponse("Ungültige Aktion.", status_code=400) + return HTMLResponse(_("Ungültige Aktion."), status_code=400) connection.commit() return RedirectResponse(f"/concerts/{concert_id}#following", status_code=303) @@ -3429,6 +3477,10 @@ def diary_page(request: Request): FROM concert_attendance ca JOIN concerts c ON c.id = ca.concert_id WHERE ca.user_id = %s AND ca.status = 'attending' AND COALESCE(c.end_datetime, c.start_datetime) < CURRENT_TIMESTAMP + AND NOT EXISTS ( + SELECT 1 FROM concert_diary_exclusions de + WHERE de.user_id = ca.user_id AND de.concert_id = ca.concert_id + ) ON CONFLICT (user_id, concert_id) DO NOTHING """, (user["id"],), @@ -3486,7 +3538,7 @@ def diary_page(request: Request): def diary_photo(request: Request, filename: str): user = get_current_user(request) if not filename or os.path.basename(filename) != filename: - return HTMLResponse("Bild nicht gefunden.", status_code=404) + return HTMLResponse(_("Bild nicht gefunden."), status_code=404) photo_path = f"/diary/photo/{filename}" with get_db_connection() as connection: with connection.cursor() as cursor: @@ -3495,10 +3547,10 @@ def diary_photo(request: Request, filename: str): (user["id"], photo_path), ) if not cursor.fetchone(): - return HTMLResponse("Bild nicht gefunden.", status_code=404) + return HTMLResponse(_("Bild nicht gefunden."), status_code=404) file_path = os.path.join(DIARY_PHOTO_DIR, filename) if not os.path.isfile(file_path): - return HTMLResponse("Bild nicht gefunden.", status_code=404) + return HTMLResponse(_("Bild nicht gefunden."), status_code=404) return FileResponse(file_path) @@ -3511,17 +3563,17 @@ def save_diary_entry( user = get_current_user(request) concert = load_concert(concert_id) if not concert or not can_view_event(user, concert): - return HTMLResponse("Veranstaltung nicht gefunden.", status_code=404) + return HTMLResponse(_("Veranstaltung nicht gefunden."), status_code=404) try: rating_value = int(rating) if rating else None except ValueError: rating_value = None if not concert["is_past"] or (rating_value is not None and rating_value not in range(1, 6)): - return HTMLResponse("Das Tagebuch ist nur für vergangene Konzerte mit einer optionalen Bewertung von 1 bis 5 verfügbar.", status_code=400) + return HTMLResponse(_("Das Tagebuch ist nur für vergangene Konzerte mit einer optionalen Bewertung von 1 bis 5 verfügbar."), status_code=400) favorite_song = favorite_song.strip() notes = notes.strip() if len(favorite_song) > 255 or len(notes) > 5000: - return HTMLResponse("Tagebucheintrag ist zu lang.", status_code=400) + return HTMLResponse(_("Tagebucheintrag ist zu lang."), status_code=400) with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( @@ -3529,12 +3581,12 @@ def save_diary_entry( (concert_id, user["id"]), ) if not cursor.fetchone(): - return HTMLResponse("Ein Tagebucheintrag ist nur für besuchte Konzerte möglich.", status_code=403) + return HTMLResponse(_("Ein Tagebucheintrag ist nur für besuchte Konzerte möglich."), status_code=403) cursor.execute("SELECT COUNT(*) FROM concert_diary_photos WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id)) existing_photo_count = cursor.fetchone()[0] uploads = [photo for photo in photos if photo and photo.filename] if existing_photo_count + len(uploads) > 3: - return HTMLResponse("Pro Tagebucheintrag sind maximal drei Bilder möglich.", status_code=400) + return HTMLResponse(_("Pro Tagebucheintrag sind maximal drei Bilder möglich."), status_code=400) saved_paths = [] for photo in uploads: saved_path, error = save_image(photo, DIARY_PHOTO_DIR, "/diary/photo/") @@ -3568,7 +3620,7 @@ def delete_diary_photo(request: Request, photo_id: int): deleted = cursor.fetchone() connection.commit() if not deleted: - return HTMLResponse("Bild nicht gefunden.", status_code=404) + return HTMLResponse(_("Bild nicht gefunden."), status_code=404) remove_uploaded_file(deleted[0], DIARY_PHOTO_DIR, "/diary/photo/") return RedirectResponse(f"/diary#concert-{deleted[1]}", status_code=303) @@ -3580,6 +3632,15 @@ def delete_diary_entry(request: Request, concert_id: int): with connection.cursor() as cursor: cursor.execute("SELECT path FROM concert_diary_photos WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id)) photo_paths = [row[0] for row in cursor.fetchall()] + cursor.execute( + """ + INSERT INTO concert_diary_exclusions (user_id, concert_id) + SELECT user_id, concert_id FROM concert_diary + WHERE user_id = %s AND concert_id = %s + ON CONFLICT DO NOTHING + """, + (user["id"], concert_id), + ) cursor.execute("DELETE FROM concert_diary_photos WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id)) cursor.execute("DELETE FROM concert_diary WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id)) connection.commit() @@ -3600,12 +3661,12 @@ def _ics_escape(value: str) -> str: def concert_ics(request: Request, concert_id: int): concert = load_concert(concert_id) if not concert: - return PlainTextResponse("Veranstaltung nicht gefunden", status_code=404) + return PlainTextResponse(_("Veranstaltung nicht gefunden"), status_code=404) # Kalenderdaten unterliegen denselben Sichtbarkeitsregeln wie die Detailseite. # Ohne Sitzung bzw. ohne Berechtigung keine Informationen preisgeben. user = get_current_user(request) if not user or not can_view_event(user, concert): - return PlainTextResponse("Veranstaltung nicht gefunden", status_code=404) + return PlainTextResponse(_("Veranstaltung nicht gefunden"), status_code=404) local_zone = ZoneInfo("Europe/Berlin") start = concert["start_datetime"].replace(tzinfo=local_zone) end_value = concert.get("end_datetime") @@ -3635,7 +3696,7 @@ def concert_detail(request: Request, concert_id: int): if not concert or not can_view_event(user, concert): return HTMLResponse( - "

Veranstaltung nicht gefunden

", + _("

Veranstaltung nicht gefunden

"), status_code=404 ) @@ -3730,7 +3791,7 @@ def concert_detail(request: Request, concert_id: int): { "id": row[0], "body": row[1], - "created_at": row[2].strftime("%d.%m.%Y %H:%M"), + "created_at": format_datetime(row[2]), "author": row[3] or row[4], "username": row[4], } @@ -3741,7 +3802,7 @@ def concert_detail(request: Request, concert_id: int): { "id": row[0], "path": row[1], - "created_at": row[2].strftime("%d.%m.%Y %H:%M"), + "created_at": format_datetime(row[2]), "author": row[3] or row[4], } for row in photo_rows @@ -3833,16 +3894,16 @@ async def create_concert( artist = artist.strip() if len(artist) < 2 or len(artist) > 255: - return HTMLResponse("

Der Titel oder Künstlername muss zwischen 2 und 255 Zeichen lang sein.

", status_code=400) + return HTMLResponse(_("

Der Titel oder Künstlername muss zwischen 2 und 255 Zeichen lang sein.

"), status_code=400) if event_type not in EVENT_TYPES: - return HTMLResponse("

Ungültige Veranstaltungskategorie.

", status_code=400) + return HTMLResponse(_("

Ungültige Veranstaltungskategorie.

"), status_code=400) parsed_bands = parse_band_names(band_names, artist, event_type) if event_type == "festival" and not end_datetime: - return HTMLResponse("

Bei Festivals ist ein Enddatum erforderlich.

", status_code=400) + return HTMLResponse(_("

Bei Festivals ist ein Enddatum erforderlich.

"), status_code=400) if event_type != "festival": end_datetime = "" if visibility not in {"public", "friends", "private"}: - return HTMLResponse("

Ungültige Sichtbarkeit.

", status_code=400) + return HTMLResponse(_("

Ungültige Sichtbarkeit.

"), status_code=400) if event_type != "other": visibility = "public" duplicate_matches = find_duplicate_concerts(user, artist, start_datetime, band_names) @@ -3852,14 +3913,13 @@ async def create_concert( for match in duplicate_matches ) return HTMLResponse( - "

Mögliche doppelte Veranstaltung

" - "

Am selben Tag existiert bereits eine Veranstaltung mit einem sehr ähnlichen Künstlernamen.

" - f"" - "

Bitte gehe zurück, prüfe den Treffer und bestätige den Hinweis im Formular, wenn du trotzdem speichern möchtest.

", + _("

Mögliche doppelte Veranstaltung

Am selben Tag existiert bereits eine Veranstaltung mit einem sehr ähnlichen Künstlernamen.

Bitte gehe zurück, prüfe den Treffer und bestätige den Hinweis im Formular, wenn du trotzdem speichern möchtest.

"), status_code=409, ) if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(start_datetime): - return HTMLResponse("

Das Enddatum darf nicht vor dem Beginn liegen.

", status_code=400) + return HTMLResponse(_("

Das Enddatum darf nicht vor dem Beginn liegen.

"), status_code=400) try: normalized_flyer_url = normalize_external_url(flyer_url, "den Flyer") except ValueError as error: @@ -3975,13 +4035,13 @@ def edit_concert_page(request: Request, concert_id: int): if not concert or not can_view_event(user, concert): return HTMLResponse( - "

Veranstaltung nicht gefunden

", + _("

Veranstaltung nicht gefunden

"), status_code=404 ) if not can_edit_concert(user, concert): return HTMLResponse( - "

Vergangene Veranstaltungen dürfen nur Admins bearbeiten.

", + _("

Vergangene Veranstaltungen dürfen nur Admins bearbeiten.

"), status_code=403 ) @@ -4035,13 +4095,13 @@ async def edit_concert( if not concert or not can_view_event(user, concert): return HTMLResponse( - "

Veranstaltung nicht gefunden

", + _("

Veranstaltung nicht gefunden

"), status_code=404 ) if not can_edit_concert(user, concert): return HTMLResponse( - "

Nicht erlaubt

", + _("

Nicht erlaubt

"), status_code=403 ) @@ -4065,21 +4125,21 @@ async def edit_concert( if not can_manage_event_access(user, concert): event_type = concert["event_type"] if event_type not in EVENT_TYPES: - return HTMLResponse("

Ungültige Veranstaltungskategorie.

", status_code=400) + return HTMLResponse(_("

Ungültige Veranstaltungskategorie.

"), status_code=400) if event_type == "festival" and not end_datetime: - return HTMLResponse("

Bei Festivals ist ein Enddatum erforderlich.

", status_code=400) + return HTMLResponse(_("

Bei Festivals ist ein Enddatum erforderlich.

"), status_code=400) if event_type != "festival": end_datetime = "" if can_manage_event_access(user, concert): if visibility not in {"public", "friends", "private"}: - return HTMLResponse("

Ungültige Sichtbarkeit.

", status_code=400) + return HTMLResponse(_("

Ungültige Sichtbarkeit.

"), status_code=400) if event_type != "other": visibility = "public" else: visibility = concert["visibility"] effective_start = start_datetime or concert["start_local"] if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(effective_start): - return HTMLResponse("

Das Enddatum darf nicht vor dem Beginn liegen.

", status_code=400) + return HTMLResponse(_("

Das Enddatum darf nicht vor dem Beginn liegen.

"), status_code=400) try: next_flyer_url = normalize_external_url(flyer_url, "den Flyer") except ValueError as error: @@ -4202,13 +4262,13 @@ def delete_concert(request: Request, concert_id: int): if not concert or not can_view_event(user, concert): return HTMLResponse( - "

Veranstaltung nicht gefunden

", + _("

Veranstaltung nicht gefunden

"), status_code=404 ) if not can_delete_concert(user, concert): return HTMLResponse( - "

Nicht erlaubt

", + _("

Nicht erlaubt

"), status_code=403 ) @@ -4240,10 +4300,10 @@ def set_attendance( if not user: return login_redirect(f"/concerts/{concert_id}") if status not in {"attending", "maybe", "ticket_search", "ticket_offer"}: - return HTMLResponse("

Ungültige Auswahl

", status_code=400) + return HTMLResponse(_("

Ungültige Auswahl

"), status_code=400) concert = load_concert(concert_id) if not concert or not can_view_event(user, concert): - return HTMLResponse("

Veranstaltung nicht gefunden

", status_code=404) + return HTMLResponse(_("

Veranstaltung nicht gefunden

"), status_code=404) with get_db_connection() as connection: with connection.cursor() as cursor: @@ -4260,8 +4320,16 @@ def set_attendance( ) if status == "attending" and concert["is_past"]: cursor.execute( - "INSERT INTO concert_diary (user_id, concert_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", - (user["id"], concert_id), + """ + INSERT INTO concert_diary (user_id, concert_id) + SELECT %s, %s + WHERE NOT EXISTS ( + SELECT 1 FROM concert_diary_exclusions + WHERE user_id = %s AND concert_id = %s + ) + ON CONFLICT DO NOTHING + """, + (user["id"], concert_id, user["id"], concert_id), ) connection.commit() @@ -4283,7 +4351,7 @@ def add_comment( if not concert or not can_view_event(user, concert): return HTMLResponse( - "

Veranstaltung nicht gefunden

", + _("

Veranstaltung nicht gefunden

"), status_code=404 ) @@ -4297,7 +4365,7 @@ def add_comment( if len(text) > 2000: return HTMLResponse( - "

Kommentar ist zu lang (maximal 2000 Zeichen).

", + _("

Kommentar ist zu lang (maximal 2000 Zeichen).

"), status_code=400 ) @@ -4337,7 +4405,7 @@ async def add_photo( if not concert or not can_view_event(user, concert): return HTMLResponse( - "

Veranstaltung nicht gefunden

", + _("

Veranstaltung nicht gefunden

"), status_code=404 ) diff --git a/app/static/css/language.css b/app/static/css/language.css new file mode 100644 index 0000000..99a74fd --- /dev/null +++ b/app/static/css/language.css @@ -0,0 +1,26 @@ +/* Persistent language choice, also available before login. */ +.language-switch { + display: flex; + justify-content: flex-end; + gap: 4px; + max-width: 1100px; + margin: 8px auto 0; + padding: 0 20px; +} +.language-switch a { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 44px; + border: 1px solid var(--border, #30363d); + border-radius: 6px; + color: var(--text, #222); + font-weight: 700; + text-decoration: none; +} +.language-switch a.active { background: #922525; color: #fff; border-color: #c74747; } +.language-switch a:focus-visible { outline: 2px solid #fff; outline-offset: 3px; } +.header-actions .language-switch { margin: 0; padding: 0; } + +.detail-topbar .language-switch { margin: 0 0 0 auto; padding: 0; } diff --git a/app/static/css/style.css b/app/static/css/style.css index 6039e8f..3f96035 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -509,6 +509,38 @@ h1, .page-title h1 { .calendar-export { display:flex; justify-content:center; margin-top:12px; } .ticket-button, .concert-action { display:inline-flex; padding:11px 16px; border:1px solid #b91c1c; border-radius:9px; background:#7f1d1d; color:#fff; font-weight:700; } .concert-action-delete { background:#450a0a; cursor:pointer; } +@media (min-width: 701px) { + .event-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 22px; + } + .event-actions .ticket-button-container, + .event-actions .calendar-export, + .event-actions .detail-actions { margin: 0; } + .event-actions .calendar-export:first-child { grid-column: 1 / -1; } + .event-actions .detail-actions { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + } + .event-actions .detail-actions > a:only-child { grid-column: 1 / -1; } + .event-actions .detail-actions form { margin: 0; width: 100%; max-width: none; } + .event-actions .ticket-button, + .event-actions .concert-action { + width: 100%; + height: 100%; + min-height: 48px; + align-items: center; + justify-content: center; + text-align: center; + font: inherit; + font-weight: 700; + letter-spacing: normal; + } +} @media (max-width:600px) { .detail-topbar { align-items:flex-start; } .concert-detail { padding:16px; } .comment-meta { flex-direction:column; gap:4px; } } @media (max-width:700px) { .advanced-filter-fields { grid-template-columns:1fr; } } diff --git a/app/templates/_admin_nav.html b/app/templates/_admin_nav.html index 778aae8..57cc8a2 100644 --- a/app/templates/_admin_nav.html +++ b/app/templates/_admin_nav.html @@ -1,6 +1,6 @@ -