Teil 3: Terminvorschläge + KI-Limit-Bypass für Admins/Mods — SW by-v435, APP_VER 414

- timeutils: next_appointment_slot() parst OSM opening_hours, findet Slot
- GET /health/terminvorschlaege: fällige/überfällige Einträge (30-Tage-Horizont)
  Impfung/Tierarzt nutzen Praxis-Öffnungszeiten, Rest nächster Werktag 09:00
- Frontend: Terminvorschlags-Karten, bestätigbares Modal, legt Event an
- ki.py: Admins, Moderatoren, Media Manager bypassen CLOUD_WEEKLY_LIMIT
This commit is contained in:
rene 2026-04-26 17:08:18 +02:00
parent 570dcd4e93
commit c935d3fbd4
7 changed files with 300 additions and 9 deletions

View file

@ -90,6 +90,15 @@ def _check_weekly_cloud_limit(user_id: int | None) -> None:
try:
from database import db
with db() as conn:
user = conn.execute(
"SELECT rolle, is_moderator FROM users WHERE id=?", (user_id,)
).fetchone()
# Admins, Moderatoren und Media Manager haben kein Limit
if user and (
user["rolle"] in ("admin", "moderator", "media_manager")
or user["is_moderator"]
):
return
used = conn.execute(
"""SELECT COALESCE(SUM(count), 0) FROM ki_daily_calls
WHERE user_id=? AND source='cloud'

View file

@ -467,3 +467,84 @@ async def list_ki_berichte(dog_id: int, user=Depends(get_current_user)):
(dog_id,)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# GET /api/dogs/{dog_id}/health/terminvorschlaege
# Gibt strukturierte Termin-Vorschläge auf Basis fälliger health-Einträge.
# ------------------------------------------------------------------
_TERMIN_TYPEN = {
'impfung': {'label': 'Impfung', 'beim_tierarzt': True, 'icon': 'syringe'},
'entwurmung': {'label': 'Entwurmung', 'beim_tierarzt': False, 'icon': 'pill'},
'tierarzt': {'label': 'Tierarztbesuch','beim_tierarzt': True, 'icon': 'first-aid'},
'medikament': {'label': 'Medikament', 'beim_tierarzt': False, 'icon': 'pill'},
'laeufigkeit': {'label': 'Läufigkeit', 'beim_tierarzt': False, 'icon': 'calendar'},
}
@router.get("/{dog_id}/health/terminvorschlaege")
async def terminvorschlaege(dog_id: int, user=Depends(get_current_user)):
from timeutils import next_appointment_slot
from datetime import date, timedelta
today = date.today()
horizon = today + timedelta(days=30)
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
# Einträge mit fälligem naechstes (überfällig oder in 30 Tagen)
rows = conn.execute(
"""SELECT id, typ, bezeichnung, naechstes, tierarzt_id
FROM health
WHERE dog_id=? AND naechstes IS NOT NULL
AND naechstes <= ? AND aktiv=1
ORDER BY naechstes ASC""",
(dog_id, horizon.isoformat())
).fetchall()
# Primäre Praxis des Users (erste aktive)
praxis = conn.execute(
"SELECT name, opening_hours, lat, lon FROM tieraerzte "
"WHERE user_id=? AND aktiv=1 ORDER BY id LIMIT 1",
(user["id"],)
).fetchone()
oh = praxis["opening_hours"] if praxis else None
praxis_name = praxis["name"] if praxis else None
praxis_lat = praxis["lat"] if praxis else None
praxis_lon = praxis["lon"] if praxis else None
vorschlaege = []
for r in rows:
cfg = _TERMIN_TYPEN.get(r["typ"])
if not cfg:
continue
naechstes = date.fromisoformat(r["naechstes"])
ueberfaellig = naechstes < today
delta_tage = (naechstes - today).days
# Terminfindung: bei Tierarzt-Typen Öffnungszeiten nutzen
slot_oh = oh if cfg["beim_tierarzt"] else None
# Frühestens ab morgen, aber nicht vor dem Fälligkeitsdatum wenn noch in der Zukunft
start = today if ueberfaellig else naechstes - timedelta(days=1)
datum_v, uhrzeit_v = next_appointment_slot(slot_oh, start_from=start)
vorschlaege.append({
"health_id": r["id"],
"typ": r["typ"],
"label": cfg["label"],
"icon": cfg["icon"],
"bezeichnung": r["bezeichnung"],
"naechstes": r["naechstes"],
"ueberfaellig": ueberfaellig,
"delta_tage": delta_tage,
"beim_tierarzt": cfg["beim_tierarzt"],
"datum_vorschlag": datum_v,
"uhrzeit_vorschlag": uhrzeit_v,
"praxis_name": praxis_name if cfg["beim_tierarzt"] else None,
"praxis_lat": praxis_lat if cfg["beim_tierarzt"] else None,
"praxis_lon": praxis_lon if cfg["beim_tierarzt"] else None,
})
return vorschlaege

View file

@ -168,7 +168,8 @@ const API = (() => {
kiZusammenfassung(dogId) {
return post(`/dogs/${dogId}/health/ki-zusammenfassung`);
},
kiBerichte(dogId) { return get(`/dogs/${dogId}/health/ki-berichte`); },
kiBerichte(dogId) { return get(`/dogs/${dogId}/health/ki-berichte`); },
terminvorschlaege(dogId) { return get(`/dogs/${dogId}/health/terminvorschlaege`); },
symptomCheck(dogId, symptoms) {
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
},

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '413'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '414'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {

View file

@ -153,6 +153,7 @@ window.Page_health = (() => {
</div>
${transponderHtml}
<div id="health-ki-berichte"></div>
<div id="health-terminvorschlaege"></div>
<div id="health-reminders"></div>
<div class="by-tabs" id="by-tabs"></div>
<div id="by-tab-content"></div>
@ -168,6 +169,7 @@ window.Page_health = (() => {
_renderErinnerungen();
_renderTab();
_loadKiBerichte(dog.id);
_loadTerminvorschlaege(dog.id);
}
// ----------------------------------------------------------
@ -1990,6 +1992,126 @@ window.Page_health = (() => {
// ----------------------------------------------------------
// KI-ZUSAMMENFASSUNG
// ----------------------------------------------------------
// TERMINVORSCHLÄGE
// ----------------------------------------------------------
async function _loadTerminvorschlaege(dogId) {
const el = _container.querySelector('#health-terminvorschlaege');
if (!el) return;
try {
const vorschlaege = await API.health.terminvorschlaege(dogId);
if (!vorschlaege || !vorschlaege.length) return;
const _fmtDatum = iso => new Date(iso + 'T00:00:00').toLocaleDateString('de-DE', {
weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric'
});
el.innerHTML = `
<div style="margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-2)">
Terminvorschläge
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${vorschlaege.map(v => {
const badge = v.ueberfaellig
? `<span style="font-size:var(--text-xs);color:var(--c-danger);font-weight:600">Überfällig seit ${_fmtDatum(v.naechstes)}</span>`
: `<span style="font-size:var(--text-xs);color:var(--c-warning);font-weight:600">Fällig am ${_fmtDatum(v.naechstes)}</span>`;
return `
<div class="health-card" style="flex-direction:row;align-items:center;gap:var(--space-3)">
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(v.bezeichnung)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(v.label)}${v.praxis_name ? ' · ' + _esc(v.praxis_name) : ''}</div>
${badge}
</div>
<div style="text-align:right;flex-shrink:0">
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Vorschlag</div>
<div style="font-size:var(--text-sm);font-weight:600">${_fmtDatum(v.datum_vorschlag)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${v.uhrzeit_vorschlag} Uhr</div>
<button class="btn btn-primary btn-sm" style="margin-top:var(--space-1)"
data-action="termin-anlegen"
data-v='${_esc(JSON.stringify(v))}'>
📅 In Kalender
</button>
</div>
</div>
`;
}).join('')}
</div>
</div>
`;
el.querySelectorAll('[data-action="termin-anlegen"]').forEach(btn => {
btn.addEventListener('click', async () => {
let v;
try { v = JSON.parse(btn.dataset.v); } catch { return; }
await _terminAnlegen(v, btn);
});
});
} catch { /* still show health page if this fails */ }
}
async function _terminAnlegen(v, btn) {
const titel = v.beim_tierarzt
? `${v.label}: ${v.bezeichnung} (Tierarzt)`
: `${v.label}: ${v.bezeichnung}`;
const beschreibung = v.praxis_name
? `Praxis: ${v.praxis_name}`
: v.ueberfaellig
? `Überfällig seit ${v.naechstes}`
: `Fällig am ${v.naechstes}`;
UI.modal.open({
title: '📅 Termin in Kalender eintragen',
body: `
<form id="termin-form">
<div class="form-group">
<label class="form-label">Bezeichnung</label>
<input class="form-control" type="text" name="titel" value="${_esc(titel)}" required>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Datum</label>
<input class="form-control" type="date" name="datum" value="${_esc(v.datum_vorschlag)}" required>
</div>
<div class="form-group">
<label class="form-label">Uhrzeit</label>
<input class="form-control" type="time" name="uhrzeit" value="${_esc(v.uhrzeit_vorschlag)}">
</div>
</div>
<div class="form-group">
<label class="form-label">Notiz</label>
<input class="form-control" type="text" name="beschreibung" value="${_esc(beschreibung)}">
</div>
</form>
`,
footer: `
<button type="button" class="btn btn-secondary flex-1" id="termin-cancel">Abbrechen</button>
<button type="submit" form="termin-form" class="btn btn-primary flex-1">Speichern</button>
`,
});
document.getElementById('termin-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('termin-form')?.addEventListener('submit', async e => {
e.preventDefault();
const saveBtn = document.querySelector('[form="termin-form"][type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(saveBtn, async () => {
await API.events.create({
titel: fd.titel,
datum: fd.datum,
uhrzeit: fd.uhrzeit || null,
beschreibung: fd.beschreibung || null,
typ: v.beim_tierarzt ? 'tierarzt' : 'sonstiges',
lat: v.praxis_lat ?? null,
lon: v.praxis_lon ?? null,
ort_name: v.praxis_name ?? null,
});
UI.modal.close();
UI.toast.success('Termin gespeichert — erscheint in deinem Kalender.');
});
});
}
// ----------------------------------------------------------
async function _showKiSummary() {
const btn = _container.querySelector('#health-ki-btn');

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v434';
const CACHE_VERSION = 'by-v435';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten

View file

@ -1,15 +1,93 @@
"""Hilfsfunktionen für client-seitige Zeitstempel."""
"""Hilfsfunktionen für client-seitige Zeitstempel und Öffnungszeiten."""
import re
from datetime import datetime
from datetime import datetime, date, timedelta
def safe_client_time(client_time: str | None) -> str:
"""Gibt client_time zurück falls valides ISO-Datetime, sonst UTC-Now.
Schützt gegen Injection: nur YYYY-MM-DD HH:MM[:SS] erlaubt.
"""
"""Gibt client_time zurück falls valides ISO-Datetime, sonst UTC-Now."""
if client_time and re.match(
r'^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?$', client_time
):
return client_time.replace('T', ' ')[:19]
return datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
# OSM-Wochentag-Kürzel → Python-weekday (Mo=0 … So=6)
_DAY = {'Mo': 0, 'Di': 1, 'Mi': 2, 'Do': 3, 'Fr': 4, 'Sa': 5, 'So': 6}
_DAY_EN = {'Mo': 0, 'Tu': 1, 'We': 2, 'Th': 3, 'Fr': 4, 'Sa': 5, 'Su': 6}
def _parse_oh_segments(oh: str) -> list[tuple[set[int], str, str]]:
"""Parst OSM-opening_hours in [(weekdays, open_time, close_time), …].
Unterstützt: "Mo-Fr 08:00-18:00; Sa 09:00-13:00", "24/7", "Mo,Mi 09:00-17:00"
"""
if not oh:
return []
oh = oh.strip()
if oh.lower() in ('24/7', '24/7 open'):
return [({0, 1, 2, 3, 4, 5, 6}, '09:00', '18:00')]
segments = []
for part in oh.split(';'):
part = part.strip()
m = re.match(
r'^([A-Za-z,\-]+)\s+(\d{2}:\d{2})-(\d{2}:\d{2})$', part
)
if not m:
continue
day_spec, open_t, close_t = m.group(1), m.group(2), m.group(3)
days: set[int] = set()
for chunk in day_spec.split(','):
chunk = chunk.strip()
if '-' in chunk:
parts = chunk.split('-')
if len(parts) == 2:
d1 = _DAY.get(parts[0], _DAY_EN.get(parts[0]))
d2 = _DAY.get(parts[1], _DAY_EN.get(parts[1]))
if d1 is not None and d2 is not None:
days.update(range(d1, d2 + 1))
else:
d = _DAY.get(chunk, _DAY_EN.get(chunk))
if d is not None:
days.add(d)
if days:
segments.append((days, open_t, close_t))
return segments
def next_appointment_slot(
opening_hours: str | None,
start_from: date | None = None,
prefer_morning: bool = True,
) -> tuple[str, str]:
"""Gibt (datum_str, uhrzeit_str) für den nächsten Slot zurück.
Sucht ab morgen (oder start_from) innerhalb der Öffnungszeiten.
Fallback: nächster Werktag 09:00 wenn keine Öffnungszeiten vorhanden.
Uhrzeit = Öffnungszeit + 1h (Puffer), min. 09:00, max. 11:00 (Vormittag).
"""
base = (start_from or date.today()) + timedelta(days=1)
segments = _parse_oh_segments(opening_hours or '')
if not segments:
# Fallback: nächster MoFr 09:00
d = base
for _ in range(14):
if d.weekday() < 5: # MoFr
return d.isoformat(), '09:00'
d += timedelta(days=1)
return base.isoformat(), '09:00'
for _ in range(14):
wd = base.weekday()
for days, open_t, close_t in segments:
if wd in days:
# Slot: Öffnungszeit + 1h, mindestens 09:00, höchstens 11:00
h, m = map(int, open_t.split(':'))
slot_h = max(9, h + 1)
slot_h = min(slot_h, 11)
return base.isoformat(), f'{slot_h:02d}:00'
base += timedelta(days=1)
return base.isoformat(), '09:00'