diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index a8ca4fe..c2982ae 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -270,7 +270,8 @@ class JobPosts(SQLModel, table=True): rows. Duplicate emails (case-insensitive) count once per job — the furthest pipeline stage is kept. Flagged is_duplicate rows are skipped. Rows with no email still count, each as themselves. Jobs with zero - applicants still appear (LEFT JOIN). + applicants still appear (LEFT JOIN). `reapplied` is how many unique + applicants on the job also applied to at least one other job. """ from g_sheet.models import FormData from inbox.models import Inbox_Messages @@ -299,7 +300,7 @@ class JobPosts(SQLModel, table=True): func.concat("noid:", cast(row_id, String)), ) - inbox_q = ( + inbox_base = ( select( Inbox_Messages.assigned_job_post_id.label("job_post_id"), dup_key(Inbox_Messages.message_from, Inbox_Messages.id).label("dup_key"), @@ -316,7 +317,7 @@ class JobPosts(SQLModel, table=True): func.nullif(func.btrim(Manual_UPLOAD_CANDIDATE.candidate_email), ""), Users.email, ) - manual_q = ( + manual_base = ( select( Manual_UPLOAD_CANDIDATE.job_post_id.label("job_post_id"), dup_key(manual_email, Manual_UPLOAD_CANDIDATE.id).label("dup_key"), @@ -332,7 +333,7 @@ class JobPosts(SQLModel, table=True): (FormData.processing_state == "rejected", "REJECTED"), else_="PENDING", ) - form_q = ( + form_base = ( select( FormData.job_post_id.label("job_post_id"), dup_key(FormData.candidate_email, FormData.id).label("dup_key"), @@ -342,12 +343,23 @@ class JobPosts(SQLModel, table=True): .where(FormData.manual_upload_candidate_id.is_(None)) .where(FormData.is_duplicate == False) # noqa: E712 ) + # Unfiltered: an applicant on this page who also applied to a job + # not in the current page still counts as a reapplicant. + all_apps = union_all(inbox_base, manual_base, form_base).subquery("all_applications") + inbox_q, manual_q, form_q = inbox_base, manual_base, form_base if job_uids: - inbox_q = inbox_q.where(Inbox_Messages.assigned_job_post_id.in_(job_uids)) - manual_q = manual_q.where(Manual_UPLOAD_CANDIDATE.job_post_id.in_(job_uids)) - form_q = form_q.where(FormData.job_post_id.in_(job_uids)) + inbox_q = inbox_base.where(Inbox_Messages.assigned_job_post_id.in_(job_uids)) + manual_q = manual_base.where(Manual_UPLOAD_CANDIDATE.job_post_id.in_(job_uids)) + form_q = form_base.where(FormData.job_post_id.in_(job_uids)) apps = union_all(inbox_q, manual_q, form_q).subquery("applications") + repeat_keys = ( + select(all_apps.c.dup_key) + .where(~all_apps.c.dup_key.like("noid:%")) + .group_by(all_apps.c.dup_key) + .having(func.count(func.distinct(all_apps.c.job_post_id)) > 1) + .subquery("repeat_emails") + ) stage_rank = case( (apps.c.stage == "HIRED", 9), (apps.c.stage == "APPROVED", 8), @@ -389,6 +401,16 @@ class JobPosts(SQLModel, table=True): .group_by(unique_apps.c.job_post_id) .subquery("job_stage_stats") ) + reapplied_stats = ( + select( + unique_apps.c.job_post_id, + func.count().label("reapplied"), + ) + .select_from(unique_apps) + .join(repeat_keys, repeat_keys.c.dup_key == unique_apps.c.dup_key) + .group_by(unique_apps.c.job_post_id) + .subquery("job_reapplied") + ) # Alias so this join does not collide with the Users join inside # the manual-upload subquery above. @@ -413,9 +435,11 @@ class JobPosts(SQLModel, table=True): func.coalesce(stats.c.rejected, 0).label("rejected"), func.coalesce(stats.c.approved, 0).label("approved"), func.coalesce(stats.c.hired, 0).label("hired"), + func.coalesce(reapplied_stats.c.reapplied, 0).label("reapplied"), ) .select_from(cls) .outerjoin(stats, stats.c.job_post_id == cls.id) + .outerjoin(reapplied_stats, reapplied_stats.c.job_post_id == cls.id) .outerjoin(Recruiter, Recruiter.id == cls.current_recruiter_id) .where(cls.is_deleted == False) # noqa: E712 ) diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index cfd756d..5cf3ef2 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -107,6 +107,7 @@ def serialize_job_stats(row) -> dict: "rejected": int(row["rejected"] or 0), "approved": int(row["approved"] or 0), "hired": int(row["hired"] or 0), + "reapplied": int(row.get("reapplied") or 0), } diff --git a/frontend/src/api/jobStats.js b/frontend/src/api/jobStats.js index cad5c6b..04f1d54 100644 --- a/frontend/src/api/jobStats.js +++ b/frontend/src/api/jobStats.js @@ -60,5 +60,6 @@ export function toJobStatsView(row) { rejected: Number(row.rejected) || 0, approved: Number(row.approved) || 0, hired: Number(row.hired) || 0, + reapplied: Number(row.reapplied) || 0, } } diff --git a/frontend/src/screens/Progress.jsx b/frontend/src/screens/Progress.jsx index c668def..360501e 100644 --- a/frontend/src/screens/Progress.jsx +++ b/frontend/src/screens/Progress.jsx @@ -118,9 +118,15 @@ function JobDetail({ job }) { )}
-