scheduler / core /google_services.py
umangchaudhry's picture
Upload 31 files
0d04b76 verified
Raw
History Blame Contribute Delete
17.6 kB
"""
Google Services — Calendar, Maps, and Gmail integrations.
"""
import os
import base64
import json
import math
import uuid
from datetime import datetime, timedelta
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from google.oauth2 import service_account
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
import googlemaps
import config
from core import settings_manager as settings
def get_secret(key: str) -> str:
"""Retrieve a secret from environment variables."""
return os.environ.get(key)
# ============================================================
# Google Calendar
# ============================================================
def get_calendar_service():
"""Initialize and return a Google Calendar API service client.
Uses GOOGLE_CALENDAR_CREDENTIALS from secrets (base64-encoded service account JSON).
"""
credentials_b64 = get_secret("GOOGLE_CALENDAR_CREDENTIALS")
credentials_json = base64.b64decode(credentials_b64).decode("utf-8")
credentials_info = json.loads(credentials_json)
credentials = service_account.Credentials.from_service_account_info(
credentials_info,
scopes=["https://www.googleapis.com/auth/calendar"]
)
service = build("calendar", "v3", credentials=credentials)
return service
def create_calendar_event(summary: str, description: str, location: str, start_datetime: str, end_datetime: str) -> str:
"""Create an event on the shared Google Calendar.
Args:
summary: Event title, e.g., "[Level 1] Robert Williams — John Moustoukas, MD"
description: Event details (addresses, contact info)
location: Visit address
start_datetime: ISO format datetime string
end_datetime: ISO format datetime string
Returns:
The created event's Google Calendar event ID
"""
service = get_calendar_service()
calendar_id = get_secret("SHARED_CALENDAR_ID")
event = {
"summary": summary,
"description": description,
"location": location,
"start": {
"dateTime": start_datetime,
"timeZone": settings.get_timezone(),
},
"end": {
"dateTime": end_datetime,
"timeZone": settings.get_timezone(),
},
}
created_event = service.events().insert(calendarId=calendar_id, body=event).execute()
return created_event["id"]
def delete_calendar_event(event_id: str) -> bool:
"""Delete an event from the shared Google Calendar.
Returns True if successful.
"""
try:
service = get_calendar_service()
calendar_id = get_secret("SHARED_CALENDAR_ID")
service.events().delete(calendarId=calendar_id, eventId=event_id).execute()
return True
except Exception:
return False
def get_calendar_events(date: str) -> list[dict]:
"""Fetch all events from the shared calendar for a given date (YYYY-MM-DD).
Returns list of event dicts with 'summary', 'start', 'end', 'id'.
"""
service = get_calendar_service()
calendar_id = get_secret("SHARED_CALENDAR_ID")
# Create time bounds for the given date
date_obj = datetime.strptime(date, "%Y-%m-%d")
time_min = date_obj.isoformat()+"Z"# + "T00:00:00Z" claude messed up here
time_max = (date_obj + timedelta(days=1)).isoformat()+"Z"# + "T00:00:00Z"
print(time_min, time_max)
events_result = service.events().list(
calendarId=calendar_id,
timeMin=time_min,
timeMax=time_max,
singleEvents=True,
orderBy="startTime"
).execute()
events = events_result.get("items", [])
return [
{
"id": event["id"],
"summary": event.get("summary", ""),
"start": event["start"].get("dateTime", event["start"].get("date")),
"end": event["end"].get("dateTime", event["end"].get("date")),
}
for event in events
]
# ============================================================
# Google Maps
# ============================================================
def get_maps_client():
"""Initialize and return a Google Maps client.
Uses GOOGLE_MAPS_API_KEY from secrets.
"""
return googlemaps.Client(key=get_secret("GOOGLE_MAPS_API_KEY"))
def calculate_travel_time(origin_address: str, destination_address: str, arrival_by: datetime) -> dict:
"""Calculate driving travel time between two addresses using traffic-aware two-pass estimation.
Pass 1: Get rough travel duration (no departure_time).
Pass 2: Estimate departure = arrival_by - rough duration, then re-query with departure_time
to get duration_in_traffic.
Final recommended departure = arrival_by - traffic-aware duration.
Args:
origin_address: Full address string
destination_address: Full address string
arrival_by: When the provider must arrive (appointment start datetime)
Returns:
Dict with 'travel_minutes' (int) and 'recommended_departure' (datetime).
Returns travel_minutes=0 if same address.
Returns travel_minutes=-1 if the API call fails.
"""
if origin_address.strip().lower() == destination_address.strip().lower():
return {"travel_minutes": 0, "recommended_departure": arrival_by}
try:
client = get_maps_client()
# Pass 1: rough estimate (no traffic)
result = client.distance_matrix(
origins=[origin_address],
destinations=[destination_address],
mode="driving"
)
element = result["rows"][0]["elements"][0]
if element["status"] != "OK":
return {"travel_minutes": -1, "recommended_departure": arrival_by}
rough_seconds = element["duration"]["value"]
rough_minutes = math.ceil(rough_seconds / 60)
rough_departure = arrival_by - timedelta(minutes=rough_minutes)
# Pass 2: traffic-aware estimate using rough departure time
result = client.distance_matrix(
origins=[origin_address],
destinations=[destination_address],
mode="driving",
departure_time=rough_departure
)
element = result["rows"][0]["elements"][0]
if element["status"] != "OK":
return {"travel_minutes": rough_minutes, "recommended_departure": arrival_by - timedelta(minutes=rough_minutes)}
traffic_seconds = element.get("duration_in_traffic", element["duration"])["value"]
traffic_minutes = math.ceil(traffic_seconds / 60)
recommended_departure = arrival_by - timedelta(minutes=traffic_minutes)
return {"travel_minutes": traffic_minutes, "recommended_departure": recommended_departure}
except Exception:
return {"travel_minutes": -1, "recommended_departure": arrival_by}
def geocode_address(address: str) -> tuple[float, float] | None:
"""Convert an address string to (latitude, longitude).
Returns None if geocoding fails.
"""
try:
client = get_maps_client()
result = client.geocode(address)
if not result:
return None
location = result[0]["geometry"]["location"]
return (location["lat"], location["lng"])
except Exception:
return None
def calculate_travel_times_batch(origin: str, destinations: list[str], arrival_by: datetime) -> list[dict]:
"""Calculate travel times from one origin to multiple destinations using traffic-aware two-pass estimation.
Pass 1: Get rough durations for all destinations (no departure_time).
Pass 2: Use the max rough duration to compute a single departure_time for the batch,
then re-query to get duration_in_traffic per destination.
Returns a list of dicts with 'travel_minutes' and 'recommended_departure', in the same order as destinations.
"""
if not destinations:
return []
try:
client = get_maps_client()
# Pass 1: rough estimates (no traffic)
result = client.distance_matrix(
origins=[origin],
destinations=destinations,
mode="driving"
)
rough_seconds_list = []
for element in result["rows"][0]["elements"]:
if element["status"] == "OK":
rough_seconds_list.append(element["duration"]["value"])
else:
rough_seconds_list.append(-1)
# Use max rough duration to compute a single departure_time for the batch
valid_seconds = [s for s in rough_seconds_list if s >= 0]
if not valid_seconds:
return [{"travel_minutes": -1, "recommended_departure": arrival_by} for _ in destinations]
max_rough_minutes = math.ceil(max(valid_seconds) / 60)
rough_departure = arrival_by - timedelta(minutes=max_rough_minutes)
# Pass 2: traffic-aware estimates
result = client.distance_matrix(
origins=[origin],
destinations=destinations,
mode="driving",
departure_time=rough_departure
)
travel_results = []
for element in result["rows"][0]["elements"]:
if element["status"] == "OK":
traffic_seconds = element.get("duration_in_traffic", element["duration"])["value"]
traffic_minutes = math.ceil(traffic_seconds / 60)
travel_results.append({
"travel_minutes": traffic_minutes,
"recommended_departure": arrival_by - timedelta(minutes=traffic_minutes),
})
else:
travel_results.append({"travel_minutes": -1, "recommended_departure": arrival_by})
return travel_results
except Exception:
return [{"travel_minutes": -1, "recommended_departure": arrival_by} for _ in destinations]
# ============================================================
# Gmail
# ============================================================
def _build_ics(appointment_details: dict, method: str = "REQUEST") -> str:
"""Build an .ics calendar string from appointment details.
Args:
appointment_details: Dict with date, start_time, end_time, patient_name,
provider_name, address.
method: VCALENDAR METHOD — "REQUEST" for new/updated, "CANCEL" for cancellations.
"""
appt_date = appointment_details.get("date", "")
start_time = appointment_details.get("start_time", "00:00")
end_time = appointment_details.get("end_time", "00:00")
patient_name = appointment_details.get("patient_name", "")
provider_name = appointment_details.get("provider_name", "")
address = appointment_details.get("address", "")
dt_start = datetime.strptime(f"{appt_date} {start_time}", "%Y-%m-%d %H:%M")
dt_end = datetime.strptime(f"{appt_date} {end_time}", "%Y-%m-%d %H:%M")
now = datetime.utcnow()
uid = uuid.uuid5(uuid.NAMESPACE_URL, f"{appt_date}-{start_time}-{patient_name}-{provider_name}")
return (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//SOS Care Now//Scheduling//EN\r\n"
f"METHOD:{method}\r\n"
"BEGIN:VEVENT\r\n"
f"UID:{uid}\r\n"
f"DTSTAMP:{now:%Y%m%dT%H%M%SZ}\r\n"
f"DTSTART;TZID={settings.get_timezone()}:{dt_start:%Y%m%dT%H%M%S}\r\n"
f"DTEND;TZID={settings.get_timezone()}:{dt_end:%Y%m%dT%H%M%S}\r\n"
f"SUMMARY:{config.ORG_NAME}{patient_name} with {provider_name}\r\n"
f"LOCATION:{address}\r\n"
f"STATUS:{'CANCELLED' if method == 'CANCEL' else 'CONFIRMED'}\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
def get_gmail_service():
"""Initialize and return a Gmail API service client.
Uses GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, and GMAIL_REFRESH_TOKEN from
secrets. The refresh token is exchanged for a short-lived access token by
the underlying google-auth library on each request.
"""
creds = Credentials.from_authorized_user_info(
{
"client_id": get_secret("GMAIL_CLIENT_ID"),
"client_secret": get_secret("GMAIL_CLIENT_SECRET"),
"refresh_token": get_secret("GMAIL_REFRESH_TOKEN"),
},
scopes=["https://www.googleapis.com/auth/gmail.send"],
)
return build("gmail", "v1", credentials=creds, cache_discovery=False)
def send_email(to_address: str, subject: str, body: str, ics_content: str | None = None) -> bool:
"""Send an email via the Gmail API.
Uses GMAIL_ADDRESS as the From header and the OAuth credentials in
GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET / GMAIL_REFRESH_TOKEN.
If ics_content is provided, attaches it as a .ics calendar invite.
Returns True if sent successfully.
"""
try:
gmail_address = get_secret("GMAIL_ADDRESS")
message = MIMEMultipart()
message["From"] = gmail_address
message["To"] = to_address
message["Subject"] = subject
message.attach(MIMEText(body, "plain"))
if ics_content:
ics_part = MIMEBase("text", "calendar", method="REQUEST", name="invite.ics")
ics_part.set_payload(ics_content.encode("utf-8"))
encoders.encode_base64(ics_part)
ics_part.add_header("Content-Disposition", "attachment", filename="invite.ics")
message.attach(ics_part)
raw = base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8")
service = get_gmail_service()
service.users().messages().send(userId="me", body={"raw": raw}).execute()
return True
except Exception:
return False
def send_scheduling_notification(provider_email: str, patient_email: str, appointment_details: dict) -> dict:
"""Send scheduling confirmation emails to both the provider and patient.
Args:
provider_email: Provider's email
patient_email: Patient's email
appointment_details: Dict with keys: patient_name, provider_name, date, start_time, end_time, visit_level, address
Returns:
Dict with 'provider_sent': bool, 'patient_sent': bool
"""
patient_name = appointment_details.get("patient_name", "Patient")
provider_name = appointment_details.get("provider_name", "Provider")
date = appointment_details.get("date", "")
start_time = appointment_details.get("start_time", "")
end_time = appointment_details.get("end_time", "")
visit_level = appointment_details.get("visit_level", "")
address = appointment_details.get("address", "")
recommended_departure = appointment_details.get("recommended_departure_time", "")
# Email to provider
provider_subject = f"{config.ORG_NAME} - New Appointment Scheduled"
departure_line = f"\nRecommended Departure Time: {recommended_departure}" if recommended_departure else ""
provider_body = f"""Hello {provider_name},
A new appointment has been scheduled:
Patient: {patient_name}
Date: {date}
Time: {start_time} - {end_time}
Visit Level: {visit_level}
Address: {address}{departure_line}
Please ensure you arrive on time.
Best regards,
{config.ORG_NAME}
"""
# Email to patient
patient_subject = f"{config.ORG_NAME} - Appointment Confirmation"
patient_body = f"""Hello {patient_name},
Your appointment has been confirmed:
Provider: {provider_name}
Date: {date}
Time: {start_time} - {end_time}
Your provider will visit you at:
{address}
If you need to reschedule or cancel, please contact us as soon as possible.
Best regards,
{config.ORG_NAME}
"""
ics = _build_ics(appointment_details)
provider_sent = send_email(provider_email, provider_subject, provider_body, ics) if provider_email else False
patient_sent = send_email(patient_email, patient_subject, patient_body, ics) if patient_email else False
return {"provider_sent": provider_sent, "patient_sent": patient_sent}
def send_cancellation_notification(provider_email: str, patient_email: str, appointment_details: dict) -> dict:
"""Send cancellation notification emails to both the provider and patient.
Returns:
Dict with 'provider_sent': bool, 'patient_sent': bool
"""
patient_name = appointment_details.get("patient_name", "Patient")
provider_name = appointment_details.get("provider_name", "Provider")
date = appointment_details.get("date", "")
start_time = appointment_details.get("start_time", "")
end_time = appointment_details.get("end_time", "")
# Email to provider
provider_subject = f"{config.ORG_NAME} - Appointment Cancelled"
provider_body = f"""Hello {provider_name},
The following appointment has been cancelled:
Patient: {patient_name}
Date: {date}
Time: {start_time} - {end_time}
This time slot is now available for other appointments.
Best regards,
{config.ORG_NAME}
"""
# Email to patient
patient_subject = f"{config.ORG_NAME} - Appointment Cancellation Confirmation"
patient_body = f"""Hello {patient_name},
Your appointment has been cancelled:
Provider: {provider_name}
Date: {date}
Time: {start_time} - {end_time}
If you would like to reschedule, please contact us.
Best regards,
{config.ORG_NAME}
"""
ics = _build_ics(appointment_details, method="CANCEL")
provider_sent = send_email(provider_email, provider_subject, provider_body, ics) if provider_email else False
patient_sent = send_email(patient_email, patient_subject, patient_body, ics) if patient_email else False
return {"provider_sent": provider_sent, "patient_sent": patient_sent}