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:
parent
570dcd4e93
commit
c935d3fbd4
7 changed files with 300 additions and 9 deletions
|
|
@ -90,6 +90,15 @@ def _check_weekly_cloud_limit(user_id: int | None) -> None:
|
||||||
try:
|
try:
|
||||||
from database import db
|
from database import db
|
||||||
with db() as conn:
|
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(
|
used = conn.execute(
|
||||||
"""SELECT COALESCE(SUM(count), 0) FROM ki_daily_calls
|
"""SELECT COALESCE(SUM(count), 0) FROM ki_daily_calls
|
||||||
WHERE user_id=? AND source='cloud'
|
WHERE user_id=? AND source='cloud'
|
||||||
|
|
|
||||||
|
|
@ -467,3 +467,84 @@ async def list_ki_berichte(dog_id: int, user=Depends(get_current_user)):
|
||||||
(dog_id,)
|
(dog_id,)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
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
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,8 @@ const API = (() => {
|
||||||
kiZusammenfassung(dogId) {
|
kiZusammenfassung(dogId) {
|
||||||
return post(`/dogs/${dogId}/health/ki-zusammenfassung`);
|
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) {
|
symptomCheck(dogId, symptoms) {
|
||||||
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
|
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
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 = (() => {
|
const App = (() => {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,7 @@ window.Page_health = (() => {
|
||||||
</div>
|
</div>
|
||||||
${transponderHtml}
|
${transponderHtml}
|
||||||
<div id="health-ki-berichte"></div>
|
<div id="health-ki-berichte"></div>
|
||||||
|
<div id="health-terminvorschlaege"></div>
|
||||||
<div id="health-reminders"></div>
|
<div id="health-reminders"></div>
|
||||||
<div class="by-tabs" id="by-tabs"></div>
|
<div class="by-tabs" id="by-tabs"></div>
|
||||||
<div id="by-tab-content"></div>
|
<div id="by-tab-content"></div>
|
||||||
|
|
@ -168,6 +169,7 @@ window.Page_health = (() => {
|
||||||
_renderErinnerungen();
|
_renderErinnerungen();
|
||||||
_renderTab();
|
_renderTab();
|
||||||
_loadKiBerichte(dog.id);
|
_loadKiBerichte(dog.id);
|
||||||
|
_loadTerminvorschlaege(dog.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -1990,6 +1992,126 @@ window.Page_health = (() => {
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// KI-ZUSAMMENFASSUNG
|
// 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() {
|
async function _showKiSummary() {
|
||||||
const btn = _container.querySelector('#health-ki-btn');
|
const btn = _container.querySelector('#health-ki-btn');
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v434';
|
const CACHE_VERSION = 'by-v435';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,93 @@
|
||||||
"""Hilfsfunktionen für client-seitige Zeitstempel."""
|
"""Hilfsfunktionen für client-seitige Zeitstempel und Öffnungszeiten."""
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime, date, timedelta
|
||||||
|
|
||||||
|
|
||||||
def safe_client_time(client_time: str | None) -> str:
|
def safe_client_time(client_time: str | None) -> str:
|
||||||
"""Gibt client_time zurück falls valides ISO-Datetime, sonst UTC-Now.
|
"""Gibt client_time zurück falls valides ISO-Datetime, sonst UTC-Now."""
|
||||||
|
|
||||||
Schützt gegen Injection: nur YYYY-MM-DD HH:MM[:SS] erlaubt.
|
|
||||||
"""
|
|
||||||
if client_time and re.match(
|
if client_time and re.match(
|
||||||
r'^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?$', client_time
|
r'^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?$', client_time
|
||||||
):
|
):
|
||||||
return client_time.replace('T', ' ')[:19]
|
return client_time.replace('T', ' ')[:19]
|
||||||
return datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
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 Mo–Fr 09:00
|
||||||
|
d = base
|
||||||
|
for _ in range(14):
|
||||||
|
if d.weekday() < 5: # Mo–Fr
|
||||||
|
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'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue