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 # PyMuPDF für PDF-Konvertierung # Globale Absicherung gegen unendliches Hängenbleiben bei Netzwerk-Aufrufen (z.B. # zu Google Sheets) - ohne das kann eine einzelne hängende Verbindung die GANZE # App beim Start lahmlegen, noch bevor Python/Gradio überhaupt eine Zeile Log # ausgibt (genau das ist am 2026-07-14 passiert: Space blieb bei "Restarting" # stehen, Container-Log zeigte NICHTS außer "Application Startup" - kein Fehler, # kein Absturz, einfach ewiges Warten auf eine Server-Antwort, die nie kam). # 30 Sekunden sind großzügig genug für normale Google-API-Aufrufe, verhindern # aber ein Hängenbleiben für immer - danach kommt wenigstens ein klarer Fehler # im Log statt stiller Funkstille. socket.setdefaulttimeout(30) # ========================================== # 1. AUTH, SETUP & CACHE # ========================================== # Google-Zugangsdaten: bevorzugt aus einem Secret (Umgebungsvariable) laden - das # ist der sichere Weg, da Secrets nie im Repo landen und damit nicht öffentlich # einsehbar sind (im Gegensatz zu einer hochgeladenen JSON-Datei, siehe der # Sicherheitsvorfall mit der geleakten Datei früher in diesem Projekt). Fällt auf # die alte datei-basierte Methode zurück, falls kein Secret gesetzt ist - dadurch # funktioniert derselbe Code sowohl auf der alten als auch auf der neuen Space. _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, []) # ========================================== # 2. ACCOUNT, LOGIN, NEWS & ADMIN STATS # ========================================== 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"
🐾 App-Reichweite

Registrierte Zoos: {zoo_count}
Gesamt-Logins aller Nutzer: {total_logins}
Versendete E-Mails gesamt: {email_zaehler()}

{email_status_text()}
" 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 "Noch kein Feedback eingegangen." html = "
" for r in reversed(eintraege): datum, von, thema, text = r[0], r[1], r[2], r[3] text_html = str(text).replace("\n", "
") html += f"""
{von}{datum}
{thema}
{text_html}
""" html += "
" return html except Exception as e: return f"Feedback-Liste konnte nicht geladen werden: {e}" 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 = "
" 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"""
{name}{admin_tag}{status_text}
📧 {email}  ·  🐾 {anzahl_tiere} Tier(e)  ·  🔑 {anzahl_logins}x eingeloggt  ·  🕐 zuletzt: {letzter_login}
""" html += "
" 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 "Noch keine registrierten Nutzer außer dir." return html except Exception as e: return f"Nutzerliste konnte nicht geladen werden: {e}" 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() # Nach erfolgreicher Registrierung direkt zurück zur Anmeldung springen, mit # bereits ausgefülltem Zoo-Namen - ein Klick weniger für den Nutzer. 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), "" # ========================================== # 3. SETTINGS, DASHBOARD & FEEDBACK # ========================================== 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() # Admin (PetBase) per E-Mail benachrichtigen, damit Feedback nicht erst beim # manuellen Nachschauen im Sheet auffällt. Fehlschlag hier (z.B. keine # E-Mail hinterlegt) soll das Feedback selbst nicht verhindern - daher # eigener try/except, der die Rückgabe nicht beeinflusst. 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: # Erster Aufruf nach Login: nur Basislinie setzen, kein Popup für # bereits vorhandenes/altes Feedback. 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 "Bitte logge dich ein." 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"
🐾 Tiere im Bestand
{anzahl}
" except Exception as e: return f"Konnte nicht geladen werden: {e}" 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] # Die gespeicherten Felder (Zeile 13-15) sind das Datum der LETZTEN # Impfung/Wurmkur/Behandlung, nicht das nächste Fälligkeitsdatum! Vorher # wurde das rohe gespeicherte Datum direkt mit heute verglichen, wodurch # JEDE Impfung der letzten 10 Jahre fälschlich als "anstehend" auftauchte, # sobald sie in der Vergangenheit lag. Jetzt wird - wie schon bei den # ICS-Erinnerungen (erstelle_erinnerung) - das nächste Fälligkeitsdatum # aus letztem Termin + Intervall berechnet. 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 "
✓ Aktuell nichts Dringendes – alles im grünen Bereich.
" html = "
" if warnungen: html += "
🥫 Futter wird knapp" for tage_rest, sorte, hersteller, barcode in warnungen[:6]: # Zwei Preisvergleichs-Links statt einem: Idealo pflegt Barcodes bei # Tierfutter oft lückenhaft, eine Barcode-Suche dort schlug meist fehl - # daher bei Idealo NUR Hersteller+Sorte als Textsuche. Google Shopping # indexiert Barcodes deutlich besser, dort wird er zusätzlich mitgegeben # für präzisere Treffer. 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"
{sorte}noch ca. {tage_rest} Tage🛒 Idealo🔍 Google
" html += "
" if anstehend: html += "
📅 Anstehend (nächste 30 Tage)" 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"
{name} – {label}{text}
" html += "
" html += "
" return html except Exception as e: return f"Übersicht konnte nicht geladen werden: {e}" 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 "Bitte logge dich ein." 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 "Noch kein Futter im Futter Vorrat." 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} # Sortiert nach Kategorie, dann Sortenname 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 {tage_rest} Tage" 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"
{info['art']}
" letzte_art = info["art"] karte = ( f"{art_header}" f"
" f"
" f"{sorte}" f"{tage_text}" f"
" f"
" f"Sack: {sack_kg:.2f} kg" f"Bestand: {bestand_kg:.2f} kg" f"
" f"
" f"
" f"
" ) 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] # ========================================== # 4. OCR SCANNER ENGINE & INVENTAR # ========================================== 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"(?= 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] # Sackgröße = Referenzwert, bleibt bei Aufstocken unverändert row[22] = datetime.now().strftime("%d.%m.%Y") # Zeitstempel für Live-Bestandsberechnung # Race-sicher: append_row() statt "Zeile zählen, dann fest dorthin schreiben" - # append_row() selbst ist an anderer Stelle (Tier_Profile, siehe v7.73.17) # nachweislich gelegentlich unzuverlässig gewesen, daher zusätzlich eine # Rücklese-Prüfung mit Warnhinweis als Sicherheitsnetz (gleiches Muster wie # bei Tagebuch/Haustierapotheke, siehe v7.88.3). 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] # ========================================== # 5. INVENTAR DROPDOWNS & DATEN-UPDATES # ========================================== 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") # manuelle Korrektur -> Live-Zähler neu starten 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")) # Zeitstempel zurücksetzen 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-Dropdown: choices UND value explizit zusammen setzen. Bei nur choices ohne # value versucht Gradio den bisherigen (evtl. veralteten) Client-Wert beizubehalten - # passt der nicht mehr zur frischen Liste, wirft Gradio serverseitig einen # ValueError ("X is not in the list of choices"), der die komplette Server-Antwort # abbrechen kann, obwohl der eigentliche Sheet-Schreibvorgang schon gelaufen war. 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() # ========================================== # 6. DOKUMENTE, TIERPROFILE & PDF EXPORT # ========================================== 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) # kurze Pause vor erneutem Versuch, falls Rate-Limit die Ursache war if url: urls.append(url) if idx < len(seiten) - 1: time.sleep(1.2) # Pause zwischen den Seiten, um Rate-Limiting von vornherein zu vermeiden 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 # WICHTIG (v7.91.1): load_doc() lädt bei bestehenden, mehrseitigen Dokumenten # aus Performance-Gründen nur die ERSTE Seite lokal herunter, um sie als # Vorschau anzuzeigen - die vollständige, ursprüngliche URL-Liste (alle Seiten) # bleibt nur im versteckten Textfeld erhalten. Ohne Hash-Vergleich wurde diese # reine Vorschau-Datei bei JEDEM Speichern (auch wenn nur z.B. das # Allergien-Feld geändert wurde) fälschlich als "neu hochgeladen" behandelt und # hat damit die übrigen Seiten 2-6 stillschweigend gelöscht. Nur wenn sich der # Datei-Inhalt tatsächlich vom zuletzt bekannten Stand unterscheidet, wird # wirklich neu hochgeladen. 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 # Profilbild und die 8 Dokumente parallel laden. Vorher teilten sich alle 9 # Downloads EIN hartes 4-Sekunden-Gesamtlimit - bei mehreren gleichzeitig # langsamen Dokumenten konnte dadurch auch das Profilbild mit abgebrochen # werden, obwohl es das wichtigste sichtbare Element ist. Jetzt bekommt das # Profilbild sein eigenes, großzügigeres Zeitbudget, unabhängig von den # Dokumenten. 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() # zu langsame Dokumente abbrechen, Rest wird trotzdem angezeigt 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 "" # v7.73.17: ws.append_row() durch ws.update() an einer explizit berechneten # Zeile ersetzt. Grund: append_row() nutzt intern eine eigene "finde die # nächste leere Zeile"-Logik von gspread, die in dieser App wiederholt und # reproduzierbar OHNE Fehlermeldung nichts geschrieben hat (mehrfach mit # Diagnose bestätigt: 0 von X Testversuchen kamen an). ws.update() mit einer # von uns selbst berechneten Zielzeile ist dagegen nachweislich zuverlässig - # genau diese Methode nutzt der "bestehendes Tier bearbeiten"-Zweig oben # bereits erfolgreich (bestätigt: Kategorie-Wechsel bei Flocke hat geklappt). 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]]) # Die Diagnose-Rücklese-Prüfung (v7.73.13-v7.73.16) ist raus: sie hat bei # JEDEM Speichern zusätzlich das komplette (kontenübergreifende) Sheet neu # eingelesen, nur um sich selbst zu bestätigen - das kostete spürbar Zeit, # besonders bei vielen Tieren/Nutzern. War nur nötig, um den echten Bug # (unzuverlässiges append_row(), siehe v7.73.17) zu finden - der ist behoben, # ws.update() mit expliziter Zielzeile ist nachweislich zuverlässig. diagnose_status = "Gespeichert!" # Kontakttelefon(e) gelten für den ganzen Account -> bei allen anderen Tieren # desselben Zoos übernehmen. WICHTIG (Performance): früher zwei einzelne # update_cell()-Netzwerk-Anfragen PRO anderem Tier - bei vielen Tieren im # Konto summierte sich das spürbar (z.B. 10 Tiere = 20 einzelne Anfragen # nacheinander). Jetzt EIN batch_update() für alle betroffenen Zellen auf # einmal, unabhängig von der Anzahl der Tiere. 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] # ========================================== # TAGEBUCH (Beobachtungen / Verlauf pro Tier) # ========================================== 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 ""] # WICHTIG (v7.88.3): "Zeile zählen, dann an genau diese Zeile schreiben" ist # anfällig für ein Wettlauf-Problem, wenn zwei Geräte (z.B. Android + iOS) # nahezu gleichzeitig einen neuen Eintrag anlegen - beide berechnen dieselbe # "nächste freie Zeile" und überschreiben sich gegenseitig, ganz ohne # Fehlermeldung (das ist vermutlich genau passiert). append_row() lässt # Google Sheets selbst serverseitig die nächste freie Zeile bestimmen, das # ist bei gleichzeitigen Zugriffen sicher. append_row() war früher an # anderer Stelle unzuverlässig (siehe v7.73.17), daher zusätzlich eine # Rücklese-Prüfung mit einem Wiederholungsversuch als Sicherheitsnetz. ws_tagebuch.append_row(neue_zeile) invalidate_cache() # Nur die LESE-Prüfung wiederholen, NICHT append_row() erneut aufrufen - # sonst würde bei einer nur kurzzeitig veralteten Lese-Antwort ein # doppelter Eintrag entstehen, statt nur die Bestätigung nachzuholen. 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() # ========================================== # HAUSTIERAPOTHEKE (kontenübergreifende, von Admins gepflegte Wissensdatenbank) # ========================================== 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")] # Race-sicher: append_row() statt "Zeile zählen, dann fest dorthin schreiben" # - siehe ausführliche Begründung in tagebuch_eintrag_speichern() (v7.88.3). # Bei der Haustierapotheke könnten mehrere Admins gleichzeitig Einträge # anlegen, dasselbe Wettlauf-Risiko besteht also auch hier. 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: # Vor jedem Eintrag prüfen, ob genug Platz auf der Seite bleibt (Titel + # Text + ggf. Foto), sonst neue Seite - vermeidet, dass Fotos mitten # zerschnitten werden. 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: # Chronologischer Tagebuch-Verlauf dieses Tieres, optional mit in die # Gesamt-Akte aufgenommen - z.B. praktisch zum Mitgeben an neue Paten # von Pflegetieren, statt eines separaten PDFs. Gleiche Aufbereitung # (Titel/Text/Foto, Seitenumbruch-Logik) wie exportiere_tagebuch_pdf(). 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") # ========================================== # E-MAIL-VERSAND (Zugangsdaten kommen ausschließlich aus Hugging-Face-Space- # "Secrets" / Umgebungsvariablen - werden NIE in der Google Sheet gespeichert) # ========================================== 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"
✅ E-Mail-Versand ist konfiguriert (Absender: {smtp_env('SMTP_FROM')})
" fehlend = [k for k in ["BREVO_API_KEY", "SMTP_FROM"] if not smtp_env(k)] return f"
❌ E-Mail-Versand noch nicht eingerichtet. Fehlende Secrets: {', '.join(fehlend)}
" def _from_name_email(smtp_from): """Zerlegt 'Name ' 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-Version zusätzlich zur reinen Textversion, mit dem App-Banner als # Footer-Bild. textContent bleibt als Fallback für Clients ohne HTML-Anzeige. html_text = text.replace("\n", "
") html_content = ( "
" f"{html_text}" "
" "
" f"PetBase" "
" ) 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://.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): # Welche Schwelle wurde erreicht/unterschritten? Die HÖCHSTE Schwelle # nehmen, die noch nicht für diesen Zyklus (Zoo+Sorte+letztes Update) # verschickt wurde - NICHT auf exakte Gleichheit prüfen (v7.74.2-Bug: # "genau 14" traf z.B. nie, wenn der Wert direkt von 15 auf 13 sprang). # Die E-Mail nennt trotzdem den ECHTEN Restbestand, nicht die Schwelle. 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)) # Sackgröße menschenfreundlich formatieren (g oder kg) 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 # Keine gewichtsbasierte Dosierung für diese Tierart -> manuelle Eingabe 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])] # 1. Exakter Treffer: Gewicht liegt in einem der Kg-Bereiche 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) # 2. Kein exakter Treffer: nächstgelegenen Bereich nehmen 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) # ========================================== # 7A. GRAPHICS INTERFACE (START) # ========================================== 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 # Maximale Anzahl gleichzeitig anzeigbarer Futtersorten-Kacheln mit Mini-Buttons APP_VERSION = "7.92.4" # Öffentlich erreichbare URL des App-Banners (aus dem Hugging-Face-Repo, nicht über # Gradios eigene Dateiauslieferung - die ist für externe E-Mail-Clients weniger # zuverlässig). Wird als Footer-Bild in automatischen E-Mails eingebunden. # Nutzt SPACE_ID, das Hugging Face automatisch für jede laufende Space setzt # (Format "username/spacename") - dadurch stimmt die URL automatisch, egal auf # welcher Space (alt oder neu) der Code läuft, ohne manuelle Anpassung. _space_id = os.environ.get("SPACE_ID", "Bob15982/Zoo-Zentrale-Julian") BANNER_URL = f"https://huggingface.co/spaces/{_space_id}/resolve/main/banner.jpg" # Kopf-Banner für die Haustierapotheke - macht den Bereich auffälliger, ohne wie # ein echter Hintergrund die Lesbarkeit von Kategorien/Text zu beeinträchtigen # (das Bild selbst ist recht detail- und textreich). APOTHEKE_BANNER_URL = f"https://huggingface.co/spaces/{_space_id}/resolve/main/apotheke_banner.jpg" # Vollständige Versionshistorie seit v7.5.0. Bei jedem neuen Release hier oben # (an Position 0) einen neuen Eintrag ergänzen: (version, [änderungen]). 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