import uuid from datetime import datetime,timezone import httpx from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import select from g_sheet.models import FormData from inbox.models import Inbox from job.candidate.models import Manual_UPLOAD_CANDIDATE from job.candidate.views import owned_job_ids_for_candidate_scope from job.history.enums import HistoryEvent from job.history.views import HistoryRecorder from job.job_post.models import JobPosts from job.pipeline.views import Pipeline from notifications.models import Notifications from offer.models import Offers,OfferStatusHistory from offer.plugins import ( email_key, is_interview_plus, non_validation_values, parse_offer_datetime, render_offer_email, send_offer_mail, stage_value, ) from offer.serializers import serialize_offer,serialize_offer_candidate from users.models import Users from users.permissions import sees_all_offers SOURCE_RANK={"inbox":0,"manual":1,"form":2} MAIL_FAIL_DETAIL="Failed: the offer could not be sent" def _as_uuid(value): if value in (None,""): return None try: return uuid.UUID(str(value)) except (TypeError,ValueError): return None def _user_id(current_user): if not current_user or not current_user.get("id"): raise HTTPException(status_code=401,detail="Not authenticated") uid=_as_uuid(current_user["id"]) if uid is None: raise HTTPException(status_code=401,detail="Invalid user id") return uid def _comp_fields(payload): fields={} for key in non_validation_values(): if key not in payload: continue value=payload[key] if key in ("start_date","expiry_date"): value=parse_offer_datetime(value) fields[key]=value return fields class Offer: def __init__(self,session:AsyncSession): self.session=session async def _hydrate_offers(self,rows): ids=[] for row in rows: ids.append(row.candidate_user_id) ids.append(row.created_by) names=await Users.names_by_ids(self.session,ids) return [ serialize_offer( row, candidate_name=names.get(str(row.candidate_user_id)), created_by_name=names.get(str(row.created_by)), ) for row in rows ] async def get_offers(self,offer_id=None,status=None,inbox_id=None,job_post_id=None,top=None,skip=0): if offer_id is not None: row=await Offers.get_offer_by_id(self.session,offer_id) if not row: raise HTTPException(status_code=404,detail="Offer not found") return (await self._hydrate_offers([row]))[0],1 rows,total=await Offers.fetch_offers( self.session, status=status, inbox_id=inbox_id, job_post_id=job_post_id, top=top, skip=skip or 0, ) return await self._hydrate_offers(rows),total async def _offer_job_ids(self,current_user): if sees_all_offers(current_user): return None return await owned_job_ids_for_candidate_scope(self.session,current_user) async def _assert_offer_job(self,current_user,job_post_id): if sees_all_offers(current_user): return owned=await owned_job_ids_for_candidate_scope(self.session,current_user) owned=set(owned or []) jid=_as_uuid(job_post_id) if jid is None or jid not in owned: raise HTTPException(status_code=403,detail="This offer is outside your assigned jobs") async def list_candidates(self,current_user,search=None,top=None,skip=0): owned=await self._offer_job_ids(current_user) if owned is not None and not owned: return [],0 inbox_rows=await Inbox.get_all(self.session,job_post_ids=owned) manual_rows=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_ids=owned) form_rows=await FormData.list_for_offer_picker( self.session,job_post_ids=owned,search=None, ) form_by_manual=await FormData.form_ids_by_manual_ids( self.session,[r.get("id") for r in manual_rows], ) merged={} for row in inbox_rows: if row.get("is_duplicate"): continue if not is_interview_plus(row.get("application_status")): continue item={ "inbox_id":row.get("inbox_id"), "manual_upload_candidate_id":None, "form_data_id":None, "source":"inbox", "user_id":row.get("user_id"), "name":row.get("name"), "email":email_key(row.get("email")), "job_post_id":row.get("assigned_job_post_id"), "job_title":row.get("title"), "application_status":stage_value(row.get("application_status")), } self._merge_candidate(merged,item) for row in manual_rows: if not is_interview_plus(row.get("application_status")): continue mid=row.get("id") item={ "inbox_id":None, "manual_upload_candidate_id":mid, "form_data_id":form_by_manual.get(str(mid)) if mid else None, "source":"manual", "user_id":row.get("user_id"), "name":row.get("name") or None, "email":email_key(row.get("email") or row.get("candidate_email")), "job_post_id":row.get("job_post_id"), "job_title":row.get("title"), "application_status":stage_value(row.get("application_status")), } self._merge_candidate(merged,item) for row in form_rows: item={ "inbox_id":None, "manual_upload_candidate_id":None, "form_data_id":row.get("form_data_id"), "source":"form", "user_id":row.get("user_id"), "name":row.get("name"), "email":email_key(row.get("email")), "job_post_id":row.get("job_post_id"), "job_title":row.get("job_title"), "application_status":stage_value(row.get("application_status")) or "PENDING", } self._merge_candidate(merged,item) items=list(merged.values()) needle=(search or "").strip().lower() if needle: items=[ r for r in items if needle in (r.get("name") or "").lower() or needle in (r.get("email") or "") ] items.sort(key=lambda r:(r.get("name") or r.get("email") or "").lower()) total=len(items) start=int(skip or 0) if start: items=items[start:] if top is not None: items=items[:int(top)] return [serialize_offer_candidate(r) for r in items],total def _merge_candidate(self,merged,item): job_id=item.get("job_post_id") if not job_id: return email=item.get("email") or "" if email: key=(email,str(job_id)) else: source=item.get("source") or "row" raw=item.get("inbox_id") or item.get("manual_upload_candidate_id") or item.get("form_data_id") key=(f"noid:{source}:{raw}",str(job_id)) existing=merged.get(key) if existing is None: merged[key]=item return if SOURCE_RANK.get(item.get("source"),9)