Files
raspi_openclaw/old/crud_python/caldav_crud.py
T
2026-05-06 08:41:06 +02:00

582 lines
23 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
caldav_crud.py — Sichere CalDAV CRUD-Operationen für mailbox.org
=================================================================
Authentifizierung: python-keyring (bevorzugt) → .env-Datei → Umgebungsvariablen
Abhängigkeiten:
pip install caldav python-dotenv keyring vobject
Verwendung:
chmod +x caldav_crud.py
./caldav_crud.py --help
./caldav_crud.py list
./caldav_crud.py create --summary "Meeting" --start 2026-04-24T10:00 --end 2026-04-24T11:00
./caldav_crud.py read --uid <event-uid>
./caldav_crud.py update --uid <event-uid> --summary "Neuer Titel"
./caldav_crud.py delete --uid <event-uid>
./caldav_crud.py store-password # Passwort einmalig in keyring speichern
./caldav_crud.py delete-password # Passwort aus keyring entfernen
"""
import argparse
import logging
import os
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
# ── Drittanbieter-Importe mit Fehlerhinweis ──────────────────────────────────
try:
import caldav
except ImportError:
sys.exit("Fehlend: caldav → pip install caldav")
try:
from dotenv import load_dotenv
except ImportError:
sys.exit("Fehlend: python-dotenv → pip install python-dotenv")
try:
import keyring
import keyring.errors
HAS_KEYRING = True
except ImportError:
HAS_KEYRING = False
print("Hinweis: keyring nicht verfügbar, falle zurück auf .env / Umgebungsvariablen.",
file=sys.stderr)
try:
import vobject
except ImportError:
sys.exit("Fehlend: vobject → pip install vobject")
try:
import pytz
except ImportError:
sys.exit("Fehlend: pytz → pip install pytz")
# ── Konstanten ───────────────────────────────────────────────────────────────
KEYRING_SERVICE = "mailbox_caldav"
KEYRING_USERNAME = "caldav_password"
ENV_FILE_PATHS = [
Path.home() / ".openclaw" / ".env", # OpenClaw-Standardpfad
Path(".env"), # Aktuelles Verzeichnis (Fallback)
]
DEFAULT_CALENDAR_NAME = None # None = ersten verfügbaren Kalender nutzen
# ── Logging ──────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.WARNING,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
log = logging.getLogger(__name__)
# ═══════════════════════════════════════════════════════════════════════════════
# Authentifizierung & Konfiguration
# ═══════════════════════════════════════════════════════════════════════════════
def load_env() -> None:
"""Lädt .env-Datei aus bekannten Pfaden (erste gefundene gewinnt)."""
for path in ENV_FILE_PATHS:
if path.exists():
load_dotenv(dotenv_path=path, override=False)
log.info(f".env geladen von: {path}")
return
log.info("Keine .env-Datei gefunden — verwende reine Umgebungsvariablen.")
def get_password(username: str) -> str:
"""
Passwort-Abruf-Reihenfolge (sicherste zuerst):
1. python-keyring (systemischer Schlüsselbund / libsecret)
2. Umgebungsvariable MAILBOX_DAV_PASSWORD (aus .env oder Shell)
3. Interaktive Eingabe (Fallback, kein Echo)
"""
# 1. Keyring
if HAS_KEYRING:
try:
password = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
if password:
log.info("Passwort aus keyring geladen.")
return password
except keyring.errors.KeyringError as exc:
log.warning(f"Keyring-Fehler: {exc} — falle zurück auf .env")
# 2. Umgebungsvariable
password = os.getenv("MAILBOX_DAV_PASSWORD")
if password:
log.info("Passwort aus Umgebungsvariable MAILBOX_DAV_PASSWORD geladen.")
return password
# 3. Interaktive Eingabe
import getpass
print(f"Kein gespeichertes Passwort für '{username}' gefunden.")
print("Tipp: Einmalig speichern mit: ./caldav_crud.py store-password")
return getpass.getpass(f"App-Passwort für {username}: ")
def get_config() -> dict:
"""
Liest Konfiguration aus Umgebungsvariablen.
Priorität: keyring-Passwort > MAILBOX_DAV_PASSWORD > interaktiv.
"""
load_env()
url = os.getenv("MAILBOX_DAV_URL", "https://dav.mailbox.org")
username = os.getenv("MAILBOX_DAV_USERNAME", "")
calendar = os.getenv("MAILBOX_DAV_CALENDAR", "") # optional: Kalenderbezeichnung
if not username:
sys.exit(
"Fehler: MAILBOX_DAV_USERNAME ist nicht gesetzt.\n"
" → Setze ihn in ~/.openclaw/.env oder als Umgebungsvariable."
)
password = get_password(username)
return {
"url": url,
"username": username,
"password": password,
"calendar": calendar or None,
}
# ═══════════════════════════════════════════════════════════════════════════════
# CalDAV-Verbindung
# ═══════════════════════════════════════════════════════════════════════════════
def connect(cfg: dict) -> caldav.DAVClient:
"""Baut eine authentifizierte DAV-Verbindung auf."""
client = caldav.DAVClient(
url=cfg["url"],
username=cfg["username"],
password=cfg["password"],
)
# Verbindungstest
try:
client.principal()
log.info(f"Verbunden mit {cfg['url']} als {cfg['username']}")
except Exception as exc:
sys.exit(f"Verbindungsfehler: {exc}")
return client
def get_calendar(client: caldav.DAVClient, calendar_name: str | None = None) -> caldav.Calendar:
"""
Gibt den gewünschten Kalender zurück.
Wenn calendar_name=None → ersten verfügbaren Kalender verwenden.
"""
principal = client.principal()
calendars = principal.calendars()
if not calendars:
sys.exit("Keine Kalender auf dem Server gefunden.")
if calendar_name:
for cal in calendars:
if cal.name and cal.name.lower() == calendar_name.lower():
return cal
available = ", ".join(c.name or "<unnamed>" for c in calendars)
sys.exit(
f"Kalender '{calendar_name}' nicht gefunden.\n"
f"Verfügbar: {available}"
)
return calendars[0]
# ═══════════════════════════════════════════════════════════════════════════════
# iCalendar-Hilfsfunktionen
# ═══════════════════════════════════════════════════════════════════════════════
def _to_vobject_dt(dt: datetime) -> datetime:
"""
vobject kann stdlib timezone.utc nicht erkennen → in pytz.UTC konvertieren.
Alle anderen tz-Infos werden ebenfalls in pytz-Objekte überführt.
"""
if dt.tzinfo is None:
return dt.replace(tzinfo=pytz.UTC)
if dt.tzinfo is timezone.utc or str(dt.tzinfo) == "UTC":
return dt.replace(tzinfo=pytz.UTC)
# Versuche, den IANA-Namen zu ermitteln (bei zoneinfo / pytz-Objekten)
try:
iana_name = dt.tzname()
if iana_name and iana_name != "UTC":
return dt.astimezone(pytz.timezone(iana_name))
except Exception:
pass
return dt.replace(tzinfo=pytz.UTC)
def make_vcalendar(
uid: str,
summary: str,
start: datetime,
end: datetime,
description: str = "",
location: str = "",
) -> str:
"""Erstellt einen validen VCALENDAR-String (RFC 5545)."""
cal = vobject.iCalendar()
cal.add("prodid").value = "-//caldav_crud.py//mailbox.org//DE"
cal.add("version").value = "2.0"
event = cal.add("vevent")
event.add("uid").value = uid
event.add("summary").value = summary
event.add("dtstart").value = _to_vobject_dt(start)
event.add("dtend").value = _to_vobject_dt(end)
event.add("dtstamp").value = _to_vobject_dt(datetime.now(tz=timezone.utc))
if description:
event.add("description").value = description
if location:
event.add("location").value = location
return cal.serialize()
def parse_datetime(value: str) -> datetime:
"""
Parst ISO-8601-Datetime-Strings flexibel.
Unterstützt: 2026-04-24T10:00, 2026-04-24T10:00:00, 2026-04-24T10:00+02:00
"""
formats = [
"%Y-%m-%dT%H:%M:%S%z",
"%Y-%m-%dT%H:%M%z",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M",
"%Y-%m-%d",
]
for fmt in formats:
try:
dt = datetime.strptime(value, fmt)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except ValueError:
continue
sys.exit(
f"Ungültiges Datum/Uhrzeit-Format: '{value}'\n"
f"Erwartet z.B.: 2026-04-24T10:00 oder 2026-04-24T10:00:00+02:00"
)
def extract_event_info(event: caldav.Event) -> dict:
"""Extrahiert lesbare Felder aus einem CalDAV-Event-Objekt."""
try:
vcal = vobject.readOne(event.data)
vevent = vcal.vevent
return {
"uid": getattr(vevent, "uid", None) and vevent.uid.value,
"summary": getattr(vevent, "summary", None) and vevent.summary.value,
"dtstart": getattr(vevent, "dtstart", None) and str(vevent.dtstart.value),
"dtend": getattr(vevent, "dtend", None) and str(vevent.dtend.value),
"description": getattr(vevent, "description", None) and vevent.description.value,
"location": getattr(vevent, "location", None) and vevent.location.value,
}
except Exception as exc:
log.warning(f"Event-Parsing-Fehler: {exc}")
return {"raw": event.data}
# ═══════════════════════════════════════════════════════════════════════════════
# CRUD-Operationen
# ═══════════════════════════════════════════════════════════════════════════════
def cmd_list(args, cal: caldav.Calendar) -> None:
"""READ-ALL: Alle Events auflisten."""
results = cal.events()
if not results:
print("Keine Events im Kalender gefunden.")
return
print(f"\n{'UID':<38} {'Start':<22} Titel")
print("-" * 90)
for ev in results:
info = extract_event_info(ev)
uid = (info.get("uid") or "")[:36]
start = (info.get("dtstart") or "")[:22]
summary = (info.get("summary") or "<kein Titel>")[:50]
print(f"{uid:<38} {start:<22} {summary}")
def cmd_create(args, cal: caldav.Calendar) -> None:
"""CREATE: Neues Event anlegen."""
if not args.summary:
sys.exit("Fehler: --summary ist Pflichtfeld für 'create'")
if not args.start:
sys.exit("Fehler: --start ist Pflichtfeld für 'create'")
if not args.end:
sys.exit("Fehler: --end ist Pflichtfeld für 'create'")
event_uid = args.uid or str(uuid.uuid4())
start_dt = parse_datetime(args.start)
end_dt = parse_datetime(args.end)
ical_data = make_vcalendar(
uid=event_uid,
summary=args.summary,
start=start_dt,
end=end_dt,
description=args.description or "",
location=args.location or "",
)
cal.save_event(ical_data)
print(f"✓ Event erstellt UID: {event_uid}")
def cmd_read(args, cal: caldav.Calendar) -> None:
"""READ: Einzelnes Event anzeigen."""
if not args.uid:
sys.exit("Fehler: --uid ist Pflichtfeld für 'read'")
event = _find_event(cal, args.uid)
info = extract_event_info(event)
print(f"\n── Event ──────────────────────────────────")
for key, val in info.items():
if val:
print(f" {key:<12}: {val}")
print()
def cmd_update(args, cal: caldav.Calendar) -> None:
"""UPDATE: Vorhandenes Event bearbeiten."""
if not args.uid:
sys.exit("Fehler: --uid ist Pflichtfeld für 'update'")
event = _find_event(cal, args.uid)
vcal = vobject.readOne(event.data)
vevent = vcal.vevent
if args.summary:
vevent.summary.value = args.summary
if args.start:
vevent.dtstart.value = _to_vobject_dt(parse_datetime(args.start))
if args.end:
vevent.dtend.value = _to_vobject_dt(parse_datetime(args.end))
if args.description:
if hasattr(vevent, "description"):
vevent.description.value = args.description
else:
vevent.add("description").value = args.description
if args.location:
if hasattr(vevent, "location"):
vevent.location.value = args.location
else:
vevent.add("location").value = args.location
# DTSTAMP aktualisieren
vevent.dtstamp.value = _to_vobject_dt(datetime.now(tz=timezone.utc))
event.data = vcal.serialize()
event.save()
print(f"✓ Event aktualisiert UID: {args.uid}")
def cmd_delete(args, cal: caldav.Calendar) -> None:
"""DELETE: Event löschen."""
if not args.uid:
sys.exit("Fehler: --uid ist Pflichtfeld für 'delete'")
event = _find_event(cal, args.uid)
if not args.force:
info = extract_event_info(event)
print(f"Event zum Löschen: {info.get('summary', '<kein Titel>')} ({args.uid})")
confirm = input("Wirklich löschen? [j/N] ").strip().lower()
if confirm not in ("j", "ja", "y", "yes"):
print("Abgebrochen.")
return
event.delete()
print(f"✓ Event gelöscht UID: {args.uid}")
def _find_event(cal: caldav.Calendar, uid: str) -> caldav.Event:
"""Sucht ein Event anhand seiner UID — wirft SystemExit wenn nicht gefunden."""
results = cal.events()
for ev in results:
info = extract_event_info(ev)
if info.get("uid") == uid:
return ev
# Fallback: direkte URL-basierte Suche via search
try:
events = cal.search(uid=uid, event=True)
if events:
return events[0]
except Exception:
pass
sys.exit(f"Event mit UID '{uid}' nicht gefunden.")
# ═══════════════════════════════════════════════════════════════════════════════
# Keyring-Verwaltung
# ═══════════════════════════════════════════════════════════════════════════════
def cmd_store_password(args) -> None:
"""Speichert das App-Passwort einmalig im System-Schlüsselbund."""
if not HAS_KEYRING:
sys.exit("Fehler: python-keyring ist nicht installiert.\n"
" → pip install keyring")
import getpass
load_env()
username = os.getenv("MAILBOX_DAV_USERNAME", "")
if not username:
username = input("mailbox.org Benutzername (E-Mail): ").strip()
password = getpass.getpass(f"App-Passwort für '{username}' (wird nicht angezeigt): ")
if not password:
sys.exit("Abgebrochen — kein Passwort eingegeben.")
try:
keyring.set_password(KEYRING_SERVICE, KEYRING_USERNAME, password)
print(f"✓ Passwort sicher im System-Schlüsselbund gespeichert.")
print(f" Service : {KEYRING_SERVICE}")
print(f" Username : {KEYRING_USERNAME}")
print(f" Hinweis : MAILBOX_DAV_PASSWORD in .env kann jetzt entfernt werden.")
except keyring.errors.KeyringError as exc:
sys.exit(f"Keyring-Fehler: {exc}")
def cmd_delete_password(args) -> None:
"""Entfernt das gespeicherte Passwort aus dem System-Schlüsselbund."""
if not HAS_KEYRING:
sys.exit("Fehler: python-keyring ist nicht installiert.")
try:
keyring.delete_password(KEYRING_SERVICE, KEYRING_USERNAME)
print(f"✓ Passwort aus keyring entfernt.")
except keyring.errors.PasswordDeleteError:
print("Kein Passwort im keyring gespeichert.")
except keyring.errors.KeyringError as exc:
sys.exit(f"Keyring-Fehler: {exc}")
# ═══════════════════════════════════════════════════════════════════════════════
# CLI-Argument-Parser
# ═══════════════════════════════════════════════════════════════════════════════
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="caldav_crud.py",
description="Sichere CalDAV CRUD-Operationen für mailbox.org",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Beispiele:
./caldav_crud.py store-password
./caldav_crud.py list
./caldav_crud.py create --summary "Arzttermin" --start 2026-04-25T09:00 --end 2026-04-25T10:00
./caldav_crud.py read --uid 550e8400-e29b-41d4-a716-446655440000
./caldav_crud.py update --uid 550e8400-e29b-41d4-a716-446655440000 --summary "Verschoben"
./caldav_crud.py delete --uid 550e8400-e29b-41d4-a716-446655440000 --force
Umgebungsvariablen (.env oder Shell):
MAILBOX_DAV_URL CalDAV-Server (Standard: https://dav.mailbox.org)
MAILBOX_DAV_USERNAME E-Mail-Adresse oder App-Benutzername
MAILBOX_DAV_PASSWORD App-Passwort (besser: keyring verwenden)
MAILBOX_DAV_CALENDAR Kalenderbezeichnung (optional, Standard: erster Kalender)
""",
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Ausführliche Ausgabe (DEBUG-Level)"
)
parser.add_argument(
"--calendar",
metavar="NAME",
help="Kalenderbezeichnung (überschreibt MAILBOX_DAV_CALENDAR)"
)
subparsers = parser.add_subparsers(dest="command", metavar="BEFEHL")
subparsers.required = True
# ── list ──────────────────────────────────────────────────────────────
subparsers.add_parser("list", help="Alle Events auflisten")
# ── create ────────────────────────────────────────────────────────────
p_create = subparsers.add_parser("create", help="Neues Event anlegen")
p_create.add_argument("--summary", required=True, help="Titel des Events")
p_create.add_argument("--start", required=True, help="Start (ISO-8601, z.B. 2026-04-25T10:00)")
p_create.add_argument("--end", required=True, help="Ende (ISO-8601)")
p_create.add_argument("--description", default="", help="Beschreibung (optional)")
p_create.add_argument("--location", default="", help="Ort (optional)")
p_create.add_argument("--uid", default=None, help="Eigene UID (Standard: auto-generiert)")
# ── read ──────────────────────────────────────────────────────────────
p_read = subparsers.add_parser("read", help="Einzelnes Event anzeigen")
p_read.add_argument("--uid", required=True, help="UID des Events")
# ── update ────────────────────────────────────────────────────────────
p_update = subparsers.add_parser("update", help="Vorhandenes Event bearbeiten")
p_update.add_argument("--uid", required=True, help="UID des Events")
p_update.add_argument("--summary", default=None, help="Neuer Titel")
p_update.add_argument("--start", default=None, help="Neuer Start (ISO-8601)")
p_update.add_argument("--end", default=None, help="Neues Ende (ISO-8601)")
p_update.add_argument("--description", default=None, help="Neue Beschreibung")
p_update.add_argument("--location", default=None, help="Neuer Ort")
# ── delete ────────────────────────────────────────────────────────────
p_delete = subparsers.add_parser("delete", help="Event löschen")
p_delete.add_argument("--uid", required=True, help="UID des Events")
p_delete.add_argument("--force", action="store_true", help="Ohne Bestätigungsabfrage löschen")
# ── Passwort-Verwaltung ───────────────────────────────────────────────
subparsers.add_parser("store-password", help="App-Passwort sicher im Schlüsselbund speichern")
subparsers.add_parser("delete-password", help="Passwort aus Schlüsselbund entfernen")
return parser
# ═══════════════════════════════════════════════════════════════════════════════
# Einstiegspunkt
# ═══════════════════════════════════════════════════════════════════════════════
def main() -> None:
parser = build_parser()
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
# Passwort-Verwaltung braucht keine CalDAV-Verbindung
if args.command == "store-password":
cmd_store_password(args)
return
if args.command == "delete-password":
cmd_delete_password(args)
return
# Konfiguration laden & verbinden
cfg = get_config()
# --calendar überschreibt Env-Variable
if args.calendar:
cfg["calendar"] = args.calendar
client = connect(cfg)
cal = get_calendar(client, cfg.get("calendar"))
# CRUD-Dispatcher
dispatch = {
"list": cmd_list,
"create": cmd_create,
"read": cmd_read,
"update": cmd_update,
"delete": cmd_delete,
}
dispatch[args.command](args, cal)
if __name__ == "__main__":
main()