Merge pull request 'feat: push interview events to connected calendar integrations' (#20) from feature/calendar-push into main

Reviewed-on: #20
This commit is contained in:
pyr0ball 2026-03-16 21:45:06 -07:00
commit b6e16eb6e9
9 changed files with 496 additions and 15 deletions

View file

@ -220,7 +220,7 @@ with mid:
disabled=unscored == 0): disabled=unscored == 0):
with st.spinner("Scoring…"): with st.spinner("Scoring…"):
result = subprocess.run( result = subprocess.run(
["conda", "run", "-n", "job-seeker", "python", "scripts/match.py"], [sys.executable, "scripts/match.py"],
capture_output=True, text=True, capture_output=True, text=True,
cwd=str(Path(__file__).parent.parent), cwd=str(Path(__file__).parent.parent),
) )

View file

@ -40,6 +40,26 @@ def _extract_session_token(cookie_header: str) -> str:
return m.group(1).strip() if m else "" return m.group(1).strip() if m else ""
def _ensure_provisioned(user_id: str, product: str) -> None:
"""Call Heimdall /admin/provision for this user if no key exists yet.
Idempotent Heimdall does nothing if a key already exists for this
(user_id, product) pair. Called once per session start so new Google
OAuth signups get a free key created automatically.
"""
if not HEIMDALL_ADMIN_TOKEN:
return
try:
requests.post(
f"{HEIMDALL_URL}/admin/provision",
json={"directus_user_id": user_id, "product": product, "tier": "free"},
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
timeout=5,
)
except Exception as exc:
log.warning("Heimdall provision failed for user %s: %s", user_id, exc)
@st.cache_data(ttl=300, show_spinner=False) @st.cache_data(ttl=300, show_spinner=False)
def _fetch_cloud_tier(user_id: str, product: str) -> str: def _fetch_cloud_tier(user_id: str, product: str) -> str:
"""Call Heimdall to resolve the current cloud tier for this user. """Call Heimdall to resolve the current cloud tier for this user.
@ -145,12 +165,19 @@ def resolve_session(app: str = "peregrine") -> None:
user_path = _user_data_path(user_id, app) user_path = _user_data_path(user_id, app)
user_path.mkdir(parents=True, exist_ok=True) user_path.mkdir(parents=True, exist_ok=True)
(user_path / "config").mkdir(exist_ok=True) config_path = user_path / "config"
config_path.mkdir(exist_ok=True)
(user_path / "data").mkdir(exist_ok=True) (user_path / "data").mkdir(exist_ok=True)
# Bootstrap config files that the UI requires to exist — never overwrite
_kw = config_path / "resume_keywords.yaml"
if not _kw.exists():
_kw.write_text("skills: []\ndomains: []\nkeywords: []\n")
st.session_state["user_id"] = user_id st.session_state["user_id"] = user_id
st.session_state["db_path"] = user_path / "staging.db" st.session_state["db_path"] = user_path / "staging.db"
st.session_state["db_key"] = derive_db_key(user_id) st.session_state["db_key"] = derive_db_key(user_id)
_ensure_provisioned(user_id, app)
st.session_state["cloud_tier"] = _fetch_cloud_tier(user_id, app) st.session_state["cloud_tier"] = _fetch_cloud_tier(user_id, app)

View file

@ -31,12 +31,19 @@ _name = _profile.name if _profile else "Job Seeker"
from scripts.db import ( from scripts.db import (
DEFAULT_DB, init_db, DEFAULT_DB, init_db,
get_interview_jobs, advance_to_stage, reject_at_stage, get_interview_jobs, advance_to_stage, reject_at_stage,
set_interview_date, add_contact, get_contacts, set_interview_date, set_calendar_event_id, add_contact, get_contacts,
get_research, get_task_for_job, get_job_by_id, get_research, get_task_for_job, get_job_by_id,
get_unread_stage_signals, dismiss_stage_signal, get_unread_stage_signals, dismiss_stage_signal,
) )
from scripts.task_runner import submit_task from scripts.task_runner import submit_task
_CONFIG_DIR = Path(__file__).parent.parent.parent / "config"
_CALENDAR_INTEGRATIONS = ("apple_calendar", "google_calendar")
_calendar_connected = any(
(_CONFIG_DIR / "integrations" / f"{n}.yaml").exists()
for n in _CALENDAR_INTEGRATIONS
)
st.title("🎯 Interviews") st.title("🎯 Interviews")
init_db(DEFAULT_DB) init_db(DEFAULT_DB)
@ -275,6 +282,19 @@ def _render_card(job: dict, stage: str, compact: bool = False) -> None:
st.success("Saved!") st.success("Saved!")
st.rerun() st.rerun()
# Calendar push — only shown when a date is saved and an integration is connected
if current_idate and _calendar_connected:
_has_event = bool(job.get("calendar_event_id"))
_cal_label = "🔄 Update Calendar" if _has_event else "📅 Add to Calendar"
if st.button(_cal_label, key=f"cal_push_{job_id}", use_container_width=True):
from scripts.calendar_push import push_interview_event
result = push_interview_event(DEFAULT_DB, job_id=job_id, config_dir=_CONFIG_DIR)
if result["ok"]:
st.success(f"Event {'updated' if _has_event else 'added'} ({result['provider'].replace('_', ' ').title()})")
st.rerun()
else:
st.error(result["error"])
if not compact: if not compact:
if stage in ("applied", "phone_screen", "interviewing"): if stage in ("applied", "phone_screen", "interviewing"):
signals = get_unread_stage_signals(DEFAULT_DB, job_id=job_id) signals = get_unread_stage_signals(DEFAULT_DB, job_id=job_id)

View file

@ -48,6 +48,12 @@ dependencies:
# ── Notion integration ──────────────────────────────────────────────────── # ── Notion integration ────────────────────────────────────────────────────
- notion-client>=3.0 - notion-client>=3.0
# ── Calendar integrations ─────────────────────────────────────────────────
- caldav>=1.3
- icalendar>=5.0
- google-api-python-client>=2.0
- google-auth>=2.0
# ── Document handling ───────────────────────────────────────────────────── # ── Document handling ─────────────────────────────────────────────────────
- pypdf - pypdf
- pdfminer-six - pdfminer-six

119
scripts/calendar_push.py Normal file
View file

@ -0,0 +1,119 @@
"""calendar_push.py — push interview events to connected calendar integrations.
Supports Apple Calendar (CalDAV) and Google Calendar. Idempotent: a second
push updates the existing event rather than creating a duplicate.
"""
from __future__ import annotations
import uuid
import yaml
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional
from scripts.db import get_job_by_id, get_research, set_calendar_event_id, DEFAULT_DB
_CALENDAR_INTEGRATIONS = ("apple_calendar", "google_calendar")
# Stage label map matches 5_Interviews.py
_STAGE_LABELS = {
"phone_screen": "Phone Screen",
"interviewing": "Interview",
"offer": "Offer Review",
}
def _load_integration(name: str, config_dir: Path):
"""Instantiate and connect an integration from its saved config file."""
config_file = config_dir / "integrations" / f"{name}.yaml"
if not config_file.exists():
return None
with open(config_file) as f:
config = yaml.safe_load(f) or {}
if name == "apple_calendar":
from scripts.integrations.apple_calendar import AppleCalendarIntegration
integration = AppleCalendarIntegration()
elif name == "google_calendar":
from scripts.integrations.google_calendar import GoogleCalendarIntegration
integration = GoogleCalendarIntegration()
else:
return None
integration.connect(config)
return integration
def _build_event_details(job: dict, research: Optional[dict]) -> tuple[str, str]:
"""Return (title, description) for the calendar event."""
stage_label = _STAGE_LABELS.get(job.get("status", ""), "Interview")
title = f"{stage_label}: {job.get('title', 'Interview')} @ {job.get('company', '')}"
lines = []
if job.get("url"):
lines.append(f"Job listing: {job['url']}")
if research and research.get("company_brief"):
brief = research["company_brief"].strip()
# Trim to first 3 sentences so the event description stays readable
sentences = brief.split(". ")
lines.append("\n" + ". ".join(sentences[:3]) + ("." if len(sentences) > 1 else ""))
lines.append("\n— Sent by Peregrine (CircuitForge)")
return title, "\n".join(lines)
def push_interview_event(
db_path: Path = DEFAULT_DB,
job_id: int = None,
config_dir: Path = None,
) -> dict:
"""Push (or update) an interview event on the first connected calendar integration.
Returns:
{"ok": True, "provider": "apple_calendar", "event_id": "..."}
{"ok": False, "error": "..."}
"""
if config_dir is None:
config_dir = Path(__file__).parent.parent / "config"
job = get_job_by_id(db_path, job_id)
if not job:
return {"ok": False, "error": f"Job {job_id} not found"}
interview_date = job.get("interview_date")
if not interview_date:
return {"ok": False, "error": "No interview date set — save a date first"}
# Build datetimes: noon UTC, 1 hour duration
try:
base = datetime.fromisoformat(interview_date).replace(
hour=12, minute=0, second=0, microsecond=0, tzinfo=timezone.utc
)
except ValueError:
return {"ok": False, "error": f"Could not parse interview_date: {interview_date!r}"}
start_dt = base
end_dt = base + timedelta(hours=1)
research = get_research(db_path, job_id)
title, description = _build_event_details(job, research)
existing_event_id = job.get("calendar_event_id")
for name in _CALENDAR_INTEGRATIONS:
integration = _load_integration(name, config_dir)
if integration is None:
continue
try:
# Use a stable UID derived from job_id for CalDAV; gcal uses the returned event id
uid = existing_event_id or f"peregrine-job-{job_id}@circuitforge.tech"
if existing_event_id:
event_id = integration.update_event(uid, title, start_dt, end_dt, description)
else:
event_id = integration.create_event(uid, title, start_dt, end_dt, description)
set_calendar_event_id(db_path, job_id, event_id)
return {"ok": True, "provider": name, "event_id": event_id}
except Exception as exc:
return {"ok": False, "error": str(exc)}
return {"ok": False, "error": "No calendar integration configured — connect one in Settings → Integrations"}

View file

@ -138,15 +138,16 @@ CREATE TABLE IF NOT EXISTS survey_responses (
""" """
_MIGRATIONS = [ _MIGRATIONS = [
("cover_letter", "TEXT"), ("cover_letter", "TEXT"),
("applied_at", "TEXT"), ("applied_at", "TEXT"),
("interview_date", "TEXT"), ("interview_date", "TEXT"),
("rejection_stage", "TEXT"), ("rejection_stage", "TEXT"),
("phone_screen_at", "TEXT"), ("phone_screen_at", "TEXT"),
("interviewing_at", "TEXT"), ("interviewing_at", "TEXT"),
("offer_at", "TEXT"), ("offer_at", "TEXT"),
("hired_at", "TEXT"), ("hired_at", "TEXT"),
("survey_at", "TEXT"), ("survey_at", "TEXT"),
("calendar_event_id", "TEXT"),
] ]
@ -508,6 +509,15 @@ def set_interview_date(db_path: Path = DEFAULT_DB, job_id: int = None,
conn.close() conn.close()
def set_calendar_event_id(db_path: Path = DEFAULT_DB, job_id: int = None,
event_id: str = "") -> None:
"""Persist the calendar event ID returned after a successful push."""
conn = sqlite3.connect(db_path)
conn.execute("UPDATE jobs SET calendar_event_id = ? WHERE id = ?", (event_id, job_id))
conn.commit()
conn.close()
# ── Contact log helpers ─────────────────────────────────────────────────────── # ── Contact log helpers ───────────────────────────────────────────────────────
def add_contact(db_path: Path = DEFAULT_DB, job_id: int = None, def add_contact(db_path: Path = DEFAULT_DB, job_id: int = None,

View file

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone
from scripts.integrations.base import IntegrationBase from scripts.integrations.base import IntegrationBase
@ -46,3 +47,60 @@ class AppleCalendarIntegration(IntegrationBase):
return principal is not None return principal is not None
except Exception: except Exception:
return False 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

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import os import os
from datetime import datetime
from scripts.integrations.base import IntegrationBase from scripts.integrations.base import IntegrationBase
@ -26,6 +27,53 @@ class GoogleCalendarIntegration(IntegrationBase):
return bool(config.get("calendar_id") and config.get("credentials_json")) return bool(config.get("calendar_id") and config.get("credentials_json"))
def test(self) -> bool: def test(self) -> bool:
# TODO: use google-api-python-client calendars().get() try:
creds = os.path.expanduser(self._config.get("credentials_json", "")) service = self._build_service()
return os.path.exists(creds) service.calendars().get(calendarId=self._config["calendar_id"]).execute()
return True
except Exception:
return False
def _build_service(self):
from google.oauth2 import service_account
from googleapiclient.discovery import build
creds_path = os.path.expanduser(self._config["credentials_json"])
creds = service_account.Credentials.from_service_account_file(
creds_path,
scopes=["https://www.googleapis.com/auth/calendar"],
)
return build("calendar", "v3", credentials=creds)
def _fmt(self, dt: datetime) -> str:
return dt.strftime("%Y-%m-%dT%H:%M:%S") + "Z"
def create_event(self, uid: str, title: str, start_dt: datetime,
end_dt: datetime, description: str = "") -> str:
"""Create a Google Calendar event. Returns the Google event ID."""
service = self._build_service()
body = {
"summary": title,
"description": description,
"start": {"dateTime": self._fmt(start_dt), "timeZone": "UTC"},
"end": {"dateTime": self._fmt(end_dt), "timeZone": "UTC"},
"extendedProperties": {"private": {"peregrine_uid": uid}},
}
result = service.events().insert(
calendarId=self._config["calendar_id"], body=body
).execute()
return result["id"]
def update_event(self, uid: str, title: str, start_dt: datetime,
end_dt: datetime, description: str = "") -> str:
"""Update an existing Google Calendar event by its stored event ID (uid is the gcal id)."""
service = self._build_service()
body = {
"summary": title,
"description": description,
"start": {"dateTime": self._fmt(start_dt), "timeZone": "UTC"},
"end": {"dateTime": self._fmt(end_dt), "timeZone": "UTC"},
}
result = service.events().update(
calendarId=self._config["calendar_id"], eventId=uid, body=body
).execute()
return result["id"]

193
tests/test_calendar_push.py Normal file
View file

@ -0,0 +1,193 @@
# tests/test_calendar_push.py
"""Unit tests for scripts/calendar_push.py.
Integration classes are mocked no real CalDAV or Google API calls.
"""
import sys
from datetime import timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
# ── Fixtures ──────────────────────────────────────────────────────────────────
def _make_db(tmp_path, interview_date="2026-04-15", calendar_event_id=None):
from scripts.db import init_db, insert_job, set_interview_date, set_calendar_event_id
db = tmp_path / "test.db"
init_db(db)
job_id = insert_job(db, {
"title": "Customer Success Manager", "company": "Acme Corp",
"url": "https://example.com/job/1", "source": "linkedin",
"location": "Remote", "is_remote": True,
"salary": "", "description": "Great role.", "date_found": "2026-04-01",
"status": "phone_screen",
})
if interview_date:
set_interview_date(db, job_id=job_id, date_str=interview_date)
if calendar_event_id:
set_calendar_event_id(db, job_id=job_id, event_id=calendar_event_id)
return db, job_id
def _config_dir_with(tmp_path, integration_name: str) -> Path:
"""Create a minimal integration config file and return the config dir."""
integrations_dir = tmp_path / "config" / "integrations"
integrations_dir.mkdir(parents=True)
(integrations_dir / f"{integration_name}.yaml").write_text(
"caldav_url: https://caldav.example.com/\n"
"username: user@example.com\n"
"app_password: test-password\n"
"calendar_name: Interviews\n"
)
return tmp_path / "config"
# ── No integration configured ─────────────────────────────────────────────────
def test_push_returns_error_when_no_integration_configured(tmp_path):
db, job_id = _make_db(tmp_path)
config_dir = tmp_path / "config"
config_dir.mkdir()
from scripts.calendar_push import push_interview_event
result = push_interview_event(db, job_id=job_id, config_dir=config_dir)
assert result["ok"] is False
assert "No calendar integration" in result["error"]
# ── No interview date ─────────────────────────────────────────────────────────
def test_push_returns_error_when_no_interview_date(tmp_path):
db, job_id = _make_db(tmp_path, interview_date=None)
config_dir = _config_dir_with(tmp_path, "apple_calendar")
from scripts.calendar_push import push_interview_event
result = push_interview_event(db, job_id=job_id, config_dir=config_dir)
assert result["ok"] is False
assert "No interview date" in result["error"]
# ── Successful create ─────────────────────────────────────────────────────────
def test_push_creates_event_and_stores_event_id(tmp_path):
db, job_id = _make_db(tmp_path)
config_dir = _config_dir_with(tmp_path, "apple_calendar")
mock_integration = MagicMock()
mock_integration.create_event.return_value = "peregrine-job-1@circuitforge.tech"
with patch("scripts.calendar_push._load_integration", return_value=mock_integration):
from scripts.calendar_push import push_interview_event
result = push_interview_event(db, job_id=job_id, config_dir=config_dir)
assert result["ok"] is True
assert result["event_id"] == "peregrine-job-1@circuitforge.tech"
mock_integration.create_event.assert_called_once()
def test_push_event_title_includes_stage_and_company(tmp_path):
db, job_id = _make_db(tmp_path)
from scripts.db import advance_to_stage
advance_to_stage(db, job_id=job_id, stage="phone_screen")
config_dir = _config_dir_with(tmp_path, "apple_calendar")
mock_integration = MagicMock()
mock_integration.create_event.return_value = "uid-123"
with patch("scripts.calendar_push._load_integration", return_value=mock_integration):
from scripts.calendar_push import push_interview_event
push_interview_event(db, job_id=job_id, config_dir=config_dir)
call_kwargs = mock_integration.create_event.call_args
title = call_kwargs.args[1] if call_kwargs.args else call_kwargs.kwargs.get("title", "")
assert "Acme Corp" in title
assert "Phone Screen" in title
def test_push_event_start_is_noon_utc(tmp_path):
db, job_id = _make_db(tmp_path, interview_date="2026-04-15")
config_dir = _config_dir_with(tmp_path, "apple_calendar")
mock_integration = MagicMock()
mock_integration.create_event.return_value = "uid-abc"
with patch("scripts.calendar_push._load_integration", return_value=mock_integration):
from scripts.calendar_push import push_interview_event
push_interview_event(db, job_id=job_id, config_dir=config_dir)
call_args = mock_integration.create_event.call_args.args
start_dt = call_args[2]
assert start_dt.hour == 12
assert start_dt.tzinfo == timezone.utc
def test_push_event_duration_is_one_hour(tmp_path):
db, job_id = _make_db(tmp_path, interview_date="2026-04-15")
config_dir = _config_dir_with(tmp_path, "apple_calendar")
mock_integration = MagicMock()
mock_integration.create_event.return_value = "uid-abc"
with patch("scripts.calendar_push._load_integration", return_value=mock_integration):
from scripts.calendar_push import push_interview_event
push_interview_event(db, job_id=job_id, config_dir=config_dir)
call_args = mock_integration.create_event.call_args.args
start_dt, end_dt = call_args[2], call_args[3]
assert (end_dt - start_dt).seconds == 3600
# ── Idempotent update ─────────────────────────────────────────────────────────
def test_push_calls_update_when_event_id_already_exists(tmp_path):
db, job_id = _make_db(tmp_path, calendar_event_id="existing-event-id")
config_dir = _config_dir_with(tmp_path, "apple_calendar")
mock_integration = MagicMock()
mock_integration.update_event.return_value = "existing-event-id"
with patch("scripts.calendar_push._load_integration", return_value=mock_integration):
from scripts.calendar_push import push_interview_event
result = push_interview_event(db, job_id=job_id, config_dir=config_dir)
assert result["ok"] is True
mock_integration.update_event.assert_called_once()
mock_integration.create_event.assert_not_called()
# ── Integration error handling ────────────────────────────────────────────────
def test_push_returns_error_on_integration_exception(tmp_path):
db, job_id = _make_db(tmp_path)
config_dir = _config_dir_with(tmp_path, "apple_calendar")
mock_integration = MagicMock()
mock_integration.create_event.side_effect = RuntimeError("CalDAV server unreachable")
with patch("scripts.calendar_push._load_integration", return_value=mock_integration):
from scripts.calendar_push import push_interview_event
result = push_interview_event(db, job_id=job_id, config_dir=config_dir)
assert result["ok"] is False
assert "CalDAV server unreachable" in result["error"]
# ── Missing job ───────────────────────────────────────────────────────────────
def test_push_returns_error_for_unknown_job_id(tmp_path):
from scripts.db import init_db
db = tmp_path / "test.db"
init_db(db)
config_dir = _config_dir_with(tmp_path, "apple_calendar")
from scripts.calendar_push import push_interview_event
result = push_interview_event(db, job_id=9999, config_dir=config_dir)
assert result["ok"] is False
assert "9999" in result["error"]