176 lines
6.5 KiB
Python
176 lines
6.5 KiB
Python
"""Calendar service — create / reschedule / cancel Outlook events for interviews.
|
|
|
|
Upstream write paths follow the documented contract but were not proven live
|
|
when this landed; a non-2xx here is an upstream-contract finding first.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime,timedelta,timezone
|
|
|
|
import httpx
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from interview.plugins import CALENDAR_API_TOKEN,cancel_event,create_event,reschedule_event
|
|
from interview.serializers import serialize_event
|
|
from job.candidate.models import Interviews
|
|
from job.interviews.serializers import serialize_interview
|
|
from job.job_post.models import JobPosts
|
|
|
|
DEFAULT_DURATION_MINUTES=30
|
|
|
|
|
|
def _naive_utc(dt):
|
|
"""ISO8601 without offset — CreateEventRequest / RescheduleRequest form."""
|
|
if dt is None:
|
|
return None
|
|
if isinstance(dt,str):
|
|
dt=datetime.fromisoformat(dt.replace("Z","+00:00"))
|
|
if dt.tzinfo is not None:
|
|
dt=dt.astimezone(timezone.utc).replace(tzinfo=None)
|
|
return dt.strftime("%Y-%m-%dT%H:%M:%S")
|
|
|
|
|
|
def _as_datetime(value):
|
|
if value is None:
|
|
return None
|
|
if isinstance(value,datetime):
|
|
return value
|
|
if isinstance(value,str):
|
|
return datetime.fromisoformat(value.replace("Z","+00:00"))
|
|
return value
|
|
|
|
|
|
class Calendar:
|
|
def __init__(self,session:AsyncSession,token=None):
|
|
self.session=session
|
|
self.token=token or CALENDAR_API_TOKEN
|
|
|
|
async def _load_interview(self,interview_id):
|
|
row=await Interviews.get_interview_by_id(self.session,interview_id)
|
|
if not row:
|
|
raise HTTPException(status_code=404,detail="Interview not found")
|
|
return row
|
|
|
|
def _attendee(self,row):
|
|
inbox=getattr(row,"inbox",None) #getattr(object,method/key,default)
|
|
user=getattr(inbox,"user",None) if inbox else None
|
|
email=(getattr(user,"email",None) or "").strip() if user else ""
|
|
name=(getattr(user,"name",None) or "").strip() if user else ""
|
|
return email or None,name or None
|
|
|
|
async def _job_title(self,row):
|
|
inbox=getattr(row,"inbox",None)
|
|
messages=getattr(inbox,"messages",None) if inbox else None
|
|
job_post_id=getattr(messages,"assigned_job_post_id",None) if messages else None
|
|
if not job_post_id:
|
|
return None
|
|
job=await JobPosts.get_job_post_by_id(self.session,str(job_post_id))
|
|
return job.title if job else None
|
|
|
|
async def _serialize(self,row):
|
|
return serialize_interview(row,job_title=await self._job_title(row))
|
|
|
|
async def create_for_interview(self,interview_id,duration_minutes=None):
|
|
row=await self._load_interview(interview_id)
|
|
if row.graph_event_id:
|
|
return await self._serialize(row)
|
|
|
|
minutes=int(duration_minutes or DEFAULT_DURATION_MINUTES)
|
|
if minutes<=0:
|
|
raise HTTPException(status_code=422,detail="duration_minutes must be positive")
|
|
|
|
start_dt=row.interview_date or row.interview_time
|
|
if start_dt is None:
|
|
raise HTTPException(status_code=422,detail="Interview has no start time")
|
|
end_dt=start_dt+timedelta(minutes=minutes)
|
|
|
|
email,name=self._attendee(row)
|
|
if not email:
|
|
raise HTTPException(status_code=422,detail="Interview candidate has no email")
|
|
|
|
subject_bits=[row.interview_type or "Interview"]
|
|
if name:
|
|
subject_bits.append(name)
|
|
job_title=await self._job_title(row)
|
|
if job_title:
|
|
subject_bits.append(job_title)
|
|
|
|
payload={
|
|
"subject":" — ".join(subject_bits),
|
|
"start":_naive_utc(start_dt),
|
|
"end":_naive_utc(end_dt),
|
|
"time_zone":"UTC",
|
|
"attendees":[{"email":email,"name":name,"type":"required"}],
|
|
"is_online_meeting":True,
|
|
"allow_new_time_proposals":False,
|
|
}
|
|
|
|
try:
|
|
raw=await create_event(payload,token=self.token)
|
|
except httpx.HTTPStatusError as e:
|
|
raise HTTPException(status_code=e.response.status_code,detail=e.response.text)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
event=serialize_event(raw)
|
|
if not event.get("id"):
|
|
raise HTTPException(status_code=502,detail="Calendar create returned no event id")
|
|
|
|
row=await Interviews.set_calendar_event(
|
|
self.session,interview_id,event["id"],event.get("web_link"),
|
|
)
|
|
return await self._serialize(row)
|
|
|
|
async def reschedule_for_interview(self,interview_id,instant,duration_minutes=None):
|
|
row=await self._load_interview(interview_id)
|
|
if not row.graph_event_id:
|
|
raise HTTPException(status_code=404,detail="No calendar event for this interview")
|
|
|
|
start_dt=_as_datetime(instant)
|
|
if start_dt is None:
|
|
raise HTTPException(status_code=422,detail="instant is required")
|
|
minutes=int(duration_minutes or DEFAULT_DURATION_MINUTES)
|
|
if minutes<=0:
|
|
raise HTTPException(status_code=422,detail="duration_minutes must be positive")
|
|
end_dt=start_dt+timedelta(minutes=minutes)
|
|
|
|
payload={
|
|
"start":_naive_utc(start_dt),
|
|
"end":_naive_utc(end_dt),
|
|
"time_zone":"UTC",
|
|
}
|
|
|
|
try:
|
|
raw=await reschedule_event(row.graph_event_id,payload,token=self.token)
|
|
except httpx.HTTPStatusError as e:
|
|
raise HTTPException(status_code=e.response.status_code,detail=e.response.text)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
event=serialize_event(raw)
|
|
fields={
|
|
"interview_date":start_dt,
|
|
"interview_time":start_dt,
|
|
}
|
|
if event.get("web_link"):
|
|
fields["web_link"]=event["web_link"]
|
|
row=await Interviews.update_interview(self.session,interview_id,fields)
|
|
return await self._serialize(row)
|
|
|
|
async def cancel_for_interview(self,interview_id,comment=None):
|
|
row=await self._load_interview(interview_id)
|
|
if not row.graph_event_id:
|
|
return await self._serialize(row)
|
|
|
|
try:
|
|
await cancel_event(row.graph_event_id,comment=comment,token=self.token)
|
|
except httpx.HTTPStatusError as e:
|
|
raise HTTPException(status_code=e.response.status_code,detail=e.response.text)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
row=await Interviews.set_calendar_event(self.session,interview_id,None,None)
|
|
return await self._serialize(row)
|