import uuid from datetime import datetime,timedelta,timezone from sqlalchemy import and_,func,or_,select from sqlalchemy.ext.asyncio import AsyncSession from analytics.serializers import ( serialize_recruiter_row, serialize_source_count, serialize_stage_count, ) from inbox.enums import Candidate_application_Status from inbox.models import Inbox,Inbox_Messages,SourceChannels from job.assignment.models import JobAssignments from job.candidate.models import ApplicationStageTransitions,Interviews from job.cost.models import HiringCosts from job.job_post.models import JobPosts from offer.models import Offers from org_settings.models import OrgSettings from role.models import EnumRoles,Roles from users.models import Users def _as_uuid(value): if value in (None,""): return None try: return uuid.UUID(str(value)) except (TypeError,ValueError): return None def _month_start(dt: datetime) -> datetime: return datetime(dt.year,dt.month,1,tzinfo=timezone.utc) def _next_month_start(dt: datetime) -> datetime: if dt.month==12: return datetime(dt.year+1,1,1,tzinfo=timezone.utc) return datetime(dt.year,dt.month+1,1,tzinfo=timezone.utc) def _resolve_windows(from_date,to_date): """Return (from_date, to_date, prior_from, prior_to). Missing bounds → current calendar month.""" now=datetime.now(timezone.utc) if from_date is None and to_date is None: from_date=_month_start(now) to_date=_next_month_start(now) elif from_date is None: # open-ended lower bound: treat as same length as a calendar month ending at to_date to_date=to_date if to_date.tzinfo else to_date.replace(tzinfo=timezone.utc) from_date=_month_start(to_date) elif to_date is None: from_date=from_date if from_date.tzinfo else from_date.replace(tzinfo=timezone.utc) to_date=_next_month_start(from_date) else: if from_date.tzinfo is None: from_date=from_date.replace(tzinfo=timezone.utc) if to_date.tzinfo is None: to_date=to_date.replace(tzinfo=timezone.utc) duration=to_date-from_date prior_to=from_date prior_from=from_date-duration return from_date,to_date,prior_from,prior_to def _month_key(dt): """Normalize date_trunc / python month buckets for dict lookup.""" if dt is None: return None if getattr(dt,"tzinfo",None) is None: dt=dt.replace(tzinfo=timezone.utc) else: dt=dt.astimezone(timezone.utc) return datetime(dt.year,dt.month,1,tzinfo=timezone.utc) def _days_expr(end_col,start_col): return func.extract("epoch",end_col-start_col)/86400.0 class Analytics: def __init__(self,session:AsyncSession): self.session=session async def _count_jobs(self,status,from_date=None,to_date=None,department=None,recruiter_id=None,*,closed_in_window=False): statement=select(func.count()).select_from(JobPosts).where(JobPosts.is_deleted==False) # noqa: E712 if status: statement=statement.where(JobPosts.requisition_status==status) if department: statement=statement.where(JobPosts.department==department) rid=_as_uuid(recruiter_id) if rid is not None: statement=statement.where(JobPosts.current_recruiter_id==rid) if closed_in_window: if from_date is not None: statement=statement.where(JobPosts.closed_at>=from_date) if to_date is not None: statement=statement.where(JobPosts.closed_at=as_of), JobPosts.requisition_status=="open", ) if department: statement=statement.where(JobPosts.department==department) rid=_as_uuid(recruiter_id) if rid is not None: statement=statement.where(JobPosts.current_recruiter_id==rid) result=await self.session.execute(statement) return int(result.scalar_one() or 0) async def _count_candidates(self,from_date=None,to_date=None,department=None,recruiter_id=None): statement=( select(func.count()) .select_from(Inbox) .join(Users,Inbox.user_id==Users.id) .join(Roles,Users.role_id==Roles.id) .where(Roles.role_name==EnumRoles.CANDIDATE.value) ) if from_date is not None: statement=statement.where(Inbox.created_at>=from_date) if to_date is not None: statement=statement.where(Inbox.created_at=from_date) if to_date is not None: statement=statement.where(stamp=from_date) if to_date is not None: statement=statement.where(hired.valid_from=from_date) if to_date is not None: msg=msg.where(Inbox.created_at=from_date) if to_date is not None: statement=statement.where(hire.c.valid_from=from_date) if to_date is not None: statement=statement.where(JobPosts.closed_at=from_date) if to_date is not None: statement=statement.where(HiringCosts.incurred_at=today_start, Interviews.interview_date=now, ) interviews_upcoming=int((await self.session.execute(upcoming_q)).scalar_one() or 0) next_q=select(func.min(func.coalesce(Interviews.interview_time,Interviews.interview_date))).where( Interviews.interview_status.ilike("scheduled"), Interviews.interview_date>=now, ) next_at=(await self.session.execute(next_q)).scalar_one() next_interview_at=next_at.isoformat() if next_at else None offers_accepted=await self._count_offers(["accepted"],window_from,window_to,department,recruiter_id) offers_accepted_prior=await self._count_offers(["accepted"],prior_from,prior_to,department,recruiter_id) offers_sent=await self._count_offers( ["sent","negotiating","accepted","declined","expired"], window_from,window_to,department,recruiter_id,exclude_draft=True, ) offers_sent_prior=await self._count_offers( ["sent","negotiating","accepted","declined","expired"], prior_from,prior_to,department,recruiter_id,exclude_draft=True, ) hires=await self._count_hires(window_from,window_to,department,recruiter_id) hires_prior=await self._count_hires(prior_from,prior_to,department,recruiter_id) time_to_hire=await self._avg_time_to_hire(window_from,window_to,department,recruiter_id) time_to_hire_prior=await self._avg_time_to_hire(prior_from,prior_to,department,recruiter_id) time_to_fill=await self._avg_time_to_fill(window_from,window_to,department,recruiter_id) time_to_fill_prior=await self._avg_time_to_fill(prior_from,prior_to,department,recruiter_id) cost_per_hire=await self._cost_per_hire(hires,window_from,window_to,department,recruiter_id) cost_per_hire_prior=await self._cost_per_hire(hires_prior,prior_from,prior_to,department,recruiter_id) # REQ-ANL-08: the time-to-hire baseline is an org setting with provenance # ({"days": N, "source": "..."}), never a constant — OPEN-12 flags the BRD's # 27-day figure as unconfirmed, so an unset baseline stays absent here. baseline_days=None baseline_source=None baseline_set_at=None baseline_row=await OrgSettings.get_by_key(self.session,"analytics.tth_baseline") if baseline_row is not None: value=baseline_row.setting_value raw_days=value.get("days") if isinstance(value,dict) else value if isinstance(value,dict): baseline_source=(str(value.get("source") or "").strip() or None) try: baseline_days=int(raw_days) if raw_days is not None else None except (TypeError,ValueError): baseline_days=None if baseline_days is not None and baseline_row.updated_at: baseline_set_at=baseline_row.updated_at.isoformat() return { "open_jobs": open_jobs, "open_jobs_prior": open_jobs_prior, "total_candidates": total_candidates, "total_candidates_prior": total_candidates_prior, "interviews_today": interviews_today, "interviews_upcoming": interviews_upcoming, "next_interview_at": next_interview_at, "offers_accepted": offers_accepted, "offers_accepted_prior": offers_accepted_prior, "offers_sent": offers_sent, "offers_sent_prior": offers_sent_prior, "time_to_hire": time_to_hire, "time_to_hire_prior": time_to_hire_prior, "time_to_fill": time_to_fill, "time_to_fill_prior": time_to_fill_prior, "cost_per_hire": cost_per_hire, "cost_per_hire_prior": cost_per_hire_prior, "closed_jobs": closed_jobs, "closed_jobs_prior": closed_jobs_prior, "hires": hires, "hires_prior": hires_prior, "tth_baseline_days": baseline_days, "tth_baseline_source": baseline_source, "tth_baseline_set_at": baseline_set_at, } async def get_funnel(self,from_date=None,to_date=None,department=None,recruiter_id=None): statement=select( Inbox_Messages.application_status, func.count().label("count"), ).select_from(Inbox_Messages) if department or recruiter_id: statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) if department: statement=statement.where(JobPosts.department==department) rid=_as_uuid(recruiter_id) if rid is not None: statement=statement.where( or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) ) if from_date is not None or to_date is not None: statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id) if from_date is not None: statement=statement.where(Inbox.created_at>=from_date) if to_date is not None: statement=statement.where(Inbox.created_at=start) .group_by(month_bucket) .order_by(month_bucket) ) if department or recruiter_id: apps_q=( apps_q .outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id) .outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) ) if department: apps_q=apps_q.where(JobPosts.department==department) rid=_as_uuid(recruiter_id) if rid is not None: apps_q=apps_q.where( or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) ) apps_rows=await self.session.execute(apps_q) apps_map={} for month,count in apps_rows.all(): apps_map[_month_key(month)]=int(count or 0) hire_bucket=func.date_trunc("month",ApplicationStageTransitions.valid_from) hires_q=( select(hire_bucket.label("month"),func.count().label("count")) .select_from(ApplicationStageTransitions) .where( ApplicationStageTransitions.to_stage==Candidate_application_Status.HIRED.value, ApplicationStageTransitions.valid_from>=start, ) .group_by(hire_bucket) .order_by(hire_bucket) ) if department or recruiter_id: hires_q=( hires_q .outerjoin(Inbox,ApplicationStageTransitions.inbox_id==Inbox.id) .outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id) .outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) ) if department: hires_q=hires_q.where(JobPosts.department==department) rid=_as_uuid(recruiter_id) if rid is not None: hires_q=hires_q.where( or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) ) hire_rows=await self.session.execute(hires_q) hire_map={} for month,count in hire_rows.all(): hire_map[_month_key(month)]=int(count or 0) labels=[] applications=[] hires=[] cursor=start for _ in range(months): labels.append(cursor.strftime("%b %Y")) applications.append(apps_map.get(cursor,0)) hires.append(hire_map.get(cursor,0)) cursor=_next_month_start(cursor) return {"labels": labels,"applications": applications,"hires": hires} async def get_source_performance(self,from_date=None,to_date=None,department=None,recruiter_id=None): statement=( select( SourceChannels.id.label("source_id"), func.coalesce(SourceChannels.label,"Unknown").label("source"), func.count().label("count"), ) .select_from(Inbox_Messages) .outerjoin(SourceChannels,Inbox_Messages.source_channel_id==SourceChannels.id) ) if department or recruiter_id: statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) if department: statement=statement.where(JobPosts.department==department) rid=_as_uuid(recruiter_id) if rid is not None: statement=statement.where( or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) ) if from_date is not None or to_date is not None: statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id) if from_date is not None: statement=statement.where(Inbox.created_at>=from_date) if to_date is not None: statement=statement.where(Inbox.created_at=from_date) if to_date is not None: spend_q=spend_q.where(HiringCosts.incurred_at=from_date) if to_date is not None: hires_q=hires_q.where(Inbox.created_at