"""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.history.enums import HistoryEvent from job.history.views import HistoryRecorder 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,current_user=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"), ) await HistoryRecorder(self.session).record( HistoryEvent.CALENDAR_CREATED.value, current_user=current_user,inbox_id=row.inbox_id, entity_type="interview",entity_id=row.id, to_value=row.graph_event_id, description=f"Calendar invite sent to {email}",commit=True, ) return await self._serialize(row) async def reschedule_for_interview(self,interview_id,instant,duration_minutes=None,current_user=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") old_instant=row.interview_date or row.interview_time 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) await HistoryRecorder(self.session).record( HistoryEvent.CALENDAR_RESCHEDULED.value, current_user=current_user,inbox_id=row.inbox_id, entity_type="interview",entity_id=row.id, from_value=old_instant.isoformat() if old_instant else None, to_value=start_dt.isoformat(),commit=True, ) return await self._serialize(row) async def cancel_for_interview(self,interview_id,comment=None,current_user=None): row=await self._load_interview(interview_id) if not row.graph_event_id: return await self._serialize(row) old_event_id=row.graph_event_id 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) await HistoryRecorder(self.session).record( HistoryEvent.CALENDAR_CANCELLED.value, current_user=current_user,inbox_id=row.inbox_id, entity_type="interview",entity_id=row.id, from_value=old_event_id,to_value=None, description=comment,commit=True, ) return await self._serialize(row)