386 lines
13 KiB
Python
386 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Cteni agendy z Medevio kalendare.
|
|
|
|
Hlavni funkce: `list_agendu(start, end=None, calendar=None)`
|
|
start date | datetime | str 'YYYY-MM-DD' nebo 'YYYY-MM-DD HH:MM'
|
|
end to stejne; pokud None, bere se konec dne `start` (23:59:59)
|
|
calendar None = oba (vlado + manzelka), nebo "vlado" / "manzelka" / UUID,
|
|
nebo list techto hodnot
|
|
|
|
Vraci list dictu serazenych podle start. Kazdy dict obsahuje pole `calendar`
|
|
(jmeno) navic.
|
|
|
|
CLI:
|
|
python agenda_dne.py # interaktivne se zepta
|
|
python agenda_dne.py --od 2026-06-03 --do 2026-06-03 --kalendar vlado
|
|
python agenda_dne.py --od 2026-06-01 --do 2026-06-07 # tyden, oba
|
|
python agenda_dne.py --den 2026-06-03 # zkratka pro jeden den
|
|
python agenda_dne.py --den dnes
|
|
python agenda_dne.py --den +1 --kalendar manzelka
|
|
"""
|
|
|
|
import sys
|
|
import json
|
|
import argparse
|
|
from pathlib import Path
|
|
from datetime import datetime, date, timedelta
|
|
|
|
import requests
|
|
from dateutil import parser as dtparser, tz
|
|
|
|
try:
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
except AttributeError:
|
|
pass
|
|
|
|
GRAPHQL_URL = "https://api.medevio.cz/graphql"
|
|
CLINIC_SLUG = "mudr-buzalkova"
|
|
PRAGUE_TZ = tz.gettz("Europe/Prague")
|
|
|
|
# Pojmenovane kalendare - viz pamet project-medevio-kalendar
|
|
CALENDARS = {
|
|
"vlado": "b6555c7e-4e95-4657-b441-87c2c9a7b2ca",
|
|
"manzelka": "144c4e12-347c-49ca-9ec0-8ca965a4470d",
|
|
}
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
TOKEN_PATH = BASE_DIR / "token.txt"
|
|
DEBUG_DIR = BASE_DIR / "debug"
|
|
|
|
QUERY = """query Agenda_ListAll(
|
|
$calendarIds: [UUID!]!, $clinicSlug: String!,
|
|
$locale: Locale!, $since: DateTime!, $until: DateTime!
|
|
) {
|
|
reservations: listClinicReservations(
|
|
clinicSlug: $clinicSlug, calendarIds: $calendarIds,
|
|
since: $since, until: $until
|
|
) {
|
|
id start end note done color canceledAt calendarId
|
|
request {
|
|
id displayTitle(locale: $locale)
|
|
extendedPatient {
|
|
id name surname dob phone identificationNumber
|
|
insuranceCompanyObject { code shortName }
|
|
}
|
|
}
|
|
}
|
|
recurringReservations: listClinicRecurringReservations(
|
|
clinicSlug: $clinicSlug, calendarIds: $calendarIds,
|
|
since: $since, until: $until
|
|
) {
|
|
recurringReservation {
|
|
id calendarId color note
|
|
rrule { frequency interval dtstart tzid byweekday bymonthday byweekno }
|
|
}
|
|
instances { start end note color }
|
|
}
|
|
}"""
|
|
|
|
|
|
# ==================== Helpery ====================
|
|
|
|
def _load_token() -> str:
|
|
token = TOKEN_PATH.read_text(encoding="utf-8").strip()
|
|
if token.startswith("Bearer "):
|
|
token = token.split(" ", 1)[1]
|
|
return token
|
|
|
|
|
|
def _headers() -> dict:
|
|
return {
|
|
"content-type": "application/json",
|
|
"authorization": f"Bearer {_load_token()}",
|
|
"origin": "https://my.medevio.cz",
|
|
"referer": "https://my.medevio.cz/",
|
|
}
|
|
|
|
|
|
def _is_uuid(s: str) -> bool:
|
|
return isinstance(s, str) and len(s) == 36 and s.count("-") == 4
|
|
|
|
|
|
def _resolve_calendars(calendar) -> list[tuple[str, str]]:
|
|
"""Vraci list (jmeno, uuid). None = oba pojmenovane."""
|
|
if calendar is None:
|
|
return list(CALENDARS.items())
|
|
items = calendar if isinstance(calendar, list) else [calendar]
|
|
out = []
|
|
for c in items:
|
|
if c in CALENDARS:
|
|
out.append((c, CALENDARS[c]))
|
|
elif _is_uuid(c):
|
|
# zkusime najit jmeno
|
|
name = next((n for n, u in CALENDARS.items() if u == c), c)
|
|
out.append((name, c))
|
|
else:
|
|
raise ValueError(f"Neznamy kalendar: {c!r}")
|
|
return out
|
|
|
|
|
|
def _to_dt(value, end_of_day: bool = False) -> datetime:
|
|
"""Prevede date/datetime/str na datetime s Europe/Prague timezone."""
|
|
if isinstance(value, datetime):
|
|
dt = value
|
|
elif isinstance(value, date):
|
|
dt = datetime.combine(
|
|
value,
|
|
datetime.max.time().replace(microsecond=0) if end_of_day else datetime.min.time(),
|
|
)
|
|
elif isinstance(value, str):
|
|
s = value.strip().replace("T", " ")
|
|
if " " in s:
|
|
dt = datetime.strptime(s, "%Y-%m-%d %H:%M")
|
|
else:
|
|
d = datetime.strptime(s, "%Y-%m-%d").date()
|
|
dt = datetime.combine(
|
|
d,
|
|
datetime.max.time().replace(microsecond=0) if end_of_day else datetime.min.time(),
|
|
)
|
|
else:
|
|
raise TypeError(f"Nelze prevest na datetime: {value!r}")
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=PRAGUE_TZ)
|
|
return dt
|
|
|
|
|
|
def _to_utc_iso(dt: datetime) -> str:
|
|
return dt.astimezone(tz.UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
|
|
|
|
# ==================== Hlavni funkce ====================
|
|
|
|
def list_agendu(
|
|
start,
|
|
end=None,
|
|
calendar=None,
|
|
save_debug: bool = True,
|
|
) -> list[dict]:
|
|
"""Vraci rezervace v zadanem rozmezi pro vybrane kalendare.
|
|
|
|
Pokud `end` neni zadan, pouzije se konec stejneho dne jako `start`.
|
|
Pokud `calendar` je None, vrati se oba pojmenovane kalendare slouceny.
|
|
"""
|
|
start_dt = _to_dt(start, end_of_day=False)
|
|
if end is None:
|
|
end_dt = _to_dt(start_dt.date(), end_of_day=True)
|
|
else:
|
|
end_dt = _to_dt(end, end_of_day=True)
|
|
|
|
cals = _resolve_calendars(calendar)
|
|
|
|
payload = {
|
|
"operationName": "Agenda_ListAll",
|
|
"variables": {
|
|
"calendarIds": [uuid for _, uuid in cals],
|
|
"clinicSlug": CLINIC_SLUG,
|
|
"since": _to_utc_iso(start_dt),
|
|
"until": _to_utc_iso(end_dt),
|
|
"locale": "cs",
|
|
},
|
|
"query": QUERY,
|
|
}
|
|
|
|
r = requests.post(GRAPHQL_URL, headers=_headers(), data=json.dumps(payload), timeout=30)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
|
|
if save_debug:
|
|
DEBUG_DIR.mkdir(exist_ok=True)
|
|
debug_path = DEBUG_DIR / f"agenda_{start_dt:%Y%m%d}_{end_dt:%Y%m%d}_{datetime.now():%H%M%S}.json"
|
|
debug_path.write_text(
|
|
json.dumps({"request": payload["variables"], "response": data}, ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
print(f"[debug] {debug_path}")
|
|
|
|
if "errors" in data:
|
|
raise RuntimeError(f"GraphQL error: {data['errors']}")
|
|
|
|
uuid_to_name = {uuid: name for name, uuid in cals}
|
|
parsed = []
|
|
|
|
# 1) Jednorazove rezervace
|
|
for res in data["data"].get("reservations") or []:
|
|
if res.get("canceledAt"):
|
|
continue
|
|
s = dtparser.isoparse(res["start"]).astimezone(PRAGUE_TZ)
|
|
e = dtparser.isoparse(res["end"]).astimezone(PRAGUE_TZ)
|
|
req = res.get("request") or {}
|
|
pat = req.get("extendedPatient") or {}
|
|
ins = pat.get("insuranceCompanyObject") or {}
|
|
request_id = req.get("id") or ""
|
|
parsed.append({
|
|
"typ": "pacient" if request_id else "poznamka",
|
|
"calendar": uuid_to_name.get(res.get("calendarId"), res.get("calendarId")),
|
|
"calendar_id": res.get("calendarId"),
|
|
"start": s,
|
|
"end": e,
|
|
"title": req.get("displayTitle") or "",
|
|
"patient": f"{pat.get('surname','')} {pat.get('name','')}".strip(),
|
|
"dob": pat.get("dob") or "",
|
|
"rc": pat.get("identificationNumber") or "",
|
|
"phone": pat.get("phone") or "",
|
|
"insurance": ins.get("shortName") or "",
|
|
"insurance_code": ins.get("code") or "",
|
|
"note": (res.get("note") or "").strip(),
|
|
"done": bool(res.get("done")),
|
|
"color": res.get("color") or "",
|
|
"reservation_id": res["id"],
|
|
"request_id": request_id,
|
|
"patient_id": pat.get("id") or "",
|
|
"recurring": False,
|
|
})
|
|
|
|
# 2) Opakujici se - jednotlive instance v intervalu
|
|
for rr in data["data"].get("recurringReservations") or []:
|
|
rule = rr.get("recurringReservation") or {}
|
|
cal_id = rule.get("calendarId")
|
|
for inst in rr.get("instances") or []:
|
|
s = dtparser.isoparse(inst["start"]).astimezone(PRAGUE_TZ)
|
|
e = dtparser.isoparse(inst["end"]).astimezone(PRAGUE_TZ)
|
|
parsed.append({
|
|
"typ": "poznamka", # opakujici se vznikaji vzdy pres "Jina udalost"
|
|
"calendar": uuid_to_name.get(cal_id, cal_id),
|
|
"calendar_id": cal_id,
|
|
"start": s,
|
|
"end": e,
|
|
"title": "",
|
|
"patient": "",
|
|
"dob": "", "rc": "", "phone": "",
|
|
"insurance": "", "insurance_code": "",
|
|
"note": (inst.get("note") or rule.get("note") or "").strip(),
|
|
"done": False,
|
|
"color": inst.get("color") or rule.get("color") or "",
|
|
"reservation_id": rule.get("id"),
|
|
"request_id": "",
|
|
"patient_id": "",
|
|
"recurring": True,
|
|
"rrule": rule.get("rrule"),
|
|
})
|
|
|
|
parsed.sort(key=lambda x: (x["start"], x["calendar"]))
|
|
return parsed
|
|
|
|
|
|
# Zachovani zpetne kompatibility
|
|
def get_agenda(day, calendar=None) -> list[dict]:
|
|
"""Vraci agendu jednoho dne. Pro zpetnou kompat se starsim API."""
|
|
return list_agendu(day, end=day, calendar=calendar)
|
|
|
|
|
|
# ==================== Vystup ====================
|
|
|
|
def print_agenda(reservations: list[dict], header: str | None = None) -> None:
|
|
cz_dny = ["pondeli", "utery", "streda", "ctvrtek", "patek", "sobota", "nedele"]
|
|
|
|
if header is None:
|
|
if reservations:
|
|
dates = sorted({r["start"].date() for r in reservations})
|
|
if len(dates) == 1:
|
|
header = f"Agenda {dates[0].isoformat()} ({cz_dny[dates[0].weekday()]})"
|
|
else:
|
|
header = f"Agenda {dates[0].isoformat()} - {dates[-1].isoformat()}"
|
|
else:
|
|
header = "Agenda"
|
|
|
|
print(header)
|
|
print("=" * len(header))
|
|
|
|
if not reservations:
|
|
print("(zadne rezervace)")
|
|
return
|
|
|
|
last_date = None
|
|
for r in reservations:
|
|
if r["start"].date() != last_date:
|
|
if last_date is not None:
|
|
print()
|
|
print(f"--- {r['start'].date().isoformat()} ({cz_dny[r['start'].weekday()]}) ---")
|
|
last_date = r["start"].date()
|
|
|
|
time_str = f"{r['start']:%H:%M}-{r['end']:%H:%M}"
|
|
cal = f"[{r['calendar']}]" if r["calendar"] else ""
|
|
typ = f"<{r.get('typ','?')}>"
|
|
flags = []
|
|
if r["done"]:
|
|
flags.append("HOTOVO")
|
|
if r.get("recurring"):
|
|
flags.append("OPAKOVANE")
|
|
flag_str = f" [{', '.join(flags)}]" if flags else ""
|
|
|
|
label = r["patient"] if r.get("typ") == "pacient" else (r.get("note") or "(bez popisu)")
|
|
line = f"{time_str} {cal} {typ} {label}"
|
|
if r["dob"]:
|
|
line += f" *{r['dob']}"
|
|
if r["insurance"]:
|
|
line += f" {r['insurance']}"
|
|
line += flag_str
|
|
print(line)
|
|
if r["title"]:
|
|
print(f" {r['title']}")
|
|
if r["note"]:
|
|
for ln in r["note"].splitlines():
|
|
print(f" poznamka: {ln}")
|
|
|
|
print()
|
|
print(f"Celkem: {len(reservations)} rezervaci")
|
|
|
|
|
|
# ==================== CLI ====================
|
|
|
|
def _parse_day_arg(arg: str | None) -> date:
|
|
if not arg or arg in ("dnes", "today"):
|
|
return date.today()
|
|
if arg in ("zitra", "tomorrow"):
|
|
return date.today() + timedelta(days=1)
|
|
if arg in ("vcera", "yesterday"):
|
|
return date.today() - timedelta(days=1)
|
|
if arg.startswith(("+", "-")) and arg[1:].isdigit():
|
|
return date.today() + timedelta(days=int(arg))
|
|
return datetime.strptime(arg, "%Y-%m-%d").date()
|
|
|
|
|
|
def _prompt_interactive() -> tuple[date, date, str | None]:
|
|
cz_dny = ["pondeli", "utery", "streda", "ctvrtek", "patek", "sobota", "nedele"]
|
|
today = date.today()
|
|
print("Cteni agendy z Medevio")
|
|
print(f"Dnes je {today.isoformat()} ({cz_dny[today.weekday()]})")
|
|
print()
|
|
print("Od (YYYY-MM-DD / +N / -N / dnes / zitra / vcera, Enter = dnes):")
|
|
od = _parse_day_arg(input("> ").strip() or None)
|
|
print(f"Do (Enter = stejny den jako od = {od.isoformat()}):")
|
|
do_raw = input("> ").strip()
|
|
do = _parse_day_arg(do_raw) if do_raw else od
|
|
print(f"Kalendar (vlado / manzelka / Enter = oba):")
|
|
cal = input("> ").strip() or None
|
|
return od, do, cal
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Cteni agendy z Medevio.")
|
|
ap.add_argument("--od", help="Pocatecni datum 'YYYY-MM-DD' / +N / -N / dnes/zitra/vcera")
|
|
ap.add_argument("--do", dest="do_", help="Koncove datum (default = stejny jako --od)")
|
|
ap.add_argument("--den", help="Zkratka: --od i --do nastaveny na tento den")
|
|
ap.add_argument("--kalendar", default=None,
|
|
help=f"{'/'.join(CALENDARS)} nebo UUID. Bez parametru = oba.")
|
|
args = ap.parse_args()
|
|
|
|
if args.den:
|
|
od = do = _parse_day_arg(args.den)
|
|
cal = args.kalendar
|
|
elif args.od:
|
|
od = _parse_day_arg(args.od)
|
|
do = _parse_day_arg(args.do_) if args.do_ else od
|
|
cal = args.kalendar
|
|
else:
|
|
od, do, cal = _prompt_interactive()
|
|
|
|
reservations = list_agendu(od, do, calendar=cal)
|
|
print_agenda(reservations)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|