File size: 409,662 Bytes
adda942 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 2174 2175 2176 2177 2178 2179 2180 2181 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 2208 2209 2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 2383 2384 2385 2386 2387 2388 2389 2390 2391 2392 2393 2394 2395 2396 2397 2398 2399 2400 2401 2402 2403 2404 2405 2406 2407 2408 2409 2410 2411 2412 2413 2414 2415 2416 2417 2418 2419 2420 2421 2422 2423 2424 2425 2426 2427 2428 2429 2430 2431 2432 2433 2434 2435 2436 2437 2438 2439 2440 2441 2442 2443 2444 2445 2446 2447 2448 2449 2450 2451 2452 2453 2454 2455 2456 2457 2458 2459 2460 2461 2462 2463 2464 2465 2466 2467 2468 2469 2470 2471 2472 2473 2474 2475 2476 2477 2478 2479 2480 2481 2482 2483 2484 2485 2486 2487 2488 2489 2490 2491 2492 2493 2494 2495 2496 2497 2498 2499 2500 2501 2502 2503 2504 2505 2506 2507 2508 2509 2510 2511 2512 2513 2514 2515 2516 2517 2518 2519 2520 2521 2522 2523 2524 2525 2526 2527 2528 2529 2530 2531 2532 2533 2534 2535 2536 2537 2538 2539 2540 2541 2542 2543 2544 2545 2546 2547 2548 2549 2550 2551 2552 2553 2554 2555 2556 2557 2558 2559 2560 2561 2562 2563 2564 2565 2566 2567 2568 2569 2570 2571 2572 2573 2574 2575 2576 2577 2578 2579 2580 2581 2582 2583 2584 2585 2586 2587 2588 2589 2590 2591 2592 2593 2594 2595 2596 2597 2598 2599 2600 2601 2602 2603 2604 2605 2606 2607 2608 2609 2610 2611 2612 2613 2614 2615 2616 2617 2618 2619 2620 2621 2622 2623 2624 2625 2626 2627 2628 2629 2630 2631 2632 2633 2634 2635 2636 2637 2638 2639 2640 2641 2642 2643 2644 2645 2646 2647 2648 2649 2650 2651 2652 2653 2654 2655 2656 2657 2658 2659 2660 2661 2662 2663 2664 2665 2666 2667 2668 2669 2670 2671 2672 2673 2674 2675 2676 2677 2678 2679 2680 2681 2682 2683 2684 2685 2686 2687 2688 2689 2690 2691 2692 2693 2694 2695 2696 2697 2698 2699 2700 2701 2702 2703 2704 2705 2706 2707 2708 2709 2710 2711 2712 2713 2714 2715 2716 2717 2718 2719 2720 2721 2722 2723 2724 2725 2726 2727 2728 2729 2730 2731 2732 2733 2734 2735 2736 2737 2738 2739 2740 2741 2742 2743 2744 2745 2746 2747 2748 2749 2750 2751 2752 2753 2754 2755 2756 2757 2758 2759 2760 2761 2762 2763 2764 2765 2766 2767 2768 2769 2770 2771 2772 2773 2774 2775 2776 2777 2778 2779 2780 2781 2782 2783 2784 2785 2786 2787 2788 2789 2790 2791 2792 2793 2794 2795 2796 2797 2798 2799 2800 2801 2802 2803 2804 2805 2806 2807 2808 2809 2810 2811 2812 2813 2814 2815 2816 2817 2818 2819 2820 2821 2822 2823 2824 2825 2826 2827 2828 2829 2830 2831 2832 2833 2834 2835 2836 2837 2838 2839 2840 2841 2842 2843 2844 2845 2846 2847 2848 2849 2850 2851 2852 2853 2854 2855 2856 2857 2858 2859 2860 2861 2862 2863 2864 2865 2866 2867 2868 2869 2870 2871 2872 2873 2874 2875 2876 2877 2878 2879 2880 2881 2882 2883 2884 2885 2886 2887 2888 2889 2890 2891 2892 2893 2894 2895 2896 2897 2898 2899 2900 2901 2902 2903 2904 2905 2906 2907 2908 2909 2910 2911 2912 2913 2914 2915 2916 2917 2918 2919 2920 2921 2922 2923 2924 2925 2926 2927 2928 2929 2930 2931 2932 2933 2934 2935 2936 2937 2938 2939 2940 2941 2942 2943 2944 2945 2946 2947 2948 2949 2950 2951 2952 2953 2954 2955 2956 2957 2958 2959 2960 2961 2962 2963 2964 2965 2966 2967 2968 2969 2970 2971 2972 2973 2974 2975 2976 2977 2978 2979 2980 2981 2982 2983 2984 2985 2986 2987 2988 2989 2990 2991 2992 2993 2994 2995 2996 2997 2998 2999 3000 3001 3002 3003 3004 3005 3006 3007 3008 3009 3010 3011 3012 3013 3014 3015 3016 3017 3018 3019 3020 3021 3022 3023 3024 3025 3026 3027 3028 3029 3030 3031 3032 3033 3034 3035 3036 3037 3038 3039 3040 3041 3042 3043 3044 3045 3046 3047 3048 3049 3050 3051 3052 3053 3054 3055 3056 3057 3058 3059 3060 3061 3062 3063 3064 3065 3066 3067 3068 3069 3070 3071 3072 3073 3074 3075 3076 3077 3078 3079 3080 3081 3082 3083 3084 3085 3086 3087 3088 3089 3090 3091 3092 3093 3094 3095 3096 3097 3098 3099 3100 3101 3102 3103 3104 3105 3106 3107 3108 3109 3110 3111 3112 3113 3114 3115 3116 3117 3118 3119 3120 3121 3122 3123 3124 3125 3126 3127 3128 3129 3130 3131 3132 3133 3134 3135 3136 3137 3138 3139 3140 3141 3142 3143 3144 3145 3146 3147 3148 3149 3150 3151 3152 3153 3154 3155 3156 3157 3158 3159 3160 3161 3162 3163 3164 3165 3166 3167 3168 3169 3170 3171 3172 3173 3174 3175 3176 3177 3178 3179 3180 3181 3182 3183 3184 3185 3186 3187 3188 3189 3190 3191 3192 3193 3194 3195 3196 3197 3198 3199 3200 3201 3202 3203 3204 3205 3206 3207 3208 3209 3210 3211 3212 3213 3214 3215 3216 3217 3218 3219 3220 3221 3222 3223 3224 3225 3226 3227 3228 3229 3230 3231 3232 3233 3234 3235 3236 3237 3238 3239 3240 3241 3242 3243 3244 3245 3246 3247 3248 3249 3250 3251 3252 3253 3254 3255 3256 3257 3258 3259 3260 3261 3262 3263 3264 3265 3266 3267 3268 3269 3270 3271 3272 3273 3274 3275 3276 3277 3278 3279 3280 3281 3282 3283 3284 3285 3286 3287 3288 3289 3290 3291 3292 3293 3294 3295 3296 3297 3298 3299 3300 3301 3302 3303 3304 3305 3306 3307 3308 3309 3310 3311 3312 3313 3314 3315 3316 3317 3318 3319 3320 3321 3322 3323 3324 3325 3326 3327 3328 3329 3330 3331 3332 3333 3334 3335 3336 3337 3338 3339 3340 3341 3342 3343 3344 3345 3346 3347 3348 3349 3350 3351 3352 3353 3354 3355 3356 3357 3358 3359 3360 3361 3362 3363 3364 3365 3366 3367 3368 3369 3370 3371 3372 3373 3374 3375 3376 3377 3378 3379 3380 3381 3382 3383 3384 3385 3386 3387 3388 3389 3390 3391 3392 3393 3394 3395 3396 3397 3398 3399 3400 3401 3402 3403 3404 3405 3406 3407 3408 3409 3410 3411 3412 3413 3414 3415 3416 3417 3418 3419 3420 3421 3422 3423 3424 3425 3426 3427 3428 3429 3430 3431 3432 3433 3434 3435 3436 3437 3438 3439 3440 3441 3442 3443 3444 3445 3446 3447 3448 3449 3450 3451 3452 3453 3454 3455 3456 3457 3458 3459 3460 3461 3462 3463 3464 3465 3466 3467 3468 3469 3470 3471 3472 3473 3474 3475 3476 3477 3478 3479 3480 3481 3482 3483 3484 3485 3486 3487 3488 3489 3490 3491 3492 3493 3494 3495 3496 3497 3498 3499 3500 3501 3502 3503 3504 3505 3506 3507 3508 3509 3510 3511 3512 3513 3514 3515 3516 3517 3518 3519 3520 3521 3522 3523 3524 3525 3526 3527 3528 3529 3530 3531 3532 3533 3534 3535 3536 3537 3538 3539 3540 3541 3542 3543 3544 3545 3546 3547 3548 3549 3550 3551 3552 3553 3554 3555 3556 3557 3558 3559 3560 3561 3562 3563 3564 3565 3566 3567 3568 3569 3570 3571 3572 3573 3574 3575 3576 3577 3578 3579 3580 3581 3582 3583 3584 3585 3586 3587 3588 3589 3590 3591 3592 3593 3594 3595 3596 3597 3598 3599 3600 3601 3602 3603 3604 3605 3606 3607 3608 3609 3610 3611 3612 3613 3614 3615 3616 3617 3618 3619 3620 3621 3622 3623 3624 3625 3626 3627 3628 3629 3630 3631 3632 3633 3634 3635 3636 3637 3638 3639 3640 3641 3642 3643 3644 3645 3646 3647 3648 3649 3650 3651 3652 3653 3654 3655 3656 3657 3658 3659 3660 3661 3662 3663 3664 3665 3666 3667 3668 3669 3670 3671 3672 3673 3674 3675 3676 3677 3678 3679 3680 3681 3682 3683 3684 3685 3686 3687 3688 3689 3690 3691 3692 3693 3694 3695 3696 3697 3698 3699 3700 3701 3702 3703 3704 3705 3706 3707 3708 3709 3710 3711 3712 3713 3714 3715 3716 3717 3718 3719 3720 3721 3722 3723 3724 3725 3726 3727 3728 3729 3730 3731 3732 3733 3734 3735 3736 3737 3738 3739 3740 3741 3742 3743 3744 3745 3746 3747 3748 3749 3750 3751 3752 3753 3754 3755 3756 3757 3758 3759 3760 3761 3762 3763 3764 3765 3766 3767 3768 3769 3770 3771 3772 3773 3774 3775 3776 3777 3778 3779 3780 3781 3782 3783 3784 3785 3786 3787 3788 3789 3790 3791 3792 3793 3794 3795 3796 3797 3798 3799 3800 3801 3802 3803 3804 3805 3806 3807 3808 3809 3810 3811 3812 3813 3814 3815 3816 3817 3818 3819 3820 3821 3822 3823 3824 3825 3826 3827 3828 3829 3830 3831 3832 3833 3834 3835 3836 3837 3838 3839 3840 3841 3842 3843 3844 3845 3846 3847 3848 3849 3850 3851 3852 3853 3854 3855 3856 3857 3858 3859 3860 3861 3862 3863 3864 3865 3866 3867 3868 3869 3870 3871 3872 3873 3874 3875 3876 3877 3878 3879 3880 3881 3882 3883 3884 3885 3886 3887 3888 3889 3890 3891 3892 3893 3894 3895 3896 3897 3898 3899 3900 3901 3902 3903 3904 3905 3906 3907 3908 3909 3910 3911 3912 3913 3914 3915 3916 3917 3918 3919 3920 3921 3922 3923 3924 3925 3926 3927 3928 3929 3930 3931 3932 3933 3934 3935 3936 3937 3938 3939 3940 3941 3942 3943 3944 3945 3946 3947 3948 3949 3950 3951 3952 3953 3954 3955 3956 3957 3958 3959 3960 3961 3962 3963 3964 3965 3966 3967 3968 3969 3970 3971 3972 3973 3974 3975 3976 3977 3978 3979 3980 3981 3982 3983 3984 3985 3986 3987 3988 3989 3990 3991 3992 3993 3994 3995 3996 3997 3998 3999 4000 4001 4002 4003 4004 4005 4006 4007 4008 4009 4010 4011 4012 4013 4014 4015 4016 4017 4018 4019 4020 4021 4022 4023 4024 4025 4026 4027 4028 4029 4030 4031 4032 4033 4034 4035 4036 4037 4038 4039 4040 4041 4042 4043 4044 4045 4046 4047 4048 4049 4050 4051 4052 4053 4054 4055 4056 4057 4058 4059 4060 4061 4062 4063 4064 4065 4066 4067 4068 4069 4070 4071 4072 4073 4074 4075 4076 4077 4078 4079 4080 4081 4082 4083 4084 4085 4086 4087 4088 4089 4090 4091 4092 4093 4094 4095 4096 4097 4098 4099 4100 4101 4102 4103 4104 4105 4106 4107 4108 4109 4110 4111 4112 4113 4114 4115 4116 4117 4118 4119 4120 4121 4122 4123 4124 4125 4126 4127 4128 4129 4130 4131 4132 4133 4134 4135 4136 4137 4138 4139 4140 4141 4142 4143 4144 4145 4146 4147 4148 4149 4150 4151 4152 4153 4154 4155 4156 4157 4158 4159 4160 4161 4162 4163 4164 4165 4166 4167 4168 4169 4170 4171 4172 4173 4174 4175 4176 4177 4178 4179 4180 4181 4182 4183 4184 4185 4186 4187 4188 4189 4190 4191 4192 4193 4194 4195 4196 4197 4198 4199 4200 4201 4202 4203 4204 4205 4206 4207 4208 4209 4210 4211 4212 4213 4214 4215 4216 4217 4218 4219 4220 4221 4222 4223 4224 4225 4226 4227 4228 4229 4230 4231 4232 4233 4234 4235 4236 4237 4238 4239 4240 4241 4242 4243 4244 4245 4246 4247 4248 4249 4250 4251 4252 4253 4254 4255 4256 4257 4258 4259 4260 4261 4262 4263 4264 4265 4266 4267 4268 4269 4270 4271 4272 4273 4274 4275 4276 4277 4278 4279 4280 4281 4282 4283 4284 4285 4286 4287 4288 4289 4290 4291 4292 4293 4294 4295 4296 4297 4298 4299 4300 4301 4302 4303 4304 4305 4306 4307 4308 4309 4310 4311 4312 4313 4314 4315 4316 4317 4318 4319 4320 4321 4322 4323 4324 4325 4326 4327 4328 4329 4330 4331 4332 4333 4334 4335 4336 4337 4338 4339 4340 4341 4342 4343 4344 4345 4346 4347 4348 4349 4350 4351 4352 4353 4354 4355 4356 4357 4358 4359 4360 4361 4362 4363 4364 4365 4366 4367 4368 4369 4370 4371 4372 4373 4374 4375 4376 4377 4378 4379 4380 4381 4382 4383 4384 4385 4386 4387 4388 4389 4390 4391 4392 4393 4394 4395 4396 4397 4398 4399 4400 4401 4402 4403 4404 4405 4406 4407 4408 4409 4410 4411 4412 4413 4414 4415 4416 4417 4418 4419 4420 4421 4422 4423 4424 4425 4426 4427 4428 4429 4430 4431 4432 4433 4434 4435 4436 4437 4438 4439 4440 4441 4442 4443 4444 4445 4446 4447 4448 4449 4450 4451 4452 4453 4454 4455 4456 4457 4458 4459 4460 4461 4462 4463 4464 4465 4466 4467 4468 4469 4470 4471 4472 4473 4474 4475 4476 4477 4478 4479 4480 4481 4482 4483 4484 4485 4486 4487 4488 4489 4490 4491 4492 4493 4494 4495 4496 4497 4498 4499 4500 4501 4502 4503 4504 4505 4506 4507 4508 4509 4510 4511 4512 4513 4514 4515 4516 4517 4518 4519 4520 4521 4522 4523 4524 4525 4526 4527 4528 4529 4530 4531 4532 4533 4534 4535 4536 4537 4538 4539 4540 4541 4542 4543 4544 4545 4546 4547 4548 4549 4550 4551 4552 4553 4554 4555 4556 4557 4558 4559 4560 4561 4562 4563 4564 4565 4566 4567 4568 4569 4570 4571 4572 4573 4574 4575 4576 4577 4578 4579 4580 4581 4582 4583 4584 4585 4586 4587 4588 4589 4590 4591 4592 4593 4594 4595 4596 4597 4598 4599 4600 4601 4602 4603 4604 4605 4606 4607 4608 4609 4610 4611 4612 4613 4614 4615 4616 4617 4618 4619 4620 4621 4622 4623 4624 4625 4626 4627 4628 4629 4630 4631 4632 4633 4634 4635 4636 4637 4638 4639 4640 4641 4642 4643 4644 4645 4646 4647 4648 4649 4650 4651 4652 4653 4654 4655 4656 4657 4658 4659 4660 4661 4662 4663 4664 4665 4666 4667 4668 4669 4670 4671 4672 4673 4674 4675 4676 4677 4678 4679 4680 4681 4682 4683 4684 4685 4686 4687 4688 4689 4690 4691 4692 4693 4694 4695 4696 4697 4698 4699 4700 4701 4702 4703 4704 4705 4706 4707 4708 4709 4710 4711 4712 4713 4714 4715 4716 4717 4718 4719 4720 4721 4722 4723 4724 4725 4726 4727 4728 4729 4730 4731 4732 4733 4734 4735 4736 4737 4738 4739 4740 4741 4742 4743 4744 4745 4746 4747 4748 4749 4750 4751 4752 4753 4754 4755 4756 4757 4758 4759 4760 4761 4762 4763 4764 4765 4766 4767 4768 4769 4770 4771 4772 4773 4774 4775 4776 4777 4778 4779 4780 4781 4782 4783 4784 4785 4786 4787 4788 4789 4790 4791 4792 4793 4794 4795 4796 4797 4798 4799 4800 4801 4802 4803 4804 4805 4806 4807 4808 4809 4810 4811 4812 4813 4814 4815 4816 4817 4818 4819 4820 4821 4822 4823 4824 4825 4826 4827 4828 4829 4830 4831 4832 4833 4834 4835 4836 4837 4838 4839 4840 4841 4842 4843 4844 4845 4846 4847 4848 4849 4850 4851 4852 4853 4854 4855 4856 4857 4858 4859 4860 4861 4862 4863 4864 4865 4866 4867 4868 4869 4870 4871 4872 4873 4874 4875 4876 4877 4878 4879 4880 4881 4882 4883 4884 4885 4886 4887 4888 4889 4890 4891 4892 4893 4894 4895 4896 4897 4898 4899 4900 4901 4902 4903 4904 4905 4906 4907 4908 4909 4910 4911 4912 4913 4914 4915 4916 4917 4918 4919 4920 4921 4922 4923 4924 4925 4926 4927 4928 4929 4930 4931 4932 4933 4934 4935 4936 4937 4938 4939 4940 4941 4942 4943 4944 4945 4946 4947 4948 4949 4950 4951 4952 4953 4954 4955 4956 4957 4958 4959 4960 4961 4962 4963 4964 4965 4966 4967 4968 4969 4970 4971 4972 4973 4974 4975 4976 4977 4978 4979 4980 4981 4982 4983 4984 4985 4986 4987 4988 4989 4990 4991 4992 4993 4994 4995 4996 4997 4998 4999 5000 5001 5002 5003 5004 5005 5006 5007 5008 5009 5010 5011 5012 5013 5014 5015 5016 5017 5018 5019 5020 5021 5022 5023 5024 5025 5026 5027 5028 5029 5030 5031 5032 5033 5034 5035 5036 5037 5038 5039 5040 5041 5042 5043 5044 5045 5046 5047 5048 5049 5050 5051 5052 5053 5054 5055 5056 5057 5058 5059 5060 5061 5062 5063 5064 5065 5066 5067 5068 5069 5070 5071 5072 5073 5074 5075 5076 5077 5078 5079 5080 5081 5082 5083 5084 5085 5086 5087 5088 5089 5090 5091 5092 5093 5094 5095 5096 5097 5098 5099 5100 5101 5102 5103 5104 5105 5106 5107 5108 5109 5110 5111 5112 5113 5114 5115 5116 5117 5118 5119 5120 5121 5122 5123 5124 5125 5126 5127 5128 5129 5130 5131 5132 5133 5134 5135 5136 5137 5138 5139 5140 5141 5142 5143 5144 5145 5146 5147 5148 5149 5150 5151 5152 5153 5154 5155 5156 5157 5158 5159 5160 5161 5162 5163 5164 5165 5166 5167 5168 5169 5170 5171 5172 5173 5174 5175 5176 5177 5178 5179 5180 5181 5182 5183 5184 5185 5186 5187 5188 5189 5190 5191 5192 5193 5194 5195 5196 5197 5198 5199 5200 5201 5202 5203 5204 5205 5206 5207 5208 5209 5210 5211 5212 5213 5214 5215 5216 5217 5218 5219 5220 5221 5222 5223 5224 5225 5226 5227 5228 5229 5230 5231 5232 5233 5234 5235 5236 5237 5238 5239 5240 5241 5242 5243 5244 5245 5246 5247 5248 5249 5250 5251 5252 5253 5254 5255 5256 5257 5258 5259 5260 5261 5262 5263 5264 5265 5266 5267 5268 5269 5270 5271 5272 5273 5274 5275 5276 5277 5278 5279 5280 5281 5282 5283 5284 5285 5286 5287 5288 5289 5290 5291 5292 5293 5294 5295 5296 5297 5298 5299 5300 5301 5302 5303 5304 5305 5306 5307 5308 5309 5310 5311 5312 5313 5314 5315 5316 5317 5318 5319 5320 5321 5322 5323 5324 5325 5326 5327 5328 5329 5330 5331 5332 5333 5334 5335 5336 5337 5338 5339 5340 5341 5342 5343 5344 5345 5346 5347 5348 5349 5350 5351 5352 5353 5354 5355 5356 5357 5358 5359 5360 5361 5362 5363 5364 5365 5366 5367 5368 5369 5370 5371 5372 5373 5374 5375 5376 5377 5378 5379 5380 5381 5382 5383 5384 5385 5386 5387 5388 5389 5390 5391 5392 5393 5394 5395 5396 5397 5398 5399 5400 5401 5402 5403 5404 5405 5406 5407 5408 5409 5410 5411 5412 5413 5414 5415 5416 5417 5418 5419 5420 5421 5422 5423 5424 5425 5426 5427 5428 5429 5430 5431 5432 5433 5434 5435 5436 5437 5438 5439 5440 5441 5442 5443 5444 5445 5446 5447 5448 5449 5450 5451 5452 5453 5454 5455 5456 5457 5458 5459 5460 5461 5462 5463 5464 5465 5466 5467 5468 5469 5470 5471 5472 5473 5474 5475 5476 5477 5478 5479 5480 5481 5482 5483 5484 5485 5486 5487 5488 5489 5490 5491 5492 5493 5494 5495 5496 5497 5498 5499 5500 5501 5502 5503 5504 5505 5506 5507 5508 5509 5510 5511 5512 5513 5514 5515 5516 5517 5518 5519 5520 5521 5522 5523 5524 5525 5526 5527 5528 5529 5530 5531 5532 5533 5534 5535 5536 5537 5538 5539 5540 5541 5542 5543 5544 5545 5546 5547 5548 5549 5550 5551 5552 5553 5554 5555 5556 5557 5558 5559 5560 5561 5562 5563 5564 5565 5566 5567 5568 5569 5570 5571 5572 5573 5574 5575 5576 5577 5578 5579 5580 5581 5582 5583 5584 5585 5586 5587 5588 5589 5590 5591 5592 5593 5594 5595 5596 5597 5598 5599 5600 5601 5602 5603 5604 5605 5606 5607 5608 5609 5610 5611 5612 5613 5614 5615 5616 5617 5618 5619 5620 5621 5622 5623 5624 5625 5626 5627 5628 5629 5630 5631 5632 5633 5634 5635 5636 5637 5638 5639 5640 5641 5642 5643 5644 5645 5646 5647 5648 5649 5650 5651 5652 5653 5654 5655 5656 5657 5658 5659 5660 5661 5662 5663 5664 5665 5666 5667 5668 5669 5670 5671 5672 5673 5674 5675 5676 5677 5678 5679 5680 5681 5682 5683 5684 5685 5686 5687 5688 5689 5690 5691 5692 5693 5694 5695 5696 5697 5698 | 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"<div style='background:#FFFCF5; padding:15px; border-radius:12px; border-left: 5px solid #6D4C41; box-shadow: 0 1px 3px rgba(59,50,38,0.15); color:#3B3226;'><span style='color:#4E342E; font-size:1.2em;'>🐾 App-Reichweite</span><br><br><b>Registrierte Zoos:</b> {zoo_count}<br><b>Gesamt-Logins aller Nutzer:</b> {total_logins}<br><b>Versendete E-Mails gesamt:</b> {email_zaehler()}<br><br>{email_status_text()}</div>"
except Exception as e: return f"Fehler Statistik: {e}"
def erstelle_feedback_liste():
"""Zeigt alle eingegangenen Feedback-Einträge (neueste zuerst), dieselben
Angaben wie in der Benachrichtigungs-E-Mail: Von, Thema, Nachricht, Datum."""
try:
rows = get_cached_data("feedback")
start_idx = 1 if len(rows) > 0 and "datum" in str(rows[0][0]).lower() else 0
eintraege = [r for r in rows[start_idx:] if len(r) >= 4 and str(r[1]).strip()]
if not eintraege:
return "<i>Noch kein Feedback eingegangen.</i>"
html = "<div style='display:flex; flex-direction:column; gap:8px;'>"
for r in reversed(eintraege):
datum, von, thema, text = r[0], r[1], r[2], r[3]
text_html = str(text).replace("\n", "<br>")
html += f"""<div style='background:#FFFCF5; border-radius:12px; padding:10px 14px; border-left:3px solid #6D4C41; box-shadow: 0 1px 3px rgba(59,50,38,0.12); color:#3B3226;'>
<div style='display:flex; justify-content:space-between; align-items:baseline;'><b style='font-size:1.02em;'>{von}</b><span style='color:#7A6E5C; font-size:0.82em;'>{datum}</span></div>
<div style='font-size:0.9em; color:#6D4C41; font-weight:bold; margin-top:2px;'>{thema}</div>
<div style='font-size:0.9em; line-height:1.4; margin-top:4px;'>{text_html}</div>
</div>"""
html += "</div>"
return html
except Exception as e:
return f"<i>Feedback-Liste konnte nicht geladen werden: {e}</i>"
def erstelle_feedback_dropdown_choices():
"""Baut die Auswahlliste für 'Als erledigt markieren' - jede Option beginnt
mit der tatsächlichen Sheet-Zeilennummer, damit das Löschen auch bei
inhaltlich identischen Einträgen eindeutig bleibt."""
try:
rows = ws_feedback.get_all_values()
start_idx = 1 if len(rows) > 0 and "datum" in str(rows[0][0]).lower() else 0
choices = []
for i, r in enumerate(rows):
if i < start_idx or len(r) < 4 or not str(r[1]).strip():
continue
kurztext = str(r[3])[:40] + ("…" if len(str(r[3])) > 40 else "")
choices.append(f"Zeile {i + 1} | {r[0]} – {r[1]} – {r[2]}: {kurztext}")
return list(reversed(choices))
except Exception:
return []
def feedback_als_erledigt(auswahl):
"""Löscht den ausgewählten Feedback-Eintrag aus dem Sheet - erscheint danach
weder in der Liste noch in der Auswahl. Kein 'Papierkorb', wirklich gelöscht,
wie vom Nutzer gewünscht."""
if not auswahl or "Zeile " not in auswahl:
return "❌ Bitte einen Eintrag auswählen.", gr.update(), gr.update()
try:
zeilen_nr = int(auswahl.split("Zeile ")[1].split(" |")[0])
ws_feedback.delete_rows(zeilen_nr)
invalidate_cache()
return "✅ Als erledigt markiert und gelöscht.", erstelle_feedback_liste(), gr.update(choices=erstelle_feedback_dropdown_choices(), value=None)
except Exception as e:
return f"❌ Fehler beim Löschen: {str(e)[:80]}", gr.update(), gr.update()
def erstelle_nutzerliste():
try:
users = get_cached_data("users")
start_idx = 1 if len(users) > 0 and "name" in str(users[0][0]).lower() else 0
set_rows = get_cached_data("set")
tier_rows = get_cached_data("tier")
email_map = {}
for r in set_rows:
if len(r) > 2: email_map[str(r[0]).strip().lower()] = r[2]
html = "<div style='display:flex; flex-direction:column; gap:8px;'>"
for r in users[start_idx:]:
if len(r) == 0 or not r[0].strip(): continue
name = r[0].strip()
if name.lower() == "petbase": continue
email = email_map.get(name.lower(), "–")
letzter_login = r[3] if len(r) > 3 and r[3] else "noch nie"
anzahl_logins = r[4] if len(r) > 4 and r[4] else "0"
anzahl_tiere = len([t for t in tier_rows if len(t) > 1 and is_zoo(t[0], name) and str(t[1]).strip().lower() != "name"])
gesperrt = len(r) > 6 and str(r[6]).strip().lower() == "ja"
admin = len(r) > 7 and str(r[7]).strip().lower() == "ja"
status_farbe = "#D32F2F" if gesperrt else "#2E7D32"
status_text = "🚫 Gesperrt" if gesperrt else "✓ Aktiv"
admin_tag = " · 👑 Admin" if admin else ""
html += f"""<div style='background:#FFFCF5; border-radius:12px; padding:10px 14px; border-left:3px solid {status_farbe}; box-shadow: 0 1px 3px rgba(59,50,38,0.12); color:#3B3226;'>
<div style='display:flex; justify-content:space-between; align-items:baseline;'><b style='font-size:1.05em;'>{name}{admin_tag}</b><span style='color:{status_farbe}; font-size:0.9em;'>{status_text}</span></div>
<div style='font-size:0.85em; color:#7A6E5C; margin-top:2px;'>📧 {email} · 🐾 {anzahl_tiere} Tier(e) · 🔑 {anzahl_logins}x eingeloggt · 🕐 zuletzt: {letzter_login}</div>
</div>"""
html += "</div>"
if start_idx == len(users) or all(not r[0].strip() or r[0].strip().lower() == "petbase" for r in users[start_idx:] if len(r) > 0):
return "<i>Noch keine registrierten Nutzer außer dir.</i>"
return html
except Exception as e:
return f"<i>Nutzerliste konnte nicht geladen werden: {e}</i>"
def hole_nutzer_gruppen():
"""Gibt vier Namenslisten zurück: aktive (nicht gesperrte), gesperrte,
nicht-Admin (können ernannt werden) und aktuelle Admins (können entzogen werden)."""
try:
users = get_cached_data("users")
start_idx = 1 if len(users) > 0 and "name" in str(users[0][0]).lower() else 0
aktive, gesperrte, nicht_admin, admins = [], [], [], []
for r in users[start_idx:]:
if len(r) == 0 or not r[0].strip(): continue
name = r[0].strip()
if name.lower() == "petbase": continue
gesperrt = len(r) > 6 and str(r[6]).strip().lower() == "ja"
admin = len(r) > 7 and str(r[7]).strip().lower() == "ja"
(gesperrte if gesperrt else aktive).append(name)
(admins if admin else nicht_admin).append(name)
return aktive, gesperrte, nicht_admin, admins
except Exception:
return [], [], [], []
def nutzer_dropdowns_update():
aktive, gesperrte, nicht_admin, admins = hole_nutzer_gruppen()
return (gr.update(choices=aktive or ["Keine aktiven Nutzer"], value=None),
gr.update(choices=gesperrte or ["Keine gesperrten Nutzer"], value=None),
gr.update(choices=nicht_admin or ["Keine passenden Nutzer"], value=None),
gr.update(choices=admins or ["Keine weiteren Admins"], value=None))
def _nutzer_sperr_aktion(admin_name, ziel_name, sperren):
ungueltig = (None, "Keine aktiven Nutzer", "Keine gesperrten Nutzer")
if not ist_admin(admin_name): return "❌ Keine Berechtigung.", erstelle_nutzerliste(), *nutzer_dropdowns_update()
if not ziel_name or ziel_name in ungueltig: return "Bitte einen Nutzer auswählen.", erstelle_nutzerliste(), *nutzer_dropdowns_update()
if ziel_name.strip().lower() == "petbase": return "❌ Der Hauptadmin kann nicht gesperrt werden.", erstelle_nutzerliste(), *nutzer_dropdowns_update()
try:
rows = ws_users.get_all_values()
for i, r in enumerate(rows):
if len(r) > 0 and is_zoo(r[0], ziel_name):
ws_users.update_cell(i + 1, 7, "Ja" if sperren else "Nein")
invalidate_cache()
text = f"✅ {ziel_name} wurde {'gesperrt' if sperren else 'entsperrt'}."
return (text, erstelle_nutzerliste()) + nutzer_dropdowns_update()
return ("❌ Nutzer nicht gefunden.", erstelle_nutzerliste()) + nutzer_dropdowns_update()
except Exception as e:
return (f"❌ Fehler: {e}", erstelle_nutzerliste()) + nutzer_dropdowns_update()
def nutzer_sperren(admin_name, ziel_name): return _nutzer_sperr_aktion(admin_name, ziel_name, True)
def nutzer_entsperren(admin_name, ziel_name): return _nutzer_sperr_aktion(admin_name, ziel_name, False)
def _admin_aktion(admin_name, ziel_name, ernennen):
ungueltig = (None, "Keine passenden Nutzer", "Keine weiteren Admins")
if str(admin_name).strip().lower() != "petbase": return ("❌ Nur der Hauptadmin kann Admin-Rechte vergeben oder entziehen.", erstelle_nutzerliste()) + nutzer_dropdowns_update()
if not ziel_name or ziel_name in ungueltig: return ("Bitte einen Nutzer auswählen.", erstelle_nutzerliste()) + nutzer_dropdowns_update()
if ziel_name.strip().lower() == "petbase": return ("❌ Der Hauptadmin-Status kann nicht geändert werden.", erstelle_nutzerliste()) + nutzer_dropdowns_update()
try:
rows = ws_users.get_all_values()
for i, r in enumerate(rows):
if len(r) > 0 and is_zoo(r[0], ziel_name):
while len(r) < 8: r.append("")
ws_users.update_cell(i + 1, 8, "Ja" if ernennen else "Nein")
invalidate_cache()
text = f"✅ {ziel_name} ist jetzt {'Admin' if ernennen else 'kein Admin mehr'}."
return (text, erstelle_nutzerliste()) + nutzer_dropdowns_update()
return ("❌ Nutzer nicht gefunden.", erstelle_nutzerliste()) + nutzer_dropdowns_update()
except Exception as e:
return (f"❌ Fehler: {e}", erstelle_nutzerliste()) + nutzer_dropdowns_update()
def admin_ernennen(admin_name, ziel_name): return _admin_aktion(admin_name, ziel_name, True)
def admin_entziehen(admin_name, ziel_name): return _admin_aktion(admin_name, ziel_name, False)
def login_user(zoo_name, pw):
if not zoo_name or not pw: return "Bitte alles ausfüllen!", ""
users = get_cached_data("users")
hashed = hash_pw(pw)
for r in users:
if len(r) >= 2 and is_zoo(r[0], zoo_name):
if r[1] != hashed: return "Falsches Passwort!", ""
if len(r) > 6 and str(r[6]).strip().lower() == "ja":
return "🚫 Dieses Konto wurde gesperrt. Bitte wende dich an den Admin.", ""
return "Login erfolgreich!", r[0].strip()
return "Zoo-Name nicht gefunden!", ""
def ist_gesperrt(zoo_name, email):
for r in get_cached_data("blacklist"):
if len(r) > 0 and is_zoo(r[0], zoo_name): return True
if len(r) > 1 and email and str(r[1]).strip().lower() == str(email).strip().lower(): return True
return False
def register_user(zoo_name, pw, email):
if not zoo_name or not pw: return "Bitte Name und Passwort ausfüllen!", gr.update(), gr.update(), gr.update(), gr.update(), gr.update()
if ist_gesperrt(zoo_name, email):
return "❌ Registrierung nicht möglich. Bitte wende dich an den Admin.", gr.update(), gr.update(), gr.update(), gr.update(), gr.update()
for r in get_cached_data("users"):
if len(r) > 0 and is_zoo(r[0], zoo_name): return "Diesen Zoo-Namen gibt es leider schon!", gr.update(), gr.update(), gr.update(), gr.update(), gr.update()
try:
rows = ws_users.get_all_values()
naechste_zeile = len(rows) + 1
ws_users.update(range_name=f"A{naechste_zeile}:E{naechste_zeile}", values=[[zoo_name.strip(), hash_pw(pw), "", "", "0"]])
if email and email.strip():
set_rows = ws_set.get_all_values()
naechste_set_zeile = len(set_rows) + 1
ws_set.update(range_name=f"A{naechste_set_zeile}:C{naechste_set_zeile}", values=[[zoo_name.strip(), "Nein", email.strip()]])
invalidate_cache()
# 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 "<i>Bitte logge dich ein.</i>"
try:
pm = lade_modus(zoo_name)
tier_rows = [r for r in get_cached_data("tier") if len(r) > 1 and is_zoo(r[0], zoo_name) and str(r[1]).strip().lower() != "name"]
if not pm:
tier_rows = [r for r in tier_rows if not (len(r) > 23 and r[23] and r[23] != "Eigenes Tier")]
anzahl = len(tier_rows)
return f"<div style='background:#FFFCF5; border-radius:16px; padding:12px 16px; border-left:3px solid #6D4C41; box-shadow: 0 1px 3px rgba(59,50,38,0.12); color:#3B3226;'><span style='color:#7A6E5C;'>🐾 Tiere im Bestand</span><div style='font-size:1.4em; font-weight:bold;'>{anzahl}</div></div>"
except Exception as e:
return f"<i>Konnte nicht geladen werden: {e}</i>"
def berechne_futter_warnungen(zoo_name, tier_rows=None):
"""Berechnet je Futtersorte die geschätzten verbleibenden Tage anhand von
Bestand, täglichem Verbrauch und Zeit seit letzter Aktualisierung. Gemeinsame
Basis für die Dashboard-Anzeige ('Futter wird knapp') und die automatischen
E-Mail-Erinnerungen. Gibt eine Liste von
(tage_rest, sorte, letzte_update_str, hersteller, sackgroesse_g, barcode) zurück,
sortiert nach tage_rest aufsteigend."""
if tier_rows is None:
pm = lade_modus(zoo_name)
tier_rows = [r for r in get_cached_data("tier") if len(r) > 1 and is_zoo(r[0], zoo_name) and str(r[1]).strip().lower() != "name"]
if not pm:
tier_rows = [r for r in tier_rows if not (len(r) > 23 and r[23] and r[23] != "Eigenes Tier")]
heute = datetime.now()
inv_rows = [r for r in get_cached_data("inv") if len(r) > 9 and r[8] and r[9] and is_zoo(r[0], zoo_name)]
bedarf = {}
for r in tier_rows:
if len(r) > 16 and str(r[7]).upper() != 'TRUE':
sorte = r[8].strip()
try: g_tag = float(str(r[16]).replace(",", "."))
except: g_tag = 0
if sorte and g_tag > 0: bedarf[sorte] = bedarf.get(sorte, 0) + g_tag
ergebnisse = []
for r in inv_rows:
sorte = r[8].strip()
verbr_tag = bedarf.get(sorte, 0)
if verbr_tag <= 0: continue
try: gespeichert = float(str(r[9]).replace(",", "."))
except: gespeichert = 0
letzte_update_str = str(r[22]).strip() if len(r) > 22 and r[22] else ""
try: letzte_update = datetime.strptime(letzte_update_str, "%d.%m.%Y")
except: letzte_update = heute
tage_vergangen = max(0, (heute - letzte_update).days)
live_bestand = max(0, gespeichert - tage_vergangen * verbr_tag)
tage_rest = int(live_bestand / verbr_tag)
hersteller = str(r[7]).strip() if len(r) > 7 and r[7] else ""
sackgroesse_g = str(r[21]).strip() if len(r) > 21 and r[21] else ""
barcode = str(r[6]).strip() if len(r) > 6 and r[6] and str(r[6]).strip().lower() != "unbekannt" else ""
ergebnisse.append((tage_rest, sorte, letzte_update_str, hersteller, sackgroesse_g, barcode))
ergebnisse.sort(key=lambda x: x[0])
return ergebnisse
def erstelle_anstehend_karte(zoo_name):
"""Anstehende Termine + Futter-Warnungen - erscheint unter den Stammdaten des ausgewählten Tieres."""
if not zoo_name:
return ""
try:
pm = lade_modus(zoo_name)
tier_rows = [r for r in get_cached_data("tier") if len(r) > 1 and is_zoo(r[0], zoo_name) and str(r[1]).strip().lower() != "name"]
if not pm:
tier_rows = [r for r in tier_rows if not (len(r) > 23 and r[23] and r[23] != "Eigenes Tier")]
heute = datetime.now()
anstehend = []
for r in tier_rows:
name = r[1]
# 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 "<div style='color:#4CAF50; padding:6px 0;'>✓ Aktuell nichts Dringendes – alles im grünen Bereich.</div>"
html = "<div style='display:flex; flex-direction:column; gap:10px;'>"
if warnungen:
html += "<div style='background:#FFFCF5; border-radius:16px; padding:10px 16px; border-left:3px solid #D32F2F; box-shadow: 0 1px 3px rgba(59,50,38,0.12); color:#3B3226;'><b style='color:#D32F2F;'>🥫 Futter wird knapp</b>"
for tage_rest, sorte, hersteller, barcode in warnungen[:6]:
# 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"<div style='padding:4px 0; font-size:0.9em; display:flex; justify-content:space-between; align-items:center;'><span>{sorte}</span><span style='display:flex; align-items:center; gap:8px;'><span style='color:#D32F2F;'>noch ca. {tage_rest} Tage</span><a href='{idealo_url}' target='_blank' rel='noopener' style='color:#6D4C41; text-decoration:none; font-size:0.85em; white-space:nowrap;'>🛒 Idealo</a><a href='{google_url}' target='_blank' rel='noopener' style='color:#6D4C41; text-decoration:none; font-size:0.85em; white-space:nowrap;'>🔍 Google</a></span></div>"
html += "</div>"
if anstehend:
html += "<div style='background:#FFFCF5; border-radius:16px; padding:10px 16px; border-left:3px solid #6D4C41; box-shadow: 0 1px 3px rgba(59,50,38,0.12); color:#3B3226;'><b style='color:#4E342E;'>📅 Anstehend (nächste 30 Tage)</b>"
for tage, name, label in anstehend[:6]:
farbe = "#D32F2F" if tage < 0 else ("#EF6C00" if tage <= 7 else "#2E7D32")
text = f"überfällig seit {abs(tage)} Tagen" if tage < 0 else (f"heute fällig" if tage == 0 else f"in {tage} Tagen")
html += f"<div style='padding:4px 0; font-size:0.9em; display:flex; justify-content:space-between;'><span>{name} – {label}</span><span style='color:{farbe};'>{text}</span></div>"
html += "</div>"
html += "</div>"
return html
except Exception as e:
return f"<i>Übersicht konnte nicht geladen werden: {e}</i>"
def erstelle_dashboard(zoo_name):
"""Nur noch für Status-Meldungen zuständig - die eigentliche Anzeige läuft über fuelle_futter_slots."""
if not zoo_name: return "<i>Bitte logge dich ein.</i>"
try:
inv_rows = [r for r in get_cached_data("inv") if len(r) > 0 and is_zoo(r[0], zoo_name)]
hat_futter = any(len(r) > 9 and r[8] and r[9] for r in inv_rows)
return "" if hat_futter else "<i>Noch kein Futter im Futter Vorrat.</i>"
except Exception as e: return f"Dashboard Fehler: {e}"
def fuelle_futter_slots(zoo_name):
"""Liefert für jeden der FUTTER_SLOT_ANZAHL Slots: (sichtbar, Karten-HTML, Sortenname)."""
leer = [(gr.update(visible=False), "", "")] * FUTTER_SLOT_ANZAHL
if not zoo_name: return [x for tpl in leer for x in tpl]
try:
pm = lade_modus(zoo_name)
inv_rows = [r for r in get_cached_data("inv") if len(r) > 0 and is_zoo(r[0], zoo_name)]
tier_rows = [r for r in get_cached_data("tier") if len(r) > 0 and is_zoo(r[0], zoo_name)]
heute = datetime.now()
bedarf = {}
for r in tier_rows:
if len(r) > 1 and str(r[1]).strip().lower() != "name":
if len(r) > 16 and str(r[7]).upper() != 'TRUE':
kat = r[23] if len(r) > 23 and r[23] else "Eigenes Tier"
if not pm and kat != "Eigenes Tier": continue
sorte = r[8].strip()
try: g_tag = float(str(r[16]).replace(",", "."))
except: g_tag = 0
if sorte and g_tag > 0: bedarf[sorte] = bedarf.get(sorte, 0) + g_tag
posten = {}
for r in inv_rows:
if len(r) > 9 and r[8] and r[9]:
sorte, art = r[8].strip(), str(r[19]).strip() if len(r) > 19 and r[19] else "Unkategorisiert"
try: gespeichert = float(str(r[9]).replace(",", "."))
except: gespeichert = 0
try: sackgroesse = float(str(r[21]).replace(",", ".")) if len(r) > 21 and r[21] else gespeichert
except: sackgroesse = gespeichert
letzte_update_str = str(r[22]).strip() if len(r) > 22 and r[22] else ""
try: letzte_update = datetime.strptime(letzte_update_str, "%d.%m.%Y")
except: letzte_update = heute
posten[sorte] = {"art": art, "gespeichert": gespeichert, "sackgroesse": sackgroesse, "letzte_update": letzte_update}
# 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 <b>{tage_rest} Tage</b>"
percent = min(100, (tage_rest/30)*100)
else:
status_color = "#9E9E9E"; tage_text = "kein Verbrauch"; percent = 0
art_header = ""
if info["art"] != letzte_art:
art_header = f"<div style='color:#4E342E; font-size:0.85em; font-weight:bold; margin:6px 0 2px 2px;'>{info['art']}</div>"
letzte_art = info["art"]
karte = (
f"{art_header}"
f"<div style='border:1px solid #E6DAC4; border-radius:12px; padding:6px 10px; background:#FFFCF5; color:#3B3226; font-size:0.85em; box-shadow: 0 1px 2px rgba(59,50,38,0.10);'>"
f"<div style='display:flex; justify-content:space-between; align-items:baseline; flex-wrap:wrap; gap:2px 8px;'>"
f"<b style='font-size:1em;'>{sorte}</b>"
f"<span style='color:{status_color};'>{tage_text}</span>"
f"</div>"
f"<div style='display:flex; justify-content:space-between; flex-wrap:wrap; gap:2px 8px; color:#7A6E5C; margin-top:1px; font-size:0.9em;'>"
f"<span>Sack: {sack_kg:.2f} kg</span>"
f"<span>Bestand: <b>{bestand_kg:.2f} kg</b></span>"
f"</div>"
f"<div style='width:100%; background:#E6DAC4; border-radius:3px; height:4px; overflow:hidden; margin-top:3px;'>"
f"<div style='width:{percent}%; height:100%; background:{status_color};'></div></div>"
f"</div>"
)
ergebnisse.append((gr.update(visible=True), karte, sorte))
else:
ergebnisse.append((gr.update(visible=False), "", ""))
return [x for tpl in ergebnisse for x in tpl]
except Exception:
return [x for tpl in leer for x in tpl]
# ==========================================
# 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"(?<![\-\+])\b([0-9.,]+)\s*(kg|g)\b(?![\s*\+\-])", clean_text_for_weight, re.IGNORECASE)
gewicht = "0"
if gew_matches:
kandidaten = []
for wert_str, einheit in gew_matches:
wert_clean = wert_str.replace(",", ".").strip()
try:
wert = float(wert_clean)
in_g = int(wert * 1000) if einheit.lower() == 'kg' else int(wert)
if 50 <= in_g <= 30000:
kandidaten.append(in_g)
except: continue
if kandidaten:
gewicht = str(max(kandidaten))
else:
try:
wert_str, einheit = gew_matches[0]
wert = float(wert_str.replace(",", ".").strip())
if einheit.lower() == 'kg' or wert < 50.0:
gewicht = str(int(wert * 1000))
else:
gewicht = str(int(wert))
except: pass
lower_text = combined_text.lower()
phase = "Unbekannt"
if re.search(r"\b(junior|welpe|puppy|young)\b", lower_text): phase = "Junior / Welpe"
elif re.search(r"\b(senior|older|ageing)\b", lower_text): phase = "Senior"
elif re.search(r"\b(adult|erwachsen)\b", lower_text): phase = "Adult"
elif re.search(r"\b(all life stages|all ages)\b", lower_text): phase = "All Ages"
lines = combined_text.split("\n")
table_pairs = []
for line in lines:
line_clean = re.sub(r'\s+-\s+', '-', line)
line_clean = re.sub(r'\s*,\s*', ',', line_clean)
kg_match = re.search(r"(\d+[\.,]?\d*-\d+[\.,]?\d*)\s*kg", line_clean, re.IGNORECASE)
if not kg_match: kg_match = re.search(r"\b(\d+[\.,]?\d*)\s*kg", line_clean, re.IGNORECASE)
if kg_match:
kg_val = kg_match.group(1).replace(" ", "")
g_matches = re.findall(r"\b(\d+-\d+)\s*g\b", line_clean, re.IGNORECASE)
if g_matches: table_pairs.append((kg_val, g_matches[0]))
else:
g_singles = re.findall(r"\b(\d+)\s*g\b", line_clean, re.IGNORECASE)
if len(g_singles) >= 2: table_pairs.append((kg_val, f"{g_singles[0]}-{g_singles[1]}"))
elif len(g_singles) == 1: table_pairs.append((kg_val, g_singles[0]))
k1, g1, k2, g2, k3, g3, k4, g4 = "", "", "", "", "", "", "", ""
einzigartige_paare = []
gesehene_kgs = set()
for k, g in table_pairs:
if k not in gesehene_kgs:
gesehene_kgs.add(k)
einzigartige_paare.append((k, g))
if einzigartige_paare:
if len(einzigartige_paare) > 0: k1, g1 = einzigartige_paare[0]
if len(einzigartige_paare) > 1: k2, g2 = einzigartige_paare[1]
if len(einzigartige_paare) > 2: k3, g3 = einzigartige_paare[2]
if len(einzigartige_paare) > 3: k4, g4 = einzigartige_paare[3]
nutzbarer_text = combined_text.replace("--", "").strip()
bereinigte_zeilen = [l.strip() for l in nutzbarer_text.split('\n') if len(l.strip()) > 10]
vorschau = "\n".join(bereinigte_zeilen[:12]) + "\n..." if len(bereinigte_zeilen) > 12 else "\n".join(bereinigte_zeilen)
if len(nutzbarer_text) < 40:
vorschau = "⚠️ Nur wenig Text erkannt – bitte Foto bei besserem Licht/schärfer/gerader aufnehmen.\n\n" + vorschau
return bc, "", "", gewicht, phase, k1, g1, k2, g2, k3, g3, k4, g4, vorschau
except Exception as e: return "Fehler", "", "", "0", "Unbekannt", "", "", "", "", "", "", "", "", str(e)
def parse_zu_gramm(wert_str):
if not wert_str: return "0"
try:
wert = float(str(wert_str).replace(",", ".").strip())
if wert < 50.0: return str(int(wert * 1000))
return str(int(wert))
except: return "0"
def speichere_inventar(zoo_name, bc, hersteller, sorte, gewicht, phase, art, futtertyp, k1, g1, k2, g2, k3, g3, k4, g4):
if not zoo_name: return "Fehler: Nicht eingeloggt!", gr.update(), gr.update(), gr.update(), gr.update()
row = [""] * 23
row[0] = zoo_name
row[6], row[7], row[8], row[9] = str(bc), str(hersteller), str(sorte).strip(), parse_zu_gramm(gewicht)
row[10], row[11] = str(k1), str(g1); row[12], row[13] = str(k2), str(g2); row[14], row[15] = str(k3), str(g3)
row[16], row[17] = str(k4), str(g4); row[18], row[19] = str(phase), str(art)
row[20] = str(futtertyp) if futtertyp else "Trockenfutter"
row[21] = row[9] # 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"<div style='color:#2E7D32;'>✅ E-Mail-Versand ist konfiguriert (Absender: {smtp_env('SMTP_FROM')})</div>"
fehlend = [k for k in ["BREVO_API_KEY", "SMTP_FROM"] if not smtp_env(k)]
return f"<div style='color:#D32F2F;'>❌ E-Mail-Versand noch nicht eingerichtet. Fehlende Secrets: {', '.join(fehlend)}</div>"
def _from_name_email(smtp_from):
"""Zerlegt 'Name <email@x.de>' in (Name, E-Mail), akzeptiert auch nur 'email@x.de'."""
m = re.match(r'^\s*(.*?)\s*<(.+?)>\s*$', smtp_from)
if m: return (m.group(1).strip() or "PetBase"), m.group(2).strip()
return "PetBase", smtp_from.strip()
def sende_email(empfaenger, betreff, text, zoo_name=""):
if not email_konfiguriert():
return False, "E-Mail-Versand ist nicht eingerichtet (fehlende Secrets)."
if not empfaenger or "@" not in str(empfaenger):
return False, "Keine gültige E-Mail-Adresse hinterlegt."
try:
name, email = _from_name_email(smtp_env("SMTP_FROM"))
# HTML-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", "<br>")
html_content = (
"<div style='font-family:Arial,sans-serif; color:#3B3226; font-size:15px; line-height:1.5;'>"
f"{html_text}"
"</div>"
"<div style='margin-top:28px;'>"
f"<img src='{BANNER_URL}' alt='PetBase' style='max-width:100%; width:400px; height:auto; border-radius:8px; display:block;'>"
"</div>"
)
resp = requests.post(
"https://api.brevo.com/v3/smtp/email",
headers={"api-key": smtp_env("BREVO_API_KEY"), "Content-Type": "application/json", "Accept": "application/json"},
json={"sender": {"name": name, "email": email}, "to": [{"email": empfaenger}], "subject": betreff, "textContent": text, "htmlContent": html_content},
timeout=10,
)
if resp.status_code not in (200, 201):
return False, f"❌ E-Mail konnte nicht versendet werden ({resp.status_code}): {resp.text[:80]}"
try:
naechste_zeile = len(ws_emaillog.get_all_values()) + 1
ws_emaillog.update(range_name=f"A{naechste_zeile}:D{naechste_zeile}", values=[[datetime.now().strftime("%d.%m.%Y %H:%M"), zoo_name, empfaenger, betreff]])
invalidate_cache()
except: pass
return True, "✅ E-Mail wurde versendet."
except Exception as e:
return False, f"❌ E-Mail konnte nicht versendet werden (Host: '{smtp_env('SMTP_HOST')}'): {str(e)[:60]}"
def email_zaehler():
try:
rows = get_cached_data("emaillog")
start_idx = 1 if len(rows) > 0 and "datum" in str(rows[0][0]).lower() else 0
return len(rows) - start_idx
except Exception: return 0
def test_email_senden(zoo_name):
email = lade_einstellungen(zoo_name)
if not email:
return "❌ Für dein Konto ist keine E-Mail-Adresse hinterlegt (Konto → Einstellungen → Erinnerungen)."
ok, msg = sende_email(email, "🐾 PetBase Test-E-Mail", f"Hallo {zoo_name},\n\ndies ist eine Test-E-Mail von PetBase. Wenn du diese E-Mail liest, funktioniert der E-Mail-Versand einwandfrei! 🎉\n\nDiese E-Mail wurde automatisch versendet.", zoo_name)
return msg
def erinnerung_per_email(zoo_name, tier_name, typ, datum_str):
email = lade_einstellungen(zoo_name)
if not email:
return "❌ Für dein Konto ist keine E-Mail-Adresse hinterlegt (Konto → Erinnerungen)."
betreff = f"🐾 PetBase Erinnerung: {typ} für {tier_name}"
text = f"Hallo {zoo_name},\n\ndies ist eine Erinnerung von PetBase:\n\n{tier_name} - {typ}\nDatum: {datum_str}\n\nDiese E-Mail wurde automatisch von deiner PetBase-App versendet."
ok, msg = sende_email(email, betreff, text, zoo_name)
return msg
def pruefe_und_sende_futter_erinnerungen():
"""Prüft für ALLE Zoo-Accounts den Futterbestand und verschickt automatisch je
eine Erinnerungs-Mail pro Futtersorte, sobald der Restbestand 14, 5 oder 0 Tage
erreicht. Gedacht für TÄGLICHEN externen Aufruf (Cron) - Hugging Face Spaces hat
keinen eingebauten Scheduler. Erreichbar über die Gradio-API dieser App unter
api_name='futter_cron_check' (siehe UI-Definition weiter unten). Ein externer
Dienst wie cron-job.org kann diese Adresse einmal täglich per POST aufrufen:
https://<dein-space>.hf.space/gradio_api/call/futter_cron_check
Duplikat-Schutz: pro (Zoo, Sorte, Datum der letzten Bestandsaktualisierung,
Schwelle) wird nur einmal verschickt (Tracking-Sheet Futter_Erinnerungen_Log) -
ändert sich die Aktualisierung (neuer Einkauf), beginnt der Zyklus neu. Nutzt
denselben sicheren Schreib-Mechanismus wie save_data() (explizite Zielzeile
statt append_row(), siehe v7.73.17 - append_row() hatte sich als unzuverlässig
erwiesen)."""
SCHWELLEN = [14, 5, 0]
gesendet = 0
try:
alle_zoos = sorted(set(str(r[0]).strip() for r in get_cached_data("set") if len(r) >= 3 and str(r[0]).strip()))
bereits_rows = get_cached_data("futtererinnerung")
bereits_set = {(str(r[0]).strip().lower(), str(r[1]).strip(), str(r[2]).strip(), str(r[3]).strip()) for r in bereits_rows if len(r) >= 4}
for zoo_name in alle_zoos:
email = lade_einstellungen(zoo_name)
if not email:
continue
for tage_rest_echt, sorte, letzte_update_str, hersteller, sackgroesse_g, barcode in berechne_futter_warnungen(zoo_name):
# 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 <button class=\"boundedheight\"> innerhalb von .upload-container mit fest height:240px von Gradio - die vorherigen zwei Versuche zielten auf falsche Elemente. Jetzt direkt und gezielt auf 60px reduziert",
]),
("7.89.2", [
"PDF-Ergebnisfeld im Tagebuch jetzt bestätigt kompakt (Danke fürs Testen!). Foto-Upload-Feld nutzt offenbar eine andere interne Struktur als das PDF-Feld, die bisherige CSS griff dort nicht - stattdessen sorgte die erzwungene Mindesthöhe für ein Überlappen von Icons und Text. Zwangshöhe erstmal entfernt, echte Größen-Anpassung folgt nach Live-Inspektion der tatsächlichen Klassen",
]),
("7.89.1", [
"PDF- und Foto-Feld im Tagebuch endlich wirklich kompakt: per Live-Inspektion (danke für die Mühe mit dem DevTools-Screenshot!) die tatsächlichen Gradio-Klassen gefunden (.wrap.full, .empty.large) - alle vorherigen Versuche zielten auf falsche/nicht existierende Klassennamen und blieben wirkungslos",
]),
("7.89.0", [
"Tagebuch-Fotofeld zurück auf Bildvorschau (Vorschau war doch wichtig) - Größe diesmal vorsichtiger reduziert, ohne etwas zu verstecken",
"Aufräumrunde durch den ganzen Code: Futter-Zentrale (neues Futter speichern) nutzte noch das unsichere append_row() - jetzt mit demselben race-sicheren Muster wie Tagebuch/Haustierapotheke abgesichert (append_row() + Rücklese-Prüfung). Alle elem_ids app-weit auf Eindeutigkeit geprüft (keine Duplikate mehr), keine verwaisten Verweise auf im Laufe der letzten Updates entfernte Komponenten gefunden, alle kritischen Ausgabe-Listen (login_outs, konto_toggle_outs) nochmal auf exakte Längen-Übereinstimmung gegengeprüft",
]),
("7.88.7", [
"v7.88.6 zurückgenommen (hatte versehentlich die Upload-Icons mit versteckt, ohne den Platz zu sparen). Foto-Feld im Tagebuch jetzt ein einfacher, garantiert kompakter Upload-Knopf statt der großen Bild-Dropzone - kein Rätselraten über Gradios interne Struktur mehr nötig. PDF-Ergebnisfeld vorsichtiger angegangen (nur Mindesthöhe gelockert, nichts versteckt, das könnte sonst wieder Funktionales mit verschlucken)",
]),
("7.88.6", [
"Tagebuch: Foto-Upload-Drop-Zone auf eine schmale Leiste zusammengestaucht (die drei Icons Hochladen/Kamera/Zwischenablage darunter reichen zum Auswählen völlig aus), PDF-Ergebnisfeld auf eine einzelne Zeile reduziert statt der bisherigen großen leeren Vorschaufläche",
]),
("7.88.5", [
"Tagebuch: Foto-Upload-Feld und PDF-Ergebnisfeld deutlich kompakter (vorher nahmen die leeren Platzhalter viel zu viel Platz weg)",
]),
("7.88.4", [
"Absicherung gegen stilles Hängenbleiben beim Start: Space blieb bei 'Restarting' stehen, Container-Log zeigte NICHTS außer 'Application Startup' - kein Fehler, kein Absturz, einfach endloses Warten auf eine Netzwerk-Antwort (vermutlich Google Sheets), noch bevor Python überhaupt eine Log-Zeile ausgeben konnte. Globaler 30-Sekunden-Timeout für alle Netzwerk-Verbindungen ergänzt - verwandelt ein stilles Für-immer-Hängen in einen klaren, sichtbaren Fehler im Log, der sich gezielt beheben lässt",
]),
("7.88.3", [
"Wichtiger Bugfix: Neue Tagebuch-Einträge konnten spurlos verschwinden, wenn zwei Geräte etwa zeitgleich einen neuen Eintrag für dasselbe Tier speicherten (z.B. Android + iOS) - beide berechneten dieselbe 'nächste freie Zeile' und überschrieben sich gegenseitig, ganz ohne Fehlermeldung. Neue Einträge nutzen jetzt append_row() (lässt Google Sheets selbst serverseitig die nächste freie Zeile bestimmen, race-sicher bei gleichzeitigen Zugriffen) plus eine Rücklese-Prüfung mit Warnhinweis, falls doch mal etwas nicht ankommt. Gleiches Muster auch für neue Haustierapotheke-Einträge angewendet, da dort ebenfalls mehrere Admins gleichzeitig aktiv sein könnten",
]),
("7.88.2", [
"Bugfix: Feld-Beschriftungen in den Stammdaten blitzten nach dem Speichern (z.B. im Tagebuch) kurz in Orange auf - Ursache war Gradios Grundthema, das intern Orange als Hauptfarbe für 'gerade aktualisiert'-Hervorhebungen nutzt. Bisher wurde das nur an einzelnen Stellen gezielt auf Braun umgestellt. Jetzt global über CSS-Variablen behoben, greift automatisch bei allen (auch zukünftigen) Gradio-Zuständen, nicht nur den bekannten",
]),
("7.88.1", [
"Bugfix (Server-Log-Auswertung): 'erstelle_bestand_karte didn't receive enough input values' - die verkettete .then()-Reihe nach dem Login (check_saved_login → erstelle_bestand_karte → erstelle_anstehend_karte) war fehleranfällig, da current_zoo eine BrowserState-Komponente ist. Beide Karten werden jetzt direkt innerhalb von check_saved_login() mit dem bereits vorliegenden Wert berechnet statt in einer separaten, fragilen Verkettung danach",
]),
("7.88.0", [
"Treffergenauigkeit app-weit erhöht (u.a. gegen das iOS-Problem 'Button sieht breit aus, reagiert aber nicht immer'): touch-action:manipulation verhindert Verzögerungen durch Doppeltipp-Erkennung, Mindest-Trefffläche von 44px als Untergrenze für alle Buttons",
"Login-Bereich komplett überarbeitet: 'Anmelden'/'Registrieren' sind jetzt große, deutlich sichtbare Buttons (gleiches Muster wie der Rest der App) statt Gradios kleiner eingebauter Tabs. Nach erfolgreicher Registrierung springt die Ansicht automatisch zurück zu 'Anmelden', mit bereits ausgefülltem Zoo-Namen",
"Nebenbei: Registrierung nutzt jetzt den sicheren Schreib-Mechanismus statt append_row()",
]),
("7.87.1", [
"Haustierapotheke-Banner jetzt per Checkbox im Admin-Bereich ein-/ausschaltbar (kein Code-Update nötig, z.B. praktisch beim Austauschen des Bildes) - neues Sheet 'App_Einstellungen' für solche globalen Schalter",
"Kategorie-Überschriften in der Haustierapotheke noch fetter und etwas größer",
"Störende Symbolleiste (Vollbild/Download/Teilen) auf allen Bildern in der App entfernt (Banner, Profilbild, Tagebuch-Fotos, Futter-Scanner) - Bilder lassen sich weiterhin per Klick vergrößern/verkleinern, die Icons waren nur zusätzlicher visueller Ballast",
]),
("7.87.0", [
"Haustierapotheke bekommt ein eigenes Kopf-Banner-Bild (analog zum Haupt-Banner) für mehr Auffälligkeit - kompakt zugeschnitten statt als Hintergrund, damit Text und Kategorien darunter gut lesbar bleiben. Bild muss als 'apotheke_banner.jpg' im Space-Repo liegen (URL wird wie beim Haupt-Banner automatisch über SPACE_ID ermittelt)",
]),
("7.86.3", [
"Bugfix Haustierapotheke: neuer Eintrag landete in einer eingeklappten Kategorie-Karte und wirkte dadurch wie 'nicht gespeichert' - die passende Kategorie öffnet sich jetzt automatisch nach dem Speichern",
"Kategorie-Karten in der Haustierapotheke deutlich auffälliger gestaltet (Hintergrund, Rahmen, fetter Text, Hinweis 'antippen zum Öffnen') statt unscheinbarem Fließtext",
"Willkommensgruß für neue Nutzer komplett aktualisiert: Tagebuch, Haustierapotheke, PDF-Export, Vertrag nachtragen und Preisvergleichs-Links waren dort nicht erwähnt. Erinnerungs-Kommentar im Code ergänzt, damit das bei künftigen neuen Funktionen nicht wieder vergessen wird",
]),
("7.86.2", [
"KRITISCHER BUGFIX: Die Seite blieb leer bzw. der Browser meldete 'Skript verlangsamt Browser' - Ursache war eine Endlos-Schleife im Tab-Ausrichtungs-Skript aus v7.86.0/7.86.1. Der MutationObserver beobachtete Klassen-Änderungen, um Ein-/Ausblenden von Tabs zu erkennen, aber derselbe Code ändert selbst Klassen (petbase_tab_stretch) - das hat den Beobachter bei jeder eigenen Änderung erneut ausgelöst und den Browser komplett blockiert. Beobachtet jetzt nur noch 'style'-Änderungen (wie Gradio Elemente tatsächlich ein-/ausblendet), nicht mehr 'class'",
]),
("7.86.1", [
"Tab-Leisten-Ansatz aus v7.86.0 korrigiert: kein Wischen/Scrollen mehr, stattdessen das gewünschte 2-Spalten-Raster (nebeneinander, letztes Element bei ungerader Anzahl über volle Breite gestreckt), plattformübergreifend einheitlich per JS berechnet",
]),
("7.86.0", [
"Bugfix Haustierapotheke: Klick auf 'Bearbeiten' hat das Accordion der Kategorie geschlossen, weil @gr.render beim Neuzeichnen alle Accordions zurücksetzt - jetzt wird gemerkt, welche Kategorie offen war, und beim Bearbeiten/Speichern/Abbrechen/Weitere-laden bleibt sie offen",
"'Neuen Eintrag hinzufügen'-Formular blendet sich jetzt aus, solange eine bestehende Karte inline bearbeitet wird - bessere Übersichtlichkeit für Admins, gleiches Prinzip wie beim Tagebuch",
]),
("7.85.2", [
"Reihenfolge der Subtabs unter Verwaltung → Tiere korrigiert: Vertrag nachtragen steht jetzt links von Haustierapotheke (war vertauscht)",
"Alle Tab-Leisten (Haupt-Bereiche wie Subtabs) nutzen jetzt ein 2-Spalten-Raster (nebeneinander, ohne Wischen/Scrollen), das letzte Element wird bei ungerader Anzahl sichtbarer Tabs automatisch über die volle Breite gestreckt. Sichtbarkeit wird dabei per JavaScript berücksichtigt (nicht per reiner CSS-Zählung), da einzelne Tabs je nach Admin-Status/Modus dynamisch ein-/ausgeblendet werden - plattformübergreifend einheitlich",
]),
("7.85.1", [
"Haustierapotheke: neue Auswahl '🐾 Tierart eingrenzen' direkt bei der Suche - schränkt sowohl die Suche als auch die Kategorien-Ansicht auf eine bestimmte Tierart ein, damit z.B. keine Katzen-Empfehlung auftaucht, wenn gezielt nach einer Schlangen-Empfehlung gesucht wird. Dropdown aktualisiert sich automatisch beim Öffnen des Tabs und nach jedem neuen Eintrag",
]),
("7.85.0", [
"Haustierapotheke komplett umgebaut: Kategorien (z.B. Hund, Katze, Maus) erscheinen jetzt als eigene aufklappbare Bereiche (Accordions) statt eines Dropdown-Filters, Karten innerhalb jeder Kategorie alphabetisch nach Titel sortiert, mit '⬇️ Weitere Einträge laden'-Button pro Kategorie (wie bei der Änderungshistorie). Jede Karte hat jetzt einen echten '✏️ Bearbeiten'-Button (nur für Admins sichtbar, wie beim Tagebuch per @gr.render umgesetzt - direkt in der Karte editierbar, kein Formular zum Scrollen). Die Suche ignoriert bei Texteingabe bewusst die Kategorie-Gliederung und zeigt alle Treffer alphabetisch sortiert über alle Kategorien hinweg",
]),
("7.84.0", [
"Tagebuch: 'Neuer Eintrag'-Formular (Titel/Text/Foto) blendet sich jetzt aus, solange gerade ein bestehender Eintrag inline in seiner Karte bearbeitet wird - vermeidet Verwirrung zwischen den beiden Modi",
"NEU: Haustierapotheke - kontenübergreifende, von Admins kuratierte Sammlung tiermedizinischer Hausmittel-Empfehlungen. Neuer Subtab 'Haustierapotheke' unter Verwaltung → Tiere (zwischen Futter Vorrat und Vertrag nachtragen, PDF-Export steht jetzt als letztes). Nutzer können nach Kategorie blättern oder per Volltextsuche suchen (durchsucht Titel UND Inhalt, nicht nur den Eintragsnamen). Admins sehen zusätzlich ein Formular zum Anlegen neuer Einträge (Kategorie, Titel, Inhalt). Neues Sheet 'Haustierapotheke', sicherer Schreib-Mechanismus wie überall sonst (explizite Zielzeile statt append_row())",
]),
("7.83.1", [
"Tagebuch-Bearbeiten jetzt komplett INLINE direkt in der jeweiligen Karte statt in einem Formular oben (kein Hochscrollen mehr nötig): Klick auf 'Bearbeiten' tauscht die Karte gegen echte Titel-/Text-/Foto-Eingabefelder samt eigenem Speichern/Abbrechen-Button, alle anderen Karten bleiben unverändert. Das Formular oben ist jetzt nur noch für neue Einträge zuständig",
]),
("7.83.0", [
"Tagebuch-Bearbeiten komplett umgebaut: statt HTML+JavaScript-Brücke (die zuverlässig von Gradios HTML-Bereinigung blockiert wurde) jetzt echte, per @gr.render dynamisch erzeugte Gradio-Buttons - ein garantiert funktionierender 'Bearbeiten'-Button direkt in jeder Karte, ganz ohne JS-Tricks. Dabei auch verwaiste Verweise auf zuvor entfernte Komponenten bereinigt, die zur Laufzeit einen Fehler ausgelöst hätten",
]),
("7.82.2", [
"Vermutliche eigentliche Ursache für den nicht reagierenden Tagebuch-'Bearbeiten'-Button: Gradios gr.HTML() bereinigt (sanitized) dynamisch gesetzten Inhalt und entfernt dabei wahrscheinlich Inline-onclick-Attribute (Sicherheitsmaßnahme gegen XSS) - das onclick im Button-HTML wurde also nie ausgeführt. Umgestellt auf Event Delegation: kein onclick mehr im HTML, stattdessen ein globaler Klick-Beobachter im statischen Seiten-Skript (unberührt von der HTML-Bereinigung), der Klicks auf die Bearbeiten-Buttons per CSS-Klasse abfängt",
]),
("7.82.1", [
"Bugfix: Tagebuch-'Bearbeiten'-Button reagierte nicht - JS-Elementsuche war zu strikt für die tatsächliche Gradio-DOM-Struktur, jetzt robuster mit mehreren Fallback-Selektoren (deckt ab, ob elem_id auf dem Wrapper oder direkt auf Feld/Button liegt)",
"Änderungshistorie-Popup ebenfalls mit 'Weitere laden'-Prinzip: zeigt jetzt nur noch die letzten 15 Versionen statt aller 130+, mit '⬇️ Weitere Versionen laden'-Button davor dem Schließen-Button - vorher musste man bei jedem Öffnen durch die komplette Liste scrollen, um zum Schließen-Button zu kommen",
]),
("7.82.0", [
"Tagebuch-Layout überarbeitet gegen 'endlos lange Seite' bei vielen Einträgen über Wochen/Monate: Eingabe-Formular steht jetzt ÜBER der Liste (kein Scrollen mehr nötig, um einen neuen Eintrag zu machen), und die Liste zeigt anfangs nur die letzten 8 Einträge mit einem '⬇️ Weitere Einträge laden'-Button für ältere Einträge statt immer alle auf einmal zu stapeln",
]),
("7.81.1", [
"Tagebuch-Bearbeiten umgebaut auf Wunsch: statt einem separaten Dropdown jetzt ein '✏️ Bearbeiten'-Button direkt unten rechts in jeder Eintrags-Karte. Klick füllt Titel/Text zum Korrigieren vor und zeigt einen Hinweis 'Du bearbeitest gerade...' inkl. Abbrechen-Button. Technisch über eine versteckte Brücke gelöst (dasselbe bewährte Muster wie beim schwebenden Zurück-Button), da die Liste als HTML gerendert wird und Buttons darin keinen Gradio-Callback direkt auslösen können",
]),
("7.81.0", [
"NEU: Tagebuch-Einträge nachträglich bearbeiten - Dropdown 'Bestehenden Eintrag bearbeiten' füllt Titel und Text zum Korrigieren/Ergänzen vor, Speichern aktualisiert die vorhandene Zeile statt eine neue anzulegen. Bestehendes Foto bleibt erhalten, falls beim Bearbeiten kein neues hochgeladen wird",
"NEU: Impressum-Platzhalter auf dem Login-Bildschirm (öffentlich ohne Login erreichbar, wie rechtlich für ein Impressum nötig) - Struktur für §5-TMG-Angaben vorbereitet, Inhalte sind noch Platzhalter und müssen vor produktivem Einsatz ausgefüllt werden",
]),
("7.80.0", [
"Performance-Fix für langsames Speichern: zwei Ursachen gefunden und behoben. Erstens: die Kontakttelefon-Synchronisierung machte bisher zwei einzelne Netzwerk-Anfragen PRO anderem Tier im Konto (bei vielen Tieren summierte sich das spürbar) - jetzt ein einziger batch_update() für alle betroffenen Zellen zusammen. Zweitens: die Diagnose-Rücklese-Prüfung aus der append_row()-Fehlersuche (v7.73.13-16) las bei jedem Speichern zusätzlich das komplette, kontenübergreifende Sheet neu ein, nur um sich selbst zu bestätigen - jetzt entfernt, da das eigentliche Problem (unzuverlässiges append_row()) längst behoben und die neue Methode nachweislich zuverlässig ist",
]),
("7.79.3", [
"Zwei getrennte Preisvergleichs-Links statt einem: '🛒 Idealo' (nur Hersteller+Sorte, kein Barcode mehr - Idealo hatte damit bei Tierfutter keine Treffer) und '🔍 Google' (Hersteller+Sorte+Barcode, Google indexiert Barcodes deutlich besser). Idealo steht zuerst, Google danach",
]),
("7.79.2", [
"Zurück auf Idealo (statt Google Shopping) auf Nutzerwunsch, mit der Verbesserung aus v7.79.1 beibehalten: Hersteller+Sorte als primäre Textsuche statt reiner Barcode-Suche, Barcode nur noch ergänzend dazu",
]),
("7.79.1", [
"Bugfix: Idealo hatte bei Tierfutter-Barcodes häufig keine Treffer (Barcodes dort lückenhaft gepflegt). Preisvergleichs-Link auf Google Shopping umgestellt - Hersteller+Sorte als primäre Textsuche (bei Tierfutter zuverlässiger als reine Barcode-Suche), Barcode wird ergänzend mitgegeben statt allein zu stehen",
]),
("7.79.0", [
"NEU: 'Futter wird knapp' auf der Startseite hat jetzt pro Sorte einen '🛒 Preise vergleichen'-Link zu Idealo - sucht per Barcode (präziseste Produktidentifikation, sofern hinterlegt) oder als Fallback per Hersteller+Sorte. Kein eigenes Preis-Scraping (unzuverlässig, rechtlich unsauber, würde bei Seiten-Änderungen brechen) - Idealo vergleicht selbst live über viele Händler inkl. Versandkosten",
]),
("7.78.0", [
"NEU: Feedback als erledigt markieren - in der Feedback-Übersicht ein Dropdown ergänzt (Auswahl des Eintrags per Datum/Absender/Thema/Kurztext) plus Button 'Als erledigt markieren & löschen'. Entfernt den Eintrag dauerhaft aus der Liste UND aus dem Google Sheet (kein Papierkorb). Auswahl bleibt auch bei inhaltlich identischen Einträgen eindeutig, da die tatsächliche Sheet-Zeilennummer intern mitgeführt wird",
]),
("7.77.3", [
"Restliche Konto-Unterpunkte (Feedback senden, Erinnerungen, Passwort, Konto löschen, News veröffentlichen, Nutzerverwaltung) bekommen jetzt ebenfalls die passende elem_id, damit der schwebende Zurück-Button auch bei diesen Navigation korrekt im Verlauf vermerkt - durchgezogener Fix aus v7.76.2/v7.77.2",
]),
("7.77.2", [
"Feedback-Übersicht-Button bekommt elem_id, damit der schwebende Zurück-Button diese Navigation korrekt im Verlauf vermerkt (gleicher Fix wie beim Tagebuch-Button in v7.76.2)",
]),
("7.77.1", [
"NEU: Feedback-Übersicht im Admin-Bereich (Konto → Admin) - Liste aller eingegangenen Feedback-Einträge, neueste zuerst, mit denselben Angaben wie in der Benachrichtigungs-E-Mail (Von, Thema, Nachricht, Datum). Alle Sichtbarkeits-Ausgabelisten automatisiert auf exakte Längen-Übereinstimmung geprüft",
]),
("7.77.0", [
"NEU: Feedback-Benachrichtigung für Admin. Wenn ein Nutzer Feedback (Fehler/Idee/Frage) einreicht, bekommt PetBase automatisch eine E-Mail mit Absender-Konto, Thema und Inhalt. Zusätzlich ein Live-Popup, solange PetBase selbst gerade in der App eingeloggt ist (fragt alle 30 Sekunden nach neuem Feedback). Nebenbei: Feedback-Speicherung nutzt jetzt den sicheren Schreib-Mechanismus statt append_row()",
]),
("7.76.4", [
"Vorbereitung für Umzug auf eine zweite Space: Google-Zugangsdaten werden jetzt bevorzugt aus einem Secret (GOOGLE_CREDENTIALS) geladen statt aus der lokal hochgeladenen JSON-Datei - sicherer, da Secrets nie im Repo landen. Fällt auf die alte Datei zurück, falls kein Secret gesetzt ist, damit derselbe Code auf alter UND neuer Space funktioniert. BANNER_URL ermittelt sich jetzt automatisch über Hugging Faces SPACE_ID-Variable statt fest einprogrammiert zu sein - passt sich also automatisch an, egal welche Space läuft. Der Spreadsheet-Key (Verbindung zum bestehenden Sheet) bleibt unverändert, damit keine Daten verloren gehen",
]),
("7.76.3", [
"Echten Logikfehler bei 'Anstehend' auf der Startseite gefunden: Die Felder 'Letzte Impfung/Wurmkur/Parasitenschutz' speichern das Datum des LETZTEN Termins, die Berechnung hat das aber direkt mit heute verglichen statt das nächste Fälligkeitsdatum zu berechnen - dadurch tauchte JEDE Impfung der letzten 10 Jahre als 'anstehend' bzw. 'überfällig' auf, sobald sie in der Vergangenheit lag (auch bei jährlicher Impfung nach nur 35 Tagen). Jetzt wird wie bei den ICS-Erinnerungen das nächste Fälligkeitsdatum aus letztem Termin + Intervall berechnet (Impfung 365 Tage, Wurmkur 90 Tage, Parasitenschutz je nach eingestelltem Intervall 90 oder 365 Tage). ACHTUNG: dadurch verschwinden vermutlich einige bisher angezeigte 'überfällige' Termine aus der Liste, da sie tatsächlich noch nicht fällig sind",
]),
("7.76.2", [
"Schwebender Zurück-Button berücksichtigte den neuen Tagebuch-Button nicht, da dieser keine passende elem_id hatte (der Zurück-Mechanismus erkennt Navigation nur an IDs mit den Präfixen b_show_/b_haupt_/b_bereich_/b_konto_tab_). elem_id ergänzt - Klick auf 'Tagebuch / Beobachtungen' wird jetzt korrekt im Navigations-Verlauf vermerkt, Zurück-Button springt danach wieder sauber zum vorherigen Tab",
]),
("7.76.1", [
"Tagebuch-Tab in der Tab-Leiste (Stammdaten/Fütterung/Gesundheit/Dokumente/Tagebuch) dauerhaft unsichtbar gemacht - stattdessen wieder ein eigener Button 'Tagebuch / Beobachtungen' direkt unter Name/Tierauswahl (an Erweiterten Modus gekoppelt wie zuvor). Klick auf diesen Button landet auf demselben Tagebuch-Inhalt, nur ohne dass der Tab selbst in der Leiste sichtbar ist",
]),
("7.76.0", [
"Tagebuch komplett umgebaut: eigener Tab '📔 Tagebuch' neben Stammdaten/Fütterung/Gesundheit/Dokumente statt aufklappbarem Panel - Button drücken = eigene Seite, losgelöst von den Stammdaten. Neues Feld 'Titel / Überschrift' pro Eintrag, erscheint fett über dem Beobachtungstext in Liste und PDF-Export. Neue Spalte 'Titel' im Tagebuch-Sheet",
]),
("7.75.2", [
"Tagebuch jetzt an Erweiterten Modus gekoppelt: Button nur sichtbar, wenn Erweiterter Modus aktiv ist (gleiche Logik wie beim Pflegevertrag-Upload). Beim Ausschalten des Modus schließt sich ein offenes Tagebuch-Panel automatisch mit",
]),
("7.75.1", [
"Tagebuch-Einträge können jetzt optional mit einem Foto belegt werden - neues Feld '📷 Foto zum Eintrag' beim Speichern, Foto erscheint als Miniaturbild in der chronologischen Liste UND im PDF-Export (Bild wird automatisch mit heruntergeladen und eingebettet, Seitenumbruch berücksichtigt genug Platz dafür). Neue Spalte 'Foto_URL' im Tagebuch-Sheet",
]),
("7.75.0", [
"NEU: Tagebuch/Beobachtungen pro Tier. Button '📔 Tagebuch / Beobachtungen' direkt unter Name+Tierauswahl (vor Pflegevertrag-Upload bzw. Tierarzt-Bereich) öffnet ein Panel mit chronologischer Liste bisheriger Einträge (neueste zuerst), einem Textfeld für neue Beobachtungen (z.B. Ankunft, Baden, Scheren, körperliche/psychische Auffälligkeiten) und einem PDF-Export des kompletten Verlaufs - z.B. zum Mitgeben an neue Paten von Pflegetieren. Neues Sheet 'Tagebuch' (Zoo_Name, Tier_Name, Datum, Eintrag), Schreibvorgänge nutzen den bewährten sicheren Mechanismus (explizite Zielzeile statt append_row())",
]),
("7.74.6", [
"Alle automatischen E-Mails (Test-Mail, Impfung/Wurmkur/Parasiten-Erinnerungen, Futter-Erinnerungen, News-Updates) sprechen jetzt mit dem hinterlegten Account-Namen an ('Hallo PetBase,' statt 'Hallo,')",
]),
("7.74.5", [
"Futter-Erinnerungs-E-Mails nennen jetzt Hersteller und Sackgröße mit, nicht nur die Sorte - z.B. 'Wild Plain – Huhn (2.5 kg) reicht nur noch ca. 13 Tage'. berechne_futter_warnungen() liefert dafür zusätzlich Hersteller und Sackgröße aus dem Futter-Inventar mit",
]),
("7.74.4", [
"Alle automatischen E-Mails (Erinnerungen, Futter-Warnungen, Test-Mail) haben jetzt zusätzlich zur Textversion eine HTML-Version mit dem App-Banner als Footer-Bild, geladen von der öffentlichen Hugging-Face-Dateiablage. Nebenbei: E-Mail-Log-Eintrag nutzt jetzt denselben sicheren Schreib-Mechanismus wie save_data() (explizite Zielzeile statt append_row())",
]),
("7.74.3", [
"Bugfix Futter-Erinnerungen: 0 E-Mails versendet trotz 'Wild Plain' bei 13 Tagen Restbestand - Ursache war eine Prüfung auf EXAKT 14/5/0 Tage, die den Wert nie traf, wenn er z.B. direkt von 15 auf 13 sprang. Jetzt wird 'Schwelle erreicht oder unterschritten' geprüft (<=14, <=5, <=0), weiterhin mit Schutz vor Doppel-Versand pro Zyklus. Die E-Mail nennt weiterhin den echten Restbestand (z.B. 13 Tage), nur die interne Nachverfolgung nutzt die Schwelle (14)",
]),
("7.74.2", [
"Externer Cron-Aufruf für die Futter-Erinnerungen lief mit 500 Internal Server Error - Gradios eingebauter API-Mechanismus (/gradio_api/call/...) erwartet eine mehrstufige Anfrage-Choreografie, die ein simpler externer Cron-Dienst wie cron-job.org nicht mitmacht. Ersetzt durch eine eigene, simple HTTP-Route (/cron/futter-check) direkt neben der Gradio-App - normaler GET/POST-Aufruf ohne JSON-Body oder spezielle Header nötig. demo.launch() selbst bewusst unverändert gelassen (nur prevent_thread_lock ergänzt), um das bestehende PWA/Favicon-Verhalten nicht zu riskieren",
]),
("7.74.1", [
"Neuer Button im Admin-Bereich (nur für PetBase sichtbar, wie beim Admin-Ernennen): '🥫 Futter-Check jetzt manuell auslösen' - führt dieselbe Prüfung wie der geplante tägliche Cron-Aufruf sofort aus, zum Testen vor der Cron-Einrichtung. Alle Sichtbarkeits-Ausgabelisten (Erfolgs-/Fehler-/Nicht-eingeloggt-Pfad von check_saved_login) automatisiert auf exakte Längen-Übereinstimmung geprüft, um die Art von Output-Mismatch-Bug zu vermeiden, die dieses Projekt schon öfter geplagt hat",
]),
("7.74.0", [
"NEU: Automatische Futter-Nachkauf-Erinnerungen per E-Mail für alle Zoo-Accounts (nicht nur PetBase) - bei 14, 5 und 0 Tagen geschätztem Restbestand pro Futtersorte. Da Hugging Face Spaces keinen eingebauten Scheduler hat, läuft die Prüfung über einen extern aufrufbaren Endpunkt (api_name='futter_cron_check'), gedacht für einen täglichen Aufruf durch einen kostenlosen externen Cron-Dienst (z.B. cron-job.org). Duplikat-Schutz über neues Tracking-Sheet 'Futter_Erinnerungen_Log': pro Zoo/Sorte/Bestands-Update/Schwelle wird nur einmal verschickt. Bestandsberechnung aus dem Dashboard ('Futter wird knapp') in eine gemeinsame Funktion ausgelagert, um Code-Duplikate zu vermeiden",
]),
("7.73.18", [
"Kritischer Bugfix: Profilbilder und der Upload-Button dafür waren komplett unsichtbar (weder gespeicherte Fotos noch die Möglichkeit, neue hochzuladen). Ursache: der height:0 + overflow:hidden-Trick aus v7.73.10 zur Höhen-Angleichung ist kollabiert, sobald die Zeile keine verlässliche Höhe lieferte, und hat dadurch den kompletten Bildbereich ausgeblendet. Höhen-Angleichungs-Versuch zurückgenommen, Profilbild-Bereich läuft wieder im normalen Layout-Fluss - Sichtbarkeit und Funktion gehen vor Kosmetik",
]),
("7.73.17", [
"Vermutlich DER eigentliche Bug hinter 'neues Tier verschwindet' (nach ausführlicher Diagnose über mehrere Versionen bestätigt: 0 von diversen Testversuchen kamen im Sheet an, obwohl append_row() nie einen Fehler warf): ws.append_row() durch ws.update() an einer selbst berechneten Zielzeile ersetzt - dieselbe Methode, die für 'bestehendes Tier bearbeiten' bereits nachweislich zuverlässig funktioniert (bestätigt durch Flockes erfolgreichen Kategorie-Wechsel). append_row()s interne 'finde die nächste leere Zeile'-Logik war offenbar die Fehlerquelle",
]),
("7.73.16", [
"Diagnose war irreführend: die gemeldete Zeilenzahl zählte versehentlich ALLE Zoo-Accounts im gemeinsamen Sheet zusammen (z.B. auch 'Lilalaunebärin'), nicht nur PetBase - die beobachteten Sprünge (+7, +2) waren dadurch nicht unbedingt aussagekräftig. Diagnose zeigt jetzt nur noch die eigene Zeilenzahl des aktuellen Zoo-Accounts plus die letzten 3 tatsächlich vorhandenen Tiernamen, um sichtbar zu machen was wirklich geschrieben wurde statt nur ein gefunden/nicht-gefunden zu melden",
]),
("7.73.15", [
"Der .select()-Fix aus v7.73.14 wirkt: Speichern liefert jetzt zuverlässig eine Meldung statt spurlos zu verschwinden. Die Diagnose zeigte aber 'Zeile fehlt beim Zurücklesen' bei 'Flocke2', obwohl das Sheet bereits 14 statt der erwarteten 7 Zeilen hatte - Verdacht auf Wettlauf zwischen Schreiben und sofortigem Zurücklesen bei Google Sheets. Diagnose-Prüfung wartet jetzt bis zu 2x 1,5 Sekunden und versucht es erneut, bevor sie einen echten Fehler meldet",
]),
("7.73.14", [
"Vermutlich DIE Ursache für spurlos verschwindende neu angelegte Tiere gefunden: tier_dropdown.change() feuerte auch dann, wenn save_data() den Dropdown-Wert nach dem Speichern selbst programmatisch setzte (seit v7.73.12 werden choices+value zusammen gesetzt) - das löste eine zweite, parallele load_tier()-Kette aus, die den noch laufenden Speicher-Vorgang störte, bevor die Bestätigung sichtbar wurde (Video-Analyse zeigte: nach Klick auf 'Profil Speichern' erschien gar keine Meldung mehr, die Seite sprang einfach zurück). Fix: .change() durch .select() ersetzt - feuert nur noch bei echter Nutzer-Auswahl, nicht bei per Code gesetzten Werten",
]),
("7.73.13", [
"Diagnose-Erweiterung: Der Dropdown-Absturz aus v7.73.12 ist behoben, aber 'Flocke2' fehlt weiterhin im Sheet - die eigentliche Ursache liegt also woanders. save_data() liest das Sheet jetzt SOFORT nach dem Schreiben nochmal zurück und prüft, ob die neue Zeile wirklich angekommen ist. Falls nicht, erscheint statt 'Gespeichert!' jetzt eine Diagnose-Meldung mit Zeilenanzahl und Status, um sichtbar zu machen ob append_row() von Google Sheets im Hintergrund verworfen wird",
]),
("7.73.12", [
"Vermutlich DER Bug hinter 'neues Tier verschwindet': update_dropdowns() setzte für das Tier-Auswahl-Dropdown nur choices, aber kein value. Gradio versuchte dann den bisherigen (evtl. veralteten) Client-Wert beizubehalten - passte der nicht zur frischen Liste, brach die komplette Server-Antwort mit 'X is not in the list of choices' ab (reproduziert beim Wechsel Eigenes Tier -> Pflegetier). update_dropdowns() bekommt jetzt einen expliziten select_name-Parameter, save_data() übergibt den gerade gespeicherten Namen direkt mit - choices und value werden immer synchron zusammen gesetzt",
]),
("7.73.11", [
"Diagnose-Hilfe: Fehlermeldung beim Speichern eines Tierprofils (save_data) nicht mehr auf 20 Zeichen abgeschnitten, sondern zeigt jetzt Fehlertyp + vollen Text (bis 120 Zeichen) - Hintergrund: neu angelegtes Tier ('Flocke') taucht trotz 'Gespeichert!'-Meldung nicht im Tier_Profile-Sheet auf, Ursache noch nicht gefunden, diese Änderung soll beim nächsten Versuch mehr Klarheit bringen, falls doch ein bisher verdeckter Fehler auftritt",
]),
("7.73.10", [
"Bugfix zu v7.73.9: bei breiten Fotos (Rosi) funktionierte die Höhenangleichung des Profilbilds an die linke Spalte, bei Hochformat-Fotos (Levi) nicht - das Bild brachte über sein Datei-Seitenverhältnis weiterhin eine eigene, sehr große natürliche Höhe mit, die die komplette Zeile mit aufblähte. Jetzt wird das Bild per height:0 komplett aus der Zeilenhöhen-Berechnung herausgenommen und stattdessen per position:absolute + min-height:100% nachträglich exakt auf die von der linken Spalte vorgegebene Höhe gezogen - funktioniert jetzt unabhängig vom Seitenverhältnis des Fotos",
]),
("7.73.9", [
"Profilbild: Höhe passt sich jetzt unabhängig vom Seitenverhältnis des hochgeladenen Fotos exakt an die linke Spalte (Name+Tierauswahl) an, statt mal zu schmal/gequetscht, mal riesig zu wirken. Umgesetzt über align-items:stretch auf der Row plus flex:1-Kette durch alle Zwischen-Container (bewusst OHNE height:100% auf Zwischenebenen - das hatte bei den Stammdaten-Feldern in v7.73.4-7.73.6 bereits den Stretch-Mechanismus blockiert, derselbe Fehler wurde hier vermieden)",
"Graue Hintergründe im Bild-Container und der Upload/Kamera-Werkzeugleiste (Gradios Standard-Grau #F3F4F6) entfernt, damit es zur warmen Creme-Farbpalette der App passt",
]),
("7.73.8", [
"Profilbild: Versuch, die eingebaute Werkzeugleiste (Vollbild/Löschen-Icons) am Überdecken des Bildinhalts (meist der Tierkopf oben im Foto) zu hindern - rein per CSS (Bild bekommt oben 40px 'Sicherheitszone' und wird unten ausgerichtet, Leisten-Hintergrund transparent), diesmal bewusst OHNE JavaScript-Ersatzbuttons, da dieser Ansatz in v7.68-7.72 mehrfach andere Buttons kaputt gemacht hatte. Muss noch am Gerät geprüft werden, da die genaue CSS-Struktur der Werkzeugleiste je nach Gradio-Version variieren kann",
]),
("7.73.7", [
"Bugfix zu v7.73.6: 'background:transparent' auf den Stammdaten-Feld-Blöcken hatte keine weiße Karte freigelegt, sondern die nackte Row-/Seitenfläche dahinter - dadurch wirkte der gesamte Bereich grau/bläulich statt weiß. Jetzt dieselbe Karten-Farbe wie im Rest der App (#FFFCF5, warmes Creme statt reinem Weiß, damit es zum bestehenden Design passt)",
]),
("7.73.6", [
"Bugfix zu v7.73.5: graue Lücke war immer noch da, weil die Höhen-Kette vom Grid bis zur Textarea an einer Zwischenstelle abriss (Label bzw. Gradios innerer 'wrap'-Div um die Textarea hatten kein height:100%, wodurch flex-grow am Textfeld ins Leere lief). Jetzt wird die Höhe durchgängig von Feld-Block über Label/Wrap bis zur Textarea durchgereicht - nur die Textarea selbst bleibt bewusst ohne feste Höhe, um Gradios JS-Autosize für mehrzeiligen Text nicht zu zerstören",
]),
("7.73.5", [
"Bugfix zu v7.73.4: 'height:100% !important' auf den Textfeldern hatte Gradios eigene JavaScript-Autosize-Höhe bei mehrzeiligem Text überschrieben - dadurch wurde Farbe auf eine viel zu kleine Höhe zusammengestaucht (Inhalt nur noch per Scrollen sichtbar) und die graue Lücke sprang zu einer anderen Zeile. Jetzt nur noch flex-grow ohne feste Höhe, dadurch wächst das kürzere Feld mit, ohne das längere zu kappen",
]),
("7.73.4", [
"Stammdaten-Spalten: graue Lücke behoben, die entstand, wenn ein Feld durch längeren Text höher war als sein Nachbar (z.B. Farbe vs. Geburtsdatum) - Felder einer Zeile werden jetzt per align-items:stretch auf gleiche Höhe gezogen, das kürzere wächst automatisch mit",
"Dropdown-Text (Tierart, Kastriert etc.) wirkte fetter/anders als der Text in normalen Feldern, da Browser <select>-Elemente mit ihrer eigenen System-Schrift rendern - jetzt font-family/font-weight/font-size erzwungen identisch zu Textfeldern",
]),
("7.73.3", [
"Stammdaten-Felder auf iOS an Android angeglichen: hellgrauer Halo/Rahmen um jedes Feld entfernt (natives Gradio-Karten-Styling, das nur auf iOS Safari durchschien) und die native Höhenabweichung von <select> gegenüber <input> per -webkit-appearance:none behoben. Da appearance:none den nativen Dropdown-Pfeil auf allen Plattformen entfernt, wurde ein eigener SVG-Pfeil ergänzt, damit Android weiterhin einen sichtbaren Pfeil hat",
]),
("7.73.2", [
"Stammdaten-Spalten auf Android behoben: Gradio hatte die beiden Felder pro Zeile heimlich in einen gemeinsamen Wrapper-Div gepackt, wodurch das 50/50-Grid nur ein Kind-Element sah und alles in die linke Hälfte quetschte (rechte Hälfte blieb leer). Wrapper wird jetzt per display:contents fürs Layout transparent gemacht, sodass die Grid-Regel direkt auf die beiden Felder wirkt",
]),
("7.73.1", [
"Stammdaten-Spalten: .zweispaltig-Klasse von Flexbox auf CSS Grid mit minmax(0, 1fr) umgestellt - verhindert, dass die native Mindestbreite von Eingabefeldern (besonders bei iOS Safari) das exakte 50/50-Aufteilen verhindert. Bleibt weiterhin eng auf die vier betroffenen Zeilen begrenzt",
]),
("7.73.0", [
"Stammdaten-Spalten: alter JavaScript-Workaround entfernt, stattdessen gezielte CSS-Klasse ('zweispaltig') nur auf die vier betroffenen Zeilen angewendet - sauberer und enger begrenzt als die bisherigen Versuche, dadurch geringeres Risiko für Nebenwirkungen an anderen Stellen",
]),
("7.72.2", [
"Profilbild-Ersatz-Buttons-Ansatz komplett zurückgenommen - hat vermutlich versehentlich auch Upload-/Kamera-/Zwischenablage-Buttons mit ausgeblendet und die Darstellung verschlechtert statt verbessert. Zurück zum Original-Verhalten von Gradio (Symbole überlappen weiterhin das Label, aber alles andere funktioniert normal)",
]),
("7.72.1", [
"Echten Bug gefunden, warum die Profilbild-Ersatz-Buttons aus v7.72.0 komplett wirkungslos waren: Die Funktion brach vorzeitig ab, wenn sie nicht exakt den Text 'Profilbild' in einem eigenen Element fand (z.B. wegen eines mit eingebetteten Icons) - eine Prüfung, die in dieser Version gar nicht mehr nötig war, aber übersehen wurde. Jetzt entfernt, Funktion sollte jetzt tatsächlich greifen",
]),
("7.72.0", [
"Profilbild-Symbole komplett neu gelöst (Idee vom Nutzer): statt die Original-Symbole zu verschieben, werden sie jetzt unsichtbar (aber weiterhin funktional) gemacht und durch eigene, garantiert sichtbare Ersatz-Buttons unter dem Bild ersetzt, die beim Klick die Original-Funktion auslösen - umgeht das wiederholt gescheiterte Positionierungsproblem",
]),
("7.71.2", [
"Beide JS-Fixes (Stammdaten-Spalten, Profilbild-Icons) robuster gemacht: weniger strenge Bedingungen, textbasierte statt klassenbasierte Suche, MutationObserver für sofortige statt verzögerte Reaktion",
]),
("7.71.1", [
"Vorherige CSS-Grid-Regel für Stammdaten-Felder zurückgenommen, da sie auf BEIDEN Plattformen (iOS und Android) zu einspaltiger Darstellung geführt hat - war ein Fehlversuch",
"Stammdaten-Zweispaltigkeit jetzt per JavaScript direkt gesetzt statt per CSS-Regel, da sich iOS Safari und Android Chrome bei Flexbox/Grid-Berechnung unterschiedlich verhalten haben",
"Profilbild-Werkzeugleiste: statt einer geschätzten Pixelzahl wird jetzt die tatsächliche Label-Höhe per JavaScript gemessen und die Icons exakt darunter positioniert",
]),
("7.71.0", [
"Stammdaten-Felder auf iOS: von Flexbox auf CSS Grid umgestellt, da Flexbox mit nowrap dort trotzdem manchmal umgebrochen hat - Grid verhält sich browserübergreifend deutlich vorhersehbarer (Name/Profilbild-Zeile bewusst ausgenommen, behält ihr 2:1-Verhältnis)",
"Werkzeugleiste (Vollbild/Löschen) bei Bild-Feldern weiter nach unten verschoben, da der vorherige Abstand nicht ausreichte, um das Label vollständig freizugeben",
]),
("7.70.2", [
"Code-Review durchgeführt: echten Bug in change_modus() gefunden und behoben - eine Schutzklausel (nicht eingeloggt) gab noch die veraltete Länge von vor mehreren Erweiterungsrunden zurück (16 statt 21 Werte), was beim Umschalten von Erweitertem Modus ohne aktive Sitzung zu einem Fehler geführt hätte. Alle anderen Kernfunktionen (load_tier, save_data, archive_tier, check_saved_login, Nutzerverwaltung) auf Längen-Konsistenz geprüft - keine weiteren Probleme gefunden",
]),
("7.70.1", [
"PDF-Export: Checkboxen stehen jetzt direkt über 'Download Akte', vierte Checkbox für alle Tierarzt-Dokumente ergänzt (bisher immer fest enthalten, jetzt ebenfalls wählbar)",
]),
("7.70.0", [
"PDF-Export: Auswahl ergänzt, was enthalten sein soll - Infoblatt, Pflegevertrag und Vermittlungsvertrag jetzt einzeln an-/abwählbar (frei kombinierbar), statt immer alles zusammen zu exportieren",
]),
("7.69.0", [
"Banner oben in der App ist jetzt anklickbar und führt direkt zur Startseite - ohne Umweg über den Zurück-Button, setzt dabei auch dessen Verlauf zurück",
]),
("7.68.2", [
"Zurück-Button: Selbstheilungs-Prüfung von 1x/Sekunde auf 4x/Sekunde erhöht, da der Wechsel zwischen Haupttabs sehr viele Elemente gleichzeitig aktualisiert und dadurch länger dauert als bei Sub-Tabs - sollte das Verschwinden speziell bei Haupttab-Wechseln beheben",
"Garantierter Endpunkt 'Startseite': Genug Zurück-Klicks führen jetzt immer zur echten Startseite (Verwaltung, kein Tier ausgewählt), unabhängig davon, wo die Navigation begonnen hat",
]),
("7.68.1", [
"Zurück-Button 'selbstheilend' gemacht: Falls er durch eine interne Aktualisierung der Seite zeitweise aus dem DOM entfernt wird, fügt er sich jetzt sofort automatisch wieder ein, statt erst beim nächsten Tab-Klick wieder aufzutauchen - sollte sowohl das gelegentliche Verschwinden als auch die uneinheitliche Funktion beheben",
]),
("7.68.0", [
"Echten Logikfehler beim Zurück-Button gefunden: Er hat versehentlich den gerade aktiven Tab nochmal angeklickt statt zum Tab davor zu springen - ein erneuter Klick auf den bereits aktiven Tab tut aber nichts, daher schien der Button wirkungslos. Jetzt korrekt behoben",
"Zurück-Button ist jetzt frei verschiebbar (Ziehen mit Maus/Finger) und merkt sich seine Position dauerhaft (pro Gerät/Browser), bis er erneut verschoben wird",
]),
("7.67.1", [
"Zurück-Button komplett überarbeitet: eigener Navigations-Speicher statt Browser-History-API (war vermutlich die Ursache, warum er keine Aktion ausgelöst hat - unzuverlässig in eingebetteten Kontexten), Position jetzt vertikal mittig statt unten links, damit er immer sichtbar ist ohne scrollen zu müssen",
]),
("7.67.0", [
"Schwebender Zurück-Button ergänzt (unten links, erscheint erst nach dem ersten Tab-Wechsel), da die native Zurück-Geste browserabhängig nicht immer zuverlässig funktioniert - nutzt dieselbe Verlaufs-Logik, springt also genauso zum vorherigen Tab zurück",
]),
("7.66.0", [
"Native Zurück-Geste (iOS-Wischen vom Rand / Android-Zurück-Geste bzw. -Taste) springt jetzt zum vorherigen Tab innerhalb der App zurück, statt die Seite/App zu verlassen - funktioniert für Haupt-, Bereichs-, Kategorie- und Konto-Tabs",
]),
("7.65.0", [
"Bugfix: Navigations-Reset (Verwaltung → anderer Tab → zurück) setzte zwar die Tier-Auswahl im Dropdown zurück, die Formularfelder darunter (Name, Bild etc.) blieben aber fälschlich mit dem zuletzt ausgewählten Tier gefüllt - jetzt wird load_tier korrekt mit aufgerufen, genau wie bei einer echten manuellen Auswahl",
"Vollbild-/Löschen-Symbole bei Bildfeldern (z.B. Profilbild) liegen nicht mehr über dem Feld-Label",
"Stammdaten-Felder werden jetzt unabhängig von der Plattform (iOS/Android) konsequent zweispaltig dargestellt",
]),
("7.64.1", [
"Bugfix: Bei mehrseitigen PDFs (z.B. 6-seitiger Pflegevertrag) wurden manche Seiten beim Hochladen stillschweigend übersprungen, wenn der kostenlose Bilder-Hosting-Dienst zu viele schnell aufeinanderfolgende Uploads gedrosselt hat. Jetzt mit Wiederholungsversuchen und kurzer Pause zwischen den Seiten deutlich zuverlässiger",
]),
("7.64.0", [
"ECHTE Ursache für den E-Mail-Timeout gefunden (bestätigt u.a. von einem Hugging-Face-Mitarbeiter im offiziellen Forum): Hugging Face Spaces blockiert grundsätzlich alle ausgehenden SMTP-Ports (25/465/587) - das betrifft jeden, der SMTP von einem Space aus nutzt, nicht nur uns",
"E-Mail-Versand komplett auf Brevo-HTTP-API umgestellt (Port 443, nicht blockiert) statt klassischem SMTP - braucht jetzt einen Brevo-API-Schlüssel statt SMTP-Zugangsdaten",
]),
("7.63.0", [
"Nutzerverwaltung: Freitextfelder durch vier Dropdowns ersetzt - Aktive Nutzer sperren, Gesperrte Nutzer entsperren, Admin-Status vergeben (nur Nicht-Admins), Admin-Status entziehen (nur aktuelle Admins) - Listen aktualisieren sich automatisch nach jeder Aktion",
"Bugfix: 'Vollbild-Bild schließt sich bei erneutem Klick' hat versehentlich das Profilbild statt nur die Vollbildansicht gelöscht, da der falsche Button getroffen wurde - jetzt über Escape-Taste gelöst, sicherer als Button-Suche",
"E-Mail-Versand: Secrets werden jetzt defensiv bereinigt (Anführungszeichen/Leerzeichen), Fehlermeldung zeigt jetzt den tatsächlich verwendeten Server-Namen zur besseren Fehlersuche",
]),
("7.62.0", [
"Test-E-Mail-Button im Admin-Bereich - sendet eine Test-Mail an die eigene hinterlegte E-Mail-Adresse, um den Versand zu prüfen",
"News können jetzt optional (Checkbox) auch per E-Mail an alle Nutzer mit hinterlegter E-Mail-Adresse verschickt werden",
"Neuer Zähler 'Versendete E-Mails gesamt' in der Admin-Übersicht (App-Reichweite)",
"Admin-Status kann jetzt auch wieder entzogen werden, nicht nur vergeben (nur durch Hauptadmin)",
"'Inventar' umbenannt zu 'Futter Vorrat' in der Navigation und den Übersichtstexten",
"Bugfix: Mehrseitige PDFs (z.B. Pflegevertrag) wurden nur mit der ersten Seite gespeichert - jetzt werden alle Seiten konvertiert, hochgeladen und beim PDF-Export vollständig eingebettet",
"Bugfix: Telefonnummern mit führender 0 oder + wurden von Google Sheets als Zahl fehlinterpretiert und verloren diese Zeichen - jetzt wird Textformat erzwungen",
"Vollbild-Profilbild schließt sich jetzt bei erneutem Antippen automatisch, falls der Schließen-Button außerhalb des Bildschirms liegt",
]),
("7.61.1", [
"Filter (Alle/Eigene Tiere/Pflege & Gäste) hatte weiterhin zu wenig Platz, da er sich eine Zeile mit 'Tier wählen' teilte - steht jetzt in voller Breite darunter statt in einer engen halben Spalte",
]),
("7.61.0", [
"Eigener Admin-Tab unter Konto ('⚙️ Einstellungen' / '👑 Admin') - alle administrativen Werkzeuge (App-Reichweite, News, Nutzerverwaltung) jetzt gebündelt, statt zwischen den normalen Einstellungen zu stehen",
"Startseite neu geordnet: 'Futter wird knapp' steht jetzt über 'Anstehende Erinnerungen', beide zusammen über 'Tiere im Bestand' - erscheinen nur noch zentral auf der Startseite, nicht mehr zusätzlich doppelt nach Tier-Auswahl",
"Navigations-Reset: Von Verwaltung zu einem anderen Haupt-Tab und zurück landet man jetzt wieder neutral auf der Tiere-Startseite, statt automatisch beim zuletzt ausgewählten Tier",
]),
("7.60.0", [
"E-Mail-Versand hinzugefügt (sicher): Zugangsdaten werden ausschließlich über Hugging-Face-Space-Secrets gelesen, NIE in der Google Sheet gespeichert",
"Neue '📧 E-Mail'-Buttons bei Impfung/Wurmkur/Parasitenschutz senden eine Erinnerung an die im Konto hinterlegte E-Mail-Adresse",
"Status 'E-Mail-Versand eingerichtet/nicht eingerichtet' erscheint jetzt in der Admin-Übersicht (App-Reichweite)",
]),
("7.59.0", [
"PDF-Export und Vertrag nachtragen sind jetzt eigenständige Bereichs-Tabs (neben Tiere/Futter-Zentrale/Archiv/Inventar), nicht mehr als Unter-Buttons in Archiv versteckt",
"Archiv, PDF-Export und Vertrag nachtragen sind jetzt nur noch sichtbar, wenn Erweiterter Modus aktiv ist",
"Vertrag nachtragen: neue Auswahl 'Aktueller Bestand' oder 'Archiv' - bei aktuellem Bestand kann jetzt direkt ein Pflege- oder Gasttier aus dem laufenden Bestand ausgewählt werden (vorher nur archivierte Tiere möglich)",
]),
("7.58.0", [
"Bugfix: Namensänderung bei einem Tier legte ein komplett neues Profil an statt das bestehende zu aktualisieren - Ursache war, dass die Suche nach der zu ändernden Zeile über den (evtl. schon geänderten) neuen statt den ursprünglichen Namen lief. Jetzt korrekt über den ursprünglich ausgewählten Namen",
"Filter-Auswahl (Alle/Eigene Tiere/Pflege & Gäste) steht jetzt untereinander statt bei Erweitertem Modus zusammengequetscht nebeneinander",
"Vollbild-Profilbild auf Bildschirmgröße begrenzt, damit der Schließen-Button immer erreichbar ist, ohne seitlich scrollen zu müssen",
]),
("7.57.0", [
"Neue Nutzerverwaltung im Admin-Bereich (Konto): Liste aller registrierten Nutzer mit E-Mail, Anzahl Tiere, letztem Login und Login-Zähler",
"Nutzer können jetzt gesperrt/entsperrt werden (gesperrte Konten können sich nicht mehr einloggen)",
"Blacklist-Mechanismus: Nach Löschen eines Kontos (über die App selbst) können derselbe Zoo-Name UND dieselbe E-Mail-Adresse nicht mehr neu registriert werden",
"Mehrfach-Admin-Unterstützung: Der Hauptadmin (PetBase) kann weitere Nutzer zu Admins ernennen, die dann ebenfalls die Nutzerverwaltung sehen (Ernennen selbst bleibt dem Hauptadmin vorbehalten)",
]),
("7.56.3", [
"Checkbox-Häkchen (BARF, Zusätzlich Nassfutter) bestätigt funktionsfähig. Radio-Punkt bei Fütterungsart (Trockenfutter/Nassfutter) auf robustere Zeichentechnik umgestellt (box-shadow statt Positionierung), da die vorherige Methode dort nicht griff",
]),
("7.56.2", [
"Fehlender Punkt/Haken: Radio-/Checkbox-Kreise werden jetzt komplett selbst per CSS gezeichnet (Kreis, Punkt bzw. Häkchen), statt uns auf Gradios eigene, teils unterdrückte native Darstellung zu verlassen - reagiert direkt auf die eingebaute Browser-Eigenschaft ':checked'",
]),
("7.56.1", [
"Bugfix (per markiertem Screenshot gefunden): Goldener Hintergrund blieb bei Trockenfutter/Nassfutter & BARF manchmal hängen, obwohl die Option schon abgewählt war - lag an einer zusätzlichen, unzuverlässigen CSS-Regel neben der eigentlich korrekten. Diese entfernt, nur noch die direkt zustandsreaktive Regel aktiv",
"Fehlender Punkt/Haken im Auswahlkreis: native Eingabe-Elemente werden jetzt explizit sichtbar und ausreichend groß gehalten, damit Gradio den Haken/Punkt überhaupt zeichnen kann",
]),
("7.56.0", [
"Ursache für 'BARF kaum sichtbar' gefunden: Anders als 'Erweiterter Modus' stand BARF nicht in einer eigenen Gruppe und bekam dadurch keine Karten-Umrandung - jetzt in eigene Gruppe gepackt, gleiche Optik wie Erweiterter Modus",
"Aktive Auswahl-Pillen (Trockenfutter/Nassfutter, Parasitenschutz-Intervall, Filter etc.) mit deutlich breiterem Satz an CSS-Regeln abgesichert, da sich Gradios interne Struktur zwischen 5.x und 6.x unterscheiden kann",
"'Aktualisieren'-Button in Inventar entfernt, da das Dashboard jetzt automatisch beim Öffnen lädt",
]),
("7.55.1", [
"Das per Screenshot markierte Feld identifiziert: 'Kategorie'-Dropdown (Eigenes Tier/Pflegetier/Urlaubsgast) in Stammdaten heißt jetzt 'Tierauswahl', die umgebende graue Box ist jetzt durchsichtig und verschmilzt mit dem Hintergrund",
]),
("7.55.0", [
"Enter/Tab springt jetzt zum nächsten Feld (mehrzeilige Felder ausgenommen, dort bleibt Enter ein Zeilenumbruch) - Speichern-Button bleibt wie gewünscht bestehen",
"Inventar-Dashboard erscheint jetzt sofort beim Öffnen des Tabs, ohne erst 'Aktualisieren' drücken zu müssen",
"Schließen-Button (✕) bei Bearbeiten steht jetzt direkt neben '🔧 Mehr' statt am Zeilenanfang",
"'Neu laden'-Button im Konto entfernt (durch Gradio-5.50-Downgrade nicht mehr nötig), 'Abmelden' zentriert, Anmelden/Registrieren-Tabs auf dem Login-Bildschirm gleichmäßig verbreitert",
"Download-Feld beim Vermisst-Steckbrief deutlich verkleinert",
"Aktive Auswahl bei Radio-/Checkbox-Feldern (BARF, Parasitenschutz-Intervall, Filter etc.) jetzt überall deutlich sichtbar hervorgehoben, nicht nur bei Erweiterter Modus",
"'Tierarzt Dok 1-6' umbenannt zu 'Tierarzt Dokumente (1-6)' im Auswahlmenü, Feld selbst heißt schlicht 'Tierarzt Dokument'",
"Neues Upload-Feld für den Pflegevertrag direkt unter Kategorie bei der Tier-Anlage, erscheint automatisch bei Kategorie='Pflegetier' + aktivem Erweiterten Modus - nutzt denselben Speicherpfad wie das bestehende Feld in Dokumente",
]),
("7.54.0", [
"PWA-Icon auf dem Homescreen gefixt: iOS Safari ignoriert beim 'Zum Home-Bildschirm hinzufügen' meistens das Web-App-Manifest und braucht einen expliziten apple-touch-icon-Tag - den gab es bisher nicht, deshalb erschien statt unserem Icon ein Seiten-Screenshot",
"apple-touch-icon zeigt jetzt auf Gradios eigenen /pwa_icon/180-Endpunkt (aus favicon_path generiert), plus Meta-Tags für Vollbild-Start ohne Browserleiste auf iOS",
"Android/Chrome nutzte bereits automatisch das manifest.json-Icon (Fix seit Gradio-PR #10341, in unserer 5.50.0 enthalten) - betrifft daher nur iPhones",
"Hinweis: Bereits vorhandene Homescreen-Icons werden von iOS/Android beim Hinzufügen gecacht und müssen einmalig gelöscht + neu hinzugefügt werden, damit das neue Icon greift",
]),
("7.53.0", [
"GROSSE ÄNDERUNG: Downgrade von Gradio 6.19.0 auf 5.50.0. Ein bestätigtes, unabhängiges Gradio-Issue (#12511) beschreibt exakt unser 'erster Klick zeigt Sichtbarkeits-Änderung nicht'-Problem als Regression, die genau beim Wechsel von 5.9 auf 6.0.2 eingeführt wurde. Keine der bekannten 5→6 Breaking-Changes (Dataframe, ChatInterface, Chatbot) betrifft unseren Code",
"Falls dieser Test nicht hilft: einfach die vorherige Version zurückspielen",
]),
("7.52.1", [
"Per Live-Debugging die eigentliche Ursache für 'Fütterung/Gesundheit öffnet nicht beim ersten Klick' gefunden: Die Tier-Lade-Kette (die selbst am Ende 'Stammdaten' setzt) war im Abbruch-Mechanismus NACH den Kategorie-Buttons registriert - dadurch konnte ein Kategorie-Klick diese ältere, noch laufende Kette nicht abbrechen. Jetzt davor registriert",
]),
("7.52.0", [
"Per Live-Debugging (direkte DOM-Inspektion) bestätigt: Die 'hide'-CSS-Klasse blieb manchmal auf einem Zielbereich hängen, obwohl der Inhalt selbst korrekt aktualisiert wurde - vermutlich ein Svelte-internes Verhalten, das eine Änderung manchmal 'übersieht'",
"Experimenteller Fix: Ein sich bei jedem Klick ändernder Dummy-Wert zwingt Svelte, die Änderung zuverlässig zu erkennen - angewendet bei Haupt-, Bereichs- und Kategorie-Tabs",
]),
("7.51.0", [
"Live-Test aufgedeckt: Kategorie-Tabs (Stammdaten/Fütterung/Gesundheit/Dokumente) öffneten konsistent nicht beim ersten Klick - derselbe Überholt-Antworten-Mechanismus wie bei den Haupt-/Bereichs-Tabs, aber der Abbruch-Fix aus v7.50.0 war dort noch nicht angewendet",
"Abbruch-Mechanismus jetzt konsequent auf ALLE Umschalt-Button-Gruppen ausgeweitet: Kategorie-Tabs, Tier-Auswahl/Speichern-Kette, Archiv-Umschaltung, Konto-Umschaltung",
]),
("7.50.1", [
"Ursache für 'Bearbeiten im Inventar öffnet nicht beim ersten Klick' gefunden (live getestet + vom Nutzer mit bewusstem Einzelklick bestätigt): Ein (oft unbewusster) Doppelklick traf beim zweiten Mal auf den neu erschienenen Schließen-Button, der zufällig an derselben Bildschirmposition lag wie der ursprüngliche Bearbeiten-Button - dadurch schloss sich das Feld sofort wieder",
"Fix: Schließen-Buttons (✕) bei Aufstocken/Bearbeiten/Mehr stehen jetzt am Zeilenanfang statt am Ende, sodass sie nicht mehr mit der Position des auslösenden Buttons überlappen",
]),
("7.50.0", [
"ECHTE Ursache live im Browser gefunden und bestätigt: Bei schnell aufeinanderfolgenden Klicks auf verschiedene Tabs kamen Server-Antworten nicht in der richtigen Reihenfolge zurück - eine ältere, überholte Antwort konnte eine neuere überschreiben. Live nachgestellt: 5 Tabs schnell hintereinander geklickt, am Ende zeigte sich der ERSTE statt der letzte Klick",
"Fix: Jeder Haupt- und Bereichs-Tab-Klick bricht jetzt die noch offenen (überholten) Anfragen der anderen Buttons derselben Ebene ab, damit sich Antworten nicht mehr gegenseitig überholen können",
]),
("7.49.0", [
"Vermutlich DER Fund zum monatelangen 'erst beim zweiten Klick'-Problem: Die Eruda-Debug-Konsole, die sich beim Login als 'PetBase' automatisch geladen hat, fing Gradios eigene Netzwerk-Anfragen ab und brach sie dabei offenbar ab - laut Konsolen-Stack-Trace des Nutzers. Komplett entfernt.",
"Das würde erklären, warum das Problem plattformübergreifend (PC und Handy) auftrat, praktisch jeden Button betraf und sich nicht zuverlässig reproduzieren ließ - alles typisch für einen selbst verursachten Netzwerk-Abfang-Konflikt, nicht für einen echten App-Fehler",
]),
("7.48.0", [
"Alle Haupt- und Bereichs-Tab-Buttons (Verwaltung/Vermisst/Konto, Tiere/Futter-Zentrale/Archiv/Inventar) von zwei- bzw. vierstufigen .then()-Ketten auf jeweils EINEN einzigen Server-Aufruf zusammengeführt - eliminiert unnötige Mehrfach-Rundreisen pro Klick komplett bei diesen Buttons",
"Weiterer gezielter Versuch gegen das durchgängige 'erst beim zweiten Klick'-Problem, diesmal durch Reduzierung der Server-Rundreisen statt durch Warm-up-Versuche",
]),
("7.47.3", [
"Neuer, gezielter Versuch gegen das durchgängige 'erst beim zweiten Klick'-Problem: ein einziger, unsichtbarer Aufruf beim Laden weckt Gradios Warteschlangen-Verbindung sofort auf (Verdacht: der allererste Klick nach dem Laden trifft manchmal auf eine noch nicht ganz aufgebaute Verbindung) - diesmal bewusst nur EIN Aufruf statt der langen Kette von v7.36.3, die frühere Verzögerungen verursacht hatte",
]),
("7.47.2", [
"Experimenteller, aber gut begründeter Versuch gegen das durchgängige 'öffnet erst beim zweiten Tippen'-Problem: touch-action:manipulation ergänzt, um die browsereigene Doppel-Tipp-Erkennungsverzögerung zu verhindern (die seit dem Aktivieren von Pinch-to-Zoom in v7.46.2 vermutlich neu hinzugekommen ist bzw. sich verschlimmert hat) - ohne Zoomen zu deaktivieren",
]),
("7.47.1", [
"Bugfix (iPhone): Tab-Beschriftungen (Stammdaten/Fütterung/Gesundheit/Dokumente etc.) wurden auf schmaleren Bildschirmen abgeschnitten ('tammdate' statt 'Stammdaten') - Ursache war mein eigener Gewicht/Menge-Fix, der versehentlich auch die Tab-Reihen erfasst hatte. Tab-Reihen jetzt gezielt ausgenommen, dürfen wieder normal umbrechen",
]),
("7.47.0", [
"Übersicht in Tiere umstrukturiert: 'Tiere im Bestand' verschwindet jetzt, sobald ein Tier ausgewählt ist; Stammdaten stehen direkt unter der Tier-Auswahl; 'Anstehende Termine' rutschen unter die Stammdaten des ausgewählten Tieres",
]),
("7.46.2", [
"Zoomen (Pinch-to-Zoom) jetzt auf beiden Plattformen ausdrücklich erlaubt, für bessere Lesbarkeit bei Bedarf",
]),
("7.46.1", [
"Bugfix (iOS): Beschriftungen wie 'Gewicht 1'/'Menge 1' konnten auf schmaleren/anders skalierten Bildschirmen (z.B. iPhone) ineinander überlappen - dürfen jetzt bei Bedarf auf eine zweite Zeile umbrechen statt zu überlappen, zusätzlich kompaktere Schrift auf sehr schmalen Bildschirmen",
]),
("7.46.0", [
"App-Titel auf 'PetBase' gesetzt und PWA-Icon aktiviert: 'Zum Startbildschirm hinzufügen' aus dem Browser zeigt jetzt das eigene Icon und den Namen 'PetBase' statt einer generischen Browser-Kachel",
]),
("7.45.5", [
"Bugfix: Gewicht/Menge im '🔧 Mehr'-Formular waren wieder untereinander statt nebeneinander - lag an der zusätzlichen Akkordeon-Verschachtelung dort, jetzt allgemeiner gelöst (betrifft auch andere verschachtelte Stellen)",
"Weiterer Versuch, das alte Akkordeon-Pfeilsymbol zuverlässig auszublenden (zeigte vorher beide Symbole gleichzeitig)",
]),
("7.45.4", [
"Bugfix: Akkordeon-Symbol zeigte kaputte Zeichen ('ƐƐ') statt drei Punkte - lag an einer fehlerhaften Zeichen-Kodierung, jetzt mit echtem Unicode-Zeichen behoben",
"Zusätzlich die vermutlich eigentliche Pfeil-Quelle (native HTML-Markierung) gezielter ausgeblendet, da das alte Pfeilsymbol vorher weiterhin sichtbar war",
]),
("7.45.3", [
"Bugfix: Gewicht/Menge-Felder (und andere Zwei-Spalten-Zeilen) waren durch die verschachtelten Karten untereinander statt nebeneinander gerutscht - jetzt per CSS erzwungen nebeneinander",
"Akkordeon-Symbol in allen Akkordeons von Pfeil/Dreieck auf klassische drei Punkte (⋮) umgestellt",
]),
("7.45.2", [
"Bugfix: 'Archivieren' war rot statt braun - korrigiert (Vermisst-Steckbrief bleibt bewusst rot)",
"Verbleibende orange Beschriftungen (z.B. Kategorie, Fütterungsart) über zusätzliche Gradio-Variablen auf Braun umgestellt",
"Ausgewählte Radio/Checkbox-Option (z.B. Trockenfutter/Nassfutter) heller gemacht - vorher war die dunkle Schrift auf dunkelbraunem Grund kaum lesbar",
]),
("7.45.1", [
"Bugfix: 'Speichern' beim Futter-Scan und 'Konto erstellen' waren fälschlich rot (Löschen-Farbe) statt braun (normale Aktion)",
"Native Gradio-Elemente (Radio-Buttons wie Trockenfutter/Nassfutter, Checkboxen, Slider) nutzen jetzt ebenfalls Braun statt des alten Orange - vorher hatten diese noch die alte Akzentfarbe",
]),
("7.45.0", [
"Farbpalette an den PetBase-Banner angepasst: warmes Creme/Sand statt kühlem Google-Grau, warmes Braun statt grellem Orange als Akzentfarbe - das Banner fügt sich jetzt nahtlos in die Seite ein statt in einer grauen Box zu sitzen",
]),
("7.44.0", [
"Großer Kurswechsel: Dark Mode komplett entfernt, App läuft jetzt in einem hellen, freundlichen Material-Design-Look (stark an Stock-Android angelehnt) - weißer Hintergrund, dezente graue Flächen, klare dunkle Schrift",
"Alle Dashboard-/Übersicht-Karten (Konto, Inventar, Tiere-Übersicht) auf helle Farben umgestellt",
"Das ist Schritt 1 des angeforderten Kurswechsels weg vom '2010-Gefühl' hin zu einem modernen, simplen Erscheinungsbild",
]),
("7.43.0", [
"Design-System 2.0: durchgängige Card-Optik für alle Formulare/Gruppen (dezenter Schatten, weicher Rand), verfeinerte Farbpalette, sanfte Verläufe bei Haupt-Buttons, klarere Überschriften-Hierarchie",
"Neue Übersicht beim Öffnen von Tiere (solange kein Tier ausgewählt ist): Anzahl Tiere, anstehende Impfungen/Wurmkuren/Parasitenschutz der nächsten 30 Tage, Futter das bald ausgeht",
"Das ist Schritt 1+2 der Modernisierung (visuelles Fundament + Startübersicht). Schritt 3 (Formulare entschlacken) folgt als nächstes",
]),
("7.42.0", [
"Das '🔧 Mehr'-Detail-Formular (Hersteller, Fütterungsart, Gewichts-Brackets) erscheint jetzt ebenfalls direkt inline unter der jeweiligen Sorte, statt an einer gemeinsamen Stelle - konsistent mit Aufstocken und Bearbeiten",
"Das frühere gemeinsame Formular für alle Sorten wurde komplett entfernt",
]),
("7.41.0", [
"Aufstocken und Bearbeiten erscheinen jetzt direkt inline unter der jeweiligen Futtersorte (Futter 1 -> Formular Futter 1 -> Futter 2 -> ...), statt an einer gemeinsamen Stelle",
"Inline-Bearbeiten zeigt nur noch Bestand + Sackgröße sowie einen 🗑️ Löschen-Button - für seltene Detail-Änderungen (Hersteller, Fütterungsart, Gewichts-Brackets) gibt es einen '🔧 Mehr'-Link ins gemeinsame Detail-Formular",
"Neuer Löschen-Button direkt im Inline-Formular, um eine Futtersorte komplett aus dem Bestand zu entfernen",
"Maximale Anzahl gleichzeitig angezeigter Sorten von 10 auf 8 reduziert (mehr Felder pro Sorte als zuvor)",
]),
("7.40.0", [
"Inventar-Formulare aufgeräumt: Futter-Auswahl-Dropdowns bei Aufstocken und Bearbeiten ausgeblendet (nicht mehr nötig, da Sorte jetzt direkt über die Mini-Buttons vorausgewählt wird)",
"Fütterungsart und die vier Gewicht/Menge-Felder wandern beim Bearbeiten in ein aufklappbares 'Erweitert'-Akkordeon",
"Reihenfolge umgestellt: Dashboard mit Futtersorten zuerst, die Aufstocken/Bearbeiten-Formulare erscheinen jetzt darunter statt darüber",
]),
("7.39.1", [
"Neues Futter hinzufügen aktualisiert jetzt automatisch die Dashboard-Übersicht samt Mini-Buttons - kein manuelles 'Aktualisieren' mehr nötig",
]),
("7.39.0", [
"Inventar umgebaut: Die großen 'Aufstocken'/'Bearbeiten'-Tabs oben sind komplett entfernt",
"Stattdessen hängen ➕ Aufstocken und ✏️ Bearbeiten jetzt direkt an der echten Live-Karte jeder einzelnen Futtersorte (keine separate vereinfachte Liste mehr)",
"Formulare haben einen eigenen ✕ Schließen-Button, da es keine übergeordneten Umschalt-Buttons mehr gibt",
]),
("7.38.0", [
"Tabs merken sich keine Unter-Auswahl mehr: Beim Wechsel eines Haupt- oder Bereichs-Tabs wird immer die Übersicht gezeigt (Kategorie zurück auf Stammdaten, offene Formulare in Archiv/Inventar/Konto schließen sich)",
"Futter-Übersicht im Inventar kompakter gestaltet",
"Unter jeder Futtersorte gibt es jetzt direkt ➕ Aufstocken und ✏️ Bearbeiten Mini-Buttons, die automatisch die richtige Sorte vorauswählen",
]),
("7.37.1", [
"Die 25-stufige Vorwärm-Kette aus v7.36.3 wieder komplett entfernt: Sie war reine Spekulation ohne bestätigten Nutzen und hat vermutlich neue Verzögerungen verursacht (eigene Klicks reihten sich hinter der Kette in die Warteschlange ein, fühlte sich wie 'angezogene Handbremse' an)",
"Die tatsächlich bestätigten Fixes bleiben erhalten: Dictionary-Format überall, gegenseitiger Ausschluss bei Toggle-Paaren, Konto-Akkordeons entfernt, Archiv/Inventar-Umstrukturierung",
]),
("7.37.0", [
"Archiv und Inventar umstrukturiert: PDF-Export/Vertrag nachtragen bzw. Aufstocken/Bearbeiten stehen jetzt direkt oben als Unter-Navigation (wie bei Tiere die Kategorien), statt unten im Formular versteckt zu sein",
"Diese Buttons zeigen jetzt auch optisch den aktiven Zustand (Unterstrich), wie die anderen Tab-Ebenen",
]),
("7.36.3", [
"Vorwärm-Sequenz beim Laden auf WIRKLICH alle Umschalt-Buttons ausgeweitet, nicht nur die äußeren Bereiche: jetzt auch News/Feedback/Erinnerungen/Passwort/Konto löschen (Konto), PDF-Export/Vertrag nachtragen (Archiv), Aufstocken/Bearbeiten (Inventar), alle vier Tiere-Kategorien und Foto-Scan",
]),
("7.36.2", [
"Experimenteller Workaround für Archiv/Inventar/Konto (öffneten erst beim zweiten Versuch): App durchläuft beim Laden einmal automatisch diese Bereiche, um Gradios verzögerte Darstellung vorwegzunehmen, bevor der Nutzer sie zu sehen bekommt",
"Keine JavaScript-Ursache mehr gefunden (vollständig durchsucht) - vermutlich ein Darstellungsverhalten in Gradio selbst, betrifft auffällig die zuletzt im Code erstellten Bereiche",
]),
("7.36.1", [
"Bugfix: Aufstocken schloss sich nicht, wenn man Bearbeiten öffnete (waren unabhängige Schalter statt eines Paares) - jetzt schließt das eine automatisch, wenn das andere geöffnet wird. Gleiches für PDF-Export/Vertrag nachtragen in Archiv",
"Kritischer Bugfix: Der obige Umbau hatte eine verwaiste Referenz auf entfernte Variablen hinterlassen (hätte die App zum Absturz gebracht) - gefunden und korrigiert, bevor ausgeliefert",
]),
("7.36.0", [
"Ursache für 'Konto öffnet erst beim zweiten Versuch' gefunden: Konto enthielt als einziger Hauptbereich noch echte Gradio-Akkordeons (News, Einstellungen, Feedback, Erinnerungen, Passwort, Konto löschen) - diese jetzt auf dasselbe zuverlässige Button+Feld-Muster umgestellt wie der Rest der App",
]),
("7.35.0", [
"Vollständige Konsistenz-Bereinigung: BARF/Nassfutter- und Dokumententyp-Umschaltung ebenfalls auf das zuverlässige Dictionary-Format umgestellt",
"Bugfix: Trockenfutter/BARF/Nassfutter-Anzeige verließ sich beim Tier-Laden auf eine verkettete Reaktion, die nicht immer zuverlässig auslöste - lädt jetzt direkt die korrekte Ansicht mit",
"Nach dem Archivieren eines Tieres schließt sich die Detailansicht jetzt zuverlässig, statt möglicherweise eine veraltete Ansicht stehen zu lassen",
"Gesamten Code auf verwaiste Referenzen und Formatinkonsistenzen geprüft - keine weiteren gefunden",
]),
("7.34.2", [
"Bugfix: Beim Tier-Wechsel oder Speichern wurde nur Stammdaten sichtbar gemacht, ohne Fütterung/Gesundheit/Dokumente zurückzusetzen - falls man zuvor auf einer anderen Kategorie war, blieben beide gleichzeitig sichtbar. Setzt jetzt beim Laden/Speichern konsequent auf Stammdaten zurück",
]),
("7.34.1", [
"Dictionary-Rückgabeformat aus v7.34.0 jetzt auf ALLE Auf/Zu-Buttons ausgeweitet, die es vorher noch nicht hatten: Foto-Scan, PDF-Export, Vertrag nachtragen, Aufstocken, Bearbeiten, Onboarding-Popup, Versionshistorie",
"Zuvor war der Fix nur auf die drei Haupt-Navigationsebenen angewendet worden, nicht auf die einzelnen Auf/Zu-Buttons innerhalb der Bereiche",
]),
("7.34.0", [
"Tab-Umschaltung komplett neu implementiert nach Gradios offiziellem Dokumentations-Muster: Rückgabe als Dictionary mit Komponenten als Schlüssel statt als Positions-Liste, einstufig statt zweistufig",
"Recherche bestätigt bekannte Gradio-Regressionen bei dynamisch umgeschalteten Bereichen in neueren Versionen - dieser Ansatz folgt jetzt exakt Gradios eigenem kanonischen Beispiel für Sichtbarkeits-Umschaltung",
]),
("7.33.0", [
"Kritischer Architektur-Fix: Client-seitige JavaScript-Sichtbarkeits-Hacks komplett entfernt (kämpften gegen Gradios eigenen Server-Zustand -> Effekt 'öffnet für einen Moment und schließt sofort wieder'). Alle Tab-Ebenen laufen jetzt ausschließlich über Python, das Gradios Zustand korrekt aktualisiert",
"Vollständiges Audit: keine doppelten Klick-Bindungen, keine doppelten IDs, keine verwaisten Referenzen mehr gefunden",
"Schriftgröße app-weit etwas verkleinert für ein weniger überladenes Erscheinungsbild",
]),
("7.32.1", [
"Kritischer Bugfix: Beim Wechseln zwischen den neuen Tab-Buttons blieb der vorherige Inhalt sichtbar und der neue kam nur dazu, statt ihn zu ersetzen - jetzt zweistufig (erst alles verstecken, dann garantiert nachgelagert nur das Ziel zeigen)",
]),
("7.32.0", [
"Alle drei Tab-Ebenen (Verwaltung/Vermisst melden/Konto, Tiere/Futter-Zentrale/Archiv/Inventar, Stammdaten/Fütterung/Gesundheit/Dokumente) laufen jetzt über eigene Buttons statt Gradios Tabs-Komponente - kein \"⋮\"-Overflow-Menü mehr möglich",
"Buttons sind optisch als Reiter gestaltet (flache Kanten, farbiger Unterstrich für den aktiven Tab) - auf den ersten Blick nicht von echten Tabs zu unterscheiden",
"Nutzt dieselbe reine Python-Sichtbarkeits-Logik ohne JavaScript, die sich beim Aufstocken/Bearbeiten-Fix als zuverlässig erwiesen hat",
]),
("7.31.2", [
"Tabs werden jetzt senkrecht untereinander statt nebeneinander angezeigt - verhindert Gradios \"⋮\"-Overflow-Menü zuverlässiger, da kein Platzmangel in der Breite mehr entstehen kann",
]),
("7.31.1", [
"Kritischer Bugfix: App startete gar nicht mehr (NameError) - verwaiste Verkabelung des in v7.31.0 entfernten Vermisst-Toggle-Buttons wurde übersehen und jetzt entfernt",
]),
("7.31.0", [
"Stammdaten/Fütterung/Gesundheit/Dokumente-Felder erscheinen jetzt erst, wenn wirklich ein Tier ausgewählt ist (vorher immer sichtbar)",
"\"🚨 Vermisst melden\" ist jetzt ein eigener Haupt-Tab mit eigener Tier-Auswahl - kein Umweg mehr über die Verwaltung nötig, direkt auffindbar und nutzbar",
]),
("7.30.2", [
"Tab-Umbruch-CSS (verhindert das \"⋮\"-Overflow-Menü) robuster gemacht: zielt jetzt zusätzlich auf die ARIA-Rolle [role=\"tablist\"], falls sich Gradio-6-interne Klassennamen geändert haben - betrifft alle Tab-Ebenen (Verwaltung/Konto, Bereichs- und Kategorie-Tabs)",
]),
("7.30.1", [
"Kritischer Bugfix: Aufstocken/Bearbeiten (und Vermisst melden, Foto-Scan, PDF-Export, Vertrag nachtragen) öffneten sich gar nicht - Ursache war eine eigene, fehlerhafte JS-Zusatzabsicherung aus v7.29.0, die das korrekte Anzeigen sofort wieder rückgängig gemacht hat. Diese wurde entfernt, Gradios eigene Sichtbarkeits-Logik war für diese einfachen Auf/Zu-Bereiche bereits zuverlässig",
]),
("7.30.0", [
"Grundlegender Umbau gegen das \"zeigt beim ersten Klick nichts\"-Problem: Bereichs-Auswahl (Tiere/Futter-Zentrale/Archiv/Inventar) und Kategorie-Auswahl (Stammdaten/Fütterung/Gesundheit/Dokumente) laufen jetzt über echte, native Gradio-Tabs statt über Buttons mit ein-/ausgeblendeten Feldern",
"Diese Tabs waren in der App schon immer zuverlässig (Verwaltung/Konto, Anmelden/Registrieren) - das Problem sollte damit an der Wurzel gelöst sein",
]),
("7.29.1", [
"Kritischer Bugfix (vermutliche Ursache): SSR-Modus (Server-Side Rendering) explizit deaktiviert - ein bekanntes, offenes Gradio-6-Problem verursacht Hydrations-Fehler genau bei bedingt sichtbaren Bereichen (Tabs, Umschalt-Buttons), die erst beim zweiten Versuch korrekt anzeigen",
]),
("7.29.0", [
"JS-Zwangsdurchsetzung (aus dem Gesundheit-Fix) jetzt app-weit auf alle Auf/Zu-Bereiche angewendet: Vermisst melden, Futter-Zentrale-Scan, PDF-Export, Vertrag nachtragen, Aufstocken, Bearbeiten sowie die Haupt-Bereichsauswahl (Tiere/Futter-Zentrale/Archiv/Inventar)",
]),
("7.28.0", [
"Bugfix: Gesundheit (und andere Kategorien) zeigten nach dem ersten Klick manchmal keine Daten - zusätzliche JavaScript-Zwangsdurchsetzung sorgt jetzt garantiert für korrekte Sichtbarkeit, unabhängig von Gradios eigener Reaktivität",
"requirements.txt: Gradio-Version gepinnt (6.19.0) statt unversioniert, damit sich das Verhalten nicht mehr bei jedem Neustart ändern kann",
]),
("7.27.4", [
"Gradio-6-Warnung behoben: css-Parameter läuft jetzt korrekt über launch() statt über den Blocks()-Konstruktor",
]),
("7.27.3", [
"Vollständiges Code-Audit: alle Funktions-Rückgaben gegen ihre UI-Verkabelung geprüft (keine weiteren Zählfehler gefunden)",
"Verwaiste/falsch eingerückte Abschnitts-Kommentare aus früheren Umbauten bereinigt",
"Unbenutzte Importe und tote Funktionen geprüft - keine gefunden",
]),
("7.27.2", [
"Kritischer Bugfix: Login schlug bei jedem Versuch fehl (Seite blieb kaputt/leer), weil seit dem Akkordeon-Umbau (v7.27.0) versucht wurde, eine Beschriftung auf ein Feld zu setzen, das keine Beschriftung mehr unterstützt - die Beschriftung läuft jetzt korrekt über einen eigenen Text-Baustein",
]),
("7.27.1", [
"Bugfix: Dark-Mode-Erzwingung löste bei jedem Aufruf über die huggingface.co-Seite einen Seiten-Reload aus, der laufende Anfragen abbrach und kurzzeitig zu kaputter Darstellung führte - jetzt per direkter CSS-Klasse ohne Reload gelöst",
]),
("7.27.0", [
"Stammdaten/Fütterung/Gesundheit/Dokumente sind jetzt normale Felder statt Akkordeons - behebt endgültig das Problem, dass Inhalte nach Button-Klick leer/geschlossen blieben",
]),
("7.26.2", [
"Bugfix: Kategorie-Akkordeons (Stammdaten/Fütterung/Gesundheit/Dokumente) wurden zwar sichtbar, blieben dabei aber optisch geschlossen - Sichtbar-machen und Öffnen laufen jetzt als zwei garantiert nacheinander ablaufende Schritte statt gemeinsam in einem Update",
]),
("7.26.1", [
"Bugfix: Kategorie-Buttons luden bei jedem Klick unnötig alle Bilder/Dokumente neu, was zu Verzögerungen und gelegentlichem Fehlverhalten führte - zurückgesetzt auf schnelles reines Umschalten",
"Bild-/Dokument-Downloads beim Tier-Laden haben jetzt ein hartes Gesamtzeitlimit (4 Sekunden) statt potenziell unbegrenzt zu warten",
]),
("7.26.0", [
"Design an Android 17 / Material 3 Expressive angeglichen: weichere größere Rundungen, Frosted-Glass-Effekt bei Popups, federndere Übergänge bei Buttons und Tabs",
"Dashboard-Karten im Inventar ebenfalls weicher gestaltet",
]),
("7.25.0", [
"Kontakttelefon wird jetzt automatisch bei allen Tieren desselben Accounts übernommen, sobald es bei einem Tier geändert wird",
"Neues Feld \"Kontakttelefon 2\" als Backup-Nummer (z.B. Familie), erscheint auch im Vermisst-Steckbrief",
"Kategorie-Buttons (Stammdaten/Fütterung/Gesundheit/Dokumente) laden jetzt bei jedem Klick frische Daten und öffnen garantiert direkt, kein doppeltes Klicken mehr nötig",
]),
("7.24.1", [
"Filter steht bei aktivem Erweitertem Modus jetzt direkt neben der Tier-Auswahl statt darüber",
]),
("7.24.0", [
"Tiere/Archiv/Inventar: Inhalte erscheinen jetzt direkt unter der Auswahl (Tier-Auswahl bzw. Hauptaktion), die Auswahl-Buttons stehen danach statt davor",
"So landet man beim Aufrufen einer Unterkategorie direkt beim Inhalt, ohne an den Buttons vorbei scrollen zu müssen",
]),
("7.23.0", [
"Nach Aufstocken/Bearbeiten/Löschen im Inventar klappen sich die Formulare automatisch zu und die Ansicht springt zurück zum Dashboard",
]),
("7.22.0", [
"Inventar-Dashboard komplett überarbeitet: zeigt jetzt Name, Sackgröße, aktueller Lagerbestand und verbleibende Tage pro Futtersorte",
"Aktueller Lagerbestand wird jetzt live berechnet (Bestand minus Tage seit letzter Aktualisierung × täglicher Verbrauch aus den Stammdaten)",
"Neues Feld \"Sackgröße\" beim Bearbeiten von Futter, unabhängig vom aktuellen Lagerbestand",
"Dashboard-Karten deutlich kompakter gestaltet",
]),
("7.21.0", [
"Archiv: \"PDF-Akte exportieren\" und \"Vertrag nachtragen\" jetzt per Button ein-/ausblendbar statt permanent als Akkordeon sichtbar",
"Inventar: \"Aufstocken\" und \"Bearbeiten\" jetzt per Button ein-/ausblendbar, Futter-Übersicht bleibt direkt sichtbar",
]),
("7.20.0", [
"Futter-Zentrale umgebaut: manuelle Eingabe aller Futterdaten ist jetzt Standard, sofort sichtbar",
"Fotoscan (3 Bilder + Auswertung) ist jetzt eine optionale Zusatzfunktion, per Button ein-/ausblendbar",
]),
("7.19.0", [
"Stammdaten/Fütterung/Gesundheit/Dokumente erscheinen jetzt erst nach Klick auf den jeweiligen Button, statt permanent als Akkordeon sichtbar zu sein",
"Filter (Alle/Eigene/Pflege & Gäste) ist bei deaktiviertem \"Erweiterter Modus\" jetzt komplett unsichtbar statt nur eingeklappt",
"Reihenfolge in der Tiere-Sektion: Filter → Tier-Auswahl → Unterkategorien → Vermisst melden",
"Bugfix: Stammdaten öffnete sich beim Laden/Speichern eines Tiers, blieb dabei aber unsichtbar",
]),
("7.18.0", [
"Futter-Zentrale: Scan-Ergebnisfelder erscheinen erst nach dem Auswerten, nicht mehr permanent",
"Archiv: Archivieren-Aktion jetzt oben (häufigste Aktion), PDF-Export in eigenem Akkordeon",
"Inventar: Futter-Übersicht jetzt direkt oben sichtbar, Aufstocken/Bearbeiten weiterhin eingeklappt",
]),
("7.17.0", [
"Filter (Alle/Eigene/Pflege & Gäste) jetzt standardmäßig eingeklappt statt immer sichtbar",
"\"🚨 Vermisst melden\" ist jetzt ein immer sichtbarer Button direkt unter der Tier-Auswahl, kein Scrollen mehr nötig",
"Neue Auswahlleiste (Stammdaten/Fütterung/Gesundheit/Dokumente) öffnet immer nur einen Bereich statt aller Akkordeons gleichzeitig",
]),
("7.16.2", [
"Kritischer Bugfix: Nach einem Lade-Fehler beim Login blieben Dashboard, Archiv und Inventar leer/veraltet, obwohl die Daten unangetastet in der Tabelle lagen (Zählfehler in der Login-Logik, bestand seit dem allerersten Upload)",
]),
("7.16.1", [
"Bugfix: Versionshistorie-Overlay blockierte die App beim Schließen (gleicher Fehler wie damals beim Onboarding-Popup)",
]),
("7.16.0", [
"Neu: Versionsnummer unten ist jetzt anklickbar und zeigt die komplette Änderungshistorie seit v7.5",
]),
("7.15.0", [
"Kalender-Erinnerungen (.ics) lösen jetzt automatisch 4 Wochen vor Fälligkeit eine Benachrichtigung aus",
"Layout bei \"Gesundheit & Kalender\" aufgeräumt (Textfeld über den Buttons, kompaktere Datei-Box)",
]),
("7.14.2", ["Bugfix: \"TASSO Vermisst-Formular öffnen\" konnte die App im Browser-iFrame überschreiben und weiß bleiben"]),
("7.14.1", ["Bugfix-Versuch: TASSO-Link in der Standalone-App (abgelöst durch 7.14.2)"]),
("7.14.0", [
"Bugfix: Tier-Laden konnte über 30 Sekunden hängen (Dokumente werden jetzt parallel statt nacheinander geladen)",
"\"Scanner\" heißt jetzt \"🥫 Futter-Zentrale\" – klarer benannt",
]),
("7.13.0", ["Nur noch 2 Haupt-Tabs: \"Verwaltung\" (mit Auswahl Tiere/Futter-Zentrale/Archiv/Inventar) und \"Konto\""]),
("7.12.0", [
"Status-Meldungen erscheinen jetzt als kurzes Overlay statt in sichtbaren Textboxen",
"\"Neu laden\"/\"Abmelden\" sind jetzt am Fußende der App",
"Bugfix: Admin-Debug-Konsole (Eruda) erschien nicht zuverlässig für PetBase",
]),
("7.11.1", ["Tab-Namen gekürzt (z. B. \"Tiere verwalten\" → \"Tiere\")"]),
("7.11.0", ["Tabs brechen um statt sich hinter einem \"⋮\"-Menü zu verstecken", "\"Neu laden\"/\"Abmelden\"-Buttons kompakter"]),
("7.10.0", ["Neu: 🚨 Vermisst melden – TASSO-Schnellzugriff + Daten zum Abtippen + PDF-Steckbrief"]),
("7.9.1", ["Bugfix: Willkommens-Popup blockierte die App, wenn man es schließen wollte"]),
("7.9.0", ["Willkommens-Popup erscheint jetzt auch bei neuen Versionen, nicht nur bei der Registrierung"]),
("7.8.0", ["Neu: Willkommens-Popup mit Feature-Übersicht beim ersten Login"]),
("7.7.0", ["Eruda-Debug-Konsole nur noch für Admin (PetBase) sichtbar", "Erzwungener Dark Mode für die App"]),
("7.6.0", ["Nassfutter kann zusätzlich zu Trockenfutter gefüttert werden", "Tierart als Dropdown mit BARF/Futter-Kategorien"]),
("7.5.0", ["Ausgangsstand vor Beginn der gemeinsamen Weiterentwicklung"]),
]
def baue_versionshistorie_text(anzahl_anzeigen=15):
teile = ["## 🕓 Versionshistorie\n"]
sichtbare = VERSION_HISTORY[:anzahl_anzeigen]
for version, aenderungen in sichtbare:
teile.append(f"### v{version}")
teile.append("\n".join(f"- {a}" for a in aenderungen))
teile.append("")
rest = len(VERSION_HISTORY) - len(sichtbare)
return "\n\n".join(teile), rest
# Kurzer "Was ist neu"-Text für Nutzer, die schon länger dabei sind (wird bei jedem Versions-Update angezeigt).
CHANGELOG_TEXT = (
"## 🆕 Was ist neu in v" + APP_VERSION + "\n\n"
+ "\n".join(f"- {a}" for a in VERSION_HISTORY[0][1])
+ "\n\n_Komplette Historie: unten auf die Versionsnummer tippen._"
)
APP_CSS = """
/* ==========================================
ICON-AUSTAUSCH (v7.91.1, Schritt 1 des App-Umbaus): Material Symbols statt
Emoji für die Hauptnavigation. Gradio-Buttons können nur reinen Text
anzeigen, kein gemischtes HTML - daher NICHT das Icon-Zeichen in den
Button-Text selbst schreiben (würde den ganzen Button-Text in die
Icon-Schriftart zwingen und wie Kauderwelsch aussehen), sondern per
::before-Pseudoelement sauber DAVOR einfügen, während der eigentliche
Button-Text in der normalen Schrift bleibt. Jede Regel ist auf eine
einzelne elem_id begrenzt, betrifft also nur genau den einen Button.
========================================== */
.msr {
font-family: 'Material Symbols Rounded';
font-weight: normal;
font-style: normal;
display: inline-block;
vertical-align: -3px;
-webkit-font-smoothing: antialiased;
}
#b_haupt_verwaltung::before { content: "pets"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_haupt_vermisst::before { content: "warning"; font-family:'Material Symbols Rounded'; font-variation-settings:'FILL' 1; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_haupt_konto::before { content: "account_circle"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_bereich_tiere::before { content: "pets"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_bereich_scanner::before { content: "photo_camera"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_bereich_archiv::before { content: "archive"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_bereich_inventar::before { content: "inventory_2"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_bereich_nachtrag::before { content: "attach_file"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_bereich_apotheke::before { content: "medication"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_bereich_pdf::before { content: "description"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_bereich_tagebuch::before { content: "auto_stories"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_tier_neu::before { content: "add_circle"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
/* Tier-Auswahl-Liste (v7.91.7) und Tagebuch-Übersicht: Karten-Look statt
nackter Standard-Buttons, Gruppen-Überschriften dezent abgesetzt. */
.tier_liste_gruppe {
margin: 14px 4px 4px !important;
font-size: 11px !important;
font-weight: 700 !important;
letter-spacing: 0.5px;
text-transform: uppercase;
color: #7A6E5C !important;
}
.tier_liste_btn, .tier_liste_btn button, .tb_uebersicht_btn, .tb_uebersicht_btn button {
background: #FFFCF5 !important;
border: 1px solid #DED2BA !important;
border-radius: 14px !important;
box-shadow: 0 1px 4px rgba(59,50,38,0.08) !important;
text-align: left !important;
justify-content: flex-start !important;
font-weight: 600 !important;
margin-bottom: 8px !important;
}
#b_login_tab::before { content: "login"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_reg_tab::before { content: "person_add"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_login_submit::before { content: "login"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_reg_submit::before { content: "how_to_reg"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_quick_login::before { content: "bolt"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_impressum::before { content: "info"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
/* Große Sammel-Runde (v7.91.6): restliche einzelne Buttons app-weit auf
Material Symbols umgestellt. Gemeinsame Klassen für inhaltlich gleichartige
Buttons (Speichern/Abbrechen/Bearbeiten/...), die an mehreren Stellen bzw.
innerhalb von @gr.render-Schleifen vorkommen (dort sind eindeutige IDs nicht
möglich, da pro Zeile neu erzeugt) - Klassen funktionieren dort zuverlässig,
IDs nicht (müssten eindeutig sein). Einzelne, wirklich nur einmal
vorkommende Buttons bekommen wie gewohnt eine eigene ID. */
.btn-ic-save button::before, .btn-ic-save::before { content: "save"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
.btn-ic-cancel button::before, .btn-ic-cancel::before { content: "close"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
.btn-ic-edit button::before, .btn-ic-edit::before { content: "edit"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
.btn-ic-more button::before, .btn-ic-more::before { content: "expand_more"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
.btn-ic-more-inline button::before, .btn-ic-more-inline::before { content: "tune"; font-family:'Material Symbols Rounded'; font-size:15px; vertical-align:-3px; margin-right:4px; display:inline-block; }
.btn-ic-reminder button::before, .btn-ic-reminder::before { content: "event"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
.btn-ic-email button::before, .btn-ic-email::before { content: "mail"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
.btn-ic-check button::before, .btn-ic-check::before { content: "check"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
/* Reine Icon-Buttons ohne Begleittext (z.B. die kleinen +/✏️/✕-Knöpfe in den
Futter-Vorrat-Zeilen): der gesamte Button-Text IST der Icon-Ligatur-Name,
deshalb hier die ganze Schrift umstellen statt nur ein ::before davorzusetzen. */
.msr-icon-btn, .msr-icon-btn button {
font-family: 'Material Symbols Rounded' !important;
font-size: 20px !important;
}
#b_show_tagebuch::before { content: "auto_stories"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_onboarding_close::before { content: "rocket_launch"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_tagebuch_speichern::before { content: "save"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_tagebuch_pdf::before { content: "description"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_profil_speichern::before { content: "save"; font-family:'Material Symbols Rounded'; font-size:20px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_scan_toggle::before { content: "photo_camera"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_scan_auswerten::before { content: "search"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_save_futter::before { content: "inventory_2"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_archivieren::before { content: "archive"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_apotheke_speichern::before { content: "save"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_nach_save::before { content: "save"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_vermisst_daten::before { content: "list_alt"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_tasso_link::before { content: "open_in_new"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_vermisst_pdf::before { content: "description"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_logout::before { content: "logout"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
#b_versionshistorie_mehr::before { content: "expand_more"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
#b_show_fb::before { content: "bug_report"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_fb_senden::before { content: "send"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
#b_show_erinn::before { content: "notifications"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_show_pw::before { content: "lock"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_pw_save::before { content: "key"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
#b_show_del::before { content: "person_remove"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_del_account::before { content: "delete_forever"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
#b_test_email::before { content: "forward_to_inbox"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
#b_futter_cron_test::before { content: "restart_alt"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
#b_show_news::before { content: "campaign"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_news_veroeffentlichen::before { content: "publish"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
#b_show_users::before { content: "group"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_sperren::before { content: "block"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
#b_admin_vergeben::before { content: "shield_person"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
#b_admin_entziehen::before { content: "remove_moderator"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
#b_show_feedback_liste::before { content: "rate_review"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_show_stam::before { content: "badge"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_show_futter::before { content: "nutrition"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_show_gesund::before { content: "vaccines"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_show_doku::before { content: "folder"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_show_tagebuch_open::before { content: "auto_stories"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_konto_tab_einst::before { content: "settings"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_konto_tab_admin::before { content: "admin_panel_settings"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
/* Feste Navigationsleiste unten (v7.91.3) - immer sichtbar, unabhängig vom
Scroll-Stand. Am Ende von main_app im DOM platziert, aber per position:fixed
optisch immer am unteren Bildschirmrand verankert. gradio-container braucht
zusätzlichen unteren Abstand, sonst würde die Leiste den letzten Inhalt
verdecken. */
.gradio-container { padding-bottom: 78px !important; }
/* Popup-Flyout über der unteren Leiste (v7.92.0) - sitzt direkt über der
Navigationsleiste, damit der Bezug zum angetippten Icon klar bleibt. */
#nav_popup_box {
position: fixed !important;
bottom: 78px !important;
left: 50% !important;
transform: translateX(-50%);
width: fit-content !important;
min-width: 220px;
max-width: calc(100vw - 40px);
max-height: 55vh;
overflow-y: auto;
background: #FFFCF5 !important;
border: 1px solid #DED2BA !important;
border-radius: 18px !important;
box-shadow: 0 -4px 20px rgba(59,50,38,0.22) !important;
z-index: 490;
padding: 14px 14px 8px !important;
}
.nav_popup_titel {
font-size: 14px !important;
color: #3B3226 !important;
margin-bottom: 6px !important;
}
.nav_popup_gruppe {
font-size: 10.5px !important;
font-weight: 700 !important;
letter-spacing: 0.5px;
text-transform: uppercase;
color: #7A6E5C !important;
margin: 10px 2px 2px !important;
}
.nav_popup_item, .nav_popup_item button {
background: #F2ECE0 !important;
border: 1px solid #DED2BA !important;
border-radius: 12px !important;
text-align: left !important;
justify-content: flex-start !important;
font-weight: 600 !important;
margin-bottom: 6px !important;
min-height: 40px !important;
}
.nav_popup_back, .nav_popup_back button {
background: transparent !important;
box-shadow: none !important;
border: none !important;
color: #7A6E5C !important;
font-size: 12px !important;
justify-content: flex-start !important;
margin-top: 4px !important;
}
.nav_popup_danger, .nav_popup_danger button {
color: #D32F2F !important;
border-color: #E8B4B0 !important;
}
#bottom_nav_bar {
position: fixed !important;
bottom: 0; left: 0; right: 0;
z-index: 500;
background: #FFFCF5 !important;
border-top: 1px solid #DED2BA;
box-shadow: 0 -2px 10px rgba(59,50,38,0.12);
padding: 6px 4px 4px !important;
gap: 0 !important;
flex-wrap: nowrap !important;
}
#bottom_nav_bar > * {
flex: 1 1 16.66% !important;
min-width: 0 !important;
}
#bottom_nav_bar button {
background: transparent !important;
border: none !important;
box-shadow: none !important;
color: #7A6E5C !important;
font-size: 9.5px !important;
font-weight: 600 !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
gap: 2px !important;
min-height: 56px !important;
padding: 4px 1px !important;
}
#bottom_nav_bar button::before {
font-family: 'Material Symbols Rounded';
font-size: 20px;
line-height: 1;
display: block;
}
#b_nav_start::before { content: "home"; }
#b_nav_tiere::before { content: "pets"; }
#b_nav_futter::before { content: "nutrition"; }
#b_nav_tagebuch::before { content: "auto_stories"; }
#b_nav_konto::before { content: "account_circle"; }
#b_nav_menu::before { content: "menu"; }
#b_haupt_futtervorrat::before { content: "medication"; font-family:'Material Symbols Rounded'; font-size:18px; vertical-align:-3px; margin-right:6px; display:inline-block; }
#b_start_vermisst::before { content: "warning"; font-family:'Material Symbols Rounded'; font-variation-settings:'FILL' 1; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
#b_start_konto::before { content: "medication"; font-family:'Material Symbols Rounded'; font-size:16px; vertical-align:-3px; margin-right:5px; display:inline-block; }
/* Gradios Grundthema (zoo_theme) nutzt intern Orange als "primary_hue" - das
erzeugt eine komplette Orange-Farbskala als CSS-Variablen (--primary-50 bis
--primary-950), die JEDES Gradio-Element für Fokus-Ringe, "gerade
aktualisiert"-Hervorhebungen, Slider, Checkboxen usw. verwendet. Bisher wurden
nur EINZELNE Stellen gezielt auf Braun umgestellt (siehe Changelog v7.2x) -
das griff nicht überall, z.B. blitzten Feld-Beschriftungen nach einem
Speichervorgang kurz in Orange auf (siehe v7.88.2). Robusterer Ansatz: die
Variablen selbst global auf eine braune Skala umbiegen, dann greift die
App-Farbe automatisch überall, auch bei zukünftigen Gradio-Zuständen, die wir
nicht einzeln kennen/vorhersehen.
Skala grob am bestehenden Braun (#6D4C41) und den App-Cremetönen ausgerichtet. */
:root, .gradio-container {
--primary-50: #FDF9F3 !important;
--primary-100: #F7EFE1 !important;
--primary-200: #EDE0C8 !important;
--primary-300: #DED2BA !important;
--primary-400: #C7AD8F !important;
--primary-500: #A8846A !important;
--primary-600: #8A6650 !important;
--primary-700: #6D4C41 !important;
--primary-800: #55392F !important;
--primary-900: #3B2A22 !important;
--primary-950: #251A15 !important;
}
/* Erzwingt eine zweispaltige Ansicht (50/50) für die damit markierten Zeilen.
CSS Grid statt Flexbox, mit minmax(0, 1fr) statt einfachem 1fr: normales "1fr"
ist eigentlich Kurzform für "minmax(auto, 1fr)" - das "auto" lässt den Browser
auf die native Mindestbreite des Inhalts schauen. iOS Safari hat bei
Eingabefeldern eine feste interne Mindestbreite und verweigert dann das exakte
Halbieren. Mit minmax(0, 1fr) wird dem Grid diese Rücksichtnahme komplett
entzogen, es teilt gnadenlos exakt in der Mitte, unabhängig vom Inhalt. */
.zweispaltig {
display: grid !important;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important;
gap: 12px !important;
width: 100% !important;
/* War vorher "start": wenn ein Feld (z.B. Farbe) durch längeren Text höher
wurde als sein Nachbar (z.B. Geburtsdatum), blieb darunter eine hässliche
graue Lücke stehen. "stretch" zieht beide Felder einer Zeile auf dieselbe
Höhe - das kürzere wächst automatisch auf die Höhe des längeren mit. */
align-items: stretch !important;
}
/* Gradio gruppiert benachbarte Formularfelder (Textbox, Dropdown etc.) innerhalb
einer Row manchmal automatisch in einen zusätzlichen Wrapper-Div (Klasse "form"
oder "wrap"). Dadurch sieht .zweispaltig nur EIN Kind-Element statt zwei, und
die 50/50-Aufteilung landet komplett in Spalte 1 (auf Android beobachtet, Spalte
2 blieb leer). display:contents macht diesen Wrapper für das Grid-Layout
"unsichtbar", sodass seine eigentlichen Kinder direkt als Grid-Items zählen -
ohne die Grid-Regeln ein zweites Mal definieren zu müssen. */
.zweispaltig > .form,
.zweispaltig > .wrap {
display: contents !important;
}
/* 1. Alle Feld-Blöcke (egal ob direktes Grid-Kind oder - falls Gradio den
Auto-Wrapper gesetzt hat - ein Kind davon) zu Flex-Containern über die volle
Zeilenhöhe machen. Das ist die Basis der Höhen-Kette, die unten bis zur
Textarea durchgereicht wird. */
.zweispaltig > *,
.zweispaltig > .form > *,
.zweispaltig > .wrap > * {
display: flex !important;
flex-direction: column !important;
height: 100% !important;
min-width: 0 !important;
max-width: 100% !important;
width: auto !important;
margin: 0 !important;
flex: none !important;
/* iOS Safari zeigt sonst einen hellgrauen Halo/Rahmen um jedes Feld (natives
Karten-Styling der Gradio-Block-Wrapper, das auf Android unsichtbar bleibt,
auf iOS aber durchscheint). "transparent" war hier falsch - dahinter liegt
keine weiße Karte, sondern direkt die Row/Seiten-Fläche, wodurch ein
grauer Bereich sichtbar wurde. Stattdessen dieselbe Karten-Farbe wie der
Rest der App (#FFFCF5, siehe .gr-group/.gr-form weiter unten) - kein
reines Weiß, das hätte nicht zum warmen Creme-Ton gepasst. */
background: #FFFCF5 !important;
border: none !important;
box-shadow: none !important;
}
/* 2. Label und Gradios innerer "wrap"-Div (der die Textarea direkt umschließt)
müssen die Höhe ebenfalls durchreichen - sonst bricht die Kette hier ab und
die Textarea hat unten keinen Platz zum Hineinwachsen. */
.zweispaltig label,
.zweispaltig > * > .wrap {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
height: 100% !important;
}
/* 3. Das eigentliche Eingabefeld füllt den verbleibenden Platz. Bewusst KEIN
height:100% hier - das würde Gradios JS-Autosize für mehrzeiligen Text wieder
überschreiben (siehe Bugfix v7.73.5, dort ist das Feld auf eine viel zu kleine
Höhe zusammengestaucht). flex-grow ohne feste Höhe lässt das kürzere Feld
mitwachsen, ohne die "auto"-Höhe des längeren Feldes zu kappen. */
.zweispaltig textarea,
.zweispaltig input,
.zweispaltig select {
flex: 1 1 auto !important;
min-height: 0 !important;
}
/* Tier-Filter (Alle/Eigene Tiere/Pflege & Gäste): untereinander statt nebeneinander
gequetscht, da der Bereich zu schmal für 3 Optionen in einer Zeile ist */
#tier_filter_radio .wrap {
flex-direction: column !important;
}
#tier_filter_radio label {
width: 100% !important;
}
/* Vollbild-Bildansicht (z.B. Profilbild antippen) auf die Bildschirmgröße begrenzen,
damit der Schließen-Button (X) immer sichtbar/erreichbar bleibt, ohne seitlich
scrollen zu müssen */
.gradio-container [class*="fullscreen"],
.gradio-container [data-testid="image"] > div:has(img) {
max-width: 100vw !important;
max-height: 100vh !important;
}
.gradio-container [class*="fullscreen"] img {
max-width: 100% !important;
max-height: 90vh !important;
width: auto !important;
object-fit: contain !important;
}
/* Profilbild: Gradios eingebaute Werkzeugleiste (Vollbild/Löschen-Icons) legt sich
standardmäßig als halbtransparenter Balken ÜBER das Bild - bei Tierfotos sitzt
der Kopf meist oben, wird also genau verdeckt. Statt die Buttons per JS zu
verstecken/ersetzen (das ist in dieser App schon mehrfach schiefgegangen, siehe
Changelog v7.68-7.72), schaffen wir hier rein per CSS oben im Bild eine kleine
"Sicherheitszone": das Bild wird minimal verkleinert und nach unten ausgerichtet,
die Werkzeugleiste bekommt einen transparenten Hintergrund.
v7.73.10 hatte hier zusätzlich versucht, das Profilbild per height:0 +
overflow:hidden + position:absolute exakt auf die Höhe der linken Spalte zu
ziehen. RÜCKGÄNGIG GEMACHT in v7.73.18: dieser Trick ist zu fragil - wenn die
Zeile aus irgendeinem Grund keine verlässliche Höhe liefert (z.B. durch einen
von Gradio eingefügten Wrapper-Div, wie schon bei den Stammdaten-Feldern
beobachtet), kollabiert der GESAMTE Bildbereich auf 0px und overflow:hidden
blendet dann Foto UND Upload-Button komplett aus - genau das ist passiert.
Funktionierende Sichtbarkeit ist wichtiger als exakte Höhen-Angleichung, daher
zurück auf normalen Fluss ohne erzwungene Höhe. */
/* Tagebuch: Foto-Upload-Fläche etwas kompakter - bewusst OHNE overflow:hidden
oder erzwungene max-height (das hatte in einem früheren Versuch versehentlich
die Icons Hochladen/Kamera/Zwischenablage mit verschluckt, siehe v7.88.6/7).
Nur die künstliche Mindesthöhe wird gelockert, das kann höchstens wirkungslos
bleiben, aber nichts Funktionales verstecken. Vorschau nach dem Hochladen
bleibt so oder so erhalten, unabhängig von dieser Regel. */
/* Tagebuch: Foto-Upload-Fläche kompakter - per Live-Inspektion (danke fürs
Dranbleiben!) die WIRKLICHEN Klassen gefunden: das eigentliche Element ist
ein <button class="... boundedheight ..."> innerhalb von .upload-container,
mit fest height:240px/min-height:44px direkt von Gradios "boundedheight"-
Klasse. Die vorherigen Versuche (.wrap.full/.empty.large) zielten auf die
falschen Elemente - die gehören offenbar zum File-Feld (PDF), nicht zum
Image-Feld (Foto). Bewusst nur innerhalb dieser beiden elem_ids begrenzt,
damit andere Bildfelder in der App (Profilbild, Apotheke-Banner) ihre eigene
Größe behalten. */
#tagebuch_foto_upload .upload-container, #tagebuch_foto_edit .upload-container {
min-height: 0 !important;
}
/* v7.90.2: 60px war zu knapp - der komplette Inhalt (drei Textzeilen "Bild hier
ablegen/oder/Hochladen" UND die drei Icons darunter) sitzt alles im selben
Button, nicht in getrennten Bereichen. Bei zu wenig Höhe überlappen sich Text
und Icons. min-height statt fixer height: schrumpft so weit wie möglich,
wächst aber automatisch mit, falls der Inhalt mehr Platz braucht. */
#tagebuch_foto_upload button.boundedheight, #tagebuch_foto_edit button.boundedheight {
height: auto !important;
min-height: 140px !important;
}
/* Gleiche Lösung jetzt auf alle anderen Bild-/Datei-Upload-Felder in der App
übertragen (v7.90.0). Unterschiedliche Höhen je nach Zweck statt überall
gleich winzig: Profilbild bleibt sichtbar prominent, die Futter-Scanner-Bilder
brauchen genug Platz zum Fotografieren/Framing von Barcode & Etikett, der
Pflegevertrag-Upload ist wie das Tagebuch-PDF ein reines Datei-Feld (andere
Struktur, siehe .wrap.full/.empty.large statt button.boundedheight). */
#profil_img_komponente .upload-container {
min-height: 0 !important;
}
#profil_img_komponente button.boundedheight {
height: auto !important;
min-height: 140px !important;
}
#futter_bild_upload_1 .upload-container, #futter_bild_upload_2 .upload-container, #futter_bild_upload_3 .upload-container {
min-height: 0 !important;
}
#futter_bild_upload_1 button.boundedheight, #futter_bild_upload_2 button.boundedheight, #futter_bild_upload_3 button.boundedheight {
height: auto !important;
min-height: 150px !important;
}
#pflege_vertrag_upload.block {
min-height: 0 !important;
}
#pflege_vertrag_upload .wrap.full {
min-height: 0 !important;
height: auto !important;
}
#pflege_vertrag_upload .empty.large {
min-height: 0 !important;
padding: 10px !important;
}
/* Tagebuch: PDF-Ergebnisfeld soll nur so hoch sein wie der Dateiname braucht,
nicht die große leere Standard-Vorschaufläche. Per Live-Inspektion (Danke!)
die TATSÄCHLICHEN Gradio-Klassen gefunden: .wrap.full (füllt die Höhe des
äußeren Blocks) und .empty.large (Gradios eigene "große" Leerzustand-Klasse,
inkl. großem Icon+Innenabstand). Der vorherige Versuch zielte auf ".container",
die es hier gar nicht gibt - deshalb wirkungslos. Bewusst nur INNERHALB von
#tagebuch_pdf_result begrenzt, damit andere Datei-Felder in der App (z.B.
Pflegevertrag-Upload) unangetastet bleiben. */
#tagebuch_pdf_result.block {
min-height: 0 !important;
}
#tagebuch_pdf_result .wrap.full {
min-height: 0 !important;
height: auto !important;
}
#tagebuch_pdf_result .empty.large {
min-height: 0 !important;
padding: 10px !important;
}
#tagebuch_pdf_result .empty.large .icon {
width: 24px !important;
height: 24px !important;
margin: 0 auto !important;
}
/* Haustierapotheke-Kopfbanner: das Bild ist hochformatig und detailreich - als
volle Höhe würde es den ganzen Bildschirm einnehmen, bevor Nutzer überhaupt
die eigentlichen Kategorien sehen. Auf eine kompakte Banner-Höhe zugeschnitten,
oberer Bildausschnitt (Logo/Schriftzug) bleibt sichtbar. */
#apotheke_banner_img img {
max-height: 200px !important;
width: 100% !important;
object-fit: cover !important;
object-position: top !important;
border-radius: 14px !important;
}
#profil_img_komponente img {
padding-top: 40px !important;
box-sizing: border-box !important;
object-position: center bottom !important;
border-radius: 12px !important;
}
#profil_img_komponente div:has(> button),
#profil_img_komponente [data-testid="image"] > div:has(button) {
background: transparent !important;
border: none !important;
}
/* Name/Tierauswahl-Box in Stammdaten: keine eigene Karten-Optik, verschmilzt mit
dem Hintergrund. */
#row_name_kategorie {
background: transparent !important;
box-shadow: none !important;
border: none !important;
}
#row_name_kategorie > * {
background: transparent !important;
box-shadow: none !important;
border: none !important;
}
/* Fußbereich (z.B. Abmelden) zentrieren statt linksbündig */
.zentrierte_reihe {
justify-content: center !important;
}
/* Anmelden/Registrieren-Tabs auf dem Login-Bildschirm gleichmäßig auf volle Breite verteilen */
#login_screen .tab-nav button, #login_screen [role="tab"] {
flex: 1 !important;
}
/* Verhindert die browsereigene Verzögerung/Mehrdeutigkeits-Prüfung beim Antippen
(Browser muss sonst kurz abwarten: "war das ein Klick oder der Beginn eines
Doppel-Tipps zum Reinzoomen?"). Das äußert sich als "Button reagiert erst
beim zweiten Tippen" bzw. "öffnet kurz und schließt sofort wieder" - vor
allem auf iOS/Safari. touch-action: manipulation erlaubt weiterhin normales
Scrollen und Pinch-to-Zoom, schaltet nur die Doppel-Tipp-Erkennung pro
Element ab, sodass ein Einzeltipp immer sofort und eindeutig auslöst. */
.gradio-container, .gradio-container * {
touch-action: manipulation !important;
}
/* Gradios eigene CSS-Variablen überschreiben, damit auch native Elemente
(Radio-Buttons, Checkboxen, Slider) das Braun statt des Standard-Orange
verwenden - robuster als einzelne Klassennamen zu erraten. */
.gradio-container {
--color-accent: #6D4C41 !important;
--color-accent-soft: #EDE5D6 !important;
--button-primary-background-fill: #6D4C41 !important;
--button-primary-background-fill-hover: #4E342E !important;
--button-primary-border-color: #6D4C41 !important;
--button-primary-border-color-hover: #4E342E !important;
--checkbox-background-color-selected: #6D4C41 !important;
--checkbox-border-color-selected: #6D4C41 !important;
--checkbox-label-background-fill-selected: #6D4C41 !important;
--checkbox-label-border-color-selected: #6D4C41 !important;
--slider-color: #6D4C41 !important;
--block-label-text-color: #7A6E5C !important;
--block-title-text-color: #3B3226 !important;
--body-text-color-subdued: #7A6E5C !important;
/* Ausgewählte Radio/Checkbox-Option: heller Ton statt dunklem Braun,
damit die dunkle Schrift darauf lesbar bleibt */
--checkbox-label-background-fill-selected: #E6C9A0 !important;
--checkbox-label-border-color-selected: #C9A66B !important;
--checkbox-label-text-color-selected: #3B3226 !important;
}
/* Checkbox/Radio komplett selbst zeichnen, statt uns auf Gradios eigene (teils
unterdrückte) native Darstellung zu verlassen. Reagiert direkt auf die
eingebaute, immer korrekte Browser-Eigenschaft ":checked" - unabhängig davon,
ob Gradio das native Element optisch versteckt. */
.gradio-container input[type="radio"] {
appearance: none !important;
-webkit-appearance: none !important;
width: 18px !important;
height: 18px !important;
min-width: 18px !important;
border-radius: 50% !important;
border: 2px solid #6D4C41 !important;
background: #FFFCF5 !important;
position: relative !important;
opacity: 1 !important;
margin: 0 6px 0 0 !important;
flex-shrink: 0;
box-sizing: border-box !important;
}
.gradio-container input[type="radio"]:checked {
background: #6D4C41 !important;
box-shadow: inset 0 0 0 4px #FFFCF5 !important;
}
.gradio-container input[type="checkbox"] {
appearance: none !important;
-webkit-appearance: none !important;
width: 18px !important;
height: 18px !important;
min-width: 18px !important;
border-radius: 4px !important;
border: 2px solid #6D4C41 !important;
background: #FFFCF5 !important;
position: relative !important;
opacity: 1 !important;
margin: 0 6px 0 0 !important;
flex-shrink: 0;
}
.gradio-container input[type="checkbox"]:checked {
background: #6D4C41 !important;
}
.gradio-container input[type="checkbox"]:checked::after {
content: "✓" !important;
position: absolute !important;
top: -3px !important;
left: 1px !important;
color: #FFFCF5 !important;
font-size: 14px !important;
font-weight: bold !important;
}
/* Ausgewählte Radio-"Pillen" (z.B. Trockenfutter/Nassfutter, Parasitenschutz-Intervall,
Filter) deutlich sichtbar hervorheben - reagiert direkt auf den echten Checked-Zustand
des Eingabe-Elements (zuverlässiger als Gradios eigene Klassen, die manchmal hängen bleiben) */
.gradio-container label:has(input[type="radio"]:checked),
.gradio-container label:has(input[type="checkbox"]:checked) {
background: #C9A66B !important;
border: 2px solid #6D4C41 !important;
color: #3B3226 !important;
font-weight: bold !important;
}
/* Nicht ausgewählte Pillen: dezent, aber klar als anklickbar erkennbar */
.gradio-container label:has(input[type="radio"]):not(:has(input:checked)),
.gradio-container label:has(input[type="checkbox"]):not(:has(input:checked)) {
background: #FFFCF5 !important;
border: 1px solid #C9A66B !important;
}
/* Etwas kleinere Schrift app-weit, damit es weniger überladen wirkt */
.gradio-container {
font-size: 0.92em !important;
}
.gradio-container label, .gradio-container span {
font-size: 0.95em !important;
}
.gradio-container button {
font-size: 0.92em !important;
}
/* Treffergenauigkeit app-weit erhöhen (v7.88.0) - besonders auf iOS Safari kam es
vor, dass optisch breite Buttons trotzdem nicht bei jedem Tipp reagierten.
touch-action:manipulation verhindert, dass der Browser auf Doppeltipp/Zoom
wartet, bevor er den Tipp verarbeitet (das kostet iOS-Nutzern gefühlt oft den
ersten Tipp). -webkit-tap-highlight-color macht zusätzlich sichtbar, dass ein
Tipp überhaupt registriert wurde. min-height als Untergrenze nach Apples
Empfehlung (44px Mindest-Trefffläche) - spezifischere Regeln für bewusst
kompakte Buttons (z.B. Karten-Aktionen) greifen weiterhin, da sie später in
der Kaskade stehen.
Wichtig: KEIN pointer-events hier verändert, das würde bei Bedarf spezifischere
Regeln blockieren statt nur die Mindest-Treffgröße abzusichern. */
.gradio-container button, .gradio-container a[role="button"] {
touch-action: manipulation !important;
-webkit-tap-highlight-color: rgba(109,76,65,0.25) !important;
min-height: 44px;
}
.gradio-container h1 { font-size: 1.3em !important; }
.gradio-container h2 { font-size: 1.15em !important; }
.gradio-container h3 { font-size: 1.05em !important; }
#onboarding_backdrop {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
width: 100%; height: 100%;
background: rgba(59,50,38,0.55);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
#onboarding_card {
background: #FFFCF5;
border: 1px solid #6D4C41;
border-radius: 16px;
padding: 24px;
max-width: 480px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(59,50,38,0.25);
}
/* Versionshistorie-Overlay (gleiches Muster wie Onboarding) */
#versionshistorie_backdrop {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
width: 100%; height: 100%;
background: rgba(59,50,38,0.55);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
#versionshistorie_card {
background: #FFFCF5;
border: 1px solid #6D4C41;
border-radius: 16px;
padding: 24px;
max-width: 480px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(59,50,38,0.25);
}
#versionshistorie_close button {
min-height: 34px !important;
max-width: 90px;
padding: 4px 12px !important;
font-size: 1em !important;
margin-left: auto;
margin-right: auto;
display: block;
}
/* Footer-Versionsnummer als dezent klickbarer Text statt Button-Optik */
#version_footer_btn button {
background: none !important;
border: none !important;
box-shadow: none !important;
color: #7A6E5C !important;
font-size: 0.8em !important;
text-decoration: underline;
min-height: unset !important;
padding: 4px !important;
margin: 0 auto !important;
display: block !important;
width: auto !important;
}
/* Tabs senkrecht untereinander statt nebeneinander anzeigen - umgeht Gradios
JS-basierte "passt nicht in die Breite -> Overflow-Menü"-Logik komplett,
da bei einer Spalte kein Platzmangel in der Breite mehr entstehen kann.
Doppelt abgesichert: alte Gradio-Klasse .tab-nav UND die ARIA-Rolle [role="tablist"]. */
.tab-nav, [role="tablist"] {
flex-direction: column !important;
flex-wrap: nowrap !important;
overflow: visible !important;
height: auto !important;
align-items: stretch !important;
gap: 4px !important;
}
.tab-nav > button, [role="tablist"] > button, [role="tablist"] [role="tab"] {
width: 100% !important;
text-align: center;
white-space: normal;
}
/* Kompakte Zeilen für die einzelnen Futtersorten-Slots mit Mini-Buttons */
.futter_slot_row {
gap: 6px !important;
align-items: center !important;
padding: 4px 8px !important;
margin-bottom: 2px !important;
background: #EDE5D6;
border-radius: 10px;
}
.futter_slot_row button {
min-height: 30px !important;
padding: 2px 6px !important;
}
.futter_slot_inline {
gap: 4px !important;
align-items: center !important;
padding: 6px 8px !important;
margin: 0 0 6px 4px !important;
background: #FFF3E0;
border-left: 2px solid #6D4C41;
border-radius: 0 8px 8px 0;
}
.futter_slot_inline button {
min-height: 34px !important;
}
/* Button-Reihen, die wie echte Reiter-Tabs aussehen sollen (statt Gradios eigener
Tabs-Komponente, die das "⋮"-Overflow-Menü nicht zuverlässig vermeidet) */
.tab_row {
gap: 4px !important;
border-bottom: 2px solid #DED2BA;
flex-wrap: wrap !important;
padding-bottom: 4px;
}
.tab_row > * {
flex: 1 1 calc(50% - 4px) !important;
min-width: 0 !important;
}
.tab_row > *.petbase_tab_stretch {
flex: 1 1 100% !important;
}
.tab_row button {
border-radius: 10px 10px 0 0 !important;
box-shadow: none !important;
border: none !important;
margin: 0 !important;
min-height: 38px !important;
width: 100% !important;
white-space: normal !important;
}
.tab_row button.primary {
border-bottom: 3px solid #6D4C41 !important;
}
.tab_row button.secondary {
background: transparent !important;
opacity: 0.75;
}
#acc_top_row {
gap: 8px !important;
}
#acc_top_row button {
min-height: 30px !important;
padding: 4px 10px !important;
font-size: 0.85em !important;
}
/* Kompakte Datei-Downloadbox (statt großer grauer Fläche) - seit v7.90.1 mit den
per Live-Inspektion bestätigten echten Gradio-Klassen (.wrap.full, .empty.large,
siehe Tagebuch-PDF-Fix) ergänzt, die alten Regeln bleiben als Sicherheitsnetz. */
.kompakte_datei {
min-height: unset !important;
}
.kompakte_datei .wrap.full {
min-height: 0 !important;
height: auto !important;
}
.kompakte_datei .empty {
min-height: unset !important;
}
.kompakte_datei .empty.large {
min-height: 0 !important;
padding: 8px !important;
}
.kompakte_datei > div {
min-height: 40px !important;
padding: 6px !important;
}
.kompakte_datei svg {
width: 24px !important;
height: 24px !important;
}
.kompakte_datei .icon-wrap {
margin: 0 !important;
}
/* ==========================================
MATERIAL 3 - Weichere große Rundungen, federnde Übergänge
========================================== */
.gradio-container button, .gradio-container .gr-button {
border-radius: 20px !important;
transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.18s ease, background-color 0.2s ease !important;
}
.gradio-container button:active {
transform: scale(0.96);
}
.gradio-container input, .gradio-container textarea, .gradio-container select,
.gradio-container .gr-box, .gradio-container .block {
border-radius: 16px !important;
transition: box-shadow 0.2s ease, border-color 0.2s ease;
}
.gradio-container .gr-group, .gradio-container .gr-form, .gradio-container .gr-panel {
border-radius: 20px !important;
}
/* Frosted-Glass-Karten für Popups (hell) */
#onboarding_card, #versionshistorie_card {
background: rgba(255, 255, 255, 0.92) !important;
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
border-radius: 28px !important;
border: 1px solid rgba(93, 64, 55, 0.5) !important;
}
#onboarding_backdrop, #versionshistorie_backdrop {
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* Akkordeons: weichere Rundung statt eckiger Kästen */
.gradio-container .label-wrap, .gradio-container details {
border-radius: 18px !important;
transition: background-color 0.25s ease;
}
/* Tabs: weichere, "pill"-artige aktive Markierung statt scharfer Linie */
.tab-nav > button, [role="tablist"] [role="tab"] {
border-radius: 14px 14px 0 0 !important;
transition: background-color 0.2s ease, transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* ==========================================
DESIGN-SYSTEM 3.0 - Hell, freundlich, an Stock-Android/Material Design angelehnt
Google-typische helle Oberfläche statt Dark Mode: weißer Grund, dezente
graue Flächen für Karten, klare Schrift, sparsamer Einsatz von Farbe.
========================================== */
/* Seitenhintergrund: helles, freundliches Grau (wie Android-Systemhintergrund) statt Dunkel */
.gradio-container {
background: #F2ECE0 !important;
color: #3B3226 !important;
}
/* Alle Container-artigen Blöcke (Group, Panel, Form, Row mit Inhalt) bekommen
eine einheitliche "Karten"-Optik: weiße Fläche, dezenter Schatten (Material Elevation 1). */
.gradio-container .gr-group,
.gradio-container .gr-panel,
.gradio-container .gr-form,
.gradio-container fieldset {
background: #FFFCF5 !important;
border: 1px solid #E6DAC4 !important;
box-shadow: 0 1px 2px rgba(59,50,38,0.10), 0 1px 3px rgba(59,50,38,0.08) !important;
border-radius: 18px !important;
padding: 14px !important;
}
/* Etwas mehr Luft zwischen aufeinanderfolgenden Karten/Blöcken */
.gradio-container .gr-group + .gr-group,
.gradio-container .gr-form + .gr-form {
margin-top: 10px !important;
}
/* Eingabefelder: leicht getönte Fläche, damit sie sich von der weißen Karte abheben */
.gradio-container input:not([type="checkbox"]):not([type="radio"]),
.gradio-container textarea,
.gradio-container select {
background: #EDE5D6 !important;
border: 1px solid #DED2BA !important;
color: #3B3226 !important;
/* iOS Safari zwingt <select>/<input> sonst eine eigene native Höhe/Optik auf
(leicht abweichend von <input>-Feldern), dadurch wirkte das Dropdown minimal
niedriger als die Textfelder daneben. -webkit-appearance:none unterbindet
das native Styling komplett, sodass beide Feldtypen identisch aussehen. */
-webkit-appearance: none !important;
appearance: none !important;
/* Browser rendern <select>-Text sonst mit ihrer eigenen System-Schrift (meist
fetter/anders als <input>/<textarea>) - hier erzwungen identisch. */
font-family: inherit !important;
font-weight: normal !important;
font-size: 1em !important;
}
/* appearance:none entfernt bei <select> auch den nativen Dropdown-Pfeil (auf
ALLEN Plattformen, nicht nur iOS) - hier durch einen eigenen SVG-Pfeil ersetzt,
damit Android nicht plötzlich den Pfeil verliert. */
.gradio-container select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233B3226' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'></polyline></svg>") !important;
background-repeat: no-repeat !important;
background-position: right 10px center !important;
background-size: 18px !important;
padding-right: 34px !important;
}
.gradio-container input:focus, .gradio-container textarea:focus, .gradio-container select:focus {
background: #FFFCF5 !important;
border-color: #6D4C41 !important;
box-shadow: 0 0 0 2px rgba(93,64,55,0.22) !important;
}
/* Primäre Buttons: sanfter Verlauf statt platter Fläche, für mehr Tiefe */
.gradio-container button.primary {
background: linear-gradient(135deg, #6D4C41, #4E342E) !important;
border: none !important;
color: #FFFCF5 !important;
box-shadow: 0 2px 8px rgba(93,64,55,0.30) !important;
}
.gradio-container button.primary:hover {
box-shadow: 0 4px 14px rgba(93,64,55,0.40) !important;
}
.gradio-container button.secondary {
background: #EDE5D6 !important;
border: 1px solid #DED2BA !important;
color: #3B3226 !important;
}
.gradio-container button.stop {
background: linear-gradient(135deg, #EF5350, #E53935) !important;
border: none !important;
color: #FFFCF5 !important;
}
/* Überschriften: klarere Hierarchie, mehr Abstand nach oben zur vorigen Sektion */
.gradio-container h3 {
color: #4E342E !important;
margin-top: 18px !important;
letter-spacing: 0.2px;
}
.gradio-container label span,
.gradio-container label,
.gradio-container .block > label span,
.gradio-container [data-testid="block-label"] {
color: #7A6E5C !important;
}
.gradio-container .gr-group h3:first-child {
margin-top: 0 !important;
}
/* Zwei-Felder-Zeilen (z.B. Gewicht/Menge, Hersteller/Sorte) bleiben immer
nebeneinander statt untereinander umzubrechen - auch bei tief verschachtelten
Karten mit viel Innenabstand, der sonst zu wenig Platz pro Spalte lässt. */
.gradio-container .form .row:not(.tab_row),
.gradio-container .gr-group .row:not(.tab_row),
.gradio-container .gr-form .row:not(.tab_row),
.gradio-container details .row:not(.tab_row) {
flex-wrap: nowrap !important;
align-items: flex-start !important;
}
.gradio-container .form .row:not(.tab_row) > *,
.gradio-container .gr-group .row:not(.tab_row) > *,
.gradio-container .gr-form .row:not(.tab_row) > *,
.gradio-container details .row:not(.tab_row) > * {
min-width: 0 !important;
overflow: hidden;
}
/* Tab-artige Button-Reihen (Stammdaten/Fütterung/.../Bereich-Tabs): 2-Spalten-Raster
mit Umbruch (siehe .tab_row Regel weiter oben), letztes Element bei ungerader
sichtbarer Anzahl über volle Breite gestreckt (per JS berechnet, siehe
petbaseTabRowsAusrichten - reine CSS-Lösung geht nicht zuverlässig, da einzelne
Tabs je nach Admin-Status/Modus dynamisch ein-/ausgeblendet werden). */
/* Beschriftungs-"Pillen" (z.B. Gewicht 1, Menge 1): dürfen bei wenig Platz
auf eine zweite Zeile umbrechen statt in die Nachbar-Spalte zu überlappen */
.gradio-container label span {
white-space: normal !important;
overflow-wrap: break-word !important;
word-break: break-word !important;
display: inline-block;
max-width: 100%;
}
@media (max-width: 480px) {
.gradio-container label span {
font-size: 0.85em !important;
padding: 3px 8px !important;
}
}
/* Etwas weniger Innenabstand bei verschachtelten Karten, damit mehr Platz
für den eigentlichen Inhalt bleibt */
.gradio-container .gr-group .gr-group,
.gradio-container .gr-form .gr-group {
padding: 8px !important;
}
/* Akkordeon-Symbol: klassische drei Punkte statt Pfeil/Dreieck, in allen Akkordeons.
Gradios Akkordeons nutzen vermutlich das native HTML <summary>-Element, dessen
Pfeil-Markierung eine Browser-eigene Darstellung ist (kein SVG) - deshalb hier
gezielt über ::marker/-webkit-details-marker ausgeblendet, nicht nur über svg. */
.gradio-container summary::-webkit-details-marker {
display: none !important;
}
.gradio-container summary::marker {
content: "" !important;
}
.gradio-container summary {
list-style: none !important;
}
.gradio-container .label-wrap svg,
.gradio-container summary svg,
.gradio-container .label-wrap > *:last-child,
.gradio-container summary > *:last-child {
display: none !important;
width: 0 !important;
visibility: hidden !important;
}
/* Haustierapotheke: Kategorie-Karten deutlich als antippbar erkennbar machen -
sonst wirken sie wie normaler Fließtext statt wie ein aufklappbares Element
(das Standard-Pfeilsymbol wird ja app-weit ausgeblendet, siehe oben). */
.apotheke_kategorie_accordion {
background: #FFFCF5 !important;
border: 1px solid #DED2BA !important;
border-radius: 14px !important;
margin-bottom: 10px !important;
box-shadow: 0 1px 4px rgba(59,50,38,0.12) !important;
}
.apotheke_kategorie_accordion > .label-wrap,
.apotheke_kategorie_accordion summary {
padding: 14px 16px !important;
}
.apotheke_kategorie_accordion > .label-wrap,
.apotheke_kategorie_accordion summary,
.apotheke_kategorie_accordion > .label-wrap *,
.apotheke_kategorie_accordion summary * {
font-weight: 800 !important;
font-size: 1.08em !important;
color: #6D4C41 !important;
}
.gradio-container .label-wrap::after,
.gradio-container summary::after {
content: "⋮" !important;
font-size: 1.3em;
line-height: 1;
color: #6D4C41;
margin-left: auto;
padding-left: 8px;
}
/* Fließtext/Markdown: dunkle, gut lesbare Schrift statt heller Schrift auf hellem Grund */
.gradio-container .prose, .gradio-container .prose p, .gradio-container .prose li {
color: #3B3226 !important;
}
"""
zoo_theme = gr.themes.Soft(primary_hue="orange", secondary_hue="amber")
CUSTOM_HEAD = """
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,300..600,0..1,0" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes">
<link rel="apple-touch-icon" sizes="180x180" href="/pwa_icon/180">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="PetBase">
<script>
// Enter springt zum nächsten Feld (wie Tab), statt nichts zu tun oder abzuschicken.
// Mehrzeilige Textfelder (mehr als 1 Zeile) sind ausgenommen - dort bleibt Enter ein normaler Zeilenumbruch.
document.addEventListener('keydown', function(e) {
if (e.key !== 'Enter' || e.shiftKey) return;
const el = e.target;
if (el.tagName === 'TEXTAREA' && el.rows > 1) return;
if (el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA') return;
if (el.type === 'checkbox' || el.type === 'radio' || el.type === 'button' || el.type === 'submit') return;
e.preventDefault();
const focusable = Array.from(document.querySelectorAll('input, textarea, select'))
.filter(x => x.offsetParent !== null && !x.disabled);
const idx = focusable.indexOf(el);
if (idx > -1 && idx < focusable.length - 1) { focusable[idx + 1].focus(); }
});
// Tab-Leisten (Haupt-Bereiche, Bereich-/Detail-Tabs): 2-Spalten-Raster, letztes
// Element bekommt volle Breite, wenn die Anzahl der SICHTBAREN (nicht aller!)
// Kinder ungerade ist. Reine CSS-Lösung (:nth-last-child) würde auch versteckte
// Tabs mitzählen, die je nach Admin-Status/Modus dynamisch ein-/ausgeblendet
// werden - deshalb per JS anhand der tatsächlichen Sichtbarkeit berechnet, und
// über einen MutationObserver + Sicherheitsnetz-Intervall aktuell gehalten.
function petbaseTabRowsAusrichten() {
document.querySelectorAll('.tab_row').forEach(function(row) {
const kinder = Array.from(row.children);
kinder.forEach(function(k) { k.classList.remove('petbase_tab_stretch'); });
const sichtbare = kinder.filter(function(k) {
return k.offsetParent !== null;
});
if (sichtbare.length % 2 === 1) {
sichtbare[sichtbare.length - 1].classList.add('petbase_tab_stretch');
}
});
}
const petbaseTabRowObserver = new MutationObserver(function() { petbaseTabRowsAusrichten(); });
function petbaseTabRowsBeobachten() {
document.querySelectorAll('.tab_row').forEach(function(row) {
// WICHTIG: NUR 'style' beobachten, NICHT 'class'! Gradio blendet Elemente
// über inline-style (display:none) ein/aus - das reicht zur Erkennung.
// 'class' wurde vorher zusätzlich beobachtet, aber petbaseTabRowsAusrichten()
// selbst ändert Klassen (petbase_tab_stretch) - das hat den Beobachter bei
// JEDER eigenen Änderung erneut ausgelöst: Endlos-Schleife, die den Browser
// komplett lahmgelegt hat ("Skript verlangsamt Browser"-Warnung).
petbaseTabRowObserver.observe(row, { attributes: true, attributeFilter: ['style'], subtree: true });
});
}
petbaseTabRowsBeobachten();
petbaseTabRowsAusrichten();
// Sicherheitsnetz: neue .tab_row-Elemente (z.B. nach Login) werden so auch ohne
// erneuten Observer-Aufruf erfasst, und verpasste Änderungen holen sich nach.
setInterval(function() { petbaseTabRowsBeobachten(); petbaseTabRowsAusrichten(); }, 1000);
// Erneuter Klick auf ein Vollbild-Bild (z.B. maximiertes Profilbild) schließt es automatisch,
// da der eigentliche Schließen-Button auf kleinen Bildschirmen oft außerhalb des sichtbaren
// Bereichs liegt und nicht erreichbar ist. Simuliert dazu die Escape-Taste (sicherer als das
// Klicken eines Buttons zu versuchen, da dabei versehentlich der falsche - z.B. "Bild löschen"
// statt "Vollbild schließen" - Button getroffen werden könnte).
document.addEventListener('click', function(e) {
const fsContainer = e.target.closest('[class*="fullscreen"]');
if (!fsContainer || e.target.tagName !== 'IMG') return;
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true }));
});
// Native Zurück-Geste (iOS-Wischen vom Rand / Android-Zurück-Geste bzw. -Taste)
// unterstützen: springt zum vorherigen Tab zurück, statt die Seite/App zu verlassen.
// Jeder Tab-Wechsel (Haupt-, Bereichs-, Kategorie- und Konto-Tabs) wird dazu im
// Browser-Verlauf vermerkt.
// Zurück-Button: springt zum vorherigen Tab zurück (Subtab -> Haupttab -> Startseite).
// Nutzt einen eigenen Navigations-Speicher statt der Browser-History-API, da diese sich
// in eingebetteten/iframe-Kontexten (z.B. Hugging-Face-Wrapper-Seite) unzuverlässig
// verhalten kann. Position vertikal mittig, damit der Button immer sichtbar ist, egal
// wie weit man auf einer langen Seite gescrollt hat.
(function() {
const navAuswahl = '[id^="b_haupt_"], [id^="b_bereich_"], [id^="b_show_"], [id^="b_konto_tab_"]';
let aktuellerTab = null;
const verlauf = [];
const zurueckBtn = document.createElement('button');
zurueckBtn.textContent = '◀';
zurueckBtn.setAttribute('aria-label', 'Zurück');
zurueckBtn.style.setProperty('position', 'fixed', 'important');
zurueckBtn.style.setProperty('z-index', '2147483647', 'important');
zurueckBtn.style.setProperty('width', '48px', 'important');
zurueckBtn.style.setProperty('height', '48px', 'important');
zurueckBtn.style.setProperty('border-radius', '50%', 'important');
zurueckBtn.style.setProperty('background', '#6D4C41', 'important');
zurueckBtn.style.setProperty('color', '#FFFCF5', 'important');
zurueckBtn.style.setProperty('border', 'none', 'important');
zurueckBtn.style.setProperty('font-size', '20px', 'important');
zurueckBtn.style.setProperty('box-shadow', '0 2px 8px rgba(0,0,0,0.4)', 'important');
zurueckBtn.style.setProperty('display', 'none', 'important');
zurueckBtn.style.setProperty('align-items', 'center', 'important');
zurueckBtn.style.setProperty('justify-content', 'center', 'important');
zurueckBtn.style.setProperty('touch-action', 'none', 'important');
zurueckBtn.style.setProperty('cursor', 'grab', 'important');
// Gespeicherte Position laden - bleibt erhalten, bis der Nutzer den Button
// erneut selbst verschiebt (auch über Neustarts der App hinweg, pro Gerät/Browser).
function ladePosition() {
try {
const g = JSON.parse(localStorage.getItem('petbase_zurueck_position'));
if (g && typeof g.top === 'number' && typeof g.left === 'number') return g;
} catch (e) {}
return { top: window.innerHeight / 2 - 24, left: 12 };
}
function setzePosition(top, left) {
top = Math.max(4, Math.min(top, window.innerHeight - 52));
left = Math.max(4, Math.min(left, window.innerWidth - 52));
zurueckBtn.style.setProperty('top', top + 'px', 'important');
zurueckBtn.style.setProperty('left', left + 'px', 'important');
return { top, left };
}
let position = setzePosition(ladePosition().top, ladePosition().left);
function einhaengen() { if (document.body && !document.body.contains(zurueckBtn)) document.body.appendChild(zurueckBtn); }
einhaengen();
document.addEventListener('DOMContentLoaded', einhaengen);
// Selbstheilung: falls der Button durch eine interne Aktualisierung der Seite aus
// dem DOM entfernt wird, sofort automatisch wieder einfügen (statt erst beim
// nächsten Tab-Klick, was zu einem zeitweisen "Verschwinden" führte).
if (window.MutationObserver && document.body) {
new MutationObserver(function() { einhaengen(); }).observe(document.body, { childList: true });
}
setInterval(einhaengen, 250); // häufige Prüfung, da größere Tab-Wechsel (z.B. Haupttabs) länger dauern können
// Button frei ziehbar machen (Maus für Desktop-Tests, Touch für Handy)
let ziehStart = null, wurdeGezogen = false;
function ziehBeginn(x, y) {
ziehStart = { x, y, top: parseFloat(zurueckBtn.style.top), left: parseFloat(zurueckBtn.style.left) };
wurdeGezogen = false;
zurueckBtn.style.setProperty('cursor', 'grabbing', 'important');
}
function ziehBewegung(x, y) {
if (!ziehStart) return;
const dx = x - ziehStart.x, dy = y - ziehStart.y;
if (Math.abs(dx) > 6 || Math.abs(dy) > 6) wurdeGezogen = true;
if (wurdeGezogen) position = setzePosition(ziehStart.top + dy, ziehStart.left + dx);
}
function ziehEnde() {
if (ziehStart && wurdeGezogen) { try { localStorage.setItem('petbase_zurueck_position', JSON.stringify(position)); } catch (e) {} }
ziehStart = null;
zurueckBtn.style.setProperty('cursor', 'grab', 'important');
}
zurueckBtn.addEventListener('mousedown', function(e) { ziehBeginn(e.clientX, e.clientY); });
document.addEventListener('mousemove', function(e) { ziehBewegung(e.clientX, e.clientY); });
document.addEventListener('mouseup', ziehEnde);
zurueckBtn.addEventListener('touchstart', function(e) { const t = e.touches[0]; ziehBeginn(t.clientX, t.clientY); }, { passive: true });
zurueckBtn.addEventListener('touchmove', function(e) { const t = e.touches[0]; ziehBewegung(t.clientX, t.clientY); }, { passive: true });
zurueckBtn.addEventListener('touchend', ziehEnde);
// Navigation: springt zum Tab VOR dem aktuell aktiven zurück (nicht zum aktuellen
// selbst - das war der Grund, warum vorher keine sichtbare Aktion passierte, da ein
// erneuter Klick auf den bereits aktiven Tab wirkungslos ist).
zurueckBtn.addEventListener('click', function(ev) {
if (wurdeGezogen) { wurdeGezogen = false; return; }
ev.preventDefault(); ev.stopPropagation();
const zielId = verlauf.pop();
if (verlauf.length === 0) zurueckBtn.style.setProperty('display', 'none', 'important');
if (!zielId) return;
aktuellerTab = (zielId === '__START__') ? null : zielId;
const zielBtn = (zielId === '__START__') ? document.getElementById('b_haupt_verwaltung') : document.getElementById(zielId);
if (zielBtn) zielBtn.click();
});
document.addEventListener('click', function(e) {
if (e.target === zurueckBtn) return;
const btn = e.target.closest(navAuswahl);
if (!btn || !btn.id || btn.id === aktuellerTab) return;
if (aktuellerTab) verlauf.push(aktuellerTab);
else verlauf.push('__START__'); // garantiert, dass genug Zurück-Klicks immer bei der echten Startseite ankommen
aktuellerTab = btn.id;
einhaengen();
if (verlauf.length > 0) zurueckBtn.style.setProperty('display', 'flex', 'important');
});
// Banner als direkter "Zurück zur Startseite"-Knopf: springt sofort zur Startseite
// und setzt dabei auch den Zurück-Verlauf zurück, da man ja bewusst ganz von vorne beginnt.
function bannerEinrichten() {
const banner = document.getElementById('app_banner');
if (!banner || banner.dataset.petbaseKlickbar) return;
banner.dataset.petbaseKlickbar = '1';
banner.style.setProperty('cursor', 'pointer', 'important');
banner.addEventListener('click', function() {
verlauf.length = 0;
aktuellerTab = null;
zurueckBtn.style.setProperty('display', 'none', 'important');
const heim = document.getElementById('b_haupt_verwaltung');
if (heim) heim.click();
});
}
bannerEinrichten();
document.addEventListener('DOMContentLoaded', bannerEinrichten);
setInterval(bannerEinrichten, 1000);
})();
</script>
"""
with gr.Blocks(title="PetBase", theme=zoo_theme, css=APP_CSS, head=CUSTOM_HEAD) as demo:
gr.Image("banner.jpg", show_label=False, interactive=False, container=False, elem_id="app_banner", show_download_button=False, show_fullscreen_button=False, show_share_button=False)
current_zoo = gr.BrowserState("")
auto_login_pref = gr.BrowserState("ja")
hidden_img_data = gr.Textbox(visible=False)
news_display = gr.Markdown(visible=True)
with gr.Column(visible=False) as onboarding_overlay:
with gr.Column(elem_id="onboarding_backdrop"):
with gr.Column(elem_id="onboarding_card"):
# ERINNERUNG: Diesen Text bei jeder größeren neuen Funktion (neuer Tab,
# neue Kernfunktion) mit aktualisieren - sonst kennen neue Nutzer die
# Funktion gar nicht erst (siehe v7.86.3, wo Tagebuch & Haustierapotheke
# hier zunächst gefehlt hatten, obwohl schon länger in der App).
onboarding_content = gr.Markdown(
"## 🐾 Willkommen bei PetBase!\n"
"Kurzer Überblick, bevor es losgeht:\n\n"
"**🐾 Tiere** – Stammdaten, Fütterung (Trocken-/Nass-/BARF), Gesundheit & Dokumente pro Tier anlegen. Im Erweiterten Modus zusätzlich ein **📔 Tagebuch** für Beobachtungen mit Foto, als PDF exportierbar.\n\n"
"**🥫 Futter-Zentrale** – Foto vom Futtersack machen, App liest Barcode, Sorte & Dosierung automatisch aus.\n\n"
"**📁 Archiv** – Tiere archivieren (vermittelt/verstorben) und komplette PDF-Akten exportieren.\n\n"
"**📊 Futter Vorrat** – Behält den Überblick, wie lange dein Futtervorrat noch reicht, inkl. Preisvergleichs-Links wenn's knapp wird.\n\n"
"**💊 Haustierapotheke** – Durchstöberbare Sammlung tiermedizinischer Hausmittel-Empfehlungen, nach Tierart geordnet, mit Suche.\n\n"
"**📄 PDF-Export / 📎 Vertrag nachtragen** – Tier-Akten exportieren bzw. Pflegeverträge nachträglich hochladen.\n\n"
"**👤 Konto** – Erweiterter Modus für Pflege-/Gasttiere, Erinnerungen, Feedback & Einstellungen.\n\n"
"Du findest diese Übersicht nicht wieder – aber du wächst schnell rein. Bei Fragen hilft das 🐞 Feedback-Formular unter 'Konto'."
)
onboarding_close_btn = gr.Button("Los geht's!", variant="primary", elem_id="b_onboarding_close")
with gr.Column(visible=True, elem_id="login_screen") as login_screen:
gr.Markdown("## Willkommen!\nBitte logge dich ein oder erstelle ein kostenloses Test-Konto.")
with gr.Row(visible=False) as quick_row:
quick_btn = gr.Button("Weiter", variant="primary", elem_id="b_quick_login")
pref_checkbox = gr.Checkbox(label="🔁 Zukünftig automatisch einloggen (ohne Passwort)", value=True)
# Große, deutlich antippbare Buttons statt Gradios eingebauter (kleiner,
# unauffälliger) Tabs - gleiches bewährtes Muster wie überall sonst in der App.
with gr.Row(elem_classes="tab_row"):
btn_login_tab = gr.Button("Anmelden", variant="primary", elem_id="b_login_tab")
btn_reg_tab = gr.Button("Registrieren", variant="secondary", elem_id="b_reg_tab")
with gr.Column(visible=True) as login_tab_box:
login_name = gr.Textbox(label="Zoo-Name")
login_pw = gr.Textbox(label="Passwort", type="password")
login_btn = gr.Button("Einloggen", variant="primary", elem_id="b_login_submit")
login_status = gr.Textbox(label="Status", visible=False)
with gr.Column(visible=False) as reg_tab_box:
reg_name = gr.Textbox(label="Gewünschter Zoo-Name")
reg_pw = gr.Textbox(label="Passwort wählen", type="password")
reg_email = gr.Textbox(label="E-Mail-Adresse (Optional)")
reg_btn = gr.Button("Konto erstellen", variant="primary", elem_id="b_reg_submit")
reg_status = gr.Textbox(label="Status", visible=False)
btn_show_impressum = gr.Button("Impressum", variant="secondary", size="sm", elem_id="b_impressum")
with gr.Column(visible=False) as impressum_box:
# PLATZHALTER - vor echtem Betrieb (insbesondere vor einer kostenpflichtigen
# Version) durch reale Angaben gemäß §5 TMG ersetzen: vollständiger Name,
# ladungsfähige Anschrift, Kontakt (E-Mail und/oder Telefon), bei
# Personengesellschaften/Unternehmen ggf. Vertretungsberechtigte,
# Registereintrag, USt-IdNr. Für Streitschlichtung zusätzlich Hinweis auf
# die OS-Plattform der EU (§36 VSBG) sinnvoll, sobald ein Bezahlmodell
# existiert. Diese Platzhalter-Version ersetzt keine Rechtsberatung.
gr.Markdown("""### Impressum
**Angaben gemäß § 5 TMG** *(Platzhalter - noch zu vervollständigen)*
**Name:** [Vor- und Nachname]
**Anschrift:** [Straße Hausnummer, PLZ Ort]
**Kontakt:** [E-Mail-Adresse] · [Telefonnummer, optional]
**Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV:** [Name wie oben]
*Hinweis: Diese Angaben sind noch nicht vollständig und müssen vor dem produktiven Einsatz - insbesondere vor Einführung einer kostenpflichtigen Version - ausgefüllt werden.*
""")
with gr.Column(visible=False) as main_app:
acc_status_md = gr.Markdown("<span class='msr'>account_circle</span> Eingeloggt")
with gr.Row(elem_classes="tab_row"):
btn_haupt_start = gr.Button("Start", variant="primary", elem_id="b_haupt_start")
btn_haupt_verwaltung = gr.Button("Verwaltung", variant="secondary", elem_id="b_haupt_verwaltung", visible=False)
btn_haupt_vermisst = gr.Button("Vermisst melden", variant="secondary", elem_id="b_haupt_vermisst")
btn_haupt_konto = gr.Button("Konto", variant="secondary", elem_id="b_haupt_konto", visible=False)
btn_haupt_futtervorrat = gr.Button("Haustierapotheke", variant="secondary", elem_id="b_haupt_futtervorrat")
with gr.Column(visible=True, elem_id="c_haupt_start") as haupt_start:
with gr.Row(elem_id="start_quickrow"):
btn_start_vermisst = gr.Button("Tier vermisst", elem_id="b_start_vermisst")
btn_start_konto = gr.Button("Haustierapotheke", elem_id="b_start_konto")
gr.Markdown("### Auf einen Blick", elem_classes="start_section_label")
start_bestand_html = gr.HTML(value="")
start_anstehend_html = gr.HTML(value="")
with gr.Column(visible=False, elem_id="c_haupt_verwaltung") as haupt_verwaltung:
# Diese Zeile war die alte Navigation innerhalb "Verwaltung" - seit dem
# Popup-System (v7.92.0) läuft die komplette Navigation über die Icons
# unten, diese Zeile ist nur noch überflüssige Doppelung ("Mist", siehe
# Nutzer-Feedback v7.92.3). Buttons bleiben im Code bestehen (werden von
# BEREICH_MAP für die interne Sichtbarkeits-Steuerung gebraucht), sind
# aber nicht mehr sichtbar.
with gr.Row(elem_classes="tab_row", visible=False):
btn_bereich_tiere = gr.Button("Tiere", variant="primary", elem_id="b_bereich_tiere")
btn_bereich_scanner = gr.Button("Futter-Zentrale", variant="secondary", elem_id="b_bereich_scanner")
btn_bereich_archiv = gr.Button("Archiv", variant="secondary", elem_id="b_bereich_archiv", visible=False)
btn_bereich_inventar = gr.Button("Futter Vorrat", variant="secondary", elem_id="b_bereich_inventar")
btn_bereich_nachtrag = gr.Button("Vertrag nachtragen", variant="secondary", elem_id="b_bereich_nachtrag", visible=False)
btn_bereich_apotheke = gr.Button("Haustierapotheke", variant="secondary", elem_id="b_bereich_apotheke")
btn_bereich_pdf = gr.Button("PDF-Export", variant="secondary", elem_id="b_bereich_pdf", visible=False)
btn_bereich_tagebuch = gr.Button("Tagebuch-Übersicht", variant="secondary", elem_id="b_bereich_tagebuch")
with gr.Column(visible=True, elem_id="c_sektion_tiere") as sektion_tiere:
# Das Dropdown bleibt als unsichtbarer Wert-Speicher bestehen, da sehr
# viele andere Stellen im Code (Speichern, Laden, ...) sich darauf
# verlassen. Die sichtbare Tier-Auswahl läuft seit v7.92.1 NUR NOCH
# über das Popup-Flyout an der unteren Leiste (nicht mehr zusätzlich
# hier als eigene Liste - das war doppelt und wurde nicht mehr
# gebraucht, sobald die direkte Auswahl unten da ist).
tier_dropdown = gr.Dropdown(label="Tier wählen", choices=["Hier Auswählen", "Neues Tier..."], scale=2, visible=False)
with gr.Accordion("⚙️ Filter", open=False, visible=False) as filter_accordion:
tier_filter = gr.Radio(choices=["🐾 Alle", "🏠 Eigene Tiere", "🧳 Pflege & Gäste"], value="🐾 Alle", label="Filter", interactive=True, elem_id="tier_filter_radio")
anstehend_karte_html = gr.HTML(value="", elem_id="anstehend_karte_anchor")
bestand_karte_html = gr.HTML(value="", elem_id="bestand_karte_anchor")
with gr.Group(visible=False) as tier_inhalt_wrapper:
with gr.Row(elem_classes="tab_row", visible=False):
btn_show_stam = gr.Button("Stammdaten", variant="primary", elem_id="b_show_stam")
btn_show_futter = gr.Button("Fütterung", variant="secondary", elem_id="b_show_futter")
btn_show_gesund = gr.Button("Gesundheit", variant="secondary", elem_id="b_show_gesund")
btn_show_doku = gr.Button("Dokumente", variant="secondary", elem_id="b_show_doku")
btn_show_tagebuch = gr.Button("Tagebuch", variant="secondary", elem_id="b_show_tagebuch", visible=False)
with gr.Column(visible=True, elem_id="c_acc_stam") as acc_stam:
with gr.Row(elem_id="row_name_kategorie"):
with gr.Column(scale=2):
name_f = gr.Textbox(label="Name")
kat_f = gr.Dropdown(label="Tierauswahl", choices=["Eigenes Tier", "Pflegetier", "Urlaubsgast"], value="Eigenes Tier")
profil_img = gr.Image(type="filepath", label="Profilbild", scale=1, elem_id="profil_img_komponente", show_download_button=False, show_fullscreen_button=False, show_share_button=False)
btn_tagebuch_oeffnen = gr.Button("Tagebuch / Beobachtungen", variant="secondary", visible=False, elem_id="b_show_tagebuch_open")
with gr.Row(visible=False) as row_pflege_upload:
pflege_vertrag_neu = gr.File(label="📄 Pflegevertrag direkt hochladen (PDF oder Bild)", file_types=["image", ".pdf"], elem_id="pflege_vertrag_upload")
with gr.Row(elem_classes="zweispaltig"): tierart_f, rasse_f = gr.Dropdown(label="Tierart", choices=["Hund", "Hündin", "Katze", "Kater", "Pferd", "Pony", "Kaninchen", "Meerschweinchen", "Hamster", "Vogel", "Reptil - Schlange/Gecko", "Reptil - Schildkröte/Leguan", "Fisch/Aquarium", "Sonstiges"], value=None, allow_custom_value=True), gr.Textbox(label="Rasse")
with gr.Row(elem_classes="zweispaltig"): geb_datum_f, farbe_f = gr.Textbox(label="Geburtsdatum"), gr.Textbox(label="Farbe")
with gr.Row(elem_classes="zweispaltig"): kastriert_f, gew_f = gr.Dropdown(label="Kastriert", choices=["Ja", "Nein", "Unbekannt"], allow_custom_value=True), gr.Number(label="Gewicht (kg)")
with gr.Row(elem_classes="zweispaltig"): chip_f, tasso_f = gr.Textbox(label="Chip- / Ringnummer"), gr.Textbox(label="Registrierung (Tasso / Equidenpass etc.)")
kontakt_tel_f = gr.Textbox(label="📞 Kontakttelefon (für Vermisst-Steckbrief)")
kontakt_tel2_f = gr.Textbox(label="📞 Kontakttelefon 2 (Backup, z.B. Familie)")
with gr.Column(visible=False, elem_id="c_acc_tagebuch") as acc_tagebuch:
gr.Markdown("### 📔 Tagebuch / Beobachtungen")
with gr.Column(visible=True) as tagebuch_neu_eintrag_box:
gr.Markdown("**Neuer Eintrag**")
tagebuch_titel_f = gr.Textbox(label="Titel / Überschrift", placeholder="z.B. Ankunft, Tierarztbesuch, Verhaltensänderung ...")
tagebuch_neu_f = gr.Textbox(label="Beobachtung", lines=3, placeholder="z.B. angekommen, gebadet, geschoren, auffälliges Verhalten, körperliche Veränderung ...")
tagebuch_foto_f = gr.Image(type="filepath", label="📷 Foto zum Eintrag (optional)", elem_id="tagebuch_foto_upload", show_download_button=False, show_fullscreen_button=False, show_share_button=False)
btn_tagebuch_speichern = gr.Button("Eintrag speichern", variant="primary", elem_id="b_tagebuch_speichern")
btn_tagebuch_pdf = gr.Button("Als PDF exportieren", variant="secondary", elem_id="b_tagebuch_pdf")
tagebuch_status = gr.Textbox(label="Status", visible=False)
tagebuch_pdf_datei = gr.File(label="Tagebuch-PDF", height=80, elem_id="tagebuch_pdf_result")
gr.Markdown("---")
gr.Markdown("**Bisherige Einträge** (neueste zuerst)")
# Nur die letzten N Einträge anzeigen statt immer alle - vermeidet
# eine endlos lange Seite bei vielen Einträgen über Wochen/Monate.
# "Weitere laden" erhöht die Anzahl schrittweise.
tagebuch_anzahl_state = gr.State(8)
# Zählt hoch nach jedem Speichern, um das @gr.render unten zum
# Neuzeichnen mit frischen Daten zu bewegen.
tagebuch_refresh_zaehler = gr.State(0)
# Welcher Eintrag (Zeilennummer) wird gerade INLINE in seiner Karte
# bearbeitet - None bedeutet "keiner, alle Karten normal anzeigen".
tagebuch_editier_zeile_state = gr.State(None)
# Echte Gradio-Buttons statt HTML+JavaScript-Brücke: Gradios gr.HTML()
# bereinigt (sanitized) dynamischen Inhalt und entfernt dabei
# vermutlich Inline-Events wie onclick - das hatte den früheren
# "Bearbeiten"-Button unbrauchbar gemacht. @gr.render erzeugt echte,
# per Python verkabelte Buttons, die zuverlässig funktionieren.
# Bearbeiten passiert INLINE in der jeweiligen Karte (kein Scrollen
# zu einem Formular oben nötig): die betroffene Karte tauscht ihre
# Anzeige gegen echte Eingabefelder, alle anderen bleiben normal.
@gr.render(inputs=[current_zoo, name_f, tagebuch_anzahl_state, tagebuch_refresh_zaehler, tagebuch_editier_zeile_state])
def render_tagebuch_karten(zoo_name, tier_name, anzahl_anzeigen, _refresh, editier_zeile):
if not zoo_name or not tier_name or tier_name in ("Hier Auswählen", "Neues Tier..."):
gr.Markdown("<i>Bitte zuerst ein Tier auswählen.</i>")
return
eintraege = _tagebuch_eintraege(zoo_name, tier_name)
if not eintraege:
gr.Markdown("<div style='color:#7A6E5C;'><i>Noch keine Einträge vorhanden.</i></div>")
return
neueste_zuerst = list(reversed(eintraege))
sichtbare = neueste_zuerst[:anzahl_anzeigen]
rest = len(neueste_zuerst) - len(sichtbare)
for e in sichtbare:
with gr.Group():
if editier_zeile == e["zeile"]:
# --- Inline-Bearbeitungsmodus für genau diese Karte ---
gr.Markdown(f"*{e['datum']}* – ✏️ **wird bearbeitet**")
edit_titel = gr.Textbox(label="Titel / Überschrift", value=e["titel"])
edit_text = gr.Textbox(label="Beobachtung", value=e["text"], lines=3)
bestehendes_foto = e["foto"].split(",")[0].strip() if e["foto"] else None
edit_foto = gr.Image(type="filepath", label="📷 Foto (nur bei Änderung neu hochladen)", value=bestehendes_foto, elem_id="tagebuch_foto_edit", show_download_button=False, show_fullscreen_button=False, show_share_button=False)
with gr.Row():
save_btn = gr.Button("Speichern", variant="primary", size="sm", elem_classes="btn-ic-save")
cancel_btn = gr.Button("Abbrechen", size="sm", elem_classes="btn-ic-cancel")
def _inline_speichern(titel_val, text_val, foto_val, zn=zoo_name, tn=tier_name, zeile=e["zeile"], altes_foto=e["foto"], rz=_refresh):
if not text_val or not text_val.strip():
return "❌ Bitte einen Text eintragen.", zeile, rz
try:
bestehende_zeile = ws_tagebuch.row_values(zeile)
datum = bestehende_zeile[2] if len(bestehende_zeile) > 2 else datetime.now().strftime("%d.%m.%Y")
foto_url = upload_image_to_cloud(foto_val) if foto_val else altes_foto
neue_zeile = [zn, tn, datum, text_val.strip(), foto_url, str(titel_val).strip() if titel_val else ""]
ws_tagebuch.update(range_name=f"A{zeile}:F{zeile}", values=[neue_zeile])
invalidate_cache()
return "✅ Eintrag aktualisiert.", None, (rz or 0) + 1
except Exception as ex:
return f"❌ Fehler beim Speichern: {str(ex)[:80]}", zeile, rz
save_btn.click(_inline_speichern, inputs=[edit_titel, edit_text, edit_foto], outputs=[tagebuch_status, tagebuch_editier_zeile_state, tagebuch_refresh_zaehler], show_progress="hidden").then(zeige_als_toast, inputs=tagebuch_status, outputs=None)
cancel_btn.click(lambda: None, inputs=None, outputs=tagebuch_editier_zeile_state, show_progress="hidden")
else:
# --- Normale, schreibgeschützte Anzeige ---
inhalt = f"*{e['datum']}*"
if e["titel"]:
inhalt += f" \n**{e['titel']}**"
inhalt += f" \n{e['text']}"
gr.Markdown(inhalt)
if e["foto"] and e["foto"].startswith("http"):
gr.Image(value=e["foto"].split(",")[0].strip(), show_label=False, container=False, height=180, interactive=False, show_download_button=False, show_fullscreen_button=False, show_share_button=False)
edit_btn = gr.Button("Bearbeiten", size="sm", elem_classes="btn-ic-edit")
edit_btn.click(lambda zeile=e["zeile"]: zeile, inputs=None, outputs=tagebuch_editier_zeile_state, show_progress="hidden")
if rest > 0:
mehr_btn = gr.Button(f"Weitere Einträge laden ({rest} älter)", size="sm", elem_classes="btn-ic-more")
mehr_btn.click(lambda a=anzahl_anzeigen: a + 10, inputs=None, outputs=tagebuch_anzahl_state, show_progress="hidden")
with gr.Column(visible=False, elem_id="c_acc_futter") as acc_futter:
with gr.Group():
barf_cb = gr.Checkbox(label="BARF / Frisch- & Rohfütterung aktiv")
with gr.Group(visible=True) as group_trocken:
with gr.Row(): trock_f, tf_phase_hinweis = gr.Dropdown(label="Trockenfutter Sorte", choices=["Hier Auswählen"], value="Hier Auswählen", allow_custom_value=True, scale=3), gr.Textbox(label="Hinweis", interactive=False, scale=1)
tf_gramm_f = gr.Number(label="Gramm pro Tag")
with gr.Group(visible=False) as group_barf:
typ_f, gramm_f = gr.Dropdown(label="BARF Typ", choices=["Hier Auswählen"], value="Hier Auswählen", allow_custom_value=True), gr.Number(label="Gramm pro Tag")
prot_f, kohl_f = gr.Number(label="Protein (g)"), gr.Number(label="Kohlenhydrate (g)")
nass_cb = gr.Checkbox(label="🥫 Zusätzlich Nassfutter füttern", visible=False)
with gr.Group(visible=False) as group_nass:
nass_f = gr.Dropdown(label="Nassfutter Sorte", choices=["Hier Auswählen"], value="Hier Auswählen", allow_custom_value=True)
nass_gramm_f = gr.Number(label="Gramm pro Tag (Nassfutter)")
with gr.Column(visible=False, elem_id="c_acc_gesund") as acc_gesund:
with gr.Group():
allergien_f = gr.Textbox(label="⚠️ Allergien / Unverträglichkeiten", placeholder="z.B. Nickel-Allergie, Getreide-Unverträglichkeit ...", lines=2)
with gr.Group():
impf_f = gr.Textbox(label="Letzte Impfung / Check-up")
with gr.Row():
btn_impf = gr.Button("Heute", size="sm")
btn_ics_impf = gr.Button("Erinnerung", size="sm", elem_classes="btn-ic-reminder")
btn_email_impf = gr.Button("E-Mail", size="sm", elem_classes="btn-ic-email")
dl_impf = gr.File(visible=False, elem_classes="kompakte_datei")
email_impf_status = gr.Textbox(label="Status", visible=False)
with gr.Group():
wurm_f = gr.Textbox(label="Letzte Wurmkur / Kotprobe")
with gr.Row():
btn_wurm = gr.Button("Heute", size="sm")
btn_ics_wurm = gr.Button("Erinnerung", size="sm", elem_classes="btn-ic-reminder")
btn_email_wurm = gr.Button("E-Mail", size="sm", elem_classes="btn-ic-email")
dl_wurm = gr.File(visible=False, elem_classes="kompakte_datei")
email_wurm_status = gr.Textbox(label="Status", visible=False)
with gr.Group():
para_f = gr.Textbox(label="Parasitenschutz / Spezialpflege (z.B. Klauen, Häutung)")
para_int_f = gr.Radio(choices=["3 Monate", "1 Jahr"], value="3 Monate", label="Intervall")
with gr.Row():
btn_para = gr.Button("Heute", size="sm")
btn_ics_para = gr.Button("Erinnerung", size="sm", elem_classes="btn-ic-reminder")
btn_email_para = gr.Button("E-Mail", size="sm", elem_classes="btn-ic-email")
dl_para = gr.File(visible=False, elem_classes="kompakte_datei")
email_para_status = gr.Textbox(label="Status", visible=False)
with gr.Column(visible=False, elem_id="c_doc_accordion") as doc_accordion:
doc_titel_md = gr.Markdown("### 📂 Dokumente & Verträge (PDF)")
gr.Markdown("Wähle im Dropdown aus, welches Dokument oder welchen Vertrag du hinzufügen oder anzeigen möchtest:")
doc_type_select = gr.Dropdown(
label="Dokument oder Vertrag zum Hinzufügen / Bearbeiten auswählen",
choices=["Bitte wählen...", "Pflegevertrag", "Vermittlungsvertrag", "Tierarzt Dokumente (1)", "Tierarzt Dokumente (2)", "Tierarzt Dokumente (3)", "Tierarzt Dokumente (4)", "Tierarzt Dokumente (5)", "Tierarzt Dokumente (6)", "Alle Dokumente anzeigen"],
value="Bitte wählen..."
)
with gr.Row():
with gr.Column(visible=False) as col_vp: vp_img = gr.File(label="Pflegevertrag", file_types=["image", ".pdf"], elem_classes="kompakte_datei"); vp_h = gr.Textbox(visible=False)
with gr.Column(visible=False) as col_vv: vv_img = gr.File(label="Vermittlungsvertrag", file_types=["image", ".pdf"], elem_classes="kompakte_datei"); vv_h = gr.Textbox(visible=False)
with gr.Row():
with gr.Column(visible=False) as col_t1: t1_img = gr.File(label="Tierarzt Dokument", file_types=["image", ".pdf"], elem_classes="kompakte_datei"); t1_h = gr.Textbox(visible=False)
with gr.Column(visible=False) as col_t2: t2_img = gr.File(label="Tierarzt Dokument", file_types=["image", ".pdf"], elem_classes="kompakte_datei"); t2_h = gr.Textbox(visible=False)
with gr.Row():
with gr.Column(visible=False) as col_t3: t3_img = gr.File(label="Tierarzt Dokument", file_types=["image", ".pdf"], elem_classes="kompakte_datei"); t3_h = gr.Textbox(visible=False)
with gr.Column(visible=False) as col_t4: t4_img = gr.File(label="Tierarzt Dokument", file_types=["image", ".pdf"], elem_classes="kompakte_datei"); t4_h = gr.Textbox(visible=False)
with gr.Row():
with gr.Column(visible=False) as col_t5: t5_img = gr.File(label="Tierarzt Dokument", file_types=["image", ".pdf"], elem_classes="kompakte_datei"); t5_h = gr.Textbox(visible=False)
with gr.Column(visible=False) as col_t6: t6_img = gr.File(label="Tierarzt Dokument", file_types=["image", ".pdf"], elem_classes="kompakte_datei"); t6_h = gr.Textbox(visible=False)
save_btn = gr.Button("Profil Speichern", variant="primary", size="lg", elem_id="b_profil_speichern")
status = gr.Textbox(label="Status", visible=False)
with gr.Column(visible=False, elem_id="c_sektion_scanner") as sektion_scanner:
gr.Markdown("### 🥫 Neues Futter hinzufügen")
btn_scan_toggle = gr.Button("Per Foto scannen (optional – füllt die Felder unten automatisch aus)", variant="secondary", elem_id="b_scan_toggle")
scan_bild_offen = gr.State(False)
with gr.Group(visible=False, elem_id="grp_scan_bild") as scan_bild_gruppe:
with gr.Row():
futter_bild_1 = gr.Image(sources=["webcam", "upload"], type="numpy", label="Bild 1", elem_id="futter_bild_upload_1", show_download_button=False, show_fullscreen_button=False, show_share_button=False)
futter_bild_2 = gr.Image(sources=["webcam", "upload"], type="numpy", label="Bild 2", elem_id="futter_bild_upload_2", show_download_button=False, show_fullscreen_button=False, show_share_button=False)
futter_bild_3 = gr.Image(sources=["webcam", "upload"], type="numpy", label="Bild 3", elem_id="futter_bild_upload_3", show_download_button=False, show_fullscreen_button=False, show_share_button=False)
btn_scan = gr.Button("Bilder auswerten", variant="primary", elem_id="b_scan_auswerten")
with gr.Row(): scan_bc, scan_hersteller, scan_sorte = gr.Textbox(label="Barcode"), gr.Textbox(label="Hersteller"), gr.Textbox(label="Sorte")
with gr.Row(): scan_phase, scan_gewicht, scan_tierart = gr.Textbox(label="Lebensphase"), gr.Textbox(label="Füllgewicht"), gr.Dropdown(label="Kategorie", choices=["Hundefutter", "Katzenfutter", "Nagerfutter", "Vogelfutter", "Pferdefutter", "Reptilienfutter", "Aquaristik", "Sonstiges"], value="Hundefutter", allow_custom_value=True)
scan_futtertyp = gr.Radio(label="Fütterungsart", choices=["Trockenfutter", "Nassfutter"], value="Trockenfutter")
with gr.Row(): scan_k1, scan_g1 = gr.Textbox(label="Gewicht 1"), gr.Textbox(label="Menge 1")
with gr.Row(): scan_k2, scan_g2 = gr.Textbox(label="Gewicht 2"), gr.Textbox(label="Menge 2")
with gr.Row(): scan_k3, scan_g3 = gr.Textbox(label="Gewicht 3"), gr.Textbox(label="Menge 3")
with gr.Row(): scan_k4, scan_g4 = gr.Textbox(label="Gewicht 4"), gr.Textbox(label="Menge 4")
with gr.Accordion("🔤 Gelesener Rohtext (zur Kontrolle)", open=False):
scan_raw = gr.Textbox(label="Gelesener Text", lines=3, interactive=False, show_label=False)
btn_save_futter = gr.Button("Speichern", variant="primary", elem_id="b_save_futter")
scan_status = gr.Textbox(label="Status", visible=False)
with gr.Column(visible=False, elem_id="c_sektion_archiv") as sektion_archiv:
gr.Markdown("### 📦 Tier archivieren")
with gr.Row():
move_dropdown = gr.Dropdown(label="Tier in Archiv verschieben", scale=2)
a_grund = gr.Dropdown(choices=["Vermittelt", "Verstorben", "Sonstiges"], label="Grund", scale=1)
with gr.Row(visible=False) as extra_vertrag_row:
a_vertrag_img = gr.File(label="📄 Vermittlungsvertrag", file_types=["image", ".pdf"], elem_classes="kompakte_datei")
btn_archive, archive_status = gr.Button("Archivieren", variant="primary", size="lg", elem_id="b_archivieren"), gr.Textbox(label="Status", visible=False)
with gr.Column(visible=False, elem_id="c_sektion_pdf") as sektion_pdf_export:
gr.Markdown("### 📄 PDF-Akte exportieren")
with gr.Row():
sel_pdf_own = gr.Dropdown(label="🏠 Eigene Tiere", choices=["Hier Auswählen"], value="Hier Auswählen", scale=2)
btn_pdf_own = gr.Button("PDF generieren", scale=1)
with gr.Row(visible=False) as row_pdf_foster:
sel_pdf_foster = gr.Dropdown(label="🧳 Pflege- & Gasttiere", choices=["Hier Auswählen"], value="Hier Auswählen", scale=2)
btn_pdf_foster = gr.Button("PDF generieren", scale=1)
with gr.Row():
sel_pdf_archiv = gr.Dropdown(label="🗄️ Archivierte Tiere", choices=["Hier Auswählen"], value="Hier Auswählen", scale=2)
btn_pdf_archiv = gr.Button("PDF generieren", scale=1)
pdf_status = gr.Textbox(label="Status PDF", visible=False)
with gr.Group():
gr.Markdown("**Was soll enthalten sein?**")
pdf_mit_info = gr.Checkbox(label="📋 Infoblatt (Stammdaten, Fütterung, Gesundheit)", value=True)
pdf_mit_pflege = gr.Checkbox(label="📎 Pflegevertrag (falls vorhanden)", value=True)
pdf_mit_vermittlung = gr.Checkbox(label="📄 Vermittlungsvertrag (falls vorhanden)", value=True)
pdf_mit_tierarzt = gr.Checkbox(label="🩺 Alle Tierarzt-Dokumente (falls vorhanden)", value=True)
pdf_mit_tagebuch = gr.Checkbox(label="📔 Tagebuch (falls vorhanden)", value=False)
pdf_file = gr.File(label="Download Akte", elem_classes="kompakte_datei")
with gr.Column(visible=False, elem_id="c_sektion_nachtrag") as sektion_nachtrag:
gr.Markdown("### 📎 Vertrag nachtragen")
nach_ziel_radio = gr.Radio(choices=["🧳 Aktueller Bestand", "🗄️ Archiv"], value="🧳 Aktueller Bestand", label="Wo befindet sich das Tier?")
nach_aktiv_dd = gr.Dropdown(label="Pflege- & Gasttier auswählen", choices=["Hier Auswählen"], value="Hier Auswählen", visible=True)
nach_archiv_dd = gr.Dropdown(label="Archiviertes Tier auswählen", choices=["Hier Auswählen"], value="Hier Auswählen", visible=False)
with gr.Row():
nach_typ_dd = gr.Dropdown(label="Vertragsart", choices=["Vermittlungsvertrag", "Pflegevertrag"], value="Vermittlungsvertrag")
nach_img = gr.File(label="📄 Vertragsdokument (Bild/PDF)", file_types=["image", ".pdf"], elem_classes="kompakte_datei")
btn_nach_save, nach_status = gr.Button("Vertrag anhängen", variant="primary", elem_id="b_nach_save"), gr.Textbox(label="Status", visible=False)
with gr.Column(visible=False, elem_id="c_sektion_tagebuch_uebersicht") as sektion_tagebuch_uebersicht:
gr.Markdown("### 📔 Tagebuch")
gr.Markdown("Wähle ein Tier aus, um sein Tagebuch zu öffnen.")
tb_uebersicht_refresh = gr.State(0)
@gr.render(inputs=[current_zoo, tb_uebersicht_refresh])
def render_tagebuch_uebersicht(zoo_name, _refresh):
tiere = _tiere_mit_tagebuch_stand(zoo_name)
if not tiere:
gr.Markdown("<i>Noch keine Tiere angelegt.</i>")
return
for t in tiere:
with gr.Row(elem_classes="tb_uebersicht_row"):
if t["anzahl"] > 0:
label = f"🐾 {t['name']} ({t['tierart']}) · {t['anzahl']} Einträge · zuletzt {t['letztes_datum']}"
else:
label = f"🐾 {t['name']} ({t['tierart']}) · noch keine Einträge"
btn = gr.Button(label, elem_classes="tb_uebersicht_btn")
btn.click(oeffne_tier_tagebuch_fabrik(t["name"]), inputs=[current_zoo, tagebuch_refresh_zaehler], outputs=tagebuch_uebersicht_sprung_outs, show_progress="hidden")
with gr.Column(visible=False, elem_id="c_sektion_inventar") as sektion_inventar:
gr.Markdown("### 📊 Futter-Übersicht")
dash_html = gr.HTML(value="<i>Bitte logge dich ein.</i>", elem_id="dash_html_anchor")
futter_slot_rows, futter_slot_labels, futter_slot_sorten = [], [], []
futter_slot_aufstock_btns, futter_slot_edit_btns = [], []
futter_slot_aufstock_rows, futter_slot_aufstock_menge, futter_slot_aufstock_confirm, futter_slot_aufstock_close = [], [], [], []
futter_slot_edit_rows, futter_slot_edit_bestand, futter_slot_edit_sack, futter_slot_edit_confirm, futter_slot_edit_del, futter_slot_edit_more, futter_slot_edit_close = [], [], [], [], [], [], []
futter_slot_mehr_rows, futter_slot_mehr_fields, futter_slot_mehr_speichern, futter_slot_mehr_close = [], [], [], []
for _i in range(FUTTER_SLOT_ANZAHL):
with gr.Row(visible=False, elem_classes="futter_slot_row") as _row:
_lbl = gr.HTML("")
_btn_a = gr.Button("add", size="sm", scale=0, min_width=44, elem_classes="msr-icon-btn")
_btn_e = gr.Button("edit", size="sm", scale=0, min_width=44, elem_classes="msr-icon-btn")
with gr.Row(visible=False, elem_classes="futter_slot_inline") as _aufstock_row:
_aufstock_close = gr.Button("close", scale=0, min_width=40, elem_classes="msr-icon-btn")
_aufstock_menge = gr.Number(label="Menge (g)", value=500, scale=2)
_aufstock_confirm = gr.Button("Aufstocken", variant="primary", scale=1, elem_classes="btn-ic-check")
with gr.Row(visible=False, elem_classes="futter_slot_inline") as _edit_row:
_edit_bestand = gr.Textbox(label="Bestand (g)", scale=1)
_edit_sack = gr.Textbox(label="Sackgröße (g)", scale=1)
_edit_confirm = gr.Button("save", scale=0, min_width=40, elem_classes="msr-icon-btn")
_edit_del = gr.Button("delete", scale=0, min_width=40, variant="stop", elem_classes="msr-icon-btn")
_edit_more = gr.Button("Mehr", scale=0, min_width=60, elem_classes="btn-ic-more-inline")
_edit_close = gr.Button("close", scale=0, min_width=40, elem_classes="msr-icon-btn")
with gr.Group(visible=False, elem_classes="futter_slot_inline") as _mehr_row:
with gr.Row(): _m_herst, _m_sorte = gr.Textbox(label="Hersteller"), gr.Textbox(label="Sorte")
_m_gew, _m_sack = gr.Textbox(visible=False), gr.Textbox(visible=False)
with gr.Row(): _m_phase, _m_art = gr.Textbox(label="Lebensphase"), gr.Dropdown(label="Kategorie", choices=["Hundefutter", "Katzenfutter", "Nagerfutter", "Vogelfutter", "Pferdefutter", "Reptilienfutter", "Aquaristik", "Sonstiges"], value="Hundefutter", allow_custom_value=True)
with gr.Accordion("⚙️ Fütterungsart & Gewichts-Brackets", open=False):
_m_futtertyp = gr.Radio(label="Fütterungsart", choices=["Trockenfutter", "Nassfutter"], value="Trockenfutter")
with gr.Row(): _m_k1, _m_g1 = gr.Textbox(label="Gewicht 1"), gr.Textbox(label="Menge 1")
with gr.Row(): _m_k2, _m_g2 = gr.Textbox(label="Gewicht 2"), gr.Textbox(label="Menge 2")
with gr.Row(): _m_k3, _m_g3 = gr.Textbox(label="Gewicht 3"), gr.Textbox(label="Menge 3")
with gr.Row(): _m_k4, _m_g4 = gr.Textbox(label="Gewicht 4"), gr.Textbox(label="Menge 4")
with gr.Row():
_m_close = gr.Button("close", scale=0, min_width=40, elem_classes="msr-icon-btn")
_m_speichern = gr.Button("Speichern", variant="primary", scale=2, elem_classes="btn-ic-save")
_sorte_state = gr.State("")
futter_slot_rows.append(_row)
futter_slot_labels.append(_lbl)
futter_slot_sorten.append(_sorte_state)
futter_slot_aufstock_btns.append(_btn_a)
futter_slot_edit_btns.append(_btn_e)
futter_slot_aufstock_rows.append(_aufstock_row)
futter_slot_aufstock_menge.append(_aufstock_menge)
futter_slot_aufstock_confirm.append(_aufstock_confirm)
futter_slot_aufstock_close.append(_aufstock_close)
futter_slot_edit_rows.append(_edit_row)
futter_slot_edit_bestand.append(_edit_bestand)
futter_slot_edit_sack.append(_edit_sack)
futter_slot_edit_confirm.append(_edit_confirm)
futter_slot_edit_del.append(_edit_del)
futter_slot_edit_more.append(_edit_more)
futter_slot_edit_close.append(_edit_close)
futter_slot_mehr_rows.append(_mehr_row)
futter_slot_mehr_fields.append([_m_herst, _m_sorte, _m_gew, _m_sack, _m_phase, _m_art, _m_futtertyp, _m_k1, _m_g1, _m_k2, _m_g2, _m_k3, _m_g3, _m_k4, _m_g4])
futter_slot_mehr_speichern.append(_m_speichern)
futter_slot_mehr_close.append(_m_close)
# Unsichtbare Platzhalter (werden von init_ui/change_modus/speichere_inventar noch referenziert, aber nirgends angezeigt)
edit_f_dd = gr.Dropdown(choices=["Hier Auswählen"], value="Hier Auswählen", allow_custom_value=True, visible=False)
dash_aufstock_dd = gr.Dropdown(choices=["Hier Auswählen"], value="Hier Auswählen", visible=False)
with gr.Column(visible=False, elem_id="c_sektion_apotheke") as sektion_apotheke:
apotheke_banner_img = gr.Image(value=APOTHEKE_BANNER_URL, show_label=False, container=False, interactive=False, elem_id="apotheke_banner_img", visible=apotheke_banner_ist_aktiv(), show_download_button=False, show_fullscreen_button=False, show_share_button=False)
gr.Markdown("### 💊 Haustierapotheke")
gr.Markdown("Hausmittel-Empfehlungen auf tiermedizinischer Basis. **Tippe auf eine Kategorie unten, um sie zu öffnen** (z.B. Hund, Katze, Maus), oder nutze die Suche (durchsucht auch die Inhalte, nicht nur die Titel).")
with gr.Row():
apotheke_tierart_filter_dd = gr.Dropdown(label="🐾 Tierart eingrenzen", choices=["Alle Tierarten"], value="Alle Tierarten", scale=1)
apotheke_suche_f = gr.Textbox(label="🔍 Suche (z.B. 'Erbrechen', 'Magen', 'Durchfall')", placeholder="Stichwort eingeben ...", scale=2)
with gr.Column(visible=False) as apotheke_admin_box:
gr.Markdown("---")
apotheke_banner_cb = gr.Checkbox(label="🖼️ Kopf-Banner-Bild anzeigen", value=apotheke_banner_ist_aktiv())
gr.Markdown("**Neuen Eintrag hinzufügen** (nur für Admins sichtbar)")
apotheke_neu_kategorie_f = gr.Textbox(label="Kategorie", placeholder="z.B. Hund, Katze, Maus ...")
apotheke_neu_titel_f = gr.Textbox(label="Titel", placeholder="z.B. Übersäuerter Magen / häufiges Erbrechen")
apotheke_neu_inhalt_f = gr.Textbox(label="Inhalt", lines=5, placeholder="Ausführliche Empfehlung, Hausmittel, Dosierung, wann zum Tierarzt ...")
btn_apotheke_speichern = gr.Button("Eintrag speichern", variant="primary", elem_id="b_apotheke_speichern")
apotheke_status = gr.Textbox(label="Status", visible=False)
gr.Markdown("---")
# Zeigt pro Kategorie einen eigenen aufklappbaren Bereich (Accordion),
# Karten alphabetisch nach Titel sortiert, mit "Weitere laden"-Button
# (wie bei der Änderungshistorie) und einem echten "Bearbeiten"-Button
# pro Karte (wie beim Tagebuch) - über @gr.render erzeugt, damit die
# Buttons zuverlässig funktionieren (siehe Tagebuch-Historie, v7.83.0).
apotheke_seiten_state = gr.State({})
apotheke_refresh_zaehler = gr.State(0)
apotheke_editier_zeile_state = gr.State(None)
# Merkt sich, welche Kategorie (Accordion) gerade aufgeklappt ist. Ohne
# diesen Merker würde jedes Neuzeichnen (z.B. beim Klick auf "Bearbeiten")
# ALLE Accordions wieder auf "zu" zurücksetzen, da @gr.render den
# kompletten Baum inkl. Accordions neu erzeugt.
apotheke_offene_kategorie_state = gr.State(None)
@gr.render(inputs=[current_zoo, apotheke_tierart_filter_dd, apotheke_suche_f, apotheke_seiten_state, apotheke_refresh_zaehler, apotheke_editier_zeile_state, apotheke_offene_kategorie_state])
def render_apotheke(zoo_name, tierart_filter, suchbegriff, seiten, _refresh, editier_zeile, offene_kategorie):
admin = ist_admin(zoo_name)
alle_eintraege = _apotheke_eintraege()
# Tierart-Eingrenzung greift VOR allem anderen (Suche wie Kategorie-
# Ansicht) - verhindert, dass z.B. eine Katzen-Empfehlung auftaucht,
# wenn jemand gezielt nach "Schlange" eingegrenzt hat.
if tierart_filter and tierart_filter != "Alle Tierarten":
alle_eintraege = [e for e in alle_eintraege if e["kategorie"] == tierart_filter]
def _karte(e, kat=None):
with gr.Group():
if editier_zeile == e["zeile"]:
edit_kat = gr.Textbox(label="Kategorie", value=e["kategorie"])
edit_titel = gr.Textbox(label="Titel", value=e["titel"])
edit_inhalt = gr.Textbox(label="Inhalt", value=e["inhalt"], lines=5)
with gr.Row():
save_btn = gr.Button("Speichern", variant="primary", size="sm", elem_classes="btn-ic-save")
cancel_btn = gr.Button("Abbrechen", size="sm", elem_classes="btn-ic-cancel")
def _speichern(kat_val, titel_val, inhalt_val, zeile=e["zeile"], altes_datum=e["datum"], rz=_refresh, k=kat):
if not titel_val or not titel_val.strip() or not inhalt_val or not inhalt_val.strip() or not kat_val or not kat_val.strip():
return "❌ Kategorie, Titel und Inhalt dürfen nicht leer sein.", zeile, rz, k
try:
neue_zeile = [kat_val.strip(), titel_val.strip(), inhalt_val.strip(), altes_datum or datetime.now().strftime("%d.%m.%Y")]
ws_apotheke.update(range_name=f"A{zeile}:D{zeile}", values=[neue_zeile])
invalidate_cache()
return "✅ Eintrag aktualisiert.", None, (rz or 0) + 1, k
except Exception as ex:
return f"❌ Fehler beim Speichern: {str(ex)[:80]}", zeile, rz, k
save_btn.click(_speichern, inputs=[edit_kat, edit_titel, edit_inhalt], outputs=[apotheke_status, apotheke_editier_zeile_state, apotheke_refresh_zaehler, apotheke_offene_kategorie_state], show_progress="hidden").then(zeige_als_toast, inputs=apotheke_status, outputs=None)
cancel_btn.click(lambda k=kat: (None, k), inputs=None, outputs=[apotheke_editier_zeile_state, apotheke_offene_kategorie_state], show_progress="hidden")
else:
gr.Markdown(f"**{e['titel']}** \n{e['inhalt']}")
if admin:
edit_btn = gr.Button("Bearbeiten", size="sm", elem_classes="btn-ic-edit")
edit_btn.click(lambda zeile=e["zeile"], k=kat: (zeile, k), inputs=None, outputs=[apotheke_editier_zeile_state, apotheke_offene_kategorie_state], show_progress="hidden")
if suchbegriff and suchbegriff.strip():
# Suche ignoriert die Kategorie-Gliederung bewusst - durchsucht
# Titel UND Inhalt über alle (ggf. per Tierart eingegrenzten)
# Kategorien hinweg, alphabetisch sortiert.
begriff = suchbegriff.strip().lower()
treffer = [e for e in alle_eintraege if begriff in e["titel"].lower() or begriff in e["inhalt"].lower() or begriff in e["kategorie"].lower()]
if not treffer:
gr.Markdown("<i>Keine Einträge gefunden. Versuch es mit einem anderen Suchbegriff oder einer anderen Tierart.</i>")
return
treffer.sort(key=lambda e: e["titel"].lower())
gr.Markdown(f"**{len(treffer)} Treffer für '{suchbegriff}'**")
for e in treffer:
_karte(e)
return
if not alle_eintraege:
gr.Markdown("<div style='color:#7A6E5C;'><i>Noch keine Einträge vorhanden.</i></div>")
return
kategorien = sorted(set(e["kategorie"] for e in alle_eintraege if e["kategorie"]))
for kat in kategorien:
kat_eintraege = sorted([e for e in alle_eintraege if e["kategorie"] == kat], key=lambda e: e["titel"].lower())
with gr.Accordion(f"{_apotheke_icon(kat)} {kat} — {len(kat_eintraege)} Einträge ▸ antippen zum Öffnen", open=(kat == offene_kategorie), elem_classes="apotheke_kategorie_accordion"):
anzahl_anzeigen = seiten.get(kat, 5)
sichtbare = kat_eintraege[:anzahl_anzeigen]
rest = len(kat_eintraege) - len(sichtbare)
for e in sichtbare:
_karte(e, kat=kat)
if rest > 0:
mehr_btn = gr.Button(f"⬇️ Weitere Einträge laden ({rest} weitere)", size="sm")
def _mehr_laden(alte_seiten=seiten, k=kat, a=anzahl_anzeigen):
neu = dict(alte_seiten)
neu[k] = a + 10
return neu, k
mehr_btn.click(_mehr_laden, inputs=None, outputs=[apotheke_seiten_state, apotheke_offene_kategorie_state], show_progress="hidden")
with gr.Column(visible=False, elem_id="c_haupt_vermisst") as haupt_vermisst:
gr.Markdown(
"### 🚨 Tier als vermisst melden\n"
"Direkt hier auswählen – kein Umweg über die Verwaltung nötig."
)
vermisst_tier_dd = gr.Dropdown(label="Welches Tier ist vermisst?", choices=["Hier Auswählen"], value="Hier Auswählen")
v_name = gr.Textbox(visible=False)
v_tierart = gr.Textbox(visible=False)
v_rasse = gr.Textbox(visible=False)
v_farbe = gr.Textbox(visible=False)
v_chip = gr.Textbox(visible=False)
v_tasso = gr.Textbox(visible=False)
v_kontakt = gr.Textbox(visible=False)
v_kontakt2 = gr.Textbox(visible=False)
v_img = gr.Image(visible=False, type="filepath")
with gr.Group(visible=False) as vermisst_gruppe:
gr.Markdown(
"TASSO bietet leider keine automatische Anbindung – hier bekommst du aber alle Daten "
"übersichtlich zum Abtippen sowie einen fertigen Steckbrief zum Ausdrucken/Teilen."
)
vermisst_notiz_f = gr.Textbox(label="Zusatzinfo für den Steckbrief (optional, z. B. wo/wann zuletzt gesehen)", lines=2)
btn_vermisst_daten = gr.Button("Daten für TASSO anzeigen", elem_id="b_vermisst_daten")
vermisst_copy_text = gr.Textbox(label="Zum Kopieren ins TASSO-Formular", lines=8, interactive=False)
btn_tasso_link = gr.Button("TASSO Vermisst-Formular öffnen", elem_id="b_tasso_link")
gr.Markdown(
"*Falls sich nichts öffnet (kann in der Standalone-App passieren): Link unten antippen & halten zum Kopieren, dann im normalen Browser (Safari/Chrome) einfügen.*\n\n"
"`https://www.tasso.net/Tierregister/Tier-vermisst/Tier-vermisst-melden`"
)
btn_vermisst_pdf = gr.Button("Vermisst-Steckbrief als PDF erstellen", variant="stop", elem_id="b_vermisst_pdf")
vermisst_pdf_status = gr.Textbox(label="Status", visible=False)
vermisst_pdf_file = gr.File(label="Download Steckbrief", elem_classes="kompakte_datei")
with gr.Column(visible=False, elem_id="c_haupt_konto") as haupt_konto:
with gr.Row(elem_classes="tab_row"):
btn_konto_tab_einst = gr.Button("Einstellungen", variant="primary", elem_id="b_konto_tab_einst")
btn_konto_tab_admin = gr.Button("Admin", variant="secondary", elem_id="b_konto_tab_admin", visible=False)
with gr.Column(visible=True) as konto_tab_einstellungen:
gr.Markdown("### ⚙️ Allgemeine Einstellungen")
modus_cb = gr.Checkbox(label="Erweiterter Modus (Pflege- & Gasttiere aktivieren)", value=False)
btn_show_fb = gr.Button("Feedback senden", variant="secondary", elem_id="b_show_fb")
with gr.Column(visible=False) as konto_fb_box:
fb_thema = gr.Dropdown(choices=["Fehler", "Idee", "Frage"], label="Thema"); fb_text = gr.Textbox(label="Nachricht")
btn_fb, fb_status = gr.Button("Senden", variant="primary", elem_id="b_fb_senden"), gr.Textbox(label="Status", visible=False)
btn_show_erinn = gr.Button("Erinnerungen", variant="secondary", elem_id="b_show_erinn")
with gr.Column(visible=False) as konto_erinn_box:
set_email = gr.Textbox(label="E-Mail")
btn_set_save, set_status = gr.Button("Speichern", variant="primary", elem_classes="btn-ic-save"), gr.Textbox(label="Status", visible=False)
btn_show_pw = gr.Button("Passwort", variant="secondary", elem_id="b_show_pw")
with gr.Column(visible=False) as konto_pw_box:
old_pw_f = gr.Textbox(label="Alt", type="password"); new_pw_f = gr.Textbox(label="Neu", type="password")
btn_pw_save, pw_status = gr.Button("Ändern", elem_id="b_pw_save"), gr.Textbox(label="Status", visible=False)
btn_show_del = gr.Button("Konto löschen", variant="secondary", elem_id="b_show_del")
with gr.Column(visible=False) as konto_del_box:
del_pw_f = gr.Textbox(label="Passwort", type="password")
btn_del_account, del_status = gr.Button("Löschen", variant="stop", elem_id="b_del_account"), gr.Textbox(label="Status", visible=False)
with gr.Column(visible=False) as konto_tab_admin:
with gr.Column(visible=False) as admin_stats:
admin_counter = gr.HTML(value="Lade Statistiken...")
btn_test_email, test_email_status = gr.Button("Test-E-Mail an mich selbst senden", variant="secondary", visible=False, elem_id="b_test_email"), gr.Textbox(label="Status", visible=False)
btn_futter_cron_test, futter_cron_status = gr.Button("Futter-Check jetzt manuell auslösen (alle Konten)", variant="secondary", visible=False, elem_id="b_futter_cron_test"), gr.Textbox(label="Status", visible=False)
btn_show_news = gr.Button("News veröffentlichen", variant="secondary", visible=False, elem_id="b_show_news")
with gr.Column(visible=False) as admin_news_box:
news_text = gr.Textbox(label="Update-Nachricht")
news_per_email_cb = gr.Checkbox(label="📧 Auch per E-Mail an alle Nutzer mit hinterlegter E-Mail-Adresse senden", value=False)
btn_news, news_status = gr.Button("Veröffentlichen", variant="primary", elem_id="b_news_veroeffentlichen"), gr.Textbox(label="Status", visible=False)
btn_show_users = gr.Button("Nutzerverwaltung", variant="secondary", visible=False, elem_id="b_show_users")
with gr.Column(visible=False) as admin_users_box:
nutzerliste_html = gr.HTML(value="")
with gr.Group():
gr.Markdown("**Nutzer sperren**")
sperren_dd = gr.Dropdown(label="Aktiver Nutzer", choices=["Keine aktiven Nutzer"])
btn_sperren, sperr_status = gr.Button("Sperren", variant="stop", elem_id="b_sperren"), gr.Textbox(label="Status", visible=False)
with gr.Group():
gr.Markdown("**Nutzer entsperren**")
entsperren_dd = gr.Dropdown(label="Gesperrter Nutzer", choices=["Keine gesperrten Nutzer"])
btn_entsperren, entsperr_status = gr.Button("Entsperren", variant="primary", elem_classes="btn-ic-check"), gr.Textbox(label="Status", visible=False)
with gr.Group(visible=False) as admin_ernennen_box:
gr.Markdown("**Admin-Status vergeben** (nur Hauptadmin)")
admin_vergeben_dd = gr.Dropdown(label="Nutzer ohne Admin-Status", choices=["Keine passenden Nutzer"])
btn_admin_vergeben, admin_vergeben_status = gr.Button("Zum Admin machen", variant="primary", elem_id="b_admin_vergeben"), gr.Textbox(label="Status", visible=False)
gr.Markdown("**Admin-Status entziehen** (nur Hauptadmin)")
admin_entziehen_dd = gr.Dropdown(label="Aktueller Admin", choices=["Keine weiteren Admins"])
btn_admin_entziehen, admin_entziehen_status = gr.Button("Admin-Status entziehen", variant="stop", elem_id="b_admin_entziehen"), gr.Textbox(label="Status", visible=False)
btn_show_feedback_liste = gr.Button("Feedback-Übersicht", variant="secondary", visible=False, elem_id="b_show_feedback_liste")
with gr.Column(visible=False) as admin_feedback_liste_box:
feedback_liste_html = gr.HTML(value="")
with gr.Group():
gr.Markdown("**Als erledigt markieren** (löscht den Eintrag dauerhaft aus Liste & Sheet)")
feedback_loeschen_dd = gr.Dropdown(label="Feedback-Eintrag", choices=[])
btn_feedback_loeschen, feedback_loeschen_status = gr.Button("Als erledigt markieren & löschen", variant="stop", elem_classes="btn-ic-check"), gr.Textbox(label="Status", visible=False)
gr.Markdown("<br>")
with gr.Row(elem_id="version_footer_btn"):
btn_versionshistorie = gr.Button(f"PetBase App v{APP_VERSION} · Änderungshistorie ansehen")
with gr.Row(elem_id="acc_top_row", elem_classes="zentrierte_reihe"):
btn_logout = gr.Button("Abmelden", size="sm", elem_id="b_logout")
# ============ Popup-Flyout für die 6 Icon (v7.92.0, Schritt 3 des
# App-Umbaus): jedes Icon springt direkt in eine kleine Auswahl, statt
# erst eine große Zwischenseite zu zeigen. Bei "Tiere" öffnet ein Tier in
# der Liste eine zweite kleine Ebene zur Auswahl des Unterbereichs
# (Stammdaten/Fütterung/...) - danach schließt sich das Popup und man
# landet direkt im gewünschten Unterbereich, nie auf einer Gruppen- oder
# Haupt-Seite. nav_popup_state kodiert, was gerade angezeigt wird:
# "" = geschlossen, "tiere"/"tagebuch"/"futter"/"konto"/"menu" = jeweilige
# Liste, "tiere:<Name>" = zweite Ebene (Unterbereichs-Auswahl für dieses
# Tier). ============
nav_popup_state = gr.State("")
with gr.Column(visible=False, elem_id="nav_popup_box") as nav_popup_box:
@gr.render(inputs=[nav_popup_state, current_zoo])
def render_nav_popup(state, zoo_name):
render_nav_popup_inhalt(state, zoo_name)
# ============ Feste Navigationsleiste unten - "Start" springt direkt,
# die übrigen 5 Icons öffnen seit v7.92.0 erst das kleine Popup-Flyout
# oben (nav_popup_box) mit einer direkten Auswahl statt sofort auf eine
# große Zwischenseite zu springen (siehe _popup_umschalten_fabrik und
# render_nav_popup_inhalt weiter unten für die genaue Logik). ============
with gr.Row(elem_id="bottom_nav_bar"):
btn_nav_start = gr.Button("Start", elem_id="b_nav_start")
btn_nav_tiere = gr.Button("Tiere", elem_id="b_nav_tiere")
btn_nav_futter = gr.Button("Futter", elem_id="b_nav_futter")
btn_nav_tagebuch = gr.Button("Tagebuch", elem_id="b_nav_tagebuch")
btn_nav_konto = gr.Button("Konto", elem_id="b_nav_konto")
btn_nav_menu = gr.Button("Sonstiges", elem_id="b_nav_menu")
with gr.Column(visible=False) as versionshistorie_overlay:
with gr.Column(elem_id="versionshistorie_backdrop"):
with gr.Column(elem_id="versionshistorie_card"):
versionshistorie_anzahl_state = gr.State(15)
versionshistorie_md = gr.Markdown(baue_versionshistorie_text(15)[0])
btn_versionshistorie_mehr = gr.Button("Weitere Versionen laden", size="sm", visible=(len(VERSION_HISTORY) > 15), elem_id="b_versionshistorie_mehr")
with gr.Row(elem_id="versionshistorie_close"):
btn_versionshistorie_schliessen = gr.Button("Zurück", elem_classes="btn-ic-cancel")
# ==========================================
# 8. EVENT ROUTINES & AUTO-LOGIN
# ==========================================
a_grund.change(lambda g: gr.update(visible=(g == "Vermittelt")), inputs=a_grund, outputs=extra_vertrag_row)
btn_fb.click(submit_feedback, inputs=[current_zoo, fb_thema, fb_text], outputs=fb_status).then(zeige_als_toast, inputs=fb_status, outputs=None)
def _login_reg_umschalten(ziel):
return gr.update(visible=(ziel == "login")), gr.update(visible=(ziel == "reg")), gr.update(variant="primary" if ziel == "login" else "secondary"), gr.update(variant="primary" if ziel == "reg" else "secondary")
btn_login_tab.click(lambda: _login_reg_umschalten("login"), inputs=None, outputs=[login_tab_box, reg_tab_box, btn_login_tab, btn_reg_tab], show_progress="hidden")
btn_reg_tab.click(lambda: _login_reg_umschalten("reg"), inputs=None, outputs=[login_tab_box, reg_tab_box, btn_login_tab, btn_reg_tab], show_progress="hidden")
reg_btn.click(register_user, inputs=[reg_name, reg_pw, reg_email], outputs=[reg_status, login_name, login_tab_box, reg_tab_box, btn_login_tab, btn_reg_tab], show_progress="hidden").then(zeige_als_toast, inputs=reg_status, outputs=None)
btn_pw_save.click(change_password, inputs=[current_zoo, old_pw_f, new_pw_f], outputs=pw_status).then(zeige_als_toast, inputs=pw_status, outputs=None)
btn_del_account.click(delete_account, inputs=[current_zoo, del_pw_f], outputs=[del_status, login_screen, main_app, current_zoo]).then(zeige_als_toast, inputs=del_status, outputs=None)
btn_news.click(publish_news, inputs=[current_zoo, news_text, news_per_email_cb], outputs=[news_status]).then(zeige_als_toast, inputs=news_status, outputs=None)
dropdown_outs = [sperren_dd, entsperren_dd, admin_vergeben_dd, admin_entziehen_dd]
btn_sperren.click(nutzer_sperren, inputs=[current_zoo, sperren_dd], outputs=[sperr_status, nutzerliste_html] + dropdown_outs).then(zeige_als_toast, inputs=sperr_status, outputs=None)
btn_entsperren.click(nutzer_entsperren, inputs=[current_zoo, entsperren_dd], outputs=[entsperr_status, nutzerliste_html] + dropdown_outs).then(zeige_als_toast, inputs=entsperr_status, outputs=None)
btn_admin_vergeben.click(admin_ernennen, inputs=[current_zoo, admin_vergeben_dd], outputs=[admin_vergeben_status, nutzerliste_html] + dropdown_outs).then(zeige_als_toast, inputs=admin_vergeben_status, outputs=None)
btn_admin_entziehen.click(admin_entziehen, inputs=[current_zoo, admin_entziehen_dd], outputs=[admin_entziehen_status, nutzerliste_html] + dropdown_outs).then(zeige_als_toast, inputs=admin_entziehen_status, outputs=None)
btn_test_email.click(test_email_senden, inputs=current_zoo, outputs=test_email_status, show_progress="hidden").then(zeige_als_toast, inputs=test_email_status, outputs=None)
btn_futter_cron_test.click(pruefe_und_sende_futter_erinnerungen, inputs=None, outputs=futter_cron_status, show_progress="hidden").then(zeige_als_toast, inputs=futter_cron_status, outputs=None)
ui_outs = [filter_accordion, kat_f, row_pdf_foster, extra_vertrag_row, tier_dropdown, move_dropdown, sel_pdf_own, sel_pdf_foster, sel_pdf_archiv, typ_f, trock_f, edit_f_dd, dash_aufstock_dd, doc_titel_md, doc_type_select, vermisst_tier_dd, nach_aktiv_dd, nach_archiv_dd, btn_bereich_archiv, btn_bereich_pdf, btn_bereich_nachtrag]
def check_saved_login(zoo):
if zoo and str(zoo).strip():
now = datetime.now().strftime("%d.%m.%Y %H:%M")
ist_erster_login = False
zeige_overlay = False
onboarding_text_update = gr.update()
try:
rows = ws_users.get_all_values()
for i, r in enumerate(rows):
if r[0] == zoo:
while len(r) < 6: r.append("")
ist_erster_login = str(r[4]).strip() in ("", "0")
gesehene_version = str(r[5]).strip()
if ist_erster_login:
zeige_overlay = True
# Standard-Onboarding-Text bleibt wie definiert (keine Änderung nötig)
elif gesehene_version != APP_VERSION:
zeige_overlay = True
onboarding_text_update = gr.update(value=CHANGELOG_TEXT)
ws_users.update_cell(i + 1, 4, now)
try: count = int(r[4]) + 1 if str(r[4]).isdigit() else 1
except: count = 1
ws_users.update_cell(i + 1, 5, count)
ws_users.update_cell(i + 1, 6, APP_VERSION)
break
invalidate_cache()
except: pass
acc_text = f"<span class='msr'>account_circle</span> Eingeloggt als **{zoo}**"
onboarding_update = gr.update(visible=zeige_overlay)
try:
unread = get_unread_news(zoo)
news_msg = "### 📢 Neue Updates:\n" + "\n".join([f"- {n}" for n in unread]) if unread else ""
is_admin = ist_admin(zoo)
is_superadmin = str(zoo).strip().lower() == "petbase"
stats_html = get_admin_stats() if is_admin else ""
nutzerliste = erstelle_nutzerliste() if is_admin else ""
ui = init_ui(zoo)
return [gr.update(visible=False), gr.update(visible=True)] + ui + [lade_einstellungen(zoo), lade_modus(zoo), erstelle_dashboard(zoo), news_msg, gr.update(visible=is_admin), gr.update(visible=is_admin), gr.update(visible=is_admin), stats_html, acc_text, onboarding_update, onboarding_text_update, gr.update(visible=is_admin), nutzerliste, gr.update(visible=is_superadmin), gr.update(visible=is_admin), gr.update(visible=is_admin), gr.update(visible=is_superadmin), gr.update(visible=is_admin), gr.update(visible=is_admin), erstelle_bestand_karte(zoo), erstelle_anstehend_karte(zoo), erstelle_bestand_karte(zoo), erstelle_anstehend_karte(zoo)]
except Exception:
return [gr.update(visible=False), gr.update(visible=True)] + [gr.update()]*21 + [gr.update(), gr.update(), "<i>⚠️ Beim Laden ist etwas schiefgelaufen. Bitte oben auf '🔄 Neu laden' tippen.</i>", "", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), "", acc_text, onboarding_update, onboarding_text_update, gr.update(visible=False), "", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(), gr.update(), gr.update(), gr.update()]
return [gr.update(visible=True), gr.update(visible=False)] + [gr.update()]*21 + [gr.update(), gr.update(), "<i>Bitte logge dich ein.</i>", "", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), "", "<span class='msr'>account_circle</span> Nicht eingeloggt", gr.update(visible=False), gr.update(), gr.update(visible=False), "", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(), gr.update(), gr.update(), gr.update()]
login_outs = [login_screen, main_app] + ui_outs + [set_email, modus_cb, dash_html, news_display, admin_news_box, btn_show_news, admin_stats, admin_counter, acc_status_md, onboarding_overlay, onboarding_content, btn_show_users, nutzerliste_html, admin_ernennen_box, btn_konto_tab_admin, btn_test_email, btn_futter_cron_test, btn_show_feedback_liste, apotheke_admin_box, bestand_karte_html, anstehend_karte_html, start_bestand_html, start_anstehend_html]
def zoo_erkannt(zoo, pref):
checkbox_val = (pref != "nein")
if zoo and str(zoo).strip() and pref != "nein":
return gr.update(visible=True), gr.update(value=f"➡️ Weiter als {zoo}"), gr.update(value=zoo), gr.update(value=checkbox_val)
return gr.update(visible=False), gr.update(), gr.update(value=zoo if zoo else ""), gr.update(value=checkbox_val)
demo.load(lambda: None, inputs=None, outputs=None)
demo.load(zoo_erkannt, inputs=[current_zoo, auto_login_pref], outputs=[quick_row, quick_btn, login_name, pref_checkbox])
pref_checkbox.change(lambda v: "ja" if v else "nein", inputs=pref_checkbox, outputs=auto_login_pref)
impressum_sichtbar_state = gr.State(False)
def toggle_impressum(sichtbar):
neu = not sichtbar
return gr.update(visible=neu), neu
btn_show_impressum.click(toggle_impressum, inputs=impressum_sichtbar_state, outputs=[impressum_box, impressum_sichtbar_state], show_progress="hidden")
login_btn.click(login_user, inputs=[login_name, login_pw], outputs=[login_status, current_zoo]).then(zeige_als_toast, inputs=login_status, outputs=None).then(check_saved_login, inputs=[current_zoo], outputs=login_outs)
quick_btn.click(check_saved_login, inputs=[current_zoo], outputs=login_outs)
def logout():
return "", gr.update(visible=True), gr.update(visible=False), "<span class='msr'>account_circle</span> Nicht eingeloggt"
btn_logout.click(logout, inputs=None, outputs=[current_zoo, login_screen, main_app, acc_status_md])
onboarding_close_btn.click(lambda: {onboarding_overlay: gr.update(visible=False)}, inputs=None, outputs=onboarding_overlay)
def _versionshistorie_oeffnen():
text, rest = baue_versionshistorie_text(15)
return gr.update(visible=True), text, gr.update(visible=rest > 0), 15
btn_versionshistorie.click(_versionshistorie_oeffnen, inputs=None, outputs=[versionshistorie_overlay, versionshistorie_md, btn_versionshistorie_mehr, versionshistorie_anzahl_state])
def _versionshistorie_mehr_laden(anzahl):
neue_anzahl = anzahl + 15
text, rest = baue_versionshistorie_text(neue_anzahl)
return text, gr.update(visible=rest > 0), neue_anzahl
btn_versionshistorie_mehr.click(_versionshistorie_mehr_laden, inputs=versionshistorie_anzahl_state, outputs=[versionshistorie_md, btn_versionshistorie_mehr, versionshistorie_anzahl_state], show_progress="hidden")
btn_versionshistorie_schliessen.click(lambda: {versionshistorie_overlay: gr.update(visible=False)}, inputs=None, outputs=versionshistorie_overlay)
modus_cb.change(change_modus, inputs=[current_zoo, modus_cb], outputs=ui_outs)
def zeige_pflege_upload(k, m):
return gr.update(visible=(k == "Pflegetier" and bool(m)))
kat_f.change(zeige_pflege_upload, inputs=[kat_f, modus_cb], outputs=row_pflege_upload)
modus_cb.change(zeige_pflege_upload, inputs=[kat_f, modus_cb], outputs=row_pflege_upload)
def zeige_tagebuch_button(m):
# Dauerhaft ausgeblendet (v7.92.4) - Tagebuch/Beobachtungen ist über die
# eigene Tagebuch-Navigation unten längst erreichbar, dieser Button in
# den Stammdaten war doppelt und wurde nicht mehr gebraucht.
return gr.update(visible=False)
pflege_vertrag_neu.change(lambda f: gr.update(value=f), inputs=pflege_vertrag_neu, outputs=vp_img)
konto_toggle_state = gr.State("")
def toggle_konto_box(ziel, aktuell):
neu = "" if aktuell == ziel else ziel
boxen = {"news": admin_news_box, "users": admin_users_box, "fb": konto_fb_box, "erinn": konto_erinn_box, "pw": konto_pw_box, "del": konto_del_box, "feedback_liste": admin_feedback_liste_box}
updates = {box: gr.update(visible=(key == neu)) for key, box in boxen.items()}
updates[konto_toggle_state] = neu
return updates
konto_toggle_outs = [konto_toggle_state, admin_news_box, admin_users_box, konto_fb_box, konto_erinn_box, konto_pw_box, konto_del_box, admin_feedback_liste_box]
_konto_toggle_events = []
_e = btn_show_news.click(lambda a: toggle_konto_box("news", a), inputs=konto_toggle_state, outputs=konto_toggle_outs, cancels=_konto_toggle_events.copy())
_konto_toggle_events.append(_e)
_e = btn_show_users.click(lambda a: toggle_konto_box("users", a), inputs=konto_toggle_state, outputs=konto_toggle_outs, cancels=_konto_toggle_events.copy()).then(lambda: (erstelle_nutzerliste(),) + nutzer_dropdowns_update(), inputs=None, outputs=[nutzerliste_html] + dropdown_outs)
_konto_toggle_events.append(_e)
_e = btn_show_fb.click(lambda a: toggle_konto_box("fb", a), inputs=konto_toggle_state, outputs=konto_toggle_outs, cancels=_konto_toggle_events.copy())
_konto_toggle_events.append(_e)
_e = btn_show_erinn.click(lambda a: toggle_konto_box("erinn", a), inputs=konto_toggle_state, outputs=konto_toggle_outs, cancels=_konto_toggle_events.copy())
_konto_toggle_events.append(_e)
_e = btn_show_pw.click(lambda a: toggle_konto_box("pw", a), inputs=konto_toggle_state, outputs=konto_toggle_outs, cancels=_konto_toggle_events.copy())
_konto_toggle_events.append(_e)
_e = btn_show_del.click(lambda a: toggle_konto_box("del", a), inputs=konto_toggle_state, outputs=konto_toggle_outs, cancels=_konto_toggle_events.copy())
_konto_toggle_events.append(_e)
_e = btn_show_feedback_liste.click(lambda a: toggle_konto_box("feedback_liste", a), inputs=konto_toggle_state, outputs=konto_toggle_outs, cancels=_konto_toggle_events.copy()).then(lambda: (erstelle_feedback_liste(), gr.update(choices=erstelle_feedback_dropdown_choices(), value=None)), inputs=None, outputs=[feedback_liste_html, feedback_loeschen_dd], show_progress="hidden")
_konto_toggle_events.append(_e)
btn_feedback_loeschen.click(feedback_als_erledigt, inputs=feedback_loeschen_dd, outputs=[feedback_loeschen_status, feedback_liste_html, feedback_loeschen_dd], show_progress="hidden").then(zeige_als_toast, inputs=feedback_loeschen_status, outputs=None)
HAUPT_MAP = {"start": (haupt_start, btn_haupt_start), "verwaltung": (haupt_verwaltung, btn_haupt_verwaltung), "vermisst": (haupt_vermisst, btn_haupt_vermisst), "konto": (haupt_konto, btn_haupt_konto)}
def zeige_haupt(ziel):
_force = f"force-{datetime.now().timestamp()}"
updates = {}
for key, (col, btn) in HAUPT_MAP.items():
updates[col] = gr.update(visible=(key == ziel), elem_classes=[_force] if key == ziel else [])
updates[btn] = gr.update(variant="primary" if key == ziel else "secondary")
return updates
haupt_outs = [c for pair in HAUPT_MAP.values() for c in pair]
DETAIL_MAP = {"stam": (acc_stam, btn_show_stam), "futter": (acc_futter, btn_show_futter), "gesund": (acc_gesund, btn_show_gesund), "doku": (doc_accordion, btn_show_doku), "tagebuch": (acc_tagebuch, btn_show_tagebuch)}
def zeige_detail(ziel):
_force = f"force-{datetime.now().timestamp()}"
updates = {}
for key, (col, btn) in DETAIL_MAP.items():
updates[col] = gr.update(visible=(key == ziel), elem_classes=[_force] if key == ziel else [])
updates[btn] = gr.update(variant="primary" if key == ziel else "secondary")
return updates
detail_outs = [c for pair in DETAIL_MAP.values() for c in pair]
_detail_events = []
KONTO_TAB_MAP = {"einst": (konto_tab_einstellungen, btn_konto_tab_einst), "admin": (konto_tab_admin, btn_konto_tab_admin)}
def zeige_konto_tab(ziel):
updates = {}
for key, (col, btn) in KONTO_TAB_MAP.items():
updates[col] = gr.update(visible=(key == ziel))
updates[btn] = gr.update(variant="primary" if key == ziel else "secondary")
return updates
konto_tab_outs = [c for pair in KONTO_TAB_MAP.values() for c in pair]
_konto_tab_events = []
_e = btn_konto_tab_einst.click(lambda: zeige_konto_tab("einst"), inputs=None, outputs=konto_tab_outs, cancels=_konto_tab_events.copy())
_konto_tab_events.append(_e)
_e = btn_konto_tab_admin.click(lambda: zeige_konto_tab("admin"), inputs=None, outputs=konto_tab_outs, cancels=_konto_tab_events.copy())
_konto_tab_events.append(_e)
# Tier laden/speichern zuerst registrieren, damit die Kategorie-Buttons unten
# eine noch laufende, überholte Antwort davon zuverlässig abbrechen können.
t_ins = [current_zoo, tier_dropdown]
t_o1 = [name_f, kat_f, tierart_f, rasse_f, geb_datum_f, farbe_f, kastriert_f, gew_f, chip_f, tasso_f, kontakt_tel_f, kontakt_tel2_f]
t_o2 = [barf_cb, trock_f, tf_phase_hinweis, typ_f, gramm_f, prot_f, kohl_f, nass_cb, nass_f, nass_gramm_f, impf_f, wurm_f, para_f]
t_o3 = [para_int_f, tf_gramm_f, profil_img, hidden_img_data, allergien_f, vp_img, vp_h, vv_img, vv_h, t1_img, t1_h]
t_o4 = [t2_img, t2_h, t3_img, t3_h, t4_img, t4_h, t5_img, t5_h, t6_img, t6_h, acc_stam, tier_inhalt_wrapper, group_trocken, group_barf, group_nass, bestand_karte_html, anstehend_karte_html]
# WICHTIG: .select() statt .change()! tier_dropdown.change() würde auch dann
# feuern, wenn save_data() den Dropdown-Wert NACH dem Speichern selbst
# programmatisch setzt (v7.73.12: choices+value zusammen gesetzt) - das hätte
# eine ZWEITE, parallele load_tier()-Kette ausgelöst, die den gerade laufenden
# Speicher-Vorgang störte, bevor die "Gespeichert!"-Meldung sichtbar wurde
# (vermutliche Ursache für spurlos verschwindende neu angelegte Tiere).
# .select() feuert dagegen NUR bei echter Nutzer-Auswahl aus der Liste, nicht
# bei per Code gesetzten Werten - tier_dropdown erlaubt ohnehin keine freie
# Texteingabe (kein allow_custom_value), daher deckt .select() jede echte
# Nutzerinteraktion zuverlässig ab.
_e = tier_dropdown.select(load_tier, inputs=t_ins, outputs=t_o1 + t_o2 + t_o3 + t_o4, show_progress="hidden").then(lambda: zeige_detail("stam"), inputs=None, outputs=detail_outs, cancels=_detail_events.copy())
# Neue Tier-Auswahl-Liste (v7.91.7, ersetzt das Dropdown): ein Klick auf ein
# Tier in der Liste soll direkt zu dessen Stammdaten springen - gleiches
# Grundmuster wie bei der Tagebuch-Übersicht (oeffne_tier_tagebuch_fabrik),
# nur landet es hier auf "stam" statt "tagebuch" und die sichtbare Bereich
# ("Tiere") ändert sich dabei nicht, nur der ausgewählte Tier-Inhalt selbst.
def oeffne_tier_stam_fabrik(tier_name, ziel_tab="stam"):
def _oeffnen(zoo_name):
d = {}
d.update(zeige_haupt("verwaltung"))
d.update(zeige_bereich("tiere"))
d[bestand_karte_html] = erstelle_bestand_karte(zoo_name)
d[anstehend_karte_html] = erstelle_anstehend_karte(zoo_name)
d[tier_dropdown] = gr.update(value=tier_name)
tier_werte = load_tier(zoo_name, tier_name)
d.update(dict(zip(t_o1 + t_o2 + t_o3 + t_o4, tier_werte)))
d.update(zeige_detail(ziel_tab))
return d
return _oeffnen
def _neues_tier_oeffnen(zoo_name):
d = {}
d.update(zeige_haupt("verwaltung"))
d.update(zeige_bereich("tiere"))
d[bestand_karte_html] = erstelle_bestand_karte(zoo_name)
d[anstehend_karte_html] = erstelle_anstehend_karte(zoo_name)
d[tier_dropdown] = gr.update(value="Neues Tier...")
tier_werte = load_tier(zoo_name, "Neues Tier...")
d.update(dict(zip(t_o1 + t_o2 + t_o3 + t_o4, tier_werte)))
d.update(zeige_detail("stam"))
return d
_detail_events.append(_e)
sv_ins1 = [current_zoo, tier_dropdown, name_f, kat_f, tierart_f, rasse_f, geb_datum_f, farbe_f, kastriert_f, gew_f, chip_f]
sv_ins2 = [tasso_f, kontakt_tel_f, kontakt_tel2_f, barf_cb, trock_f, typ_f, gramm_f, prot_f, kohl_f, nass_cb, nass_f, nass_gramm_f, impf_f, wurm_f, para_f, para_int_f]
sv_ins3 = [tf_gramm_f, profil_img, hidden_img_data, vp_img, vp_h, vv_img, vv_h, t1_img, t1_h, t2_img, t2_h]
sv_ins4 = [t3_img, t3_h, t4_img, t4_h, t5_img, t5_h, t6_img, t6_h, allergien_f]
sv_outs = [status, tier_dropdown, move_dropdown, sel_pdf_own, sel_pdf_foster, sel_pdf_archiv, profil_img, hidden_img_data, acc_stam, dash_html]
_e = save_btn.click(save_data, inputs=sv_ins1 + sv_ins2 + sv_ins3 + sv_ins4, outputs=sv_outs, show_progress="hidden").then(zeige_als_toast, inputs=status, outputs=None).then(lambda: zeige_detail("stam"), inputs=None, outputs=detail_outs, cancels=_detail_events.copy())
_detail_events.append(_e)
_e = btn_show_stam.click(lambda: zeige_detail("stam"), inputs=None, outputs=detail_outs, cancels=_detail_events.copy())
_detail_events.append(_e)
_e = btn_show_futter.click(lambda: zeige_detail("futter"), inputs=None, outputs=detail_outs, cancels=_detail_events.copy())
_detail_events.append(_e)
_e = btn_show_gesund.click(lambda: zeige_detail("gesund"), inputs=None, outputs=detail_outs, cancels=_detail_events.copy())
_detail_events.append(_e)
_e = btn_show_doku.click(lambda: zeige_detail("doku"), inputs=None, outputs=detail_outs, cancels=_detail_events.copy())
_detail_events.append(_e)
_e = btn_tagebuch_oeffnen.click(lambda: zeige_detail("tagebuch"), inputs=None, outputs=detail_outs, cancels=_detail_events.copy()).then(lambda rz: (8, (rz or 0) + 1, None), inputs=tagebuch_refresh_zaehler, outputs=[tagebuch_anzahl_state, tagebuch_refresh_zaehler, tagebuch_editier_zeile_state], show_progress="hidden")
_detail_events.append(_e)
modus_cb.change(zeige_tagebuch_button, inputs=modus_cb, outputs=btn_tagebuch_oeffnen).then(lambda: zeige_detail("stam"), inputs=None, outputs=detail_outs, cancels=_detail_events.copy())
BEREICH_MAP = {"tiere": (sektion_tiere, btn_bereich_tiere), "scanner": (sektion_scanner, btn_bereich_scanner), "archiv": (sektion_archiv, btn_bereich_archiv), "inventar": (sektion_inventar, btn_bereich_inventar), "apotheke": (sektion_apotheke, btn_bereich_apotheke), "pdf": (sektion_pdf_export, btn_bereich_pdf), "nachtrag": (sektion_nachtrag, btn_bereich_nachtrag), "tagebuch_uebersicht": (sektion_tagebuch_uebersicht, btn_bereich_tagebuch)}
def zeige_bereich(ziel):
_force = f"force-{datetime.now().timestamp()}"
updates = {}
for key, (col, btn) in BEREICH_MAP.items():
updates[col] = gr.update(visible=(key == ziel), elem_classes=[_force] if key == ziel else [])
updates[btn] = gr.update(variant="primary" if key == ziel else "secondary")
return updates
bereich_outs = [c for pair in BEREICH_MAP.values() for c in pair]
doc_type_select.change(on_doc_type_change, inputs=[doc_type_select, modus_cb], outputs=[col_vp, col_vv, col_t1, col_t2, col_t3, col_t4, col_t5, col_t6])
tier_filter.change(filter_tiere, inputs=[current_zoo, tier_filter], outputs=tier_dropdown)
tierart_f.change(filter_futter_nach_tierart_ui, inputs=[current_zoo, tierart_f], outputs=trock_f, show_progress="hidden")
tierart_f.change(filter_nassfutter_nach_tierart_ui, inputs=[current_zoo, tierart_f], outputs=nass_f, show_progress="hidden")
tierart_f.change(toggle_futterarten_verfuegbarkeit, inputs=[tierart_f, barf_cb, nass_cb], outputs=[barf_cb, group_trocken, group_barf, nass_cb, group_nass], show_progress="hidden")
tierart_f.change(toggle_id_felder, inputs=tierart_f, outputs=[kastriert_f, chip_f, tasso_f], show_progress="hidden")
s_outs = [scan_bc, scan_hersteller, scan_sorte, scan_gewicht, scan_phase, scan_k1, scan_g1, scan_k2, scan_g2, scan_k3, scan_g3, scan_k4, scan_g4, scan_raw]
btn_scan.click(scanne_und_suche, inputs=[futter_bild_1, futter_bild_2, futter_bild_3], outputs=s_outs)
def toggle_scan_bilder(offen):
neu = not offen
return {scan_bild_offen: neu, scan_bild_gruppe: gr.update(visible=neu)}
btn_scan_toggle.click(toggle_scan_bilder, inputs=scan_bild_offen, outputs=[scan_bild_offen, scan_bild_gruppe])
futter_slot_outs = [x for i in range(FUTTER_SLOT_ANZAHL) for x in (futter_slot_rows[i], futter_slot_labels[i], futter_slot_sorten[i])]
def reset_alle_untertabs():
updates = {}
updates.update(zeige_detail("stam"))
updates.update(zeige_konto_tab("einst"))
for _i in range(FUTTER_SLOT_ANZAHL):
updates[futter_slot_aufstock_rows[_i]] = gr.update(visible=False)
updates[futter_slot_edit_rows[_i]] = gr.update(visible=False)
updates[futter_slot_mehr_rows[_i]] = gr.update(visible=False)
updates[konto_toggle_state] = ""
updates[admin_news_box] = gr.update(visible=False)
updates[admin_users_box] = gr.update(visible=False)
updates[konto_fb_box] = gr.update(visible=False)
updates[konto_erinn_box] = gr.update(visible=False)
updates[konto_pw_box] = gr.update(visible=False)
updates[konto_del_box] = gr.update(visible=False)
return updates
reset_untertab_outs = (detail_outs + konto_tab_outs
+ futter_slot_aufstock_rows + futter_slot_edit_rows + futter_slot_mehr_rows
+ konto_toggle_outs)
def haupt_klick_kombiniert(ziel):
d = {}
d.update(zeige_haupt(ziel))
d.update(reset_alle_untertabs())
return d
haupt_combo_outs = haupt_outs + reset_untertab_outs
tier_liste_sprung_outs = haupt_combo_outs + bereich_outs + [bestand_karte_html, anstehend_karte_html, tier_dropdown] + t_o1 + t_o2 + t_o3 + t_o4 + detail_outs
_haupt_events = []
def haupt_verwaltung_klick_kombiniert(zoo_name):
d = haupt_klick_kombiniert("verwaltung")
d.update(zeige_bereich("tiere"))
tier_werte = load_tier(zoo_name, "Hier Auswählen")
d.update(dict(zip(t_o1 + t_o2 + t_o3 + t_o4, tier_werte)))
d[tier_dropdown] = gr.update(value="Hier Auswählen")
return d
_e = btn_haupt_verwaltung.click(haupt_verwaltung_klick_kombiniert, inputs=current_zoo, outputs=haupt_combo_outs + bereich_outs + [tier_dropdown] + t_o1 + t_o2 + t_o3 + t_o4, cancels=_haupt_events.copy())
_haupt_events.append(_e)
def start_klick_kombiniert(zoo_name):
d = haupt_klick_kombiniert("start")
d[start_bestand_html] = erstelle_bestand_karte(zoo_name)
d[start_anstehend_html] = erstelle_anstehend_karte(zoo_name)
return d
_e = btn_haupt_start.click(start_klick_kombiniert, inputs=current_zoo, outputs=haupt_combo_outs + [start_bestand_html, start_anstehend_html], cancels=_haupt_events.copy())
_haupt_events.append(_e)
_e = btn_haupt_vermisst.click(lambda: haupt_klick_kombiniert("vermisst"), inputs=None, outputs=haupt_combo_outs, cancels=_haupt_events.copy())
_haupt_events.append(_e)
_e = btn_haupt_konto.click(lambda: haupt_klick_kombiniert("konto"), inputs=None, outputs=haupt_combo_outs, cancels=_haupt_events.copy())
_haupt_events.append(_e)
_e = btn_start_vermisst.click(lambda: haupt_klick_kombiniert("vermisst"), inputs=None, outputs=haupt_combo_outs, cancels=_haupt_events.copy())
_haupt_events.append(_e)
def bereich_klick_kombiniert(ziel):
d = {}
d.update(zeige_bereich(ziel))
d.update(reset_alle_untertabs())
return d
bereich_combo_outs = bereich_outs + reset_untertab_outs
def bereich_tiere_klick_kombiniert(zoo_name):
d = bereich_klick_kombiniert("tiere")
d[bestand_karte_html] = erstelle_bestand_karte(zoo_name)
d[anstehend_karte_html] = erstelle_anstehend_karte(zoo_name)
return d
def bereich_inventar_klick_kombiniert(zoo_name):
d = bereich_klick_kombiniert("inventar")
d[dash_html] = erstelle_dashboard(zoo_name)
d.update(dict(zip(futter_slot_outs, fuelle_futter_slots(zoo_name))))
return d
# Tagebuch-Übersicht (v7.91.4): ein Klick auf ein Tier dort soll direkt zu
# dessen Tagebuch-Tab springen - kombiniert Bereich-Wechsel (wie
# bereich_tiere_klick_kombiniert), Tier laden (wie tier_dropdown.select) und
# Tab-Wechsel (wie btn_tagebuch_oeffnen.click) in einem Rutsch. Als
# Fabrik-Funktion, damit jede der dynamisch gerenderten Zeilen ihren eigenen
# Tiernamen fest eingebunden bekommt (gleiches Muster wie bei den
# Tagebuch-/Haustierapotheke-Bearbeiten-Buttons).
def oeffne_tier_tagebuch_fabrik(tier_name):
def _oeffnen(zoo_name, rz):
d = bereich_tiere_klick_kombiniert(zoo_name)
d[tier_dropdown] = gr.update(value=tier_name)
tier_werte = load_tier(zoo_name, tier_name)
d.update(dict(zip(t_o1 + t_o2 + t_o3 + t_o4, tier_werte)))
d.update(zeige_detail("tagebuch"))
d[tagebuch_anzahl_state] = 8
d[tagebuch_refresh_zaehler] = (rz or 0) + 1
d[tagebuch_editier_zeile_state] = None
return d
return _oeffnen
tagebuch_uebersicht_sprung_outs = bereich_combo_outs + [bestand_karte_html, anstehend_karte_html, tier_dropdown] + t_o1 + t_o2 + t_o3 + t_o4 + detail_outs + [tagebuch_anzahl_state, tagebuch_refresh_zaehler, tagebuch_editier_zeile_state]
# Untere Navigationsleiste (v7.91.6): "Tiere"/"Futter"/"Tagebuch" bedeuten
# dort jeweils zwei Schritte auf einmal (Haupt-Tab UND Bereich wechseln).
# Das vorher über zwei verkettete Klicks per JS zu lösen war KEIN reines
# Timing-Problem, sondern ein grundsätzlicher Wettlauf zwischen zwei
# UNABHÄNGIGEN Server-Antworten, die teils dieselben Bereiche beschreiben -
# welche zuletzt ankommt, gewinnt, unabhängig von der Wartezeit im Browser.
# Robuste Lösung: EINE einzige kombinierte Funktion pro Ziel, ein einziger
# Server-Rundlauf, kein Wettlauf mehr möglich.
# Diese kombinierte Funktion wird jetzt vom Popup-Flyout genutzt (siehe
# render_nav_popup_inhalt weiter unten) statt von einer direkten
# Bottom-Nav-Verkabelung - "Futter" öffnet seit v7.92.0 erst eine kleine
# Auswahl (Scanner/Vorrat), statt sofort direkt zum Scanner zu springen.
def nav_futter_klick_kombiniert(zoo_name):
d = haupt_klick_kombiniert("verwaltung")
d.update(bereich_klick_kombiniert("scanner"))
return d
nav_futter_outs = haupt_combo_outs + bereich_combo_outs
_bereich_events = []
_e = btn_bereich_tiere.click(bereich_tiere_klick_kombiniert, inputs=current_zoo, outputs=bereich_combo_outs + [bestand_karte_html, anstehend_karte_html], cancels=_bereich_events.copy())
_bereich_events.append(_e)
_e = btn_bereich_scanner.click(lambda: bereich_klick_kombiniert("scanner"), inputs=None, outputs=bereich_combo_outs, cancels=_bereich_events.copy())
_bereich_events.append(_e)
_e = btn_bereich_archiv.click(lambda: bereich_klick_kombiniert("archiv"), inputs=None, outputs=bereich_combo_outs, cancels=_bereich_events.copy())
_bereich_events.append(_e)
_e = btn_bereich_inventar.click(bereich_inventar_klick_kombiniert, inputs=current_zoo, outputs=bereich_combo_outs + [dash_html] + futter_slot_outs, cancels=_bereich_events.copy())
_bereich_events.append(_e)
def bereich_apotheke_klick_kombiniert():
d = bereich_klick_kombiniert("apotheke")
d[apotheke_tierart_filter_dd] = gr.update(choices=["Alle Tierarten"] + lade_apotheke_kategorien())
return d
_e = btn_bereich_apotheke.click(bereich_apotheke_klick_kombiniert, inputs=None, outputs=bereich_combo_outs + [apotheke_tierart_filter_dd], cancels=_bereich_events.copy())
_bereich_events.append(_e)
# Navigations-Umbau (v7.91.7): "Konto", "Mein Profil" und "Mehr" führten alle
# zum selben Ziel (Konto-Einstellungen) - drei verschiedene Beschriftungen für
# dasselbe Ziel ergaben keinen Sinn. "Mein Profil" zeigt jetzt direkt die
# Haustierapotheke, die Konto-Kachel oben zeigt jetzt direkt Futter Vorrat,
# "Verwaltung" ist oben nicht mehr nötig (dafür jetzt "Menü" unten).
def nav_futtervorrat_klick_kombiniert(zoo_name):
d = haupt_klick_kombiniert("verwaltung")
d.update(bereich_inventar_klick_kombiniert(zoo_name))
return d
nav_futtervorrat_outs = haupt_combo_outs + bereich_combo_outs + [dash_html] + futter_slot_outs
def nav_apotheke_klick_kombiniert(zoo_name):
d = haupt_klick_kombiniert("verwaltung")
d.update(bereich_apotheke_klick_kombiniert())
return d
nav_apotheke_outs = haupt_combo_outs + bereich_combo_outs + [apotheke_tierart_filter_dd]
_e = btn_start_konto.click(nav_apotheke_klick_kombiniert, inputs=current_zoo, outputs=nav_apotheke_outs, cancels=_haupt_events.copy())
_haupt_events.append(_e)
_e = btn_haupt_futtervorrat.click(nav_apotheke_klick_kombiniert, inputs=current_zoo, outputs=nav_apotheke_outs, cancels=_haupt_events.copy())
_haupt_events.append(_e)
_e = btn_bereich_pdf.click(lambda: bereich_klick_kombiniert("pdf"), inputs=None, outputs=bereich_combo_outs, cancels=_bereich_events.copy())
_bereich_events.append(_e)
_e = btn_bereich_nachtrag.click(lambda: bereich_klick_kombiniert("nachtrag"), inputs=None, outputs=bereich_combo_outs, cancels=_bereich_events.copy())
_bereich_events.append(_e)
_e = btn_bereich_tagebuch.click(lambda rz: dict(bereich_klick_kombiniert("tagebuch_uebersicht"), **{tb_uebersicht_refresh: (rz or 0) + 1}), inputs=tb_uebersicht_refresh, outputs=bereich_combo_outs + [tb_uebersicht_refresh], cancels=_bereich_events.copy())
_bereich_events.append(_e)
btn_apotheke_speichern.click(apotheke_eintrag_speichern, inputs=[current_zoo, apotheke_neu_kategorie_f, apotheke_neu_titel_f, apotheke_neu_inhalt_f, apotheke_refresh_zaehler], outputs=[apotheke_status, apotheke_neu_kategorie_f, apotheke_neu_titel_f, apotheke_neu_inhalt_f, apotheke_refresh_zaehler, apotheke_offene_kategorie_state], show_progress="hidden").then(zeige_als_toast, inputs=apotheke_status, outputs=None).then(lambda: gr.update(choices=["Alle Tierarten"] + lade_apotheke_kategorien()), inputs=None, outputs=apotheke_tierart_filter_dd, show_progress="hidden")
apotheke_banner_cb.change(apotheke_banner_umschalten, inputs=apotheke_banner_cb, outputs=apotheke_banner_img, show_progress="hidden")
# "Neuen Eintrag hinzufügen"-Formular ausblenden, solange gerade eine
# bestehende Karte inline bearbeitet wird - bessere Übersichtlichkeit für Admins.
apotheke_editier_zeile_state.change(lambda z: gr.update(visible=(z is None)), inputs=apotheke_editier_zeile_state, outputs=apotheke_admin_box, show_progress="hidden")
s_in = [current_zoo, scan_bc, scan_hersteller, scan_sorte, scan_gewicht, scan_phase, scan_tierart, scan_futtertyp, scan_k1, scan_g1, scan_k2, scan_g2, scan_k3, scan_g3, scan_k4, scan_g4]
btn_save_futter.click(speichere_inventar, inputs=s_in, outputs=[scan_status, typ_f, trock_f, edit_f_dd, dash_aufstock_dd], show_progress="hidden").then(zeige_als_toast, inputs=scan_status, outputs=None).then(erstelle_dashboard, inputs=current_zoo, outputs=dash_html).then(fuelle_futter_slots, inputs=current_zoo, outputs=futter_slot_outs)
SCROLL_ZU_DASHBOARD_JS = """
() => {
const el = document.getElementById('dash_html_anchor');
if (el) { el.scrollIntoView({behavior: 'smooth', block: 'start'}); }
}
"""
def oeffne_inline_aufstock(_i):
def fn():
return {futter_slot_aufstock_rows[_i]: gr.update(visible=True), futter_slot_edit_rows[_i]: gr.update(visible=False), futter_slot_mehr_rows[_i]: gr.update(visible=False)}
return fn
for _i in range(FUTTER_SLOT_ANZAHL):
futter_slot_aufstock_btns[_i].click(oeffne_inline_aufstock(_i), inputs=None, outputs=[futter_slot_aufstock_rows[_i], futter_slot_edit_rows[_i], futter_slot_mehr_rows[_i]])
futter_slot_aufstock_close[_i].click(lambda: gr.update(visible=False), inputs=None, outputs=futter_slot_aufstock_rows[_i])
futter_slot_aufstock_confirm[_i].click(inline_aufstocken, inputs=[current_zoo, futter_slot_sorten[_i], futter_slot_aufstock_menge[_i]], outputs=[dash_html] + futter_slot_outs).then(lambda: gr.update(visible=False), inputs=None, outputs=futter_slot_aufstock_rows[_i])
def oeffne_inline_edit(_i):
def fn(zoo_name, sorte):
bestand, sack = hole_bestand_sackgroesse(zoo_name, sorte)
return {futter_slot_edit_rows[_i]: gr.update(visible=True), futter_slot_aufstock_rows[_i]: gr.update(visible=False), futter_slot_mehr_rows[_i]: gr.update(visible=False),
futter_slot_edit_bestand[_i]: bestand, futter_slot_edit_sack[_i]: sack}
return fn
for _i in range(FUTTER_SLOT_ANZAHL):
futter_slot_edit_btns[_i].click(oeffne_inline_edit(_i), inputs=[current_zoo, futter_slot_sorten[_i]], outputs=[futter_slot_edit_rows[_i], futter_slot_aufstock_rows[_i], futter_slot_mehr_rows[_i], futter_slot_edit_bestand[_i], futter_slot_edit_sack[_i]])
futter_slot_edit_close[_i].click(lambda: gr.update(visible=False), inputs=None, outputs=futter_slot_edit_rows[_i])
futter_slot_edit_confirm[_i].click(inline_bestand_setzen, inputs=[current_zoo, futter_slot_sorten[_i], futter_slot_edit_bestand[_i], futter_slot_edit_sack[_i]], outputs=[dash_html] + futter_slot_outs).then(lambda: gr.update(visible=False), inputs=None, outputs=futter_slot_edit_rows[_i])
futter_slot_edit_del[_i].click(inline_loeschen, inputs=[current_zoo, futter_slot_sorten[_i]], outputs=[dash_html] + futter_slot_outs).then(lambda: gr.update(visible=False), inputs=None, outputs=futter_slot_edit_rows[_i])
def oeffne_inline_mehr(_i):
def fn(zoo_name, sorte):
felder = load_futter_edit(zoo_name, sorte)
result = {futter_slot_mehr_rows[_i]: gr.update(visible=True), futter_slot_edit_rows[_i]: gr.update(visible=False), futter_slot_aufstock_rows[_i]: gr.update(visible=False)}
for komponente, wert in zip(futter_slot_mehr_fields[_i], felder):
result[komponente] = wert
return result
return fn
for _i in range(FUTTER_SLOT_ANZAHL):
futter_slot_edit_more[_i].click(oeffne_inline_mehr(_i), inputs=[current_zoo, futter_slot_sorten[_i]], outputs=[futter_slot_mehr_rows[_i], futter_slot_edit_rows[_i], futter_slot_aufstock_rows[_i]] + futter_slot_mehr_fields[_i])
futter_slot_mehr_close[_i].click(lambda: gr.update(visible=False), inputs=None, outputs=futter_slot_mehr_rows[_i])
futter_slot_mehr_speichern[_i].click(inline_mehr_speichern, inputs=[current_zoo, futter_slot_sorten[_i]] + futter_slot_mehr_fields[_i], outputs=[dash_html] + futter_slot_outs).then(lambda: gr.update(visible=False), inputs=None, outputs=futter_slot_mehr_rows[_i])
btn_set_save.click(speichere_einstellungen, inputs=[current_zoo, set_email], outputs=set_status).then(zeige_als_toast, inputs=set_status, outputs=None)
btn_impf.click(get_heute, outputs=impf_f); btn_wurm.click(get_heute, outputs=wurm_f); btn_para.click(get_heute, outputs=para_f)
btn_ics_impf.click(lambda n, d: erstelle_erinnerung(n, d, "Impfung"), [name_f, impf_f], dl_impf, show_progress="hidden")
btn_ics_wurm.click(lambda n, d: erstelle_erinnerung(n, d, "Wurmkur"), [name_f, wurm_f], dl_wurm, show_progress="hidden")
btn_ics_para.click(lambda n, d, i: erstelle_erinnerung(n, d, "Parasitenschutz", i), [name_f, para_f, para_int_f], dl_para, show_progress="hidden")
btn_email_impf.click(lambda z, n, d: erinnerung_per_email(z, n, "Impfung", d), [current_zoo, name_f, impf_f], email_impf_status, show_progress="hidden").then(zeige_als_toast, inputs=email_impf_status, outputs=None)
btn_email_wurm.click(lambda z, n, d: erinnerung_per_email(z, n, "Wurmkur", d), [current_zoo, name_f, wurm_f], email_wurm_status, show_progress="hidden").then(zeige_als_toast, inputs=email_wurm_status, outputs=None)
btn_email_para.click(lambda z, n, d: erinnerung_per_email(z, n, "Parasitenschutz", d), [current_zoo, name_f, para_f], email_para_status, show_progress="hidden").then(zeige_als_toast, inputs=email_para_status, outputs=None)
barf_cb.change(lambda is_barf: {group_trocken: gr.update(visible=not is_barf), group_barf: gr.update(visible=is_barf)}, barf_cb, [group_trocken, group_barf])
nass_cb.change(lambda is_nass: {group_nass: gr.update(visible=is_nass)}, nass_cb, group_nass)
for f in [gew_f, barf_cb, typ_f, tierart_f]: f.change(berechne_barf, [current_zoo, gew_f, barf_cb, typ_f, tierart_f], [gramm_f, prot_f, kohl_f], show_progress="hidden")
gew_f.change(berechne_trockenfutter, [current_zoo, gew_f, trock_f, tf_gramm_f, tierart_f], tf_gramm_f, show_progress="hidden")
trock_f.change(berechne_trockenfutter, [current_zoo, gew_f, trock_f, tf_gramm_f, tierart_f], tf_gramm_f, show_progress="hidden")
trock_f.change(zeige_futter_phase, [current_zoo, trock_f], tf_phase_hinweis, show_progress="hidden")
gew_f.change(berechne_trockenfutter, [current_zoo, gew_f, nass_f, nass_gramm_f, tierart_f], nass_gramm_f, show_progress="hidden")
nass_f.change(berechne_trockenfutter, [current_zoo, gew_f, nass_f, nass_gramm_f, tierart_f], nass_gramm_f, show_progress="hidden")
tierart_f.change(berechne_trockenfutter, [current_zoo, gew_f, trock_f, tf_gramm_f, tierart_f], tf_gramm_f, show_progress="hidden")
tierart_f.change(berechne_trockenfutter, [current_zoo, gew_f, nass_f, nass_gramm_f, tierart_f], nass_gramm_f, show_progress="hidden")
btn_pdf_own.click(lambda z, s, i, p, v, t, tb: export_pdf(z, s, False, i, p, v, t, tb), [current_zoo, sel_pdf_own, pdf_mit_info, pdf_mit_pflege, pdf_mit_vermittlung, pdf_mit_tierarzt, pdf_mit_tagebuch], [pdf_file, pdf_status]).then(zeige_als_toast, inputs=pdf_status, outputs=None)
btn_pdf_foster.click(lambda z, s, i, p, v, t, tb: export_pdf(z, s, False, i, p, v, t, tb), [current_zoo, sel_pdf_foster, pdf_mit_info, pdf_mit_pflege, pdf_mit_vermittlung, pdf_mit_tierarzt, pdf_mit_tagebuch], [pdf_file, pdf_status]).then(zeige_als_toast, inputs=pdf_status, outputs=None)
btn_tagebuch_speichern.click(tagebuch_eintrag_speichern, inputs=[current_zoo, name_f, tagebuch_titel_f, tagebuch_neu_f, tagebuch_foto_f, tagebuch_anzahl_state, tagebuch_refresh_zaehler], outputs=[tagebuch_status, tagebuch_titel_f, tagebuch_neu_f, tagebuch_foto_f, tagebuch_anzahl_state, tagebuch_refresh_zaehler], show_progress="hidden").then(zeige_als_toast, inputs=tagebuch_status, outputs=None)
# "Neuer Eintrag"-Formular (Titel/Text/Foto) ausblenden, solange gerade ein
# bestehender Eintrag inline in seiner Karte bearbeitet wird - vermeidet
# Verwirrung zwischen "neu anlegen" und "bestehenden Eintrag ändern".
tagebuch_editier_zeile_state.change(lambda z: gr.update(visible=(z is None)), inputs=tagebuch_editier_zeile_state, outputs=tagebuch_neu_eintrag_box, show_progress="hidden")
btn_tagebuch_pdf.click(exportiere_tagebuch_pdf, inputs=[current_zoo, name_f], outputs=[tagebuch_pdf_datei, tagebuch_status], show_progress="hidden").then(zeige_als_toast, inputs=tagebuch_status, outputs=None)
btn_vermisst_daten.click(zeige_vermisst_daten, [v_name, v_tierart, v_rasse, v_farbe, v_chip, v_tasso, v_kontakt, v_kontakt2], vermisst_copy_text, show_progress="hidden")
btn_tasso_link.click(None, None, None, js="""
() => {
const url = "https://www.tasso.net/Tierregister/Tier-vermisst/Tier-vermisst-melden";
window.open(url, "_blank", "noopener,noreferrer");
}
""")
btn_vermisst_pdf.click(export_vermisst_steckbrief, [current_zoo, v_name, v_tierart, v_rasse, v_farbe, v_chip, v_tasso, v_kontakt, v_kontakt2, vermisst_notiz_f, v_img], [vermisst_pdf_file, vermisst_pdf_status]).then(zeige_als_toast, inputs=vermisst_pdf_status, outputs=None)
def lade_tier_fuer_vermisst(zoo_name, tier_name):
leer = ("", "", "", "", "", "", "", "", gr.update(value=None), gr.update(visible=False))
if not zoo_name or not tier_name or tier_name == "Hier Auswählen":
return leer
try:
rows = get_cached_data("tier")
row = next((r for r in rows if len(r) > 1 and str(r[1]).strip() == str(tier_name).strip() and is_zoo(r[0], zoo_name) and str(r[1]).strip().lower() != "name"), None)
if not row: return leer
while len(row) < 39: row.append("")
img_val, _ = load_doc(str(row[19]))
return row[1], row[2], row[3], row[21], row[5], row[6], von_text(row[37]), von_text(row[38]), img_val, gr.update(visible=True)
except:
return leer
vermisst_tier_dd.change(lade_tier_fuer_vermisst, inputs=[current_zoo, vermisst_tier_dd], outputs=[v_name, v_tierart, v_rasse, v_farbe, v_chip, v_tasso, v_kontakt, v_kontakt2, v_img, vermisst_gruppe], show_progress="hidden")
btn_pdf_archiv.click(lambda z, s, i, p, v, t, tb: export_pdf(z, s, True, i, p, v, t, tb), [current_zoo, sel_pdf_archiv, pdf_mit_info, pdf_mit_pflege, pdf_mit_vermittlung, pdf_mit_tierarzt, pdf_mit_tagebuch], [pdf_file, pdf_status]).then(zeige_als_toast, inputs=pdf_status, outputs=None)
btn_archive.click(archive_tier, inputs=[current_zoo, move_dropdown, a_grund, a_vertrag_img], outputs=[archive_status, tier_dropdown, move_dropdown, sel_pdf_own, sel_pdf_foster, sel_pdf_archiv]).then(zeige_als_toast, inputs=archive_status, outputs=None).then(lambda: {tier_inhalt_wrapper: gr.update(visible=False)}, inputs=None, outputs=tier_inhalt_wrapper)
def zeige_nachtrag_ziel(ziel):
ist_archiv = ziel and "Archiv" in ziel
return {nach_aktiv_dd: gr.update(visible=not ist_archiv), nach_archiv_dd: gr.update(visible=ist_archiv)}
nach_ziel_radio.change(zeige_nachtrag_ziel, inputs=nach_ziel_radio, outputs=[nach_aktiv_dd, nach_archiv_dd])
btn_nach_save.click(nachtrag_speichern, inputs=[current_zoo, nach_ziel_radio, nach_aktiv_dd, nach_archiv_dd, nach_typ_dd, nach_img], outputs=[nach_status], show_progress="hidden").then(zeige_als_toast, inputs=nach_status, outputs=None)
def nav_start_klick_kombiniert(zoo_name):
d = start_klick_kombiniert(zoo_name)
d[nav_popup_state] = ""
d[nav_popup_box] = gr.update(visible=False)
return d
btn_nav_start.click(nav_start_klick_kombiniert, inputs=current_zoo, outputs=haupt_combo_outs + [start_bestand_html, start_anstehend_html, nav_popup_state, nav_popup_box], cancels=_haupt_events.copy(), show_progress="hidden").then(None, None, None, js="() => { window.scrollTo({top: 0, behavior: 'smooth'}); }")
# ============ Popup-Flyout-Inhalt (v7.92.0) - siehe Kommentar bei
# nav_popup_box weiter oben für das Grundprinzip. Jede Zeile schließt das
# Popup UND springt in einem Rutsch direkt zum Ziel - nie über eine
# Zwischenseite. ============
def _popup_umschalten_fabrik(ziel):
def _umschalten(aktueller_zustand):
neu = "" if aktueller_zustand == ziel else ziel
return neu, gr.update(visible=bool(neu))
return _umschalten
btn_nav_tiere.click(_popup_umschalten_fabrik("tiere"), inputs=nav_popup_state, outputs=[nav_popup_state, nav_popup_box], show_progress="hidden")
btn_nav_futter.click(_popup_umschalten_fabrik("futter"), inputs=nav_popup_state, outputs=[nav_popup_state, nav_popup_box], show_progress="hidden")
btn_nav_tagebuch.click(_popup_umschalten_fabrik("tagebuch"), inputs=nav_popup_state, outputs=[nav_popup_state, nav_popup_box], show_progress="hidden")
btn_nav_konto.click(_popup_umschalten_fabrik("konto"), inputs=nav_popup_state, outputs=[nav_popup_state, nav_popup_box], show_progress="hidden")
btn_nav_menu.click(_popup_umschalten_fabrik("menu"), inputs=nav_popup_state, outputs=[nav_popup_state, nav_popup_box], show_progress="hidden")
def nav_konto_einst_klick_kombiniert():
d = haupt_klick_kombiniert("konto")
d.update(zeige_konto_tab("einst"))
return d
def nav_konto_admin_klick_kombiniert():
d = haupt_klick_kombiniert("konto")
d.update(zeige_konto_tab("admin"))
return d
nav_konto_outs = haupt_combo_outs + konto_tab_outs
def _mit_popup_schliessen(fn):
"""Verpackt eine Navigations-Funktion so, dass sie das Popup GLEICHZEITIG
mit dem eigentlichen Sprung schließt - ein einziger Rutsch, ein einziges
Update. Vorher lief das über zwei Schritte (erst schließen, dann
springen) - dabei hat das Schließen (Ändern von nav_popup_state) das
@gr.render, das genau diesen Button enthält, sofort neu gezeichnet und
damit den Button MITTEN in seiner eigenen laufenden Aktion zerstört,
bevor der zweite Schritt ankam. Ergebnis: der Sprung ging verloren, man
landete wieder auf dem, was ohnehin schon sichtbar war (meist Start)."""
def _wrapped(*args):
d = fn(*args)
d[nav_popup_state] = ""
d[nav_popup_box] = gr.update(visible=False)
return d
return _wrapped
def render_nav_popup_inhalt(state, zoo_name):
if not state:
return
if state == "tiere":
gr.Markdown("**Tier wählen**", elem_classes="nav_popup_titel")
gruppen = _tiere_gruppiert(zoo_name)
if not gruppen:
gr.Markdown("<i>Noch keine Tiere angelegt.</i>")
for gruppen_name, tiere in gruppen:
gr.Markdown(f"*{gruppen_name}*", elem_classes="nav_popup_gruppe")
for name in tiere:
btn = gr.Button(name, elem_classes="nav_popup_item")
btn.click(lambda n=name: (f"tiere:{n}", gr.update(visible=True)), inputs=None, outputs=[nav_popup_state, nav_popup_box], show_progress="hidden")
btn_neu = gr.Button("+ Neues Tier anlegen", elem_classes="nav_popup_item")
btn_neu.click(_mit_popup_schliessen(_neues_tier_oeffnen), inputs=current_zoo, outputs=tier_liste_sprung_outs + [nav_popup_state, nav_popup_box], show_progress="hidden")
elif state.startswith("tiere:"):
name = state[6:]
gr.Markdown(f"**{name}** – was möchtest du anpassen?", elem_classes="nav_popup_titel")
for label, ziel in [("Stammdaten", "stam"), ("Fütterung", "futter"), ("Gesundheit", "gesund"), ("Dokumente", "doku"), ("Tagebuch", "tagebuch")]:
btn = gr.Button(label, elem_classes="nav_popup_item")
btn.click(_mit_popup_schliessen(oeffne_tier_stam_fabrik(name, ziel)), inputs=current_zoo, outputs=tier_liste_sprung_outs + [nav_popup_state, nav_popup_box], show_progress="hidden")
btn_back = gr.Button("← Zurück zur Tierliste", elem_classes="nav_popup_back")
btn_back.click(lambda: ("tiere", gr.update(visible=True)), inputs=None, outputs=[nav_popup_state, nav_popup_box], show_progress="hidden")
elif state == "tagebuch":
gr.Markdown("**Tagebuch – Tier wählen**", elem_classes="nav_popup_titel")
tiere = _tiere_mit_tagebuch_stand(zoo_name)
if not tiere:
gr.Markdown("<i>Noch keine Tiere angelegt.</i>")
for t in tiere:
label = f"{t['name']} ({t['anzahl']} Einträge)" if t["anzahl"] > 0 else f"{t['name']} (noch keine Einträge)"
btn = gr.Button(label, elem_classes="nav_popup_item")
btn.click(_mit_popup_schliessen(oeffne_tier_tagebuch_fabrik(t["name"])), inputs=[current_zoo, tagebuch_refresh_zaehler], outputs=tagebuch_uebersicht_sprung_outs + [nav_popup_state, nav_popup_box], show_progress="hidden")
elif state == "futter":
gr.Markdown("**Futter**", elem_classes="nav_popup_titel")
btn1 = gr.Button("Futter Scanner", elem_classes="nav_popup_item")
btn1.click(_mit_popup_schliessen(nav_futter_klick_kombiniert), inputs=current_zoo, outputs=nav_futter_outs + [nav_popup_state, nav_popup_box], show_progress="hidden")
btn2 = gr.Button("Futter Vorrat", elem_classes="nav_popup_item")
btn2.click(_mit_popup_schliessen(nav_futtervorrat_klick_kombiniert), inputs=current_zoo, outputs=nav_futtervorrat_outs + [nav_popup_state, nav_popup_box], show_progress="hidden")
elif state == "konto":
gr.Markdown("**Konto**", elem_classes="nav_popup_titel")
btn1 = gr.Button("Einstellungen", elem_classes="nav_popup_item")
btn1.click(_mit_popup_schliessen(lambda: nav_konto_einst_klick_kombiniert()), inputs=None, outputs=nav_konto_outs + [nav_popup_state, nav_popup_box], show_progress="hidden")
if ist_admin(zoo_name):
btn2 = gr.Button("Admin", elem_classes="nav_popup_item")
btn2.click(_mit_popup_schliessen(lambda: nav_konto_admin_klick_kombiniert()), inputs=None, outputs=nav_konto_outs + [nav_popup_state, nav_popup_box], show_progress="hidden")
btn3 = gr.Button("Abmelden", elem_classes="nav_popup_item nav_popup_danger")
btn3.click(_mit_popup_schliessen(lambda: dict(zip([current_zoo, login_screen, main_app, acc_status_md], logout()))), inputs=None, outputs=[current_zoo, login_screen, main_app, acc_status_md, nav_popup_state, nav_popup_box], show_progress="hidden")
elif state == "menu":
gr.Markdown("**Sonstiges**", elem_classes="nav_popup_titel")
for label, ziel in [("Archiv", "archiv"), ("PDF-Export", "pdf"), ("Vertrag nachtragen", "nachtrag")]:
btn = gr.Button(label, elem_classes="nav_popup_item")
def _fabrik(z=ziel):
def _f(zoo_name):
d = haupt_klick_kombiniert("verwaltung")
d.update(bereich_klick_kombiniert(z))
return d
return _f
btn.click(_mit_popup_schliessen(_fabrik()), inputs=current_zoo, outputs=haupt_combo_outs + bereich_combo_outs + [nav_popup_state, nav_popup_box], show_progress="hidden")
demo.load(None, None, None, js="""
() => {
document.documentElement.classList.remove('dark');
document.body.classList.remove('dark');
const container = document.querySelector('.gradio-container');
if (container) { container.classList.remove('dark'); }
}
""")
# Behalten für internes manuelles Testen über den Admin-Button (btn_futter_cron_test
# weiter oben) - der eigentliche externe Cron-Aufruf läuft über eine eigene,
# simple HTTP-Route (siehe ganz unten bei demo.launch()), nicht über diesen
# Button oder Gradios eingebauten API-Mechanismus.
_cron_out = gr.Textbox(visible=False, elem_id="cron_out_futter")
_cron_btn = gr.Button(visible=False, elem_id="cron_btn_futter")
_cron_btn.click(pruefe_und_sende_futter_erinnerungen, inputs=None, outputs=_cron_out)
# Live-Popup für neues Feedback, solange PetBase (Admin) in der App eingeloggt
# ist - Ergänzung zur E-Mail-Benachrichtigung in submit_feedback(). Fragt alle
# 30 Sekunden nach, ob neues Feedback im Sheet steht.
feedback_stand_state = gr.State(None)
feedback_popup_msg = gr.Textbox(visible=False)
feedback_timer = gr.Timer(30)
feedback_timer.tick(pruefe_neues_feedback, inputs=[current_zoo, feedback_stand_state], outputs=[feedback_stand_state, feedback_popup_msg], show_progress="hidden").then(zeige_als_toast, inputs=feedback_popup_msg, outputs=None)
if __name__ == "__main__":
# prevent_thread_lock=True lässt launch() sofort zurückkehren (Server läuft im
# Hintergrund weiter), damit wir danach noch eine eigene HTTP-Route ergänzen
# können - alle anderen Parameter (ssr_mode, pwa, favicon_path) bleiben exakt
# wie zuvor, damit sich am bestehenden, funktionierenden Start nichts ändert.
demo.launch(ssr_mode=False, pwa=True, favicon_path="petbase_icon.png", prevent_thread_lock=True)
# Eigene, simple HTTP-Route für den externen Cron-Dienst (z.B. cron-job.org).
# Gradios eingebauter API-Mechanismus (/gradio_api/call/<api_name>) erwartet
# eine mehrstufige Anfrage-Choreografie (POST zum Einreihen, dann separates
# GET/SSE zum Abholen des Ergebnisses) - ein simpler externer Cron-Dienst, der
# nur einmal aufruft und die Antwort nicht weiterverfolgt, bekam dabei einen
# 500er zurück. Diese Route ist ein ganz normaler HTTP-Endpunkt ohne
# Gradio-Choreografie - einfacher GET- oder POST-Aufruf reicht:
# https://<dein-space-name>.hf.space/cron/futter-check
@demo.app.get("/cron/futter-check")
@demo.app.post("/cron/futter-check")
def cron_futter_check_route():
ergebnis = pruefe_und_sende_futter_erinnerungen()
return {"status": ergebnis}
# Hauptthread am Leben halten, da launch() durch prevent_thread_lock sofort
# zurückkehrt und das Programm sonst hier enden würde.
while True:
time.sleep(3600)
|