HR-ATS-Portal/backend/interview/views.py

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)