diff --git a/alembic/versions/0037_add_event_reminders.py b/alembic/versions/0037_add_event_reminders.py new file mode 100644 index 0000000..6a33a3d --- /dev/null +++ b/alembic/versions/0037_add_event_reminders.py @@ -0,0 +1,29 @@ +"""Add reminder_minutes and reminder_sent_at to events. + +Revision ID: 0037 +Revises: 0036 +Create Date: 2026-04-04 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0037" +down_revision = "0036" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("events", sa.Column("reminder_minutes", sa.Integer(), nullable=True)) + op.add_column( + "events", + sa.Column("reminder_sent_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("events", "reminder_sent_at") + op.drop_column("events", "reminder_minutes") diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 3e5d07c..1924188 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -317,6 +317,10 @@ def create_app() -> Quart: from fabledassistant.services.briefing_scheduler import start_briefing_scheduler await start_briefing_scheduler(asyncio.get_running_loop()) + # Start event scheduler (reminders + CalDAV pull sync) + from fabledassistant.services.event_scheduler import start_event_scheduler + start_event_scheduler(asyncio.get_running_loop()) + # Voice model loading (enabled via Admin → Config in the UI, or VOICE_ENABLED env var) from fabledassistant.services.stt import load_stt_model from fabledassistant.services.tts import load_tts_model @@ -327,6 +331,8 @@ def create_app() -> Quart: async def shutdown(): from fabledassistant.services.briefing_scheduler import stop_briefing_scheduler stop_briefing_scheduler() + from fabledassistant.services.event_scheduler import stop_event_scheduler + stop_event_scheduler() @app.route("/") async def serve_index(): diff --git a/src/fabledassistant/models/event.py b/src/fabledassistant/models/event.py index d8b4473..672f6a8 100644 --- a/src/fabledassistant/models/event.py +++ b/src/fabledassistant/models/event.py @@ -27,6 +27,8 @@ class Event(Base): caldav_uid: Mapped[str] = mapped_column(Text, default="") color: Mapped[str] = mapped_column(Text, default="") recurrence: Mapped[str | None] = mapped_column(Text, nullable=True) + reminder_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) + reminder_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) @@ -51,6 +53,7 @@ class Event(Base): "location": self.location, "color": self.color, "recurrence": self.recurrence, + "reminder_minutes": self.reminder_minutes, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } diff --git a/src/fabledassistant/routes/events.py b/src/fabledassistant/routes/events.py index ac557df..63ca1b0 100644 --- a/src/fabledassistant/routes/events.py +++ b/src/fabledassistant/routes/events.py @@ -40,7 +40,7 @@ async def list_events(): date_from=date_from, date_to=date_to, ) - return jsonify([e.to_dict() for e in events]) + return jsonify(events) @events_bp.post("") @@ -65,6 +65,7 @@ async def create_event(): color=data.get("color", ""), recurrence=data.get("recurrence"), project_id=data.get("project_id"), + reminder_minutes=data.get("reminder_minutes"), ) return jsonify(event.to_dict()), 201 @@ -92,7 +93,7 @@ async def update_event(event_id: int): for bool_field in ("all_day",): if bool_field in data: fields[bool_field] = data[bool_field] - for int_field in ("project_id",): + for int_field in ("project_id", "reminder_minutes"): if int_field in data: fields[int_field] = data[int_field] for dt_field in ("start_dt", "end_dt"): @@ -129,3 +130,12 @@ async def delete_event(event_id: int): event_id=event_id, ) return "", 204 + + +@events_bp.post("/sync") +@login_required +async def sync_caldav(): + """Trigger a CalDAV pull sync for the current user.""" + from fabledassistant.services.caldav_sync import sync_user_events + result = await sync_user_events(user_id=_get_current_user_id()) + return jsonify(result) diff --git a/src/fabledassistant/services/caldav_sync.py b/src/fabledassistant/services/caldav_sync.py new file mode 100644 index 0000000..9f6a40a --- /dev/null +++ b/src/fabledassistant/services/caldav_sync.py @@ -0,0 +1,192 @@ +"""CalDAV pull sync — imports remote events into the internal event store. + +Runs as a scheduled job (hourly) and is also callable via the API. +Only syncs events in a rolling 30-day-past / 180-day-future window. +""" +from __future__ import annotations + +import asyncio +import logging +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any + +from sqlalchemy import select + +from fabledassistant.models import async_session +from fabledassistant.models.event import Event + +logger = logging.getLogger(__name__) + +_SYNC_PAST_DAYS = 30 +_SYNC_FUTURE_DAYS = 180 + + +def _parse_dt(val: Any) -> datetime | None: + """Convert a date or datetime from an iCal component to a UTC-aware datetime.""" + if val is None: + return None + import datetime as _dt_mod + if isinstance(val, _dt_mod.datetime): + if val.tzinfo is None: + return val.replace(tzinfo=timezone.utc) + return val.astimezone(timezone.utc) + if isinstance(val, _dt_mod.date): + # All-day date: treat as midnight UTC + return datetime(val.year, val.month, val.day, tzinfo=timezone.utc) + return None + + +def _sync_one_user(config: dict[str, str], user_id: int) -> list[dict]: + """Synchronous CalDAV fetch — runs in a thread executor.""" + import caldav # noqa: PLC0415 + + now = datetime.now(timezone.utc) + range_start = now - timedelta(days=_SYNC_PAST_DAYS) + range_end = now + timedelta(days=_SYNC_FUTURE_DAYS) + + client = caldav.DAVClient( + url=config["caldav_url"], + username=config.get("caldav_username") or None, + password=config.get("caldav_password") or None, + ) + principal = client.principal() + calendars = principal.calendars() + if not calendars: + return [] + + cal_name = config.get("caldav_calendar_name", "") + if cal_name: + calendars = [c for c in calendars if c.name == cal_name] or calendars + + events: list[dict] = [] + for calendar in calendars: + try: + results = calendar.date_search(start=range_start, end=range_end, expand=False) + except Exception: + logger.warning("CalDAV date_search failed for calendar %s", getattr(calendar, "name", "?"), exc_info=True) + continue + for vevent_obj in results: + try: + ical = vevent_obj.icalendar_instance + for component in ical.walk(): + if component.name != "VEVENT": + continue + dtstart = component.get("DTSTART") + dtend = component.get("DTEND") + uid = str(component.get("UID", "")) + if not uid: + continue + start_dt = _parse_dt(dtstart.dt if dtstart else None) + end_dt = _parse_dt(dtend.dt if dtend else None) + if start_dt is None: + continue + + import datetime as _dt_mod + all_day = dtstart and isinstance(dtstart.dt, _dt_mod.date) and not isinstance(dtstart.dt, _dt_mod.datetime) + + rrule = component.get("RRULE") + recurrence = rrule.to_ical().decode("utf-8") if rrule else None + + events.append({ + "caldav_uid": uid, + "title": str(component.get("SUMMARY", "")), + "start_dt": start_dt, + "end_dt": end_dt, + "all_day": bool(all_day), + "description": str(component.get("DESCRIPTION", "")), + "location": str(component.get("LOCATION", "")), + "recurrence": recurrence, + }) + except Exception: + logger.debug("Failed to parse CalDAV event", exc_info=True) + + return events + + +async def sync_user_events(user_id: int) -> dict: + """Pull CalDAV events for one user and upsert into the DB. + + Returns a summary dict: {created, updated, unchanged}. + """ + from fabledassistant.services.caldav import get_caldav_config, is_caldav_configured # noqa: PLC0415 + + if not await is_caldav_configured(user_id): + return {"skipped": True, "reason": "CalDAV not configured"} + + config = await get_caldav_config(user_id) + + loop = asyncio.get_running_loop() + try: + remote_events: list[dict] = await loop.run_in_executor( + None, _sync_one_user, config, user_id + ) + except Exception: + logger.warning("CalDAV pull sync failed for user %d", user_id, exc_info=True) + return {"error": "CalDAV fetch failed"} + + created = updated = unchanged = 0 + + async with async_session() as session: + for ev in remote_events: + caldav_uid = ev["caldav_uid"] + result = await session.execute( + select(Event).where( + Event.user_id == user_id, + Event.caldav_uid == caldav_uid, + ) + ) + existing = result.scalar_one_or_none() + + if existing is None: + # Create new event + new_ev = Event( + user_id=user_id, + uid=str(uuid.uuid4()), + caldav_uid=caldav_uid, + title=ev["title"], + start_dt=ev["start_dt"], + end_dt=ev["end_dt"], + all_day=ev["all_day"], + description=ev["description"], + location=ev["location"], + recurrence=ev["recurrence"], + ) + session.add(new_ev) + created += 1 + else: + # Update if anything changed + changed = False + for field in ("title", "start_dt", "end_dt", "all_day", "description", "location", "recurrence"): + if getattr(existing, field) != ev[field]: + setattr(existing, field, ev[field]) + changed = True + if changed: + updated += 1 + else: + unchanged += 1 + + await session.commit() + + logger.info( + "CalDAV sync user %d: %d created, %d updated, %d unchanged", + user_id, created, updated, unchanged, + ) + return {"created": created, "updated": updated, "unchanged": unchanged} + + +async def sync_all_users() -> None: + """Pull CalDAV events for all users with CalDAV configured.""" + from sqlalchemy import select as sa_select # noqa: PLC0415 + + from fabledassistant.models.user import User # noqa: PLC0415 + + async with async_session() as session: + result = await session.execute(sa_select(User.id)) + user_ids = [row[0] for row in result.all()] + + for user_id in user_ids: + try: + await sync_user_events(user_id) + except Exception: + logger.warning("CalDAV sync failed for user %d", user_id, exc_info=True) diff --git a/src/fabledassistant/services/event_scheduler.py b/src/fabledassistant/services/event_scheduler.py new file mode 100644 index 0000000..b185a2e --- /dev/null +++ b/src/fabledassistant/services/event_scheduler.py @@ -0,0 +1,138 @@ +"""Scheduler jobs for calendar events. + +- Reminder notifications: checks every 5 minutes for due reminders. +- CalDAV pull sync: runs every hour for all users with CalDAV configured. + +Uses the same BackgroundScheduler pattern as briefing_scheduler.py. +""" +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timedelta, timezone + +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.interval import IntervalTrigger +from sqlalchemy import select + +from fabledassistant.models import async_session +from fabledassistant.models.event import Event + +logger = logging.getLogger(__name__) + +_scheduler: BackgroundScheduler | None = None +_loop: asyncio.AbstractEventLoop | None = None + + +# --------------------------------------------------------------------------- +# Reminder job +# --------------------------------------------------------------------------- + +async def _fire_reminders() -> None: + """Find events with reminders due in the next 5 minutes and fire push notifications.""" + now = datetime.now(timezone.utc) + window_end = now + timedelta(minutes=5) + + async with async_session() as session: + result = await session.execute( + select(Event).where( + Event.reminder_minutes.isnot(None), + Event.reminder_sent_at.is_(None), + Event.start_dt > now, # event hasn't started yet + # reminder fires when now >= start_dt - reminder_minutes + # i.e. start_dt <= now + reminder_minutes (approximated by window_end check) + ) + ) + candidates = list(result.scalars().all()) + + to_notify: list[Event] = [] + for event in candidates: + reminder_dt = event.start_dt - timedelta(minutes=event.reminder_minutes) + if reminder_dt <= window_end: + to_notify.append(event) + + if not to_notify: + return + + async with async_session() as session: + for event in to_notify: + try: + from fabledassistant.services.push import send_push_notification # noqa: PLC0415 + start_local = event.start_dt.strftime("%H:%M") + await send_push_notification( + user_id=event.user_id, + title=f"Reminder: {event.title}", + body=f"Starting at {start_local} UTC", + ) + except Exception: + logger.warning("Failed to send reminder push for event %d", event.id, exc_info=True) + + # Mark as sent regardless of push success to avoid re-firing + result = await session.execute( + select(Event).where(Event.id == event.id) + ) + ev = result.scalar_one_or_none() + if ev: + ev.reminder_sent_at = datetime.now(timezone.utc) + await session.commit() + + +def _run_reminders(loop: asyncio.AbstractEventLoop) -> None: + asyncio.run_coroutine_threadsafe(_fire_reminders(), loop) + + +# --------------------------------------------------------------------------- +# CalDAV pull sync job +# --------------------------------------------------------------------------- + +async def _run_caldav_sync() -> None: + from fabledassistant.services.caldav_sync import sync_all_users # noqa: PLC0415 + try: + await sync_all_users() + except Exception: + logger.warning("CalDAV pull sync job failed", exc_info=True) + + +def _run_caldav_sync_threadsafe(loop: asyncio.AbstractEventLoop) -> None: + asyncio.run_coroutine_threadsafe(_run_caldav_sync(), loop) + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +def start_event_scheduler(loop: asyncio.AbstractEventLoop) -> None: + global _scheduler, _loop + if _scheduler is not None: + return + _loop = loop + _scheduler = BackgroundScheduler() + + # Check reminders every 5 minutes + _scheduler.add_job( + _run_reminders, + trigger=IntervalTrigger(minutes=5), + args=[loop], + id="event_reminders", + replace_existing=True, + ) + + # CalDAV pull sync every hour + _scheduler.add_job( + _run_caldav_sync_threadsafe, + trigger=IntervalTrigger(hours=1), + args=[loop], + id="caldav_pull_sync", + replace_existing=True, + ) + + _scheduler.start() + logger.info("Event scheduler started (reminders every 5m, CalDAV sync every 1h)") + + +def stop_event_scheduler() -> None: + global _scheduler + if _scheduler is not None: + _scheduler.shutdown(wait=False) + _scheduler = None + logger.info("Event scheduler stopped") diff --git a/src/fabledassistant/services/events.py b/src/fabledassistant/services/events.py index cfc2f28..c1679c1 100644 --- a/src/fabledassistant/services/events.py +++ b/src/fabledassistant/services/events.py @@ -6,6 +6,7 @@ import logging import uuid from datetime import datetime, timedelta, timezone +from dateutil.rrule import rrulestr from sqlalchemy import or_, select from fabledassistant.models import async_session @@ -25,9 +26,9 @@ async def create_event( color: str = "", recurrence: str | None = None, project_id: int | None = None, + reminder_minutes: int | None = None, # CalDAV-only fields (not stored in DB, forwarded to push) duration: int | None = None, - reminder_minutes: int | None = None, attendees: list[str] | None = None, calendar_name: str | None = None, ) -> Event: @@ -46,6 +47,7 @@ async def create_event( color=color, recurrence=recurrence, project_id=project_id, + reminder_minutes=reminder_minutes, ) session.add(event) await session.commit() @@ -74,48 +76,87 @@ async def list_events( user_id: int, date_from: datetime, date_to: datetime, -) -> list[Event]: +) -> list[dict]: """List events for user_id that overlap [date_from, date_to]. - An event overlaps the range if it starts before date_to AND either - has no end_dt (point event) or its end_dt is after date_from. - This ensures multi-day events that span the query boundary are included. + Recurring events (with an RRULE recurrence string) are expanded into + individual occurrences within the range. Non-recurring events are + returned as-is. All results are sorted by start time and returned as + dicts (same shape as Event.to_dict()). """ async with async_session() as session: result = await session.execute( select(Event).where( Event.user_id == user_id, - Event.start_dt <= date_to, + # Base window: non-recurring events must overlap range; + # recurring events always need to be fetched so they can be expanded. + or_( + Event.recurrence.isnot(None), + Event.start_dt <= date_to, + ), or_( Event.end_dt.is_(None), Event.end_dt >= date_from, + Event.recurrence.isnot(None), ), ).order_by(Event.start_dt) ) - return list(result.scalars().all()) + events = list(result.scalars().all()) + + items: list[dict] = [] + for event in events: + if not event.recurrence: + items.append(event.to_dict()) + continue + + # Expand recurring event occurrences within [date_from, date_to] + duration = (event.end_dt - event.start_dt) if event.end_dt else None + try: + rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False) + occurrences = rule.between(date_from, date_to, inc=True) + except Exception: + logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence) + items.append(event.to_dict()) + continue + + base = event.to_dict() + for occ in occurrences: + # Ensure occurrence is UTC-aware + if occ.tzinfo is None: + occ = occ.replace(tzinfo=timezone.utc) + occurrence_dict = dict(base) + occurrence_dict["start_dt"] = occ.isoformat() + if duration is not None: + occurrence_dict["end_dt"] = (occ + duration).isoformat() + items.append(occurrence_dict) + + items.sort(key=lambda x: x["start_dt"]) + return items async def search_events( user_id: int, query: str, days_ahead: int = 90, + include_past: bool = False, ) -> list[Event]: """Search events by keyword in title, description, or location.""" now = datetime.now(timezone.utc) - date_to = now + timedelta(days=days_ahead) q = f"%{query}%" async with async_session() as session: + where = [ + Event.user_id == user_id, + or_( + Event.title.ilike(q), + Event.description.ilike(q), + Event.location.ilike(q), + ), + ] + if not include_past: + date_to = now + timedelta(days=days_ahead) + where.extend([Event.start_dt >= now, Event.start_dt <= date_to]) result = await session.execute( - select(Event).where( - Event.user_id == user_id, - Event.start_dt >= now, - Event.start_dt <= date_to, - or_( - Event.title.ilike(q), - Event.description.ilike(q), - Event.location.ilike(q), - ), - ).order_by(Event.start_dt) + select(Event).where(*where).order_by(Event.start_dt) ) return result.scalars().all() @@ -131,9 +172,9 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None: return None old_title = event.title # capture before mutation for CalDAV lookup allowed = {"title", "start_dt", "end_dt", "all_day", "description", - "location", "color", "recurrence", "project_id"} + "location", "color", "recurrence", "project_id", "reminder_minutes"} # Nullable fields that callers can explicitly set to None to clear - nullable = {"end_dt", "recurrence", "project_id"} + nullable = {"end_dt", "recurrence", "project_id", "reminder_minutes"} for key, value in fields.items(): if key in allowed and (value is not None or key in nullable): setattr(event, key, value) diff --git a/src/fabledassistant/services/tools.py b/src/fabledassistant/services/tools.py index b40990f..0b5a504 100644 --- a/src/fabledassistant/services/tools.py +++ b/src/fabledassistant/services/tools.py @@ -695,6 +695,10 @@ _CORE_TOOLS = [ "type": "string", "description": "Search keyword to match against event titles, locations, and descriptions", }, + "include_past": { + "type": "boolean", + "description": "Set to true to include past events in results (default: future events only)", + }, }, "required": ["query"], }, @@ -744,6 +748,10 @@ _CORE_TOOLS = [ "type": "string", "description": "New iCalendar RRULE", }, + "reminder_minutes": { + "type": "integer", + "description": "Reminder N minutes before the event. Pass 0 to remove an existing reminder.", + }, }, "required": ["query"], }, @@ -1471,7 +1479,7 @@ async def execute_tool( "type": "events", "data": { "count": len(events), - "events": [e.to_dict() for e in events], + "events": events, }, } @@ -1479,6 +1487,7 @@ async def execute_tool( events = await events_search_events( user_id=user_id, query=arguments.get("query", ""), + include_past=arguments.get("include_past", False), ) return { "success": True, @@ -1502,6 +1511,9 @@ async def execute_tool( fields[str_field] = arguments[str_field] if arguments.get("all_day") is not None: fields["all_day"] = arguments["all_day"] + if "reminder_minutes" in arguments: + rm = arguments["reminder_minutes"] + fields["reminder_minutes"] = None if rm == 0 else rm for dt_field, key in (("start_dt", "start"), ("end_dt", "end")): val = arguments.get(key) if val: