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

558 lines
25 KiB
Python

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 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<to_date)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_open_snapshot(self,as_of,department=None,recruiter_id=None):
"""Jobs that existed and were still open at `as_of` (best-effort)."""
statement=select(func.count()).select_from(JobPosts).where(
JobPosts.is_deleted==False, # noqa: E712
JobPosts.created_at<as_of,
or_(JobPosts.closed_at.is_(None),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<to_date)
# Best-effort department/recruiter via linked message → job post
if department or recruiter_id:
statement=(
statement
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.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)
)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_offers(self,statuses,from_date=None,to_date=None,department=None,recruiter_id=None,*,exclude_draft=False):
statement=select(func.count()).select_from(Offers)
if exclude_draft:
statement=statement.where(Offers.status!="draft")
elif statuses:
statement=statement.where(Offers.status.in_(statuses))
stamp=func.coalesce(Offers.sent_at,Offers.responded_at,Offers.created_at)
if from_date is not None:
statement=statement.where(stamp>=from_date)
if to_date is not None:
statement=statement.where(stamp<to_date)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,Offers.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(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_hires(self,from_date=None,to_date=None,department=None,recruiter_id=None):
# Prefer HIRED transitions in window; fall back path uses inbox_messages status.
hired=ApplicationStageTransitions
statement=select(func.count()).select_from(hired).where(hired.to_stage==Candidate_application_Status.HIRED.value)
if from_date is not None:
statement=statement.where(hired.valid_from>=from_date)
if to_date is not None:
statement=statement.where(hired.valid_from<to_date)
if department or recruiter_id:
statement=(
statement
.outerjoin(Inbox,hired.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:
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)
)
result=await self.session.execute(statement)
count=int(result.scalar_one() or 0)
if count:
return count
# Fallback: messages currently HIRED, windowed via inbox.created_at
msg=(
select(func.count())
.select_from(Inbox_Messages)
.join(Inbox,Inbox.message_id==Inbox_Messages.id)
.where(Inbox_Messages.application_status==Candidate_application_Status.HIRED)
)
if from_date is not None:
msg=msg.where(Inbox.created_at>=from_date)
if to_date is not None:
msg=msg.where(Inbox.created_at<to_date)
if department or recruiter_id:
msg=msg.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
if department:
msg=msg.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
msg=msg.where(or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid))
result=await self.session.execute(msg)
return int(result.scalar_one() or 0)
async def _avg_time_to_hire(self,from_date=None,to_date=None,department=None,recruiter_id=None):
entry=ApplicationStageTransitions.__table__.alias("entry")
hire=ApplicationStageTransitions.__table__.alias("hire")
days=_days_expr(hire.c.valid_from,entry.c.valid_from)
statement=(
select(func.avg(days))
.select_from(
hire.join(
entry,
and_(
hire.c.inbox_id==entry.c.inbox_id,
entry.c.from_stage.is_(None),
),
)
)
.where(hire.c.to_stage==Candidate_application_Status.HIRED.value)
)
if from_date is not None:
statement=statement.where(hire.c.valid_from>=from_date)
if to_date is not None:
statement=statement.where(hire.c.valid_from<to_date)
if department or recruiter_id:
statement=(
statement
.outerjoin(Inbox,hire.c.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:
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)
)
result=await self.session.execute(statement)
value=result.scalar_one()
return float(value) if value is not None else None
async def _avg_time_to_fill(self,from_date=None,to_date=None,department=None,recruiter_id=None):
days=_days_expr(JobPosts.closed_at,JobPosts.created_at)
statement=select(func.avg(days)).select_from(JobPosts).where(
JobPosts.is_deleted==False, # noqa: E712
JobPosts.requisition_status=="closed",
JobPosts.closed_at.is_not(None),
)
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<to_date)
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)
value=result.scalar_one()
return float(value) if value is not None else None
async def _cost_per_hire(self,hires,from_date=None,to_date=None,department=None,recruiter_id=None):
if not hires:
return None
statement=select(func.coalesce(func.sum(HiringCosts.amount),0.0))
if from_date is not None:
statement=statement.where(HiringCosts.incurred_at>=from_date)
if to_date is not None:
statement=statement.where(HiringCosts.incurred_at<to_date)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,HiringCosts.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(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
total=float(result.scalar_one() or 0.0)
return total/hires
async def get_kpis(self,from_date=None,to_date=None,department=None,recruiter_id=None):
window_from,window_to,prior_from,prior_to=_resolve_windows(from_date,to_date)
now=datetime.now(timezone.utc)
today_start=datetime(now.year,now.month,now.day,tzinfo=timezone.utc)
tomorrow=today_start+timedelta(days=1)
open_jobs=await self._count_jobs("open",department=department,recruiter_id=recruiter_id)
open_jobs_prior=await self._count_open_snapshot(window_from,department=department,recruiter_id=recruiter_id)
closed_jobs=await self._count_jobs(
"closed",window_from,window_to,department,recruiter_id,closed_in_window=True
)
closed_jobs_prior=await self._count_jobs(
"closed",prior_from,prior_to,department,recruiter_id,closed_in_window=True
)
total_candidates=await self._count_candidates(window_from,window_to,department,recruiter_id)
total_candidates_prior=await self._count_candidates(prior_from,prior_to,department,recruiter_id)
interviews_today_q=select(func.count()).select_from(Interviews).where(
Interviews.interview_date>=today_start,
Interviews.interview_date<tomorrow,
)
interviews_today=int((await self.session.execute(interviews_today_q)).scalar_one() or 0)
upcoming_q=select(func.count()).select_from(Interviews).where(
Interviews.interview_status.ilike("scheduled"),
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)
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,
}
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<to_date)
statement=statement.group_by(Inbox_Messages.application_status)
result=await self.session.execute(statement)
counts={str(row[0].value if hasattr(row[0],"value") else row[0]): int(row[1] or 0) for row in result.all()}
return [
serialize_stage_count(stage.value,counts.get(stage.value,0))
for stage in Candidate_application_Status
]
async def get_hiring_trend(self,months=7,from_date=None,to_date=None,department=None,recruiter_id=None):
months=max(1,int(months or 7))
now=datetime.now(timezone.utc)
start=_month_start(now)
# Walk back (months-1) months
for _ in range(months-1):
start=_month_start(start-timedelta(days=1))
month_bucket=func.date_trunc("month",Inbox.created_at)
apps_q=(
select(month_bucket.label("month"),func.count().label("count"))
.select_from(Inbox)
.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(
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<to_date)
statement=statement.group_by(SourceChannels.label).order_by(func.count().desc())
result=await self.session.execute(statement)
return [serialize_source_count(source,count) for source,count in result.all()]
async def get_recruiter_performance(self,top=5,from_date=None,to_date=None,department=None,recruiter_id=None):
top=max(1,int(top or 5))
recruiters_q=(
select(Users)
.join(Roles,Users.role_id==Roles.id)
.where(
Roles.role_name==EnumRoles.RECRUITER.value,
Users.is_deleted==False, # noqa: E712
)
)
rid=_as_uuid(recruiter_id)
if rid is not None:
recruiters_q=recruiters_q.where(Users.id==rid)
recruiters=list((await self.session.execute(recruiters_q)).scalars().all())
rows=[]
for user in recruiters:
hires_q=select(func.count()).select_from(Inbox_Messages).where(
Inbox_Messages.recruiter_id==user.id,
Inbox_Messages.application_status==Candidate_application_Status.HIRED,
)
if department:
hires_q=hires_q.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id).where(
JobPosts.department==department
)
if from_date is not None or to_date is not None:
hires_q=hires_q.join(Inbox,Inbox.message_id==Inbox_Messages.id)
if from_date is not None:
hires_q=hires_q.where(Inbox.created_at>=from_date)
if to_date is not None:
hires_q=hires_q.where(Inbox.created_at<to_date)
hires=int((await self.session.execute(hires_q)).scalar_one() or 0)
open_assign=await JobAssignments.count_open_reqs_by_user(self.session,user.id)
open_posts_q=select(func.count()).select_from(JobPosts).where(
JobPosts.current_recruiter_id==user.id,
JobPosts.requisition_status=="open",
JobPosts.is_deleted==False, # noqa: E712
)
if department:
open_posts_q=open_posts_q.where(JobPosts.department==department)
open_posts=int((await self.session.execute(open_posts_q)).scalar_one() or 0)
open_reqs=max(open_assign,open_posts)
avg_tth=await self._avg_time_to_hire(
from_date,to_date,department,recruiter_id=str(user.id)
)
rows.append(serialize_recruiter_row(user.id,user.name,hires,open_reqs,avg_tth))
rows.sort(key=lambda r: r["hires"],reverse=True)
return rows[:top]