Implements idempotent calendar push for Apple Calendar (CalDAV) and Google Calendar from the Interviews kanban. - db: add calendar_event_id column (migration) + set_calendar_event_id helper - integrations/apple_calendar: create_event / update_event via caldav + icalendar - integrations/google_calendar: create_event / update_event via google-api-python-client; test() now makes a real API call instead of checking file existence - scripts/calendar_push: orchestrates push/update, builds event title from stage + job title + company, attaches job URL and company brief to description, defaults to noon UTC / 1hr duration - app/pages/5_Interviews: "Add to Calendar" / "Update Calendar" button shown when interview date is set and a calendar integration is configured - environment.yml: pin caldav, icalendar, google-api-python-client, google-auth - tests/test_calendar_push: 9 tests covering create, update, error handling, event timing, idempotency, and missing job/date guards
106 lines
4.1 KiB
Python
106 lines
4.1 KiB
Python
from __future__ import annotations
|
|
from datetime import datetime, timedelta, timezone
|
|
from scripts.integrations.base import IntegrationBase
|
|
|
|
|
|
class AppleCalendarIntegration(IntegrationBase):
|
|
name = "apple_calendar"
|
|
label = "Apple Calendar (CalDAV)"
|
|
tier = "paid"
|
|
|
|
def __init__(self):
|
|
self._config: dict = {}
|
|
|
|
def fields(self) -> list[dict]:
|
|
return [
|
|
{"key": "caldav_url", "label": "CalDAV URL", "type": "url",
|
|
"placeholder": "https://caldav.icloud.com/", "required": True,
|
|
"help": "iCloud: https://caldav.icloud.com/ | self-hosted: your server URL"},
|
|
{"key": "username", "label": "Apple ID / username", "type": "text",
|
|
"placeholder": "you@icloud.com", "required": True,
|
|
"help": ""},
|
|
{"key": "app_password", "label": "App-Specific Password", "type": "password",
|
|
"placeholder": "xxxx-xxxx-xxxx-xxxx", "required": True,
|
|
"help": "appleid.apple.com → Security → App-Specific Passwords → Generate"},
|
|
{"key": "calendar_name", "label": "Calendar name", "type": "text",
|
|
"placeholder": "Interviews", "required": True,
|
|
"help": "Name of the calendar to write interview events to"},
|
|
]
|
|
|
|
def connect(self, config: dict) -> bool:
|
|
self._config = config
|
|
return bool(
|
|
config.get("caldav_url") and
|
|
config.get("username") and
|
|
config.get("app_password")
|
|
)
|
|
|
|
def test(self) -> bool:
|
|
try:
|
|
import caldav
|
|
client = caldav.DAVClient(
|
|
url=self._config["caldav_url"],
|
|
username=self._config["username"],
|
|
password=self._config["app_password"],
|
|
)
|
|
principal = client.principal()
|
|
return principal is not None
|
|
except Exception:
|
|
return False
|
|
|
|
def _get_calendar(self):
|
|
"""Return the configured caldav Calendar object."""
|
|
import caldav
|
|
client = caldav.DAVClient(
|
|
url=self._config["caldav_url"],
|
|
username=self._config["username"],
|
|
password=self._config["app_password"],
|
|
)
|
|
principal = client.principal()
|
|
cal_name = self._config.get("calendar_name", "Interviews")
|
|
for cal in principal.calendars():
|
|
if cal.name == cal_name:
|
|
return cal
|
|
# Calendar not found — create it
|
|
return principal.make_calendar(name=cal_name)
|
|
|
|
def create_event(self, uid: str, title: str, start_dt: datetime,
|
|
end_dt: datetime, description: str = "") -> str:
|
|
"""Create a calendar event. Returns the UID (used as calendar_event_id)."""
|
|
from icalendar import Calendar, Event
|
|
cal = Calendar()
|
|
cal.add("prodid", "-//CircuitForge Peregrine//EN")
|
|
cal.add("version", "2.0")
|
|
event = Event()
|
|
event.add("uid", uid)
|
|
event.add("summary", title)
|
|
event.add("dtstart", start_dt)
|
|
event.add("dtend", end_dt)
|
|
event.add("description", description)
|
|
cal.add_component(event)
|
|
dav_cal = self._get_calendar()
|
|
dav_cal.add_event(cal.to_ical().decode())
|
|
return uid
|
|
|
|
def update_event(self, uid: str, title: str, start_dt: datetime,
|
|
end_dt: datetime, description: str = "") -> str:
|
|
"""Update an existing event by UID, or create it if not found."""
|
|
from icalendar import Calendar, Event
|
|
dav_cal = self._get_calendar()
|
|
try:
|
|
existing = dav_cal.event_by_uid(uid)
|
|
cal = Calendar()
|
|
cal.add("prodid", "-//CircuitForge Peregrine//EN")
|
|
cal.add("version", "2.0")
|
|
event = Event()
|
|
event.add("uid", uid)
|
|
event.add("summary", title)
|
|
event.add("dtstart", start_dt)
|
|
event.add("dtend", end_dt)
|
|
event.add("description", description)
|
|
cal.add_component(event)
|
|
existing.data = cal.to_ical().decode()
|
|
existing.save()
|
|
except Exception:
|
|
return self.create_event(uid, title, start_dt, end_dt, description)
|
|
return uid
|