Initial commit: Waste Collection API
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
173
app/collector.py
Normal file
173
app/collector.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Collector — wraps waste_collection_schedule library calls.
|
||||
|
||||
Key insight: icalevents has a timezone bug (offset-naive vs offset-aware datetime
|
||||
comparison). We fix it by monkey-patching the library's ICS.convert() to use icalendar.
|
||||
"""
|
||||
import datetime
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
import requests
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ── Library path setup ────────────────────────────────────────────────────────
|
||||
WASTE_LIB_BASE = Path(__file__).resolve().parent.parent.parent
|
||||
_WASTE_OUTER = WASTE_LIB_BASE / "waste_lib" / "custom_components" / "waste_collection_schedule"
|
||||
_WASTE_INNER = _WASTE_OUTER / "waste_collection_schedule"
|
||||
_PATCHED = False
|
||||
|
||||
|
||||
def _ensure_path():
|
||||
global _PATCHED
|
||||
if str(_WASTE_INNER) not in sys.path:
|
||||
sys.path.insert(0, str(_WASTE_INNER))
|
||||
sys.path.insert(1, str(_WASTE_OUTER))
|
||||
if not _PATCHED:
|
||||
_patch_icalevents_bug()
|
||||
_PATCHED = True
|
||||
|
||||
|
||||
def _patch_icalevents_bug():
|
||||
"""
|
||||
Monkey-patch waste_collection_schedule.service.ICS.ICS.convert()
|
||||
to use icalendar instead of icalevents (which has a timezone bug).
|
||||
Returns List[Tuple[datetime.date, str]] as the original does.
|
||||
"""
|
||||
import icalendar
|
||||
import jinja2
|
||||
|
||||
from waste_collection_schedule.service.ICS import ICS as OriginalICS
|
||||
|
||||
_original_convert = OriginalICS.convert
|
||||
|
||||
def patched_convert(self, text: str) -> List[Any]:
|
||||
try:
|
||||
return _original_convert(self, text)
|
||||
except TypeError as ex:
|
||||
if "offset-naive" not in str(ex):
|
||||
raise
|
||||
log.debug("ICS.convert: icalevents bug detected, falling back to icalendar")
|
||||
|
||||
cal = icalendar.Calendar.from_ical(text)
|
||||
now = datetime.datetime.now().date()
|
||||
end = now + datetime.timedelta(days=365)
|
||||
entries: List[Any] = []
|
||||
|
||||
for component in cal.walk():
|
||||
if component.name != "VEVENT":
|
||||
continue
|
||||
dtstart = component.get("DTSTART")
|
||||
if dtstart is None:
|
||||
continue
|
||||
dt = dtstart.dt
|
||||
if isinstance(dt, datetime.datetime):
|
||||
dt = dt.date()
|
||||
if not isinstance(dt, datetime.date):
|
||||
continue
|
||||
if dt < now or dt > end:
|
||||
continue
|
||||
if self._offset:
|
||||
dt = dt + datetime.timedelta(days=self._offset)
|
||||
# Try SUMMARY first, then DESCRIPTION
|
||||
summary = str(component.get("SUMMARY", "") or component.get("DESCRIPTION", "") or "")
|
||||
if not summary and self._title_template:
|
||||
env = jinja2.Environment()
|
||||
title_tpl = env.from_string(self._title_template)
|
||||
summary = title_tpl.render(date=dt)
|
||||
entries.append((dt, summary))
|
||||
# Sort by date (icalevents returns sorted results)
|
||||
entries.sort(key=lambda x: x[0])
|
||||
return entries
|
||||
|
||||
OriginalICS.convert = patched_convert
|
||||
log.info("Patched ICS.convert to use icalendar fallback")
|
||||
|
||||
|
||||
# ── Collection mapping ────────────────────────────────────────────────────────
|
||||
def _map_collection(wc) -> dict:
|
||||
return {
|
||||
"date": wc.date.isoformat(),
|
||||
"type": wc.get("type", "Unknown"),
|
||||
"daysUntil": (wc.date - datetime.datetime.now().date()).days,
|
||||
"icon": wc.icon,
|
||||
}
|
||||
|
||||
|
||||
# ── ICS via icalendar (bypasses icalevents timezone bug) ─────────────────────
|
||||
def _fetch_ics_via_icalendar(url: str, count: int) -> list[dict]:
|
||||
import icalendar
|
||||
|
||||
resp = requests.get(url, timeout=15)
|
||||
resp.raise_for_status()
|
||||
cal = icalendar.Calendar.from_ical(resp.text)
|
||||
now = datetime.datetime.now().date()
|
||||
entries = []
|
||||
for component in cal.walk():
|
||||
if component.name != "VEVENT":
|
||||
continue
|
||||
dtstart = component.get("DTSTART")
|
||||
if dtstart is None:
|
||||
continue
|
||||
dt = dtstart.dt
|
||||
if isinstance(dt, datetime.datetime):
|
||||
dt = dt.date()
|
||||
if not isinstance(dt, datetime.date):
|
||||
continue
|
||||
if dt < now:
|
||||
continue
|
||||
summary = str(component.get("SUMMARY", "Unknown"))
|
||||
entries.append({
|
||||
"date": dt.isoformat(),
|
||||
"type": summary,
|
||||
"daysUntil": (dt - now).days,
|
||||
"icon": None,
|
||||
})
|
||||
entries.sort(key=lambda x: x["date"])
|
||||
return entries[:count]
|
||||
|
||||
|
||||
# ── Public API ───────────────────────────────────────────────────────────────
|
||||
def fetch_ics(url: str, count: int = 20) -> list[dict]:
|
||||
_ensure_path()
|
||||
from waste_collection_schedule.source.ics import Source as ICSSource
|
||||
|
||||
source = ICSSource(url=url)
|
||||
entries = source.fetch()
|
||||
return [_map_collection(e) for e in entries[:count]]
|
||||
|
||||
|
||||
def fetch_abfall_io(
|
||||
key: str, f_id_kommune: int, f_id_strasse: int, count: int
|
||||
) -> list[dict]:
|
||||
_ensure_path()
|
||||
from waste_collection_schedule.source.abfall_io import Source as AbfallIOSource
|
||||
|
||||
source = AbfallIOSource(
|
||||
key=key,
|
||||
f_id_kommune=f_id_kommune,
|
||||
f_id_strasse=f_id_strasse,
|
||||
)
|
||||
entries = source.fetch()
|
||||
return [_map_collection(e) for e in entries[:count]]
|
||||
|
||||
|
||||
def fetch_awm_muenchen(street: str, house_number: str, count: int) -> list[dict]:
|
||||
_ensure_path()
|
||||
from waste_collection_schedule.source.awm_muenchen_de import Source as AWMSource
|
||||
|
||||
source = AWMSource(street=street, house_number=house_number)
|
||||
entries = source.fetch()
|
||||
return [_map_collection(e) for e in entries[:count]]
|
||||
|
||||
|
||||
def fetch_stuttgart(street: str, streetnr: int, count: int) -> list[dict]:
|
||||
_ensure_path()
|
||||
from waste_collection_schedule.source.stuttgart_de import Source
|
||||
|
||||
source = Source(street=street, streetnr=streetnr)
|
||||
entries = source.fetch()
|
||||
return [_map_collection(e) for e in entries[:count]]
|
||||
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
42
app/core/notifier.py
Normal file
42
app/core/notifier.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Send notification via Telegram.
|
||||
Requires bot token and chat_id configured in environment or .env file.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "8638072533:AAG3fcm0Sq_JBSSA1UXLspgRzn-tdIJ9N_8")
|
||||
TELEGRAM_CHAT_ID = int(os.getenv("TELEGRAM_CHAT_ID", "1320170074"))
|
||||
|
||||
|
||||
def send_telegram(text: str, chat_id: int = TELEGRAM_CHAT_ID) -> bool:
|
||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
|
||||
try:
|
||||
resp = httpx.post(url, json={"chat_id": chat_id, "text": text}, timeout=10)
|
||||
resp.raise_for_status()
|
||||
log.info(f"Telegram sent to {chat_id}: {text[:50]}")
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error(f"Telegram failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def format_collection_reminder(collection: dict, source_id: str) -> str:
|
||||
date = collection.get("date", "?")
|
||||
wtype = collection.get("type", "?")
|
||||
days = collection.get("daysUntil", "?")
|
||||
icon = collection.get("icon", "🗑️")
|
||||
return (
|
||||
f"{icon} *Waste Collection Reminder*\n"
|
||||
f"📅 {date} (in {days} day{'s' if days != 1 else ''})\n"
|
||||
f"🗑️ {wtype}\n"
|
||||
f"🔔 from: {source_id}"
|
||||
)
|
||||
118
app/core/scheduler.py
Normal file
118
app/core/scheduler.py
Normal file
@@ -0,0 +1,118 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
import json
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
NOTIFS_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "notifications.json"
|
||||
|
||||
# ─── Source registry ───────────────────────────────────────────
|
||||
SOURCE_DEFS = {
|
||||
"ics": {
|
||||
"name": "Generic ICS / iCal URL",
|
||||
"params": [
|
||||
{"name": "url", "type": "string", "required": True, "example": "https://example.com/kalender.ics"},
|
||||
],
|
||||
},
|
||||
"abfall_io": {
|
||||
"name": "Abfall.IO (Germany)",
|
||||
"params": [
|
||||
{"name": "key", "type": "string", "required": True, "example": "8215c62763967916979e0e8566b6172e"},
|
||||
{"name": "f_id_kommune", "type": "integer", "required": True, "example": 2999},
|
||||
{"name": "f_id_strasse", "type": "integer", "required": True, "example": 1087},
|
||||
],
|
||||
},
|
||||
"awm_muenchen_de": {
|
||||
"name": "AWM München (Germany)",
|
||||
"params": [
|
||||
{"name": "street", "type": "string", "required": True, "example": "Bahnstr."},
|
||||
{"name": "house_number", "type": "string", "required": True, "example": "11"},
|
||||
],
|
||||
},
|
||||
"stuttgart_de": {
|
||||
"name": "Abfall Stuttgart (Germany)",
|
||||
"params": [
|
||||
{"name": "street", "type": "string", "required": True, "example": "Im Steinengarten"},
|
||||
{"name": "streetnr", "type": "integer", "required": True, "example": 7},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class NotificationScheduler:
|
||||
def __init__(self):
|
||||
self._notifications: dict[str, dict] = {}
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
if NOTIFS_FILE.exists():
|
||||
try:
|
||||
data = json.loads(NOTIFS_FILE.read_text())
|
||||
self._notifications = {n["id"]: n for n in data.get("notifications", [])}
|
||||
log.info(f"Loaded {len(self._notifications)} notifications from {NOTIFS_FILE}")
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to load notifications: {e}")
|
||||
|
||||
def _save(self):
|
||||
NOTIFS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
NOTIFS_FILE.write_text(
|
||||
json.dumps({"notifications": list(self._notifications.values())}, indent=2)
|
||||
)
|
||||
|
||||
def add(
|
||||
self,
|
||||
source_id: str,
|
||||
collection: dict,
|
||||
notify_at_days_before: int,
|
||||
channels: list[str],
|
||||
chat_id: int,
|
||||
) -> dict:
|
||||
nid = f"notif_{uuid.uuid4().hex[:8]}"
|
||||
entry = {
|
||||
"id": nid,
|
||||
"source_id": source_id,
|
||||
"collection_type": collection.get("type", "Unknown"),
|
||||
"collection_date": collection.get("date", ""),
|
||||
"notify_at_days_before": notify_at_days_before,
|
||||
"channels": channels,
|
||||
"chat_id": chat_id,
|
||||
"fired": False,
|
||||
}
|
||||
self._notifications[nid] = entry
|
||||
self._save()
|
||||
return entry
|
||||
|
||||
def list_all(self) -> list[dict]:
|
||||
return list(self._notifications.values())
|
||||
|
||||
def delete(self, nid: str) -> bool:
|
||||
if nid in self._notifications:
|
||||
del self._notifications[nid]
|
||||
self._save()
|
||||
return True
|
||||
return False
|
||||
|
||||
def due_today(self) -> list[dict]:
|
||||
today = datetime.now().date().isoformat()
|
||||
due = []
|
||||
for n in self._notifications.values():
|
||||
if n.get("fired"):
|
||||
continue
|
||||
coll_date = n.get("collection_date", "")
|
||||
days_before = n.get("notify_at_days_before", 1)
|
||||
# check if today is notify_at_days_before days before collection
|
||||
try:
|
||||
coll = datetime.fromisoformat(coll_date).date()
|
||||
notify_date = coll - timedelta(days=days_before)
|
||||
if notify_date.isoformat() == today:
|
||||
due.append(n)
|
||||
except Exception:
|
||||
pass
|
||||
return due
|
||||
|
||||
def mark_fired(self, nid: str):
|
||||
if nid in self._notifications:
|
||||
self._notifications[nid]["fired"] = True
|
||||
self._save()
|
||||
42
app/main.py
Normal file
42
app/main.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import logging
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .routes import sources, collections, notifications
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(
|
||||
title="Waste Collection API",
|
||||
description="Waste collection schedules for German municipalities with notification support",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ─── Routes ────────────────────────────────────────────────────
|
||||
app.include_router(sources.router)
|
||||
app.include_router(collections.router)
|
||||
app.include_router(notifications.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {
|
||||
"name": "Waste Collection API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"sources": "/sources",
|
||||
}
|
||||
34
app/models.py
Normal file
34
app/models.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Pure Pydantic models — no waste_collection_schedule imports here.
|
||||
from .schemas import (
|
||||
Collection,
|
||||
Notification,
|
||||
ICSRequest,
|
||||
AbfallIORequest,
|
||||
AWMRequest,
|
||||
NotifyRequest,
|
||||
SourceInfo,
|
||||
SourceListResponse,
|
||||
SourceDetailResponse,
|
||||
CollectionsResponse,
|
||||
NotificationsResponse,
|
||||
NotifyResponse,
|
||||
DeleteResponse,
|
||||
SourceParam,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Collection",
|
||||
"Notification",
|
||||
"ICSRequest",
|
||||
"AbfallIORequest",
|
||||
"AWMRequest",
|
||||
"NotifyRequest",
|
||||
"SourceInfo",
|
||||
"SourceListResponse",
|
||||
"SourceDetailResponse",
|
||||
"CollectionsResponse",
|
||||
"NotificationsResponse",
|
||||
"NotifyResponse",
|
||||
"DeleteResponse",
|
||||
"SourceParam",
|
||||
]
|
||||
0
app/routes/__init__.py
Normal file
0
app/routes/__init__.py
Normal file
56
app/routes/collections.py
Normal file
56
app/routes/collections.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from ..schemas import (
|
||||
ICSRequest,
|
||||
AbfallIORequest,
|
||||
AWMRequest,
|
||||
StuttgartRequest,
|
||||
CollectionsResponse,
|
||||
)
|
||||
from ..collector import fetch_ics, fetch_abfall_io, fetch_awm_muenchen, fetch_stuttgart
|
||||
|
||||
router = APIRouter(prefix="/collections", tags=["collections"])
|
||||
|
||||
|
||||
@router.post("/ics", response_model=CollectionsResponse)
|
||||
def collections_ics(body: ICSRequest):
|
||||
try:
|
||||
collections = fetch_ics(body.url, body.count)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
if not collections:
|
||||
raise HTTPException(status_code=422, detail="No collections returned from this ICS URL")
|
||||
return CollectionsResponse(source="ics", collections=collections)
|
||||
|
||||
|
||||
@router.post("/abfall_io", response_model=CollectionsResponse)
|
||||
def collections_abfall_io(body: AbfallIORequest):
|
||||
try:
|
||||
collections = fetch_abfall_io(body.key, body.f_id_kommune, body.f_id_strasse, body.count)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
if not collections:
|
||||
raise HTTPException(status_code=422, detail="No collections returned. Check your IDs.")
|
||||
return CollectionsResponse(source="abfall_io", collections=collections)
|
||||
|
||||
|
||||
@router.post("/awm_muenchen_de", response_model=CollectionsResponse)
|
||||
def collections_awm_muenchen(body: AWMRequest):
|
||||
try:
|
||||
collections = fetch_awm_muenchen(body.street, body.house_number, body.count)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
if not collections:
|
||||
raise HTTPException(status_code=422, detail="No collections returned. Check street name.")
|
||||
return CollectionsResponse(source="awm_muenchen_de", collections=collections)
|
||||
|
||||
|
||||
@router.post("/stuttgart_de", response_model=CollectionsResponse)
|
||||
def collections_stuttgart(body: StuttgartRequest):
|
||||
try:
|
||||
collections = fetch_stuttgart(body.street, body.streetnr, body.count)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
if not collections:
|
||||
raise HTTPException(status_code=422, detail="No collections returned. Check street name and number.")
|
||||
return CollectionsResponse(source="stuttgart_de", collections=collections)
|
||||
44
app/routes/notifications.py
Normal file
44
app/routes/notifications.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from ..schemas import (
|
||||
NotifyRequest,
|
||||
NotifyResponse,
|
||||
NotificationsResponse,
|
||||
DeleteResponse,
|
||||
)
|
||||
from ..core.scheduler import NotificationScheduler
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
# singleton scheduler
|
||||
_scheduler = NotificationScheduler()
|
||||
|
||||
|
||||
@router.post("", response_model=NotifyResponse)
|
||||
def create_notification(body: NotifyRequest):
|
||||
entry = _scheduler.add(
|
||||
source_id=body.source_id,
|
||||
collection=body.collection.model_dump(),
|
||||
notify_at_days_before=body.notify_at_days_before,
|
||||
channels=body.channels,
|
||||
chat_id=body.chat_id,
|
||||
)
|
||||
return NotifyResponse(
|
||||
ok=True,
|
||||
notification_id=entry["id"],
|
||||
message=f"Reminder set for {entry['collection_date']}: {entry['collection_type']}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=NotificationsResponse)
|
||||
def list_notifications():
|
||||
notes = _scheduler.list_all()
|
||||
return NotificationsResponse(count=len(notes), notifications=notes)
|
||||
|
||||
|
||||
@router.delete("/{notification_id}", response_model=DeleteResponse)
|
||||
def delete_notification(notification_id: str):
|
||||
ok = _scheduler.delete(notification_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail=f"Notification '{notification_id}' not found")
|
||||
return DeleteResponse(ok=True, deleted=notification_id)
|
||||
65
app/routes/sources.py
Normal file
65
app/routes/sources.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from ..schemas import (
|
||||
SourceListResponse,
|
||||
SourceDetailResponse,
|
||||
SourceParam,
|
||||
)
|
||||
from ..core.scheduler import SOURCE_DEFS
|
||||
|
||||
router = APIRouter(prefix="/sources", tags=["sources"])
|
||||
|
||||
SOURCES_JSON = (
|
||||
Path(__file__).resolve().parent.parent.parent.parent
|
||||
/ "waste_lib"
|
||||
/ "custom_components"
|
||||
/ "waste_collection_schedule"
|
||||
/ "sources.json"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=SourceListResponse)
|
||||
def list_sources():
|
||||
return SourceListResponse(
|
||||
count=len(SOURCE_DEFS),
|
||||
sources=[{"id": k, "name": v["name"]} for k, v in SOURCE_DEFS.items()],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/germany")
|
||||
def list_germany_sources():
|
||||
"""
|
||||
Return all 727 German municipalities from sources.json.
|
||||
Includes title, module type, and default_params (if any).
|
||||
"""
|
||||
with open(SOURCES_JSON) as f:
|
||||
data = json.load(f)
|
||||
return {"country": "Germany", "count": len(data["Germany"]), "sources": data["Germany"]}
|
||||
|
||||
|
||||
@router.get("/germany/search")
|
||||
def search_germany_sources(q: str):
|
||||
"""
|
||||
Search Germany municipalities by name (case-insensitive).
|
||||
"""
|
||||
with open(SOURCES_JSON) as f:
|
||||
data = json.load(f)
|
||||
q = q.lower()
|
||||
results = [s for s in data["Germany"] if q in s["title"].lower()]
|
||||
return {"query": q, "count": len(results), "sources": results[:20]}
|
||||
|
||||
|
||||
@router.get("/{source_id}", response_model=SourceDetailResponse)
|
||||
def get_source(source_id: str):
|
||||
if source_id not in SOURCE_DEFS:
|
||||
raise HTTPException(status_code=404, detail=f"Source '{source_id}' not found")
|
||||
s = SOURCE_DEFS[source_id]
|
||||
return SourceDetailResponse(
|
||||
id=source_id,
|
||||
name=s["name"],
|
||||
params=[SourceParam(**p) for p in s["params"]],
|
||||
)
|
||||
100
app/schemas.py
Normal file
100
app/schemas.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ─── Collection ───────────────────────────────────────────────
|
||||
class Collection(BaseModel):
|
||||
date: str = Field(description="Collection date YYYY-MM-DD")
|
||||
type: str = Field(description="Waste type label")
|
||||
daysUntil: int = Field(description="Days until collection")
|
||||
icon: Optional[str] = Field(default=None, description="MDI icon name")
|
||||
|
||||
|
||||
# ─── Source descriptors ────────────────────────────────────────
|
||||
class SourceInfo(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class SourceParam(BaseModel):
|
||||
name: str
|
||||
type: str
|
||||
required: bool
|
||||
example: str
|
||||
|
||||
|
||||
class SourceDetailResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
params: list[SourceParam]
|
||||
|
||||
|
||||
class SourceListResponse(BaseModel):
|
||||
count: int
|
||||
sources: list[SourceInfo]
|
||||
|
||||
|
||||
# ─── Request bodies ────────────────────────────────────────────
|
||||
class ICSRequest(BaseModel):
|
||||
url: str = Field(description="Direct URL to .ics / .ical file")
|
||||
count: int = Field(default=20, ge=1, le=100)
|
||||
|
||||
|
||||
class AbfallIORequest(BaseModel):
|
||||
key: str = Field(description="Abfall.IO API key")
|
||||
f_id_kommune: int = Field(description="Municipality ID")
|
||||
f_id_strasse: int = Field(description="Street ID")
|
||||
count: int = Field(default=20, ge=1, le=100)
|
||||
|
||||
|
||||
class AWMRequest(BaseModel):
|
||||
street: str = Field(description="Street name in Munich")
|
||||
house_number: str = Field(description="House number")
|
||||
count: int = Field(default=20, ge=1, le=100)
|
||||
|
||||
|
||||
class StuttgartRequest(BaseModel):
|
||||
street: str = Field(description="Street name in Stuttgart")
|
||||
streetnr: int = Field(description="House number")
|
||||
count: int = Field(default=20, ge=1, le=100)
|
||||
|
||||
|
||||
class NotifyRequest(BaseModel):
|
||||
source_id: str
|
||||
collection: Collection
|
||||
notify_at_days_before: int = Field(default=1, ge=0)
|
||||
channels: list[str] = Field(description='Notification channels, e.g. ["telegram"]')
|
||||
chat_id: int
|
||||
|
||||
|
||||
# ─── Response wrappers ─────────────────────────────────────────
|
||||
class CollectionsResponse(BaseModel):
|
||||
source: str
|
||||
collections: list[Collection]
|
||||
|
||||
|
||||
class Notification(BaseModel):
|
||||
id: str
|
||||
source_id: str
|
||||
collection_type: str
|
||||
collection_date: str
|
||||
notify_at_days_before: int
|
||||
channels: list[str]
|
||||
chat_id: int
|
||||
fired: bool
|
||||
|
||||
|
||||
class NotificationsResponse(BaseModel):
|
||||
count: int
|
||||
notifications: list[Notification]
|
||||
|
||||
|
||||
class NotifyResponse(BaseModel):
|
||||
ok: bool
|
||||
notification_id: str
|
||||
message: str
|
||||
|
||||
|
||||
class DeleteResponse(BaseModel):
|
||||
ok: bool
|
||||
deleted: str
|
||||
Reference in New Issue
Block a user