| import gradio as gr |
| import gspread |
| import requests |
| import socket |
| import tempfile |
| import os |
| import json |
| from urllib.parse import quote as url_quote |
| import hashlib |
| import re |
| import cv2 |
| import pytesseract |
| import threading |
| import time |
| from concurrent.futures import ThreadPoolExecutor |
| import concurrent.futures |
| from pyzbar.pyzbar import decode |
| from datetime import datetime, timedelta |
| from PIL import Image as PILImage |
| from fpdf import FPDF |
| import fitz |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| socket.setdefaulttimeout(30) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _google_creds_json = os.environ.get("GOOGLE_CREDENTIALS") |
| if _google_creds_json: |
| gc = gspread.service_account_from_dict(json.loads(_google_creds_json)) |
| else: |
| gc = gspread.service_account(filename="carbide-theme-491608-t3-1223e08c6a66.json") |
|
|
| def _mit_wiederholung(fn, *args, **kwargs): |
| """Führt einen Google-Sheets-Aufruf mit steigender Wartezeit erneut aus, falls |
| Google kurzzeitig mit 503/500 (überlastet) oder 429 (Ratenlimit) antwortet - |
| das ist ein bekanntes, rein vorübergehendes Google-seitiges Problem, kein |
| Fehler in unserem Code (siehe v7.91.5, Absturz beim Start durch |
| gspread.exceptions.APIError: [503] direkt bei sh.worksheet(...)). Ohne das |
| stürzte die GESAMTE App schon beim Hochfahren ab, wenn Google in genau diesem |
| Sekundenbruchteil mal kurz überlastet war - nicht behebbar durch den |
| 30-Sekunden-Timeout aus v7.88.4 allein, da das nur endloses Hängen verhindert, |
| aber keine neuen Versuche unternimmt.""" |
| wartezeit = 2 |
| for versuch in range(5): |
| try: |
| return fn(*args, **kwargs) |
| except gspread.exceptions.APIError as e: |
| code = getattr(getattr(e, "response", None), "status_code", None) |
| if code in (503, 500, 429) and versuch < 4: |
| time.sleep(wartezeit) |
| wartezeit *= 2 |
| continue |
| raise |
|
|
| sh = _mit_wiederholung(gc.open_by_key, "10aL6G3CDr40I5ZREN-z8u4lnE8kenUVHQID0GdkAfzE") |
| ws = _mit_wiederholung(sh.worksheet, "Tier_Profile") |
| try: |
| if ws.col_count < 40: ws.resize(cols=40) |
| except: pass |
| ws_archiv = _mit_wiederholung(sh.worksheet, "Archiv") |
| ws_inv = _mit_wiederholung(sh.worksheet, "Futter_Inventar") |
| try: |
| if ws_inv.col_count < 23: ws_inv.resize(cols=23) |
| except: pass |
| ws_sorten = _mit_wiederholung(sh.worksheet, "Futter_Sorten") |
| ws_set = _mit_wiederholung(sh.worksheet, "Einstellungen") |
|
|
| try: ws_users = _mit_wiederholung(sh.worksheet, "Master_Nutzer") |
| except: ws_users = sh.add_worksheet(title="Master_Nutzer", rows="100", cols="8") |
|
|
| try: |
| if ws_users.col_count < 8: ws_users.resize(cols=8) |
| except: pass |
|
|
| try: ws_blacklist = _mit_wiederholung(sh.worksheet, "Blacklist") |
| except: |
| ws_blacklist = sh.add_worksheet(title="Blacklist", rows="200", cols="3") |
| ws_blacklist.append_row(["Zoo_Name", "Email", "Datum"]) |
|
|
| try: ws_emaillog = _mit_wiederholung(sh.worksheet, "EmailLog") |
| except: |
| ws_emaillog = sh.add_worksheet(title="EmailLog", rows="1000", cols="4") |
| ws_emaillog.append_row(["Datum", "Zoo_Name", "Empfänger", "Betreff"]) |
|
|
| try: ws_futtererinnerung = _mit_wiederholung(sh.worksheet, "Futter_Erinnerungen_Log") |
| except: |
| ws_futtererinnerung = sh.add_worksheet(title="Futter_Erinnerungen_Log", rows="1000", cols="5") |
| ws_futtererinnerung.append_row(["Zoo_Name", "Sorte", "Letzte_Aktualisierung", "Schwelle", "Gesendet_Am"]) |
|
|
| try: ws_tagebuch = _mit_wiederholung(sh.worksheet, "Tagebuch") |
| except: |
| ws_tagebuch = sh.add_worksheet(title="Tagebuch", rows="2000", cols="6") |
| ws_tagebuch.append_row(["Zoo_Name", "Tier_Name", "Datum", "Eintrag", "Foto_URL", "Titel"]) |
|
|
| try: ws_apotheke = _mit_wiederholung(sh.worksheet, "Haustierapotheke") |
| except: |
| ws_apotheke = sh.add_worksheet(title="Haustierapotheke", rows="1000", cols="4") |
| ws_apotheke.append_row(["Kategorie", "Titel", "Inhalt", "Erstellt_Am"]) |
|
|
| try: ws_app_einstellungen = _mit_wiederholung(sh.worksheet, "App_Einstellungen") |
| except: |
| ws_app_einstellungen = sh.add_worksheet(title="App_Einstellungen", rows="20", cols="2") |
| ws_app_einstellungen.append_row(["Key", "Value"]) |
| ws_app_einstellungen.append_row(["apotheke_banner_aktiv", "ja"]) |
|
|
| try: ws_feedback = _mit_wiederholung(sh.worksheet, "Feedback") |
| except: |
| ws_feedback = sh.add_worksheet(title="Feedback", rows="100", cols="4") |
| ws_feedback.append_row(["Datum", "Zoo_Name", "Thema", "Nachricht"]) |
|
|
| try: ws_news = _mit_wiederholung(sh.worksheet, "News") |
| except: |
| ws_news = sh.add_worksheet(title="News", rows="100", cols="3") |
| ws_news.append_row(["Datum", "Admin", "Nachricht"]) |
|
|
| CACHE = {"inv": (None, []), "tier": (None, []), "set": (None, []), "users": (None, []), "archiv": (None, []), "news": (None, []), "blacklist": (None, []), "emaillog": (None, []), "futtererinnerung": (None, []), "tagebuch": (None, []), "feedback": (None, []), "apotheke": (None, [])} |
| CACHE_LOCK = threading.Lock() |
|
|
| def get_cached_data(sheet_name): |
| with CACHE_LOCK: |
| now = datetime.now() |
| if CACHE[sheet_name][0] is None or (now - CACHE[sheet_name][0]).total_seconds() > 5: |
| try: |
| if sheet_name == "inv": CACHE["inv"] = (now, ws_inv.get_all_values()) |
| elif sheet_name == "tier": CACHE["tier"] = (now, ws.get_all_values()) |
| elif sheet_name == "set": CACHE["set"] = (now, ws_set.get_all_values()) |
| elif sheet_name == "users": CACHE["users"] = (now, ws_users.get_all_values()) |
| elif sheet_name == "archiv": CACHE["archiv"] = (now, ws_archiv.get_all_values()) |
| elif sheet_name == "news": CACHE["news"] = (now, ws_news.get_all_values()) |
| elif sheet_name == "blacklist": CACHE["blacklist"] = (now, ws_blacklist.get_all_values()) |
| elif sheet_name == "emaillog": CACHE["emaillog"] = (now, ws_emaillog.get_all_values()) |
| elif sheet_name == "futtererinnerung": CACHE["futtererinnerung"] = (now, ws_futtererinnerung.get_all_values()) |
| elif sheet_name == "tagebuch": CACHE["tagebuch"] = (now, ws_tagebuch.get_all_values()) |
| elif sheet_name == "feedback": CACHE["feedback"] = (now, ws_feedback.get_all_values()) |
| elif sheet_name == "apotheke": CACHE["apotheke"] = (now, ws_apotheke.get_all_values()) |
| except: pass |
| return CACHE[sheet_name][1] |
|
|
| def invalidate_cache(): |
| with CACHE_LOCK: |
| CACHE["inv"] = (None, []); CACHE["tier"] = (None, []); CACHE["set"] = (None, []); CACHE["users"] = (None, []); CACHE["archiv"] = (None, []); CACHE["news"] = (None, []); CACHE["blacklist"] = (None, []); CACHE["emaillog"] = (None, []); CACHE["futtererinnerung"] = (None, []); CACHE["tagebuch"] = (None, []); CACHE["feedback"] = (None, []); CACHE["apotheke"] = (None, []) |
|
|
| |
| |
| |
| def is_zoo(val, current): return str(val).strip().lower() == str(current).strip().lower() |
|
|
| def als_text(wert): |
| """Erzwingt Google-Sheets-Textformat (führendes Apostroph), damit führende 0 oder + bei |
| Telefonnummern & Co. nicht von Sheets als Zahl/Formel fehlinterpretiert und verloren gehen.""" |
| wert = str(wert).strip() if wert else "" |
| if not wert or wert.startswith("'"): return wert |
| return "'" + wert if (wert[0] in ("0", "+")) else wert |
|
|
| def von_text(wert): |
| """Entfernt ein evtl. führendes Apostroph beim Anzeigen/Weiterverarbeiten.""" |
| wert = str(wert) if wert else "" |
| return wert[1:] if wert.startswith("'") else wert |
|
|
| def hash_pw(pw): return hashlib.sha256(str(pw).encode()).hexdigest() |
|
|
| def ist_admin(zoo_name): |
| if not zoo_name: return False |
| if str(zoo_name).strip().lower() == "petbase": return True |
| for r in get_cached_data("users"): |
| if len(r) > 0 and is_zoo(r[0], zoo_name): |
| return len(r) > 7 and str(r[7]).strip().lower() == "ja" |
| return False |
|
|
| def publish_news(admin_name, text, auch_per_email): |
| if not ist_admin(admin_name): return "❌ Nur der Admin darf News posten!" |
| if not text: return "❌ Bitte Text eingeben!" |
| datum = datetime.now().strftime("%d.%m.%Y %H:%M") |
| ws_news.append_row([datum, admin_name, text]) |
| invalidate_cache() |
| if not auch_per_email: |
| return "✅ News erfolgreich veröffentlicht!" |
| if not email_konfiguriert(): |
| return "✅ News veröffentlicht. ⚠️ E-Mail-Versand ist nicht eingerichtet, daher keine E-Mails verschickt." |
| versendet, fehler = 0, 0 |
| for r in get_cached_data("set"): |
| if len(r) > 2 and r[2] and "@" in str(r[2]): |
| ok, _ = sende_email(r[2], "🐾 PetBase Update", f"Hallo {r[0]},\n\nes gibt ein neues Update in PetBase:\n\n{text}\n\nDiese E-Mail wurde automatisch versendet.", r[0]) |
| versendet += 1 if ok else 0 |
| fehler += 0 if ok else 1 |
| return f"✅ News veröffentlicht! 📧 {versendet} E-Mail(s) versendet" + (f", {fehler} fehlgeschlagen." if fehler else ".") |
|
|
| def get_unread_news(user_name): |
| rows = get_cached_data("users") |
| last_login = None |
| for r in rows: |
| if r[0] == user_name: |
| if len(r) >= 4 and r[3]: last_login = r[3] |
| break |
| news_rows = get_cached_data("news")[1:] |
| if not last_login: return [n[2] for n in news_rows if len(n) > 2] |
| |
| try: last_dt = datetime.strptime(last_login, "%d.%m.%Y %H:%M") |
| except: return [n[2] for n in news_rows if len(n) > 2] |
| |
| unread = [] |
| for n in news_rows: |
| try: |
| if len(n) > 2 and datetime.strptime(n[0], "%d.%m.%Y %H:%M") > last_dt: |
| unread.append(n[2]) |
| except: pass |
| return unread |
|
|
| def get_admin_stats(): |
| try: |
| users = get_cached_data("users") |
| start_idx = 1 if len(users) > 0 and "name" in str(users[0][0]).lower() else 0 |
| total_logins = sum([int(r[4]) for r in users[start_idx:] |
| if len(r) > 4 and str(r[4]).isdigit() |
| and str(r[0]).strip().lower() != "petbase"]) |
| zoo_count = len([r for r in users[start_idx:] |
| if len(r) > 0 and str(r[0]).strip().lower() != "petbase"]) |
| return f"<div style='background:#FFFCF5; padding:15px; border-radius:12px; border-left: 5px solid #6D4C41; box-shadow: 0 1px 3px rgba(59,50,38,0.15); color:#3B3226;'><span style='color:#4E342E; font-size:1.2em;'>🐾 App-Reichweite</span><br><br><b>Registrierte Zoos:</b> {zoo_count}<br><b>Gesamt-Logins aller Nutzer:</b> {total_logins}<br><b>Versendete E-Mails gesamt:</b> {email_zaehler()}<br><br>{email_status_text()}</div>" |
| except Exception as e: return f"Fehler Statistik: {e}" |
|
|
| def erstelle_feedback_liste(): |
| """Zeigt alle eingegangenen Feedback-Einträge (neueste zuerst), dieselben |
| Angaben wie in der Benachrichtigungs-E-Mail: Von, Thema, Nachricht, Datum.""" |
| try: |
| rows = get_cached_data("feedback") |
| start_idx = 1 if len(rows) > 0 and "datum" in str(rows[0][0]).lower() else 0 |
| eintraege = [r for r in rows[start_idx:] if len(r) >= 4 and str(r[1]).strip()] |
| if not eintraege: |
| return "<i>Noch kein Feedback eingegangen.</i>" |
| html = "<div style='display:flex; flex-direction:column; gap:8px;'>" |
| for r in reversed(eintraege): |
| datum, von, thema, text = r[0], r[1], r[2], r[3] |
| text_html = str(text).replace("\n", "<br>") |
| html += f"""<div style='background:#FFFCF5; border-radius:12px; padding:10px 14px; border-left:3px solid #6D4C41; box-shadow: 0 1px 3px rgba(59,50,38,0.12); color:#3B3226;'> |
| <div style='display:flex; justify-content:space-between; align-items:baseline;'><b style='font-size:1.02em;'>{von}</b><span style='color:#7A6E5C; font-size:0.82em;'>{datum}</span></div> |
| <div style='font-size:0.9em; color:#6D4C41; font-weight:bold; margin-top:2px;'>{thema}</div> |
| <div style='font-size:0.9em; line-height:1.4; margin-top:4px;'>{text_html}</div> |
| </div>""" |
| html += "</div>" |
| return html |
| except Exception as e: |
| return f"<i>Feedback-Liste konnte nicht geladen werden: {e}</i>" |
|
|
| def erstelle_feedback_dropdown_choices(): |
| """Baut die Auswahlliste für 'Als erledigt markieren' - jede Option beginnt |
| mit der tatsächlichen Sheet-Zeilennummer, damit das Löschen auch bei |
| inhaltlich identischen Einträgen eindeutig bleibt.""" |
| try: |
| rows = ws_feedback.get_all_values() |
| start_idx = 1 if len(rows) > 0 and "datum" in str(rows[0][0]).lower() else 0 |
| choices = [] |
| for i, r in enumerate(rows): |
| if i < start_idx or len(r) < 4 or not str(r[1]).strip(): |
| continue |
| kurztext = str(r[3])[:40] + ("…" if len(str(r[3])) > 40 else "") |
| choices.append(f"Zeile {i + 1} | {r[0]} – {r[1]} – {r[2]}: {kurztext}") |
| return list(reversed(choices)) |
| except Exception: |
| return [] |
|
|
| def feedback_als_erledigt(auswahl): |
| """Löscht den ausgewählten Feedback-Eintrag aus dem Sheet - erscheint danach |
| weder in der Liste noch in der Auswahl. Kein 'Papierkorb', wirklich gelöscht, |
| wie vom Nutzer gewünscht.""" |
| if not auswahl or "Zeile " not in auswahl: |
| return "❌ Bitte einen Eintrag auswählen.", gr.update(), gr.update() |
| try: |
| zeilen_nr = int(auswahl.split("Zeile ")[1].split(" |")[0]) |
| ws_feedback.delete_rows(zeilen_nr) |
| invalidate_cache() |
| return "✅ Als erledigt markiert und gelöscht.", erstelle_feedback_liste(), gr.update(choices=erstelle_feedback_dropdown_choices(), value=None) |
| except Exception as e: |
| return f"❌ Fehler beim Löschen: {str(e)[:80]}", gr.update(), gr.update() |
|
|
| def erstelle_nutzerliste(): |
| try: |
| users = get_cached_data("users") |
| start_idx = 1 if len(users) > 0 and "name" in str(users[0][0]).lower() else 0 |
| set_rows = get_cached_data("set") |
| tier_rows = get_cached_data("tier") |
| email_map = {} |
| for r in set_rows: |
| if len(r) > 2: email_map[str(r[0]).strip().lower()] = r[2] |
| html = "<div style='display:flex; flex-direction:column; gap:8px;'>" |
| for r in users[start_idx:]: |
| if len(r) == 0 or not r[0].strip(): continue |
| name = r[0].strip() |
| if name.lower() == "petbase": continue |
| email = email_map.get(name.lower(), "–") |
| letzter_login = r[3] if len(r) > 3 and r[3] else "noch nie" |
| anzahl_logins = r[4] if len(r) > 4 and r[4] else "0" |
| anzahl_tiere = len([t for t in tier_rows if len(t) > 1 and is_zoo(t[0], name) and str(t[1]).strip().lower() != "name"]) |
| gesperrt = len(r) > 6 and str(r[6]).strip().lower() == "ja" |
| admin = len(r) > 7 and str(r[7]).strip().lower() == "ja" |
| status_farbe = "#D32F2F" if gesperrt else "#2E7D32" |
| status_text = "🚫 Gesperrt" if gesperrt else "✓ Aktiv" |
| admin_tag = " · 👑 Admin" if admin else "" |
| html += f"""<div style='background:#FFFCF5; border-radius:12px; padding:10px 14px; border-left:3px solid {status_farbe}; box-shadow: 0 1px 3px rgba(59,50,38,0.12); color:#3B3226;'> |
| <div style='display:flex; justify-content:space-between; align-items:baseline;'><b style='font-size:1.05em;'>{name}{admin_tag}</b><span style='color:{status_farbe}; font-size:0.9em;'>{status_text}</span></div> |
| <div style='font-size:0.85em; color:#7A6E5C; margin-top:2px;'>📧 {email} · 🐾 {anzahl_tiere} Tier(e) · 🔑 {anzahl_logins}x eingeloggt · 🕐 zuletzt: {letzter_login}</div> |
| </div>""" |
| html += "</div>" |
| if start_idx == len(users) or all(not r[0].strip() or r[0].strip().lower() == "petbase" for r in users[start_idx:] if len(r) > 0): |
| return "<i>Noch keine registrierten Nutzer außer dir.</i>" |
| return html |
| except Exception as e: |
| return f"<i>Nutzerliste konnte nicht geladen werden: {e}</i>" |
|
|
| def hole_nutzer_gruppen(): |
| """Gibt vier Namenslisten zurück: aktive (nicht gesperrte), gesperrte, |
| nicht-Admin (können ernannt werden) und aktuelle Admins (können entzogen werden).""" |
| try: |
| users = get_cached_data("users") |
| start_idx = 1 if len(users) > 0 and "name" in str(users[0][0]).lower() else 0 |
| aktive, gesperrte, nicht_admin, admins = [], [], [], [] |
| for r in users[start_idx:]: |
| if len(r) == 0 or not r[0].strip(): continue |
| name = r[0].strip() |
| if name.lower() == "petbase": continue |
| gesperrt = len(r) > 6 and str(r[6]).strip().lower() == "ja" |
| admin = len(r) > 7 and str(r[7]).strip().lower() == "ja" |
| (gesperrte if gesperrt else aktive).append(name) |
| (admins if admin else nicht_admin).append(name) |
| return aktive, gesperrte, nicht_admin, admins |
| except Exception: |
| return [], [], [], [] |
|
|
| def nutzer_dropdowns_update(): |
| aktive, gesperrte, nicht_admin, admins = hole_nutzer_gruppen() |
| return (gr.update(choices=aktive or ["Keine aktiven Nutzer"], value=None), |
| gr.update(choices=gesperrte or ["Keine gesperrten Nutzer"], value=None), |
| gr.update(choices=nicht_admin or ["Keine passenden Nutzer"], value=None), |
| gr.update(choices=admins or ["Keine weiteren Admins"], value=None)) |
|
|
| def _nutzer_sperr_aktion(admin_name, ziel_name, sperren): |
| ungueltig = (None, "Keine aktiven Nutzer", "Keine gesperrten Nutzer") |
| if not ist_admin(admin_name): return "❌ Keine Berechtigung.", erstelle_nutzerliste(), *nutzer_dropdowns_update() |
| if not ziel_name or ziel_name in ungueltig: return "Bitte einen Nutzer auswählen.", erstelle_nutzerliste(), *nutzer_dropdowns_update() |
| if ziel_name.strip().lower() == "petbase": return "❌ Der Hauptadmin kann nicht gesperrt werden.", erstelle_nutzerliste(), *nutzer_dropdowns_update() |
| try: |
| rows = ws_users.get_all_values() |
| for i, r in enumerate(rows): |
| if len(r) > 0 and is_zoo(r[0], ziel_name): |
| ws_users.update_cell(i + 1, 7, "Ja" if sperren else "Nein") |
| invalidate_cache() |
| text = f"✅ {ziel_name} wurde {'gesperrt' if sperren else 'entsperrt'}." |
| return (text, erstelle_nutzerliste()) + nutzer_dropdowns_update() |
| return ("❌ Nutzer nicht gefunden.", erstelle_nutzerliste()) + nutzer_dropdowns_update() |
| except Exception as e: |
| return (f"❌ Fehler: {e}", erstelle_nutzerliste()) + nutzer_dropdowns_update() |
|
|
| def nutzer_sperren(admin_name, ziel_name): return _nutzer_sperr_aktion(admin_name, ziel_name, True) |
| def nutzer_entsperren(admin_name, ziel_name): return _nutzer_sperr_aktion(admin_name, ziel_name, False) |
|
|
| def _admin_aktion(admin_name, ziel_name, ernennen): |
| ungueltig = (None, "Keine passenden Nutzer", "Keine weiteren Admins") |
| if str(admin_name).strip().lower() != "petbase": return ("❌ Nur der Hauptadmin kann Admin-Rechte vergeben oder entziehen.", erstelle_nutzerliste()) + nutzer_dropdowns_update() |
| if not ziel_name or ziel_name in ungueltig: return ("Bitte einen Nutzer auswählen.", erstelle_nutzerliste()) + nutzer_dropdowns_update() |
| if ziel_name.strip().lower() == "petbase": return ("❌ Der Hauptadmin-Status kann nicht geändert werden.", erstelle_nutzerliste()) + nutzer_dropdowns_update() |
| try: |
| rows = ws_users.get_all_values() |
| for i, r in enumerate(rows): |
| if len(r) > 0 and is_zoo(r[0], ziel_name): |
| while len(r) < 8: r.append("") |
| ws_users.update_cell(i + 1, 8, "Ja" if ernennen else "Nein") |
| invalidate_cache() |
| text = f"✅ {ziel_name} ist jetzt {'Admin' if ernennen else 'kein Admin mehr'}." |
| return (text, erstelle_nutzerliste()) + nutzer_dropdowns_update() |
| return ("❌ Nutzer nicht gefunden.", erstelle_nutzerliste()) + nutzer_dropdowns_update() |
| except Exception as e: |
| return (f"❌ Fehler: {e}", erstelle_nutzerliste()) + nutzer_dropdowns_update() |
|
|
| def admin_ernennen(admin_name, ziel_name): return _admin_aktion(admin_name, ziel_name, True) |
| def admin_entziehen(admin_name, ziel_name): return _admin_aktion(admin_name, ziel_name, False) |
|
|
| def login_user(zoo_name, pw): |
| if not zoo_name or not pw: return "Bitte alles ausfüllen!", "" |
| users = get_cached_data("users") |
| hashed = hash_pw(pw) |
| for r in users: |
| if len(r) >= 2 and is_zoo(r[0], zoo_name): |
| if r[1] != hashed: return "Falsches Passwort!", "" |
| if len(r) > 6 and str(r[6]).strip().lower() == "ja": |
| return "🚫 Dieses Konto wurde gesperrt. Bitte wende dich an den Admin.", "" |
| return "Login erfolgreich!", r[0].strip() |
| return "Zoo-Name nicht gefunden!", "" |
|
|
| def ist_gesperrt(zoo_name, email): |
| for r in get_cached_data("blacklist"): |
| if len(r) > 0 and is_zoo(r[0], zoo_name): return True |
| if len(r) > 1 and email and str(r[1]).strip().lower() == str(email).strip().lower(): return True |
| return False |
|
|
| def register_user(zoo_name, pw, email): |
| if not zoo_name or not pw: return "Bitte Name und Passwort ausfüllen!", gr.update(), gr.update(), gr.update(), gr.update(), gr.update() |
| if ist_gesperrt(zoo_name, email): |
| return "❌ Registrierung nicht möglich. Bitte wende dich an den Admin.", gr.update(), gr.update(), gr.update(), gr.update(), gr.update() |
| for r in get_cached_data("users"): |
| if len(r) > 0 and is_zoo(r[0], zoo_name): return "Diesen Zoo-Namen gibt es leider schon!", gr.update(), gr.update(), gr.update(), gr.update(), gr.update() |
| try: |
| rows = ws_users.get_all_values() |
| naechste_zeile = len(rows) + 1 |
| ws_users.update(range_name=f"A{naechste_zeile}:E{naechste_zeile}", values=[[zoo_name.strip(), hash_pw(pw), "", "", "0"]]) |
| if email and email.strip(): |
| set_rows = ws_set.get_all_values() |
| naechste_set_zeile = len(set_rows) + 1 |
| ws_set.update(range_name=f"A{naechste_set_zeile}:C{naechste_set_zeile}", values=[[zoo_name.strip(), "Nein", email.strip()]]) |
| invalidate_cache() |
| |
| |
| return ("✅ Erfolgreich registriert! Du kannst dich jetzt einloggen.", gr.update(value=zoo_name.strip()), |
| gr.update(visible=True), gr.update(visible=False), gr.update(variant="primary"), gr.update(variant="secondary")) |
| except Exception as e: |
| return f"❌ Fehler bei der Registrierung: {str(e)[:80]}", gr.update(), gr.update(), gr.update(), gr.update(), gr.update() |
|
|
| def change_password(zoo_name, old_pw, new_pw): |
| if not zoo_name or not old_pw or not new_pw: return "Bitte alle Felder ausfüllen!" |
| hashed_old = hash_pw(old_pw) |
| rows = ws_users.get_all_values() |
| for i, r in enumerate(rows): |
| if len(r) >= 2 and is_zoo(r[0], zoo_name): |
| if r[1] == hashed_old: |
| ws_users.update_cell(i+1, 2, hash_pw(new_pw)) |
| invalidate_cache() |
| return "Passwort erfolgreich geändert!" |
| else: return "❌ Altes Passwort ist falsch!" |
| return "Fehler beim Ändern." |
| def delete_account(zoo_name, pw): |
| if not zoo_name or not pw: return "Passwort eingeben!", gr.update(), gr.update(), zoo_name |
| hashed = hash_pw(pw) |
| valid = False |
| for r in get_cached_data("users"): |
| if len(r) >= 2 and is_zoo(r[0], zoo_name) and r[1] == hashed: valid = True; break |
| if not valid: return "Falsches Passwort!", gr.update(), gr.update(), zoo_name |
| email = lade_einstellungen(zoo_name) |
| try: |
| ws_blacklist.append_row([zoo_name.strip(), email.strip() if email else "", datetime.now().strftime("%d.%m.%Y")]) |
| except: pass |
| for sheet_obj in [ws_users, ws, ws_archiv, ws_inv, ws_set]: |
| try: |
| rows = sheet_obj.get_all_values() |
| to_delete = [i+1 for i, r in enumerate(rows) if len(r) > 0 and is_zoo(r[0], zoo_name)] |
| for idx in sorted(to_delete, reverse=True): sheet_obj.delete_rows(idx) |
| except: pass |
| invalidate_cache() |
| return "Dein Konto und alle Daten wurden gelöscht.", gr.update(visible=True), gr.update(visible=False), "" |
|
|
| |
| |
| |
| def lade_einstellungen(zoo_name): |
| if not zoo_name: return "" |
| for r in get_cached_data("set"): |
| if len(r) >= 3 and is_zoo(r[0], zoo_name): return r[2] |
| return "" |
|
|
| def lade_modus(zoo_name): |
| if not zoo_name: return False |
| for r in get_cached_data("set"): |
| if len(r) >= 2 and is_zoo(r[0], zoo_name): return True if str(r[1]).upper() == "JA" else False |
| return False |
|
|
| def speichere_einstellungen(zoo_name, email): |
| if not zoo_name: return "Fehler: Nicht eingeloggt!" |
| rows = ws_set.get_all_values() |
| for i, r in enumerate(rows): |
| if len(r) > 0 and is_zoo(r[0], zoo_name): |
| ws_set.update_cell(i+1, 3, email) |
| invalidate_cache() |
| return "Gespeichert!" |
| ws_set.append_row([zoo_name, "Nein", email]) |
| invalidate_cache() |
| return "Erfolgreich angelegt und gespeichert!" |
|
|
| def submit_feedback(zoo_name, thema, text): |
| if not zoo_name: return "Fehler: Nicht eingeloggt!" |
| if not thema or not text: return "Bitte Thema wählen und Nachricht schreiben!" |
| try: |
| rows = ws_feedback.get_all_values() |
| naechste_zeile = len(rows) + 1 |
| ws_feedback.update(range_name=f"A{naechste_zeile}:D{naechste_zeile}", values=[[datetime.now().strftime("%d.%m.%Y %H:%M"), zoo_name, thema, text]]) |
| invalidate_cache() |
| |
| |
| |
| |
| try: |
| admin_email = lade_einstellungen("PetBase") |
| if admin_email and str(zoo_name).strip().lower() != "petbase": |
| betreff = f"🐞 Neues Feedback von {zoo_name}: {thema}" |
| body = f"Hallo PetBase,\n\nneues Feedback wurde in PetBase eingereicht:\n\nVon: {zoo_name}\nThema: {thema}\n\n{text}\n\nDiese E-Mail wurde automatisch von deiner PetBase-App versendet." |
| sende_email(admin_email, betreff, body, "PetBase") |
| except Exception: |
| pass |
| return "Feedback gesendet! Vielen Dank." |
| except Exception as e: return f"Fehler beim Senden: {str(e)[:80]}" |
|
|
| def pruefe_neues_feedback(zoo_name, letzter_stand): |
| """Wird periodisch (Timer) aufgerufen, um neu eingereichtes Feedback live als |
| Popup anzuzeigen, solange PetBase (Admin) selbst in der App eingeloggt ist - |
| Ergänzung zur E-Mail-Benachrichtigung für den Fall, dass der Admin gerade aktiv |
| in der App ist. Nur für den Superadmin-Account aktiv.""" |
| if not zoo_name or str(zoo_name).strip().lower() != "petbase": |
| return letzter_stand, "" |
| try: |
| rows = ws_feedback.get_all_values() |
| aktueller_stand = len(rows) |
| if letzter_stand is None: |
| |
| |
| return aktueller_stand, "" |
| if aktueller_stand > letzter_stand: |
| neue_zeilen = rows[letzter_stand:] |
| details = "\n".join([f"• {r[1]} – {r[2]}: {r[3][:60]}{'…' if len(r[3]) > 60 else ''}" for r in neue_zeilen if len(r) > 3]) |
| return aktueller_stand, f"🐞 Neues Feedback erhalten!\n{details}" |
| return aktueller_stand, "" |
| except Exception: |
| return letzter_stand, "" |
|
|
| def erstelle_bestand_karte(zoo_name): |
| """Nur die Anzahl-Karte, die verschwindet sobald ein Tier ausgewählt ist.""" |
| if not zoo_name: |
| return "<i>Bitte logge dich ein.</i>" |
| try: |
| pm = lade_modus(zoo_name) |
| tier_rows = [r for r in get_cached_data("tier") if len(r) > 1 and is_zoo(r[0], zoo_name) and str(r[1]).strip().lower() != "name"] |
| if not pm: |
| tier_rows = [r for r in tier_rows if not (len(r) > 23 and r[23] and r[23] != "Eigenes Tier")] |
| anzahl = len(tier_rows) |
| return f"<div style='background:#FFFCF5; border-radius:16px; padding:12px 16px; border-left:3px solid #6D4C41; box-shadow: 0 1px 3px rgba(59,50,38,0.12); color:#3B3226;'><span style='color:#7A6E5C;'>🐾 Tiere im Bestand</span><div style='font-size:1.4em; font-weight:bold;'>{anzahl}</div></div>" |
| except Exception as e: |
| return f"<i>Konnte nicht geladen werden: {e}</i>" |
|
|
| def berechne_futter_warnungen(zoo_name, tier_rows=None): |
| """Berechnet je Futtersorte die geschätzten verbleibenden Tage anhand von |
| Bestand, täglichem Verbrauch und Zeit seit letzter Aktualisierung. Gemeinsame |
| Basis für die Dashboard-Anzeige ('Futter wird knapp') und die automatischen |
| E-Mail-Erinnerungen. Gibt eine Liste von |
| (tage_rest, sorte, letzte_update_str, hersteller, sackgroesse_g, barcode) zurück, |
| sortiert nach tage_rest aufsteigend.""" |
| if tier_rows is None: |
| pm = lade_modus(zoo_name) |
| tier_rows = [r for r in get_cached_data("tier") if len(r) > 1 and is_zoo(r[0], zoo_name) and str(r[1]).strip().lower() != "name"] |
| if not pm: |
| tier_rows = [r for r in tier_rows if not (len(r) > 23 and r[23] and r[23] != "Eigenes Tier")] |
| heute = datetime.now() |
| inv_rows = [r for r in get_cached_data("inv") if len(r) > 9 and r[8] and r[9] and is_zoo(r[0], zoo_name)] |
| bedarf = {} |
| for r in tier_rows: |
| if len(r) > 16 and str(r[7]).upper() != 'TRUE': |
| sorte = r[8].strip() |
| try: g_tag = float(str(r[16]).replace(",", ".")) |
| except: g_tag = 0 |
| if sorte and g_tag > 0: bedarf[sorte] = bedarf.get(sorte, 0) + g_tag |
| ergebnisse = [] |
| for r in inv_rows: |
| sorte = r[8].strip() |
| verbr_tag = bedarf.get(sorte, 0) |
| if verbr_tag <= 0: continue |
| try: gespeichert = float(str(r[9]).replace(",", ".")) |
| except: gespeichert = 0 |
| letzte_update_str = str(r[22]).strip() if len(r) > 22 and r[22] else "" |
| try: letzte_update = datetime.strptime(letzte_update_str, "%d.%m.%Y") |
| except: letzte_update = heute |
| tage_vergangen = max(0, (heute - letzte_update).days) |
| live_bestand = max(0, gespeichert - tage_vergangen * verbr_tag) |
| tage_rest = int(live_bestand / verbr_tag) |
| hersteller = str(r[7]).strip() if len(r) > 7 and r[7] else "" |
| sackgroesse_g = str(r[21]).strip() if len(r) > 21 and r[21] else "" |
| barcode = str(r[6]).strip() if len(r) > 6 and r[6] and str(r[6]).strip().lower() != "unbekannt" else "" |
| ergebnisse.append((tage_rest, sorte, letzte_update_str, hersteller, sackgroesse_g, barcode)) |
| ergebnisse.sort(key=lambda x: x[0]) |
| return ergebnisse |
|
|
| def erstelle_anstehend_karte(zoo_name): |
| """Anstehende Termine + Futter-Warnungen - erscheint unter den Stammdaten des ausgewählten Tieres.""" |
| if not zoo_name: |
| return "" |
| try: |
| pm = lade_modus(zoo_name) |
| tier_rows = [r for r in get_cached_data("tier") if len(r) > 1 and is_zoo(r[0], zoo_name) and str(r[1]).strip().lower() != "name"] |
| if not pm: |
| tier_rows = [r for r in tier_rows if not (len(r) > 23 and r[23] and r[23] != "Eigenes Tier")] |
|
|
| heute = datetime.now() |
| anstehend = [] |
| for r in tier_rows: |
| name = r[1] |
| |
| |
| |
| |
| |
| |
| |
| para_int_wert = r[24] if len(r) > 24 and r[24] else "3 Monate" |
| intervall_tage = {"Impfung": 365, "Wurmkur": 90, "Parasitenschutz": 365 if para_int_wert == "1 Jahr" else 90} |
| for idx, label in [(13, "Impfung"), (14, "Wurmkur"), (15, "Parasitenschutz")]: |
| if len(r) > idx and r[idx]: |
| try: |
| letzter_termin = datetime.strptime(str(r[idx]).strip(), "%d.%m.%Y") |
| naechster_termin = letzter_termin + timedelta(days=intervall_tage[label]) |
| tage = (naechster_termin - heute).days |
| if -3650 < tage <= 30: |
| anstehend.append((tage, name, label)) |
| except Exception: |
| pass |
| anstehend.sort(key=lambda x: x[0]) |
|
|
| warnungen_roh = berechne_futter_warnungen(zoo_name, tier_rows=tier_rows) |
| warnungen = [(tage_rest, sorte, hersteller, barcode) for tage_rest, sorte, _, hersteller, _, barcode in warnungen_roh if tage_rest <= 14] |
|
|
| if not anstehend and not warnungen: |
| return "<div style='color:#4CAF50; padding:6px 0;'>✓ Aktuell nichts Dringendes – alles im grünen Bereich.</div>" |
|
|
| html = "<div style='display:flex; flex-direction:column; gap:10px;'>" |
| if warnungen: |
| html += "<div style='background:#FFFCF5; border-radius:16px; padding:10px 16px; border-left:3px solid #D32F2F; box-shadow: 0 1px 3px rgba(59,50,38,0.12); color:#3B3226;'><b style='color:#D32F2F;'>🥫 Futter wird knapp</b>" |
| for tage_rest, sorte, hersteller, barcode in warnungen[:6]: |
| |
| |
| |
| |
| |
| text_query = f"{hersteller} {sorte}".strip() |
| idealo_url = f"https://www.idealo.de/preisvergleich/MainSearchProductCategory.html?q={url_quote(text_query)}" |
| google_query = f"{text_query} {barcode}".strip() if barcode else text_query |
| google_url = f"https://www.google.com/search?tbm=shop&q={url_quote(google_query)}" |
| html += f"<div style='padding:4px 0; font-size:0.9em; display:flex; justify-content:space-between; align-items:center;'><span>{sorte}</span><span style='display:flex; align-items:center; gap:8px;'><span style='color:#D32F2F;'>noch ca. {tage_rest} Tage</span><a href='{idealo_url}' target='_blank' rel='noopener' style='color:#6D4C41; text-decoration:none; font-size:0.85em; white-space:nowrap;'>🛒 Idealo</a><a href='{google_url}' target='_blank' rel='noopener' style='color:#6D4C41; text-decoration:none; font-size:0.85em; white-space:nowrap;'>🔍 Google</a></span></div>" |
| html += "</div>" |
|
|
| if anstehend: |
| html += "<div style='background:#FFFCF5; border-radius:16px; padding:10px 16px; border-left:3px solid #6D4C41; box-shadow: 0 1px 3px rgba(59,50,38,0.12); color:#3B3226;'><b style='color:#4E342E;'>📅 Anstehend (nächste 30 Tage)</b>" |
| for tage, name, label in anstehend[:6]: |
| farbe = "#D32F2F" if tage < 0 else ("#EF6C00" if tage <= 7 else "#2E7D32") |
| text = f"überfällig seit {abs(tage)} Tagen" if tage < 0 else (f"heute fällig" if tage == 0 else f"in {tage} Tagen") |
| html += f"<div style='padding:4px 0; font-size:0.9em; display:flex; justify-content:space-between;'><span>{name} – {label}</span><span style='color:{farbe};'>{text}</span></div>" |
| html += "</div>" |
|
|
| html += "</div>" |
| return html |
| except Exception as e: |
| return f"<i>Übersicht konnte nicht geladen werden: {e}</i>" |
|
|
| def erstelle_dashboard(zoo_name): |
| """Nur noch für Status-Meldungen zuständig - die eigentliche Anzeige läuft über fuelle_futter_slots.""" |
| if not zoo_name: return "<i>Bitte logge dich ein.</i>" |
| try: |
| inv_rows = [r for r in get_cached_data("inv") if len(r) > 0 and is_zoo(r[0], zoo_name)] |
| hat_futter = any(len(r) > 9 and r[8] and r[9] for r in inv_rows) |
| return "" if hat_futter else "<i>Noch kein Futter im Futter Vorrat.</i>" |
| except Exception as e: return f"Dashboard Fehler: {e}" |
|
|
| def fuelle_futter_slots(zoo_name): |
| """Liefert für jeden der FUTTER_SLOT_ANZAHL Slots: (sichtbar, Karten-HTML, Sortenname).""" |
| leer = [(gr.update(visible=False), "", "")] * FUTTER_SLOT_ANZAHL |
| if not zoo_name: return [x for tpl in leer for x in tpl] |
| try: |
| pm = lade_modus(zoo_name) |
| inv_rows = [r for r in get_cached_data("inv") if len(r) > 0 and is_zoo(r[0], zoo_name)] |
| tier_rows = [r for r in get_cached_data("tier") if len(r) > 0 and is_zoo(r[0], zoo_name)] |
| heute = datetime.now() |
|
|
| bedarf = {} |
| for r in tier_rows: |
| if len(r) > 1 and str(r[1]).strip().lower() != "name": |
| if len(r) > 16 and str(r[7]).upper() != 'TRUE': |
| kat = r[23] if len(r) > 23 and r[23] else "Eigenes Tier" |
| if not pm and kat != "Eigenes Tier": continue |
| sorte = r[8].strip() |
| try: g_tag = float(str(r[16]).replace(",", ".")) |
| except: g_tag = 0 |
| if sorte and g_tag > 0: bedarf[sorte] = bedarf.get(sorte, 0) + g_tag |
|
|
| posten = {} |
| for r in inv_rows: |
| if len(r) > 9 and r[8] and r[9]: |
| sorte, art = r[8].strip(), str(r[19]).strip() if len(r) > 19 and r[19] else "Unkategorisiert" |
| try: gespeichert = float(str(r[9]).replace(",", ".")) |
| except: gespeichert = 0 |
| try: sackgroesse = float(str(r[21]).replace(",", ".")) if len(r) > 21 and r[21] else gespeichert |
| except: sackgroesse = gespeichert |
| letzte_update_str = str(r[22]).strip() if len(r) > 22 and r[22] else "" |
| try: letzte_update = datetime.strptime(letzte_update_str, "%d.%m.%Y") |
| except: letzte_update = heute |
| posten[sorte] = {"art": art, "gespeichert": gespeichert, "sackgroesse": sackgroesse, "letzte_update": letzte_update} |
|
|
| |
| sortierte_sorten = sorted(posten.keys(), key=lambda s: (posten[s]["art"], s)) |
|
|
| ergebnisse = [] |
| letzte_art = None |
| for i in range(FUTTER_SLOT_ANZAHL): |
| if i < len(sortierte_sorten): |
| sorte = sortierte_sorten[i] |
| info = posten[sorte] |
| verbr_tag = bedarf.get(sorte, 0) |
| tage_vergangen = max(0, (heute - info["letzte_update"]).days) |
| live_bestand = max(0, info["gespeichert"] - tage_vergangen * verbr_tag) |
| sack_kg = info["sackgroesse"] / 1000 |
| bestand_kg = live_bestand / 1000 |
| if verbr_tag > 0: |
| tage_rest = int(live_bestand / verbr_tag) |
| status_color = "#2E7D32" if tage_rest > 14 else ("#EF6C00" if tage_rest > 7 else "#D32F2F") |
| tage_text = f"noch <b>{tage_rest} Tage</b>" |
| percent = min(100, (tage_rest/30)*100) |
| else: |
| status_color = "#9E9E9E"; tage_text = "kein Verbrauch"; percent = 0 |
| art_header = "" |
| if info["art"] != letzte_art: |
| art_header = f"<div style='color:#4E342E; font-size:0.85em; font-weight:bold; margin:6px 0 2px 2px;'>{info['art']}</div>" |
| letzte_art = info["art"] |
| karte = ( |
| f"{art_header}" |
| f"<div style='border:1px solid #E6DAC4; border-radius:12px; padding:6px 10px; background:#FFFCF5; color:#3B3226; font-size:0.85em; box-shadow: 0 1px 2px rgba(59,50,38,0.10);'>" |
| f"<div style='display:flex; justify-content:space-between; align-items:baseline; flex-wrap:wrap; gap:2px 8px;'>" |
| f"<b style='font-size:1em;'>{sorte}</b>" |
| f"<span style='color:{status_color};'>{tage_text}</span>" |
| f"</div>" |
| f"<div style='display:flex; justify-content:space-between; flex-wrap:wrap; gap:2px 8px; color:#7A6E5C; margin-top:1px; font-size:0.9em;'>" |
| f"<span>Sack: {sack_kg:.2f} kg</span>" |
| f"<span>Bestand: <b>{bestand_kg:.2f} kg</b></span>" |
| f"</div>" |
| f"<div style='width:100%; background:#E6DAC4; border-radius:3px; height:4px; overflow:hidden; margin-top:3px;'>" |
| f"<div style='width:{percent}%; height:100%; background:{status_color};'></div></div>" |
| f"</div>" |
| ) |
| ergebnisse.append((gr.update(visible=True), karte, sorte)) |
| else: |
| ergebnisse.append((gr.update(visible=False), "", "")) |
| return [x for tpl in ergebnisse for x in tpl] |
| except Exception: |
| return [x for tpl in leer for x in tpl] |
|
|
| |
| |
| |
| def resize_for_ocr(bild_np, target_width=1800): |
| if bild_np is None: return None |
| h, w = bild_np.shape[:2] |
| scale = target_width / w |
| new_w = int(w * scale) |
| new_h = int(h * scale) |
| return cv2.resize(bild_np, (new_w, new_h), interpolation=cv2.INTER_CUBIC if scale > 1 else cv2.INTER_AREA) |
|
|
| def optimiere_bild_fuer_ocr(bild_np): |
| try: |
| grau = cv2.cvtColor(bild_np, cv2.COLOR_RGB2GRAY) |
| adaptiv = cv2.adaptiveThreshold(grau, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 51, 15) |
| _, otsu = cv2.threshold(grau, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) |
| return grau, adaptiv, otsu |
| except: |
| g = cv2.cvtColor(bild_np, cv2.COLOR_RGB2GRAY) if bild_np is not None else None |
| return g, g, g |
|
|
| def scanne_und_suche(b1, b2, b3): |
| bilder = [b for b in [b1, b2, b3] if b is not None] |
| if not bilder: return "Kein Bild", "", "", "0", "Unbekannt", "", "", "", "", "", "", "", "", "Bitte Bild laden." |
| try: |
| bc, combined_text = "Unbekannt", "" |
|
|
| for raw_bild in bilder: |
| if bc != "Unbekannt": break |
| bc_bild = resize_for_ocr(raw_bild, target_width=2000) |
| barcodes = decode(bc_bild) |
| if barcodes: |
| bc = barcodes[0].data.decode('utf-8') |
| else: |
| grau_bc = cv2.cvtColor(bc_bild, cv2.COLOR_RGB2GRAY) |
| grau_bc = cv2.equalizeHist(grau_bc) |
| barcodes = decode(grau_bc) |
| if barcodes: |
| bc = barcodes[0].data.decode('utf-8') |
|
|
| for raw_bild in bilder: |
| bild = resize_for_ocr(raw_bild, target_width=1800) |
| grau, adaptiv, otsu = optimiere_bild_fuer_ocr(bild) |
| text_pool = [] |
|
|
| for img_variant in [grau, adaptiv, otsu]: |
| if img_variant is None: continue |
| pil_img = PILImage.fromarray(img_variant) |
| t3 = pytesseract.image_to_string(pil_img, config='--psm 3 -l deu+eng') |
| if len(t3.strip()) > 15: text_pool.append(t3) |
| t11 = pytesseract.image_to_string(pil_img, config='--psm 11 -l deu+eng') |
| if len(t11.strip()) > 15: text_pool.append(t11) |
|
|
| best_segment = "" |
| for t in text_pool: |
| if len(t) > len(best_segment): |
| best_segment = t |
| combined_text += best_segment + "\n" + "\n".join(text_pool) + "\n--\n" |
|
|
| clean_text_for_weight = re.sub(r'\b\d+\s*-\s*\d+\s*(?:kg|g)\b', '', combined_text, flags=re.IGNORECASE) |
| gew_matches = re.findall(r"(?<![\-\+])\b([0-9.,]+)\s*(kg|g)\b(?![\s*\+\-])", clean_text_for_weight, re.IGNORECASE) |
| gewicht = "0" |
|
|
| if gew_matches: |
| kandidaten = [] |
| for wert_str, einheit in gew_matches: |
| wert_clean = wert_str.replace(",", ".").strip() |
| try: |
| wert = float(wert_clean) |
| in_g = int(wert * 1000) if einheit.lower() == 'kg' else int(wert) |
| if 50 <= in_g <= 30000: |
| kandidaten.append(in_g) |
| except: continue |
|
|
| if kandidaten: |
| gewicht = str(max(kandidaten)) |
| else: |
| try: |
| wert_str, einheit = gew_matches[0] |
| wert = float(wert_str.replace(",", ".").strip()) |
| if einheit.lower() == 'kg' or wert < 50.0: |
| gewicht = str(int(wert * 1000)) |
| else: |
| gewicht = str(int(wert)) |
| except: pass |
|
|
| lower_text = combined_text.lower() |
| phase = "Unbekannt" |
| if re.search(r"\b(junior|welpe|puppy|young)\b", lower_text): phase = "Junior / Welpe" |
| elif re.search(r"\b(senior|older|ageing)\b", lower_text): phase = "Senior" |
| elif re.search(r"\b(adult|erwachsen)\b", lower_text): phase = "Adult" |
| elif re.search(r"\b(all life stages|all ages)\b", lower_text): phase = "All Ages" |
|
|
| lines = combined_text.split("\n") |
| table_pairs = [] |
| for line in lines: |
| line_clean = re.sub(r'\s+-\s+', '-', line) |
| line_clean = re.sub(r'\s*,\s*', ',', line_clean) |
| kg_match = re.search(r"(\d+[\.,]?\d*-\d+[\.,]?\d*)\s*kg", line_clean, re.IGNORECASE) |
| if not kg_match: kg_match = re.search(r"\b(\d+[\.,]?\d*)\s*kg", line_clean, re.IGNORECASE) |
| if kg_match: |
| kg_val = kg_match.group(1).replace(" ", "") |
| g_matches = re.findall(r"\b(\d+-\d+)\s*g\b", line_clean, re.IGNORECASE) |
| if g_matches: table_pairs.append((kg_val, g_matches[0])) |
| else: |
| g_singles = re.findall(r"\b(\d+)\s*g\b", line_clean, re.IGNORECASE) |
| if len(g_singles) >= 2: table_pairs.append((kg_val, f"{g_singles[0]}-{g_singles[1]}")) |
| elif len(g_singles) == 1: table_pairs.append((kg_val, g_singles[0])) |
|
|
| k1, g1, k2, g2, k3, g3, k4, g4 = "", "", "", "", "", "", "", "" |
| einzigartige_paare = [] |
| gesehene_kgs = set() |
| for k, g in table_pairs: |
| if k not in gesehene_kgs: |
| gesehene_kgs.add(k) |
| einzigartige_paare.append((k, g)) |
|
|
| if einzigartige_paare: |
| if len(einzigartige_paare) > 0: k1, g1 = einzigartige_paare[0] |
| if len(einzigartige_paare) > 1: k2, g2 = einzigartige_paare[1] |
| if len(einzigartige_paare) > 2: k3, g3 = einzigartige_paare[2] |
| if len(einzigartige_paare) > 3: k4, g4 = einzigartige_paare[3] |
|
|
| nutzbarer_text = combined_text.replace("--", "").strip() |
| bereinigte_zeilen = [l.strip() for l in nutzbarer_text.split('\n') if len(l.strip()) > 10] |
| vorschau = "\n".join(bereinigte_zeilen[:12]) + "\n..." if len(bereinigte_zeilen) > 12 else "\n".join(bereinigte_zeilen) |
|
|
| if len(nutzbarer_text) < 40: |
| vorschau = "⚠️ Nur wenig Text erkannt – bitte Foto bei besserem Licht/schärfer/gerader aufnehmen.\n\n" + vorschau |
|
|
| return bc, "", "", gewicht, phase, k1, g1, k2, g2, k3, g3, k4, g4, vorschau |
| except Exception as e: return "Fehler", "", "", "0", "Unbekannt", "", "", "", "", "", "", "", "", str(e) |
|
|
| def parse_zu_gramm(wert_str): |
| if not wert_str: return "0" |
| try: |
| wert = float(str(wert_str).replace(",", ".").strip()) |
| if wert < 50.0: return str(int(wert * 1000)) |
| return str(int(wert)) |
| except: return "0" |
| def speichere_inventar(zoo_name, bc, hersteller, sorte, gewicht, phase, art, futtertyp, k1, g1, k2, g2, k3, g3, k4, g4): |
| if not zoo_name: return "Fehler: Nicht eingeloggt!", gr.update(), gr.update(), gr.update(), gr.update() |
| row = [""] * 23 |
| row[0] = zoo_name |
| row[6], row[7], row[8], row[9] = str(bc), str(hersteller), str(sorte).strip(), parse_zu_gramm(gewicht) |
| row[10], row[11] = str(k1), str(g1); row[12], row[13] = str(k2), str(g2); row[14], row[15] = str(k3), str(g3) |
| row[16], row[17] = str(k4), str(g4); row[18], row[19] = str(phase), str(art) |
| row[20] = str(futtertyp) if futtertyp else "Trockenfutter" |
| row[21] = row[9] |
| row[22] = datetime.now().strftime("%d.%m.%Y") |
| |
| |
| |
| |
| |
| ws_inv.append_row(row[:23]) |
| invalidate_cache() |
| gefunden = False |
| for versuch in range(3): |
| if versuch > 0: time.sleep(1.5) |
| for r in ws_inv.get_all_values(): |
| if len(r) >= 10 and r[0] == zoo_name and r[7] == str(hersteller) and r[8] == str(sorte).strip(): |
| gefunden = True |
| break |
| if gefunden: |
| break |
| d = update_dropdowns(zoo_name) |
| status = f"Erfolgreich gespeichert: {sorte} ({row[9]}g)" if gefunden else f"⚠️ Konnte nicht sicher bestätigt werden: {sorte} - bitte in der Futter-Zentrale prüfen und ggf. erneut speichern." |
| return status, d[5], d[6], d[7], d[8] |
| |
| |
| |
| |
| def load_futter_edit(zoo_name, sorte): |
| if not zoo_name or not sorte or sorte == "Hier Auswählen": return [gr.update(value="")] * 15 |
| try: |
| raw = [r for r in get_cached_data("inv") if len(r) > 0 and is_zoo(r[0], zoo_name)] |
| s = str(sorte).strip() |
| for r in raw: |
| if len(r) > 8 and r[8].strip() == s: |
| while len(r) < 23: r.append("") |
| art_val = str(r[19]) if len(r) > 19 and str(r[19]) else "Hundefutter" |
| futtertyp_val = str(r[20]) if len(r) > 20 and str(r[20]) else "Trockenfutter" |
| sackgroesse_val = str(r[21]) if len(r) > 21 and r[21] else str(r[9]) |
| return [gr.update(value=str(r[7])), gr.update(value=str(r[8])), gr.update(value=str(r[9])), gr.update(value=sackgroesse_val), gr.update(value=str(r[18])), gr.update(value=art_val), gr.update(value=futtertyp_val), gr.update(value=str(r[10])), gr.update(value=str(r[11])), gr.update(value=str(r[12])), gr.update(value=str(r[13])), gr.update(value=str(r[14])), gr.update(value=str(r[15])), gr.update(value=str(r[16])), gr.update(value=str(r[17]))] |
| return [gr.update(value="Sorte nicht gefunden!")] + [gr.update(value="")] * 14 |
| except Exception as e: return [gr.update(value=f"Fehler: {str(e)[:20]}")] + [gr.update(value="")] * 14 |
|
|
| def save_futter_edit(zoo_name, alt_sorte, hersteller, sorte, gewicht, sackgroesse, phase, art, futtertyp, k1, g1, k2, g2, k3, g3, k4, g4): |
| if not zoo_name or not alt_sorte or alt_sorte == "Hier Auswählen": return "Kein Futter gewählt!", gr.update(), gr.update(), erstelle_dashboard(zoo_name), gr.update() |
| try: |
| raw = ws_inv.get_all_values() |
| s = str(alt_sorte).strip() |
| for i, r in enumerate(raw): |
| if len(r) > 8 and is_zoo(r[0], zoo_name) and r[8].strip() == s: |
| while len(r) < 23: r.append("") |
| r[7], r[8], r[9] = str(hersteller), str(sorte).strip(), parse_zu_gramm(gewicht) |
| r[10], r[11] = str(k1), str(g1); r[12], r[13] = str(k2), str(g2); r[14], r[15] = str(k3), str(g3) |
| r[16], r[17] = str(k4), str(g4); r[18], r[19] = str(phase), str(art) |
| r[20] = str(futtertyp) if futtertyp else "Trockenfutter" |
| r[21] = str(parse_zu_gramm(sackgroesse)) if sackgroesse else r[9] |
| r[22] = datetime.now().strftime("%d.%m.%Y") |
| ws_inv.update(range_name=f"A{i+1}:W{i+1}", values=[r[:23]]) |
| invalidate_cache() |
| d = update_dropdowns(zoo_name) |
| return f"Erfolgreich aktualisiert: {sorte}", d[7], d[6], erstelle_dashboard(zoo_name), d[8] |
| return "Fehler: Sorte nicht gefunden.", gr.update(), gr.update(), erstelle_dashboard(zoo_name), gr.update() |
| except Exception as e: return f"Fehler: {str(e)[:30]}", gr.update(), gr.update(), erstelle_dashboard(zoo_name), gr.update() |
|
|
| def delete_futter_edit(zoo_name, sorte): |
| if not zoo_name or not sorte or sorte == "Hier Auswählen": return "Kein Futter gewählt!", gr.update(), gr.update(), erstelle_dashboard(zoo_name), gr.update() |
| try: |
| raw = ws_inv.get_all_values() |
| s = str(sorte).strip() |
| for i, r in enumerate(raw): |
| if len(r) > 8 and is_zoo(r[0], zoo_name) and r[8].strip() == s: |
| ws_inv.delete_rows(i+1) |
| invalidate_cache() |
| d = update_dropdowns(zoo_name) |
| return f"Erfolgreich gelöscht: {sorte}", d[7], d[6], erstelle_dashboard(zoo_name), d[8] |
| return "Fehler: Sorte nicht gefunden.", gr.update(), gr.update(), erstelle_dashboard(zoo_name), gr.update() |
| except Exception as e: return f"Fehler beim Löschen: {str(e)[:30]}", gr.update(), gr.update(), erstelle_dashboard(zoo_name), gr.update() |
|
|
| def schnell_aufstocken(zoo_name, sorte, menge): |
| if not zoo_name or not sorte or sorte == "Hier Auswählen" or not menge or menge <= 0: return erstelle_dashboard(zoo_name), gr.update() |
| try: |
| raw = ws_inv.get_all_values() |
| s = str(sorte).strip() |
| for i, r in enumerate(raw): |
| if len(r) > 8 and is_zoo(r[0], zoo_name) and r[8].strip() == s: |
| while len(r) < 23: r.append("") |
| alt_menge = float(str(r[9]).replace(",", ".")) if len(r) > 9 and r[9] else 0 |
| eingegebene_menge = float(menge) |
| if eingegebene_menge < 50.0: eingegebene_menge *= 1000 |
| neu_menge = alt_menge + eingegebene_menge |
| ws_inv.update_cell(i+1, 10, str(int(neu_menge) if neu_menge.is_integer() else neu_menge)) |
| ws_inv.update_cell(i+1, 23, datetime.now().strftime("%d.%m.%Y")) |
| invalidate_cache() |
| return erstelle_dashboard(zoo_name), gr.update(value="Hier Auswählen") |
| return erstelle_dashboard(zoo_name), gr.update() |
| except Exception as e: return erstelle_dashboard(zoo_name), gr.update() |
|
|
| def inline_aufstocken(zoo_name, sorte, menge): |
| """Für die Inline-Buttons direkt an der Futter-Karte: bucht die Menge und aktualisiert Dashboard + Slots.""" |
| schnell_aufstocken(zoo_name, sorte, menge) |
| return (erstelle_dashboard(zoo_name),) + tuple(fuelle_futter_slots(zoo_name)) |
|
|
| def hole_bestand_sackgroesse(zoo_name, sorte): |
| """Liefert aktuellen Lagerbestand und Sackgröße (g) für das Inline-Formular.""" |
| if not zoo_name or not sorte: return "", "" |
| try: |
| rows = get_cached_data("inv") |
| for r in rows: |
| if len(r) > 8 and is_zoo(r[0], zoo_name) and r[8].strip() == str(sorte).strip(): |
| bestand = str(r[9]) if len(r) > 9 and r[9] else "" |
| sack = str(r[21]) if len(r) > 21 and r[21] else bestand |
| return bestand, sack |
| except Exception: pass |
| return "", "" |
|
|
| def inline_bestand_setzen(zoo_name, sorte, bestand, sackgroesse): |
| """Setzt Lagerbestand und Sackgröße direkt (absolute Werte, kein Aufaddieren) und aktualisiert Dashboard + Slots.""" |
| if zoo_name and sorte: |
| try: |
| raw = ws_inv.get_all_values() |
| s = str(sorte).strip() |
| for i, r in enumerate(raw): |
| if len(r) > 8 and is_zoo(r[0], zoo_name) and r[8].strip() == s: |
| while len(r) < 23: r.append("") |
| ws_inv.update_cell(i+1, 10, str(bestand)) |
| ws_inv.update_cell(i+1, 22, str(sackgroesse)) |
| ws_inv.update_cell(i+1, 23, datetime.now().strftime("%d.%m.%Y")) |
| invalidate_cache() |
| break |
| except Exception: pass |
| return (erstelle_dashboard(zoo_name),) + tuple(fuelle_futter_slots(zoo_name)) |
|
|
| def inline_loeschen(zoo_name, sorte): |
| """Löscht eine Futtersorte komplett aus dem Bestand und aktualisiert Dashboard + Slots.""" |
| if zoo_name and sorte: |
| delete_futter_edit(zoo_name, sorte) |
| return (erstelle_dashboard(zoo_name),) + tuple(fuelle_futter_slots(zoo_name)) |
|
|
| def inline_mehr_speichern(zoo_name, alt_sorte, herst, sorte, gew, sack, phase, art, futtertyp, k1, g1, k2, g2, k3, g3, k4, g4): |
| """Speichert die erweiterten Detail-Felder (Hersteller, Fütterungsart, Gewichts-Brackets) für eine Sorte.""" |
| if zoo_name and alt_sorte: |
| save_futter_edit(zoo_name, alt_sorte, herst, sorte, gew, sack, phase, art, futtertyp, k1, g1, k2, g2, k3, g3, k4, g4) |
| return (erstelle_dashboard(zoo_name),) + tuple(fuelle_futter_slots(zoo_name)) |
|
|
| BARF_PROZENT_TABELLE = { |
| "Welpe (bis 6 Monate)": 0.06, |
| "Junghund (6-12 Monate)": 0.035, |
| "Erwachsen (Normalaktiv)": 0.025, |
| "Sportlich / Sehr aktiv": 0.035, |
| "Senior (Wenig aktiv)": 0.015, |
| "Übergewichtig (Diät)": 0.0125, |
| "Trächtig (ab 5. Woche)": 0.035, |
| "Laktierend (Säugend)": 0.05, |
| "Kastriert (Neigung zu Übergewicht)": 0.0175, |
| } |
|
|
| def get_barf_typen(zoo_name): return ["Hier Auswählen"] + list(BARF_PROZENT_TABELLE.keys()) |
| def get_trockenfutter_sorten(zoo_name): return ["Hier Auswählen"] + [r[8].strip() for r in get_cached_data("inv")[1:] if len(r) > 8 and is_zoo(r[0], zoo_name) and r[8].strip()] |
|
|
| def update_dropdowns(zoo_name, select_name=None): |
| if not zoo_name: return (gr.update(),)*9 |
| try: |
| pm = lade_modus(zoo_name) |
| ws_data = get_cached_data("tier") |
| own_names, foster_names, all_visible_names = [], [], [] |
| for r in ws_data: |
| if len(r) > 1 and str(r[1]).strip() and str(r[1]).strip().lower() != "name" and is_zoo(r[0], zoo_name): |
| kat = r[23] if len(r) > 23 and r[23] else "Eigenes Tier" |
| if kat == "Eigenes Tier": |
| own_names.append(r[1]); all_visible_names.append(r[1]) |
| else: |
| foster_names.append(r[1]) |
| if pm: all_visible_names.append(r[1]) |
| archiv_names = [] |
| for r in get_cached_data("archiv"): |
| if len(r) > 1 and str(r[1]).strip() and str(r[1]).strip().lower() != "name" and is_zoo(r[0], zoo_name): |
| kat = r[23] if len(r) > 23 and r[23] else "Eigenes Tier" |
| if pm or kat == "Eigenes Tier": archiv_names.append(r[1]) |
| barf, trocken = get_barf_typen(zoo_name), get_trockenfutter_sorten(zoo_name) |
| |
| |
| |
| |
| |
| tier_choices = ["Hier Auswählen", "Neues Tier..."] + all_visible_names |
| if select_name and select_name in tier_choices: |
| tier_dd_update = gr.update(choices=tier_choices, value=select_name) |
| else: |
| tier_dd_update = gr.update(choices=tier_choices, value="Hier Auswählen") |
| return (tier_dd_update, gr.update(choices=all_visible_names), gr.update(choices=["Hier Auswählen"] + own_names), gr.update(choices=["Hier Auswählen"] + foster_names), gr.update(choices=["Hier Auswählen"] + archiv_names), gr.update(choices=barf), gr.update(choices=trocken), gr.update(choices=trocken), gr.update(choices=trocken)) |
| except: return (gr.update(),)*9 |
|
|
| def change_modus(zoo_name, is_aktiv): |
| if not zoo_name: return (gr.update(),)*21 |
| rows = ws_set.get_all_values() |
| found = False |
| for i, r in enumerate(rows): |
| if len(r) > 0 and is_zoo(r[0], zoo_name): |
| ws_set.update_cell(i+1, 2, "Ja" if is_aktiv else "Nein") |
| found = True; break |
| if not found: ws_set.append_row([zoo_name, "Ja" if is_aktiv else "Nein", ""]) |
| invalidate_cache() |
| pm = is_aktiv |
| d = update_dropdowns(zoo_name) |
| titel_text = "### 📂 Dokument / Verträge / Tierarztunterlagen" if pm else "### 📂 Tierunterlagen" |
| choices = ["Bitte wählen...", "Pflegevertrag", "Vermittlungsvertrag", "Tierarzt Dokumente (1)", "Tierarzt Dokumente (2)", "Tierarzt Dokumente (3)", "Tierarzt Dokumente (4)", "Tierarzt Dokumente (5)", "Tierarzt Dokumente (6)", "Alle Dokumente anzeigen"] if pm else ["Bitte wählen...", "Tierarzt Dokumente (1)", "Tierarzt Dokumente (2)", "Tierarzt Dokumente (3)", "Tierarzt Dokumente (4)", "Tierarzt Dokumente (5)", "Tierarzt Dokumente (6)", "Alle Dokumente anzeigen"] |
| return [gr.update(visible=False), gr.update(visible=pm), gr.update(visible=pm), gr.update(visible=pm)] + list(d) + [gr.update(value=titel_text), gr.update(choices=choices, value="Bitte wählen..."), d[0], d[3], d[4], gr.update(visible=pm), gr.update(visible=pm), gr.update(visible=pm)] |
|
|
| def filter_tiere(zoo_name, filter_val): |
| try: |
| pm = lade_modus(zoo_name) |
| filtered = ["Hier Auswählen", "Neues Tier..."] |
| for r in get_cached_data("tier"): |
| if len(r) > 1 and str(r[1]).strip() and str(r[1]).strip().lower() != "name" and is_zoo(r[0], zoo_name): |
| kat = r[23] if len(r) > 23 and r[23] else "Eigenes Tier" |
| if not pm and kat != "Eigenes Tier": continue |
| if filter_val == "🐾 Alle" or (filter_val == "🏠 Eigene Tiere" and kat == "Eigenes Tier") or (filter_val == "🧳 Pflege & Gäste" and kat in ["Pflegetier", "Urlaubsgast"]): filtered.append(r[1]) |
| return gr.update(choices=filtered, value="Hier Auswählen") |
| except: return gr.update() |
|
|
| |
| |
| |
| def get_file_hash(filepath): |
| if not filepath or not os.path.exists(filepath): return "" |
| with open(filepath, "rb") as f: return hashlib.md5(f.read()).hexdigest() |
|
|
| def convert_pdf_to_jpgs(pdf_path): |
| if not pdf_path or not str(pdf_path).lower().endswith('.pdf'): return [pdf_path] |
| try: |
| doc = fitz.open(pdf_path) |
| pfade = [] |
| for page_num in range(len(doc)): |
| page = doc.load_page(page_num) |
| pix = page.get_pixmap(dpi=150) |
| tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") |
| pix.save(tmp.name) |
| pfade.append(tmp.name) |
| return pfade if pfade else [pdf_path] |
| except Exception: return [pdf_path] |
|
|
| def upload_image_to_cloud(filepath): |
| if not filepath or str(filepath).startswith("http"): return filepath |
| try: |
| seiten = convert_pdf_to_jpgs(filepath) if str(filepath).lower().endswith('.pdf') else [filepath] |
| urls = [] |
| for idx, seite in enumerate(seiten): |
| url = None |
| for versuch in range(3): |
| try: |
| with open(seite, 'rb') as f: |
| r = requests.post('https://freeimage.host/api/1/upload', data={'key': '6d207e02198a847aa98d0a2a901485a5', 'action': 'upload'}, files={'source': f}, timeout=15) |
| if r.status_code == 200: |
| url = r.json()['image']['url'] |
| break |
| except Exception: |
| pass |
| time.sleep(2) |
| if url: urls.append(url) |
| if idx < len(seiten) - 1: |
| time.sleep(1.2) |
| return ",".join(urls) |
| except Exception: pass |
| return "" |
|
|
| def cloud_helper(img, hidden_str): |
| h_str = str(hidden_str) if hidden_str else "" |
| alte_url = h_str.split("|")[0] if "|" in h_str else "" |
| alter_hash = h_str.split("|")[1] if "|" in h_str and len(h_str.split("|")) > 1 else None |
| if not img: |
| return alte_url |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| aktueller_hash = get_file_hash(img) |
| if alter_hash and aktueller_hash == alter_hash: |
| return alte_url |
| return upload_image_to_cloud(img) |
|
|
| def load_doc(raw_url): |
| if not raw_url or not raw_url.startswith("http"): return gr.update(value=None), "" |
| erste_url = raw_url.split(",")[0].strip() |
| try: |
| resp = requests.get(erste_url, headers={"User-Agent": "Mozilla/5.0"}, timeout=3) |
| if resp.status_code == 200: |
| tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") |
| tmp.write(resp.content); tmp.close() |
| return gr.update(value=tmp.name), f"{raw_url}|{get_file_hash(tmp.name)}" |
| except: pass |
| return gr.update(value=None), "" |
|
|
| def nachtrag_archiv_vertrag(zoo_name, tier_name, vertrag_typ, img_path): |
| if not zoo_name: return "❌ Bitte einloggen!" |
| if not tier_name or tier_name == "Hier Auswählen": return "❌ Bitte archiviertes Tier auswählen!" |
| if not img_path: return "❌ Bitte ein Vertragsdokument hochladen!" |
| try: |
| cloud_url = cloud_helper(img_path, "") |
| if not cloud_url: return "❌ Fehler beim Cloud-Upload." |
| rows = ws_archiv.get_all_values() |
| for i, r in enumerate(rows): |
| if len(r) > 1 and is_zoo(r[0], zoo_name) and str(r[1]).strip() == str(tier_name).strip(): |
| col_idx = 27 if vertrag_typ == "Pflegevertrag" else 28 |
| ws_archiv.update_cell(i + 1, col_idx, cloud_url) |
| invalidate_cache() |
| return f"✅ {vertrag_typ} erfolgreich hinzugefügt!" |
| return "❌ Tier im Archiv nicht gefunden." |
| except Exception as e: return f"❌ Fehler: {str(e)[:30]}" |
|
|
| def nachtrag_aktiv_vertrag(zoo_name, tier_name, vertrag_typ, img_path): |
| if not zoo_name: return "❌ Bitte einloggen!" |
| if not tier_name or tier_name == "Hier Auswählen": return "❌ Bitte Tier auswählen!" |
| if not img_path: return "❌ Bitte ein Vertragsdokument hochladen!" |
| try: |
| cloud_url = cloud_helper(img_path, "") |
| if not cloud_url: return "❌ Fehler beim Cloud-Upload." |
| rows = ws.get_all_values() |
| for i, r in enumerate(rows): |
| if len(r) > 1 and is_zoo(r[0], zoo_name) and str(r[1]).strip() == str(tier_name).strip(): |
| col_idx = 27 if vertrag_typ == "Pflegevertrag" else 28 |
| ws.update_cell(i + 1, col_idx, cloud_url) |
| invalidate_cache() |
| return f"✅ {vertrag_typ} erfolgreich hinzugefügt!" |
| return "❌ Tier nicht gefunden." |
| except Exception as e: return f"❌ Fehler: {str(e)[:30]}" |
|
|
| def nachtrag_speichern(zoo_name, ziel, aktiv_name, archiv_name, vertrag_typ, img_path): |
| if ziel and "Archiv" in ziel: |
| return nachtrag_archiv_vertrag(zoo_name, archiv_name, vertrag_typ, img_path) |
| return nachtrag_aktiv_vertrag(zoo_name, aktiv_name, vertrag_typ, img_path) |
|
|
| def safe_float(val): |
| try: return float(str(val).replace(",", ".").strip()) |
| except: return 0.0 |
|
|
| def load_tier(zoo_name, n): |
| if not zoo_name: return [""] * 53 |
| pm = lade_modus(zoo_name) |
| if n in ["Hier Auswählen", "Neues Tier...", "", None]: |
| is_new = (n == "Neues Tier...") |
| choices = get_gefilterte_sorten(zoo_name, "", "Trockenfutter") |
| trock_f_update = gr.update(value="Hier Auswählen", choices=choices) |
| nass_choices = get_gefilterte_sorten(zoo_name, "", "Nassfutter") |
| nass_f_update = gr.update(value="Hier Auswählen", choices=nass_choices) |
| barf_choices = get_barf_typen(zoo_name) |
| typ_f_update = gr.update(value="Hier Auswählen", choices=barf_choices) |
| return ["", gr.update(value="Eigenes Tier", visible=pm), "", "", "", "", "Nein", 0, "", "", "", "", False, trock_f_update, "", typ_f_update, 0, 0, 0, False, nass_f_update, 0, "", "", "", "3 Monate", 0, gr.update(value=None), "", ""] + [gr.update(value=None), ""]*8 + [gr.update(visible=is_new), gr.update(visible=is_new), gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(visible=not is_new), gr.update(visible=not is_new)] |
| try: |
| rows = get_cached_data("tier") |
| row = next((r for r in rows if len(r) > 1 and str(r[1]).strip() == str(n).strip() and is_zoo(r[0], zoo_name) and str(r[1]).strip().lower() != "name"), None) |
| if not row: return ["", gr.update(value="Eigenes Tier", visible=pm), "", "", "", "", "Nein", 0, "", "", "", "", False, "Hier Auswählen", "", "Hier Auswählen", 0, 0, 0, False, "Hier Auswählen", 0, "", "", "", "3 Monate", 0, gr.update(value=None), "", ""] + [gr.update(value=None), ""]*8 + [gr.update(), gr.update(visible=False), gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(visible=True)] |
| while len(row) < 40: row.append("") |
| is_barf = True if str(row[7]).upper() == 'TRUE' else False |
|
|
| |
| |
| |
| |
| |
| |
| doc_urls = [str(row[i]) for i in range(26, 34)] |
| ergebnisse = [(gr.update(value=None), "")] * len(doc_urls) |
| with ThreadPoolExecutor(max_workers=len(doc_urls) + 1) as executor: |
| img_future = executor.submit(load_doc, str(row[19])) |
| future_zu_index = {executor.submit(load_doc, url): idx for idx, url in enumerate(doc_urls)} |
| fertig, offen = concurrent.futures.wait(list(future_zu_index.keys()) + [img_future], timeout=4) |
| for future in fertig: |
| if future is img_future: continue |
| idx = future_zu_index[future] |
| try: ergebnisse[idx] = future.result() |
| except: pass |
| try: img_val, hidden_val = img_future.result(timeout=4) if img_future in offen else img_future.result() |
| except Exception: img_val, hidden_val = gr.update(value=None), "" |
| for future in offen: |
| future.cancel() |
| doc_vals = [] |
| for v, h in ergebnisse: |
| doc_vals.extend([v, h]) |
| sorte, phase = str(row[8]).strip(), "" |
| try: |
| for ir in get_cached_data("inv")[1:]: |
| if len(ir) > 18 and is_zoo(ir[0], zoo_name) and ir[8].strip() == sorte: phase = ir[18]; break |
| except: pass |
| kat_val, para_int_val = row[23] if row[23] else "Eigenes Tier", str(row[24]) if row[24] else "3 Monate" |
|
|
| tierart_str = str(row[2]) |
| choices = get_gefilterte_sorten(zoo_name, tierart_str, "Trockenfutter") |
| trock_f_val = row[8].strip() if row[8] else "Hier Auswählen" |
| trock_f_update = gr.update(value=trock_f_val, choices=choices) |
| |
| barf_choices = get_barf_typen(zoo_name) |
| typ_f_val = row[9].strip() if row[9] else "Hier Auswählen" |
| typ_f_update = gr.update(value=typ_f_val, choices=barf_choices) |
|
|
| nass_aktiv_val = True if str(row[34]).strip().lower() in ("ja", "true") else False |
| nass_choices = get_gefilterte_sorten(zoo_name, tierart_str, "Nassfutter") |
| nass_sorte_val = row[35].strip() if row[35] else "Hier Auswählen" |
| nass_f_update = gr.update(value=nass_sorte_val, choices=nass_choices) |
| nass_gramm_val = safe_float(row[36]) |
|
|
| base_data = [row[1], gr.update(value=kat_val, visible=pm), row[2], row[3], row[20], row[21], row[22], safe_float(row[4]), row[5], row[6], von_text(row[37]), von_text(row[38]), is_barf, trock_f_update, phase, typ_f_update, safe_float(row[10]), safe_float(row[11]), safe_float(row[12]), nass_aktiv_val, nass_f_update, nass_gramm_val, row[13], row[14], row[15], para_int_val, safe_float(row[16]), img_val, hidden_val, row[39]] |
| return base_data + doc_vals + [gr.update(visible=True), gr.update(visible=True), gr.update(visible=not is_barf), gr.update(visible=is_barf), gr.update(visible=nass_aktiv_val), gr.update(visible=False), gr.update(visible=False)] |
| except Exception as e: return ["", gr.update(value="Eigenes Tier", visible=pm), "", "", "", "", "Nein", 0, "", "", "", "", False, "Hier Auswählen", "", "Hier Auswählen", 0, 0, 0, False, "Hier Auswählen", 0, "", "", "", "3 Monate", 0, gr.update(value=None), "", ""] + [gr.update(value=None), ""]*8 + [gr.update(), gr.update(visible=False), gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(visible=True)] |
| |
| def save_data(zoo_name, urspruenglicher_name, name, kat, tierart, rasse, geb_datum, farbe, kastriert, gew, chip, tasso, kontakt_tel, kontakt_tel2, barf_cb, trock, typ, gramm, prot, kohl, nass_cb, nass_sorte, nass_gramm, impf, wurm, para, para_int, tf_gramm, p_img, h_img, vp_img, vp_h, vv_img, vv_h, t1_img, t1_h, t2_img, t2_h, t3_img, t3_h, t4_img, t4_h, t5_img, t5_h, t6_img, t6_h, allergien): |
| if not zoo_name: return "Fehler: Nicht eingeloggt!", *[gr.update()]*5, gr.update(), gr.update(), gr.update(), erstelle_dashboard(zoo_name) |
| if not name or name == "Hier Auswählen": return "Fehler! Bitte Namen eintragen.", *[gr.update()]*5, gr.update(), gr.update(), gr.update(), erstelle_dashboard(zoo_name) |
| try: |
| pm = lade_modus(zoo_name) |
| kat_val = str(kat) if pm and kat else "Eigenes Tier" |
| rows = ws.get_all_values() |
| such_name = urspruenglicher_name if urspruenglicher_name and urspruenglicher_name not in ("Hier Auswählen", "Neues Tier...") else name |
| gs_row = None |
| for i, r in enumerate(rows): |
| if len(r) > 1 and is_zoo(r[0], zoo_name) and str(r[1]).strip() == str(such_name).strip(): gs_row = i + 1; break |
| existing_row = rows[gs_row-1] if gs_row else [""] * 40 |
| while len(existing_row) < 40: existing_row.append("") |
| existing_row[0] = zoo_name |
| existing_row[1:17] = [str(name).strip(), str(tierart), str(rasse), str(gew), str(chip), str(tasso), str(barf_cb), str(trock).strip(), str(typ), str(gramm), str(prot), str(kohl), str(impf), str(wurm), str(para), str(tf_gramm)] |
| existing_row[20:23] = [str(geb_datum), str(farbe), str(kastriert)] |
| existing_row[23], existing_row[24] = kat_val, str(para_int) |
| if not existing_row[25]: existing_row[25] = datetime.now().strftime("%d.%m.%Y") |
| existing_row[19] = cloud_helper(p_img, h_img) |
| docs = [(vp_img, vp_h), (vv_img, vv_h), (t1_img, t1_h), (t2_img, t2_h), (t3_img, t3_h), (t4_img, t4_h), (t5_img, t5_h), (t6_img, t6_h)] |
| for idx, (img, h) in enumerate(docs): existing_row[26 + idx] = cloud_helper(img, h) |
| existing_row[34] = "Ja" if nass_cb else "Nein" |
| existing_row[35] = str(nass_sorte).strip() if nass_sorte and nass_sorte != "Hier Auswählen" else "" |
| existing_row[36] = str(nass_gramm) if nass_gramm else "" |
| existing_row[37] = als_text(kontakt_tel) if kontakt_tel else "" |
| existing_row[38] = als_text(kontakt_tel2) if kontakt_tel2 else "" |
| existing_row[39] = str(allergien).strip() if allergien else "" |
| |
| |
| |
| |
| |
| |
| |
| |
| if gs_row: |
| ws.update(range_name=f"A{gs_row}:AN{gs_row}", values=[existing_row[:40]]) |
| else: |
| naechste_zeile = len(rows) + 1 |
| ws.update(range_name=f"A{naechste_zeile}:AN{naechste_zeile}", values=[existing_row[:40]]) |
| |
| |
| |
| |
| |
| |
| diagnose_status = "Gespeichert!" |
| |
| |
| |
| |
| |
| |
| try: |
| batch_updates = [] |
| for i, r in enumerate(rows): |
| if len(r) > 1 and is_zoo(r[0], zoo_name) and str(r[1]).strip() != str(such_name).strip() and str(r[1]).strip().lower() != "name": |
| batch_updates.append({"range": f"AL{i + 1}", "values": [[existing_row[37]]]}) |
| batch_updates.append({"range": f"AM{i + 1}", "values": [[existing_row[38]]]}) |
| if batch_updates: |
| ws.batch_update(batch_updates, value_input_option="RAW") |
| except: pass |
| invalidate_cache() |
| d = update_dropdowns(zoo_name, select_name=str(name).strip()) |
| return diagnose_status, d[0], d[1], d[2], d[3], d[4], gr.update(value=p_img), gr.update(value=f"{existing_row[19]}|{get_file_hash(p_img)}" if p_img else ""), gr.update(visible=True), erstelle_dashboard(zoo_name) |
| except Exception as e: return f"Fehler beim Speichern: {type(e).__name__}: {str(e)[:120]}", *[gr.update()]*5, gr.update(), gr.update(), gr.update(visible=True), erstelle_dashboard(zoo_name) |
|
|
| def archive_tier(zoo_name, name, grund, add_vertrag): |
| if not zoo_name or not name or name == "Hier Auswählen": return "Kein Tier ausgewählt!", *[gr.update()]*5 |
| rows = get_cached_data("tier") |
| row_idx = next((i for i, r in enumerate(rows) if len(r) > 1 and str(r[1]).strip() == str(name).strip() and is_zoo(r[0], zoo_name)), None) |
| if row_idx is None: return "Tier nicht gefunden!", *[gr.update()]*5 |
| row = list(rows[row_idx]) |
| while len(row) < 34: row.append("") |
| row[17], row[18] = grund, datetime.now().strftime("%d.%m.%Y") |
| if grund == "Vermittelt" and add_vertrag: row[27] = cloud_helper(add_vertrag, "") |
| ws_archiv.insert_row(row[:34], index=2) |
| ws.delete_rows(row_idx + 1) |
| invalidate_cache() |
| d = update_dropdowns(zoo_name) |
| return f"{name} wurde erfolgreich ins Archiv verschoben!", d[0], d[1], d[2], d[3], d[4] |
|
|
| |
| |
| |
| def _tiere_gruppiert(zoo_name): |
| """Liefert alle aktiven Tiere eines Kontos gruppiert nach Kategorie, für die |
| neue Tier-Auswahl-Liste (v7.91.7, ersetzt das Dropdown). Pflegetiere und |
| Urlaubsgäste nur, wenn der Erweiterte Modus aktiv ist - genau wie bisher |
| beim Dropdown auch. Nur Gruppen mit mindestens einem Tier werden geliefert, |
| leere Gruppen fallen weg.""" |
| pm = lade_modus(zoo_name) |
| gruppen = {"Eigene Tiere": [], "Pflegetiere": [], "Urlaubsgäste": []} |
| for r in get_cached_data("tier"): |
| if len(r) > 1 and is_zoo(r[0], zoo_name) and str(r[1]).strip() and str(r[1]).strip().lower() != "name": |
| kat = str(r[23]).strip() if len(r) > 23 and r[23] else "Eigenes Tier" |
| if kat == "Pflegetier" and pm: |
| gruppen["Pflegetiere"].append(str(r[1]).strip()) |
| elif kat == "Urlaubsgast" and pm: |
| gruppen["Urlaubsgäste"].append(str(r[1]).strip()) |
| elif kat == "Eigenes Tier" or not pm: |
| if kat == "Eigenes Tier": |
| gruppen["Eigene Tiere"].append(str(r[1]).strip()) |
| return [(name, tiere) for name, tiere in gruppen.items() if tiere] |
|
|
| def _alle_tiere_namen(zoo_name): |
| """Liefert die Namen aller (nicht-archivierten) Tiere eines Kontos, für die |
| Tagebuch-Übersicht - unabhängig davon, ob schon Einträge existieren.""" |
| pm = lade_modus(zoo_name) |
| namen = [] |
| for r in get_cached_data("tier"): |
| if len(r) > 1 and is_zoo(r[0], zoo_name) and str(r[1]).strip() and str(r[1]).strip().lower() != "name": |
| if pm or not (len(r) > 23 and r[23] and r[23] != "Eigenes Tier"): |
| namen.append(str(r[1]).strip()) |
| return namen |
|
|
| def _tiere_mit_tagebuch_stand(zoo_name): |
| """Liefert alle aktiven (nicht archivierten) Tiere dieses Zoos mit Anzahl und |
| letztem Datum ihrer Tagebuch-Einträge, für die Tagebuch-Übersicht (v7.91.4). |
| Tiere mit Einträgen zuerst (neuestes Datum zuerst), Tiere ohne Einträge danach |
| alphabetisch - damit das Aktivste ganz oben steht, aber nichts verschwindet.""" |
| if not zoo_name: |
| return [] |
| tb_nach_tier = {} |
| for r in get_cached_data("tagebuch"): |
| if len(r) >= 3 and is_zoo(r[0], zoo_name): |
| name = str(r[1]).strip() |
| eintrag = tb_nach_tier.setdefault(name, {"anzahl": 0, "letztes_datum": ""}) |
| eintrag["anzahl"] += 1 |
| try: |
| d = datetime.strptime(str(r[2]).strip(), "%d.%m.%Y") |
| if not eintrag["letztes_datum"] or d > datetime.strptime(eintrag["letztes_datum"], "%d.%m.%Y"): |
| eintrag["letztes_datum"] = str(r[2]).strip() |
| except Exception: |
| pass |
| ergebnis = [] |
| for r in get_cached_data("tier"): |
| if len(r) > 1 and is_zoo(r[0], zoo_name): |
| name = str(r[1]).strip() |
| if not name: |
| continue |
| stand = tb_nach_tier.get(name, {"anzahl": 0, "letztes_datum": ""}) |
| tierart = str(r[2]).strip() if len(r) > 2 else "" |
| ergebnis.append({"name": name, "tierart": tierart, "anzahl": stand["anzahl"], "letztes_datum": stand["letztes_datum"]}) |
| def sortier_schluessel(t): |
| try: |
| datum_sort = datetime.strptime(t["letztes_datum"], "%d.%m.%Y") if t["letztes_datum"] else datetime.min |
| except Exception: |
| datum_sort = datetime.min |
| return (t["anzahl"] == 0, -datum_sort.timestamp() if t["anzahl"] > 0 else 0, t["name"].lower()) |
| ergebnis.sort(key=sortier_schluessel) |
| return ergebnis |
|
|
| def _tagebuch_eintraege(zoo_name, tier_name): |
| """Liefert alle Tagebuch-Einträge für dieses Tier, chronologisch (älteste |
| zuerst, damit sortiert nach Datum stabil bleibt auch bei gleichem Tagesdatum). |
| Enthält die tatsächliche Sheet-Zeilennummer für den Bearbeiten-Modus.""" |
| if not zoo_name or not tier_name: |
| return [] |
| eintraege = [] |
| for i, r in enumerate(get_cached_data("tagebuch")): |
| if len(r) >= 4 and is_zoo(r[0], zoo_name) and str(r[1]).strip() == str(tier_name).strip(): |
| foto_url = str(r[4]).strip() if len(r) > 4 and r[4] else "" |
| titel = str(r[5]).strip() if len(r) > 5 and r[5] else "" |
| eintraege.append({"zeile": i + 1, "datum": str(r[2]).strip(), "text": str(r[3]).strip(), "foto": foto_url, "titel": titel}) |
| return eintraege |
|
|
| def tagebuch_eintrag_speichern(zoo_name, tier_name, titel, text, foto_pfad, anzahl_anzeigen, refresh_zaehler): |
| """Speichert einen NEUEN Tagebuch-Eintrag inkl. Titel und optionalem Foto. |
| Race-sicheres append_row() + Rücklese-Prüfung (siehe v7.88.3) statt "Zeile |
| zählen, dann fest dorthin schreiben" - Letzteres kann bei zeitgleichem |
| Speichern auf zwei Geräten (z.B. Android + iOS) zum gegenseitigen |
| Überschreiben führen, ganz ohne Fehlermeldung. Das Bearbeiten bestehender |
| Einträge läuft seit v7.83.1 direkt inline in der jeweiligen Karte (siehe |
| @gr.render weiter unten), dieses Formular ist nur noch für neue Einträge |
| zuständig. 'refresh_zaehler' wird hochgezählt, um die Karten-Liste zum |
| Neuzeichnen mit den frischen Daten zu bewegen.""" |
| if not zoo_name: |
| return "❌ Nicht eingeloggt.", gr.update(), gr.update(), gr.update(), gr.update(), gr.update() |
| if not tier_name or tier_name in ("Hier Auswählen", "Neues Tier..."): |
| return "❌ Bitte zuerst ein Tier auswählen (und einmal speichern, falls neu angelegt).", gr.update(), gr.update(), gr.update(), gr.update(), gr.update() |
| if not text or not text.strip(): |
| return "❌ Bitte einen Text eintragen.", gr.update(), gr.update(), gr.update(), gr.update(), gr.update() |
| try: |
| foto_url = upload_image_to_cloud(foto_pfad) if foto_pfad else "" |
| neue_zeile = [zoo_name, tier_name, datetime.now().strftime("%d.%m.%Y"), text.strip(), foto_url, str(titel).strip() if titel else ""] |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| ws_tagebuch.append_row(neue_zeile) |
| invalidate_cache() |
| |
| |
| |
| gefunden = False |
| for versuch in range(3): |
| if versuch > 0: time.sleep(1.5) |
| for r in ws_tagebuch.get_all_values(): |
| if len(r) >= 4 and r[0] == zoo_name and r[1] == tier_name and r[3] == text.strip(): |
| gefunden = True |
| break |
| if gefunden: |
| break |
| status = "✅ Eintrag gespeichert." if gefunden else "⚠️ Eintrag konnte nicht sicher bestätigt werden - bitte prüfen und ggf. erneut speichern." |
| return status, "", "", None, 8, (refresh_zaehler or 0) + 1 |
| except Exception as e: |
| return f"❌ Fehler beim Speichern: {str(e)[:80]}", gr.update(), gr.update(), gr.update(), gr.update(), gr.update() |
|
|
| |
| |
| |
| APOTHEKE_KATEGORIE_ICONS = [ |
| (("hund",), "🐶"), |
| (("katze", "kater"), "🐱"), |
| (("pferd", "pony"), "🐴"), |
| (("kaninchen", "hase"), "🐰"), |
| (("meerschwein",), "🐹"), |
| (("hamster",), "🐹"), |
| (("vogel", "wellensittich", "papagei"), "🐦"), |
| (("schlange", "gecko", "reptil"), "🦎"), |
| (("schildkröte", "leguan"), "🐢"), |
| (("fisch", "aquarium"), "🐠"), |
| ] |
|
|
| def _apotheke_icon(kategorie): |
| """Liefert ein zur Tierart passendes Icon für eine Haustierapotheke-Kategorie |
| (z.B. 🐶 für 'Hund'), erkannt per Stichwort im Kategorienamen - fällt bei |
| unbekannten/freien Kategorien auf die neutrale Pfote zurück.""" |
| k = str(kategorie).strip().lower() |
| for stichworte, icon in APOTHEKE_KATEGORIE_ICONS: |
| if any(s in k for s in stichworte): |
| return icon |
| return "🐾" |
|
|
| def _apotheke_eintraege(): |
| """Liefert alle Einträge der Haustierapotheke (kontenübergreifend, nicht nach |
| Zoo gefiltert - das ist bewusst eine gemeinsame Datenbank für alle Nutzer).""" |
| eintraege = [] |
| for i, r in enumerate(get_cached_data("apotheke")): |
| if i == 0 and str(r[0]).strip().lower() == "kategorie": |
| continue |
| if len(r) >= 3 and str(r[1]).strip(): |
| eintraege.append({"zeile": i + 1, "kategorie": str(r[0]).strip(), "titel": str(r[1]).strip(), "inhalt": str(r[2]).strip(), "datum": str(r[3]).strip() if len(r) > 3 else ""}) |
| return eintraege |
|
|
| def lade_apotheke_kategorien(): |
| """Liefert die Liste aller vorhandenen Kategorien, alphabetisch sortiert.""" |
| return sorted(set(e["kategorie"] for e in _apotheke_eintraege() if e["kategorie"])) |
|
|
| def apotheke_banner_ist_aktiv(): |
| """Liest, ob das Kopf-Banner-Bild der Haustierapotheke gerade angezeigt werden |
| soll. Über den Admin-Bereich umschaltbar, ohne dass Code geändert werden muss - |
| praktisch z.B. wenn gerade ein neues Bild hochgeladen wird und das alte/kaputte |
| Bild zwischenzeitlich nicht angezeigt werden soll.""" |
| try: |
| for r in ws_app_einstellungen.get_all_values(): |
| if len(r) >= 2 and str(r[0]).strip() == "apotheke_banner_aktiv": |
| return str(r[1]).strip().lower() == "ja" |
| except Exception: |
| pass |
| return True |
|
|
| def apotheke_banner_umschalten(neuer_status): |
| """Schaltet das Haustierapotheke-Banner an/aus (Admin-Checkbox).""" |
| try: |
| rows = ws_app_einstellungen.get_all_values() |
| for i, r in enumerate(rows): |
| if len(r) >= 1 and str(r[0]).strip() == "apotheke_banner_aktiv": |
| ws_app_einstellungen.update(range_name=f"A{i + 1}:B{i + 1}", values=[["apotheke_banner_aktiv", "ja" if neuer_status else "nein"]]) |
| return gr.update(visible=neuer_status) |
| naechste_zeile = len(rows) + 1 |
| ws_app_einstellungen.update(range_name=f"A{naechste_zeile}:B{naechste_zeile}", values=[["apotheke_banner_aktiv", "ja" if neuer_status else "nein"]]) |
| return gr.update(visible=neuer_status) |
| except Exception: |
| return gr.update() |
|
|
| def apotheke_eintrag_speichern(zoo_name, kategorie, titel, inhalt, refresh_zaehler): |
| """Legt einen neuen Haustierapotheke-Eintrag an - nur für Admins zugänglich |
| (siehe UI-Sichtbarkeit), da die Datenbank bewusst kuratiert wird und nicht von |
| allen Nutzern frei bearbeitbar ist. Race-sicheres append_row() + Rücklese- |
| Prüfung (siehe v7.88.3) statt "Zeile zählen, dann fest dorthin schreiben" - |
| Letzteres kann bei zeitgleichen Speicherungen mehrerer Admins zum |
| gegenseitigen Überschreiben führen. 'refresh_zaehler' wird hochgezählt, um die |
| Kategorien-Ansicht (siehe @gr.render weiter unten) zum Neuzeichnen mit den |
| frischen Daten zu bewegen. Öffnet zusätzlich automatisch die passende |
| Kategorie-Karte, sonst landet der neue Eintrag in einer eingeklappten Karte |
| und sieht aus wie 'nicht gespeichert'.""" |
| if not zoo_name: |
| return "❌ Nicht eingeloggt.", gr.update(), gr.update(), gr.update(), gr.update(), gr.update() |
| if not kategorie or not kategorie.strip(): |
| return "❌ Bitte eine Kategorie eintragen.", gr.update(), gr.update(), gr.update(), gr.update(), gr.update() |
| if not titel or not titel.strip(): |
| return "❌ Bitte einen Titel eintragen.", gr.update(), gr.update(), gr.update(), gr.update(), gr.update() |
| if not inhalt or not inhalt.strip(): |
| return "❌ Bitte einen Inhalt eintragen.", gr.update(), gr.update(), gr.update(), gr.update(), gr.update() |
| try: |
| neue_zeile = [kategorie.strip(), titel.strip(), inhalt.strip(), datetime.now().strftime("%d.%m.%Y")] |
| |
| |
| |
| |
| ws_apotheke.append_row(neue_zeile) |
| invalidate_cache() |
| gefunden = False |
| for versuch in range(3): |
| if versuch > 0: time.sleep(1.5) |
| for r in ws_apotheke.get_all_values(): |
| if len(r) >= 3 and r[0] == kategorie.strip() and r[1] == titel.strip(): |
| gefunden = True |
| break |
| if gefunden: |
| break |
| status = "✅ Eintrag gespeichert." if gefunden else "⚠️ Eintrag konnte nicht sicher bestätigt werden - bitte prüfen und ggf. erneut speichern." |
| return status, "", "", "", (refresh_zaehler or 0) + 1, kategorie.strip() |
| except Exception as e: |
| return f"❌ Fehler beim Speichern: {str(e)[:80]}", gr.update(), gr.update(), gr.update(), gr.update(), gr.update() |
|
|
| def exportiere_tagebuch_pdf(zoo_name, tier_name): |
| """Exportiert den kompletten chronologischen Verlauf eines Tieres als PDF |
| inkl. Titel und Fotos, z.B. zum Mitgeben an neue Paten von Pflegetieren.""" |
| if not zoo_name or not tier_name or tier_name in ("Hier Auswählen", "Neues Tier..."): |
| err_file = "Keine_Auswahl.txt" |
| with open(err_file, "w") as f: f.write("Bitte wähle zuerst ein Tier aus.") |
| return err_file, "❌ Bitte wähle zuerst ein Tier aus!" |
| eintraege = _tagebuch_eintraege(zoo_name, tier_name) |
| if not eintraege: |
| err_file = "Keine_Eintraege.txt" |
| with open(err_file, "w") as f: f.write("Für dieses Tier gibt es noch keine Tagebuch-Einträge.") |
| return err_file, "❌ Für dieses Tier gibt es noch keine Einträge!" |
| try: |
| def safe_txt(t): return str(t).encode('latin-1', 'replace').decode('latin-1') |
| pdf = FPDF() |
| pdf.add_page() |
| pdf.set_font("Arial", 'B', 16) |
| pdf.cell(190, 10, txt=safe_txt(f"Tagebuch: {tier_name}"), ln=True, align='C') |
| pdf.set_font("Arial", '', 10) |
| pdf.set_text_color(120, 110, 92) |
| pdf.cell(190, 6, txt=safe_txt(f"Erstellt am {datetime.now().strftime('%d.%m.%Y')} mit PetBase"), ln=True, align='C') |
| pdf.set_text_color(0, 0, 0) |
| pdf.ln(6) |
| for e in eintraege: |
| |
| |
| |
| geschaetzte_hoehe = 20 + (7 if e.get("titel") else 0) + (55 if e.get("foto") else 0) |
| if pdf.get_y() + geschaetzte_hoehe > 280: |
| pdf.add_page() |
| pdf.set_font("Arial", 'B', 11) |
| pdf.cell(190, 7, txt=safe_txt(e["datum"]), ln=True) |
| if e.get("titel"): |
| pdf.set_font("Arial", 'B', 12) |
| pdf.cell(190, 7, txt=safe_txt(e["titel"]), ln=True) |
| pdf.set_font("Arial", '', 11) |
| pdf.multi_cell(190, 6, txt=safe_txt(e["text"])) |
| if e.get("foto") and e["foto"].startswith("http"): |
| erste_foto_url = e["foto"].split(",")[0].strip() |
| try: |
| resp = requests.get(erste_foto_url, headers={"User-Agent": "Mozilla/5.0"}, timeout=5) |
| if resp.status_code == 200: |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp: |
| tmp.write(resp.content); tmp.close() |
| with PILImage.open(tmp.name) as img: |
| if img.mode != "RGB": img = img.convert("RGB") |
| img.save(tmp.name, "JPEG") |
| aspect = img.size[1] / max(img.size[0], 1) |
| bild_breite = 70 |
| pdf.image(tmp.name, x=10, y=pdf.get_y() + 2, w=bild_breite, h=bild_breite * aspect) |
| pdf.set_y(pdf.get_y() + bild_breite * aspect + 4) |
| except Exception: |
| pass |
| pdf.ln(3) |
| clean_name = str(tier_name).replace(" ", "_").replace("/", "").strip() |
| filename = f"{clean_name}_Tagebuch.pdf" |
| pdf.output(filename) |
| return filename, "✅ PDF wurde erstellt." |
| except Exception as e: |
| err_file = "Fehler.txt" |
| with open(err_file, "w") as f: f.write(f"Fehler: {e}") |
| return err_file, f"❌ Fehler beim Erstellen: {str(e)[:80]}" |
|
|
| def export_pdf(zoo_name, sel_name, is_archive, mit_info=True, mit_pflege=True, mit_vermittlung=True, mit_tierarzt=True, mit_tagebuch=False): |
| if not zoo_name or not sel_name or sel_name == "Hier Auswählen": |
| err_file = "Keine_Auswahl.txt" |
| with open(err_file, "w") as f: f.write("Bitte wähle zuerst etwas aus.") |
| return err_file, "❌ Bitte wähle zuerst ein Tier aus!" |
| if not (mit_info or mit_pflege or mit_vermittlung or mit_tierarzt or mit_tagebuch): |
| err_file = "Keine_Auswahl.txt" |
| with open(err_file, "w") as f: f.write("Bitte mindestens eine Option auswählen.") |
| return err_file, "❌ Bitte mindestens eine Option auswählen (Infoblatt/Pflegevertrag/Vermittlungsvertrag/Tagebuch)!" |
| try: |
| pm = lade_modus(zoo_name) |
| pdf = FPDF() |
| clean_name = sel_name.replace(" ", "_").replace("/", "").strip() |
| filename = f"{clean_name}_Akte.pdf" |
| def safe_txt(t): return str(t).encode('latin-1', 'replace').decode('latin-1') |
| raw_data = ws_archiv.get_all_values() if is_archive else get_cached_data("tier") |
| data = [] |
| for r in raw_data: |
| if len(r) == 0 or not str(r[1]).strip() or str(r[1]).strip().lower() == "name" or not is_zoo(r[0], zoo_name): continue |
| kat = r[23] if len(r) > 23 and r[23] else "Eigenes Tier" |
| if sel_name == "Alle eigenen Tiere" and kat == "Eigenes Tier": data.append(r) |
| elif sel_name == "Alle Pflege- & Gasttiere" and kat in ["Pflegetier", "Urlaubsgast"]: data.append(r) |
| elif sel_name == "Alle Archivierten": |
| if pm or kat == "Eigenes Tier": data.append(r) |
| elif str(r[1]).strip() == str(sel_name).strip(): data.append(r) |
| if not data: |
| err_file = "Keine_Daten.txt" |
| with open(err_file, "w") as f: f.write("Keine Tiere gefunden.") |
| return err_file, "❌ Keine Tiere gefunden!" |
| inv_data = [ir for ir in get_cached_data("inv")[1:] if len(ir) > 0 and is_zoo(ir[0], zoo_name)] |
| futter_details = {ir[8].strip(): ir for ir in inv_data if len(ir) > 8} |
| for r in data: |
| row = r + [""] * (40 - len(r)) |
| img_url = str(row[19]) |
| if mit_info: |
| pdf.add_page() |
| start_y = pdf.get_y() |
| pdf.set_font("Arial", 'B', 16) |
| pdf.cell(200, 10, txt=safe_txt(f"Digitale Akte: {row[1]}"), ln=True, align='C') |
| pdf.ln(5) |
| pdf.set_font("Arial", 'B', 14) |
| kat_str = f" [{row[23]}]" if row[23] and pm else "" |
| pdf.cell(130, 8, txt=safe_txt(f"Name: {row[1]}{kat_str}"), ln=True) |
| pdf.set_font("Arial", '', 11) |
| pdf.cell(130, 6, txt=safe_txt(f"Tierart: {row[2]} | Rasse: {row[3]}"), ln=True) |
| pdf.cell(130, 6, txt=safe_txt(f"Geburtsdatum: {row[20]} | Farbe: {row[21]}"), ln=True) |
| pdf.cell(130, 6, txt=safe_txt(f"Kastriert: {row[22]} | Gewicht: {row[4]} kg"), ln=True) |
| pdf.cell(130, 6, txt=safe_txt(f"ID/Ring: {row[5]} | Registrierung: {row[6]}"), ln=True) |
| if len(row) > 39 and row[39]: |
| pdf.ln(1) |
| pdf.set_font("Arial", 'B', 11) |
| pdf.set_text_color(180, 30, 30) |
| pdf.multi_cell(130, 6, txt=safe_txt(f"ACHTUNG - Allergien/Unverträglichkeiten: {row[39]}")) |
| pdf.set_text_color(0, 0, 0) |
|
|
| if is_archive: |
| grund = row[17] if len(row) > 17 and row[17] else "Unbekannt" |
| datum_archiv = row[18] if len(row) > 18 and row[18] else "Unbekannt" |
| pdf.cell(130, 6, txt=safe_txt(f"Status: Archiviert am {datum_archiv} | Grund: {grund}"), ln=True) |
|
|
| pdf.ln(2); pdf.set_font("Arial", 'B', 11); pdf.cell(130, 6, txt=safe_txt("Fütterung:"), ln=True); pdf.set_font("Arial", '', 11) |
| if str(row[7]).upper() == 'TRUE': |
| pdf.cell(130, 6, txt=safe_txt(f"Frischfütterung/BARF: {row[9]} | Gesamt: {row[10]} g/Tag"), ln=True) |
| pdf.cell(130, 6, txt=safe_txt(f" -> davon Protein/Fleisch: {row[11]} g | Kohlenhydrate/Faser: {row[12]} g"), ln=True) |
| else: |
| sorte = str(row[8]).strip() |
| pdf.cell(130, 6, txt=safe_txt(f"Hauptfutter: {sorte} | {row[16]} g/Tag"), ln=True) |
| if len(row) > 36 and str(row[34]).strip().lower() in ("ja", "true") and row[35]: |
| pdf.cell(130, 6, txt=safe_txt(f"Zusätzlich Nassfutter: {row[35]} | {row[36]} g/Tag"), ln=True) |
|
|
| pdf.ln(2); pdf.set_font("Arial", 'B', 11); pdf.cell(130, 6, txt=safe_txt("Gesundheit & Termine:"), ln=True); pdf.set_font("Arial", '', 11) |
| pdf.cell(130, 6, txt=safe_txt(f"Impfung / Check-up: {row[13]}"), ln=True) |
| pdf.cell(130, 6, txt=safe_txt(f"Wurmkur / Kotprobe: {row[14]}"), ln=True) |
| para_int = row[24] if len(row) > 24 and row[24] else "3 Monate" |
| pdf.cell(130, 6, txt=safe_txt(f"Spezialpflege / Parasitenschutz: {row[15]} (Intervall: {para_int})"), ln=True) |
|
|
| if img_url.startswith("http"): |
| try: |
| resp = requests.get(img_url, headers={"User-Agent": "Mozilla/5.0"}, timeout=3) |
| if resp.status_code == 200: |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp: |
| tmp.write(resp.content); tmp.close() |
| with PILImage.open(tmp.name) as img: |
| if img.mode != "RGB": img = img.convert("RGB") |
| img.save(tmp.name, "JPEG") |
| aspect = img.size[1]/max(img.size[0], 1) |
| pdf.image(tmp.name, x=160, y=start_y+10, w=30, h=30*aspect) |
| except: pass |
|
|
| docs_to_print = [] |
| if mit_pflege and len(row) > 26 and row[26].startswith("http"): docs_to_print.append((row[26], "Pflegevertrag")) |
| if mit_vermittlung and len(row) > 27 and row[27].startswith("http"): docs_to_print.append((row[27], "Vermittlungsvertrag")) |
| if mit_tierarzt: |
| for i in range(28, 34): |
| if len(row) > i and row[i].startswith("http"): |
| docs_to_print.append((row[i], f"Tierarzt-Dokument {i-27}")) |
| |
| for doc_url, doc_title in docs_to_print: |
| seiten_urls = [u.strip() for u in doc_url.split(",") if u.strip().startswith("http")] |
| for seiten_idx, einzel_url in enumerate(seiten_urls): |
| try: |
| resp = requests.get(einzel_url, headers={"User-Agent": "Mozilla/5.0"}, timeout=5) |
| if resp.status_code == 200: |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp: |
| tmp.write(resp.content); tmp.close() |
| with PILImage.open(tmp.name) as img: |
| if img.mode != "RGB": img = img.convert("RGB") |
| img.save(tmp.name, "JPEG") |
| titel = f"{doc_title} (Seite {seiten_idx+1}/{len(seiten_urls)})" if len(seiten_urls) > 1 else doc_title |
| pdf.add_page(); pdf.set_font("Arial", 'B', 12) |
| pdf.cell(200, 10, txt=safe_txt(titel), ln=True, align='C') |
| w, h = img.size; ratio = min(190/w, 250/h) |
| pdf.image(tmp.name, x=10, y=25, w=w*ratio, h=h*ratio) |
| except: pass |
|
|
| if mit_tagebuch: |
| |
| |
| |
| |
| tb_eintraege = _tagebuch_eintraege(zoo_name, row[1]) |
| if tb_eintraege: |
| pdf.add_page() |
| pdf.set_font("Arial", 'B', 14) |
| pdf.cell(190, 10, txt=safe_txt(f"Tagebuch: {row[1]}"), ln=True, align='C') |
| pdf.ln(4) |
| for e in tb_eintraege: |
| geschaetzte_hoehe = 20 + (7 if e.get("titel") else 0) + (55 if e.get("foto") else 0) |
| if pdf.get_y() + geschaetzte_hoehe > 280: |
| pdf.add_page() |
| pdf.set_font("Arial", 'B', 11) |
| pdf.cell(190, 7, txt=safe_txt(e["datum"]), ln=True) |
| if e.get("titel"): |
| pdf.set_font("Arial", 'B', 12) |
| pdf.cell(190, 7, txt=safe_txt(e["titel"]), ln=True) |
| pdf.set_font("Arial", '', 11) |
| pdf.multi_cell(190, 6, txt=safe_txt(e["text"])) |
| if e.get("foto") and e["foto"].startswith("http"): |
| erste_foto_url = e["foto"].split(",")[0].strip() |
| try: |
| resp = requests.get(erste_foto_url, headers={"User-Agent": "Mozilla/5.0"}, timeout=5) |
| if resp.status_code == 200: |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp: |
| tmp.write(resp.content); tmp.close() |
| with PILImage.open(tmp.name) as img: |
| if img.mode != "RGB": img = img.convert("RGB") |
| img.save(tmp.name, "JPEG") |
| aspect = img.size[1] / max(img.size[0], 1) |
| bild_breite = 70 |
| pdf.image(tmp.name, x=10, y=pdf.get_y() + 2, w=bild_breite, h=bild_breite * aspect) |
| pdf.set_y(pdf.get_y() + bild_breite * aspect + 4) |
| except Exception: |
| pass |
| pdf.ln(3) |
|
|
| if pdf.page_no() == 0: |
| err_file = "Keine_Inhalte.txt" |
| with open(err_file, "w") as f: f.write("Für die gewählte Auswahl gibt es keine passenden Inhalte.") |
| return err_file, "❌ Für die gewählte Kombination gibt es keine passenden Inhalte (z.B. kein hochgeladener Vertrag vorhanden)." |
| pdf.output(filename) |
| return filename, f"✅ Komplette Akte für {sel_name} erstellt!" |
| except Exception as e: |
| err_file = "PDF_Fehler.txt" |
| with open(err_file, "w") as f: f.write(f"Fehler:\n{str(e)}") |
| return err_file, "❌ Fehler bei PDF Erstellung." |
|
|
| def zeige_vermisst_daten(name, tierart, rasse, farbe, chip, tasso, kontakt_tel, kontakt_tel2): |
| if not name or name == "Hier Auswählen": |
| return "Bitte zuerst ein Tier auswählen bzw. laden." |
| zeilen = [ |
| f"Name: {name}", |
| f"Tierart: {tierart or '-'}", |
| f"Rasse: {rasse or '-'}", |
| f"Farbe: {farbe or '-'}", |
| f"Chip-/Ringnummer: {chip or '-'}", |
| f"Tasso-/Registrierungsnummer: {tasso or '-'}", |
| f"Kontakttelefon: {kontakt_tel or '-'}", |
| ] |
| if kontakt_tel2: zeilen.append(f"Kontakttelefon 2 (Backup): {kontakt_tel2}") |
| return "\n".join(zeilen) |
|
|
| def export_vermisst_steckbrief(zoo_name, name, tierart, rasse, farbe, chip, tasso, kontakt_tel, kontakt_tel2, notiz, p_img_path): |
| if not zoo_name or not name or name == "Hier Auswählen": |
| err_file = "Kein_Tier.txt" |
| with open(err_file, "w") as f: f.write("Bitte zuerst ein Tier auswählen.") |
| return err_file, "❌ Bitte zuerst ein Tier auswählen bzw. laden." |
| try: |
| def safe_txt(t): return str(t).encode('latin-1', 'replace').decode('latin-1') |
| pdf = FPDF() |
| pdf.add_page() |
| pdf.set_font("Arial", 'B', 30) |
| pdf.set_text_color(200, 0, 0) |
| pdf.cell(0, 22, txt="VERMISST", ln=True, align='C') |
| pdf.set_text_color(0, 0, 0) |
|
|
| y_nach_bild = 32 |
| if p_img_path: |
| try: |
| with PILImage.open(p_img_path) as img: |
| if img.mode != "RGB": img = img.convert("RGB") |
| tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") |
| img.save(tmp.name, "JPEG") |
| w = 100 |
| aspect = img.size[1] / max(img.size[0], 1) |
| pdf.image(tmp.name, x=(210 - w) / 2, y=32, w=w, h=w * aspect) |
| y_nach_bild = 32 + w * aspect + 8 |
| except: pass |
|
|
| pdf.set_y(y_nach_bild) |
| pdf.set_font("Arial", 'B', 18) |
| pdf.cell(0, 10, txt=safe_txt(name), ln=True, align='C') |
| pdf.set_font("Arial", '', 13) |
| pdf.ln(2) |
| for label, wert in [("Tierart", tierart), ("Rasse", rasse), ("Farbe", farbe), ("Chip-/Ringnummer", chip), ("Tasso-/Registrierung", tasso)]: |
| if wert: |
| pdf.cell(0, 8, txt=safe_txt(f"{label}: {wert}"), ln=True, align='C') |
|
|
| if notiz: |
| pdf.ln(3) |
| pdf.set_font("Arial", 'I', 12) |
| pdf.multi_cell(0, 7, txt=safe_txt(notiz), align='C') |
|
|
| pdf.ln(6) |
| pdf.set_font("Arial", 'B', 15) |
| pdf.set_text_color(200, 0, 0) |
| kontakt_zeile = f"Bitte melden unter: {kontakt_tel}" if kontakt_tel else "Bitte bei TASSO oder einem Tierarzt melden" |
| if kontakt_tel2: kontakt_zeile += f"\noder: {kontakt_tel2}" |
| pdf.multi_cell(0, 9, txt=safe_txt(kontakt_zeile), align='C') |
|
|
| clean_name = str(name).replace(" ", "_").replace("/", "") |
| filename = f"Vermisst_{clean_name}.pdf" |
| pdf.output(filename) |
| return filename, f"✅ Steckbrief für {name} erstellt!" |
| except Exception as e: |
| err_file = "PDF_Fehler.txt" |
| with open(err_file, "w") as f: f.write(f"Fehler:\n{str(e)}") |
| return err_file, "❌ Fehler beim Erstellen des Steckbriefs." |
|
|
| def zeige_als_toast(msg): |
| """Zeigt einen Status-Text als kurzes Overlay statt in einer sichtbaren Textbox.""" |
| if not msg or not str(msg).strip(): |
| return |
| msg = str(msg) |
| if msg.startswith("❌") or msg.startswith("⚠️") or "Fehler" in msg: |
| gr.Warning(msg) |
| else: |
| gr.Info(msg) |
|
|
| def get_heute(): return datetime.now().strftime("%d.%m.%Y") |
| |
| |
| |
| |
| def smtp_env(key): |
| """Liest eine Umgebungsvariable und entfernt versehentlich mitkopierte |
| Anführungszeichen/Leerzeichen - ein häufiger Fehler beim Einfügen von Secrets.""" |
| wert = os.environ.get(key, "") |
| if not wert: return "" |
| wert = wert.strip() |
| if len(wert) >= 2 and wert[0] == wert[-1] and wert[0] in ("'", '"'): |
| wert = wert[1:-1].strip() |
| return wert |
|
|
| def email_konfiguriert(): |
| return all([smtp_env("BREVO_API_KEY"), smtp_env("SMTP_FROM")]) |
|
|
| def email_status_text(): |
| if email_konfiguriert(): |
| return f"<div style='color:#2E7D32;'>✅ E-Mail-Versand ist konfiguriert (Absender: {smtp_env('SMTP_FROM')})</div>" |
| fehlend = [k for k in ["BREVO_API_KEY", "SMTP_FROM"] if not smtp_env(k)] |
| return f"<div style='color:#D32F2F;'>❌ E-Mail-Versand noch nicht eingerichtet. Fehlende Secrets: {', '.join(fehlend)}</div>" |
|
|
| def _from_name_email(smtp_from): |
| """Zerlegt 'Name <email@x.de>' in (Name, E-Mail), akzeptiert auch nur 'email@x.de'.""" |
| m = re.match(r'^\s*(.*?)\s*<(.+?)>\s*$', smtp_from) |
| if m: return (m.group(1).strip() or "PetBase"), m.group(2).strip() |
| return "PetBase", smtp_from.strip() |
|
|
| def sende_email(empfaenger, betreff, text, zoo_name=""): |
| if not email_konfiguriert(): |
| return False, "E-Mail-Versand ist nicht eingerichtet (fehlende Secrets)." |
| if not empfaenger or "@" not in str(empfaenger): |
| return False, "Keine gültige E-Mail-Adresse hinterlegt." |
| try: |
| name, email = _from_name_email(smtp_env("SMTP_FROM")) |
| |
| |
| html_text = text.replace("\n", "<br>") |
| html_content = ( |
| "<div style='font-family:Arial,sans-serif; color:#3B3226; font-size:15px; line-height:1.5;'>" |
| f"{html_text}" |
| "</div>" |
| "<div style='margin-top:28px;'>" |
| f"<img src='{BANNER_URL}' alt='PetBase' style='max-width:100%; width:400px; height:auto; border-radius:8px; display:block;'>" |
| "</div>" |
| ) |
| resp = requests.post( |
| "https://api.brevo.com/v3/smtp/email", |
| headers={"api-key": smtp_env("BREVO_API_KEY"), "Content-Type": "application/json", "Accept": "application/json"}, |
| json={"sender": {"name": name, "email": email}, "to": [{"email": empfaenger}], "subject": betreff, "textContent": text, "htmlContent": html_content}, |
| timeout=10, |
| ) |
| if resp.status_code not in (200, 201): |
| return False, f"❌ E-Mail konnte nicht versendet werden ({resp.status_code}): {resp.text[:80]}" |
| try: |
| naechste_zeile = len(ws_emaillog.get_all_values()) + 1 |
| ws_emaillog.update(range_name=f"A{naechste_zeile}:D{naechste_zeile}", values=[[datetime.now().strftime("%d.%m.%Y %H:%M"), zoo_name, empfaenger, betreff]]) |
| invalidate_cache() |
| except: pass |
| return True, "✅ E-Mail wurde versendet." |
| except Exception as e: |
| return False, f"❌ E-Mail konnte nicht versendet werden (Host: '{smtp_env('SMTP_HOST')}'): {str(e)[:60]}" |
|
|
| def email_zaehler(): |
| try: |
| rows = get_cached_data("emaillog") |
| start_idx = 1 if len(rows) > 0 and "datum" in str(rows[0][0]).lower() else 0 |
| return len(rows) - start_idx |
| except Exception: return 0 |
|
|
| def test_email_senden(zoo_name): |
| email = lade_einstellungen(zoo_name) |
| if not email: |
| return "❌ Für dein Konto ist keine E-Mail-Adresse hinterlegt (Konto → Einstellungen → Erinnerungen)." |
| ok, msg = sende_email(email, "🐾 PetBase Test-E-Mail", f"Hallo {zoo_name},\n\ndies ist eine Test-E-Mail von PetBase. Wenn du diese E-Mail liest, funktioniert der E-Mail-Versand einwandfrei! 🎉\n\nDiese E-Mail wurde automatisch versendet.", zoo_name) |
| return msg |
|
|
| def erinnerung_per_email(zoo_name, tier_name, typ, datum_str): |
| email = lade_einstellungen(zoo_name) |
| if not email: |
| return "❌ Für dein Konto ist keine E-Mail-Adresse hinterlegt (Konto → Erinnerungen)." |
| betreff = f"🐾 PetBase Erinnerung: {typ} für {tier_name}" |
| text = f"Hallo {zoo_name},\n\ndies ist eine Erinnerung von PetBase:\n\n{tier_name} - {typ}\nDatum: {datum_str}\n\nDiese E-Mail wurde automatisch von deiner PetBase-App versendet." |
| ok, msg = sende_email(email, betreff, text, zoo_name) |
| return msg |
|
|
| def pruefe_und_sende_futter_erinnerungen(): |
| """Prüft für ALLE Zoo-Accounts den Futterbestand und verschickt automatisch je |
| eine Erinnerungs-Mail pro Futtersorte, sobald der Restbestand 14, 5 oder 0 Tage |
| erreicht. Gedacht für TÄGLICHEN externen Aufruf (Cron) - Hugging Face Spaces hat |
| keinen eingebauten Scheduler. Erreichbar über die Gradio-API dieser App unter |
| api_name='futter_cron_check' (siehe UI-Definition weiter unten). Ein externer |
| Dienst wie cron-job.org kann diese Adresse einmal täglich per POST aufrufen: |
| https://<dein-space>.hf.space/gradio_api/call/futter_cron_check |
| Duplikat-Schutz: pro (Zoo, Sorte, Datum der letzten Bestandsaktualisierung, |
| Schwelle) wird nur einmal verschickt (Tracking-Sheet Futter_Erinnerungen_Log) - |
| ändert sich die Aktualisierung (neuer Einkauf), beginnt der Zyklus neu. Nutzt |
| denselben sicheren Schreib-Mechanismus wie save_data() (explizite Zielzeile |
| statt append_row(), siehe v7.73.17 - append_row() hatte sich als unzuverlässig |
| erwiesen).""" |
| SCHWELLEN = [14, 5, 0] |
| gesendet = 0 |
| try: |
| alle_zoos = sorted(set(str(r[0]).strip() for r in get_cached_data("set") if len(r) >= 3 and str(r[0]).strip())) |
| bereits_rows = get_cached_data("futtererinnerung") |
| bereits_set = {(str(r[0]).strip().lower(), str(r[1]).strip(), str(r[2]).strip(), str(r[3]).strip()) for r in bereits_rows if len(r) >= 4} |
| for zoo_name in alle_zoos: |
| email = lade_einstellungen(zoo_name) |
| if not email: |
| continue |
| for tage_rest_echt, sorte, letzte_update_str, hersteller, sackgroesse_g, barcode in berechne_futter_warnungen(zoo_name): |
| |
| |
| |
| |
| |
| schwelle_treffer = None |
| for schwelle in SCHWELLEN: |
| if tage_rest_echt <= schwelle: |
| key = (zoo_name.strip().lower(), sorte, letzte_update_str, str(schwelle)) |
| if key not in bereits_set: |
| schwelle_treffer = schwelle |
| break |
| if schwelle_treffer is None: |
| continue |
| key = (zoo_name.strip().lower(), sorte, letzte_update_str, str(schwelle_treffer)) |
| |
| try: |
| sack_num = float(str(sackgroesse_g).replace(",", ".")) |
| sack_text = f"{sack_num/1000:.1f} kg".replace(".0 kg", " kg") if sack_num >= 1000 else f"{int(sack_num)} g" |
| except (ValueError, TypeError): |
| sack_text = "" |
| produkt_bezeichnung = " – ".join([t for t in [hersteller, sorte] if t]) or sorte |
| if sack_text: |
| produkt_bezeichnung += f" ({sack_text})" |
| if tage_rest_echt > 0: |
| betreff = f"🐾 PetBase: {produkt_bezeichnung} reicht nur noch ca. {tage_rest_echt} Tage" |
| text = f"Hallo {zoo_name},\n\nder Futtervorrat für '{produkt_bezeichnung}' reicht laut Berechnung nur noch ca. {tage_rest_echt} Tage.\n\nBitte rechtzeitig nachkaufen, damit es nicht knapp wird.\n\nDiese E-Mail wurde automatisch von deiner PetBase-App versendet." |
| else: |
| betreff = f"🐾 PetBase: {produkt_bezeichnung} sollte jetzt leer sein!" |
| text = f"Hallo {zoo_name},\n\nder Futtervorrat für '{produkt_bezeichnung}' sollte laut Berechnung heute aufgebraucht sein.\n\nBitte den Bestand prüfen und ggf. nachtragen, sobald neues Futter da ist.\n\nDiese E-Mail wurde automatisch von deiner PetBase-App versendet." |
| ok, _ = sende_email(email, betreff, text, zoo_name) |
| if ok: |
| naechste_zeile = len(ws_futtererinnerung.get_all_values()) + 1 |
| ws_futtererinnerung.update(range_name=f"A{naechste_zeile}:E{naechste_zeile}", values=[[zoo_name, sorte, letzte_update_str, str(schwelle_treffer), datetime.now().strftime("%d.%m.%Y %H:%M")]]) |
| bereits_set.add(key) |
| gesendet += 1 |
| invalidate_cache() |
| return f"Futter-Check abgeschlossen: {gesendet} Erinnerungs-E-Mail(s) versendet." |
| except Exception as e: |
| return f"Fehler beim Futter-Check: {type(e).__name__}: {str(e)[:150]}" |
|
|
| def erstelle_erinnerung(tier_name, datum_str, typ, intervall=""): |
| if not datum_str or not tier_name: return gr.update(visible=False) |
| try: |
| date_obj = datetime.strptime(datum_str.strip(), "%d.%m.%Y") |
| days = 365 if typ == "Impfung" or intervall == "1 Jahr" else 90 |
| next_date = date_obj + timedelta(days=days) |
| date_format = next_date.strftime("%Y%m%d") |
| now_stamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") |
| safe_name = str(tier_name).replace(",", "") |
| ics_content = ( |
| "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Zoo-Zentrale//DE\nMETHOD:PUBLISH\n" |
| "BEGIN:VEVENT\n" |
| f"UID:{now_stamp}-{safe_name}@zoozentrale\n" |
| f"DTSTAMP:{now_stamp}\n" |
| f"SUMMARY: 🐾 Fällig: {typ} ({tier_name})\n" |
| f"DTSTART;VALUE=DATE:{date_format}\n" |
| f"DTEND;VALUE=DATE:{date_format}\n" |
| f"DESCRIPTION:Die Behandlung {typ} ist fällig!\n" |
| "BEGIN:VALARM\n" |
| "TRIGGER:-P4W\n" |
| "ACTION:DISPLAY\n" |
| f"DESCRIPTION:🐾 In 4 Wochen fällig: {typ} ({tier_name})\n" |
| "END:VALARM\n" |
| "END:VEVENT\nEND:VCALENDAR" |
| ) |
| tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".ics") |
| tmp.write(ics_content.encode("utf-8")); tmp.close() |
| return gr.update(value=tmp.name, visible=True, label=f"📅 Termin speichern") |
| except: return gr.update(visible=False) |
|
|
| def zeige_futter_phase(zoo_name, sorte): |
| if not zoo_name or not sorte or sorte == "Hier Auswählen": return "" |
| try: |
| for r in get_cached_data("inv")[1:]: |
| if len(r) > 18 and is_zoo(r[0], zoo_name) and r[8].strip() == str(sorte).strip(): return r[18] |
| except: pass |
| return "" |
|
|
| def get_gefilterte_sorten(zoo_name, tierart, futtertyp="Trockenfutter"): |
| if not zoo_name: return ["Hier Auswählen"] |
| tierart_lower = str(tierart).strip().lower() |
|
|
| gesuchte_kategorie = "Alle" |
| if any(h in tierart_lower for h in ["hund", "hünd"]): gesuchte_kategorie = "Hundefutter" |
| elif any(k in tierart_lower for k in ["katz", "kater"]): gesuchte_kategorie = "Katzenfutter" |
| elif any(n in tierart_lower for n in ["kaninchen", "meerschwein", "hamster", "maus", "ratte", "nager"]): gesuchte_kategorie = "Nagerfutter" |
| elif any(v in tierart_lower for v in ["vogel", "papagei", "sittich", "huhn", "wachtel"]): gesuchte_kategorie = "Vogelfutter" |
| elif "pferd" in tierart_lower or "pony" in tierart_lower: gesuchte_kategorie = "Pferdefutter" |
| elif any(r in tierart_lower for r in ["reptil", "schildkröte", "bartagame", "schlange", "gecko"]): gesuchte_kategorie = "Reptilienfutter" |
| elif "fisch" in tierart_lower or "koi" in tierart_lower or "aquarium" in tierart_lower: gesuchte_kategorie = "Aquaristik" |
| elif tierart_lower != "": gesuchte_kategorie = "Sonstiges" |
|
|
| gefilterte_sorten = ["Hier Auswählen"] |
| try: |
| for r in get_cached_data("inv")[1:]: |
| if len(r) > 8 and is_zoo(r[0], zoo_name): |
| sorte = r[8].strip() |
| kategorie = r[19].strip() if len(r) > 19 and r[19] else "Sonstiges" |
| ftyp = r[20].strip() if len(r) > 20 and r[20] else "Trockenfutter" |
| if sorte and ftyp == futtertyp: |
| if gesuchte_kategorie == "Alle" or kategorie == gesuchte_kategorie or kategorie == "Sonstiges": |
| gefilterte_sorten.append(sorte) |
| return gefilterte_sorten |
| except: return ["Hier Auswählen"] |
|
|
| BARF_GEEIGNETE_TIERARTEN = {"hund", "hündin", "katze", "kater", "reptil - schlange/gecko"} |
| NASS_GEEIGNETE_TIERARTEN = {"hund", "hündin", "katze", "kater"} |
|
|
| def toggle_futterarten_verfuegbarkeit(tierart, ist_barf_aktiv, ist_nass_aktiv): |
| art_lower = str(tierart).strip().lower() |
| barf_geeignet = art_lower in BARF_GEEIGNETE_TIERARTEN |
| nass_geeignet = art_lower in NASS_GEEIGNETE_TIERARTEN |
|
|
| barf_update = gr.update(visible=True) if barf_geeignet else gr.update(visible=False, value=False) |
| nass_update = gr.update(visible=True) if nass_geeignet else gr.update(visible=False, value=False) |
|
|
| barf_effektiv = bool(ist_barf_aktiv) and barf_geeignet |
| group_trocken_update = gr.update(visible=not barf_effektiv) |
| group_barf_update = gr.update(visible=barf_effektiv) |
| group_nass_update = gr.update(visible=bool(ist_nass_aktiv) and nass_geeignet) |
|
|
| return {barf_cb: barf_update, group_trocken: group_trocken_update, group_barf: group_barf_update, nass_cb: nass_update, group_nass: group_nass_update} |
|
|
| KASTRIERT_RELEVANTE_ARTEN = {"hund", "hündin", "katze", "kater", "pferd", "pony", "kaninchen", "meerschweinchen", "hamster", "sonstiges"} |
| CHIP_TASSO_RELEVANTE_ARTEN = {"hund", "hündin", "katze", "kater", "pferd", "pony", "vogel", "reptil - schlange/gecko", "reptil - schildkröte/leguan", "sonstiges"} |
|
|
| def toggle_id_felder(tierart): |
| art_lower = str(tierart).strip().lower() |
| kastriert_sichtbar = art_lower in KASTRIERT_RELEVANTE_ARTEN if art_lower else True |
| chip_tasso_sichtbar = art_lower in CHIP_TASSO_RELEVANTE_ARTEN if art_lower else True |
| return {kastriert_f: gr.update(visible=kastriert_sichtbar), chip_f: gr.update(visible=chip_tasso_sichtbar), tasso_f: gr.update(visible=chip_tasso_sichtbar)} |
|
|
| def filter_futter_nach_tierart_ui(zoo_name, tierart): |
| return gr.update(choices=get_gefilterte_sorten(zoo_name, tierart, "Trockenfutter")) |
|
|
| def filter_nassfutter_nach_tierart_ui(zoo_name, tierart): |
| return gr.update(choices=get_gefilterte_sorten(zoo_name, tierart, "Nassfutter")) |
|
|
| def _parse_bereich(s): |
| """Parst '5-10' -> (5.0, 10.0) oder '120' -> (120.0, 120.0). None bei leer/ungültig.""" |
| s = str(s).strip().replace(",", ".") |
| if not s: return None |
| if "-" in s: |
| teile = s.split("-") |
| try: return float(teile[0]), float(teile[1]) |
| except: return None |
| try: |
| v = float(s) |
| return v, v |
| except: return None |
|
|
| WEIGHT_BASIERTE_ARTEN = {"hund", "hündin", "katze", "kater", "pferd", "pony", "kaninchen", "meerschweinchen", "hamster"} |
|
|
| def berechne_trockenfutter(zoo_name, hund_gew, sorte, current_val, tierart=None): |
| if tierart is not None and str(tierart).strip().lower() not in WEIGHT_BASIERTE_ARTEN: |
| return current_val |
| if not zoo_name or not sorte or sorte in ("Hier Auswählen", "") or not hund_gew: |
| return current_val |
| try: |
| gew = float(str(hund_gew).replace(",", ".")) |
| except: |
| return current_val |
| if gew <= 0: |
| return current_val |
| try: |
| s = str(sorte).strip() |
| row = next((r for r in get_cached_data("inv") |
| if len(r) > 8 and is_zoo(r[0], zoo_name) and r[8].strip() == s), None) |
| if not row: return current_val |
| row = row + [""] * (18 - len(row)) if len(row) < 18 else row |
| paare = [(row[10], row[11]), (row[12], row[13]), (row[14], row[15]), (row[16], row[17])] |
|
|
| |
| for k, g in paare: |
| k_bereich = _parse_bereich(k) |
| if not k_bereich: continue |
| k_low, k_high = min(k_bereich), max(k_bereich) |
| if k_low <= gew <= k_high: |
| g_bereich = _parse_bereich(g) |
| if g_bereich: |
| return round((g_bereich[0] + g_bereich[1]) / 2, 1) |
|
|
| |
| kandidaten = [] |
| for k, g in paare: |
| k_bereich, g_bereich = _parse_bereich(k), _parse_bereich(g) |
| if k_bereich and g_bereich: |
| mitte_k = (k_bereich[0] + k_bereich[1]) / 2 |
| mitte_g = (g_bereich[0] + g_bereich[1]) / 2 |
| kandidaten.append((abs(mitte_k - gew), mitte_g)) |
| if kandidaten: |
| kandidaten.sort(key=lambda x: x[0]) |
| return round(kandidaten[0][1], 1) |
|
|
| return current_val |
| except: |
| return current_val |
| def hole_barf_prozent(zoo_name, typ): |
| return BARF_PROZENT_TABELLE.get(str(typ).strip(), 0.025) |
|
|
| def berechne_barf(zoo_name, gew, barf_aktiv, barf_typ, tierart): |
| if not barf_aktiv or not gew: return 0, 0, 0 |
| try: |
| gew_kg = float(str(gew).replace(",", ".")) |
| except: return 0, 0, 0 |
| if gew_kg <= 0: return 0, 0, 0 |
| |
| prozent = hole_barf_prozent(zoo_name, barf_typ) |
| gesamt_gramm = gew_kg * 1000 * prozent |
| |
| tierart_lower = str(tierart).strip().lower() if tierart else "" |
| anteil_protein = 0.80 |
| anteil_kohl = 0.20 |
| |
| if any(t in tierart_lower for t in ["pferd", "pony", "esel", "alpaka"]): |
| anteil_protein = 0.10; anteil_kohl = 0.90 |
| elif any(t in tierart_lower for t in ["kaninchen", "meerschwein", "chinchilla", "nager"]): |
| anteil_protein = 0.20; anteil_kohl = 0.80 |
| elif any(t in tierart_lower for t in ["vogel", "papagei", "sittich", "huhn"]): |
| anteil_protein = 0.30; anteil_kohl = 0.70 |
| elif any(t in tierart_lower for t in ["schlange", "gecko", "karnivor"]): |
| anteil_protein = 1.00; anteil_kohl = 0.00 |
| elif any(t in tierart_lower for t in ["schildkröte", "leguan"]): |
| anteil_protein = 0.15; anteil_kohl = 0.85 |
| |
| return round(gesamt_gramm, 1), round(gesamt_gramm * anteil_protein, 1), round(gesamt_gramm * anteil_kohl, 1) |
|
|
| |
| |
| |
| def on_doc_type_change(selected_type, is_aktiv): |
| cols = [col_vp, col_vv, col_t1, col_t2, col_t3, col_t4, col_t5, col_t6] |
| vals = [False] * 8 |
| if selected_type == "Pflegevertrag" and is_aktiv: |
| vals[0] = True |
| elif selected_type == "Vermittlungsvertrag" and is_aktiv: |
| vals[1] = True |
| elif selected_type == "Tierarzt Dokumente (1)": |
| vals[2] = True |
| elif selected_type == "Tierarzt Dokumente (2)": |
| vals[3] = True |
| elif selected_type == "Tierarzt Dokumente (3)": |
| vals[4] = True |
| elif selected_type == "Tierarzt Dokumente (4)": |
| vals[5] = True |
| elif selected_type == "Tierarzt Dokumente (5)": |
| vals[6] = True |
| elif selected_type == "Tierarzt Dokumente (6)": |
| vals[7] = True |
| elif selected_type == "Alle Dokumente anzeigen": |
| vals = [is_aktiv, is_aktiv, True, True, True, True, True, True] |
| return {col: gr.update(visible=v) for col, v in zip(cols, vals)} |
|
|
| def init_ui(zoo_name): |
| pm = lade_modus(zoo_name) |
| d = update_dropdowns(zoo_name) |
| titel_text = "### 📂 Dokument / Verträge / Tierarztunterlagen" if pm else "### 📂 Tierunterlagen" |
| choices = ["Bitte wählen...", "Pflegevertrag", "Vermittlungsvertrag", "Tierarzt Dokumente (1)", "Tierarzt Dokumente (2)", "Tierarzt Dokumente (3)", "Tierarzt Dokumente (4)", "Tierarzt Dokumente (5)", "Tierarzt Dokumente (6)", "Alle Dokumente anzeigen"] if pm else ["Bitte wählen...", "Tierarzt Dokumente (1)", "Tierarzt Dokumente (2)", "Tierarzt Dokumente (3)", "Tierarzt Dokumente (4)", "Tierarzt Dokumente (5)", "Tierarzt Dokumente (6)", "Alle Dokumente anzeigen"] |
| return [gr.update(visible=False), gr.update(visible=pm), gr.update(visible=pm), gr.update(visible=pm)] + list(d) + [gr.update(value=titel_text), gr.update(choices=choices, value="Bitte wählen..."), d[0], d[3], d[4], gr.update(visible=pm), gr.update(visible=pm), gr.update(visible=pm)] |
|
|
| FUTTER_SLOT_ANZAHL = 8 |
|
|
| APP_VERSION = "7.92.4" |
| |
| |
| |
| |
| |
| |
| _space_id = os.environ.get("SPACE_ID", "Bob15982/Zoo-Zentrale-Julian") |
| BANNER_URL = f"https://huggingface.co/spaces/{_space_id}/resolve/main/banner.jpg" |
| |
| |
| |
| APOTHEKE_BANNER_URL = f"https://huggingface.co/spaces/{_space_id}/resolve/main/apotheke_banner.jpg" |
|
|
| |
| |
| VERSION_HISTORY = [ |
| ("7.92.4", [ |
| "Aufräumrunde: Filter (⚙️ Filter), die Stammdaten/Fütterung/Gesundheit/Dokumente-Buttonleiste sowie der separate 'Tagebuch / Beobachtungen'-Button in den Stammdaten sind jetzt dauerhaft ausgeblendet - alle drei sind seit der Umstellung auf die Popup-Navigation an der unteren Leiste überflüssig geworden", |
| "Haustierapotheke: Kategorien zeigen jetzt ein zur Tierart passendes Icon (🐶 Hund, 🐱 Katze, 🐴 Pferd, 🐰 Kaninchen, 🦎 Reptil, ...) statt für alle Kategorien dieselbe neutrale Pfote", |
| "Bugfix Profilbild: Profilbild und die 8 Dokumente teilten sich bisher EIN hartes 4-Sekunden-Gesamtzeitlimit beim Laden eines Tiers - bei mehreren gleichzeitig langsamen Dokumenten konnte dadurch auch das Profilbild grundlos mit abgebrochen werden, obwohl es das wichtigste sichtbare Element ist. Profilbild hat jetzt sein eigenes, unabhängiges Zeitbudget", |
| ]), |
| ("7.92.3", [ |
| "Die alte Bereich-Auswahlzeile (Tiere/Futter-Zentrale/Archiv/Futter Vorrat/Vertrag nachtragen/Haustierapotheke/PDF-Export/Tagebuch-Übersicht) unter 'Verwaltung' ist jetzt ausgeblendet - komplett überflüssig geworden, seit die Navigation über die Icons unten läuft. Deutlich aufgeräumtere Ansicht", |
| ]), |
| ("7.92.2", [ |
| "Wichtiger Bugfix: JEDE Auswahl in den Popups (Tiere, Futter, Tagebuch, Konto, Sonstiges) landete auf der Startseite statt am gewünschten Ziel. Ursache: Schließen und Springen liefen in zwei getrennten Schritten - das Schließen hat dabei sofort den Button neu gezeichnet, der gerade noch mitten in seiner eigenen Aktion steckte, wodurch der zweite Schritt (der eigentliche Sprung) verloren ging. Jetzt läuft beides in einem einzigen, untrennbaren Schritt", |
| ]), |
| ("7.92.1", [ |
| "Bugfix: 'Start'-Icon reagierte gar nicht und schloss auch kein offenes Popup - jetzt ein echter Sprung, der zusätzlich jedes offene Popup mit schließt", |
| "Vermutlicher Grund gefunden & behoben, warum bei Tieren immer nur Stammdaten erschienen: die Sprung-Funktionen setzten den Unterbereich über einen Umweg zurück, der die eigentlich gewünschte Auswahl (Fütterung/Gesundheit/...) teilweise wieder überschrieben haben könnte - jetzt direkter und einfacher aufgebaut", |
| "Doppelte Tier-Liste entfernt: die eigene, direkt in der Tiere-Seite eingebaute Liste (aus v7.91.7) war seit dem neuen Popup überflüssig geworden und wird nicht mehr angezeigt", |
| "Startseite: die Kachel oben zeigt jetzt 'Haustierapotheke' statt 'Futter Vorrat' (Vorrat ist über das Futter-Popup weiterhin erreichbar) - dafür wurde Haustierapotheke aus dem Sonstiges-Popup entfernt, um die Dopplung zu vermeiden", |
| "'Menü' umbenannt in 'Sonstiges'", |
| "Popup-Fenster passen sich jetzt der Breite ihres Inhalts an, statt immer die volle Bildschirmbreite einzunehmen", |
| ]), |
| ("7.92.0", [ |
| "Schritt 3 des App-Umbaus: die 5 Icons unten (außer Start) öffnen jetzt ein kleines Popup direkt über der Leiste statt sofort auf eine große Zwischenseite zu springen. 'Tiere' zeigt eine Liste zum Auswählen, danach eine zweite kleine Auswahl (Stammdaten/Fütterung/Gesundheit/Dokumente/Tagebuch) für genau dieses Tier. 'Tagebuch' zeigt direkt die Tierliste zur Auswahl. 'Futter' zeigt Scanner/Vorrat zur Auswahl. 'Konto' zeigt Einstellungen/Admin/Abmelden. 'Menü' zeigt die restlichen Bereiche (Archiv/Haustierapotheke/PDF-Export/Vertrag nachtragen). Jede Auswahl schließt das Popup und springt direkt in den gewünschten Unterbereich, nie über eine Zwischenseite", |
| ]), |
| ("7.91.7", [ |
| "Navigation entwirrt: 'Konto', 'Mein Profil' und 'Mehr' führten alle zum selben Ziel (Konto-Einstellungen) - drei verschiedene Beschriftungen für dasselbe Ziel. 'Mein Profil' zeigt jetzt direkt die Haustierapotheke, die Kachel oben (vorher 'Konto') zeigt jetzt direkt Futter Vorrat, 'Verwaltung' ist oben nicht mehr nötig. Untere Leiste: 'Mehr' heißt jetzt 'Konto' (führt weiterhin dorthin), neuer Button 'Menü' zeigt Verwaltung mit allen Bereichen zum freien Auswählen", |
| "Tier-Auswahl per Dropdown ersetzt durch eine echte, antippbare Liste, gruppiert nach 'Eigene Tiere', 'Pflegetiere' und 'Urlaubsgäste' (Pflege-/Gast-Gruppen nur im Erweiterten Modus sichtbar, leere Gruppen werden ausgeblendet) - inkl. eigenem 'Neues Tier anlegen'-Knopf", |
| ]), |
| ("7.91.6", [ |
| "Wichtiger Bugfix: der 'Tagebuch'-Link war kein Timing-Problem, sondern ein echter Wettlauf zwischen zwei unabhängigen Server-Antworten (Verwaltung öffnen + Bereich wechseln), die sich teils gegenseitig überschrieben haben - unabhängig von jeder Wartezeit im Browser. Für 'Tiere'/'Futter'/'Tagebuch' unten läuft jetzt jeweils EIN einziger kombinierter Serveraufruf, kein Wettlauf mehr möglich", |
| "Aufgeräumt: eine alte, nie erreichbare Tagebuch-Übersicht (toter Code, nirgends verlinkt, vermutlich ein früherer nie fertiggestellter Versuch) gefunden und entfernt, um Verwechslungen mit der neuen funktionierenden Version zu vermeiden", |
| "Login-/Registrieren-Bildschirm sowie praktisch alle verbliebenen Buttons app-weit (Speichern/Bearbeiten/Abbrechen, Admin-Bereich, Futter-Vorrat-Zeilen, Tagebuch/Haustierapotheke-Karten, u.v.m.) von Emoji auf Material Symbols umgestellt", |
| ]), |
| ("7.91.5", [ |
| "Absturz behoben: Google antwortete beim Start kurzzeitig mit 503 (überlastet) genau beim ersten Verbindungsaufbau, wodurch die ganze App abstürzte statt einfach kurz zu warten. Alle Sheet-Verbindungen versuchen es jetzt bei 503/500/429 automatisch bis zu 5x mit steigender Wartezeit erneut, bevor sie aufgeben", |
| "Bugfix: der 'Tagebuch'-Sprung in der unteren Navigationsleiste wirkte manchmal wie tot - Ursache war eine zu kurz bemessene feste Wartezeit (150ms) zwischen den zwei nötigen Klicks, wodurch der zweite Klick manchmal zu früh kam und vom noch laufenden ersten Schritt wieder überschrieben wurde. Jetzt wird aktiv geprüft, ob der erste Schritt wirklich fertig ist, bevor der zweite Klick erfolgt - betrifft auch 'Tiere' und 'Futter' in der Leiste", |
| ]), |
| ("7.91.4", [ |
| "Bugfix: die 5 Symbole der unteren Navigationsleiste wurden je nach Bildschirmbreite als 2-2-1 umgebrochen statt in einer Reihe zu bleiben - erzwungen auf eine Zeile", |
| "'Start' unten zeigt jetzt wirklich den bestehenden Start-Tab mit den echten Dashboard-Karten, statt nur hochzuscrollen", |
| "NEU: echte Tagebuch-Übersicht (Verwaltung → Tagebuch-Übersicht, auch über die untere Leiste erreichbar) - zeigt alle Tiere mit Anzahl & Datum ihrer Einträge, antippen springt direkt in dessen Tagebuch-Tab, kein Umweg mehr über die Tier-Auswahl nötig", |
| ]), |
| ("7.91.3", [ |
| "Schritt 2 des App-Umbaus: feste Navigationsleiste unten (Start/Tiere/Futter/Tagebuch/Mehr), immer sichtbar unabhängig vom Scroll-Stand. Zeigt vorerst auf die jeweils nächstliegende bestehende Funktion (z.B. 'Futter' → Futter-Zentrale, 'Tagebuch' → Tiere-Auswahl) - eigene Zielseiten dafür folgen in einem der nächsten Schritte. Technisch über Klick-Simulation auf die bereits bestehenden, bewährten Buttons gelöst statt eigener Verkabelung, um die vorhandene Logik risikoarm wiederzuverwenden", |
| ]), |
| ("7.91.2", [ |
| "Icon-Nachbesserung: 'Vermisst melden'-Symbol war ein unauffälliger Stern ohne Signalwirkung - jetzt ein deutliches, gefülltes Warndreieck. Profil-Symbol in der Status-Zeile ('Eingeloggt als...') war beim letzten Update übersehen worden (andere Komponente als die Navigation) - jetzt ebenfalls auf Material Symbols umgestellt", |
| ]), |
| ("7.91.1", [ |
| "Wichtiger Bugfix: mehrseitige Verträge (z.B. Pflegevertrag mit 4-6 Seiten) verloren beim nächsten Speichern des Tiers (auch bei unrelated Änderungen wie dem neuen Allergien-Feld) alle Seiten bis auf die erste - Ursache war eine fehlende Unterscheidung zwischen 'wirklich neu hochgeladen' und 'nur die zur Vorschau geladene erste Seite eines bestehenden Dokuments'. Jetzt per Hash-Vergleich zuverlässig unterschieden, bestehende mehrseitige Dokumente bleiben vollständig erhalten", |
| "Schritt 1 des App-Umbaus: Hauptnavigation (Verwaltung/Vermisst melden/Konto, Tiere/Futter-Zentrale/Archiv/Futter Vorrat/Vertrag nachtragen/Haustierapotheke/PDF-Export, Stammdaten/Fütterung/Gesundheit/Dokumente/Tagebuch, Einstellungen/Admin) nutzt jetzt Material Symbols Icons statt Emoji - einheitlicher, moderner, sieht auf jedem Handy gleich aus", |
| ]), |
| ("7.91.0", [ |
| "NEU: Feld 'Allergien / Unverträglichkeiten' im Gesundheit-Bereich jedes Tieres - wird gespeichert, beim Laden angezeigt und erscheint zusätzlich auffällig (fett, rot) im PDF-Export der Akte, falls ausgefüllt. Datenstruktur dafür um eine Spalte erweitert (39 auf 40 Spalten)", |
| ]), |
| ("7.90.2", [ |
| "Bugfix: Foto-Upload-Felder (Tagebuch, Profilbild, Futter-Scanner) hatten sich bei zu geringer Zwangshöhe überlappt, da Text ('Bild hier ablegen/oder/Hochladen') UND die drei Auswahl-Icons alle im selben Button-Element sitzen, nicht in getrennten Bereichen. Auf min-height statt fester height umgestellt - schrumpft so weit wie möglich, wächst aber automatisch mit, falls der Inhalt mehr Platz braucht, kein Überlappen mehr", |
| ]), |
| ("7.90.1", [ |
| "Alle übrigen PDF-/Datei-Felder in der App kompakter gemacht (Download Akte, Pflegevertrag/Vermittlungsvertrag/Tierarzt-Dokumente, Nachtrag) - bestehende 'kompakte_datei'-Klasse mit den jetzt bestätigten echten Gradio-Klassen (.wrap.full, .empty.large) verstärkt", |
| "NEU: PDF-Export (Verwaltung → Tiere → PDF-Export) hat jetzt eine Option '📔 Tagebuch (falls vorhanden)' - bindet den kompletten Tagebuch-Verlauf des Tieres inkl. Fotos in die Gesamt-Akte mit ein, z.B. praktisch zum Mitgeben an neue Paten von Pflegetieren statt eines separaten PDFs. Standardmäßig aus (opt-in)", |
| ]), |
| ("7.90.0", [ |
| "Die im Tagebuch gefundene Lösung (button.boundedheight) jetzt auf alle anderen Bild-/Datei-Upload-Felder in der App übertragen: Profilbild (120px), die drei Futter-Zentrale-Scanner-Bilder (150px, bewusst etwas größer für ordentliches Framing beim Fotografieren von Barcode/Etikett) und der Pflegevertrag-Upload (wie Tagebuch-PDF behandelt, da als Datei- statt Bildfeld eine andere interne Struktur hat)", |
| ]), |
| ("7.89.3", [ |
| "Foto-Upload-Feld im Tagebuch endlich mit der wirklich richtigen Klasse getroffen: es ist ein <button class=\"boundedheight\"> innerhalb von .upload-container mit fest height:240px von Gradio - die vorherigen zwei Versuche zielten auf falsche Elemente. Jetzt direkt und gezielt auf 60px reduziert", |
| ]), |
| ("7.89.2", [ |
| "PDF-Ergebnisfeld im Tagebuch jetzt bestätigt kompakt (Danke fürs Testen!). Foto-Upload-Feld nutzt offenbar eine andere interne Struktur als das PDF-Feld, die bisherige CSS griff dort nicht - stattdessen sorgte die erzwungene Mindesthöhe für ein Überlappen von Icons und Text. Zwangshöhe erstmal entfernt, echte Größen-Anpassung folgt nach Live-Inspektion der tatsächlichen Klassen", |
| ]), |
| ("7.89.1", [ |
| "PDF- und Foto-Feld im Tagebuch endlich wirklich kompakt: per Live-Inspektion (danke für die Mühe mit dem DevTools-Screenshot!) die tatsächlichen Gradio-Klassen gefunden (.wrap.full, .empty.large) - alle vorherigen Versuche zielten auf falsche/nicht existierende Klassennamen und blieben wirkungslos", |
| ]), |
| ("7.89.0", [ |
| "Tagebuch-Fotofeld zurück auf Bildvorschau (Vorschau war doch wichtig) - Größe diesmal vorsichtiger reduziert, ohne etwas zu verstecken", |
| "Aufräumrunde durch den ganzen Code: Futter-Zentrale (neues Futter speichern) nutzte noch das unsichere append_row() - jetzt mit demselben race-sicheren Muster wie Tagebuch/Haustierapotheke abgesichert (append_row() + Rücklese-Prüfung). Alle elem_ids app-weit auf Eindeutigkeit geprüft (keine Duplikate mehr), keine verwaisten Verweise auf im Laufe der letzten Updates entfernte Komponenten gefunden, alle kritischen Ausgabe-Listen (login_outs, konto_toggle_outs) nochmal auf exakte Längen-Übereinstimmung gegengeprüft", |
| ]), |
| ("7.88.7", [ |
| "v7.88.6 zurückgenommen (hatte versehentlich die Upload-Icons mit versteckt, ohne den Platz zu sparen). Foto-Feld im Tagebuch jetzt ein einfacher, garantiert kompakter Upload-Knopf statt der großen Bild-Dropzone - kein Rätselraten über Gradios interne Struktur mehr nötig. PDF-Ergebnisfeld vorsichtiger angegangen (nur Mindesthöhe gelockert, nichts versteckt, das könnte sonst wieder Funktionales mit verschlucken)", |
| ]), |
| ("7.88.6", [ |
| "Tagebuch: Foto-Upload-Drop-Zone auf eine schmale Leiste zusammengestaucht (die drei Icons Hochladen/Kamera/Zwischenablage darunter reichen zum Auswählen völlig aus), PDF-Ergebnisfeld auf eine einzelne Zeile reduziert statt der bisherigen großen leeren Vorschaufläche", |
| ]), |
| ("7.88.5", [ |
| "Tagebuch: Foto-Upload-Feld und PDF-Ergebnisfeld deutlich kompakter (vorher nahmen die leeren Platzhalter viel zu viel Platz weg)", |
| ]), |
| ("7.88.4", [ |
| "Absicherung gegen stilles Hängenbleiben beim Start: Space blieb bei 'Restarting' stehen, Container-Log zeigte NICHTS außer 'Application Startup' - kein Fehler, kein Absturz, einfach endloses Warten auf eine Netzwerk-Antwort (vermutlich Google Sheets), noch bevor Python überhaupt eine Log-Zeile ausgeben konnte. Globaler 30-Sekunden-Timeout für alle Netzwerk-Verbindungen ergänzt - verwandelt ein stilles Für-immer-Hängen in einen klaren, sichtbaren Fehler im Log, der sich gezielt beheben lässt", |
| ]), |
| ("7.88.3", [ |
| "Wichtiger Bugfix: Neue Tagebuch-Einträge konnten spurlos verschwinden, wenn zwei Geräte etwa zeitgleich einen neuen Eintrag für dasselbe Tier speicherten (z.B. Android + iOS) - beide berechneten dieselbe 'nächste freie Zeile' und überschrieben sich gegenseitig, ganz ohne Fehlermeldung. Neue Einträge nutzen jetzt append_row() (lässt Google Sheets selbst serverseitig die nächste freie Zeile bestimmen, race-sicher bei gleichzeitigen Zugriffen) plus eine Rücklese-Prüfung mit Warnhinweis, falls doch mal etwas nicht ankommt. Gleiches Muster auch für neue Haustierapotheke-Einträge angewendet, da dort ebenfalls mehrere Admins gleichzeitig aktiv sein könnten", |
| ]), |
| ("7.88.2", [ |
| "Bugfix: Feld-Beschriftungen in den Stammdaten blitzten nach dem Speichern (z.B. im Tagebuch) kurz in Orange auf - Ursache war Gradios Grundthema, das intern Orange als Hauptfarbe für 'gerade aktualisiert'-Hervorhebungen nutzt. Bisher wurde das nur an einzelnen Stellen gezielt auf Braun umgestellt. Jetzt global über CSS-Variablen behoben, greift automatisch bei allen (auch zukünftigen) Gradio-Zuständen, nicht nur den bekannten", |
| ]), |
| ("7.88.1", [ |
| "Bugfix (Server-Log-Auswertung): 'erstelle_bestand_karte didn't receive enough input values' - die verkettete .then()-Reihe nach dem Login (check_saved_login → erstelle_bestand_karte → erstelle_anstehend_karte) war fehleranfällig, da current_zoo eine BrowserState-Komponente ist. Beide Karten werden jetzt direkt innerhalb von check_saved_login() mit dem bereits vorliegenden Wert berechnet statt in einer separaten, fragilen Verkettung danach", |
| ]), |
| ("7.88.0", [ |
| "Treffergenauigkeit app-weit erhöht (u.a. gegen das iOS-Problem 'Button sieht breit aus, reagiert aber nicht immer'): touch-action:manipulation verhindert Verzögerungen durch Doppeltipp-Erkennung, Mindest-Trefffläche von 44px als Untergrenze für alle Buttons", |
| "Login-Bereich komplett überarbeitet: 'Anmelden'/'Registrieren' sind jetzt große, deutlich sichtbare Buttons (gleiches Muster wie der Rest der App) statt Gradios kleiner eingebauter Tabs. Nach erfolgreicher Registrierung springt die Ansicht automatisch zurück zu 'Anmelden', mit bereits ausgefülltem Zoo-Namen", |
| "Nebenbei: Registrierung nutzt jetzt den sicheren Schreib-Mechanismus statt append_row()", |
| ]), |
| ("7.87.1", [ |
| "Haustierapotheke-Banner jetzt per Checkbox im Admin-Bereich ein-/ausschaltbar (kein Code-Update nötig, z.B. praktisch beim Austauschen des Bildes) - neues Sheet 'App_Einstellungen' für solche globalen Schalter", |
| "Kategorie-Überschriften in der Haustierapotheke noch fetter und etwas größer", |
| "Störende Symbolleiste (Vollbild/Download/Teilen) auf allen Bildern in der App entfernt (Banner, Profilbild, Tagebuch-Fotos, Futter-Scanner) - Bilder lassen sich weiterhin per Klick vergrößern/verkleinern, die Icons waren nur zusätzlicher visueller Ballast", |
| ]), |
| ("7.87.0", [ |
| "Haustierapotheke bekommt ein eigenes Kopf-Banner-Bild (analog zum Haupt-Banner) für mehr Auffälligkeit - kompakt zugeschnitten statt als Hintergrund, damit Text und Kategorien darunter gut lesbar bleiben. Bild muss als 'apotheke_banner.jpg' im Space-Repo liegen (URL wird wie beim Haupt-Banner automatisch über SPACE_ID ermittelt)", |
| ]), |
| ("7.86.3", [ |
| "Bugfix Haustierapotheke: neuer Eintrag landete in einer eingeklappten Kategorie-Karte und wirkte dadurch wie 'nicht gespeichert' - die passende Kategorie öffnet sich jetzt automatisch nach dem Speichern", |
| "Kategorie-Karten in der Haustierapotheke deutlich auffälliger gestaltet (Hintergrund, Rahmen, fetter Text, Hinweis 'antippen zum Öffnen') statt unscheinbarem Fließtext", |
| "Willkommensgruß für neue Nutzer komplett aktualisiert: Tagebuch, Haustierapotheke, PDF-Export, Vertrag nachtragen und Preisvergleichs-Links waren dort nicht erwähnt. Erinnerungs-Kommentar im Code ergänzt, damit das bei künftigen neuen Funktionen nicht wieder vergessen wird", |
| ]), |
| ("7.86.2", [ |
| "KRITISCHER BUGFIX: Die Seite blieb leer bzw. der Browser meldete 'Skript verlangsamt Browser' - Ursache war eine Endlos-Schleife im Tab-Ausrichtungs-Skript aus v7.86.0/7.86.1. Der MutationObserver beobachtete Klassen-Änderungen, um Ein-/Ausblenden von Tabs zu erkennen, aber derselbe Code ändert selbst Klassen (petbase_tab_stretch) - das hat den Beobachter bei jeder eigenen Änderung erneut ausgelöst und den Browser komplett blockiert. Beobachtet jetzt nur noch 'style'-Änderungen (wie Gradio Elemente tatsächlich ein-/ausblendet), nicht mehr 'class'", |
| ]), |
| ("7.86.1", [ |
| "Tab-Leisten-Ansatz aus v7.86.0 korrigiert: kein Wischen/Scrollen mehr, stattdessen das gewünschte 2-Spalten-Raster (nebeneinander, letztes Element bei ungerader Anzahl über volle Breite gestreckt), plattformübergreifend einheitlich per JS berechnet", |
| ]), |
| ("7.86.0", [ |
| "Bugfix Haustierapotheke: Klick auf 'Bearbeiten' hat das Accordion der Kategorie geschlossen, weil @gr.render beim Neuzeichnen alle Accordions zurücksetzt - jetzt wird gemerkt, welche Kategorie offen war, und beim Bearbeiten/Speichern/Abbrechen/Weitere-laden bleibt sie offen", |
| "'Neuen Eintrag hinzufügen'-Formular blendet sich jetzt aus, solange eine bestehende Karte inline bearbeitet wird - bessere Übersichtlichkeit für Admins, gleiches Prinzip wie beim Tagebuch", |
| ]), |
| ("7.85.2", [ |
| "Reihenfolge der Subtabs unter Verwaltung → Tiere korrigiert: Vertrag nachtragen steht jetzt links von Haustierapotheke (war vertauscht)", |
| "Alle Tab-Leisten (Haupt-Bereiche wie Subtabs) nutzen jetzt ein 2-Spalten-Raster (nebeneinander, ohne Wischen/Scrollen), das letzte Element wird bei ungerader Anzahl sichtbarer Tabs automatisch über die volle Breite gestreckt. Sichtbarkeit wird dabei per JavaScript berücksichtigt (nicht per reiner CSS-Zählung), da einzelne Tabs je nach Admin-Status/Modus dynamisch ein-/ausgeblendet werden - plattformübergreifend einheitlich", |
| ]), |
| ("7.85.1", [ |
| "Haustierapotheke: neue Auswahl '🐾 Tierart eingrenzen' direkt bei der Suche - schränkt sowohl die Suche als auch die Kategorien-Ansicht auf eine bestimmte Tierart ein, damit z.B. keine Katzen-Empfehlung auftaucht, wenn gezielt nach einer Schlangen-Empfehlung gesucht wird. Dropdown aktualisiert sich automatisch beim Öffnen des Tabs und nach jedem neuen Eintrag", |
| ]), |
| ("7.85.0", [ |
| "Haustierapotheke komplett umgebaut: Kategorien (z.B. Hund, Katze, Maus) erscheinen jetzt als eigene aufklappbare Bereiche (Accordions) statt eines Dropdown-Filters, Karten innerhalb jeder Kategorie alphabetisch nach Titel sortiert, mit '⬇️ Weitere Einträge laden'-Button pro Kategorie (wie bei der Änderungshistorie). Jede Karte hat jetzt einen echten '✏️ Bearbeiten'-Button (nur für Admins sichtbar, wie beim Tagebuch per @gr.render umgesetzt - direkt in der Karte editierbar, kein Formular zum Scrollen). Die Suche ignoriert bei Texteingabe bewusst die Kategorie-Gliederung und zeigt alle Treffer alphabetisch sortiert über alle Kategorien hinweg", |
| ]), |
| ("7.84.0", [ |
| "Tagebuch: 'Neuer Eintrag'-Formular (Titel/Text/Foto) blendet sich jetzt aus, solange gerade ein bestehender Eintrag inline in seiner Karte bearbeitet wird - vermeidet Verwirrung zwischen den beiden Modi", |
| "NEU: Haustierapotheke - kontenübergreifende, von Admins kuratierte Sammlung tiermedizinischer Hausmittel-Empfehlungen. Neuer Subtab 'Haustierapotheke' unter Verwaltung → Tiere (zwischen Futter Vorrat und Vertrag nachtragen, PDF-Export steht jetzt als letztes). Nutzer können nach Kategorie blättern oder per Volltextsuche suchen (durchsucht Titel UND Inhalt, nicht nur den Eintragsnamen). Admins sehen zusätzlich ein Formular zum Anlegen neuer Einträge (Kategorie, Titel, Inhalt). Neues Sheet 'Haustierapotheke', sicherer Schreib-Mechanismus wie überall sonst (explizite Zielzeile statt append_row())", |
| ]), |
| ("7.83.1", [ |
| "Tagebuch-Bearbeiten jetzt komplett INLINE direkt in der jeweiligen Karte statt in einem Formular oben (kein Hochscrollen mehr nötig): Klick auf 'Bearbeiten' tauscht die Karte gegen echte Titel-/Text-/Foto-Eingabefelder samt eigenem Speichern/Abbrechen-Button, alle anderen Karten bleiben unverändert. Das Formular oben ist jetzt nur noch für neue Einträge zuständig", |
| ]), |
| ("7.83.0", [ |
| "Tagebuch-Bearbeiten komplett umgebaut: statt HTML+JavaScript-Brücke (die zuverlässig von Gradios HTML-Bereinigung blockiert wurde) jetzt echte, per @gr.render dynamisch erzeugte Gradio-Buttons - ein garantiert funktionierender 'Bearbeiten'-Button direkt in jeder Karte, ganz ohne JS-Tricks. Dabei auch verwaiste Verweise auf zuvor entfernte Komponenten bereinigt, die zur Laufzeit einen Fehler ausgelöst hätten", |
| ]), |
| ("7.82.2", [ |
| "Vermutliche eigentliche Ursache für den nicht reagierenden Tagebuch-'Bearbeiten'-Button: Gradios gr.HTML() bereinigt (sanitized) dynamisch gesetzten Inhalt und entfernt dabei wahrscheinlich Inline-onclick-Attribute (Sicherheitsmaßnahme gegen XSS) - das onclick im Button-HTML wurde also nie ausgeführt. Umgestellt auf Event Delegation: kein onclick mehr im HTML, stattdessen ein globaler Klick-Beobachter im statischen Seiten-Skript (unberührt von der HTML-Bereinigung), der Klicks auf die Bearbeiten-Buttons per CSS-Klasse abfängt", |
| ]), |
| ("7.82.1", [ |
| "Bugfix: Tagebuch-'Bearbeiten'-Button reagierte nicht - JS-Elementsuche war zu strikt für die tatsächliche Gradio-DOM-Struktur, jetzt robuster mit mehreren Fallback-Selektoren (deckt ab, ob elem_id auf dem Wrapper oder direkt auf Feld/Button liegt)", |
| "Änderungshistorie-Popup ebenfalls mit 'Weitere laden'-Prinzip: zeigt jetzt nur noch die letzten 15 Versionen statt aller 130+, mit '⬇️ Weitere Versionen laden'-Button davor dem Schließen-Button - vorher musste man bei jedem Öffnen durch die komplette Liste scrollen, um zum Schließen-Button zu kommen", |
| ]), |
| ("7.82.0", [ |
| "Tagebuch-Layout überarbeitet gegen 'endlos lange Seite' bei vielen Einträgen über Wochen/Monate: Eingabe-Formular steht jetzt ÜBER der Liste (kein Scrollen mehr nötig, um einen neuen Eintrag zu machen), und die Liste zeigt anfangs nur die letzten 8 Einträge mit einem '⬇️ Weitere Einträge laden'-Button für ältere Einträge statt immer alle auf einmal zu stapeln", |
| ]), |
| ("7.81.1", [ |
| "Tagebuch-Bearbeiten umgebaut auf Wunsch: statt einem separaten Dropdown jetzt ein '✏️ Bearbeiten'-Button direkt unten rechts in jeder Eintrags-Karte. Klick füllt Titel/Text zum Korrigieren vor und zeigt einen Hinweis 'Du bearbeitest gerade...' inkl. Abbrechen-Button. Technisch über eine versteckte Brücke gelöst (dasselbe bewährte Muster wie beim schwebenden Zurück-Button), da die Liste als HTML gerendert wird und Buttons darin keinen Gradio-Callback direkt auslösen können", |
| ]), |
| ("7.81.0", [ |
| "NEU: Tagebuch-Einträge nachträglich bearbeiten - Dropdown 'Bestehenden Eintrag bearbeiten' füllt Titel und Text zum Korrigieren/Ergänzen vor, Speichern aktualisiert die vorhandene Zeile statt eine neue anzulegen. Bestehendes Foto bleibt erhalten, falls beim Bearbeiten kein neues hochgeladen wird", |
| "NEU: Impressum-Platzhalter auf dem Login-Bildschirm (öffentlich ohne Login erreichbar, wie rechtlich für ein Impressum nötig) - Struktur für §5-TMG-Angaben vorbereitet, Inhalte sind noch Platzhalter und müssen vor produktivem Einsatz ausgefüllt werden", |
| ]), |
| ("7.80.0", [ |
| "Performance-Fix für langsames Speichern: zwei Ursachen gefunden und behoben. Erstens: die Kontakttelefon-Synchronisierung machte bisher zwei einzelne Netzwerk-Anfragen PRO anderem Tier im Konto (bei vielen Tieren summierte sich das spürbar) - jetzt ein einziger batch_update() für alle betroffenen Zellen zusammen. Zweitens: die Diagnose-Rücklese-Prüfung aus der append_row()-Fehlersuche (v7.73.13-16) las bei jedem Speichern zusätzlich das komplette, kontenübergreifende Sheet neu ein, nur um sich selbst zu bestätigen - jetzt entfernt, da das eigentliche Problem (unzuverlässiges append_row()) längst behoben und die neue Methode nachweislich zuverlässig ist", |
| ]), |
| ("7.79.3", [ |
| "Zwei getrennte Preisvergleichs-Links statt einem: '🛒 Idealo' (nur Hersteller+Sorte, kein Barcode mehr - Idealo hatte damit bei Tierfutter keine Treffer) und '🔍 Google' (Hersteller+Sorte+Barcode, Google indexiert Barcodes deutlich besser). Idealo steht zuerst, Google danach", |
| ]), |
| ("7.79.2", [ |
| "Zurück auf Idealo (statt Google Shopping) auf Nutzerwunsch, mit der Verbesserung aus v7.79.1 beibehalten: Hersteller+Sorte als primäre Textsuche statt reiner Barcode-Suche, Barcode nur noch ergänzend dazu", |
| ]), |
| ("7.79.1", [ |
| "Bugfix: Idealo hatte bei Tierfutter-Barcodes häufig keine Treffer (Barcodes dort lückenhaft gepflegt). Preisvergleichs-Link auf Google Shopping umgestellt - Hersteller+Sorte als primäre Textsuche (bei Tierfutter zuverlässiger als reine Barcode-Suche), Barcode wird ergänzend mitgegeben statt allein zu stehen", |
| ]), |
| ("7.79.0", [ |
| "NEU: 'Futter wird knapp' auf der Startseite hat jetzt pro Sorte einen '🛒 Preise vergleichen'-Link zu Idealo - sucht per Barcode (präziseste Produktidentifikation, sofern hinterlegt) oder als Fallback per Hersteller+Sorte. Kein eigenes Preis-Scraping (unzuverlässig, rechtlich unsauber, würde bei Seiten-Änderungen brechen) - Idealo vergleicht selbst live über viele Händler inkl. Versandkosten", |
| ]), |
| ("7.78.0", [ |
| "NEU: Feedback als erledigt markieren - in der Feedback-Übersicht ein Dropdown ergänzt (Auswahl des Eintrags per Datum/Absender/Thema/Kurztext) plus Button 'Als erledigt markieren & löschen'. Entfernt den Eintrag dauerhaft aus der Liste UND aus dem Google Sheet (kein Papierkorb). Auswahl bleibt auch bei inhaltlich identischen Einträgen eindeutig, da die tatsächliche Sheet-Zeilennummer intern mitgeführt wird", |
| ]), |
| ("7.77.3", [ |
| "Restliche Konto-Unterpunkte (Feedback senden, Erinnerungen, Passwort, Konto löschen, News veröffentlichen, Nutzerverwaltung) bekommen jetzt ebenfalls die passende elem_id, damit der schwebende Zurück-Button auch bei diesen Navigation korrekt im Verlauf vermerkt - durchgezogener Fix aus v7.76.2/v7.77.2", |
| ]), |
| ("7.77.2", [ |
| "Feedback-Übersicht-Button bekommt elem_id, damit der schwebende Zurück-Button diese Navigation korrekt im Verlauf vermerkt (gleicher Fix wie beim Tagebuch-Button in v7.76.2)", |
| ]), |
| ("7.77.1", [ |
| "NEU: Feedback-Übersicht im Admin-Bereich (Konto → Admin) - Liste aller eingegangenen Feedback-Einträge, neueste zuerst, mit denselben Angaben wie in der Benachrichtigungs-E-Mail (Von, Thema, Nachricht, Datum). Alle Sichtbarkeits-Ausgabelisten automatisiert auf exakte Längen-Übereinstimmung geprüft", |
| ]), |
| ("7.77.0", [ |
| "NEU: Feedback-Benachrichtigung für Admin. Wenn ein Nutzer Feedback (Fehler/Idee/Frage) einreicht, bekommt PetBase automatisch eine E-Mail mit Absender-Konto, Thema und Inhalt. Zusätzlich ein Live-Popup, solange PetBase selbst gerade in der App eingeloggt ist (fragt alle 30 Sekunden nach neuem Feedback). Nebenbei: Feedback-Speicherung nutzt jetzt den sicheren Schreib-Mechanismus statt append_row()", |
| ]), |
| ("7.76.4", [ |
| "Vorbereitung für Umzug auf eine zweite Space: Google-Zugangsdaten werden jetzt bevorzugt aus einem Secret (GOOGLE_CREDENTIALS) geladen statt aus der lokal hochgeladenen JSON-Datei - sicherer, da Secrets nie im Repo landen. Fällt auf die alte Datei zurück, falls kein Secret gesetzt ist, damit derselbe Code auf alter UND neuer Space funktioniert. BANNER_URL ermittelt sich jetzt automatisch über Hugging Faces SPACE_ID-Variable statt fest einprogrammiert zu sein - passt sich also automatisch an, egal welche Space läuft. Der Spreadsheet-Key (Verbindung zum bestehenden Sheet) bleibt unverändert, damit keine Daten verloren gehen", |
| ]), |
| ("7.76.3", [ |
| "Echten Logikfehler bei 'Anstehend' auf der Startseite gefunden: Die Felder 'Letzte Impfung/Wurmkur/Parasitenschutz' speichern das Datum des LETZTEN Termins, die Berechnung hat das aber direkt mit heute verglichen statt das nächste Fälligkeitsdatum zu berechnen - dadurch tauchte JEDE Impfung der letzten 10 Jahre als 'anstehend' bzw. 'überfällig' auf, sobald sie in der Vergangenheit lag (auch bei jährlicher Impfung nach nur 35 Tagen). Jetzt wird wie bei den ICS-Erinnerungen das nächste Fälligkeitsdatum aus letztem Termin + Intervall berechnet (Impfung 365 Tage, Wurmkur 90 Tage, Parasitenschutz je nach eingestelltem Intervall 90 oder 365 Tage). ACHTUNG: dadurch verschwinden vermutlich einige bisher angezeigte 'überfällige' Termine aus der Liste, da sie tatsächlich noch nicht fällig sind", |
| ]), |
| ("7.76.2", [ |
| "Schwebender Zurück-Button berücksichtigte den neuen Tagebuch-Button nicht, da dieser keine passende elem_id hatte (der Zurück-Mechanismus erkennt Navigation nur an IDs mit den Präfixen b_show_/b_haupt_/b_bereich_/b_konto_tab_). elem_id ergänzt - Klick auf 'Tagebuch / Beobachtungen' wird jetzt korrekt im Navigations-Verlauf vermerkt, Zurück-Button springt danach wieder sauber zum vorherigen Tab", |
| ]), |
| ("7.76.1", [ |
| "Tagebuch-Tab in der Tab-Leiste (Stammdaten/Fütterung/Gesundheit/Dokumente/Tagebuch) dauerhaft unsichtbar gemacht - stattdessen wieder ein eigener Button 'Tagebuch / Beobachtungen' direkt unter Name/Tierauswahl (an Erweiterten Modus gekoppelt wie zuvor). Klick auf diesen Button landet auf demselben Tagebuch-Inhalt, nur ohne dass der Tab selbst in der Leiste sichtbar ist", |
| ]), |
| ("7.76.0", [ |
| "Tagebuch komplett umgebaut: eigener Tab '📔 Tagebuch' neben Stammdaten/Fütterung/Gesundheit/Dokumente statt aufklappbarem Panel - Button drücken = eigene Seite, losgelöst von den Stammdaten. Neues Feld 'Titel / Überschrift' pro Eintrag, erscheint fett über dem Beobachtungstext in Liste und PDF-Export. Neue Spalte 'Titel' im Tagebuch-Sheet", |
| ]), |
| ("7.75.2", [ |
| "Tagebuch jetzt an Erweiterten Modus gekoppelt: Button nur sichtbar, wenn Erweiterter Modus aktiv ist (gleiche Logik wie beim Pflegevertrag-Upload). Beim Ausschalten des Modus schließt sich ein offenes Tagebuch-Panel automatisch mit", |
| ]), |
| ("7.75.1", [ |
| "Tagebuch-Einträge können jetzt optional mit einem Foto belegt werden - neues Feld '📷 Foto zum Eintrag' beim Speichern, Foto erscheint als Miniaturbild in der chronologischen Liste UND im PDF-Export (Bild wird automatisch mit heruntergeladen und eingebettet, Seitenumbruch berücksichtigt genug Platz dafür). Neue Spalte 'Foto_URL' im Tagebuch-Sheet", |
| ]), |
| ("7.75.0", [ |
| "NEU: Tagebuch/Beobachtungen pro Tier. Button '📔 Tagebuch / Beobachtungen' direkt unter Name+Tierauswahl (vor Pflegevertrag-Upload bzw. Tierarzt-Bereich) öffnet ein Panel mit chronologischer Liste bisheriger Einträge (neueste zuerst), einem Textfeld für neue Beobachtungen (z.B. Ankunft, Baden, Scheren, körperliche/psychische Auffälligkeiten) und einem PDF-Export des kompletten Verlaufs - z.B. zum Mitgeben an neue Paten von Pflegetieren. Neues Sheet 'Tagebuch' (Zoo_Name, Tier_Name, Datum, Eintrag), Schreibvorgänge nutzen den bewährten sicheren Mechanismus (explizite Zielzeile statt append_row())", |
| ]), |
| ("7.74.6", [ |
| "Alle automatischen E-Mails (Test-Mail, Impfung/Wurmkur/Parasiten-Erinnerungen, Futter-Erinnerungen, News-Updates) sprechen jetzt mit dem hinterlegten Account-Namen an ('Hallo PetBase,' statt 'Hallo,')", |
| ]), |
| ("7.74.5", [ |
| "Futter-Erinnerungs-E-Mails nennen jetzt Hersteller und Sackgröße mit, nicht nur die Sorte - z.B. 'Wild Plain – Huhn (2.5 kg) reicht nur noch ca. 13 Tage'. berechne_futter_warnungen() liefert dafür zusätzlich Hersteller und Sackgröße aus dem Futter-Inventar mit", |
| ]), |
| ("7.74.4", [ |
| "Alle automatischen E-Mails (Erinnerungen, Futter-Warnungen, Test-Mail) haben jetzt zusätzlich zur Textversion eine HTML-Version mit dem App-Banner als Footer-Bild, geladen von der öffentlichen Hugging-Face-Dateiablage. Nebenbei: E-Mail-Log-Eintrag nutzt jetzt denselben sicheren Schreib-Mechanismus wie save_data() (explizite Zielzeile statt append_row())", |
| ]), |
| ("7.74.3", [ |
| "Bugfix Futter-Erinnerungen: 0 E-Mails versendet trotz 'Wild Plain' bei 13 Tagen Restbestand - Ursache war eine Prüfung auf EXAKT 14/5/0 Tage, die den Wert nie traf, wenn er z.B. direkt von 15 auf 13 sprang. Jetzt wird 'Schwelle erreicht oder unterschritten' geprüft (<=14, <=5, <=0), weiterhin mit Schutz vor Doppel-Versand pro Zyklus. Die E-Mail nennt weiterhin den echten Restbestand (z.B. 13 Tage), nur die interne Nachverfolgung nutzt die Schwelle (14)", |
| ]), |
| ("7.74.2", [ |
| "Externer Cron-Aufruf für die Futter-Erinnerungen lief mit 500 Internal Server Error - Gradios eingebauter API-Mechanismus (/gradio_api/call/...) erwartet eine mehrstufige Anfrage-Choreografie, die ein simpler externer Cron-Dienst wie cron-job.org nicht mitmacht. Ersetzt durch eine eigene, simple HTTP-Route (/cron/futter-check) direkt neben der Gradio-App - normaler GET/POST-Aufruf ohne JSON-Body oder spezielle Header nötig. demo.launch() selbst bewusst unverändert gelassen (nur prevent_thread_lock ergänzt), um das bestehende PWA/Favicon-Verhalten nicht zu riskieren", |
| ]), |
| ("7.74.1", [ |
| "Neuer Button im Admin-Bereich (nur für PetBase sichtbar, wie beim Admin-Ernennen): '🥫 Futter-Check jetzt manuell auslösen' - führt dieselbe Prüfung wie der geplante tägliche Cron-Aufruf sofort aus, zum Testen vor der Cron-Einrichtung. Alle Sichtbarkeits-Ausgabelisten (Erfolgs-/Fehler-/Nicht-eingeloggt-Pfad von check_saved_login) automatisiert auf exakte Längen-Übereinstimmung geprüft, um die Art von Output-Mismatch-Bug zu vermeiden, die dieses Projekt schon öfter geplagt hat", |
| ]), |
| ("7.74.0", [ |
| "NEU: Automatische Futter-Nachkauf-Erinnerungen per E-Mail für alle Zoo-Accounts (nicht nur PetBase) - bei 14, 5 und 0 Tagen geschätztem Restbestand pro Futtersorte. Da Hugging Face Spaces keinen eingebauten Scheduler hat, läuft die Prüfung über einen extern aufrufbaren Endpunkt (api_name='futter_cron_check'), gedacht für einen täglichen Aufruf durch einen kostenlosen externen Cron-Dienst (z.B. cron-job.org). Duplikat-Schutz über neues Tracking-Sheet 'Futter_Erinnerungen_Log': pro Zoo/Sorte/Bestands-Update/Schwelle wird nur einmal verschickt. Bestandsberechnung aus dem Dashboard ('Futter wird knapp') in eine gemeinsame Funktion ausgelagert, um Code-Duplikate zu vermeiden", |
| ]), |
| ("7.73.18", [ |
| "Kritischer Bugfix: Profilbilder und der Upload-Button dafür waren komplett unsichtbar (weder gespeicherte Fotos noch die Möglichkeit, neue hochzuladen). Ursache: der height:0 + overflow:hidden-Trick aus v7.73.10 zur Höhen-Angleichung ist kollabiert, sobald die Zeile keine verlässliche Höhe lieferte, und hat dadurch den kompletten Bildbereich ausgeblendet. Höhen-Angleichungs-Versuch zurückgenommen, Profilbild-Bereich läuft wieder im normalen Layout-Fluss - Sichtbarkeit und Funktion gehen vor Kosmetik", |
| ]), |
| ("7.73.17", [ |
| "Vermutlich DER eigentliche Bug hinter 'neues Tier verschwindet' (nach ausführlicher Diagnose über mehrere Versionen bestätigt: 0 von diversen Testversuchen kamen im Sheet an, obwohl append_row() nie einen Fehler warf): ws.append_row() durch ws.update() an einer selbst berechneten Zielzeile ersetzt - dieselbe Methode, die für 'bestehendes Tier bearbeiten' bereits nachweislich zuverlässig funktioniert (bestätigt durch Flockes erfolgreichen Kategorie-Wechsel). append_row()s interne 'finde die nächste leere Zeile'-Logik war offenbar die Fehlerquelle", |
| ]), |
| ("7.73.16", [ |
| "Diagnose war irreführend: die gemeldete Zeilenzahl zählte versehentlich ALLE Zoo-Accounts im gemeinsamen Sheet zusammen (z.B. auch 'Lilalaunebärin'), nicht nur PetBase - die beobachteten Sprünge (+7, +2) waren dadurch nicht unbedingt aussagekräftig. Diagnose zeigt jetzt nur noch die eigene Zeilenzahl des aktuellen Zoo-Accounts plus die letzten 3 tatsächlich vorhandenen Tiernamen, um sichtbar zu machen was wirklich geschrieben wurde statt nur ein gefunden/nicht-gefunden zu melden", |
| ]), |
| ("7.73.15", [ |
| "Der .select()-Fix aus v7.73.14 wirkt: Speichern liefert jetzt zuverlässig eine Meldung statt spurlos zu verschwinden. Die Diagnose zeigte aber 'Zeile fehlt beim Zurücklesen' bei 'Flocke2', obwohl das Sheet bereits 14 statt der erwarteten 7 Zeilen hatte - Verdacht auf Wettlauf zwischen Schreiben und sofortigem Zurücklesen bei Google Sheets. Diagnose-Prüfung wartet jetzt bis zu 2x 1,5 Sekunden und versucht es erneut, bevor sie einen echten Fehler meldet", |
| ]), |
| ("7.73.14", [ |
| "Vermutlich DIE Ursache für spurlos verschwindende neu angelegte Tiere gefunden: tier_dropdown.change() feuerte auch dann, wenn save_data() den Dropdown-Wert nach dem Speichern selbst programmatisch setzte (seit v7.73.12 werden choices+value zusammen gesetzt) - das löste eine zweite, parallele load_tier()-Kette aus, die den noch laufenden Speicher-Vorgang störte, bevor die Bestätigung sichtbar wurde (Video-Analyse zeigte: nach Klick auf 'Profil Speichern' erschien gar keine Meldung mehr, die Seite sprang einfach zurück). Fix: .change() durch .select() ersetzt - feuert nur noch bei echter Nutzer-Auswahl, nicht bei per Code gesetzten Werten", |
| ]), |
| ("7.73.13", [ |
| "Diagnose-Erweiterung: Der Dropdown-Absturz aus v7.73.12 ist behoben, aber 'Flocke2' fehlt weiterhin im Sheet - die eigentliche Ursache liegt also woanders. save_data() liest das Sheet jetzt SOFORT nach dem Schreiben nochmal zurück und prüft, ob die neue Zeile wirklich angekommen ist. Falls nicht, erscheint statt 'Gespeichert!' jetzt eine Diagnose-Meldung mit Zeilenanzahl und Status, um sichtbar zu machen ob append_row() von Google Sheets im Hintergrund verworfen wird", |
| ]), |
| ("7.73.12", [ |
| "Vermutlich DER Bug hinter 'neues Tier verschwindet': update_dropdowns() setzte für das Tier-Auswahl-Dropdown nur choices, aber kein value. Gradio versuchte dann den bisherigen (evtl. veralteten) Client-Wert beizubehalten - passte der nicht zur frischen Liste, brach die komplette Server-Antwort mit 'X is not in the list of choices' ab (reproduziert beim Wechsel Eigenes Tier -> Pflegetier). update_dropdowns() bekommt jetzt einen expliziten select_name-Parameter, save_data() übergibt den gerade gespeicherten Namen direkt mit - choices und value werden immer synchron zusammen gesetzt", |
| ]), |
| ("7.73.11", [ |
| "Diagnose-Hilfe: Fehlermeldung beim Speichern eines Tierprofils (save_data) nicht mehr auf 20 Zeichen abgeschnitten, sondern zeigt jetzt Fehlertyp + vollen Text (bis 120 Zeichen) - Hintergrund: neu angelegtes Tier ('Flocke') taucht trotz 'Gespeichert!'-Meldung nicht im Tier_Profile-Sheet auf, Ursache noch nicht gefunden, diese Änderung soll beim nächsten Versuch mehr Klarheit bringen, falls doch ein bisher verdeckter Fehler auftritt", |
| ]), |
| ("7.73.10", [ |
| "Bugfix zu v7.73.9: bei breiten Fotos (Rosi) funktionierte die Höhenangleichung des Profilbilds an die linke Spalte, bei Hochformat-Fotos (Levi) nicht - das Bild brachte über sein Datei-Seitenverhältnis weiterhin eine eigene, sehr große natürliche Höhe mit, die die komplette Zeile mit aufblähte. Jetzt wird das Bild per height:0 komplett aus der Zeilenhöhen-Berechnung herausgenommen und stattdessen per position:absolute + min-height:100% nachträglich exakt auf die von der linken Spalte vorgegebene Höhe gezogen - funktioniert jetzt unabhängig vom Seitenverhältnis des Fotos", |
| ]), |
| ("7.73.9", [ |
| "Profilbild: Höhe passt sich jetzt unabhängig vom Seitenverhältnis des hochgeladenen Fotos exakt an die linke Spalte (Name+Tierauswahl) an, statt mal zu schmal/gequetscht, mal riesig zu wirken. Umgesetzt über align-items:stretch auf der Row plus flex:1-Kette durch alle Zwischen-Container (bewusst OHNE height:100% auf Zwischenebenen - das hatte bei den Stammdaten-Feldern in v7.73.4-7.73.6 bereits den Stretch-Mechanismus blockiert, derselbe Fehler wurde hier vermieden)", |
| "Graue Hintergründe im Bild-Container und der Upload/Kamera-Werkzeugleiste (Gradios Standard-Grau #F3F4F6) entfernt, damit es zur warmen Creme-Farbpalette der App passt", |
| ]), |
| ("7.73.8", [ |
| "Profilbild: Versuch, die eingebaute Werkzeugleiste (Vollbild/Löschen-Icons) am Überdecken des Bildinhalts (meist der Tierkopf oben im Foto) zu hindern - rein per CSS (Bild bekommt oben 40px 'Sicherheitszone' und wird unten ausgerichtet, Leisten-Hintergrund transparent), diesmal bewusst OHNE JavaScript-Ersatzbuttons, da dieser Ansatz in v7.68-7.72 mehrfach andere Buttons kaputt gemacht hatte. Muss noch am Gerät geprüft werden, da die genaue CSS-Struktur der Werkzeugleiste je nach Gradio-Version variieren kann", |
| ]), |
| ("7.73.7", [ |
| "Bugfix zu v7.73.6: 'background:transparent' auf den Stammdaten-Feld-Blöcken hatte keine weiße Karte freigelegt, sondern die nackte Row-/Seitenfläche dahinter - dadurch wirkte der gesamte Bereich grau/bläulich statt weiß. Jetzt dieselbe Karten-Farbe wie im Rest der App (#FFFCF5, warmes Creme statt reinem Weiß, damit es zum bestehenden Design passt)", |
| ]), |
| ("7.73.6", [ |
| "Bugfix zu v7.73.5: graue Lücke war immer noch da, weil die Höhen-Kette vom Grid bis zur Textarea an einer Zwischenstelle abriss (Label bzw. Gradios innerer 'wrap'-Div um die Textarea hatten kein height:100%, wodurch flex-grow am Textfeld ins Leere lief). Jetzt wird die Höhe durchgängig von Feld-Block über Label/Wrap bis zur Textarea durchgereicht - nur die Textarea selbst bleibt bewusst ohne feste Höhe, um Gradios JS-Autosize für mehrzeiligen Text nicht zu zerstören", |
| ]), |
| ("7.73.5", [ |
| "Bugfix zu v7.73.4: 'height:100% !important' auf den Textfeldern hatte Gradios eigene JavaScript-Autosize-Höhe bei mehrzeiligem Text überschrieben - dadurch wurde Farbe auf eine viel zu kleine Höhe zusammengestaucht (Inhalt nur noch per Scrollen sichtbar) und die graue Lücke sprang zu einer anderen Zeile. Jetzt nur noch flex-grow ohne feste Höhe, dadurch wächst das kürzere Feld mit, ohne das längere zu kappen", |
| ]), |
| ("7.73.4", [ |
| "Stammdaten-Spalten: graue Lücke behoben, die entstand, wenn ein Feld durch längeren Text höher war als sein Nachbar (z.B. Farbe vs. Geburtsdatum) - Felder einer Zeile werden jetzt per align-items:stretch auf gleiche Höhe gezogen, das kürzere wächst automatisch mit", |
| "Dropdown-Text (Tierart, Kastriert etc.) wirkte fetter/anders als der Text in normalen Feldern, da Browser <select>-Elemente mit ihrer eigenen System-Schrift rendern - jetzt font-family/font-weight/font-size erzwungen identisch zu Textfeldern", |
| ]), |
| ("7.73.3", [ |
| "Stammdaten-Felder auf iOS an Android angeglichen: hellgrauer Halo/Rahmen um jedes Feld entfernt (natives Gradio-Karten-Styling, das nur auf iOS Safari durchschien) und die native Höhenabweichung von <select> gegenüber <input> per -webkit-appearance:none behoben. Da appearance:none den nativen Dropdown-Pfeil auf allen Plattformen entfernt, wurde ein eigener SVG-Pfeil ergänzt, damit Android weiterhin einen sichtbaren Pfeil hat", |
| ]), |
| ("7.73.2", [ |
| "Stammdaten-Spalten auf Android behoben: Gradio hatte die beiden Felder pro Zeile heimlich in einen gemeinsamen Wrapper-Div gepackt, wodurch das 50/50-Grid nur ein Kind-Element sah und alles in die linke Hälfte quetschte (rechte Hälfte blieb leer). Wrapper wird jetzt per display:contents fürs Layout transparent gemacht, sodass die Grid-Regel direkt auf die beiden Felder wirkt", |
| ]), |
| ("7.73.1", [ |
| "Stammdaten-Spalten: .zweispaltig-Klasse von Flexbox auf CSS Grid mit minmax(0, 1fr) umgestellt - verhindert, dass die native Mindestbreite von Eingabefeldern (besonders bei iOS Safari) das exakte 50/50-Aufteilen verhindert. Bleibt weiterhin eng auf die vier betroffenen Zeilen begrenzt", |
| ]), |
| ("7.73.0", [ |
| "Stammdaten-Spalten: alter JavaScript-Workaround entfernt, stattdessen gezielte CSS-Klasse ('zweispaltig') nur auf die vier betroffenen Zeilen angewendet - sauberer und enger begrenzt als die bisherigen Versuche, dadurch geringeres Risiko für Nebenwirkungen an anderen Stellen", |
| ]), |
| ("7.72.2", [ |
| "Profilbild-Ersatz-Buttons-Ansatz komplett zurückgenommen - hat vermutlich versehentlich auch Upload-/Kamera-/Zwischenablage-Buttons mit ausgeblendet und die Darstellung verschlechtert statt verbessert. Zurück zum Original-Verhalten von Gradio (Symbole überlappen weiterhin das Label, aber alles andere funktioniert normal)", |
| ]), |
| ("7.72.1", [ |
| "Echten Bug gefunden, warum die Profilbild-Ersatz-Buttons aus v7.72.0 komplett wirkungslos waren: Die Funktion brach vorzeitig ab, wenn sie nicht exakt den Text 'Profilbild' in einem eigenen Element fand (z.B. wegen eines mit eingebetteten Icons) - eine Prüfung, die in dieser Version gar nicht mehr nötig war, aber übersehen wurde. Jetzt entfernt, Funktion sollte jetzt tatsächlich greifen", |
| ]), |
| ("7.72.0", [ |
| "Profilbild-Symbole komplett neu gelöst (Idee vom Nutzer): statt die Original-Symbole zu verschieben, werden sie jetzt unsichtbar (aber weiterhin funktional) gemacht und durch eigene, garantiert sichtbare Ersatz-Buttons unter dem Bild ersetzt, die beim Klick die Original-Funktion auslösen - umgeht das wiederholt gescheiterte Positionierungsproblem", |
| ]), |
| ("7.71.2", [ |
| "Beide JS-Fixes (Stammdaten-Spalten, Profilbild-Icons) robuster gemacht: weniger strenge Bedingungen, textbasierte statt klassenbasierte Suche, MutationObserver für sofortige statt verzögerte Reaktion", |
| ]), |
| ("7.71.1", [ |
| "Vorherige CSS-Grid-Regel für Stammdaten-Felder zurückgenommen, da sie auf BEIDEN Plattformen (iOS und Android) zu einspaltiger Darstellung geführt hat - war ein Fehlversuch", |
| "Stammdaten-Zweispaltigkeit jetzt per JavaScript direkt gesetzt statt per CSS-Regel, da sich iOS Safari und Android Chrome bei Flexbox/Grid-Berechnung unterschiedlich verhalten haben", |
| "Profilbild-Werkzeugleiste: statt einer geschätzten Pixelzahl wird jetzt die tatsächliche Label-Höhe per JavaScript gemessen und die Icons exakt darunter positioniert", |
| ]), |
| ("7.71.0", [ |
| "Stammdaten-Felder auf iOS: von Flexbox auf CSS Grid umgestellt, da Flexbox mit nowrap dort trotzdem manchmal umgebrochen hat - Grid verhält sich browserübergreifend deutlich vorhersehbarer (Name/Profilbild-Zeile bewusst ausgenommen, behält ihr 2:1-Verhältnis)", |
| "Werkzeugleiste (Vollbild/Löschen) bei Bild-Feldern weiter nach unten verschoben, da der vorherige Abstand nicht ausreichte, um das Label vollständig freizugeben", |
| ]), |
| ("7.70.2", [ |
| "Code-Review durchgeführt: echten Bug in change_modus() gefunden und behoben - eine Schutzklausel (nicht eingeloggt) gab noch die veraltete Länge von vor mehreren Erweiterungsrunden zurück (16 statt 21 Werte), was beim Umschalten von Erweitertem Modus ohne aktive Sitzung zu einem Fehler geführt hätte. Alle anderen Kernfunktionen (load_tier, save_data, archive_tier, check_saved_login, Nutzerverwaltung) auf Längen-Konsistenz geprüft - keine weiteren Probleme gefunden", |
| ]), |
| ("7.70.1", [ |
| "PDF-Export: Checkboxen stehen jetzt direkt über 'Download Akte', vierte Checkbox für alle Tierarzt-Dokumente ergänzt (bisher immer fest enthalten, jetzt ebenfalls wählbar)", |
| ]), |
| ("7.70.0", [ |
| "PDF-Export: Auswahl ergänzt, was enthalten sein soll - Infoblatt, Pflegevertrag und Vermittlungsvertrag jetzt einzeln an-/abwählbar (frei kombinierbar), statt immer alles zusammen zu exportieren", |
| ]), |
| ("7.69.0", [ |
| "Banner oben in der App ist jetzt anklickbar und führt direkt zur Startseite - ohne Umweg über den Zurück-Button, setzt dabei auch dessen Verlauf zurück", |
| ]), |
| ("7.68.2", [ |
| "Zurück-Button: Selbstheilungs-Prüfung von 1x/Sekunde auf 4x/Sekunde erhöht, da der Wechsel zwischen Haupttabs sehr viele Elemente gleichzeitig aktualisiert und dadurch länger dauert als bei Sub-Tabs - sollte das Verschwinden speziell bei Haupttab-Wechseln beheben", |
| "Garantierter Endpunkt 'Startseite': Genug Zurück-Klicks führen jetzt immer zur echten Startseite (Verwaltung, kein Tier ausgewählt), unabhängig davon, wo die Navigation begonnen hat", |
| ]), |
| ("7.68.1", [ |
| "Zurück-Button 'selbstheilend' gemacht: Falls er durch eine interne Aktualisierung der Seite zeitweise aus dem DOM entfernt wird, fügt er sich jetzt sofort automatisch wieder ein, statt erst beim nächsten Tab-Klick wieder aufzutauchen - sollte sowohl das gelegentliche Verschwinden als auch die uneinheitliche Funktion beheben", |
| ]), |
| ("7.68.0", [ |
| "Echten Logikfehler beim Zurück-Button gefunden: Er hat versehentlich den gerade aktiven Tab nochmal angeklickt statt zum Tab davor zu springen - ein erneuter Klick auf den bereits aktiven Tab tut aber nichts, daher schien der Button wirkungslos. Jetzt korrekt behoben", |
| "Zurück-Button ist jetzt frei verschiebbar (Ziehen mit Maus/Finger) und merkt sich seine Position dauerhaft (pro Gerät/Browser), bis er erneut verschoben wird", |
| ]), |
| ("7.67.1", [ |
| "Zurück-Button komplett überarbeitet: eigener Navigations-Speicher statt Browser-History-API (war vermutlich die Ursache, warum er keine Aktion ausgelöst hat - unzuverlässig in eingebetteten Kontexten), Position jetzt vertikal mittig statt unten links, damit er immer sichtbar ist ohne scrollen zu müssen", |
| ]), |
| ("7.67.0", [ |
| "Schwebender Zurück-Button ergänzt (unten links, erscheint erst nach dem ersten Tab-Wechsel), da die native Zurück-Geste browserabhängig nicht immer zuverlässig funktioniert - nutzt dieselbe Verlaufs-Logik, springt also genauso zum vorherigen Tab zurück", |
| ]), |
| ("7.66.0", [ |
| "Native Zurück-Geste (iOS-Wischen vom Rand / Android-Zurück-Geste bzw. -Taste) springt jetzt zum vorherigen Tab innerhalb der App zurück, statt die Seite/App zu verlassen - funktioniert für Haupt-, Bereichs-, Kategorie- und Konto-Tabs", |
| ]), |
| ("7.65.0", [ |
| "Bugfix: Navigations-Reset (Verwaltung → anderer Tab → zurück) setzte zwar die Tier-Auswahl im Dropdown zurück, die Formularfelder darunter (Name, Bild etc.) blieben aber fälschlich mit dem zuletzt ausgewählten Tier gefüllt - jetzt wird load_tier korrekt mit aufgerufen, genau wie bei einer echten manuellen Auswahl", |
| "Vollbild-/Löschen-Symbole bei Bildfeldern (z.B. Profilbild) liegen nicht mehr über dem Feld-Label", |
| "Stammdaten-Felder werden jetzt unabhängig von der Plattform (iOS/Android) konsequent zweispaltig dargestellt", |
| ]), |
| ("7.64.1", [ |
| "Bugfix: Bei mehrseitigen PDFs (z.B. 6-seitiger Pflegevertrag) wurden manche Seiten beim Hochladen stillschweigend übersprungen, wenn der kostenlose Bilder-Hosting-Dienst zu viele schnell aufeinanderfolgende Uploads gedrosselt hat. Jetzt mit Wiederholungsversuchen und kurzer Pause zwischen den Seiten deutlich zuverlässiger", |
| ]), |
| ("7.64.0", [ |
| "ECHTE Ursache für den E-Mail-Timeout gefunden (bestätigt u.a. von einem Hugging-Face-Mitarbeiter im offiziellen Forum): Hugging Face Spaces blockiert grundsätzlich alle ausgehenden SMTP-Ports (25/465/587) - das betrifft jeden, der SMTP von einem Space aus nutzt, nicht nur uns", |
| "E-Mail-Versand komplett auf Brevo-HTTP-API umgestellt (Port 443, nicht blockiert) statt klassischem SMTP - braucht jetzt einen Brevo-API-Schlüssel statt SMTP-Zugangsdaten", |
| ]), |
| ("7.63.0", [ |
| "Nutzerverwaltung: Freitextfelder durch vier Dropdowns ersetzt - Aktive Nutzer sperren, Gesperrte Nutzer entsperren, Admin-Status vergeben (nur Nicht-Admins), Admin-Status entziehen (nur aktuelle Admins) - Listen aktualisieren sich automatisch nach jeder Aktion", |
| "Bugfix: 'Vollbild-Bild schließt sich bei erneutem Klick' hat versehentlich das Profilbild statt nur die Vollbildansicht gelöscht, da der falsche Button getroffen wurde - jetzt über Escape-Taste gelöst, sicherer als Button-Suche", |
| "E-Mail-Versand: Secrets werden jetzt defensiv bereinigt (Anführungszeichen/Leerzeichen), Fehlermeldung zeigt jetzt den tatsächlich verwendeten Server-Namen zur besseren Fehlersuche", |
| ]), |
| ("7.62.0", [ |
| "Test-E-Mail-Button im Admin-Bereich - sendet eine Test-Mail an die eigene hinterlegte E-Mail-Adresse, um den Versand zu prüfen", |
| "News können jetzt optional (Checkbox) auch per E-Mail an alle Nutzer mit hinterlegter E-Mail-Adresse verschickt werden", |
| "Neuer Zähler 'Versendete E-Mails gesamt' in der Admin-Übersicht (App-Reichweite)", |
| "Admin-Status kann jetzt auch wieder entzogen werden, nicht nur vergeben (nur durch Hauptadmin)", |
| "'Inventar' umbenannt zu 'Futter Vorrat' in der Navigation und den Übersichtstexten", |
| "Bugfix: Mehrseitige PDFs (z.B. Pflegevertrag) wurden nur mit der ersten Seite gespeichert - jetzt werden alle Seiten konvertiert, hochgeladen und beim PDF-Export vollständig eingebettet", |
| "Bugfix: Telefonnummern mit führender 0 oder + wurden von Google Sheets als Zahl fehlinterpretiert und verloren diese Zeichen - jetzt wird Textformat erzwungen", |
| "Vollbild-Profilbild schließt sich jetzt bei erneutem Antippen automatisch, falls der Schließen-Button außerhalb des Bildschirms liegt", |
| ]), |
| ("7.61.1", [ |
| "Filter (Alle/Eigene Tiere/Pflege & Gäste) hatte weiterhin zu wenig Platz, da er sich eine Zeile mit 'Tier wählen' teilte - steht jetzt in voller Breite darunter statt in einer engen halben Spalte", |
| ]), |
| ("7.61.0", [ |
| "Eigener Admin-Tab unter Konto ('⚙️ Einstellungen' / '👑 Admin') - alle administrativen Werkzeuge (App-Reichweite, News, Nutzerverwaltung) jetzt gebündelt, statt zwischen den normalen Einstellungen zu stehen", |
| "Startseite neu geordnet: 'Futter wird knapp' steht jetzt über 'Anstehende Erinnerungen', beide zusammen über 'Tiere im Bestand' - erscheinen nur noch zentral auf der Startseite, nicht mehr zusätzlich doppelt nach Tier-Auswahl", |
| "Navigations-Reset: Von Verwaltung zu einem anderen Haupt-Tab und zurück landet man jetzt wieder neutral auf der Tiere-Startseite, statt automatisch beim zuletzt ausgewählten Tier", |
| ]), |
| ("7.60.0", [ |
| "E-Mail-Versand hinzugefügt (sicher): Zugangsdaten werden ausschließlich über Hugging-Face-Space-Secrets gelesen, NIE in der Google Sheet gespeichert", |
| "Neue '📧 E-Mail'-Buttons bei Impfung/Wurmkur/Parasitenschutz senden eine Erinnerung an die im Konto hinterlegte E-Mail-Adresse", |
| "Status 'E-Mail-Versand eingerichtet/nicht eingerichtet' erscheint jetzt in der Admin-Übersicht (App-Reichweite)", |
| ]), |
| ("7.59.0", [ |
| "PDF-Export und Vertrag nachtragen sind jetzt eigenständige Bereichs-Tabs (neben Tiere/Futter-Zentrale/Archiv/Inventar), nicht mehr als Unter-Buttons in Archiv versteckt", |
| "Archiv, PDF-Export und Vertrag nachtragen sind jetzt nur noch sichtbar, wenn Erweiterter Modus aktiv ist", |
| "Vertrag nachtragen: neue Auswahl 'Aktueller Bestand' oder 'Archiv' - bei aktuellem Bestand kann jetzt direkt ein Pflege- oder Gasttier aus dem laufenden Bestand ausgewählt werden (vorher nur archivierte Tiere möglich)", |
| ]), |
| ("7.58.0", [ |
| "Bugfix: Namensänderung bei einem Tier legte ein komplett neues Profil an statt das bestehende zu aktualisieren - Ursache war, dass die Suche nach der zu ändernden Zeile über den (evtl. schon geänderten) neuen statt den ursprünglichen Namen lief. Jetzt korrekt über den ursprünglich ausgewählten Namen", |
| "Filter-Auswahl (Alle/Eigene Tiere/Pflege & Gäste) steht jetzt untereinander statt bei Erweitertem Modus zusammengequetscht nebeneinander", |
| "Vollbild-Profilbild auf Bildschirmgröße begrenzt, damit der Schließen-Button immer erreichbar ist, ohne seitlich scrollen zu müssen", |
| ]), |
| ("7.57.0", [ |
| "Neue Nutzerverwaltung im Admin-Bereich (Konto): Liste aller registrierten Nutzer mit E-Mail, Anzahl Tiere, letztem Login und Login-Zähler", |
| "Nutzer können jetzt gesperrt/entsperrt werden (gesperrte Konten können sich nicht mehr einloggen)", |
| "Blacklist-Mechanismus: Nach Löschen eines Kontos (über die App selbst) können derselbe Zoo-Name UND dieselbe E-Mail-Adresse nicht mehr neu registriert werden", |
| "Mehrfach-Admin-Unterstützung: Der Hauptadmin (PetBase) kann weitere Nutzer zu Admins ernennen, die dann ebenfalls die Nutzerverwaltung sehen (Ernennen selbst bleibt dem Hauptadmin vorbehalten)", |
| ]), |
| ("7.56.3", [ |
| "Checkbox-Häkchen (BARF, Zusätzlich Nassfutter) bestätigt funktionsfähig. Radio-Punkt bei Fütterungsart (Trockenfutter/Nassfutter) auf robustere Zeichentechnik umgestellt (box-shadow statt Positionierung), da die vorherige Methode dort nicht griff", |
| ]), |
| ("7.56.2", [ |
| "Fehlender Punkt/Haken: Radio-/Checkbox-Kreise werden jetzt komplett selbst per CSS gezeichnet (Kreis, Punkt bzw. Häkchen), statt uns auf Gradios eigene, teils unterdrückte native Darstellung zu verlassen - reagiert direkt auf die eingebaute Browser-Eigenschaft ':checked'", |
| ]), |
| ("7.56.1", [ |
| "Bugfix (per markiertem Screenshot gefunden): Goldener Hintergrund blieb bei Trockenfutter/Nassfutter & BARF manchmal hängen, obwohl die Option schon abgewählt war - lag an einer zusätzlichen, unzuverlässigen CSS-Regel neben der eigentlich korrekten. Diese entfernt, nur noch die direkt zustandsreaktive Regel aktiv", |
| "Fehlender Punkt/Haken im Auswahlkreis: native Eingabe-Elemente werden jetzt explizit sichtbar und ausreichend groß gehalten, damit Gradio den Haken/Punkt überhaupt zeichnen kann", |
| ]), |
| ("7.56.0", [ |
| "Ursache für 'BARF kaum sichtbar' gefunden: Anders als 'Erweiterter Modus' stand BARF nicht in einer eigenen Gruppe und bekam dadurch keine Karten-Umrandung - jetzt in eigene Gruppe gepackt, gleiche Optik wie Erweiterter Modus", |
| "Aktive Auswahl-Pillen (Trockenfutter/Nassfutter, Parasitenschutz-Intervall, Filter etc.) mit deutlich breiterem Satz an CSS-Regeln abgesichert, da sich Gradios interne Struktur zwischen 5.x und 6.x unterscheiden kann", |
| "'Aktualisieren'-Button in Inventar entfernt, da das Dashboard jetzt automatisch beim Öffnen lädt", |
| ]), |
| ("7.55.1", [ |
| "Das per Screenshot markierte Feld identifiziert: 'Kategorie'-Dropdown (Eigenes Tier/Pflegetier/Urlaubsgast) in Stammdaten heißt jetzt 'Tierauswahl', die umgebende graue Box ist jetzt durchsichtig und verschmilzt mit dem Hintergrund", |
| ]), |
| ("7.55.0", [ |
| "Enter/Tab springt jetzt zum nächsten Feld (mehrzeilige Felder ausgenommen, dort bleibt Enter ein Zeilenumbruch) - Speichern-Button bleibt wie gewünscht bestehen", |
| "Inventar-Dashboard erscheint jetzt sofort beim Öffnen des Tabs, ohne erst 'Aktualisieren' drücken zu müssen", |
| "Schließen-Button (✕) bei Bearbeiten steht jetzt direkt neben '🔧 Mehr' statt am Zeilenanfang", |
| "'Neu laden'-Button im Konto entfernt (durch Gradio-5.50-Downgrade nicht mehr nötig), 'Abmelden' zentriert, Anmelden/Registrieren-Tabs auf dem Login-Bildschirm gleichmäßig verbreitert", |
| "Download-Feld beim Vermisst-Steckbrief deutlich verkleinert", |
| "Aktive Auswahl bei Radio-/Checkbox-Feldern (BARF, Parasitenschutz-Intervall, Filter etc.) jetzt überall deutlich sichtbar hervorgehoben, nicht nur bei Erweiterter Modus", |
| "'Tierarzt Dok 1-6' umbenannt zu 'Tierarzt Dokumente (1-6)' im Auswahlmenü, Feld selbst heißt schlicht 'Tierarzt Dokument'", |
| "Neues Upload-Feld für den Pflegevertrag direkt unter Kategorie bei der Tier-Anlage, erscheint automatisch bei Kategorie='Pflegetier' + aktivem Erweiterten Modus - nutzt denselben Speicherpfad wie das bestehende Feld in Dokumente", |
| ]), |
| ("7.54.0", [ |
| "PWA-Icon auf dem Homescreen gefixt: iOS Safari ignoriert beim 'Zum Home-Bildschirm hinzufügen' meistens das Web-App-Manifest und braucht einen expliziten apple-touch-icon-Tag - den gab es bisher nicht, deshalb erschien statt unserem Icon ein Seiten-Screenshot", |
| "apple-touch-icon zeigt jetzt auf Gradios eigenen /pwa_icon/180-Endpunkt (aus favicon_path generiert), plus Meta-Tags für Vollbild-Start ohne Browserleiste auf iOS", |
| "Android/Chrome nutzte bereits automatisch das manifest.json-Icon (Fix seit Gradio-PR #10341, in unserer 5.50.0 enthalten) - betrifft daher nur iPhones", |
| "Hinweis: Bereits vorhandene Homescreen-Icons werden von iOS/Android beim Hinzufügen gecacht und müssen einmalig gelöscht + neu hinzugefügt werden, damit das neue Icon greift", |
| ]), |
| ("7.53.0", [ |
| "GROSSE ÄNDERUNG: Downgrade von Gradio 6.19.0 auf 5.50.0. Ein bestätigtes, unabhängiges Gradio-Issue (#12511) beschreibt exakt unser 'erster Klick zeigt Sichtbarkeits-Änderung nicht'-Problem als Regression, die genau beim Wechsel von 5.9 auf 6.0.2 eingeführt wurde. Keine der bekannten 5→6 Breaking-Changes (Dataframe, ChatInterface, Chatbot) betrifft unseren Code", |
| "Falls dieser Test nicht hilft: einfach die vorherige Version zurückspielen", |
| ]), |
| ("7.52.1", [ |
| "Per Live-Debugging die eigentliche Ursache für 'Fütterung/Gesundheit öffnet nicht beim ersten Klick' gefunden: Die Tier-Lade-Kette (die selbst am Ende 'Stammdaten' setzt) war im Abbruch-Mechanismus NACH den Kategorie-Buttons registriert - dadurch konnte ein Kategorie-Klick diese ältere, noch laufende Kette nicht abbrechen. Jetzt davor registriert", |
| ]), |
| ("7.52.0", [ |
| "Per Live-Debugging (direkte DOM-Inspektion) bestätigt: Die 'hide'-CSS-Klasse blieb manchmal auf einem Zielbereich hängen, obwohl der Inhalt selbst korrekt aktualisiert wurde - vermutlich ein Svelte-internes Verhalten, das eine Änderung manchmal 'übersieht'", |
| "Experimenteller Fix: Ein sich bei jedem Klick ändernder Dummy-Wert zwingt Svelte, die Änderung zuverlässig zu erkennen - angewendet bei Haupt-, Bereichs- und Kategorie-Tabs", |
| ]), |
| ("7.51.0", [ |
| "Live-Test aufgedeckt: Kategorie-Tabs (Stammdaten/Fütterung/Gesundheit/Dokumente) öffneten konsistent nicht beim ersten Klick - derselbe Überholt-Antworten-Mechanismus wie bei den Haupt-/Bereichs-Tabs, aber der Abbruch-Fix aus v7.50.0 war dort noch nicht angewendet", |
| "Abbruch-Mechanismus jetzt konsequent auf ALLE Umschalt-Button-Gruppen ausgeweitet: Kategorie-Tabs, Tier-Auswahl/Speichern-Kette, Archiv-Umschaltung, Konto-Umschaltung", |
| ]), |
| ("7.50.1", [ |
| "Ursache für 'Bearbeiten im Inventar öffnet nicht beim ersten Klick' gefunden (live getestet + vom Nutzer mit bewusstem Einzelklick bestätigt): Ein (oft unbewusster) Doppelklick traf beim zweiten Mal auf den neu erschienenen Schließen-Button, der zufällig an derselben Bildschirmposition lag wie der ursprüngliche Bearbeiten-Button - dadurch schloss sich das Feld sofort wieder", |
| "Fix: Schließen-Buttons (✕) bei Aufstocken/Bearbeiten/Mehr stehen jetzt am Zeilenanfang statt am Ende, sodass sie nicht mehr mit der Position des auslösenden Buttons überlappen", |
| ]), |
| ("7.50.0", [ |
| "ECHTE Ursache live im Browser gefunden und bestätigt: Bei schnell aufeinanderfolgenden Klicks auf verschiedene Tabs kamen Server-Antworten nicht in der richtigen Reihenfolge zurück - eine ältere, überholte Antwort konnte eine neuere überschreiben. Live nachgestellt: 5 Tabs schnell hintereinander geklickt, am Ende zeigte sich der ERSTE statt der letzte Klick", |
| "Fix: Jeder Haupt- und Bereichs-Tab-Klick bricht jetzt die noch offenen (überholten) Anfragen der anderen Buttons derselben Ebene ab, damit sich Antworten nicht mehr gegenseitig überholen können", |
| ]), |
| ("7.49.0", [ |
| "Vermutlich DER Fund zum monatelangen 'erst beim zweiten Klick'-Problem: Die Eruda-Debug-Konsole, die sich beim Login als 'PetBase' automatisch geladen hat, fing Gradios eigene Netzwerk-Anfragen ab und brach sie dabei offenbar ab - laut Konsolen-Stack-Trace des Nutzers. Komplett entfernt.", |
| "Das würde erklären, warum das Problem plattformübergreifend (PC und Handy) auftrat, praktisch jeden Button betraf und sich nicht zuverlässig reproduzieren ließ - alles typisch für einen selbst verursachten Netzwerk-Abfang-Konflikt, nicht für einen echten App-Fehler", |
| ]), |
| ("7.48.0", [ |
| "Alle Haupt- und Bereichs-Tab-Buttons (Verwaltung/Vermisst/Konto, Tiere/Futter-Zentrale/Archiv/Inventar) von zwei- bzw. vierstufigen .then()-Ketten auf jeweils EINEN einzigen Server-Aufruf zusammengeführt - eliminiert unnötige Mehrfach-Rundreisen pro Klick komplett bei diesen Buttons", |
| "Weiterer gezielter Versuch gegen das durchgängige 'erst beim zweiten Klick'-Problem, diesmal durch Reduzierung der Server-Rundreisen statt durch Warm-up-Versuche", |
| ]), |
| ("7.47.3", [ |
| "Neuer, gezielter Versuch gegen das durchgängige 'erst beim zweiten Klick'-Problem: ein einziger, unsichtbarer Aufruf beim Laden weckt Gradios Warteschlangen-Verbindung sofort auf (Verdacht: der allererste Klick nach dem Laden trifft manchmal auf eine noch nicht ganz aufgebaute Verbindung) - diesmal bewusst nur EIN Aufruf statt der langen Kette von v7.36.3, die frühere Verzögerungen verursacht hatte", |
| ]), |
| ("7.47.2", [ |
| "Experimenteller, aber gut begründeter Versuch gegen das durchgängige 'öffnet erst beim zweiten Tippen'-Problem: touch-action:manipulation ergänzt, um die browsereigene Doppel-Tipp-Erkennungsverzögerung zu verhindern (die seit dem Aktivieren von Pinch-to-Zoom in v7.46.2 vermutlich neu hinzugekommen ist bzw. sich verschlimmert hat) - ohne Zoomen zu deaktivieren", |
| ]), |
| ("7.47.1", [ |
| "Bugfix (iPhone): Tab-Beschriftungen (Stammdaten/Fütterung/Gesundheit/Dokumente etc.) wurden auf schmaleren Bildschirmen abgeschnitten ('tammdate' statt 'Stammdaten') - Ursache war mein eigener Gewicht/Menge-Fix, der versehentlich auch die Tab-Reihen erfasst hatte. Tab-Reihen jetzt gezielt ausgenommen, dürfen wieder normal umbrechen", |
| ]), |
| ("7.47.0", [ |
| "Übersicht in Tiere umstrukturiert: 'Tiere im Bestand' verschwindet jetzt, sobald ein Tier ausgewählt ist; Stammdaten stehen direkt unter der Tier-Auswahl; 'Anstehende Termine' rutschen unter die Stammdaten des ausgewählten Tieres", |
| ]), |
| ("7.46.2", [ |
| "Zoomen (Pinch-to-Zoom) jetzt auf beiden Plattformen ausdrücklich erlaubt, für bessere Lesbarkeit bei Bedarf", |
| ]), |
| ("7.46.1", [ |
| "Bugfix (iOS): Beschriftungen wie 'Gewicht 1'/'Menge 1' konnten auf schmaleren/anders skalierten Bildschirmen (z.B. iPhone) ineinander überlappen - dürfen jetzt bei Bedarf auf eine zweite Zeile umbrechen statt zu überlappen, zusätzlich kompaktere Schrift auf sehr schmalen Bildschirmen", |
| ]), |
| ("7.46.0", [ |
| "App-Titel auf 'PetBase' gesetzt und PWA-Icon aktiviert: 'Zum Startbildschirm hinzufügen' aus dem Browser zeigt jetzt das eigene Icon und den Namen 'PetBase' statt einer generischen Browser-Kachel", |
| ]), |
| ("7.45.5", [ |
| "Bugfix: Gewicht/Menge im '🔧 Mehr'-Formular waren wieder untereinander statt nebeneinander - lag an der zusätzlichen Akkordeon-Verschachtelung dort, jetzt allgemeiner gelöst (betrifft auch andere verschachtelte Stellen)", |
| "Weiterer Versuch, das alte Akkordeon-Pfeilsymbol zuverlässig auszublenden (zeigte vorher beide Symbole gleichzeitig)", |
| ]), |
| ("7.45.4", [ |
| "Bugfix: Akkordeon-Symbol zeigte kaputte Zeichen ('ƐƐ') statt drei Punkte - lag an einer fehlerhaften Zeichen-Kodierung, jetzt mit echtem Unicode-Zeichen behoben", |
| "Zusätzlich die vermutlich eigentliche Pfeil-Quelle (native HTML-Markierung) gezielter ausgeblendet, da das alte Pfeilsymbol vorher weiterhin sichtbar war", |
| ]), |
| ("7.45.3", [ |
| "Bugfix: Gewicht/Menge-Felder (und andere Zwei-Spalten-Zeilen) waren durch die verschachtelten Karten untereinander statt nebeneinander gerutscht - jetzt per CSS erzwungen nebeneinander", |
| "Akkordeon-Symbol in allen Akkordeons von Pfeil/Dreieck auf klassische drei Punkte (⋮) umgestellt", |
| ]), |
| ("7.45.2", [ |
| "Bugfix: 'Archivieren' war rot statt braun - korrigiert (Vermisst-Steckbrief bleibt bewusst rot)", |
| "Verbleibende orange Beschriftungen (z.B. Kategorie, Fütterungsart) über zusätzliche Gradio-Variablen auf Braun umgestellt", |
| "Ausgewählte Radio/Checkbox-Option (z.B. Trockenfutter/Nassfutter) heller gemacht - vorher war die dunkle Schrift auf dunkelbraunem Grund kaum lesbar", |
| ]), |
| ("7.45.1", [ |
| "Bugfix: 'Speichern' beim Futter-Scan und 'Konto erstellen' waren fälschlich rot (Löschen-Farbe) statt braun (normale Aktion)", |
| "Native Gradio-Elemente (Radio-Buttons wie Trockenfutter/Nassfutter, Checkboxen, Slider) nutzen jetzt ebenfalls Braun statt des alten Orange - vorher hatten diese noch die alte Akzentfarbe", |
| ]), |
| ("7.45.0", [ |
| "Farbpalette an den PetBase-Banner angepasst: warmes Creme/Sand statt kühlem Google-Grau, warmes Braun statt grellem Orange als Akzentfarbe - das Banner fügt sich jetzt nahtlos in die Seite ein statt in einer grauen Box zu sitzen", |
| ]), |
| ("7.44.0", [ |
| "Großer Kurswechsel: Dark Mode komplett entfernt, App läuft jetzt in einem hellen, freundlichen Material-Design-Look (stark an Stock-Android angelehnt) - weißer Hintergrund, dezente graue Flächen, klare dunkle Schrift", |
| "Alle Dashboard-/Übersicht-Karten (Konto, Inventar, Tiere-Übersicht) auf helle Farben umgestellt", |
| "Das ist Schritt 1 des angeforderten Kurswechsels weg vom '2010-Gefühl' hin zu einem modernen, simplen Erscheinungsbild", |
| ]), |
| ("7.43.0", [ |
| "Design-System 2.0: durchgängige Card-Optik für alle Formulare/Gruppen (dezenter Schatten, weicher Rand), verfeinerte Farbpalette, sanfte Verläufe bei Haupt-Buttons, klarere Überschriften-Hierarchie", |
| "Neue Übersicht beim Öffnen von Tiere (solange kein Tier ausgewählt ist): Anzahl Tiere, anstehende Impfungen/Wurmkuren/Parasitenschutz der nächsten 30 Tage, Futter das bald ausgeht", |
| "Das ist Schritt 1+2 der Modernisierung (visuelles Fundament + Startübersicht). Schritt 3 (Formulare entschlacken) folgt als nächstes", |
| ]), |
| ("7.42.0", [ |
| "Das '🔧 Mehr'-Detail-Formular (Hersteller, Fütterungsart, Gewichts-Brackets) erscheint jetzt ebenfalls direkt inline unter der jeweiligen Sorte, statt an einer gemeinsamen Stelle - konsistent mit Aufstocken und Bearbeiten", |
| "Das frühere gemeinsame Formular für alle Sorten wurde komplett entfernt", |
| ]), |
| ("7.41.0", [ |
| "Aufstocken und Bearbeiten erscheinen jetzt direkt inline unter der jeweiligen Futtersorte (Futter 1 -> Formular Futter 1 -> Futter 2 -> ...), statt an einer gemeinsamen Stelle", |
| "Inline-Bearbeiten zeigt nur noch Bestand + Sackgröße sowie einen 🗑️ Löschen-Button - für seltene Detail-Änderungen (Hersteller, Fütterungsart, Gewichts-Brackets) gibt es einen '🔧 Mehr'-Link ins gemeinsame Detail-Formular", |
| "Neuer Löschen-Button direkt im Inline-Formular, um eine Futtersorte komplett aus dem Bestand zu entfernen", |
| "Maximale Anzahl gleichzeitig angezeigter Sorten von 10 auf 8 reduziert (mehr Felder pro Sorte als zuvor)", |
| ]), |
| ("7.40.0", [ |
| "Inventar-Formulare aufgeräumt: Futter-Auswahl-Dropdowns bei Aufstocken und Bearbeiten ausgeblendet (nicht mehr nötig, da Sorte jetzt direkt über die Mini-Buttons vorausgewählt wird)", |
| "Fütterungsart und die vier Gewicht/Menge-Felder wandern beim Bearbeiten in ein aufklappbares 'Erweitert'-Akkordeon", |
| "Reihenfolge umgestellt: Dashboard mit Futtersorten zuerst, die Aufstocken/Bearbeiten-Formulare erscheinen jetzt darunter statt darüber", |
| ]), |
| ("7.39.1", [ |
| "Neues Futter hinzufügen aktualisiert jetzt automatisch die Dashboard-Übersicht samt Mini-Buttons - kein manuelles 'Aktualisieren' mehr nötig", |
| ]), |
| ("7.39.0", [ |
| "Inventar umgebaut: Die großen 'Aufstocken'/'Bearbeiten'-Tabs oben sind komplett entfernt", |
| "Stattdessen hängen ➕ Aufstocken und ✏️ Bearbeiten jetzt direkt an der echten Live-Karte jeder einzelnen Futtersorte (keine separate vereinfachte Liste mehr)", |
| "Formulare haben einen eigenen ✕ Schließen-Button, da es keine übergeordneten Umschalt-Buttons mehr gibt", |
| ]), |
| ("7.38.0", [ |
| "Tabs merken sich keine Unter-Auswahl mehr: Beim Wechsel eines Haupt- oder Bereichs-Tabs wird immer die Übersicht gezeigt (Kategorie zurück auf Stammdaten, offene Formulare in Archiv/Inventar/Konto schließen sich)", |
| "Futter-Übersicht im Inventar kompakter gestaltet", |
| "Unter jeder Futtersorte gibt es jetzt direkt ➕ Aufstocken und ✏️ Bearbeiten Mini-Buttons, die automatisch die richtige Sorte vorauswählen", |
| ]), |
| ("7.37.1", [ |
| "Die 25-stufige Vorwärm-Kette aus v7.36.3 wieder komplett entfernt: Sie war reine Spekulation ohne bestätigten Nutzen und hat vermutlich neue Verzögerungen verursacht (eigene Klicks reihten sich hinter der Kette in die Warteschlange ein, fühlte sich wie 'angezogene Handbremse' an)", |
| "Die tatsächlich bestätigten Fixes bleiben erhalten: Dictionary-Format überall, gegenseitiger Ausschluss bei Toggle-Paaren, Konto-Akkordeons entfernt, Archiv/Inventar-Umstrukturierung", |
| ]), |
| ("7.37.0", [ |
| "Archiv und Inventar umstrukturiert: PDF-Export/Vertrag nachtragen bzw. Aufstocken/Bearbeiten stehen jetzt direkt oben als Unter-Navigation (wie bei Tiere die Kategorien), statt unten im Formular versteckt zu sein", |
| "Diese Buttons zeigen jetzt auch optisch den aktiven Zustand (Unterstrich), wie die anderen Tab-Ebenen", |
| ]), |
| ("7.36.3", [ |
| "Vorwärm-Sequenz beim Laden auf WIRKLICH alle Umschalt-Buttons ausgeweitet, nicht nur die äußeren Bereiche: jetzt auch News/Feedback/Erinnerungen/Passwort/Konto löschen (Konto), PDF-Export/Vertrag nachtragen (Archiv), Aufstocken/Bearbeiten (Inventar), alle vier Tiere-Kategorien und Foto-Scan", |
| ]), |
| ("7.36.2", [ |
| "Experimenteller Workaround für Archiv/Inventar/Konto (öffneten erst beim zweiten Versuch): App durchläuft beim Laden einmal automatisch diese Bereiche, um Gradios verzögerte Darstellung vorwegzunehmen, bevor der Nutzer sie zu sehen bekommt", |
| "Keine JavaScript-Ursache mehr gefunden (vollständig durchsucht) - vermutlich ein Darstellungsverhalten in Gradio selbst, betrifft auffällig die zuletzt im Code erstellten Bereiche", |
| ]), |
| ("7.36.1", [ |
| "Bugfix: Aufstocken schloss sich nicht, wenn man Bearbeiten öffnete (waren unabhängige Schalter statt eines Paares) - jetzt schließt das eine automatisch, wenn das andere geöffnet wird. Gleiches für PDF-Export/Vertrag nachtragen in Archiv", |
| "Kritischer Bugfix: Der obige Umbau hatte eine verwaiste Referenz auf entfernte Variablen hinterlassen (hätte die App zum Absturz gebracht) - gefunden und korrigiert, bevor ausgeliefert", |
| ]), |
| ("7.36.0", [ |
| "Ursache für 'Konto öffnet erst beim zweiten Versuch' gefunden: Konto enthielt als einziger Hauptbereich noch echte Gradio-Akkordeons (News, Einstellungen, Feedback, Erinnerungen, Passwort, Konto löschen) - diese jetzt auf dasselbe zuverlässige Button+Feld-Muster umgestellt wie der Rest der App", |
| ]), |
| ("7.35.0", [ |
| "Vollständige Konsistenz-Bereinigung: BARF/Nassfutter- und Dokumententyp-Umschaltung ebenfalls auf das zuverlässige Dictionary-Format umgestellt", |
| "Bugfix: Trockenfutter/BARF/Nassfutter-Anzeige verließ sich beim Tier-Laden auf eine verkettete Reaktion, die nicht immer zuverlässig auslöste - lädt jetzt direkt die korrekte Ansicht mit", |
| "Nach dem Archivieren eines Tieres schließt sich die Detailansicht jetzt zuverlässig, statt möglicherweise eine veraltete Ansicht stehen zu lassen", |
| "Gesamten Code auf verwaiste Referenzen und Formatinkonsistenzen geprüft - keine weiteren gefunden", |
| ]), |
| ("7.34.2", [ |
| "Bugfix: Beim Tier-Wechsel oder Speichern wurde nur Stammdaten sichtbar gemacht, ohne Fütterung/Gesundheit/Dokumente zurückzusetzen - falls man zuvor auf einer anderen Kategorie war, blieben beide gleichzeitig sichtbar. Setzt jetzt beim Laden/Speichern konsequent auf Stammdaten zurück", |
| ]), |
| ("7.34.1", [ |
| "Dictionary-Rückgabeformat aus v7.34.0 jetzt auf ALLE Auf/Zu-Buttons ausgeweitet, die es vorher noch nicht hatten: Foto-Scan, PDF-Export, Vertrag nachtragen, Aufstocken, Bearbeiten, Onboarding-Popup, Versionshistorie", |
| "Zuvor war der Fix nur auf die drei Haupt-Navigationsebenen angewendet worden, nicht auf die einzelnen Auf/Zu-Buttons innerhalb der Bereiche", |
| ]), |
| ("7.34.0", [ |
| "Tab-Umschaltung komplett neu implementiert nach Gradios offiziellem Dokumentations-Muster: Rückgabe als Dictionary mit Komponenten als Schlüssel statt als Positions-Liste, einstufig statt zweistufig", |
| "Recherche bestätigt bekannte Gradio-Regressionen bei dynamisch umgeschalteten Bereichen in neueren Versionen - dieser Ansatz folgt jetzt exakt Gradios eigenem kanonischen Beispiel für Sichtbarkeits-Umschaltung", |
| ]), |
| ("7.33.0", [ |
| "Kritischer Architektur-Fix: Client-seitige JavaScript-Sichtbarkeits-Hacks komplett entfernt (kämpften gegen Gradios eigenen Server-Zustand -> Effekt 'öffnet für einen Moment und schließt sofort wieder'). Alle Tab-Ebenen laufen jetzt ausschließlich über Python, das Gradios Zustand korrekt aktualisiert", |
| "Vollständiges Audit: keine doppelten Klick-Bindungen, keine doppelten IDs, keine verwaisten Referenzen mehr gefunden", |
| "Schriftgröße app-weit etwas verkleinert für ein weniger überladenes Erscheinungsbild", |
| ]), |
| ("7.32.1", [ |
| "Kritischer Bugfix: Beim Wechseln zwischen den neuen Tab-Buttons blieb der vorherige Inhalt sichtbar und der neue kam nur dazu, statt ihn zu ersetzen - jetzt zweistufig (erst alles verstecken, dann garantiert nachgelagert nur das Ziel zeigen)", |
| ]), |
| ("7.32.0", [ |
| "Alle drei Tab-Ebenen (Verwaltung/Vermisst melden/Konto, Tiere/Futter-Zentrale/Archiv/Inventar, Stammdaten/Fütterung/Gesundheit/Dokumente) laufen jetzt über eigene Buttons statt Gradios Tabs-Komponente - kein \"⋮\"-Overflow-Menü mehr möglich", |
| "Buttons sind optisch als Reiter gestaltet (flache Kanten, farbiger Unterstrich für den aktiven Tab) - auf den ersten Blick nicht von echten Tabs zu unterscheiden", |
| "Nutzt dieselbe reine Python-Sichtbarkeits-Logik ohne JavaScript, die sich beim Aufstocken/Bearbeiten-Fix als zuverlässig erwiesen hat", |
| ]), |
| ("7.31.2", [ |
| "Tabs werden jetzt senkrecht untereinander statt nebeneinander angezeigt - verhindert Gradios \"⋮\"-Overflow-Menü zuverlässiger, da kein Platzmangel in der Breite mehr entstehen kann", |
| ]), |
| ("7.31.1", [ |
| "Kritischer Bugfix: App startete gar nicht mehr (NameError) - verwaiste Verkabelung des in v7.31.0 entfernten Vermisst-Toggle-Buttons wurde übersehen und jetzt entfernt", |
| ]), |
| ("7.31.0", [ |
| "Stammdaten/Fütterung/Gesundheit/Dokumente-Felder erscheinen jetzt erst, wenn wirklich ein Tier ausgewählt ist (vorher immer sichtbar)", |
| "\"🚨 Vermisst melden\" ist jetzt ein eigener Haupt-Tab mit eigener Tier-Auswahl - kein Umweg mehr über die Verwaltung nötig, direkt auffindbar und nutzbar", |
| ]), |
| ("7.30.2", [ |
| "Tab-Umbruch-CSS (verhindert das \"⋮\"-Overflow-Menü) robuster gemacht: zielt jetzt zusätzlich auf die ARIA-Rolle [role=\"tablist\"], falls sich Gradio-6-interne Klassennamen geändert haben - betrifft alle Tab-Ebenen (Verwaltung/Konto, Bereichs- und Kategorie-Tabs)", |
| ]), |
| ("7.30.1", [ |
| "Kritischer Bugfix: Aufstocken/Bearbeiten (und Vermisst melden, Foto-Scan, PDF-Export, Vertrag nachtragen) öffneten sich gar nicht - Ursache war eine eigene, fehlerhafte JS-Zusatzabsicherung aus v7.29.0, die das korrekte Anzeigen sofort wieder rückgängig gemacht hat. Diese wurde entfernt, Gradios eigene Sichtbarkeits-Logik war für diese einfachen Auf/Zu-Bereiche bereits zuverlässig", |
| ]), |
| ("7.30.0", [ |
| "Grundlegender Umbau gegen das \"zeigt beim ersten Klick nichts\"-Problem: Bereichs-Auswahl (Tiere/Futter-Zentrale/Archiv/Inventar) und Kategorie-Auswahl (Stammdaten/Fütterung/Gesundheit/Dokumente) laufen jetzt über echte, native Gradio-Tabs statt über Buttons mit ein-/ausgeblendeten Feldern", |
| "Diese Tabs waren in der App schon immer zuverlässig (Verwaltung/Konto, Anmelden/Registrieren) - das Problem sollte damit an der Wurzel gelöst sein", |
| ]), |
| ("7.29.1", [ |
| "Kritischer Bugfix (vermutliche Ursache): SSR-Modus (Server-Side Rendering) explizit deaktiviert - ein bekanntes, offenes Gradio-6-Problem verursacht Hydrations-Fehler genau bei bedingt sichtbaren Bereichen (Tabs, Umschalt-Buttons), die erst beim zweiten Versuch korrekt anzeigen", |
| ]), |
| ("7.29.0", [ |
| "JS-Zwangsdurchsetzung (aus dem Gesundheit-Fix) jetzt app-weit auf alle Auf/Zu-Bereiche angewendet: Vermisst melden, Futter-Zentrale-Scan, PDF-Export, Vertrag nachtragen, Aufstocken, Bearbeiten sowie die Haupt-Bereichsauswahl (Tiere/Futter-Zentrale/Archiv/Inventar)", |
| ]), |
| ("7.28.0", [ |
| "Bugfix: Gesundheit (und andere Kategorien) zeigten nach dem ersten Klick manchmal keine Daten - zusätzliche JavaScript-Zwangsdurchsetzung sorgt jetzt garantiert für korrekte Sichtbarkeit, unabhängig von Gradios eigener Reaktivität", |
| "requirements.txt: Gradio-Version gepinnt (6.19.0) statt unversioniert, damit sich das Verhalten nicht mehr bei jedem Neustart ändern kann", |
| ]), |
| ("7.27.4", [ |
| "Gradio-6-Warnung behoben: css-Parameter läuft jetzt korrekt über launch() statt über den Blocks()-Konstruktor", |
| ]), |
| ("7.27.3", [ |
| "Vollständiges Code-Audit: alle Funktions-Rückgaben gegen ihre UI-Verkabelung geprüft (keine weiteren Zählfehler gefunden)", |
| "Verwaiste/falsch eingerückte Abschnitts-Kommentare aus früheren Umbauten bereinigt", |
| "Unbenutzte Importe und tote Funktionen geprüft - keine gefunden", |
| ]), |
| ("7.27.2", [ |
| "Kritischer Bugfix: Login schlug bei jedem Versuch fehl (Seite blieb kaputt/leer), weil seit dem Akkordeon-Umbau (v7.27.0) versucht wurde, eine Beschriftung auf ein Feld zu setzen, das keine Beschriftung mehr unterstützt - die Beschriftung läuft jetzt korrekt über einen eigenen Text-Baustein", |
| ]), |
| ("7.27.1", [ |
| "Bugfix: Dark-Mode-Erzwingung löste bei jedem Aufruf über die huggingface.co-Seite einen Seiten-Reload aus, der laufende Anfragen abbrach und kurzzeitig zu kaputter Darstellung führte - jetzt per direkter CSS-Klasse ohne Reload gelöst", |
| ]), |
| ("7.27.0", [ |
| "Stammdaten/Fütterung/Gesundheit/Dokumente sind jetzt normale Felder statt Akkordeons - behebt endgültig das Problem, dass Inhalte nach Button-Klick leer/geschlossen blieben", |
| ]), |
| ("7.26.2", [ |
| "Bugfix: Kategorie-Akkordeons (Stammdaten/Fütterung/Gesundheit/Dokumente) wurden zwar sichtbar, blieben dabei aber optisch geschlossen - Sichtbar-machen und Öffnen laufen jetzt als zwei garantiert nacheinander ablaufende Schritte statt gemeinsam in einem Update", |
| ]), |
| ("7.26.1", [ |
| "Bugfix: Kategorie-Buttons luden bei jedem Klick unnötig alle Bilder/Dokumente neu, was zu Verzögerungen und gelegentlichem Fehlverhalten führte - zurückgesetzt auf schnelles reines Umschalten", |
| "Bild-/Dokument-Downloads beim Tier-Laden haben jetzt ein hartes Gesamtzeitlimit (4 Sekunden) statt potenziell unbegrenzt zu warten", |
| ]), |
| ("7.26.0", [ |
| "Design an Android 17 / Material 3 Expressive angeglichen: weichere größere Rundungen, Frosted-Glass-Effekt bei Popups, federndere Übergänge bei Buttons und Tabs", |
| "Dashboard-Karten im Inventar ebenfalls weicher gestaltet", |
| ]), |
| ("7.25.0", [ |
| "Kontakttelefon wird jetzt automatisch bei allen Tieren desselben Accounts übernommen, sobald es bei einem Tier geändert wird", |
| "Neues Feld \"Kontakttelefon 2\" als Backup-Nummer (z.B. Familie), erscheint auch im Vermisst-Steckbrief", |
| "Kategorie-Buttons (Stammdaten/Fütterung/Gesundheit/Dokumente) laden jetzt bei jedem Klick frische Daten und öffnen garantiert direkt, kein doppeltes Klicken mehr nötig", |
| ]), |
| ("7.24.1", [ |
| "Filter steht bei aktivem Erweitertem Modus jetzt direkt neben der Tier-Auswahl statt darüber", |
| ]), |
| ("7.24.0", [ |
| "Tiere/Archiv/Inventar: Inhalte erscheinen jetzt direkt unter der Auswahl (Tier-Auswahl bzw. Hauptaktion), die Auswahl-Buttons stehen danach statt davor", |
| "So landet man beim Aufrufen einer Unterkategorie direkt beim Inhalt, ohne an den Buttons vorbei scrollen zu müssen", |
| ]), |
| ("7.23.0", [ |
| "Nach Aufstocken/Bearbeiten/Löschen im Inventar klappen sich die Formulare automatisch zu und die Ansicht springt zurück zum Dashboard", |
| ]), |
| ("7.22.0", [ |
| "Inventar-Dashboard komplett überarbeitet: zeigt jetzt Name, Sackgröße, aktueller Lagerbestand und verbleibende Tage pro Futtersorte", |
| "Aktueller Lagerbestand wird jetzt live berechnet (Bestand minus Tage seit letzter Aktualisierung × täglicher Verbrauch aus den Stammdaten)", |
| "Neues Feld \"Sackgröße\" beim Bearbeiten von Futter, unabhängig vom aktuellen Lagerbestand", |
| "Dashboard-Karten deutlich kompakter gestaltet", |
| ]), |
| ("7.21.0", [ |
| "Archiv: \"PDF-Akte exportieren\" und \"Vertrag nachtragen\" jetzt per Button ein-/ausblendbar statt permanent als Akkordeon sichtbar", |
| "Inventar: \"Aufstocken\" und \"Bearbeiten\" jetzt per Button ein-/ausblendbar, Futter-Übersicht bleibt direkt sichtbar", |
| ]), |
| ("7.20.0", [ |
| "Futter-Zentrale umgebaut: manuelle Eingabe aller Futterdaten ist jetzt Standard, sofort sichtbar", |
| "Fotoscan (3 Bilder + Auswertung) ist jetzt eine optionale Zusatzfunktion, per Button ein-/ausblendbar", |
| ]), |
| ("7.19.0", [ |
| "Stammdaten/Fütterung/Gesundheit/Dokumente erscheinen jetzt erst nach Klick auf den jeweiligen Button, statt permanent als Akkordeon sichtbar zu sein", |
| "Filter (Alle/Eigene/Pflege & Gäste) ist bei deaktiviertem \"Erweiterter Modus\" jetzt komplett unsichtbar statt nur eingeklappt", |
| "Reihenfolge in der Tiere-Sektion: Filter → Tier-Auswahl → Unterkategorien → Vermisst melden", |
| "Bugfix: Stammdaten öffnete sich beim Laden/Speichern eines Tiers, blieb dabei aber unsichtbar", |
| ]), |
| ("7.18.0", [ |
| "Futter-Zentrale: Scan-Ergebnisfelder erscheinen erst nach dem Auswerten, nicht mehr permanent", |
| "Archiv: Archivieren-Aktion jetzt oben (häufigste Aktion), PDF-Export in eigenem Akkordeon", |
| "Inventar: Futter-Übersicht jetzt direkt oben sichtbar, Aufstocken/Bearbeiten weiterhin eingeklappt", |
| ]), |
| ("7.17.0", [ |
| "Filter (Alle/Eigene/Pflege & Gäste) jetzt standardmäßig eingeklappt statt immer sichtbar", |
| "\"🚨 Vermisst melden\" ist jetzt ein immer sichtbarer Button direkt unter der Tier-Auswahl, kein Scrollen mehr nötig", |
| "Neue Auswahlleiste (Stammdaten/Fütterung/Gesundheit/Dokumente) öffnet immer nur einen Bereich statt aller Akkordeons gleichzeitig", |
| ]), |
| ("7.16.2", [ |
| "Kritischer Bugfix: Nach einem Lade-Fehler beim Login blieben Dashboard, Archiv und Inventar leer/veraltet, obwohl die Daten unangetastet in der Tabelle lagen (Zählfehler in der Login-Logik, bestand seit dem allerersten Upload)", |
| ]), |
| ("7.16.1", [ |
| "Bugfix: Versionshistorie-Overlay blockierte die App beim Schließen (gleicher Fehler wie damals beim Onboarding-Popup)", |
| ]), |
| ("7.16.0", [ |
| "Neu: Versionsnummer unten ist jetzt anklickbar und zeigt die komplette Änderungshistorie seit v7.5", |
| ]), |
| ("7.15.0", [ |
| "Kalender-Erinnerungen (.ics) lösen jetzt automatisch 4 Wochen vor Fälligkeit eine Benachrichtigung aus", |
| "Layout bei \"Gesundheit & Kalender\" aufgeräumt (Textfeld über den Buttons, kompaktere Datei-Box)", |
| ]), |
| ("7.14.2", ["Bugfix: \"TASSO Vermisst-Formular öffnen\" konnte die App im Browser-iFrame überschreiben und weiß bleiben"]), |
| ("7.14.1", ["Bugfix-Versuch: TASSO-Link in der Standalone-App (abgelöst durch 7.14.2)"]), |
| ("7.14.0", [ |
| "Bugfix: Tier-Laden konnte über 30 Sekunden hängen (Dokumente werden jetzt parallel statt nacheinander geladen)", |
| "\"Scanner\" heißt jetzt \"🥫 Futter-Zentrale\" – klarer benannt", |
| ]), |
| ("7.13.0", ["Nur noch 2 Haupt-Tabs: \"Verwaltung\" (mit Auswahl Tiere/Futter-Zentrale/Archiv/Inventar) und \"Konto\""]), |
| ("7.12.0", [ |
| "Status-Meldungen erscheinen jetzt als kurzes Overlay statt in sichtbaren Textboxen", |
| "\"Neu laden\"/\"Abmelden\" sind jetzt am Fußende der App", |
| "Bugfix: Admin-Debug-Konsole (Eruda) erschien nicht zuverlässig für PetBase", |
| ]), |
| ("7.11.1", ["Tab-Namen gekürzt (z. B. \"Tiere verwalten\" → \"Tiere\")"]), |
| ("7.11.0", ["Tabs brechen um statt sich hinter einem \"⋮\"-Menü zu verstecken", "\"Neu laden\"/\"Abmelden\"-Buttons kompakter"]), |
| ("7.10.0", ["Neu: 🚨 Vermisst melden – TASSO-Schnellzugriff + Daten zum Abtippen + PDF-Steckbrief"]), |
| ("7.9.1", ["Bugfix: Willkommens-Popup blockierte die App, wenn man es schließen wollte"]), |
| ("7.9.0", ["Willkommens-Popup erscheint jetzt auch bei neuen Versionen, nicht nur bei der Registrierung"]), |
| ("7.8.0", ["Neu: Willkommens-Popup mit Feature-Übersicht beim ersten Login"]), |
| ("7.7.0", ["Eruda-Debug-Konsole nur noch für Admin (PetBase) sichtbar", "Erzwungener Dark Mode für die App"]), |
| ("7.6.0", ["Nassfutter kann zusätzlich zu Trockenfutter gefüttert werden", "Tierart als Dropdown mit BARF/Futter-Kategorien"]), |
| ("7.5.0", ["Ausgangsstand vor Beginn der gemeinsamen Weiterentwicklung"]), |
| ] |
|
|
| def baue_versionshistorie_text(anzahl_anzeigen=15): |
| teile = ["## 🕓 Versionshistorie\n"] |
| sichtbare = VERSION_HISTORY[:anzahl_anzeigen] |
| for version, aenderungen in sichtbare: |
| teile.append(f"### v{version}") |
| teile.append("\n".join(f"- {a}" for a in aenderungen)) |
| teile.append("") |
| rest = len(VERSION_HISTORY) - len(sichtbare) |
| return "\n\n".join(teile), rest |
|
|
| |
| CHANGELOG_TEXT = ( |
| "## 🆕 Was ist neu in v" + APP_VERSION + "\n\n" |
| + "\n".join(f"- {a}" for a in VERSION_HISTORY[0][1]) |
| + "\n\n_Komplette Historie: unten auf die Versionsnummer tippen._" |
| ) |
|
|
| APP_CSS = """ |
| /* ========================================== |
| ICON-AUSTAUSCH (v7.91.1, Schritt 1 des App-Umbaus): Material Symbols statt |
| Emoji für die Hauptnavigation. Gradio-Buttons können nur reinen Text |
| anzeigen, kein gemischtes HTML - daher NICHT das Icon-Zeichen in den |
| Button-Text selbst schreiben (würde den ganzen Button-Text in die |
| Icon-Schriftart zwingen und wie Kauderwelsch aussehen), sondern per |
| ::before-Pseudoelement sauber DAVOR einfügen, während der eigentliche |
| Button-Text in der normalen Schrift bleibt. Jede Regel ist auf eine |
| einzelne elem_id begrenzt, betrifft also nur genau den einen Button. |
| ========================================== */ |
| .msr { |
| font-family: 'Material Symbols Rounded'; |
| font-weight: normal; |
| font-style: normal; |
| display: inline-block; |
| vertical-align: -3px; |
| -webkit-font-smoothing: antialiased; |
| } |
| #b_haupt_verwaltung::before { content: "pets"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_haupt_vermisst::before { content: "warning"; font-family:'Material Symbols Rounded'; font-variation-settings:'FILL' 1; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_haupt_konto::before { content: "account_circle"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_bereich_tiere::before { content: "pets"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_bereich_scanner::before { content: "photo_camera"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_bereich_archiv::before { content: "archive"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_bereich_inventar::before { content: "inventory_2"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_bereich_nachtrag::before { content: "attach_file"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_bereich_apotheke::before { content: "medication"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_bereich_pdf::before { content: "description"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_bereich_tagebuch::before { content: "auto_stories"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_tier_neu::before { content: "add_circle"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| /* Tier-Auswahl-Liste (v7.91.7) und Tagebuch-Übersicht: Karten-Look statt |
| nackter Standard-Buttons, Gruppen-Überschriften dezent abgesetzt. */ |
| .tier_liste_gruppe { |
| margin: 14px 4px 4px !important; |
| font-size: 11px !important; |
| font-weight: 700 !important; |
| letter-spacing: 0.5px; |
| text-transform: uppercase; |
| color: #7A6E5C !important; |
| } |
| .tier_liste_btn, .tier_liste_btn button, .tb_uebersicht_btn, .tb_uebersicht_btn button { |
| background: #FFFCF5 !important; |
| border: 1px solid #DED2BA !important; |
| border-radius: 14px !important; |
| box-shadow: 0 1px 4px rgba(59,50,38,0.08) !important; |
| text-align: left !important; |
| justify-content: flex-start !important; |
| font-weight: 600 !important; |
| margin-bottom: 8px !important; |
| } |
| #b_login_tab::before { content: "login"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_reg_tab::before { content: "person_add"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_login_submit::before { content: "login"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_reg_submit::before { content: "how_to_reg"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_quick_login::before { content: "bolt"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_impressum::before { content: "info"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| |
| /* Große Sammel-Runde (v7.91.6): restliche einzelne Buttons app-weit auf |
| Material Symbols umgestellt. Gemeinsame Klassen für inhaltlich gleichartige |
| Buttons (Speichern/Abbrechen/Bearbeiten/...), die an mehreren Stellen bzw. |
| innerhalb von @gr.render-Schleifen vorkommen (dort sind eindeutige IDs nicht |
| möglich, da pro Zeile neu erzeugt) - Klassen funktionieren dort zuverlässig, |
| IDs nicht (müssten eindeutig sein). Einzelne, wirklich nur einmal |
| vorkommende Buttons bekommen wie gewohnt eine eigene ID. */ |
| .btn-ic-save button::before, .btn-ic-save::before { content: "save"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| .btn-ic-cancel button::before, .btn-ic-cancel::before { content: "close"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| .btn-ic-edit button::before, .btn-ic-edit::before { content: "edit"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| .btn-ic-more button::before, .btn-ic-more::before { content: "expand_more"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| .btn-ic-more-inline button::before, .btn-ic-more-inline::before { content: "tune"; font-family:'Material Symbols Rounded'; font-size:15px; vertical-align:-3px; margin-right:4px; display:inline-block; } |
| .btn-ic-reminder button::before, .btn-ic-reminder::before { content: "event"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| .btn-ic-email button::before, .btn-ic-email::before { content: "mail"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| .btn-ic-check button::before, .btn-ic-check::before { content: "check"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| /* Reine Icon-Buttons ohne Begleittext (z.B. die kleinen +/✏️/✕-Knöpfe in den |
| Futter-Vorrat-Zeilen): der gesamte Button-Text IST der Icon-Ligatur-Name, |
| deshalb hier die ganze Schrift umstellen statt nur ein ::before davorzusetzen. */ |
| .msr-icon-btn, .msr-icon-btn button { |
| font-family: 'Material Symbols Rounded' !important; |
| font-size: 20px !important; |
| } |
| |
| #b_show_tagebuch::before { content: "auto_stories"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_onboarding_close::before { content: "rocket_launch"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_tagebuch_speichern::before { content: "save"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_tagebuch_pdf::before { content: "description"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_profil_speichern::before { content: "save"; font-family:'Material Symbols Rounded'; font-size:20px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_scan_toggle::before { content: "photo_camera"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_scan_auswerten::before { content: "search"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_save_futter::before { content: "inventory_2"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_archivieren::before { content: "archive"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_apotheke_speichern::before { content: "save"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_nach_save::before { content: "save"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_vermisst_daten::before { content: "list_alt"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_tasso_link::before { content: "open_in_new"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_vermisst_pdf::before { content: "description"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_logout::before { content: "logout"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| #b_versionshistorie_mehr::before { content: "expand_more"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| #b_show_fb::before { content: "bug_report"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_fb_senden::before { content: "send"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| #b_show_erinn::before { content: "notifications"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_show_pw::before { content: "lock"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_pw_save::before { content: "key"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| #b_show_del::before { content: "person_remove"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_del_account::before { content: "delete_forever"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| #b_test_email::before { content: "forward_to_inbox"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| #b_futter_cron_test::before { content: "restart_alt"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| #b_show_news::before { content: "campaign"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_news_veroeffentlichen::before { content: "publish"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| #b_show_users::before { content: "group"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_sperren::before { content: "block"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| #b_admin_vergeben::before { content: "shield_person"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| #b_admin_entziehen::before { content: "remove_moderator"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| #b_show_feedback_liste::before { content: "rate_review"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_show_stam::before { content: "badge"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_show_futter::before { content: "nutrition"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_show_gesund::before { content: "vaccines"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_show_doku::before { content: "folder"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_show_tagebuch_open::before { content: "auto_stories"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_konto_tab_einst::before { content: "settings"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_konto_tab_admin::before { content: "admin_panel_settings"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| |
| /* Feste Navigationsleiste unten (v7.91.3) - immer sichtbar, unabhängig vom |
| Scroll-Stand. Am Ende von main_app im DOM platziert, aber per position:fixed |
| optisch immer am unteren Bildschirmrand verankert. gradio-container braucht |
| zusätzlichen unteren Abstand, sonst würde die Leiste den letzten Inhalt |
| verdecken. */ |
| .gradio-container { padding-bottom: 78px !important; } |
| /* Popup-Flyout über der unteren Leiste (v7.92.0) - sitzt direkt über der |
| Navigationsleiste, damit der Bezug zum angetippten Icon klar bleibt. */ |
| #nav_popup_box { |
| position: fixed !important; |
| bottom: 78px !important; |
| left: 50% !important; |
| transform: translateX(-50%); |
| width: fit-content !important; |
| min-width: 220px; |
| max-width: calc(100vw - 40px); |
| max-height: 55vh; |
| overflow-y: auto; |
| background: #FFFCF5 !important; |
| border: 1px solid #DED2BA !important; |
| border-radius: 18px !important; |
| box-shadow: 0 -4px 20px rgba(59,50,38,0.22) !important; |
| z-index: 490; |
| padding: 14px 14px 8px !important; |
| } |
| .nav_popup_titel { |
| font-size: 14px !important; |
| color: #3B3226 !important; |
| margin-bottom: 6px !important; |
| } |
| .nav_popup_gruppe { |
| font-size: 10.5px !important; |
| font-weight: 700 !important; |
| letter-spacing: 0.5px; |
| text-transform: uppercase; |
| color: #7A6E5C !important; |
| margin: 10px 2px 2px !important; |
| } |
| .nav_popup_item, .nav_popup_item button { |
| background: #F2ECE0 !important; |
| border: 1px solid #DED2BA !important; |
| border-radius: 12px !important; |
| text-align: left !important; |
| justify-content: flex-start !important; |
| font-weight: 600 !important; |
| margin-bottom: 6px !important; |
| min-height: 40px !important; |
| } |
| .nav_popup_back, .nav_popup_back button { |
| background: transparent !important; |
| box-shadow: none !important; |
| border: none !important; |
| color: #7A6E5C !important; |
| font-size: 12px !important; |
| justify-content: flex-start !important; |
| margin-top: 4px !important; |
| } |
| .nav_popup_danger, .nav_popup_danger button { |
| color: #D32F2F !important; |
| border-color: #E8B4B0 !important; |
| } |
| |
| #bottom_nav_bar { |
| position: fixed !important; |
| bottom: 0; left: 0; right: 0; |
| z-index: 500; |
| background: #FFFCF5 !important; |
| border-top: 1px solid #DED2BA; |
| box-shadow: 0 -2px 10px rgba(59,50,38,0.12); |
| padding: 6px 4px 4px !important; |
| gap: 0 !important; |
| flex-wrap: nowrap !important; |
| } |
| #bottom_nav_bar > * { |
| flex: 1 1 16.66% !important; |
| min-width: 0 !important; |
| } |
| #bottom_nav_bar button { |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| color: #7A6E5C !important; |
| font-size: 9.5px !important; |
| font-weight: 600 !important; |
| display: flex !important; |
| flex-direction: column !important; |
| align-items: center !important; |
| gap: 2px !important; |
| min-height: 56px !important; |
| padding: 4px 1px !important; |
| } |
| #bottom_nav_bar button::before { |
| font-family: 'Material Symbols Rounded'; |
| font-size: 20px; |
| line-height: 1; |
| display: block; |
| } |
| #b_nav_start::before { content: "home"; } |
| #b_nav_tiere::before { content: "pets"; } |
| #b_nav_futter::before { content: "nutrition"; } |
| #b_nav_tagebuch::before { content: "auto_stories"; } |
| #b_nav_konto::before { content: "account_circle"; } |
| #b_nav_menu::before { content: "menu"; } |
| #b_haupt_futtervorrat::before { content: "medication"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; } |
| #b_start_vermisst::before { content: "warning"; font-family:'Material Symbols Rounded'; font-variation-settings:'FILL' 1; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| #b_start_konto::before { content: "medication"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; } |
| |
| /* Gradios Grundthema (zoo_theme) nutzt intern Orange als "primary_hue" - das |
| erzeugt eine komplette Orange-Farbskala als CSS-Variablen (--primary-50 bis |
| --primary-950), die JEDES Gradio-Element für Fokus-Ringe, "gerade |
| aktualisiert"-Hervorhebungen, Slider, Checkboxen usw. verwendet. Bisher wurden |
| nur EINZELNE Stellen gezielt auf Braun umgestellt (siehe Changelog v7.2x) - |
| das griff nicht überall, z.B. blitzten Feld-Beschriftungen nach einem |
| Speichervorgang kurz in Orange auf (siehe v7.88.2). Robusterer Ansatz: die |
| Variablen selbst global auf eine braune Skala umbiegen, dann greift die |
| App-Farbe automatisch überall, auch bei zukünftigen Gradio-Zuständen, die wir |
| nicht einzeln kennen/vorhersehen. |
| Skala grob am bestehenden Braun (#6D4C41) und den App-Cremetönen ausgerichtet. */ |
| :root, .gradio-container { |
| --primary-50: #FDF9F3 !important; |
| --primary-100: #F7EFE1 !important; |
| --primary-200: #EDE0C8 !important; |
| --primary-300: #DED2BA !important; |
| --primary-400: #C7AD8F !important; |
| --primary-500: #A8846A !important; |
| --primary-600: #8A6650 !important; |
| --primary-700: #6D4C41 !important; |
| --primary-800: #55392F !important; |
| --primary-900: #3B2A22 !important; |
| --primary-950: #251A15 !important; |
| } |
| /* Erzwingt eine zweispaltige Ansicht (50/50) für die damit markierten Zeilen. |
| CSS Grid statt Flexbox, mit minmax(0, 1fr) statt einfachem 1fr: normales "1fr" |
| ist eigentlich Kurzform für "minmax(auto, 1fr)" - das "auto" lässt den Browser |
| auf die native Mindestbreite des Inhalts schauen. iOS Safari hat bei |
| Eingabefeldern eine feste interne Mindestbreite und verweigert dann das exakte |
| Halbieren. Mit minmax(0, 1fr) wird dem Grid diese Rücksichtnahme komplett |
| entzogen, es teilt gnadenlos exakt in der Mitte, unabhängig vom Inhalt. */ |
| .zweispaltig { |
| display: grid !important; |
| grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important; |
| gap: 12px !important; |
| width: 100% !important; |
| /* War vorher "start": wenn ein Feld (z.B. Farbe) durch längeren Text höher |
| wurde als sein Nachbar (z.B. Geburtsdatum), blieb darunter eine hässliche |
| graue Lücke stehen. "stretch" zieht beide Felder einer Zeile auf dieselbe |
| Höhe - das kürzere wächst automatisch auf die Höhe des längeren mit. */ |
| align-items: stretch !important; |
| } |
| /* Gradio gruppiert benachbarte Formularfelder (Textbox, Dropdown etc.) innerhalb |
| einer Row manchmal automatisch in einen zusätzlichen Wrapper-Div (Klasse "form" |
| oder "wrap"). Dadurch sieht .zweispaltig nur EIN Kind-Element statt zwei, und |
| die 50/50-Aufteilung landet komplett in Spalte 1 (auf Android beobachtet, Spalte |
| 2 blieb leer). display:contents macht diesen Wrapper für das Grid-Layout |
| "unsichtbar", sodass seine eigentlichen Kinder direkt als Grid-Items zählen - |
| ohne die Grid-Regeln ein zweites Mal definieren zu müssen. */ |
| .zweispaltig > .form, |
| .zweispaltig > .wrap { |
| display: contents !important; |
| } |
| /* 1. Alle Feld-Blöcke (egal ob direktes Grid-Kind oder - falls Gradio den |
| Auto-Wrapper gesetzt hat - ein Kind davon) zu Flex-Containern über die volle |
| Zeilenhöhe machen. Das ist die Basis der Höhen-Kette, die unten bis zur |
| Textarea durchgereicht wird. */ |
| .zweispaltig > *, |
| .zweispaltig > .form > *, |
| .zweispaltig > .wrap > * { |
| display: flex !important; |
| flex-direction: column !important; |
| height: 100% !important; |
| min-width: 0 !important; |
| max-width: 100% !important; |
| width: auto !important; |
| margin: 0 !important; |
| flex: none !important; |
| /* iOS Safari zeigt sonst einen hellgrauen Halo/Rahmen um jedes Feld (natives |
| Karten-Styling der Gradio-Block-Wrapper, das auf Android unsichtbar bleibt, |
| auf iOS aber durchscheint). "transparent" war hier falsch - dahinter liegt |
| keine weiße Karte, sondern direkt die Row/Seiten-Fläche, wodurch ein |
| grauer Bereich sichtbar wurde. Stattdessen dieselbe Karten-Farbe wie der |
| Rest der App (#FFFCF5, siehe .gr-group/.gr-form weiter unten) - kein |
| reines Weiß, das hätte nicht zum warmen Creme-Ton gepasst. */ |
| background: #FFFCF5 !important; |
| border: none !important; |
| box-shadow: none !important; |
| } |
| /* 2. Label und Gradios innerer "wrap"-Div (der die Textarea direkt umschließt) |
| müssen die Höhe ebenfalls durchreichen - sonst bricht die Kette hier ab und |
| die Textarea hat unten keinen Platz zum Hineinwachsen. */ |
| .zweispaltig label, |
| .zweispaltig > * > .wrap { |
| display: flex !important; |
| flex-direction: column !important; |
| flex: 1 1 auto !important; |
| height: 100% !important; |
| } |
| /* 3. Das eigentliche Eingabefeld füllt den verbleibenden Platz. Bewusst KEIN |
| height:100% hier - das würde Gradios JS-Autosize für mehrzeiligen Text wieder |
| überschreiben (siehe Bugfix v7.73.5, dort ist das Feld auf eine viel zu kleine |
| Höhe zusammengestaucht). flex-grow ohne feste Höhe lässt das kürzere Feld |
| mitwachsen, ohne die "auto"-Höhe des längeren Feldes zu kappen. */ |
| .zweispaltig textarea, |
| .zweispaltig input, |
| .zweispaltig select { |
| flex: 1 1 auto !important; |
| min-height: 0 !important; |
| } |
| /* Tier-Filter (Alle/Eigene Tiere/Pflege & Gäste): untereinander statt nebeneinander |
| gequetscht, da der Bereich zu schmal für 3 Optionen in einer Zeile ist */ |
| #tier_filter_radio .wrap { |
| flex-direction: column !important; |
| } |
| #tier_filter_radio label { |
| width: 100% !important; |
| } |
| /* Vollbild-Bildansicht (z.B. Profilbild antippen) auf die Bildschirmgröße begrenzen, |
| damit der Schließen-Button (X) immer sichtbar/erreichbar bleibt, ohne seitlich |
| scrollen zu müssen */ |
| .gradio-container [class*="fullscreen"], |
| .gradio-container [data-testid="image"] > div:has(img) { |
| max-width: 100vw !important; |
| max-height: 100vh !important; |
| } |
| .gradio-container [class*="fullscreen"] img { |
| max-width: 100% !important; |
| max-height: 90vh !important; |
| width: auto !important; |
| object-fit: contain !important; |
| } |
| /* Profilbild: Gradios eingebaute Werkzeugleiste (Vollbild/Löschen-Icons) legt sich |
| standardmäßig als halbtransparenter Balken ÜBER das Bild - bei Tierfotos sitzt |
| der Kopf meist oben, wird also genau verdeckt. Statt die Buttons per JS zu |
| verstecken/ersetzen (das ist in dieser App schon mehrfach schiefgegangen, siehe |
| Changelog v7.68-7.72), schaffen wir hier rein per CSS oben im Bild eine kleine |
| "Sicherheitszone": das Bild wird minimal verkleinert und nach unten ausgerichtet, |
| die Werkzeugleiste bekommt einen transparenten Hintergrund. |
| v7.73.10 hatte hier zusätzlich versucht, das Profilbild per height:0 + |
| overflow:hidden + position:absolute exakt auf die Höhe der linken Spalte zu |
| ziehen. RÜCKGÄNGIG GEMACHT in v7.73.18: dieser Trick ist zu fragil - wenn die |
| Zeile aus irgendeinem Grund keine verlässliche Höhe liefert (z.B. durch einen |
| von Gradio eingefügten Wrapper-Div, wie schon bei den Stammdaten-Feldern |
| beobachtet), kollabiert der GESAMTE Bildbereich auf 0px und overflow:hidden |
| blendet dann Foto UND Upload-Button komplett aus - genau das ist passiert. |
| Funktionierende Sichtbarkeit ist wichtiger als exakte Höhen-Angleichung, daher |
| zurück auf normalen Fluss ohne erzwungene Höhe. */ |
| /* Tagebuch: Foto-Upload-Fläche etwas kompakter - bewusst OHNE overflow:hidden |
| oder erzwungene max-height (das hatte in einem früheren Versuch versehentlich |
| die Icons Hochladen/Kamera/Zwischenablage mit verschluckt, siehe v7.88.6/7). |
| Nur die künstliche Mindesthöhe wird gelockert, das kann höchstens wirkungslos |
| bleiben, aber nichts Funktionales verstecken. Vorschau nach dem Hochladen |
| bleibt so oder so erhalten, unabhängig von dieser Regel. */ |
| /* Tagebuch: Foto-Upload-Fläche kompakter - per Live-Inspektion (danke fürs |
| Dranbleiben!) die WIRKLICHEN Klassen gefunden: das eigentliche Element ist |
| ein <button class="... boundedheight ..."> innerhalb von .upload-container, |
| mit fest height:240px/min-height:44px direkt von Gradios "boundedheight"- |
| Klasse. Die vorherigen Versuche (.wrap.full/.empty.large) zielten auf die |
| falschen Elemente - die gehören offenbar zum File-Feld (PDF), nicht zum |
| Image-Feld (Foto). Bewusst nur innerhalb dieser beiden elem_ids begrenzt, |
| damit andere Bildfelder in der App (Profilbild, Apotheke-Banner) ihre eigene |
| Größe behalten. */ |
| #tagebuch_foto_upload .upload-container, #tagebuch_foto_edit .upload-container { |
| min-height: 0 !important; |
| } |
| /* v7.90.2: 60px war zu knapp - der komplette Inhalt (drei Textzeilen "Bild hier |
| ablegen/oder/Hochladen" UND die drei Icons darunter) sitzt alles im selben |
| Button, nicht in getrennten Bereichen. Bei zu wenig Höhe überlappen sich Text |
| und Icons. min-height statt fixer height: schrumpft so weit wie möglich, |
| wächst aber automatisch mit, falls der Inhalt mehr Platz braucht. */ |
| #tagebuch_foto_upload button.boundedheight, #tagebuch_foto_edit button.boundedheight { |
| height: auto !important; |
| min-height: 140px !important; |
| } |
| /* Gleiche Lösung jetzt auf alle anderen Bild-/Datei-Upload-Felder in der App |
| übertragen (v7.90.0). Unterschiedliche Höhen je nach Zweck statt überall |
| gleich winzig: Profilbild bleibt sichtbar prominent, die Futter-Scanner-Bilder |
| brauchen genug Platz zum Fotografieren/Framing von Barcode & Etikett, der |
| Pflegevertrag-Upload ist wie das Tagebuch-PDF ein reines Datei-Feld (andere |
| Struktur, siehe .wrap.full/.empty.large statt button.boundedheight). */ |
| #profil_img_komponente .upload-container { |
| min-height: 0 !important; |
| } |
| #profil_img_komponente button.boundedheight { |
| height: auto !important; |
| min-height: 140px !important; |
| } |
| #futter_bild_upload_1 .upload-container, #futter_bild_upload_2 .upload-container, #futter_bild_upload_3 .upload-container { |
| min-height: 0 !important; |
| } |
| #futter_bild_upload_1 button.boundedheight, #futter_bild_upload_2 button.boundedheight, #futter_bild_upload_3 button.boundedheight { |
| height: auto !important; |
| min-height: 150px !important; |
| } |
| #pflege_vertrag_upload.block { |
| min-height: 0 !important; |
| } |
| #pflege_vertrag_upload .wrap.full { |
| min-height: 0 !important; |
| height: auto !important; |
| } |
| #pflege_vertrag_upload .empty.large { |
| min-height: 0 !important; |
| padding: 10px !important; |
| } |
| /* Tagebuch: PDF-Ergebnisfeld soll nur so hoch sein wie der Dateiname braucht, |
| nicht die große leere Standard-Vorschaufläche. Per Live-Inspektion (Danke!) |
| die TATSÄCHLICHEN Gradio-Klassen gefunden: .wrap.full (füllt die Höhe des |
| äußeren Blocks) und .empty.large (Gradios eigene "große" Leerzustand-Klasse, |
| inkl. großem Icon+Innenabstand). Der vorherige Versuch zielte auf ".container", |
| die es hier gar nicht gibt - deshalb wirkungslos. Bewusst nur INNERHALB von |
| #tagebuch_pdf_result begrenzt, damit andere Datei-Felder in der App (z.B. |
| Pflegevertrag-Upload) unangetastet bleiben. */ |
| #tagebuch_pdf_result.block { |
| min-height: 0 !important; |
| } |
| #tagebuch_pdf_result .wrap.full { |
| min-height: 0 !important; |
| height: auto !important; |
| } |
| #tagebuch_pdf_result .empty.large { |
| min-height: 0 !important; |
| padding: 10px !important; |
| } |
| #tagebuch_pdf_result .empty.large .icon { |
| width: 24px !important; |
| height: 24px !important; |
| margin: 0 auto !important; |
| } |
| /* Haustierapotheke-Kopfbanner: das Bild ist hochformatig und detailreich - als |
| volle Höhe würde es den ganzen Bildschirm einnehmen, bevor Nutzer überhaupt |
| die eigentlichen Kategorien sehen. Auf eine kompakte Banner-Höhe zugeschnitten, |
| oberer Bildausschnitt (Logo/Schriftzug) bleibt sichtbar. */ |
| #apotheke_banner_img img { |
| max-height: 200px !important; |
| width: 100% !important; |
| object-fit: cover !important; |
| object-position: top !important; |
| border-radius: 14px !important; |
| } |
| #profil_img_komponente img { |
| padding-top: 40px !important; |
| box-sizing: border-box !important; |
| object-position: center bottom !important; |
| border-radius: 12px !important; |
| } |
| #profil_img_komponente div:has(> button), |
| #profil_img_komponente [data-testid="image"] > div:has(button) { |
| background: transparent !important; |
| border: none !important; |
| } |
| /* Name/Tierauswahl-Box in Stammdaten: keine eigene Karten-Optik, verschmilzt mit |
| dem Hintergrund. */ |
| #row_name_kategorie { |
| background: transparent !important; |
| box-shadow: none !important; |
| border: none !important; |
| } |
| #row_name_kategorie > * { |
| background: transparent !important; |
| box-shadow: none !important; |
| border: none !important; |
| } |
| /* Fußbereich (z.B. Abmelden) zentrieren statt linksbündig */ |
| .zentrierte_reihe { |
| justify-content: center !important; |
| } |
| /* Anmelden/Registrieren-Tabs auf dem Login-Bildschirm gleichmäßig auf volle Breite verteilen */ |
| #login_screen .tab-nav button, #login_screen [role="tab"] { |
| flex: 1 !important; |
| } |
| /* Verhindert die browsereigene Verzögerung/Mehrdeutigkeits-Prüfung beim Antippen |
| (Browser muss sonst kurz abwarten: "war das ein Klick oder der Beginn eines |
| Doppel-Tipps zum Reinzoomen?"). Das äußert sich als "Button reagiert erst |
| beim zweiten Tippen" bzw. "öffnet kurz und schließt sofort wieder" - vor |
| allem auf iOS/Safari. touch-action: manipulation erlaubt weiterhin normales |
| Scrollen und Pinch-to-Zoom, schaltet nur die Doppel-Tipp-Erkennung pro |
| Element ab, sodass ein Einzeltipp immer sofort und eindeutig auslöst. */ |
| .gradio-container, .gradio-container * { |
| touch-action: manipulation !important; |
| } |
| |
| /* Gradios eigene CSS-Variablen überschreiben, damit auch native Elemente |
| (Radio-Buttons, Checkboxen, Slider) das Braun statt des Standard-Orange |
| verwenden - robuster als einzelne Klassennamen zu erraten. */ |
| .gradio-container { |
| --color-accent: #6D4C41 !important; |
| --color-accent-soft: #EDE5D6 !important; |
| --button-primary-background-fill: #6D4C41 !important; |
| --button-primary-background-fill-hover: #4E342E !important; |
| --button-primary-border-color: #6D4C41 !important; |
| --button-primary-border-color-hover: #4E342E !important; |
| --checkbox-background-color-selected: #6D4C41 !important; |
| --checkbox-border-color-selected: #6D4C41 !important; |
| --checkbox-label-background-fill-selected: #6D4C41 !important; |
| --checkbox-label-border-color-selected: #6D4C41 !important; |
| --slider-color: #6D4C41 !important; |
| --block-label-text-color: #7A6E5C !important; |
| --block-title-text-color: #3B3226 !important; |
| --body-text-color-subdued: #7A6E5C !important; |
| /* Ausgewählte Radio/Checkbox-Option: heller Ton statt dunklem Braun, |
| damit die dunkle Schrift darauf lesbar bleibt */ |
| --checkbox-label-background-fill-selected: #E6C9A0 !important; |
| --checkbox-label-border-color-selected: #C9A66B !important; |
| --checkbox-label-text-color-selected: #3B3226 !important; |
| } |
| /* Checkbox/Radio komplett selbst zeichnen, statt uns auf Gradios eigene (teils |
| unterdrückte) native Darstellung zu verlassen. Reagiert direkt auf die |
| eingebaute, immer korrekte Browser-Eigenschaft ":checked" - unabhängig davon, |
| ob Gradio das native Element optisch versteckt. */ |
| .gradio-container input[type="radio"] { |
| appearance: none !important; |
| -webkit-appearance: none !important; |
| width: 18px !important; |
| height: 18px !important; |
| min-width: 18px !important; |
| border-radius: 50% !important; |
| border: 2px solid #6D4C41 !important; |
| background: #FFFCF5 !important; |
| position: relative !important; |
| opacity: 1 !important; |
| margin: 0 6px 0 0 !important; |
| flex-shrink: 0; |
| box-sizing: border-box !important; |
| } |
| .gradio-container input[type="radio"]:checked { |
| background: #6D4C41 !important; |
| box-shadow: inset 0 0 0 4px #FFFCF5 !important; |
| } |
| .gradio-container input[type="checkbox"] { |
| appearance: none !important; |
| -webkit-appearance: none !important; |
| width: 18px !important; |
| height: 18px !important; |
| min-width: 18px !important; |
| border-radius: 4px !important; |
| border: 2px solid #6D4C41 !important; |
| background: #FFFCF5 !important; |
| position: relative !important; |
| opacity: 1 !important; |
| margin: 0 6px 0 0 !important; |
| flex-shrink: 0; |
| } |
| .gradio-container input[type="checkbox"]:checked { |
| background: #6D4C41 !important; |
| } |
| .gradio-container input[type="checkbox"]:checked::after { |
| content: "✓" !important; |
| position: absolute !important; |
| top: -3px !important; |
| left: 1px !important; |
| color: #FFFCF5 !important; |
| font-size: 14px !important; |
| font-weight: bold !important; |
| } |
| /* Ausgewählte Radio-"Pillen" (z.B. Trockenfutter/Nassfutter, Parasitenschutz-Intervall, |
| Filter) deutlich sichtbar hervorheben - reagiert direkt auf den echten Checked-Zustand |
| des Eingabe-Elements (zuverlässiger als Gradios eigene Klassen, die manchmal hängen bleiben) */ |
| .gradio-container label:has(input[type="radio"]:checked), |
| .gradio-container label:has(input[type="checkbox"]:checked) { |
| background: #C9A66B !important; |
| border: 2px solid #6D4C41 !important; |
| color: #3B3226 !important; |
| font-weight: bold !important; |
| } |
| /* Nicht ausgewählte Pillen: dezent, aber klar als anklickbar erkennbar */ |
| .gradio-container label:has(input[type="radio"]):not(:has(input:checked)), |
| .gradio-container label:has(input[type="checkbox"]):not(:has(input:checked)) { |
| background: #FFFCF5 !important; |
| border: 1px solid #C9A66B !important; |
| } |
| |
| /* Etwas kleinere Schrift app-weit, damit es weniger überladen wirkt */ |
| .gradio-container { |
| font-size: 0.92em !important; |
| } |
| .gradio-container label, .gradio-container span { |
| font-size: 0.95em !important; |
| } |
| .gradio-container button { |
| font-size: 0.92em !important; |
| } |
| /* Treffergenauigkeit app-weit erhöhen (v7.88.0) - besonders auf iOS Safari kam es |
| vor, dass optisch breite Buttons trotzdem nicht bei jedem Tipp reagierten. |
| touch-action:manipulation verhindert, dass der Browser auf Doppeltipp/Zoom |
| wartet, bevor er den Tipp verarbeitet (das kostet iOS-Nutzern gefühlt oft den |
| ersten Tipp). -webkit-tap-highlight-color macht zusätzlich sichtbar, dass ein |
| Tipp überhaupt registriert wurde. min-height als Untergrenze nach Apples |
| Empfehlung (44px Mindest-Trefffläche) - spezifischere Regeln für bewusst |
| kompakte Buttons (z.B. Karten-Aktionen) greifen weiterhin, da sie später in |
| der Kaskade stehen. |
| Wichtig: KEIN pointer-events hier verändert, das würde bei Bedarf spezifischere |
| Regeln blockieren statt nur die Mindest-Treffgröße abzusichern. */ |
| .gradio-container button, .gradio-container a[role="button"] { |
| touch-action: manipulation !important; |
| -webkit-tap-highlight-color: rgba(109,76,65,0.25) !important; |
| min-height: 44px; |
| } |
| .gradio-container h1 { font-size: 1.3em !important; } |
| .gradio-container h2 { font-size: 1.15em !important; } |
| .gradio-container h3 { font-size: 1.05em !important; } |
| |
| #onboarding_backdrop { |
| position: fixed; |
| top: 0; left: 0; right: 0; bottom: 0; |
| width: 100%; height: 100%; |
| background: rgba(59,50,38,0.55); |
| z-index: 9999; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| padding: 20px; |
| } |
| #onboarding_card { |
| background: #FFFCF5; |
| border: 1px solid #6D4C41; |
| border-radius: 16px; |
| padding: 24px; |
| max-width: 480px; |
| max-height: 85vh; |
| overflow-y: auto; |
| box-shadow: 0 8px 32px rgba(59,50,38,0.25); |
| } |
| |
| /* Versionshistorie-Overlay (gleiches Muster wie Onboarding) */ |
| #versionshistorie_backdrop { |
| position: fixed; |
| top: 0; left: 0; right: 0; bottom: 0; |
| width: 100%; height: 100%; |
| background: rgba(59,50,38,0.55); |
| z-index: 9999; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| padding: 20px; |
| } |
| #versionshistorie_card { |
| background: #FFFCF5; |
| border: 1px solid #6D4C41; |
| border-radius: 16px; |
| padding: 24px; |
| max-width: 480px; |
| max-height: 85vh; |
| overflow-y: auto; |
| box-shadow: 0 8px 32px rgba(59,50,38,0.25); |
| } |
| #versionshistorie_close button { |
| min-height: 34px !important; |
| max-width: 90px; |
| padding: 4px 12px !important; |
| font-size: 1em !important; |
| margin-left: auto; |
| margin-right: auto; |
| display: block; |
| } |
| /* Footer-Versionsnummer als dezent klickbarer Text statt Button-Optik */ |
| #version_footer_btn button { |
| background: none !important; |
| border: none !important; |
| box-shadow: none !important; |
| color: #7A6E5C !important; |
| font-size: 0.8em !important; |
| text-decoration: underline; |
| min-height: unset !important; |
| padding: 4px !important; |
| margin: 0 auto !important; |
| display: block !important; |
| width: auto !important; |
| } |
| |
| /* Tabs senkrecht untereinander statt nebeneinander anzeigen - umgeht Gradios |
| JS-basierte "passt nicht in die Breite -> Overflow-Menü"-Logik komplett, |
| da bei einer Spalte kein Platzmangel in der Breite mehr entstehen kann. |
| Doppelt abgesichert: alte Gradio-Klasse .tab-nav UND die ARIA-Rolle [role="tablist"]. */ |
| .tab-nav, [role="tablist"] { |
| flex-direction: column !important; |
| flex-wrap: nowrap !important; |
| overflow: visible !important; |
| height: auto !important; |
| align-items: stretch !important; |
| gap: 4px !important; |
| } |
| .tab-nav > button, [role="tablist"] > button, [role="tablist"] [role="tab"] { |
| width: 100% !important; |
| text-align: center; |
| white-space: normal; |
| } |
| |
| /* Kompakte Zeilen für die einzelnen Futtersorten-Slots mit Mini-Buttons */ |
| .futter_slot_row { |
| gap: 6px !important; |
| align-items: center !important; |
| padding: 4px 8px !important; |
| margin-bottom: 2px !important; |
| background: #EDE5D6; |
| border-radius: 10px; |
| } |
| .futter_slot_row button { |
| min-height: 30px !important; |
| padding: 2px 6px !important; |
| } |
| .futter_slot_inline { |
| gap: 4px !important; |
| align-items: center !important; |
| padding: 6px 8px !important; |
| margin: 0 0 6px 4px !important; |
| background: #FFF3E0; |
| border-left: 2px solid #6D4C41; |
| border-radius: 0 8px 8px 0; |
| } |
| .futter_slot_inline button { |
| min-height: 34px !important; |
| } |
| |
| /* Button-Reihen, die wie echte Reiter-Tabs aussehen sollen (statt Gradios eigener |
| Tabs-Komponente, die das "⋮"-Overflow-Menü nicht zuverlässig vermeidet) */ |
| .tab_row { |
| gap: 4px !important; |
| border-bottom: 2px solid #DED2BA; |
| flex-wrap: wrap !important; |
| padding-bottom: 4px; |
| } |
| .tab_row > * { |
| flex: 1 1 calc(50% - 4px) !important; |
| min-width: 0 !important; |
| } |
| .tab_row > *.petbase_tab_stretch { |
| flex: 1 1 100% !important; |
| } |
| .tab_row button { |
| border-radius: 10px 10px 0 0 !important; |
| box-shadow: none !important; |
| border: none !important; |
| margin: 0 !important; |
| min-height: 38px !important; |
| width: 100% !important; |
| white-space: normal !important; |
| } |
| .tab_row button.primary { |
| border-bottom: 3px solid #6D4C41 !important; |
| } |
| .tab_row button.secondary { |
| background: transparent !important; |
| opacity: 0.75; |
| } |
| |
| #acc_top_row { |
| gap: 8px !important; |
| } |
| #acc_top_row button { |
| min-height: 30px !important; |
| padding: 4px 10px !important; |
| font-size: 0.85em !important; |
| } |
| |
| /* Kompakte Datei-Downloadbox (statt großer grauer Fläche) - seit v7.90.1 mit den |
| per Live-Inspektion bestätigten echten Gradio-Klassen (.wrap.full, .empty.large, |
| siehe Tagebuch-PDF-Fix) ergänzt, die alten Regeln bleiben als Sicherheitsnetz. */ |
| .kompakte_datei { |
| min-height: unset !important; |
| } |
| .kompakte_datei .wrap.full { |
| min-height: 0 !important; |
| height: auto !important; |
| } |
| .kompakte_datei .empty { |
| min-height: unset !important; |
| } |
| .kompakte_datei .empty.large { |
| min-height: 0 !important; |
| padding: 8px !important; |
| } |
| .kompakte_datei > div { |
| min-height: 40px !important; |
| padding: 6px !important; |
| } |
| .kompakte_datei svg { |
| width: 24px !important; |
| height: 24px !important; |
| } |
| .kompakte_datei .icon-wrap { |
| margin: 0 !important; |
| } |
| |
| /* ========================================== |
| MATERIAL 3 - Weichere große Rundungen, federnde Übergänge |
| ========================================== */ |
| .gradio-container button, .gradio-container .gr-button { |
| border-radius: 20px !important; |
| transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.18s ease, background-color 0.2s ease !important; |
| } |
| .gradio-container button:active { |
| transform: scale(0.96); |
| } |
| .gradio-container input, .gradio-container textarea, .gradio-container select, |
| .gradio-container .gr-box, .gradio-container .block { |
| border-radius: 16px !important; |
| transition: box-shadow 0.2s ease, border-color 0.2s ease; |
| } |
| .gradio-container .gr-group, .gradio-container .gr-form, .gradio-container .gr-panel { |
| border-radius: 20px !important; |
| } |
| /* Frosted-Glass-Karten für Popups (hell) */ |
| #onboarding_card, #versionshistorie_card { |
| background: rgba(255, 255, 255, 0.92) !important; |
| backdrop-filter: blur(18px) saturate(160%); |
| -webkit-backdrop-filter: blur(18px) saturate(160%); |
| border-radius: 28px !important; |
| border: 1px solid rgba(93, 64, 55, 0.5) !important; |
| } |
| #onboarding_backdrop, #versionshistorie_backdrop { |
| backdrop-filter: blur(4px); |
| -webkit-backdrop-filter: blur(4px); |
| } |
| /* Akkordeons: weichere Rundung statt eckiger Kästen */ |
| .gradio-container .label-wrap, .gradio-container details { |
| border-radius: 18px !important; |
| transition: background-color 0.25s ease; |
| } |
| /* Tabs: weichere, "pill"-artige aktive Markierung statt scharfer Linie */ |
| .tab-nav > button, [role="tablist"] [role="tab"] { |
| border-radius: 14px 14px 0 0 !important; |
| transition: background-color 0.2s ease, transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1); |
| } |
| |
| /* ========================================== |
| DESIGN-SYSTEM 3.0 - Hell, freundlich, an Stock-Android/Material Design angelehnt |
| Google-typische helle Oberfläche statt Dark Mode: weißer Grund, dezente |
| graue Flächen für Karten, klare Schrift, sparsamer Einsatz von Farbe. |
| ========================================== */ |
| |
| /* Seitenhintergrund: helles, freundliches Grau (wie Android-Systemhintergrund) statt Dunkel */ |
| .gradio-container { |
| background: #F2ECE0 !important; |
| color: #3B3226 !important; |
| } |
| |
| /* Alle Container-artigen Blöcke (Group, Panel, Form, Row mit Inhalt) bekommen |
| eine einheitliche "Karten"-Optik: weiße Fläche, dezenter Schatten (Material Elevation 1). */ |
| .gradio-container .gr-group, |
| .gradio-container .gr-panel, |
| .gradio-container .gr-form, |
| .gradio-container fieldset { |
| background: #FFFCF5 !important; |
| border: 1px solid #E6DAC4 !important; |
| box-shadow: 0 1px 2px rgba(59,50,38,0.10), 0 1px 3px rgba(59,50,38,0.08) !important; |
| border-radius: 18px !important; |
| padding: 14px !important; |
| } |
| |
| /* Etwas mehr Luft zwischen aufeinanderfolgenden Karten/Blöcken */ |
| .gradio-container .gr-group + .gr-group, |
| .gradio-container .gr-form + .gr-form { |
| margin-top: 10px !important; |
| } |
| |
| /* Eingabefelder: leicht getönte Fläche, damit sie sich von der weißen Karte abheben */ |
| .gradio-container input:not([type="checkbox"]):not([type="radio"]), |
| .gradio-container textarea, |
| .gradio-container select { |
| background: #EDE5D6 !important; |
| border: 1px solid #DED2BA !important; |
| color: #3B3226 !important; |
| /* iOS Safari zwingt <select>/<input> sonst eine eigene native Höhe/Optik auf |
| (leicht abweichend von <input>-Feldern), dadurch wirkte das Dropdown minimal |
| niedriger als die Textfelder daneben. -webkit-appearance:none unterbindet |
| das native Styling komplett, sodass beide Feldtypen identisch aussehen. */ |
| -webkit-appearance: none !important; |
| appearance: none !important; |
| /* Browser rendern <select>-Text sonst mit ihrer eigenen System-Schrift (meist |
| fetter/anders als <input>/<textarea>) - hier erzwungen identisch. */ |
| font-family: inherit !important; |
| font-weight: normal !important; |
| font-size: 1em !important; |
| } |
| /* appearance:none entfernt bei <select> auch den nativen Dropdown-Pfeil (auf |
| ALLEN Plattformen, nicht nur iOS) - hier durch einen eigenen SVG-Pfeil ersetzt, |
| damit Android nicht plötzlich den Pfeil verliert. */ |
| .gradio-container select { |
| background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233B3226' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'></polyline></svg>") !important; |
| background-repeat: no-repeat !important; |
| background-position: right 10px center !important; |
| background-size: 18px !important; |
| padding-right: 34px !important; |
| } |
| .gradio-container input:focus, .gradio-container textarea:focus, .gradio-container select:focus { |
| background: #FFFCF5 !important; |
| border-color: #6D4C41 !important; |
| box-shadow: 0 0 0 2px rgba(93,64,55,0.22) !important; |
| } |
| |
| /* Primäre Buttons: sanfter Verlauf statt platter Fläche, für mehr Tiefe */ |
| .gradio-container button.primary { |
| background: linear-gradient(135deg, #6D4C41, #4E342E) !important; |
| border: none !important; |
| color: #FFFCF5 !important; |
| box-shadow: 0 2px 8px rgba(93,64,55,0.30) !important; |
| } |
| .gradio-container button.primary:hover { |
| box-shadow: 0 4px 14px rgba(93,64,55,0.40) !important; |
| } |
| .gradio-container button.secondary { |
| background: #EDE5D6 !important; |
| border: 1px solid #DED2BA !important; |
| color: #3B3226 !important; |
| } |
| .gradio-container button.stop { |
| background: linear-gradient(135deg, #EF5350, #E53935) !important; |
| border: none !important; |
| color: #FFFCF5 !important; |
| } |
| |
| /* Überschriften: klarere Hierarchie, mehr Abstand nach oben zur vorigen Sektion */ |
| .gradio-container h3 { |
| color: #4E342E !important; |
| margin-top: 18px !important; |
| letter-spacing: 0.2px; |
| } |
| .gradio-container label span, |
| .gradio-container label, |
| .gradio-container .block > label span, |
| .gradio-container [data-testid="block-label"] { |
| color: #7A6E5C !important; |
| } |
| .gradio-container .gr-group h3:first-child { |
| margin-top: 0 !important; |
| } |
| |
| /* Zwei-Felder-Zeilen (z.B. Gewicht/Menge, Hersteller/Sorte) bleiben immer |
| nebeneinander statt untereinander umzubrechen - auch bei tief verschachtelten |
| Karten mit viel Innenabstand, der sonst zu wenig Platz pro Spalte lässt. */ |
| .gradio-container .form .row:not(.tab_row), |
| .gradio-container .gr-group .row:not(.tab_row), |
| .gradio-container .gr-form .row:not(.tab_row), |
| .gradio-container details .row:not(.tab_row) { |
| flex-wrap: nowrap !important; |
| align-items: flex-start !important; |
| } |
| .gradio-container .form .row:not(.tab_row) > *, |
| .gradio-container .gr-group .row:not(.tab_row) > *, |
| .gradio-container .gr-form .row:not(.tab_row) > *, |
| .gradio-container details .row:not(.tab_row) > * { |
| min-width: 0 !important; |
| overflow: hidden; |
| } |
| /* Tab-artige Button-Reihen (Stammdaten/Fütterung/.../Bereich-Tabs): 2-Spalten-Raster |
| mit Umbruch (siehe .tab_row Regel weiter oben), letztes Element bei ungerader |
| sichtbarer Anzahl über volle Breite gestreckt (per JS berechnet, siehe |
| petbaseTabRowsAusrichten - reine CSS-Lösung geht nicht zuverlässig, da einzelne |
| Tabs je nach Admin-Status/Modus dynamisch ein-/ausgeblendet werden). */ |
| /* Beschriftungs-"Pillen" (z.B. Gewicht 1, Menge 1): dürfen bei wenig Platz |
| auf eine zweite Zeile umbrechen statt in die Nachbar-Spalte zu überlappen */ |
| .gradio-container label span { |
| white-space: normal !important; |
| overflow-wrap: break-word !important; |
| word-break: break-word !important; |
| display: inline-block; |
| max-width: 100%; |
| } |
| @media (max-width: 480px) { |
| .gradio-container label span { |
| font-size: 0.85em !important; |
| padding: 3px 8px !important; |
| } |
| } |
| /* Etwas weniger Innenabstand bei verschachtelten Karten, damit mehr Platz |
| für den eigentlichen Inhalt bleibt */ |
| .gradio-container .gr-group .gr-group, |
| .gradio-container .gr-form .gr-group { |
| padding: 8px !important; |
| } |
| |
| /* Akkordeon-Symbol: klassische drei Punkte statt Pfeil/Dreieck, in allen Akkordeons. |
| Gradios Akkordeons nutzen vermutlich das native HTML <summary>-Element, dessen |
| Pfeil-Markierung eine Browser-eigene Darstellung ist (kein SVG) - deshalb hier |
| gezielt über ::marker/-webkit-details-marker ausgeblendet, nicht nur über svg. */ |
| .gradio-container summary::-webkit-details-marker { |
| display: none !important; |
| } |
| .gradio-container summary::marker { |
| content: "" !important; |
| } |
| .gradio-container summary { |
| list-style: none !important; |
| } |
| .gradio-container .label-wrap svg, |
| .gradio-container summary svg, |
| .gradio-container .label-wrap > *:last-child, |
| .gradio-container summary > *:last-child { |
| display: none !important; |
| width: 0 !important; |
| visibility: hidden !important; |
| } |
| /* Haustierapotheke: Kategorie-Karten deutlich als antippbar erkennbar machen - |
| sonst wirken sie wie normaler Fließtext statt wie ein aufklappbares Element |
| (das Standard-Pfeilsymbol wird ja app-weit ausgeblendet, siehe oben). */ |
| .apotheke_kategorie_accordion { |
| background: #FFFCF5 !important; |
| border: 1px solid #DED2BA !important; |
| border-radius: 14px !important; |
| margin-bottom: 10px !important; |
| box-shadow: 0 1px 4px rgba(59,50,38,0.12) !important; |
| } |
| .apotheke_kategorie_accordion > .label-wrap, |
| .apotheke_kategorie_accordion summary { |
| padding: 14px 16px !important; |
| } |
| .apotheke_kategorie_accordion > .label-wrap, |
| .apotheke_kategorie_accordion summary, |
| .apotheke_kategorie_accordion > .label-wrap *, |
| .apotheke_kategorie_accordion summary * { |
| font-weight: 800 !important; |
| font-size: 1.08em !important; |
| color: #6D4C41 !important; |
| } |
| .gradio-container .label-wrap::after, |
| .gradio-container summary::after { |
| content: "⋮" !important; |
| font-size: 1.3em; |
| line-height: 1; |
| color: #6D4C41; |
| margin-left: auto; |
| padding-left: 8px; |
| } |
| |
| /* Fließtext/Markdown: dunkle, gut lesbare Schrift statt heller Schrift auf hellem Grund */ |
| .gradio-container .prose, .gradio-container .prose p, .gradio-container .prose li { |
| color: #3B3226 !important; |
| } |
| """ |
|
|
| zoo_theme = gr.themes.Soft(primary_hue="orange", secondary_hue="amber") |
| CUSTOM_HEAD = """ |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,300..600,0..1,0" rel="stylesheet"> |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes"> |
| <link rel="apple-touch-icon" sizes="180x180" href="/pwa_icon/180"> |
| <meta name="apple-mobile-web-app-capable" content="yes"> |
| <meta name="apple-mobile-web-app-status-bar-style" content="default"> |
| <meta name="apple-mobile-web-app-title" content="PetBase"> |
| <script> |
| // Enter springt zum nächsten Feld (wie Tab), statt nichts zu tun oder abzuschicken. |
| // Mehrzeilige Textfelder (mehr als 1 Zeile) sind ausgenommen - dort bleibt Enter ein normaler Zeilenumbruch. |
| document.addEventListener('keydown', function(e) { |
| if (e.key !== 'Enter' || e.shiftKey) return; |
| const el = e.target; |
| if (el.tagName === 'TEXTAREA' && el.rows > 1) return; |
| if (el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA') return; |
| if (el.type === 'checkbox' || el.type === 'radio' || el.type === 'button' || el.type === 'submit') return; |
| e.preventDefault(); |
| const focusable = Array.from(document.querySelectorAll('input, textarea, select')) |
| .filter(x => x.offsetParent !== null && !x.disabled); |
| const idx = focusable.indexOf(el); |
| if (idx > -1 && idx < focusable.length - 1) { focusable[idx + 1].focus(); } |
| }); |
| |
| // Tab-Leisten (Haupt-Bereiche, Bereich-/Detail-Tabs): 2-Spalten-Raster, letztes |
| // Element bekommt volle Breite, wenn die Anzahl der SICHTBAREN (nicht aller!) |
| // Kinder ungerade ist. Reine CSS-Lösung (:nth-last-child) würde auch versteckte |
| // Tabs mitzählen, die je nach Admin-Status/Modus dynamisch ein-/ausgeblendet |
| // werden - deshalb per JS anhand der tatsächlichen Sichtbarkeit berechnet, und |
| // über einen MutationObserver + Sicherheitsnetz-Intervall aktuell gehalten. |
| function petbaseTabRowsAusrichten() { |
| document.querySelectorAll('.tab_row').forEach(function(row) { |
| const kinder = Array.from(row.children); |
| kinder.forEach(function(k) { k.classList.remove('petbase_tab_stretch'); }); |
| const sichtbare = kinder.filter(function(k) { |
| return k.offsetParent !== null; |
| }); |
| if (sichtbare.length % 2 === 1) { |
| sichtbare[sichtbare.length - 1].classList.add('petbase_tab_stretch'); |
| } |
| }); |
| } |
| const petbaseTabRowObserver = new MutationObserver(function() { petbaseTabRowsAusrichten(); }); |
| function petbaseTabRowsBeobachten() { |
| document.querySelectorAll('.tab_row').forEach(function(row) { |
| // WICHTIG: NUR 'style' beobachten, NICHT 'class'! Gradio blendet Elemente |
| // über inline-style (display:none) ein/aus - das reicht zur Erkennung. |
| // 'class' wurde vorher zusätzlich beobachtet, aber petbaseTabRowsAusrichten() |
| // selbst ändert Klassen (petbase_tab_stretch) - das hat den Beobachter bei |
| // JEDER eigenen Änderung erneut ausgelöst: Endlos-Schleife, die den Browser |
| // komplett lahmgelegt hat ("Skript verlangsamt Browser"-Warnung). |
| petbaseTabRowObserver.observe(row, { attributes: true, attributeFilter: ['style'], subtree: true }); |
| }); |
| } |
| petbaseTabRowsBeobachten(); |
| petbaseTabRowsAusrichten(); |
| // Sicherheitsnetz: neue .tab_row-Elemente (z.B. nach Login) werden so auch ohne |
| // erneuten Observer-Aufruf erfasst, und verpasste Änderungen holen sich nach. |
| setInterval(function() { petbaseTabRowsBeobachten(); petbaseTabRowsAusrichten(); }, 1000); |
| |
| // Erneuter Klick auf ein Vollbild-Bild (z.B. maximiertes Profilbild) schließt es automatisch, |
| // da der eigentliche Schließen-Button auf kleinen Bildschirmen oft außerhalb des sichtbaren |
| // Bereichs liegt und nicht erreichbar ist. Simuliert dazu die Escape-Taste (sicherer als das |
| // Klicken eines Buttons zu versuchen, da dabei versehentlich der falsche - z.B. "Bild löschen" |
| // statt "Vollbild schließen" - Button getroffen werden könnte). |
| document.addEventListener('click', function(e) { |
| const fsContainer = e.target.closest('[class*="fullscreen"]'); |
| if (!fsContainer || e.target.tagName !== 'IMG') return; |
| document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true })); |
| }); |
| // Native Zurück-Geste (iOS-Wischen vom Rand / Android-Zurück-Geste bzw. -Taste) |
| // unterstützen: springt zum vorherigen Tab zurück, statt die Seite/App zu verlassen. |
| // Jeder Tab-Wechsel (Haupt-, Bereichs-, Kategorie- und Konto-Tabs) wird dazu im |
| // Browser-Verlauf vermerkt. |
| // Zurück-Button: springt zum vorherigen Tab zurück (Subtab -> Haupttab -> Startseite). |
| // Nutzt einen eigenen Navigations-Speicher statt der Browser-History-API, da diese sich |
| // in eingebetteten/iframe-Kontexten (z.B. Hugging-Face-Wrapper-Seite) unzuverlässig |
| // verhalten kann. Position vertikal mittig, damit der Button immer sichtbar ist, egal |
| // wie weit man auf einer langen Seite gescrollt hat. |
| (function() { |
| const navAuswahl = '[id^="b_haupt_"], [id^="b_bereich_"], [id^="b_show_"], [id^="b_konto_tab_"]'; |
| let aktuellerTab = null; |
| const verlauf = []; |
| const zurueckBtn = document.createElement('button'); |
| zurueckBtn.textContent = '◀'; |
| zurueckBtn.setAttribute('aria-label', 'Zurück'); |
| zurueckBtn.style.setProperty('position', 'fixed', 'important'); |
| zurueckBtn.style.setProperty('z-index', '2147483647', 'important'); |
| zurueckBtn.style.setProperty('width', '48px', 'important'); |
| zurueckBtn.style.setProperty('height', '48px', 'important'); |
| zurueckBtn.style.setProperty('border-radius', '50%', 'important'); |
| zurueckBtn.style.setProperty('background', '#6D4C41', 'important'); |
| zurueckBtn.style.setProperty('color', '#FFFCF5', 'important'); |
| zurueckBtn.style.setProperty('border', 'none', 'important'); |
| zurueckBtn.style.setProperty('font-size', '20px', 'important'); |
| zurueckBtn.style.setProperty('box-shadow', '0 2px 8px rgba(0,0,0,0.4)', 'important'); |
| zurueckBtn.style.setProperty('display', 'none', 'important'); |
| zurueckBtn.style.setProperty('align-items', 'center', 'important'); |
| zurueckBtn.style.setProperty('justify-content', 'center', 'important'); |
| zurueckBtn.style.setProperty('touch-action', 'none', 'important'); |
| zurueckBtn.style.setProperty('cursor', 'grab', 'important'); |
| |
| // Gespeicherte Position laden - bleibt erhalten, bis der Nutzer den Button |
| // erneut selbst verschiebt (auch über Neustarts der App hinweg, pro Gerät/Browser). |
| function ladePosition() { |
| try { |
| const g = JSON.parse(localStorage.getItem('petbase_zurueck_position')); |
| if (g && typeof g.top === 'number' && typeof g.left === 'number') return g; |
| } catch (e) {} |
| return { top: window.innerHeight / 2 - 24, left: 12 }; |
| } |
| function setzePosition(top, left) { |
| top = Math.max(4, Math.min(top, window.innerHeight - 52)); |
| left = Math.max(4, Math.min(left, window.innerWidth - 52)); |
| zurueckBtn.style.setProperty('top', top + 'px', 'important'); |
| zurueckBtn.style.setProperty('left', left + 'px', 'important'); |
| return { top, left }; |
| } |
| let position = setzePosition(ladePosition().top, ladePosition().left); |
| |
| function einhaengen() { if (document.body && !document.body.contains(zurueckBtn)) document.body.appendChild(zurueckBtn); } |
| einhaengen(); |
| document.addEventListener('DOMContentLoaded', einhaengen); |
| // Selbstheilung: falls der Button durch eine interne Aktualisierung der Seite aus |
| // dem DOM entfernt wird, sofort automatisch wieder einfügen (statt erst beim |
| // nächsten Tab-Klick, was zu einem zeitweisen "Verschwinden" führte). |
| if (window.MutationObserver && document.body) { |
| new MutationObserver(function() { einhaengen(); }).observe(document.body, { childList: true }); |
| } |
| setInterval(einhaengen, 250); // häufige Prüfung, da größere Tab-Wechsel (z.B. Haupttabs) länger dauern können |
| |
| // Button frei ziehbar machen (Maus für Desktop-Tests, Touch für Handy) |
| let ziehStart = null, wurdeGezogen = false; |
| function ziehBeginn(x, y) { |
| ziehStart = { x, y, top: parseFloat(zurueckBtn.style.top), left: parseFloat(zurueckBtn.style.left) }; |
| wurdeGezogen = false; |
| zurueckBtn.style.setProperty('cursor', 'grabbing', 'important'); |
| } |
| function ziehBewegung(x, y) { |
| if (!ziehStart) return; |
| const dx = x - ziehStart.x, dy = y - ziehStart.y; |
| if (Math.abs(dx) > 6 || Math.abs(dy) > 6) wurdeGezogen = true; |
| if (wurdeGezogen) position = setzePosition(ziehStart.top + dy, ziehStart.left + dx); |
| } |
| function ziehEnde() { |
| if (ziehStart && wurdeGezogen) { try { localStorage.setItem('petbase_zurueck_position', JSON.stringify(position)); } catch (e) {} } |
| ziehStart = null; |
| zurueckBtn.style.setProperty('cursor', 'grab', 'important'); |
| } |
| zurueckBtn.addEventListener('mousedown', function(e) { ziehBeginn(e.clientX, e.clientY); }); |
| document.addEventListener('mousemove', function(e) { ziehBewegung(e.clientX, e.clientY); }); |
| document.addEventListener('mouseup', ziehEnde); |
| zurueckBtn.addEventListener('touchstart', function(e) { const t = e.touches[0]; ziehBeginn(t.clientX, t.clientY); }, { passive: true }); |
| zurueckBtn.addEventListener('touchmove', function(e) { const t = e.touches[0]; ziehBewegung(t.clientX, t.clientY); }, { passive: true }); |
| zurueckBtn.addEventListener('touchend', ziehEnde); |
| |
| // Navigation: springt zum Tab VOR dem aktuell aktiven zurück (nicht zum aktuellen |
| // selbst - das war der Grund, warum vorher keine sichtbare Aktion passierte, da ein |
| // erneuter Klick auf den bereits aktiven Tab wirkungslos ist). |
| zurueckBtn.addEventListener('click', function(ev) { |
| if (wurdeGezogen) { wurdeGezogen = false; return; } |
| ev.preventDefault(); ev.stopPropagation(); |
| const zielId = verlauf.pop(); |
| if (verlauf.length === 0) zurueckBtn.style.setProperty('display', 'none', 'important'); |
| if (!zielId) return; |
| aktuellerTab = (zielId === '__START__') ? null : zielId; |
| const zielBtn = (zielId === '__START__') ? document.getElementById('b_haupt_verwaltung') : document.getElementById(zielId); |
| if (zielBtn) zielBtn.click(); |
| }); |
| document.addEventListener('click', function(e) { |
| if (e.target === zurueckBtn) return; |
| const btn = e.target.closest(navAuswahl); |
| if (!btn || !btn.id || btn.id === aktuellerTab) return; |
| if (aktuellerTab) verlauf.push(aktuellerTab); |
| else verlauf.push('__START__'); // garantiert, dass genug Zurück-Klicks immer bei der echten Startseite ankommen |
| aktuellerTab = btn.id; |
| einhaengen(); |
| if (verlauf.length > 0) zurueckBtn.style.setProperty('display', 'flex', 'important'); |
| }); |
| |
| // Banner als direkter "Zurück zur Startseite"-Knopf: springt sofort zur Startseite |
| // und setzt dabei auch den Zurück-Verlauf zurück, da man ja bewusst ganz von vorne beginnt. |
| function bannerEinrichten() { |
| const banner = document.getElementById('app_banner'); |
| if (!banner || banner.dataset.petbaseKlickbar) return; |
| banner.dataset.petbaseKlickbar = '1'; |
| banner.style.setProperty('cursor', 'pointer', 'important'); |
| banner.addEventListener('click', function() { |
| verlauf.length = 0; |
| aktuellerTab = null; |
| zurueckBtn.style.setProperty('display', 'none', 'important'); |
| const heim = document.getElementById('b_haupt_verwaltung'); |
| if (heim) heim.click(); |
| }); |
| } |
| bannerEinrichten(); |
| document.addEventListener('DOMContentLoaded', bannerEinrichten); |
| setInterval(bannerEinrichten, 1000); |
| })(); |
| </script> |
| """ |
| with gr.Blocks(title="PetBase", theme=zoo_theme, css=APP_CSS, head=CUSTOM_HEAD) as demo: |
| gr.Image("banner.jpg", show_label=False, interactive=False, container=False, elem_id="app_banner", show_download_button=False, show_fullscreen_button=False, show_share_button=False) |
| current_zoo = gr.BrowserState("") |
| auto_login_pref = gr.BrowserState("ja") |
| hidden_img_data = gr.Textbox(visible=False) |
| news_display = gr.Markdown(visible=True) |
|
|
| with gr.Column(visible=False) as onboarding_overlay: |
| with gr.Column(elem_id="onboarding_backdrop"): |
| with gr.Column(elem_id="onboarding_card"): |
| |
| |
| |
| |
| onboarding_content = gr.Markdown( |
| "## 🐾 Willkommen bei PetBase!\n" |
| "Kurzer Überblick, bevor es losgeht:\n\n" |
| "**🐾 Tiere** – Stammdaten, Fütterung (Trocken-/Nass-/BARF), Gesundheit & Dokumente pro Tier anlegen. Im Erweiterten Modus zusätzlich ein **📔 Tagebuch** für Beobachtungen mit Foto, als PDF exportierbar.\n\n" |
| "**🥫 Futter-Zentrale** – Foto vom Futtersack machen, App liest Barcode, Sorte & Dosierung automatisch aus.\n\n" |
| "**📁 Archiv** – Tiere archivieren (vermittelt/verstorben) und komplette PDF-Akten exportieren.\n\n" |
| "**📊 Futter Vorrat** – Behält den Überblick, wie lange dein Futtervorrat noch reicht, inkl. Preisvergleichs-Links wenn's knapp wird.\n\n" |
| "**💊 Haustierapotheke** – Durchstöberbare Sammlung tiermedizinischer Hausmittel-Empfehlungen, nach Tierart geordnet, mit Suche.\n\n" |
| "**📄 PDF-Export / 📎 Vertrag nachtragen** – Tier-Akten exportieren bzw. Pflegeverträge nachträglich hochladen.\n\n" |
| "**👤 Konto** – Erweiterter Modus für Pflege-/Gasttiere, Erinnerungen, Feedback & Einstellungen.\n\n" |
| "Du findest diese Übersicht nicht wieder – aber du wächst schnell rein. Bei Fragen hilft das 🐞 Feedback-Formular unter 'Konto'." |
| ) |
| onboarding_close_btn = gr.Button("Los geht's!", variant="primary", elem_id="b_onboarding_close") |
|
|
| with gr.Column(visible=True, elem_id="login_screen") as login_screen: |
| gr.Markdown("## Willkommen!\nBitte logge dich ein oder erstelle ein kostenloses Test-Konto.") |
| with gr.Row(visible=False) as quick_row: |
| quick_btn = gr.Button("Weiter", variant="primary", elem_id="b_quick_login") |
| pref_checkbox = gr.Checkbox(label="🔁 Zukünftig automatisch einloggen (ohne Passwort)", value=True) |
| |
| |
| with gr.Row(elem_classes="tab_row"): |
| btn_login_tab = gr.Button("Anmelden", variant="primary", elem_id="b_login_tab") |
| btn_reg_tab = gr.Button("Registrieren", variant="secondary", elem_id="b_reg_tab") |
| with gr.Column(visible=True) as login_tab_box: |
| login_name = gr.Textbox(label="Zoo-Name") |
| login_pw = gr.Textbox(label="Passwort", type="password") |
| login_btn = gr.Button("Einloggen", variant="primary", elem_id="b_login_submit") |
| login_status = gr.Textbox(label="Status", visible=False) |
| with gr.Column(visible=False) as reg_tab_box: |
| reg_name = gr.Textbox(label="Gewünschter Zoo-Name") |
| reg_pw = gr.Textbox(label="Passwort wählen", type="password") |
| reg_email = gr.Textbox(label="E-Mail-Adresse (Optional)") |
| reg_btn = gr.Button("Konto erstellen", variant="primary", elem_id="b_reg_submit") |
| reg_status = gr.Textbox(label="Status", visible=False) |
| btn_show_impressum = gr.Button("Impressum", variant="secondary", size="sm", elem_id="b_impressum") |
| with gr.Column(visible=False) as impressum_box: |
| |
| |
| |
| |
| |
| |
| |
| gr.Markdown("""### Impressum |
| **Angaben gemäß § 5 TMG** *(Platzhalter - noch zu vervollständigen)* |
| |
| **Name:** [Vor- und Nachname] |
| **Anschrift:** [Straße Hausnummer, PLZ Ort] |
| **Kontakt:** [E-Mail-Adresse] · [Telefonnummer, optional] |
| |
| **Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV:** [Name wie oben] |
| |
| *Hinweis: Diese Angaben sind noch nicht vollständig und müssen vor dem produktiven Einsatz - insbesondere vor Einführung einer kostenpflichtigen Version - ausgefüllt werden.* |
| """) |
|
|
| with gr.Column(visible=False) as main_app: |
| acc_status_md = gr.Markdown("<span class='msr'>account_circle</span> Eingeloggt") |
| with gr.Row(elem_classes="tab_row"): |
| btn_haupt_start = gr.Button("Start", variant="primary", elem_id="b_haupt_start") |
| btn_haupt_verwaltung = gr.Button("Verwaltung", variant="secondary", elem_id="b_haupt_verwaltung", visible=False) |
| btn_haupt_vermisst = gr.Button("Vermisst melden", variant="secondary", elem_id="b_haupt_vermisst") |
| btn_haupt_konto = gr.Button("Konto", variant="secondary", elem_id="b_haupt_konto", visible=False) |
| btn_haupt_futtervorrat = gr.Button("Haustierapotheke", variant="secondary", elem_id="b_haupt_futtervorrat") |
| with gr.Column(visible=True, elem_id="c_haupt_start") as haupt_start: |
| with gr.Row(elem_id="start_quickrow"): |
| btn_start_vermisst = gr.Button("Tier vermisst", elem_id="b_start_vermisst") |
| btn_start_konto = gr.Button("Haustierapotheke", elem_id="b_start_konto") |
| gr.Markdown("### Auf einen Blick", elem_classes="start_section_label") |
| start_bestand_html = gr.HTML(value="") |
| start_anstehend_html = gr.HTML(value="") |
| with gr.Column(visible=False, elem_id="c_haupt_verwaltung") as haupt_verwaltung: |
| |
| |
| |
| |
| |
| |
| with gr.Row(elem_classes="tab_row", visible=False): |
| btn_bereich_tiere = gr.Button("Tiere", variant="primary", elem_id="b_bereich_tiere") |
| btn_bereich_scanner = gr.Button("Futter-Zentrale", variant="secondary", elem_id="b_bereich_scanner") |
| btn_bereich_archiv = gr.Button("Archiv", variant="secondary", elem_id="b_bereich_archiv", visible=False) |
| btn_bereich_inventar = gr.Button("Futter Vorrat", variant="secondary", elem_id="b_bereich_inventar") |
| btn_bereich_nachtrag = gr.Button("Vertrag nachtragen", variant="secondary", elem_id="b_bereich_nachtrag", visible=False) |
| btn_bereich_apotheke = gr.Button("Haustierapotheke", variant="secondary", elem_id="b_bereich_apotheke") |
| btn_bereich_pdf = gr.Button("PDF-Export", variant="secondary", elem_id="b_bereich_pdf", visible=False) |
| btn_bereich_tagebuch = gr.Button("Tagebuch-Übersicht", variant="secondary", elem_id="b_bereich_tagebuch") |
| with gr.Column(visible=True, elem_id="c_sektion_tiere") as sektion_tiere: |
| |
| |
| |
| |
| |
| |
| tier_dropdown = gr.Dropdown(label="Tier wählen", choices=["Hier Auswählen", "Neues Tier..."], scale=2, visible=False) |
| with gr.Accordion("⚙️ Filter", open=False, visible=False) as filter_accordion: |
| tier_filter = gr.Radio(choices=["🐾 Alle", "🏠 Eigene Tiere", "🧳 Pflege & Gäste"], value="🐾 Alle", label="Filter", interactive=True, elem_id="tier_filter_radio") |
| anstehend_karte_html = gr.HTML(value="", elem_id="anstehend_karte_anchor") |
| bestand_karte_html = gr.HTML(value="", elem_id="bestand_karte_anchor") |
| with gr.Group(visible=False) as tier_inhalt_wrapper: |
| with gr.Row(elem_classes="tab_row", visible=False): |
| btn_show_stam = gr.Button("Stammdaten", variant="primary", elem_id="b_show_stam") |
| btn_show_futter = gr.Button("Fütterung", variant="secondary", elem_id="b_show_futter") |
| btn_show_gesund = gr.Button("Gesundheit", variant="secondary", elem_id="b_show_gesund") |
| btn_show_doku = gr.Button("Dokumente", variant="secondary", elem_id="b_show_doku") |
| btn_show_tagebuch = gr.Button("Tagebuch", variant="secondary", elem_id="b_show_tagebuch", visible=False) |
| with gr.Column(visible=True, elem_id="c_acc_stam") as acc_stam: |
| with gr.Row(elem_id="row_name_kategorie"): |
| with gr.Column(scale=2): |
| name_f = gr.Textbox(label="Name") |
| kat_f = gr.Dropdown(label="Tierauswahl", choices=["Eigenes Tier", "Pflegetier", "Urlaubsgast"], value="Eigenes Tier") |
| profil_img = gr.Image(type="filepath", label="Profilbild", scale=1, elem_id="profil_img_komponente", show_download_button=False, show_fullscreen_button=False, show_share_button=False) |
| btn_tagebuch_oeffnen = gr.Button("Tagebuch / Beobachtungen", variant="secondary", visible=False, elem_id="b_show_tagebuch_open") |
| with gr.Row(visible=False) as row_pflege_upload: |
| pflege_vertrag_neu = gr.File(label="📄 Pflegevertrag direkt hochladen (PDF oder Bild)", file_types=["image", ".pdf"], elem_id="pflege_vertrag_upload") |
| with gr.Row(elem_classes="zweispaltig"): tierart_f, rasse_f = gr.Dropdown(label="Tierart", choices=["Hund", "Hündin", "Katze", "Kater", "Pferd", "Pony", "Kaninchen", "Meerschweinchen", "Hamster", "Vogel", "Reptil - Schlange/Gecko", "Reptil - Schildkröte/Leguan", "Fisch/Aquarium", "Sonstiges"], value=None, allow_custom_value=True), gr.Textbox(label="Rasse") |
| with gr.Row(elem_classes="zweispaltig"): geb_datum_f, farbe_f = gr.Textbox(label="Geburtsdatum"), gr.Textbox(label="Farbe") |
| with gr.Row(elem_classes="zweispaltig"): kastriert_f, gew_f = gr.Dropdown(label="Kastriert", choices=["Ja", "Nein", "Unbekannt"], allow_custom_value=True), gr.Number(label="Gewicht (kg)") |
| with gr.Row(elem_classes="zweispaltig"): chip_f, tasso_f = gr.Textbox(label="Chip- / Ringnummer"), gr.Textbox(label="Registrierung (Tasso / Equidenpass etc.)") |
| kontakt_tel_f = gr.Textbox(label="📞 Kontakttelefon (für Vermisst-Steckbrief)") |
| kontakt_tel2_f = gr.Textbox(label="📞 Kontakttelefon 2 (Backup, z.B. Familie)") |
| with gr.Column(visible=False, elem_id="c_acc_tagebuch") as acc_tagebuch: |
| gr.Markdown("### 📔 Tagebuch / Beobachtungen") |
| with gr.Column(visible=True) as tagebuch_neu_eintrag_box: |
| gr.Markdown("**Neuer Eintrag**") |
| tagebuch_titel_f = gr.Textbox(label="Titel / Überschrift", placeholder="z.B. Ankunft, Tierarztbesuch, Verhaltensänderung ...") |
| tagebuch_neu_f = gr.Textbox(label="Beobachtung", lines=3, placeholder="z.B. angekommen, gebadet, geschoren, auffälliges Verhalten, körperliche Veränderung ...") |
| tagebuch_foto_f = gr.Image(type="filepath", label="📷 Foto zum Eintrag (optional)", elem_id="tagebuch_foto_upload", show_download_button=False, show_fullscreen_button=False, show_share_button=False) |
| btn_tagebuch_speichern = gr.Button("Eintrag speichern", variant="primary", elem_id="b_tagebuch_speichern") |
| btn_tagebuch_pdf = gr.Button("Als PDF exportieren", variant="secondary", elem_id="b_tagebuch_pdf") |
| tagebuch_status = gr.Textbox(label="Status", visible=False) |
| tagebuch_pdf_datei = gr.File(label="Tagebuch-PDF", height=80, elem_id="tagebuch_pdf_result") |
| gr.Markdown("---") |
| gr.Markdown("**Bisherige Einträge** (neueste zuerst)") |
| |
| |
| |
| tagebuch_anzahl_state = gr.State(8) |
| |
| |
| tagebuch_refresh_zaehler = gr.State(0) |
| |
| |
| tagebuch_editier_zeile_state = gr.State(None) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| @gr.render(inputs=[current_zoo, name_f, tagebuch_anzahl_state, tagebuch_refresh_zaehler, tagebuch_editier_zeile_state]) |
| def render_tagebuch_karten(zoo_name, tier_name, anzahl_anzeigen, _refresh, editier_zeile): |
| if not zoo_name or not tier_name or tier_name in ("Hier Auswählen", "Neues Tier..."): |
| gr.Markdown("<i>Bitte zuerst ein Tier auswählen.</i>") |
| return |
| eintraege = _tagebuch_eintraege(zoo_name, tier_name) |
| if not eintraege: |
| gr.Markdown("<div style='color:#7A6E5C;'><i>Noch keine Einträge vorhanden.</i></div>") |
| return |
| neueste_zuerst = list(reversed(eintraege)) |
| sichtbare = neueste_zuerst[:anzahl_anzeigen] |
| rest = len(neueste_zuerst) - len(sichtbare) |
| for e in sichtbare: |
| with gr.Group(): |
| if editier_zeile == e["zeile"]: |
| |
| gr.Markdown(f"*{e['datum']}* – ✏️ **wird bearbeitet**") |
| edit_titel = gr.Textbox(label="Titel / Überschrift", value=e["titel"]) |
| edit_text = gr.Textbox(label="Beobachtung", value=e["text"], lines=3) |
| bestehendes_foto = e["foto"].split(",")[0].strip() if e["foto"] else None |
| edit_foto = gr.Image(type="filepath", label="📷 Foto (nur bei Änderung neu hochladen)", value=bestehendes_foto, elem_id="tagebuch_foto_edit", show_download_button=False, show_fullscreen_button=False, show_share_button=False) |
| with gr.Row(): |
| save_btn = gr.Button("Speichern", variant="primary", size="sm", elem_classes="btn-ic-save") |
| cancel_btn = gr.Button("Abbrechen", size="sm", elem_classes="btn-ic-cancel") |
|
|
| def _inline_speichern(titel_val, text_val, foto_val, zn=zoo_name, tn=tier_name, zeile=e["zeile"], altes_foto=e["foto"], rz=_refresh): |
| if not text_val or not text_val.strip(): |
| return "❌ Bitte einen Text eintragen.", zeile, rz |
| try: |
| bestehende_zeile = ws_tagebuch.row_values(zeile) |
| datum = bestehende_zeile[2] if len(bestehende_zeile) > 2 else datetime.now().strftime("%d.%m.%Y") |
| foto_url = upload_image_to_cloud(foto_val) if foto_val else altes_foto |
| neue_zeile = [zn, tn, datum, text_val.strip(), foto_url, str(titel_val).strip() if titel_val else ""] |
| ws_tagebuch.update(range_name=f"A{zeile}:F{zeile}", values=[neue_zeile]) |
| invalidate_cache() |
| return "✅ Eintrag aktualisiert.", None, (rz or 0) + 1 |
| except Exception as ex: |
| return f"❌ Fehler beim Speichern: {str(ex)[:80]}", zeile, rz |
|
|
| save_btn.click(_inline_speichern, inputs=[edit_titel, edit_text, edit_foto], outputs=[tagebuch_status, tagebuch_editier_zeile_state, tagebuch_refresh_zaehler], show_progress="hidden").then(zeige_als_toast, inputs=tagebuch_status, outputs=None) |
| cancel_btn.click(lambda: None, inputs=None, outputs=tagebuch_editier_zeile_state, show_progress="hidden") |
| else: |
| |
| inhalt = f"*{e['datum']}*" |
| if e["titel"]: |
| inhalt += f" \n**{e['titel']}**" |
| inhalt += f" \n{e['text']}" |
| gr.Markdown(inhalt) |
| if e["foto"] and e["foto"].startswith("http"): |
| gr.Image(value=e["foto"].split(",")[0].strip(), show_label=False, container=False, height=180, interactive=False, show_download_button=False, show_fullscreen_button=False, show_share_button=False) |
| edit_btn = gr.Button("Bearbeiten", size="sm", elem_classes="btn-ic-edit") |
| edit_btn.click(lambda zeile=e["zeile"]: zeile, inputs=None, outputs=tagebuch_editier_zeile_state, show_progress="hidden") |
| if rest > 0: |
| mehr_btn = gr.Button(f"Weitere Einträge laden ({rest} älter)", size="sm", elem_classes="btn-ic-more") |
| mehr_btn.click(lambda a=anzahl_anzeigen: a + 10, inputs=None, outputs=tagebuch_anzahl_state, show_progress="hidden") |
| with gr.Column(visible=False, elem_id="c_acc_futter") as acc_futter: |
| with gr.Group(): |
| barf_cb = gr.Checkbox(label="BARF / Frisch- & Rohfütterung aktiv") |
| with gr.Group(visible=True) as group_trocken: |
| with gr.Row(): trock_f, tf_phase_hinweis = gr.Dropdown(label="Trockenfutter Sorte", choices=["Hier Auswählen"], value="Hier Auswählen", allow_custom_value=True, scale=3), gr.Textbox(label="Hinweis", interactive=False, scale=1) |
| tf_gramm_f = gr.Number(label="Gramm pro Tag") |
| with gr.Group(visible=False) as group_barf: |
| typ_f, gramm_f = gr.Dropdown(label="BARF Typ", choices=["Hier Auswählen"], value="Hier Auswählen", allow_custom_value=True), gr.Number(label="Gramm pro Tag") |
| prot_f, kohl_f = gr.Number(label="Protein (g)"), gr.Number(label="Kohlenhydrate (g)") |
| nass_cb = gr.Checkbox(label="🥫 Zusätzlich Nassfutter füttern", visible=False) |
| with gr.Group(visible=False) as group_nass: |
| nass_f = gr.Dropdown(label="Nassfutter Sorte", choices=["Hier Auswählen"], value="Hier Auswählen", allow_custom_value=True) |
| nass_gramm_f = gr.Number(label="Gramm pro Tag (Nassfutter)") |
| with gr.Column(visible=False, elem_id="c_acc_gesund") as acc_gesund: |
| with gr.Group(): |
| allergien_f = gr.Textbox(label="⚠️ Allergien / Unverträglichkeiten", placeholder="z.B. Nickel-Allergie, Getreide-Unverträglichkeit ...", lines=2) |
| with gr.Group(): |
| impf_f = gr.Textbox(label="Letzte Impfung / Check-up") |
| with gr.Row(): |
| btn_impf = gr.Button("Heute", size="sm") |
| btn_ics_impf = gr.Button("Erinnerung", size="sm", elem_classes="btn-ic-reminder") |
| btn_email_impf = gr.Button("E-Mail", size="sm", elem_classes="btn-ic-email") |
| dl_impf = gr.File(visible=False, elem_classes="kompakte_datei") |
| email_impf_status = gr.Textbox(label="Status", visible=False) |
| with gr.Group(): |
| wurm_f = gr.Textbox(label="Letzte Wurmkur / Kotprobe") |
| with gr.Row(): |
| btn_wurm = gr.Button("Heute", size="sm") |
| btn_ics_wurm = gr.Button("Erinnerung", size="sm", elem_classes="btn-ic-reminder") |
| btn_email_wurm = gr.Button("E-Mail", size="sm", elem_classes="btn-ic-email") |
| dl_wurm = gr.File(visible=False, elem_classes="kompakte_datei") |
| email_wurm_status = gr.Textbox(label="Status", visible=False) |
| with gr.Group(): |
| para_f = gr.Textbox(label="Parasitenschutz / Spezialpflege (z.B. Klauen, Häutung)") |
| para_int_f = gr.Radio(choices=["3 Monate", "1 Jahr"], value="3 Monate", label="Intervall") |
| with gr.Row(): |
| btn_para = gr.Button("Heute", size="sm") |
| btn_ics_para = gr.Button("Erinnerung", size="sm", elem_classes="btn-ic-reminder") |
| btn_email_para = gr.Button("E-Mail", size="sm", elem_classes="btn-ic-email") |
| dl_para = gr.File(visible=False, elem_classes="kompakte_datei") |
| email_para_status = gr.Textbox(label="Status", visible=False) |
| with gr.Column(visible=False, elem_id="c_doc_accordion") as doc_accordion: |
| doc_titel_md = gr.Markdown("### 📂 Dokumente & Verträge (PDF)") |
| gr.Markdown("Wähle im Dropdown aus, welches Dokument oder welchen Vertrag du hinzufügen oder anzeigen möchtest:") |
| doc_type_select = gr.Dropdown( |
| label="Dokument oder Vertrag zum Hinzufügen / Bearbeiten auswählen", |
| choices=["Bitte wählen...", "Pflegevertrag", "Vermittlungsvertrag", "Tierarzt Dokumente (1)", "Tierarzt Dokumente (2)", "Tierarzt Dokumente (3)", "Tierarzt Dokumente (4)", "Tierarzt Dokumente (5)", "Tierarzt Dokumente (6)", "Alle Dokumente anzeigen"], |
| value="Bitte wählen..." |
| ) |
| with gr.Row(): |
| with gr.Column(visible=False) as col_vp: vp_img = gr.File(label="Pflegevertrag", file_types=["image", ".pdf"], elem_classes="kompakte_datei"); vp_h = gr.Textbox(visible=False) |
| with gr.Column(visible=False) as col_vv: vv_img = gr.File(label="Vermittlungsvertrag", file_types=["image", ".pdf"], elem_classes="kompakte_datei"); vv_h = gr.Textbox(visible=False) |
| with gr.Row(): |
| with gr.Column(visible=False) as col_t1: t1_img = gr.File(label="Tierarzt Dokument", file_types=["image", ".pdf"], elem_classes="kompakte_datei"); t1_h = gr.Textbox(visible=False) |
| with gr.Column(visible=False) as col_t2: t2_img = gr.File(label="Tierarzt Dokument", file_types=["image", ".pdf"], elem_classes="kompakte_datei"); t2_h = gr.Textbox(visible=False) |
| with gr.Row(): |
| with gr.Column(visible=False) as col_t3: t3_img = gr.File(label="Tierarzt Dokument", file_types=["image", ".pdf"], elem_classes="kompakte_datei"); t3_h = gr.Textbox(visible=False) |
| with gr.Column(visible=False) as col_t4: t4_img = gr.File(label="Tierarzt Dokument", file_types=["image", ".pdf"], elem_classes="kompakte_datei"); t4_h = gr.Textbox(visible=False) |
| with gr.Row(): |
| with gr.Column(visible=False) as col_t5: t5_img = gr.File(label="Tierarzt Dokument", file_types=["image", ".pdf"], elem_classes="kompakte_datei"); t5_h = gr.Textbox(visible=False) |
| with gr.Column(visible=False) as col_t6: t6_img = gr.File(label="Tierarzt Dokument", file_types=["image", ".pdf"], elem_classes="kompakte_datei"); t6_h = gr.Textbox(visible=False) |
| save_btn = gr.Button("Profil Speichern", variant="primary", size="lg", elem_id="b_profil_speichern") |
| status = gr.Textbox(label="Status", visible=False) |
|
|
| with gr.Column(visible=False, elem_id="c_sektion_scanner") as sektion_scanner: |
| gr.Markdown("### 🥫 Neues Futter hinzufügen") |
| btn_scan_toggle = gr.Button("Per Foto scannen (optional – füllt die Felder unten automatisch aus)", variant="secondary", elem_id="b_scan_toggle") |
| scan_bild_offen = gr.State(False) |
| with gr.Group(visible=False, elem_id="grp_scan_bild") as scan_bild_gruppe: |
| with gr.Row(): |
| futter_bild_1 = gr.Image(sources=["webcam", "upload"], type="numpy", label="Bild 1", elem_id="futter_bild_upload_1", show_download_button=False, show_fullscreen_button=False, show_share_button=False) |
| futter_bild_2 = gr.Image(sources=["webcam", "upload"], type="numpy", label="Bild 2", elem_id="futter_bild_upload_2", show_download_button=False, show_fullscreen_button=False, show_share_button=False) |
| futter_bild_3 = gr.Image(sources=["webcam", "upload"], type="numpy", label="Bild 3", elem_id="futter_bild_upload_3", show_download_button=False, show_fullscreen_button=False, show_share_button=False) |
| btn_scan = gr.Button("Bilder auswerten", variant="primary", elem_id="b_scan_auswerten") |
| with gr.Row(): scan_bc, scan_hersteller, scan_sorte = gr.Textbox(label="Barcode"), gr.Textbox(label="Hersteller"), gr.Textbox(label="Sorte") |
| with gr.Row(): scan_phase, scan_gewicht, scan_tierart = gr.Textbox(label="Lebensphase"), gr.Textbox(label="Füllgewicht"), gr.Dropdown(label="Kategorie", choices=["Hundefutter", "Katzenfutter", "Nagerfutter", "Vogelfutter", "Pferdefutter", "Reptilienfutter", "Aquaristik", "Sonstiges"], value="Hundefutter", allow_custom_value=True) |
| scan_futtertyp = gr.Radio(label="Fütterungsart", choices=["Trockenfutter", "Nassfutter"], value="Trockenfutter") |
| with gr.Row(): scan_k1, scan_g1 = gr.Textbox(label="Gewicht 1"), gr.Textbox(label="Menge 1") |
| with gr.Row(): scan_k2, scan_g2 = gr.Textbox(label="Gewicht 2"), gr.Textbox(label="Menge 2") |
| with gr.Row(): scan_k3, scan_g3 = gr.Textbox(label="Gewicht 3"), gr.Textbox(label="Menge 3") |
| with gr.Row(): scan_k4, scan_g4 = gr.Textbox(label="Gewicht 4"), gr.Textbox(label="Menge 4") |
| with gr.Accordion("🔤 Gelesener Rohtext (zur Kontrolle)", open=False): |
| scan_raw = gr.Textbox(label="Gelesener Text", lines=3, interactive=False, show_label=False) |
| btn_save_futter = gr.Button("Speichern", variant="primary", elem_id="b_save_futter") |
| scan_status = gr.Textbox(label="Status", visible=False) |
| with gr.Column(visible=False, elem_id="c_sektion_archiv") as sektion_archiv: |
| gr.Markdown("### 📦 Tier archivieren") |
| with gr.Row(): |
| move_dropdown = gr.Dropdown(label="Tier in Archiv verschieben", scale=2) |
| a_grund = gr.Dropdown(choices=["Vermittelt", "Verstorben", "Sonstiges"], label="Grund", scale=1) |
| with gr.Row(visible=False) as extra_vertrag_row: |
| a_vertrag_img = gr.File(label="📄 Vermittlungsvertrag", file_types=["image", ".pdf"], elem_classes="kompakte_datei") |
| btn_archive, archive_status = gr.Button("Archivieren", variant="primary", size="lg", elem_id="b_archivieren"), gr.Textbox(label="Status", visible=False) |
| with gr.Column(visible=False, elem_id="c_sektion_pdf") as sektion_pdf_export: |
| gr.Markdown("### 📄 PDF-Akte exportieren") |
| with gr.Row(): |
| sel_pdf_own = gr.Dropdown(label="🏠 Eigene Tiere", choices=["Hier Auswählen"], value="Hier Auswählen", scale=2) |
| btn_pdf_own = gr.Button("PDF generieren", scale=1) |
| with gr.Row(visible=False) as row_pdf_foster: |
| sel_pdf_foster = gr.Dropdown(label="🧳 Pflege- & Gasttiere", choices=["Hier Auswählen"], value="Hier Auswählen", scale=2) |
| btn_pdf_foster = gr.Button("PDF generieren", scale=1) |
| with gr.Row(): |
| sel_pdf_archiv = gr.Dropdown(label="🗄️ Archivierte Tiere", choices=["Hier Auswählen"], value="Hier Auswählen", scale=2) |
| btn_pdf_archiv = gr.Button("PDF generieren", scale=1) |
| pdf_status = gr.Textbox(label="Status PDF", visible=False) |
| with gr.Group(): |
| gr.Markdown("**Was soll enthalten sein?**") |
| pdf_mit_info = gr.Checkbox(label="📋 Infoblatt (Stammdaten, Fütterung, Gesundheit)", value=True) |
| pdf_mit_pflege = gr.Checkbox(label="📎 Pflegevertrag (falls vorhanden)", value=True) |
| pdf_mit_vermittlung = gr.Checkbox(label="📄 Vermittlungsvertrag (falls vorhanden)", value=True) |
| pdf_mit_tierarzt = gr.Checkbox(label="🩺 Alle Tierarzt-Dokumente (falls vorhanden)", value=True) |
| pdf_mit_tagebuch = gr.Checkbox(label="📔 Tagebuch (falls vorhanden)", value=False) |
| pdf_file = gr.File(label="Download Akte", elem_classes="kompakte_datei") |
| with gr.Column(visible=False, elem_id="c_sektion_nachtrag") as sektion_nachtrag: |
| gr.Markdown("### 📎 Vertrag nachtragen") |
| nach_ziel_radio = gr.Radio(choices=["🧳 Aktueller Bestand", "🗄️ Archiv"], value="🧳 Aktueller Bestand", label="Wo befindet sich das Tier?") |
| nach_aktiv_dd = gr.Dropdown(label="Pflege- & Gasttier auswählen", choices=["Hier Auswählen"], value="Hier Auswählen", visible=True) |
| nach_archiv_dd = gr.Dropdown(label="Archiviertes Tier auswählen", choices=["Hier Auswählen"], value="Hier Auswählen", visible=False) |
| with gr.Row(): |
| nach_typ_dd = gr.Dropdown(label="Vertragsart", choices=["Vermittlungsvertrag", "Pflegevertrag"], value="Vermittlungsvertrag") |
| nach_img = gr.File(label="📄 Vertragsdokument (Bild/PDF)", file_types=["image", ".pdf"], elem_classes="kompakte_datei") |
| btn_nach_save, nach_status = gr.Button("Vertrag anhängen", variant="primary", elem_id="b_nach_save"), gr.Textbox(label="Status", visible=False) |
| with gr.Column(visible=False, elem_id="c_sektion_tagebuch_uebersicht") as sektion_tagebuch_uebersicht: |
| gr.Markdown("### 📔 Tagebuch") |
| gr.Markdown("Wähle ein Tier aus, um sein Tagebuch zu öffnen.") |
| tb_uebersicht_refresh = gr.State(0) |
| @gr.render(inputs=[current_zoo, tb_uebersicht_refresh]) |
| def render_tagebuch_uebersicht(zoo_name, _refresh): |
| tiere = _tiere_mit_tagebuch_stand(zoo_name) |
| if not tiere: |
| gr.Markdown("<i>Noch keine Tiere angelegt.</i>") |
| return |
| for t in tiere: |
| with gr.Row(elem_classes="tb_uebersicht_row"): |
| if t["anzahl"] > 0: |
| label = f"🐾 {t['name']} ({t['tierart']}) · {t['anzahl']} Einträge · zuletzt {t['letztes_datum']}" |
| else: |
| label = f"🐾 {t['name']} ({t['tierart']}) · noch keine Einträge" |
| btn = gr.Button(label, elem_classes="tb_uebersicht_btn") |
| btn.click(oeffne_tier_tagebuch_fabrik(t["name"]), inputs=[current_zoo, tagebuch_refresh_zaehler], outputs=tagebuch_uebersicht_sprung_outs, show_progress="hidden") |
| with gr.Column(visible=False, elem_id="c_sektion_inventar") as sektion_inventar: |
| gr.Markdown("### 📊 Futter-Übersicht") |
| dash_html = gr.HTML(value="<i>Bitte logge dich ein.</i>", elem_id="dash_html_anchor") |
| futter_slot_rows, futter_slot_labels, futter_slot_sorten = [], [], [] |
| futter_slot_aufstock_btns, futter_slot_edit_btns = [], [] |
| futter_slot_aufstock_rows, futter_slot_aufstock_menge, futter_slot_aufstock_confirm, futter_slot_aufstock_close = [], [], [], [] |
| futter_slot_edit_rows, futter_slot_edit_bestand, futter_slot_edit_sack, futter_slot_edit_confirm, futter_slot_edit_del, futter_slot_edit_more, futter_slot_edit_close = [], [], [], [], [], [], [] |
| futter_slot_mehr_rows, futter_slot_mehr_fields, futter_slot_mehr_speichern, futter_slot_mehr_close = [], [], [], [] |
| for _i in range(FUTTER_SLOT_ANZAHL): |
| with gr.Row(visible=False, elem_classes="futter_slot_row") as _row: |
| _lbl = gr.HTML("") |
| _btn_a = gr.Button("add", size="sm", scale=0, min_width=44, elem_classes="msr-icon-btn") |
| _btn_e = gr.Button("edit", size="sm", scale=0, min_width=44, elem_classes="msr-icon-btn") |
| with gr.Row(visible=False, elem_classes="futter_slot_inline") as _aufstock_row: |
| _aufstock_close = gr.Button("close", scale=0, min_width=40, elem_classes="msr-icon-btn") |
| _aufstock_menge = gr.Number(label="Menge (g)", value=500, scale=2) |
| _aufstock_confirm = gr.Button("Aufstocken", variant="primary", scale=1, elem_classes="btn-ic-check") |
| with gr.Row(visible=False, elem_classes="futter_slot_inline") as _edit_row: |
| _edit_bestand = gr.Textbox(label="Bestand (g)", scale=1) |
| _edit_sack = gr.Textbox(label="Sackgröße (g)", scale=1) |
| _edit_confirm = gr.Button("save", scale=0, min_width=40, elem_classes="msr-icon-btn") |
| _edit_del = gr.Button("delete", scale=0, min_width=40, variant="stop", elem_classes="msr-icon-btn") |
| _edit_more = gr.Button("Mehr", scale=0, min_width=60, elem_classes="btn-ic-more-inline") |
| _edit_close = gr.Button("close", scale=0, min_width=40, elem_classes="msr-icon-btn") |
| with gr.Group(visible=False, elem_classes="futter_slot_inline") as _mehr_row: |
| with gr.Row(): _m_herst, _m_sorte = gr.Textbox(label="Hersteller"), gr.Textbox(label="Sorte") |
| _m_gew, _m_sack = gr.Textbox(visible=False), gr.Textbox(visible=False) |
| with gr.Row(): _m_phase, _m_art = gr.Textbox(label="Lebensphase"), gr.Dropdown(label="Kategorie", choices=["Hundefutter", "Katzenfutter", "Nagerfutter", "Vogelfutter", "Pferdefutter", "Reptilienfutter", "Aquaristik", "Sonstiges"], value="Hundefutter", allow_custom_value=True) |
| with gr.Accordion("⚙️ Fütterungsart & Gewichts-Brackets", open=False): |
| _m_futtertyp = gr.Radio(label="Fütterungsart", choices=["Trockenfutter", "Nassfutter"], value="Trockenfutter") |
| with gr.Row(): _m_k1, _m_g1 = gr.Textbox(label="Gewicht 1"), gr.Textbox(label="Menge 1") |
| with gr.Row(): _m_k2, _m_g2 = gr.Textbox(label="Gewicht 2"), gr.Textbox(label="Menge 2") |
| with gr.Row(): _m_k3, _m_g3 = gr.Textbox(label="Gewicht 3"), gr.Textbox(label="Menge 3") |
| with gr.Row(): _m_k4, _m_g4 = gr.Textbox(label="Gewicht 4"), gr.Textbox(label="Menge 4") |
| with gr.Row(): |
| _m_close = gr.Button("close", scale=0, min_width=40, elem_classes="msr-icon-btn") |
| _m_speichern = gr.Button("Speichern", variant="primary", scale=2, elem_classes="btn-ic-save") |
| _sorte_state = gr.State("") |
| futter_slot_rows.append(_row) |
| futter_slot_labels.append(_lbl) |
| futter_slot_sorten.append(_sorte_state) |
| futter_slot_aufstock_btns.append(_btn_a) |
| futter_slot_edit_btns.append(_btn_e) |
| futter_slot_aufstock_rows.append(_aufstock_row) |
| futter_slot_aufstock_menge.append(_aufstock_menge) |
| futter_slot_aufstock_confirm.append(_aufstock_confirm) |
| futter_slot_aufstock_close.append(_aufstock_close) |
| futter_slot_edit_rows.append(_edit_row) |
| futter_slot_edit_bestand.append(_edit_bestand) |
| futter_slot_edit_sack.append(_edit_sack) |
| futter_slot_edit_confirm.append(_edit_confirm) |
| futter_slot_edit_del.append(_edit_del) |
| futter_slot_edit_more.append(_edit_more) |
| futter_slot_edit_close.append(_edit_close) |
| futter_slot_mehr_rows.append(_mehr_row) |
| futter_slot_mehr_fields.append([_m_herst, _m_sorte, _m_gew, _m_sack, _m_phase, _m_art, _m_futtertyp, _m_k1, _m_g1, _m_k2, _m_g2, _m_k3, _m_g3, _m_k4, _m_g4]) |
| futter_slot_mehr_speichern.append(_m_speichern) |
| futter_slot_mehr_close.append(_m_close) |
| |
| edit_f_dd = gr.Dropdown(choices=["Hier Auswählen"], value="Hier Auswählen", allow_custom_value=True, visible=False) |
| dash_aufstock_dd = gr.Dropdown(choices=["Hier Auswählen"], value="Hier Auswählen", visible=False) |
| with gr.Column(visible=False, elem_id="c_sektion_apotheke") as sektion_apotheke: |
| apotheke_banner_img = gr.Image(value=APOTHEKE_BANNER_URL, show_label=False, container=False, interactive=False, elem_id="apotheke_banner_img", visible=apotheke_banner_ist_aktiv(), show_download_button=False, show_fullscreen_button=False, show_share_button=False) |
| gr.Markdown("### 💊 Haustierapotheke") |
| gr.Markdown("Hausmittel-Empfehlungen auf tiermedizinischer Basis. **Tippe auf eine Kategorie unten, um sie zu öffnen** (z.B. Hund, Katze, Maus), oder nutze die Suche (durchsucht auch die Inhalte, nicht nur die Titel).") |
| with gr.Row(): |
| apotheke_tierart_filter_dd = gr.Dropdown(label="🐾 Tierart eingrenzen", choices=["Alle Tierarten"], value="Alle Tierarten", scale=1) |
| apotheke_suche_f = gr.Textbox(label="🔍 Suche (z.B. 'Erbrechen', 'Magen', 'Durchfall')", placeholder="Stichwort eingeben ...", scale=2) |
| with gr.Column(visible=False) as apotheke_admin_box: |
| gr.Markdown("---") |
| apotheke_banner_cb = gr.Checkbox(label="🖼️ Kopf-Banner-Bild anzeigen", value=apotheke_banner_ist_aktiv()) |
| gr.Markdown("**Neuen Eintrag hinzufügen** (nur für Admins sichtbar)") |
| apotheke_neu_kategorie_f = gr.Textbox(label="Kategorie", placeholder="z.B. Hund, Katze, Maus ...") |
| apotheke_neu_titel_f = gr.Textbox(label="Titel", placeholder="z.B. Übersäuerter Magen / häufiges Erbrechen") |
| apotheke_neu_inhalt_f = gr.Textbox(label="Inhalt", lines=5, placeholder="Ausführliche Empfehlung, Hausmittel, Dosierung, wann zum Tierarzt ...") |
| btn_apotheke_speichern = gr.Button("Eintrag speichern", variant="primary", elem_id="b_apotheke_speichern") |
| apotheke_status = gr.Textbox(label="Status", visible=False) |
| gr.Markdown("---") |
| |
| |
| |
| |
| |
| apotheke_seiten_state = gr.State({}) |
| apotheke_refresh_zaehler = gr.State(0) |
| apotheke_editier_zeile_state = gr.State(None) |
| |
| |
| |
| |
| apotheke_offene_kategorie_state = gr.State(None) |
|
|
| @gr.render(inputs=[current_zoo, apotheke_tierart_filter_dd, apotheke_suche_f, apotheke_seiten_state, apotheke_refresh_zaehler, apotheke_editier_zeile_state, apotheke_offene_kategorie_state]) |
| def render_apotheke(zoo_name, tierart_filter, suchbegriff, seiten, _refresh, editier_zeile, offene_kategorie): |
| admin = ist_admin(zoo_name) |
| alle_eintraege = _apotheke_eintraege() |
| |
| |
| |
| if tierart_filter and tierart_filter != "Alle Tierarten": |
| alle_eintraege = [e for e in alle_eintraege if e["kategorie"] == tierart_filter] |
|
|
| def _karte(e, kat=None): |
| with gr.Group(): |
| if editier_zeile == e["zeile"]: |
| edit_kat = gr.Textbox(label="Kategorie", value=e["kategorie"]) |
| edit_titel = gr.Textbox(label="Titel", value=e["titel"]) |
| edit_inhalt = gr.Textbox(label="Inhalt", value=e["inhalt"], lines=5) |
| with gr.Row(): |
| save_btn = gr.Button("Speichern", variant="primary", size="sm", elem_classes="btn-ic-save") |
| cancel_btn = gr.Button("Abbrechen", size="sm", elem_classes="btn-ic-cancel") |
|
|
| def _speichern(kat_val, titel_val, inhalt_val, zeile=e["zeile"], altes_datum=e["datum"], rz=_refresh, k=kat): |
| if not titel_val or not titel_val.strip() or not inhalt_val or not inhalt_val.strip() or not kat_val or not kat_val.strip(): |
| return "❌ Kategorie, Titel und Inhalt dürfen nicht leer sein.", zeile, rz, k |
| try: |
| neue_zeile = [kat_val.strip(), titel_val.strip(), inhalt_val.strip(), altes_datum or datetime.now().strftime("%d.%m.%Y")] |
| ws_apotheke.update(range_name=f"A{zeile}:D{zeile}", values=[neue_zeile]) |
| invalidate_cache() |
| return "✅ Eintrag aktualisiert.", None, (rz or 0) + 1, k |
| except Exception as ex: |
| return f"❌ Fehler beim Speichern: {str(ex)[:80]}", zeile, rz, k |
|
|
| save_btn.click(_speichern, inputs=[edit_kat, edit_titel, edit_inhalt], outputs=[apotheke_status, apotheke_editier_zeile_state, apotheke_refresh_zaehler, apotheke_offene_kategorie_state], show_progress="hidden").then(zeige_als_toast, inputs=apotheke_status, outputs=None) |
| cancel_btn.click(lambda k=kat: (None, k), inputs=None, outputs=[apotheke_editier_zeile_state, apotheke_offene_kategorie_state], show_progress="hidden") |
| else: |
| gr.Markdown(f"**{e['titel']}** \n{e['inhalt']}") |
| if admin: |
| edit_btn = gr.Button("Bearbeiten", size="sm", elem_classes="btn-ic-edit") |
| edit_btn.click(lambda zeile=e["zeile"], k=kat: (zeile, k), inputs=None, outputs=[apotheke_editier_zeile_state, apotheke_offene_kategorie_state], show_progress="hidden") |
|
|
| if suchbegriff and suchbegriff.strip(): |
| |
| |
| |
| begriff = suchbegriff.strip().lower() |
| treffer = [e for e in alle_eintraege if begriff in e["titel"].lower() or begriff in e["inhalt"].lower() or begriff in e["kategorie"].lower()] |
| if not treffer: |
| gr.Markdown("<i>Keine Einträge gefunden. Versuch es mit einem anderen Suchbegriff oder einer anderen Tierart.</i>") |
| return |
| treffer.sort(key=lambda e: e["titel"].lower()) |
| gr.Markdown(f"**{len(treffer)} Treffer für '{suchbegriff}'**") |
| for e in treffer: |
| _karte(e) |
| return |
|
|
| if not alle_eintraege: |
| gr.Markdown("<div style='color:#7A6E5C;'><i>Noch keine Einträge vorhanden.</i></div>") |
| return |
| kategorien = sorted(set(e["kategorie"] for e in alle_eintraege if e["kategorie"])) |
| for kat in kategorien: |
| kat_eintraege = sorted([e for e in alle_eintraege if e["kategorie"] == kat], key=lambda e: e["titel"].lower()) |
| with gr.Accordion(f"{_apotheke_icon(kat)} {kat} — {len(kat_eintraege)} Einträge ▸ antippen zum Öffnen", open=(kat == offene_kategorie), elem_classes="apotheke_kategorie_accordion"): |
| anzahl_anzeigen = seiten.get(kat, 5) |
| sichtbare = kat_eintraege[:anzahl_anzeigen] |
| rest = len(kat_eintraege) - len(sichtbare) |
| for e in sichtbare: |
| _karte(e, kat=kat) |
| if rest > 0: |
| mehr_btn = gr.Button(f"⬇️ Weitere Einträge laden ({rest} weitere)", size="sm") |
|
|
| def _mehr_laden(alte_seiten=seiten, k=kat, a=anzahl_anzeigen): |
| neu = dict(alte_seiten) |
| neu[k] = a + 10 |
| return neu, k |
| mehr_btn.click(_mehr_laden, inputs=None, outputs=[apotheke_seiten_state, apotheke_offene_kategorie_state], show_progress="hidden") |
|
|
| with gr.Column(visible=False, elem_id="c_haupt_vermisst") as haupt_vermisst: |
| gr.Markdown( |
| "### 🚨 Tier als vermisst melden\n" |
| "Direkt hier auswählen – kein Umweg über die Verwaltung nötig." |
| ) |
| vermisst_tier_dd = gr.Dropdown(label="Welches Tier ist vermisst?", choices=["Hier Auswählen"], value="Hier Auswählen") |
| v_name = gr.Textbox(visible=False) |
| v_tierart = gr.Textbox(visible=False) |
| v_rasse = gr.Textbox(visible=False) |
| v_farbe = gr.Textbox(visible=False) |
| v_chip = gr.Textbox(visible=False) |
| v_tasso = gr.Textbox(visible=False) |
| v_kontakt = gr.Textbox(visible=False) |
| v_kontakt2 = gr.Textbox(visible=False) |
| v_img = gr.Image(visible=False, type="filepath") |
| with gr.Group(visible=False) as vermisst_gruppe: |
| gr.Markdown( |
| "TASSO bietet leider keine automatische Anbindung – hier bekommst du aber alle Daten " |
| "übersichtlich zum Abtippen sowie einen fertigen Steckbrief zum Ausdrucken/Teilen." |
| ) |
| vermisst_notiz_f = gr.Textbox(label="Zusatzinfo für den Steckbrief (optional, z. B. wo/wann zuletzt gesehen)", lines=2) |
| btn_vermisst_daten = gr.Button("Daten für TASSO anzeigen", elem_id="b_vermisst_daten") |
| vermisst_copy_text = gr.Textbox(label="Zum Kopieren ins TASSO-Formular", lines=8, interactive=False) |
| btn_tasso_link = gr.Button("TASSO Vermisst-Formular öffnen", elem_id="b_tasso_link") |
| gr.Markdown( |
| "*Falls sich nichts öffnet (kann in der Standalone-App passieren): Link unten antippen & halten zum Kopieren, dann im normalen Browser (Safari/Chrome) einfügen.*\n\n" |
| "`https://www.tasso.net/Tierregister/Tier-vermisst/Tier-vermisst-melden`" |
| ) |
| btn_vermisst_pdf = gr.Button("Vermisst-Steckbrief als PDF erstellen", variant="stop", elem_id="b_vermisst_pdf") |
| vermisst_pdf_status = gr.Textbox(label="Status", visible=False) |
| vermisst_pdf_file = gr.File(label="Download Steckbrief", elem_classes="kompakte_datei") |
|
|
| with gr.Column(visible=False, elem_id="c_haupt_konto") as haupt_konto: |
| with gr.Row(elem_classes="tab_row"): |
| btn_konto_tab_einst = gr.Button("Einstellungen", variant="primary", elem_id="b_konto_tab_einst") |
| btn_konto_tab_admin = gr.Button("Admin", variant="secondary", elem_id="b_konto_tab_admin", visible=False) |
| with gr.Column(visible=True) as konto_tab_einstellungen: |
| gr.Markdown("### ⚙️ Allgemeine Einstellungen") |
| modus_cb = gr.Checkbox(label="Erweiterter Modus (Pflege- & Gasttiere aktivieren)", value=False) |
| btn_show_fb = gr.Button("Feedback senden", variant="secondary", elem_id="b_show_fb") |
| with gr.Column(visible=False) as konto_fb_box: |
| fb_thema = gr.Dropdown(choices=["Fehler", "Idee", "Frage"], label="Thema"); fb_text = gr.Textbox(label="Nachricht") |
| btn_fb, fb_status = gr.Button("Senden", variant="primary", elem_id="b_fb_senden"), gr.Textbox(label="Status", visible=False) |
| btn_show_erinn = gr.Button("Erinnerungen", variant="secondary", elem_id="b_show_erinn") |
| with gr.Column(visible=False) as konto_erinn_box: |
| set_email = gr.Textbox(label="E-Mail") |
| btn_set_save, set_status = gr.Button("Speichern", variant="primary", elem_classes="btn-ic-save"), gr.Textbox(label="Status", visible=False) |
| btn_show_pw = gr.Button("Passwort", variant="secondary", elem_id="b_show_pw") |
| with gr.Column(visible=False) as konto_pw_box: |
| old_pw_f = gr.Textbox(label="Alt", type="password"); new_pw_f = gr.Textbox(label="Neu", type="password") |
| btn_pw_save, pw_status = gr.Button("Ändern", elem_id="b_pw_save"), gr.Textbox(label="Status", visible=False) |
| btn_show_del = gr.Button("Konto löschen", variant="secondary", elem_id="b_show_del") |
| with gr.Column(visible=False) as konto_del_box: |
| del_pw_f = gr.Textbox(label="Passwort", type="password") |
| btn_del_account, del_status = gr.Button("Löschen", variant="stop", elem_id="b_del_account"), gr.Textbox(label="Status", visible=False) |
| with gr.Column(visible=False) as konto_tab_admin: |
| with gr.Column(visible=False) as admin_stats: |
| admin_counter = gr.HTML(value="Lade Statistiken...") |
| btn_test_email, test_email_status = gr.Button("Test-E-Mail an mich selbst senden", variant="secondary", visible=False, elem_id="b_test_email"), gr.Textbox(label="Status", visible=False) |
| btn_futter_cron_test, futter_cron_status = gr.Button("Futter-Check jetzt manuell auslösen (alle Konten)", variant="secondary", visible=False, elem_id="b_futter_cron_test"), gr.Textbox(label="Status", visible=False) |
| btn_show_news = gr.Button("News veröffentlichen", variant="secondary", visible=False, elem_id="b_show_news") |
| with gr.Column(visible=False) as admin_news_box: |
| news_text = gr.Textbox(label="Update-Nachricht") |
| news_per_email_cb = gr.Checkbox(label="📧 Auch per E-Mail an alle Nutzer mit hinterlegter E-Mail-Adresse senden", value=False) |
| btn_news, news_status = gr.Button("Veröffentlichen", variant="primary", elem_id="b_news_veroeffentlichen"), gr.Textbox(label="Status", visible=False) |
| btn_show_users = gr.Button("Nutzerverwaltung", variant="secondary", visible=False, elem_id="b_show_users") |
| with gr.Column(visible=False) as admin_users_box: |
| nutzerliste_html = gr.HTML(value="") |
| with gr.Group(): |
| gr.Markdown("**Nutzer sperren**") |
| sperren_dd = gr.Dropdown(label="Aktiver Nutzer", choices=["Keine aktiven Nutzer"]) |
| btn_sperren, sperr_status = gr.Button("Sperren", variant="stop", elem_id="b_sperren"), gr.Textbox(label="Status", visible=False) |
| with gr.Group(): |
| gr.Markdown("**Nutzer entsperren**") |
| entsperren_dd = gr.Dropdown(label="Gesperrter Nutzer", choices=["Keine gesperrten Nutzer"]) |
| btn_entsperren, entsperr_status = gr.Button("Entsperren", variant="primary", elem_classes="btn-ic-check"), gr.Textbox(label="Status", visible=False) |
| with gr.Group(visible=False) as admin_ernennen_box: |
| gr.Markdown("**Admin-Status vergeben** (nur Hauptadmin)") |
| admin_vergeben_dd = gr.Dropdown(label="Nutzer ohne Admin-Status", choices=["Keine passenden Nutzer"]) |
| btn_admin_vergeben, admin_vergeben_status = gr.Button("Zum Admin machen", variant="primary", elem_id="b_admin_vergeben"), gr.Textbox(label="Status", visible=False) |
| gr.Markdown("**Admin-Status entziehen** (nur Hauptadmin)") |
| admin_entziehen_dd = gr.Dropdown(label="Aktueller Admin", choices=["Keine weiteren Admins"]) |
| btn_admin_entziehen, admin_entziehen_status = gr.Button("Admin-Status entziehen", variant="stop", elem_id="b_admin_entziehen"), gr.Textbox(label="Status", visible=False) |
| btn_show_feedback_liste = gr.Button("Feedback-Übersicht", variant="secondary", visible=False, elem_id="b_show_feedback_liste") |
| with gr.Column(visible=False) as admin_feedback_liste_box: |
| feedback_liste_html = gr.HTML(value="") |
| with gr.Group(): |
| gr.Markdown("**Als erledigt markieren** (löscht den Eintrag dauerhaft aus Liste & Sheet)") |
| feedback_loeschen_dd = gr.Dropdown(label="Feedback-Eintrag", choices=[]) |
| btn_feedback_loeschen, feedback_loeschen_status = gr.Button("Als erledigt markieren & löschen", variant="stop", elem_classes="btn-ic-check"), gr.Textbox(label="Status", visible=False) |
| gr.Markdown("<br>") |
| with gr.Row(elem_id="version_footer_btn"): |
| btn_versionshistorie = gr.Button(f"PetBase App v{APP_VERSION} · Änderungshistorie ansehen") |
| with gr.Row(elem_id="acc_top_row", elem_classes="zentrierte_reihe"): |
| btn_logout = gr.Button("Abmelden", size="sm", elem_id="b_logout") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| nav_popup_state = gr.State("") |
| with gr.Column(visible=False, elem_id="nav_popup_box") as nav_popup_box: |
| @gr.render(inputs=[nav_popup_state, current_zoo]) |
| def render_nav_popup(state, zoo_name): |
| render_nav_popup_inhalt(state, zoo_name) |
|
|
| |
| |
| |
| |
| |
| with gr.Row(elem_id="bottom_nav_bar"): |
| btn_nav_start = gr.Button("Start", elem_id="b_nav_start") |
| btn_nav_tiere = gr.Button("Tiere", elem_id="b_nav_tiere") |
| btn_nav_futter = gr.Button("Futter", elem_id="b_nav_futter") |
| btn_nav_tagebuch = gr.Button("Tagebuch", elem_id="b_nav_tagebuch") |
| btn_nav_konto = gr.Button("Konto", elem_id="b_nav_konto") |
| btn_nav_menu = gr.Button("Sonstiges", elem_id="b_nav_menu") |
|
|
| with gr.Column(visible=False) as versionshistorie_overlay: |
| with gr.Column(elem_id="versionshistorie_backdrop"): |
| with gr.Column(elem_id="versionshistorie_card"): |
| versionshistorie_anzahl_state = gr.State(15) |
| versionshistorie_md = gr.Markdown(baue_versionshistorie_text(15)[0]) |
| btn_versionshistorie_mehr = gr.Button("Weitere Versionen laden", size="sm", visible=(len(VERSION_HISTORY) > 15), elem_id="b_versionshistorie_mehr") |
| with gr.Row(elem_id="versionshistorie_close"): |
| btn_versionshistorie_schliessen = gr.Button("Zurück", elem_classes="btn-ic-cancel") |
| |
| |
| |
| a_grund.change(lambda g: gr.update(visible=(g == "Vermittelt")), inputs=a_grund, outputs=extra_vertrag_row) |
| btn_fb.click(submit_feedback, inputs=[current_zoo, fb_thema, fb_text], outputs=fb_status).then(zeige_als_toast, inputs=fb_status, outputs=None) |
| def _login_reg_umschalten(ziel): |
| return gr.update(visible=(ziel == "login")), gr.update(visible=(ziel == "reg")), gr.update(variant="primary" if ziel == "login" else "secondary"), gr.update(variant="primary" if ziel == "reg" else "secondary") |
| btn_login_tab.click(lambda: _login_reg_umschalten("login"), inputs=None, outputs=[login_tab_box, reg_tab_box, btn_login_tab, btn_reg_tab], show_progress="hidden") |
| btn_reg_tab.click(lambda: _login_reg_umschalten("reg"), inputs=None, outputs=[login_tab_box, reg_tab_box, btn_login_tab, btn_reg_tab], show_progress="hidden") |
| reg_btn.click(register_user, inputs=[reg_name, reg_pw, reg_email], outputs=[reg_status, login_name, login_tab_box, reg_tab_box, btn_login_tab, btn_reg_tab], show_progress="hidden").then(zeige_als_toast, inputs=reg_status, outputs=None) |
| btn_pw_save.click(change_password, inputs=[current_zoo, old_pw_f, new_pw_f], outputs=pw_status).then(zeige_als_toast, inputs=pw_status, outputs=None) |
| btn_del_account.click(delete_account, inputs=[current_zoo, del_pw_f], outputs=[del_status, login_screen, main_app, current_zoo]).then(zeige_als_toast, inputs=del_status, outputs=None) |
| btn_news.click(publish_news, inputs=[current_zoo, news_text, news_per_email_cb], outputs=[news_status]).then(zeige_als_toast, inputs=news_status, outputs=None) |
| dropdown_outs = [sperren_dd, entsperren_dd, admin_vergeben_dd, admin_entziehen_dd] |
| btn_sperren.click(nutzer_sperren, inputs=[current_zoo, sperren_dd], outputs=[sperr_status, nutzerliste_html] + dropdown_outs).then(zeige_als_toast, inputs=sperr_status, outputs=None) |
| btn_entsperren.click(nutzer_entsperren, inputs=[current_zoo, entsperren_dd], outputs=[entsperr_status, nutzerliste_html] + dropdown_outs).then(zeige_als_toast, inputs=entsperr_status, outputs=None) |
| btn_admin_vergeben.click(admin_ernennen, inputs=[current_zoo, admin_vergeben_dd], outputs=[admin_vergeben_status, nutzerliste_html] + dropdown_outs).then(zeige_als_toast, inputs=admin_vergeben_status, outputs=None) |
| btn_admin_entziehen.click(admin_entziehen, inputs=[current_zoo, admin_entziehen_dd], outputs=[admin_entziehen_status, nutzerliste_html] + dropdown_outs).then(zeige_als_toast, inputs=admin_entziehen_status, outputs=None) |
| btn_test_email.click(test_email_senden, inputs=current_zoo, outputs=test_email_status, show_progress="hidden").then(zeige_als_toast, inputs=test_email_status, outputs=None) |
| btn_futter_cron_test.click(pruefe_und_sende_futter_erinnerungen, inputs=None, outputs=futter_cron_status, show_progress="hidden").then(zeige_als_toast, inputs=futter_cron_status, outputs=None) |
|
|
| ui_outs = [filter_accordion, kat_f, row_pdf_foster, extra_vertrag_row, tier_dropdown, move_dropdown, sel_pdf_own, sel_pdf_foster, sel_pdf_archiv, typ_f, trock_f, edit_f_dd, dash_aufstock_dd, doc_titel_md, doc_type_select, vermisst_tier_dd, nach_aktiv_dd, nach_archiv_dd, btn_bereich_archiv, btn_bereich_pdf, btn_bereich_nachtrag] |
|
|
| def check_saved_login(zoo): |
| if zoo and str(zoo).strip(): |
| now = datetime.now().strftime("%d.%m.%Y %H:%M") |
| ist_erster_login = False |
| zeige_overlay = False |
| onboarding_text_update = gr.update() |
| try: |
| rows = ws_users.get_all_values() |
| for i, r in enumerate(rows): |
| if r[0] == zoo: |
| while len(r) < 6: r.append("") |
| ist_erster_login = str(r[4]).strip() in ("", "0") |
| gesehene_version = str(r[5]).strip() |
| if ist_erster_login: |
| zeige_overlay = True |
| |
| elif gesehene_version != APP_VERSION: |
| zeige_overlay = True |
| onboarding_text_update = gr.update(value=CHANGELOG_TEXT) |
| ws_users.update_cell(i + 1, 4, now) |
| try: count = int(r[4]) + 1 if str(r[4]).isdigit() else 1 |
| except: count = 1 |
| ws_users.update_cell(i + 1, 5, count) |
| ws_users.update_cell(i + 1, 6, APP_VERSION) |
| break |
| invalidate_cache() |
| except: pass |
| acc_text = f"<span class='msr'>account_circle</span> Eingeloggt als **{zoo}**" |
| onboarding_update = gr.update(visible=zeige_overlay) |
| try: |
| unread = get_unread_news(zoo) |
| news_msg = "### 📢 Neue Updates:\n" + "\n".join([f"- {n}" for n in unread]) if unread else "" |
| is_admin = ist_admin(zoo) |
| is_superadmin = str(zoo).strip().lower() == "petbase" |
| stats_html = get_admin_stats() if is_admin else "" |
| nutzerliste = erstelle_nutzerliste() if is_admin else "" |
| ui = init_ui(zoo) |
| return [gr.update(visible=False), gr.update(visible=True)] + ui + [lade_einstellungen(zoo), lade_modus(zoo), erstelle_dashboard(zoo), news_msg, gr.update(visible=is_admin), gr.update(visible=is_admin), gr.update(visible=is_admin), stats_html, acc_text, onboarding_update, onboarding_text_update, gr.update(visible=is_admin), nutzerliste, gr.update(visible=is_superadmin), gr.update(visible=is_admin), gr.update(visible=is_admin), gr.update(visible=is_superadmin), gr.update(visible=is_admin), gr.update(visible=is_admin), erstelle_bestand_karte(zoo), erstelle_anstehend_karte(zoo), erstelle_bestand_karte(zoo), erstelle_anstehend_karte(zoo)] |
| except Exception: |
| return [gr.update(visible=False), gr.update(visible=True)] + [gr.update()]*21 + [gr.update(), gr.update(), "<i>⚠️ Beim Laden ist etwas schiefgelaufen. Bitte oben auf '🔄 Neu laden' tippen.</i>", "", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), "", acc_text, onboarding_update, onboarding_text_update, gr.update(visible=False), "", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(), gr.update(), gr.update(), gr.update()] |
| return [gr.update(visible=True), gr.update(visible=False)] + [gr.update()]*21 + [gr.update(), gr.update(), "<i>Bitte logge dich ein.</i>", "", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), "", "<span class='msr'>account_circle</span> Nicht eingeloggt", gr.update(visible=False), gr.update(), gr.update(visible=False), "", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(), gr.update(), gr.update(), gr.update()] |
|
|
| login_outs = [login_screen, main_app] + ui_outs + [set_email, modus_cb, dash_html, news_display, admin_news_box, btn_show_news, admin_stats, admin_counter, acc_status_md, onboarding_overlay, onboarding_content, btn_show_users, nutzerliste_html, admin_ernennen_box, btn_konto_tab_admin, btn_test_email, btn_futter_cron_test, btn_show_feedback_liste, apotheke_admin_box, bestand_karte_html, anstehend_karte_html, start_bestand_html, start_anstehend_html] |
|
|
| def zoo_erkannt(zoo, pref): |
| checkbox_val = (pref != "nein") |
| if zoo and str(zoo).strip() and pref != "nein": |
| return gr.update(visible=True), gr.update(value=f"➡️ Weiter als {zoo}"), gr.update(value=zoo), gr.update(value=checkbox_val) |
| return gr.update(visible=False), gr.update(), gr.update(value=zoo if zoo else ""), gr.update(value=checkbox_val) |
|
|
| demo.load(lambda: None, inputs=None, outputs=None) |
| demo.load(zoo_erkannt, inputs=[current_zoo, auto_login_pref], outputs=[quick_row, quick_btn, login_name, pref_checkbox]) |
| pref_checkbox.change(lambda v: "ja" if v else "nein", inputs=pref_checkbox, outputs=auto_login_pref) |
|
|
| impressum_sichtbar_state = gr.State(False) |
| def toggle_impressum(sichtbar): |
| neu = not sichtbar |
| return gr.update(visible=neu), neu |
| btn_show_impressum.click(toggle_impressum, inputs=impressum_sichtbar_state, outputs=[impressum_box, impressum_sichtbar_state], show_progress="hidden") |
| login_btn.click(login_user, inputs=[login_name, login_pw], outputs=[login_status, current_zoo]).then(zeige_als_toast, inputs=login_status, outputs=None).then(check_saved_login, inputs=[current_zoo], outputs=login_outs) |
| quick_btn.click(check_saved_login, inputs=[current_zoo], outputs=login_outs) |
|
|
| def logout(): |
| return "", gr.update(visible=True), gr.update(visible=False), "<span class='msr'>account_circle</span> Nicht eingeloggt" |
| btn_logout.click(logout, inputs=None, outputs=[current_zoo, login_screen, main_app, acc_status_md]) |
| onboarding_close_btn.click(lambda: {onboarding_overlay: gr.update(visible=False)}, inputs=None, outputs=onboarding_overlay) |
| def _versionshistorie_oeffnen(): |
| text, rest = baue_versionshistorie_text(15) |
| return gr.update(visible=True), text, gr.update(visible=rest > 0), 15 |
| btn_versionshistorie.click(_versionshistorie_oeffnen, inputs=None, outputs=[versionshistorie_overlay, versionshistorie_md, btn_versionshistorie_mehr, versionshistorie_anzahl_state]) |
| def _versionshistorie_mehr_laden(anzahl): |
| neue_anzahl = anzahl + 15 |
| text, rest = baue_versionshistorie_text(neue_anzahl) |
| return text, gr.update(visible=rest > 0), neue_anzahl |
| btn_versionshistorie_mehr.click(_versionshistorie_mehr_laden, inputs=versionshistorie_anzahl_state, outputs=[versionshistorie_md, btn_versionshistorie_mehr, versionshistorie_anzahl_state], show_progress="hidden") |
| btn_versionshistorie_schliessen.click(lambda: {versionshistorie_overlay: gr.update(visible=False)}, inputs=None, outputs=versionshistorie_overlay) |
|
|
| modus_cb.change(change_modus, inputs=[current_zoo, modus_cb], outputs=ui_outs) |
|
|
| def zeige_pflege_upload(k, m): |
| return gr.update(visible=(k == "Pflegetier" and bool(m))) |
| kat_f.change(zeige_pflege_upload, inputs=[kat_f, modus_cb], outputs=row_pflege_upload) |
| modus_cb.change(zeige_pflege_upload, inputs=[kat_f, modus_cb], outputs=row_pflege_upload) |
|
|
| def zeige_tagebuch_button(m): |
| |
| |
| |
| return gr.update(visible=False) |
| pflege_vertrag_neu.change(lambda f: gr.update(value=f), inputs=pflege_vertrag_neu, outputs=vp_img) |
|
|
| konto_toggle_state = gr.State("") |
| def toggle_konto_box(ziel, aktuell): |
| neu = "" if aktuell == ziel else ziel |
| boxen = {"news": admin_news_box, "users": admin_users_box, "fb": konto_fb_box, "erinn": konto_erinn_box, "pw": konto_pw_box, "del": konto_del_box, "feedback_liste": admin_feedback_liste_box} |
| updates = {box: gr.update(visible=(key == neu)) for key, box in boxen.items()} |
| updates[konto_toggle_state] = neu |
| return updates |
| konto_toggle_outs = [konto_toggle_state, admin_news_box, admin_users_box, konto_fb_box, konto_erinn_box, konto_pw_box, konto_del_box, admin_feedback_liste_box] |
| _konto_toggle_events = [] |
| _e = btn_show_news.click(lambda a: toggle_konto_box("news", a), inputs=konto_toggle_state, outputs=konto_toggle_outs, cancels=_konto_toggle_events.copy()) |
| _konto_toggle_events.append(_e) |
| _e = btn_show_users.click(lambda a: toggle_konto_box("users", a), inputs=konto_toggle_state, outputs=konto_toggle_outs, cancels=_konto_toggle_events.copy()).then(lambda: (erstelle_nutzerliste(),) + nutzer_dropdowns_update(), inputs=None, outputs=[nutzerliste_html] + dropdown_outs) |
| _konto_toggle_events.append(_e) |
| _e = btn_show_fb.click(lambda a: toggle_konto_box("fb", a), inputs=konto_toggle_state, outputs=konto_toggle_outs, cancels=_konto_toggle_events.copy()) |
| _konto_toggle_events.append(_e) |
| _e = btn_show_erinn.click(lambda a: toggle_konto_box("erinn", a), inputs=konto_toggle_state, outputs=konto_toggle_outs, cancels=_konto_toggle_events.copy()) |
| _konto_toggle_events.append(_e) |
| _e = btn_show_pw.click(lambda a: toggle_konto_box("pw", a), inputs=konto_toggle_state, outputs=konto_toggle_outs, cancels=_konto_toggle_events.copy()) |
| _konto_toggle_events.append(_e) |
| _e = btn_show_del.click(lambda a: toggle_konto_box("del", a), inputs=konto_toggle_state, outputs=konto_toggle_outs, cancels=_konto_toggle_events.copy()) |
| _konto_toggle_events.append(_e) |
| _e = btn_show_feedback_liste.click(lambda a: toggle_konto_box("feedback_liste", a), inputs=konto_toggle_state, outputs=konto_toggle_outs, cancels=_konto_toggle_events.copy()).then(lambda: (erstelle_feedback_liste(), gr.update(choices=erstelle_feedback_dropdown_choices(), value=None)), inputs=None, outputs=[feedback_liste_html, feedback_loeschen_dd], show_progress="hidden") |
| _konto_toggle_events.append(_e) |
| btn_feedback_loeschen.click(feedback_als_erledigt, inputs=feedback_loeschen_dd, outputs=[feedback_loeschen_status, feedback_liste_html, feedback_loeschen_dd], show_progress="hidden").then(zeige_als_toast, inputs=feedback_loeschen_status, outputs=None) |
|
|
| HAUPT_MAP = {"start": (haupt_start, btn_haupt_start), "verwaltung": (haupt_verwaltung, btn_haupt_verwaltung), "vermisst": (haupt_vermisst, btn_haupt_vermisst), "konto": (haupt_konto, btn_haupt_konto)} |
| def zeige_haupt(ziel): |
| _force = f"force-{datetime.now().timestamp()}" |
| updates = {} |
| for key, (col, btn) in HAUPT_MAP.items(): |
| updates[col] = gr.update(visible=(key == ziel), elem_classes=[_force] if key == ziel else []) |
| updates[btn] = gr.update(variant="primary" if key == ziel else "secondary") |
| return updates |
| haupt_outs = [c for pair in HAUPT_MAP.values() for c in pair] |
|
|
|
|
| DETAIL_MAP = {"stam": (acc_stam, btn_show_stam), "futter": (acc_futter, btn_show_futter), "gesund": (acc_gesund, btn_show_gesund), "doku": (doc_accordion, btn_show_doku), "tagebuch": (acc_tagebuch, btn_show_tagebuch)} |
| def zeige_detail(ziel): |
| _force = f"force-{datetime.now().timestamp()}" |
| updates = {} |
| for key, (col, btn) in DETAIL_MAP.items(): |
| updates[col] = gr.update(visible=(key == ziel), elem_classes=[_force] if key == ziel else []) |
| updates[btn] = gr.update(variant="primary" if key == ziel else "secondary") |
| return updates |
| detail_outs = [c for pair in DETAIL_MAP.values() for c in pair] |
| _detail_events = [] |
|
|
| KONTO_TAB_MAP = {"einst": (konto_tab_einstellungen, btn_konto_tab_einst), "admin": (konto_tab_admin, btn_konto_tab_admin)} |
| def zeige_konto_tab(ziel): |
| updates = {} |
| for key, (col, btn) in KONTO_TAB_MAP.items(): |
| updates[col] = gr.update(visible=(key == ziel)) |
| updates[btn] = gr.update(variant="primary" if key == ziel else "secondary") |
| return updates |
| konto_tab_outs = [c for pair in KONTO_TAB_MAP.values() for c in pair] |
| _konto_tab_events = [] |
| _e = btn_konto_tab_einst.click(lambda: zeige_konto_tab("einst"), inputs=None, outputs=konto_tab_outs, cancels=_konto_tab_events.copy()) |
| _konto_tab_events.append(_e) |
| _e = btn_konto_tab_admin.click(lambda: zeige_konto_tab("admin"), inputs=None, outputs=konto_tab_outs, cancels=_konto_tab_events.copy()) |
| _konto_tab_events.append(_e) |
|
|
| |
| |
| t_ins = [current_zoo, tier_dropdown] |
| t_o1 = [name_f, kat_f, tierart_f, rasse_f, geb_datum_f, farbe_f, kastriert_f, gew_f, chip_f, tasso_f, kontakt_tel_f, kontakt_tel2_f] |
| t_o2 = [barf_cb, trock_f, tf_phase_hinweis, typ_f, gramm_f, prot_f, kohl_f, nass_cb, nass_f, nass_gramm_f, impf_f, wurm_f, para_f] |
| t_o3 = [para_int_f, tf_gramm_f, profil_img, hidden_img_data, allergien_f, vp_img, vp_h, vv_img, vv_h, t1_img, t1_h] |
| t_o4 = [t2_img, t2_h, t3_img, t3_h, t4_img, t4_h, t5_img, t5_h, t6_img, t6_h, acc_stam, tier_inhalt_wrapper, group_trocken, group_barf, group_nass, bestand_karte_html, anstehend_karte_html] |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _e = tier_dropdown.select(load_tier, inputs=t_ins, outputs=t_o1 + t_o2 + t_o3 + t_o4, show_progress="hidden").then(lambda: zeige_detail("stam"), inputs=None, outputs=detail_outs, cancels=_detail_events.copy()) |
|
|
| |
| |
| |
| |
| |
| def oeffne_tier_stam_fabrik(tier_name, ziel_tab="stam"): |
| def _oeffnen(zoo_name): |
| d = {} |
| d.update(zeige_haupt("verwaltung")) |
| d.update(zeige_bereich("tiere")) |
| d[bestand_karte_html] = erstelle_bestand_karte(zoo_name) |
| d[anstehend_karte_html] = erstelle_anstehend_karte(zoo_name) |
| d[tier_dropdown] = gr.update(value=tier_name) |
| tier_werte = load_tier(zoo_name, tier_name) |
| d.update(dict(zip(t_o1 + t_o2 + t_o3 + t_o4, tier_werte))) |
| d.update(zeige_detail(ziel_tab)) |
| return d |
| return _oeffnen |
| def _neues_tier_oeffnen(zoo_name): |
| d = {} |
| d.update(zeige_haupt("verwaltung")) |
| d.update(zeige_bereich("tiere")) |
| d[bestand_karte_html] = erstelle_bestand_karte(zoo_name) |
| d[anstehend_karte_html] = erstelle_anstehend_karte(zoo_name) |
| d[tier_dropdown] = gr.update(value="Neues Tier...") |
| tier_werte = load_tier(zoo_name, "Neues Tier...") |
| d.update(dict(zip(t_o1 + t_o2 + t_o3 + t_o4, tier_werte))) |
| d.update(zeige_detail("stam")) |
| return d |
| _detail_events.append(_e) |
|
|
| sv_ins1 = [current_zoo, tier_dropdown, name_f, kat_f, tierart_f, rasse_f, geb_datum_f, farbe_f, kastriert_f, gew_f, chip_f] |
| sv_ins2 = [tasso_f, kontakt_tel_f, kontakt_tel2_f, barf_cb, trock_f, typ_f, gramm_f, prot_f, kohl_f, nass_cb, nass_f, nass_gramm_f, impf_f, wurm_f, para_f, para_int_f] |
| sv_ins3 = [tf_gramm_f, profil_img, hidden_img_data, vp_img, vp_h, vv_img, vv_h, t1_img, t1_h, t2_img, t2_h] |
| sv_ins4 = [t3_img, t3_h, t4_img, t4_h, t5_img, t5_h, t6_img, t6_h, allergien_f] |
| sv_outs = [status, tier_dropdown, move_dropdown, sel_pdf_own, sel_pdf_foster, sel_pdf_archiv, profil_img, hidden_img_data, acc_stam, dash_html] |
| _e = save_btn.click(save_data, inputs=sv_ins1 + sv_ins2 + sv_ins3 + sv_ins4, outputs=sv_outs, show_progress="hidden").then(zeige_als_toast, inputs=status, outputs=None).then(lambda: zeige_detail("stam"), inputs=None, outputs=detail_outs, cancels=_detail_events.copy()) |
| _detail_events.append(_e) |
|
|
| _e = btn_show_stam.click(lambda: zeige_detail("stam"), inputs=None, outputs=detail_outs, cancels=_detail_events.copy()) |
| _detail_events.append(_e) |
| _e = btn_show_futter.click(lambda: zeige_detail("futter"), inputs=None, outputs=detail_outs, cancels=_detail_events.copy()) |
| _detail_events.append(_e) |
| _e = btn_show_gesund.click(lambda: zeige_detail("gesund"), inputs=None, outputs=detail_outs, cancels=_detail_events.copy()) |
| _detail_events.append(_e) |
| _e = btn_show_doku.click(lambda: zeige_detail("doku"), inputs=None, outputs=detail_outs, cancels=_detail_events.copy()) |
| _detail_events.append(_e) |
| _e = btn_tagebuch_oeffnen.click(lambda: zeige_detail("tagebuch"), inputs=None, outputs=detail_outs, cancels=_detail_events.copy()).then(lambda rz: (8, (rz or 0) + 1, None), inputs=tagebuch_refresh_zaehler, outputs=[tagebuch_anzahl_state, tagebuch_refresh_zaehler, tagebuch_editier_zeile_state], show_progress="hidden") |
| _detail_events.append(_e) |
| modus_cb.change(zeige_tagebuch_button, inputs=modus_cb, outputs=btn_tagebuch_oeffnen).then(lambda: zeige_detail("stam"), inputs=None, outputs=detail_outs, cancels=_detail_events.copy()) |
|
|
| BEREICH_MAP = {"tiere": (sektion_tiere, btn_bereich_tiere), "scanner": (sektion_scanner, btn_bereich_scanner), "archiv": (sektion_archiv, btn_bereich_archiv), "inventar": (sektion_inventar, btn_bereich_inventar), "apotheke": (sektion_apotheke, btn_bereich_apotheke), "pdf": (sektion_pdf_export, btn_bereich_pdf), "nachtrag": (sektion_nachtrag, btn_bereich_nachtrag), "tagebuch_uebersicht": (sektion_tagebuch_uebersicht, btn_bereich_tagebuch)} |
| def zeige_bereich(ziel): |
| _force = f"force-{datetime.now().timestamp()}" |
| updates = {} |
| for key, (col, btn) in BEREICH_MAP.items(): |
| updates[col] = gr.update(visible=(key == ziel), elem_classes=[_force] if key == ziel else []) |
| updates[btn] = gr.update(variant="primary" if key == ziel else "secondary") |
| return updates |
| bereich_outs = [c for pair in BEREICH_MAP.values() for c in pair] |
|
|
|
|
|
|
| doc_type_select.change(on_doc_type_change, inputs=[doc_type_select, modus_cb], outputs=[col_vp, col_vv, col_t1, col_t2, col_t3, col_t4, col_t5, col_t6]) |
| tier_filter.change(filter_tiere, inputs=[current_zoo, tier_filter], outputs=tier_dropdown) |
| tierart_f.change(filter_futter_nach_tierart_ui, inputs=[current_zoo, tierart_f], outputs=trock_f, show_progress="hidden") |
| tierart_f.change(filter_nassfutter_nach_tierart_ui, inputs=[current_zoo, tierart_f], outputs=nass_f, show_progress="hidden") |
| tierart_f.change(toggle_futterarten_verfuegbarkeit, inputs=[tierart_f, barf_cb, nass_cb], outputs=[barf_cb, group_trocken, group_barf, nass_cb, group_nass], show_progress="hidden") |
| tierart_f.change(toggle_id_felder, inputs=tierart_f, outputs=[kastriert_f, chip_f, tasso_f], show_progress="hidden") |
| |
| s_outs = [scan_bc, scan_hersteller, scan_sorte, scan_gewicht, scan_phase, scan_k1, scan_g1, scan_k2, scan_g2, scan_k3, scan_g3, scan_k4, scan_g4, scan_raw] |
| btn_scan.click(scanne_und_suche, inputs=[futter_bild_1, futter_bild_2, futter_bild_3], outputs=s_outs) |
|
|
| def toggle_scan_bilder(offen): |
| neu = not offen |
| return {scan_bild_offen: neu, scan_bild_gruppe: gr.update(visible=neu)} |
| btn_scan_toggle.click(toggle_scan_bilder, inputs=scan_bild_offen, outputs=[scan_bild_offen, scan_bild_gruppe]) |
|
|
| futter_slot_outs = [x for i in range(FUTTER_SLOT_ANZAHL) for x in (futter_slot_rows[i], futter_slot_labels[i], futter_slot_sorten[i])] |
|
|
| def reset_alle_untertabs(): |
| updates = {} |
| updates.update(zeige_detail("stam")) |
| updates.update(zeige_konto_tab("einst")) |
| for _i in range(FUTTER_SLOT_ANZAHL): |
| updates[futter_slot_aufstock_rows[_i]] = gr.update(visible=False) |
| updates[futter_slot_edit_rows[_i]] = gr.update(visible=False) |
| updates[futter_slot_mehr_rows[_i]] = gr.update(visible=False) |
| updates[konto_toggle_state] = "" |
| updates[admin_news_box] = gr.update(visible=False) |
| updates[admin_users_box] = gr.update(visible=False) |
| updates[konto_fb_box] = gr.update(visible=False) |
| updates[konto_erinn_box] = gr.update(visible=False) |
| updates[konto_pw_box] = gr.update(visible=False) |
| updates[konto_del_box] = gr.update(visible=False) |
| return updates |
| reset_untertab_outs = (detail_outs + konto_tab_outs |
| + futter_slot_aufstock_rows + futter_slot_edit_rows + futter_slot_mehr_rows |
| + konto_toggle_outs) |
|
|
| def haupt_klick_kombiniert(ziel): |
| d = {} |
| d.update(zeige_haupt(ziel)) |
| d.update(reset_alle_untertabs()) |
| return d |
| haupt_combo_outs = haupt_outs + reset_untertab_outs |
| tier_liste_sprung_outs = haupt_combo_outs + bereich_outs + [bestand_karte_html, anstehend_karte_html, tier_dropdown] + t_o1 + t_o2 + t_o3 + t_o4 + detail_outs |
| _haupt_events = [] |
| def haupt_verwaltung_klick_kombiniert(zoo_name): |
| d = haupt_klick_kombiniert("verwaltung") |
| d.update(zeige_bereich("tiere")) |
| tier_werte = load_tier(zoo_name, "Hier Auswählen") |
| d.update(dict(zip(t_o1 + t_o2 + t_o3 + t_o4, tier_werte))) |
| d[tier_dropdown] = gr.update(value="Hier Auswählen") |
| return d |
| _e = btn_haupt_verwaltung.click(haupt_verwaltung_klick_kombiniert, inputs=current_zoo, outputs=haupt_combo_outs + bereich_outs + [tier_dropdown] + t_o1 + t_o2 + t_o3 + t_o4, cancels=_haupt_events.copy()) |
| _haupt_events.append(_e) |
| def start_klick_kombiniert(zoo_name): |
| d = haupt_klick_kombiniert("start") |
| d[start_bestand_html] = erstelle_bestand_karte(zoo_name) |
| d[start_anstehend_html] = erstelle_anstehend_karte(zoo_name) |
| return d |
| _e = btn_haupt_start.click(start_klick_kombiniert, inputs=current_zoo, outputs=haupt_combo_outs + [start_bestand_html, start_anstehend_html], cancels=_haupt_events.copy()) |
| _haupt_events.append(_e) |
| _e = btn_haupt_vermisst.click(lambda: haupt_klick_kombiniert("vermisst"), inputs=None, outputs=haupt_combo_outs, cancels=_haupt_events.copy()) |
| _haupt_events.append(_e) |
| _e = btn_haupt_konto.click(lambda: haupt_klick_kombiniert("konto"), inputs=None, outputs=haupt_combo_outs, cancels=_haupt_events.copy()) |
| _haupt_events.append(_e) |
| _e = btn_start_vermisst.click(lambda: haupt_klick_kombiniert("vermisst"), inputs=None, outputs=haupt_combo_outs, cancels=_haupt_events.copy()) |
| _haupt_events.append(_e) |
|
|
| def bereich_klick_kombiniert(ziel): |
| d = {} |
| d.update(zeige_bereich(ziel)) |
| d.update(reset_alle_untertabs()) |
| return d |
| bereich_combo_outs = bereich_outs + reset_untertab_outs |
| def bereich_tiere_klick_kombiniert(zoo_name): |
| d = bereich_klick_kombiniert("tiere") |
| d[bestand_karte_html] = erstelle_bestand_karte(zoo_name) |
| d[anstehend_karte_html] = erstelle_anstehend_karte(zoo_name) |
| return d |
| def bereich_inventar_klick_kombiniert(zoo_name): |
| d = bereich_klick_kombiniert("inventar") |
| d[dash_html] = erstelle_dashboard(zoo_name) |
| d.update(dict(zip(futter_slot_outs, fuelle_futter_slots(zoo_name)))) |
| return d |
| |
| |
| |
| |
| |
| |
| |
| def oeffne_tier_tagebuch_fabrik(tier_name): |
| def _oeffnen(zoo_name, rz): |
| d = bereich_tiere_klick_kombiniert(zoo_name) |
| d[tier_dropdown] = gr.update(value=tier_name) |
| tier_werte = load_tier(zoo_name, tier_name) |
| d.update(dict(zip(t_o1 + t_o2 + t_o3 + t_o4, tier_werte))) |
| d.update(zeige_detail("tagebuch")) |
| d[tagebuch_anzahl_state] = 8 |
| d[tagebuch_refresh_zaehler] = (rz or 0) + 1 |
| d[tagebuch_editier_zeile_state] = None |
| return d |
| return _oeffnen |
| tagebuch_uebersicht_sprung_outs = bereich_combo_outs + [bestand_karte_html, anstehend_karte_html, tier_dropdown] + t_o1 + t_o2 + t_o3 + t_o4 + detail_outs + [tagebuch_anzahl_state, tagebuch_refresh_zaehler, tagebuch_editier_zeile_state] |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| def nav_futter_klick_kombiniert(zoo_name): |
| d = haupt_klick_kombiniert("verwaltung") |
| d.update(bereich_klick_kombiniert("scanner")) |
| return d |
| nav_futter_outs = haupt_combo_outs + bereich_combo_outs |
| _bereich_events = [] |
| _e = btn_bereich_tiere.click(bereich_tiere_klick_kombiniert, inputs=current_zoo, outputs=bereich_combo_outs + [bestand_karte_html, anstehend_karte_html], cancels=_bereich_events.copy()) |
| _bereich_events.append(_e) |
| _e = btn_bereich_scanner.click(lambda: bereich_klick_kombiniert("scanner"), inputs=None, outputs=bereich_combo_outs, cancels=_bereich_events.copy()) |
| _bereich_events.append(_e) |
| _e = btn_bereich_archiv.click(lambda: bereich_klick_kombiniert("archiv"), inputs=None, outputs=bereich_combo_outs, cancels=_bereich_events.copy()) |
| _bereich_events.append(_e) |
| _e = btn_bereich_inventar.click(bereich_inventar_klick_kombiniert, inputs=current_zoo, outputs=bereich_combo_outs + [dash_html] + futter_slot_outs, cancels=_bereich_events.copy()) |
| _bereich_events.append(_e) |
| def bereich_apotheke_klick_kombiniert(): |
| d = bereich_klick_kombiniert("apotheke") |
| d[apotheke_tierart_filter_dd] = gr.update(choices=["Alle Tierarten"] + lade_apotheke_kategorien()) |
| return d |
| _e = btn_bereich_apotheke.click(bereich_apotheke_klick_kombiniert, inputs=None, outputs=bereich_combo_outs + [apotheke_tierart_filter_dd], cancels=_bereich_events.copy()) |
| _bereich_events.append(_e) |
|
|
| |
| |
| |
| |
| |
| def nav_futtervorrat_klick_kombiniert(zoo_name): |
| d = haupt_klick_kombiniert("verwaltung") |
| d.update(bereich_inventar_klick_kombiniert(zoo_name)) |
| return d |
| nav_futtervorrat_outs = haupt_combo_outs + bereich_combo_outs + [dash_html] + futter_slot_outs |
| def nav_apotheke_klick_kombiniert(zoo_name): |
| d = haupt_klick_kombiniert("verwaltung") |
| d.update(bereich_apotheke_klick_kombiniert()) |
| return d |
| nav_apotheke_outs = haupt_combo_outs + bereich_combo_outs + [apotheke_tierart_filter_dd] |
| _e = btn_start_konto.click(nav_apotheke_klick_kombiniert, inputs=current_zoo, outputs=nav_apotheke_outs, cancels=_haupt_events.copy()) |
| _haupt_events.append(_e) |
| _e = btn_haupt_futtervorrat.click(nav_apotheke_klick_kombiniert, inputs=current_zoo, outputs=nav_apotheke_outs, cancels=_haupt_events.copy()) |
| _haupt_events.append(_e) |
| _e = btn_bereich_pdf.click(lambda: bereich_klick_kombiniert("pdf"), inputs=None, outputs=bereich_combo_outs, cancels=_bereich_events.copy()) |
| _bereich_events.append(_e) |
| _e = btn_bereich_nachtrag.click(lambda: bereich_klick_kombiniert("nachtrag"), inputs=None, outputs=bereich_combo_outs, cancels=_bereich_events.copy()) |
| _bereich_events.append(_e) |
| _e = btn_bereich_tagebuch.click(lambda rz: dict(bereich_klick_kombiniert("tagebuch_uebersicht"), **{tb_uebersicht_refresh: (rz or 0) + 1}), inputs=tb_uebersicht_refresh, outputs=bereich_combo_outs + [tb_uebersicht_refresh], cancels=_bereich_events.copy()) |
| _bereich_events.append(_e) |
| btn_apotheke_speichern.click(apotheke_eintrag_speichern, inputs=[current_zoo, apotheke_neu_kategorie_f, apotheke_neu_titel_f, apotheke_neu_inhalt_f, apotheke_refresh_zaehler], outputs=[apotheke_status, apotheke_neu_kategorie_f, apotheke_neu_titel_f, apotheke_neu_inhalt_f, apotheke_refresh_zaehler, apotheke_offene_kategorie_state], show_progress="hidden").then(zeige_als_toast, inputs=apotheke_status, outputs=None).then(lambda: gr.update(choices=["Alle Tierarten"] + lade_apotheke_kategorien()), inputs=None, outputs=apotheke_tierart_filter_dd, show_progress="hidden") |
| apotheke_banner_cb.change(apotheke_banner_umschalten, inputs=apotheke_banner_cb, outputs=apotheke_banner_img, show_progress="hidden") |
| |
| |
| apotheke_editier_zeile_state.change(lambda z: gr.update(visible=(z is None)), inputs=apotheke_editier_zeile_state, outputs=apotheke_admin_box, show_progress="hidden") |
| s_in = [current_zoo, scan_bc, scan_hersteller, scan_sorte, scan_gewicht, scan_phase, scan_tierart, scan_futtertyp, scan_k1, scan_g1, scan_k2, scan_g2, scan_k3, scan_g3, scan_k4, scan_g4] |
| btn_save_futter.click(speichere_inventar, inputs=s_in, outputs=[scan_status, typ_f, trock_f, edit_f_dd, dash_aufstock_dd], show_progress="hidden").then(zeige_als_toast, inputs=scan_status, outputs=None).then(erstelle_dashboard, inputs=current_zoo, outputs=dash_html).then(fuelle_futter_slots, inputs=current_zoo, outputs=futter_slot_outs) |
|
|
| SCROLL_ZU_DASHBOARD_JS = """ |
| () => { |
| const el = document.getElementById('dash_html_anchor'); |
| if (el) { el.scrollIntoView({behavior: 'smooth', block: 'start'}); } |
| } |
| """ |
|
|
|
|
| def oeffne_inline_aufstock(_i): |
| def fn(): |
| return {futter_slot_aufstock_rows[_i]: gr.update(visible=True), futter_slot_edit_rows[_i]: gr.update(visible=False), futter_slot_mehr_rows[_i]: gr.update(visible=False)} |
| return fn |
| for _i in range(FUTTER_SLOT_ANZAHL): |
| futter_slot_aufstock_btns[_i].click(oeffne_inline_aufstock(_i), inputs=None, outputs=[futter_slot_aufstock_rows[_i], futter_slot_edit_rows[_i], futter_slot_mehr_rows[_i]]) |
| futter_slot_aufstock_close[_i].click(lambda: gr.update(visible=False), inputs=None, outputs=futter_slot_aufstock_rows[_i]) |
| futter_slot_aufstock_confirm[_i].click(inline_aufstocken, inputs=[current_zoo, futter_slot_sorten[_i], futter_slot_aufstock_menge[_i]], outputs=[dash_html] + futter_slot_outs).then(lambda: gr.update(visible=False), inputs=None, outputs=futter_slot_aufstock_rows[_i]) |
|
|
| def oeffne_inline_edit(_i): |
| def fn(zoo_name, sorte): |
| bestand, sack = hole_bestand_sackgroesse(zoo_name, sorte) |
| return {futter_slot_edit_rows[_i]: gr.update(visible=True), futter_slot_aufstock_rows[_i]: gr.update(visible=False), futter_slot_mehr_rows[_i]: gr.update(visible=False), |
| futter_slot_edit_bestand[_i]: bestand, futter_slot_edit_sack[_i]: sack} |
| return fn |
| for _i in range(FUTTER_SLOT_ANZAHL): |
| futter_slot_edit_btns[_i].click(oeffne_inline_edit(_i), inputs=[current_zoo, futter_slot_sorten[_i]], outputs=[futter_slot_edit_rows[_i], futter_slot_aufstock_rows[_i], futter_slot_mehr_rows[_i], futter_slot_edit_bestand[_i], futter_slot_edit_sack[_i]]) |
| futter_slot_edit_close[_i].click(lambda: gr.update(visible=False), inputs=None, outputs=futter_slot_edit_rows[_i]) |
| futter_slot_edit_confirm[_i].click(inline_bestand_setzen, inputs=[current_zoo, futter_slot_sorten[_i], futter_slot_edit_bestand[_i], futter_slot_edit_sack[_i]], outputs=[dash_html] + futter_slot_outs).then(lambda: gr.update(visible=False), inputs=None, outputs=futter_slot_edit_rows[_i]) |
| futter_slot_edit_del[_i].click(inline_loeschen, inputs=[current_zoo, futter_slot_sorten[_i]], outputs=[dash_html] + futter_slot_outs).then(lambda: gr.update(visible=False), inputs=None, outputs=futter_slot_edit_rows[_i]) |
|
|
| def oeffne_inline_mehr(_i): |
| def fn(zoo_name, sorte): |
| felder = load_futter_edit(zoo_name, sorte) |
| result = {futter_slot_mehr_rows[_i]: gr.update(visible=True), futter_slot_edit_rows[_i]: gr.update(visible=False), futter_slot_aufstock_rows[_i]: gr.update(visible=False)} |
| for komponente, wert in zip(futter_slot_mehr_fields[_i], felder): |
| result[komponente] = wert |
| return result |
| return fn |
| for _i in range(FUTTER_SLOT_ANZAHL): |
| futter_slot_edit_more[_i].click(oeffne_inline_mehr(_i), inputs=[current_zoo, futter_slot_sorten[_i]], outputs=[futter_slot_mehr_rows[_i], futter_slot_edit_rows[_i], futter_slot_aufstock_rows[_i]] + futter_slot_mehr_fields[_i]) |
| futter_slot_mehr_close[_i].click(lambda: gr.update(visible=False), inputs=None, outputs=futter_slot_mehr_rows[_i]) |
| futter_slot_mehr_speichern[_i].click(inline_mehr_speichern, inputs=[current_zoo, futter_slot_sorten[_i]] + futter_slot_mehr_fields[_i], outputs=[dash_html] + futter_slot_outs).then(lambda: gr.update(visible=False), inputs=None, outputs=futter_slot_mehr_rows[_i]) |
|
|
| btn_set_save.click(speichere_einstellungen, inputs=[current_zoo, set_email], outputs=set_status).then(zeige_als_toast, inputs=set_status, outputs=None) |
| btn_impf.click(get_heute, outputs=impf_f); btn_wurm.click(get_heute, outputs=wurm_f); btn_para.click(get_heute, outputs=para_f) |
| btn_ics_impf.click(lambda n, d: erstelle_erinnerung(n, d, "Impfung"), [name_f, impf_f], dl_impf, show_progress="hidden") |
| btn_ics_wurm.click(lambda n, d: erstelle_erinnerung(n, d, "Wurmkur"), [name_f, wurm_f], dl_wurm, show_progress="hidden") |
| btn_ics_para.click(lambda n, d, i: erstelle_erinnerung(n, d, "Parasitenschutz", i), [name_f, para_f, para_int_f], dl_para, show_progress="hidden") |
| btn_email_impf.click(lambda z, n, d: erinnerung_per_email(z, n, "Impfung", d), [current_zoo, name_f, impf_f], email_impf_status, show_progress="hidden").then(zeige_als_toast, inputs=email_impf_status, outputs=None) |
| btn_email_wurm.click(lambda z, n, d: erinnerung_per_email(z, n, "Wurmkur", d), [current_zoo, name_f, wurm_f], email_wurm_status, show_progress="hidden").then(zeige_als_toast, inputs=email_wurm_status, outputs=None) |
| btn_email_para.click(lambda z, n, d: erinnerung_per_email(z, n, "Parasitenschutz", d), [current_zoo, name_f, para_f], email_para_status, show_progress="hidden").then(zeige_als_toast, inputs=email_para_status, outputs=None) |
| barf_cb.change(lambda is_barf: {group_trocken: gr.update(visible=not is_barf), group_barf: gr.update(visible=is_barf)}, barf_cb, [group_trocken, group_barf]) |
| nass_cb.change(lambda is_nass: {group_nass: gr.update(visible=is_nass)}, nass_cb, group_nass) |
| for f in [gew_f, barf_cb, typ_f, tierart_f]: f.change(berechne_barf, [current_zoo, gew_f, barf_cb, typ_f, tierart_f], [gramm_f, prot_f, kohl_f], show_progress="hidden") |
| gew_f.change(berechne_trockenfutter, [current_zoo, gew_f, trock_f, tf_gramm_f, tierart_f], tf_gramm_f, show_progress="hidden") |
| trock_f.change(berechne_trockenfutter, [current_zoo, gew_f, trock_f, tf_gramm_f, tierart_f], tf_gramm_f, show_progress="hidden") |
| trock_f.change(zeige_futter_phase, [current_zoo, trock_f], tf_phase_hinweis, show_progress="hidden") |
| gew_f.change(berechne_trockenfutter, [current_zoo, gew_f, nass_f, nass_gramm_f, tierart_f], nass_gramm_f, show_progress="hidden") |
| nass_f.change(berechne_trockenfutter, [current_zoo, gew_f, nass_f, nass_gramm_f, tierart_f], nass_gramm_f, show_progress="hidden") |
| tierart_f.change(berechne_trockenfutter, [current_zoo, gew_f, trock_f, tf_gramm_f, tierart_f], tf_gramm_f, show_progress="hidden") |
| tierart_f.change(berechne_trockenfutter, [current_zoo, gew_f, nass_f, nass_gramm_f, tierart_f], nass_gramm_f, show_progress="hidden") |
| |
| btn_pdf_own.click(lambda z, s, i, p, v, t, tb: export_pdf(z, s, False, i, p, v, t, tb), [current_zoo, sel_pdf_own, pdf_mit_info, pdf_mit_pflege, pdf_mit_vermittlung, pdf_mit_tierarzt, pdf_mit_tagebuch], [pdf_file, pdf_status]).then(zeige_als_toast, inputs=pdf_status, outputs=None) |
| btn_pdf_foster.click(lambda z, s, i, p, v, t, tb: export_pdf(z, s, False, i, p, v, t, tb), [current_zoo, sel_pdf_foster, pdf_mit_info, pdf_mit_pflege, pdf_mit_vermittlung, pdf_mit_tierarzt, pdf_mit_tagebuch], [pdf_file, pdf_status]).then(zeige_als_toast, inputs=pdf_status, outputs=None) |
| btn_tagebuch_speichern.click(tagebuch_eintrag_speichern, inputs=[current_zoo, name_f, tagebuch_titel_f, tagebuch_neu_f, tagebuch_foto_f, tagebuch_anzahl_state, tagebuch_refresh_zaehler], outputs=[tagebuch_status, tagebuch_titel_f, tagebuch_neu_f, tagebuch_foto_f, tagebuch_anzahl_state, tagebuch_refresh_zaehler], show_progress="hidden").then(zeige_als_toast, inputs=tagebuch_status, outputs=None) |
| |
| |
| |
| tagebuch_editier_zeile_state.change(lambda z: gr.update(visible=(z is None)), inputs=tagebuch_editier_zeile_state, outputs=tagebuch_neu_eintrag_box, show_progress="hidden") |
| btn_tagebuch_pdf.click(exportiere_tagebuch_pdf, inputs=[current_zoo, name_f], outputs=[tagebuch_pdf_datei, tagebuch_status], show_progress="hidden").then(zeige_als_toast, inputs=tagebuch_status, outputs=None) |
| btn_vermisst_daten.click(zeige_vermisst_daten, [v_name, v_tierart, v_rasse, v_farbe, v_chip, v_tasso, v_kontakt, v_kontakt2], vermisst_copy_text, show_progress="hidden") |
| btn_tasso_link.click(None, None, None, js=""" |
| () => { |
| const url = "https://www.tasso.net/Tierregister/Tier-vermisst/Tier-vermisst-melden"; |
| window.open(url, "_blank", "noopener,noreferrer"); |
| } |
| """) |
| btn_vermisst_pdf.click(export_vermisst_steckbrief, [current_zoo, v_name, v_tierart, v_rasse, v_farbe, v_chip, v_tasso, v_kontakt, v_kontakt2, vermisst_notiz_f, v_img], [vermisst_pdf_file, vermisst_pdf_status]).then(zeige_als_toast, inputs=vermisst_pdf_status, outputs=None) |
|
|
| def lade_tier_fuer_vermisst(zoo_name, tier_name): |
| leer = ("", "", "", "", "", "", "", "", gr.update(value=None), gr.update(visible=False)) |
| if not zoo_name or not tier_name or tier_name == "Hier Auswählen": |
| return leer |
| try: |
| rows = get_cached_data("tier") |
| row = next((r for r in rows if len(r) > 1 and str(r[1]).strip() == str(tier_name).strip() and is_zoo(r[0], zoo_name) and str(r[1]).strip().lower() != "name"), None) |
| if not row: return leer |
| while len(row) < 39: row.append("") |
| img_val, _ = load_doc(str(row[19])) |
| return row[1], row[2], row[3], row[21], row[5], row[6], von_text(row[37]), von_text(row[38]), img_val, gr.update(visible=True) |
| except: |
| return leer |
|
|
| vermisst_tier_dd.change(lade_tier_fuer_vermisst, inputs=[current_zoo, vermisst_tier_dd], outputs=[v_name, v_tierart, v_rasse, v_farbe, v_chip, v_tasso, v_kontakt, v_kontakt2, v_img, vermisst_gruppe], show_progress="hidden") |
| btn_pdf_archiv.click(lambda z, s, i, p, v, t, tb: export_pdf(z, s, True, i, p, v, t, tb), [current_zoo, sel_pdf_archiv, pdf_mit_info, pdf_mit_pflege, pdf_mit_vermittlung, pdf_mit_tierarzt, pdf_mit_tagebuch], [pdf_file, pdf_status]).then(zeige_als_toast, inputs=pdf_status, outputs=None) |
| btn_archive.click(archive_tier, inputs=[current_zoo, move_dropdown, a_grund, a_vertrag_img], outputs=[archive_status, tier_dropdown, move_dropdown, sel_pdf_own, sel_pdf_foster, sel_pdf_archiv]).then(zeige_als_toast, inputs=archive_status, outputs=None).then(lambda: {tier_inhalt_wrapper: gr.update(visible=False)}, inputs=None, outputs=tier_inhalt_wrapper) |
| def zeige_nachtrag_ziel(ziel): |
| ist_archiv = ziel and "Archiv" in ziel |
| return {nach_aktiv_dd: gr.update(visible=not ist_archiv), nach_archiv_dd: gr.update(visible=ist_archiv)} |
| nach_ziel_radio.change(zeige_nachtrag_ziel, inputs=nach_ziel_radio, outputs=[nach_aktiv_dd, nach_archiv_dd]) |
| btn_nach_save.click(nachtrag_speichern, inputs=[current_zoo, nach_ziel_radio, nach_aktiv_dd, nach_archiv_dd, nach_typ_dd, nach_img], outputs=[nach_status], show_progress="hidden").then(zeige_als_toast, inputs=nach_status, outputs=None) |
| |
| def nav_start_klick_kombiniert(zoo_name): |
| d = start_klick_kombiniert(zoo_name) |
| d[nav_popup_state] = "" |
| d[nav_popup_box] = gr.update(visible=False) |
| return d |
| btn_nav_start.click(nav_start_klick_kombiniert, inputs=current_zoo, outputs=haupt_combo_outs + [start_bestand_html, start_anstehend_html, nav_popup_state, nav_popup_box], cancels=_haupt_events.copy(), show_progress="hidden").then(None, None, None, js="() => { window.scrollTo({top: 0, behavior: 'smooth'}); }") |
|
|
| |
| |
| |
| |
| def _popup_umschalten_fabrik(ziel): |
| def _umschalten(aktueller_zustand): |
| neu = "" if aktueller_zustand == ziel else ziel |
| return neu, gr.update(visible=bool(neu)) |
| return _umschalten |
| btn_nav_tiere.click(_popup_umschalten_fabrik("tiere"), inputs=nav_popup_state, outputs=[nav_popup_state, nav_popup_box], show_progress="hidden") |
| btn_nav_futter.click(_popup_umschalten_fabrik("futter"), inputs=nav_popup_state, outputs=[nav_popup_state, nav_popup_box], show_progress="hidden") |
| btn_nav_tagebuch.click(_popup_umschalten_fabrik("tagebuch"), inputs=nav_popup_state, outputs=[nav_popup_state, nav_popup_box], show_progress="hidden") |
| btn_nav_konto.click(_popup_umschalten_fabrik("konto"), inputs=nav_popup_state, outputs=[nav_popup_state, nav_popup_box], show_progress="hidden") |
| btn_nav_menu.click(_popup_umschalten_fabrik("menu"), inputs=nav_popup_state, outputs=[nav_popup_state, nav_popup_box], show_progress="hidden") |
|
|
| def nav_konto_einst_klick_kombiniert(): |
| d = haupt_klick_kombiniert("konto") |
| d.update(zeige_konto_tab("einst")) |
| return d |
| def nav_konto_admin_klick_kombiniert(): |
| d = haupt_klick_kombiniert("konto") |
| d.update(zeige_konto_tab("admin")) |
| return d |
| nav_konto_outs = haupt_combo_outs + konto_tab_outs |
|
|
| def _mit_popup_schliessen(fn): |
| """Verpackt eine Navigations-Funktion so, dass sie das Popup GLEICHZEITIG |
| mit dem eigentlichen Sprung schließt - ein einziger Rutsch, ein einziges |
| Update. Vorher lief das über zwei Schritte (erst schließen, dann |
| springen) - dabei hat das Schließen (Ändern von nav_popup_state) das |
| @gr.render, das genau diesen Button enthält, sofort neu gezeichnet und |
| damit den Button MITTEN in seiner eigenen laufenden Aktion zerstört, |
| bevor der zweite Schritt ankam. Ergebnis: der Sprung ging verloren, man |
| landete wieder auf dem, was ohnehin schon sichtbar war (meist Start).""" |
| def _wrapped(*args): |
| d = fn(*args) |
| d[nav_popup_state] = "" |
| d[nav_popup_box] = gr.update(visible=False) |
| return d |
| return _wrapped |
|
|
| def render_nav_popup_inhalt(state, zoo_name): |
| if not state: |
| return |
| if state == "tiere": |
| gr.Markdown("**Tier wählen**", elem_classes="nav_popup_titel") |
| gruppen = _tiere_gruppiert(zoo_name) |
| if not gruppen: |
| gr.Markdown("<i>Noch keine Tiere angelegt.</i>") |
| for gruppen_name, tiere in gruppen: |
| gr.Markdown(f"*{gruppen_name}*", elem_classes="nav_popup_gruppe") |
| for name in tiere: |
| btn = gr.Button(name, elem_classes="nav_popup_item") |
| btn.click(lambda n=name: (f"tiere:{n}", gr.update(visible=True)), inputs=None, outputs=[nav_popup_state, nav_popup_box], show_progress="hidden") |
| btn_neu = gr.Button("+ Neues Tier anlegen", elem_classes="nav_popup_item") |
| btn_neu.click(_mit_popup_schliessen(_neues_tier_oeffnen), inputs=current_zoo, outputs=tier_liste_sprung_outs + [nav_popup_state, nav_popup_box], show_progress="hidden") |
|
|
| elif state.startswith("tiere:"): |
| name = state[6:] |
| gr.Markdown(f"**{name}** – was möchtest du anpassen?", elem_classes="nav_popup_titel") |
| for label, ziel in [("Stammdaten", "stam"), ("Fütterung", "futter"), ("Gesundheit", "gesund"), ("Dokumente", "doku"), ("Tagebuch", "tagebuch")]: |
| btn = gr.Button(label, elem_classes="nav_popup_item") |
| btn.click(_mit_popup_schliessen(oeffne_tier_stam_fabrik(name, ziel)), inputs=current_zoo, outputs=tier_liste_sprung_outs + [nav_popup_state, nav_popup_box], show_progress="hidden") |
| btn_back = gr.Button("← Zurück zur Tierliste", elem_classes="nav_popup_back") |
| btn_back.click(lambda: ("tiere", gr.update(visible=True)), inputs=None, outputs=[nav_popup_state, nav_popup_box], show_progress="hidden") |
|
|
| elif state == "tagebuch": |
| gr.Markdown("**Tagebuch – Tier wählen**", elem_classes="nav_popup_titel") |
| tiere = _tiere_mit_tagebuch_stand(zoo_name) |
| if not tiere: |
| gr.Markdown("<i>Noch keine Tiere angelegt.</i>") |
| for t in tiere: |
| label = f"{t['name']} ({t['anzahl']} Einträge)" if t["anzahl"] > 0 else f"{t['name']} (noch keine Einträge)" |
| btn = gr.Button(label, elem_classes="nav_popup_item") |
| btn.click(_mit_popup_schliessen(oeffne_tier_tagebuch_fabrik(t["name"])), inputs=[current_zoo, tagebuch_refresh_zaehler], outputs=tagebuch_uebersicht_sprung_outs + [nav_popup_state, nav_popup_box], show_progress="hidden") |
|
|
| elif state == "futter": |
| gr.Markdown("**Futter**", elem_classes="nav_popup_titel") |
| btn1 = gr.Button("Futter Scanner", elem_classes="nav_popup_item") |
| btn1.click(_mit_popup_schliessen(nav_futter_klick_kombiniert), inputs=current_zoo, outputs=nav_futter_outs + [nav_popup_state, nav_popup_box], show_progress="hidden") |
| btn2 = gr.Button("Futter Vorrat", elem_classes="nav_popup_item") |
| btn2.click(_mit_popup_schliessen(nav_futtervorrat_klick_kombiniert), inputs=current_zoo, outputs=nav_futtervorrat_outs + [nav_popup_state, nav_popup_box], show_progress="hidden") |
|
|
| elif state == "konto": |
| gr.Markdown("**Konto**", elem_classes="nav_popup_titel") |
| btn1 = gr.Button("Einstellungen", elem_classes="nav_popup_item") |
| btn1.click(_mit_popup_schliessen(lambda: nav_konto_einst_klick_kombiniert()), inputs=None, outputs=nav_konto_outs + [nav_popup_state, nav_popup_box], show_progress="hidden") |
| if ist_admin(zoo_name): |
| btn2 = gr.Button("Admin", elem_classes="nav_popup_item") |
| btn2.click(_mit_popup_schliessen(lambda: nav_konto_admin_klick_kombiniert()), inputs=None, outputs=nav_konto_outs + [nav_popup_state, nav_popup_box], show_progress="hidden") |
| btn3 = gr.Button("Abmelden", elem_classes="nav_popup_item nav_popup_danger") |
| btn3.click(_mit_popup_schliessen(lambda: dict(zip([current_zoo, login_screen, main_app, acc_status_md], logout()))), inputs=None, outputs=[current_zoo, login_screen, main_app, acc_status_md, nav_popup_state, nav_popup_box], show_progress="hidden") |
|
|
| elif state == "menu": |
| gr.Markdown("**Sonstiges**", elem_classes="nav_popup_titel") |
| for label, ziel in [("Archiv", "archiv"), ("PDF-Export", "pdf"), ("Vertrag nachtragen", "nachtrag")]: |
| btn = gr.Button(label, elem_classes="nav_popup_item") |
| def _fabrik(z=ziel): |
| def _f(zoo_name): |
| d = haupt_klick_kombiniert("verwaltung") |
| d.update(bereich_klick_kombiniert(z)) |
| return d |
| return _f |
| btn.click(_mit_popup_schliessen(_fabrik()), inputs=current_zoo, outputs=haupt_combo_outs + bereich_combo_outs + [nav_popup_state, nav_popup_box], show_progress="hidden") |
|
|
| demo.load(None, None, None, js=""" |
| () => { |
| document.documentElement.classList.remove('dark'); |
| document.body.classList.remove('dark'); |
| const container = document.querySelector('.gradio-container'); |
| if (container) { container.classList.remove('dark'); } |
| } |
| """) |
|
|
| |
| |
| |
| |
| _cron_out = gr.Textbox(visible=False, elem_id="cron_out_futter") |
| _cron_btn = gr.Button(visible=False, elem_id="cron_btn_futter") |
| _cron_btn.click(pruefe_und_sende_futter_erinnerungen, inputs=None, outputs=_cron_out) |
|
|
| |
| |
| |
| feedback_stand_state = gr.State(None) |
| feedback_popup_msg = gr.Textbox(visible=False) |
| feedback_timer = gr.Timer(30) |
| feedback_timer.tick(pruefe_neues_feedback, inputs=[current_zoo, feedback_stand_state], outputs=[feedback_stand_state, feedback_popup_msg], show_progress="hidden").then(zeige_als_toast, inputs=feedback_popup_msg, outputs=None) |
|
|
| if __name__ == "__main__": |
| |
| |
| |
| |
| demo.launch(ssr_mode=False, pwa=True, favicon_path="petbase_icon.png", prevent_thread_lock=True) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| @demo.app.get("/cron/futter-check") |
| @demo.app.post("/cron/futter-check") |
| def cron_futter_check_route(): |
| ergebnis = pruefe_und_sende_futter_erinnerungen() |
| return {"status": ergebnis} |
|
|
| |
| |
| while True: |
| time.sleep(3600) |
|
|