Compare commits
178 Commits
Talha_merg
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
756023e304 | |
|
|
331a4cb0fc | |
|
|
a021d60fc3 | |
|
|
80a3b290b9 | |
|
|
1f4ec4d451 | |
|
|
1eea85f47e | |
|
|
1ca15207c8 | |
|
|
80eb5b3bbc | |
|
|
e6d2cffddb | |
|
|
7ed9e4be59 | |
|
|
ea13b5b98c | |
|
|
f8cd8d26f2 | |
|
|
e64e6849bb | |
|
|
b9c5b41e19 | |
|
|
a441a930d1 | |
|
|
6974ca4a82 | |
|
|
79a479ba36 | |
|
|
ed689ccea1 | |
|
|
8b09242ace | |
|
|
87282328f6 | |
|
|
cff1f74dee | |
|
|
de4d360e06 | |
|
|
493120d906 | |
|
|
9ffa2b1367 | |
|
|
34f15c7183 | |
|
|
74bae5330e | |
|
|
2efc2cdf3a | |
|
|
45d82feeea | |
|
|
bdfae436ac | |
|
|
3933ba55d6 | |
|
|
07faee1180 | |
|
|
a04d75e482 | |
|
|
4a33651082 | |
|
|
5fdc6031aa | |
|
|
c0e4a94d58 | |
|
|
52624d99d8 | |
|
|
25220d237b | |
|
|
30fb63f4f9 | |
|
|
0e4902d486 | |
|
|
f63878fe19 | |
|
|
06427a4f32 | |
|
|
96cf699f81 | |
|
|
07b90c1d59 | |
|
|
3969d62a51 | |
|
|
9d31cdd198 | |
|
|
fddd9a6456 | |
|
|
1f7088f21c | |
|
|
1b64881bb0 | |
|
|
09f74e6667 | |
|
|
1cd0f34e0b | |
|
|
adcd3097f8 | |
|
|
cc58dd09fc | |
|
|
f162a766b3 | |
|
|
cd4743e953 | |
|
|
6ceb5b2c26 | |
|
|
9f6f453220 | |
|
|
80a7a0abd7 | |
|
|
79098aefd5 | |
|
|
3253db1dde | |
|
|
2a3c7554ba | |
|
|
a077d55d3e | |
|
|
c520b2d9c2 | |
|
|
86531c77db | |
|
|
1fd40c3f7f | |
|
|
6979eb4b83 | |
|
|
ad6f8ba121 | |
|
|
7c9e18741d | |
|
|
5cc1dc1997 | |
|
|
d00ebcf6f5 | |
|
|
9036a48098 | |
|
|
cc504fed65 | |
|
|
ded1b9053d | |
|
|
41f46c2c39 | |
|
|
5b6588f90a | |
|
|
3dd716c3d3 | |
|
|
6998563aa2 | |
|
|
c141362056 | |
|
|
e378b323b9 | |
|
|
3d032a5069 | |
|
|
0ede681a30 | |
|
|
7ceecdb6c7 | |
|
|
db1dcb154c | |
|
|
f692680c6e | |
|
|
82686f0bdf | |
|
|
57167e77ba | |
|
|
53b1d4d238 | |
|
|
3086f3fca7 | |
|
|
20f03aad3c | |
|
|
2e0e6b1dda | |
|
|
5b29e2b592 | |
|
|
05ca7bce8f | |
|
|
ac49a903ae | |
|
|
0d4f5be565 | |
|
|
74ad8dd7e1 | |
|
|
40c25caeff | |
|
|
7e11d136e8 | |
|
|
6ae6f0538d | |
|
|
6b4ad6b0dd | |
|
|
14a2817afd | |
|
|
b33aa0c322 | |
|
|
c5d603c621 | |
|
|
0365ee3a8f | |
|
|
3cdfdd4c4c | |
|
|
4086bbdeb4 | |
|
|
6a1f5ba9fa | |
|
|
c353f41b20 | |
|
|
0558af52c4 | |
|
|
8dcd262dc4 | |
|
|
d541b82067 | |
|
|
6ebdd7c5ba | |
|
|
0d209315ab | |
|
|
0eff43def4 | |
|
|
8540577531 | |
|
|
22b2a7323f | |
|
|
2101fbac74 | |
|
|
3ca8e2d287 | |
|
|
cb94d8bc51 | |
|
|
0113509fa1 | |
|
|
3b5ba425e8 | |
|
|
d1455ed117 | |
|
|
a6d12be5fb | |
|
|
6c1d4c848e | |
|
|
921d3bf383 | |
|
|
db0652ab24 | |
|
|
3f142570f6 | |
|
|
fb94db5f41 | |
|
|
e22da6ed0b | |
|
|
1133517117 | |
|
|
1399cf3fec | |
|
|
2400f0fef8 | |
|
|
e7752b94e6 | |
|
|
e292714b88 | |
|
|
b7700b766a | |
|
|
cc9d7c08d1 | |
|
|
30123563e9 | |
|
|
15bdd5c08a | |
|
|
56a05f840f | |
|
|
0be2ba035a | |
|
|
2abc28fc14 | |
|
|
fbfcbb4eb8 | |
|
|
0e3c5d5551 | |
|
|
ef8d77ccfe | |
|
|
25382cdce7 | |
|
|
2e66c91d63 | |
|
|
c08e28a18c | |
|
|
12dc77542d | |
|
|
59afcadec9 | |
|
|
08db8dda15 | |
|
|
2bfdbdee93 | |
|
|
ec8a4af52d | |
|
|
5f77ba9f8e | |
|
|
ff718bccd2 | |
|
|
d1fa58ecc3 | |
|
|
638c5b11ac | |
|
|
75943cf03f | |
|
|
a33f4e18fc | |
|
|
b3c890476f | |
|
|
73ec556f8c | |
|
|
3f06fbeff1 | |
|
|
e5d1a20dc6 | |
|
|
63d5232cac | |
|
|
7068f75008 | |
|
|
f4c1899d5b | |
|
|
c7d630875c | |
|
|
182ee134ec | |
|
|
ea43b02b99 | |
|
|
75972d29b2 | |
|
|
14c78970bd | |
|
|
6db8e7aff3 | |
|
|
3ab1dc9117 | |
|
|
b0d280ec86 | |
|
|
8c5549a201 | |
|
|
a68cc43455 | |
|
|
f9fd88c5aa | |
|
|
2998ba94a7 | |
|
|
88b6c96a11 | |
|
|
9b5c2c8053 | |
|
|
cf40e43423 |
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Shell scripts must be LF in the repository, whatever a contributor's
|
||||||
|
# core.autocrlf happens to be.
|
||||||
|
#
|
||||||
|
# scripts/ci-checks.sh is executed by bash on the Gitea runner. Committed with
|
||||||
|
# CRLF it fails there with `$'\r': command not found` on the first line, which
|
||||||
|
# reads as a broken pipeline rather than a line-ending problem. This machine
|
||||||
|
# has core.autocrlf=true and normalises correctly on its own; that is a local
|
||||||
|
# setting, not a property of the repo, so it is pinned here instead.
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
@ -47,7 +47,6 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
echo "Uploading repo contents to S3..."
|
echo "Uploading repo contents to S3..."
|
||||||
aws s3 cp utopia-ai-hr-ats-portal.zip s3://utopia-ai-s3-repo-bucket/utopia-ai-hr-ats-portal.zip
|
aws s3 cp utopia-ai-hr-ats-portal.zip s3://utopia-ai-s3-repo-bucket/utopia-ai-hr-ats-portal.zip
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ dist/**/*
|
||||||
.claude/
|
.claude/
|
||||||
.audit.js
|
.audit.js
|
||||||
|
|
||||||
|
# Local macOS launcher (not shared — machine-specific)
|
||||||
|
Start.command
|
||||||
|
start.command
|
||||||
|
|
||||||
# Backups
|
# Backups
|
||||||
.backup-prebrand/
|
.backup-prebrand/
|
||||||
*.bak
|
*.bak
|
||||||
|
|
@ -71,7 +75,6 @@ Utopia-ai-hr-ats-portal 1.pem
|
||||||
|
|
||||||
# Local-only Compose overrides (never deployed)
|
# Local-only Compose overrides (never deployed)
|
||||||
docker.local.env
|
docker.local.env
|
||||||
tests/**
|
|
||||||
**/.env**
|
**/.env**
|
||||||
|
|
||||||
# Paper form source documents (Annexure A/E/J) — reference material, not code.
|
# Paper form source documents (Annexure A/E/J) — reference material, not code.
|
||||||
|
|
@ -81,5 +84,13 @@ frontend/dist/** */
|
||||||
docker.local.frontend/dist/** */
|
docker.local.frontend/dist/** */
|
||||||
frontend/dist/index.html
|
frontend/dist/index.html
|
||||||
frontend/dist/index.html
|
frontend/dist/index.html
|
||||||
tests/**
|
# `tests/**` and `/backend/tests/**` used to sit here. Both test suites are
|
||||||
/backend/tests/**
|
# tracked and both are run by scripts/ci-checks.sh, so the rules were inert for
|
||||||
|
# the files that already existed and did nothing but silently swallow NEW ones:
|
||||||
|
# a test added to either suite never showed up in `git status`, and CI ran a
|
||||||
|
# suite that did not include it. Removed rather than negated, because there is
|
||||||
|
# nothing under either path that should be ignored.
|
||||||
|
frontend/dist/**
|
||||||
|
nginx.conf
|
||||||
|
smoke.test.mjs
|
||||||
|
Annex**
|
||||||
|
|
@ -0,0 +1,654 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<script src="./vendor/react.js"></script>
|
||||||
|
<script src="./vendor/react-dom.js"></script>
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#03171d; --bg-elev:#071e26; --bg-sunken:#0c2933; --border:#1b404b; --border-strong:#315764;
|
||||||
|
--text:#edf7fa; --text-2:#b6ced7; --text-3:#9ebbc6;
|
||||||
|
--primary:#ccfa70; --primary-fg:#14210b; --primary-soft:#ccfa7012;
|
||||||
|
--success:#25e9a5; --success-soft:rgba(37,233,165,.12);
|
||||||
|
--warning:#ffd16e; --warning-soft:rgba(255,209,110,.14);
|
||||||
|
--danger:#ff7c86; --danger-soft:rgba(255,124,134,.14);
|
||||||
|
--info:#82bcff; --info-soft:rgba(130,188,255,.14);
|
||||||
|
--purple:#b6a6ff; --purple-soft:rgba(182,166,255,.14);
|
||||||
|
--teal:#25e9a5; --teal-soft:rgba(37,233,165,.12);
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;}
|
||||||
|
a{color:inherit;text-decoration:none;}
|
||||||
|
a:hover{color:var(--text);}
|
||||||
|
button{font:inherit;color:inherit;background:none;border:none;cursor:pointer;}
|
||||||
|
input,textarea,select{font:inherit;color:inherit;}
|
||||||
|
body{margin:0;font-family:'Neue Montreal','Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;color-scheme:dark;}
|
||||||
|
.page-shell{min-height:100%;background:radial-gradient(ellipse at 50% 0,rgba(11,41,48,.3),transparent 58%) var(--bg);}
|
||||||
|
|
||||||
|
/* ---------- icons ---------- */
|
||||||
|
.icon{fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0;}
|
||||||
|
.icon-14{width:14px;height:14px;} .icon-15{width:15px;height:15px;} .icon-16{width:16px;height:16px;}
|
||||||
|
.icon-17{width:17px;height:17px;} .icon-18{width:18px;height:18px;} .icon-20{width:20px;height:20px;} .icon-22{width:22px;height:22px;}
|
||||||
|
|
||||||
|
/* ---------- topbar ---------- */
|
||||||
|
.topbar{display:flex;align-items:center;gap:22px;min-height:67px;padding:12px 42px;background:#03181e;border-bottom:1px solid var(--border);}
|
||||||
|
.candidate-brand{display:flex;align-items:center;gap:13px;min-width:230px;}
|
||||||
|
.brand-mark{width:36px;height:36px;fill:#25e9a5;}
|
||||||
|
.candidate-brand strong{display:block;font-size:18px;line-height:1.25;letter-spacing:-.4px;}
|
||||||
|
.candidate-brand small{display:block;color:var(--text-3);font-size:12px;margin-top:3px;}
|
||||||
|
.menu-toggle{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;color:var(--text-2);}
|
||||||
|
.menu-toggle:hover{background:var(--bg-sunken);}
|
||||||
|
.topbar-search{position:relative;flex:1;max-width:520px;}
|
||||||
|
.topbar-search input{width:100%;height:38px;padding:0 14px 0 38px;border-radius:8px;background:#0c2832;border:1px solid var(--border);color:var(--text);font-size:13px;}
|
||||||
|
.topbar-search input::placeholder{color:var(--text-3);}
|
||||||
|
.search-icn{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--text-3);}
|
||||||
|
.topbar-actions{display:flex;align-items:center;gap:12px;margin-left:auto;}
|
||||||
|
.icon-btn{position:relative;width:36px;height:36px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;color:var(--text-2);}
|
||||||
|
.icon-btn:hover{background:var(--bg-sunken);color:var(--text);}
|
||||||
|
.dot-red{position:absolute;top:7px;right:7px;width:7px;height:7px;border-radius:50%;background:var(--danger);border:2px solid #03181e;}
|
||||||
|
.topbar-divider{width:1px;height:24px;background:var(--border);}
|
||||||
|
.profile-btn{display:flex;align-items:center;gap:10px;padding:5px 8px 5px 5px;border-radius:30px;}
|
||||||
|
.profile-btn:hover{background:var(--bg-sunken);}
|
||||||
|
.avatar{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;font-weight:600;font-size:13px;color:#071720;flex-shrink:0;}
|
||||||
|
.avatar-grad{background:#a19df5;}
|
||||||
|
.profile-meta{display:flex;flex-direction:column;line-height:1.2;text-align:left;}
|
||||||
|
.profile-name{font-weight:600;font-size:13px;}
|
||||||
|
.profile-role{font-size:11.5px;color:var(--text-3);}
|
||||||
|
.chev{color:var(--text-3);}
|
||||||
|
|
||||||
|
/* ---------- page shell ---------- */
|
||||||
|
.content{padding:0 42px 40px;}
|
||||||
|
.cand-page{max-width:1740px;margin-inline:auto;font-size:13px;}
|
||||||
|
.cand-page-bar{display:flex;align-items:center;gap:16px;min-height:58px;padding-block:16px;}
|
||||||
|
.cand-page-crumb{font-size:12px;color:var(--text-3);flex:1;}
|
||||||
|
.cand-page-crumb strong{color:var(--text);}
|
||||||
|
.cand-page-crumb span{margin:0 8px;}
|
||||||
|
|
||||||
|
/* ---------- buttons / badges ---------- */
|
||||||
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;padding:7px 12px;border-radius:7px;font-weight:500;font-size:12px;white-space:nowrap;border:1px solid transparent;transition:.15s;}
|
||||||
|
.btn-secondary{border:1px solid var(--border);color:var(--text);background:linear-gradient(120deg,#0c2730,#071e26);}
|
||||||
|
.btn-secondary:hover{background:#13333d;border-color:#39606b;}
|
||||||
|
.btn-primary{color:var(--primary-fg);border:1px solid #c5ed6e;background:linear-gradient(105deg,#d3fd80,#c9f86b);font-weight:650;}
|
||||||
|
.btn-primary:hover{background:#dcff9b;}
|
||||||
|
.btn-sm{min-height:30px;padding:5px 8px;}
|
||||||
|
.btn:disabled{cursor:not-allowed;opacity:.45;}
|
||||||
|
.cw-danger{border:1px solid #ae4a55;color:#ff7c86;background:#2a172055;}
|
||||||
|
.cw-danger:hover:not(:disabled){background:#50232b;}
|
||||||
|
.star-btn.on{color:var(--primary);border-color:#788e49;}
|
||||||
|
|
||||||
|
.badge{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:20px;font-size:12px;font-weight:600;white-space:nowrap;}
|
||||||
|
.badge::before{content:'';width:6px;height:6px;border-radius:50%;background:currentColor;}
|
||||||
|
.st-blue{color:var(--info);background:var(--info-soft);}
|
||||||
|
.st-purple{color:var(--purple);background:var(--purple-soft);}
|
||||||
|
.st-amber{color:var(--warning);background:var(--warning-soft);}
|
||||||
|
.st-indigo{color:var(--primary);background:var(--primary-soft);}
|
||||||
|
.st-teal{color:var(--teal);background:var(--teal-soft);}
|
||||||
|
.st-green{color:var(--success);background:var(--success-soft);}
|
||||||
|
.st-red{color:var(--danger);background:var(--danger-soft);}
|
||||||
|
.st-gray{color:var(--text-2);background:var(--bg-sunken);}
|
||||||
|
|
||||||
|
/* ---------- hero ---------- */
|
||||||
|
.cw-hero{display:flex;gap:24px;padding:22px 24px 20px;border:1px solid var(--border);border-radius:13px;background:linear-gradient(110deg,#09252e,#061d25 70%,#09252c);}
|
||||||
|
.cw-avatar{width:78px;height:78px;font-size:28px;flex-shrink:0;}
|
||||||
|
.cw-hero-body,.cw-identity{flex:1;min-width:0;}
|
||||||
|
.cw-hero-top{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;}
|
||||||
|
.cw-name{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:8px;}
|
||||||
|
.cw-name h1{margin:0;font-size:28px;line-height:1.2;letter-spacing:-.7px;font-weight:650;}
|
||||||
|
.cw-contact{display:flex;flex-wrap:wrap;gap:9px 22px;color:var(--text-3);font-size:12px;}
|
||||||
|
.cw-contact>*{display:inline-flex;align-items:center;gap:8px;}
|
||||||
|
.cw-external{color:#9dd3f1 !important;text-decoration:underline;text-underline-offset:3px;}
|
||||||
|
.cw-hero-actions{display:flex;gap:10px;flex-shrink:0;}
|
||||||
|
.cw-facts{display:grid;grid-template-columns:1.1fr 1fr .9fr 1.2fr .8fr 1.1fr .8fr;margin-top:22px;}
|
||||||
|
.cw-fact{display:flex;align-items:center;gap:12px;min-width:0;padding:0 16px;border-left:1px solid var(--border);}
|
||||||
|
.cw-fact:first-child{border-left:0;padding-left:0;}
|
||||||
|
.cw-fact:last-child{padding-right:0;}
|
||||||
|
.cw-fact>svg{color:#c3dce4;}
|
||||||
|
.cw-fact span{display:block;color:var(--text-3);font-size:12px;margin-bottom:4px;}
|
||||||
|
.cw-fact strong{font-size:13px;font-weight:500;}
|
||||||
|
|
||||||
|
/* ---------- tabs ---------- */
|
||||||
|
.cw-tabs{margin-top:16px;}
|
||||||
|
.tabs{display:flex;gap:10px;border-bottom:1px solid var(--border);overflow-x:auto;}
|
||||||
|
.tab{display:inline-flex;align-items:center;gap:8px;min-height:55px;padding:12px 20px;font-size:13px;font-weight:400;color:var(--text-2);border-bottom:3px solid transparent;margin-bottom:-1px;white-space:nowrap;}
|
||||||
|
.tab:hover{color:var(--text);}
|
||||||
|
.tab.active{color:var(--primary);border-bottom-color:var(--primary);font-weight:600;}
|
||||||
|
.tab-count{background:#153941;color:#cbdee4;font-size:11px;min-width:18px;text-align:center;padding:1px 6px;border-radius:20px;}
|
||||||
|
.tab.active .tab-count{background:var(--primary-soft);color:var(--primary);}
|
||||||
|
|
||||||
|
/* ---------- overview grid ---------- */
|
||||||
|
.cw-overview{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.65fr) minmax(0,.99fr);gap:16px;align-items:start;margin-top:16px;}
|
||||||
|
.cw-column{display:flex;flex-direction:column;gap:14px;min-width:0;}
|
||||||
|
.cw-card{min-width:0;padding:18px 17px;border:1px solid var(--border);border-radius:12px;background:linear-gradient(120deg,#09232c,#061e26 90%);}
|
||||||
|
.cw-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:16px;}
|
||||||
|
.cw-card-head h2{font-size:15px;font-weight:650;letter-spacing:-.2px;margin:0;}
|
||||||
|
.cw-link{display:inline-flex;align-items:center;gap:6px;color:#b4ed91;text-decoration:underline;text-underline-offset:3px;font-size:12px;}
|
||||||
|
.cw-info{display:grid;gap:15px;margin:0;}
|
||||||
|
.cw-info>div{display:grid;grid-template-columns:minmax(115px,.9fr) minmax(0,1.4fr);gap:12px;line-height:1.4;font-size:12px;}
|
||||||
|
.cw-info dt{display:flex;align-items:flex-start;gap:10px;color:var(--text-3);margin:0;}
|
||||||
|
.cw-info dd{margin:0;}
|
||||||
|
.cw-skills{display:flex;gap:8px;flex-wrap:wrap;}
|
||||||
|
.cw-skills>span{padding:6px 10px;border:1px solid #284b57;border-radius:12px;background:#102d38;color:#e0edf3;font-size:12px;}
|
||||||
|
.cw-table-wrap{overflow:auto;}
|
||||||
|
.cw-applications{width:100%;border-collapse:collapse;font-size:12px;text-align:left;}
|
||||||
|
.cw-applications th{color:#bad1dc;text-transform:uppercase;letter-spacing:.4px;font-size:11px;font-weight:500;border-top:1px solid #15343d;border-bottom:1px solid #15343d;padding:9px 6px;white-space:nowrap;}
|
||||||
|
.cw-applications td{padding:13px 6px;border-bottom:1px solid #15343d;}
|
||||||
|
.cw-applications td:first-child,.cw-applications th:first-child{padding-left:0;}
|
||||||
|
.cw-applications td:last-child,.cw-applications th:last-child{padding-right:0;}
|
||||||
|
.cw-applications tr:last-child td{border-bottom:0;}
|
||||||
|
.cw-applications td:nth-child(2){color:var(--text-2);white-space:nowrap;}
|
||||||
|
.cw-applications strong{display:block;font-size:13px;font-weight:550;}
|
||||||
|
.cw-applications small{display:block;color:var(--text-3);font-size:11px;margin-top:4px;}
|
||||||
|
.cw-applications .badge{font-size:11px;padding:3px 7px;}
|
||||||
|
.cw-applications .is-current{background:linear-gradient(90deg,rgba(18,53,52,.22),transparent);}
|
||||||
|
.cw-summary{margin:0;color:var(--text-2);font-size:13px;line-height:1.8;}
|
||||||
|
.cw-empty{color:var(--text-3);font-size:13px;line-height:1.7;margin:0;}
|
||||||
|
.cw-document{display:flex;align-items:center;gap:11px;padding:10px;border:1px solid var(--border);border-radius:8px;background:linear-gradient(100deg,#0d2c36,#0a232b);}
|
||||||
|
.cw-document-icon{display:flex;flex-direction:column;align-items:center;justify-content:center;width:29px;height:36px;background:linear-gradient(135deg,#ff7575,#df424d);border-radius:4px;color:#fff;flex-shrink:0;}
|
||||||
|
.cw-document-icon small{font-size:7px;margin-top:2px;}
|
||||||
|
.cw-document-name{flex:1;min-width:0;}
|
||||||
|
.cw-document-name strong{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-weight:500;}
|
||||||
|
.cw-document-name small{display:block;color:var(--text-3);font-size:11px;margin-top:4px;}
|
||||||
|
.cw-document-actions{display:flex;gap:6px;flex-shrink:0;}
|
||||||
|
.cw-document-list{display:grid;gap:9px;}
|
||||||
|
.cw-bottom-grid{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);gap:14px;}
|
||||||
|
.cw-bottom-grid .cw-card{padding:17px;}
|
||||||
|
.cw-rating{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
|
||||||
|
.cw-rating>span{font-size:12px;color:var(--primary);}
|
||||||
|
.rating-stars{display:inline-flex;gap:3px;}
|
||||||
|
.rating-stars .rs{color:var(--border-strong);}
|
||||||
|
.rating-stars .rs svg{width:18px;height:18px;}
|
||||||
|
.rating-stars .rs.on{color:var(--warning);}
|
||||||
|
.rating-stars .rs.on svg{fill:currentColor;}
|
||||||
|
.cw-recruiter{display:flex;align-items:center;gap:10px;}
|
||||||
|
.cw-recruiter .avatar{width:32px;height:32px;font-size:12px;}
|
||||||
|
.cw-recruiter strong{display:block;font-size:12px;font-weight:500;}
|
||||||
|
.cw-recruiter small{display:block;color:var(--text-3);font-size:12px;margin-top:3px;}
|
||||||
|
.cw-action-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px;}
|
||||||
|
.cw-action-grid .btn{font-size:12px;justify-content:flex-start;padding:8px;white-space:normal;text-align:left;}
|
||||||
|
.cw-active-application{font-size:12px;color:var(--text-3);margin:-4px 0 12px;}
|
||||||
|
.cw-status-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.15fr);gap:10px;}
|
||||||
|
.cw-field-label{display:block;color:var(--text-3);font-size:12px;margin-bottom:5px;}
|
||||||
|
.cw-status-grid select,.cw-status-value{width:100%;min-height:37px;padding:8px 10px;background:#0c2933;color:var(--text);border:1px solid var(--border);border-radius:7px;font-size:12px;}
|
||||||
|
.cw-status-grid select{appearance:none;}
|
||||||
|
.cw-status-value{display:flex;align-items:center;gap:8px;}
|
||||||
|
.cw-status-dot{width:7px;height:7px;background:var(--success);border-radius:50%;flex-shrink:0;}
|
||||||
|
.cw-status-dot.is-closed{background:var(--text-3);}
|
||||||
|
.cw-activity{list-style:none;margin:0;padding:0;}
|
||||||
|
.cw-activity li{position:relative;padding:0 0 23px 24px;}
|
||||||
|
.cw-activity li:last-child{padding-bottom:0;}
|
||||||
|
.cw-activity li::before{content:'';position:absolute;left:0;top:4px;width:10px;height:10px;background:#59a8ff;border:2px solid #245788;border-radius:50%;}
|
||||||
|
.cw-activity li:not(:last-child)::after{content:'';position:absolute;width:1px;left:4px;top:15px;bottom:3px;background:#315662;}
|
||||||
|
.cw-activity-top{display:flex;align-items:baseline;justify-content:space-between;gap:8px;}
|
||||||
|
.cw-activity strong{font-size:12px;font-weight:550;}
|
||||||
|
.cw-activity time{color:var(--text-3);font-size:11px;white-space:nowrap;}
|
||||||
|
.cw-activity p{color:var(--text-3);font-size:12px;line-height:1.65;margin:5px 0 0;}
|
||||||
|
.cw-activity small{color:var(--text-3);font-size:11px;}
|
||||||
|
.cw-screening{display:flex;align-items:center;gap:18px;}
|
||||||
|
.cw-match{display:grid;justify-items:center;gap:6px;flex-shrink:0;}
|
||||||
|
.cw-match small{color:var(--text-3);font-size:12px;}
|
||||||
|
.score-ring{--pct:0;position:relative;width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:conic-gradient(var(--sc-color) calc(var(--pct)*1%),var(--bg-sunken) 0);}
|
||||||
|
.score-ring::after{content:'';position:absolute;inset:4px;border-radius:50%;background:var(--bg-elev);}
|
||||||
|
.score-ring span{position:relative;z-index:1;font-size:10px;font-weight:700;}
|
||||||
|
.cw-tab-content{padding:22px;background:var(--bg-elev);border:1px solid var(--border);border-radius:12px;margin-top:16px;}
|
||||||
|
.cw-muted{color:var(--text-3);}
|
||||||
|
|
||||||
|
/* ---------- secondary-tab content (lighter fidelity, same tokens) ---------- */
|
||||||
|
.simple-row{display:flex;align-items:center;gap:12px;padding:12px 0;border-top:1px solid var(--border);}
|
||||||
|
.simple-row:first-child{border-top:0;padding-top:0;}
|
||||||
|
.simple-row-icn{width:36px;height:36px;border-radius:9px;display:grid;place-items:center;flex-shrink:0;background:var(--bg-sunken);color:var(--info);}
|
||||||
|
.simple-row-main{flex:1;min-width:0;}
|
||||||
|
.simple-row-title{font-size:13px;font-weight:600;}
|
||||||
|
.simple-row-sub{font-size:12px;color:var(--text-3);margin-top:3px;}
|
||||||
|
.note-compose textarea{width:100%;min-height:64px;padding:10px 12px;background:#0c2933;border:1px solid var(--border);border-radius:8px;color:var(--text);font:inherit;resize:vertical;margin-bottom:10px;}
|
||||||
|
.note-compose textarea::placeholder{color:var(--text-3);}
|
||||||
|
.section-label{font-size:12px;color:var(--text-3);font-weight:600;text-transform:uppercase;letter-spacing:.4px;margin-bottom:12px;}
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
|
||||||
|
<div class="page-shell">
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<a class="candidate-brand" href="#">
|
||||||
|
<svg class="brand-mark" viewBox="0 0 100 64.46" aria-hidden="true"><path d="M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z"/></svg>
|
||||||
|
<span><strong>Utopia Brands</strong><small>HR Portal</small></span>
|
||||||
|
</a>
|
||||||
|
<button class="menu-toggle" aria-label="Toggle menu">
|
||||||
|
<svg class="icon icon-20" viewBox="0 0 24 24"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="topbar-search">
|
||||||
|
<svg class="icon icon-16 search-icn" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
<input type="text" placeholder="Search candidates, jobs, requisitions…" />
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<button class="icon-btn" aria-label="Notifications">
|
||||||
|
<svg class="icon icon-20" viewBox="0 0 24 24"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||||
|
<span class="dot-red"></span>
|
||||||
|
</button>
|
||||||
|
<div class="topbar-divider"></div>
|
||||||
|
<button class="profile-btn">
|
||||||
|
<span class="avatar avatar-grad">MK</span>
|
||||||
|
<span class="profile-meta"><span class="profile-name">Meera Khan</span><span class="profile-role">Recruiter</span></span>
|
||||||
|
<svg class="icon icon-16 chev" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<div class="cand-page">
|
||||||
|
|
||||||
|
<div class="cand-page-bar">
|
||||||
|
<button class="btn btn-secondary btn-sm"><svg class="icon icon-16" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>Back</button>
|
||||||
|
<div class="cand-page-crumb">Candidates <span>/</span> <strong>Ada Lovelace</strong></div>
|
||||||
|
<div class="cand-page-actions">
|
||||||
|
<button class="btn btn-secondary star-btn {{favClass}}" onClick="{{favoriteToggle}}">
|
||||||
|
<svg class="icon icon-16" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>{{favLabel}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ HERO ============ -->
|
||||||
|
<header class="cw-hero">
|
||||||
|
<span class="avatar cw-avatar" style="background:linear-gradient(145deg,#b2acff,#9395f0)">AL</span>
|
||||||
|
<div class="cw-hero-body">
|
||||||
|
<div class="cw-hero-top">
|
||||||
|
<div class="cw-identity">
|
||||||
|
<div class="cw-name">
|
||||||
|
<h1>Ada Lovelace</h1>
|
||||||
|
<span class="badge {{stageClass}}">{{stage}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cw-contact">
|
||||||
|
<a href="mailto:ada.lovelace@example.com"><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>ada.lovelace@example.com</a>
|
||||||
|
<a href="tel:+15552147788"><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.98.36 1.94.7 2.85a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.87.57 2.85.7A2 2 0 0 1 22 16.92z"/></svg>+1 (555) 214-7788</a>
|
||||||
|
<span><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>Austin, TX</span>
|
||||||
|
<a class="cw-external" href="#" target="_blank" rel="noopener noreferrer"><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/></svg>LinkedIn profile</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cw-hero-actions">
|
||||||
|
<button class="btn btn-secondary"><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Download CV</button>
|
||||||
|
<button class="btn btn-primary"><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>Open Resume</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cw-facts">
|
||||||
|
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg><div><span>Applied for</span><strong>Senior Backend Engineer</strong></div></div>
|
||||||
|
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg><div><span>Applied on</span><strong>Mar 12, 2026</strong></div></div>
|
||||||
|
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg><div><span>Source</span><strong>Careers page</strong></div></div>
|
||||||
|
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg><div><span>Current company</span><strong>Meridian Systems</strong></div></div>
|
||||||
|
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><div><span>Experience</span><strong>6 years</strong></div></div>
|
||||||
|
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/></svg><div><span>Education</span><strong>MSc Computer Science</strong></div></div>
|
||||||
|
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><div><span>Total applications</span><strong>{{appCount}}</strong></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ============ TABS ============ -->
|
||||||
|
<div class="cw-tabs">
|
||||||
|
<div class="tabs">
|
||||||
|
<sc-for list="{{tabs}}" as="t" hint-placeholder-count="8">
|
||||||
|
<button class="{{t.cls}}" onClick="{{t.pick}}">{{t.label}}<sc-if value="{{t.hasCount}}" hint-placeholder-val="{{true}}"><span class="tab-count">{{t.count}}</span></sc-if></button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ OVERVIEW ============ -->
|
||||||
|
<sc-if value="{{showOverview}}" hint-placeholder-val="{{true}}">
|
||||||
|
<div class="cw-overview">
|
||||||
|
|
||||||
|
<div class="cw-column">
|
||||||
|
<section class="cw-card">
|
||||||
|
<div class="cw-card-head"><h2>Candidate Information</h2></div>
|
||||||
|
<dl class="cw-info">
|
||||||
|
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Full name</dt><dd>Ada Lovelace</dd></div>
|
||||||
|
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>Email</dt><dd>ada.lovelace@example.com</dd></div>
|
||||||
|
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.98.36 1.94.7 2.85a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.87.57 2.85.7A2 2 0 0 1 22 16.92z"/></svg>Phone</dt><dd>+1 (555) 214-7788</dd></div>
|
||||||
|
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>Location</dt><dd>Austin, TX</dd></div>
|
||||||
|
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>Current company</dt><dd>Meridian Systems</dd></div>
|
||||||
|
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>Current title</dt><dd>Senior Backend Engineer</dd></div>
|
||||||
|
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>Experience</dt><dd>6 years</dd></div>
|
||||||
|
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/></svg>Education</dt><dd>MSc Computer Science — Imperial College London</dd></div>
|
||||||
|
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/></svg>LinkedIn</dt><dd><a class="cw-external" href="#" target="_blank" rel="noopener noreferrer">View LinkedIn profile</a></dd></div>
|
||||||
|
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>Notice period</dt><dd><sc-if value="{{hasNoticePeriod}}" hint-placeholder-val="{{true}}">{{noticePeriod}}</sc-if><sc-if value="{{noNoticePeriod}}" hint-placeholder-val="{{false}}"><span class="cw-muted">—</span></sc-if></dd></div>
|
||||||
|
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>Expected salary</dt><dd><sc-if value="{{hasExpectedSalary}}" hint-placeholder-val="{{true}}">{{expectedSalary}}</sc-if><sc-if value="{{noExpectedSalary}}" hint-placeholder-val="{{false}}"><span class="cw-muted">—</span></sc-if></dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<section class="cw-card">
|
||||||
|
<div class="cw-card-head"><h2>Skills & Tags</h2></div>
|
||||||
|
<div class="cw-skills">
|
||||||
|
<span>Python</span><span>FastAPI</span><span>PostgreSQL</span><span>Docker</span><span>Kubernetes</span><span>REST APIs</span><span>Kafka</span><span>AWS</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cw-column">
|
||||||
|
<section class="cw-card">
|
||||||
|
<div class="cw-card-head">
|
||||||
|
<h2>Applications ({{appCount}})</h2>
|
||||||
|
<sc-if value="{{hasMoreApps}}" hint-placeholder-val="{{true}}">
|
||||||
|
<button class="cw-link" onClick="{{toggleApps}}"><svg class="icon icon-15" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>{{appsToggleLabel}}</button>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
<div class="cw-table-wrap">
|
||||||
|
<table class="cw-applications">
|
||||||
|
<thead><tr><th>Job title</th><th>Applied on</th><th>Status</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<sc-for list="{{applications}}" as="a" hint-placeholder-count="3">
|
||||||
|
<tr class="{{a.rowCls}}">
|
||||||
|
<td><strong>{{a.title}}</strong><small>{{a.sub}}</small></td>
|
||||||
|
<td>{{a.when}}</td>
|
||||||
|
<td><span class="badge {{a.cls}}">{{a.status}}</span></td>
|
||||||
|
<td><sc-if value="{{a.current}}" hint-placeholder-val="{{false}}"><span class="cw-muted">—</span></sc-if><sc-if value="{{a.notCurrent}}" hint-placeholder-val="{{true}}"><button class="btn btn-secondary btn-sm">View</button></sc-if></td>
|
||||||
|
</tr>
|
||||||
|
</sc-for>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cw-card">
|
||||||
|
<div class="cw-card-head"><h2>Professional Summary</h2></div>
|
||||||
|
<p class="cw-summary">Senior backend engineer with 6 years building high-throughput payment and fulfillment services. Led the migration of a monolith to event-driven microservices on Kafka, cutting checkout latency by 40%. Comfortable owning a service from design through on-call.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cw-card">
|
||||||
|
<div class="cw-card-head"><h2>Resume</h2></div>
|
||||||
|
<div class="cw-document">
|
||||||
|
<span class="cw-document-icon"><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><small>PDF</small></span>
|
||||||
|
<div class="cw-document-name"><strong title="Ada_Lovelace_Resume.pdf">Ada_Lovelace_Resume.pdf</strong><small>PDF · Mar 12, 2026</small></div>
|
||||||
|
<div class="cw-document-actions">
|
||||||
|
<button class="btn btn-secondary btn-sm"><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>Preview</button>
|
||||||
|
<button class="btn btn-secondary btn-sm"><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Download</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cw-card">
|
||||||
|
<div class="cw-card-head"><h2>Ratings</h2></div>
|
||||||
|
<div class="cw-rating" role="radiogroup" aria-label="Candidate rating">
|
||||||
|
<div class="rating-stars">
|
||||||
|
<sc-for list="{{stars}}" as="star" hint-placeholder-count="5">
|
||||||
|
<span class="{{star.cls}}" onClick="{{star.pick}}"><svg class="icon" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<span>{{ratingText}}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cw-card">
|
||||||
|
<div class="cw-card-head"><h2>Recruiter</h2></div>
|
||||||
|
<div class="cw-recruiter">
|
||||||
|
<span class="avatar" style="background:linear-gradient(145deg,#b2acff,#9395f0)">MK</span>
|
||||||
|
<div><strong>Meera Khan</strong><small>Hiring team</small></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cw-card">
|
||||||
|
<div class="cw-card-head"><h2>AI Screening</h2></div>
|
||||||
|
<div class="cw-screening">
|
||||||
|
<div class="cw-match">
|
||||||
|
<span class="score-ring" style="--pct:82;--sc-color:var(--warning)"><span>82</span></span>
|
||||||
|
<small>Strong Match</small>
|
||||||
|
</div>
|
||||||
|
<div class="cw-summary"><p>Meets every mandatory requirement with demonstrated production experience; missing only the Kubernetes depth the role prefers.</p></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cw-card">
|
||||||
|
<div class="cw-card-head"><h2>Suggested Roles</h2></div>
|
||||||
|
<div class="cw-skills"><span>Platform Engineer</span><span>Staff Backend Engineer</span></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="cw-column" aria-label="Candidate actions and activity">
|
||||||
|
<section class="cw-card">
|
||||||
|
<div class="cw-card-head"><h2>Quick Actions</h2></div>
|
||||||
|
<div class="cw-action-grid">
|
||||||
|
<button class="btn btn-primary"><svg class="icon icon-16" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>Schedule Interview</button>
|
||||||
|
<button class="btn btn-secondary" onClick="{{moveNext}}"><svg class="icon icon-16" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>Move to {{nextStageLabel}}</button>
|
||||||
|
<button class="btn btn-secondary" onClick="{{goNotes}}"><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Add Note</button>
|
||||||
|
<button class="btn btn-secondary" onClick="{{goForms}}"><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>View Forms</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="cw-card">
|
||||||
|
<div class="cw-card-head"><h2>Status & Stage</h2></div>
|
||||||
|
<p class="cw-active-application">Active application: Senior Backend Engineer</p>
|
||||||
|
<div class="cw-status-grid">
|
||||||
|
<div>
|
||||||
|
<span class="cw-field-label">Status</span>
|
||||||
|
<div class="cw-status-value"><span class="cw-status-dot {{statusDotCls}}"></span>{{statusLabel}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="cw-field-label">Stage</span>
|
||||||
|
<div class="cw-status-value">{{stage}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="cw-card">
|
||||||
|
<div class="cw-card-head"><h2>Recent Activity</h2><button class="cw-link" onClick="{{goTimeline}}"><svg class="icon icon-15" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>View all</button></div>
|
||||||
|
<ol class="cw-activity">
|
||||||
|
<li><div class="cw-activity-top"><strong>Interview scheduled</strong><time>Mar 15, 2026</time></div><p>Technical round with hiring panel</p></li>
|
||||||
|
<li><div class="cw-activity-top"><strong>Internal note added</strong><time>Mar 14, 2026</time></div><p>Great communication, prior fintech experience.</p><small>By Meera Khan</small></li>
|
||||||
|
<li><div class="cw-activity-top"><strong>Screening completed</strong><time>Mar 13, 2026</time></div><p>Match score: 82% — strong technical alignment</p></li>
|
||||||
|
<li><div class="cw-activity-top"><strong>Application received</strong><time>Mar 12, 2026</time></div><p>Applied via Careers page</p></li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- ============ SECONDARY TABS ============ -->
|
||||||
|
<sc-if value="{{showResume}}" hint-placeholder-val="{{false}}">
|
||||||
|
<div class="cw-tab-content">
|
||||||
|
<div class="cw-document">
|
||||||
|
<span class="cw-document-icon"><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><small>PDF</small></span>
|
||||||
|
<div class="cw-document-name"><strong>Ada_Lovelace_Resume.pdf</strong><small>Original CV from the application</small></div>
|
||||||
|
<div class="cw-document-actions"><button class="btn btn-secondary btn-sm">Preview</button><button class="btn btn-secondary btn-sm">Download</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{showInterview}}" hint-placeholder-val="{{false}}">
|
||||||
|
<div class="cw-tab-content">
|
||||||
|
<div class="simple-row">
|
||||||
|
<span class="simple-row-icn"><svg class="icon icon-18" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></span>
|
||||||
|
<div class="simple-row-main"><div class="simple-row-title">Technical — System Design</div><div class="simple-row-sub">Mar 15, 2026 · 3:00 PM</div></div>
|
||||||
|
<span class="badge st-blue">Scheduled</span>
|
||||||
|
</div>
|
||||||
|
<p class="cw-empty" style="margin-top:16px">Scheduling a new round attaches it to this application.</p>
|
||||||
|
<button class="btn btn-primary btn-sm" style="margin-top:10px"><svg class="icon icon-16" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Schedule Interview</button>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{showForms}}" hint-placeholder-val="{{false}}">
|
||||||
|
<div class="cw-tab-content">
|
||||||
|
<div class="simple-row">
|
||||||
|
<span class="simple-row-icn"><svg class="icon icon-18" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></span>
|
||||||
|
<div class="simple-row-main"><div class="simple-row-title">Technical scorecard</div><div class="simple-row-sub">Submitted by Farhan Ali · Mar 15, 2026</div></div>
|
||||||
|
<span class="badge st-green">Submitted</span>
|
||||||
|
</div>
|
||||||
|
<div class="simple-row">
|
||||||
|
<span class="simple-row-icn"><svg class="icon icon-18" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></span>
|
||||||
|
<div class="simple-row-main"><div class="simple-row-title">Offer approval</div><div class="simple-row-sub">Pending hiring manager sign-off</div></div>
|
||||||
|
<span class="badge st-amber">Pending</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{showNotes}}" hint-placeholder-val="{{false}}">
|
||||||
|
<div class="cw-tab-content">
|
||||||
|
<div class="note-compose">
|
||||||
|
<textarea placeholder="Write a private note about this candidate…" value="{{noteDraft}}" onChange="{{onNoteDraftChange}}"></textarea>
|
||||||
|
<button class="btn btn-primary btn-sm" onClick="{{addNote}}">Add Note</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:18px">
|
||||||
|
<sc-for list="{{notes}}" as="n" hint-placeholder-count="2">
|
||||||
|
<div class="simple-row">
|
||||||
|
<span class="avatar" style="background:linear-gradient(145deg,#b2acff,#9395f0)">{{n.initials}}</span>
|
||||||
|
<div class="simple-row-main"><div class="simple-row-title">{{n.author}}</div><div class="simple-row-sub">{{n.text}}</div><div class="simple-row-sub">{{n.when}}</div></div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{showActivity}}" hint-placeholder-val="{{false}}">
|
||||||
|
<div class="cw-tab-content">
|
||||||
|
<div class="simple-row"><span class="simple-row-icn"><svg class="icon icon-18" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span><div class="simple-row-main"><div class="simple-row-sub">Profile viewed by Meera Khan</div><div class="simple-row-sub">1h ago</div></div></div>
|
||||||
|
<div class="simple-row"><span class="simple-row-icn"><svg class="icon icon-18" viewBox="0 0 24 24"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><polyline points="22,6 12,13 2,6"/></svg></span><div class="simple-row-main"><div class="simple-row-sub">Email sent: Interview invitation</div><div class="simple-row-sub">1 day ago</div></div></div>
|
||||||
|
<div class="simple-row"><span class="simple-row-icn"><svg class="icon icon-18" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span><div class="simple-row-main"><div class="simple-row-sub">Assessment score updated to 82%</div><div class="simple-row-sub">2 days ago</div></div></div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{showTimeline}}" hint-placeholder-val="{{false}}">
|
||||||
|
<div class="cw-tab-content">
|
||||||
|
<ol class="cw-activity">
|
||||||
|
<li><div class="cw-activity-top"><strong>Application received</strong><time>Mar 12, 2026</time></div><p>Applied via Careers page</p></li>
|
||||||
|
<li><div class="cw-activity-top"><strong>AI screening completed</strong><time>Mar 13, 2026</time></div><p>Match score: 82%</p></li>
|
||||||
|
<li><div class="cw-activity-top"><strong>Interview scheduled</strong><time>Mar 15, 2026</time></div><p>Technical — System Design</p></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{showHistory}}" hint-placeholder-val="{{false}}">
|
||||||
|
<div class="cw-tab-content">
|
||||||
|
<div class="section-label">Today</div>
|
||||||
|
<div class="simple-row"><span class="simple-row-icn"><svg class="icon icon-18" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></span><div class="simple-row-main"><div class="simple-row-title">Stage changed</div><div class="simple-row-sub">Screening → Interview</div><div class="simple-row-sub">Meera Khan · 10:14 AM</div></div></div>
|
||||||
|
<div class="simple-row"><span class="simple-row-icn"><svg class="icon icon-18" viewBox="0 0 24 24"><circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/></svg></span><div class="simple-row-main"><div class="simple-row-title">Feedback submitted</div><div class="simple-row-sub">by Farhan Ali</div></div></div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script data-dc-script>
|
||||||
|
class Component extends DCLogic {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
tab: 'Overview',
|
||||||
|
rating: 4,
|
||||||
|
stage: 'Interview',
|
||||||
|
favorite: true,
|
||||||
|
appsExpanded: false,
|
||||||
|
noteDraft: '',
|
||||||
|
noticePeriod: '4 weeks',
|
||||||
|
expectedSalary: '$168,000',
|
||||||
|
notes: [
|
||||||
|
{ id: 1, author: 'Meera Khan', initials: 'MK', text: 'Great communication, prior fintech experience.', when: 'Mar 14, 2026' },
|
||||||
|
{ id: 2, author: 'Farhan Ali', initials: 'FA', text: 'Strong system design answers in the technical screen.', when: 'Mar 10, 2026' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
this.selectTab = this.selectTab.bind(this);
|
||||||
|
this.setRating = this.setRating.bind(this);
|
||||||
|
this.toggleFavorite = this.toggleFavorite.bind(this);
|
||||||
|
this.toggleApps = this.toggleApps.bind(this);
|
||||||
|
this.moveNext = this.moveNext.bind(this);
|
||||||
|
this.addNote = this.addNote.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectTab(key) { this.setState({ tab: key }); }
|
||||||
|
setRating(n) { this.setState({ rating: n }); }
|
||||||
|
toggleFavorite() { this.setState({ favorite: !this.state.favorite }); }
|
||||||
|
toggleApps() { this.setState({ appsExpanded: !this.state.appsExpanded }); }
|
||||||
|
moveNext() {
|
||||||
|
const order = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired'];
|
||||||
|
const i = order.indexOf(this.state.stage);
|
||||||
|
if (i >= 0 && i < order.length - 1) this.setState({ stage: order[i + 1] });
|
||||||
|
}
|
||||||
|
addNote() {
|
||||||
|
const text = this.state.noteDraft.trim();
|
||||||
|
if (!text) return;
|
||||||
|
const note = { id: Date.now(), author: 'You', initials: 'Y', text, when: 'Just now' };
|
||||||
|
this.setState({ notes: [note, ...this.state.notes], noteDraft: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals() {
|
||||||
|
const s = this.state;
|
||||||
|
const KANBAN = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired'];
|
||||||
|
const STATUS_CLASS = {
|
||||||
|
Shortlist: 'st-blue', Screening: 'st-purple', Assessment: 'st-amber', Interview: 'st-indigo',
|
||||||
|
Offer: 'st-teal', Approved: 'st-gray', Hired: 'st-green', Rejected: 'st-red', 'On Hold': 'st-amber',
|
||||||
|
};
|
||||||
|
const idx = KANBAN.indexOf(s.stage);
|
||||||
|
const nextStage = idx >= 0 && idx < KANBAN.length - 1 ? KANBAN[idx + 1] : null;
|
||||||
|
const isClosed = s.stage === 'Rejected' || s.stage === 'Hired';
|
||||||
|
const statusLabel = isClosed ? 'Closed' : s.stage === 'On Hold' ? 'On hold' : 'In progress';
|
||||||
|
|
||||||
|
const tabDefs = [
|
||||||
|
{ key: 'Overview', label: 'Overview', count: null },
|
||||||
|
{ key: 'Resume', label: 'Resume', count: null },
|
||||||
|
{ key: 'Interview', label: 'Interviews', count: 1 },
|
||||||
|
{ key: 'Forms', label: 'Forms', count: 2 },
|
||||||
|
{ key: 'Notes', label: 'Notes', count: s.notes.length },
|
||||||
|
{ key: 'Activity', label: 'Activity', count: 5 },
|
||||||
|
{ key: 'Timeline', label: 'Timeline', count: null },
|
||||||
|
{ key: 'History', label: 'History', count: null },
|
||||||
|
];
|
||||||
|
const tabs = tabDefs.map((t) => ({
|
||||||
|
...t,
|
||||||
|
cls: t.key === s.tab ? 'tab active' : 'tab',
|
||||||
|
hasCount: t.count != null,
|
||||||
|
pick: () => this.selectTab(t.key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const allApplications = [
|
||||||
|
{ title: 'Senior Backend Engineer', sub: 'Current application', when: 'Mar 12, 2026', status: s.stage, cls: STATUS_CLASS[s.stage] || 'st-gray', current: true },
|
||||||
|
{ title: 'Backend Engineer', sub: 'Email application', when: 'Jan 5, 2025', status: 'Rejected', cls: 'st-red', current: false },
|
||||||
|
{ title: 'Platform Engineer II', sub: 'Email application', when: 'Aug 22, 2024', status: 'Rejected', cls: 'st-red', current: false },
|
||||||
|
{ title: 'Backend Engineer Intern', sub: 'Application form', when: 'Jun 3, 2022', status: 'Hired', cls: 'st-green', current: false },
|
||||||
|
].map((a) => ({ ...a, rowCls: a.current ? 'is-current' : '', notCurrent: !a.current }));
|
||||||
|
const applications = s.appsExpanded ? allApplications : allApplications.slice(0, 3);
|
||||||
|
|
||||||
|
const stars = [1, 2, 3, 4, 5].map((n) => ({
|
||||||
|
n, cls: n <= s.rating ? 'rs on' : 'rs', pick: () => this.setRating(n),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
tab: s.tab, tabs,
|
||||||
|
showOverview: s.tab === 'Overview',
|
||||||
|
showResume: s.tab === 'Resume',
|
||||||
|
showInterview: s.tab === 'Interview',
|
||||||
|
showForms: s.tab === 'Forms',
|
||||||
|
showNotes: s.tab === 'Notes',
|
||||||
|
showActivity: s.tab === 'Activity',
|
||||||
|
showTimeline: s.tab === 'Timeline',
|
||||||
|
showHistory: s.tab === 'History',
|
||||||
|
|
||||||
|
favClass: s.favorite ? 'on' : '',
|
||||||
|
favLabel: s.favorite ? 'Favorited' : 'Favorite',
|
||||||
|
favoriteToggle: this.toggleFavorite,
|
||||||
|
|
||||||
|
stage: s.stage,
|
||||||
|
stageClass: STATUS_CLASS[s.stage] || 'st-gray',
|
||||||
|
statusLabel,
|
||||||
|
statusDotCls: isClosed ? 'is-closed' : '',
|
||||||
|
nextStageLabel: nextStage || 'Rejected',
|
||||||
|
moveNext: this.moveNext,
|
||||||
|
|
||||||
|
stars, ratingText: s.rating ? `${s.rating.toFixed(1)} / 5` : 'Not rated',
|
||||||
|
|
||||||
|
appCount: allApplications.length,
|
||||||
|
applications,
|
||||||
|
hasMoreApps: allApplications.length > 3,
|
||||||
|
appsToggleLabel: s.appsExpanded ? 'Show less' : 'View all',
|
||||||
|
toggleApps: this.toggleApps,
|
||||||
|
|
||||||
|
notes: s.notes, noteDraft: s.noteDraft,
|
||||||
|
onNoteDraftChange: (e) => this.setState({ noteDraft: e.target.value }),
|
||||||
|
addNote: this.addNote,
|
||||||
|
|
||||||
|
noticePeriod: s.noticePeriod, hasNoticePeriod: !!s.noticePeriod, noNoticePeriod: !s.noticePeriod,
|
||||||
|
expectedSalary: s.expectedSalary, hasExpectedSalary: !!s.expectedSalary, noExpectedSalary: !s.expectedSalary,
|
||||||
|
|
||||||
|
goNotes: () => this.selectTab('Notes'),
|
||||||
|
goForms: () => this.selectTab('Forms'),
|
||||||
|
goTimeline: () => this.selectTab('Timeline'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -23,9 +23,11 @@ _BACKEND_ENV = Path(__file__).resolve().parents[2] / "backend" / ".env"
|
||||||
# releases faster than this file can be updated, and rejecting a brand-new gpt-5.x
|
# releases faster than this file can be updated, and rejecting a brand-new gpt-5.x
|
||||||
# would be worse than the small risk of admitting one with a different feature set.
|
# would be worse than the small risk of admitting one with a different feature set.
|
||||||
#
|
#
|
||||||
# The gpt-4o family is excluded on purpose: snapshots before 2024-08-06 lack structured
|
# gpt-4o-mini is admitted: its only snapshot (2024-07-18) supports structured outputs
|
||||||
# outputs, and distinguishing them by alias is not reliable.
|
# and it is the production model. The wider gpt-4o family stays excluded because
|
||||||
SUPPORTED_MODEL_PREFIXES: tuple[str, ...] = ("gpt-5", "gpt-4.1", "o3", "o4")
|
# snapshots before 2024-08-06 lack structured outputs and aliases do not say which
|
||||||
|
# snapshot you get. It is not a reasoning model, so `reasoning` is omitted for it.
|
||||||
|
SUPPORTED_MODEL_PREFIXES: tuple[str, ...] = ("gpt-5", "gpt-4.1", "o3", "o4", "gpt-4o-mini")
|
||||||
|
|
||||||
# "-chat-latest" variants track the ChatGPT product surface rather than the API model
|
# "-chat-latest" variants track the ChatGPT product surface rather than the API model
|
||||||
# line and do not expose reasoning effort.
|
# line and do not expose reasoning effort.
|
||||||
|
|
|
||||||
|
|
@ -55,13 +55,16 @@ class ATSScore(StrictModel):
|
||||||
matched_keywords: list[str] = Field(default_factory=list, max_length=30)
|
matched_keywords: list[str] = Field(default_factory=list, max_length=30)
|
||||||
missing_keywords: list[str] = Field(default_factory=list, max_length=30)
|
missing_keywords: list[str] = Field(default_factory=list, max_length=30)
|
||||||
summary_critique: str = Field(min_length=1, max_length=500)
|
summary_critique: str = Field(min_length=1, max_length=500)
|
||||||
|
professional_summary: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
@field_validator("matched_keywords", "missing_keywords", mode="before")
|
@field_validator("matched_keywords", "missing_keywords", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize(cls, value: Any) -> Any:
|
def _normalize(cls, value: Any) -> Any:
|
||||||
return _normalize_keywords(value)
|
return _normalize_keywords(value)
|
||||||
|
|
||||||
@field_validator("candidate_name", "job_title", "current_company", mode="before")
|
@field_validator(
|
||||||
|
"candidate_name", "job_title", "current_company", "professional_summary", mode="before"
|
||||||
|
)
|
||||||
@classmethod
|
@classmethod
|
||||||
def _blank_profile_text_to_none(cls, value: Any) -> Any:
|
def _blank_profile_text_to_none(cls, value: Any) -> Any:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,10 @@ entries; null if neither is stated.
|
||||||
(for example "6 years of experience"), use that stated number; otherwise compute \
|
(for example "6 years of experience"), use that stated number; otherwise compute \
|
||||||
whole years only from dates or durations explicitly stated in the resume; null \
|
whole years only from dates or durations explicitly stated in the resume; null \
|
||||||
whenever neither is available.
|
whenever neither is available.
|
||||||
|
- professional_summary: one or two sentences naming the candidate's tech-stack \
|
||||||
|
speciality and functional department from the resume alone. Ignore the job \
|
||||||
|
description. This is not summary_critique. Null if the resume does not evidence \
|
||||||
|
either a stack or a department.
|
||||||
|
|
||||||
Return concise, evidence-based fields matching the supplied JSON schema. \
|
Return concise, evidence-based fields matching the supplied JSON schema. \
|
||||||
matched_keywords must contain only skills that appear in the resume, written with the \
|
matched_keywords must contain only skills that appear in the resume, written with the \
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import re
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import PurePosixPath, PureWindowsPath
|
from pathlib import PurePosixPath, PureWindowsPath
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pypdf import PdfReader
|
from pypdf import PdfReader
|
||||||
|
|
||||||
|
|
@ -82,6 +83,55 @@ def _normalize_text(text: str) -> str:
|
||||||
return text.strip()
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def is_glyph_fragmented(text: str | None, *, min_lines: int = 20, ratio: float = 0.4) -> bool:
|
||||||
|
"""True when pypdf emitted one character per line instead of words.
|
||||||
|
|
||||||
|
Design tools that position every glyph separately (Canva, InDesign and
|
||||||
|
friends) make pypdf's default mode break after each one, so a CV reading
|
||||||
|
"LinkedIn: linkedin.com/in/jane" arrives as thirty single-character lines.
|
||||||
|
A model reads that fine, which is why it hides: what breaks is every
|
||||||
|
substring check downstream. ``verify_matched_keywords`` drops every keyword,
|
||||||
|
and on the recruiting side the LinkedIn scan and the skills, company and
|
||||||
|
education clamps all return nothing, silently.
|
||||||
|
|
||||||
|
``min_lines`` stops a two-line PDF or a near-empty page from tripping the
|
||||||
|
check on a handful of legitimately short lines.
|
||||||
|
"""
|
||||||
|
lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
|
||||||
|
if len(lines) < min_lines:
|
||||||
|
return False
|
||||||
|
singles = sum(1 for ln in lines if len(ln) == 1)
|
||||||
|
return singles / len(lines) >= ratio
|
||||||
|
|
||||||
|
|
||||||
|
def extract_pdf_text(reader: PdfReader) -> str:
|
||||||
|
"""Page text from a reader, repaired when the default mode shatters it.
|
||||||
|
|
||||||
|
Default mode first: it is faster and already correct for ordinary CVs.
|
||||||
|
Layout mode is the fallback, never the default -- it rebuilds the page from
|
||||||
|
glyph coordinates, which recovers word and line structure on a fragmented
|
||||||
|
file but is slower and pads ordinary documents with alignment whitespace.
|
||||||
|
Reaching for it only when the default output is measurably broken means a
|
||||||
|
CV that extracts cleanly today keeps extracting exactly as it does now.
|
||||||
|
|
||||||
|
The fallback is checked before it is trusted: if layout mode comes back
|
||||||
|
fragmented too, or empty, the default text is kept. Fragmented text still
|
||||||
|
scores a candidate; empty text fails them outright.
|
||||||
|
"""
|
||||||
|
default = "\n".join((page.extract_text() or "") for page in reader.pages)
|
||||||
|
if not is_glyph_fragmented(default):
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
layout = "\n".join(
|
||||||
|
(page.extract_text(extraction_mode="layout") or "") for page in reader.pages
|
||||||
|
)
|
||||||
|
except Exception: # older pypdf, or a page layout mode chokes on
|
||||||
|
return default
|
||||||
|
if not layout.strip() or is_glyph_fragmented(layout):
|
||||||
|
return default
|
||||||
|
return layout
|
||||||
|
|
||||||
|
|
||||||
def _truncate(text: str, max_chars: int) -> tuple[str, bool]:
|
def _truncate(text: str, max_chars: int) -> tuple[str, bool]:
|
||||||
"""Cut at a line boundary near the limit rather than mid-word."""
|
"""Cut at a line boundary near the limit rather than mid-word."""
|
||||||
if len(text) <= max_chars:
|
if len(text) <= max_chars:
|
||||||
|
|
@ -120,13 +170,26 @@ def extract_resume(data: bytes, filename: str, max_chars: int) -> ExtractedResum
|
||||||
if not pages:
|
if not pages:
|
||||||
raise InvalidPDFError("document has no pages")
|
raise InvalidPDFError("document has no pages")
|
||||||
|
|
||||||
page_texts: list[str] = []
|
def _pages_in_mode(mode: Literal["plain", "layout"]) -> list[str]:
|
||||||
for page in pages:
|
out: list[str] = []
|
||||||
try:
|
for page in pages:
|
||||||
raw_text = page.extract_text() or ""
|
try:
|
||||||
except Exception: # a single bad page must not sink the whole document
|
raw = page.extract_text(extraction_mode=mode) or ""
|
||||||
raw_text = ""
|
except Exception: # a single bad page must not sink the whole document
|
||||||
page_texts.append(_normalize_text(raw_text))
|
raw = ""
|
||||||
|
out.append(_normalize_text(raw))
|
||||||
|
return out
|
||||||
|
|
||||||
|
page_texts = _pages_in_mode("plain")
|
||||||
|
# The same repair extract_pdf_text performs, but page by page, because the
|
||||||
|
# page markers below need the split preserved. The whole document is judged
|
||||||
|
# together and then every page is re-extracted in one mode, so a document
|
||||||
|
# cannot end up half in each.
|
||||||
|
if is_glyph_fragmented("\n".join(page_texts)):
|
||||||
|
repaired = _pages_in_mode("layout")
|
||||||
|
joined = "\n".join(repaired)
|
||||||
|
if joined.strip() and not is_glyph_fragmented(joined):
|
||||||
|
page_texts = repaired
|
||||||
|
|
||||||
body = "\n".join(chunk for chunk in page_texts if chunk)
|
body = "\n".join(chunk for chunk in page_texts if chunk)
|
||||||
if len(body) < _MIN_USABLE_CHARS or not _ALPHANUMERIC.search(body):
|
if len(body) < _MIN_USABLE_CHARS or not _ALPHANUMERIC.search(body):
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@ CALENDAR_API_TOKEN=
|
||||||
EMAIL_SYNC_FOLDER=inbox
|
EMAIL_SYNC_FOLDER=inbox
|
||||||
EMAIL_SYNC_SINCE=
|
EMAIL_SYNC_SINCE=
|
||||||
EMAIL_SYNC_CRON=* * * * *
|
EMAIL_SYNC_CRON=* * * * *
|
||||||
|
# Daily Sync Inbox (POST /email/sync) at 05:00 AM PKT. Token must match the
|
||||||
|
# Bearer the cron sends; leave blank to skip the tick with a warning.
|
||||||
|
INBOX_SYNC_CRON=0 5 * * *
|
||||||
|
INBOX_SYNC_CRON_TZ=Asia/Karachi
|
||||||
|
CRON_INBOX_SYNC_TOKEN=
|
||||||
|
|
||||||
JWT_SECRET_KEY=
|
JWT_SECRET_KEY=
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
|
|
@ -69,10 +74,13 @@ APIFY_PROFILE_MODE=Full
|
||||||
APIFY_TIMEOUT=30
|
APIFY_TIMEOUT=30
|
||||||
|
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_MODEL=gpt-5.4-mini
|
# Production model. Not a reasoning model: OPENAI_EFFORT is accepted and ignored.
|
||||||
|
# Define every OPENAI_* name once; python-dotenv takes the LAST occurrence.
|
||||||
|
OPENAI_MODEL=gpt-4o-mini-2024-07-18
|
||||||
# Blank omits the parameter, for reasoning models that reject it.
|
# Blank omits the parameter, for reasoning models that reject it.
|
||||||
OPENAI_TEMPERATURE=0
|
OPENAI_TEMPERATURE=0
|
||||||
OPENAI_MAX_OUTPUT_TOKENS=4096
|
# gpt-4o-mini rejects values above 16384 with a 400.
|
||||||
|
OPENAI_MAX_OUTPUT_TOKENS=4000
|
||||||
OPENAI_TIMEOUT=60
|
OPENAI_TIMEOUT=60
|
||||||
OPENAI_MAX_RETRIES=3
|
OPENAI_MAX_RETRIES=3
|
||||||
OPENAI_CONNECT_RETRIES=3
|
OPENAI_CONNECT_RETRIES=3
|
||||||
|
|
@ -112,6 +120,18 @@ TASKIQ_IDLE_TIMEOUT_MS=600000
|
||||||
MANUAL_UPLOAD_TO_ADDRESS=manual-cv-upload@hr-ats.local
|
MANUAL_UPLOAD_TO_ADDRESS=manual-cv-upload@hr-ats.local
|
||||||
APP_VERSION=dev
|
APP_VERSION=dev
|
||||||
|
|
||||||
|
# CV Bank. Retention is stamped on the row at upload, so raising this later does
|
||||||
|
# not extend CVs already taken in. The sweep flags expired entries; it never
|
||||||
|
# deletes. Leave the notify address blank to keep the log line only.
|
||||||
|
CV_BANK_RETENTION_MONTHS=24
|
||||||
|
CV_BANK_RETENTION_CRON=0 3 * * *
|
||||||
|
CV_BANK_RETENTION_NOTIFY_EMAIL=
|
||||||
|
# Tier-1 rank (free keyword overlap) a banked CV must clear to notify a recruiter
|
||||||
|
# when a job opens; and the ATS score a rejected applicant needs to count as a
|
||||||
|
# silver medalist.
|
||||||
|
CV_BANK_SUGGEST_THRESHOLD=55
|
||||||
|
CV_BANK_SILVER_FLOOR=60
|
||||||
|
|
||||||
# Compose host ports (docker compose --env-file ./backend/.env …).
|
# Compose host ports (docker compose --env-file ./backend/.env …).
|
||||||
FRONTEND_PORT=5173
|
FRONTEND_PORT=5173
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
|
|
|
||||||
|
|
@ -593,6 +593,9 @@ Taskiq over **Redis Streams**, with a result backend and a Redis-backed schedule
|
||||||
| `inbox.match_message` (CV broker) | enqueued by `/candidate/cv_upload` onto the `cv_upload` stream | Same matcher as above; isolated so uploads never sit behind `/email/fetch` backlog |
|
| `inbox.match_message` (CV broker) | enqueued by `/candidate/cv_upload` onto the `cv_upload` stream | Same matcher as above; isolated so uploads never sit behind `/email/fetch` backlog |
|
||||||
| `inbox.score_message` | enqueued by `PATCH /inbox/{id}/assign-job-post` onto the `inbox` stream | ATS-score one message against one job. Idempotent — a completed (message, job) pair returns `already_scored` without paying for a second call |
|
| `inbox.score_message` | enqueued by `PATCH /inbox/{id}/assign-job-post` onto the `inbox` stream | ATS-score one message against one job. Idempotent — a completed (message, job) pair returns `already_scored` without paying for a second call |
|
||||||
| `inbox.sync_read_status` | cron, `EMAIL_SYNC_CRON` (default every minute) | Pull read-status deltas from the Email API and apply them |
|
| `inbox.sync_read_status` | cron, `EMAIL_SYNC_CRON` (default every minute) | Pull read-status deltas from the Email API and apply them |
|
||||||
|
| `cvbank.rank_for_job` | enqueued by `POST /job/post-job` after `insert_job_post` | Tier-1 rank every banked CV against the new job into `cv_bank_matches`, then notify the recruiter if any clears `CV_BANK_SUGGEST_THRESHOLD` |
|
||||||
|
| `cvbank.backfill_profiles` | manual, one-off | Extract skills/title/company/years for CVs banked before migration 029. Re-runnable; returns `remaining` so it can be enqueued in batches |
|
||||||
|
| `cvbank.sweep_expired` | cron, `CV_BANK_RETENTION_CRON` (default `0 3 * * *`) | Flag bank CVs past `bank_expires_at` for review. Flags only — it never deletes |
|
||||||
| `ping` | manual | Framework smoke test |
|
| `ping` | manual | Framework smoke test |
|
||||||
|
|
||||||
**Auto-scoring never fails a match.** `match_inbox_message` commits the agent result first,
|
**Auto-scoring never fails a match.** `match_inbox_message` commits the agent result first,
|
||||||
|
|
@ -616,6 +619,55 @@ un-reading a mail in Outlook no longer propagates here. `sync_read_status` holds
|
||||||
(`inbox:sync_read_status:lock`, 300s TTL) so overlapping cron ticks cannot double-run, and
|
(`inbox:sync_read_status:lock`, 300s TTL) so overlapping cron ticks cannot double-run, and
|
||||||
pages at most 10 rounds per tick.
|
pages at most 10 rounds per tick.
|
||||||
|
|
||||||
|
**Ranking on job creation is fire-and-forget.** `JobPost._rank_cv_bank` swallows broker
|
||||||
|
errors: the job post is already committed, and Redis being down must not turn a successful
|
||||||
|
creation into a 500. The CV Bank screen recomputes any missing rank on read, so a dropped
|
||||||
|
enqueue degrades to a slower page rather than a wrong one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The CV Bank
|
||||||
|
|
||||||
|
Two populations behind one screen and one endpoint (`GET /candidate/cv-bank/fetch`):
|
||||||
|
|
||||||
|
| Source | Where it lives | How it got there |
|
||||||
|
|---|---|---|
|
||||||
|
| `speculative` | `manual_upload_candidate` with `apply_via='cv_bank'`, `job_post_id IS NULL` | `POST /candidate/cv-bank/upload` — a CV with no job |
|
||||||
|
| `silver_medalist` | `inbox_messages` + `ats_results`, read live | Applied, scored at or above `CV_BANK_SILVER_FLOOR`, `application_status='REJECTED'` |
|
||||||
|
|
||||||
|
Silver medalists are a **union query, not a copy**. The application rows keep being the
|
||||||
|
source of truth, so there is no sync to get wrong. Only `REJECTED` qualifies — `CLOSED` is
|
||||||
|
the ingest default for unprocessed mail, and treating it as a rejection would tip the whole
|
||||||
|
unread inbox into the bank.
|
||||||
|
|
||||||
|
**Two-tier matching.** `matching/ranking.py::rank_profile` is deterministic keyword overlap,
|
||||||
|
free, and runs over the entire bank whenever a job opens. The real ATS score costs money and
|
||||||
|
runs only from `POST /candidate/cv-bank/score`, per row, on the ones a recruiter picks. The
|
||||||
|
UI draws them differently on purpose — a rank is not an assessment. The same function backs
|
||||||
|
Find Talent (`talent/plugins.py` re-exports it as `relevance_score`) so the two cannot drift.
|
||||||
|
|
||||||
|
**Extraction is what makes the bank usable.** `run_employment_agent` returns skills, years,
|
||||||
|
title, company, education and phone; before migration 029 the bank stored only `full_text`
|
||||||
|
and could not be searched or ranked at all.
|
||||||
|
|
||||||
|
### Retention and deletion
|
||||||
|
|
||||||
|
A banked CV is personal data held with no job to justify it, so it is held for a stated
|
||||||
|
period rather than indefinitely.
|
||||||
|
|
||||||
|
- `bank_expires_at` is stamped **at upload** from `CV_BANK_RETENTION_MONTHS` (default 24).
|
||||||
|
Stamping on the row rather than computing on read means changing the setting later cannot
|
||||||
|
silently extend CVs already taken in.
|
||||||
|
- Expired rows are excluded from `list_bank_for_ranking`, so an expired CV is never put in
|
||||||
|
front of a recruiter.
|
||||||
|
- `cvbank.sweep_expired` runs nightly and **flags, never deletes.** A misconfigured window
|
||||||
|
would otherwise destroy the entire bank on one cron tick, and a resume someone sent us is
|
||||||
|
not something to drop on a timer with no record. Set
|
||||||
|
`CV_BANK_RETENTION_NOTIFY_EMAIL` to have the sweep raise an in-app notification.
|
||||||
|
- Deletion is a human action: `DELETE /candidate/cv-bank/delete` hard-deletes the row and
|
||||||
|
its bytes (`cv_bank_files` cascades) and removes the S3 object. It refuses rows that
|
||||||
|
already have a `job_post_id` — those are applications, not bank entries.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The matching agent
|
## The matching agent
|
||||||
|
|
@ -681,6 +733,12 @@ and appended to `ats_results` as a supersede-chained history — see
|
||||||
job: OpenAI prompt caching keys on an exact prefix match, so one volatile byte (an id, a
|
job: OpenAI prompt caching keys on an exact prefix match, so one volatile byte (an id, a
|
||||||
timestamp) would stop the whole batch reusing the cached JD prefix.
|
timestamp) would stop the whole batch reusing the cached JD prefix.
|
||||||
|
|
||||||
|
When the candidate already has `professional_summary`, `summary_gate` runs first: it
|
||||||
|
asks whether that stack/department could plausibly fit the job post. A no skips PDF
|
||||||
|
load and ATS (`SUMMARY_NOT_SUITABLE`). No summary always continues, so the first score
|
||||||
|
can write one. `SUMMARY_GATE_MIN_CONFIDENCE` is the gradient threshold. Disable with
|
||||||
|
`SUMMARY_GATE_ENABLED=false`.
|
||||||
|
|
||||||
Résumé text is run through `normalize_spaced_text` **before** scoring, so that keyword
|
Résumé text is run through `normalize_spaced_text` **before** scoring, so that keyword
|
||||||
verification sees exactly the text the model saw. Designer-made CVs position every glyph
|
verification sees exactly the text the model saw. Designer-made CVs position every glyph
|
||||||
individually and `pypdf` returns `S K I L L S`; the `despace_line` decorator rebuilds those.
|
individually and `pypdf` returns `S K I L L S`; the `despace_line` decorator rebuilds those.
|
||||||
|
|
@ -928,7 +986,7 @@ own keys with `os.getenv` from the same file.
|
||||||
| `APIFY_MAX_COST_USD` | `1.0` | Sent as `maxTotalChargeUsd`; Apify's minimum is $0.10 |
|
| `APIFY_MAX_COST_USD` | `1.0` | Sent as `maxTotalChargeUsd`; Apify's minimum is $0.10 |
|
||||||
| `APIFY_TIMEOUT` | `30` | Per-request httpx timeout, seconds |
|
| `APIFY_TIMEOUT` | `30` | Per-request httpx timeout, seconds |
|
||||||
| `APIFY_EXCLUDE_COMPANIES` | `Utopia Brands,Utopia Deals` | Own companies: current employees are filtered out server-side before profiles are stored (case-insensitive substring on current company, headline fallback) |
|
| `APIFY_EXCLUDE_COMPANIES` | `Utopia Brands,Utopia Deals` | Own companies: current employees are filtered out server-side before profiles are stored (case-insensitive substring on current company, headline fallback) |
|
||||||
| `APIFY_EXCLUDE_COMPANY_URLS` | the Utopia Deals / Utopia Brands USA / Utopia Brands Pakistan pages | Full LinkedIn company URLs for the actor's `excludeCurrentCompanies` filter — stops those profiles being scraped (and billed) at all |
|
| `APIFY_exclude_company_URLS` | the Utopia Deals / Utopia Brands USA / Utopia Brands Pakistan pages | Full LinkedIn company URLs for the actor's `excludeCurrentCompanies` filter — stops those profiles being scraped (and billed) at all |
|
||||||
|
|
||||||
### OpenAI
|
### OpenAI
|
||||||
|
|
||||||
|
|
@ -958,6 +1016,16 @@ own keys with `os.getenv` from the same file.
|
||||||
| `MANUAL_UPLOAD_TO_ADDRESS` | `manual-cv-upload@hr-ats.local` — To address stamped on synthetic inbox rows so source resolves to `Manual CV Upload` |
|
| `MANUAL_UPLOAD_TO_ADDRESS` | `manual-cv-upload@hr-ats.local` — To address stamped on synthetic inbox rows so source resolves to `Manual CV Upload` |
|
||||||
| `APP_VERSION` | `dev` |
|
| `APP_VERSION` | `dev` |
|
||||||
|
|
||||||
|
### CV Bank
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `CV_BANK_RETENTION_MONTHS` | `24` | Stamped onto `bank_expires_at` at upload, so a later change cannot extend CVs already taken in |
|
||||||
|
| `CV_BANK_RETENTION_CRON` | `0 3 * * *` | `cvbank.sweep_expired` schedule |
|
||||||
|
| `CV_BANK_RETENTION_NOTIFY_EMAIL` | — | Recipient of the expiry-review notification; blank disables it (the log line is still written) |
|
||||||
|
| `CV_BANK_SUGGEST_THRESHOLD` | `55` | Tier-1 rank a banked CV must clear before the recruiter is notified on job creation |
|
||||||
|
| `CV_BANK_SILVER_FLOOR` | `60` | Minimum ATS score for a rejected applicant to appear as a silver medalist |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Running locally
|
## Running locally
|
||||||
|
|
@ -990,7 +1058,8 @@ LLM failures are logged and skipped; the API still comes up.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
taskiq worker taskiq_management.broker_setup:broker \
|
taskiq worker taskiq_management.broker_setup:broker \
|
||||||
inbox.tasks inbox.sync_tasks taskiq_management.tasks g_sheet.tasks
|
inbox.tasks inbox.sync_tasks taskiq_management.tasks g_sheet.tasks \
|
||||||
|
job.candidate.bank_tasks
|
||||||
```
|
```
|
||||||
|
|
||||||
**CV-upload worker** (isolated stream for manual uploads):
|
**CV-upload worker** (isolated stream for manual uploads):
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,18 @@ Respond with JSON only:
|
||||||
|
|
||||||
|
|
||||||
def user_prompt(state) -> str:
|
def user_prompt(state) -> str:
|
||||||
|
"""The user turn as JSON.
|
||||||
|
|
||||||
|
job_posts comes first on purpose: it is identical for every CV in a sync run,
|
||||||
|
and OpenAI prompt caching works on an exact token prefix. With the stable
|
||||||
|
block ahead of the per-candidate subject and resume, every CV after the first
|
||||||
|
reads the whole job list from cache at the discounted input rate.
|
||||||
|
"""
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
|
"job_posts": state.get("job_posts") or [],
|
||||||
"subject": state.get("subject") or "",
|
"subject": state.get("subject") or "",
|
||||||
"resume_text": state.get("resume_text") or "",
|
"resume_text": state.get("resume_text") or "",
|
||||||
"job_posts": state.get("job_posts") or [],
|
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,26 @@ async def fetch_source_performance(
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/analytics/applications-per-job/fetch")
|
||||||
|
async def fetch_applications_per_job(
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)),
|
||||||
|
top: int = Query(10,ge=1),
|
||||||
|
from_date: datetime | None = Query(None),
|
||||||
|
to_date: datetime | None = Query(None),
|
||||||
|
department: str | None = Query(None),
|
||||||
|
recruiter_id: str | None = Query(None),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=Analytics(session=session)
|
||||||
|
data=await service.get_applications_per_job(top,from_date,to_date,department,recruiter_id)
|
||||||
|
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/analytics/ask")
|
@router.post("/analytics/ask")
|
||||||
async def ask(
|
async def ask(
|
||||||
payload: AskRequest,
|
payload: AskRequest,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,18 @@ def serialize_source_count(source,count,source_id=None,spend=0.0) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_job_application_count(job,count) -> dict:
|
||||||
|
return {
|
||||||
|
"job_post_id": str(job.id),
|
||||||
|
"title": job.title or "",
|
||||||
|
"department": job.department or "",
|
||||||
|
"requisition_status": job.requisition_status,
|
||||||
|
"vacancies": int(job.vacancies or 0),
|
||||||
|
"is_active": bool(job.is_active),
|
||||||
|
"count": int(count or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def serialize_recruiter_row(user_id,name,hires,open_reqs,avg_time_to_hire,completed=0) -> dict:
|
def serialize_recruiter_row(user_id,name,hires,open_reqs,avg_time_to_hire,completed=0) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(user_id) if user_id else None,
|
"id": str(user_id) if user_id else None,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from datetime import datetime,timedelta,timezone
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from analytics.serializers import (
|
from analytics.serializers import (
|
||||||
|
serialize_job_application_count,
|
||||||
serialize_recruiter_row,
|
serialize_recruiter_row,
|
||||||
serialize_source_count,
|
serialize_source_count,
|
||||||
serialize_stage_count,
|
serialize_stage_count,
|
||||||
|
|
@ -54,6 +55,32 @@ def _resolve_windows(from_date,to_date):
|
||||||
return from_date,to_date,prior_from,prior_to
|
return from_date,to_date,prior_from,prior_to
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_job_counts(inbox_map,manual_map,job_rows,open_rows,top):
|
||||||
|
"""Merge per-job counts from both sources, zero-fill open reqs, sort, cap.
|
||||||
|
|
||||||
|
job_rows are the posts that actually received applications — closed or even
|
||||||
|
deleted ones stay visible, because their applications happened. open_rows
|
||||||
|
zero-fill only open, non-deleted reqs, so the fill never resurrects a dead
|
||||||
|
posting. Sorted by count desc then title, capped at `top`.
|
||||||
|
"""
|
||||||
|
counts={}
|
||||||
|
for src in (inbox_map,manual_map):
|
||||||
|
for job_id,n in src.items():
|
||||||
|
counts[job_id]=counts.get(job_id,0)+int(n or 0)
|
||||||
|
by_id={str(job.id):job for job in job_rows}
|
||||||
|
for job in open_rows:
|
||||||
|
key=str(job.id)
|
||||||
|
counts.setdefault(key,0)
|
||||||
|
by_id.setdefault(key,job)
|
||||||
|
rows=[
|
||||||
|
serialize_job_application_count(by_id[job_id],count)
|
||||||
|
for job_id,count in counts.items()
|
||||||
|
if job_id in by_id
|
||||||
|
]
|
||||||
|
rows.sort(key=lambda r: (-r["count"],r["title"].lower()))
|
||||||
|
return rows[:max(1,int(top or 10))]
|
||||||
|
|
||||||
|
|
||||||
def _month_key(dt):
|
def _month_key(dt):
|
||||||
"""Normalize date_trunc / python month buckets for dict lookup."""
|
"""Normalize date_trunc / python month buckets for dict lookup."""
|
||||||
if dt is None:
|
if dt is None:
|
||||||
|
|
@ -236,6 +263,33 @@ class Analytics:
|
||||||
for stage in Candidate_application_Status
|
for stage in Candidate_application_Status
|
||||||
]
|
]
|
||||||
|
|
||||||
|
async def get_applications_per_job(self,top=10,from_date=None,to_date=None,department=None,recruiter_id=None):
|
||||||
|
"""Applications received per job post — inbox + manual upload, the same
|
||||||
|
two sources get_funnel counts, so these rows sum to the funnel total
|
||||||
|
under identical filters. form_data stays excluded because the funnel
|
||||||
|
excludes it; the two cards share a screen and must agree.
|
||||||
|
|
||||||
|
Dates pass through raw (no _resolve_windows), matching funnel/sources:
|
||||||
|
the caller sends both bounds, and none means all-time.
|
||||||
|
|
||||||
|
Open reqs with zero applications are included on purpose — a req nobody
|
||||||
|
applied to is the strongest signal this endpoint exists to surface.
|
||||||
|
"""
|
||||||
|
inbox_map=await Inbox_Messages.counts_by_job_post(
|
||||||
|
self.session,from_date=from_date,to_date=to_date,
|
||||||
|
department=department,recruiter_id=recruiter_id,
|
||||||
|
)
|
||||||
|
manual_map=await Manual_UPLOAD_CANDIDATE.counts_by_job_post(
|
||||||
|
self.session,from_date=from_date,to_date=to_date,
|
||||||
|
department=department,recruiter_id=recruiter_id,
|
||||||
|
)
|
||||||
|
ids=set(inbox_map)|set(manual_map)
|
||||||
|
job_rows=await JobPosts.get_by_ids(self.session,list(ids),active_only=False) if ids else []
|
||||||
|
open_rows=await JobPosts.list_open_reqs(
|
||||||
|
self.session,department=department,recruiter_id=recruiter_id,
|
||||||
|
)
|
||||||
|
return _merge_job_counts(inbox_map,manual_map,job_rows,open_rows,top)
|
||||||
|
|
||||||
async def get_hiring_trend(self,months=7,from_date=None,to_date=None,department=None,recruiter_id=None):
|
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))
|
months=max(1,int(months or 7))
|
||||||
now=datetime.now(timezone.utc)
|
now=datetime.now(timezone.utc)
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,53 @@
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
|
from dis import Positions
|
||||||
|
from typing import Type
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
import uuid
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from typing import Optional
|
||||||
from candidate_forms.plugins import definitions_payload
|
from candidate_forms.plugins import definitions_payload
|
||||||
from candidate_forms.views import CandidateForm
|
from candidate_forms.views import CandidateForm
|
||||||
from db_setup import get_session
|
from db_setup import get_session
|
||||||
from users.permissions import PermissionTag, require_permission
|
from users.permissions import PermissionTag, require_permission
|
||||||
|
from candidate_forms.enums import EmploymentType, Position, ReplacementFor, InternalRecommendate
|
||||||
|
from candidate_forms.views import RequisitionForm
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
class RequisitionFormCreate(BaseModel):
|
||||||
|
form_type: str = "requisition"
|
||||||
|
position:Position
|
||||||
|
replacement_for:Optional[ReplacementFor]
|
||||||
|
refferal_by:Optional[InternalRecommendate]
|
||||||
|
initiated_by:Optional[str]
|
||||||
|
initiated_date:Optional[date]
|
||||||
|
recommended_by:Optional[str]
|
||||||
|
recommended_date:Optional[date]
|
||||||
|
approved_by_hr:Optional[bool]
|
||||||
|
approved_by_date_hr:Optional[date]
|
||||||
|
approved_by_vp:Optional[bool]
|
||||||
|
approved_by_date_vp:Optional[date]
|
||||||
|
approved_by_svp:Optional[bool]
|
||||||
|
approved_by_date_svp:Optional[date]
|
||||||
|
|
||||||
|
|
||||||
|
class RequisitionFormUpdate(BaseModel):
|
||||||
|
position:Optional[Position]=None
|
||||||
|
replacement_for:Optional[ReplacementFor]=None
|
||||||
|
refferal_by:Optional[InternalRecommendate]=None
|
||||||
|
initiated_by:Optional[str]=None
|
||||||
|
initiated_date:Optional[date]=None
|
||||||
|
recommended_by:Optional[str]=None
|
||||||
|
recommended_date:Optional[date]=None
|
||||||
|
approved_by_hr:Optional[bool]=None
|
||||||
|
approved_by_date_hr:Optional[date]=None
|
||||||
|
approved_by_vp:Optional[bool]=None
|
||||||
|
approved_by_date_vp:Optional[date]=None
|
||||||
|
approved_by_svp:Optional[bool]=None
|
||||||
|
approved_by_date_svp:Optional[date]=None
|
||||||
|
|
||||||
|
|
||||||
class FormCreate(BaseModel):
|
class FormCreate(BaseModel):
|
||||||
form_type: str
|
form_type: str
|
||||||
|
|
@ -32,6 +68,85 @@ class FormUpdate(BaseModel):
|
||||||
fields: dict | None = None
|
fields: dict | None = None
|
||||||
recommendation: str | None = None
|
recommendation: str | None = None
|
||||||
|
|
||||||
|
@router.get("/forms/requisition/search")
|
||||||
|
async def search_requisitions(
|
||||||
|
current_user: dict = Depends(
|
||||||
|
require_permission(
|
||||||
|
PermissionTag.REQUISITIONS_VIEW,
|
||||||
|
PermissionTag.JOB_BOARD_CREATE,
|
||||||
|
PermissionTag.JOBS_CREATE,
|
||||||
|
require_all=False,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
q: str | None = Query(None),
|
||||||
|
top: int = Query(50, ge=1, le=100),
|
||||||
|
job_post_id: uuid.UUID | None = Query(None),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Searchable picker for job create: `{position_title} - {department}`.
|
||||||
|
|
||||||
|
`q` matches either field (ilike). Empty `q` returns recent rows.
|
||||||
|
Linked requisitions are omitted (1:1 with job posts). Pass `job_post_id`
|
||||||
|
on edit so the job's current requisition remains selectable until unlinked.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
service = RequisitionForm(session=session)
|
||||||
|
data = await service.search(q, top=top, job_post_id=job_post_id)
|
||||||
|
return JSONResponse(content={"data": data, "status_code": 200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/forms/requisition/fetch")
|
||||||
|
async def fetch_requisition_form(
|
||||||
|
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),
|
||||||
|
form_id:str=Query(None),
|
||||||
|
session:AsyncSession=Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Requisition table. Admins get every non-deleted row (job link does not
|
||||||
|
hide anything). Other roles stay scoped to created_by."""
|
||||||
|
try:
|
||||||
|
service=RequisitionForm(session=session)
|
||||||
|
data=await service.get_form_by_id(form_id,current_user)
|
||||||
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.post("/forms/requisition/create")
|
||||||
|
async def create_requisition_form(
|
||||||
|
payload: RequisitionFormCreate,
|
||||||
|
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_CREATE)),
|
||||||
|
session:AsyncSession=Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service = RequisitionForm(session=session)
|
||||||
|
data = await service.create_form(payload.model_dump(exclude_unset=True), current_user)
|
||||||
|
return JSONResponse(content={"data": data, "status_code": 200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/forms/requisition/update")
|
||||||
|
async def update_requisition_form(
|
||||||
|
payload: RequisitionFormUpdate,
|
||||||
|
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_EDIT)),
|
||||||
|
form_id:str=Query(...),
|
||||||
|
session:AsyncSession=Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=RequisitionForm(session=session)
|
||||||
|
data=await service.update_form(form_id,payload.model_dump(exclude_unset=True),current_user)
|
||||||
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
@router.get("/forms/definitions")
|
@router.get("/forms/definitions")
|
||||||
async def fetch_form_definitions(
|
async def fetch_form_definitions(
|
||||||
|
|
@ -61,6 +176,7 @@ async def fetch_forms(
|
||||||
service = CandidateForm(session=session)
|
service = CandidateForm(session=session)
|
||||||
data, summary, total = await service.get_forms(
|
data, summary, total = await service.get_forms(
|
||||||
form_id, inbox_id, manual_upload_candidate_id, job_post_id, form_type, top, skip,
|
form_id, inbox_id, manual_upload_candidate_id, job_post_id, form_type, top, skip,
|
||||||
|
current_user=current_user,
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"data": data, "summary": summary, "total": total, "status_code": 200}
|
content={"data": data, "summary": summary, "total": total, "status_code": 200}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
from enum import Enum
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
class EmploymentType(str,Enum):
|
||||||
|
PERMANENT = "permanent"
|
||||||
|
CONTRACT = "contract"
|
||||||
|
TEMPORARY = "temporary"
|
||||||
|
INTERNEE="internee"
|
||||||
|
|
||||||
|
class Position(BaseModel):
|
||||||
|
department:Optional[str]
|
||||||
|
title:Optional[str]
|
||||||
|
date:Optional[date]
|
||||||
|
date_needed:Optional[date]
|
||||||
|
type:Optional[EmploymentType]
|
||||||
|
job_description:Optional[str]
|
||||||
|
period_from:Optional[date]=None
|
||||||
|
period_to:Optional[date]=None
|
||||||
|
jd_available:Optional[bool]=None
|
||||||
|
|
||||||
|
class InternalRecommendate(BaseModel):
|
||||||
|
employee_name:Optional[str]=None
|
||||||
|
employee_department:Optional[str]=None
|
||||||
|
entity:Optional[str]=None
|
||||||
|
|
||||||
|
class ReplacementFor(BaseModel):
|
||||||
|
to_replace:Optional[str]
|
||||||
|
grade:Optional[str]
|
||||||
|
title:Optional[str]
|
||||||
|
date_separated:Optional[date]
|
||||||
|
justification:Optional[str]
|
||||||
|
budget:Optional[str]
|
||||||
|
recommended_grade:Optional[str]
|
||||||
|
|
@ -1,14 +1,251 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, date as Date, timezone
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import DateTime, JSON, func
|
from sqlalchemy import DateTime, Enum as SAEnum, JSON, func, or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import Field, SQLModel, select
|
from sqlmodel import Field, Relationship, SQLModel, select
|
||||||
|
|
||||||
|
from candidate_forms.enums import EmploymentType
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
class Requisition(SQLModel, table=True):
|
||||||
|
__tablename__ = "requisitions"
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
|
||||||
|
department: Optional[str] = None
|
||||||
|
position_title: Optional[str] = None
|
||||||
|
date: Optional[Date] = None
|
||||||
|
date_needed: Optional[Date] = None
|
||||||
|
employment_type: Optional[EmploymentType] = Field(
|
||||||
|
default=None,
|
||||||
|
sa_type=SAEnum(
|
||||||
|
EmploymentType,
|
||||||
|
name="employmenttype",
|
||||||
|
schema="app",
|
||||||
|
native_enum=True,
|
||||||
|
values_callable=lambda enum: [member.value for member in enum],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
job_description: Optional[str] = None
|
||||||
|
period_from: Optional[Date] = None
|
||||||
|
period_to: Optional[Date] = None
|
||||||
|
jd_available: Optional[bool] = None
|
||||||
|
|
||||||
|
employee_name: Optional[str] = None
|
||||||
|
employee_department: Optional[str] = None
|
||||||
|
entity: Optional[str] = None
|
||||||
|
|
||||||
|
to_replace: Optional[str] = None
|
||||||
|
grade: Optional[str] = None
|
||||||
|
recruitment_title: Optional[str] = None
|
||||||
|
date_separated: Optional[Date] = None
|
||||||
|
justification: Optional[str] = None
|
||||||
|
budget: Optional[str] = None
|
||||||
|
recommended_grade: Optional[str] = None
|
||||||
|
|
||||||
|
initiated_by: Optional[str] = None
|
||||||
|
initiated_date: Optional[Date] = None
|
||||||
|
recommended_by: Optional[str] = None
|
||||||
|
recommended_date: Optional[Date] = None
|
||||||
|
approved_by_hr: Optional[bool] = None
|
||||||
|
approved_by_date_hr: Optional[Date] = None
|
||||||
|
approved_by_vp: Optional[bool] = None
|
||||||
|
approved_by_date_vp: Optional[Date] = None
|
||||||
|
approved_by_svp: Optional[bool] = None
|
||||||
|
approved_by_date_svp: Optional[Date] = None
|
||||||
|
created_by: Optional[uuid.UUID] = Field(foreign_key="users.id")
|
||||||
|
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
is_deleted: bool = Field(default=False)
|
||||||
|
# Optional 1:1: job_posts.requisition_id points here. uselist=False so a
|
||||||
|
# requisition has at most one job post (enforced in DB by the unique FK).
|
||||||
|
job_post: Optional["JobPosts"] = Relationship(
|
||||||
|
back_populates="requisition",
|
||||||
|
sa_relationship_kwargs={"uselist": False, "lazy": "selectin"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_form_by_id(cls, session: AsyncSession, record_id=None, created_by=None):
|
||||||
|
qry = select(cls).where(cls.is_deleted == False) # noqa: E712
|
||||||
|
if created_by is not None:
|
||||||
|
qry = qry.where(cls.created_by == created_by)
|
||||||
|
if record_id not in (None, ""):
|
||||||
|
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(str(record_id))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
qry = qry.where(cls.id == uid)
|
||||||
|
qry = qry.order_by(cls.created_at.desc(),cls.id.desc())
|
||||||
|
result = await session.execute(qry)
|
||||||
|
return result.scalars().first()
|
||||||
|
result = await session.execute(qry.order_by(cls.created_at.desc(),cls.id.desc()))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def search(
|
||||||
|
cls,
|
||||||
|
session: AsyncSession,
|
||||||
|
q: str | None = None,
|
||||||
|
*,
|
||||||
|
top: int = 50,
|
||||||
|
job_post_id=None,
|
||||||
|
):
|
||||||
|
"""Dropdown rows: match position_title or department (either side).
|
||||||
|
|
||||||
|
Empty `q` returns the most recent non-deleted rows so the picker has a
|
||||||
|
list before the user types. Not scoped to created_by — job creators
|
||||||
|
need the org-wide list, not only requisitions they opened themselves.
|
||||||
|
|
||||||
|
job_posts.requisition_id is 1:1. Hide requisitions already linked to a
|
||||||
|
live job post. Pass `job_post_id` when editing so that job's current
|
||||||
|
requisition stays in the list until the link is cleared.
|
||||||
|
"""
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
statement = select(cls).where(cls.is_deleted == False) # noqa: E712
|
||||||
|
held = select(JobPosts.requisition_id).where(
|
||||||
|
JobPosts.requisition_id.is_not(None),
|
||||||
|
JobPosts.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
except_uid = JobPosts._as_uuid(job_post_id) if job_post_id else None
|
||||||
|
if except_uid is not None:
|
||||||
|
held = held.where(JobPosts.id != except_uid)
|
||||||
|
statement = statement.where(cls.id.notin_(held))
|
||||||
|
term = (q or "").strip()
|
||||||
|
if term:
|
||||||
|
like = f"%{term}%"
|
||||||
|
statement = statement.where(
|
||||||
|
or_(cls.position_title.ilike(like), cls.department.ilike(like))
|
||||||
|
)
|
||||||
|
limit = max(1, min(int(top or 50), 100))
|
||||||
|
statement = statement.order_by(cls.created_at.desc(), cls.id.desc()).limit(limit)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def insert_form(cls, session: AsyncSession, fields: dict):
|
||||||
|
position = fields.get("position") if fields.get("position") else {}
|
||||||
|
replacement = fields.get("replacement_for") if fields.get("replacement_for") else {}
|
||||||
|
referral = fields.get("refferal_by") if fields.get("refferal_by") else {}
|
||||||
|
row = cls(
|
||||||
|
department=position.get("department") if position.get("department") else None,
|
||||||
|
position_title=position.get("title") if position.get("title") else None,
|
||||||
|
date=position.get("date") if position.get("date") else None,
|
||||||
|
date_needed=position.get("date_needed") if position.get("date_needed") else None,
|
||||||
|
employment_type=EmploymentType(position.get("type")) if position.get("type") else None,
|
||||||
|
job_description=position.get("job_description") if position.get("job_description") else None,
|
||||||
|
period_from=position.get("period_from") if position.get("period_from") else None,
|
||||||
|
period_to=position.get("period_to") if position.get("period_to") else None,
|
||||||
|
jd_available=position.get("jd_available") if position.get("jd_available") is not None else None,
|
||||||
|
employee_name=referral.get("employee_name") if referral.get("employee_name") else None,
|
||||||
|
employee_department=referral.get("employee_department") if referral.get("employee_department") else None,
|
||||||
|
entity=referral.get("entity") if referral.get("entity") else None,
|
||||||
|
to_replace=replacement.get("to_replace") if replacement.get("to_replace") else None,
|
||||||
|
grade=replacement.get("grade") if replacement.get("grade") else None,
|
||||||
|
recruitment_title=replacement.get("title") if replacement.get("title") else None,
|
||||||
|
date_separated=replacement.get("date_separated") if replacement.get("date_separated") else None,
|
||||||
|
justification=replacement.get("justification") if replacement.get("justification") else None,
|
||||||
|
budget=replacement.get("budget") if replacement.get("budget") else None,
|
||||||
|
recommended_grade=replacement.get("recommended_grade") if replacement.get("recommended_grade") else None,
|
||||||
|
initiated_by=fields.get("initiated_by") if fields.get("initiated_by") else None,
|
||||||
|
initiated_date=fields.get("initiated_date") if fields.get("initiated_date") else None,
|
||||||
|
recommended_by=fields.get("recommended_by") if fields.get("recommended_by") else None,
|
||||||
|
recommended_date=fields.get("recommended_date") if fields.get("recommended_date") else None,
|
||||||
|
approved_by_hr=fields.get("approved_by_hr") if fields.get("approved_by_hr") is not None else None,
|
||||||
|
approved_by_date_hr=fields.get("approved_by_date_hr") if fields.get("approved_by_date_hr") else None,
|
||||||
|
approved_by_vp=fields.get("approved_by_vp") if fields.get("approved_by_vp") is not None else None,
|
||||||
|
approved_by_date_vp=fields.get("approved_by_date_vp") if fields.get("approved_by_date_vp") else None,
|
||||||
|
approved_by_svp=fields.get("approved_by_svp") if fields.get("approved_by_svp") is not None else None,
|
||||||
|
approved_by_date_svp=fields.get("approved_by_date_svp") if fields.get("approved_by_date_svp") else None,
|
||||||
|
created_by=fields.get("created_by") if fields.get("created_by") else None,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return await cls.get_form_by_id(session, row.id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def update_form(cls, session: AsyncSession, record_id, fields: dict):
|
||||||
|
row = await cls.get_form_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
if "position" in fields:
|
||||||
|
position = fields.get("position") if fields.get("position") else {}
|
||||||
|
if "department" in position:
|
||||||
|
row.department = position.get("department") if position.get("department") else None
|
||||||
|
if "title" in position:
|
||||||
|
row.position_title = position.get("title") if position.get("title") else None
|
||||||
|
if "date" in position:
|
||||||
|
row.date = position.get("date") if position.get("date") else None
|
||||||
|
if "date_needed" in position:
|
||||||
|
row.date_needed = position.get("date_needed") if position.get("date_needed") else None
|
||||||
|
if "type" in position:
|
||||||
|
row.employment_type = EmploymentType(position.get("type")) if position.get("type") else None
|
||||||
|
if "job_description" in position:
|
||||||
|
row.job_description = position.get("job_description") if position.get("job_description") else None
|
||||||
|
if "period_from" in position:
|
||||||
|
row.period_from = position.get("period_from") if position.get("period_from") else None
|
||||||
|
if "period_to" in position:
|
||||||
|
row.period_to = position.get("period_to") if position.get("period_to") else None
|
||||||
|
if "jd_available" in position:
|
||||||
|
row.jd_available = position.get("jd_available") if position.get("jd_available") is not None else None
|
||||||
|
if "replacement_for" in fields:
|
||||||
|
replacement = fields.get("replacement_for") if fields.get("replacement_for") else {}
|
||||||
|
if "to_replace" in replacement:
|
||||||
|
row.to_replace = replacement.get("to_replace") if replacement.get("to_replace") else None
|
||||||
|
if "grade" in replacement:
|
||||||
|
row.grade = replacement.get("grade") if replacement.get("grade") else None
|
||||||
|
if "title" in replacement:
|
||||||
|
row.recruitment_title = replacement.get("title") if replacement.get("title") else None
|
||||||
|
if "date_separated" in replacement:
|
||||||
|
row.date_separated = replacement.get("date_separated") if replacement.get("date_separated") else None
|
||||||
|
if "justification" in replacement:
|
||||||
|
row.justification = replacement.get("justification") if replacement.get("justification") else None
|
||||||
|
if "budget" in replacement:
|
||||||
|
row.budget = replacement.get("budget") if replacement.get("budget") else None
|
||||||
|
if "recommended_grade" in replacement:
|
||||||
|
row.recommended_grade = replacement.get("recommended_grade") if replacement.get("recommended_grade") else None
|
||||||
|
if "refferal_by" in fields:
|
||||||
|
referral = fields.get("refferal_by") if fields.get("refferal_by") else {}
|
||||||
|
if "employee_name" in referral:
|
||||||
|
row.employee_name = referral.get("employee_name") if referral.get("employee_name") else None
|
||||||
|
if "employee_department" in referral:
|
||||||
|
row.employee_department = referral.get("employee_department") if referral.get("employee_department") else None
|
||||||
|
if "entity" in referral:
|
||||||
|
row.entity = referral.get("entity") if referral.get("entity") else None
|
||||||
|
if "initiated_by" in fields:
|
||||||
|
row.initiated_by = fields.get("initiated_by") if fields.get("initiated_by") else None
|
||||||
|
if "initiated_date" in fields:
|
||||||
|
row.initiated_date = fields.get("initiated_date") if fields.get("initiated_date") else None
|
||||||
|
if "recommended_by" in fields:
|
||||||
|
row.recommended_by = fields.get("recommended_by") if fields.get("recommended_by") else None
|
||||||
|
if "recommended_date" in fields:
|
||||||
|
row.recommended_date = fields.get("recommended_date") if fields.get("recommended_date") else None
|
||||||
|
if "approved_by_hr" in fields:
|
||||||
|
row.approved_by_hr = fields.get("approved_by_hr") if fields.get("approved_by_hr") is not None else None
|
||||||
|
if "approved_by_date_hr" in fields:
|
||||||
|
row.approved_by_date_hr = fields.get("approved_by_date_hr") if fields.get("approved_by_date_hr") else None
|
||||||
|
if "approved_by_vp" in fields:
|
||||||
|
row.approved_by_vp = fields.get("approved_by_vp") if fields.get("approved_by_vp") is not None else None
|
||||||
|
if "approved_by_date_vp" in fields:
|
||||||
|
row.approved_by_date_vp = fields.get("approved_by_date_vp") if fields.get("approved_by_date_vp") else None
|
||||||
|
if "approved_by_svp" in fields:
|
||||||
|
row.approved_by_svp = fields.get("approved_by_svp") if fields.get("approved_by_svp") is not None else None
|
||||||
|
if "approved_by_date_svp" in fields:
|
||||||
|
row.approved_by_date_svp = fields.get("approved_by_date_svp") if fields.get("approved_by_date_svp") else None
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
class CandidateForms(SQLModel, table=True):
|
class CandidateForms(SQLModel, table=True):
|
||||||
"""One digitized hiring form (Annexure A requisition, or one of the two
|
"""One digitized hiring form (Annexure A requisition, or one of the two
|
||||||
|
|
@ -100,8 +337,20 @@ class CandidateForms(SQLModel, table=True):
|
||||||
return list(result.scalars().all()), total
|
return list(result.scalars().all()), total
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def insert_form(cls, session: AsyncSession, fields: dict):
|
async def insert_form(cls, session: AsyncSession, fields: dict):
|
||||||
row = cls(**fields)
|
row = cls(
|
||||||
|
form_type=fields.get("form_type"),
|
||||||
|
inbox_id=fields.get("inbox_id"),
|
||||||
|
manual_upload_candidate_id=fields.get("manual_upload_candidate_id"),
|
||||||
|
job_post_id=fields.get("job_post_id"),
|
||||||
|
interviewer_id=fields.get("interviewer_id"),
|
||||||
|
form_date=fields.get("form_date"),
|
||||||
|
sections=fields.get("sections"),
|
||||||
|
fields=fields.get("fields"),
|
||||||
|
overall_score=fields.get("overall_score"),
|
||||||
|
recommendation=fields.get("recommendation"),
|
||||||
|
created_by=fields.get("created_by"),
|
||||||
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return await cls.get_form_by_id(session, row.id)
|
return await cls.get_form_by_id(session, row.id)
|
||||||
|
|
@ -111,8 +360,18 @@ class CandidateForms(SQLModel, table=True):
|
||||||
row = await cls.get_form_by_id(session, record_id)
|
row = await cls.get_form_by_id(session, record_id)
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
for key, value in fields.items():
|
if "interviewer_id" in fields:
|
||||||
setattr(row, key, value)
|
row.interviewer_id = fields.get("interviewer_id")
|
||||||
|
if "form_date" in fields:
|
||||||
|
row.form_date = fields.get("form_date")
|
||||||
|
if "sections" in fields:
|
||||||
|
row.sections = fields.get("sections")
|
||||||
|
if "fields" in fields:
|
||||||
|
row.fields = fields.get("fields")
|
||||||
|
if "overall_score" in fields:
|
||||||
|
row.overall_score = fields.get("overall_score")
|
||||||
|
if "recommendation" in fields:
|
||||||
|
row.recommendation = fields.get("recommendation")
|
||||||
row.updated_at = _now()
|
row.updated_at = _now()
|
||||||
session.add(row)
|
session.add(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,17 @@ into every saved row so historical records survive future renames.
|
||||||
|
|
||||||
FORM_TYPES = ("requisition", "interview_analysis", "cultural_fit")
|
FORM_TYPES = ("requisition", "interview_analysis", "cultural_fit")
|
||||||
|
|
||||||
RATING_MIN = 1
|
RATING_POINTS = (25, 50, 75, 100)
|
||||||
RATING_MAX = 4
|
# Paper ticks used to be 1–4; coerce those to the matching percentage.
|
||||||
|
_LEGACY_TICK = {1: 25, 2: 50, 3: 75, 4: 100}
|
||||||
RATING_LABELS = {
|
RATING_LABELS = {
|
||||||
1: "Below Average (1)",
|
25: "Below Average (25%)",
|
||||||
2: "Average (2)",
|
50: "Average (50%)",
|
||||||
3: "Good (3)",
|
75: "Good (75%)",
|
||||||
4: "Excellent (4)",
|
100: "Excellent (100%)",
|
||||||
}
|
}
|
||||||
RATING_SCALE_NOTE = (
|
RATING_SCALE_NOTE = (
|
||||||
"Rating Scale: 1 = Below Average | 2 = Average | 3 = Good | 4 = Excellent. "
|
"Rating Scale: Below Average = 25% | Average = 50% | Good = 75% | Excellent = 100%. "
|
||||||
"Tick the box that applies for each criterion."
|
"Tick the box that applies for each criterion."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -79,7 +80,8 @@ FORM_DEFINITIONS = {
|
||||||
"criteria": [
|
"criteria": [
|
||||||
{"key": "core_job_knowledge", "label": "Core Job Knowledge & Domain Expertise"},
|
{"key": "core_job_knowledge", "label": "Core Job Knowledge & Domain Expertise"},
|
||||||
{"key": "relevant_experience", "label": "Depth of Relevant Experience"},
|
{"key": "relevant_experience", "label": "Depth of Relevant Experience"},
|
||||||
{"key": "problem_solving", "label": "Problem Solving & Analytical Reasoning"},
|
{"key": "problem_solving", "label": "Problem Solving"},
|
||||||
|
{"key": "analytical_reasoning", "label": "Analytical Reasoning"},
|
||||||
{"key": "tools_proficiency", "label": "Technical Tools & Systems Proficiency"},
|
{"key": "tools_proficiency", "label": "Technical Tools & Systems Proficiency"},
|
||||||
{"key": "quality_of_work", "label": "Quality of Work & Attention to Detail"},
|
{"key": "quality_of_work", "label": "Quality of Work & Attention to Detail"},
|
||||||
],
|
],
|
||||||
|
|
@ -108,27 +110,41 @@ FORM_DEFINITIONS = {
|
||||||
),
|
),
|
||||||
"has_recommendation": True,
|
"has_recommendation": True,
|
||||||
},
|
},
|
||||||
|
# form_type/section/field keys below stay "cultural_fit"/"cultural"/"cultural_note" —
|
||||||
|
# renamed labels only. Titles and criterion labels are denormalized into every
|
||||||
|
# saved row at write time (see module docstring), so historical rows keep the
|
||||||
|
# "Cultural Fit" wording they were saved under while new rows pick up the fuller
|
||||||
|
# revision 2 "HR Evaluation" section below; the key stays stable so old rows keep
|
||||||
|
# validating and combined_summary()'s "cultural" lookup keeps matching both.
|
||||||
"cultural_fit": {
|
"cultural_fit": {
|
||||||
"title": "Cultural Fit",
|
"title": "HR Evaluation",
|
||||||
"source": "Annexure E - Interview Evaluation Form",
|
"source": "Annexure E - Interview Evaluation Form",
|
||||||
"scale_note": RATING_SCALE_NOTE,
|
"scale_note": RATING_SCALE_NOTE,
|
||||||
"sections": [
|
"sections": [
|
||||||
{
|
{
|
||||||
"key": "cultural",
|
"key": "cultural",
|
||||||
"title": "CULTURAL FIT",
|
"title": "HR EVALUATION",
|
||||||
"average_label": "CULTURAL FIT SECTION",
|
"average_label": "HR EVALUATION SECTION",
|
||||||
"criteria": [
|
"criteria": [
|
||||||
{"key": "company_values", "label": "Alignment with Company Values"},
|
{"key": "basic_jd_requirement", "label": "Basic JD requirement"},
|
||||||
|
{"key": "company_values", "label": "Alignment with Company Culture"},
|
||||||
{"key": "professionalism", "label": "Professionalism & Integrity"},
|
{"key": "professionalism", "label": "Professionalism & Integrity"},
|
||||||
{"key": "collaboration", "label": "Collaboration & Team Orientation"},
|
{"key": "collaboration", "label": "Collaboration & Team Orientation"},
|
||||||
{"key": "adaptability", "label": "Adaptability to Change"},
|
{"key": "adaptability", "label": "Adaptability"},
|
||||||
{"key": "work_ethic", "label": "Work Ethic & Reliability"},
|
{"key": "agility", "label": "Agility"},
|
||||||
|
{"key": "work_ethic", "label": "Work Ethics"},
|
||||||
|
{"key": "communication_articulation", "label": "Communication & Articulation"},
|
||||||
|
{"key": "problem_solving_orientation", "label": "Problem Solving & Solution Orientation"},
|
||||||
|
{"key": "critical_thinking", "label": "Critical Thinking & Analytical Capability"},
|
||||||
|
{"key": "initiative", "label": "Initiative & Proactiveness"},
|
||||||
|
{"key": "decision_making", "label": "Decision Making"},
|
||||||
|
{"key": "leadership", "label": "Leadership"},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"fields": (
|
"fields": (
|
||||||
_EVALUATION_HEADER_FIELDS
|
_EVALUATION_HEADER_FIELDS
|
||||||
+ [{"key": "cultural_note", "label": "Cultural Fit — Notes", "kind": "text"}]
|
+ [{"key": "cultural_note", "label": "HR Evaluation — Notes", "kind": "text"}]
|
||||||
+ _EVALUATION_FOOTER_FIELDS
|
+ _EVALUATION_FOOTER_FIELDS
|
||||||
),
|
),
|
||||||
"has_recommendation": True,
|
"has_recommendation": True,
|
||||||
|
|
@ -173,6 +189,7 @@ FORM_DEFINITIONS = {
|
||||||
{"key": "internal_recommendation", "label": "INCASE OF INTERNAL RECOMMENDATE", "kind": "bool"},
|
{"key": "internal_recommendation", "label": "INCASE OF INTERNAL RECOMMENDATE", "kind": "bool"},
|
||||||
{"key": "recommended_employee_name", "label": "EMPLOYEE NAME", "kind": "text"},
|
{"key": "recommended_employee_name", "label": "EMPLOYEE NAME", "kind": "text"},
|
||||||
{"key": "recommended_employee_department", "label": "EMPLOYEE DEPARTMENT", "kind": "text"},
|
{"key": "recommended_employee_department", "label": "EMPLOYEE DEPARTMENT", "kind": "text"},
|
||||||
|
{"key": "entity", "label": "Entity", "kind": "text"},
|
||||||
{"key": "initiated_by", "label": "Initiated By — Name", "kind": "text"},
|
{"key": "initiated_by", "label": "Initiated By — Name", "kind": "text"},
|
||||||
{"key": "initiated_date", "label": "Initiated By — Date", "kind": "date"},
|
{"key": "initiated_date", "label": "Initiated By — Date", "kind": "date"},
|
||||||
{"key": "recommended_by", "label": "Recommended By — Name (Director)", "kind": "text"},
|
{"key": "recommended_by", "label": "Recommended By — Name (Director)", "kind": "text"},
|
||||||
|
|
@ -194,6 +211,7 @@ def definitions_payload() -> dict:
|
||||||
"form_types": list(FORM_TYPES),
|
"form_types": list(FORM_TYPES),
|
||||||
"forms": FORM_DEFINITIONS,
|
"forms": FORM_DEFINITIONS,
|
||||||
"rating_labels": {str(k): v for k, v in RATING_LABELS.items()},
|
"rating_labels": {str(k): v for k, v in RATING_LABELS.items()},
|
||||||
|
"rating_points": list(RATING_POINTS),
|
||||||
"recommendations": list(RECOMMENDATIONS),
|
"recommendations": list(RECOMMENDATIONS),
|
||||||
"recommendation_labels": dict(RECOMMENDATION_LABELS),
|
"recommendation_labels": dict(RECOMMENDATION_LABELS),
|
||||||
"employment_types": list(EMPLOYMENT_TYPES),
|
"employment_types": list(EMPLOYMENT_TYPES),
|
||||||
|
|
@ -211,9 +229,10 @@ def _coerce_rating(value):
|
||||||
raise ValueError(f"rating must be a number, got {value!r}")
|
raise ValueError(f"rating must be a number, got {value!r}")
|
||||||
if number != int(number):
|
if number != int(number):
|
||||||
raise ValueError(f"rating must be a whole number, got {value!r}")
|
raise ValueError(f"rating must be a whole number, got {value!r}")
|
||||||
rating = int(number)
|
rating = _LEGACY_TICK.get(int(number), int(number))
|
||||||
if rating < RATING_MIN or rating > RATING_MAX:
|
if rating not in RATING_POINTS:
|
||||||
raise ValueError(f"rating must be between {RATING_MIN} and {RATING_MAX}, got {rating}")
|
allowed = ", ".join(str(p) for p in RATING_POINTS)
|
||||||
|
raise ValueError(f"rating must be one of {allowed}, got {rating}")
|
||||||
return rating
|
return rating
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -224,15 +243,34 @@ def _mean(values, digits=2):
|
||||||
return round(sum(values) / len(values), digits)
|
return round(sum(values) / len(values), digits)
|
||||||
|
|
||||||
|
|
||||||
|
def to_percent(score):
|
||||||
|
"""Keep derived scores on 0–100.
|
||||||
|
|
||||||
|
New ticks are 25/50/75/100 and averages are already percentages. Legacy
|
||||||
|
1–4 ticks or means (0, 4] convert once via (score / 4) × 100.
|
||||||
|
"""
|
||||||
|
if score is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
number = float(score)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if 0 < number <= 4:
|
||||||
|
return round((number / 4) * 100, 2)
|
||||||
|
return round(number, 2)
|
||||||
|
|
||||||
|
|
||||||
def normalize_sections(form_type: str, sections):
|
def normalize_sections(form_type: str, sections):
|
||||||
"""Validate submitted rated sections against the form definition and
|
"""Validate submitted rated sections against the form definition and
|
||||||
recompute all derived numbers. Returns (normalized_sections, overall_score).
|
recompute all derived numbers. Returns (normalized_sections, overall_score).
|
||||||
|
|
||||||
Every definition section is emitted in definition order with denormalized
|
Every definition section is emitted in definition order with denormalized
|
||||||
labels; submitted per-criterion ratings are merged in; client-sent averages
|
labels; submitted per-criterion ratings are merged in; client-sent averages
|
||||||
are discarded and recomputed (mean of the non-null ratings, 2 dp). The
|
are discarded and recomputed. Criterion ticks are 25/50/75/100. A section
|
||||||
overall score is the mean of the section averages. Raises ValueError on
|
average is the mean of those percentages; the overall score is the mean of
|
||||||
unknown section/criterion keys or out-of-range ratings (422 material).
|
the section averages. Legacy 1–4 ticks are coerced to the matching percent
|
||||||
|
before averaging. Raises ValueError on unknown section/criterion keys or
|
||||||
|
ratings outside the scale (422 material).
|
||||||
"""
|
"""
|
||||||
definition = FORM_DEFINITIONS.get(form_type)
|
definition = FORM_DEFINITIONS.get(form_type)
|
||||||
if definition is None:
|
if definition is None:
|
||||||
|
|
@ -278,7 +316,7 @@ def normalize_sections(form_type: str, sections):
|
||||||
}
|
}
|
||||||
for c in section_def["criteria"]
|
for c in section_def["criteria"]
|
||||||
]
|
]
|
||||||
average = _mean([c["rating"] for c in criteria])
|
average = to_percent(_mean([c["rating"] for c in criteria]))
|
||||||
if average is not None:
|
if average is not None:
|
||||||
section_averages.append(average)
|
section_averages.append(average)
|
||||||
normalized.append(
|
normalized.append(
|
||||||
|
|
@ -332,9 +370,14 @@ def combined_summary(rows):
|
||||||
|
|
||||||
`rows` are candidate_forms records (attribute access: form_type, created_at,
|
`rows` are candidate_forms records (attribute access: form_type, created_at,
|
||||||
sections). The latest interview_analysis row supplies the technical and
|
sections). The latest interview_analysis row supplies the technical and
|
||||||
behavioral averages, the latest cultural_fit row the cultural average.
|
behavioral averages, the latest cultural_fit row the "cultural" section
|
||||||
The combined overall (mean of the three section averages, 2 dp) appears
|
average — cultural_fit's own section carries the fuller HR Evaluation
|
||||||
only once all three exist. Returns None when neither evaluation exists.
|
criteria as of revision 2, but the key stays "cultural" so this lookup
|
||||||
|
(and the `cultural_avg` key below) don't need to change with it.
|
||||||
|
The combined overall (mean of the three section averages, 2 dp, already
|
||||||
|
ranged onto 0–100) appears only once all three exist. Returns None when
|
||||||
|
neither evaluation exists. Legacy 1–4 section averages are converted
|
||||||
|
through to_percent so mixed old/new rows stay comparable.
|
||||||
"""
|
"""
|
||||||
latest = {}
|
latest = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|
@ -351,7 +394,7 @@ def combined_summary(rows):
|
||||||
for section in row.sections or []:
|
for section in row.sections or []:
|
||||||
key = section.get("key")
|
key = section.get("key")
|
||||||
if key in averages:
|
if key in averages:
|
||||||
averages[key] = section.get("average")
|
averages[key] = to_percent(section.get("average"))
|
||||||
|
|
||||||
complete = all(v is not None for v in averages.values())
|
complete = all(v is not None for v in averages.values())
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,24 @@
|
||||||
|
from candidate_forms.plugins import to_percent
|
||||||
|
|
||||||
|
|
||||||
|
def _sections_as_percent(sections):
|
||||||
|
if not sections:
|
||||||
|
return list(sections) if sections else None
|
||||||
|
out = []
|
||||||
|
for section in sections:
|
||||||
|
item = dict(section)
|
||||||
|
if "average" in item:
|
||||||
|
item["average"] = to_percent(item.get("average"))
|
||||||
|
criteria = item.get("criteria")
|
||||||
|
if criteria:
|
||||||
|
item["criteria"] = [
|
||||||
|
{**c, "rating": to_percent(c.get("rating"))} if isinstance(c, dict) else c
|
||||||
|
for c in criteria
|
||||||
|
]
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def serialize_form(
|
def serialize_form(
|
||||||
row,
|
row,
|
||||||
*,
|
*,
|
||||||
|
|
@ -21,12 +42,78 @@ def serialize_form(
|
||||||
"interviewer_id": str(row.interviewer_id) if row.interviewer_id else None,
|
"interviewer_id": str(row.interviewer_id) if row.interviewer_id else None,
|
||||||
"interviewer_name": interviewer_name,
|
"interviewer_name": interviewer_name,
|
||||||
"form_date": row.form_date.isoformat() if row.form_date else None,
|
"form_date": row.form_date.isoformat() if row.form_date else None,
|
||||||
"sections": list(row.sections) if row.sections else None,
|
"sections": _sections_as_percent(row.sections),
|
||||||
"fields": dict(row.fields) if row.fields else {},
|
"fields": dict(row.fields) if row.fields else {},
|
||||||
"overall_score": row.overall_score,
|
"overall_score": to_percent(row.overall_score),
|
||||||
"recommendation": row.recommendation,
|
"recommendation": row.recommendation,
|
||||||
"created_by": str(row.created_by) if row.created_by else None,
|
"created_by": str(row.created_by) if row.created_by else None,
|
||||||
"created_by_name": created_by_name,
|
"created_by_name": created_by_name,
|
||||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _date(value):
|
||||||
|
return value.isoformat() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _enum(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return getattr(value, "value", value)
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_requisition_option(row) -> dict:
|
||||||
|
"""Compact row for a searchable picker: `{job title} - {department}`."""
|
||||||
|
title = (row.position_title or "").strip()
|
||||||
|
department = (row.department or "").strip()
|
||||||
|
return {
|
||||||
|
"id": str(row.id) if row.id else None,
|
||||||
|
"title": row.position_title,
|
||||||
|
"department": row.department,
|
||||||
|
"label": f"{title or 'Untitled'} - {department or '—'}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_requisition(row) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(row.id) if row.id else None,
|
||||||
|
"position": {
|
||||||
|
"department": row.department,
|
||||||
|
"title": row.position_title,
|
||||||
|
"date": _date(row.date),
|
||||||
|
"date_needed": _date(row.date_needed),
|
||||||
|
"type": _enum(row.employment_type),
|
||||||
|
"job_description": row.job_description,
|
||||||
|
"period_from": _date(row.period_from),
|
||||||
|
"period_to": _date(row.period_to),
|
||||||
|
"jd_available": row.jd_available,
|
||||||
|
},
|
||||||
|
"replacement_for": {
|
||||||
|
"to_replace": row.to_replace,
|
||||||
|
"grade": row.grade,
|
||||||
|
"title": row.recruitment_title,
|
||||||
|
"date_separated": _date(row.date_separated),
|
||||||
|
"justification": row.justification,
|
||||||
|
"budget": row.budget,
|
||||||
|
"recommended_grade": row.recommended_grade,
|
||||||
|
},
|
||||||
|
"refferal_by": {
|
||||||
|
"employee_name": row.employee_name,
|
||||||
|
"employee_department": row.employee_department,
|
||||||
|
"entity": row.entity,
|
||||||
|
},
|
||||||
|
"initiated_by": row.initiated_by,
|
||||||
|
"initiated_date": _date(row.initiated_date),
|
||||||
|
"recommended_by": row.recommended_by,
|
||||||
|
"recommended_date": _date(row.recommended_date),
|
||||||
|
"approved_by_hr": row.approved_by_hr,
|
||||||
|
"approved_by_date_hr": _date(row.approved_by_date_hr),
|
||||||
|
"approved_by_vp": row.approved_by_vp,
|
||||||
|
"approved_by_date_vp": _date(row.approved_by_date_vp),
|
||||||
|
"approved_by_svp": row.approved_by_svp,
|
||||||
|
"approved_by_date_svp": _date(row.approved_by_date_svp),
|
||||||
|
"created_by": str(row.created_by) if row.created_by else None,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ from datetime import timezone
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from candidate_forms.models import CandidateForms, _now
|
from candidate_forms.models import CandidateForms, Requisition, _now
|
||||||
from candidate_forms.plugins import (
|
from candidate_forms.plugins import (
|
||||||
|
FORM_DEFINITIONS,
|
||||||
FORM_READY_STATUSES,
|
FORM_READY_STATUSES,
|
||||||
FORM_TYPES,
|
FORM_TYPES,
|
||||||
RECOMMENDATIONS,
|
RECOMMENDATIONS,
|
||||||
|
|
@ -14,13 +15,19 @@ from candidate_forms.plugins import (
|
||||||
normalize_fields,
|
normalize_fields,
|
||||||
normalize_sections,
|
normalize_sections,
|
||||||
)
|
)
|
||||||
from candidate_forms.serializers import serialize_form
|
from candidate_forms.serializers import (
|
||||||
|
serialize_form,
|
||||||
|
serialize_requisition,
|
||||||
|
serialize_requisition_option,
|
||||||
|
)
|
||||||
from inbox.models import Inbox
|
from inbox.models import Inbox
|
||||||
from job.candidate.models import Interviews, Manual_UPLOAD_CANDIDATE
|
from job.candidate.models import Interviews, Manual_UPLOAD_CANDIDATE
|
||||||
|
from job.candidate.views import assert_manager_candidate_access
|
||||||
from job.history.enums import HistoryEvent
|
from job.history.enums import HistoryEvent
|
||||||
from job.history.views import HistoryRecorder
|
from job.history.views import HistoryRecorder
|
||||||
from job.job_post.models import JobPosts
|
from job.job_post.models import JobPosts
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
|
from users.permissions import is_admin, is_hiring_manager
|
||||||
|
|
||||||
logger = logging.getLogger("candidate_forms")
|
logger = logging.getLogger("candidate_forms")
|
||||||
|
|
||||||
|
|
@ -53,6 +60,35 @@ def _stage_value(status) -> str:
|
||||||
return str(getattr(status, "value", status) or "").upper()
|
return str(getattr(status, "value", status) or "").upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _recommendation(form_type, value):
|
||||||
|
definition = FORM_DEFINITIONS.get(form_type) or {}
|
||||||
|
if not definition.get("has_recommendation"):
|
||||||
|
return None
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
value = str(value).strip()
|
||||||
|
if value not in RECOMMENDATIONS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}",
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _score_sections(form_type, sections):
|
||||||
|
try:
|
||||||
|
return normalize_sections(form_type, sections)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _score_fields(form_type, fields):
|
||||||
|
try:
|
||||||
|
return normalize_fields(form_type, fields)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
class CandidateForm:
|
class CandidateForm:
|
||||||
def __init__(self, session: AsyncSession):
|
def __init__(self, session: AsyncSession):
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|
@ -80,55 +116,28 @@ class CandidateForm:
|
||||||
stage = _stage_value(
|
stage = _stage_value(
|
||||||
link.messages.application_status if link.messages is not None else None
|
link.messages.application_status if link.messages is not None else None
|
||||||
)
|
)
|
||||||
|
app_job = (
|
||||||
|
link.messages.assigned_job_post_id if link.messages is not None else None
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
inbox_id = None
|
inbox_id = None
|
||||||
manual = await Manual_UPLOAD_CANDIDATE.get_by_id(self.session, manual_id)
|
manual = await Manual_UPLOAD_CANDIDATE.get_by_id(self.session, manual_id)
|
||||||
if manual is None:
|
if manual is None:
|
||||||
raise HTTPException(status_code=404, detail="Manual upload candidate not found")
|
raise HTTPException(status_code=404, detail="Manual upload candidate not found")
|
||||||
stage = _stage_value(manual.status)
|
stage = _stage_value(manual.status)
|
||||||
|
app_job = manual.job_post_id
|
||||||
|
|
||||||
job_post_id = _as_uuid(payload.get("job_post_id"))
|
job_post_id = _as_uuid(payload.get("job_post_id"))
|
||||||
if payload.get("job_post_id") and job_post_id is None:
|
if payload.get("job_post_id") and job_post_id is None:
|
||||||
raise HTTPException(status_code=422, detail="Invalid job_post_id")
|
raise HTTPException(status_code=422, detail="Invalid job_post_id")
|
||||||
|
if job_post_id is None:
|
||||||
|
job_post_id = app_job
|
||||||
if job_post_id is not None:
|
if job_post_id is not None:
|
||||||
post = await JobPosts.get_job_post_by_id(self.session, str(job_post_id))
|
post = await JobPosts.get_job_post_by_id(self.session, str(job_post_id))
|
||||||
if not post or post.is_deleted:
|
if not post or post.is_deleted:
|
||||||
raise HTTPException(status_code=404, detail="Job post not found")
|
raise HTTPException(status_code=404, detail="Job post not found")
|
||||||
return inbox_id, manual_id, job_post_id, stage
|
return inbox_id, manual_id, job_post_id, stage
|
||||||
|
|
||||||
def _normalize_payload(self, form_type, payload):
|
|
||||||
"""Shared create/update normalization. Returns the writable fields dict
|
|
||||||
for the keys present in `payload`."""
|
|
||||||
fields = {}
|
|
||||||
if "sections" in payload:
|
|
||||||
try:
|
|
||||||
sections, overall = normalize_sections(form_type, payload.get("sections"))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=422, detail=str(exc))
|
|
||||||
fields["sections"] = sections
|
|
||||||
fields["overall_score"] = overall
|
|
||||||
if "fields" in payload:
|
|
||||||
try:
|
|
||||||
fields["fields"] = normalize_fields(form_type, payload.get("fields"))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=422, detail=str(exc))
|
|
||||||
if "recommendation" in payload:
|
|
||||||
recommendation = payload.get("recommendation") or None
|
|
||||||
if recommendation is not None and recommendation not in RECOMMENDATIONS:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}",
|
|
||||||
)
|
|
||||||
fields["recommendation"] = recommendation
|
|
||||||
if "interviewer_id" in payload:
|
|
||||||
interviewer_id = _as_uuid(payload.get("interviewer_id"))
|
|
||||||
if payload.get("interviewer_id") and interviewer_id is None:
|
|
||||||
raise HTTPException(status_code=422, detail="Invalid interviewer_id")
|
|
||||||
fields["interviewer_id"] = interviewer_id
|
|
||||||
if "form_date" in payload:
|
|
||||||
fields["form_date"] = _aware(payload.get("form_date"))
|
|
||||||
return fields
|
|
||||||
|
|
||||||
async def _context_maps(self, rows):
|
async def _context_maps(self, rows):
|
||||||
inbox_ids = [r.inbox_id for r in rows if r.inbox_id is not None]
|
inbox_ids = [r.inbox_id for r in rows if r.inbox_id is not None]
|
||||||
manual_ids = [r.manual_upload_candidate_id for r in rows if r.manual_upload_candidate_id]
|
manual_ids = [r.manual_upload_candidate_id for r in rows if r.manual_upload_candidate_id]
|
||||||
|
|
@ -213,11 +222,27 @@ class CandidateForm:
|
||||||
form_type=None,
|
form_type=None,
|
||||||
top=None,
|
top=None,
|
||||||
skip=0,
|
skip=0,
|
||||||
|
current_user=None,
|
||||||
):
|
):
|
||||||
if form_type and form_type not in FORM_TYPES:
|
if form_type and form_type not in FORM_TYPES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}"
|
status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}"
|
||||||
)
|
)
|
||||||
|
if is_hiring_manager(current_user) and not (
|
||||||
|
form_id or inbox_id is not None or manual_upload_candidate_id or job_post_id
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Hiring managers can only load forms for candidates on their requisitions",
|
||||||
|
)
|
||||||
|
if inbox_id is not None or manual_upload_candidate_id is not None or job_post_id:
|
||||||
|
await assert_manager_candidate_access(
|
||||||
|
self.session,
|
||||||
|
current_user,
|
||||||
|
job_post_id=job_post_id,
|
||||||
|
inbox_id=inbox_id,
|
||||||
|
manual_id=manual_upload_candidate_id,
|
||||||
|
)
|
||||||
rows, total = await CandidateForms.fetch_forms(
|
rows, total = await CandidateForms.fetch_forms(
|
||||||
self.session,
|
self.session,
|
||||||
form_id=form_id,
|
form_id=form_id,
|
||||||
|
|
@ -228,6 +253,14 @@ class CandidateForm:
|
||||||
top=top,
|
top=top,
|
||||||
skip=skip or 0,
|
skip=skip or 0,
|
||||||
)
|
)
|
||||||
|
if form_id and rows:
|
||||||
|
await assert_manager_candidate_access(
|
||||||
|
self.session,
|
||||||
|
current_user,
|
||||||
|
job_post_id=rows[0].job_post_id,
|
||||||
|
inbox_id=rows[0].inbox_id,
|
||||||
|
manual_id=rows[0].manual_upload_candidate_id,
|
||||||
|
)
|
||||||
|
|
||||||
summary = None
|
summary = None
|
||||||
if inbox_id is not None or manual_upload_candidate_id is not None:
|
if inbox_id is not None or manual_upload_candidate_id is not None:
|
||||||
|
|
@ -250,11 +283,14 @@ class CandidateForm:
|
||||||
status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}"
|
status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}"
|
||||||
)
|
)
|
||||||
inbox_id, manual_id, job_post_id, stage = await self._validate_link(payload)
|
inbox_id, manual_id, job_post_id, stage = await self._validate_link(payload)
|
||||||
|
await assert_manager_candidate_access(
|
||||||
|
self.session,
|
||||||
|
current_user,
|
||||||
|
job_post_id=job_post_id,
|
||||||
|
inbox_id=inbox_id,
|
||||||
|
manual_id=manual_id,
|
||||||
|
)
|
||||||
if stage not in FORM_READY_STATUSES:
|
if stage not in FORM_READY_STATUSES:
|
||||||
# A scheduled interview also unlocks the forms: the paperwork belongs
|
|
||||||
# to the interview, not to which kanban column the card sits in.
|
|
||||||
# (Interview records link only to inbox rows, so manual-upload
|
|
||||||
# candidates unlock by stage alone.)
|
|
||||||
has_interview = False
|
has_interview = False
|
||||||
if inbox_id is not None:
|
if inbox_id is not None:
|
||||||
rows = await Interviews.get_interviews_by_inbox(self.session, inbox_id)
|
rows = await Interviews.get_interviews_by_inbox(self.session, inbox_id)
|
||||||
|
|
@ -269,28 +305,29 @@ class CandidateForm:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
fields = {
|
interviewer_id = _as_uuid(payload.get("interviewer_id"))
|
||||||
"inbox_id": inbox_id,
|
if form_type != "requisition" and interviewer_id is None:
|
||||||
"manual_upload_candidate_id": manual_id,
|
interviewer_id = _user_id(current_user)
|
||||||
"job_post_id": job_post_id,
|
form_date = _aware(payload.get("form_date")) or _now()
|
||||||
"form_type": form_type,
|
sections, overall_score = _score_sections(form_type, payload.get("sections"))
|
||||||
"created_by": _user_id(current_user),
|
fields = _score_fields(form_type, payload.get("fields"))
|
||||||
}
|
|
||||||
fields.update(
|
|
||||||
self._normalize_payload(
|
|
||||||
form_type,
|
|
||||||
{
|
|
||||||
key: payload.get(key)
|
|
||||||
for key in ("sections", "fields", "recommendation", "interviewer_id", "form_date")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if form_type != "requisition" and fields.get("interviewer_id") is None:
|
|
||||||
fields["interviewer_id"] = _user_id(current_user)
|
|
||||||
if fields.get("form_date") is None:
|
|
||||||
fields["form_date"] = _now()
|
|
||||||
|
|
||||||
row = await CandidateForms.insert_form(self.session, fields)
|
row = await CandidateForms.insert_form(
|
||||||
|
self.session,
|
||||||
|
{
|
||||||
|
"form_type": form_type,
|
||||||
|
"inbox_id": inbox_id,
|
||||||
|
"manual_upload_candidate_id": manual_id,
|
||||||
|
"job_post_id": job_post_id,
|
||||||
|
"interviewer_id": interviewer_id,
|
||||||
|
"form_date": form_date,
|
||||||
|
"sections": sections,
|
||||||
|
"fields": fields,
|
||||||
|
"overall_score": overall_score,
|
||||||
|
"recommendation": _recommendation(form_type, payload.get("recommendation")),
|
||||||
|
"created_by": _user_id(current_user),
|
||||||
|
},
|
||||||
|
)
|
||||||
await HistoryRecorder(self.session).record(
|
await HistoryRecorder(self.session).record(
|
||||||
HistoryEvent.FORM_CREATED,
|
HistoryEvent.FORM_CREATED,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
|
|
@ -308,8 +345,27 @@ class CandidateForm:
|
||||||
row = await CandidateForms.get_form_by_id(self.session, form_id)
|
row = await CandidateForms.get_form_by_id(self.session, form_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Form not found")
|
raise HTTPException(status_code=404, detail="Form not found")
|
||||||
|
await assert_manager_candidate_access(
|
||||||
|
self.session,
|
||||||
|
current_user,
|
||||||
|
job_post_id=row.job_post_id,
|
||||||
|
inbox_id=row.inbox_id,
|
||||||
|
manual_id=row.manual_upload_candidate_id,
|
||||||
|
)
|
||||||
|
|
||||||
fields = self._normalize_payload(row.form_type, payload)
|
fields = {}
|
||||||
|
if "interviewer_id" in payload:
|
||||||
|
fields["interviewer_id"] = _as_uuid(payload.get("interviewer_id"))
|
||||||
|
if "form_date" in payload:
|
||||||
|
fields["form_date"] = _aware(payload.get("form_date"))
|
||||||
|
if "sections" in payload:
|
||||||
|
sections, overall_score = _score_sections(row.form_type, payload.get("sections"))
|
||||||
|
fields["sections"] = sections
|
||||||
|
fields["overall_score"] = overall_score
|
||||||
|
if "fields" in payload:
|
||||||
|
fields["fields"] = _score_fields(row.form_type, payload.get("fields"))
|
||||||
|
if "recommendation" in payload:
|
||||||
|
fields["recommendation"] = _recommendation(row.form_type, payload.get("recommendation"))
|
||||||
if not fields:
|
if not fields:
|
||||||
raise HTTPException(status_code=400, detail="No fields to update")
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
|
||||||
|
|
@ -330,7 +386,57 @@ class CandidateForm:
|
||||||
|
|
||||||
async def delete_form(self, form_id, current_user):
|
async def delete_form(self, form_id, current_user):
|
||||||
_user_id(current_user)
|
_user_id(current_user)
|
||||||
|
row = await CandidateForms.get_form_by_id(self.session, form_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Form not found")
|
||||||
|
await assert_manager_candidate_access(
|
||||||
|
self.session,
|
||||||
|
current_user,
|
||||||
|
job_post_id=row.job_post_id,
|
||||||
|
inbox_id=row.inbox_id,
|
||||||
|
manual_id=row.manual_upload_candidate_id,
|
||||||
|
)
|
||||||
row = await CandidateForms.soft_delete_form(self.session, form_id)
|
row = await CandidateForms.soft_delete_form(self.session, form_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Form not found")
|
raise HTTPException(status_code=404, detail="Form not found")
|
||||||
return {"id": str(row.id), "deleted": True}
|
return {"id": str(row.id), "deleted": True}
|
||||||
|
|
||||||
|
class RequisitionForm:
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def create_form(self, payload, current_user):
|
||||||
|
payload["created_by"] = _user_id(current_user)
|
||||||
|
row = await Requisition.insert_form(self.session, payload)
|
||||||
|
return serialize_requisition(row)
|
||||||
|
|
||||||
|
async def update_form(self, form_id, payload, current_user):
|
||||||
|
_user_id(current_user)
|
||||||
|
row = await Requisition.get_form_by_id(self.session, form_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Form not found")
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
updated = await Requisition.update_form(self.session, form_id, payload)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=404, detail="Form not found")
|
||||||
|
return serialize_requisition(updated)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_form_by_id(self, form_id, current_user):
|
||||||
|
# Admins see the full table. Managers still only see rows they opened.
|
||||||
|
# Job-post linkage is ignored here — that filter is search/picker only.
|
||||||
|
created_by = None if is_admin(current_user) else _user_id(current_user)
|
||||||
|
if form_id:
|
||||||
|
row = await Requisition.get_form_by_id(self.session, record_id=form_id, created_by=created_by)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Form not found")
|
||||||
|
return serialize_requisition(row)
|
||||||
|
rows = await Requisition.get_form_by_id(self.session, created_by=created_by)
|
||||||
|
return [serialize_requisition(r) for r in rows]
|
||||||
|
|
||||||
|
async def search(self, q, top=50, job_post_id=None):
|
||||||
|
rows = await Requisition.search(
|
||||||
|
self.session, q, top=top, job_post_id=job_post_id,
|
||||||
|
)
|
||||||
|
return [serialize_requisition_option(r) for r in rows]
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
"type": "authorized_user",
|
"type": "authorized_user",
|
||||||
"client_id": "679334897177-ufal3rogbg8cgm3qcren6pv20phqd7nl.apps.googleusercontent.com",
|
"client_id": "679334897177-ufal3rogbg8cgm3qcren6pv20phqd7nl.apps.googleusercontent.com",
|
||||||
"client_secret": "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",
|
"client_secret": "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",
|
||||||
"refresh_token": "1//03C8LMkk-9mSyCgYIARAAGAMSNwF-L9IrAcPhShsp3cDeprSlqI-P6lArpmbyzu-PcKiLfQ5gC3H_MEk930IaKfewy3cxP3T0Oo8",
|
"refresh_token": "1//03Tu7LRVp_zBACgYIARAAGAMSNwF-L9IrIXwqqdEZGxpxULAKbtgCygCih8DHmO-ELciPtMV8VCVNyZCrO9l6veq0WPwT6fbcAC8",
|
||||||
"universe_domain": "googleapis.com",
|
"universe_domain": "googleapis.com",
|
||||||
"account": "ahmed.mujtaba@utopiabrands.com",
|
"account": "ahmed.mujtaba@utopiabrands.com",
|
||||||
"token": "ya29.a0AdMD6Eg_6meQs84gTmiyhzZp7C-JlZeJU6-ECm6twwAcMqvfvRvyvs5LQGbAhHarHzZF-jiU-sicebJmxXIN4l6hNDXoaHcrojuhq--hj2oSBWojiEKaGIgLKPM8frdspz_wVANrwkFwpIKhN3RpWID9mJCt7N6IFaNrZtgStakdF0sVCKKVttE7qWK0vIvJT3HZHpVbaCgYKAX8SARASFQHGX2MiQvJqWStYQDgYFK4E6GJ9Zw0207",
|
"token": "ya29.a0AdMD6Eh9Kvd-ACJZT90CDywa396Zsrf84OWg17u8X-AVffmKhB0nuql60ail5cAY8XlkRuySHSZRKSdXQ7W3IM2dticjCoYgeMmVErMi5UAawUQAd6q0CEsCbi7EPnLTgraOXTAO1MRlWaHwU-R179t0GsAQwnlj9SWBC5Zgfu7Ubf8dGyBcuzg9zknfCF2zbmZFZOsaCgYKAV0SARASFQHGX2MiOeWelgu56Tql44hrVoy1iw0206",
|
||||||
"expiry": "2026-08-31T09:52:09Z",
|
"expiry": "2026-09-08T12:10:05Z",
|
||||||
"quota_project_id": "hrms-ats-portal"
|
"quota_project_id": "hrms-ats-portal"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""HTTP client for scheduled system jobs — call the portal API, do not import views.
|
||||||
|
|
||||||
|
The daily inbox sync goes through POST /email/sync so enqueue, coalescing, and
|
||||||
|
the mailbox_sync worker stay on one code path with the UI Sync Inbox button.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
INBOX_SYNC_PATH="/email/sync"
|
||||||
|
INBOX_SYNC_TIMEOUT=float(os.getenv("INBOX_SYNC_TIMEOUT_SECONDS","30"))
|
||||||
|
|
||||||
|
|
||||||
|
def _backend_url() -> str:
|
||||||
|
return (os.getenv("BACKEND_URL") or "http://localhost:8000").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _cron_token() -> str:
|
||||||
|
return (os.getenv("CRON_INBOX_SYNC_TOKEN") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def call_inbox_sync_api(*, top=100, skip=0, test_on=True) -> dict:
|
||||||
|
"""POST /email/sync on this service -> the JSON envelope.
|
||||||
|
|
||||||
|
Uses CRON_INBOX_SYNC_TOKEN as Bearer. The route accepts that shared secret
|
||||||
|
in place of a recruiter JWT so the 05:00 PKT scheduler can enqueue a run.
|
||||||
|
"""
|
||||||
|
token=_cron_token()
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("CRON_INBOX_SYNC_TOKEN is not set")
|
||||||
|
params={
|
||||||
|
"top":int(top if top is not None else 100),
|
||||||
|
"skip":int(skip if skip is not None else 0),
|
||||||
|
"test_on":bool(test_on) if test_on is not None else True,
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=INBOX_SYNC_TIMEOUT) as client:
|
||||||
|
response=await client.post(
|
||||||
|
f"{_backend_url()}{INBOX_SYNC_PATH}",
|
||||||
|
params=params,
|
||||||
|
headers={"Authorization":f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
if response.status_code>=400:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
response.text,
|
||||||
|
request=response.request,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
"""Daily Sync Inbox cron — 05:00 AM PKT via httpx POST /email/sync.
|
||||||
|
|
||||||
|
Worker: taskiq worker taskiq_management.broker_setup:broker cron_schdule.tasks
|
||||||
|
Scheduler: taskiq scheduler taskiq_management.broker_setup:scheduler cron_schdule.tasks
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from cron_schdule.plugins import call_inbox_sync_api
|
||||||
|
from taskiq_management.broker_setup import broker
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
logger=logging.getLogger("cron_schdule.inbox_sync")
|
||||||
|
|
||||||
|
# 05:00 Asia/Karachi (PKT, UTC+5, no DST). Override the expression or zone in .env.
|
||||||
|
INBOX_SYNC_CRON=os.getenv("INBOX_SYNC_CRON","0 5 * * *")
|
||||||
|
INBOX_SYNC_CRON_TZ=os.getenv("INBOX_SYNC_CRON_TZ","Asia/Karachi")
|
||||||
|
INBOX_SYNC_TOP=int(os.getenv("INBOX_SYNC_TOP","100"))
|
||||||
|
INBOX_SYNC_SKIP=int(os.getenv("INBOX_SYNC_SKIP","0"))
|
||||||
|
INBOX_SYNC_TEST_ON=os.getenv("INBOX_SYNC_TEST_ON","true").strip().lower() not in ("0","false","no")
|
||||||
|
|
||||||
|
|
||||||
|
@broker.task(
|
||||||
|
task_name="cron_schdule.sync_inbox",
|
||||||
|
schedule=[{"cron":INBOX_SYNC_CRON,"cron_offset":INBOX_SYNC_CRON_TZ}],
|
||||||
|
)
|
||||||
|
async def sync_inbox_daily() -> dict:
|
||||||
|
"""Enqueue Outlook mailbox sync the same way the Inbox UI button does."""
|
||||||
|
try:
|
||||||
|
payload=await call_inbox_sync_api(
|
||||||
|
top=INBOX_SYNC_TOP,
|
||||||
|
skip=INBOX_SYNC_SKIP,
|
||||||
|
test_on=INBOX_SYNC_TEST_ON,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.warning("daily inbox sync skipped: %s",e)
|
||||||
|
return {"error":"not_configured","detail":str(e)}
|
||||||
|
except httpx.ConnectError as e:
|
||||||
|
logger.warning("daily inbox sync unreachable: %s",e)
|
||||||
|
return {"error":"unreachable","detail":str(e)}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
status=e.response.status_code if e.response is not None else None
|
||||||
|
logger.warning("daily inbox sync HTTP %s",status)
|
||||||
|
return {"error":"http_error","status_code":status}
|
||||||
|
data=payload.get("data") if isinstance(payload,dict) else None
|
||||||
|
return {"status":"ok","data":data}
|
||||||
|
|
@ -17,7 +17,18 @@ from __future__ import annotations
|
||||||
import re
|
import re
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from employment_agent.prompt import EDUCATION,NO_COMPANY,NO_LINKEDIN,NO_PHONE
|
from employment_agent.prompt import EDUCATION,NO_CITY,NO_COMPANY,NO_LINKEDIN,NO_NAME,NO_PHONE
|
||||||
|
from global_cities import CITY_BY_KEY,CITY_RE
|
||||||
|
|
||||||
|
_CITY_SENTINELS=frozenset({
|
||||||
|
NO_CITY.lower(),"none","null","n/a","-","na","n.a.","n.a",
|
||||||
|
})
|
||||||
|
_CITY_DROP=frozenset({
|
||||||
|
"dha","cantt","cantonment","cant","phase","sector","area","district",
|
||||||
|
"tehsil","division","housing","society","scheme","block","street","house",
|
||||||
|
"near","colony","neighborhood","neighbourhood","suburb",
|
||||||
|
})
|
||||||
|
_SECTOR_RE=re.compile(r"^(?:[a-z]-?\d+[a-z]?|\d+[a-z]?)$",re.I)
|
||||||
|
|
||||||
|
|
||||||
def require_json_object(func):
|
def require_json_object(func):
|
||||||
|
|
@ -90,18 +101,130 @@ def _clean_phone(value,resume_text):
|
||||||
text=(value or "").strip()
|
text=(value or "").strip()
|
||||||
if not text or text.lower() in (NO_PHONE.lower(),"none","null","n/a","-"):
|
if not text or text.lower() in (NO_PHONE.lower(),"none","null","n/a","-"):
|
||||||
return None
|
return None
|
||||||
digits=re.sub(r"\D","",text)
|
from employment_agent.plugins import _phone_digits,_phone_score,phone_in_resume
|
||||||
if digits.startswith("00"):
|
digits=_phone_digits(text)
|
||||||
digits=digits[2:]
|
if _phone_score(digits)<0:
|
||||||
if len(digits)<10 or len(digits)>15:
|
return None
|
||||||
|
if (resume_text or "").strip() and not phone_in_resume(digits,resume_text):
|
||||||
return None
|
return None
|
||||||
if (resume_text or "").strip():
|
|
||||||
haystack=re.sub(r"\D","",resume_text)
|
|
||||||
if digits not in haystack:
|
|
||||||
return None
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_city(text):
|
||||||
|
"""Write-time only: messy locality → one proper city name, or None.
|
||||||
|
|
||||||
|
Looks up `global_cities.Countries` (every country, Pakistan included).
|
||||||
|
"Karachi(Malir)" / "London(Westminster)" / "DHA Karachi" / "Wah Cantt"
|
||||||
|
map to the listed city. Sentinels and blanks are None. Never rejects a CV.
|
||||||
|
"""
|
||||||
|
raw=(text or "").strip()
|
||||||
|
if not raw or raw.lower() in _CITY_SENTINELS:
|
||||||
|
return None
|
||||||
|
known=CITY_BY_KEY.get(raw.lower())
|
||||||
|
if known:
|
||||||
|
return known
|
||||||
|
normalised=re.sub(r"[()\[\]{}]"," ",raw)
|
||||||
|
normalised=re.sub(r"[,/;|]+"," ",normalised)
|
||||||
|
normalised=re.sub(r"\s+"," ",normalised).strip()
|
||||||
|
if not normalised:
|
||||||
|
return None
|
||||||
|
known=CITY_BY_KEY.get(normalised.lower())
|
||||||
|
if known:
|
||||||
|
return known
|
||||||
|
match=CITY_RE.search(normalised.lower())
|
||||||
|
if match:
|
||||||
|
return CITY_BY_KEY[match.group(0)]
|
||||||
|
leftover=[]
|
||||||
|
for token in normalised.split():
|
||||||
|
lowered=token.lower()
|
||||||
|
if lowered in _CITY_DROP or _SECTOR_RE.fullmatch(token):
|
||||||
|
continue
|
||||||
|
leftover.append(token)
|
||||||
|
if not leftover:
|
||||||
|
return None
|
||||||
|
cleaned=" ".join(leftover)
|
||||||
|
known=CITY_BY_KEY.get(cleaned.lower())
|
||||||
|
if known:
|
||||||
|
return known
|
||||||
|
if len(cleaned)>40 or len(leftover)>3:
|
||||||
|
return leftover[0][:1].upper()+leftover[0][1:]
|
||||||
|
return " ".join(t[:1].upper()+t[1:] for t in leftover)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_city(value,resume_text):
|
||||||
|
"""Optional residence city. Sentinel / blank → None. Never rejects the CV.
|
||||||
|
|
||||||
|
After the employment-agent JSON is parsed, clamp to a proper city name so
|
||||||
|
a model that still returns "Karachi(Malir)" is stored as "Karachi".
|
||||||
|
"""
|
||||||
|
return canonical_city(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_skills(value,resume_text):
|
||||||
|
"""Keep only skills the resume actually contains, deduplicated, capped at 30.
|
||||||
|
|
||||||
|
Same discipline as the company/education clamps: the model is asked for the
|
||||||
|
resume's own spelling, so anything absent from the text is an invention. A
|
||||||
|
skill chip is read as "this is in the CV", and the bank filters on it.
|
||||||
|
|
||||||
|
Deduplication runs BEFORE the ceiling so a model that returns 31 near-
|
||||||
|
duplicates collapses under the limit instead of losing real skills.
|
||||||
|
"""
|
||||||
|
if not isinstance(value,list):
|
||||||
|
return []
|
||||||
|
haystack=(resume_text or "").lower()
|
||||||
|
kept=[]
|
||||||
|
seen=set()
|
||||||
|
for entry in value:
|
||||||
|
if not isinstance(entry,str):
|
||||||
|
continue
|
||||||
|
text=entry.strip()
|
||||||
|
if not text or len(text)>60:
|
||||||
|
continue
|
||||||
|
lowered=text.lower()
|
||||||
|
if lowered in seen:
|
||||||
|
continue
|
||||||
|
if haystack and lowered not in haystack:
|
||||||
|
continue
|
||||||
|
seen.add(lowered)
|
||||||
|
kept.append(text)
|
||||||
|
return kept[:30]
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_name(value,resume_text):
|
||||||
|
"""Full name from the resume header. Invented / email-shaped values drop."""
|
||||||
|
text=(value or "").strip()
|
||||||
|
if not text or text.lower() in {NO_NAME.lower(),"none","null","n/a","-"}:
|
||||||
|
return ""
|
||||||
|
if "@" in text or len(text)>120:
|
||||||
|
return ""
|
||||||
|
haystack=(resume_text or "").lower()
|
||||||
|
first=text.split()[0].lower()
|
||||||
|
if haystack and first not in haystack:
|
||||||
|
return ""
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_years(value,resume_text):
|
||||||
|
"""Whole years of experience, bounded 0-60. Anything else is None.
|
||||||
|
|
||||||
|
Seniority language is not a duration, so an unparseable value has to read
|
||||||
|
as "unknown" rather than 0 — 0 would sort as a junior candidate.
|
||||||
|
"""
|
||||||
|
if isinstance(value,bool):
|
||||||
|
return None
|
||||||
|
if isinstance(value,(int,float)):
|
||||||
|
years=int(value)
|
||||||
|
elif isinstance(value,str):
|
||||||
|
digits=re.search(r"\d+",value)
|
||||||
|
if not digits:
|
||||||
|
return None
|
||||||
|
years=int(digits.group())
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return years if 0<=years<=60 else None
|
||||||
|
|
||||||
|
|
||||||
def prefer_extracted_phone(func):
|
def prefer_extracted_phone(func):
|
||||||
"""Merge CV regex phone with the LLM value; keep the longer complete number."""
|
"""Merge CV regex phone with the LLM value; keep the longer complete number."""
|
||||||
|
|
||||||
|
|
@ -118,6 +241,10 @@ clamp_company_to_resume=clamp_in_resume("current_employment",NO_COMPANY)
|
||||||
clamp_education_to_resume=clamp_in_resume("education",EDUCATION)
|
clamp_education_to_resume=clamp_in_resume("education",EDUCATION)
|
||||||
clamp_linkedin_url=clamp_field("linkedin_url",_clean_linkedin)
|
clamp_linkedin_url=clamp_field("linkedin_url",_clean_linkedin)
|
||||||
clamp_phone=clamp_field("phone",_clean_phone)
|
clamp_phone=clamp_field("phone",_clean_phone)
|
||||||
|
clamp_skills=clamp_field("skills",_clean_skills)
|
||||||
|
clamp_years_experience=clamp_field("years_experience",_clean_years)
|
||||||
|
clamp_city=clamp_field("city",_clean_city)
|
||||||
|
clamp_candidate_name=clamp_field("candidate_name",_clean_name)
|
||||||
|
|
||||||
|
|
||||||
@require_json_object
|
@require_json_object
|
||||||
|
|
@ -126,15 +253,28 @@ clamp_phone=clamp_field("phone",_clean_phone)
|
||||||
@clamp_linkedin_url
|
@clamp_linkedin_url
|
||||||
@prefer_extracted_phone
|
@prefer_extracted_phone
|
||||||
@clamp_phone
|
@clamp_phone
|
||||||
|
@clamp_skills
|
||||||
|
@clamp_years_experience
|
||||||
|
@clamp_city
|
||||||
|
@clamp_candidate_name
|
||||||
def parse_employment_response(data,resume_text=""):
|
def parse_employment_response(data,resume_text=""):
|
||||||
"""Pull company, education, title, linkedin_url, and phone from the agent JSON."""
|
"""Pull name, company, education, title, linkedin_url, phone, city, skills, and years
|
||||||
|
from the agent JSON.
|
||||||
|
|
||||||
|
skills, years_experience, and candidate_name default to []/None/"" when the
|
||||||
|
key is absent, so a model reply predating the extended prompt still parses.
|
||||||
|
"""
|
||||||
def as_str(key):
|
def as_str(key):
|
||||||
value=data.get(key)
|
value=data.get(key)
|
||||||
return value.strip() if isinstance(value,str) else ""
|
return value.strip() if isinstance(value,str) else ""
|
||||||
return {
|
return {
|
||||||
|
"candidate_name":as_str("candidate_name"),
|
||||||
"current_employment":as_str("current_employment"),
|
"current_employment":as_str("current_employment"),
|
||||||
"education":as_str("education"),
|
"education":as_str("education"),
|
||||||
"current_title":as_str("current_title"),
|
"current_title":as_str("current_title"),
|
||||||
"linkedin_url":as_str("linkedin_url"),
|
"linkedin_url":as_str("linkedin_url"),
|
||||||
"phone":as_str("phone"),
|
"phone":as_str("phone"),
|
||||||
|
"city":as_str("city"),
|
||||||
|
"skills":data.get("skills") if isinstance(data.get("skills"),list) else [],
|
||||||
|
"years_experience":data.get("years_experience"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,15 @@ async def run_employment_agent(*,resume_text=""):
|
||||||
text=(resume_text or "").strip()
|
text=(resume_text or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return {
|
return {
|
||||||
|
"candidate_name":"",
|
||||||
"current_employment":NO_COMPANY,
|
"current_employment":NO_COMPANY,
|
||||||
"education":EDUCATION,
|
"education":EDUCATION,
|
||||||
"current_title":CURRENT_TITLE,
|
"current_title":CURRENT_TITLE,
|
||||||
"linkedin_url":None,
|
"linkedin_url":None,
|
||||||
"phone":None,
|
"phone":None,
|
||||||
|
"city":None,
|
||||||
|
"skills":[],
|
||||||
|
"years_experience":None,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
data=await llm_call(prompt(),user_prompt(text),json_mode=True)
|
data=await llm_call(prompt(),user_prompt(text),json_mode=True)
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ Call like the rest of the backend:
|
||||||
fields=parse_linkedin({"linkedin_url":raw},resume_text)
|
fields=parse_linkedin({"linkedin_url":raw},resume_text)
|
||||||
url=fields["linkedin_url"]
|
url=fields["linkedin_url"]
|
||||||
|
|
||||||
`scan_phone` is the regex guts `prefer_extracted_phone` uses so the stacked
|
`scan_phone` is the digit-span scan `prefer_extracted_phone` uses so the
|
||||||
parser cannot recurse into itself.
|
stacked parser cannot recurse into itself.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -23,16 +23,43 @@ from employment_agent.decorators import (
|
||||||
prefer_extracted_phone,
|
prefer_extracted_phone,
|
||||||
)
|
)
|
||||||
|
|
||||||
_PK_MOBILE=re.compile(
|
# PDF extraction uses en/em dashes, nbsp, and bullets as digit separators.
|
||||||
r"(?:(?:\+|00)[\s\-.]*)?(?:92[\s\-.]*)?0?3\d{2}(?:[\s\-.\n]*\d){7}"
|
_DASH_TO_HYPHEN=str.maketrans({
|
||||||
)
|
"\u2010":"-","\u2011":"-","\u2012":"-","\u2013":"-","\u2014":"-",
|
||||||
_PHONE_SPAN=re.compile(
|
"\u2015":"-","\u2212":"-","\u2043":"-","\uFE58":"-","\uFE63":"-",
|
||||||
r"(?:(?:\+|00)[\s\-.]*)?(?:\(?\d[\s\-()./\n]*){8,16}\d"
|
"\uFF0D":"-",
|
||||||
)
|
})
|
||||||
|
_STRIP_INVISIBLE="".join((
|
||||||
|
"\u00ad","\u200b","\u200c","\u200d","\u2060","\ufeff",
|
||||||
|
))
|
||||||
|
_DIGIT_TO_ASCII=str.maketrans({
|
||||||
|
**{chr(0x0660+i):str(i) for i in range(10)},
|
||||||
|
**{chr(0x06F0+i):str(i) for i in range(10)},
|
||||||
|
**{chr(0xFF10+i):str(i) for i in range(10)},
|
||||||
|
})
|
||||||
|
_OCR_O=re.compile(r"(?<![A-Za-z0-9])[Oo](?=3\d{2}[\s\-.\d]{6,})")
|
||||||
|
_DIGIT_GROUP=re.compile(r"\+?\d+")
|
||||||
|
_GAP_OK=re.compile(r"^[\s\-./()[\]{},:|•·∙+_]*$")
|
||||||
|
_WA_ME=re.compile(r"(?i)(?:wa\.me/|api\.whatsapp\.com/send\?phone=)(\+?\d{10,15})")
|
||||||
|
_TEL_URI=re.compile(r"(?i)tel:\s*(\+?[\d\s\-().]{8,22})")
|
||||||
|
_YEAR=re.compile(r"^(?:19|20)\d{2}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_phone_text(text:str) -> str:
|
||||||
|
raw=(text or "").translate(_DIGIT_TO_ASCII).translate(_DASH_TO_HYPHEN)
|
||||||
|
raw=raw.replace("\xa0"," ").replace("\u202f"," ").replace("\u2009"," ")
|
||||||
|
raw=raw.replace("\u2007"," ").replace("\u2028","\n").replace("\u2029","\n")
|
||||||
|
for ch in _STRIP_INVISIBLE:
|
||||||
|
raw=raw.replace(ch,"")
|
||||||
|
return _OCR_O.sub("0",raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _digits_only(raw:str) -> str:
|
||||||
|
return re.sub(r"\D","",_normalize_phone_text(raw or ""))
|
||||||
|
|
||||||
|
|
||||||
def _phone_digits(raw:str) -> str:
|
def _phone_digits(raw:str) -> str:
|
||||||
digits=re.sub(r"\D","",raw or "")
|
digits=_digits_only(raw)
|
||||||
if digits.startswith("00"):
|
if digits.startswith("00"):
|
||||||
digits=digits[2:]
|
digits=digits[2:]
|
||||||
return digits
|
return digits
|
||||||
|
|
@ -54,37 +81,90 @@ def _phone_score(digits:str) -> int:
|
||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def _phone_keys(digits:str) -> set[str]:
|
||||||
|
"""03XX / +92 3XX / 3XX national forms of the same PK mobile."""
|
||||||
|
d=_phone_digits(digits) if re.search(r"\D",digits or "") else (digits or "")
|
||||||
|
if d.startswith("00"):
|
||||||
|
d=d[2:]
|
||||||
|
keys={d}
|
||||||
|
if d.startswith("92") and len(d)>=12:
|
||||||
|
rest=d[2:]
|
||||||
|
keys.add(rest)
|
||||||
|
if rest.startswith("3"):
|
||||||
|
keys.add("0"+rest)
|
||||||
|
if d.startswith("0") and len(d)>=11:
|
||||||
|
keys.add(d[1:])
|
||||||
|
keys.add("92"+d[1:])
|
||||||
|
if d.startswith("3") and len(d)==10:
|
||||||
|
keys.add("0"+d)
|
||||||
|
keys.add("92"+d)
|
||||||
|
return {k for k in keys if len(k)>=10}
|
||||||
|
|
||||||
|
|
||||||
|
def phone_in_resume(digits:str,resume_text:str) -> bool:
|
||||||
|
"""True when this number (or its 03 / +92 twin) appears in the CV digits."""
|
||||||
|
haystack=_digits_only(resume_text)
|
||||||
|
if not haystack:
|
||||||
|
return True
|
||||||
|
return any(key in haystack for key in _phone_keys(digits))
|
||||||
|
|
||||||
|
|
||||||
|
def _tidy_raw(raw:str) -> str:
|
||||||
|
compact=re.sub(r"[\n\r]+"," ",raw or "")
|
||||||
|
compact=re.sub(r"[ \t]+"," ",compact)
|
||||||
|
return compact.strip(" \t-./()[]{},:|•·∙_")
|
||||||
|
|
||||||
|
|
||||||
|
def _consider(raw:str,best:str|None,best_score:int) -> tuple[str|None,int]:
|
||||||
|
value=_tidy_raw(raw)
|
||||||
|
score=_phone_score(_phone_digits(value))
|
||||||
|
if score>best_score:
|
||||||
|
return value,score
|
||||||
|
return best,best_score
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_digit_groups(text:str,best:str|None,best_score:int) -> tuple[str|None,int]:
|
||||||
|
groups=list(_DIGIT_GROUP.finditer(text))
|
||||||
|
for i,start_g in enumerate(groups):
|
||||||
|
acc=start_g.group(0)
|
||||||
|
end=start_g.end()
|
||||||
|
best,best_score=_consider(acc,best,best_score)
|
||||||
|
for nxt in groups[i+1:]:
|
||||||
|
gap=text[end:nxt.start()]
|
||||||
|
if not _GAP_OK.match(gap):
|
||||||
|
break
|
||||||
|
nxt_digits=nxt.group(0).lstrip("+")
|
||||||
|
if _YEAR.match(nxt_digits) and len(_phone_digits(acc))>=10:
|
||||||
|
break
|
||||||
|
combined=_phone_digits(acc+nxt.group(0))
|
||||||
|
if len(combined)>15:
|
||||||
|
break
|
||||||
|
acc=text[start_g.start():nxt.end()]
|
||||||
|
end=nxt.end()
|
||||||
|
best,best_score=_consider(acc,best,best_score)
|
||||||
|
return best,best_score
|
||||||
|
|
||||||
|
|
||||||
def scan_phone(text:str) -> str|None:
|
def scan_phone(text:str) -> str|None:
|
||||||
"""Regex scan of CV text — complete numbers only, never a truncated prefix."""
|
"""Scan CV text for a complete phone — unicode separators, wrap, tel/wa.me."""
|
||||||
best=None
|
haystack=_normalize_phone_text(text or "")
|
||||||
best_score=-1
|
best,best_score=None,-1
|
||||||
haystack=text or ""
|
best,best_score=_scan_digit_groups(haystack,best,best_score)
|
||||||
for pattern in (_PK_MOBILE,_PHONE_SPAN):
|
for pattern in (_WA_ME,_TEL_URI):
|
||||||
for match in pattern.finditer(haystack):
|
for match in pattern.finditer(haystack):
|
||||||
raw=re.sub(r"[\n\r]+"," ",match.group(0))
|
best,best_score=_consider(match.group(1),best,best_score)
|
||||||
raw=re.sub(r"[\s\-()]+"," ",raw).strip()
|
|
||||||
score=_phone_score(_phone_digits(raw))
|
|
||||||
if score>best_score:
|
|
||||||
best_score=score
|
|
||||||
best=raw
|
|
||||||
if best_score>=180:
|
|
||||||
return best
|
|
||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
def prefer_full_phone(*candidates) -> str|None:
|
def prefer_full_phone(*candidates) -> str|None:
|
||||||
"""Keep the candidate with the most digits (min 10). Truncated regex loses."""
|
"""Keep the strongest complete number. Truncated / CNIC-shaped values lose."""
|
||||||
best=None
|
best,best_score=None,-1
|
||||||
best_n=-1
|
|
||||||
for raw in candidates:
|
for raw in candidates:
|
||||||
value=(raw or "").strip()
|
value=(raw or "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
continue
|
continue
|
||||||
n=len(_phone_digits(value))
|
best,best_score=_consider(value,best,best_score)
|
||||||
if n>=10 and n>best_n:
|
return best if best_score>=0 else None
|
||||||
best_n=n
|
|
||||||
best=value
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def _as_str(data,key):
|
def _as_str(data,key):
|
||||||
|
|
|
||||||
|
|
@ -7,21 +7,36 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from global_cities import countries_prompt_block
|
||||||
|
|
||||||
NO_COMPANY="no company was mentioned"
|
NO_COMPANY="no company was mentioned"
|
||||||
EDUCATION="No Education Mentioned"
|
EDUCATION="No Education Mentioned"
|
||||||
CURRENT_TITLE="No JOB POSITION MENTIONED"
|
CURRENT_TITLE="No JOB POSITION MENTIONED"
|
||||||
NO_LINKEDIN="no linkedin url mentioned"
|
NO_LINKEDIN="no linkedin url mentioned"
|
||||||
NO_PHONE="no phone number mentioned"
|
NO_PHONE="no phone number mentioned"
|
||||||
|
NO_CITY="no city mentioned"
|
||||||
|
NO_NAME="no name mentioned"
|
||||||
|
|
||||||
|
CITY_POLICY="""- Return ONE proper city name only — the city, not an area, town, sector, housing society, cantonment, district, or parenthetical locality.
|
||||||
|
- Identify the city if possible. Map it to exactly one city name from the country→cities list supplied below. Pakistan is in that list along with every other country — do not prefer one country.
|
||||||
|
- Return the city name only, never the country. If the text names a neighborhood or area of a listed city, return that city: "Karachi(Malir)" / "Karachi Malir" / "DHA Karachi" → "Karachi". "London(Westminster)" → "London". "Gulberg, Lahore" → "Lahore". "F-10 Islamabad" → "Islamabad".
|
||||||
|
- Drop "Cantt" / "Cantonment" and housing-society prefixes: "Lahore Cantt" → "Lahore", "Wah Cantt" → "Wah".
|
||||||
|
- Never concatenate two places. If the string is messy (for example "Karachi(Malir) Wah Cantt"), return the single residence city, not both strings glued together.
|
||||||
|
- Do not return province, country, street, house number, neighborhood, cantonment, or text inside parentheses.
|
||||||
|
- Drop junk tokens, empty values, and unintelligible strings.
|
||||||
|
- If you cannot map the residence to a listed city, still return a single proper city name. If none is stated, use the no-city sentinel."""
|
||||||
|
|
||||||
|
|
||||||
def prompt():
|
def prompt():
|
||||||
return f"""You are an HR-ATS recruiting assistant.
|
return f"""You are an HR-ATS recruiting assistant.
|
||||||
|
|
||||||
You are given CV/resume text. Identify the candidate's CURRENT employer company
|
You are given CV/resume text. Identify the candidate's full name, CURRENT employer company
|
||||||
name, their education (degree / school), their current job title, their
|
name, their education (degree / school), their current job title, their
|
||||||
LinkedIn profile URL, and their phone number when present.
|
LinkedIn profile URL, their phone number, their city of residence, their skills,
|
||||||
|
and their total years of professional experience, when present.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
- Return only the candidate name that appears in the resume header.
|
||||||
- Return only the company name that appears in the resume text for the ongoing / most recent role.
|
- Return only the company name that appears in the resume text for the ongoing / most recent role.
|
||||||
- Return only education that appears in the resume text.
|
- Return only education that appears in the resume text.
|
||||||
- Return only job title that appears in the resume text.
|
- Return only job title that appears in the resume text.
|
||||||
|
|
@ -32,6 +47,24 @@ Rules:
|
||||||
- Do not invent education. If none is mentioned, return exactly: {EDUCATION}
|
- Do not invent education. If none is mentioned, return exactly: {EDUCATION}
|
||||||
- Do not invent job title. If none is mentioned, return exactly: {CURRENT_TITLE}
|
- Do not invent job title. If none is mentioned, return exactly: {CURRENT_TITLE}
|
||||||
|
|
||||||
|
candidate_name (its own key — a string or the no-name sentinel):
|
||||||
|
- The candidate's full name exactly as written on the resume header / contact block.
|
||||||
|
- Do not invent a name from the email local-part, file name, or LinkedIn slug.
|
||||||
|
- If none is stated, return exactly: {NO_NAME}
|
||||||
|
|
||||||
|
skills (its own key — a JSON array of strings):
|
||||||
|
- List the candidate's concrete technical and professional skills: technologies, tools, languages, platforms, and named methodologies.
|
||||||
|
- Write each skill using the resume's own spelling. Every skill you return MUST appear in the resume text.
|
||||||
|
- Do not infer a skill from a job title, an employer, or a degree. "Backend Engineer" is not evidence of "Python".
|
||||||
|
- One skill per entry. Do not return sentences, responsibilities, or soft-skill filler like "team player" or "hard working".
|
||||||
|
- At most 30 entries, most relevant first. If the resume lists none, return an empty array [].
|
||||||
|
|
||||||
|
years_experience (its own key — an integer or null):
|
||||||
|
- If the resume states a total (for example "6 years of experience"), use that stated number.
|
||||||
|
- Otherwise compute whole years only from employment dates explicitly written in the resume.
|
||||||
|
- Never infer it from seniority words, education dates, or the number of jobs listed.
|
||||||
|
- Must be between 0 and 60. If the resume supports neither a stated total nor explicit dates, return null.
|
||||||
|
|
||||||
linkedin_url (its own key — extract this separately from the other fields):
|
linkedin_url (its own key — extract this separately from the other fields):
|
||||||
- Return the candidate's own public LinkedIn profile URL (linkedin.com/in/..., /pub/..., /mwlite/in/..., or lnkd.in/...).
|
- Return the candidate's own public LinkedIn profile URL (linkedin.com/in/..., /pub/..., /mwlite/in/..., or lnkd.in/...).
|
||||||
- Reconstruct the URL if PDF extraction wrapped or spaced it (e.g. "linkedin.com/in/\\njane-doe" or "linkedin . com / in / jane-doe").
|
- Reconstruct the URL if PDF extraction wrapped or spaced it (e.g. "linkedin.com/in/\\njane-doe" or "linkedin . com / in / jane-doe").
|
||||||
|
|
@ -41,24 +74,39 @@ linkedin_url (its own key — extract this separately from the other fields):
|
||||||
- Do not invent a profile. If none is mentioned, return exactly: {NO_LINKEDIN}
|
- Do not invent a profile. If none is mentioned, return exactly: {NO_LINKEDIN}
|
||||||
- Never guess a slug or construct linkedin.com/in/<name> from the candidate's name. The stored value will be null when this sentinel is returned.
|
- Never guess a slug or construct linkedin.com/in/<name> from the candidate's name. The stored value will be null when this sentinel is returned.
|
||||||
|
|
||||||
|
city (its own key — OPTIONAL. A missing city must not fail the candidate):
|
||||||
|
{CITY_POLICY}
|
||||||
|
- Extract city ONLY from the candidate's contact / location / address header (the block with name, phone, email, LinkedIn, "Address", "Location", "based in", "currently living in").
|
||||||
|
- Do NOT extract city from Work Experience. A job that lists Karachi, UAE, USA, or any other city is the employer's location, not proof the candidate lives there.
|
||||||
|
- If the contact/location section does not name a city, return exactly: {NO_CITY}. Leave it blank rather than guessing from jobs, education, or nationality.
|
||||||
|
|
||||||
|
Country → cities (map messy locality to exactly one city from this list; return the city, never the country):
|
||||||
|
{countries_prompt_block()}
|
||||||
|
|
||||||
phone (its own key — extract this separately; copy EVERY digit):
|
phone (its own key — extract this separately; copy EVERY digit):
|
||||||
- Return the candidate's own mobile / phone exactly as written, including country code when present.
|
- Return the candidate's own mobile / phone exactly as written, including country code when present.
|
||||||
- Pakistani mobiles are 11 digits local (03XX-XXXXXXX / 03XX XXXXXXX) or +92 3XX XXXXXXX (12 digits with country code). Copy the last group in full — never stop after 7 or 8 digits.
|
- Pakistani mobiles are 11 digits local (03XX-XXXXXXX / 03XX XXXXXXX) or +92 3XX XXXXXXX (12 digits with country code). Copy the last group in full — never stop after 7 or 8 digits.
|
||||||
- If PDF extraction wrapped the number across lines (e.g. "0321-5551\\n234"), join the groups into one complete number.
|
- If PDF extraction wrapped the number across lines (e.g. "0321-5551\\n234"), join the groups into one complete number.
|
||||||
- Spaces, hyphens, and parentheses are allowed; do not delete trailing digits to "clean" the value.
|
- Spaces, hyphens, parentheses, en-dashes, bullets, and non-breaking spaces are allowed; do not delete trailing digits to "clean" the value.
|
||||||
|
- A Phone / Mobile / Cell / WhatsApp / Tel label may sit on the line above the digits — still copy the number.
|
||||||
|
- 03XX-XXXXXXX and +92 3XX XXXXXXX are the same number; return the form written on the resume.
|
||||||
- Do not invent a number. If none is mentioned, return exactly: {NO_PHONE}
|
- Do not invent a number. If none is mentioned, return exactly: {NO_PHONE}
|
||||||
|
|
||||||
Examples of CORRECT values (copy this completeness; these are format samples, not this candidate):
|
Examples of CORRECT values (copy this completeness; these are format samples, not this candidate):
|
||||||
|
|
||||||
Example 1 — local 11-digit PK mobile, full LinkedIn:
|
Example 1 — local 11-digit PK mobile, full LinkedIn:
|
||||||
Resume: "Ali Khan | 0321-5551234 | https://www.linkedin.com/in/ali-khan | Acme | BS CS | Engineer"
|
Resume: "Ali Khan | Karachi | 0321-5551234 | https://www.linkedin.com/in/ali-khan | Acme | BS CS | Engineer | Skills: Python, Django, PostgreSQL | 6 years of experience"
|
||||||
JSON:
|
JSON:
|
||||||
{{
|
{{
|
||||||
|
"candidate_name": "Ali Khan",
|
||||||
"current_employment": "Acme",
|
"current_employment": "Acme",
|
||||||
"education": "BS CS",
|
"education": "BS CS",
|
||||||
"current_title": "Engineer",
|
"current_title": "Engineer",
|
||||||
"linkedin_url": "https://www.linkedin.com/in/ali-khan",
|
"linkedin_url": "https://www.linkedin.com/in/ali-khan",
|
||||||
"phone": "0321-5551234"
|
"phone": "0321-5551234",
|
||||||
|
"city": "Karachi",
|
||||||
|
"skills": ["Python", "Django", "PostgreSQL"],
|
||||||
|
"years_experience": 6
|
||||||
}}
|
}}
|
||||||
|
|
||||||
Example 2 — +92 with spaces; every digit kept:
|
Example 2 — +92 with spaces; every digit kept:
|
||||||
|
|
@ -77,14 +125,46 @@ Example 5 — wrapped LinkedIn slug:
|
||||||
Resume: "linkedin.com/in/\\njane-doe-123"
|
Resume: "linkedin.com/in/\\njane-doe-123"
|
||||||
JSON linkedin_url must be "https://www.linkedin.com/in/jane-doe-123". Not ".../jane-doe".
|
JSON linkedin_url must be "https://www.linkedin.com/in/jane-doe-123". Not ".../jane-doe".
|
||||||
|
|
||||||
|
Example 6 — no stated total and no dates:
|
||||||
|
Resume: "Senior Architect. Led large teams."
|
||||||
|
JSON years_experience must be null. "Senior" is not a duration.
|
||||||
|
|
||||||
|
Example 7 — dates only:
|
||||||
|
Resume: "Acme, Jan 2018 - Jan 2024, Engineer"
|
||||||
|
JSON years_experience must be 6, and skills must be [] because none are listed.
|
||||||
|
|
||||||
|
Example 8 — work-experience cities are NOT residence:
|
||||||
|
Resume: "Ali Khan | 0321-5551234\\nExperience: Acme, Karachi, 2019-2021; Globex, UAE, 2022-2024; Contoso, USA, 2024-present"
|
||||||
|
JSON city must be exactly: {NO_CITY}. Do not return Karachi, UAE, USA, or any other job-site city.
|
||||||
|
|
||||||
|
Example 9 — contact/location city is residence:
|
||||||
|
Resume: "Ali Khan | Location: Lahore | 0321-5551234\\nExperience: Acme, Karachi, Engineer"
|
||||||
|
JSON city must be "Lahore". Not "Karachi".
|
||||||
|
|
||||||
|
Example 10 — neighborhood / cantonment is not the city:
|
||||||
|
Resume: "Ali Khan | Karachi(Malir) | 0321-5551234"
|
||||||
|
JSON city must be "Karachi". Not "Karachi(Malir)" and not "Malir".
|
||||||
|
|
||||||
|
Example 11 — DHA / sector / cantonment still collapse to the city:
|
||||||
|
Resume: "Address: DHA Karachi" → "Karachi". "Lahore Cantt" → "Lahore". "F-10 Islamabad" → "Islamabad". "Wah Cantt" → "Wah". "London(Westminster)" → "London".
|
||||||
|
|
||||||
|
Example 12 — do not glue two place fragments:
|
||||||
|
Resume: "Address: Karachi(Malir) Wah Cantt"
|
||||||
|
JSON city must be "Karachi" (one city). Not "Karachi(Malir) Wah Cantt" and not "Wah Cantt".
|
||||||
|
|
||||||
Respond with JSON only:
|
Respond with JSON only:
|
||||||
{{
|
{{
|
||||||
|
"candidate_name": "Full Name",
|
||||||
"current_employment": "Company Name",
|
"current_employment": "Company Name",
|
||||||
"education": "Degree / School",
|
"education": "Degree / School",
|
||||||
"current_title": "Job Title",
|
"current_title": "Job Title",
|
||||||
"linkedin_url": "https://www.linkedin.com/in/slug",
|
"linkedin_url": "https://www.linkedin.com/in/slug",
|
||||||
"phone": "+92 300 1234567"
|
"phone": "+92 300 1234567",
|
||||||
|
"city": "Lahore",
|
||||||
|
"skills": ["Skill One", "Skill Two"],
|
||||||
|
"years_experience": 5
|
||||||
}}
|
}}
|
||||||
|
If the contact/location section has no city, city must be "{NO_CITY}" — still return the rest of the JSON. Never omit the candidate because city is blank.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from fastapi import APIRouter,Depends,HTTPException,Query
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
import uuid
|
||||||
|
|
||||||
from db_setup import get_session
|
from db_setup import get_session
|
||||||
from g_sheet.views import (
|
from g_sheet.views import (
|
||||||
|
|
@ -18,6 +19,28 @@ load_dotenv()
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _city_values(city: str | None):
|
||||||
|
if not city or not str(city).strip():
|
||||||
|
return None
|
||||||
|
parts=[p.strip() for p in str(city).split(",") if p.strip()]
|
||||||
|
return parts or None
|
||||||
|
|
||||||
|
|
||||||
|
def _job_ids(value: str | None):
|
||||||
|
if not value or not str(value).strip():
|
||||||
|
return None
|
||||||
|
out=[]
|
||||||
|
for part in str(value).split(","):
|
||||||
|
text=part.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out.append(uuid.UUID(text))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
class AppendRowsBody(BaseModel):
|
class AppendRowsBody(BaseModel):
|
||||||
rows: list[list[str]]
|
rows: list[list[str]]
|
||||||
|
|
||||||
|
|
@ -196,8 +219,15 @@ async def fetch_form_data(
|
||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
processing_state: str | None = Query(None),
|
processing_state: str | None = Query(None),
|
||||||
is_duplicate: bool | None = Query(None),
|
is_duplicate: bool | None = Query(None),
|
||||||
|
has_linkedin: bool | None = Query(None),
|
||||||
|
has_resume: bool | None = Query(None),
|
||||||
|
city: str | None = Query(None),
|
||||||
|
source: str | None = Query(None),
|
||||||
|
assigned: bool | None = Query(None),
|
||||||
|
no_suggestions: bool | None = Query(None),
|
||||||
|
has_suggestions: bool | None = Query(None),
|
||||||
|
job_post_ids: str | None = Query(None),
|
||||||
offset: int = Query(0,ge=0),
|
offset: int = Query(0,ge=0),
|
||||||
# Caller-chosen page size (Inbox sends 10/25/50/100). None = unpaged.
|
|
||||||
limit: int | None = Query(None,ge=1,le=500),
|
limit: int | None = Query(None,ge=1,le=500),
|
||||||
current_user: dict = Depends(_FORM_DATA_READ),
|
current_user: dict = Depends(_FORM_DATA_READ),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
|
@ -207,6 +237,10 @@ async def fetch_form_data(
|
||||||
items,total=await service.get_form_data(
|
items,total=await service.get_form_data(
|
||||||
sheet=sheet,search=search,offset=offset,limit=limit,
|
sheet=sheet,search=search,offset=offset,limit=limit,
|
||||||
processing_state=processing_state,is_duplicate=is_duplicate,
|
processing_state=processing_state,is_duplicate=is_duplicate,
|
||||||
|
has_linkedin=has_linkedin,has_resume=has_resume,
|
||||||
|
city=_city_values(city),source=(source or "").strip() or None,
|
||||||
|
assigned=assigned,no_suggestions=no_suggestions,
|
||||||
|
has_suggestions=has_suggestions,job_post_ids=_job_ids(job_post_ids),
|
||||||
)
|
)
|
||||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -218,12 +252,27 @@ async def fetch_form_data(
|
||||||
@router.get("/sheet/form-data/counts")
|
@router.get("/sheet/form-data/counts")
|
||||||
async def fetch_form_data_counts(
|
async def fetch_form_data_counts(
|
||||||
sheet: str | None = Query(None),
|
sheet: str | None = Query(None),
|
||||||
|
# The badges narrow with the list. Without these the tab counts describe the
|
||||||
|
# whole sheet while the rows beneath them describe a filtered slice.
|
||||||
|
# processing_state and is_duplicate are absent on purpose: those two ARE the
|
||||||
|
# tabs, so passing them would make every badge report the current tab.
|
||||||
|
search: str | None = Query(None),
|
||||||
|
has_linkedin: bool | None = Query(None),
|
||||||
|
has_resume: bool | None = Query(None),
|
||||||
|
city: str | None = Query(None),
|
||||||
|
source: str | None = Query(None),
|
||||||
|
assigned: bool | None = Query(None),
|
||||||
|
job_post_ids: str | None = Query(None),
|
||||||
current_user: dict = Depends(_FORM_DATA_READ),
|
current_user: dict = Depends(_FORM_DATA_READ),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=SheetFormData(session=session)
|
service=SheetFormData(session=session)
|
||||||
data=await service.get_counts(sheet=sheet)
|
data=await service.get_counts(
|
||||||
|
sheet=sheet,search=search,has_linkedin=has_linkedin,has_resume=has_resume,
|
||||||
|
city=_city_values(city),source=(source or "").strip() or None,assigned=assigned,
|
||||||
|
job_post_ids=_job_ids(job_post_ids),
|
||||||
|
)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,334 @@
|
||||||
|
"""Drive CV extract wrapper for sheet ingest.
|
||||||
|
|
||||||
|
Hang `@extract_drive_cvs` on `SheetImport.import_sheet` only (the worker).
|
||||||
|
HTTP enqueue routes must not run this — FormData rows do not exist yet.
|
||||||
|
|
||||||
|
Worker job pattern (same as a Taskiq message): create a temp dir for the run,
|
||||||
|
stream each Drive CV to a file, extract, write extracted_data, delete that file.
|
||||||
|
A finally block removes the job dir so a successful run leaves no CVs on disk.
|
||||||
|
One row at a time — a plain sequential loop, no extra locks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime,timezone
|
||||||
|
from functools import wraps
|
||||||
|
from inspect import signature
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from g_sheet.models import FormData
|
||||||
|
from g_sheet.plugins import (
|
||||||
|
SheetsApiError,
|
||||||
|
drive_file_id,
|
||||||
|
download_drive_file,
|
||||||
|
ensure_fresh,
|
||||||
|
load_credentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
load_dotenv(Path(__file__).resolve().parent.parent/".env")
|
||||||
|
|
||||||
|
logger=logging.getLogger("g_sheet.decorators")
|
||||||
|
|
||||||
|
_MAX_RESUME_CHARS=int(os.getenv("MAX_RESUME_CHARS","60000"))
|
||||||
|
_MAX_PDF_SIZE_MB=int(os.getenv("MAX_PDF_SIZE_MB","10"))
|
||||||
|
_TEMP_ROOT=Path(__file__).resolve().parent/"tmp"/"cv_extract"
|
||||||
|
|
||||||
|
|
||||||
|
def build_extracted_data(
|
||||||
|
*,
|
||||||
|
status,
|
||||||
|
resume_link,
|
||||||
|
file_id=None,
|
||||||
|
filename=None,
|
||||||
|
mime_type=None,
|
||||||
|
text=None,
|
||||||
|
page_count=None,
|
||||||
|
truncated=None,
|
||||||
|
error_code=None,
|
||||||
|
error_message=None,
|
||||||
|
):
|
||||||
|
"""Stable JSON blob stored on form_data.extracted_data."""
|
||||||
|
return {
|
||||||
|
"status":status,
|
||||||
|
"resume_link":resume_link or "",
|
||||||
|
"file_id":file_id,
|
||||||
|
"filename":filename,
|
||||||
|
"mime_type":mime_type,
|
||||||
|
"text":text,
|
||||||
|
"page_count":page_count,
|
||||||
|
"truncated":truncated,
|
||||||
|
"char_count":len(text) if isinstance(text,str) else None,
|
||||||
|
"error_code":error_code,
|
||||||
|
"error_message":error_message,
|
||||||
|
"extracted_at":datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _drive_error_code(status_code):
|
||||||
|
if status_code in (401,403):
|
||||||
|
return "DRIVE_FORBIDDEN"
|
||||||
|
if status_code==404:
|
||||||
|
return "DRIVE_FILE_NOT_FOUND"
|
||||||
|
if status_code==413:
|
||||||
|
return "PAYLOAD_TOO_LARGE"
|
||||||
|
if status_code in (400,415):
|
||||||
|
return "UNSUPPORTED_FILE_TYPE"
|
||||||
|
return "DRIVE_DOWNLOAD_FAILED"
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_drive_credentials(service):
|
||||||
|
"""Load/refresh the Google session. Never raises — None means skip extract."""
|
||||||
|
try:
|
||||||
|
if service is None:
|
||||||
|
return load_credentials()
|
||||||
|
creds=getattr(service,"credentials",None)
|
||||||
|
path=getattr(service,"credentials_path",None)
|
||||||
|
scopes=getattr(service,"scopes",None)
|
||||||
|
if creds is not None:
|
||||||
|
return ensure_fresh(creds,path)
|
||||||
|
return load_credentials(path,scopes)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Google Drive session unavailable; skipping CV extract")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_sheet_service(obj):
|
||||||
|
return obj is not None and hasattr(obj,"session") and hasattr(obj,"spreadsheet_id")
|
||||||
|
|
||||||
|
|
||||||
|
def _tab_from(result,args,kwargs):
|
||||||
|
if isinstance(result,dict) and result.get("tab"):
|
||||||
|
return result.get("tab")
|
||||||
|
if kwargs.get("tab"):
|
||||||
|
return kwargs.get("tab")
|
||||||
|
if args:
|
||||||
|
return args[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _should_ingest(result):
|
||||||
|
if not isinstance(result,dict):
|
||||||
|
return False
|
||||||
|
if result.get("error"):
|
||||||
|
return False
|
||||||
|
if result.get("status") in ("queued","running","failed"):
|
||||||
|
return False
|
||||||
|
if result.get("rows_read",1)==0 and result.get("inserted",1)==0:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def make_job_temp_dir(root=None):
|
||||||
|
"""Temp dir for one extract job. Caller must remove_job_temp_dir in finally."""
|
||||||
|
base=Path(root) if root else _TEMP_ROOT
|
||||||
|
base.mkdir(parents=True,exist_ok=True)
|
||||||
|
job_dir=base/uuid.uuid4().hex
|
||||||
|
job_dir.mkdir()
|
||||||
|
return job_dir
|
||||||
|
|
||||||
|
|
||||||
|
def remove_job_temp_dir(job_dir):
|
||||||
|
"""Delete leftover CVs and the job dir. No-op if missing."""
|
||||||
|
if not job_dir:
|
||||||
|
return
|
||||||
|
path=Path(job_dir)
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
shutil.rmtree(path,ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _unlink(path):
|
||||||
|
if path is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
Path(path).unlink(missing_ok=True)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("could not delete temp CV %s: %s",path,e)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pdf(data,filename,max_chars):
|
||||||
|
from app.services.pdf import extract_resume,sanitize_filename
|
||||||
|
from job.candidate.plugins import normalize_spaced_text
|
||||||
|
|
||||||
|
resume=extract_resume(data,sanitize_filename(filename),max_chars)
|
||||||
|
return {
|
||||||
|
"text":normalize_spaced_text(resume.text),
|
||||||
|
"page_count":resume.page_count,
|
||||||
|
"truncated":resume.truncated,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _download_and_extract(credentials,link,max_chars,max_bytes,dest_dir):
|
||||||
|
"""Stream one Drive file into dest_dir, extract, then delete that file."""
|
||||||
|
file_id=drive_file_id(link)
|
||||||
|
if not file_id:
|
||||||
|
return build_extracted_data(
|
||||||
|
status="skipped",
|
||||||
|
resume_link=link,
|
||||||
|
error_code="NOT_DRIVE_URL",
|
||||||
|
error_message="Resume link is not a Google Drive file URL",
|
||||||
|
)
|
||||||
|
dest=None
|
||||||
|
try:
|
||||||
|
downloaded=download_drive_file(
|
||||||
|
credentials,link,max_bytes=max_bytes,dest_dir=dest_dir,
|
||||||
|
)
|
||||||
|
dest=downloaded.get("path")
|
||||||
|
if dest is None:
|
||||||
|
return build_extracted_data(
|
||||||
|
status="failed",
|
||||||
|
resume_link=link,
|
||||||
|
file_id=downloaded.get("file_id") or file_id,
|
||||||
|
error_code="DRIVE_DOWNLOAD_FAILED",
|
||||||
|
error_message="Drive download did not write a file",
|
||||||
|
)
|
||||||
|
data=Path(dest).read_bytes()
|
||||||
|
try:
|
||||||
|
parsed=_extract_pdf(data,downloaded.get("filename") or "resume.pdf",max_chars)
|
||||||
|
finally:
|
||||||
|
data=b""
|
||||||
|
return build_extracted_data(
|
||||||
|
status="completed",
|
||||||
|
resume_link=link,
|
||||||
|
file_id=downloaded.get("file_id") or file_id,
|
||||||
|
filename=downloaded.get("filename"),
|
||||||
|
mime_type=downloaded.get("mime_type"),
|
||||||
|
text=parsed["text"],
|
||||||
|
page_count=parsed["page_count"],
|
||||||
|
truncated=parsed["truncated"],
|
||||||
|
)
|
||||||
|
except SheetsApiError as e:
|
||||||
|
return build_extracted_data(
|
||||||
|
status="failed",
|
||||||
|
resume_link=link,
|
||||||
|
file_id=file_id,
|
||||||
|
error_code=_drive_error_code(e.status_code),
|
||||||
|
error_message=(e.message or "")[:300],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
from app.core.errors import ATSError
|
||||||
|
if isinstance(e,ATSError):
|
||||||
|
return build_extracted_data(
|
||||||
|
status="failed",
|
||||||
|
resume_link=link,
|
||||||
|
file_id=file_id,
|
||||||
|
error_code=e.error_code,
|
||||||
|
error_message=e.public_message,
|
||||||
|
)
|
||||||
|
logger.exception("drive download/extract failed for file_id=%s",file_id)
|
||||||
|
return build_extracted_data(
|
||||||
|
status="failed",
|
||||||
|
resume_link=link,
|
||||||
|
file_id=file_id,
|
||||||
|
error_code="DRIVE_DOWNLOAD_FAILED",
|
||||||
|
error_message="Drive download failed",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_unlink(dest)
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_one_resume(credentials,resume_link,dest_dir,max_chars=None,max_bytes=None):
|
||||||
|
"""Download one Drive URL into dest_dir and return extracted_data JSON."""
|
||||||
|
if max_chars is None:
|
||||||
|
max_chars=_MAX_RESUME_CHARS
|
||||||
|
if max_bytes is None:
|
||||||
|
max_bytes=_MAX_PDF_SIZE_MB*1024*1024
|
||||||
|
link=(resume_link or "").strip()
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_download_and_extract,credentials,link,max_chars,max_bytes,dest_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_form_resume_links(session,sheet,credentials,temp_root=None):
|
||||||
|
"""One Drive file per resume_link: download → extract → DB → delete file.
|
||||||
|
|
||||||
|
The job temp dir is created at start and removed in finally so a finished
|
||||||
|
run leaves no CVs on disk (Taskiq worker cleanup).
|
||||||
|
"""
|
||||||
|
rows=await FormData.fetch_resume_links(session,sheet)
|
||||||
|
completed=0
|
||||||
|
failed=0
|
||||||
|
max_chars=_MAX_RESUME_CHARS
|
||||||
|
max_bytes=_MAX_PDF_SIZE_MB*1024*1024
|
||||||
|
job_dir=make_job_temp_dir(temp_root)
|
||||||
|
logger.info(
|
||||||
|
"drive CV extract starting tab=%s resumes=%s temp=%s",
|
||||||
|
sheet,len(rows),job_dir,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
for record_id,resume_link in rows:
|
||||||
|
payload=await extract_one_resume(
|
||||||
|
credentials,resume_link,job_dir,max_chars,max_bytes,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
saved=await FormData.set_extracted_data(session,record_id,payload)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("could not persist extracted_data for %s",record_id)
|
||||||
|
failed+=1
|
||||||
|
try:
|
||||||
|
await session.rollback()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("rollback after extracted_data persist failed")
|
||||||
|
continue
|
||||||
|
status=payload.get("status")
|
||||||
|
if status=="completed":
|
||||||
|
completed+=1
|
||||||
|
if saved is not None:
|
||||||
|
from g_sheet.scoring import enqueue_form_row_scores
|
||||||
|
await enqueue_form_row_scores(saved)
|
||||||
|
elif status=="failed":
|
||||||
|
failed+=1
|
||||||
|
logger.info(
|
||||||
|
"drive CV extract finished tab=%s extracted=%s failed=%s",
|
||||||
|
sheet,completed,failed,
|
||||||
|
)
|
||||||
|
return {"extracted":completed,"extract_failed":failed}
|
||||||
|
finally:
|
||||||
|
remove_job_temp_dir(job_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_drive_cvs(func):
|
||||||
|
"""Hang on SheetImport.import_sheet (worker ingest), not on HTTP enqueue.
|
||||||
|
|
||||||
|
After rows are inserted: one Drive download + extract per resume_link,
|
||||||
|
written to form_data.extracted_data at the end of each row.
|
||||||
|
Credentials load only if ingest will actually run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args,**kwargs):
|
||||||
|
result=await func(*args,**kwargs)
|
||||||
|
service=args[0] if args and _is_sheet_service(args[0]) else None
|
||||||
|
session=getattr(service,"session",None) if service is not None else None
|
||||||
|
rest=args[1:] if service is not None else args
|
||||||
|
tab=_tab_from(result,rest,kwargs)
|
||||||
|
if session is None or not tab or not _should_ingest(result):
|
||||||
|
logger.info(
|
||||||
|
"drive CV extract skipped tab=%s session=%s ingest=%s",
|
||||||
|
tab,session is not None,
|
||||||
|
_should_ingest(result) if isinstance(result,dict) else False,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
credentials=await asyncio.to_thread(_prepare_drive_credentials,service)
|
||||||
|
if credentials is None:
|
||||||
|
logger.warning("Google Drive session unavailable; skipping CV extract")
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
stats=await ingest_form_resume_links(session,tab,credentials)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("drive CV extract after import of %s failed",tab)
|
||||||
|
stats={"extracted":0,"extract_failed":0}
|
||||||
|
if isinstance(result,dict):
|
||||||
|
result["extracted"]=stats.get("extracted",0)
|
||||||
|
result["extract_failed"]=stats.get("extract_failed",0)
|
||||||
|
return result
|
||||||
|
|
||||||
|
wrapper.__signature__=signature(func)
|
||||||
|
return wrapper
|
||||||
|
|
@ -171,6 +171,8 @@ class FormDataColumn(str, Enum):
|
||||||
ID = "id"
|
ID = "id"
|
||||||
SHEET = "sheet"
|
SHEET = "sheet"
|
||||||
JOB_POST_ID = "job_post_id"
|
JOB_POST_ID = "job_post_id"
|
||||||
|
ASSIGNED_JOB_POST_ID = "assigned_job_post_id"
|
||||||
|
SUGGESTED_JOB_POST_IDS = "suggested_job_post_ids"
|
||||||
MANUAL_UPLOAD_CANDIDATE_ID = "manual_upload_candidate_id"
|
MANUAL_UPLOAD_CANDIDATE_ID = "manual_upload_candidate_id"
|
||||||
ROW_NUMBER = "row_number"
|
ROW_NUMBER = "row_number"
|
||||||
SERIAL_NO = "serial_no"
|
SERIAL_NO = "serial_no"
|
||||||
|
|
@ -189,6 +191,7 @@ class FormDataColumn(str, Enum):
|
||||||
CANDIDATE_EMAIL = "candidate_email"
|
CANDIDATE_EMAIL = "candidate_email"
|
||||||
PROFILE_LINK = "profile_link"
|
PROFILE_LINK = "profile_link"
|
||||||
RESUME_LINK = "resume_link"
|
RESUME_LINK = "resume_link"
|
||||||
|
EXTRACTED_DATA = "extracted_data"
|
||||||
AREA_OF_EXPERTISE = "area_of_expertise"
|
AREA_OF_EXPERTISE = "area_of_expertise"
|
||||||
REQUISITION_NUMBER = "requisition_number"
|
REQUISITION_NUMBER = "requisition_number"
|
||||||
POSITION_APPLIED_FOR = "position_applied_for"
|
POSITION_APPLIED_FOR = "position_applied_for"
|
||||||
|
|
@ -203,6 +206,8 @@ class FormDataColumn(str, Enum):
|
||||||
EXPERIENCE_DETAILS = "experience_details"
|
EXPERIENCE_DETAILS = "experience_details"
|
||||||
AREA_OF_RESIDENCE = "area_of_residence"
|
AREA_OF_RESIDENCE = "area_of_residence"
|
||||||
RESIDING_CITY = "residing_city"
|
RESIDING_CITY = "residing_city"
|
||||||
|
CITY = "city"
|
||||||
|
PROFESSIONAL_SUMMARY = "professional_summary"
|
||||||
RESIDING_COUNTRY = "residing_country"
|
RESIDING_COUNTRY = "residing_country"
|
||||||
COMMUNICATION_SKILLS = "communication_skills"
|
COMMUNICATION_SKILLS = "communication_skills"
|
||||||
PREFERRED_TIMINGS = "preferred_timings"
|
PREFERRED_TIMINGS = "preferred_timings"
|
||||||
|
|
@ -220,6 +225,7 @@ class FormDataColumn(str, Enum):
|
||||||
# Same vocabulary as inbox_messages — Import / Shortlist / Reject / Duplicate.
|
# Same vocabulary as inbox_messages — Import / Shortlist / Reject / Duplicate.
|
||||||
PROCESSING_STATE = "processing_state"
|
PROCESSING_STATE = "processing_state"
|
||||||
IS_DUPLICATE = "is_duplicate"
|
IS_DUPLICATE = "is_duplicate"
|
||||||
|
REAPPLIED = "reapplied"
|
||||||
RAW_RECORD = "raw_record"
|
RAW_RECORD = "raw_record"
|
||||||
IMPORTED_AT = "imported_at"
|
IMPORTED_AT = "imported_at"
|
||||||
CREATED_AT = "created_at"
|
CREATED_AT = "created_at"
|
||||||
|
|
@ -233,9 +239,10 @@ FORM_DATA_FIELDS: tuple[str, ...] = tuple(member.value for member in FormDataCol
|
||||||
# -- Date parsing ------------------------------------------------------------
|
# -- Date parsing ------------------------------------------------------------
|
||||||
|
|
||||||
class DateFormat(str, Enum):
|
class DateFormat(str, Enum):
|
||||||
"""strptime patterns tried in definition order.
|
"""strptime patterns tried in definition order after numeric slash dates.
|
||||||
|
|
||||||
DD/MM before MM/DD: 14/10/20 is ambiguous and DD/MM is the local convention.
|
Numeric D/M vs M/D is resolved in parse_date (8/28 → Aug 28, 28/8 → 28 Aug,
|
||||||
|
8/12 follows prefer_mdy). These patterns cover named months and ISO.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
D_MON_Y_DASH = "%d-%b-%Y"
|
D_MON_Y_DASH = "%d-%b-%Y"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime, Index, case, delete, func, insert, or_
|
from sqlalchemy import Column, DateTime, Index, and_, case, delete, false, func, insert, or_, update
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import Field, SQLModel, select
|
from sqlmodel import Field, SQLModel, select
|
||||||
|
|
@ -17,6 +17,17 @@ def _now() -> datetime:
|
||||||
|
|
||||||
_BULK_CHUNK = 1000
|
_BULK_CHUNK = 1000
|
||||||
|
|
||||||
|
# profile_link holds whatever the candidate typed into the form's "LinkedIn
|
||||||
|
# Profile Link" box. Nothing on the ingest path validates it — the real LinkedIn
|
||||||
|
# parsing runs only when a row is promoted, and writes to a different table — so
|
||||||
|
# matching on these is a heuristic, not proof of a profile. It misses a bare
|
||||||
|
# handle and it accepts a malformed URL that merely contains the domain.
|
||||||
|
#
|
||||||
|
# Module level, not a class attribute: SQLModel hands any leading-underscore
|
||||||
|
# class attribute to Pydantic, which turns it into a ModelPrivateAttr that is not
|
||||||
|
# iterable at class scope.
|
||||||
|
LINKEDIN_PATTERNS = ("%linkedin.com%", "%lnkd.in%")
|
||||||
|
|
||||||
|
|
||||||
class FormData(SQLModel, table=True):
|
class FormData(SQLModel, table=True):
|
||||||
"""One spreadsheet data row. raw_record keeps the full original header→value map."""
|
"""One spreadsheet data row. raw_record keeps the full original header→value map."""
|
||||||
|
|
@ -28,9 +39,15 @@ class FormData(SQLModel, table=True):
|
||||||
|
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
sheet: str = Field(nullable=False, index=True)
|
sheet: str = Field(nullable=False, index=True)
|
||||||
# Optional link to a job post. DB FK only — no ORM Relationship (avoids
|
# Recruiter-assigned job. DB FK only — no ORM Relationship (avoids
|
||||||
# pulling job_posts into the sheet worker metadata graph).
|
# pulling job_posts into the sheet worker metadata graph).
|
||||||
job_post_id: uuid.UUID | None = Field(default=None, index=True)
|
job_post_id: uuid.UUID | None = Field(default=None, index=True)
|
||||||
|
assigned_job_post_id: uuid.UUID | None = Field(default=None, index=True)
|
||||||
|
# ILIKE title matches from Position Applied For. One form row → many jobs.
|
||||||
|
# Suggested, not assigned. ATS scores each id separately.
|
||||||
|
suggested_job_post_ids: list[str] | None = Field(
|
||||||
|
default=None, sa_column=Column(JSONB),
|
||||||
|
)
|
||||||
# Set when this form row is promoted into the hiring pipeline (Users +
|
# Set when this form row is promoted into the hiring pipeline (Users +
|
||||||
# manual_upload_candidate). Idempotency key for assign / shortlist.
|
# manual_upload_candidate). Idempotency key for assign / shortlist.
|
||||||
manual_upload_candidate_id: uuid.UUID | None = Field(default=None, index=True)
|
manual_upload_candidate_id: uuid.UUID | None = Field(default=None, index=True)
|
||||||
|
|
@ -51,6 +68,8 @@ class FormData(SQLModel, table=True):
|
||||||
candidate_email: str | None = Field(default=None, index=True)
|
candidate_email: str | None = Field(default=None, index=True)
|
||||||
profile_link: str | None = Field(default=None)
|
profile_link: str | None = Field(default=None)
|
||||||
resume_link: str | None = Field(default=None)
|
resume_link: str | None = Field(default=None)
|
||||||
|
# Drive CV extract JSON written by @extract_drive_cvs after sheet ingest.
|
||||||
|
extracted_data: dict | None = Field(default=None, sa_column=Column(JSONB))
|
||||||
area_of_expertise: str | None = Field(default=None)
|
area_of_expertise: str | None = Field(default=None)
|
||||||
requisition_number: str | None = Field(default=None, index=True)
|
requisition_number: str | None = Field(default=None, index=True)
|
||||||
position_applied_for: str | None = Field(default=None)
|
position_applied_for: str | None = Field(default=None)
|
||||||
|
|
@ -66,6 +85,9 @@ class FormData(SQLModel, table=True):
|
||||||
area_of_residence: str | None = Field(default=None)
|
area_of_residence: str | None = Field(default=None)
|
||||||
residing_city: str | None = Field(default=None)
|
residing_city: str | None = Field(default=None)
|
||||||
residing_country: str | None = Field(default=None)
|
residing_country: str | None = Field(default=None)
|
||||||
|
city: str | None = Field(default=None)
|
||||||
|
professional_summary: str | None = Field(default=None)
|
||||||
|
reapplied: list[str] = Field(default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"})
|
||||||
communication_skills: int | None = Field(default=None)
|
communication_skills: int | None = Field(default=None)
|
||||||
preferred_timings: str | None = Field(default=None)
|
preferred_timings: str | None = Field(default=None)
|
||||||
ho_availability: str | None = Field(default=None)
|
ho_availability: str | None = Field(default=None)
|
||||||
|
|
@ -91,14 +113,199 @@ class FormData(SQLModel, table=True):
|
||||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _filters(cls, *, sheet=None, search=None, processing_state=None, is_duplicate=None):
|
def _no_suggested_jobs(cls):
|
||||||
|
"""True when suggested_job_post_ids is missing, not an array, or [].
|
||||||
|
|
||||||
|
jsonb_array_length() raises on scalar JSONB. CASE evaluates WHEN arms
|
||||||
|
in order, so length is only read after jsonb_typeof confirms an array.
|
||||||
|
"""
|
||||||
|
typeof = func.jsonb_typeof(cls.suggested_job_post_ids)
|
||||||
|
return case(
|
||||||
|
(cls.suggested_job_post_ids.is_(None), True),
|
||||||
|
(typeof != "array", True),
|
||||||
|
(func.jsonb_array_length(cls.suggested_job_post_ids) == 0, True),
|
||||||
|
else_=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _suggested_contains_any(cls, job_post_ids):
|
||||||
|
ids = [str(jid) for jid in (job_post_ids or []) if jid]
|
||||||
|
if not ids:
|
||||||
|
return false()
|
||||||
|
return or_(*(cls.suggested_job_post_ids.contains([sid]) for sid in ids))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _has_job_link(cls):
|
||||||
|
return or_(
|
||||||
|
cls.assigned_job_post_id.is_not(None),
|
||||||
|
cls.job_post_id.is_not(None),
|
||||||
|
~cls._no_suggested_jobs(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _matches_any_job(cls, job_post_ids):
|
||||||
|
ids = list(job_post_ids or [])
|
||||||
|
if not ids:
|
||||||
|
return false()
|
||||||
|
return or_(
|
||||||
|
cls.assigned_job_post_id.in_(ids),
|
||||||
|
cls.job_post_id.in_(ids),
|
||||||
|
cls._suggested_contains_any(ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _reapplicant_ids(cls):
|
||||||
|
"""Form rows from emails that have applied more than once.
|
||||||
|
|
||||||
|
Duplicates tab lists flagged duplicates AND every form row from a
|
||||||
|
repeat email, not only the latest.
|
||||||
|
"""
|
||||||
|
ranked = (
|
||||||
|
select(
|
||||||
|
cls.id,
|
||||||
|
cls.reapplied,
|
||||||
|
func.count().over(
|
||||||
|
partition_by=func.lower(func.coalesce(cls.candidate_email, "")),
|
||||||
|
).label("cnt"),
|
||||||
|
)
|
||||||
|
.where(func.coalesce(cls.candidate_email, "") != "")
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
reapplied_n = func.coalesce(func.jsonb_array_length(ranked.c.reapplied), 0)
|
||||||
|
return select(ranked.c.id).where(or_(ranked.c.cnt > 1, reapplied_n > 0))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _duplicates_tab_filter(cls):
|
||||||
|
return or_(cls.is_duplicate == True, cls.id.in_(cls._reapplicant_ids())) # noqa: E712
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _talent_pool_filters(cls, *, search=None, job_post_ids=None, assignment=None):
|
||||||
|
"""Same WHERE as list_for_talent_pool / count_for_talent_pool."""
|
||||||
|
filters = [cls.manual_upload_candidate_id.is_(None)]
|
||||||
|
if assignment == "assigned":
|
||||||
|
filters.append(or_(cls.assigned_job_post_id.is_not(None), cls.job_post_id.is_not(None)))
|
||||||
|
if job_post_ids is not None:
|
||||||
|
filters.append(or_(
|
||||||
|
cls.assigned_job_post_id.in_(list(job_post_ids)),
|
||||||
|
cls.job_post_id.in_(list(job_post_ids)),
|
||||||
|
))
|
||||||
|
elif assignment == "unassigned":
|
||||||
|
filters.append(cls.assigned_job_post_id.is_(None))
|
||||||
|
filters.append(cls.job_post_id.is_(None))
|
||||||
|
filters.append(~cls._no_suggested_jobs())
|
||||||
|
if job_post_ids is not None:
|
||||||
|
filters.append(cls._suggested_contains_any(list(job_post_ids)))
|
||||||
|
elif job_post_ids is not None:
|
||||||
|
filters.append(cls._matches_any_job(list(job_post_ids)))
|
||||||
|
else:
|
||||||
|
filters.append(cls._has_job_link())
|
||||||
|
if search:
|
||||||
|
like = f"%{search.strip()}%"
|
||||||
|
filters.append(or_(cls.name.ilike(like), cls.candidate_email.ilike(like)))
|
||||||
|
return filters
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None, assignment=None):
|
||||||
|
"""Candidates list: unpromoted form rows with assigned or suggested jobs."""
|
||||||
|
if job_post_ids is not None and not list(job_post_ids):
|
||||||
|
return []
|
||||||
|
qry = (
|
||||||
|
select(cls)
|
||||||
|
.where(*cls._talent_pool_filters(search=search, job_post_ids=job_post_ids, assignment=assignment))
|
||||||
|
.order_by(cls.created_at.desc(), cls.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
result = await session.execute(qry)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def count_for_talent_pool(cls, session: AsyncSession, *, search=None, job_post_ids=None, assignment=None):
|
||||||
|
if job_post_ids is not None and not list(job_post_ids):
|
||||||
|
return 0
|
||||||
|
qry = select(func.count()).select_from(cls).where(
|
||||||
|
*cls._talent_pool_filters(search=search, job_post_ids=job_post_ids, assignment=assignment)
|
||||||
|
)
|
||||||
|
result = await session.execute(qry)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cities_match(column, cities):
|
||||||
|
"""Agent city name vs stored raw text: Karachi matches Karachi(Malir)."""
|
||||||
|
clauses = []
|
||||||
|
for city in cities or []:
|
||||||
|
text = (city or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
safe = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
clauses.append(column.ilike(f"%{safe}%", escape="\\"))
|
||||||
|
return or_(*clauses) if clauses else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _filters(
|
||||||
|
cls, *, sheet=None, search=None, processing_state=None, is_duplicate=None,
|
||||||
|
has_linkedin=None, has_resume=None, city=None, source=None, assigned=None,
|
||||||
|
no_suggestions=None, inbox_filter=None, has_suggestions=None, job_post_ids=None,
|
||||||
|
):
|
||||||
filters = []
|
filters = []
|
||||||
if sheet:
|
if sheet:
|
||||||
filters.append(cls.sheet == sheet)
|
filters.append(cls.sheet == sheet)
|
||||||
if processing_state:
|
if processing_state:
|
||||||
filters.append(cls.processing_state == processing_state)
|
filters.append(cls.processing_state == processing_state)
|
||||||
if is_duplicate is not None:
|
if is_duplicate is not None:
|
||||||
filters.append(cls.is_duplicate == bool(is_duplicate))
|
if is_duplicate:
|
||||||
|
filters.append(cls._duplicates_tab_filter())
|
||||||
|
else:
|
||||||
|
filters.append(cls.is_duplicate == bool(is_duplicate))
|
||||||
|
if has_linkedin is not None:
|
||||||
|
matches = [cls.profile_link.ilike(p) for p in LINKEDIN_PATTERNS]
|
||||||
|
if has_linkedin:
|
||||||
|
filters.append(or_(*matches))
|
||||||
|
else:
|
||||||
|
# The NULL arm is load-bearing. `NOT (NULL ILIKE ...)` evaluates to
|
||||||
|
# NULL, which WHERE discards, so without it the rows with no link
|
||||||
|
# at all would drop out of the "no LinkedIn" view — precisely the
|
||||||
|
# rows that view exists to find.
|
||||||
|
filters.append(or_(
|
||||||
|
cls.profile_link.is_(None),
|
||||||
|
and_(*[~m for m in matches]),
|
||||||
|
))
|
||||||
|
if has_resume is not None:
|
||||||
|
# _cell() stores a blank sheet cell as NULL, never "", so a NULL test
|
||||||
|
# is the whole check and an empty-string arm would be dead weight.
|
||||||
|
filters.append(
|
||||||
|
cls.resume_link.is_not(None) if has_resume else cls.resume_link.is_(None)
|
||||||
|
)
|
||||||
|
cities = [c.strip() for c in (city or []) if (c or "").strip()]
|
||||||
|
if cities:
|
||||||
|
clause = cls._cities_match(func.coalesce(cls.city, cls.residing_city), cities)
|
||||||
|
if clause is not None:
|
||||||
|
filters.append(clause)
|
||||||
|
if source:
|
||||||
|
text = source.strip()
|
||||||
|
lowered = text.lower()
|
||||||
|
if lowered not in ("google sheet", "google_sheet", "sheet"):
|
||||||
|
filters.append(cls.source_of_application.ilike(f"%{text}%"))
|
||||||
|
if assigned is True:
|
||||||
|
filters.append(or_(cls.assigned_job_post_id.is_not(None), cls.job_post_id.is_not(None)))
|
||||||
|
elif assigned is False:
|
||||||
|
filters.append(cls.assigned_job_post_id.is_(None))
|
||||||
|
filters.append(cls.job_post_id.is_(None))
|
||||||
|
if no_suggestions is True:
|
||||||
|
filters.append(cls._no_suggested_jobs())
|
||||||
|
elif has_suggestions is True:
|
||||||
|
filters.append(~cls._no_suggested_jobs())
|
||||||
|
if job_post_ids:
|
||||||
|
filters.append(cls._matches_any_job(list(job_post_ids)))
|
||||||
|
if inbox_filter == "matched":
|
||||||
|
filters.append(or_(cls.assigned_job_post_id.is_not(None), cls.job_post_id.is_not(None)))
|
||||||
|
elif inbox_filter == "unassigned":
|
||||||
|
filters.append(cls.assigned_job_post_id.is_(None))
|
||||||
|
filters.append(cls.job_post_id.is_(None))
|
||||||
|
elif inbox_filter == "rejected":
|
||||||
|
filters.append(cls.processing_state == "rejected")
|
||||||
|
elif inbox_filter == "duplicate":
|
||||||
|
filters.append(cls.is_duplicate == True) # noqa: E712
|
||||||
if search:
|
if search:
|
||||||
# Twelve unanchored ILIKEs over ~26k rows is a sequential scan of a few
|
# Twelve unanchored ILIKEs over ~26k rows is a sequential scan of a few
|
||||||
# tens of ms — acceptable at this size; a pg_trgm GIN index is the
|
# tens of ms — acceptable at this size; a pg_trgm GIN index is the
|
||||||
|
|
@ -119,6 +326,7 @@ class FormData(SQLModel, table=True):
|
||||||
cls.source_of_application.ilike(pattern),
|
cls.source_of_application.ilike(pattern),
|
||||||
cls.cnic.ilike(pattern),
|
cls.cnic.ilike(pattern),
|
||||||
cls.residing_city.ilike(pattern),
|
cls.residing_city.ilike(pattern),
|
||||||
|
cls.city.ilike(pattern),
|
||||||
))
|
))
|
||||||
return filters
|
return filters
|
||||||
|
|
||||||
|
|
@ -131,19 +339,76 @@ class FormData(SQLModel, table=True):
|
||||||
result = await session.execute(select(cls).where(cls.id == rid))
|
result = await session.execute(select(cls).where(cls.id == rid))
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def set_professional_summary(cls, session: AsyncSession, record_id, summary):
|
||||||
|
row = await cls.get_form_data_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.professional_summary = (summary or "").strip() or None
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_with_job(cls, session: AsyncSession, record_id, job_post_id):
|
||||||
|
"""Form row + one job it may be scored against (suggested or assigned)."""
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
form = await cls.get_form_data_by_id(session, record_id)
|
||||||
|
if form is None:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
jid = uuid.UUID(str(job_post_id))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, None
|
||||||
|
if jid not in set(cls.score_job_ids(form)):
|
||||||
|
return None, None
|
||||||
|
job = await JobPosts.get_job_post_by_id(session, jid)
|
||||||
|
if job is None or job.is_deleted:
|
||||||
|
return None, None
|
||||||
|
return form, job
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def score_job_ids(row) -> list[uuid.UUID]:
|
||||||
|
"""Jobs ATS may score: assigned only, else every suggested id."""
|
||||||
|
if row is None:
|
||||||
|
return []
|
||||||
|
getter = row.get if isinstance(row, dict) else lambda key, default=None: getattr(row, key, default)
|
||||||
|
assigned = getter("assigned_job_post_id") or getter("job_post_id")
|
||||||
|
if assigned not in (None, ""):
|
||||||
|
try:
|
||||||
|
return [uuid.UUID(str(assigned))]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return []
|
||||||
|
out: list[uuid.UUID] = []
|
||||||
|
seen: set[uuid.UUID] = set()
|
||||||
|
for raw in getter("suggested_job_post_ids") or []:
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(str(raw))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if uid not in seen:
|
||||||
|
seen.add(uid)
|
||||||
|
out.append(uid)
|
||||||
|
return out
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def set_job_post(cls, session: AsyncSession, record_id, job_post_id):
|
async def set_job_post(cls, session: AsyncSession, record_id, job_post_id):
|
||||||
"""Set or clear job_post_id; returns the row or None if missing."""
|
"""Set or clear the recruiter assignment; returns the row or None if missing."""
|
||||||
row = await cls.get_form_data_by_id(session, record_id)
|
row = await cls.get_form_data_by_id(session, record_id)
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
if job_post_id is None:
|
if job_post_id is None:
|
||||||
row.job_post_id = None
|
row.job_post_id = None
|
||||||
|
row.assigned_job_post_id = None
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
row.job_post_id = uuid.UUID(str(job_post_id))
|
uid = uuid.UUID(str(job_post_id))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
row.job_post_id = uid
|
||||||
|
row.assigned_job_post_id = uid
|
||||||
row.updated_at = _now()
|
row.updated_at = _now()
|
||||||
session.add(row)
|
session.add(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -190,52 +455,263 @@ class FormData(SQLModel, table=True):
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def set_extracted_data(
|
||||||
|
cls, session: AsyncSession, record_id, extracted_data, *, commit: bool = True,
|
||||||
|
):
|
||||||
|
row = await cls.get_form_data_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.extracted_data = extracted_data
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
if commit:
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def fetch_resume_links(cls, session: AsyncSession, sheet: str):
|
||||||
|
"""(id, resume_link) for one tab. Blank links are dropped."""
|
||||||
|
statement = (
|
||||||
|
select(cls.id, cls.resume_link)
|
||||||
|
.where(cls.sheet == sheet)
|
||||||
|
.where(cls.resume_link.is_not(None))
|
||||||
|
.order_by(cls.row_number)
|
||||||
|
)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
rows = []
|
||||||
|
for record_id, link in result.all():
|
||||||
|
text = (link or "").strip()
|
||||||
|
if text:
|
||||||
|
rows.append((record_id, text))
|
||||||
|
return rows
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def fetch_form_data(
|
async def fetch_form_data(
|
||||||
cls, session: AsyncSession, *, sheet=None, search=None,
|
cls, session: AsyncSession, *, sheet=None, search=None,
|
||||||
processing_state=None, is_duplicate=None, offset=0, limit=None,
|
processing_state=None, is_duplicate=None, has_linkedin=None,
|
||||||
|
has_resume=None, city=None, source=None, assigned=None,
|
||||||
|
no_suggestions=None, inbox_filter=None, has_suggestions=None, job_post_ids=None,
|
||||||
|
offset=0, limit=None,
|
||||||
):
|
):
|
||||||
statement = select(cls).order_by(cls.sheet, cls.row_number)
|
statement = select(cls).order_by(cls.created_at.desc(), cls.id.desc())
|
||||||
for clause in cls._filters(
|
for clause in cls._filters(
|
||||||
sheet=sheet, search=search,
|
sheet=sheet, search=search,
|
||||||
processing_state=processing_state, is_duplicate=is_duplicate,
|
processing_state=processing_state, is_duplicate=is_duplicate,
|
||||||
|
has_linkedin=has_linkedin, has_resume=has_resume,
|
||||||
|
city=city, source=source, assigned=assigned,
|
||||||
|
no_suggestions=no_suggestions, inbox_filter=inbox_filter,
|
||||||
|
has_suggestions=has_suggestions, job_post_ids=job_post_ids,
|
||||||
):
|
):
|
||||||
statement = statement.where(clause)
|
statement = statement.where(clause)
|
||||||
if offset:
|
if offset:
|
||||||
statement = statement.offset(offset)
|
statement = statement.offset(offset)
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
statement = statement.limit(limit)
|
statement = statement.limit(limit)
|
||||||
statement = statement.order_by(cls.row_number)
|
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_on_hold_scan_rows(cls, session: AsyncSession, sheet=None):
|
||||||
|
"""On-Hold Sheet Forms: id + email. Entire catalogue, optional sheet tab."""
|
||||||
|
statement = select(cls.id, cls.candidate_email, cls.professional_summary)
|
||||||
|
for clause in cls._filters(sheet=sheet, no_suggestions=True):
|
||||||
|
statement = statement.where(clause)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
rows = []
|
||||||
|
for record_id, email, summary in result.all():
|
||||||
|
rows.append({
|
||||||
|
"id": record_id,
|
||||||
|
"email": (email or "").strip().lower() or None,
|
||||||
|
"professional_summary": (summary or "").strip() or None,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_by_emails(cls, session: AsyncSession, emails):
|
||||||
|
"""Sheet applicants for these addresses. Promoted rows are omitted —
|
||||||
|
those already live on manual_upload_candidate."""
|
||||||
|
from g_sheet.plugins import form_applied_at_iso
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||||
|
if not lowers:
|
||||||
|
return []
|
||||||
|
assigned = func.coalesce(cls.assigned_job_post_id, cls.job_post_id)
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls, JobPosts.title)
|
||||||
|
.outerjoin(JobPosts, assigned == JobPosts.id)
|
||||||
|
.where(func.lower(cls.candidate_email).in_(lowers))
|
||||||
|
.where(cls.manual_upload_candidate_id.is_(None))
|
||||||
|
.order_by(cls.created_at.desc())
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
for rec, title in result.all():
|
||||||
|
job_id = rec.assigned_job_post_id or rec.job_post_id
|
||||||
|
rows.append({
|
||||||
|
"source": "form",
|
||||||
|
"email": (rec.candidate_email or "").strip().lower() or None,
|
||||||
|
"inbox_id": None,
|
||||||
|
"message_id": None,
|
||||||
|
"manual_upload_candidate_id": None,
|
||||||
|
"form_data_id": str(rec.id),
|
||||||
|
"candidate_id": None,
|
||||||
|
"job_post_id": str(job_id) if job_id else None,
|
||||||
|
"job_title": title or rec.position_applied_for or None,
|
||||||
|
"status": rec.processing_state or None,
|
||||||
|
"applied_at": form_applied_at_iso(rec),
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_for_offer_picker(cls, session: AsyncSession, *, job_post_ids=None, search=None):
|
||||||
|
"""Unpromoted assigned sheet applicants for the offer dropdown."""
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
assigned = func.coalesce(cls.assigned_job_post_id, cls.job_post_id)
|
||||||
|
qry = (
|
||||||
|
select(cls, JobPosts.title)
|
||||||
|
.outerjoin(JobPosts, assigned == JobPosts.id)
|
||||||
|
.where(assigned.is_not(None))
|
||||||
|
.where(cls.manual_upload_candidate_id.is_(None))
|
||||||
|
.where(cls.is_duplicate == False) # noqa: E712
|
||||||
|
.where(cls.processing_state != "rejected")
|
||||||
|
.order_by(cls.created_at.desc(), cls.id.desc())
|
||||||
|
)
|
||||||
|
if job_post_ids is not None:
|
||||||
|
ids = list(job_post_ids)
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
qry = qry.where(assigned.in_(ids))
|
||||||
|
if search:
|
||||||
|
pattern = f"%{search.strip()}%"
|
||||||
|
qry = qry.where(or_(cls.name.ilike(pattern), cls.candidate_email.ilike(pattern)))
|
||||||
|
result = await session.execute(qry)
|
||||||
|
rows = []
|
||||||
|
for rec, title in result.all():
|
||||||
|
job_id = rec.assigned_job_post_id or rec.job_post_id
|
||||||
|
rows.append({
|
||||||
|
"form_data_id": str(rec.id),
|
||||||
|
"user_id": None,
|
||||||
|
"name": (rec.name or "").strip() or None,
|
||||||
|
"email": (rec.candidate_email or "").strip().lower() or None,
|
||||||
|
"job_post_id": str(job_id) if job_id else None,
|
||||||
|
"job_title": title or rec.position_applied_for or None,
|
||||||
|
"application_status": rec.processing_state or "PENDING",
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def form_ids_by_manual_ids(cls, session: AsyncSession, manual_ids):
|
||||||
|
"""form_data.id keyed by the promoted manual_upload_candidate_id."""
|
||||||
|
uids = []
|
||||||
|
for raw in manual_ids or []:
|
||||||
|
try:
|
||||||
|
uids.append(uuid.UUID(str(raw)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if not uids:
|
||||||
|
return {}
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls.manual_upload_candidate_id, cls.id)
|
||||||
|
.where(cls.manual_upload_candidate_id.in_(uids))
|
||||||
|
)
|
||||||
|
out = {}
|
||||||
|
for manual_id, form_id in result.all():
|
||||||
|
if manual_id and form_id:
|
||||||
|
out[str(manual_id)] = str(form_id)
|
||||||
|
return out
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def job_post_ids_by_emails(cls, session: AsyncSession, emails):
|
||||||
|
"""(email, job_post_id) pairs from assigned or job_post_id. Unlinked skipped."""
|
||||||
|
lowers=sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||||
|
if not lowers:
|
||||||
|
return []
|
||||||
|
result=await session.execute(
|
||||||
|
select(cls.candidate_email,cls.job_post_id,cls.assigned_job_post_id)
|
||||||
|
.where(func.lower(cls.candidate_email).in_(lowers))
|
||||||
|
)
|
||||||
|
rows=[]
|
||||||
|
for email,job_id,assigned_id in result.all():
|
||||||
|
key=(email or "").strip().lower()
|
||||||
|
if assigned_id is not None:
|
||||||
|
rows.append((key,str(assigned_id)))
|
||||||
|
if job_id is not None and job_id!=assigned_id:
|
||||||
|
rows.append((key,str(job_id)))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def set_reapplied_by_emails(cls, session: AsyncSession, mapping):
|
||||||
|
if not mapping:
|
||||||
|
return 0
|
||||||
|
updated=0
|
||||||
|
for email, ids in mapping.items():
|
||||||
|
key=(email or "").strip().lower()
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
result=await session.execute(
|
||||||
|
update(cls).where(func.lower(cls.candidate_email)==key).values(reapplied=list(ids or []))
|
||||||
|
)
|
||||||
|
updated+=result.rowcount or 0
|
||||||
|
await session.commit()
|
||||||
|
return updated
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def count_form_data(
|
async def count_form_data(
|
||||||
cls, session: AsyncSession, *, sheet=None, search=None,
|
cls, session: AsyncSession, *, sheet=None, search=None,
|
||||||
processing_state=None, is_duplicate=None,
|
processing_state=None, is_duplicate=None, has_linkedin=None,
|
||||||
|
has_resume=None, city=None, source=None, assigned=None,
|
||||||
|
no_suggestions=None, inbox_filter=None, has_suggestions=None, job_post_ids=None,
|
||||||
):
|
):
|
||||||
statement = select(func.count()).select_from(cls)
|
statement = select(func.count()).select_from(cls)
|
||||||
for clause in cls._filters(
|
for clause in cls._filters(
|
||||||
sheet=sheet, search=search,
|
sheet=sheet, search=search,
|
||||||
processing_state=processing_state, is_duplicate=is_duplicate,
|
processing_state=processing_state, is_duplicate=is_duplicate,
|
||||||
|
has_linkedin=has_linkedin, has_resume=has_resume,
|
||||||
|
city=city, source=source, assigned=assigned,
|
||||||
|
no_suggestions=no_suggestions, inbox_filter=inbox_filter,
|
||||||
|
has_suggestions=has_suggestions, job_post_ids=job_post_ids,
|
||||||
):
|
):
|
||||||
statement = statement.where(clause)
|
statement = statement.where(clause)
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def count_processing(cls, session: AsyncSession, *, sheet=None):
|
async def count_processing(
|
||||||
"""Tab badge counts for the Sheet Forms channel."""
|
cls, session: AsyncSession, *, sheet=None, search=None,
|
||||||
|
has_linkedin=None, has_resume=None, city=None, source=None, assigned=None,
|
||||||
|
job_post_ids=None,
|
||||||
|
):
|
||||||
|
"""Tab badge counts for the Sheet Forms channel.
|
||||||
|
|
||||||
|
Narrowed by the same predicates as the list, through the same _filters()
|
||||||
|
call, because a badge that disagrees with the rows under it reads as a
|
||||||
|
bug. This used to take only `sheet`, so switching on the search box
|
||||||
|
already left "All Applications 612" sitting above twelve rows; adding
|
||||||
|
the link filters would have made that worse.
|
||||||
|
|
||||||
|
processing_state and is_duplicate are deliberately NOT accepted: those
|
||||||
|
two ARE the tabs. Passing them would have each badge count only its own
|
||||||
|
tab, so every badge would report the tab the user is already on.
|
||||||
|
"""
|
||||||
statement = select(
|
statement = select(
|
||||||
func.count().label("all"),
|
func.count().label("all"),
|
||||||
func.coalesce(func.sum(case((cls.processing_state == "unread", 1), else_=0)), 0).label("unread"),
|
func.coalesce(func.sum(case((cls.processing_state == "unread", 1), else_=0)), 0).label("unread"),
|
||||||
func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"),
|
func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"),
|
||||||
func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"),
|
func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"),
|
||||||
func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"),
|
func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"),
|
||||||
func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712
|
func.coalesce(func.sum(case((cls._duplicates_tab_filter(), 1), else_=0)), 0).label("duplicates"),
|
||||||
|
func.coalesce(func.sum(case((cls._no_suggested_jobs(), 1), else_=0)), 0).label("on_hold"),
|
||||||
|
func.coalesce(func.sum(case((~cls._no_suggested_jobs(), 1), else_=0)), 0).label("suggested"),
|
||||||
).select_from(cls)
|
).select_from(cls)
|
||||||
if sheet:
|
for clause in cls._filters(
|
||||||
statement = statement.where(cls.sheet == sheet)
|
sheet=sheet, search=search,
|
||||||
|
has_linkedin=has_linkedin, has_resume=has_resume, city=city,
|
||||||
|
source=source, assigned=assigned, job_post_ids=job_post_ids,
|
||||||
|
):
|
||||||
|
statement = statement.where(clause)
|
||||||
row = (await session.execute(statement)).one()
|
row = (await session.execute(statement)).one()
|
||||||
return {
|
return {
|
||||||
"all": int(row.all or 0),
|
"all": int(row.all or 0),
|
||||||
|
|
@ -244,6 +720,8 @@ class FormData(SQLModel, table=True):
|
||||||
"processed": int(row.processed or 0),
|
"processed": int(row.processed or 0),
|
||||||
"rejected": int(row.rejected or 0),
|
"rejected": int(row.rejected or 0),
|
||||||
"duplicates": int(row.duplicates or 0),
|
"duplicates": int(row.duplicates or 0),
|
||||||
|
"on_hold": int(row.on_hold or 0),
|
||||||
|
"suggested": int(row.suggested or 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -253,6 +731,24 @@ class FormData(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def distinct_cities(cls, session: AsyncSession):
|
||||||
|
"""Non-blank city values on this table. Distinct only within form_data."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls.city).where(cls.city.is_not(None), cls.city != "").distinct()
|
||||||
|
)
|
||||||
|
return [text.strip() for text in result.scalars().all() if (text or "").strip()]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def distinct_sources(cls, session: AsyncSession):
|
||||||
|
"""Non-blank source_of_application values. Distinct only within form_data."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls.source_of_application)
|
||||||
|
.where(cls.source_of_application.is_not(None), cls.source_of_application != "")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
return [text.strip() for text in result.scalars().all() if (text or "").strip()]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def delete_by_sheet(cls, session: AsyncSession, sheet: str, *, commit: bool = True):
|
async def delete_by_sheet(cls, session: AsyncSession, sheet: str, *, commit: bool = True):
|
||||||
count_result = await session.execute(
|
count_result = await session.execute(
|
||||||
|
|
@ -296,6 +792,29 @@ class FormData(SQLModel, table=True):
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"deleted": deleted, "inserted": inserted}
|
return {"deleted": deleted, "inserted": inserted}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def stamp_suggested_job_posts(
|
||||||
|
cls, session: AsyncSession, records: list[dict],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Set suggested_job_post_ids from ILIKE title match on position_applied_for.
|
||||||
|
|
||||||
|
One applied-for title can match many job_posts. Blank or no match → [].
|
||||||
|
Recruiter assignment (job_post_id / assigned_job_post_id) stays unset.
|
||||||
|
"""
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
found = await JobPosts.ids_for_titles_ilike(
|
||||||
|
session,
|
||||||
|
[r.get("position_applied_for") for r in records],
|
||||||
|
)
|
||||||
|
for record in records:
|
||||||
|
applied = (record.get("position_applied_for") or "").strip()
|
||||||
|
hits = found.get(applied) or [] if applied else []
|
||||||
|
record["suggested_job_post_ids"] = [str(uid) for uid in hits]
|
||||||
|
record["job_post_id"] = None
|
||||||
|
record["assigned_job_post_id"] = None
|
||||||
|
return records
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _cell(data: dict, key: str):
|
def _cell(data: dict, key: str):
|
||||||
"""Sheet cell → stripped str, or None if missing/blank."""
|
"""Sheet cell → stripped str, or None if missing/blank."""
|
||||||
|
|
@ -312,6 +831,7 @@ class FormData(SQLModel, table=True):
|
||||||
Year of Graduation: prefer the second column when present; else the first;
|
Year of Graduation: prefer the second column when present; else the first;
|
||||||
else None. Duplicate headers are renamed Year of Graduation_1 by normalise_headers.
|
else None. Duplicate headers are renamed Year of Graduation_1 by normalise_headers.
|
||||||
"""
|
"""
|
||||||
|
from employment_agent.decorators import canonical_city
|
||||||
from g_sheet.plugins import parse_date, parse_date_time, parse_salary
|
from g_sheet.plugins import parse_date, parse_date_time, parse_salary
|
||||||
|
|
||||||
first_year = cls._cell(data, "Year of Graduation")
|
first_year = cls._cell(data, "Year of Graduation")
|
||||||
|
|
@ -328,6 +848,7 @@ class FormData(SQLModel, table=True):
|
||||||
|
|
||||||
current_salary = cls._cell(data, "Current Salary")
|
current_salary = cls._cell(data, "Current Salary")
|
||||||
expected_salary = cls._cell(data, "Expected Salary")
|
expected_salary = cls._cell(data, "Expected Salary")
|
||||||
|
residing_city = cls._cell(data, "Residing City")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"sheet": sheet,
|
"sheet": sheet,
|
||||||
|
|
@ -346,7 +867,8 @@ class FormData(SQLModel, table=True):
|
||||||
"position_applied_for": cls._cell(data, "Position Applied For"),
|
"position_applied_for": cls._cell(data, "Position Applied For"),
|
||||||
"profile_link": cls._cell(data, "LinkedIn Profile Link"),
|
"profile_link": cls._cell(data, "LinkedIn Profile Link"),
|
||||||
"residing_country": cls._cell(data, "Residing Country"),
|
"residing_country": cls._cell(data, "Residing Country"),
|
||||||
"residing_city": cls._cell(data, "Residing City"),
|
"residing_city": residing_city,
|
||||||
|
"city": canonical_city(residing_city),
|
||||||
"ho_availability": cls._cell(data, "Are you willing to relocate?"),
|
"ho_availability": cls._cell(data, "Are you willing to relocate?"),
|
||||||
"degree": cls._cell(data, "Educational Degree"),
|
"degree": cls._cell(data, "Educational Degree"),
|
||||||
"university": cls._cell(data, "University"),
|
"university": cls._cell(data, "University"),
|
||||||
|
|
@ -423,6 +945,14 @@ class SheetImportRun(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def delete_failed(cls, session: AsyncSession, *, commit: bool = True):
|
||||||
|
"""Drop failed import rows so a new job is not blocked by them."""
|
||||||
|
result = await session.execute(delete(cls).where(cls.status == "failed"))
|
||||||
|
if commit:
|
||||||
|
await session.commit()
|
||||||
|
return result.rowcount
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def insert_run(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
|
async def insert_run(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
|
||||||
row = cls(**fields)
|
row = cls(**fields)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ the session can be copied to Linux prod. Re-auth lives in g_sheet/store_session.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
@ -25,6 +26,7 @@ from google.auth import default as google_auth_default
|
||||||
from google.auth.transport.requests import Request
|
from google.auth.transport.requests import Request
|
||||||
from googleapiclient.discovery import build
|
from googleapiclient.discovery import build
|
||||||
from googleapiclient.errors import HttpError
|
from googleapiclient.errors import HttpError
|
||||||
|
from googleapiclient.http import MediaIoBaseDownload
|
||||||
|
|
||||||
from g_sheet.enums import (
|
from g_sheet.enums import (
|
||||||
ALIAS_TO_FIELD,
|
ALIAS_TO_FIELD,
|
||||||
|
|
@ -206,6 +208,130 @@ def build_sheets_client(credentials):
|
||||||
raise SheetsApiError(f"Could not build the Sheets client: {e}")
|
raise SheetsApiError(f"Could not build the Sheets client: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_drive_client(credentials):
|
||||||
|
"""Drive v3 client. Same ADC session as Sheets; cache_discovery=False under threads."""
|
||||||
|
try:
|
||||||
|
return build("drive","v3",credentials=credentials,cache_discovery=False)
|
||||||
|
except Exception as e:
|
||||||
|
raise SheetsApiError(f"Could not build the Drive client: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
_DRIVE_FILE_ID_PATTERNS=(
|
||||||
|
re.compile(r"/file/d/([a-zA-Z0-9_-]+)"),
|
||||||
|
re.compile(r"/document/d/([a-zA-Z0-9_-]+)"),
|
||||||
|
re.compile(r"[?&]id=([a-zA-Z0-9_-]+)"),
|
||||||
|
re.compile(r"/d/([a-zA-Z0-9_-]+)"),
|
||||||
|
)
|
||||||
|
_GOOGLE_APPS_SHORTCUT="application/vnd.google-apps.shortcut"
|
||||||
|
_GOOGLE_APPS_DOCUMENT="application/vnd.google-apps.document"
|
||||||
|
_GOOGLE_APPS_PREFIX="application/vnd.google-apps."
|
||||||
|
|
||||||
|
|
||||||
|
def drive_file_id(url):
|
||||||
|
"""Extract a Drive/Docs file id from a Google URL, or None if it is not one."""
|
||||||
|
raw=(url or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
lowered=raw.lower()
|
||||||
|
if "drive.google.com" not in lowered and "docs.google.com" not in lowered:
|
||||||
|
return None
|
||||||
|
if "/folders/" in lowered:
|
||||||
|
return None
|
||||||
|
for pattern in _DRIVE_FILE_ID_PATTERNS:
|
||||||
|
match=pattern.search(raw)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _drive_file_meta(drive,file_id):
|
||||||
|
request=drive.files().get(
|
||||||
|
fileId=file_id,
|
||||||
|
fields="id,name,mimeType,size,shortcutDetails",
|
||||||
|
supportsAllDrives=True,
|
||||||
|
)
|
||||||
|
return execute(request,"drive file metadata")
|
||||||
|
|
||||||
|
|
||||||
|
def _download_media(request,dest_path=None):
|
||||||
|
"""Stream a Drive media request. dest_path set → write that file; else return bytes."""
|
||||||
|
try:
|
||||||
|
if dest_path is None:
|
||||||
|
buf=io.BytesIO()
|
||||||
|
downloader=MediaIoBaseDownload(buf,request)
|
||||||
|
done=False
|
||||||
|
while not done:
|
||||||
|
_,done=downloader.next_chunk()
|
||||||
|
return buf.getvalue()
|
||||||
|
path=Path(dest_path)
|
||||||
|
path.parent.mkdir(parents=True,exist_ok=True)
|
||||||
|
with path.open("wb") as fh:
|
||||||
|
downloader=MediaIoBaseDownload(fh,request)
|
||||||
|
done=False
|
||||||
|
while not done:
|
||||||
|
_,done=downloader.next_chunk()
|
||||||
|
return path
|
||||||
|
except HttpError as e:
|
||||||
|
status=_status_of(e)
|
||||||
|
raise SheetsApiError(f"drive download failed: {_reason_of(e)}",status or 502)
|
||||||
|
|
||||||
|
|
||||||
|
def _cv_dest_path(dest_dir,file_id,filename):
|
||||||
|
dest_dir=Path(dest_dir).resolve()
|
||||||
|
suffix=Path(filename or "resume.pdf").suffix.lower() or ".pdf"
|
||||||
|
if suffix not in (".pdf",".doc",".docx"):
|
||||||
|
suffix=".pdf"
|
||||||
|
safe_id=re.sub(r"[^a-zA-Z0-9_-]","",file_id or "") or "file"
|
||||||
|
dest=(dest_dir/f"{safe_id}{suffix}").resolve()
|
||||||
|
if dest.parent!=dest_dir:
|
||||||
|
raise SheetsApiError("invalid download path",400)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def download_drive_file(credentials,url,*,max_bytes=None,dest_dir=None):
|
||||||
|
"""Download one Drive file via the existing Google session.
|
||||||
|
|
||||||
|
When dest_dir is set the file is streamed to disk and `path` is returned
|
||||||
|
(`data` is None). Otherwise `data` holds the bytes (tests / callers without a
|
||||||
|
work dir).
|
||||||
|
"""
|
||||||
|
file_id=drive_file_id(url)
|
||||||
|
if not file_id:
|
||||||
|
raise SheetsApiError("not a Google Drive file URL",400)
|
||||||
|
ensure_fresh(credentials)
|
||||||
|
drive=build_drive_client(credentials)
|
||||||
|
meta=_drive_file_meta(drive,file_id)
|
||||||
|
if (meta.get("mimeType") or "")==_GOOGLE_APPS_SHORTCUT:
|
||||||
|
target=(meta.get("shortcutDetails") or {}).get("targetId")
|
||||||
|
if not target:
|
||||||
|
raise SheetsApiError("Drive shortcut has no target",400)
|
||||||
|
file_id=target
|
||||||
|
meta=_drive_file_meta(drive,file_id)
|
||||||
|
mime=meta.get("mimeType") or ""
|
||||||
|
name=meta.get("name") or "resume.pdf"
|
||||||
|
size=meta.get("size")
|
||||||
|
if max_bytes is not None and size is not None:
|
||||||
|
try:
|
||||||
|
if int(size)>max_bytes:
|
||||||
|
raise SheetsApiError("The file exceeds the size limit.",413)
|
||||||
|
except (TypeError,ValueError):
|
||||||
|
pass
|
||||||
|
if mime==_GOOGLE_APPS_DOCUMENT:
|
||||||
|
request=drive.files().export_media(fileId=file_id,mimeType="application/pdf")
|
||||||
|
if not name.lower().endswith(".pdf"):
|
||||||
|
name=f"{name}.pdf"
|
||||||
|
elif mime.startswith(_GOOGLE_APPS_PREFIX):
|
||||||
|
raise SheetsApiError("unsupported Google file type",415)
|
||||||
|
else:
|
||||||
|
request=drive.files().get_media(fileId=file_id,supportsAllDrives=True)
|
||||||
|
if dest_dir is None:
|
||||||
|
data=_download_media(request)
|
||||||
|
return {"file_id":file_id,"filename":name,"mime_type":mime,"data":data,"path":None}
|
||||||
|
dest=_cv_dest_path(dest_dir,file_id,name)
|
||||||
|
_download_media(request,dest)
|
||||||
|
return {"file_id":file_id,"filename":name,"mime_type":mime,"data":None,"path":dest}
|
||||||
|
|
||||||
|
|
||||||
def _status_of(error):
|
def _status_of(error):
|
||||||
status=getattr(getattr(error,"resp",None),"status",None)
|
status=getattr(getattr(error,"resp",None),"status",None)
|
||||||
if status is None:
|
if status is None:
|
||||||
|
|
@ -318,8 +444,12 @@ def stringify_rows(rows):
|
||||||
# -- FormData mapping ------------------------------------------------------
|
# -- FormData mapping ------------------------------------------------------
|
||||||
|
|
||||||
_TIME_RE=re.compile(r"(\d{1,2}:\d{2}\s*(?:[AaPp][Mm])?)")
|
_TIME_RE=re.compile(r"(\d{1,2}:\d{2}\s*(?:[AaPp][Mm])?)")
|
||||||
|
_APPLIED_HMS_RE=re.compile(
|
||||||
|
r"(?P<h>\d{1,2}):(?P<m>\d{2})(?::(?P<s>\d{2}))?\s*(?P<ap>[AaPp][Mm])?",
|
||||||
|
)
|
||||||
_DAY_ORDINAL_RE=re.compile(r"\b(\d+)(st|nd|rd|th)\b",re.I)
|
_DAY_ORDINAL_RE=re.compile(r"\b(\d+)(st|nd|rd|th)\b",re.I)
|
||||||
_DIGIT_RE=re.compile(r"\d")
|
_DIGIT_RE=re.compile(r"\d")
|
||||||
|
_NUMERIC_DATE_RE=re.compile(r"^(\d{1,2})([/\-.])(\d{1,2})\2(\d{2,4})$")
|
||||||
_AGE_RE=re.compile(r"\d+")
|
_AGE_RE=re.compile(r"\d+")
|
||||||
_SCORE_RE=re.compile(r"\d+")
|
_SCORE_RE=re.compile(r"\d+")
|
||||||
_SALARY_UNIT_RE=re.compile(
|
_SALARY_UNIT_RE=re.compile(
|
||||||
|
|
@ -331,6 +461,7 @@ _CURRENCY_STRIP_RE=re.compile(r"(?:rs\.?|pkr|inr|usd|\$|€|£)",re.I)
|
||||||
# Every typed column key the mapper must emit (uniform dicts for bulk insert).
|
# Every typed column key the mapper must emit (uniform dicts for bulk insert).
|
||||||
_FORM_DATA_COLUMN_KEYS=tuple(field.value for field in FormDataField)+(
|
_FORM_DATA_COLUMN_KEYS=tuple(field.value for field in FormDataField)+(
|
||||||
"age_raw","current_salary_value","expected_salary_value","job_post_id",
|
"age_raw","current_salary_value","expected_salary_value","job_post_id",
|
||||||
|
"assigned_job_post_id","suggested_job_post_ids",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -383,8 +514,31 @@ def _normalise_month_spellings(text):
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def parse_date(value):
|
def _from_numeric_date(first,second,year,prefer_mdy):
|
||||||
"""Tolerant date parse → aware UTC datetime, or None. Never raises."""
|
"""Slash/dash/dot numeric dates. 8/28 is MDY; 28/8 is DMY; 8/12 is ambiguous."""
|
||||||
|
if year<100:
|
||||||
|
year+=2000
|
||||||
|
if first>12 and 1<=second<=12:
|
||||||
|
day,month=first,second
|
||||||
|
elif second>12 and 1<=first<=12:
|
||||||
|
month,day=first,second
|
||||||
|
elif prefer_mdy:
|
||||||
|
month,day=first,second
|
||||||
|
else:
|
||||||
|
day,month=first,second
|
||||||
|
try:
|
||||||
|
return datetime(year,month,day,tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(value,prefer_mdy=False):
|
||||||
|
"""Tolerant date parse → aware UTC datetime, or None. Never raises.
|
||||||
|
|
||||||
|
prefer_mdy=True for Google Form Timestamp (US M/D/YYYY). Leave False for
|
||||||
|
local DD/MM fields like date of birth. Unambiguous values (8/28, 28/8)
|
||||||
|
are resolved from the numbers, not the flag.
|
||||||
|
"""
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
text=str(value).strip()
|
text=str(value).strip()
|
||||||
|
|
@ -405,6 +559,17 @@ def parse_date(value):
|
||||||
date_part=_normalise_month_spellings(date_part)
|
date_part=_normalise_month_spellings(date_part)
|
||||||
date_part=re.sub(r"\s+"," ",date_part).strip(" ,;")
|
date_part=re.sub(r"\s+"," ",date_part).strip(" ,;")
|
||||||
|
|
||||||
|
numeric=_NUMERIC_DATE_RE.match(date_part)
|
||||||
|
if numeric:
|
||||||
|
parsed=_from_numeric_date(
|
||||||
|
int(numeric.group(1)),
|
||||||
|
int(numeric.group(3)),
|
||||||
|
int(numeric.group(4)),
|
||||||
|
prefer_mdy,
|
||||||
|
)
|
||||||
|
if parsed is not None:
|
||||||
|
return parsed
|
||||||
|
|
||||||
for fmt in DateFormat:
|
for fmt in DateFormat:
|
||||||
try:
|
try:
|
||||||
return datetime.strptime(date_part,fmt.value).replace(tzinfo=timezone.utc)
|
return datetime.strptime(date_part,fmt.value).replace(tzinfo=timezone.utc)
|
||||||
|
|
@ -414,8 +579,11 @@ def parse_date(value):
|
||||||
|
|
||||||
|
|
||||||
def parse_date_time(value):
|
def parse_date_time(value):
|
||||||
"""(datetime|None, time_string|None) — fills entry_time when the cell carries one."""
|
"""(datetime|None, time_string|None) — fills entry_time when the cell carries one.
|
||||||
parsed=parse_date(value)
|
|
||||||
|
Google Form Timestamp is M/D/YYYY, so 8/12/2026 is 12 Aug, not 8 Dec.
|
||||||
|
"""
|
||||||
|
parsed=parse_date(value,prefer_mdy=True)
|
||||||
if value is None:
|
if value is None:
|
||||||
return parsed,None
|
return parsed,None
|
||||||
text=str(value).strip()
|
text=str(value).strip()
|
||||||
|
|
@ -424,6 +592,58 @@ def parse_date_time(value):
|
||||||
return parsed,time_str
|
return parsed,time_str
|
||||||
|
|
||||||
|
|
||||||
|
def _hms_from_text(text):
|
||||||
|
if not text:
|
||||||
|
return 0,0,0
|
||||||
|
match=_APPLIED_HMS_RE.search(str(text).strip())
|
||||||
|
if not match:
|
||||||
|
return 0,0,0
|
||||||
|
hours=int(match.group("h"))
|
||||||
|
minutes=int(match.group("m"))
|
||||||
|
seconds=int(match.group("s") or 0)
|
||||||
|
ap=(match.group("ap") or "").lower()
|
||||||
|
if ap=="pm" and hours<12:
|
||||||
|
hours+=12
|
||||||
|
if ap=="am" and hours==12:
|
||||||
|
hours=0
|
||||||
|
return min(hours,23),minutes,seconds
|
||||||
|
|
||||||
|
|
||||||
|
def form_applied_at_iso(row):
|
||||||
|
"""Wall-clock apply time for history. Not UTC midnight and not import time.
|
||||||
|
|
||||||
|
Google Form Timestamp is the source of truth (M/D/YYYY). entry_date is stored
|
||||||
|
as timestamptz at 00:00+00:00, so isoformat() would send `…T00:00:00+00:00`
|
||||||
|
and drop entry_time — the UI then paints 12:00am or shifts +5h.
|
||||||
|
"""
|
||||||
|
raw=getattr(row,"raw_record",None)
|
||||||
|
ts=None
|
||||||
|
if isinstance(raw,dict):
|
||||||
|
for key,val in raw.items():
|
||||||
|
if str(key).strip().lower()=="timestamp" and val not in (None,""):
|
||||||
|
ts=str(val).strip()
|
||||||
|
break
|
||||||
|
if ts:
|
||||||
|
parsed,_=parse_date_time(ts)
|
||||||
|
if parsed is not None:
|
||||||
|
hours,minutes,seconds=_hms_from_text(ts)
|
||||||
|
return (
|
||||||
|
f"{parsed.year:04d}-{parsed.month:02d}-{parsed.day:02d}"
|
||||||
|
f"T{hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||||
|
)
|
||||||
|
entry_date=getattr(row,"entry_date",None)
|
||||||
|
if entry_date is not None:
|
||||||
|
hours,minutes,seconds=_hms_from_text(getattr(row,"entry_time",None))
|
||||||
|
return (
|
||||||
|
f"{entry_date.year:04d}-{entry_date.month:02d}-{entry_date.day:02d}"
|
||||||
|
f"T{hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||||
|
)
|
||||||
|
created=getattr(row,"created_at",None)
|
||||||
|
if created is None:
|
||||||
|
return None
|
||||||
|
return created.isoformat()
|
||||||
|
|
||||||
|
|
||||||
def parse_age(value):
|
def parse_age(value):
|
||||||
"""(int|None, raw|None) — first digit run if 0 < n < 100, always keep the raw."""
|
"""(int|None, raw|None) — first digit run if 0 < n < 100, always keep the raw."""
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
"""ATS-score a form_data CV against every linked job post.
|
||||||
|
|
||||||
|
A form row can match many jobs (suggested_job_post_ids). Recruiter assignment
|
||||||
|
is assigned_job_post_id. If assigned is set, ATS scores only that job; else
|
||||||
|
every suggested id. Resume text comes from extracted_data. Each
|
||||||
|
(form_data_id, job_post_id) pair lands as its own ats_results row.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.models.scoring import CompletedCandidate
|
||||||
|
from app.services.pdf import ExtractedResume
|
||||||
|
from app.services.scoring import score_batch
|
||||||
|
from db_setup import session_scope
|
||||||
|
from g_sheet.models import FormData
|
||||||
|
from inbox.models import AtsResults, InboxRescanRun
|
||||||
|
from job.candidate.plugins import build_job_description, get_scorer, get_scoring_settings
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
logger = logging.getLogger("g_sheet.scoring")
|
||||||
|
|
||||||
|
|
||||||
|
def resume_text_from_extracted(payload) -> str | None:
|
||||||
|
"""Usable CV text from form_data.extracted_data, or None."""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
if payload.get("status") != "completed":
|
||||||
|
return None
|
||||||
|
text = (payload.get("text") or "").strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _band(score) -> str:
|
||||||
|
if score is None:
|
||||||
|
return ""
|
||||||
|
return "Strong Match" if score >= 82 else "Potential Match" if score >= 65 else "Weak Match"
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_form_ats(row) -> dict:
|
||||||
|
"""One current ats_results row for a Sheet Forms applicant."""
|
||||||
|
return {
|
||||||
|
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
|
||||||
|
"overall_score": row.overall_score,
|
||||||
|
"band": row.band or None,
|
||||||
|
"professional_summary": row.professional_summary or None,
|
||||||
|
"computed_at": row.computed_at.isoformat() if row.computed_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue_form_score(form_data_id, job_post_id) -> None:
|
||||||
|
"""Queue ATS for one form row against one job. Broker-down only logs."""
|
||||||
|
if not form_data_id or not job_post_id:
|
||||||
|
return
|
||||||
|
await enqueue_form_scores(form_data_id, [job_post_id])
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue_form_scores(form_data_id, job_post_ids) -> None:
|
||||||
|
"""Queue ATS for one form row against each job. Broker-down only logs."""
|
||||||
|
if not form_data_id:
|
||||||
|
return
|
||||||
|
from inbox.tasks import score_form_data
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in job_post_ids or []:
|
||||||
|
job_id = str(raw or "").strip()
|
||||||
|
if not job_id or job_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(job_id)
|
||||||
|
try:
|
||||||
|
await score_form_data.kicker().with_labels(
|
||||||
|
created_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
correlation_id=str(form_data_id),
|
||||||
|
queue="inbox",
|
||||||
|
).kiq(str(form_data_id), job_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"could not queue form ats score for %s vs %s: %s",
|
||||||
|
form_data_id, job_id, exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue_form_row_scores(form_row) -> None:
|
||||||
|
"""Queue ATS: assigned job only, else every suggested job."""
|
||||||
|
if form_row is None:
|
||||||
|
return
|
||||||
|
assigned=getattr(form_row,"assigned_job_post_id",None) or getattr(form_row,"job_post_id",None)
|
||||||
|
if assigned:
|
||||||
|
await enqueue_form_score(form_row.id,assigned)
|
||||||
|
return
|
||||||
|
job_ids=FormData.score_job_ids(form_row)
|
||||||
|
if not job_ids:
|
||||||
|
return
|
||||||
|
await enqueue_form_scores(form_row.id,job_ids)
|
||||||
|
|
||||||
|
|
||||||
|
async def score_form_against_job(form_data_id: str, job_id: str, rescan_run_id=None) -> dict:
|
||||||
|
"""Score one Sheet Forms CV against one job. Idempotent per (form, job)."""
|
||||||
|
try:
|
||||||
|
uuid.UUID(str(form_data_id))
|
||||||
|
uuid.UUID(str(job_id))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {"status": "skipped", "reason": "invalid_ids"}
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
existing = await AtsResults.get_for_form_job(session, form_data_id, job_id)
|
||||||
|
if existing is not None:
|
||||||
|
return {"status": "already_scored"}
|
||||||
|
form_row, job = await FormData.get_with_job(session, form_data_id, job_id)
|
||||||
|
if form_row is None or job is None:
|
||||||
|
return {"status": "skipped", "reason": "no_join"}
|
||||||
|
settings = get_scoring_settings()
|
||||||
|
jd = build_job_description(job)
|
||||||
|
if len(jd) > settings.max_jd_chars:
|
||||||
|
return {"status": "skipped", "reason": "jd_too_large"}
|
||||||
|
stored_summary = (form_row.professional_summary or "").strip() or None
|
||||||
|
text = resume_text_from_extracted(form_row.extracted_data)
|
||||||
|
filename = (form_row.extracted_data or {}).get("filename") or "resume.pdf"
|
||||||
|
page_count = int((form_row.extracted_data or {}).get("page_count") or 1)
|
||||||
|
truncated = bool((form_row.extracted_data or {}).get("truncated"))
|
||||||
|
form_pk = form_row.id
|
||||||
|
job_pk = job.id
|
||||||
|
|
||||||
|
from summary_gate.execute_agent import allow_ats
|
||||||
|
if not await allow_ats(stored_summary, jd):
|
||||||
|
return {"status": "skipped", "reason": "not_suitable"}
|
||||||
|
if not text:
|
||||||
|
return {"status": "skipped", "reason": "no_extract"}
|
||||||
|
|
||||||
|
resume = ExtractedResume(
|
||||||
|
filename=str(filename),
|
||||||
|
candidate_id=str(form_pk),
|
||||||
|
text=text,
|
||||||
|
page_count=page_count,
|
||||||
|
truncated=truncated,
|
||||||
|
)
|
||||||
|
scored = await score_batch(
|
||||||
|
[resume],
|
||||||
|
job_description=jd,
|
||||||
|
scorer=get_scorer(),
|
||||||
|
concurrency=1,
|
||||||
|
)
|
||||||
|
result = scored[0] if scored else None
|
||||||
|
if not isinstance(result, CompletedCandidate):
|
||||||
|
error = getattr(result, "error_code", None) if result is not None else "MODEL_UNAVAILABLE"
|
||||||
|
logger.warning("form ats failed form_data=%s job=%s code=%s", form_data_id, job_id, error)
|
||||||
|
return {"status": "failed", "error_code": error}
|
||||||
|
|
||||||
|
summary = (result.professional_summary or "").strip() or None
|
||||||
|
run_id = None
|
||||||
|
if rescan_run_id not in (None, ""):
|
||||||
|
try:
|
||||||
|
run_id = uuid.UUID(str(rescan_run_id))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
run_id = None
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
existing = await AtsResults.get_for_form_job(session, form_pk, job_pk)
|
||||||
|
if existing is not None:
|
||||||
|
return {"status": "already_scored"}
|
||||||
|
job = await JobPosts.get_job_post_by_id(session, job_pk)
|
||||||
|
if job is None or job.is_deleted:
|
||||||
|
return {"status": "skipped", "reason": "job_gone"}
|
||||||
|
await FormData.set_professional_summary(session, form_pk, summary)
|
||||||
|
await AtsResults.insert_result(session, {
|
||||||
|
"inbox_id": None,
|
||||||
|
"user_id": None,
|
||||||
|
"candidate_id": None,
|
||||||
|
"form_data_id": form_pk,
|
||||||
|
"job_post_id": job_pk,
|
||||||
|
"overall_score": float(result.match_score),
|
||||||
|
"band": _band(result.match_score),
|
||||||
|
"model_name": settings.openai_model,
|
||||||
|
"professional_summary": summary,
|
||||||
|
"rescan_run_id": run_id,
|
||||||
|
"is_current": True,
|
||||||
|
})
|
||||||
|
if run_id:
|
||||||
|
await InboxRescanRun.append_summary(session, run_id, {
|
||||||
|
"kind": "form",
|
||||||
|
"record_id": str(form_pk),
|
||||||
|
"job_post_id": str(job_pk),
|
||||||
|
"professional_summary": summary,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"status": "scored",
|
||||||
|
"overall_score": result.match_score,
|
||||||
|
"band": _band(result.match_score),
|
||||||
|
"job_post_id": str(job_pk),
|
||||||
|
}
|
||||||
|
|
@ -111,6 +111,8 @@ def serialize_form_data(row) -> dict:
|
||||||
out[key] = _iso(value)
|
out[key] = _iso(value)
|
||||||
elif isinstance(value, uuid.UUID):
|
elif isinstance(value, uuid.UUID):
|
||||||
out[key] = str(value)
|
out[key] = str(value)
|
||||||
|
elif key == "suggested_job_post_ids":
|
||||||
|
out[key] = [str(v) for v in (value or []) if v not in (None, "")]
|
||||||
else:
|
else:
|
||||||
out[key] = value
|
out[key] = value
|
||||||
return out
|
return out
|
||||||
|
|
@ -128,6 +130,8 @@ def serialize_import(report: dict) -> dict:
|
||||||
"ages_parsed": report.get("ages_parsed", 0),
|
"ages_parsed": report.get("ages_parsed", 0),
|
||||||
"salaries_parsed": report.get("salaries_parsed", 0),
|
"salaries_parsed": report.get("salaries_parsed", 0),
|
||||||
"unmapped_headers": report.get("unmapped_headers") or [],
|
"unmapped_headers": report.get("unmapped_headers") or [],
|
||||||
|
"extracted": report.get("extracted", 0),
|
||||||
|
"extract_failed": report.get("extract_failed", 0),
|
||||||
"error": report.get("error"),
|
"error": report.get("error"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,6 +146,8 @@ def serialize_import_all(reports: list[dict]) -> dict:
|
||||||
"failed": len(failed),
|
"failed": len(failed),
|
||||||
"inserted": sum(r.get("inserted", 0) for r in ok),
|
"inserted": sum(r.get("inserted", 0) for r in ok),
|
||||||
"deleted": sum(r.get("deleted", 0) for r in ok),
|
"deleted": sum(r.get("deleted", 0) for r in ok),
|
||||||
|
"extracted": sum(r.get("extracted", 0) for r in ok),
|
||||||
|
"extract_failed": sum(r.get("extract_failed", 0) for r in reports),
|
||||||
"reports": [serialize_import(r) for r in reports],
|
"reports": [serialize_import(r) for r in reports],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,13 +49,30 @@ async def import_sheets(run_id:str) -> dict:
|
||||||
try:
|
try:
|
||||||
acquired=await client.set(_LOCK_KEY,run_id,nx=True,ex=_LOCK_TTL)
|
acquired=await client.set(_LOCK_KEY,run_id,nx=True,ex=_LOCK_TTL)
|
||||||
if not acquired:
|
if not acquired:
|
||||||
return await _fail(run_id,"another sheet import is already running")
|
holder=await client.get(_LOCK_KEY)
|
||||||
|
# Crash/restart redelivers the same run_id while the TTL lock is
|
||||||
|
# still set. Failing that as "another import" strands the lock
|
||||||
|
# until expiry and every later click also bounces.
|
||||||
|
if holder==run_id:
|
||||||
|
await client.expire(_LOCK_KEY,_LOCK_TTL)
|
||||||
|
logger.warning("sheet import %s reclaimed its own stale lock",run_id)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"sheet import %s skipped: lock held by %s",run_id,holder,
|
||||||
|
)
|
||||||
|
return await _fail(run_id,"another sheet import is already running")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with session_scope() as session:
|
async with session_scope() as session:
|
||||||
row=await SheetImportRun.get_by_id(session,run_id)
|
row=await SheetImportRun.get_by_id(session,run_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise PermanentTaskError(f"import run {run_id} not found")
|
raise PermanentTaskError(f"import run {run_id} not found")
|
||||||
|
if row.status=="failed":
|
||||||
|
await SheetImportRun.delete_failed(session)
|
||||||
|
return {"status":"failed","error":row.error}
|
||||||
|
if row.status=="completed":
|
||||||
|
return {"status":"completed","report":row.report}
|
||||||
|
await SheetImportRun.delete_failed(session)
|
||||||
await SheetImportRun.update_run(session,run_id,{
|
await SheetImportRun.update_run(session,run_id,{
|
||||||
"status":"running",
|
"status":"running",
|
||||||
"started_at":datetime.now(timezone.utc),
|
"started_at":datetime.now(timezone.utc),
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,12 @@ Hierarchy:
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
import uuid
|
||||||
from datetime import datetime,timezone
|
from datetime import datetime,timezone
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from g_sheet.decorators import extract_drive_cvs
|
||||||
from g_sheet.plugins import (
|
from g_sheet.plugins import (
|
||||||
SCOPES,
|
SCOPES,
|
||||||
SPREADSHEET_ID,
|
SPREADSHEET_ID,
|
||||||
|
|
@ -232,6 +234,7 @@ class SheetHealth(SheetRead):
|
||||||
class SheetImport(SheetRead):
|
class SheetImport(SheetRead):
|
||||||
"""Google Sheet → FormData import + import-run tracking."""
|
"""Google Sheet → FormData import + import-run tracking."""
|
||||||
|
|
||||||
|
@extract_drive_cvs
|
||||||
async def import_sheet(self,tab):
|
async def import_sheet(self,tab):
|
||||||
"""Read one tab from Google Sheets and replace its FormData rows."""
|
"""Read one tab from Google Sheets and replace its FormData rows."""
|
||||||
session=self._require_session()
|
session=self._require_session()
|
||||||
|
|
@ -247,7 +250,12 @@ class SheetImport(SheetRead):
|
||||||
FormData.from_sheet_row(tab,row_number,record)
|
FormData.from_sheet_row(tab,row_number,record)
|
||||||
for row_number,record in indexed
|
for row_number,record in indexed
|
||||||
]
|
]
|
||||||
|
mapped=await FormData.stamp_suggested_job_posts(session,mapped)
|
||||||
result=await FormData.replace_sheet(session,tab,mapped)
|
result=await FormData.replace_sheet(session,tab,mapped)
|
||||||
|
from inbox.views import Reapplied
|
||||||
|
await Reapplied(session=session).sync_for_emails(
|
||||||
|
[r.get("candidate_email") for r in mapped]
|
||||||
|
)
|
||||||
return serialize_import({
|
return serialize_import({
|
||||||
"tab":tab,
|
"tab":tab,
|
||||||
"rows_read":len(indexed),
|
"rows_read":len(indexed),
|
||||||
|
|
@ -279,15 +287,18 @@ class SheetImport(SheetRead):
|
||||||
return serialize_import_all(reports)
|
return serialize_import_all(reports)
|
||||||
|
|
||||||
async def start_import(self,current_user=None,tab=None):
|
async def start_import(self,current_user=None,tab=None):
|
||||||
"""Enqueue a sheet import on the shared Taskiq worker; return the run row.
|
"""Enqueue a sheet import.
|
||||||
|
|
||||||
If a queued/running import already exists, return it instead of stacking another.
|
At the start of every new job: queued/running → keep that job;
|
||||||
|
failed → delete those rows and start this one; completed → start this one.
|
||||||
"""
|
"""
|
||||||
session=self._require_session()
|
session=self._require_session()
|
||||||
active=await SheetImportRun.get_active(session)
|
active=await SheetImportRun.get_active(session)
|
||||||
if active:
|
if active:
|
||||||
return serialize_import_run(active)
|
return serialize_import_run(active)
|
||||||
|
|
||||||
|
await SheetImportRun.delete_failed(session)
|
||||||
|
|
||||||
created_by=None
|
created_by=None
|
||||||
if isinstance(current_user,dict) and current_user.get("id"):
|
if isinstance(current_user,dict) and current_user.get("id"):
|
||||||
created_by=SheetImportRun._as_uuid(current_user.get("id"))
|
created_by=SheetImportRun._as_uuid(current_user.get("id"))
|
||||||
|
|
@ -329,55 +340,123 @@ class SheetFormData(Sheet):
|
||||||
"""FormData DB mirror — query / delete only (no Google client)."""
|
"""FormData DB mirror — query / delete only (no Google client)."""
|
||||||
|
|
||||||
async def _hydrate_job_posts(self,items):
|
async def _hydrate_job_posts(self,items):
|
||||||
"""Attach matching job_posts (title == position_applied_for) + assigned_job_post.
|
"""Attach suggested job titles, assigned_job_post, and per-job ATS scores.
|
||||||
|
|
||||||
No AI suggestions — form applicants already name the role. One query for
|
Preferred source is suggested_job_post_ids (ILIKE matches stored on
|
||||||
titles on the page, one for any assigned ids.
|
import). Legacy rows without that list still title-match. ATS is one
|
||||||
|
current score per (form, job). Full JD loads when a card is expanded.
|
||||||
"""
|
"""
|
||||||
if not items:
|
if not items:
|
||||||
return items
|
return items
|
||||||
|
from g_sheet.scoring import serialize_form_ats
|
||||||
|
from inbox.models import AtsResults
|
||||||
from job.job_post.models import JobPosts
|
from job.job_post.models import JobPosts
|
||||||
from job.job_post.serializers import serialize_job_post
|
from job.job_post.serializers import serialize_job_post_title
|
||||||
|
|
||||||
session=self._require_session()
|
session=self._require_session()
|
||||||
|
|
||||||
|
def _job_payload(post):
|
||||||
|
payload=serialize_job_post_title(post)
|
||||||
|
if post.is_deleted or not post.is_active:
|
||||||
|
payload={**payload,"unavailable":True}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
suggested_ids=[]
|
||||||
|
for item in items:
|
||||||
|
for raw in item.get("suggested_job_post_ids") or []:
|
||||||
|
if raw:
|
||||||
|
suggested_ids.append(raw)
|
||||||
|
assigned_ids=[]
|
||||||
|
for item in items:
|
||||||
|
aid=item.get("assigned_job_post_id") or item.get("job_post_id")
|
||||||
|
if aid:
|
||||||
|
assigned_ids.append(aid)
|
||||||
|
wanted=list(dict.fromkeys([*suggested_ids,*assigned_ids]))
|
||||||
|
by_id={}
|
||||||
|
if wanted:
|
||||||
|
for post in await JobPosts.titles_by_ids(session,wanted,active_only=False):
|
||||||
|
by_id[str(post.id)]=_job_payload(post)
|
||||||
|
|
||||||
titles=[(item.get("position_applied_for") or "").strip() for item in items]
|
titles=[(item.get("position_applied_for") or "").strip() for item in items]
|
||||||
titles=[t for t in titles if t]
|
titles=[t for t in titles if t]
|
||||||
by_title={}
|
by_title={}
|
||||||
if titles:
|
needs_title=any(not (item.get("suggested_job_post_ids") or []) for item in items)
|
||||||
|
if titles and needs_title:
|
||||||
for post in await JobPosts.get_by_titles(session,titles):
|
for post in await JobPosts.get_by_titles(session,titles):
|
||||||
key=(post.title or "").strip().lower()
|
key=(post.title or "").strip().lower()
|
||||||
payload=serialize_job_post(post)
|
by_title.setdefault(key,[]).append(_job_payload(post))
|
||||||
if post.is_deleted or not post.is_active:
|
|
||||||
payload={**payload,"unavailable":True}
|
|
||||||
by_title.setdefault(key,[]).append(payload)
|
|
||||||
|
|
||||||
assigned_ids=[item.get("job_post_id") for item in items if item.get("job_post_id")]
|
ats_by_form=await AtsResults.get_current_for_forms(
|
||||||
assigned_map={}
|
session,[item.get("id") for item in items],
|
||||||
if assigned_ids:
|
)
|
||||||
for post in await JobPosts.get_by_ids(session,assigned_ids,active_only=False):
|
|
||||||
assigned_map[str(post.id)]=serialize_job_post(post)
|
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
key=(item.get("position_applied_for") or "").strip().lower()
|
suggested=[str(raw) for raw in (item.get("suggested_job_post_ids") or []) if raw]
|
||||||
item["job_posts"]=list(by_title.get(key) or [])
|
item["suggested_job_post_ids"]=suggested
|
||||||
aid=item.get("job_post_id")
|
if suggested:
|
||||||
item["assigned_job_post"]=assigned_map.get(str(aid)) if aid else None
|
posts=[]
|
||||||
|
for sid in suggested:
|
||||||
|
payload=by_id.get(sid)
|
||||||
|
if payload is None:
|
||||||
|
posts.append({"id":sid,"unavailable":True})
|
||||||
|
else:
|
||||||
|
posts.append(dict(payload))
|
||||||
|
item["job_posts"]=posts
|
||||||
|
else:
|
||||||
|
key=(item.get("position_applied_for") or "").strip().lower()
|
||||||
|
item["job_posts"]=[dict(p) for p in (by_title.get(key) or [])]
|
||||||
|
|
||||||
|
aid=item.get("assigned_job_post_id") or item.get("job_post_id")
|
||||||
|
item["assigned_job_post_id"]=str(aid) if aid else None
|
||||||
|
item["assigned_job_post"]=by_id.get(str(aid)) if aid else None
|
||||||
|
|
||||||
|
fid=item.get("id")
|
||||||
|
try:
|
||||||
|
form_uid=uuid.UUID(str(fid)) if fid else None
|
||||||
|
except (TypeError,ValueError):
|
||||||
|
form_uid=None
|
||||||
|
scores=[serialize_form_ats(row) for row in (ats_by_form.get(form_uid) or [])]
|
||||||
|
item["ats_results"]=scores
|
||||||
|
score_by_job={
|
||||||
|
str(s["job_post_id"]):s for s in scores if s.get("job_post_id")
|
||||||
|
}
|
||||||
|
for post in item["job_posts"]:
|
||||||
|
hit=score_by_job.get(str(post.get("id")))
|
||||||
|
if hit:
|
||||||
|
post["overall_score"]=hit.get("overall_score")
|
||||||
|
post["band"]=hit.get("band")
|
||||||
|
assigned_score=score_by_job.get(str(aid)) if aid else None
|
||||||
|
if assigned_score and assigned_score.get("overall_score") is not None:
|
||||||
|
item["ats_score"]=round(float(assigned_score.get("overall_score")))
|
||||||
|
else:
|
||||||
|
nums=[s.get("overall_score") for s in scores if s.get("overall_score") is not None]
|
||||||
|
item["ats_score"]=round(float(max(nums))) if nums else None
|
||||||
return items
|
return items
|
||||||
|
|
||||||
async def get_form_data(
|
async def get_form_data(
|
||||||
self,sheet=None,search=None,offset=0,limit=None,
|
self,sheet=None,search=None,offset=0,limit=None,
|
||||||
processing_state=None,is_duplicate=None,
|
processing_state=None,is_duplicate=None,has_linkedin=None,has_resume=None,
|
||||||
|
city=None,source=None,assigned=None,no_suggestions=None,
|
||||||
|
has_suggestions=None,job_post_ids=None,
|
||||||
):
|
):
|
||||||
session=self._require_session()
|
session=self._require_session()
|
||||||
rows=await FormData.fetch_form_data(
|
rows=await FormData.fetch_form_data(
|
||||||
session,sheet=sheet,search=search,offset=offset,limit=limit,
|
session,sheet=sheet,search=search,offset=offset,limit=limit,
|
||||||
processing_state=processing_state,is_duplicate=is_duplicate,
|
processing_state=processing_state,is_duplicate=is_duplicate,
|
||||||
|
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
|
||||||
|
source=source,assigned=assigned,no_suggestions=no_suggestions,
|
||||||
|
has_suggestions=has_suggestions,job_post_ids=job_post_ids,
|
||||||
)
|
)
|
||||||
total=await FormData.count_form_data(
|
total=await FormData.count_form_data(
|
||||||
session,sheet=sheet,search=search,
|
session,sheet=sheet,search=search,
|
||||||
processing_state=processing_state,is_duplicate=is_duplicate,
|
processing_state=processing_state,is_duplicate=is_duplicate,
|
||||||
|
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
|
||||||
|
source=source,assigned=assigned,no_suggestions=no_suggestions,
|
||||||
|
has_suggestions=has_suggestions,job_post_ids=job_post_ids,
|
||||||
)
|
)
|
||||||
items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows])
|
items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows])
|
||||||
|
from job.candidate.views import CandidateView
|
||||||
|
items=await CandidateView(session=session).attach_application_history(items)
|
||||||
return items,total
|
return items,total
|
||||||
|
|
||||||
async def get_form_data_by_id(self,record_id):
|
async def get_form_data_by_id(self,record_id):
|
||||||
|
|
@ -386,10 +465,11 @@ class SheetFormData(Sheet):
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404,detail="Form data not found")
|
raise HTTPException(status_code=404,detail="Form data not found")
|
||||||
items=await self._hydrate_job_posts([serialize_form_data(row)])
|
items=await self._hydrate_job_posts([serialize_form_data(row)])
|
||||||
return items[0]
|
from job.candidate.views import CandidateView
|
||||||
|
return await CandidateView(session=session).attach_application_history(items[0])
|
||||||
|
|
||||||
async def assign_job_post(self,record_id,job_post_id):
|
async def assign_job_post(self,record_id,job_post_id):
|
||||||
"""Set or clear form_data.job_post_id (same contract as inbox assign).
|
"""Set or clear form_data.assigned_job_post_id (same contract as inbox assign).
|
||||||
|
|
||||||
Setting a job promotes the row into Users + manual_upload_candidate so
|
Setting a job promotes the row into Users + manual_upload_candidate so
|
||||||
Candidates / Talent Pool / Pipeline can see it (platform tag: Form).
|
Candidates / Talent Pool / Pipeline can see it (platform tag: Form).
|
||||||
|
|
@ -403,8 +483,12 @@ class SheetFormData(Sheet):
|
||||||
updated=await FormData.set_job_post(session,record_id,job_post_id)
|
updated=await FormData.set_job_post(session,record_id,job_post_id)
|
||||||
if not updated:
|
if not updated:
|
||||||
raise HTTPException(status_code=404,detail="Form data not found")
|
raise HTTPException(status_code=404,detail="Form data not found")
|
||||||
|
from inbox.views import Reapplied
|
||||||
|
await Reapplied(session=session).sync_for_email(updated.candidate_email)
|
||||||
if job_post_id is not None:
|
if job_post_id is not None:
|
||||||
await self._promote_to_application(updated)
|
await self._promote_to_application(updated)
|
||||||
|
from g_sheet.scoring import enqueue_form_score
|
||||||
|
await enqueue_form_score(updated.id,job_post_id)
|
||||||
return await self.get_form_data_by_id(record_id)
|
return await self.get_form_data_by_id(record_id)
|
||||||
|
|
||||||
async def set_processing_state(self,record_id,processing_state,current_user=None):
|
async def set_processing_state(self,record_id,processing_state,current_user=None):
|
||||||
|
|
@ -495,6 +579,8 @@ class SheetFormData(Sheet):
|
||||||
"full_text":"",
|
"full_text":"",
|
||||||
"linkedin_url":linkedin_url,
|
"linkedin_url":linkedin_url,
|
||||||
})
|
})
|
||||||
|
from inbox.views import Reapplied
|
||||||
|
await Reapplied(session=session).sync_for_email(email)
|
||||||
await FormData.link_manual_upload(session,form_row.id,row.id)
|
await FormData.link_manual_upload(session,form_row.id,row.id)
|
||||||
try:
|
try:
|
||||||
await HistoryRecorder(session).record(
|
await HistoryRecorder(session).record(
|
||||||
|
|
@ -517,11 +603,18 @@ class SheetFormData(Sheet):
|
||||||
raise HTTPException(status_code=404,detail="Form data not found")
|
raise HTTPException(status_code=404,detail="Form data not found")
|
||||||
return await self.get_form_data_by_id(record_id)
|
return await self.get_form_data_by_id(record_id)
|
||||||
|
|
||||||
async def get_counts(self,sheet=None):
|
async def get_counts(self,sheet=None,search=None,has_linkedin=None,has_resume=None,city=None,source=None,assigned=None,job_post_ids=None):
|
||||||
return await FormData.count_processing(self._require_session(),sheet=sheet)
|
return await FormData.count_processing(
|
||||||
|
self._require_session(),sheet=sheet,search=search,
|
||||||
|
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
|
||||||
|
source=source,assigned=assigned,job_post_ids=job_post_ids,
|
||||||
|
)
|
||||||
|
|
||||||
async def count_rows(self,sheet=None):
|
async def count_rows(self,sheet=None,search=None,has_linkedin=None,has_resume=None):
|
||||||
return await FormData.count_form_data(self._require_session(),sheet=sheet)
|
return await FormData.count_form_data(
|
||||||
|
self._require_session(),sheet=sheet,search=search,
|
||||||
|
has_linkedin=has_linkedin,has_resume=has_resume,
|
||||||
|
)
|
||||||
|
|
||||||
async def get_imported_sheets(self):
|
async def get_imported_sheets(self):
|
||||||
session=self._require_session()
|
session=self._require_session()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
"""Global country → cities dataset for residence canonicalization.
|
||||||
|
|
||||||
|
Pakistan is one country in this map, not a special case. The employment-agent
|
||||||
|
prompt receives `countries_prompt_block()` so the model can map a messy
|
||||||
|
locality to exactly one city name. `canonical_city` uses the same index.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import re
|
||||||
|
|
||||||
|
Countries={
|
||||||
|
"Afghanistan":["Kabul","Kandahar","Herat","Mazar-i-Sharif","Jalalabad"],
|
||||||
|
"Albania":["Tirana","Durres","Vlore","Shkoder"],
|
||||||
|
"Algeria":["Algiers","Oran","Constantine","Annaba"],
|
||||||
|
"Andorra":["Andorra la Vella"],
|
||||||
|
"Angola":["Luanda","Huambo","Lobito","Benguela"],
|
||||||
|
"Argentina":["Buenos Aires","Cordoba","Rosario","Mendoza","La Plata"],
|
||||||
|
"Armenia":["Yerevan","Gyumri","Vanadzor"],
|
||||||
|
"Australia":["Sydney","Melbourne","Brisbane","Perth","Adelaide","Canberra","Gold Coast","Hobart","Darwin"],
|
||||||
|
"Austria":["Vienna","Graz","Linz","Salzburg","Innsbruck"],
|
||||||
|
"Azerbaijan":["Baku","Ganja","Sumqayit"],
|
||||||
|
"Bahamas":["Nassau","Freeport"],
|
||||||
|
"Bahrain":["Manama","Riffa","Muharraq"],
|
||||||
|
"Bangladesh":["Dhaka","Chittagong","Khulna","Rajshahi","Sylhet","Gazipur","Narayanganj"],
|
||||||
|
"Belarus":["Minsk","Gomel","Mogilev","Vitebsk"],
|
||||||
|
"Belgium":["Brussels","Antwerp","Ghent","Charleroi","Liege","Bruges"],
|
||||||
|
"Belize":["Belmopan","Belize City"],
|
||||||
|
"Benin":["Porto-Novo","Cotonou"],
|
||||||
|
"Bhutan":["Thimphu","Phuntsholing"],
|
||||||
|
"Bolivia":["La Paz","Santa Cruz","Cochabamba","Sucre"],
|
||||||
|
"Bosnia and Herzegovina":["Sarajevo","Banja Luka","Mostar","Tuzla"],
|
||||||
|
"Botswana":["Gaborone","Francistown"],
|
||||||
|
"Brazil":["Sao Paulo","Rio de Janeiro","Brasilia","Salvador","Fortaleza","Belo Horizonte","Manaus","Curitiba","Recife","Porto Alegre"],
|
||||||
|
"Brunei":["Bandar Seri Begawan"],
|
||||||
|
"Bulgaria":["Sofia","Plovdiv","Varna","Burgas"],
|
||||||
|
"Burkina Faso":["Ouagadougou","Bobo-Dioulasso"],
|
||||||
|
"Burundi":["Gitega","Bujumbura"],
|
||||||
|
"Cambodia":["Phnom Penh","Siem Reap","Sihanoukville"],
|
||||||
|
"Cameroon":["Yaounde","Douala","Garoua"],
|
||||||
|
"Canada":["Toronto","Montreal","Vancouver","Calgary","Ottawa","Edmonton","Winnipeg","Quebec City","Hamilton","Halifax"],
|
||||||
|
"Cape Verde":["Praia","Mindelo"],
|
||||||
|
"Central African Republic":["Bangui"],
|
||||||
|
"Chad":["N'Djamena","Moundou"],
|
||||||
|
"Chile":["Santiago","Valparaiso","Concepcion","Antofagasta"],
|
||||||
|
"China":["Beijing","Shanghai","Guangzhou","Shenzhen","Chengdu","Chongqing","Tianjin","Wuhan","Hangzhou","Nanjing","Xi'an","Suzhou","Dongguan","Qingdao","Dalian"],
|
||||||
|
"Colombia":["Bogota","Medellin","Cali","Barranquilla","Cartagena"],
|
||||||
|
"Comoros":["Moroni"],
|
||||||
|
"Congo":["Brazzaville","Pointe-Noire"],
|
||||||
|
"Costa Rica":["San Jose","Alajuela","Cartago"],
|
||||||
|
"Croatia":["Zagreb","Split","Rijeka","Osijek"],
|
||||||
|
"Cuba":["Havana","Santiago de Cuba","Camaguey"],
|
||||||
|
"Cyprus":["Nicosia","Limassol","Larnaca","Paphos"],
|
||||||
|
"Czech Republic":["Prague","Brno","Ostrava","Plzen"],
|
||||||
|
"Democratic Republic of the Congo":["Kinshasa","Lubumbashi","Mbuji-Mayi"],
|
||||||
|
"Denmark":["Copenhagen","Aarhus","Odense","Aalborg"],
|
||||||
|
"Djibouti":["Djibouti"],
|
||||||
|
"Dominican Republic":["Santo Domingo","Santiago"],
|
||||||
|
"Ecuador":["Quito","Guayaquil","Cuenca"],
|
||||||
|
"Egypt":["Cairo","Alexandria","Giza","Shubra El Kheima","Port Said","Suez","Luxor"],
|
||||||
|
"El Salvador":["San Salvador","Santa Ana","San Miguel"],
|
||||||
|
"Equatorial Guinea":["Malabo","Bata"],
|
||||||
|
"Eritrea":["Asmara"],
|
||||||
|
"Estonia":["Tallinn","Tartu"],
|
||||||
|
"Eswatini":["Mbabane","Manzini"],
|
||||||
|
"Ethiopia":["Addis Ababa","Dire Dawa","Mekelle"],
|
||||||
|
"Fiji":["Suva","Nadi"],
|
||||||
|
"Finland":["Helsinki","Espoo","Tampere","Oulu","Turku"],
|
||||||
|
"France":["Paris","Marseille","Lyon","Toulouse","Nice","Nantes","Strasbourg","Bordeaux","Lille","Rennes"],
|
||||||
|
"Gabon":["Libreville"],
|
||||||
|
"Gambia":["Banjul","Serekunda"],
|
||||||
|
"Georgia":["Tbilisi","Batumi","Kutaisi"],
|
||||||
|
"Germany":["Berlin","Hamburg","Munich","Cologne","Frankfurt","Stuttgart","Dusseldorf","Dortmund","Essen","Leipzig","Dresden","Hanover","Nuremberg"],
|
||||||
|
"Ghana":["Accra","Kumasi","Tamale","Takoradi"],
|
||||||
|
"Greece":["Athens","Thessaloniki","Patras","Heraklion"],
|
||||||
|
"Guatemala":["Guatemala City","Quetzaltenango"],
|
||||||
|
"Guinea":["Conakry"],
|
||||||
|
"Guyana":["Georgetown"],
|
||||||
|
"Haiti":["Port-au-Prince","Cap-Haitien"],
|
||||||
|
"Honduras":["Tegucigalpa","San Pedro Sula"],
|
||||||
|
"Hungary":["Budapest","Debrecen","Szeged","Miskolc"],
|
||||||
|
"Iceland":["Reykjavik"],
|
||||||
|
"India":["Mumbai","Delhi","Bengaluru","Hyderabad","Ahmedabad","Chennai","Kolkata","Pune","Jaipur","Surat","Lucknow","Kanpur","Nagpur","Indore","Bhopal","Patna","Chandigarh","Noida","Gurgaon","Kochi","Coimbatore"],
|
||||||
|
"Indonesia":["Jakarta","Surabaya","Bandung","Medan","Bekasi","Depok","Tangerang","Semarang","Makassar","Palembang"],
|
||||||
|
"Iran":["Tehran","Mashhad","Isfahan","Karaj","Shiraz","Tabriz","Qom","Ahvaz"],
|
||||||
|
"Iraq":["Baghdad","Basra","Mosul","Erbil","Najaf","Karbala","Sulaymaniyah"],
|
||||||
|
"Ireland":["Dublin","Cork","Limerick","Galway","Waterford"],
|
||||||
|
"Israel":["Jerusalem","Tel Aviv","Haifa","Rishon LeZion","Petah Tikva"],
|
||||||
|
"Italy":["Rome","Milan","Naples","Turin","Palermo","Genoa","Bologna","Florence","Venice","Bari"],
|
||||||
|
"Ivory Coast":["Yamoussoukro","Abidjan"],
|
||||||
|
"Jamaica":["Kingston","Montego Bay"],
|
||||||
|
"Japan":["Tokyo","Yokohama","Osaka","Nagoya","Sapporo","Fukuoka","Kobe","Kyoto","Kawasaki","Saitama","Hiroshima","Sendai"],
|
||||||
|
"Jordan":["Amman","Zarqa","Irbid","Aqaba"],
|
||||||
|
"Kazakhstan":["Astana","Almaty","Shymkent","Aktobe"],
|
||||||
|
"Kenya":["Nairobi","Mombasa","Kisumu","Nakuru"],
|
||||||
|
"Kuwait":["Kuwait City","Hawalli","Salmiya","Jahra"],
|
||||||
|
"Kyrgyzstan":["Bishkek","Osh"],
|
||||||
|
"Laos":["Vientiane","Luang Prabang"],
|
||||||
|
"Latvia":["Riga","Daugavpils"],
|
||||||
|
"Lebanon":["Beirut","Tripoli","Sidon","Zahle"],
|
||||||
|
"Lesotho":["Maseru"],
|
||||||
|
"Liberia":["Monrovia"],
|
||||||
|
"Libya":["Tripoli","Benghazi","Misrata"],
|
||||||
|
"Liechtenstein":["Vaduz"],
|
||||||
|
"Lithuania":["Vilnius","Kaunas","Klaipeda"],
|
||||||
|
"Luxembourg":["Luxembourg"],
|
||||||
|
"Madagascar":["Antananarivo","Toamasina"],
|
||||||
|
"Malawi":["Lilongwe","Blantyre"],
|
||||||
|
"Malaysia":["Kuala Lumpur","George Town","Johor Bahru","Ipoh","Shah Alam","Petaling Jaya","Kota Kinabalu","Kuching","Malacca"],
|
||||||
|
"Maldives":["Male"],
|
||||||
|
"Mali":["Bamako"],
|
||||||
|
"Malta":["Valletta","Birkirkara"],
|
||||||
|
"Mauritania":["Nouakchott"],
|
||||||
|
"Mauritius":["Port Louis"],
|
||||||
|
"Mexico":["Mexico City","Guadalajara","Monterrey","Puebla","Tijuana","Leon","Juarez","Merida","Cancun","Queretaro"],
|
||||||
|
"Moldova":["Chisinau"],
|
||||||
|
"Monaco":["Monaco"],
|
||||||
|
"Mongolia":["Ulaanbaatar"],
|
||||||
|
"Montenegro":["Podgorica","Niksic"],
|
||||||
|
"Morocco":["Rabat","Casablanca","Fes","Marrakesh","Tangier","Agadir","Meknes"],
|
||||||
|
"Mozambique":["Maputo","Beira","Nampula"],
|
||||||
|
"Myanmar":["Naypyidaw","Yangon","Mandalay"],
|
||||||
|
"Namibia":["Windhoek","Walvis Bay"],
|
||||||
|
"Nepal":["Kathmandu","Pokhara","Lalitpur","Biratnagar"],
|
||||||
|
"Netherlands":["Amsterdam","Rotterdam","The Hague","Utrecht","Eindhoven","Groningen"],
|
||||||
|
"New Zealand":["Auckland","Wellington","Christchurch","Hamilton","Dunedin"],
|
||||||
|
"Nicaragua":["Managua"],
|
||||||
|
"Niger":["Niamey"],
|
||||||
|
"Nigeria":["Abuja","Lagos","Kano","Ibadan","Port Harcourt","Benin City","Kaduna"],
|
||||||
|
"North Korea":["Pyongyang"],
|
||||||
|
"North Macedonia":["Skopje"],
|
||||||
|
"Norway":["Oslo","Bergen","Trondheim","Stavanger"],
|
||||||
|
"Oman":["Muscat","Salalah","Sohar","Nizwa"],
|
||||||
|
"Pakistan":[
|
||||||
|
"Karachi","Lahore","Islamabad","Rawalpindi","Peshawar","Quetta","Faisalabad",
|
||||||
|
"Multan","Hyderabad","Sialkot","Gujranwala","Sargodha","Bahawalpur",
|
||||||
|
"Sukkur","Larkana","Sheikhupura","Rahim Yar Khan","Sahiwal","Jhang","Okara",
|
||||||
|
"Gujrat","Kasur","Dera Ghazi Khan","Mardan","Abbottabad","Mingora","Nawabshah",
|
||||||
|
"Mirpur","Muzaffarabad","Gilgit","Skardu","Wah","Attock","Jhelum","Chakwal",
|
||||||
|
"Taxila","Kamra","Haripur","Mansehra","Kohat","Bannu","Dera Ismail Khan",
|
||||||
|
"Charsadda","Nowshera","Swat","Chitral","Swabi","Jacobabad","Khairpur","Thatta",
|
||||||
|
"Gwadar","Turbat","Hub","Kotri","Jamshoro","Shikarpur","Dadu","Badin","Khuzdar",
|
||||||
|
"Chaman","Kamoke","Muridke","Hafizabad","Narowal","Pakpattan","Vehari","Khanewal",
|
||||||
|
"Layyah","Burewala","Gojra","Chiniot","Bhakkar","Mianwali","Khushab","Murree",
|
||||||
|
"Kotli","Bhimber","Rawalakot","Toba Tek Singh","Mandi Bahauddin","Muzaffargarh",
|
||||||
|
"Mirpur Khas","Hasan Abdal",
|
||||||
|
],
|
||||||
|
"Palestine":["Gaza","Ramallah","Hebron","Nablus"],
|
||||||
|
"Panama":["Panama City","Colon"],
|
||||||
|
"Papua New Guinea":["Port Moresby"],
|
||||||
|
"Paraguay":["Asuncion","Ciudad del Este"],
|
||||||
|
"Peru":["Lima","Arequipa","Trujillo","Cusco"],
|
||||||
|
"Philippines":["Manila","Quezon City","Davao","Cebu","Zamboanga","Taguig","Pasig","Cagayan de Oro"],
|
||||||
|
"Poland":["Warsaw","Krakow","Lodz","Wroclaw","Poznan","Gdansk","Szczecin"],
|
||||||
|
"Portugal":["Lisbon","Porto","Braga","Coimbra","Faro"],
|
||||||
|
"Qatar":["Doha","Al Rayyan","Al Wakrah"],
|
||||||
|
"Romania":["Bucharest","Cluj-Napoca","Timisoara","Iasi","Constanta","Brasov"],
|
||||||
|
"Russia":["Moscow","Saint Petersburg","Novosibirsk","Yekaterinburg","Kazan","Nizhny Novgorod","Chelyabinsk","Samara","Rostov-on-Don","Ufa"],
|
||||||
|
"Rwanda":["Kigali"],
|
||||||
|
"Saudi Arabia":["Riyadh","Jeddah","Mecca","Medina","Dammam","Khobar","Dhahran","Tabuk","Abha","Taif"],
|
||||||
|
"Senegal":["Dakar","Touba","Thies"],
|
||||||
|
"Serbia":["Belgrade","Novi Sad","Nis"],
|
||||||
|
"Seychelles":["Victoria"],
|
||||||
|
"Sierra Leone":["Freetown"],
|
||||||
|
"Singapore":["Singapore"],
|
||||||
|
"Slovakia":["Bratislava","Kosice"],
|
||||||
|
"Slovenia":["Ljubljana","Maribor"],
|
||||||
|
"Somalia":["Mogadishu","Hargeisa"],
|
||||||
|
"South Africa":["Johannesburg","Cape Town","Durban","Pretoria","Port Elizabeth","Bloemfontein","East London","Soweto"],
|
||||||
|
"South Korea":["Seoul","Busan","Incheon","Daegu","Daejeon","Gwangju","Suwon","Ulsan"],
|
||||||
|
"South Sudan":["Juba"],
|
||||||
|
"Spain":["Madrid","Barcelona","Valencia","Seville","Zaragoza","Malaga","Murcia","Palma","Bilbao","Alicante"],
|
||||||
|
"Sri Lanka":["Colombo","Kandy","Galle","Jaffna","Negombo"],
|
||||||
|
"Sudan":["Khartoum","Omdurman","Port Sudan"],
|
||||||
|
"Suriname":["Paramaribo"],
|
||||||
|
"Sweden":["Stockholm","Gothenburg","Malmo","Uppsala"],
|
||||||
|
"Switzerland":["Zurich","Geneva","Basel","Bern","Lausanne","Lucerne"],
|
||||||
|
"Syria":["Damascus","Aleppo","Homs","Latakia"],
|
||||||
|
"Taiwan":["Taipei","Kaohsiung","Taichung","Tainan"],
|
||||||
|
"Tajikistan":["Dushanbe"],
|
||||||
|
"Tanzania":["Dodoma","Dar es Salaam","Mwanza","Arusha","Zanzibar"],
|
||||||
|
"Thailand":["Bangkok","Chiang Mai","Pattaya","Phuket","Nonthaburi","Hat Yai"],
|
||||||
|
"Togo":["Lome"],
|
||||||
|
"Trinidad and Tobago":["Port of Spain","San Fernando"],
|
||||||
|
"Tunisia":["Tunis","Sfax","Sousse"],
|
||||||
|
"Turkey":["Istanbul","Ankara","Izmir","Bursa","Antalya","Adana","Gaziantep","Konya","Mersin"],
|
||||||
|
"Turkmenistan":["Ashgabat"],
|
||||||
|
"Uganda":["Kampala","Gulu"],
|
||||||
|
"Ukraine":["Kyiv","Kharkiv","Odesa","Dnipro","Lviv","Zaporizhzhia"],
|
||||||
|
"United Arab Emirates":["Dubai","Abu Dhabi","Sharjah","Ajman","Ras Al Khaimah","Fujairah","Al Ain","Umm Al Quwain"],
|
||||||
|
"United Kingdom":["London","Birmingham","Manchester","Glasgow","Liverpool","Leeds","Sheffield","Edinburgh","Bristol","Leicester","Newcastle","Cardiff","Belfast","Nottingham","Southampton"],
|
||||||
|
"United States":[
|
||||||
|
"New York","Los Angeles","Chicago","Houston","Phoenix","Philadelphia","San Antonio",
|
||||||
|
"San Diego","Dallas","San Jose","Austin","Jacksonville","Fort Worth","Columbus",
|
||||||
|
"Charlotte","San Francisco","Indianapolis","Seattle","Denver","Washington",
|
||||||
|
"Boston","Nashville","Detroit","Portland","Las Vegas","Baltimore","Milwaukee",
|
||||||
|
"Albuquerque","Atlanta","Miami","Minneapolis","Tampa","Orlando","Cleveland",
|
||||||
|
"Pittsburgh","Cincinnati","Kansas City","St. Louis","Raleigh","Salt Lake City",
|
||||||
|
],
|
||||||
|
"Uruguay":["Montevideo"],
|
||||||
|
"Uzbekistan":["Tashkent","Samarkand","Bukhara"],
|
||||||
|
"Venezuela":["Caracas","Maracaibo","Valencia"],
|
||||||
|
"Vietnam":["Hanoi","Ho Chi Minh City","Da Nang","Hai Phong","Can Tho"],
|
||||||
|
"Yemen":["Sanaa","Aden","Taiz"],
|
||||||
|
"Zambia":["Lusaka","Ndola","Kitwe"],
|
||||||
|
"Zimbabwe":["Harare","Bulawayo"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _city_index():
|
||||||
|
"""First spelling of each city name wins. Longest names are matched first."""
|
||||||
|
out={}
|
||||||
|
for cities in Countries.values():
|
||||||
|
for city in cities:
|
||||||
|
name=(city or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
out.setdefault(name.lower(),name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
CITY_BY_KEY=_city_index()
|
||||||
|
CITY_RE=re.compile(
|
||||||
|
r"\b(?:"+"|".join(
|
||||||
|
re.escape(name) for name in sorted(CITY_BY_KEY,key=len,reverse=True)
|
||||||
|
)+r")\b",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def countries_prompt_block():
|
||||||
|
"""Compact country → cities block fed into the employment-agent prompt."""
|
||||||
|
lines=[]
|
||||||
|
for country,cities in Countries.items():
|
||||||
|
names=[c.strip() for c in cities if (c or "").strip()]
|
||||||
|
if not names:
|
||||||
|
continue
|
||||||
|
# De-dupe while keeping order — Pakistan lists Peshawar twice above.
|
||||||
|
seen=set()
|
||||||
|
unique=[]
|
||||||
|
for name in names:
|
||||||
|
key=name.lower()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
unique.append(name)
|
||||||
|
lines.append(f"{country}: {', '.join(unique)}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
@ -1,22 +1,91 @@
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter,Depends, Query
|
from fastapi import APIRouter,Depends, Query
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from db_setup import get_session
|
from db_setup import get_session
|
||||||
from inbox.enums import Candidate_application_Status
|
from inbox.enums import Candidate_application_Status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from inbox.views import Email
|
from inbox.views import Email
|
||||||
from users.permissions import PermissionTag, require_permission
|
from users.permissions import PermissionTag, get_current_user, require_permission
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
_optional_bearer=HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _city_values(city: str | None):
|
||||||
|
if not city or not str(city).strip():
|
||||||
|
return None
|
||||||
|
parts=[p.strip() for p in str(city).split(",") if p.strip()]
|
||||||
|
return parts or None
|
||||||
|
|
||||||
|
|
||||||
|
def _job_ids(raw: str | None):
|
||||||
|
if not raw or not str(raw).strip():
|
||||||
|
return None
|
||||||
|
out=[]
|
||||||
|
for part in str(raw).split(","):
|
||||||
|
text=part.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out.append(uuid.UUID(text))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
|
def _apps_payload(items,total,cities=None,sources=None):
|
||||||
|
body={"data":items,"total":total,"status_code":200}
|
||||||
|
if cities is not None:
|
||||||
|
body["cities"]=cities
|
||||||
|
if sources is not None:
|
||||||
|
body["sources"]=sources
|
||||||
|
return JSONResponse(content=body)
|
||||||
|
|
||||||
|
|
||||||
|
def _cron_inbox_sync_token_ok(provided: str) -> bool:
|
||||||
|
expected=(os.getenv("CRON_INBOX_SYNC_TOKEN") or "").strip()
|
||||||
|
token=(provided or "").strip()
|
||||||
|
if not expected or not token or len(expected)!=len(token):
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(token, expected)
|
||||||
|
|
||||||
|
|
||||||
|
async def inbox_sync_caller(
|
||||||
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_optional_bearer)],
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""JWT with inbox.edit, or CRON_INBOX_SYNC_TOKEN for the daily scheduler."""
|
||||||
|
token=credentials.credentials if credentials else ""
|
||||||
|
if _cron_inbox_sync_token_ok(token):
|
||||||
|
return None
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate":"Bearer"},
|
||||||
|
)
|
||||||
|
current_user=await get_current_user(credentials,session)
|
||||||
|
checker=require_permission(PermissionTag.INBOX_EDIT)
|
||||||
|
return await checker(current_user)
|
||||||
|
|
||||||
|
|
||||||
class AssignJobPostBody(BaseModel):
|
class AssignJobPostBody(BaseModel):
|
||||||
job_post_id: str | None = None
|
job_post_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AssignRecruiterBody(BaseModel):
|
||||||
|
recruiter_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProcessingStateBody(BaseModel):
|
class ProcessingStateBody(BaseModel):
|
||||||
processing_state: str
|
processing_state: str
|
||||||
|
|
||||||
|
|
@ -25,6 +94,11 @@ class DuplicateBody(BaseModel):
|
||||||
is_duplicate: bool
|
is_duplicate: bool
|
||||||
|
|
||||||
|
|
||||||
|
class OnHoldRescanBody(BaseModel):
|
||||||
|
channel: str = "all"
|
||||||
|
sheet: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ReadBody(BaseModel):
|
class ReadBody(BaseModel):
|
||||||
read: bool = True
|
read: bool = True
|
||||||
|
|
||||||
|
|
@ -48,7 +122,12 @@ class ReadAllBody(BaseModel):
|
||||||
application_status: Candidate_application_Status = Candidate_application_Status.CLOSED
|
application_status: Candidate_application_Status = Candidate_application_Status.CLOSED
|
||||||
assigned: bool | None = None
|
assigned: bool | None = None
|
||||||
is_duplicate: bool | None = None
|
is_duplicate: bool | None = None
|
||||||
|
no_suggestions: bool | None = None
|
||||||
|
has_suggestions: bool | None = None
|
||||||
processing_state: str | None = None
|
processing_state: str | None = None
|
||||||
|
city: str | None = None
|
||||||
|
source: str | None = None
|
||||||
|
job_post_ids: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TriageOverrideBody(BaseModel):
|
class TriageOverrideBody(BaseModel):
|
||||||
|
|
@ -104,13 +183,14 @@ async def start_email_sync(
|
||||||
top:int=Query(100,ge=1,le=100),
|
top:int=Query(100,ge=1,le=100),
|
||||||
skip:int=Query(0,ge=0),
|
skip:int=Query(0,ge=0),
|
||||||
test_on: bool = Query(True),
|
test_on: bool = Query(True),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
current_user: dict | None = Depends(inbox_sync_caller),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Enqueue mailbox sync on the dedicated mailbox_sync Taskiq queue.
|
"""Enqueue mailbox sync on the dedicated mailbox_sync Taskiq queue.
|
||||||
|
|
||||||
Returns immediately with a run id. Poll GET /email/sync/fetch until completed.
|
Returns immediately with a run id. Poll GET /email/sync/fetch until completed.
|
||||||
Closing the browser does not cancel the worker.
|
Closing the browser does not cancel the worker. The daily cron uses the same
|
||||||
|
route with CRON_INBOX_SYNC_TOKEN instead of a recruiter JWT.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
service=Email(session=session)
|
service=Email(session=session)
|
||||||
|
|
@ -199,6 +279,23 @@ async def assign_job_post(
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/inbox/{record_id}/assign-recruiter")
|
||||||
|
async def assign_recruiter(
|
||||||
|
record_id: str,
|
||||||
|
payload: AssignRecruiterBody,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=Email(session=session)
|
||||||
|
data=await service.assign_recruiter(record_id,payload.recruiter_id)
|
||||||
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/inbox/{record_id}/read")
|
@router.post("/inbox/{record_id}/read")
|
||||||
async def mark_inbox_read(
|
async def mark_inbox_read(
|
||||||
record_id: str,
|
record_id: str,
|
||||||
|
|
@ -252,7 +349,12 @@ async def mark_all_inbox_read(
|
||||||
application_status=payload.application_status,
|
application_status=payload.application_status,
|
||||||
assigned=payload.assigned,
|
assigned=payload.assigned,
|
||||||
is_duplicate=payload.is_duplicate,
|
is_duplicate=payload.is_duplicate,
|
||||||
|
no_suggestions=payload.no_suggestions,
|
||||||
processing_state=payload.processing_state,
|
processing_state=payload.processing_state,
|
||||||
|
city=_city_values(payload.city),
|
||||||
|
source=(payload.source or "").strip() or None,
|
||||||
|
has_suggestions=payload.has_suggestions,
|
||||||
|
job_post_ids=_job_ids(payload.job_post_ids),
|
||||||
)
|
)
|
||||||
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
|
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -285,8 +387,13 @@ async def get_all_applications(
|
||||||
assigned: bool | None = Query(default=None),
|
assigned: bool | None = Query(default=None),
|
||||||
is_duplicate: bool | None = Query(default=None),
|
is_duplicate: bool | None = Query(default=None),
|
||||||
no_suggestions: bool | None = Query(default=None),
|
no_suggestions: bool | None = Query(default=None),
|
||||||
|
has_suggestions: bool | None = Query(default=None),
|
||||||
processing_state: str | None = Query(default=None),
|
processing_state: str | None = Query(default=None),
|
||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
|
city: str | None = Query(None),
|
||||||
|
source: str | None = Query(None),
|
||||||
|
job_post_ids: str | None = Query(None),
|
||||||
|
city_list: bool = Query(default=False),
|
||||||
# Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged.
|
# Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged.
|
||||||
top: int | None = Query(None, ge=1, le=500),
|
top: int | None = Query(None, ge=1, le=500),
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
|
|
@ -295,22 +402,28 @@ async def get_all_applications(
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Email(session=session)
|
service=Email(session=session)
|
||||||
|
city_values=_city_values(city)
|
||||||
|
source_value=(source or "").strip() or None
|
||||||
|
job_ids=_job_ids(job_post_ids)
|
||||||
|
extra=dict(has_suggestions=has_suggestions,job_post_ids=job_ids)
|
||||||
|
cities=await service.list_cities() if city_list else None
|
||||||
|
sources=await service.list_sources() if city_list else None
|
||||||
|
|
||||||
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
|
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
|
||||||
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
|
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value, **extra)
|
||||||
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
|
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value, **extra)
|
||||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
return _apps_payload(items,total,cities,sources)
|
||||||
if isread==False:
|
if isread==False:
|
||||||
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
|
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value, **extra)
|
||||||
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
|
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value, **extra)
|
||||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
return _apps_payload(items,total,cities,sources)
|
||||||
if record_id:
|
if record_id:
|
||||||
item=await service.get_application_by_id(record_id)
|
item=await service.get_application_by_id(record_id)
|
||||||
return JSONResponse(content={"data":item,"total":1,"status_code":200})
|
return _apps_payload(item,1,cities,sources)
|
||||||
|
|
||||||
items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value,**extra)
|
||||||
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value,**extra)
|
||||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
return _apps_payload(items,total,cities,sources)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -450,3 +563,38 @@ async def reply_email(
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/inbox/rescan-on-hold")
|
||||||
|
async def start_on_hold_rescan(
|
||||||
|
payload: OnHoldRescanBody,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Score every On-Hold CV against all job posts. Poll GET until completed."""
|
||||||
|
try:
|
||||||
|
service=Email(session=session)
|
||||||
|
data=await service.start_on_hold_rescan(
|
||||||
|
channel=payload.channel,sheet=payload.sheet,current_user=current_user,
|
||||||
|
)
|
||||||
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inbox/rescan-on-hold")
|
||||||
|
async def fetch_on_hold_rescan(
|
||||||
|
run_id: str | None = Query(None),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=Email(session=session)
|
||||||
|
data=await service.get_on_hold_rescan(run_id=run_id)
|
||||||
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,13 @@ from datetime import datetime,timezone
|
||||||
|
|
||||||
import redis.asyncio as redis
|
import redis.asyncio as redis
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from taskiq import TaskiqEvents
|
||||||
|
|
||||||
from db_setup import session_scope
|
from db_setup import session_scope
|
||||||
from inbox.models import MailboxSyncRun
|
from inbox.models import MailboxSyncRun
|
||||||
from inbox.views import Email
|
from inbox.views import Email
|
||||||
from taskiq_management.broker_setup import MAX_RETRIES,RETRY_DELAY
|
from taskiq_management.broker_setup import MAX_RETRIES,RETRY_DELAY
|
||||||
from taskiq_management.mailbox_sync_broker_setup import mailbox_sync_broker
|
from taskiq_management.mailbox_sync_broker_setup import MAILBOX_SYNC_QUEUE_NAME,mailbox_sync_broker
|
||||||
from taskiq_management.middleware import PermanentTaskError
|
from taskiq_management.middleware import PermanentTaskError
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
@ -22,6 +23,21 @@ logger=logging.getLogger("inbox.mailbox_sync")
|
||||||
REDIS_URL=os.getenv("REDIS_URL","redis://localhost:6379/0")
|
REDIS_URL=os.getenv("REDIS_URL","redis://localhost:6379/0")
|
||||||
_LOCK_KEY="inbox:mailbox_sync:lock"
|
_LOCK_KEY="inbox:mailbox_sync:lock"
|
||||||
_LOCK_TTL=900
|
_LOCK_TTL=900
|
||||||
|
_CONSUMER_GROUP=os.getenv("TASKIQ_CONSUMER_GROUP","taskiq")
|
||||||
|
# taskiq-redis listen() skips XAUTOCLAIM while this key exists. redis-py Lock has
|
||||||
|
# no TTL by default, so SIGKILL (compose rebuild) leaves it forever and the Sync
|
||||||
|
# button stays on a running row nobody will finish.
|
||||||
|
_AUTOCLAIM_KEY=f"autoclaim:{_CONSUMER_GROUP}:{MAILBOX_SYNC_QUEUE_NAME}"
|
||||||
|
|
||||||
|
|
||||||
|
@mailbox_sync_broker.on_event(TaskiqEvents.WORKER_STARTUP)
|
||||||
|
async def _drop_stale_autoclaim(_state) -> None:
|
||||||
|
client=redis.from_url(REDIS_URL,decode_responses=True)
|
||||||
|
try:
|
||||||
|
if await client.delete(_AUTOCLAIM_KEY):
|
||||||
|
logger.warning("dropped stale autoclaim lock %s",_AUTOCLAIM_KEY)
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
async def _fail(run_id:str,error:str) -> dict:
|
async def _fail(run_id:str,error:str) -> dict:
|
||||||
|
|
@ -49,13 +65,25 @@ async def sync_mailbox(run_id:str) -> dict:
|
||||||
try:
|
try:
|
||||||
acquired=await client.set(_LOCK_KEY,run_id,nx=True,ex=_LOCK_TTL)
|
acquired=await client.set(_LOCK_KEY,run_id,nx=True,ex=_LOCK_TTL)
|
||||||
if not acquired:
|
if not acquired:
|
||||||
return await _fail(run_id,"another mailbox sync is already running")
|
holder=await client.get(_LOCK_KEY)
|
||||||
|
# Crash/restart redelivers the same run_id while the TTL lock is
|
||||||
|
# still set. Failing that as "another sync" strands the lock until
|
||||||
|
# expiry and every later click also bounces.
|
||||||
|
if holder==run_id:
|
||||||
|
await client.expire(_LOCK_KEY,_LOCK_TTL)
|
||||||
|
logger.warning("mailbox sync %s reclaimed its own stale lock",run_id)
|
||||||
|
else:
|
||||||
|
logger.warning("mailbox sync %s skipped: lock held by %s",run_id,holder)
|
||||||
|
return await _fail(run_id,"another mailbox sync is already running")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with session_scope() as session:
|
async with session_scope() as session:
|
||||||
row=await MailboxSyncRun.get_by_id(session,run_id)
|
row=await MailboxSyncRun.get_by_id(session,run_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise PermanentTaskError(f"sync run {run_id} not found")
|
raise PermanentTaskError(f"sync run {run_id} not found")
|
||||||
|
if row.status in ("completed","failed"):
|
||||||
|
logger.info("mailbox sync %s already %s, skipping",run_id,row.status)
|
||||||
|
return {"status":row.status,"error":row.error}
|
||||||
await MailboxSyncRun.update_run(session,run_id,{
|
await MailboxSyncRun.update_run(session,run_id,{
|
||||||
"status":"running",
|
"status":"running",
|
||||||
"started_at":datetime.now(timezone.utc),
|
"started_at":datetime.now(timezone.utc),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,7 +3,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -138,6 +137,7 @@ def load_file_bytes(path_or_url: str) -> bytes | None:
|
||||||
|
|
||||||
|
|
||||||
def load_message_files(message:Inbox_Messages) -> list[dict]:
|
def load_message_files(message:Inbox_Messages) -> list[dict]:
|
||||||
|
"""Filename + public URL only. Open-resume uses the S3 link; do not pull bytes."""
|
||||||
if not message.file_path:
|
if not message.file_path:
|
||||||
return []
|
return []
|
||||||
names=[n.strip() for n in (message.file_name or "").split(",") if n.strip()]
|
names=[n.strip() for n in (message.file_name or "").split(",") if n.strip()]
|
||||||
|
|
@ -147,22 +147,6 @@ def load_message_files(message:Inbox_Messages) -> list[dict]:
|
||||||
entry={"file_name":name or "resume.pdf","url":None,"content_base64":None,"size":0}
|
entry={"file_name":name or "resume.pdf","url":None,"content_base64":None,"size":0}
|
||||||
if path_str.lower().startswith("http://") or path_str.lower().startswith("https://"):
|
if path_str.lower().startswith("http://") or path_str.lower().startswith("https://"):
|
||||||
entry["url"]=path_str
|
entry["url"]=path_str
|
||||||
raw=load_file_bytes(path_str)
|
|
||||||
if raw is not None:
|
|
||||||
entry["content_base64"]=base64.b64encode(raw).decode("ascii")
|
|
||||||
entry["size"]=len(raw)
|
|
||||||
files.append(entry)
|
|
||||||
continue
|
|
||||||
path=resolve_attachment_path(path_str)
|
|
||||||
if not path.is_file():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
raw=path.read_bytes()
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
entry["file_name"]=path.name
|
|
||||||
entry["content_base64"]=base64.b64encode(raw).decode("ascii")
|
|
||||||
entry["size"]=len(raw)
|
|
||||||
files.append(entry)
|
files.append(entry)
|
||||||
return files
|
return files
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,24 @@ from pathlib import Path
|
||||||
|
|
||||||
from inbox.models import Inbox_Message_Triage, Inbox_Messages
|
from inbox.models import Inbox_Message_Triage, Inbox_Messages
|
||||||
|
|
||||||
|
_PHONE_PLACEHOLDER = "xxx-xxx-xxxx"
|
||||||
|
|
||||||
|
|
||||||
|
def _stored_phone(value):
|
||||||
|
text = (value or "").strip()
|
||||||
|
if not text or text.lower() == _PHONE_PLACEHOLDER:
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _stored_experience(value):
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||||
|
years = int(value)
|
||||||
|
return str(years)
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
# match_status (inbox/tasks.py) -> the resume badge the inbox tabs render.
|
# match_status (inbox/tasks.py) -> the resume badge the inbox tabs render.
|
||||||
_RESUME_STATUS = {
|
_RESUME_STATUS = {
|
||||||
"processing": "Parsing",
|
"processing": "Parsing",
|
||||||
|
|
@ -13,9 +31,19 @@ _RESUME_STATUS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _sender_name(message: Inbox_Messages) -> str:
|
def _sender_name(message: Inbox_Messages, *, light: bool = False) -> str:
|
||||||
"""Graph's display name when the payload carries one, else the raw address."""
|
"""Graph's display name when the payload carries one, else the raw address.
|
||||||
sender_name = message.message_from
|
|
||||||
|
`light` must not touch `full_email_response` — the list query defers that
|
||||||
|
column, and reading it here would lazy-load the whole Graph payload per row.
|
||||||
|
"""
|
||||||
|
raw = message.message_from or ""
|
||||||
|
if light:
|
||||||
|
# "Jane Doe <jane@x.com>" → Jane Doe; otherwise the address as stored.
|
||||||
|
if "<" in raw and raw.endswith(">"):
|
||||||
|
return raw.split("<", 1)[0].strip() or raw
|
||||||
|
return raw
|
||||||
|
sender_name = raw
|
||||||
full = message.full_email_response
|
full = message.full_email_response
|
||||||
if isinstance(full, dict):
|
if isinstance(full, dict):
|
||||||
from_block = full.get("from")
|
from_block = full.get("from")
|
||||||
|
|
@ -50,6 +78,8 @@ def serialize_message(message: Inbox_Messages, *, linkedin_url=None) -> dict:
|
||||||
"subject": message.message_subject,
|
"subject": message.message_subject,
|
||||||
"body": message.message_body,
|
"body": message.message_body,
|
||||||
"when": message.message_received_time,
|
"when": message.message_received_time,
|
||||||
|
"received": message.message_received_time,
|
||||||
|
"created_at": message.created_at.isoformat() if message.created_at else None,
|
||||||
"unread": not message.message_read,
|
"unread": not message.message_read,
|
||||||
"attachment": message.attachment,
|
"attachment": message.attachment,
|
||||||
"attachment_name": attachment_name,
|
"attachment_name": attachment_name,
|
||||||
|
|
@ -70,6 +100,28 @@ def serialize_message(message: Inbox_Messages, *, linkedin_url=None) -> dict:
|
||||||
"match_error": message.match_error,
|
"match_error": message.match_error,
|
||||||
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
|
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
|
||||||
"resume_text": message.resume_text,
|
"resume_text": message.resume_text,
|
||||||
|
"ats_score": message.ats_score,
|
||||||
|
"ats_band": message.ats_band or None,
|
||||||
|
"professional_summary": message.professional_summary or None,
|
||||||
|
"phone": _stored_phone(message.candidate_phone_number),
|
||||||
|
"experience": _stored_experience(message.experience),
|
||||||
|
"current_employment": message.current_employment or "",
|
||||||
|
"current_title": message.current_title or "",
|
||||||
|
"city": message.city or None,
|
||||||
|
"education": message.candidate_education or "",
|
||||||
|
"recruiter_id": str(message.recruiter_id) if message.recruiter_id else None,
|
||||||
|
"recruiter": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_ats_result(row) -> dict:
|
||||||
|
"""One inbox ats_results row — same keys the Sheet Forms cards paint."""
|
||||||
|
return {
|
||||||
|
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
|
||||||
|
"overall_score": row.overall_score,
|
||||||
|
"band": row.band or None,
|
||||||
|
"professional_summary": row.professional_summary or None,
|
||||||
|
"computed_at": row.computed_at.isoformat() if row.computed_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -81,7 +133,7 @@ _PROCESSING_LABEL = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def serialize_application(message: Inbox_Messages, *, linkedin_url=None) -> dict:
|
def serialize_application(message: Inbox_Messages, *, linkedin_url=None, light: bool = False) -> dict:
|
||||||
"""inbox_messages row -> the shape the #inbox All Applications tab renders.
|
"""inbox_messages row -> the shape the #inbox All Applications tab renders.
|
||||||
|
|
||||||
`position` is the mail subject and `source` is the To address, which is where
|
`position` is the mail subject and `source` is the To address, which is where
|
||||||
|
|
@ -92,19 +144,24 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None) -> dict
|
||||||
processed / rejected from the processing_state column so those writes are
|
processed / rejected from the processing_state column so those writes are
|
||||||
visible; the default `unread` state still follows message_read so existing
|
visible; the default `unread` state still follows message_read so existing
|
||||||
rows keep Read/Unread until someone PATCHes a later state.
|
rows keep Read/Unread until someone PATCHes a later state.
|
||||||
|
|
||||||
|
`light=True` is the list path: skip resume_text / match_reasoning /
|
||||||
|
Graph-payload name lookup so we never touch columns the list query defers.
|
||||||
"""
|
"""
|
||||||
state = (message.processing_state or "").strip().lower()
|
state = (message.processing_state or "").strip().lower()
|
||||||
if state in ("imported", "processed", "rejected"):
|
if state in ("imported", "processed", "rejected"):
|
||||||
processing = _PROCESSING_LABEL[state]
|
processing = _PROCESSING_LABEL[state]
|
||||||
else:
|
else:
|
||||||
processing = "Read" if message.message_read else "Unread"
|
processing = "Read" if message.message_read else "Unread"
|
||||||
return {
|
payload = {
|
||||||
"id": str(message.id),
|
"id": str(message.id),
|
||||||
"name": _sender_name(message),
|
"message_id": str(message.message_id) if message.message_id else None,
|
||||||
|
"name": _sender_name(message, light=light),
|
||||||
"email": message.message_from,
|
"email": message.message_from,
|
||||||
"position": message.message_subject,
|
"position": message.message_subject,
|
||||||
"source": message.message_to,
|
"source": message.message_to,
|
||||||
"received": message.message_received_time,
|
"received": message.message_received_time,
|
||||||
|
"created_at": message.created_at.isoformat() if message.created_at else None,
|
||||||
"unread": not message.message_read,
|
"unread": not message.message_read,
|
||||||
"processing": processing,
|
"processing": processing,
|
||||||
"application_status": message.application_status,
|
"application_status": message.application_status,
|
||||||
|
|
@ -114,25 +171,31 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None) -> dict
|
||||||
"file_path": message.file_path,
|
"file_path": message.file_path,
|
||||||
"linkedin_slug": message.linkedin_slug or None,
|
"linkedin_slug": message.linkedin_slug or None,
|
||||||
"linkedin_url": linkedin_url or None,
|
"linkedin_url": linkedin_url or None,
|
||||||
"resume_text": message.resume_text,
|
|
||||||
"suggested_job_post_ids": list(message.suggested_job_post_ids or []),
|
"suggested_job_post_ids": list(message.suggested_job_post_ids or []),
|
||||||
"assigned_job_post_id": str(message.assigned_job_post_id) if message.assigned_job_post_id else None,
|
"assigned_job_post_id": str(message.assigned_job_post_id) if message.assigned_job_post_id else None,
|
||||||
"match_summary": message.match_summary,
|
"match_summary": message.match_summary,
|
||||||
"match_reasoning": message.match_reasoning,
|
|
||||||
"match_status": message.match_status,
|
"match_status": message.match_status,
|
||||||
"match_error": message.match_error,
|
"match_error": message.match_error,
|
||||||
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
|
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
|
||||||
"ats_score": message.ats_score,
|
"ats_score": message.ats_score,
|
||||||
"ats_band": message.ats_band or None,
|
"ats_band": message.ats_band or None,
|
||||||
"phone": message.candidate_phone_number,
|
"professional_summary": message.professional_summary or None,
|
||||||
"experience": message.experience or "",
|
"phone": _stored_phone(message.candidate_phone_number),
|
||||||
|
"experience": _stored_experience(message.experience),
|
||||||
"current_employment": message.current_employment or "",
|
"current_employment": message.current_employment or "",
|
||||||
"current_title": message.current_title or "",
|
"current_title": message.current_title or "",
|
||||||
"recruiter": str(message.recruiter_id) if message.recruiter_id else None,
|
"city": message.city or None,
|
||||||
|
"education": message.candidate_education or "",
|
||||||
|
"recruiter_id": str(message.recruiter_id) if message.recruiter_id else None,
|
||||||
|
"recruiter": None,
|
||||||
"duplicate": message.is_duplicate,
|
"duplicate": message.is_duplicate,
|
||||||
"processing_state": message.processing_state,
|
"processing_state": message.processing_state,
|
||||||
"source_channel_id": message.source_channel_id,
|
"source_channel_id": message.source_channel_id,
|
||||||
}
|
}
|
||||||
|
if not light:
|
||||||
|
payload["resume_text"] = message.resume_text
|
||||||
|
payload["match_reasoning"] = message.match_reasoning
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def serialize_triage(row: Inbox_Message_Triage) -> dict:
|
def serialize_triage(row: Inbox_Message_Triage) -> dict:
|
||||||
|
|
@ -180,3 +243,28 @@ def serialize_mailbox_sync_run(row) -> dict:
|
||||||
"started_at": row.started_at.isoformat() if row.started_at else None,
|
"started_at": row.started_at.isoformat() if row.started_at else None,
|
||||||
"finished_at": row.finished_at.isoformat() if row.finished_at else None,
|
"finished_at": row.finished_at.isoformat() if row.finished_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_inbox_rescan_run(row) -> dict:
|
||||||
|
"""Progress for the On-Hold catalogue ATS rescan, plus per-pair summaries."""
|
||||||
|
pair_count = int(row.pair_count or 0)
|
||||||
|
done_count = int(row.done_count or 0)
|
||||||
|
return {
|
||||||
|
"id": str(row.id),
|
||||||
|
"status": row.status,
|
||||||
|
"channel": row.channel,
|
||||||
|
"sheet": row.sheet,
|
||||||
|
"task_id": row.task_id,
|
||||||
|
"created_by": str(row.created_by) if row.created_by else None,
|
||||||
|
"job_count": int(row.job_count or 0),
|
||||||
|
"candidate_count": int(row.candidate_count or 0),
|
||||||
|
"skipped_candidates": int(row.skipped_candidates or 0),
|
||||||
|
"skipped_pairs": int(row.skipped_pairs or 0),
|
||||||
|
"pair_count": pair_count,
|
||||||
|
"done_count": done_count,
|
||||||
|
"summaries": list(row.summaries or []),
|
||||||
|
"error": row.error,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"started_at": row.started_at.isoformat() if row.started_at else None,
|
||||||
|
"finished_at": row.finished_at.isoformat() if row.finished_at else None,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ logger=logging.getLogger("inbox.tasks")
|
||||||
_DONE=frozenset({"matched","skipped","no_text","failed","dlq"})
|
_DONE=frozenset({"matched","skipped","no_text","failed","dlq"})
|
||||||
|
|
||||||
|
|
||||||
async def score_message_against_job(record_id:str,job_id:str) -> dict:
|
async def score_message_against_job(record_id:str,job_id:str,rescan_run_id=None) -> dict:
|
||||||
"""ATS-score one inbox CV against one job post — the no-upload path.
|
"""ATS-score one inbox CV against one job post — the no-upload path.
|
||||||
|
|
||||||
The decoded attachment already on disk is the CV; the job post in the
|
The decoded attachment already on disk is the CV; the job post in the
|
||||||
|
|
@ -39,6 +39,8 @@ async def score_message_against_job(record_id:str,job_id:str) -> dict:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise PermanentTaskError("record_id and job_id must be uuids")
|
raise PermanentTaskError("record_id and job_id must be uuids")
|
||||||
|
|
||||||
|
already=False
|
||||||
|
results=[]
|
||||||
async with session_scope() as session:
|
async with session_scope() as session:
|
||||||
link=await Inbox.get_inbox_by_message_id(session,mid)
|
link=await Inbox.get_inbox_by_message_id(session,mid)
|
||||||
if link is not None:
|
if link is not None:
|
||||||
|
|
@ -46,22 +48,86 @@ async def score_message_against_job(record_id:str,job_id:str) -> dict:
|
||||||
# a user, so already_scored must not depend on it.
|
# a user, so already_scored must not depend on it.
|
||||||
existing=await AtsResults.get_for_inbox_job(session,link.id,jid)
|
existing=await AtsResults.get_for_inbox_job(session,link.id,jid)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return {"status":"already_scored"}
|
already=True
|
||||||
job=await JobPosts.get_job_post_by_id(session,job_id)
|
if not already:
|
||||||
if job is None or job.is_deleted:
|
job=await JobPosts.get_job_post_by_id(session,job_id)
|
||||||
raise PermanentTaskError("job post missing or deleted")
|
if job is None or job.is_deleted:
|
||||||
service=CandidateScoring(session=session)
|
raise PermanentTaskError("job post missing or deleted")
|
||||||
try:
|
service=CandidateScoring(session=session)
|
||||||
# Attribute the rows to the job's owner — there is no request user
|
try:
|
||||||
# in a background task.
|
# Attribute the rows to the job's owner — there is no request user
|
||||||
results=await service.score_inbox(job_id,[record_id],{"id":str(job.created_by)})
|
# in a background task.
|
||||||
except HTTPException as exc:
|
results=await service.score_inbox(job_id,[record_id],{"id":str(job.created_by)},rescan_run_id=rescan_run_id)
|
||||||
# 400/404 from score_inbox are permanent (no attachment, bad ids);
|
except HTTPException as exc:
|
||||||
# retrying cannot fix them.
|
# 400/404 from score_inbox are permanent (no attachment, bad ids);
|
||||||
raise PermanentTaskError(str(exc.detail)) from exc
|
# retrying cannot fix them.
|
||||||
|
raise PermanentTaskError(str(exc.detail)) from exc
|
||||||
|
if already:
|
||||||
|
# Assigning a job already scored as a suggestion must still flip the
|
||||||
|
# denormed chip from max-of-suggestions to that job.
|
||||||
|
await denorm_message_ats_score(record_id)
|
||||||
|
return {"status":"already_scored"}
|
||||||
|
await denorm_message_ats_score(record_id)
|
||||||
return {"status":"scored","results":len(results)}
|
return {"status":"scored","results":len(results)}
|
||||||
|
|
||||||
|
|
||||||
|
async def denorm_message_ats_score(record_id:str) -> None:
|
||||||
|
"""Stamp inbox_messages.ats_score: assigned job if set, else max of suggestions."""
|
||||||
|
async with session_scope() as session:
|
||||||
|
msg=await Inbox_Messages.get_inbox_message_by_id(session,record_id)
|
||||||
|
if msg is None:
|
||||||
|
return
|
||||||
|
link=await Inbox.get_inbox_by_message_id(session,record_id)
|
||||||
|
if link is None:
|
||||||
|
return
|
||||||
|
rows=await AtsResults.get_latest_by_job_for_inbox(session,link.id)
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
chosen=None
|
||||||
|
assigned=msg.assigned_job_post_id
|
||||||
|
if assigned:
|
||||||
|
chosen=next((r for r in rows if str(r.job_post_id)==str(assigned)),None)
|
||||||
|
if chosen is None:
|
||||||
|
chosen=max(rows,key=lambda r:float(r.overall_score or 0))
|
||||||
|
await Inbox_Messages.set_ats_score(session,record_id,chosen.overall_score,chosen.band)
|
||||||
|
|
||||||
|
|
||||||
|
async def score_message_against_jobs(record_id:str,job_ids) -> None:
|
||||||
|
"""Score one inbox CV against each job. Failures do not abort the rest."""
|
||||||
|
seen=set()
|
||||||
|
for raw in job_ids or []:
|
||||||
|
job_id=str(raw or "").strip()
|
||||||
|
if not job_id or job_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(job_id)
|
||||||
|
try:
|
||||||
|
outcome=await score_message_against_job(record_id,job_id)
|
||||||
|
logger.info("ats auto-score %s vs %s: %s",record_id,job_id,outcome.get("status"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ats auto-score failed for %s vs %s: %s",record_id,job_id,exc)
|
||||||
|
|
||||||
|
|
||||||
|
@broker.task(
|
||||||
|
task_name="g_sheet.score_form",
|
||||||
|
retry_on_error=True,
|
||||||
|
max_retries=MAX_RETRIES,
|
||||||
|
delay=RETRY_DELAY,
|
||||||
|
)
|
||||||
|
async def score_form_data(form_data_id:str,job_id:str) -> dict:
|
||||||
|
"""ATS-score one Sheet Forms CV (extracted_data) against one job post."""
|
||||||
|
from g_sheet.scoring import score_form_against_job
|
||||||
|
|
||||||
|
try:
|
||||||
|
uuid.UUID(str(form_data_id))
|
||||||
|
uuid.UUID(str(job_id))
|
||||||
|
except (TypeError,ValueError):
|
||||||
|
raise PermanentTaskError("form_data_id and job_id must be uuids")
|
||||||
|
result=await score_form_against_job(form_data_id,job_id)
|
||||||
|
if result.get("status")=="failed":
|
||||||
|
raise RuntimeError(result.get("error_code") or "form ats failed")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@broker.task(
|
@broker.task(
|
||||||
task_name="inbox.score_message",
|
task_name="inbox.score_message",
|
||||||
retry_on_error=True,
|
retry_on_error=True,
|
||||||
|
|
@ -72,6 +138,39 @@ async def score_inbox_message(record_id:str,job_id:str) -> dict:
|
||||||
return await score_message_against_job(record_id,job_id)
|
return await score_message_against_job(record_id,job_id)
|
||||||
|
|
||||||
|
|
||||||
|
@broker.task(
|
||||||
|
task_name="inbox.rescan_on_hold",
|
||||||
|
retry_on_error=True,
|
||||||
|
max_retries=MAX_RETRIES,
|
||||||
|
delay=RETRY_DELAY,
|
||||||
|
)
|
||||||
|
async def rescan_on_hold_run(run_id:str,cursor:int=0) -> dict:
|
||||||
|
"""Score On-Hold CVs against every job post, in short idempotent chunks."""
|
||||||
|
from g_sheet.scoring import score_form_against_job
|
||||||
|
from inbox.views import Email
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
prepared=await Email(session=session).prepare_on_hold_rescan_chunk(run_id,cursor)
|
||||||
|
status=prepared.get("status")
|
||||||
|
if status in ("completed","failed","missing"):
|
||||||
|
return prepared
|
||||||
|
for item in prepared.get("batch") or []:
|
||||||
|
kind=item.get("kind")
|
||||||
|
record_id=item.get("record_id")
|
||||||
|
job_id=item.get("job_id")
|
||||||
|
try:
|
||||||
|
if kind=="form":
|
||||||
|
await score_form_against_job(record_id,job_id,run_id)
|
||||||
|
else:
|
||||||
|
await score_message_against_job(record_id,job_id,run_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("on-hold rescan failed for %s vs %s: %s",record_id,job_id,exc)
|
||||||
|
async with session_scope() as session:
|
||||||
|
return await Email(session=session).finish_on_hold_rescan_chunk(
|
||||||
|
run_id,prepared.get("next_cursor") or 0,bool(prepared.get("more")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@broker.task(
|
@broker.task(
|
||||||
task_name="inbox.match_message",
|
task_name="inbox.match_message",
|
||||||
retry_on_error=True,
|
retry_on_error=True,
|
||||||
|
|
@ -114,9 +213,8 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
|
||||||
|
|
||||||
result=await run_agent(subject=subject,resume_text=text,job_posts=job_posts)
|
result=await run_agent(subject=subject,resume_text=text,job_posts=job_posts)
|
||||||
status=result.get("status") or "failed"
|
status=result.get("status") or "failed"
|
||||||
if status=="failed":
|
# Extract the profile even when matching finds no job — On-Hold / unassigned
|
||||||
raise RuntimeError(result.get("error") or "agent returned failed status")
|
# CVs still need name, title, years, and phone on every screen.
|
||||||
|
|
||||||
fields=await run_employment_agent(
|
fields=await run_employment_agent(
|
||||||
resume_text=text if not body else f"{text}\n\n{body}",
|
resume_text=text if not body else f"{text}\n\n{body}",
|
||||||
)
|
)
|
||||||
|
|
@ -125,41 +223,63 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
|
||||||
current_title=fields["current_title"]
|
current_title=fields["current_title"]
|
||||||
linkedin_url=fields["linkedin_url"]
|
linkedin_url=fields["linkedin_url"]
|
||||||
phone=fields["phone"]
|
phone=fields["phone"]
|
||||||
|
city=fields.get("city") or None
|
||||||
|
years=fields.get("years_experience")
|
||||||
|
experience=(result.get("experience") or "").strip()
|
||||||
|
if not experience and years is not None:
|
||||||
|
experience=str(int(years)) if isinstance(years,(int,float)) and not isinstance(years,bool) else str(years)
|
||||||
|
|
||||||
|
if status=="failed":
|
||||||
|
async with session_scope() as session:
|
||||||
|
await Inbox_Messages.set_match_result(
|
||||||
|
session,
|
||||||
|
record_id,
|
||||||
|
resume_text=text,
|
||||||
|
experience=experience,
|
||||||
|
candidate_phone_number=phone if phone else "",
|
||||||
|
current_employment=current_employment,
|
||||||
|
current_title=current_title,
|
||||||
|
candidate_education=education,
|
||||||
|
linkedin_url=linkedin_url,
|
||||||
|
city=city,
|
||||||
|
suggested_job_post_ids=[],
|
||||||
|
summary=result.get("summary") or "",
|
||||||
|
reasoning=result.get("reasoning") or "",
|
||||||
|
status="failed",
|
||||||
|
error=result.get("error") or "agent returned failed status",
|
||||||
|
)
|
||||||
|
raise RuntimeError(result.get("error") or "agent returned failed status")
|
||||||
|
|
||||||
async with session_scope() as session:
|
async with session_scope() as session:
|
||||||
await Inbox_Messages.set_match_result(
|
await Inbox_Messages.set_match_result(
|
||||||
session,
|
session,
|
||||||
record_id,
|
record_id,
|
||||||
resume_text=text,
|
resume_text=text,
|
||||||
experience=result.get("experience") or "",
|
experience=experience,
|
||||||
candidate_phone_number=phone,
|
candidate_phone_number=phone if phone else "",
|
||||||
current_employment=current_employment,
|
current_employment=current_employment,
|
||||||
current_title=current_title,
|
current_title=current_title,
|
||||||
candidate_education=education,
|
candidate_education=education,
|
||||||
linkedin_url=linkedin_url,
|
linkedin_url=linkedin_url,
|
||||||
|
city=city,
|
||||||
suggested_job_post_ids=result.get("suggested_job_post_ids") or [],
|
suggested_job_post_ids=result.get("suggested_job_post_ids") or [],
|
||||||
summary=result.get("summary") or "",
|
summary=result.get("summary") or "",
|
||||||
reasoning=result.get("reasoning") or "",
|
reasoning=result.get("reasoning") or "",
|
||||||
status=status,
|
status=status,
|
||||||
error=result.get("error") or "",
|
error=result.get("error") or "",
|
||||||
)
|
)
|
||||||
# Auto-score: the match just paired this CV with jobs, so run the ATS on the
|
# Auto-score every suggested job. The list chip is the max until a recruiter
|
||||||
# spot — assigned job first, else the agent's top suggestion. Scoring failures
|
# assigns one; assignment then re-runs ATS against that job_post_id.
|
||||||
# must not fail the match; the match result is already committed above.
|
# Scoring failures must not fail the match; the match result is committed above.
|
||||||
suggested=[str(j) for j in (result.get("suggested_job_post_ids") or []) if j]
|
suggested=[str(j) for j in (result.get("suggested_job_post_ids") or []) if j]
|
||||||
score_job_id=None
|
score_job_ids=list(suggested)
|
||||||
async with session_scope() as session:
|
async with session_scope() as session:
|
||||||
fresh=await Inbox_Messages.get_inbox_message_by_id(session,record_id)
|
fresh=await Inbox_Messages.get_inbox_message_by_id(session,record_id)
|
||||||
if fresh is not None and fresh.assigned_job_post_id:
|
if fresh is not None and fresh.assigned_job_post_id:
|
||||||
score_job_id=str(fresh.assigned_job_post_id)
|
assigned=str(fresh.assigned_job_post_id)
|
||||||
if score_job_id is None and suggested:
|
if assigned not in score_job_ids:
|
||||||
score_job_id=suggested[0]
|
score_job_ids.append(assigned)
|
||||||
if score_job_id:
|
await score_message_against_jobs(record_id,score_job_ids)
|
||||||
try:
|
|
||||||
outcome=await score_message_against_job(record_id,score_job_id)
|
|
||||||
logger.info("ats auto-score %s vs %s: %s",record_id,score_job_id,outcome.get("status"))
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("ats auto-score failed for %s vs %s: %s",record_id,score_job_id,exc)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status":status,
|
"status":status,
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ import uuid
|
||||||
import httpx,os
|
import httpx,os
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from inbox.enums import Candidate_application_Status
|
from inbox.enums import Candidate_application_Status
|
||||||
from inbox.models import Inbox_Messages,Inbox_Message_Triage,MailboxSyncRun,Inbox
|
from inbox.models import Inbox_Messages,Inbox_Message_Triage,MailboxSyncRun,InboxRescanRun,Inbox,SourceChannels,AtsResults
|
||||||
from inbox.file_decoder import extract_pdf_attachments
|
from inbox.file_decoder import extract_pdf_attachments
|
||||||
from inbox.serializers import serialize_application, serialize_message, serialize_triage, serialize_mailbox_sync_run
|
from inbox.serializers import serialize_application, serialize_message, serialize_triage, serialize_mailbox_sync_run, serialize_inbox_rescan_run, serialize_ats_result
|
||||||
from inbox.plugins import (
|
from inbox.plugins import (
|
||||||
EMAIL_API_TOKEN,
|
EMAIL_API_TOKEN,
|
||||||
attach_email_pdfs_to_s3,
|
attach_email_pdfs_to_s3,
|
||||||
|
|
@ -212,6 +212,7 @@ class Email:
|
||||||
row,new_user_email=await Inbox_Messages.insert_email(
|
row,new_user_email=await Inbox_Messages.insert_email(
|
||||||
session=self.session,email_data=data,file_path=None,
|
session=self.session,email_data=data,file_path=None,
|
||||||
)
|
)
|
||||||
|
await Reapplied(session=self.session).sync_for_email(row.message_from)
|
||||||
if pdfs:
|
if pdfs:
|
||||||
row=await attach_email_pdfs_to_s3(
|
row=await attach_email_pdfs_to_s3(
|
||||||
self.session,row,pdfs,created_new=(already is None),
|
self.session,row,pdfs,created_new=(already is None),
|
||||||
|
|
@ -255,40 +256,123 @@ class Email:
|
||||||
item["files"]=files
|
item["files"]=files
|
||||||
from job.candidate.views import CandidateView
|
from job.candidate.views import CandidateView
|
||||||
cv=CandidateView(session=self.session)
|
cv=CandidateView(session=self.session)
|
||||||
suggested=[]
|
items=await self._attach_job_posts([item])
|
||||||
for job_id in item.get("suggested_job_post_ids") or []:
|
return await cv.attach_application_history(items[0])
|
||||||
jp=await cv.get_job_post_by_id(record_id=job_id)
|
|
||||||
if jp:
|
|
||||||
if jp.get("is_deleted") or not jp.get("is_active"):
|
|
||||||
suggested.append({**jp,"unavailable":True})
|
|
||||||
else:
|
|
||||||
suggested.append(jp)
|
|
||||||
else:
|
|
||||||
suggested.append({"id":str(job_id),"unavailable":True})
|
|
||||||
item["suggested_job_posts"]=suggested
|
|
||||||
assigned_id=item.get("assigned_job_post_id")
|
|
||||||
if assigned_id:
|
|
||||||
item["assigned_job_post"]=await cv.get_job_post_by_id(record_id=assigned_id)
|
|
||||||
else:
|
|
||||||
item["assigned_job_post"]=None
|
|
||||||
return item
|
|
||||||
|
|
||||||
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None):
|
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None,has_suggestions=None,job_post_ids=None):
|
||||||
|
extra=dict(has_suggestions=has_suggestions,job_post_ids=job_post_ids,light=True)
|
||||||
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
|
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
|
||||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
|
||||||
elif isread==False:
|
elif isread==False:
|
||||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
|
||||||
else:
|
else:
|
||||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
|
||||||
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages])
|
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages])
|
||||||
return [serialize_application(m,linkedin_url=urls.get(m.id)) for m in messages]
|
items=[serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages]
|
||||||
|
items=await self._attach_job_posts(items)
|
||||||
|
from job.candidate.views import CandidateView
|
||||||
|
return await CandidateView(session=self.session).attach_application_history(items)
|
||||||
|
|
||||||
|
async def _attach_job_posts(self,items):
|
||||||
|
"""List payload needs assigned + suggested titles — export reads names.
|
||||||
|
|
||||||
|
Detail hydrates one row. Full JD (location, requirements) loads when
|
||||||
|
the recruiter expands a card. Assigned job and Suggested jobs in the
|
||||||
|
Inbox .xlsx stay as titles.
|
||||||
|
"""
|
||||||
|
ids=[]
|
||||||
|
for item in items:
|
||||||
|
aid=item.get("assigned_job_post_id")
|
||||||
|
if aid:
|
||||||
|
ids.append(aid)
|
||||||
|
for sid in item.get("suggested_job_post_ids") or []:
|
||||||
|
if sid:
|
||||||
|
ids.append(sid)
|
||||||
|
by_id={}
|
||||||
|
if ids:
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
from job.job_post.serializers import serialize_job_post_title
|
||||||
|
for post in await JobPosts.titles_by_ids(self.session,ids,active_only=False):
|
||||||
|
payload=serialize_job_post_title(post)
|
||||||
|
if post.is_deleted or not post.is_active:
|
||||||
|
payload={**payload,"unavailable":True}
|
||||||
|
by_id[str(post.id)]=payload
|
||||||
|
for item in items:
|
||||||
|
aid=item.get("assigned_job_post_id")
|
||||||
|
item["assigned_job_post"]=by_id.get(str(aid)) if aid else None
|
||||||
|
suggested=[]
|
||||||
|
for sid in item.get("suggested_job_post_ids") or []:
|
||||||
|
if not sid:
|
||||||
|
continue
|
||||||
|
payload=by_id.get(str(sid))
|
||||||
|
if payload is None:
|
||||||
|
suggested.append({"id":str(sid),"unavailable":True})
|
||||||
|
else:
|
||||||
|
suggested.append(dict(payload))
|
||||||
|
item["suggested_job_posts"]=suggested
|
||||||
|
items=await self._paint_inbox_ats(items)
|
||||||
|
items=await self._attach_recruiters(items)
|
||||||
|
return items
|
||||||
|
|
||||||
|
async def _attach_recruiters(self,items):
|
||||||
|
"""Resolve recruiter_id → display name. Serializer leaves recruiter None."""
|
||||||
|
ids=[item.get("recruiter_id") for item in items if item.get("recruiter_id")]
|
||||||
|
names={}
|
||||||
|
if ids:
|
||||||
|
from users.models import Users
|
||||||
|
names=await Users.names_by_ids(self.session,ids)
|
||||||
|
for item in items:
|
||||||
|
rid=item.get("recruiter_id")
|
||||||
|
item["recruiter"]=names.get(str(rid)) if rid else None
|
||||||
|
return items
|
||||||
|
|
||||||
|
async def _paint_inbox_ats(self,items):
|
||||||
|
"""Attach per-job ATS scores onto suggested/assigned posts and stamp max.
|
||||||
|
|
||||||
|
Unassigned rows show the highest suggestion score; assigned rows show
|
||||||
|
the score against assigned_job_post_id — same rule as Sheet Forms.
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
return items
|
||||||
|
by_msg=await AtsResults.get_latest_by_job_for_messages(
|
||||||
|
self.session,[item.get("id") for item in items],
|
||||||
|
)
|
||||||
|
for item in items:
|
||||||
|
rows=by_msg.get(str(item.get("id") or "")) or []
|
||||||
|
scores=[serialize_ats_result(r) for r in rows]
|
||||||
|
item["ats_results"]=scores
|
||||||
|
score_by_job={
|
||||||
|
str(s["job_post_id"]):s for s in scores if s.get("job_post_id")
|
||||||
|
}
|
||||||
|
for post in item.get("suggested_job_posts") or []:
|
||||||
|
hit=score_by_job.get(str(post.get("id")))
|
||||||
|
if hit:
|
||||||
|
post["overall_score"]=hit.get("overall_score")
|
||||||
|
post["band"]=hit.get("band")
|
||||||
|
assigned=item.get("assigned_job_post")
|
||||||
|
if assigned:
|
||||||
|
hit=score_by_job.get(str(assigned.get("id")))
|
||||||
|
if hit:
|
||||||
|
assigned["overall_score"]=hit.get("overall_score")
|
||||||
|
assigned["band"]=hit.get("band")
|
||||||
|
aid=item.get("assigned_job_post_id")
|
||||||
|
assigned_score=score_by_job.get(str(aid)) if aid else None
|
||||||
|
if assigned_score and assigned_score.get("overall_score") is not None:
|
||||||
|
item["ats_score"]=round(float(assigned_score.get("overall_score")))
|
||||||
|
else:
|
||||||
|
nums=[s.get("overall_score") for s in scores if s.get("overall_score") is not None]
|
||||||
|
if nums:
|
||||||
|
item["ats_score"]=round(float(max(nums)))
|
||||||
|
return items
|
||||||
|
|
||||||
async def get_application_by_id(self,record_id):
|
async def get_application_by_id(self,record_id):
|
||||||
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||||
if not message:
|
if not message:
|
||||||
raise HTTPException(status_code=404,detail="Application not found")
|
raise HTTPException(status_code=404,detail="Application not found")
|
||||||
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[message.id])
|
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[message.id])
|
||||||
return serialize_application(message,linkedin_url=urls.get(message.id))
|
item=serialize_application(message,linkedin_url=urls.get(message.id))
|
||||||
|
from job.candidate.views import CandidateView
|
||||||
|
return await CandidateView(session=self.session).attach_application_history(item)
|
||||||
|
|
||||||
async def queue_rematch(self,record_id):
|
async def queue_rematch(self,record_id):
|
||||||
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||||
|
|
@ -315,7 +399,9 @@ class Email:
|
||||||
"""Enqueue Outlook pull on the mailbox_sync queue; return the run row.
|
"""Enqueue Outlook pull on the mailbox_sync queue; return the run row.
|
||||||
|
|
||||||
If a queued/running sync already exists, return it instead of stacking another.
|
If a queued/running sync already exists, return it instead of stacking another.
|
||||||
|
A stranded row from a killed worker is failed first so Sync is clickable again.
|
||||||
"""
|
"""
|
||||||
|
await MailboxSyncRun.fail_stale(self.session)
|
||||||
active=await MailboxSyncRun.get_active(self.session)
|
active=await MailboxSyncRun.get_active(self.session)
|
||||||
if active:
|
if active:
|
||||||
return serialize_mailbox_sync_run(active)
|
return serialize_mailbox_sync_run(active)
|
||||||
|
|
@ -342,6 +428,7 @@ class Email:
|
||||||
return serialize_mailbox_sync_run(row)
|
return serialize_mailbox_sync_run(row)
|
||||||
|
|
||||||
async def get_mailbox_sync(self,run_id=None):
|
async def get_mailbox_sync(self,run_id=None):
|
||||||
|
await MailboxSyncRun.fail_stale(self.session)
|
||||||
if run_id:
|
if run_id:
|
||||||
row=await MailboxSyncRun.get_by_id(self.session,run_id)
|
row=await MailboxSyncRun.get_by_id(self.session,run_id)
|
||||||
if not row:
|
if not row:
|
||||||
|
|
@ -356,6 +443,266 @@ class Email:
|
||||||
raise HTTPException(status_code=404,detail="No sync runs yet")
|
raise HTTPException(status_code=404,detail="No sync runs yet")
|
||||||
return serialize_mailbox_sync_run(row)
|
return serialize_mailbox_sync_run(row)
|
||||||
|
|
||||||
|
async def start_on_hold_rescan(self,channel="all",sheet=None,current_user=None):
|
||||||
|
"""Queue an On-Hold catalogue ATS scan against every job_posts row.
|
||||||
|
|
||||||
|
Returns an existing queued/running/scoring run instead of stacking another.
|
||||||
|
"""
|
||||||
|
channel=(channel or "all").strip().lower()
|
||||||
|
if channel not in ("all","email","forms"):
|
||||||
|
raise HTTPException(status_code=422,detail="channel must be all, email, or forms")
|
||||||
|
sheet=(sheet or "").strip() or None
|
||||||
|
if channel!="forms":
|
||||||
|
sheet=None
|
||||||
|
|
||||||
|
active=await InboxRescanRun.get_active(self.session)
|
||||||
|
if active:
|
||||||
|
return serialize_inbox_rescan_run(active)
|
||||||
|
|
||||||
|
created_by=None
|
||||||
|
if isinstance(current_user,dict) and current_user.get("id"):
|
||||||
|
created_by=InboxRescanRun._as_uuid(current_user.get("id"))
|
||||||
|
row=await InboxRescanRun.insert_run(self.session,{
|
||||||
|
"status":"queued",
|
||||||
|
"channel":channel,
|
||||||
|
"sheet":sheet,
|
||||||
|
"created_by":created_by,
|
||||||
|
})
|
||||||
|
from inbox.tasks import rescan_on_hold_run
|
||||||
|
try:
|
||||||
|
task=await rescan_on_hold_run.kicker().with_labels(
|
||||||
|
created_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
correlation_id=str(row.id),
|
||||||
|
queue="inbox",
|
||||||
|
).kiq(str(row.id),0)
|
||||||
|
except Exception as exc:
|
||||||
|
await InboxRescanRun.update_run(self.session,row.id,{
|
||||||
|
"status":"failed",
|
||||||
|
"error":str(exc),
|
||||||
|
"finished_at":datetime.now(timezone.utc),
|
||||||
|
})
|
||||||
|
raise HTTPException(status_code=503,detail="Could not queue On-Hold rescan") from exc
|
||||||
|
row=await InboxRescanRun.update_run(self.session,row.id,{"task_id":task.task_id})
|
||||||
|
return serialize_inbox_rescan_run(row)
|
||||||
|
|
||||||
|
async def get_on_hold_rescan(self,run_id=None):
|
||||||
|
if run_id:
|
||||||
|
row=await InboxRescanRun.get_by_id(self.session,run_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404,detail="Rescan run not found")
|
||||||
|
return serialize_inbox_rescan_run(row)
|
||||||
|
row=await InboxRescanRun.get_active(self.session)
|
||||||
|
if row:
|
||||||
|
return serialize_inbox_rescan_run(row)
|
||||||
|
row=await InboxRescanRun.get_latest(self.session)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return serialize_inbox_rescan_run(row)
|
||||||
|
|
||||||
|
async def plan_on_hold_pairs(self,channel,sheet=None):
|
||||||
|
"""Build (candidate, active job) pairs that have never been ATS-scored.
|
||||||
|
|
||||||
|
Only active openings are scored: a score against a closed role is never
|
||||||
|
shown for shortlisting, and each pair is a paid model call.
|
||||||
|
When professional_summary is present, the summary-vs-job gradient runs
|
||||||
|
before the already-scored pair skip: an obvious mismatch never reaches ATS.
|
||||||
|
Skip a candidate who already has a score against an active job.
|
||||||
|
Skip a pair that already exists on ats_results for that person/row.
|
||||||
|
"""
|
||||||
|
from g_sheet.models import FormData
|
||||||
|
from job.candidate.plugins import build_job_description
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
from summary_gate.execute_agent import allow_ats
|
||||||
|
from summary_gate.plugins import GATE_ENABLED
|
||||||
|
|
||||||
|
job_ids=[str(jid) for jid in await JobPosts.list_ids(self.session,active_only=True)]
|
||||||
|
active_ids=set(job_ids)
|
||||||
|
jd_by_id={}
|
||||||
|
if GATE_ENABLED:
|
||||||
|
jobs=await JobPosts.get_by_ids(self.session,job_ids,active_only=False)
|
||||||
|
jd_by_id={str(j.id):build_job_description(j) for j in jobs}
|
||||||
|
|
||||||
|
async def _unsuitable(summary, job_id) -> bool:
|
||||||
|
text=(summary or "").strip()
|
||||||
|
if not text or not GATE_ENABLED:
|
||||||
|
return False
|
||||||
|
jd=jd_by_id.get(str(job_id))
|
||||||
|
if not jd:
|
||||||
|
return False
|
||||||
|
return not await allow_ats(text,jd)
|
||||||
|
inbox_rows=[]
|
||||||
|
form_rows=[]
|
||||||
|
if channel in ("all","email"):
|
||||||
|
inbox_rows=await Inbox_Messages.list_on_hold_scan_rows(self.session)
|
||||||
|
if channel in ("all","forms"):
|
||||||
|
form_rows=await FormData.list_on_hold_scan_rows(self.session,sheet=sheet)
|
||||||
|
|
||||||
|
emails=[]
|
||||||
|
for row in inbox_rows:
|
||||||
|
if row.get("email"):
|
||||||
|
emails.append(row["email"])
|
||||||
|
for row in form_rows:
|
||||||
|
if row.get("email"):
|
||||||
|
emails.append(row["email"])
|
||||||
|
scored_by_email=await AtsResults.job_ids_by_emails(self.session,emails)
|
||||||
|
scored_by_message=await AtsResults.job_ids_for_messages(
|
||||||
|
self.session,[row["id"] for row in inbox_rows],
|
||||||
|
)
|
||||||
|
scored_by_form=await AtsResults.job_ids_for_forms(
|
||||||
|
self.session,[row["id"] for row in form_rows],
|
||||||
|
)
|
||||||
|
skipped_active=set()
|
||||||
|
for email,jobs in scored_by_email.items():
|
||||||
|
if jobs & active_ids:
|
||||||
|
skipped_active.add(email)
|
||||||
|
known_by_email={key:set(jobs) for key,jobs in scored_by_email.items()}
|
||||||
|
|
||||||
|
pairs=[]
|
||||||
|
skipped_candidates=0
|
||||||
|
skipped_pairs=0
|
||||||
|
|
||||||
|
def _already(email,row_jobs):
|
||||||
|
jobs=set(row_jobs or ())
|
||||||
|
if email:
|
||||||
|
jobs |= known_by_email.get(email) or set()
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
def _mark(email,job_id):
|
||||||
|
if email:
|
||||||
|
known_by_email.setdefault(email,set()).add(job_id)
|
||||||
|
|
||||||
|
def _skip_for_active(email,row_jobs):
|
||||||
|
if email and email in skipped_active:
|
||||||
|
return True
|
||||||
|
if (row_jobs or set()) & active_ids:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
for row in inbox_rows:
|
||||||
|
email=row.get("email")
|
||||||
|
mid=str(row["id"])
|
||||||
|
row_jobs=scored_by_message.get(mid) or set()
|
||||||
|
if not row.get("file_path"):
|
||||||
|
skipped_candidates += 1
|
||||||
|
continue
|
||||||
|
if _skip_for_active(email,row_jobs):
|
||||||
|
skipped_candidates += 1
|
||||||
|
continue
|
||||||
|
known=_already(email,row_jobs)
|
||||||
|
for job_id in job_ids:
|
||||||
|
if await _unsuitable(row.get("professional_summary"),job_id):
|
||||||
|
skipped_pairs += 1
|
||||||
|
continue
|
||||||
|
if job_id in known:
|
||||||
|
skipped_pairs += 1
|
||||||
|
continue
|
||||||
|
pairs.append({"kind":"inbox","record_id":mid,"job_id":job_id})
|
||||||
|
known.add(job_id)
|
||||||
|
_mark(email,job_id)
|
||||||
|
|
||||||
|
for row in form_rows:
|
||||||
|
email=row.get("email")
|
||||||
|
fid=str(row["id"])
|
||||||
|
row_jobs=scored_by_form.get(fid) or set()
|
||||||
|
if _skip_for_active(email,row_jobs):
|
||||||
|
skipped_candidates += 1
|
||||||
|
continue
|
||||||
|
known=_already(email,row_jobs)
|
||||||
|
for job_id in job_ids:
|
||||||
|
if await _unsuitable(row.get("professional_summary"),job_id):
|
||||||
|
skipped_pairs += 1
|
||||||
|
continue
|
||||||
|
if job_id in known:
|
||||||
|
skipped_pairs += 1
|
||||||
|
continue
|
||||||
|
pairs.append({"kind":"form","record_id":fid,"job_id":job_id})
|
||||||
|
known.add(job_id)
|
||||||
|
_mark(email,job_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"job_count":len(job_ids),
|
||||||
|
"candidate_count":len(inbox_rows)+len(form_rows),
|
||||||
|
"skipped_candidates":skipped_candidates,
|
||||||
|
"skipped_pairs":skipped_pairs,
|
||||||
|
"pairs":pairs,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def prepare_on_hold_rescan_chunk(self,run_id,cursor=0):
|
||||||
|
"""Plan pairs if needed and return the next batch. Scoring happens outside."""
|
||||||
|
row=await InboxRescanRun.get_by_id(self.session,run_id)
|
||||||
|
if row is None:
|
||||||
|
return {"status":"missing"}
|
||||||
|
if row.status in ("failed","completed"):
|
||||||
|
return {"status":row.status}
|
||||||
|
|
||||||
|
now=datetime.now(timezone.utc)
|
||||||
|
entries=list(row.entries or [])
|
||||||
|
if not entries and int(cursor or 0)==0:
|
||||||
|
await InboxRescanRun.update_run(self.session,run_id,{
|
||||||
|
"status":"running",
|
||||||
|
"started_at":row.started_at or now,
|
||||||
|
})
|
||||||
|
plan=await self.plan_on_hold_pairs(row.channel,row.sheet)
|
||||||
|
entries=plan["pairs"]
|
||||||
|
await InboxRescanRun.update_run(self.session,run_id,{
|
||||||
|
"status":"scoring" if entries else "completed",
|
||||||
|
"job_count":plan["job_count"],
|
||||||
|
"candidate_count":plan["candidate_count"],
|
||||||
|
"skipped_candidates":plan["skipped_candidates"],
|
||||||
|
"skipped_pairs":plan["skipped_pairs"],
|
||||||
|
"pair_count":len(entries),
|
||||||
|
"done_count":0,
|
||||||
|
"entries":entries,
|
||||||
|
"finished_at":None if entries else now,
|
||||||
|
})
|
||||||
|
if not entries:
|
||||||
|
return {"status":"completed","pair_count":0,"batch":[]}
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
await InboxRescanRun.update_run(self.session,run_id,{
|
||||||
|
"status":"failed",
|
||||||
|
"error":"Rescan has no stored pairs",
|
||||||
|
"finished_at":datetime.now(timezone.utc),
|
||||||
|
})
|
||||||
|
return {"status":"failed","batch":[]}
|
||||||
|
|
||||||
|
chunk=4
|
||||||
|
start=int(cursor or 0)
|
||||||
|
batch=entries[start:start+chunk]
|
||||||
|
return {
|
||||||
|
"status":"scoring",
|
||||||
|
"batch":batch,
|
||||||
|
"pair_count":len(entries),
|
||||||
|
"next_cursor":min(start+len(batch),len(entries)),
|
||||||
|
"more":(start+len(batch))<len(entries),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def finish_on_hold_rescan_chunk(self,run_id,next_cursor,more):
|
||||||
|
"""Stamp progress and enqueue the next chunk after scores land."""
|
||||||
|
from inbox.tasks import rescan_on_hold_run
|
||||||
|
|
||||||
|
fields={
|
||||||
|
"status":"scoring" if more else "completed",
|
||||||
|
"done_count":int(next_cursor or 0),
|
||||||
|
"finished_at":None if more else datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
await InboxRescanRun.update_run(self.session,run_id,fields)
|
||||||
|
if more:
|
||||||
|
try:
|
||||||
|
await rescan_on_hold_run.kicker().with_labels(
|
||||||
|
created_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
correlation_id=str(run_id),
|
||||||
|
queue="inbox",
|
||||||
|
).kiq(str(run_id),int(next_cursor or 0))
|
||||||
|
except Exception as exc:
|
||||||
|
await InboxRescanRun.update_run(self.session,run_id,{
|
||||||
|
"status":"failed",
|
||||||
|
"error":str(exc),
|
||||||
|
"finished_at":datetime.now(timezone.utc),
|
||||||
|
})
|
||||||
|
raise
|
||||||
|
return {"status":fields["status"],"done_count":fields["done_count"]}
|
||||||
|
|
||||||
async def run_mailbox_sync_page(self,top=100,skip=0,test_on=True,on_progress=None):
|
async def run_mailbox_sync_page(self,top=100,skip=0,test_on=True,on_progress=None):
|
||||||
"""Pull one Outlook page, triage, ingest, enqueue matching. Returns summary.
|
"""Pull one Outlook page, triage, ingest, enqueue matching. Returns summary.
|
||||||
|
|
||||||
|
|
@ -434,13 +781,30 @@ class Email:
|
||||||
results.append({"email":email,"sent":False})
|
results.append({"email":email,"sent":False})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None):
|
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None,has_suggestions=None,job_post_ids=None):
|
||||||
|
extra=dict(has_suggestions=has_suggestions,job_post_ids=job_post_ids)
|
||||||
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED or processing_state:
|
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED or processing_state:
|
||||||
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
|
||||||
elif isread==False:
|
elif isread==False:
|
||||||
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
|
||||||
else:
|
else:
|
||||||
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
|
||||||
|
|
||||||
|
async def list_cities(self):
|
||||||
|
"""Proper city names for the Inbox filter — DISTINCT of the stored city column."""
|
||||||
|
from g_sheet.models import FormData
|
||||||
|
inbox=await Inbox_Messages.distinct_cities(self.session)
|
||||||
|
forms=await FormData.distinct_cities(self.session)
|
||||||
|
return Reapplied(session=self.session).merge_cities(inbox,forms)
|
||||||
|
|
||||||
|
async def list_sources(self):
|
||||||
|
"""Source / platform labels: seeded channels, Google Sheet, form sources."""
|
||||||
|
from g_sheet.models import FormData
|
||||||
|
channels=await SourceChannels.list_active(self.session)
|
||||||
|
forms=await FormData.distinct_sources(self.session)
|
||||||
|
return Reapplied(session=self.session).merge_cities(
|
||||||
|
[c.label for c in channels],["Google Sheet"],forms,
|
||||||
|
)
|
||||||
|
|
||||||
async def assign_job_post(self,record_id,job_post_id):
|
async def assign_job_post(self,record_id,job_post_id):
|
||||||
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||||
|
|
@ -454,6 +818,7 @@ class Email:
|
||||||
updated=await Inbox_Messages.set_assigned_job_post(self.session,record_id,job_post_id)
|
updated=await Inbox_Messages.set_assigned_job_post(self.session,record_id,job_post_id)
|
||||||
if not updated:
|
if not updated:
|
||||||
raise HTTPException(status_code=404,detail="Message not found")
|
raise HTTPException(status_code=404,detail="Message not found")
|
||||||
|
await Reapplied(session=self.session).sync_for_email(updated.message_from)
|
||||||
if job_post_id is not None:
|
if job_post_id is not None:
|
||||||
# Assignment pairs this CV with a JD we already have — queue the ATS
|
# Assignment pairs this CV with a JD we already have — queue the ATS
|
||||||
# score in the background so the recruiter is not held on an OpenAI
|
# score in the background so the recruiter is not held on an OpenAI
|
||||||
|
|
@ -470,6 +835,23 @@ class Email:
|
||||||
logger.warning("could not queue ats score for %s: %s",record_id,exc)
|
logger.warning("could not queue ats score for %s: %s",record_id,exc)
|
||||||
return await self.get_inbox_message_by_id(record_id)
|
return await self.get_inbox_message_by_id(record_id)
|
||||||
|
|
||||||
|
async def assign_recruiter(self,record_id,recruiter_id):
|
||||||
|
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status_code=404,detail="Message not found")
|
||||||
|
if recruiter_id is not None:
|
||||||
|
from role.models import EnumRoles
|
||||||
|
from users.models import Users
|
||||||
|
user=await Users.get_user_by_id(self.session,recruiter_id)
|
||||||
|
role=getattr(user,"role",None) if user else None
|
||||||
|
role_name=getattr(role,"role_name",None)
|
||||||
|
if user is None or role_name != EnumRoles.RECRUITER.value:
|
||||||
|
raise HTTPException(status_code=422,detail="recruiter_id must be an active recruiter")
|
||||||
|
updated=await Inbox_Messages.set_recruiter(self.session,record_id,recruiter_id)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=404,detail="Message not found")
|
||||||
|
return await self.get_inbox_message_by_id(record_id)
|
||||||
|
|
||||||
async def mark_read(self,record_id,read=True):
|
async def mark_read(self,record_id,read=True):
|
||||||
message=await Inbox_Messages.mark_message_read(self.session,record_id,read)
|
message=await Inbox_Messages.mark_message_read(self.session,record_id,read)
|
||||||
if not message:
|
if not message:
|
||||||
|
|
@ -498,7 +880,8 @@ class Email:
|
||||||
|
|
||||||
async def set_read_all(self,read,search=None,isread:bool=True,
|
async def set_read_all(self,read,search=None,isread:bool=True,
|
||||||
application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,
|
application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,
|
||||||
assigned=None,is_duplicate=None,processing_state=None):
|
assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,
|
||||||
|
city=None,source=None,has_suggestions=None,job_post_ids=None):
|
||||||
"""Mark every row the SAME filter set would have listed.
|
"""Mark every row the SAME filter set would have listed.
|
||||||
|
|
||||||
The filter arguments are the caller's current view, not a free-form query: the
|
The filter arguments are the caller's current view, not a free-form query: the
|
||||||
|
|
@ -508,7 +891,9 @@ class Email:
|
||||||
updated=await Inbox_Messages.set_read_scope(
|
updated=await Inbox_Messages.set_read_scope(
|
||||||
self.session,read,search=search,isread=isread,
|
self.session,read,search=search,isread=isread,
|
||||||
application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,
|
application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,
|
||||||
processing_state=processing_state,
|
no_suggestions=no_suggestions,processing_state=processing_state,
|
||||||
|
city=city,source=source,
|
||||||
|
has_suggestions=has_suggestions,job_post_ids=job_post_ids,
|
||||||
)
|
)
|
||||||
logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s dup=%s",
|
logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s dup=%s",
|
||||||
updated,bool(read),isread,assigned,getattr(application_status,"value",application_status),is_duplicate)
|
updated,bool(read),isread,assigned,getattr(application_status,"value",application_status),is_duplicate)
|
||||||
|
|
@ -614,6 +999,7 @@ class Email:
|
||||||
message,new_user_email=await Inbox_Messages.insert_email(
|
message,new_user_email=await Inbox_Messages.insert_email(
|
||||||
session=self.session,email_data=data,file_path=None,
|
session=self.session,email_data=data,file_path=None,
|
||||||
)
|
)
|
||||||
|
await Reapplied(session=self.session).sync_for_email(message.message_from)
|
||||||
if pdfs:
|
if pdfs:
|
||||||
message=await attach_email_pdfs_to_s3(
|
message=await attach_email_pdfs_to_s3(
|
||||||
self.session,message,pdfs,created_new=(already is None),
|
self.session,message,pdfs,created_new=(already is None),
|
||||||
|
|
@ -704,3 +1090,78 @@ class Email:
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("notification insert skipped: %s",exc)
|
logger.warning("notification insert skipped: %s",exc)
|
||||||
return {"accepted":True,"to":to_email,"subject":subject}
|
return {"accepted":True,"to":to_email,"subject":subject}
|
||||||
|
|
||||||
|
|
||||||
|
class Reapplied:
|
||||||
|
def __init__(self,session:AsyncSession):
|
||||||
|
self.session=session
|
||||||
|
self.collected={}
|
||||||
|
self.seen={}
|
||||||
|
|
||||||
|
def _norm_email(self,email):
|
||||||
|
return (email or "").strip().lower()
|
||||||
|
|
||||||
|
def _as_job_id(self,value):
|
||||||
|
if value in (None,""):
|
||||||
|
return None
|
||||||
|
text=str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
def _add(self,email,job_id):
|
||||||
|
uid=self._as_job_id(job_id)
|
||||||
|
if not uid or email not in self.seen or uid in self.seen[email]:
|
||||||
|
return
|
||||||
|
self.seen[email].add(uid)
|
||||||
|
self.collected[email].append(uid)
|
||||||
|
|
||||||
|
def merge_cities(self,*groups):
|
||||||
|
"""Case-insensitive unique cities, first spelling wins, sorted."""
|
||||||
|
seen=set()
|
||||||
|
out=[]
|
||||||
|
for group in groups:
|
||||||
|
for raw in group or []:
|
||||||
|
text=(raw or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
key=text.lower()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(text)
|
||||||
|
out.sort(key=str.lower)
|
||||||
|
return out
|
||||||
|
|
||||||
|
async def sync_for_email(self,email):
|
||||||
|
stamped=await self.sync_for_emails([email])
|
||||||
|
return stamped.get(self._norm_email(email),[])
|
||||||
|
|
||||||
|
async def sync_for_emails(self,emails):
|
||||||
|
"""Collect linked job_post_ids for these emails and stamp reapplied on all 3 tables.
|
||||||
|
|
||||||
|
Records with no job_post_id are ignored while collecting. Empty collections
|
||||||
|
leave reapplied untouched.
|
||||||
|
"""
|
||||||
|
from g_sheet.models import FormData
|
||||||
|
from job.candidate.models import Manual_UPLOAD_CANDIDATE
|
||||||
|
from users.models import Users
|
||||||
|
|
||||||
|
lowers=sorted({self._norm_email(e) for e in (emails or []) if self._norm_email(e)})
|
||||||
|
if not lowers:
|
||||||
|
return {}
|
||||||
|
self.collected={email:[] for email in lowers}
|
||||||
|
self.seen={email:set() for email in lowers}
|
||||||
|
|
||||||
|
for email,job_id in await Manual_UPLOAD_CANDIDATE.job_post_ids_by_emails(self.session,lowers):
|
||||||
|
self._add(email,job_id)
|
||||||
|
for email,job_id in await FormData.job_post_ids_by_emails(self.session,lowers):
|
||||||
|
self._add(email,job_id)
|
||||||
|
for email,job_id in await Inbox_Messages.assigned_job_post_ids_by_emails(self.session,lowers):
|
||||||
|
self._add(email,job_id)
|
||||||
|
|
||||||
|
stamped={email:ids for email,ids in self.collected.items() if ids}
|
||||||
|
if not stamped:
|
||||||
|
return {}
|
||||||
|
await Users.set_reapplied_by_emails(self.session,stamped)
|
||||||
|
await Manual_UPLOAD_CANDIDATE.set_reapplied_by_emails(self.session,stamped)
|
||||||
|
await FormData.set_reapplied_by_emails(self.session,stamped)
|
||||||
|
return stamped
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,27 @@ def _auth_headers(token=None):
|
||||||
return {"Authorization":f"Bearer {auth_token}"}
|
return {"Authorization":f"Bearer {auth_token}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_event(event_id, token=None):
|
||||||
|
"""GET {base}/calendar/events/{id} -> event dict, or None on 404."""
|
||||||
|
encoded_id=quote(str(event_id),safe="")
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response=await client.get(
|
||||||
|
f"{_base_url()}/calendar/events/{encoded_id}",
|
||||||
|
headers={**_auth_headers(token),"accept":"application/json"},
|
||||||
|
)
|
||||||
|
if response.status_code==404:
|
||||||
|
return None
|
||||||
|
if response.status_code>=400:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
response.text,
|
||||||
|
request=response.request,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
if not response.content:
|
||||||
|
return {}
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
async def create_event(payload, token=None):
|
async def create_event(payload, token=None):
|
||||||
"""POST {base}/calendar/events -> created event dict."""
|
"""POST {base}/calendar/events -> created event dict."""
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,34 @@
|
||||||
|
def _email_address(block):
|
||||||
|
if not isinstance(block,dict):
|
||||||
|
return None,None
|
||||||
|
inner=block.get("emailAddress") or block.get("email_address") or block
|
||||||
|
if not isinstance(inner,dict):
|
||||||
|
return None,None
|
||||||
|
email=(inner.get("address") or inner.get("email") or "").strip() or None
|
||||||
|
name=(inner.get("name") or "").strip() or None
|
||||||
|
return email,name
|
||||||
|
|
||||||
|
|
||||||
|
def participants_from_event(payload):
|
||||||
|
"""Graph event dict -> (organizer, attendees) as {name, email} snapshots."""
|
||||||
|
if not isinstance(payload,dict):
|
||||||
|
payload={}
|
||||||
|
org_email,org_name=_email_address(payload.get("organizer") or {})
|
||||||
|
organizer=None
|
||||||
|
if org_email or org_name:
|
||||||
|
organizer={"name":org_name,"email":org_email}
|
||||||
|
attendees=[]
|
||||||
|
seen=set()
|
||||||
|
for item in payload.get("attendees") or []:
|
||||||
|
email,name=_email_address(item)
|
||||||
|
key=(email or "").lower()
|
||||||
|
if not email or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
attendees.append({"name":name,"email":email})
|
||||||
|
return organizer,attendees
|
||||||
|
|
||||||
|
|
||||||
def serialize_event(payload) -> dict:
|
def serialize_event(payload) -> dict:
|
||||||
"""Upstream calendar event -> the fields the interview row stores."""
|
"""Upstream calendar event -> the fields the interview row stores."""
|
||||||
if not isinstance(payload,dict):
|
if not isinstance(payload,dict):
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from fastapi import APIRouter,Depends,Query,Response
|
||||||
from fastapi.responses import FileResponse,JSONResponse
|
from fastapi.responses import FileResponse,JSONResponse
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from db_setup import get_session
|
from db_setup import get_session
|
||||||
from job.candidate.views import CandidateScoring,FileRead,CandidateView,parse_linkedin_url_from_cv
|
from job.candidate.views import CandidateScoring,FileRead,CandidateView,extract_bank_profile_from_cv,parse_linkedin_url_from_cv
|
||||||
from job.interviews.views import Interview
|
from job.interviews.views import Interview
|
||||||
from job.notes.views import Note
|
from job.notes.views import Note
|
||||||
from job.activity.views import ActivityLog
|
from job.activity.views import ActivityLog
|
||||||
|
|
@ -12,7 +12,7 @@ from job.history.views import HistoryRecorder
|
||||||
from job.assignment.views import Assignment
|
from job.assignment.views import Assignment
|
||||||
from job.cost.views import HiringCost
|
from job.cost.views import HiringCost
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from users.permissions import PermissionTag, require_permission
|
from users.permissions import PermissionTag, is_hiring_manager, require_permission
|
||||||
from job.job_post.views import JobPost,JobPostCreate
|
from job.job_post.views import JobPost,JobPostCreate
|
||||||
from job_assist.execute_agent import run_field_assist
|
from job_assist.execute_agent import run_field_assist
|
||||||
from job.job_post.export import build_jobs_workbook
|
from job.job_post.export import build_jobs_workbook
|
||||||
|
|
@ -25,6 +25,7 @@ from datetime import datetime, time, timezone
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
@ -32,12 +33,25 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# A banked CV is personal data held with no job to justify it, so it is held for
|
||||||
|
# a stated period rather than forever. Stamped on the row at upload so changing
|
||||||
|
# the setting later cannot silently extend CVs already taken in.
|
||||||
|
CV_BANK_RETENTION_MONTHS = int(os.getenv("CV_BANK_RETENTION_MONTHS", "24"))
|
||||||
|
# Deterministic keyword overlap, not comprehension — the floor only decides who
|
||||||
|
# is worth telling a recruiter about, never who is qualified.
|
||||||
|
CV_BANK_SUGGEST_THRESHOLD = int(os.getenv("CV_BANK_SUGGEST_THRESHOLD", "55"))
|
||||||
|
|
||||||
|
|
||||||
class MatchingAssign(BaseModel):
|
class MatchingAssign(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
job_post_id: UUID | None = None
|
job_post_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CvBankScoreRequest(BaseModel):
|
||||||
|
job_id: UUID
|
||||||
|
ids: list[UUID]
|
||||||
|
|
||||||
|
|
||||||
class CandidateUpdate(BaseModel):
|
class CandidateUpdate(BaseModel):
|
||||||
favorite: bool | None = None
|
favorite: bool | None = None
|
||||||
rating: float | None = None
|
rating: float | None = None
|
||||||
|
|
@ -139,7 +153,9 @@ class JobUpdate(BaseModel):
|
||||||
experience_max: int | None = None
|
experience_max: int | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
current_recruiter_id: UUID | None = None
|
current_recruiter_id: UUID | None = None
|
||||||
|
current_recruiter_ids: list[UUID] | None = None
|
||||||
hiring_manager_id: UUID | None = None
|
hiring_manager_id: UUID | None = None
|
||||||
|
requisition_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class JobStatusUpdate(BaseModel):
|
class JobStatusUpdate(BaseModel):
|
||||||
|
|
@ -230,14 +246,22 @@ async def fetch_users(
|
||||||
role_id:Optional[int]=Query(None),
|
role_id:Optional[int]=Query(None),
|
||||||
top:Optional[int]=Query(None),
|
top:Optional[int]=Query(None),
|
||||||
skip:Optional[int]=Query(None),
|
skip:Optional[int]=Query(None),
|
||||||
|
assigned_job_post_id:Optional[str]=Query(None),
|
||||||
search:Optional[str]=Query(None),
|
search:Optional[str]=Query(None),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
if is_hiring_manager(current_user):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Hiring managers can only list candidates on their requisitions",
|
||||||
|
)
|
||||||
service=User(session=session)
|
service=User(session=session)
|
||||||
data=await service.get_users(role_id=role_id,top=top,skip=skip)
|
data=await service.get_users(role_id=role_id,top=top,skip=skip,assigned_job_post_id=assigned_job_post_id)
|
||||||
return JSONResponse(content={"data":data,"status_code":200})
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
@ -246,14 +270,22 @@ async def fetch_users(
|
||||||
async def count_candidate_users(
|
async def count_candidate_users(
|
||||||
role_id:Optional[int]=Query(None),
|
role_id:Optional[int]=Query(None),
|
||||||
search:Optional[str]=Query(None),
|
search:Optional[str]=Query(None),
|
||||||
|
assigned_job_post_id:Optional[str]=Query(None),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Total matching users for the Candidates pager. Called once on page open."""
|
"""Total matching users for the Candidates pager. Called once on page open."""
|
||||||
try:
|
try:
|
||||||
|
if is_hiring_manager(current_user):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Hiring managers can only list candidates on their requisitions",
|
||||||
|
)
|
||||||
service=User(session=session)
|
service=User(session=session)
|
||||||
total=await service.count_users(search=search,role_id=role_id)
|
total=await service.count_users(search=search,role_id=role_id,assigned_job_post_id=assigned_job_post_id)
|
||||||
return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200})
|
return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
@ -304,20 +336,28 @@ async def cv_bank_upload(
|
||||||
parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF
|
parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF
|
||||||
text=parsed.get("text") or ""
|
text=parsed.get("text") or ""
|
||||||
detected,_=extract_candidate_email(text)
|
detected,_=extract_candidate_email(text)
|
||||||
parsed_linkedin=await parse_linkedin_url_from_cv(text)
|
# One agent call for the whole profile. Banking is the only ingest path
|
||||||
|
# with no job attached, so this is the CV's only structured data until
|
||||||
|
# a recruiter scores it against a real opening.
|
||||||
|
profile=await extract_bank_profile_from_cv(text)
|
||||||
# Basename against both separator styles — a Windows client sends
|
# Basename against both separator styles — a Windows client sends
|
||||||
# C:\Users\x\cv.pdf whose PosixPath name is the whole string.
|
# C:\Users\x\cv.pdf whose PosixPath name is the whole string.
|
||||||
original=PurePosixPath(PureWindowsPath(file.filename or "resume.pdf").name).name or "resume.pdf"
|
original=PurePosixPath(PureWindowsPath(file.filename or "resume.pdf").name).name or "resume.pdf"
|
||||||
row=await Manual_UPLOAD_CANDIDATE.insert_bank_cv(
|
row=await Manual_UPLOAD_CANDIDATE.insert_bank_cv(
|
||||||
session,
|
session,
|
||||||
candidate_email=detected or "",
|
candidate_email=detected or "",
|
||||||
candidate_name="",
|
candidate_name=(profile.get("candidate_name") or "").strip(),
|
||||||
full_text=text,
|
full_text=text,
|
||||||
file_name=original,
|
file_name=original,
|
||||||
created_by=current_user.get("id"),
|
created_by=current_user.get("id"),
|
||||||
pdf_bytes=content,
|
pdf_bytes=content,
|
||||||
linkedin_url=parsed_linkedin,
|
linkedin_url=profile.get("linkedin_url"),
|
||||||
|
profile=profile,
|
||||||
|
bank_reason="speculative",
|
||||||
|
retention_months=CV_BANK_RETENTION_MONTHS,
|
||||||
)
|
)
|
||||||
|
from inbox.views import Reapplied
|
||||||
|
await Reapplied(session=session).sync_for_email(detected or "")
|
||||||
try:
|
try:
|
||||||
uploaded=S3().upload_for_record(
|
uploaded=S3().upload_for_record(
|
||||||
content,
|
content,
|
||||||
|
|
@ -355,27 +395,84 @@ async def cv_bank_upload(
|
||||||
async def cv_bank_fetch(
|
async def cv_bank_fetch(
|
||||||
top: int = Query(100, ge=1, le=500),
|
top: int = Query(100, ge=1, le=500),
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
|
source: Literal["speculative","silver_medalist"] | None = Query(default=None),
|
||||||
|
search: str | None = Query(default=None),
|
||||||
|
skills: list[str] | None = Query(default=None),
|
||||||
|
min_years: int | None = Query(default=None, ge=0, le=60),
|
||||||
|
band: str | None = Query(default=None),
|
||||||
|
job_post_id: str | None = Query(default=None),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""The stored-CV bank, newest first. Download the file via
|
"""The CV Bank: speculative uploads plus rejected applicants who scored well.
|
||||||
GET /documents/download?manual_upload_candidate_id=<id>."""
|
|
||||||
from job.candidate.models import Manual_UPLOAD_CANDIDATE
|
`job_post_id` does not filter the list — it attaches the deterministic
|
||||||
|
tier-1 rank for that job and sorts by it, which is the "a role just opened,
|
||||||
|
who do we already have" view. Download a file via
|
||||||
|
GET /candidate/cv-bank/file?id=<record_id>."""
|
||||||
try:
|
try:
|
||||||
rows,total=await Manual_UPLOAD_CANDIDATE.list_bank(session,limit=top,offset=skip)
|
service=CandidateView(session=session)
|
||||||
data=[{
|
data,total=await service.list_bank(
|
||||||
"id":str(r.id),
|
source=source,search=search,skills=skills,min_years=min_years,
|
||||||
"file_name":r.file_name,
|
band=band,job_post_id=job_post_id,limit=top,offset=skip,
|
||||||
"file_path":(r.file_path or "").strip() or None,
|
)
|
||||||
"candidate_email":r.candidate_email or None,
|
|
||||||
"candidate_name":r.candidate_name or None,
|
|
||||||
"linkedin_url":r.linkedin_url or None,
|
|
||||||
"created_at":r.created_at.isoformat() if r.created_at else None,
|
|
||||||
} for r in rows]
|
|
||||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.exception("cv-bank fetch failed")
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/candidate/cv-bank/score")
|
||||||
|
async def cv_bank_score(
|
||||||
|
payload: CvBankScoreRequest,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Run the real ATS score on CVs already in the bank — the paid tier.
|
||||||
|
|
||||||
|
No upload: the bytes are already stored. Mirrors POST /candidate/score_inbox,
|
||||||
|
and the results land in candidates / ats_results like any other scored CV,
|
||||||
|
so a banked candidate shows up on the leaderboard the same way."""
|
||||||
|
try:
|
||||||
|
service=CandidateScoring(session=session)
|
||||||
|
data=await service.score_bank(str(payload.job_id),payload.ids,current_user)
|
||||||
|
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("cv-bank scoring failed")
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/candidate/cv-bank/suggestions")
|
||||||
|
async def cv_bank_suggestions(
|
||||||
|
job_post_id: str = Query(...),
|
||||||
|
top: int = Query(20, ge=1, le=200),
|
||||||
|
min_rank: int | None = Query(default=None, ge=0, le=100),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Banked CVs worth looking at for one job, best first.
|
||||||
|
|
||||||
|
rank_score is deterministic keyword overlap, not an ATS score — it orders
|
||||||
|
the bank so a recruiter knows where to start. Scoring for real costs money
|
||||||
|
and happens via POST /candidate/cv-bank/score on the ones they pick."""
|
||||||
|
try:
|
||||||
|
service=CandidateView(session=session)
|
||||||
|
floor=CV_BANK_SUGGEST_THRESHOLD if min_rank is None else min_rank
|
||||||
|
rows,_=await service.list_bank(
|
||||||
|
job_post_id=job_post_id,limit=service.BANK_SCAN_CAP,offset=0,
|
||||||
|
)
|
||||||
|
data=[r for r in rows if (r.get("rank_score") or 0)>=floor][:top]
|
||||||
|
return JSONResponse(content={
|
||||||
|
"data":data,"total":len(data),"threshold":floor,"status_code":200,
|
||||||
|
})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("cv-bank suggestions failed")
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -634,6 +731,12 @@ class InboxScoreRequest(BaseModel):
|
||||||
message_ids: list[str] # inbox_messages PK uuids, not Graph message ids
|
message_ids: list[str] # inbox_messages PK uuids, not Graph message ids
|
||||||
|
|
||||||
|
|
||||||
|
class AtsRerunBody(BaseModel):
|
||||||
|
user_id: UUID | None = None
|
||||||
|
inbox_message_id: UUID | None = None
|
||||||
|
manual_upload_candidate_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/candidate/score")
|
@router.post("/candidate/score")
|
||||||
async def score_candidates(
|
async def score_candidates(
|
||||||
job_id: str = Form(...),
|
job_id: str = Form(...),
|
||||||
|
|
@ -670,6 +773,28 @@ async def score_inbox_candidates(
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/candidate/ats-rerun")
|
||||||
|
async def rerun_candidate_ats(
|
||||||
|
payload: AtsRerunBody,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Re-score one candidate from stored professional_summary / inbox extract."""
|
||||||
|
try:
|
||||||
|
service=CandidateScoring(session=session)
|
||||||
|
data=await service.rerun_ats(
|
||||||
|
current_user,
|
||||||
|
user_id=payload.user_id,
|
||||||
|
inbox_message_id=payload.inbox_message_id,
|
||||||
|
manual_upload_candidate_id=payload.manual_upload_candidate_id,
|
||||||
|
)
|
||||||
|
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/candidate/scored/fetch")
|
@router.get("/candidate/scored/fetch")
|
||||||
async def fetch_scored_candidates(
|
async def fetch_scored_candidates(
|
||||||
job_id: str = Query(None),
|
job_id: str = Query(None),
|
||||||
|
|
@ -719,6 +844,47 @@ async def fetch_job_posts(
|
||||||
skip=skip,
|
skip=skip,
|
||||||
ids=id_list,
|
ids=id_list,
|
||||||
active_only=active_only,
|
active_only=active_only,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/job/stats/fetch")
|
||||||
|
async def fetch_job_stats(
|
||||||
|
job_post_id: Optional[uuid.UUID] = Query(None),
|
||||||
|
search: str | None = Query(None),
|
||||||
|
ids: str | None = Query(None),
|
||||||
|
top: int | None = Query(10, ge=1, le=500),
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
active_only: bool = Query(False),
|
||||||
|
current_user: dict = Depends(
|
||||||
|
require_permission(
|
||||||
|
PermissionTag.JOBS_VIEW,
|
||||||
|
PermissionTag.PIPELINE_VIEW,
|
||||||
|
require_all=False,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Live pipeline-stage counts per job post (inbox + manual upload).
|
||||||
|
|
||||||
|
Omit job_post_id for a paged list (optional search / ids). Pass job_post_id
|
||||||
|
for a single object. Counts are aggregated, not stored.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
service=JobPost(session=session)
|
||||||
|
id_list=[x.strip() for x in (ids or "").split(",") if x.strip()] or None
|
||||||
|
data,total=await service.fetch_job_stats(
|
||||||
|
job_post_id=job_post_id,
|
||||||
|
search=search,
|
||||||
|
ids=id_list,
|
||||||
|
top=top,
|
||||||
|
skip=skip,
|
||||||
|
active_only=active_only,
|
||||||
)
|
)
|
||||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -808,7 +974,7 @@ async def fetch_jobs(
|
||||||
data,total=await service.fetch_jobs(
|
data,total=await service.fetch_jobs(
|
||||||
search=search,department=department,requisition_status=requisition_status,
|
search=search,department=department,requisition_status=requisition_status,
|
||||||
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
|
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
|
||||||
top=top,skip=skip,active_only=active_only,
|
top=top,skip=skip,active_only=active_only,current_user=current_user,
|
||||||
)
|
)
|
||||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -834,7 +1000,7 @@ async def export_jobs(
|
||||||
data,_=await service.fetch_jobs(
|
data,_=await service.fetch_jobs(
|
||||||
search=search,department=department,requisition_status=requisition_status,
|
search=search,department=department,requisition_status=requisition_status,
|
||||||
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
|
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
|
||||||
top=None,skip=0,active_only=active_only,
|
top=None,skip=0,active_only=active_only,current_user=current_user,
|
||||||
)
|
)
|
||||||
filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx"
|
filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx"
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -864,22 +1030,67 @@ async def fetch_candidate_by_id(
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/candidate/manager/fetch")
|
||||||
|
async def fetch_manager_candidates(
|
||||||
|
limit:int=Query(50,ge=1,le=200),
|
||||||
|
offset:int=Query(0,ge=0),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Candidates allocated to jobs opened from this user's requisitions
|
||||||
|
(or where they are the assigned hiring manager)."""
|
||||||
|
try:
|
||||||
|
service=CandidateView(session=session)
|
||||||
|
data,total=await service.list_manager_candidates(current_user,limit=limit,offset=offset)
|
||||||
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/candidate/fetch")
|
@router.get("/candidate/fetch")
|
||||||
async def fetch_candidate(
|
async def fetch_candidate(
|
||||||
user_id:str=Query(None),
|
user_id:str=Query(None),
|
||||||
limit:int=Query(10,ge=1,le=100),
|
limit:int=Query(10,ge=1,le=100),
|
||||||
|
assigned_job_post_id:Optional[str]=Query(None),
|
||||||
|
assignment:Optional[str]=Query(None),
|
||||||
offset:int=Query(0,ge=0),
|
offset:int=Query(0,ge=0),
|
||||||
search:str=Query(None),
|
search:str=Query(None),
|
||||||
|
created_by:Optional[bool]=Query(False),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
assignment_value=(assignment or "").strip().lower() or None
|
||||||
|
if assignment_value and assignment_value not in ("assigned","unassigned"):
|
||||||
|
raise HTTPException(status_code=422,detail="assignment must be assigned or unassigned")
|
||||||
|
service=CandidateView(session=session)
|
||||||
|
data=await service.get_candidate(
|
||||||
|
user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,assignment=assignment_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
total=await service.count_candidates(
|
||||||
|
user_id=user_id,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,assignment=assignment_value,
|
||||||
|
) if isinstance(data,list) else 1
|
||||||
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/candidate/applications/fetch")
|
||||||
|
async def fetch_candidate_applications(
|
||||||
|
email:str=Query(...),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=CandidateView(session=session)
|
service=CandidateView(session=session)
|
||||||
data=await service.get_candidate(user_id=user_id,limit=limit,offset=offset,search=search)
|
data=await service.get_application_history(email)
|
||||||
|
return JSONResponse(content={"data":data,"total":len(data.get("applications") or []),"status_code":200})
|
||||||
|
|
||||||
total=await service.count_candidates(user_id=user_id,search=search) if isinstance(data,list) else 1
|
|
||||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -890,12 +1101,13 @@ async def fetch_candidate(
|
||||||
async def update_candidate(
|
async def update_candidate(
|
||||||
user_id:str=Query(...),
|
user_id:str=Query(...),
|
||||||
payload:CandidateUpdate=...,
|
payload:CandidateUpdate=...,
|
||||||
|
created_by:Optional[bool]=Query(False),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=CandidateView(session=session)
|
service=CandidateView(session=session)
|
||||||
data=await service.update_candidate(user_id,payload.model_dump(exclude_unset=True),current_user)
|
data=await service.update_candidate(user_id,payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -906,7 +1118,7 @@ async def update_candidate(
|
||||||
@router.get("/candidate/history/fetch")
|
@router.get("/candidate/history/fetch")
|
||||||
async def fetch_candidate_history(
|
async def fetch_candidate_history(
|
||||||
user_id:str=Query(...),
|
user_id:str=Query(...),
|
||||||
limit:int=Query(200,ge=1,le=500),
|
limit:int=Query(10,ge=1,le=100),
|
||||||
offset:int=Query(0,ge=0),
|
offset:int=Query(0,ge=0),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
|
@ -931,7 +1143,7 @@ async def fetch_interview(
|
||||||
recruiter_id:str=Query(None),
|
recruiter_id:str=Query(None),
|
||||||
top:int=Query(None),
|
top:int=Query(None),
|
||||||
skip:int=Query(0,ge=0),
|
skip:int=Query(0,ge=0),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_VIEW,PermissionTag.CANDIDATES_VIEW,require_all=False)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
|
@ -959,7 +1171,7 @@ async def fetch_interview(
|
||||||
@router.post("/interview/create")
|
@router.post("/interview/create")
|
||||||
async def create_interview(
|
async def create_interview(
|
||||||
payload:InterviewCreate,
|
payload:InterviewCreate,
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_CREATE,PermissionTag.CANDIDATES_CREATE,require_all=False)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
|
@ -976,7 +1188,7 @@ async def create_interview(
|
||||||
async def update_interview(
|
async def update_interview(
|
||||||
interview_id:str=Query(...),
|
interview_id:str=Query(...),
|
||||||
payload:InterviewUpdate=...,
|
payload:InterviewUpdate=...,
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_EDIT,PermissionTag.CANDIDATES_EDIT,require_all=False)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
|
@ -993,12 +1205,13 @@ async def update_interview(
|
||||||
async def fetch_notes(
|
async def fetch_notes(
|
||||||
note_id:str=Query(None),
|
note_id:str=Query(None),
|
||||||
user_id:str=Query(None),
|
user_id:str=Query(None),
|
||||||
|
created_by:Optional[bool]=Query(False),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Note(session=session)
|
service=Note(session=session)
|
||||||
data=await service.get_note(note_id=note_id,user_id=user_id)
|
data=await service.get_note(note_id=note_id,user_id=user_id,current_user=current_user,created_by=created_by)
|
||||||
total=1 if isinstance(data,dict) else len(data)
|
total=1 if isinstance(data,dict) else len(data)
|
||||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -1010,12 +1223,13 @@ async def fetch_notes(
|
||||||
@router.post("/notes/create")
|
@router.post("/notes/create")
|
||||||
async def create_note(
|
async def create_note(
|
||||||
payload:NoteCreate,
|
payload:NoteCreate,
|
||||||
|
created_by:Optional[bool]=Query(False),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Note(session=session)
|
service=Note(session=session)
|
||||||
data=await service.create_note(payload.model_dump(exclude_unset=True),current_user)
|
data=await service.create_note(payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -1027,12 +1241,13 @@ async def create_note(
|
||||||
async def update_note(
|
async def update_note(
|
||||||
note_id:str=Query(...),
|
note_id:str=Query(...),
|
||||||
payload:NoteUpdate=...,
|
payload:NoteUpdate=...,
|
||||||
|
created_by:Optional[bool]=Query(False),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Note(session=session)
|
service=Note(session=session)
|
||||||
data=await service.update_note(note_id,payload.model_dump(exclude_unset=True),current_user)
|
data=await service.update_note(note_id,payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -1154,6 +1369,7 @@ async def change_candidate_stage(
|
||||||
@router.get("/pipeline/candidates/fetch")
|
@router.get("/pipeline/candidates/fetch")
|
||||||
async def fetch_pipeline_candidates(
|
async def fetch_pipeline_candidates(
|
||||||
job_post_id:Optional[uuid.UUID]=Query(None),
|
job_post_id:Optional[uuid.UUID]=Query(None),
|
||||||
|
search:Optional[str]=Query(None),
|
||||||
limit:int=Query(10,ge=1,le=1000),
|
limit:int=Query(10,ge=1,le=1000),
|
||||||
offset:int=Query(0,ge=0),
|
offset:int=Query(0,ge=0),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
|
||||||
|
|
@ -1161,7 +1377,7 @@ async def fetch_pipeline_candidates(
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Pipeline(session=session)
|
service=Pipeline(session=session)
|
||||||
result=await service.get_all(job_post_id=job_post_id,limit=limit,offset=offset)
|
result=await service.get_all(job_post_id=job_post_id,limit=limit,offset=offset,search=search)
|
||||||
return JSONResponse(content={**result,"status_code":200})
|
return JSONResponse(content={**result,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,50 @@ class JobAssignments(SQLModel, table=True):
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return len(rows)
|
return len(rows)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def sync_open(cls, session: AsyncSession, job_post_id, assignment_role, user_ids, assigned_by):
|
||||||
|
"""Make open intervals for this role match user_ids (order preserved)."""
|
||||||
|
uid = cls._as_uuid(job_post_id)
|
||||||
|
by_uid = cls._as_uuid(assigned_by)
|
||||||
|
if uid is None or not assignment_role or by_uid is None:
|
||||||
|
return 0
|
||||||
|
wanted = []
|
||||||
|
seen = set()
|
||||||
|
for raw in user_ids or []:
|
||||||
|
user_uid = cls._as_uuid(raw)
|
||||||
|
if user_uid is None:
|
||||||
|
continue
|
||||||
|
key = str(user_uid)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
wanted.append(user_uid)
|
||||||
|
current = await cls.fetch_by_job(
|
||||||
|
session, uid, current_only=True, assignment_role=assignment_role,
|
||||||
|
)
|
||||||
|
current_map = {str(r.user_id): r for r in current}
|
||||||
|
now = _now()
|
||||||
|
wanted_set = {str(u) for u in wanted}
|
||||||
|
changed = False
|
||||||
|
for key, row in current_map.items():
|
||||||
|
if key not in wanted_set:
|
||||||
|
row.valid_to = now
|
||||||
|
session.add(row)
|
||||||
|
changed = True
|
||||||
|
for user_uid in wanted:
|
||||||
|
if str(user_uid) in current_map:
|
||||||
|
continue
|
||||||
|
session.add(cls(
|
||||||
|
job_post_id=uid,
|
||||||
|
user_id=user_uid,
|
||||||
|
assignment_role=assignment_role,
|
||||||
|
assigned_by=by_uid,
|
||||||
|
))
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
await session.commit()
|
||||||
|
return len(wanted)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def insert_assignment(cls, session: AsyncSession, fields: dict):
|
async def insert_assignment(cls, session: AsyncSession, fields: dict):
|
||||||
row = cls(**fields)
|
row = cls(**fields)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
import logging
|
||||||
|
|
||||||
from job.assignment.models import ApplicationAssignments, JobAssignments
|
from job.assignment.models import ApplicationAssignments, JobAssignments
|
||||||
from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment
|
from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment
|
||||||
|
|
@ -18,6 +19,8 @@ JOB_OWNER_COLUMN = {
|
||||||
"hiring_manager": "hiring_manager_id",
|
"hiring_manager": "hiring_manager_id",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Assignment:
|
class Assignment:
|
||||||
def __init__(self,session:AsyncSession):
|
def __init__(self,session:AsyncSession):
|
||||||
|
|
@ -50,9 +53,8 @@ class Assignment:
|
||||||
async def record_job_owner(self,job_post_id,user_id,assignment_role,assigned_by):
|
async def record_job_owner(self,job_post_id,user_id,assignment_role,assigned_by):
|
||||||
"""Close the open interval of this role, then open a new one.
|
"""Close the open interval of this role, then open a new one.
|
||||||
|
|
||||||
user_id None = unassign (hiring_manager cannot be cleared; callers
|
user_id None = unassign. No-ops when the same person already holds the
|
||||||
must not pass None for that role). No-ops when the same person already
|
open interval. Does not touch job_posts columns.
|
||||||
holds the open interval. Does not touch job_posts columns.
|
|
||||||
"""
|
"""
|
||||||
role=self._job_role(assignment_role)
|
role=self._job_role(assignment_role)
|
||||||
job_uid=JobAssignments._as_uuid(job_post_id)
|
job_uid=JobAssignments._as_uuid(job_post_id)
|
||||||
|
|
@ -63,8 +65,6 @@ class Assignment:
|
||||||
self.session,job_uid,current_only=True,assignment_role=role,
|
self.session,job_uid,current_only=True,assignment_role=role,
|
||||||
)
|
)
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
if role=="hiring_manager":
|
|
||||||
raise HTTPException(status_code=422,detail="hiring_manager_id is required")
|
|
||||||
await JobAssignments.close_current(self.session,job_uid,role)
|
await JobAssignments.close_current(self.session,job_uid,role)
|
||||||
return None
|
return None
|
||||||
user_uid=JobAssignments._as_uuid(user_id)
|
user_uid=JobAssignments._as_uuid(user_id)
|
||||||
|
|
@ -80,6 +80,16 @@ class Assignment:
|
||||||
"assigned_by":by_uid,
|
"assigned_by":by_uid,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async def record_job_recruiters(self,job_post_id,user_ids,assigned_by):
|
||||||
|
"""Keep open primary_recruiter intervals in sync with the JSON list."""
|
||||||
|
job_uid=JobAssignments._as_uuid(job_post_id)
|
||||||
|
by_uid=JobAssignments._as_uuid(assigned_by)
|
||||||
|
if not job_uid or not by_uid:
|
||||||
|
raise HTTPException(status_code=422,detail="Invalid job_post_id or assigned_by")
|
||||||
|
return await JobAssignments.sync_open(
|
||||||
|
self.session,job_uid,"primary_recruiter",user_ids,by_uid,
|
||||||
|
)
|
||||||
|
|
||||||
async def list_job_assignments(self,job_post_id,current_only=True,assignment_role=None):
|
async def list_job_assignments(self,job_post_id,current_only=True,assignment_role=None):
|
||||||
if not job_post_id:
|
if not job_post_id:
|
||||||
raise HTTPException(status_code=400,detail="job_post_id is required")
|
raise HTTPException(status_code=400,detail="job_post_id is required")
|
||||||
|
|
@ -105,7 +115,27 @@ class Assignment:
|
||||||
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
||||||
row=await self.record_job_owner(job_post_id,user_id,role,assigned_by)
|
row=await self.record_job_owner(job_post_id,user_id,role,assigned_by)
|
||||||
column=JOB_OWNER_COLUMN[role]
|
column=JOB_OWNER_COLUMN[role]
|
||||||
await JobPosts.update_job_post(self.session,job_post_id,{column:user_id})
|
patch={column:user_id}
|
||||||
|
if role=="primary_recruiter":
|
||||||
|
patch["current_recruiter_ids"]=[str(user_id)]
|
||||||
|
updated=await JobPosts.update_job_post(self.session,job_post_id,patch)
|
||||||
|
if updated:
|
||||||
|
try:
|
||||||
|
from notifications.views import notify_job_assignment
|
||||||
|
label="hiring manager" if role=="hiring_manager" else "recruiter"
|
||||||
|
previous=(
|
||||||
|
[job.hiring_manager_id]
|
||||||
|
if role=="hiring_manager"
|
||||||
|
else JobPosts.recruiter_ids_of(job)
|
||||||
|
)
|
||||||
|
await notify_job_assignment(
|
||||||
|
self.session,updated,
|
||||||
|
role_label=label,
|
||||||
|
actor_id=assigned_by,
|
||||||
|
previous_ids=previous,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("notification insert skipped: %s", exc)
|
||||||
names=await Users.names_by_ids(
|
names=await Users.names_by_ids(
|
||||||
self.session,[row.user_id,row.assigned_by] if row else [],
|
self.session,[row.user_id,row.assigned_by] if row else [],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
"""CV Bank Taskiq tasks — profile backfill and job-opening rank.
|
||||||
|
|
||||||
|
Worker: taskiq worker taskiq_management.broker_setup:broker job.candidate.bank_tasks
|
||||||
|
|
||||||
|
Two jobs live here, both about the bank being useful rather than merely stored:
|
||||||
|
|
||||||
|
cvbank.backfill_profiles one-off, for CVs banked before extraction existed
|
||||||
|
cvbank.rank_for_job fired when a job opens, so the bank is offered up
|
||||||
|
instead of waiting to be remembered
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from db_setup import session_scope
|
||||||
|
from taskiq_management.broker_setup import MAX_RETRIES, RETRY_DELAY, broker
|
||||||
|
from taskiq_management.middleware import PermanentTaskError
|
||||||
|
|
||||||
|
logger = logging.getLogger("cvbank.tasks")
|
||||||
|
|
||||||
|
# One agent call per CV, so a backfill of a large bank is paced across runs
|
||||||
|
# rather than fired as one unbounded burst.
|
||||||
|
BACKFILL_BATCH = 25
|
||||||
|
|
||||||
|
# 03:00 daily. The sweep only flags, so the exact hour does not matter; off-peak
|
||||||
|
# just keeps it away from the scoring workload.
|
||||||
|
RETENTION_SWEEP_CRON = os.getenv("CV_BANK_RETENTION_CRON", "0 3 * * *")
|
||||||
|
|
||||||
|
|
||||||
|
@broker.task(
|
||||||
|
task_name="cvbank.backfill_profiles",
|
||||||
|
retry_on_error=True,
|
||||||
|
max_retries=MAX_RETRIES,
|
||||||
|
delay=RETRY_DELAY,
|
||||||
|
)
|
||||||
|
async def backfill_bank_profiles(limit: int = BACKFILL_BATCH) -> dict:
|
||||||
|
"""Extract skills/title/company/years for CVs banked before migration 029.
|
||||||
|
|
||||||
|
Re-runnable: rows are selected by "has no extraction yet", so a finished
|
||||||
|
bank returns scanned=0 and the task becomes a no-op. Returns `remaining`
|
||||||
|
so a caller can decide whether to enqueue another batch.
|
||||||
|
"""
|
||||||
|
from job.candidate.models import Manual_UPLOAD_CANDIDATE
|
||||||
|
from job.candidate.views import extract_bank_profile_from_cv
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
failed = 0
|
||||||
|
async with session_scope() as session:
|
||||||
|
rows = await Manual_UPLOAD_CANDIDATE.list_bank_needing_profile(
|
||||||
|
session, limit=max(1, int(limit or BACKFILL_BATCH)),
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
# extract_bank_profile_from_cv never raises, but a bad row must not
|
||||||
|
# cost the whole batch either.
|
||||||
|
try:
|
||||||
|
profile = await extract_bank_profile_from_cv(row.full_text)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("bank profile backfill failed id=%s", row.id)
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
if not any(profile.get(k) for k in ("skills", "current_position", "current_company")):
|
||||||
|
continue
|
||||||
|
await Manual_UPLOAD_CANDIDATE.set_bank_profile(session, row.id, profile)
|
||||||
|
updated += 1
|
||||||
|
remaining = len(
|
||||||
|
await Manual_UPLOAD_CANDIDATE.list_bank_needing_profile(session, limit=1)
|
||||||
|
)
|
||||||
|
return {"scanned": len(rows), "updated": updated, "failed": failed, "remaining": remaining}
|
||||||
|
|
||||||
|
|
||||||
|
@broker.task(
|
||||||
|
task_name="cvbank.rank_for_job",
|
||||||
|
retry_on_error=True,
|
||||||
|
max_retries=MAX_RETRIES,
|
||||||
|
delay=RETRY_DELAY,
|
||||||
|
)
|
||||||
|
async def rank_bank_for_job(job_post_id: str) -> dict:
|
||||||
|
"""Score every banked CV against a newly opened job — tier 1, free.
|
||||||
|
|
||||||
|
Deterministic keyword overlap only. No LLM call, so this runs over the
|
||||||
|
whole bank on every job opening without a bill; the paid ATS score happens
|
||||||
|
later and only for the handful a recruiter shortlists.
|
||||||
|
"""
|
||||||
|
from job.candidate.models import CvBankMatches, Manual_UPLOAD_CANDIDATE
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
from matching.ranking import rank_bank_row
|
||||||
|
|
||||||
|
if not job_post_id or not str(job_post_id).strip():
|
||||||
|
raise PermanentTaskError("job_post_id is required")
|
||||||
|
job_post_id = str(job_post_id).strip()
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
job = await JobPosts.get_job_post_by_id(session, job_post_id)
|
||||||
|
if job is None or job.is_deleted:
|
||||||
|
raise PermanentTaskError("job post missing or deleted")
|
||||||
|
job_fields = {
|
||||||
|
"title": job.title,
|
||||||
|
"requirements": job.requirements,
|
||||||
|
"optional_skills": job.optional_skills,
|
||||||
|
}
|
||||||
|
rows = await Manual_UPLOAD_CANDIDATE.list_bank_for_ranking(session)
|
||||||
|
scores = [(row.id, rank_bank_row(job_fields, row)) for row in rows]
|
||||||
|
await CvBankMatches.replace_for_job(session, job.id, scores)
|
||||||
|
|
||||||
|
threshold = _suggest_threshold()
|
||||||
|
strong = [s for _, s in scores if s >= threshold]
|
||||||
|
if strong:
|
||||||
|
await _notify_owner(job_post_id, len(strong))
|
||||||
|
return {"ranked": len(scores), "above_threshold": len(strong)}
|
||||||
|
|
||||||
|
|
||||||
|
@broker.task(
|
||||||
|
task_name="cvbank.sweep_expired",
|
||||||
|
schedule=[{"cron": RETENTION_SWEEP_CRON}],
|
||||||
|
)
|
||||||
|
async def sweep_expired_bank_cvs() -> dict:
|
||||||
|
"""Flag banked CVs past their retention window — nightly.
|
||||||
|
|
||||||
|
Flags, never deletes. These are resumes a person sent us: dropping them on
|
||||||
|
a timer with no record would be worse than holding them, and a wrongly
|
||||||
|
configured window would silently destroy the whole bank. A human decides,
|
||||||
|
the sweep only makes the decision unavoidable.
|
||||||
|
|
||||||
|
Expired rows are already excluded from ranking (list_bank_for_ranking), so
|
||||||
|
nothing is being surfaced to recruiters in the meantime.
|
||||||
|
"""
|
||||||
|
from job.candidate.models import Manual_UPLOAD_CANDIDATE
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
rows = await Manual_UPLOAD_CANDIDATE.list_bank_expired(session)
|
||||||
|
for row in rows:
|
||||||
|
logger.info(
|
||||||
|
"cv-bank retention expired id=%s banked_at=%s expired_at=%s",
|
||||||
|
row.id,
|
||||||
|
row.created_at.isoformat() if row.created_at else None,
|
||||||
|
row.bank_expires_at.isoformat() if row.bank_expires_at else None,
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
await _notify_retention_review(len(rows))
|
||||||
|
return {"expired": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_retention_review(count: int) -> None:
|
||||||
|
"""Tell whoever banked the CVs that the window has run out.
|
||||||
|
|
||||||
|
Best effort — the log line above is the durable record.
|
||||||
|
"""
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
try:
|
||||||
|
from notifications.models import Notifications
|
||||||
|
from users.models import Users
|
||||||
|
|
||||||
|
recipient = os.getenv("CV_BANK_RETENTION_NOTIFY_EMAIL", "").strip().lower()
|
||||||
|
if not recipient:
|
||||||
|
return
|
||||||
|
async with session_scope() as session:
|
||||||
|
user = await Users.get_user_by_email(session, recipient)
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
await Notifications.insert_notification(session, {
|
||||||
|
"user_id": _uuid.UUID(str(user.id)),
|
||||||
|
"kind": "system",
|
||||||
|
"title": "CV Bank retention review",
|
||||||
|
"body": (
|
||||||
|
f"{count} stored CV{'s' if count != 1 else ''} passed the retention "
|
||||||
|
"window and need to be kept with a reason or deleted."
|
||||||
|
),
|
||||||
|
"link_path": "/cvbank",
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("cv-bank retention notification failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _suggest_threshold() -> int:
|
||||||
|
return int(os.getenv("CV_BANK_SUGGEST_THRESHOLD", "55"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_owner(job_post_id: str, count: int) -> None:
|
||||||
|
"""Tell the job's recruiter the bank already holds plausible candidates.
|
||||||
|
|
||||||
|
This is the whole point of ranking on job creation: without it the bank
|
||||||
|
only gets searched by someone who remembers it exists.
|
||||||
|
|
||||||
|
Best effort — a missing notification must never fail the ranking that has
|
||||||
|
already been persisted.
|
||||||
|
"""
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
try:
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
from notifications.models import Notifications
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
job = await JobPosts.get_job_post_by_id(session, job_post_id)
|
||||||
|
if job is None:
|
||||||
|
return
|
||||||
|
ids = JobPosts.recruiter_ids_of(job)
|
||||||
|
if not ids:
|
||||||
|
created = getattr(job, "created_by", None)
|
||||||
|
if created:
|
||||||
|
ids = [str(created)]
|
||||||
|
if not ids:
|
||||||
|
return
|
||||||
|
body = (
|
||||||
|
f"{count} stored CV{'s' if count != 1 else ''} look relevant to "
|
||||||
|
f"{job.title}. Open the CV Bank to review them."
|
||||||
|
)
|
||||||
|
for raw in ids:
|
||||||
|
await Notifications.insert_notification(session, {
|
||||||
|
"user_id": _uuid.UUID(str(raw)),
|
||||||
|
"kind": "application",
|
||||||
|
"title": "CVs in the bank match this job",
|
||||||
|
"body": body,
|
||||||
|
"link_path": f"/cvbank?job={job_post_id}",
|
||||||
|
"job_post_id": job.id,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("cv-bank suggestion notification failed job=%s", job_post_id)
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, and_, func, or_
|
from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, and_, func, or_, update
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
@ -52,6 +53,19 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
platform: str = Field(default="")
|
platform: str = Field(default="")
|
||||||
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||||
experience: str = Field(default="")
|
experience: str = Field(default="")
|
||||||
|
# Employment-agent extractions, written at CV-bank ingest (see 029). These
|
||||||
|
# are what make the bank searchable — full_text alone cannot be filtered on.
|
||||||
|
# `experience` above is free text from the Add Candidate form; this one is
|
||||||
|
# the numeric years the bank filters and sorts by, so they stay separate.
|
||||||
|
skills: list[str] = Field(default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"})
|
||||||
|
years_experience: int | None = Field(default=None)
|
||||||
|
city: str | None = Field(default=None)
|
||||||
|
professional_summary: str | None = Field(default=None)
|
||||||
|
reapplied: list[str] = Field(default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"})
|
||||||
|
education: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||||
|
# Why the CV is held (speculative / referral) and when retention expires.
|
||||||
|
bank_reason: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||||
|
bank_expires_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||||
# Candidate_application_Status value (PENDING, SCREENING, …). Empty reads as Shortlist.
|
# Candidate_application_Status value (PENDING, SCREENING, …). Empty reads as Shortlist.
|
||||||
status: str = Field(default="")
|
status: str = Field(default="")
|
||||||
# Free text, not a users FK: a referrer is often someone outside the system
|
# Free text, not a users FK: a referrer is often someone outside the system
|
||||||
|
|
@ -62,7 +76,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_all(cls, session: AsyncSession, job_post_id=None, limit=None, offset=0):
|
async def get_all(cls, session: AsyncSession, job_post_id=None, job_post_ids=None, limit=None, offset=0, search=None):
|
||||||
try:
|
try:
|
||||||
from inbox.models import AtsResults
|
from inbox.models import AtsResults
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
|
|
@ -75,6 +89,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
Users.email,
|
Users.email,
|
||||||
cls.job_post_id,
|
cls.job_post_id,
|
||||||
Users.name,
|
Users.name,
|
||||||
|
cls.candidate_name,
|
||||||
cls.candidate_phone,
|
cls.candidate_phone,
|
||||||
JobPosts.title,
|
JobPosts.title,
|
||||||
cls.status,
|
cls.status,
|
||||||
|
|
@ -111,8 +126,21 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
cls.id.desc(),
|
cls.id.desc(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if job_post_id:
|
if job_post_ids is not None:
|
||||||
|
ids=list(job_post_ids)
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
qry=qry.where(cls.job_post_id.in_(ids))
|
||||||
|
elif job_post_id:
|
||||||
qry=qry.where(cls.job_post_id==job_post_id)
|
qry=qry.where(cls.job_post_id==job_post_id)
|
||||||
|
if search and str(search).strip():
|
||||||
|
like=f"%{str(search).strip()}%"
|
||||||
|
qry=qry.where(or_(
|
||||||
|
Users.name.ilike(like),
|
||||||
|
Users.email.ilike(like),
|
||||||
|
cls.candidate_name.ilike(like),
|
||||||
|
cls.candidate_email.ilike(like),
|
||||||
|
))
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
qry=qry.limit(limit).offset(offset)
|
qry=qry.limit(limit).offset(offset)
|
||||||
result=await session.execute(qry)
|
result=await session.execute(qry)
|
||||||
|
|
@ -135,7 +163,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
"user_id":str(row["user_id"]) if row["user_id"] else None,
|
"user_id":str(row["user_id"]) if row["user_id"] else None,
|
||||||
"email":row["email"],
|
"email":row["email"],
|
||||||
"job_post_id":str(row["job_post_id"]) if row["job_post_id"] else None,
|
"job_post_id":str(row["job_post_id"]) if row["job_post_id"] else None,
|
||||||
"name":row["name"],
|
"name": (row["name"] or "").strip() or (row["candidate_name"] or "").strip() or None,
|
||||||
"candidate_phone":row["candidate_phone"],
|
"candidate_phone":row["candidate_phone"],
|
||||||
"title":row["title"] or None,
|
"title":row["title"] or None,
|
||||||
"application_status":row["status"] or None,
|
"application_status":row["status"] or None,
|
||||||
|
|
@ -154,7 +182,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def count_by_status(cls, session: AsyncSession, job_post_id=None):
|
async def count_by_status(cls, session: AsyncSession, job_post_id=None, search=None):
|
||||||
try:
|
try:
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
from job.job_post.models import JobPosts
|
from job.job_post.models import JobPosts
|
||||||
|
|
@ -167,6 +195,14 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
if job_post_id:
|
if job_post_id:
|
||||||
qry=qry.where(cls.job_post_id==job_post_id)
|
qry=qry.where(cls.job_post_id==job_post_id)
|
||||||
|
if search and str(search).strip():
|
||||||
|
like=f"%{str(search).strip()}%"
|
||||||
|
qry=qry.where(or_(
|
||||||
|
Users.name.ilike(like),
|
||||||
|
Users.email.ilike(like),
|
||||||
|
cls.candidate_name.ilike(like),
|
||||||
|
cls.candidate_email.ilike(like),
|
||||||
|
))
|
||||||
result=await session.execute(qry)
|
result=await session.execute(qry)
|
||||||
counts={}
|
counts={}
|
||||||
for status,n in result.all():
|
for status,n in result.all():
|
||||||
|
|
@ -205,7 +241,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
except (TypeError,ValueError):
|
except (TypeError,ValueError):
|
||||||
rid=None
|
rid=None
|
||||||
if rid is not None:
|
if rid is not None:
|
||||||
qry=qry.where(JobPosts.current_recruiter_id==rid)
|
qry=qry.where(JobPosts.has_recruiter(rid))
|
||||||
qry=qry.group_by(cls.status)
|
qry=qry.group_by(cls.status)
|
||||||
result=await session.execute(qry)
|
result=await session.execute(qry)
|
||||||
counts={}
|
counts={}
|
||||||
|
|
@ -214,6 +250,35 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
counts[key]=counts.get(key,0)+int(n or 0)
|
counts[key]=counts.get(key,0)+int(n or 0)
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def counts_by_job_post(
|
||||||
|
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
|
||||||
|
):
|
||||||
|
"""Per-job counts for the same manual-upload population as
|
||||||
|
counts_by_application_status, so inbox + manual per-job sums stay equal
|
||||||
|
to the funnel total under identical filters.
|
||||||
|
"""
|
||||||
|
from users.models import Users
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
qry=(
|
||||||
|
select(cls.job_post_id,func.count())
|
||||||
|
.select_from(cls)
|
||||||
|
.join(Users,cls.user_id==Users.id)
|
||||||
|
.join(JobPosts,cls.job_post_id==JobPosts.id)
|
||||||
|
)
|
||||||
|
if from_date is not None:
|
||||||
|
qry=qry.where(cls.created_at>=from_date)
|
||||||
|
if to_date is not None:
|
||||||
|
qry=qry.where(cls.created_at<to_date)
|
||||||
|
if department:
|
||||||
|
qry=qry.where(JobPosts.department==department)
|
||||||
|
rid=cls._as_uuid(recruiter_id)
|
||||||
|
if rid is not None:
|
||||||
|
qry=qry.where(JobPosts.has_recruiter(rid))
|
||||||
|
qry=qry.group_by(cls.job_post_id)
|
||||||
|
result=await session.execute(qry)
|
||||||
|
return {str(job_id):int(n or 0) for job_id,n in result.all()}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||||
if record_id in (None, ""):
|
if record_id in (None, ""):
|
||||||
|
|
@ -276,6 +341,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
referral_by=(fields.get("referral_by") or "").strip(),
|
referral_by=(fields.get("referral_by") or "").strip(),
|
||||||
file_name=(fields.get("file_name") or "").strip(),
|
file_name=(fields.get("file_name") or "").strip(),
|
||||||
file_path=(fields.get("file_path") or "").strip(),
|
file_path=(fields.get("file_path") or "").strip(),
|
||||||
|
city=(fields.get("city") or "").strip() or None,
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -329,6 +395,36 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
result = await session.execute(select(cls).where(cls.id == uid))
|
result = await session.execute(select(cls).where(cls.id == uid))
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def set_full_text(cls, session: AsyncSession, record_id, text, *, only_if_empty=True):
|
||||||
|
"""Keep extracted CV text on the bank/manual row (Inbox uses resume_text)."""
|
||||||
|
row = await cls.get_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
value = (text or "").strip()
|
||||||
|
if not value:
|
||||||
|
return row
|
||||||
|
if only_if_empty and (row.full_text or "").strip():
|
||||||
|
return row
|
||||||
|
row.full_text = value
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def set_professional_summary(cls, session: AsyncSession, record_id, summary):
|
||||||
|
row = await cls.get_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.professional_summary = (summary or "").strip() or None
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_ids(cls, session: AsyncSession, ids):
|
async def get_by_ids(cls, session: AsyncSession, ids):
|
||||||
keys = []
|
keys = []
|
||||||
|
|
@ -356,19 +452,80 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None):
|
async def list_by_emails(cls, session: AsyncSession, emails):
|
||||||
"""Newest applications with a user + job for Talent Pool (manual / form)."""
|
"""Every manual/form/cv-bank row for these addresses, newest first."""
|
||||||
from users.models import Users
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
statement = (
|
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||||
select(cls)
|
if not lowers:
|
||||||
.join(Users, cls.user_id == Users.id)
|
return []
|
||||||
.where(cls.user_id.is_not(None), cls.job_post_id.is_not(None))
|
result = await session.execute(
|
||||||
|
select(cls, JobPosts.title)
|
||||||
|
.outerjoin(JobPosts, cls.job_post_id == JobPosts.id)
|
||||||
|
.where(func.lower(cls.candidate_email).in_(lowers))
|
||||||
.order_by(cls.created_at.desc())
|
.order_by(cls.created_at.desc())
|
||||||
)
|
)
|
||||||
|
rows = []
|
||||||
|
for rec, title in result.all():
|
||||||
|
status = (rec.status or "").strip() or None
|
||||||
|
rows.append({
|
||||||
|
"source": "manual",
|
||||||
|
"email": (rec.candidate_email or "").strip().lower() or None,
|
||||||
|
"inbox_id": None,
|
||||||
|
"message_id": None,
|
||||||
|
"manual_upload_candidate_id": str(rec.id),
|
||||||
|
"form_data_id": None,
|
||||||
|
"candidate_id": None,
|
||||||
|
"user_id": str(rec.user_id) if rec.user_id else None,
|
||||||
|
"job_post_id": str(rec.job_post_id) if rec.job_post_id else None,
|
||||||
|
"job_title": title or None,
|
||||||
|
"status": status or "PENDING",
|
||||||
|
"applied_at": rec.created_at.isoformat() if rec.created_at else None,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def job_post_ids_by_emails(cls, session: AsyncSession, emails):
|
||||||
|
"""(email, job_post_id) pairs that actually have a job. Unlinked rows skipped."""
|
||||||
|
lowers=sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||||
|
if not lowers:
|
||||||
|
return []
|
||||||
|
result=await session.execute(
|
||||||
|
select(cls.candidate_email,cls.job_post_id)
|
||||||
|
.where(func.lower(cls.candidate_email).in_(lowers))
|
||||||
|
.where(cls.job_post_id.is_not(None))
|
||||||
|
)
|
||||||
|
rows=[]
|
||||||
|
for email,job_id in result.all():
|
||||||
|
if job_id is None:
|
||||||
|
continue
|
||||||
|
rows.append(((email or "").strip().lower(),str(job_id)))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def set_reapplied_by_emails(cls, session: AsyncSession, mapping):
|
||||||
|
if not mapping:
|
||||||
|
return 0
|
||||||
|
updated=0
|
||||||
|
for email, ids in mapping.items():
|
||||||
|
key=(email or "").strip().lower()
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
result=await session.execute(
|
||||||
|
update(cls).where(func.lower(cls.candidate_email)==key).values(reapplied=list(ids or []))
|
||||||
|
)
|
||||||
|
updated+=result.rowcount or 0
|
||||||
|
await session.commit()
|
||||||
|
return updated
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _talent_pool_filters(cls, Users, *, search=None, job_post_ids=None):
|
||||||
|
filters = [cls.user_id.is_not(None), cls.job_post_id.is_not(None)]
|
||||||
|
if job_post_ids is not None:
|
||||||
|
filters.append(cls.job_post_id.in_(list(job_post_ids)))
|
||||||
if search:
|
if search:
|
||||||
like = f"%{search.strip()}%"
|
like = f"%{search.strip()}%"
|
||||||
statement = statement.where(
|
filters.append(
|
||||||
or_(
|
or_(
|
||||||
cls.candidate_name.ilike(like),
|
cls.candidate_name.ilike(like),
|
||||||
cls.candidate_email.ilike(like),
|
cls.candidate_email.ilike(like),
|
||||||
|
|
@ -376,10 +533,41 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
Users.email.ilike(like),
|
Users.email.ilike(like),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
statement = statement.limit(limit).offset(offset)
|
return filters
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None):
|
||||||
|
"""Newest applications with a user + assigned job for Candidates / Talent Pool."""
|
||||||
|
from users.models import Users
|
||||||
|
|
||||||
|
if job_post_ids is not None and not list(job_post_ids):
|
||||||
|
return []
|
||||||
|
statement = (
|
||||||
|
select(cls)
|
||||||
|
.join(Users, cls.user_id == Users.id)
|
||||||
|
.where(*cls._talent_pool_filters(Users, search=search, job_post_ids=job_post_ids))
|
||||||
|
.order_by(cls.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def count_for_talent_pool(cls, session: AsyncSession, *, search=None, job_post_ids=None):
|
||||||
|
from users.models import Users
|
||||||
|
|
||||||
|
if job_post_ids is not None and not list(job_post_ids):
|
||||||
|
return 0
|
||||||
|
statement = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(cls)
|
||||||
|
.join(Users, cls.user_id == Users.id)
|
||||||
|
.where(*cls._talent_pool_filters(Users, search=search, job_post_ids=job_post_ids))
|
||||||
|
)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def sources_by_user_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
|
async def sources_by_user_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
|
||||||
"""Newest platform/apply_via label per user — Candidates Form badges."""
|
"""Newest platform/apply_via label per user — Candidates Form badges."""
|
||||||
|
|
@ -442,7 +630,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
candidate_name, full_text, file_name,
|
candidate_name, full_text, file_name,
|
||||||
created_by, pdf_bytes,
|
created_by, pdf_bytes,
|
||||||
content_type="application/pdf",
|
content_type="application/pdf",
|
||||||
linkedin_url=None):
|
linkedin_url=None, profile=None,
|
||||||
|
bank_reason="speculative", retention_months=None):
|
||||||
"""Bank a CV: metadata row + its bytes (cv_bank_files) in one commit.
|
"""Bank a CV: metadata row + its bytes (cv_bank_files) in one commit.
|
||||||
file_path is filled after S3 upload under Temp/{id}/ (see cv_bank_upload).
|
file_path is filled after S3 upload under Temp/{id}/ (see cv_bank_upload).
|
||||||
|
|
||||||
|
|
@ -451,7 +640,13 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
candidate ACCOUNT is created/reused so the person shows up on the
|
candidate ACCOUNT is created/reused so the person shows up on the
|
||||||
Candidates screen; unlike an application there is still no inbox entry,
|
Candidates screen; unlike an application there is still no inbox entry,
|
||||||
no scoring, and no setup email. A CV with no detectable email banks
|
no scoring, and no setup email. A CV with no detectable email banks
|
||||||
fine and simply stays account-less."""
|
fine and simply stays account-less.
|
||||||
|
|
||||||
|
`profile` is the rest of the employment-agent extraction (company,
|
||||||
|
title, education, phone, skills, years) — the only structured data a
|
||||||
|
banked CV gets, since nothing scores it until it is matched to a job.
|
||||||
|
`retention_months` stamps bank_expires_at so the CV is held for a
|
||||||
|
stated period rather than indefinitely."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from role.models import EnumRoles, Roles
|
from role.models import EnumRoles, Roles
|
||||||
|
|
@ -480,7 +675,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
user.is_active = True
|
user.is_active = True
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
|
||||||
url = (linkedin_url or "").strip() or None
|
extracted = profile or {}
|
||||||
|
url = (linkedin_url or extracted.get("linkedin_url") or "").strip() or None
|
||||||
if url:
|
if url:
|
||||||
linkedin_slug = slug_from_url(url) or NO_SLUG
|
linkedin_slug = slug_from_url(url) or NO_SLUG
|
||||||
else:
|
else:
|
||||||
|
|
@ -488,19 +684,33 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
if user and url:
|
if user and url:
|
||||||
await Users.set_linkedin_url_if_empty(session, user_id=user.id, url=url)
|
await Users.set_linkedin_url_if_empty(session, user_id=user.id, url=url)
|
||||||
|
|
||||||
|
expires_at = None
|
||||||
|
if retention_months:
|
||||||
|
expires_at = _now() + timedelta(days=30 * int(retention_months))
|
||||||
|
|
||||||
row = cls(
|
row = cls(
|
||||||
candidate_email=email,
|
candidate_email=email,
|
||||||
candidate_name=(candidate_name or "").strip() or (email or ""),
|
candidate_name=(candidate_name or "").strip() or (email or ""),
|
||||||
|
candidate_phone=(extracted.get("candidate_phone") or "").strip(),
|
||||||
job_post_id=None,
|
job_post_id=None,
|
||||||
full_text=full_text or "",
|
full_text=full_text or "",
|
||||||
linkedin_slug=linkedin_slug,
|
linkedin_slug=linkedin_slug,
|
||||||
linkedin_url=url,
|
linkedin_url=url,
|
||||||
|
current_company=(extracted.get("current_company") or "").strip(),
|
||||||
|
current_position=(extracted.get("current_position") or "").strip(),
|
||||||
|
education=(extracted.get("education") or "").strip(),
|
||||||
|
skills=extracted.get("skills") or [],
|
||||||
|
years_experience=extracted.get("years_experience"),
|
||||||
|
experience="" if extracted.get("years_experience") is None else str(extracted.get("years_experience")),
|
||||||
|
bank_reason=(bank_reason or "").strip(),
|
||||||
|
bank_expires_at=expires_at,
|
||||||
apply_via="cv_bank",
|
apply_via="cv_bank",
|
||||||
user_id=user.id if user else None,
|
user_id=user.id if user else None,
|
||||||
created_by=cls._as_uuid(created_by),
|
created_by=cls._as_uuid(created_by),
|
||||||
status="BANKED",
|
status="BANKED",
|
||||||
file_name=(file_name or "").strip(),
|
file_name=(file_name or "").strip(),
|
||||||
file_path="",
|
file_path="",
|
||||||
|
city=(extracted.get("city") or "").strip() or None,
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
@ -511,14 +721,21 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
file_path=None,
|
file_path=None,
|
||||||
data=pdf_bytes,
|
data=pdf_bytes,
|
||||||
))
|
))
|
||||||
|
if user and row.city:
|
||||||
|
await Users.set_city_if_empty(session, user_id=user.id, city=row.city)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def list_bank(cls, session: AsyncSession, limit=100, offset=0):
|
async def list_bank(cls, session: AsyncSession, limit=100, offset=0):
|
||||||
"""Unassigned No-job CVs only — assigned rows leave the bank for Matching."""
|
"""Speculative CVs (`apply_via=cv_bank`), including ones later linked to a job.
|
||||||
bank = (cls.apply_via == "cv_bank", cls.job_post_id.is_(None))
|
|
||||||
|
Run ATS assigns job_post_id so the row joins Pipeline like any other
|
||||||
|
application. The bank still lists them so the ATS score is visible on
|
||||||
|
this screen. Matching remains the assign queue for unassigned rows.
|
||||||
|
"""
|
||||||
|
bank = (cls.apply_via == "cv_bank",)
|
||||||
total = (
|
total = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(func.count()).select_from(cls).where(*bank)
|
select(func.count()).select_from(cls).where(*bank)
|
||||||
|
|
@ -533,6 +750,67 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return list(result.scalars().all()), total
|
return list(result.scalars().all()), total
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_bank_needing_profile(cls, session: AsyncSession, limit=50):
|
||||||
|
"""Bank rows stored before the extraction existed (see 029).
|
||||||
|
|
||||||
|
Skills is the marker: a CV that genuinely lists none still gets its
|
||||||
|
title or years filled, so an empty skills array plus a blank title
|
||||||
|
means the agent never ran, not that the CV was sparse.
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(
|
||||||
|
cls.apply_via == "cv_bank",
|
||||||
|
cls.full_text != "",
|
||||||
|
func.coalesce(func.jsonb_array_length(cls.skills), 0) == 0,
|
||||||
|
or_(cls.current_position == "", cls.current_position.is_(None)),
|
||||||
|
)
|
||||||
|
.order_by(cls.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def set_bank_profile(cls, session: AsyncSession, record_id, profile):
|
||||||
|
"""Write an employment-agent extraction onto an existing bank row.
|
||||||
|
|
||||||
|
Only fills blanks — a recruiter may have corrected the company or title
|
||||||
|
by hand, and a backfill must not overwrite that.
|
||||||
|
"""
|
||||||
|
row = await session.get(cls, cls._as_uuid(record_id))
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
extracted_name = (profile.get("candidate_name") or "").strip()
|
||||||
|
current_name = (row.candidate_name or "").strip()
|
||||||
|
email = (row.candidate_email or "").strip()
|
||||||
|
if extracted_name and (not current_name or current_name.lower() == email.lower()):
|
||||||
|
row.candidate_name = extracted_name
|
||||||
|
if not (row.current_company or "").strip():
|
||||||
|
row.current_company = (profile.get("current_company") or "").strip()
|
||||||
|
if not (row.current_position or "").strip():
|
||||||
|
row.current_position = (profile.get("current_position") or "").strip()
|
||||||
|
if not (row.education or "").strip():
|
||||||
|
row.education = (profile.get("education") or "").strip()
|
||||||
|
if not (row.candidate_phone or "").strip():
|
||||||
|
row.candidate_phone = (profile.get("candidate_phone") or "").strip()
|
||||||
|
if not row.skills:
|
||||||
|
row.skills = profile.get("skills") or []
|
||||||
|
if row.years_experience is None:
|
||||||
|
row.years_experience = profile.get("years_experience")
|
||||||
|
if not (row.experience or "").strip() and row.years_experience is not None:
|
||||||
|
row.experience = str(row.years_experience)
|
||||||
|
if not (row.linkedin_url or "").strip() and profile.get("linkedin_url"):
|
||||||
|
row.linkedin_url = profile["linkedin_url"]
|
||||||
|
row.linkedin_slug = slug_from_url(profile["linkedin_url"]) or NO_SLUG
|
||||||
|
if not (row.city or "").strip() and profile.get("city"):
|
||||||
|
row.city = (profile.get("city") or "").strip() or None
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def list_matching(cls, session: AsyncSession, *, assigned=None,
|
async def list_matching(cls, session: AsyncSession, *, assigned=None,
|
||||||
search=None, limit=100, offset=0):
|
search=None, limit=100, offset=0):
|
||||||
|
|
@ -625,6 +903,41 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_bank_for_ranking(cls, session: AsyncSession, limit=5000):
|
||||||
|
"""Every CV still in the bank, for tier-1 ranking against a new job.
|
||||||
|
|
||||||
|
Expired CVs are excluded: ranking one would put a candidate in front of
|
||||||
|
a recruiter after the retention window said to stop holding them.
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(
|
||||||
|
cls.apply_via == "cv_bank",
|
||||||
|
cls.job_post_id.is_(None),
|
||||||
|
or_(cls.bank_expires_at.is_(None), cls.bank_expires_at > _now()),
|
||||||
|
)
|
||||||
|
.order_by(cls.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_bank_expired(cls, session: AsyncSession, limit=500):
|
||||||
|
"""Bank CVs past their retention window, for the nightly sweep."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(
|
||||||
|
cls.apply_via == "cv_bank",
|
||||||
|
cls.job_post_id.is_(None),
|
||||||
|
cls.bank_expires_at.is_not(None),
|
||||||
|
cls.bank_expires_at <= _now(),
|
||||||
|
)
|
||||||
|
.order_by(cls.bank_expires_at.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def delete_bank_cv(cls, session: AsyncSession, record_id):
|
async def delete_bank_cv(cls, session: AsyncSession, record_id):
|
||||||
"""Hard delete unassigned bank rows only — assigned rows are applications.
|
"""Hard delete unassigned bank rows only — assigned rows are applications.
|
||||||
|
|
@ -640,6 +953,60 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
class CvBankMatches(SQLModel, table=True):
|
||||||
|
"""Tier-1 rank of one banked CV against one job post (see 030).
|
||||||
|
|
||||||
|
Persisted rather than computed on read because the whole point is to tell a
|
||||||
|
recruiter the bank already holds candidates the moment a job opens — a
|
||||||
|
notification cannot wait for someone to open the screen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "cv_bank_matches"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"manual_upload_candidate_id", "job_post_id",
|
||||||
|
name="uq_cv_bank_matches_pair",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
manual_upload_candidate_id: uuid.UUID = Field(foreign_key="manual_upload_candidate.id")
|
||||||
|
job_post_id: uuid.UUID = Field(foreign_key="job_posts.id", index=True)
|
||||||
|
rank_score: int = Field(default=0)
|
||||||
|
computed_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def replace_for_job(cls, session: AsyncSession, job_post_id, scores):
|
||||||
|
"""Swap in a fresh ranking for one job.
|
||||||
|
|
||||||
|
Delete-then-insert rather than upsert: a re-rank after the job's
|
||||||
|
requirements were edited must not leave behind scores for CVs that have
|
||||||
|
since been assigned or deleted.
|
||||||
|
"""
|
||||||
|
jid = job_post_id if isinstance(job_post_id, uuid.UUID) else uuid.UUID(str(job_post_id))
|
||||||
|
existing = await session.execute(select(cls).where(cls.job_post_id == jid))
|
||||||
|
for row in existing.scalars().all():
|
||||||
|
await session.delete(row)
|
||||||
|
await session.flush()
|
||||||
|
for record_id, score in scores:
|
||||||
|
session.add(cls(
|
||||||
|
manual_upload_candidate_id=record_id,
|
||||||
|
job_post_id=jid,
|
||||||
|
rank_score=int(score or 0),
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
return len(scores)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def scores_for_job(cls, session: AsyncSession, job_post_id) -> dict:
|
||||||
|
"""rank_score keyed by manual_upload_candidate_id, as strings."""
|
||||||
|
jid = job_post_id if isinstance(job_post_id, uuid.UUID) else uuid.UUID(str(job_post_id))
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls.manual_upload_candidate_id, cls.rank_score).where(cls.job_post_id == jid)
|
||||||
|
)
|
||||||
|
return {str(record_id): int(score) for record_id, score in result.all()}
|
||||||
|
|
||||||
|
|
||||||
class CvBankFiles(SQLModel, table=True):
|
class CvBankFiles(SQLModel, table=True):
|
||||||
"""PDF bytes of a CV-bank entry — in the database so production redeploys
|
"""PDF bytes of a CV-bank entry — in the database so production redeploys
|
||||||
(ephemeral container filesystems) can never lose a stored CV. Created in
|
(ephemeral container filesystems) can never lose a stored CV. Created in
|
||||||
|
|
@ -687,8 +1054,15 @@ class Candidates(SQLModel, table=True):
|
||||||
matched_keywords: list[str] = Field(default_factory=list, sa_type=JSON)
|
matched_keywords: list[str] = Field(default_factory=list, sa_type=JSON)
|
||||||
missing_keywords: list[str] = Field(default_factory=list, sa_type=JSON)
|
missing_keywords: list[str] = Field(default_factory=list, sa_type=JSON)
|
||||||
summary_critique: str | None = Field(default=None)
|
summary_critique: str | None = Field(default=None)
|
||||||
|
professional_summary: str | None = Field(default=None)
|
||||||
# Public LinkedIn URL extracted from the scored CV. Fetch reads this column.
|
# Public LinkedIn URL extracted from the scored CV. Fetch reads this column.
|
||||||
linkedin_url: str | None = Field(default=None)
|
linkedin_url: str | None = Field(default=None)
|
||||||
|
# Application that owns the extracted CV text. Inbox email → inbox_messages.resume_text;
|
||||||
|
# Add Candidate / CV bank → manual_upload_candidate.full_text. Migration 036.
|
||||||
|
inbox_message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id", index=True)
|
||||||
|
manual_upload_candidate_id: uuid.UUID | None = Field(
|
||||||
|
default=None, foreign_key="manual_upload_candidate.id", index=True
|
||||||
|
)
|
||||||
|
|
||||||
status: str # "completed" | "failed"
|
status: str # "completed" | "failed"
|
||||||
error_code: str | None = Field(default=None)
|
error_code: str | None = Field(default=None)
|
||||||
|
|
@ -769,6 +1143,70 @@ class Candidates(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_by_emails(cls, session: AsyncSession, emails):
|
||||||
|
"""Scored `candidates` rows for these addresses (ATS, not pipeline stage)."""
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||||
|
if not lowers:
|
||||||
|
return []
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls, JobPosts.title)
|
||||||
|
.outerjoin(JobPosts, cls.job_id == JobPosts.id)
|
||||||
|
.where(func.lower(cls.candidate_email).in_(lowers))
|
||||||
|
.order_by(cls.created_at.desc())
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
for rec, title in result.all():
|
||||||
|
email = (rec.candidate_email or "").strip().lower() or None
|
||||||
|
rows.append({
|
||||||
|
"source": "ats",
|
||||||
|
"email": email,
|
||||||
|
"inbox_id": None,
|
||||||
|
"message_id": str(rec.inbox_message_id) if getattr(rec, "inbox_message_id", None) else None,
|
||||||
|
"manual_upload_candidate_id": str(rec.manual_upload_candidate_id) if getattr(rec, "manual_upload_candidate_id", None) else None,
|
||||||
|
"form_data_id": None,
|
||||||
|
"candidate_id": str(rec.id),
|
||||||
|
"job_post_id": str(rec.job_id) if rec.job_id else None,
|
||||||
|
"job_title": title or rec.job_title or None,
|
||||||
|
"status": rec.status or None,
|
||||||
|
"applied_at": rec.created_at.isoformat() if rec.created_at else None,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def latest_completed_by_emails(cls, session: AsyncSession, emails):
|
||||||
|
"""Newest completed ATS score per email, with the job it ran against.
|
||||||
|
|
||||||
|
CV Bank speculative rows have no candidates FK; email is the join the
|
||||||
|
scorer already writes (score_bank → upsert_candidate).
|
||||||
|
"""
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||||
|
if not lowers:
|
||||||
|
return {}
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls, JobPosts.title)
|
||||||
|
.outerjoin(JobPosts, cls.job_id == JobPosts.id)
|
||||||
|
.where(func.lower(cls.candidate_email).in_(lowers))
|
||||||
|
.where(cls.status == "completed")
|
||||||
|
.where(cls.match_score.is_not(None))
|
||||||
|
.order_by(cls.updated_at.desc(), cls.created_at.desc())
|
||||||
|
)
|
||||||
|
out = {}
|
||||||
|
for rec, title in result.all():
|
||||||
|
email = (rec.candidate_email or "").strip().lower()
|
||||||
|
if not email or email in out:
|
||||||
|
continue
|
||||||
|
out[email] = {
|
||||||
|
"match_score": int(rec.match_score) if rec.match_score is not None else None,
|
||||||
|
"job_post_id": str(rec.job_id) if rec.job_id else None,
|
||||||
|
"job_title": title or rec.job_title or None,
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def upsert_candidate(cls, session: AsyncSession, fields: dict):
|
async def upsert_candidate(cls, session: AsyncSession, fields: dict):
|
||||||
existing = None
|
existing = None
|
||||||
|
|
@ -879,6 +1317,18 @@ class Interviews(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_interviews_by_ids(cls, session: AsyncSession, ids):
|
||||||
|
keys=[]
|
||||||
|
for raw in ids or []:
|
||||||
|
uid=cls._as_uuid(raw)
|
||||||
|
if uid is not None:
|
||||||
|
keys.append(uid)
|
||||||
|
if not keys:
|
||||||
|
return []
|
||||||
|
result=await session.execute(select(cls).where(cls.id.in_(keys)))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_interviews_by_inbox(cls, session: AsyncSession, inbox_id: int):
|
async def get_interviews_by_inbox(cls, session: AsyncSession, inbox_id: int):
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
|
|
@ -908,7 +1358,7 @@ class Interviews(SQLModel, table=True):
|
||||||
.outerjoin(Inbox, cls.inbox_id == Inbox.id)
|
.outerjoin(Inbox, cls.inbox_id == Inbox.id)
|
||||||
.outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id)
|
.outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id)
|
||||||
.outerjoin(JobPosts, JobPosts.id == job_id)
|
.outerjoin(JobPosts, JobPosts.id == job_id)
|
||||||
.where(JobPosts.current_recruiter_id == rid)
|
.where(JobPosts.has_recruiter(rid))
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -1382,7 +1832,7 @@ class ApplicationStageTransitions(SQLModel, table=True):
|
||||||
rid = cls._as_uuid(recruiter_id)
|
rid = cls._as_uuid(recruiter_id)
|
||||||
if rid is not None:
|
if rid is not None:
|
||||||
statement = statement.where(
|
statement = statement.where(
|
||||||
or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
|
or_(Inbox_Messages.recruiter_id == rid, JobPosts.has_recruiter(rid))
|
||||||
)
|
)
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
value = result.scalar_one()
|
value = result.scalar_one()
|
||||||
|
|
@ -1406,7 +1856,7 @@ class ApplicationStageTransitions(SQLModel, table=True):
|
||||||
rid = cls._as_uuid(recruiter_id)
|
rid = cls._as_uuid(recruiter_id)
|
||||||
if rid is not None:
|
if rid is not None:
|
||||||
statement = statement.where(
|
statement = statement.where(
|
||||||
or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
|
or_(Inbox_Messages.recruiter_id == rid, JobPosts.has_recruiter(rid))
|
||||||
)
|
)
|
||||||
return statement
|
return statement
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ from pathlib import Path
|
||||||
|
|
||||||
from app.core.config import Settings, get_settings
|
from app.core.config import Settings, get_settings
|
||||||
from app.services.llm import OpenAIScorer
|
from app.services.llm import OpenAIScorer
|
||||||
|
# One definition, two extractors. The bulk-ATS engine and this recruiting path
|
||||||
|
# both read CVs with pypdf and both broke the same way on glyph-fragmented
|
||||||
|
# files, so the repair lives in the package that owns PDF handling and is
|
||||||
|
# re-exported here for the callers that import it from this module.
|
||||||
|
from app.services.pdf import extract_pdf_text, is_glyph_fragmented # noqa: F401
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from job.candidate.decorators import despace_line, normalize_unicode
|
from job.candidate.decorators import despace_line, normalize_unicode
|
||||||
|
|
@ -96,12 +101,20 @@ def build_job_description(job) -> str:
|
||||||
|
|
||||||
|
|
||||||
def candidate_base_fields(source):
|
def candidate_base_fields(source):
|
||||||
return {
|
fields = {
|
||||||
"filename": source["safe_name"],
|
"filename": source["safe_name"],
|
||||||
"file_path": source["file_path"],
|
"file_path": source["file_path"],
|
||||||
"content_sha256": source["sha256"],
|
"content_sha256": source["sha256"],
|
||||||
"candidate_email": source.get("candidate_email"),
|
"candidate_email": source.get("candidate_email"),
|
||||||
}
|
}
|
||||||
|
# Omit empty FKs so an upsert cannot wipe a link that this source does not know.
|
||||||
|
inbox_mid = source.get("inbox_message_id")
|
||||||
|
if inbox_mid:
|
||||||
|
fields["inbox_message_id"] = inbox_mid
|
||||||
|
manual_id = source.get("manual_upload_candidate_id")
|
||||||
|
if manual_id:
|
||||||
|
fields["manual_upload_candidate_id"] = manual_id
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
def candidate_failed_fields(source, code, message):
|
def candidate_failed_fields(source, code, message):
|
||||||
|
|
@ -134,6 +147,7 @@ def candidate_completed_fields(source, result):
|
||||||
"matched_keywords": result.matched_keywords,
|
"matched_keywords": result.matched_keywords,
|
||||||
"missing_keywords": result.missing_keywords,
|
"missing_keywords": result.missing_keywords,
|
||||||
"summary_critique": result.summary_critique,
|
"summary_critique": result.summary_critique,
|
||||||
|
"professional_summary": result.professional_summary,
|
||||||
"linkedin_url": None,
|
"linkedin_url": None,
|
||||||
"error_code": None,
|
"error_code": None,
|
||||||
"error_message": None,
|
"error_message": None,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,18 @@ from job.activity.serializers import serialize_activity
|
||||||
from job.feedback.serializers import serialize_feedback
|
from job.feedback.serializers import serialize_feedback
|
||||||
from job.job_post.serializers import serialize_job_post
|
from job.job_post.serializers import serialize_job_post
|
||||||
|
|
||||||
|
def _id_str(value):
|
||||||
|
if value in (None,""):
|
||||||
|
return None
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def _id_list(value):
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
if isinstance(value,(list,tuple)):
|
||||||
|
return [str(v) for v in value if v not in (None,"")]
|
||||||
|
return [str(value)]
|
||||||
|
|
||||||
def serialize_candidate(row) -> dict:
|
def serialize_candidate(row) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
|
|
@ -30,7 +42,10 @@ def serialize_candidate(row) -> dict:
|
||||||
"matched_keywords": list(row.matched_keywords or []),
|
"matched_keywords": list(row.matched_keywords or []),
|
||||||
"missing_keywords": list(row.missing_keywords or []),
|
"missing_keywords": list(row.missing_keywords or []),
|
||||||
"summary_critique": row.summary_critique,
|
"summary_critique": row.summary_critique,
|
||||||
|
"professional_summary": row.professional_summary or None,
|
||||||
"linkedin_url": row.linkedin_url or None,
|
"linkedin_url": row.linkedin_url or None,
|
||||||
|
"inbox_message_id": str(row.inbox_message_id) if getattr(row, "inbox_message_id", None) else None,
|
||||||
|
"manual_upload_candidate_id": str(row.manual_upload_candidate_id) if getattr(row, "manual_upload_candidate_id", None) else None,
|
||||||
"status": row.status,
|
"status": row.status,
|
||||||
"error_code": row.error_code,
|
"error_code": row.error_code,
|
||||||
"error_message": row.error_message,
|
"error_message": row.error_message,
|
||||||
|
|
@ -63,6 +78,110 @@ def serialize_matching_candidate(row, job_post=None) -> Dict[str,Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_bank_candidate(row, *, rank_score=None) -> Dict[str,Any]:
|
||||||
|
"""A CV held with no job, for the CV Bank screen.
|
||||||
|
|
||||||
|
Same shape as serialize_bank_silver_medalist so the table renders one row
|
||||||
|
type regardless of which population the candidate came from. `id` is
|
||||||
|
prefixed because the two sources have different key spaces and would
|
||||||
|
otherwise collide in a merged list.
|
||||||
|
|
||||||
|
rank_score is optional keyword overlap used by /cv-bank/suggestions, not
|
||||||
|
by the CV Bank table. ai_score is filled after serialize by joining the
|
||||||
|
latest candidates row for this email — this function leaves it None.
|
||||||
|
Speculative uploads have no inbox suggestions; suggested_job_post_ids is [].
|
||||||
|
"""
|
||||||
|
name=(row.candidate_name or "").strip() or (row.candidate_email or "").strip() or (row.file_name or "").strip() or "Unknown"
|
||||||
|
assigned=_id_str(row.job_post_id)
|
||||||
|
return {
|
||||||
|
"id":f"bank:{row.id}",
|
||||||
|
"record_id":str(row.id),
|
||||||
|
"bank_source":"speculative",
|
||||||
|
"name":name,
|
||||||
|
"email":(row.candidate_email or "").strip() or None,
|
||||||
|
"phone":(row.candidate_phone or "").strip() or None,
|
||||||
|
"file_name":(row.file_name or "").strip() or None,
|
||||||
|
"file_path":(row.file_path or "").strip() or None,
|
||||||
|
"linkedin_url":row.linkedin_url or None,
|
||||||
|
"current_company":(row.current_company or "").strip() or None,
|
||||||
|
"current_position":(row.current_position or "").strip() or None,
|
||||||
|
"education":(row.education or "").strip() or None,
|
||||||
|
"city":(getattr(row,"city",None) or "").strip() or None,
|
||||||
|
"skills":list(row.skills or []),
|
||||||
|
"years_experience":row.years_experience,
|
||||||
|
"ai_score":None,
|
||||||
|
"recommendation":None,
|
||||||
|
"rank_score":rank_score,
|
||||||
|
"last_job_title":None,
|
||||||
|
"bank_reason":(row.bank_reason or "").strip() or None,
|
||||||
|
"bank_expires_at":row.bank_expires_at.isoformat() if row.bank_expires_at else None,
|
||||||
|
"user_id":str(row.user_id) if row.user_id else None,
|
||||||
|
"message_id":None,
|
||||||
|
"assigned_job_post_id":assigned,
|
||||||
|
"assigned_job_title":None,
|
||||||
|
"scored_job_post_id":None,
|
||||||
|
"scored_job_title":None,
|
||||||
|
"suggested_job_post_ids":[],
|
||||||
|
"suggested_jobs":[],
|
||||||
|
"created_at":row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"updated_at":row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_bank_silver_medalist(row, *, rank_score=None) -> Dict[str,Any]:
|
||||||
|
"""A past applicant who scored well and did not get the job.
|
||||||
|
|
||||||
|
Read from the live application tables rather than copied into the bank, so
|
||||||
|
there is no second source of truth to keep in sync. `row` is the flat
|
||||||
|
mapping produced by Inbox.list_silver_medalists.
|
||||||
|
"""
|
||||||
|
def get(key):
|
||||||
|
value=row.get(key)
|
||||||
|
return value.strip() if isinstance(value,str) else value
|
||||||
|
|
||||||
|
name=(get("name") or "") or (get("email") or "") or "Unknown"
|
||||||
|
expires=get("bank_expires_at")
|
||||||
|
created=get("created_at")
|
||||||
|
assigned=_id_str(get("assigned_job_post_id") or get("last_job_post_id"))
|
||||||
|
suggested=_id_list(row.get("suggested_job_post_ids"))
|
||||||
|
last_title=get("last_job_title") or None
|
||||||
|
return {
|
||||||
|
"id":f"app:{get('inbox_id')}",
|
||||||
|
"record_id":str(get("inbox_id")),
|
||||||
|
"bank_source":"silver_medalist",
|
||||||
|
"name":name,
|
||||||
|
"email":get("email") or None,
|
||||||
|
"phone":get("phone") or None,
|
||||||
|
"file_name":get("file_name") or None,
|
||||||
|
"file_path":get("file_path") or None,
|
||||||
|
"linkedin_url":get("linkedin_url") or None,
|
||||||
|
"current_company":get("current_company") or None,
|
||||||
|
"current_position":get("current_title") or None,
|
||||||
|
"education":get("education") or None,
|
||||||
|
"city":get("city") or None,
|
||||||
|
# Inbox applications never ran the skills extraction — their structured
|
||||||
|
# signal is the ATS score, which is stronger than a keyword list.
|
||||||
|
"skills":list(row.get("matched_keywords") or []),
|
||||||
|
"years_experience":get("years_experience"),
|
||||||
|
"ai_score":get("ai_score"),
|
||||||
|
"recommendation":get("recommendation"),
|
||||||
|
"rank_score":rank_score,
|
||||||
|
"last_job_title":last_title,
|
||||||
|
"bank_reason":"silver_medalist",
|
||||||
|
"bank_expires_at":expires.isoformat() if hasattr(expires,"isoformat") else expires,
|
||||||
|
"user_id":str(get("user_id")) if get("user_id") else None,
|
||||||
|
"message_id":_id_str(get("message_id")),
|
||||||
|
"assigned_job_post_id":assigned,
|
||||||
|
"assigned_job_title":last_title,
|
||||||
|
"scored_job_post_id":assigned,
|
||||||
|
"scored_job_title":last_title,
|
||||||
|
"suggested_job_post_ids":suggested,
|
||||||
|
"suggested_jobs":[],
|
||||||
|
"created_at":created.isoformat() if hasattr(created,"isoformat") else created,
|
||||||
|
"updated_at":None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
|
def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
|
||||||
return {
|
return {
|
||||||
"id":str(row.id) if row.id else None,
|
"id":str(row.id) if row.id else None,
|
||||||
|
|
@ -123,6 +242,10 @@ def serialize_candidate_profile(
|
||||||
"match_status": message.match_status if message else None,
|
"match_status": message.match_status if message else None,
|
||||||
"match_error": message.match_error if message else None,
|
"match_error": message.match_error if message else None,
|
||||||
"matched_at": message.matched_at.isoformat() if message and message.matched_at else None,
|
"matched_at": message.matched_at.isoformat() if message and message.matched_at else None,
|
||||||
|
"professional_summary": (
|
||||||
|
(message.professional_summary if message else None)
|
||||||
|
or (user.professional_summary if user else None)
|
||||||
|
),
|
||||||
"file_path": _first_file_path(message.file_path if message else None),
|
"file_path": _first_file_path(message.file_path if message else None),
|
||||||
"job_posts": [],
|
"job_posts": [],
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +305,7 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
|
||||||
"message_id": None,
|
"message_id": None,
|
||||||
"created_at": created,
|
"created_at": created,
|
||||||
"application_status": row.status or None,
|
"application_status": row.status or None,
|
||||||
"experience": (row.experience or "").strip() or None,
|
"experience": (row.experience or "").strip() or (str(row.years_experience) if row.years_experience is not None else None),
|
||||||
"current_employment": company,
|
"current_employment": company,
|
||||||
"current_title": position,
|
"current_title": position,
|
||||||
"resume_text": row.full_text or None,
|
"resume_text": row.full_text or None,
|
||||||
|
|
@ -193,11 +316,12 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
|
||||||
"match_status": None,
|
"match_status": None,
|
||||||
"match_error": None,
|
"match_error": None,
|
||||||
"matched_at": None,
|
"matched_at": None,
|
||||||
|
"professional_summary": row.professional_summary or (user.professional_summary if user else None),
|
||||||
"job_posts": [job_payload] if job_payload else [],
|
"job_posts": [job_payload] if job_payload else [],
|
||||||
"favorite": None,
|
"favorite": None,
|
||||||
"rating": None,
|
"rating": None,
|
||||||
"phone": (row.candidate_phone or "").strip() or None,
|
"phone": (row.candidate_phone or "").strip() or None,
|
||||||
"education": None,
|
"education": (row.education or "").strip() or None,
|
||||||
"currentCompany": company,
|
"currentCompany": company,
|
||||||
"stage": row.status or None,
|
"stage": row.status or None,
|
||||||
"source": (row.platform or "").strip() or None,
|
"source": (row.platform or "").strip() or None,
|
||||||
|
|
@ -220,3 +344,206 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
|
||||||
"summary_critique": None,
|
"summary_critique": None,
|
||||||
"scored_at": None,
|
"scored_at": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_manual_candidate_list(profile: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""List shape of serialize_manual_candidate_profile — drop heavy detail."""
|
||||||
|
return {
|
||||||
|
"inbox_id": None,
|
||||||
|
"manual_upload_candidate_id": profile.get("manual_upload_candidate_id"),
|
||||||
|
"user_id": profile.get("user_id"),
|
||||||
|
"candidate_id": None,
|
||||||
|
"name": profile.get("name"),
|
||||||
|
"email": profile.get("email"),
|
||||||
|
"is_active": profile.get("is_active"),
|
||||||
|
"message_id": None,
|
||||||
|
"created_at": profile.get("created_at"),
|
||||||
|
"application_status": profile.get("application_status"),
|
||||||
|
"experience": profile.get("experience"),
|
||||||
|
"current_employment": profile.get("current_employment"),
|
||||||
|
"current_title": profile.get("current_title"),
|
||||||
|
"resume_text": None,
|
||||||
|
"suggested_job_post_ids": profile.get("suggested_job_post_ids") or [],
|
||||||
|
"assigned_job_post_id": profile.get("assigned_job_post_id"),
|
||||||
|
"job_posts": profile.get("job_posts") or [],
|
||||||
|
"assigned_job_post": profile.get("assigned_job_post"),
|
||||||
|
"job_title": profile.get("job_title"),
|
||||||
|
"recruiter": profile.get("recruiter"),
|
||||||
|
"recruiter_id": profile.get("recruiter_id"),
|
||||||
|
"source": profile.get("source"),
|
||||||
|
"file_path": profile.get("file_path"),
|
||||||
|
"ai_score": None,
|
||||||
|
"recommendation": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_form_candidate_list(row) -> Dict[str, Any]:
|
||||||
|
"""GET /candidate/fetch list row for an unpromoted FormData application.
|
||||||
|
|
||||||
|
processing_state is an inbox tab, not Candidate_application_Status, so it
|
||||||
|
is not copied onto application_status — CLOSED/rejected-tab values would
|
||||||
|
paint every sheet row as Rejected on Candidates.
|
||||||
|
"""
|
||||||
|
assigned = row.assigned_job_post_id or row.job_post_id
|
||||||
|
suggested = [str(v) for v in (row.suggested_job_post_ids or []) if v not in (None, "")]
|
||||||
|
created = row.created_at.isoformat() if row.created_at else None
|
||||||
|
name = (row.name or "").strip() or None
|
||||||
|
email = (row.candidate_email or "").strip() or None
|
||||||
|
return {
|
||||||
|
"inbox_id": None,
|
||||||
|
"form_data_id": str(row.id),
|
||||||
|
"manual_upload_candidate_id": None,
|
||||||
|
"user_id": None,
|
||||||
|
"candidate_id": None,
|
||||||
|
"name": name,
|
||||||
|
"email": email,
|
||||||
|
"is_active": None,
|
||||||
|
"message_id": None,
|
||||||
|
"created_at": created,
|
||||||
|
"application_status": None,
|
||||||
|
"experience": (row.experience or "").strip() or None,
|
||||||
|
"current_employment": (row.current_company or "").strip() or None,
|
||||||
|
"current_title": (row.position_applied_for or "").strip() or None,
|
||||||
|
"resume_text": None,
|
||||||
|
"suggested_job_post_ids": suggested,
|
||||||
|
"assigned_job_post_id": str(assigned) if assigned else None,
|
||||||
|
"job_posts": [],
|
||||||
|
"assigned_job_post": None,
|
||||||
|
"job_title": (row.position_applied_for or "").strip() or None,
|
||||||
|
"recruiter": None,
|
||||||
|
"recruiter_id": None,
|
||||||
|
"source": "Form",
|
||||||
|
"file_path": None,
|
||||||
|
"ai_score": None,
|
||||||
|
"recommendation": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_manager_candidate(row, *, source) -> dict:
|
||||||
|
"""One application on a hiring-manager's job — list row, not the profile."""
|
||||||
|
inbox_id = row.get("inbox_id")
|
||||||
|
manual_id = row.get("id") if source == "manual" else None
|
||||||
|
job_post_id = row.get("assigned_job_post_id") or row.get("job_post_id")
|
||||||
|
user_id = row.get("user_id")
|
||||||
|
ats = row.get("ats_result") or {}
|
||||||
|
score = ats.get("overall_score")
|
||||||
|
band = (ats.get("band") or "").strip() or None
|
||||||
|
if score is not None and not band:
|
||||||
|
band = "Strong Match" if score >= 82 else "Potential Match" if score >= 65 else "Weak Match"
|
||||||
|
return {
|
||||||
|
"id": user_id or (f"inbox:{inbox_id}" if inbox_id is not None else f"manual:{manual_id}"),
|
||||||
|
"user_id": user_id,
|
||||||
|
"name": row.get("name"),
|
||||||
|
"email": row.get("email") or row.get("candidate_email"),
|
||||||
|
"job_post_id": job_post_id,
|
||||||
|
"job_title": row.get("title"),
|
||||||
|
"application_status": row.get("application_status"),
|
||||||
|
"inbox_id": inbox_id,
|
||||||
|
"manual_upload_candidate_id": str(manual_id) if manual_id else None,
|
||||||
|
"created_at": row.get("created_at"),
|
||||||
|
"source": source,
|
||||||
|
"ai_score": score,
|
||||||
|
"recommendation": band,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_WRONG_FORMAT_MATCH = frozenset({"no_text", "failed", "dlq"})
|
||||||
|
|
||||||
|
|
||||||
|
def is_assigned_application(row) -> bool:
|
||||||
|
"""True when the row is an application to a real job, not an unassigned email.
|
||||||
|
|
||||||
|
Sheet forms name a role in job_title even before a job post is linked.
|
||||||
|
Unassigned inbox mail is still a kept attempt — see is_kept_application.
|
||||||
|
"""
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
return False
|
||||||
|
if row.get("job_post_id"):
|
||||||
|
return True
|
||||||
|
if row.get("source") == "form" and row.get("job_title"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_kept_application(row) -> bool:
|
||||||
|
"""True when the row is a real application, including unassigned inbox mail.
|
||||||
|
|
||||||
|
A CV attachment counts even when text extraction failed (`no_text`) — they
|
||||||
|
still applied. Body-only mail and classifier drops stay in history but do
|
||||||
|
not count as a reapplication. Two On-Hold emails from the same person do.
|
||||||
|
"""
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
return False
|
||||||
|
if row.get("source") == "filtered":
|
||||||
|
return False
|
||||||
|
if row.get("source") == "inbox" and row.get("attachment") is False:
|
||||||
|
return False
|
||||||
|
if row.get("source") == "inbox" and row.get("attachment") is True:
|
||||||
|
return True
|
||||||
|
return rejection_reason(row) != "wrong_format"
|
||||||
|
|
||||||
|
|
||||||
|
def rejection_reason(row) -> str | None:
|
||||||
|
"""Why an unassigned attempt never reached a job — or None if it is still open.
|
||||||
|
|
||||||
|
Wrong format: no CV, unreadable PDF, matcher failed, or the classifier
|
||||||
|
kept the mail out of the inbox. Assigned rows keep their pipeline status.
|
||||||
|
"""
|
||||||
|
if not isinstance(row, dict) or is_assigned_application(row):
|
||||||
|
return None
|
||||||
|
if row.get("rejection_reason") == "wrong_format":
|
||||||
|
return "wrong_format"
|
||||||
|
if row.get("source") == "filtered":
|
||||||
|
return "wrong_format"
|
||||||
|
match = str(row.get("match_status") or "").strip().lower()
|
||||||
|
if match in _WRONG_FORMAT_MATCH:
|
||||||
|
return "wrong_format"
|
||||||
|
if row.get("source") == "inbox" and row.get("attachment") is False:
|
||||||
|
return "wrong_format"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_application_history_item(row) -> dict:
|
||||||
|
"""One prior application / score / sheet row for a reapplicant lookup."""
|
||||||
|
reason = rejection_reason(row)
|
||||||
|
status = row.get("status")
|
||||||
|
if reason == "wrong_format":
|
||||||
|
status = "WRONG_FORMAT"
|
||||||
|
return {
|
||||||
|
"source": row.get("source"),
|
||||||
|
"inbox_id": row.get("inbox_id"),
|
||||||
|
"message_id": row.get("message_id"),
|
||||||
|
"upstream_id": row.get("upstream_id"),
|
||||||
|
"manual_upload_candidate_id": row.get("manual_upload_candidate_id"),
|
||||||
|
"form_data_id": row.get("form_data_id"),
|
||||||
|
"candidate_id": row.get("candidate_id"),
|
||||||
|
"user_id": str(row.get("user_id")) if row.get("user_id") else None,
|
||||||
|
"job_post_id": row.get("job_post_id"),
|
||||||
|
"job_title": row.get("job_title"),
|
||||||
|
"status": status,
|
||||||
|
"applied_at": row.get("applied_at"),
|
||||||
|
"rejection_reason": reason,
|
||||||
|
"match_status": row.get("match_status"),
|
||||||
|
"attachment": row.get("attachment"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_application_history(email, *, user=None, present_in=None, applications=None) -> dict:
|
||||||
|
items = [
|
||||||
|
item for item in (
|
||||||
|
serialize_application_history_item(row) for row in (applications or [])
|
||||||
|
)
|
||||||
|
if is_kept_application(item)
|
||||||
|
]
|
||||||
|
found = bool(user or present_in or items)
|
||||||
|
return {
|
||||||
|
"email": email,
|
||||||
|
"found": found,
|
||||||
|
"present_in": list(present_in or []),
|
||||||
|
"user": (
|
||||||
|
{"id": str(user.id), "name": user.name, "email": user.email}
|
||||||
|
if user is not None else None
|
||||||
|
),
|
||||||
|
"is_reapplicant": len(items) > 1,
|
||||||
|
"applications": items,
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -92,7 +92,7 @@ class HiringCosts(SQLModel, table=True):
|
||||||
statement = statement.where(JobPosts.department == department)
|
statement = statement.where(JobPosts.department == department)
|
||||||
rid = cls._as_uuid(recruiter_id)
|
rid = cls._as_uuid(recruiter_id)
|
||||||
if rid is not None:
|
if rid is not None:
|
||||||
statement = statement.where(JobPosts.current_recruiter_id == rid)
|
statement = statement.where(JobPosts.has_recruiter(rid))
|
||||||
return statement
|
return statement
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
|
|
@ -20,3 +20,4 @@ class HistoryEvent(str, Enum):
|
||||||
ATS_SCORED = "ats.scored"
|
ATS_SCORED = "ats.scored"
|
||||||
FORM_CREATED = "form.created"
|
FORM_CREATED = "form.created"
|
||||||
FORM_UPDATED = "form.updated"
|
FORM_UPDATED = "form.updated"
|
||||||
|
OFFER_SENT = "offer.sent"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
def serialize_history(row, actor_name=None) -> dict:
|
def serialize_history(row, actor_name=None, organizer_email=None, attendee_emails=None) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"user_id": str(row.user_id) if row.user_id else None,
|
"user_id": str(row.user_id) if row.user_id else None,
|
||||||
|
|
@ -16,5 +16,7 @@ def serialize_history(row, actor_name=None) -> dict:
|
||||||
"actor_name": actor_name,
|
"actor_name": actor_name,
|
||||||
"actor_kind": row.actor_kind,
|
"actor_kind": row.actor_kind,
|
||||||
"meta": row.meta,
|
"meta": row.meta,
|
||||||
|
"organizer_email": organizer_email,
|
||||||
|
"attendee_emails": attendee_emails or None,
|
||||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,25 @@
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from inbox.models import Inbox
|
from inbox.models import Inbox
|
||||||
from job.candidate.models import CandidateHistory, Manual_UPLOAD_CANDIDATE
|
from job.candidate.models import CandidateHistory, Interviews, Manual_UPLOAD_CANDIDATE
|
||||||
|
from job.history.enums import HistoryEvent
|
||||||
from job.history.serializers import serialize_history
|
from job.history.serializers import serialize_history
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
INTERVIEW_HISTORY_EVENTS = {
|
||||||
|
HistoryEvent.INTERVIEW_CREATED.value,
|
||||||
|
HistoryEvent.INTERVIEW_UPDATED.value,
|
||||||
|
HistoryEvent.CALENDAR_CREATED.value,
|
||||||
|
HistoryEvent.CALENDAR_RESCHEDULED.value,
|
||||||
|
HistoryEvent.CALENDAR_CANCELLED.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _text(value):
|
def _text(value):
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|
@ -101,7 +111,19 @@ class HistoryRecorder:
|
||||||
"actor_kind": actor_kind or "user",
|
"actor_kind": actor_kind or "user",
|
||||||
"meta": meta,
|
"meta": meta,
|
||||||
}
|
}
|
||||||
return await CandidateHistory.insert_event(self.session, fields, commit=commit)
|
row = await CandidateHistory.insert_event(self.session, fields, commit=commit)
|
||||||
|
try:
|
||||||
|
from notifications.views import notify_candidate_history
|
||||||
|
await notify_candidate_history(
|
||||||
|
self.session,
|
||||||
|
row,
|
||||||
|
inbox_id=inbox_id,
|
||||||
|
manual_upload_candidate_id=manual_upload_candidate_id,
|
||||||
|
commit=commit,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("candidate history notification failed for %s", event_type)
|
||||||
|
return row
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("candidate history record failed for %s", event_type)
|
logger.exception("candidate history record failed for %s", event_type)
|
||||||
if commit:
|
if commit:
|
||||||
|
|
@ -111,10 +133,70 @@ class HistoryRecorder:
|
||||||
logger.exception("candidate history rollback failed")
|
logger.exception("candidate history rollback failed")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def _event_emails(self, event_id):
|
||||||
|
from interview.plugins import get_event
|
||||||
|
from interview.serializers import participants_from_event
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await get_event(event_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("calendar event fetch failed for history")
|
||||||
|
return None, []
|
||||||
|
if not raw:
|
||||||
|
return None, []
|
||||||
|
organizer, attendees = participants_from_event(raw)
|
||||||
|
org_email = (organizer or {}).get("email")
|
||||||
|
attendee_emails = [a.get("email") for a in (attendees or []) if a.get("email")]
|
||||||
|
return org_email, attendee_emails
|
||||||
|
|
||||||
|
async def _attach_outlook_emails(self, rows, items):
|
||||||
|
pairs = [
|
||||||
|
(row, item)
|
||||||
|
for row, item in zip(rows, items)
|
||||||
|
if row.event_type in INTERVIEW_HISTORY_EVENTS and row.entity_id
|
||||||
|
]
|
||||||
|
if not pairs:
|
||||||
|
return
|
||||||
|
interviews = await Interviews.get_interviews_by_ids(
|
||||||
|
self.session, {row.entity_id for row, _item in pairs}
|
||||||
|
)
|
||||||
|
event_by_interview = {
|
||||||
|
str(r.id): r.graph_event_id for r in interviews if r.graph_event_id
|
||||||
|
}
|
||||||
|
pending = []
|
||||||
|
event_ids = []
|
||||||
|
for row, item in pairs:
|
||||||
|
eid = event_by_interview.get(str(row.entity_id))
|
||||||
|
if not eid:
|
||||||
|
continue
|
||||||
|
event_ids.append(eid)
|
||||||
|
pending.append((item, eid))
|
||||||
|
ids = list(dict.fromkeys(event_ids))
|
||||||
|
if not ids:
|
||||||
|
return
|
||||||
|
sem = asyncio.Semaphore(5)
|
||||||
|
|
||||||
|
async def one(eid):
|
||||||
|
async with sem:
|
||||||
|
return eid, *(await self._event_emails(eid))
|
||||||
|
|
||||||
|
fetched = {eid: (org, atts) for eid, org, atts in await asyncio.gather(*[one(eid) for eid in ids])}
|
||||||
|
for item, eid in pending:
|
||||||
|
org_email, attendee_emails = fetched.get(eid, (None, []))
|
||||||
|
if org_email:
|
||||||
|
item["organizer_email"] = org_email
|
||||||
|
if attendee_emails:
|
||||||
|
item["attendee_emails"] = attendee_emails
|
||||||
|
|
||||||
async def list_for_user(self, user_id, *, limit=200, offset=0):
|
async def list_for_user(self, user_id, *, limit=200, offset=0):
|
||||||
rows, total = await CandidateHistory.fetch_by_user(
|
rows, total = await CandidateHistory.fetch_by_user(
|
||||||
self.session, user_id, limit=limit, offset=offset
|
self.session, user_id, limit=limit, offset=offset
|
||||||
)
|
)
|
||||||
actor_ids = {r.actor_id for r in rows if r.actor_id}
|
actor_ids = {r.actor_id for r in rows if r.actor_id}
|
||||||
names = await Users.names_by_ids(self.session, actor_ids)
|
names = await Users.names_by_ids(self.session, actor_ids)
|
||||||
return [serialize_history(r, actor_name=names.get(str(r.actor_id))) for r in rows], total
|
items = [serialize_history(r, actor_name=names.get(str(r.actor_id))) for r in rows]
|
||||||
|
try:
|
||||||
|
await self._attach_outlook_emails(rows, items)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("calendar participant hydrate failed for history")
|
||||||
|
return items, total
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,16 @@ import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import DateTime, JSON, Index, func, or_
|
from sqlalchemy import DateTime, JSON, Index, String, and_, case, cast, false, func, or_, union_all
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import aliased, load_only
|
||||||
from sqlmodel import Field, Relationship, SQLModel, select
|
from sqlmodel import Field, Relationship, SQLModel, select
|
||||||
|
|
||||||
from job.job_post.enums import RequisitionStatus
|
from job.job_post.enums import RequisitionStatus
|
||||||
|
|
||||||
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
|
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
|
||||||
|
from candidate_forms.models import Requisition
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -22,12 +25,7 @@ class JobPosts(SQLModel, table=True):
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
title: str = Field(index=True)
|
title: str = Field(index=True)
|
||||||
|
|
||||||
# foreign_keys is required, not decoration: current_recruiter_id and
|
|
||||||
# hiring_manager_id below are extra FKs into users.id, so the join is
|
|
||||||
# ambiguous without it and every mapper fails to initialize. `user` is the
|
|
||||||
# AUTHOR of the post. The recruiter and hiring-manager columns stay bare —
|
|
||||||
# Users already carries five selectin relations that load on every
|
|
||||||
# authenticated request. Same pairing as Notes.user / Notes.author.
|
|
||||||
user: Optional["Users"] = Relationship(
|
user: Optional["Users"] = Relationship(
|
||||||
back_populates="job_posts",
|
back_populates="job_posts",
|
||||||
sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"},
|
sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"},
|
||||||
|
|
@ -51,19 +49,32 @@ class JobPosts(SQLModel, table=True):
|
||||||
buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||||
status: str = Field(default="draft")
|
status: str = Field(default="draft")
|
||||||
buffer_error: str | None = Field(default=None)
|
buffer_error: str | None = Field(default=None)
|
||||||
# requisition_status is the hiring lifecycle (RequisitionStatus). Distinct from
|
|
||||||
# `status`, which tracks Buffer publishing (draft/scheduled/published/failed).
|
|
||||||
# server_default is load-bearing: this column arrives as an ALTER on a populated table.
|
|
||||||
requisition_status: str = Field(default="open", sa_column_kwargs={"server_default": "open"})
|
requisition_status: str = Field(default="open", sa_column_kwargs={"server_default": "open"})
|
||||||
department: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
department: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||||
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
|
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
|
||||||
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||||
# Who is working the req now (swappable). History lives in job_assignments
|
# Who is working the req now (swappable). History lives in job_assignments
|
||||||
# with assignment_role=primary_recruiter; this column is the current pointer.
|
# with assignment_role=primary_recruiter; this column is the first / primary
|
||||||
|
# pointer so existing joins keep working. current_recruiter_ids is the full
|
||||||
|
# list (UUID strings) so more than one recruiter can sit on the same job.
|
||||||
current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||||
# Who owns the requisition (stable). Required at create. History lives in
|
current_recruiter_ids: list[str] = Field(
|
||||||
|
default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"},
|
||||||
|
)
|
||||||
|
# Who owns the requisition (stable). Optional. History lives in
|
||||||
# job_assignments with assignment_role=hiring_manager.
|
# job_assignments with assignment_role=hiring_manager.
|
||||||
hiring_manager_id: uuid.UUID | None = Field(default=None, foreign_key="users.id", index=True)
|
hiring_manager_id: uuid.UUID | None = Field(default=None, foreign_key="users.id", index=True)
|
||||||
|
# Annexure A employee requisition this job was opened from (optional 1:1).
|
||||||
|
# Distinct from requisition_status, which is the hiring lifecycle on this row.
|
||||||
|
# unique=True so two job posts cannot share one requisition; NULLs stay allowed.
|
||||||
|
requisition_id: uuid.UUID | None = Field(
|
||||||
|
default=None, foreign_key="requisitions.id", ondelete="SET NULL", unique=True, index=True,
|
||||||
|
)
|
||||||
|
requisition: Optional["Requisition"] = Relationship(
|
||||||
|
back_populates="job_post",
|
||||||
|
sa_relationship_kwargs={"lazy": "selectin"},
|
||||||
|
)
|
||||||
created_by: uuid.UUID = Field(foreign_key="users.id")
|
created_by: uuid.UUID = Field(foreign_key="users.id")
|
||||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
@ -72,9 +83,58 @@ class JobPosts(SQLModel, table=True):
|
||||||
def _as_uuid(record_id: str) -> uuid.UUID | None:
|
def _as_uuid(record_id: str) -> uuid.UUID | None:
|
||||||
try:
|
try:
|
||||||
return uuid.UUID(str(record_id))
|
return uuid.UUID(str(record_id))
|
||||||
except ValueError:
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def recruiter_ids_of(row) -> list[str]:
|
||||||
|
"""UUID strings currently assigned as recruiters on a job row or mapping.
|
||||||
|
|
||||||
|
Prefers current_recruiter_ids; falls back to current_recruiter_id so a
|
||||||
|
row that has not been backfilled still maps to one person.
|
||||||
|
"""
|
||||||
|
if isinstance(row, dict):
|
||||||
|
raw = row.get("current_recruiter_ids")
|
||||||
|
fallback = row.get("current_recruiter_id")
|
||||||
|
else:
|
||||||
|
raw = getattr(row, "current_recruiter_ids", None)
|
||||||
|
fallback = getattr(row, "current_recruiter_id", None)
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in raw or []:
|
||||||
|
uid = JobPosts._as_uuid(item)
|
||||||
|
if uid is None:
|
||||||
|
continue
|
||||||
|
key = str(uid)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(key)
|
||||||
|
if not out:
|
||||||
|
uid = JobPosts._as_uuid(fallback)
|
||||||
|
if uid is not None:
|
||||||
|
out.append(str(uid))
|
||||||
|
return out
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def has_recruiter(cls, recruiter_id):
|
||||||
|
"""SQL: this recruiter is the primary pointer or in current_recruiter_ids."""
|
||||||
|
uid = recruiter_id if isinstance(recruiter_id, uuid.UUID) else cls._as_uuid(recruiter_id)
|
||||||
|
if uid is None:
|
||||||
|
return false()
|
||||||
|
return or_(
|
||||||
|
cls.current_recruiter_id == uid,
|
||||||
|
cls.current_recruiter_ids.contains([str(uid)]),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def no_recruiters(cls):
|
||||||
|
"""SQL: neither the pointer nor the JSON list names anyone."""
|
||||||
|
return and_(
|
||||||
|
cls.current_recruiter_id.is_(None),
|
||||||
|
func.coalesce(func.jsonb_array_length(cls.current_recruiter_ids), 0) == 0,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_job_post_by_id(cls, session: AsyncSession, record_id: str):
|
async def get_job_post_by_id(cls, session: AsyncSession, record_id: str):
|
||||||
uid = cls._as_uuid(record_id)
|
uid = cls._as_uuid(record_id)
|
||||||
|
|
@ -83,6 +143,20 @@ class JobPosts(SQLModel, table=True):
|
||||||
result = await session.execute(select(cls).where(cls.id == uid))
|
result = await session.execute(select(cls).where(cls.id == uid))
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_requisition_id(cls, session: AsyncSession, requisition_id):
|
||||||
|
"""Live job post already opened from this Annexure A requisition, if any."""
|
||||||
|
uid = cls._as_uuid(requisition_id)
|
||||||
|
if uid is None:
|
||||||
|
return None
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls).where(
|
||||||
|
cls.requisition_id == uid,
|
||||||
|
cls.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_active_job_posts(cls, session: AsyncSession):
|
async def get_active_job_posts(cls, session: AsyncSession):
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
|
|
@ -90,6 +164,15 @@ class JobPosts(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_ids(cls, session: AsyncSession, *, active_only: bool = False):
|
||||||
|
"""Non-deleted job_posts.id values. active_only limits to live openings."""
|
||||||
|
statement = select(cls.id).where(cls.is_deleted == False) # noqa: E712
|
||||||
|
if active_only:
|
||||||
|
statement = statement.where(cls.is_active == True) # noqa: E712
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return [row[0] for row in result.all()]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_ids(cls, session: AsyncSession, ids: list[str], *, active_only: bool = True):
|
async def get_by_ids(cls, session: AsyncSession, ids: list[str], *, active_only: bool = True):
|
||||||
uids = []
|
uids = []
|
||||||
|
|
@ -108,6 +191,31 @@ class JobPosts(SQLModel, table=True):
|
||||||
# Preserve request order so suggestion ranks stay stable.
|
# Preserve request order so suggestion ranks stay stable.
|
||||||
return [by_id[str(u)] for u in uids if str(u) in by_id]
|
return [by_id[str(u)] for u in uids if str(u) in by_id]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def titles_by_ids(cls, session: AsyncSession, ids: list[str], *, active_only: bool = False):
|
||||||
|
"""id + title + status only — inbox suggestion rail before a card expands.
|
||||||
|
|
||||||
|
Skips description / post_text TOAST columns. Rank order matches `ids`.
|
||||||
|
"""
|
||||||
|
uids = []
|
||||||
|
for raw in ids or []:
|
||||||
|
uid = cls._as_uuid(raw)
|
||||||
|
if uid is not None:
|
||||||
|
uids.append(uid)
|
||||||
|
if not uids:
|
||||||
|
return []
|
||||||
|
statement = (
|
||||||
|
select(cls)
|
||||||
|
.options(load_only(cls.id, cls.title, cls.status, cls.is_active, cls.is_deleted))
|
||||||
|
.where(cls.id.in_(uids))
|
||||||
|
)
|
||||||
|
if active_only:
|
||||||
|
statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712
|
||||||
|
result = await session.execute(statement)
|
||||||
|
rows = list(result.scalars().all())
|
||||||
|
by_id = {str(r.id): r for r in rows}
|
||||||
|
return [by_id[str(u)] for u in uids if str(u) in by_id]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_titles(cls, session: AsyncSession, titles: list[str], *, active_only: bool = False):
|
async def get_by_titles(cls, session: AsyncSession, titles: list[str], *, active_only: bool = False):
|
||||||
"""Match job posts whose title equals any of `titles` (trim + case-insensitive).
|
"""Match job posts whose title equals any of `titles` (trim + case-insensitive).
|
||||||
|
|
@ -129,6 +237,52 @@ class JobPosts(SQLModel, table=True):
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def title_ilike_pattern(applied_for: str) -> str | None:
|
||||||
|
"""ILIKE pattern so job_posts.title contains the form's Position Applied For."""
|
||||||
|
needle = (applied_for or "").strip()
|
||||||
|
if not needle:
|
||||||
|
return None
|
||||||
|
escaped = needle.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
return f"%{escaped}%"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def ids_for_title_ilike(cls, session: AsyncSession, applied_for: str) -> list[uuid.UUID]:
|
||||||
|
"""All non-deleted job_posts.id whose title ILIKE-contains applied_for.
|
||||||
|
|
||||||
|
One form title can match many posts. Order is created_at DESC, id DESC
|
||||||
|
so the UI/ATS list is stable. Empty if blank or no row. Suggested,
|
||||||
|
not recruiter-assigned.
|
||||||
|
"""
|
||||||
|
pattern = cls.title_ilike_pattern(applied_for)
|
||||||
|
if not pattern:
|
||||||
|
return []
|
||||||
|
statement = (
|
||||||
|
select(cls.id)
|
||||||
|
.where(cls.is_deleted == False) # noqa: E712
|
||||||
|
.where(cls.title.ilike(pattern, escape="\\"))
|
||||||
|
.order_by(cls.created_at.desc(), cls.id.desc())
|
||||||
|
)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def ids_for_titles_ilike(
|
||||||
|
cls, session: AsyncSession, titles: list[str],
|
||||||
|
) -> dict[str, list[uuid.UUID]]:
|
||||||
|
"""Map stripped Position Applied For → every matching job_posts.id."""
|
||||||
|
out: dict[str, list[uuid.UUID]] = {}
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in titles or []:
|
||||||
|
key = (raw or "").strip()
|
||||||
|
if not key or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
found = await cls.ids_for_title_ilike(session, key)
|
||||||
|
if found:
|
||||||
|
out[key] = found
|
||||||
|
return out
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def fetch_job_posts(
|
async def fetch_job_posts(
|
||||||
cls,
|
cls,
|
||||||
|
|
@ -144,6 +298,7 @@ class JobPosts(SQLModel, table=True):
|
||||||
requisition_status: str | None = None,
|
requisition_status: str | None = None,
|
||||||
employment_type: str | None = None,
|
employment_type: str | None = None,
|
||||||
hiring_manager_id: uuid.UUID | None = None,
|
hiring_manager_id: uuid.UUID | None = None,
|
||||||
|
restrict_ids: list | None = None,
|
||||||
):
|
):
|
||||||
if ids:
|
if ids:
|
||||||
rows = await cls.get_by_ids(session, ids, active_only=active_only)
|
rows = await cls.get_by_ids(session, ids, active_only=active_only)
|
||||||
|
|
@ -154,6 +309,15 @@ class JobPosts(SQLModel, table=True):
|
||||||
statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712
|
statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712
|
||||||
elif not include_deleted:
|
elif not include_deleted:
|
||||||
statement = statement.where(cls.is_deleted == False) # noqa: E712
|
statement = statement.where(cls.is_deleted == False) # noqa: E712
|
||||||
|
if restrict_ids is not None:
|
||||||
|
uids = []
|
||||||
|
for raw in restrict_ids:
|
||||||
|
uid = raw if isinstance(raw, uuid.UUID) else cls._as_uuid(raw)
|
||||||
|
if uid is not None:
|
||||||
|
uids.append(uid)
|
||||||
|
if not uids:
|
||||||
|
return [], 0
|
||||||
|
statement = statement.where(cls.id.in_(uids))
|
||||||
if search:
|
if search:
|
||||||
like = f"%{search.strip()}%"
|
like = f"%{search.strip()}%"
|
||||||
statement = statement.where(
|
statement = statement.where(
|
||||||
|
|
@ -177,6 +341,219 @@ class JobPosts(SQLModel, table=True):
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return list(result.scalars().all()), total
|
return list(result.scalars().all()), total
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def fetch_job_stats(
|
||||||
|
cls,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
job_post_id=None,
|
||||||
|
search: str | None = None,
|
||||||
|
ids: list[str] | None = None,
|
||||||
|
top: int | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
active_only: bool = False,
|
||||||
|
):
|
||||||
|
"""Per-job pipeline stage counts for every applicant assigned to the job.
|
||||||
|
|
||||||
|
Inbox, manual-upload / Add Candidate / CV-bank, and unpromoted sheet
|
||||||
|
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). `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
|
||||||
|
from job.candidate.models import Manual_UPLOAD_CANDIDATE
|
||||||
|
from users.models import Users
|
||||||
|
|
||||||
|
job_uids = []
|
||||||
|
if job_post_id is not None:
|
||||||
|
uid = job_post_id if isinstance(job_post_id, uuid.UUID) else cls._as_uuid(job_post_id)
|
||||||
|
if uid is None:
|
||||||
|
return [], 0
|
||||||
|
job_uids = [uid]
|
||||||
|
elif ids:
|
||||||
|
for raw in ids:
|
||||||
|
uid = cls._as_uuid(raw)
|
||||||
|
if uid is not None:
|
||||||
|
job_uids.append(uid)
|
||||||
|
if not job_uids:
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
def dup_key(email_col, row_id):
|
||||||
|
# Same person = lower(trim(email)). No address -> unique per row
|
||||||
|
# so blank emails do not collapse into one applicant.
|
||||||
|
return func.coalesce(
|
||||||
|
func.nullif(func.lower(func.btrim(email_col)), ""),
|
||||||
|
func.concat("noid:", cast(row_id, String)),
|
||||||
|
)
|
||||||
|
|
||||||
|
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"),
|
||||||
|
cast(Inbox_Messages.application_status, String).label("stage"),
|
||||||
|
)
|
||||||
|
.where(Inbox_Messages.assigned_job_post_id.is_not(None))
|
||||||
|
.where(Inbox_Messages.is_duplicate == False) # noqa: E712
|
||||||
|
)
|
||||||
|
manual_stage = func.coalesce(
|
||||||
|
func.nullif(func.btrim(Manual_UPLOAD_CANDIDATE.status), ""),
|
||||||
|
"PENDING",
|
||||||
|
)
|
||||||
|
manual_email = func.coalesce(
|
||||||
|
func.nullif(func.btrim(Manual_UPLOAD_CANDIDATE.candidate_email), ""),
|
||||||
|
Users.email,
|
||||||
|
)
|
||||||
|
manual_base = (
|
||||||
|
select(
|
||||||
|
Manual_UPLOAD_CANDIDATE.job_post_id.label("job_post_id"),
|
||||||
|
dup_key(manual_email, Manual_UPLOAD_CANDIDATE.id).label("dup_key"),
|
||||||
|
manual_stage.label("stage"),
|
||||||
|
)
|
||||||
|
.select_from(Manual_UPLOAD_CANDIDATE)
|
||||||
|
.outerjoin(Users, Users.id == Manual_UPLOAD_CANDIDATE.user_id)
|
||||||
|
.where(Manual_UPLOAD_CANDIDATE.job_post_id.is_not(None))
|
||||||
|
)
|
||||||
|
# Unpromoted sheet applicants only — promoted rows already live on
|
||||||
|
# manual_upload_candidate (manual_upload_candidate_id set).
|
||||||
|
form_stage = case(
|
||||||
|
(FormData.processing_state == "rejected", "REJECTED"),
|
||||||
|
else_="PENDING",
|
||||||
|
)
|
||||||
|
form_base = (
|
||||||
|
select(
|
||||||
|
FormData.job_post_id.label("job_post_id"),
|
||||||
|
dup_key(FormData.candidate_email, FormData.id).label("dup_key"),
|
||||||
|
form_stage.label("stage"),
|
||||||
|
)
|
||||||
|
.where(FormData.job_post_id.is_not(None))
|
||||||
|
.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_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),
|
||||||
|
(apps.c.stage == "OFFER", 7),
|
||||||
|
(apps.c.stage == "INTERVIEW", 6),
|
||||||
|
(apps.c.stage == "ASSESSMENT", 5),
|
||||||
|
(apps.c.stage.in_(["SCREENING", "PROCESS"]), 4),
|
||||||
|
(apps.c.stage == "PENDING", 3),
|
||||||
|
(apps.c.stage == "ONHOLD", 2),
|
||||||
|
(apps.c.stage.in_(["REJECTED", "CLOSED"]), 1),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
unique_apps = (
|
||||||
|
select(apps.c.job_post_id, apps.c.dup_key, apps.c.stage)
|
||||||
|
.distinct(apps.c.job_post_id, apps.c.dup_key)
|
||||||
|
.order_by(apps.c.job_post_id, apps.c.dup_key, stage_rank.desc())
|
||||||
|
.subquery("unique_applicants")
|
||||||
|
)
|
||||||
|
stage = unique_apps.c.stage
|
||||||
|
|
||||||
|
def stage_count(*values):
|
||||||
|
return func.coalesce(func.sum(case((stage.in_(list(values)), 1), else_=0)), 0)
|
||||||
|
|
||||||
|
stats = (
|
||||||
|
select(
|
||||||
|
unique_apps.c.job_post_id,
|
||||||
|
func.count().label("total_applicants"),
|
||||||
|
stage_count("PENDING").label("shortlisting"),
|
||||||
|
stage_count("SCREENING", "PROCESS").label("screened"),
|
||||||
|
stage_count("ASSESSMENT").label("assessment"),
|
||||||
|
stage_count("INTERVIEW").label("interviewed"),
|
||||||
|
stage_count("OFFER").label("offered"),
|
||||||
|
stage_count("ONHOLD").label("on_hold"),
|
||||||
|
stage_count("REJECTED", "CLOSED").label("rejected"),
|
||||||
|
stage_count("APPROVED").label("approved"),
|
||||||
|
stage_count("HIRED").label("hired"),
|
||||||
|
)
|
||||||
|
.select_from(unique_apps)
|
||||||
|
.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.
|
||||||
|
Recruiter=aliased(Users)
|
||||||
|
statement = (
|
||||||
|
select(
|
||||||
|
cls.id.label("job_post_id"),
|
||||||
|
cls.title,
|
||||||
|
cls.department,
|
||||||
|
cls.location,
|
||||||
|
cls.requisition_status,
|
||||||
|
cls.current_recruiter_id,
|
||||||
|
cls.current_recruiter_ids,
|
||||||
|
cls.created_at,
|
||||||
|
Recruiter.name.label("recruiter_name"),
|
||||||
|
func.coalesce(stats.c.total_applicants, 0).label("total_applicants"),
|
||||||
|
func.coalesce(stats.c.shortlisting, 0).label("shortlisting"),
|
||||||
|
func.coalesce(stats.c.screened, 0).label("screened"),
|
||||||
|
func.coalesce(stats.c.assessment, 0).label("assessment"),
|
||||||
|
func.coalesce(stats.c.interviewed, 0).label("interviewed"),
|
||||||
|
func.coalesce(stats.c.offered, 0).label("offered"),
|
||||||
|
func.coalesce(stats.c.on_hold, 0).label("on_hold"),
|
||||||
|
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
|
||||||
|
)
|
||||||
|
if active_only:
|
||||||
|
statement = statement.where(cls.is_active == True) # noqa: E712
|
||||||
|
if job_uids:
|
||||||
|
statement = statement.where(cls.id.in_(job_uids))
|
||||||
|
if search:
|
||||||
|
like = f"%{search.strip()}%"
|
||||||
|
statement = statement.where(
|
||||||
|
or_(cls.title.ilike(like), cls.location.ilike(like), cls.department.ilike(like))
|
||||||
|
)
|
||||||
|
|
||||||
|
count_statement = select(func.count()).select_from(statement.subquery())
|
||||||
|
total = (await session.execute(count_statement)).scalar_one()
|
||||||
|
statement = statement.order_by(cls.created_at.desc())
|
||||||
|
if job_post_id is None:
|
||||||
|
if skip:
|
||||||
|
statement = statement.offset(skip)
|
||||||
|
if top is not None:
|
||||||
|
statement = statement.limit(top)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return list(result.mappings().all()), int(total or 0)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def list_departments(cls, session: AsyncSession, *, active_only: bool = False):
|
async def list_departments(cls, session: AsyncSession, *, active_only: bool = False):
|
||||||
"""Distinct non-empty departments on non-deleted job posts.
|
"""Distinct non-empty departments on non-deleted job posts.
|
||||||
|
|
@ -195,6 +572,66 @@ class JobPosts(SQLModel, table=True):
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def ids_for_manager(cls, session: AsyncSession, user_id):
|
||||||
|
"""Job posts this user owns: assigned hiring_manager, or opened from
|
||||||
|
a requisition they created. The manager Candidates list and form
|
||||||
|
scope both follow this chain."""
|
||||||
|
uid = cls._as_uuid(user_id)
|
||||||
|
if uid is None:
|
||||||
|
return []
|
||||||
|
from candidate_forms.models import Requisition
|
||||||
|
|
||||||
|
assigned = await session.execute(
|
||||||
|
select(cls.id).where(
|
||||||
|
cls.hiring_manager_id == uid,
|
||||||
|
cls.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
via_req = await session.execute(
|
||||||
|
select(cls.id)
|
||||||
|
.join(Requisition, cls.requisition_id == Requisition.id)
|
||||||
|
.where(
|
||||||
|
Requisition.created_by == uid,
|
||||||
|
Requisition.is_deleted == False, # noqa: E712
|
||||||
|
cls.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seen: set[uuid.UUID] = set()
|
||||||
|
out: list[uuid.UUID] = []
|
||||||
|
for row_id in list(assigned.scalars().all()) + list(via_req.scalars().all()):
|
||||||
|
if row_id not in seen:
|
||||||
|
seen.add(row_id)
|
||||||
|
out.append(row_id)
|
||||||
|
return out
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def ids_for_creator(cls, session: AsyncSession, user_id, created_by=False):
|
||||||
|
"""Jobs this recruiter should see on Candidates (when they lack
|
||||||
|
candidates.manage). created_by=True → created_by = session user only.
|
||||||
|
Otherwise: current_recruiter_ids / current_recruiter_id when set, else created_by."""
|
||||||
|
uid = cls._as_uuid(user_id)
|
||||||
|
if uid is None:
|
||||||
|
return []
|
||||||
|
if created_by:
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls.id).where(
|
||||||
|
cls.created_by == uid,
|
||||||
|
cls.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls.id).where(
|
||||||
|
or_(
|
||||||
|
cls.has_recruiter(uid),
|
||||||
|
and_(cls.no_recruiters(), cls.created_by == uid),
|
||||||
|
),
|
||||||
|
cls.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids):
|
async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids):
|
||||||
"""Open requisitions per hiring manager, keyed by users.id."""
|
"""Open requisitions per hiring manager, keyed by users.id."""
|
||||||
|
|
@ -218,12 +655,12 @@ class JobPosts(SQLModel, table=True):
|
||||||
cls, session: AsyncSession, recruiter_id, *, status, department=None,
|
cls, session: AsyncSession, recruiter_id, *, status, department=None,
|
||||||
from_date=None, to_date=None,
|
from_date=None, to_date=None,
|
||||||
):
|
):
|
||||||
"""Requisitions owned by current_recruiter_id in one requisition_status."""
|
"""Requisitions owned by this recruiter (pointer or JSON list) in one status."""
|
||||||
uid = cls._as_uuid(recruiter_id)
|
uid = cls._as_uuid(recruiter_id)
|
||||||
if uid is None:
|
if uid is None:
|
||||||
return 0
|
return 0
|
||||||
statement = select(func.count()).select_from(cls).where(
|
statement = select(func.count()).select_from(cls).where(
|
||||||
cls.current_recruiter_id == uid,
|
cls.has_recruiter(uid),
|
||||||
cls.requisition_status == status,
|
cls.requisition_status == status,
|
||||||
cls.is_deleted == False, # noqa: E712
|
cls.is_deleted == False, # noqa: E712
|
||||||
)
|
)
|
||||||
|
|
@ -242,7 +679,7 @@ class JobPosts(SQLModel, table=True):
|
||||||
statement = statement.where(cls.department == department)
|
statement = statement.where(cls.department == department)
|
||||||
uid = cls._as_uuid(recruiter_id) if recruiter_id is not None else None
|
uid = cls._as_uuid(recruiter_id) if recruiter_id is not None else None
|
||||||
if uid is not None:
|
if uid is not None:
|
||||||
statement = statement.where(cls.current_recruiter_id == uid)
|
statement = statement.where(cls.has_recruiter(uid))
|
||||||
return statement
|
return statement
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -262,6 +699,19 @@ class JobPosts(SQLModel, table=True):
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return int(result.scalar_one() or 0)
|
return int(result.scalar_one() or 0)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_open_reqs(cls, session: AsyncSession, department=None, recruiter_id=None):
|
||||||
|
"""Open, non-deleted requisitions — the zero-application fill for
|
||||||
|
analytics' per-job counts. Same scoping semantics as count_requisitions.
|
||||||
|
"""
|
||||||
|
statement = select(cls).where(
|
||||||
|
cls.is_deleted == False, # noqa: E712
|
||||||
|
cls.requisition_status == RequisitionStatus.OPEN.value,
|
||||||
|
)
|
||||||
|
statement = cls._scoped(statement, department, recruiter_id)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def count_open_snapshot(cls, session: AsyncSession, as_of, department=None, recruiter_id=None):
|
async def count_open_snapshot(cls, session: AsyncSession, as_of, department=None, recruiter_id=None):
|
||||||
"""Jobs that existed and were still open at `as_of` (best-effort)."""
|
"""Jobs that existed and were still open at `as_of` (best-effort)."""
|
||||||
|
|
@ -377,6 +827,7 @@ class JobPosts(SQLModel, table=True):
|
||||||
return None
|
return None
|
||||||
row.is_deleted = True
|
row.is_deleted = True
|
||||||
row.is_active = False
|
row.is_active = False
|
||||||
|
row.requisition_id = None
|
||||||
row.updated_at = _now()
|
row.updated_at = _now()
|
||||||
session.add(row)
|
session.add(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -530,4 +981,7 @@ class SocialPlatform(SQLModel, table=True):
|
||||||
return {r.alias: r.buffer_service for r in rows}
|
return {r.alias: r.buffer_service for r in rows}
|
||||||
|
|
||||||
|
|
||||||
import users.models as _users_models
|
# Requisition must be registered before Users relationships trigger mapper
|
||||||
|
# configure — JobPosts.requisition_id FKs to app.requisitions.
|
||||||
|
import candidate_forms.models as _requisition_models # noqa: E402, F401
|
||||||
|
import users.models as _users_models # noqa: E402, F401
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from job.job_post.enums import RequisitionStatus
|
from job.job_post.enums import RequisitionStatus
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
|
||||||
def _status_label(value):
|
def _status_label(value):
|
||||||
|
|
@ -8,7 +9,33 @@ def _status_label(value):
|
||||||
return parsed.label if parsed else value
|
return parsed.label if parsed else value
|
||||||
|
|
||||||
|
|
||||||
def serialize_job_post(row) -> dict:
|
def serialize_job_post_title(row) -> dict:
|
||||||
|
"""Inbox suggestion rail — title only until the recruiter expands the card."""
|
||||||
|
return {
|
||||||
|
"id": str(row.id),
|
||||||
|
"title": row.title,
|
||||||
|
"status": row.status,
|
||||||
|
"is_active": row.is_active,
|
||||||
|
"is_deleted": row.is_deleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _recruiter_payload(row, names=None):
|
||||||
|
"""List of recruiter ids plus mapped names; first id stays the legacy pointer."""
|
||||||
|
names = names or {}
|
||||||
|
ids = JobPosts.recruiter_ids_of(row)
|
||||||
|
mapped = [names.get(i) for i in ids]
|
||||||
|
first = ids[0] if ids else None
|
||||||
|
return {
|
||||||
|
"current_recruiter_id": first,
|
||||||
|
"current_recruiter_ids": ids,
|
||||||
|
"recruiter_name": next((n for n in mapped if n), None),
|
||||||
|
"recruiter_names": [n for n in mapped if n],
|
||||||
|
"recruiters": [{"id": i, "name": names.get(i)} for i in ids],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_job_post(row, *, names=None) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"title": row.title,
|
"title": row.title,
|
||||||
|
|
@ -36,16 +63,22 @@ def serialize_job_post(row) -> dict:
|
||||||
"created_by_name": row.user.name if getattr(row, "user", None) else None,
|
"created_by_name": row.user.name if getattr(row, "user", None) else None,
|
||||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
|
||||||
|
**_recruiter_payload(row, names),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, applicant_count=0) -> dict:
|
def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_name=None, applicant_count=0) -> dict:
|
||||||
"""Requisition view of a job post, for the Jobs screen.
|
"""Requisition view of a job post, for the Jobs screen.
|
||||||
|
|
||||||
Deliberately separate from serialize_job_post: that payload is shared by the
|
Deliberately separate from serialize_job_post: that payload is shared by the
|
||||||
inbox, candidate and matching paths. department is the one shared field —
|
inbox, candidate and matching paths. department is the one shared field —
|
||||||
talent-pool filters key off it on attached job_posts.
|
talent-pool filters key off it on attached job_posts.
|
||||||
"""
|
"""
|
||||||
|
req = getattr(row, "requisition", None)
|
||||||
|
payload = _recruiter_payload(row, names)
|
||||||
|
if recruiter_name and not payload["recruiter_name"]:
|
||||||
|
payload["recruiter_name"] = recruiter_name
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"title": row.title,
|
"title": row.title,
|
||||||
|
|
@ -66,10 +99,12 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app
|
||||||
"description": row.description,
|
"description": row.description,
|
||||||
"is_active": row.is_active,
|
"is_active": row.is_active,
|
||||||
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
|
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
|
||||||
"current_recruiter_id": str(row.current_recruiter_id) if row.current_recruiter_id else None,
|
**payload,
|
||||||
"recruiter_name": recruiter_name,
|
|
||||||
"hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None,
|
"hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None,
|
||||||
"hiring_manager_name": hiring_manager_name,
|
"hiring_manager_name": hiring_manager_name,
|
||||||
|
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
|
||||||
|
"requisition_title": req.position_title if req else None,
|
||||||
|
"requisition_department": req.department if req else None,
|
||||||
"applicant_count": applicant_count,
|
"applicant_count": applicant_count,
|
||||||
"created_by": str(row.created_by) if row.created_by else None,
|
"created_by": str(row.created_by) if row.created_by else None,
|
||||||
"created_by_name": row.user.name if getattr(row, "user", None) else None,
|
"created_by_name": row.user.name if getattr(row, "user", None) else None,
|
||||||
|
|
@ -78,6 +113,35 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_job_stats(row, *, names=None) -> dict:
|
||||||
|
"""One job post's live pipeline-stage counts. `row` is a mapping from fetch_job_stats."""
|
||||||
|
created_at = row.get("created_at")
|
||||||
|
payload = _recruiter_payload(row, names)
|
||||||
|
if not payload["recruiter_name"] and row.get("recruiter_name"):
|
||||||
|
payload["recruiter_name"] = row.get("recruiter_name")
|
||||||
|
return {
|
||||||
|
"job_post_id": str(row["job_post_id"]),
|
||||||
|
"title": row["title"],
|
||||||
|
"department": row["department"] or None,
|
||||||
|
"location": row["location"],
|
||||||
|
"requisition_status": row["requisition_status"],
|
||||||
|
**payload,
|
||||||
|
# Frontend computes days-open vs client clock; no server days_open field.
|
||||||
|
"created_at": created_at.isoformat() if created_at else None,
|
||||||
|
"total_applicants": int(row["total_applicants"] or 0),
|
||||||
|
"shortlisting": int(row["shortlisting"] or 0),
|
||||||
|
"screened": int(row["screened"] or 0),
|
||||||
|
"assessment": int(row["assessment"] or 0),
|
||||||
|
"interviewed": int(row["interviewed"] or 0),
|
||||||
|
"offered": int(row["offered"] or 0),
|
||||||
|
"on_hold": int(row["on_hold"] or 0),
|
||||||
|
"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),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def serialize_status_history(row, *, changed_by_name=None) -> dict:
|
def serialize_status_history(row, *, changed_by_name=None) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from uuid import UUID
|
||||||
import httpx
|
import httpx
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from pydantic import BaseModel, model_validator
|
from pydantic import BaseModel, model_validator
|
||||||
from inbox.models import Inbox_Messages
|
from inbox.models import Inbox_Messages
|
||||||
|
|
@ -26,7 +27,7 @@ from job.job_post.plugins import (
|
||||||
render_job_post,
|
render_job_post,
|
||||||
resolve_channel,
|
resolve_channel,
|
||||||
)
|
)
|
||||||
from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_status_history
|
from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_job_stats, serialize_status_history
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
logger=logging.getLogger("job.job_post")
|
logger=logging.getLogger("job.job_post")
|
||||||
|
|
@ -39,6 +40,32 @@ IMAGE_TYPE_BY_EXT={"png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","web
|
||||||
MAX_JOB_IMAGE_BYTES=5*1024*1024
|
MAX_JOB_IMAGE_BYTES=5*1024*1024
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_recruiter_ids(payload):
|
||||||
|
"""Prefer current_recruiter_ids; fall back to current_recruiter_id. None = omitted."""
|
||||||
|
has_list="current_recruiter_ids" in payload and payload.get("current_recruiter_ids") is not None
|
||||||
|
has_one="current_recruiter_id" in payload
|
||||||
|
if has_list:
|
||||||
|
raw=payload.get("current_recruiter_ids") or []
|
||||||
|
if not isinstance(raw,(list,tuple)):
|
||||||
|
raw=[raw]
|
||||||
|
ids=list(raw)
|
||||||
|
if not ids and has_one and payload.get("current_recruiter_id") not in (None,""):
|
||||||
|
ids=[payload.get("current_recruiter_id")]
|
||||||
|
return ids
|
||||||
|
if has_one:
|
||||||
|
raw=payload.get("current_recruiter_id")
|
||||||
|
return [] if raw in (None,"") else [raw]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _recruiter_fields(users):
|
||||||
|
ids=[str(u.id) for u in users]
|
||||||
|
return {
|
||||||
|
"current_recruiter_ids": ids,
|
||||||
|
"current_recruiter_id": users[0].id if users else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _job_image_key(job_post_id) -> uuid.UUID:
|
def _job_image_key(job_post_id) -> uuid.UUID:
|
||||||
try:
|
try:
|
||||||
return uuid.UUID(str(job_post_id))
|
return uuid.UUID(str(job_post_id))
|
||||||
|
|
@ -64,8 +91,10 @@ class JobPostCreate(BaseModel):
|
||||||
scheduler_time: time | None = time(0, 0, 0)
|
scheduler_time: time | None = time(0, 0, 0)
|
||||||
scheduler_date: date | None = None
|
scheduler_date: date | None = None
|
||||||
due_at: str | None = None
|
due_at: str | None = None
|
||||||
hiring_manager_id: UUID
|
hiring_manager_id: UUID | None = None
|
||||||
current_recruiter_id: UUID | None = None
|
current_recruiter_id: UUID | None = None
|
||||||
|
current_recruiter_ids: list[UUID] | None = None
|
||||||
|
requisition_id: UUID | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_mode_and_due_at(self):
|
def validate_mode_and_due_at(self):
|
||||||
|
|
@ -82,6 +111,31 @@ class JobPost:
|
||||||
self.buffer_api=os.getenv("BUFFER_API")
|
self.buffer_api=os.getenv("BUFFER_API")
|
||||||
self.channel_id=os.getenv("BUFFER_CHANNEL_ID")
|
self.channel_id=os.getenv("BUFFER_CHANNEL_ID")
|
||||||
|
|
||||||
|
async def _resolve_recruiters(self,assignment,raw_ids):
|
||||||
|
"""Validate each id is an active recruiter. Dedup, preserve order."""
|
||||||
|
users=[]
|
||||||
|
seen=set()
|
||||||
|
for raw in raw_ids or []:
|
||||||
|
if raw in (None,""):
|
||||||
|
continue
|
||||||
|
rec=await assignment.require_role(raw,EnumRoles.RECRUITER,"current_recruiter_ids")
|
||||||
|
key=str(rec.id)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
users.append(rec)
|
||||||
|
return users
|
||||||
|
|
||||||
|
async def _names_for(self,row):
|
||||||
|
ids=JobPosts.recruiter_ids_of(row)
|
||||||
|
extra=[]
|
||||||
|
if getattr(row,"hiring_manager_id",None):
|
||||||
|
extra.append(row.hiring_manager_id)
|
||||||
|
return await Users.names_by_ids(self.session,ids+extra)
|
||||||
|
|
||||||
|
async def _serialize_post(self,row):
|
||||||
|
return serialize_job_post(row,names=await self._names_for(row))
|
||||||
|
|
||||||
async def _resolve_target(self,payload,aliases=None):
|
async def _resolve_target(self,payload,aliases=None):
|
||||||
"""Pick the Buffer channel to post to, and the service it belongs to.
|
"""Pick the Buffer channel to post to, and the service it belongs to.
|
||||||
|
|
||||||
|
|
@ -146,25 +200,62 @@ class JobPost:
|
||||||
fields["platform"]="internal"
|
fields["platform"]="internal"
|
||||||
|
|
||||||
assignment=Assignment(self.session)
|
assignment=Assignment(self.session)
|
||||||
hm=await assignment.require_role(
|
hm=None
|
||||||
payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id",
|
if payload.get("hiring_manager_id"):
|
||||||
)
|
hm=await assignment.require_role(
|
||||||
fields["hiring_manager_id"]=hm.id
|
payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id",
|
||||||
rec=None
|
|
||||||
if payload.get("current_recruiter_id"):
|
|
||||||
rec=await assignment.require_role(
|
|
||||||
payload.get("current_recruiter_id"),EnumRoles.RECRUITER,"current_recruiter_id",
|
|
||||||
)
|
)
|
||||||
fields["current_recruiter_id"]=rec.id
|
fields["hiring_manager_id"]=hm.id
|
||||||
|
rec_users=[]
|
||||||
|
raw_ids=_payload_recruiter_ids(payload)
|
||||||
|
if raw_ids:
|
||||||
|
rec_users=await self._resolve_recruiters(assignment,raw_ids)
|
||||||
|
fields.update(_recruiter_fields(rec_users))
|
||||||
|
|
||||||
row=await JobPosts.insert_job_post(self.session,fields)
|
if payload.get("requisition_id"):
|
||||||
|
from candidate_forms.models import Requisition
|
||||||
|
req=await Requisition.get_form_by_id(
|
||||||
|
self.session, record_id=str(payload["requisition_id"]),
|
||||||
|
)
|
||||||
|
if not req:
|
||||||
|
raise HTTPException(status_code=404, detail="Requisition not found")
|
||||||
|
held=await JobPosts.get_by_requisition_id(self.session, req.id)
|
||||||
|
if held:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="This requisition is already linked to a job post",
|
||||||
|
)
|
||||||
|
fields["requisition_id"]=req.id
|
||||||
|
|
||||||
|
try:
|
||||||
|
row=await JobPosts.insert_job_post(self.session,fields)
|
||||||
|
except IntegrityError as e:
|
||||||
|
orig=str(getattr(e,"orig",e)).lower()
|
||||||
|
if "requisition" in orig:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,detail="This requisition is already linked to a job post",
|
||||||
|
) from e
|
||||||
|
raise
|
||||||
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
||||||
await assignment.record_job_owner(row.id,hm.id,"hiring_manager",assigned_by)
|
if hm:
|
||||||
if rec:
|
await assignment.record_job_owner(row.id,hm.id,"hiring_manager",assigned_by)
|
||||||
await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by)
|
if rec_users:
|
||||||
|
await assignment.record_job_recruiters(row.id,[u.id for u in rec_users],assigned_by)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from notifications.views import notify_job_created
|
||||||
|
await notify_job_created(self.session,row,actor_id=assigned_by)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("notification insert skipped: %s",exc)
|
||||||
|
|
||||||
|
# A new opening is the moment the CV Bank is worth reading. Ranking it
|
||||||
|
# here is what turns the bank from a pile someone has to remember into
|
||||||
|
# something that offers itself up. Fire-and-forget: the job is already
|
||||||
|
# created, and a queue that is down must not fail the request.
|
||||||
|
await self._rank_cv_bank(row.id)
|
||||||
|
|
||||||
if not publish:
|
if not publish:
|
||||||
return serialize_job_post(row)
|
return await self._serialize_post(row)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
post=await create_buffer_post(
|
post=await create_buffer_post(
|
||||||
|
|
@ -187,7 +278,26 @@ class JobPost:
|
||||||
sent_at=parse_buffer_datetime(post.get("sentAt")),
|
sent_at=parse_buffer_datetime(post.get("sentAt")),
|
||||||
platform=post.get("channelService"),
|
platform=post.get("channelService"),
|
||||||
)
|
)
|
||||||
return serialize_job_post(saved)
|
return serialize_job_post(saved,names=await self._names_for(saved))
|
||||||
|
|
||||||
|
async def _rank_cv_bank(self,job_post_id):
|
||||||
|
"""Queue the tier-1 rank of every banked CV against a brand-new job.
|
||||||
|
|
||||||
|
Best effort by design: this is a convenience signal, not part of
|
||||||
|
creating the job post. Redis being unavailable must not turn a
|
||||||
|
successful job creation into a 500.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from datetime import datetime as _dt,timezone as _tz
|
||||||
|
|
||||||
|
from job.candidate.bank_tasks import rank_bank_for_job
|
||||||
|
await rank_bank_for_job.kicker().with_labels(
|
||||||
|
created_at=_dt.now(_tz.utc).isoformat(),
|
||||||
|
correlation_id=str(job_post_id),
|
||||||
|
queue="inbox",
|
||||||
|
).kiq(str(job_post_id))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("cv-bank rank not queued for job %s: %s",job_post_id,exc)
|
||||||
|
|
||||||
async def list_channels(self):
|
async def list_channels(self):
|
||||||
try:
|
try:
|
||||||
|
|
@ -195,7 +305,24 @@ class JobPost:
|
||||||
except (httpx.HTTPError,BufferError,RuntimeError) as e:
|
except (httpx.HTTPError,BufferError,RuntimeError) as e:
|
||||||
raise HTTPException(status_code=502,detail="Failed to list Buffer channels") from e
|
raise HTTPException(status_code=502,detail="Failed to list Buffer channels") from e
|
||||||
|
|
||||||
async def fetch_job_posts(self,search=None,top=None,skip=0,ids=None,active_only=True):
|
async def _restrict_ids_for_requisition_scope(self,current_user):
|
||||||
|
"""None = unscoped. Empty list = no jobs. Else owned job-post ids."""
|
||||||
|
from users.permissions import scopes_to_own_requisitions
|
||||||
|
if not scopes_to_own_requisitions(current_user):
|
||||||
|
return None
|
||||||
|
return await JobPosts.ids_for_manager(self.session,current_user.get("id") if current_user else None)
|
||||||
|
|
||||||
|
async def fetch_job_posts(self,search=None,top=None,skip=0,ids=None,active_only=True,current_user=None):
|
||||||
|
restrict=await self._restrict_ids_for_requisition_scope(current_user)
|
||||||
|
if restrict is not None:
|
||||||
|
owned={str(i) for i in restrict}
|
||||||
|
if ids:
|
||||||
|
ids=[i for i in ids if str(i) in owned]
|
||||||
|
if not ids:
|
||||||
|
return [],0
|
||||||
|
restrict=None
|
||||||
|
elif not restrict:
|
||||||
|
return [],0
|
||||||
rows,total=await JobPosts.fetch_job_posts(
|
rows,total=await JobPosts.fetch_job_posts(
|
||||||
self.session,
|
self.session,
|
||||||
search=search,
|
search=search,
|
||||||
|
|
@ -203,8 +330,39 @@ class JobPost:
|
||||||
skip=skip,
|
skip=skip,
|
||||||
ids=ids,
|
ids=ids,
|
||||||
active_only=active_only,
|
active_only=active_only,
|
||||||
|
restrict_ids=restrict,
|
||||||
)
|
)
|
||||||
return [serialize_job_post(r) for r in rows],total
|
names=await Users.names_by_ids(
|
||||||
|
self.session,
|
||||||
|
[uid for r in rows for uid in JobPosts.recruiter_ids_of(r)],
|
||||||
|
)
|
||||||
|
return [serialize_job_post(r,names=names) for r in rows],total
|
||||||
|
|
||||||
|
async def fetch_job_stats(self,job_post_id=None,search=None,ids=None,top=None,skip=0,active_only=False):
|
||||||
|
uid=None
|
||||||
|
if job_post_id not in (None,""):
|
||||||
|
uid=JobPosts._as_uuid(job_post_id)
|
||||||
|
if uid is None:
|
||||||
|
raise HTTPException(status_code=422,detail="job_post_id must be a UUID")
|
||||||
|
rows,total=await JobPosts.fetch_job_stats(
|
||||||
|
self.session,
|
||||||
|
job_post_id=uid,
|
||||||
|
search=search,
|
||||||
|
ids=ids,
|
||||||
|
top=top,
|
||||||
|
skip=skip,
|
||||||
|
active_only=active_only,
|
||||||
|
)
|
||||||
|
names=await Users.names_by_ids(
|
||||||
|
self.session,
|
||||||
|
[uid for r in rows for uid in JobPosts.recruiter_ids_of(r)],
|
||||||
|
)
|
||||||
|
data=[serialize_job_stats(r,names=names) for r in rows]
|
||||||
|
if uid is not None:
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status_code=404,detail="Job post not found")
|
||||||
|
return data[0],1
|
||||||
|
return data,total
|
||||||
|
|
||||||
async def fetch_departments(self,active_only=False):
|
async def fetch_departments(self,active_only=False):
|
||||||
return await JobPosts.list_departments(self.session,active_only=active_only)
|
return await JobPosts.list_departments(self.session,active_only=active_only)
|
||||||
|
|
@ -224,7 +382,11 @@ class JobPost:
|
||||||
]
|
]
|
||||||
|
|
||||||
async def fetch_jobs(self,search=None,department=None,requisition_status=None,
|
async def fetch_jobs(self,search=None,department=None,requisition_status=None,
|
||||||
employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True):
|
employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True,
|
||||||
|
current_user=None):
|
||||||
|
restrict=await self._restrict_ids_for_requisition_scope(current_user)
|
||||||
|
if restrict is not None and not restrict:
|
||||||
|
return [],0
|
||||||
hm_uid=None
|
hm_uid=None
|
||||||
if hiring_manager_id:
|
if hiring_manager_id:
|
||||||
hm_uid=JobPosts._as_uuid(hiring_manager_id)
|
hm_uid=JobPosts._as_uuid(hiring_manager_id)
|
||||||
|
|
@ -234,16 +396,17 @@ class JobPost:
|
||||||
self.session,search=search,top=top,skip=skip,active_only=active_only,
|
self.session,search=search,top=top,skip=skip,active_only=active_only,
|
||||||
department=department,requisition_status=requisition_status,
|
department=department,requisition_status=requisition_status,
|
||||||
employment_type=employment_type,hiring_manager_id=hm_uid,
|
employment_type=employment_type,hiring_manager_id=hm_uid,
|
||||||
|
restrict_ids=restrict,
|
||||||
)
|
)
|
||||||
names=await Users.names_by_ids(
|
names=await Users.names_by_ids(
|
||||||
self.session,
|
self.session,
|
||||||
[r.current_recruiter_id for r in rows]+[r.hiring_manager_id for r in rows],
|
[uid for r in rows for uid in JobPosts.recruiter_ids_of(r)]+[r.hiring_manager_id for r in rows],
|
||||||
)
|
)
|
||||||
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows])
|
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows])
|
||||||
return [
|
return [
|
||||||
serialize_job_row(
|
serialize_job_row(
|
||||||
r,
|
r,
|
||||||
recruiter_name=names.get(str(r.current_recruiter_id)),
|
names=names,
|
||||||
hiring_manager_name=names.get(str(r.hiring_manager_id)),
|
hiring_manager_name=names.get(str(r.hiring_manager_id)),
|
||||||
applicant_count=counts.get(str(r.id),0),
|
applicant_count=counts.get(str(r.id),0),
|
||||||
)
|
)
|
||||||
|
|
@ -253,11 +416,11 @@ class JobPost:
|
||||||
async def _job_row(self,row):
|
async def _job_row(self,row):
|
||||||
names=await Users.names_by_ids(
|
names=await Users.names_by_ids(
|
||||||
self.session,
|
self.session,
|
||||||
[row.current_recruiter_id,row.hiring_manager_id],
|
JobPosts.recruiter_ids_of(row)+[row.hiring_manager_id],
|
||||||
)
|
)
|
||||||
return serialize_job_row(
|
return serialize_job_row(
|
||||||
row,
|
row,
|
||||||
recruiter_name=names.get(str(row.current_recruiter_id)),
|
names=names,
|
||||||
hiring_manager_name=names.get(str(row.hiring_manager_id)),
|
hiring_manager_name=names.get(str(row.hiring_manager_id)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -288,24 +451,47 @@ class JobPost:
|
||||||
rec_changed=False
|
rec_changed=False
|
||||||
if "hiring_manager_id" in payload:
|
if "hiring_manager_id" in payload:
|
||||||
raw=payload.get("hiring_manager_id")
|
raw=payload.get("hiring_manager_id")
|
||||||
if not raw:
|
|
||||||
raise HTTPException(status_code=422,detail="hiring_manager_id is required")
|
|
||||||
hm=await assignment.require_role(raw,EnumRoles.HIRING_MANAGER,"hiring_manager_id")
|
|
||||||
fields["hiring_manager_id"]=hm.id
|
|
||||||
hm_changed=str(existing.hiring_manager_id)!=str(hm.id)
|
|
||||||
if "current_recruiter_id" in payload:
|
|
||||||
raw=payload.get("current_recruiter_id")
|
|
||||||
if raw is None or raw=="":
|
if raw is None or raw=="":
|
||||||
fields["current_recruiter_id"]=None
|
fields["hiring_manager_id"]=None
|
||||||
rec_changed=existing.current_recruiter_id is not None
|
hm_changed=existing.hiring_manager_id is not None
|
||||||
else:
|
else:
|
||||||
rec=await assignment.require_role(raw,EnumRoles.RECRUITER,"current_recruiter_id")
|
hm=await assignment.require_role(raw,EnumRoles.HIRING_MANAGER,"hiring_manager_id")
|
||||||
fields["current_recruiter_id"]=rec.id
|
fields["hiring_manager_id"]=hm.id
|
||||||
rec_changed=str(existing.current_recruiter_id)!=str(rec.id)
|
hm_changed=str(existing.hiring_manager_id)!=str(hm.id)
|
||||||
|
if "requisition_id" in payload:
|
||||||
|
raw=payload.get("requisition_id")
|
||||||
|
if raw is None or raw=="":
|
||||||
|
fields["requisition_id"]=None
|
||||||
|
else:
|
||||||
|
from candidate_forms.models import Requisition
|
||||||
|
req=await Requisition.get_form_by_id(self.session,record_id=str(raw))
|
||||||
|
if not req:
|
||||||
|
raise HTTPException(status_code=404,detail="Requisition not found")
|
||||||
|
held=await JobPosts.get_by_requisition_id(self.session, req.id)
|
||||||
|
if held and str(held.id)!=str(existing.id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="This requisition is already linked to a job post",
|
||||||
|
)
|
||||||
|
fields["requisition_id"]=req.id
|
||||||
|
rec_users=None
|
||||||
|
raw_ids=_payload_recruiter_ids(payload)
|
||||||
|
if raw_ids is not None:
|
||||||
|
rec_users=await self._resolve_recruiters(assignment,raw_ids)
|
||||||
|
fields.update(_recruiter_fields(rec_users))
|
||||||
|
rec_changed=JobPosts.recruiter_ids_of(existing)!=[str(u.id) for u in rec_users]
|
||||||
|
|
||||||
if not fields:
|
if not fields:
|
||||||
raise HTTPException(status_code=400,detail="No fields to update")
|
raise HTTPException(status_code=400,detail="No fields to update")
|
||||||
row=await JobPosts.update_job_post(self.session,job_post_id,fields)
|
try:
|
||||||
|
row=await JobPosts.update_job_post(self.session,job_post_id,fields)
|
||||||
|
except IntegrityError as e:
|
||||||
|
orig=str(getattr(e,"orig",e)).lower()
|
||||||
|
if "requisition" in orig:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,detail="This requisition is already linked to a job post",
|
||||||
|
) from e
|
||||||
|
raise
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404,detail="Job post not found")
|
raise HTTPException(status_code=404,detail="Job post not found")
|
||||||
if hm_changed:
|
if hm_changed:
|
||||||
|
|
@ -313,9 +499,25 @@ class JobPost:
|
||||||
job_post_id,fields["hiring_manager_id"],"hiring_manager",assigned_by,
|
job_post_id,fields["hiring_manager_id"],"hiring_manager",assigned_by,
|
||||||
)
|
)
|
||||||
if rec_changed:
|
if rec_changed:
|
||||||
await assignment.record_job_owner(
|
await assignment.record_job_recruiters(
|
||||||
job_post_id,fields.get("current_recruiter_id"),"primary_recruiter",assigned_by,
|
job_post_id,fields.get("current_recruiter_ids") or [],assigned_by,
|
||||||
)
|
)
|
||||||
|
if hm_changed or rec_changed:
|
||||||
|
try:
|
||||||
|
from notifications.views import notify_job_assignment
|
||||||
|
labels=[]
|
||||||
|
if hm_changed:
|
||||||
|
labels.append("hiring manager")
|
||||||
|
if rec_changed:
|
||||||
|
labels.append("recruiter")
|
||||||
|
await notify_job_assignment(
|
||||||
|
self.session,row,
|
||||||
|
role_label=" and ".join(labels),
|
||||||
|
actor_id=assigned_by,
|
||||||
|
previous_ids=[existing.hiring_manager_id,*JobPosts.recruiter_ids_of(existing)],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("notification insert skipped: %s",exc)
|
||||||
return await self._job_row(row)
|
return await self._job_row(row)
|
||||||
|
|
||||||
async def delete_job(self,job_post_id,current_user):
|
async def delete_job(self,job_post_id,current_user):
|
||||||
|
|
@ -375,25 +577,22 @@ class JobPost:
|
||||||
)
|
)
|
||||||
status=parsed.value
|
status=parsed.value
|
||||||
actor=current_user.get("id") if isinstance(current_user,dict) else None
|
actor=current_user.get("id") if isinstance(current_user,dict) else None
|
||||||
|
previous=None
|
||||||
|
existing=await JobPosts.get_job_post_by_id(self.session,job_post_id)
|
||||||
|
if existing:
|
||||||
|
previous=existing.requisition_status
|
||||||
row=await JobPosts.set_requisition_status(
|
row=await JobPosts.set_requisition_status(
|
||||||
self.session,job_post_id,status,changed_by=actor,
|
self.session,job_post_id,status,changed_by=actor,
|
||||||
)
|
)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404,detail="Job post not found")
|
raise HTTPException(status_code=404,detail="Job post not found")
|
||||||
if status==RequisitionStatus.CLOSED.value:
|
if previous!=status:
|
||||||
try:
|
try:
|
||||||
from notifications.models import Notifications
|
from notifications.views import notify_job_status
|
||||||
raw=row.current_recruiter_id or (current_user.get("id") if current_user else None)
|
await notify_job_status(
|
||||||
recipient=uuid.UUID(str(raw)) if raw else None
|
self.session,row,
|
||||||
if recipient:
|
from_status=previous,to_status=status,actor_id=actor,
|
||||||
await Notifications.insert_notification(self.session,{
|
)
|
||||||
"user_id":recipient,
|
|
||||||
"kind":"approval",
|
|
||||||
"title":"Requisition closed",
|
|
||||||
"body":f"{row.title} was closed",
|
|
||||||
"link_path":"/jobs",
|
|
||||||
"job_post_id":row.id,
|
|
||||||
})
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("notification insert skipped: %s",exc)
|
logger.warning("notification insert skipped: %s",exc)
|
||||||
return await self._job_row(row)
|
return await self._job_row(row)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from job.candidate.models import Notes
|
from job.candidate.models import Notes
|
||||||
|
from job.candidate.views import assert_manager_candidate_access
|
||||||
from job.history.enums import HistoryEvent
|
from job.history.enums import HistoryEvent
|
||||||
from job.history.views import HistoryRecorder
|
from job.history.views import HistoryRecorder
|
||||||
from job.notes.serializers import serialize_note
|
from job.notes.serializers import serialize_note
|
||||||
|
|
@ -14,21 +15,25 @@ class Note:
|
||||||
async def _load(self,record_id):
|
async def _load(self,record_id):
|
||||||
return await Notes.get_note_by_id(self.session,record_id)
|
return await Notes.get_note_by_id(self.session,record_id)
|
||||||
|
|
||||||
async def get_note(self,note_id=None,user_id=None):
|
async def get_note(self,note_id=None,user_id=None,current_user=None,created_by=False):
|
||||||
if note_id:
|
if note_id:
|
||||||
row=await self._load(note_id)
|
row=await self._load(note_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404,detail="Note not found")
|
raise HTTPException(status_code=404,detail="Note not found")
|
||||||
|
await assert_manager_candidate_access(
|
||||||
|
self.session,current_user,user_id=row.user_id,created_by=created_by,
|
||||||
|
)
|
||||||
return serialize_note(row)
|
return serialize_note(row)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise HTTPException(status_code=400,detail="note_id or user_id is required")
|
raise HTTPException(status_code=400,detail="note_id or user_id is required")
|
||||||
uid=Notes._as_uuid(user_id)
|
uid=Notes._as_uuid(user_id)
|
||||||
if uid is None:
|
if uid is None:
|
||||||
raise HTTPException(status_code=400,detail="Invalid user_id")
|
raise HTTPException(status_code=400,detail="Invalid user_id")
|
||||||
|
await assert_manager_candidate_access(self.session,current_user,user_id=uid,created_by=created_by)
|
||||||
rows=await Notes.get_notes_by_user(self.session,uid)
|
rows=await Notes.get_notes_by_user(self.session,uid)
|
||||||
return [serialize_note(r) for r in rows]
|
return [serialize_note(r) for r in rows]
|
||||||
|
|
||||||
async def create_note(self,payload,current_user):
|
async def create_note(self,payload,current_user,created_by=False):
|
||||||
fields={
|
fields={
|
||||||
"note":payload.get("note") or "",
|
"note":payload.get("note") or "",
|
||||||
"user_id":payload.get("user_id"),
|
"user_id":payload.get("user_id"),
|
||||||
|
|
@ -36,6 +41,9 @@ class Note:
|
||||||
}
|
}
|
||||||
if not fields["user_id"]:
|
if not fields["user_id"]:
|
||||||
raise HTTPException(status_code=400,detail="user_id is required")
|
raise HTTPException(status_code=400,detail="user_id is required")
|
||||||
|
await assert_manager_candidate_access(
|
||||||
|
self.session,current_user,user_id=fields["user_id"],created_by=created_by,
|
||||||
|
)
|
||||||
row=await Notes.insert_note(self.session,fields)
|
row=await Notes.insert_note(self.session,fields)
|
||||||
await HistoryRecorder(self.session).record(
|
await HistoryRecorder(self.session).record(
|
||||||
HistoryEvent.NOTE_CREATED.value,
|
HistoryEvent.NOTE_CREATED.value,
|
||||||
|
|
@ -46,13 +54,16 @@ class Note:
|
||||||
row=await self._load(row.id)
|
row=await self._load(row.id)
|
||||||
return serialize_note(row)
|
return serialize_note(row)
|
||||||
|
|
||||||
async def update_note(self,note_id,payload,current_user=None):
|
async def update_note(self,note_id,payload,current_user=None,created_by=False):
|
||||||
fields={k:v for k,v in payload.items() if v is not None and k in ("note",)}
|
fields={k:v for k,v in payload.items() if v is not None and k in ("note",)}
|
||||||
if not fields:
|
if not fields:
|
||||||
raise HTTPException(status_code=400,detail="No fields to update")
|
raise HTTPException(status_code=400,detail="No fields to update")
|
||||||
before=await self._load(note_id)
|
before=await self._load(note_id)
|
||||||
if not before:
|
if not before:
|
||||||
raise HTTPException(status_code=404,detail="Note not found")
|
raise HTTPException(status_code=404,detail="Note not found")
|
||||||
|
await assert_manager_candidate_access(
|
||||||
|
self.session,current_user,user_id=before.user_id,created_by=created_by,
|
||||||
|
)
|
||||||
old_note=before.note or ""
|
old_note=before.note or ""
|
||||||
row=await Notes.update_note(self.session,note_id,fields)
|
row=await Notes.update_note(self.session,note_id,fields)
|
||||||
if not row:
|
if not row:
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,21 @@ class Pipeline:
|
||||||
def __init__(self,session:AsyncSession):
|
def __init__(self,session:AsyncSession):
|
||||||
self.session=session
|
self.session=session
|
||||||
|
|
||||||
async def get_all(self,job_post_id=None,limit=10,offset=0):
|
async def get_all(self,job_post_id=None,limit=10,offset=0,search=None):
|
||||||
# limit/offset are per-source, not a merged page: two tables that cannot be
|
# limit/offset are per-source, not a merged page: two tables that cannot be
|
||||||
# paged as one. limit=10 returns up to 10 inbox AND up to 10 manual rows,
|
# paged as one. limit=10 returns up to 10 inbox AND up to 10 manual rows,
|
||||||
# each newest-first by created_at. `counts`/`total` stay full-set sizes so
|
# each newest-first by created_at. `counts`/`total` stay full-set sizes so
|
||||||
# the caller can drive paging off them.
|
# the caller can drive paging off them.
|
||||||
try:
|
try:
|
||||||
inbox_data=await Inbox.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset)
|
inbox_data=await Inbox.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset,search=search)
|
||||||
manual_upload_data=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset)
|
manual_upload_data=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset,search=search)
|
||||||
|
from job.candidate.views import CandidateView
|
||||||
|
history=CandidateView(session=self.session)
|
||||||
|
inbox_data=await history.attach_application_history(inbox_data)
|
||||||
|
manual_upload_data=await history.attach_application_history(manual_upload_data)
|
||||||
counts=serialize_pipeline_counts(
|
counts=serialize_pipeline_counts(
|
||||||
await Inbox.count_by_status(self.session,job_post_id=job_post_id),
|
await Inbox.count_by_status(self.session,job_post_id=job_post_id,search=search),
|
||||||
await Manual_UPLOAD_CANDIDATE.count_by_status(self.session,job_post_id=job_post_id),
|
await Manual_UPLOAD_CANDIDATE.count_by_status(self.session,job_post_id=job_post_id,search=search),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"data":{"inbox":inbox_data,"manual_upload":manual_upload_data},
|
"data":{"inbox":inbox_data,"manual_upload":manual_upload_data},
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,9 @@ OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL") or None
|
||||||
OPENAI_ORGANIZATION = os.getenv("OPENAI_ORGANIZATION") or None
|
OPENAI_ORGANIZATION = os.getenv("OPENAI_ORGANIZATION") or None
|
||||||
OPENAI_PROJECT = os.getenv("OPENAI_PROJECT") or None
|
OPENAI_PROJECT = os.getenv("OPENAI_PROJECT") or None
|
||||||
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-5.4-mini")
|
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-5.4-mini")
|
||||||
OPENAI_MAX_OUTPUT_TOKENS = int(os.getenv("OPENAI_MAX_OUTPUT_TOKENS") or 32768)
|
# Default kept under gpt-4o-mini's 16384 completion ceiling: a larger value is a 400
|
||||||
|
# on every call, not a bigger budget.
|
||||||
|
OPENAI_MAX_OUTPUT_TOKENS = int(os.getenv("OPENAI_MAX_OUTPUT_TOKENS") or 4096)
|
||||||
OPENAI_TIMEOUT = float(os.getenv("OPENAI_TIMEOUT") or 60)
|
OPENAI_TIMEOUT = float(os.getenv("OPENAI_TIMEOUT") or 60)
|
||||||
OPENAI_MAX_RETRIES = int(os.getenv("OPENAI_MAX_RETRIES") or 3)
|
OPENAI_MAX_RETRIES = int(os.getenv("OPENAI_MAX_RETRIES") or 3)
|
||||||
OPENAI_CONNECT_RETRIES = int(os.getenv("OPENAI_CONNECT_RETRIES") or 3)
|
OPENAI_CONNECT_RETRIES = int(os.getenv("OPENAI_CONNECT_RETRIES") or 3)
|
||||||
|
|
@ -80,6 +82,7 @@ async def llm_call(system, user, *, model=None, temperature=None, json_mode=Fals
|
||||||
kwargs["response_format"] = {"type": "json_object"}
|
kwargs["response_format"] = {"type": "json_object"}
|
||||||
|
|
||||||
response = await get_client().chat.completions.create(**kwargs)
|
response = await get_client().chat.completions.create(**kwargs)
|
||||||
|
_log_usage(response, kwargs["model"])
|
||||||
content = (response.choices[0].message.content or "").strip()
|
content = (response.choices[0].message.content or "").strip()
|
||||||
if not json_mode:
|
if not json_mode:
|
||||||
return content
|
return content
|
||||||
|
|
@ -89,6 +92,22 @@ async def llm_call(system, user, *, model=None, temperature=None, json_mode=Fals
|
||||||
raise RuntimeError(f"model did not return valid JSON: {content[:200]}") from exc
|
raise RuntimeError(f"model did not return valid JSON: {content[:200]}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _log_usage(response, model) -> None:
|
||||||
|
"""Per-call token and cache visibility. Never logs prompt or reply text."""
|
||||||
|
usage = getattr(response, "usage", None)
|
||||||
|
if usage is None:
|
||||||
|
return
|
||||||
|
prompt_details = getattr(usage, "prompt_tokens_details", None)
|
||||||
|
logger.info(
|
||||||
|
"llm usage: model=%s request_id=%s prompt_tokens=%s completion_tokens=%s cached_tokens=%s",
|
||||||
|
model,
|
||||||
|
getattr(response, "_request_id", None),
|
||||||
|
getattr(usage, "prompt_tokens", None),
|
||||||
|
getattr(usage, "completion_tokens", None),
|
||||||
|
getattr(prompt_details, "cached_tokens", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def check_connection(retries=None, delay=1.0):
|
async def check_connection(retries=None, delay=1.0):
|
||||||
"""Confirm the key works, retrying with a capped backoff."""
|
"""Confirm the key works, retrying with a capped backoff."""
|
||||||
attempts = OPENAI_CONNECT_RETRIES if retries is None else retries
|
attempts = OPENAI_CONNECT_RETRIES if retries is None else retries
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,11 @@ async def lifespan(app):
|
||||||
close_classifier()
|
close_classifier()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("classifier close skipped: %s",exc)
|
logger.warning("classifier close skipped: %s",exc)
|
||||||
|
try:
|
||||||
|
from summary_gate.agent_setup import close_gate
|
||||||
|
close_gate()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("summary gate close skipped: %s",exc)
|
||||||
if llm_ready and close_llm is not None:
|
if llm_ready and close_llm is not None:
|
||||||
await close_llm()
|
await close_llm()
|
||||||
if sheet_broker_ready and sheet_broker is not None:
|
if sheet_broker_ready and sheet_broker is not None:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Shared deterministic matching — provider-free, no LLM, no database.
|
||||||
|
|
||||||
|
Pure functions only, so both Find Talent (LinkedIn profiles) and the CV Bank
|
||||||
|
(stored resumes) rank against a job with the same arithmetic.
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
"""Deterministic job-fit ranking, shared by Find Talent and the CV Bank.
|
||||||
|
|
||||||
|
This arithmetic started life in talent/plugins.py for LinkedIn profiles. The CV
|
||||||
|
Bank needs the same thing for stored resumes, and two copies of a scoring rule
|
||||||
|
drift: one gets tuned against live data and the other quietly does not. So the
|
||||||
|
implementation lives here and talent/plugins.py re-exports it.
|
||||||
|
|
||||||
|
What this is NOT: an ATS score. There is no comprehension here, only token
|
||||||
|
overlap. It orders a pile of CVs so a recruiter can start at the top; it does
|
||||||
|
not judge whether anyone is qualified. The paid OpenAI score does that, and
|
||||||
|
only for the handful a human decides to shortlist.
|
||||||
|
|
||||||
|
Pure module: no FastAPI, no database, no I/O.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_TOKEN_STOPWORDS = {
|
||||||
|
"and", "or", "the", "of", "for", "with", "in", "a", "an", "to",
|
||||||
|
# Requirement-prose filler that appears in almost every profile and would
|
||||||
|
# inflate every score equally, flattening the ranking.
|
||||||
|
"experience", "years", "year", "strong", "including", "ability",
|
||||||
|
"knowledge", "skills", "understanding", "familiarity", "proficiency",
|
||||||
|
"hands", "must", "have", "plus", "good", "excellent", "etc",
|
||||||
|
}
|
||||||
|
|
||||||
|
# A resume's full text is mostly prose; feeding all of it to the token overlap
|
||||||
|
# would match half the dictionary and flatten every score toward the ceiling.
|
||||||
|
# Only a lead excerpt is used, which in practice is the summary/skills header.
|
||||||
|
RESUME_EXCERPT_CHARS = 1200
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_phrase(text) -> str:
|
||||||
|
cleaned = re.sub(r"[^a-z0-9+#]+", " ", str(text or "").lower())
|
||||||
|
return " ".join(
|
||||||
|
t for t in cleaned.split() if len(t) > 1 and t not in _TOKEN_STOPWORDS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _match_tokens(*texts) -> set[str]:
|
||||||
|
tokens: set[str] = set()
|
||||||
|
for text in texts:
|
||||||
|
tokens.update(_clean_phrase(text).split())
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def rank_profile(job: dict, profile: dict) -> int:
|
||||||
|
"""0-100 job-fit rank for sorting, computed when a profile is persisted.
|
||||||
|
|
||||||
|
Deterministic and free. Title component: a current title CONTAINING every
|
||||||
|
job-title token scores 55 — containment, not exact phrase, because job
|
||||||
|
titles rarely reappear verbatim ("Generative Engineer" vs the pool's
|
||||||
|
"Generative AI Engineer"; seen live: the phrase rule dropped every real
|
||||||
|
match to the scattered tier and compressed the whole pool into the 40s).
|
||||||
|
The job title as an exact phrase in the headline scores 45; scattered
|
||||||
|
token overlap caps at 35 — a keyword-stuffed headline ("AI/ML Engineer |
|
||||||
|
Python | FastAPI | ...") must not outrank someone whose title IS the job
|
||||||
|
title, which is exactly what token overlap alone did on live data. The
|
||||||
|
headline tier stays phrase-only for the same reason: stuffed headlines
|
||||||
|
contain every token of every hot title.
|
||||||
|
|
||||||
|
Skills component (up to 45): GRADED token overlap between the content
|
||||||
|
words of the job's requirements + optional skills and the person's
|
||||||
|
title/headline/skills/summary. Graded, not per-term all-or-nothing: the
|
||||||
|
title facet makes every sourced profile earn the same title points, so
|
||||||
|
all differentiation lives here — an all-or-nothing single term put a
|
||||||
|
whole live pool on exactly 60.
|
||||||
|
"""
|
||||||
|
job_title = _clean_phrase(job.get("title"))
|
||||||
|
job_title_tokens = set(job_title.split())
|
||||||
|
title_text = _clean_phrase(profile.get("current_title"))
|
||||||
|
headline_text = _clean_phrase(profile.get("headline"))
|
||||||
|
if job_title and job_title_tokens <= set(title_text.split()):
|
||||||
|
title_component = 55.0
|
||||||
|
elif job_title and job_title in headline_text:
|
||||||
|
title_component = 45.0
|
||||||
|
else:
|
||||||
|
role_tokens = set(title_text.split()) | set(headline_text.split())
|
||||||
|
ratio = (
|
||||||
|
len(job_title_tokens & role_tokens) / len(job_title_tokens)
|
||||||
|
if job_title_tokens
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
title_component = 35 * ratio
|
||||||
|
|
||||||
|
job_tokens = _match_tokens(
|
||||||
|
*(job.get("requirements") or []), *(job.get("optional_skills") or [])
|
||||||
|
)
|
||||||
|
profile_tokens = _match_tokens(
|
||||||
|
profile.get("current_title"),
|
||||||
|
profile.get("headline"),
|
||||||
|
" ".join(profile.get("skills") or []),
|
||||||
|
profile.get("summary"),
|
||||||
|
)
|
||||||
|
skills_ratio = (
|
||||||
|
len(job_tokens & profile_tokens) / len(job_tokens) if job_tokens else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
return round(title_component + 45 * skills_ratio)
|
||||||
|
|
||||||
|
|
||||||
|
def bank_row_as_profile(row) -> dict:
|
||||||
|
"""Map a manual_upload_candidate bank row onto the profile shape.
|
||||||
|
|
||||||
|
A resume has no headline, so the employer stands in for one: it is the
|
||||||
|
other short, title-adjacent string a person is described by. full_text is
|
||||||
|
the summary, truncated — see RESUME_EXCERPT_CHARS.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"current_title": getattr(row, "current_position", "") or "",
|
||||||
|
"headline": getattr(row, "current_company", "") or "",
|
||||||
|
"skills": list(getattr(row, "skills", None) or []),
|
||||||
|
"summary": (getattr(row, "full_text", "") or "")[:RESUME_EXCERPT_CHARS],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def rank_bank_row(job: dict, row) -> int:
|
||||||
|
"""Tier-1 rank for one banked CV against one job."""
|
||||||
|
return rank_profile(job, bank_row_as_profile(row))
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
-- 019_requisitions_rbac.sql
|
||||||
|
-- Manual one-shot: the `requisitions` permission module (8 tags), a
|
||||||
|
-- `requisitions_management` bundle holding them, and the bundle attached to
|
||||||
|
-- the staff roles that fill Employee Requisition forms (Annexure A). Mirrors
|
||||||
|
-- 007's idempotent pattern; applied automatically at startup by
|
||||||
|
-- alembic_setup.run_manual_sql() and recorded in manual_migrations.
|
||||||
|
--
|
||||||
|
-- The all_access bundle is a fixed id list seeded before this module existed,
|
||||||
|
-- so system_administrator gets requisitions access through THIS bundle, not
|
||||||
|
-- that one. Users must log in again after this applies — permissions are
|
||||||
|
-- resolved from the DB per request, but the frontend caches the list from
|
||||||
|
-- /users/me.
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 1. The 8 requisitions.* permission tags
|
||||||
|
-- =============================================================================
|
||||||
|
INSERT INTO app.permission_tags
|
||||||
|
(tag_name, module, action, description, created_at, updated_at, is_active, is_deleted)
|
||||||
|
VALUES
|
||||||
|
('requisitions.view', 'requisitions', 'view', NULL, NOW(), NOW(), true, false),
|
||||||
|
('requisitions.create', 'requisitions', 'create', NULL, NOW(), NOW(), true, false),
|
||||||
|
('requisitions.edit', 'requisitions', 'edit', NULL, NOW(), NOW(), true, false),
|
||||||
|
('requisitions.delete', 'requisitions', 'delete', NULL, NOW(), NOW(), true, false),
|
||||||
|
('requisitions.approve', 'requisitions', 'approve', NULL, NOW(), NOW(), true, false),
|
||||||
|
('requisitions.export', 'requisitions', 'export', NULL, NOW(), NOW(), true, false),
|
||||||
|
('requisitions.manage', 'requisitions', 'manage', NULL, NOW(), NOW(), true, false),
|
||||||
|
('requisitions.configure', 'requisitions', 'configure', NULL, NOW(), NOW(), true, false)
|
||||||
|
ON CONFLICT (tag_name) DO NOTHING;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 2. Bundle holding all eight requisitions tags
|
||||||
|
-- =============================================================================
|
||||||
|
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
||||||
|
SELECT
|
||||||
|
'requisitions_management',
|
||||||
|
'Employee requisition forms: view, create, edit and manage requisitions',
|
||||||
|
(
|
||||||
|
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
||||||
|
FROM app.permission_tags
|
||||||
|
WHERE is_deleted = false
|
||||||
|
AND module = 'requisitions'
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM app.permissions WHERE name = 'requisitions_management'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 3. Attach the bundle to the staff roles (idempotent; same role list as 007)
|
||||||
|
-- =============================================================================
|
||||||
|
UPDATE app.roles r
|
||||||
|
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM app.permissions p
|
||||||
|
WHERE p.name = 'requisitions_management'
|
||||||
|
AND r.role_name IN (
|
||||||
|
'system_administrator',
|
||||||
|
'hr_administrator',
|
||||||
|
'recruiter',
|
||||||
|
'hiring_manager',
|
||||||
|
'department_head',
|
||||||
|
'ceo'
|
||||||
|
)
|
||||||
|
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
-- 020_requisitions.sql
|
||||||
|
-- Employee Requisition (Annexure A) as its own table, separate from
|
||||||
|
-- candidate_forms (Interview Analysis / Cultural Fit). Nested request objects
|
||||||
|
-- flatten onto columns: position.title → position_title, replacement_for.title
|
||||||
|
-- → recruitment_title, refferal_by → employee_name / employee_department.
|
||||||
|
-- employment type is a CHECK over EmploymentType values, not a native PG enum
|
||||||
|
-- (point releases of the Python enum should not require a type ALTER).
|
||||||
|
--
|
||||||
|
-- "date" and "type" are quoted: both are PostgreSQL keywords.
|
||||||
|
--
|
||||||
|
-- Idempotent, applied automatically at startup by alembic_setup.run_manual_sql()
|
||||||
|
-- and recorded in manual_migrations. Matches Requisition in
|
||||||
|
-- backend/candidate_forms/models.py (needed here because prod boots with
|
||||||
|
-- DB_AUTOGENERATE=false and never autogenerates new tables).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app.requisitions (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
department varchar,
|
||||||
|
position_title varchar,
|
||||||
|
"date" date,
|
||||||
|
date_needed date,
|
||||||
|
"type" varchar
|
||||||
|
CHECK ("type" IS NULL OR "type" IN (
|
||||||
|
'permanent', 'contract', 'temporary', 'internee'
|
||||||
|
)),
|
||||||
|
job_description text,
|
||||||
|
|
||||||
|
employee_name varchar,
|
||||||
|
employee_department varchar,
|
||||||
|
|
||||||
|
to_replace varchar,
|
||||||
|
grade varchar,
|
||||||
|
recruitment_title varchar,
|
||||||
|
date_separated date,
|
||||||
|
justification text,
|
||||||
|
budget varchar,
|
||||||
|
recommended_grade varchar,
|
||||||
|
|
||||||
|
initiated_by varchar,
|
||||||
|
initiated_date date,
|
||||||
|
recommended_by varchar,
|
||||||
|
recommended_date date,
|
||||||
|
approved_by_hr boolean,
|
||||||
|
approved_by_date_hr date,
|
||||||
|
approved_by_vp boolean,
|
||||||
|
approved_by_date_vp date,
|
||||||
|
approved_by_svp boolean,
|
||||||
|
approved_by_date_svp date,
|
||||||
|
|
||||||
|
created_by uuid REFERENCES app.users(id),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
is_deleted boolean NOT NULL DEFAULT false
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_requisitions_created_by
|
||||||
|
ON app.requisitions (created_by);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_requisitions_is_deleted
|
||||||
|
ON app.requisitions (is_deleted);
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
-- 021_job_post_requisition.sql
|
||||||
|
-- Optional link from a job post to the Annexure A employee requisition it
|
||||||
|
-- was opened from (app.requisitions). Distinct from requisition_status, which
|
||||||
|
-- is the hiring lifecycle on job_posts. Applied at startup by
|
||||||
|
-- alembic_setup.run_manual_sql(). Needed because prod boots with
|
||||||
|
-- DB_AUTOGENERATE=false.
|
||||||
|
|
||||||
|
ALTER TABLE app.job_posts
|
||||||
|
ADD COLUMN IF NOT EXISTS requisition_id UUID REFERENCES app.requisitions(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Unique so one requisition maps to at most one job post. Postgres unique
|
||||||
|
-- indexes allow multiple NULLs, so unlinked job posts stay valid.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_job_posts_requisition_id
|
||||||
|
ON app.job_posts (requisition_id);
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- 022_job_post_requisition_unique.sql
|
||||||
|
-- 021 originally created a non-unique ix_job_posts_requisition_id. Replace it
|
||||||
|
-- with a unique index so the optional job_posts.requisition_id link is 1:1.
|
||||||
|
-- Postgres unique indexes allow multiple NULLs. Applied at startup by
|
||||||
|
-- alembic_setup.run_manual_sql(). Needed because prod boots with
|
||||||
|
-- DB_AUTOGENERATE=false.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS app.ix_job_posts_requisition_id;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_job_posts_requisition_id
|
||||||
|
ON app.job_posts (requisition_id);
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
-- 023_requisition_employmenttype.sql
|
||||||
|
-- Native PG enum for Annexure A employment type. 020 stored this as a quoted
|
||||||
|
-- varchar "type" with a CHECK; SQLAlchemy maps EmploymentType to
|
||||||
|
-- app.employmenttype, which 020 never created. Rename "type" (a PG keyword)
|
||||||
|
-- to employment_type and convert the column. Applied at startup by
|
||||||
|
-- alembic_setup.run_manual_sql(). Needed because prod boots with
|
||||||
|
-- DB_AUTOGENERATE=false.
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE app.employmenttype AS ENUM (
|
||||||
|
'permanent', 'contract', 'temporary', 'internee'
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r record;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT con.conname
|
||||||
|
FROM pg_constraint con
|
||||||
|
JOIN pg_class rel ON rel.oid = con.conrelid
|
||||||
|
JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
|
||||||
|
WHERE nsp.nspname = 'app'
|
||||||
|
AND rel.relname = 'requisitions'
|
||||||
|
AND con.contype = 'c'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE app.requisitions DROP CONSTRAINT IF EXISTS %I', r.conname);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'app'
|
||||||
|
AND table_name = 'requisitions'
|
||||||
|
AND column_name = 'type'
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'app'
|
||||||
|
AND table_name = 'requisitions'
|
||||||
|
AND column_name = 'employment_type'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE app.requisitions RENAME COLUMN "type" TO employment_type;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'app'
|
||||||
|
AND table_name = 'requisitions'
|
||||||
|
AND column_name = 'employment_type'
|
||||||
|
AND udt_name <> 'employmenttype'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE app.requisitions
|
||||||
|
ALTER COLUMN employment_type TYPE app.employmenttype
|
||||||
|
USING CASE
|
||||||
|
WHEN employment_type IS NULL OR btrim(employment_type) = '' THEN NULL
|
||||||
|
ELSE lower(employment_type)::app.employmenttype
|
||||||
|
END;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
-- 024_manager_candidates_rbac.sql
|
||||||
|
-- Manual one-shot: hiring_manager can list candidates on their requisition
|
||||||
|
-- jobs and write notes on those profiles. Form fill already comes from
|
||||||
|
-- hiring_forms (interviews.create/edit) + analytics_dashboard (interviews.view).
|
||||||
|
-- Applied at startup by alembic_setup.run_manual_sql().
|
||||||
|
--
|
||||||
|
-- Users must log in again after this applies — the frontend caches /users/me.
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 1. Bundle: candidates.view / create / edit (list + notes)
|
||||||
|
-- =============================================================================
|
||||||
|
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
||||||
|
SELECT
|
||||||
|
'manager_candidates',
|
||||||
|
'Hiring manager: list candidates on own requisition jobs, view profiles, write notes',
|
||||||
|
(
|
||||||
|
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
||||||
|
FROM app.permission_tags
|
||||||
|
WHERE is_deleted = false
|
||||||
|
AND tag_name IN ('candidates.view', 'candidates.create', 'candidates.edit')
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM app.permissions WHERE name = 'manager_candidates'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 2. Attach the bundle to hiring_manager only
|
||||||
|
-- =============================================================================
|
||||||
|
UPDATE app.roles r
|
||||||
|
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM app.permissions p
|
||||||
|
WHERE p.name = 'manager_candidates'
|
||||||
|
AND r.role_name = 'hiring_manager'
|
||||||
|
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
-- 025_manager_role_candidates_rbac.sql
|
||||||
|
-- The Access Control role named Manager (Ahmed Baig) is not the seeded
|
||||||
|
-- hiring_manager role. 024 only attached manager_candidates to hiring_manager,
|
||||||
|
-- so Manager had 0 candidates.* tags and the Candidates nav item never appeared
|
||||||
|
-- (routes.js permission is candidates.view). Same hole broke Calendar:
|
||||||
|
-- GET /interview/fetch is gated on candidates.view, not interviews.view.
|
||||||
|
-- Applied at startup by alembic_setup.run_manual_sql(). Log in again after.
|
||||||
|
|
||||||
|
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
||||||
|
SELECT
|
||||||
|
'manager_candidates',
|
||||||
|
'Hiring manager: list candidates on own requisition jobs, view profiles, write notes',
|
||||||
|
(
|
||||||
|
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
||||||
|
FROM app.permission_tags
|
||||||
|
WHERE is_deleted = false
|
||||||
|
AND tag_name IN ('candidates.view', 'candidates.create', 'candidates.edit')
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM app.permissions WHERE name = 'manager_candidates'
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE app.roles r
|
||||||
|
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM app.permissions p
|
||||||
|
WHERE p.name = 'manager_candidates'
|
||||||
|
AND lower(r.role_name) IN ('hiring_manager', 'manager')
|
||||||
|
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- 026: The organisation runs four staff roles — system_administrator,
|
||||||
|
-- recruiter, hiring_manager, department_head. Soft-delete the unused seeded
|
||||||
|
-- staff roles (hr_administrator, interviewer, ceo) so they stop appearing in
|
||||||
|
-- the Access Control screen and every role picker (listings filter on
|
||||||
|
-- is_deleted).
|
||||||
|
--
|
||||||
|
-- NOT touched:
|
||||||
|
-- * candidate — not a staff role: every applicant account is a role-8 user
|
||||||
|
-- and the Candidates screen is keyed to it.
|
||||||
|
-- * any role that still has live members — pruning a role out from under a
|
||||||
|
-- user would strand their permissions; such a role keeps working until
|
||||||
|
-- the members are reassigned by hand, and this migration (idempotent)
|
||||||
|
-- picks it up on a later boot.
|
||||||
|
UPDATE app.roles r
|
||||||
|
SET is_deleted = TRUE,
|
||||||
|
is_active = FALSE,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE r.role_name IN ('hr_administrator', 'interviewer', 'ceo')
|
||||||
|
AND r.is_deleted = FALSE
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM app.users u
|
||||||
|
WHERE u.role_id = r.id
|
||||||
|
AND COALESCE(u.is_deleted, FALSE) = FALSE
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- 027: The hand-made Access Control role "Manager" duplicates the seeded
|
||||||
|
-- hiring_manager, which has carried the manager_candidates bundle since 024.
|
||||||
|
-- Consolidate: move its live members onto hiring_manager, then soft-delete
|
||||||
|
-- it. Both steps are idempotent and guarded; the seeded hiring_manager row
|
||||||
|
-- is never matched (name check excludes it, and it is is_system).
|
||||||
|
UPDATE app.users u
|
||||||
|
SET role_id = hm.id,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM app.roles hm
|
||||||
|
WHERE hm.role_name = 'hiring_manager' AND hm.is_deleted = FALSE
|
||||||
|
AND u.role_id IN (
|
||||||
|
SELECT r.id FROM app.roles r
|
||||||
|
WHERE lower(r.role_name) = 'manager'
|
||||||
|
AND r.role_name <> 'hiring_manager'
|
||||||
|
AND r.is_deleted = FALSE
|
||||||
|
)
|
||||||
|
AND COALESCE(u.is_deleted, FALSE) = FALSE;
|
||||||
|
|
||||||
|
UPDATE app.roles r
|
||||||
|
SET is_deleted = TRUE,
|
||||||
|
is_active = FALSE,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE lower(r.role_name) = 'manager'
|
||||||
|
AND r.role_name <> 'hiring_manager'
|
||||||
|
AND r.is_deleted = FALSE
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM app.users u
|
||||||
|
WHERE u.role_id = r.id AND COALESCE(u.is_deleted, FALSE) = FALSE
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
-- 028: Slim bundles for custom Access Control roles that should see only the
|
||||||
|
-- Requisitions and Interviews/Calendar tabs. The tags already exist (001, 019);
|
||||||
|
-- the seeded bundles are too wide — requisitions_management includes
|
||||||
|
-- requisitions.manage (org-wide list), analytics_dashboard hangs interviews.view
|
||||||
|
-- off dashboard/analytics/offers, hiring_forms has interview writes but no view.
|
||||||
|
--
|
||||||
|
-- These two are NOT attached to seeded staff roles (those already have the wide
|
||||||
|
-- bundles). Admins tick them on a new role in Access Control.
|
||||||
|
-- Applied at startup by alembic_setup.run_manual_sql(). Log in again after.
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 1. Own requisitions only (omit .manage so is_admin() stays false)
|
||||||
|
-- =============================================================================
|
||||||
|
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
||||||
|
SELECT
|
||||||
|
'requisitions_self',
|
||||||
|
'Own employee requisition forms: view, create, edit (not org-wide manage)',
|
||||||
|
(
|
||||||
|
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
||||||
|
FROM app.permission_tags
|
||||||
|
WHERE is_deleted = false
|
||||||
|
AND tag_name IN ('requisitions.view', 'requisitions.create', 'requisitions.edit')
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM app.permissions WHERE name = 'requisitions_self'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 2. Interviews / Calendar tab (view + schedule + amend)
|
||||||
|
-- =============================================================================
|
||||||
|
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
||||||
|
SELECT
|
||||||
|
'interviews_tab',
|
||||||
|
'Interviews and Calendar tabs: list, schedule, reschedule',
|
||||||
|
(
|
||||||
|
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
||||||
|
FROM app.permission_tags
|
||||||
|
WHERE is_deleted = false
|
||||||
|
AND tag_name IN ('interviews.view', 'interviews.create', 'interviews.edit')
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM app.permissions WHERE name = 'interviews_tab'
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- 029_cv_bank_profile.sql
|
||||||
|
-- Structured profile fields for banked CVs. Until now a bank row carried only
|
||||||
|
-- full_text, so the bank was write-only: you could store a CV but not search
|
||||||
|
-- or rank it. The employment agent already extracts these on upload; its
|
||||||
|
-- output was being discarded.
|
||||||
|
--
|
||||||
|
-- bank_reason records WHY the CV is held (speculative / referral); bank_expires_at
|
||||||
|
-- gives the retention policy something to enforce.
|
||||||
|
-- Applied at startup by alembic_setup.run_manual_sql().
|
||||||
|
|
||||||
|
ALTER TABLE app.manual_upload_candidate
|
||||||
|
ADD COLUMN IF NOT EXISTS skills JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS years_experience INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS education TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS bank_reason TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS bank_expires_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Containment queries ("has React") need GIN; a btree on a JSONB array is useless.
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_manual_upload_candidate_skills
|
||||||
|
ON app.manual_upload_candidate USING GIN (skills);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_manual_upload_candidate_years_experience
|
||||||
|
ON app.manual_upload_candidate (years_experience);
|
||||||
|
|
||||||
|
-- Rows banked before this migration were all speculative uploads: the only
|
||||||
|
-- writer of apply_via='cv_bank' is POST /candidate/cv-bank/upload.
|
||||||
|
UPDATE app.manual_upload_candidate
|
||||||
|
SET bank_reason = 'speculative'
|
||||||
|
WHERE apply_via = 'cv_bank'
|
||||||
|
AND COALESCE(TRIM(bank_reason), '') = '';
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
-- 030_cv_bank_matches.sql
|
||||||
|
-- Tier-1 ranking of banked CVs against a job post.
|
||||||
|
--
|
||||||
|
-- Computed by the cvbank.rank_for_job task when a job opens, not on read: the
|
||||||
|
-- point is to notify a recruiter that the bank already holds candidates, and a
|
||||||
|
-- notification needs a result that exists before anyone opens the screen.
|
||||||
|
--
|
||||||
|
-- rank_score is deterministic keyword overlap (matching/ranking.py), NOT an ATS
|
||||||
|
-- score. Cheap enough to recompute for the whole bank on every job opening.
|
||||||
|
-- Applied at startup by alembic_setup.run_manual_sql().
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app.cv_bank_matches (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
manual_upload_candidate_id UUID NOT NULL
|
||||||
|
REFERENCES app.manual_upload_candidate (id) ON DELETE CASCADE,
|
||||||
|
job_post_id UUID NOT NULL
|
||||||
|
REFERENCES app.job_posts (id) ON DELETE CASCADE,
|
||||||
|
rank_score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_cv_bank_matches_pair UNIQUE (manual_upload_candidate_id, job_post_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The read is always "best candidates for THIS job", so the job leads.
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_cv_bank_matches_job_rank
|
||||||
|
ON app.cv_bank_matches (job_post_id, rank_score DESC);
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
-- 031_candidate_city_reapplied.sql
|
||||||
|
-- City of residence (employment-agent extraction) on the four candidate
|
||||||
|
-- surfaces, plus a denormalised reapplied JSONB list of prior job_post_ids
|
||||||
|
-- on users / manual_upload_candidate / form_data.
|
||||||
|
--
|
||||||
|
-- form_data already has residing_city from the sheet; city is a separate
|
||||||
|
-- column so inbox / users / bank share one name. Existing sheet values are
|
||||||
|
-- copied across. Applied at startup by alembic_setup.run_manual_sql().
|
||||||
|
|
||||||
|
ALTER TABLE app.users
|
||||||
|
ADD COLUMN IF NOT EXISTS city TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS reapplied JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
ALTER TABLE app.manual_upload_candidate
|
||||||
|
ADD COLUMN IF NOT EXISTS city TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS reapplied JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
ALTER TABLE app.form_data
|
||||||
|
ADD COLUMN IF NOT EXISTS city TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS reapplied JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
ALTER TABLE app.inbox_messages
|
||||||
|
ADD COLUMN IF NOT EXISTS city TEXT;
|
||||||
|
|
||||||
|
UPDATE app.form_data
|
||||||
|
SET city = residing_city
|
||||||
|
WHERE city IS NULL
|
||||||
|
AND COALESCE(TRIM(residing_city), '') <> '';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_users_city
|
||||||
|
ON app.users (city);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_manual_upload_candidate_city
|
||||||
|
ON app.manual_upload_candidate (city);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_form_data_city
|
||||||
|
ON app.form_data (city);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_inbox_messages_city
|
||||||
|
ON app.inbox_messages (city);
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- 032_inbox_rescan_runs.sql
|
||||||
|
-- On-Hold catalogue ATS rescan: one run row so the Inbox ReScan button can
|
||||||
|
-- poll progress while workers score (candidate, job_post) pairs.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app.inbox_rescan_runs (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
status varchar NOT NULL DEFAULT 'queued',
|
||||||
|
channel varchar NOT NULL DEFAULT 'all',
|
||||||
|
sheet varchar,
|
||||||
|
task_id varchar,
|
||||||
|
created_by uuid REFERENCES app.users (id),
|
||||||
|
job_count integer NOT NULL DEFAULT 0,
|
||||||
|
candidate_count integer NOT NULL DEFAULT 0,
|
||||||
|
skipped_candidates integer NOT NULL DEFAULT 0,
|
||||||
|
skipped_pairs integer NOT NULL DEFAULT 0,
|
||||||
|
pair_count integer NOT NULL DEFAULT 0,
|
||||||
|
done_count integer NOT NULL DEFAULT 0,
|
||||||
|
entries jsonb,
|
||||||
|
error text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
started_at timestamptz,
|
||||||
|
finished_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_inbox_rescan_runs_status
|
||||||
|
ON app.inbox_rescan_runs (status);
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- 033_professional_summary.sql
|
||||||
|
-- Job-agnostic professional summary (tech-stack speciality + department).
|
||||||
|
-- Latest denorm on each intake row; per-run snapshot on ats_results.
|
||||||
|
-- ats_results.rescan_run_id is the 1:N link from an On-Hold ReScan run.
|
||||||
|
-- Applied at startup by alembic_setup.run_manual_sql().
|
||||||
|
|
||||||
|
ALTER TABLE app.inbox_messages
|
||||||
|
ADD COLUMN IF NOT EXISTS professional_summary TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE app.form_data
|
||||||
|
ADD COLUMN IF NOT EXISTS professional_summary TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE app.manual_upload_candidate
|
||||||
|
ADD COLUMN IF NOT EXISTS professional_summary TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE app.users
|
||||||
|
ADD COLUMN IF NOT EXISTS professional_summary TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE app.candidates
|
||||||
|
ADD COLUMN IF NOT EXISTS professional_summary TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE app.ats_results
|
||||||
|
ADD COLUMN IF NOT EXISTS professional_summary TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS rescan_run_id UUID REFERENCES app.inbox_rescan_runs (id);
|
||||||
|
|
||||||
|
ALTER TABLE app.inbox_rescan_runs
|
||||||
|
ADD COLUMN IF NOT EXISTS summaries JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_ats_results_rescan_run_id
|
||||||
|
ON app.ats_results (rescan_run_id);
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
-- 034_offers_sent_identity.sql
|
||||||
|
-- Offers may target inbox, manual-upload, or sheet FormData applications.
|
||||||
|
-- inbox_id was NOT NULL, which blocked non-email candidates. Applied at
|
||||||
|
-- startup by alembic_setup.run_manual_sql().
|
||||||
|
|
||||||
|
ALTER TABLE app.offers
|
||||||
|
ALTER COLUMN inbox_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE app.offers
|
||||||
|
ADD COLUMN IF NOT EXISTS manual_upload_candidate_id UUID REFERENCES app.manual_upload_candidate (id);
|
||||||
|
|
||||||
|
ALTER TABLE app.offers
|
||||||
|
ADD COLUMN IF NOT EXISTS form_data_id UUID REFERENCES app.form_data (id);
|
||||||
|
|
||||||
|
ALTER TABLE app.offers
|
||||||
|
ADD COLUMN IF NOT EXISTS equity_units INTEGER;
|
||||||
|
|
||||||
|
ALTER TABLE app.offers
|
||||||
|
ADD COLUMN IF NOT EXISTS equity_instrument VARCHAR;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_offers_manual_upload_candidate_id
|
||||||
|
ON app.offers (manual_upload_candidate_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_offers_form_data_id
|
||||||
|
ON app.offers (form_data_id);
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- 035_job_post_recruiter_ids.sql
|
||||||
|
-- A job post can have more than one recruiter. current_recruiter_id stays the
|
||||||
|
-- first / primary pointer so existing joins and filters keep working;
|
||||||
|
-- current_recruiter_ids is the full JSONB list used by create / update / get.
|
||||||
|
-- Applied at startup by alembic_setup.run_manual_sql(). Needed because prod
|
||||||
|
-- boots with DB_AUTOGENERATE=false.
|
||||||
|
|
||||||
|
ALTER TABLE app.job_posts
|
||||||
|
ADD COLUMN IF NOT EXISTS current_recruiter_ids JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
UPDATE app.job_posts
|
||||||
|
SET current_recruiter_ids = jsonb_build_array(current_recruiter_id::text)
|
||||||
|
WHERE current_recruiter_id IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_recruiter_ids IS NULL
|
||||||
|
OR current_recruiter_ids = '[]'::jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_job_posts_current_recruiter_ids
|
||||||
|
ON app.job_posts USING GIN (current_recruiter_ids);
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
-- 036_candidate_source_links.sql
|
||||||
|
-- Scored `candidates` rows point at the application that owns the CV text.
|
||||||
|
-- Extract lives on inbox_messages.resume_text / manual_upload_candidate.full_text
|
||||||
|
-- (so Inbox and CV Bank can fetch it). Do not duplicate that blob here.
|
||||||
|
-- Drops resume_text if an earlier draft of this file added it.
|
||||||
|
-- Applied at startup by alembic_setup.run_manual_sql().
|
||||||
|
|
||||||
|
ALTER TABLE app.candidates
|
||||||
|
DROP COLUMN IF EXISTS resume_text;
|
||||||
|
|
||||||
|
ALTER TABLE app.candidates
|
||||||
|
ADD COLUMN IF NOT EXISTS inbox_message_id UUID REFERENCES app.inbox_messages (id);
|
||||||
|
|
||||||
|
ALTER TABLE app.candidates
|
||||||
|
ADD COLUMN IF NOT EXISTS manual_upload_candidate_id UUID REFERENCES app.manual_upload_candidate (id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_candidates_inbox_message_id
|
||||||
|
ON app.candidates (inbox_message_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_candidates_manual_upload_candidate_id
|
||||||
|
ON app.candidates (manual_upload_candidate_id);
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- 037_requisition_period_jd_entity.sql
|
||||||
|
-- Annexure A revision 2 added three fields the original 020 table never had:
|
||||||
|
-- "If not permanent, specify the period From/To" on the position block, the
|
||||||
|
-- mandatory "JD Available Yes/No" flag (distinct from the free-text
|
||||||
|
-- job_description), and "Entity" alongside Employee Name/Department in the
|
||||||
|
-- internal-recommendation block. Matches Requisition in
|
||||||
|
-- backend/candidate_forms/models.py. Applied at startup by
|
||||||
|
-- alembic_setup.run_manual_sql() — needed because prod boots with
|
||||||
|
-- DB_AUTOGENERATE=false and never autogenerates new columns.
|
||||||
|
|
||||||
|
ALTER TABLE app.requisitions
|
||||||
|
ADD COLUMN IF NOT EXISTS period_from date,
|
||||||
|
ADD COLUMN IF NOT EXISTS period_to date,
|
||||||
|
ADD COLUMN IF NOT EXISTS jd_available boolean,
|
||||||
|
ADD COLUMN IF NOT EXISTS entity varchar;
|
||||||
|
|
@ -148,7 +148,9 @@ class Notifications(SQLModel, table=True):
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
):
|
):
|
||||||
statement = select(cls).where(
|
statement = select(cls).where(
|
||||||
cls.user_id == user_id, cls.is_deleted == False # noqa: E712
|
cls.user_id == user_id,
|
||||||
|
cls.is_deleted == False, # noqa: E712
|
||||||
|
cls.title != "ATS score ready",
|
||||||
)
|
)
|
||||||
if unread_only:
|
if unread_only:
|
||||||
statement = statement.where(cls.is_read == False) # noqa: E712
|
statement = statement.where(cls.is_read == False) # noqa: E712
|
||||||
|
|
@ -158,6 +160,7 @@ class Notifications(SQLModel, table=True):
|
||||||
cls.user_id == user_id,
|
cls.user_id == user_id,
|
||||||
cls.is_deleted == False, # noqa: E712
|
cls.is_deleted == False, # noqa: E712
|
||||||
cls.is_read == False, # noqa: E712
|
cls.is_read == False, # noqa: E712
|
||||||
|
cls.title != "ATS score ready",
|
||||||
)
|
)
|
||||||
unread = (await session.execute(unread_statement)).scalar_one()
|
unread = (await session.execute(unread_statement)).scalar_one()
|
||||||
statement = statement.order_by(cls.created_at.desc())
|
statement = statement.order_by(cls.created_at.desc())
|
||||||
|
|
@ -175,6 +178,30 @@ class Notifications(SQLModel, table=True):
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return await cls.get_by_id(session, row.id)
|
return await cls.get_by_id(session, row.id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def insert_many(cls, session: AsyncSession, payloads, *, commit: bool = True):
|
||||||
|
"""One row per payload. `commit=False` rides the caller's transaction."""
|
||||||
|
rows = []
|
||||||
|
for fields in payloads or []:
|
||||||
|
uid = cls._as_uuid(fields.get("user_id"))
|
||||||
|
if uid is None or not fields.get("kind") or not fields.get("title"):
|
||||||
|
continue
|
||||||
|
job_id = cls._as_uuid(fields.get("job_post_id")) if fields.get("job_post_id") else None
|
||||||
|
row = cls(
|
||||||
|
user_id=uid,
|
||||||
|
kind=fields["kind"],
|
||||||
|
title=fields["title"],
|
||||||
|
body=fields.get("body"),
|
||||||
|
link_path=fields.get("link_path"),
|
||||||
|
inbox_id=fields.get("inbox_id"),
|
||||||
|
job_post_id=job_id,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
rows.append(row)
|
||||||
|
if commit and rows:
|
||||||
|
await session.commit()
|
||||||
|
return rows
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def mark_read(cls, session: AsyncSession, record_id, *, user_id):
|
async def mark_read(cls, session: AsyncSession, record_id, *, user_id):
|
||||||
row = await cls.get_by_id(session, record_id, user_id=user_id)
|
row = await cls.get_by_id(session, record_id, user_id=user_id)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
import httpx
|
import httpx
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from notifications.models import EmailConfirmationTokens,Notifications
|
from notifications.models import EmailConfirmationTokens,Notifications
|
||||||
|
from role.models import EnumRoles
|
||||||
from notifications.plugins import (
|
from notifications.plugins import (
|
||||||
CONFIRM_TOKEN_RESEND_SECONDS,
|
CONFIRM_TOKEN_RESEND_SECONDS,
|
||||||
CONFIRM_TOKEN_TTL_SECONDS,
|
CONFIRM_TOKEN_TTL_SECONDS,
|
||||||
|
|
@ -26,6 +27,70 @@ from notifications.serializers import (
|
||||||
)
|
)
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SILENT_HISTORY_EVENTS = frozenset({"ats.scored"})
|
||||||
|
_HISTORY_KIND = {
|
||||||
|
"stage.changed": "application",
|
||||||
|
"candidate.created": "application",
|
||||||
|
"candidate.imported": "application",
|
||||||
|
"interview.created": "interview",
|
||||||
|
"interview.updated": "interview",
|
||||||
|
"calendar.created": "interview",
|
||||||
|
"calendar.rescheduled": "interview",
|
||||||
|
"calendar.cancelled": "interview",
|
||||||
|
"ats.scored": "assessment",
|
||||||
|
"form.created": "approval",
|
||||||
|
"form.updated": "approval",
|
||||||
|
"offer.sent": "application",
|
||||||
|
"note.created": "message",
|
||||||
|
"note.updated": "message",
|
||||||
|
"feedback.created": "message",
|
||||||
|
"feedback.updated": "message",
|
||||||
|
}
|
||||||
|
_HISTORY_TITLE = {
|
||||||
|
"stage.changed": "Stage changed",
|
||||||
|
"note.created": "Note added",
|
||||||
|
"note.updated": "Note updated",
|
||||||
|
"feedback.created": "Feedback added",
|
||||||
|
"feedback.updated": "Feedback updated",
|
||||||
|
"interview.created": "Interview scheduled",
|
||||||
|
"interview.updated": "Interview updated",
|
||||||
|
"calendar.created": "Calendar event created",
|
||||||
|
"calendar.rescheduled": "Interview rescheduled",
|
||||||
|
"calendar.cancelled": "Interview cancelled",
|
||||||
|
"favorite.changed": "Favorite updated",
|
||||||
|
"rating.changed": "Rating updated",
|
||||||
|
"candidate.created": "Candidate added",
|
||||||
|
"candidate.imported": "Candidate imported",
|
||||||
|
"document.uploaded": "Document uploaded",
|
||||||
|
"ats.scored": "ATS score ready",
|
||||||
|
"form.created": "Form submitted",
|
||||||
|
"form.updated": "Form updated",
|
||||||
|
"offer.sent": "Offer sent",
|
||||||
|
}
|
||||||
|
_HISTORY_TAB = {
|
||||||
|
"stage.changed": "History",
|
||||||
|
"interview.created": "Interview",
|
||||||
|
"interview.updated": "Interview",
|
||||||
|
"calendar.created": "Interview",
|
||||||
|
"calendar.rescheduled": "Interview",
|
||||||
|
"calendar.cancelled": "Interview",
|
||||||
|
"note.created": "Notes",
|
||||||
|
"note.updated": "Notes",
|
||||||
|
"feedback.created": "Activity",
|
||||||
|
"feedback.updated": "Activity",
|
||||||
|
"form.created": "Forms",
|
||||||
|
"form.updated": "Forms",
|
||||||
|
"offer.sent": "History",
|
||||||
|
"ats.scored": "Resume",
|
||||||
|
"document.uploaded": "History",
|
||||||
|
"candidate.created": "History",
|
||||||
|
"candidate.imported": "History",
|
||||||
|
"favorite.changed": "History",
|
||||||
|
"rating.changed": "History",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Confirmation:
|
class Confirmation:
|
||||||
def __init__(self,session:AsyncSession):
|
def __init__(self,session:AsyncSession):
|
||||||
|
|
@ -118,6 +183,322 @@ def _user_id(current_user):
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
|
|
||||||
|
def _humanize_event(event_type):
|
||||||
|
text = str(event_type or "").replace(".", " ").replace("_", " ").strip()
|
||||||
|
return text[:1].upper() + text[1:] if text else "Update"
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_link(user_id, tab="History"):
|
||||||
|
path = f"/candidate/{user_id}"
|
||||||
|
if tab:
|
||||||
|
return f"{path}?tab={tab}"
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _job_link(job_post_id, tab=None):
|
||||||
|
path = f"/jobs?job={job_post_id}"
|
||||||
|
if tab:
|
||||||
|
return f"{path}&tab={tab}"
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
async def system_admin_ids(session):
|
||||||
|
return await Users.ids_by_role_names(session, [EnumRoles.SYSTEM_ADMINISTRATOR.value])
|
||||||
|
|
||||||
|
|
||||||
|
async def job_recruiter_ids(session, job):
|
||||||
|
"""Recruiters currently linked to the job post.
|
||||||
|
|
||||||
|
Uses the live pointer (current_recruiter_id), the JSON list
|
||||||
|
(current_recruiter_ids), and open job_assignments rows with
|
||||||
|
assignment_role=primary_recruiter.
|
||||||
|
"""
|
||||||
|
ids = set()
|
||||||
|
if job is None:
|
||||||
|
return ids
|
||||||
|
uid = _as_uuid(getattr(job, "current_recruiter_id", None))
|
||||||
|
if uid is not None:
|
||||||
|
ids.add(uid)
|
||||||
|
for raw in getattr(job, "current_recruiter_ids", None) or []:
|
||||||
|
extra = _as_uuid(raw)
|
||||||
|
if extra is not None:
|
||||||
|
ids.add(extra)
|
||||||
|
from job.assignment.models import JobAssignments
|
||||||
|
rows = await JobAssignments.fetch_by_job(
|
||||||
|
session, job.id, current_only=True, assignment_role="primary_recruiter",
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
rid = _as_uuid(row.user_id)
|
||||||
|
if rid is not None:
|
||||||
|
ids.add(rid)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
async def job_stakeholder_ids(session, jobs, *, extra_ids=None, include_admins=True):
|
||||||
|
"""Recruiter, hiring manager, created_by, plus system admins.
|
||||||
|
|
||||||
|
`jobs` may be one row or an iterable. Extra ids cover people who just
|
||||||
|
left an assignment so they still see the history entry.
|
||||||
|
"""
|
||||||
|
rows = jobs if isinstance(jobs, (list, tuple, set)) else [jobs]
|
||||||
|
ids = set()
|
||||||
|
for job in rows:
|
||||||
|
if job is None:
|
||||||
|
continue
|
||||||
|
ids.update(await job_recruiter_ids(session, job))
|
||||||
|
for raw in (job.hiring_manager_id, job.created_by):
|
||||||
|
uid = _as_uuid(raw)
|
||||||
|
if uid is not None:
|
||||||
|
ids.add(uid)
|
||||||
|
for raw in extra_ids or []:
|
||||||
|
uid = _as_uuid(raw)
|
||||||
|
if uid is not None:
|
||||||
|
ids.add(uid)
|
||||||
|
if include_admins:
|
||||||
|
ids.update(await system_admin_ids(session))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_users(
|
||||||
|
session,
|
||||||
|
user_ids,
|
||||||
|
*,
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
body=None,
|
||||||
|
link_path=None,
|
||||||
|
inbox_id=None,
|
||||||
|
job_post_id=None,
|
||||||
|
exclude_ids=None,
|
||||||
|
commit=True,
|
||||||
|
):
|
||||||
|
"""Fan-out one in-app row per recipient. Failures never raise."""
|
||||||
|
try:
|
||||||
|
exclude = {_as_uuid(x) for x in (exclude_ids or [])}
|
||||||
|
exclude.discard(None)
|
||||||
|
seen = set()
|
||||||
|
payloads = []
|
||||||
|
for raw in user_ids or []:
|
||||||
|
uid = _as_uuid(raw)
|
||||||
|
if uid is None or uid in exclude or uid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(uid)
|
||||||
|
payloads.append({
|
||||||
|
"user_id": uid,
|
||||||
|
"kind": kind,
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"link_path": link_path,
|
||||||
|
"inbox_id": inbox_id,
|
||||||
|
"job_post_id": job_post_id,
|
||||||
|
})
|
||||||
|
if not payloads:
|
||||||
|
return []
|
||||||
|
return await Notifications.insert_many(session, payloads, commit=commit)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("notification insert skipped: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_job_stakeholders(
|
||||||
|
session,
|
||||||
|
job,
|
||||||
|
*,
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
body=None,
|
||||||
|
link_path=None,
|
||||||
|
extra_ids=None,
|
||||||
|
exclude_ids=None,
|
||||||
|
commit=True,
|
||||||
|
):
|
||||||
|
if job is None:
|
||||||
|
return []
|
||||||
|
recipients = await job_stakeholder_ids(session, job, extra_ids=extra_ids)
|
||||||
|
return await notify_users(
|
||||||
|
session,
|
||||||
|
recipients,
|
||||||
|
kind=kind,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
link_path=link_path or _job_link(job.id),
|
||||||
|
job_post_id=job.id,
|
||||||
|
exclude_ids=exclude_ids,
|
||||||
|
commit=commit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_job_created(session, job, *, actor_id=None):
|
||||||
|
"""New requisition: recruiter, hiring manager, created_by, system admins.
|
||||||
|
|
||||||
|
created_by is included even when they are the actor — that is who asked.
|
||||||
|
"""
|
||||||
|
if job is None:
|
||||||
|
return []
|
||||||
|
title = (job.title or "A job post").strip() or "A job post"
|
||||||
|
return await notify_job_stakeholders(
|
||||||
|
session,
|
||||||
|
job,
|
||||||
|
kind="approval",
|
||||||
|
title="New job post",
|
||||||
|
body=f"{title} was created",
|
||||||
|
link_path=_job_link(job.id),
|
||||||
|
exclude_ids=None,
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_job_status(session, job, *, from_status, to_status, actor_id=None):
|
||||||
|
"""Status history: assigned recruiter, created_by, and every system admin."""
|
||||||
|
if job is None:
|
||||||
|
return []
|
||||||
|
title = (job.title or "A job post").strip() or "A job post"
|
||||||
|
if from_status:
|
||||||
|
body = f"{title} moved from {from_status} to {to_status}"
|
||||||
|
heading = "Requisition closed" if to_status == "closed" else "Requisition updated"
|
||||||
|
else:
|
||||||
|
body = f"{title} is now {to_status}"
|
||||||
|
heading = "Requisition updated"
|
||||||
|
recipients = await job_recruiter_ids(session, job)
|
||||||
|
created = _as_uuid(job.created_by)
|
||||||
|
if created is not None:
|
||||||
|
recipients.add(created)
|
||||||
|
recipients.update(await system_admin_ids(session))
|
||||||
|
return await notify_users(
|
||||||
|
session,
|
||||||
|
recipients,
|
||||||
|
kind="approval",
|
||||||
|
title=heading,
|
||||||
|
body=body,
|
||||||
|
link_path=_job_link(job.id, tab="history"),
|
||||||
|
job_post_id=job.id,
|
||||||
|
exclude_ids=None,
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_job_assignment(session, job, *, role_label, actor_id=None, previous_ids=None):
|
||||||
|
if job is None:
|
||||||
|
return []
|
||||||
|
title = (job.title or "A job post").strip() or "A job post"
|
||||||
|
return await notify_job_stakeholders(
|
||||||
|
session,
|
||||||
|
job,
|
||||||
|
kind="approval",
|
||||||
|
title="Job assignment updated",
|
||||||
|
body=f"{title}: {role_label} changed",
|
||||||
|
link_path=_job_link(job.id, tab="history"),
|
||||||
|
extra_ids=previous_ids,
|
||||||
|
exclude_ids=[actor_id] if actor_id else None,
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _job_ids_for_candidate_event(session, *, user_id, inbox_id, manual_upload_candidate_id):
|
||||||
|
"""Resolve job posts for a history row without importing candidate views."""
|
||||||
|
from inbox.models import Inbox
|
||||||
|
from job.candidate.models import Manual_UPLOAD_CANDIDATE
|
||||||
|
|
||||||
|
ids = set()
|
||||||
|
if inbox_id is not None:
|
||||||
|
link = await Inbox.get_inbox_with_message(session, inbox_id)
|
||||||
|
msg = getattr(link, "messages", None) if link is not None else None
|
||||||
|
jid = getattr(msg, "assigned_job_post_id", None) if msg is not None else None
|
||||||
|
if jid:
|
||||||
|
ids.add(jid)
|
||||||
|
if manual_upload_candidate_id is not None:
|
||||||
|
manual = await Manual_UPLOAD_CANDIDATE.get_by_id(session, manual_upload_candidate_id)
|
||||||
|
if manual and manual.job_post_id:
|
||||||
|
ids.add(manual.job_post_id)
|
||||||
|
scoped = inbox_id is not None or manual_upload_candidate_id is not None
|
||||||
|
if ids or user_id is None or scoped:
|
||||||
|
return ids
|
||||||
|
rows = await Inbox.get_candidate_profile(session=session, user_id=user_id, limit=1000, offset=0)
|
||||||
|
records = rows if isinstance(rows, list) else ([rows] if rows else [])
|
||||||
|
for rec in records:
|
||||||
|
msg = getattr(rec, "messages", None)
|
||||||
|
jid = getattr(msg, "assigned_job_post_id", None) if msg is not None else None
|
||||||
|
if jid:
|
||||||
|
ids.add(jid)
|
||||||
|
manual = await Manual_UPLOAD_CANDIDATE.get_by_user_id(session, user_id)
|
||||||
|
if manual and manual.job_post_id:
|
||||||
|
ids.add(manual.job_post_id)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_candidate_history(
|
||||||
|
session,
|
||||||
|
row,
|
||||||
|
*,
|
||||||
|
inbox_id=None,
|
||||||
|
manual_upload_candidate_id=None,
|
||||||
|
commit=True,
|
||||||
|
):
|
||||||
|
"""One in-app row per job stakeholder for a candidate_history write."""
|
||||||
|
if row is None:
|
||||||
|
return []
|
||||||
|
if row.event_type in _SILENT_HISTORY_EVENTS:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
event_type = row.event_type
|
||||||
|
names = await Users.names_by_ids(session, [row.user_id])
|
||||||
|
candidate_name = names.get(str(row.user_id)) or "A candidate"
|
||||||
|
heading = _HISTORY_TITLE.get(event_type) or _humanize_event(event_type)
|
||||||
|
kind = _HISTORY_KIND.get(event_type, "system")
|
||||||
|
tab = _HISTORY_TAB.get(event_type, "History")
|
||||||
|
if row.description:
|
||||||
|
detail = row.description
|
||||||
|
elif row.from_value and row.to_value:
|
||||||
|
detail = f"{row.from_value} → {row.to_value}"
|
||||||
|
elif row.to_value:
|
||||||
|
detail = str(row.to_value)
|
||||||
|
else:
|
||||||
|
detail = heading
|
||||||
|
body = f"{candidate_name}: {detail}"
|
||||||
|
link_path = _candidate_link(row.user_id, tab=tab)
|
||||||
|
|
||||||
|
job_ids = await _job_ids_for_candidate_event(
|
||||||
|
session,
|
||||||
|
user_id=row.user_id,
|
||||||
|
inbox_id=inbox_id if inbox_id is not None else row.inbox_id,
|
||||||
|
manual_upload_candidate_id=(
|
||||||
|
manual_upload_candidate_id
|
||||||
|
if manual_upload_candidate_id is not None
|
||||||
|
else row.manual_upload_candidate_id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
jobs = []
|
||||||
|
if job_ids:
|
||||||
|
jobs = await JobPosts.get_by_ids(session, list(job_ids), active_only=False)
|
||||||
|
if jobs:
|
||||||
|
recipients = await job_stakeholder_ids(session, jobs, include_admins=True)
|
||||||
|
else:
|
||||||
|
recipients = set(await system_admin_ids(session))
|
||||||
|
|
||||||
|
exclude = [row.user_id]
|
||||||
|
if row.actor_id:
|
||||||
|
exclude.append(row.actor_id)
|
||||||
|
job_post_id = jobs[0].id if jobs else None
|
||||||
|
inbox_value = row.inbox_id if row.inbox_id is not None else inbox_id
|
||||||
|
return await notify_users(
|
||||||
|
session,
|
||||||
|
recipients,
|
||||||
|
kind=kind,
|
||||||
|
title=heading,
|
||||||
|
body=body,
|
||||||
|
link_path=link_path,
|
||||||
|
inbox_id=inbox_value,
|
||||||
|
job_post_id=job_post_id,
|
||||||
|
exclude_ids=exclude,
|
||||||
|
commit=commit,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("notification insert skipped: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
class Notification:
|
class Notification:
|
||||||
def __init__(self,session:AsyncSession):
|
def __init__(self,session:AsyncSession):
|
||||||
self.session=session
|
self.session=session
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,12 @@ router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class OfferCreate(BaseModel):
|
class OfferCreate(BaseModel):
|
||||||
inbox_id: int
|
offer_id: str | None = None
|
||||||
|
inbox_id: int | None = None
|
||||||
|
manual_upload_candidate_id: str | None = None
|
||||||
|
form_data_id: str | None = None
|
||||||
job_post_id: str
|
job_post_id: str
|
||||||
candidate_user_id: str
|
candidate_user_id: str | None = None
|
||||||
status: str | None = "draft"
|
status: str | None = "draft"
|
||||||
base_salary: float | None = None
|
base_salary: float | None = None
|
||||||
currency: str | None = None
|
currency: str | None = None
|
||||||
|
|
@ -69,19 +72,39 @@ class OfferIssue(BaseModel):
|
||||||
change_reason: str | None = None
|
change_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OfferSent(BaseModel):
|
||||||
|
offer_id: str | None = None
|
||||||
|
inbox_id: int | None = None
|
||||||
|
manual_upload_candidate_id: str | None = None
|
||||||
|
form_data_id: str | None = None
|
||||||
|
job_post_id: str | None = None
|
||||||
|
candidate_user_id: str | None = None
|
||||||
|
base_salary: float | None = None
|
||||||
|
currency: str | None = "USD"
|
||||||
|
salary_period: str | None = "year"
|
||||||
|
signing_bonus: float | None = None
|
||||||
|
annual_bonus_pct: float | None = None
|
||||||
|
equity_units: int | None = None
|
||||||
|
equity_instrument: str | None = None
|
||||||
|
start_date: datetime | None = None
|
||||||
|
expiry_date: datetime | None = None
|
||||||
|
change_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/offers/fetch")
|
@router.get("/offers/fetch")
|
||||||
async def fetch_offers(
|
async def fetch_offers(
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.OFFERS_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.OFFERS_VIEW)),
|
||||||
offer_id: str | None = Query(None),
|
offer_id: str | None = Query(None),
|
||||||
status: str | None = Query(None),
|
status: str | None = Query(None),
|
||||||
inbox_id: int | None = Query(None),
|
inbox_id: int | None = Query(None),
|
||||||
|
job_post_id: str | None = Query(None),
|
||||||
top: int | None = Query(None),
|
top: int | None = Query(None),
|
||||||
skip: int = Query(0,ge=0),
|
skip: int = Query(0,ge=0),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Offer(session=session)
|
service=Offer(session=session)
|
||||||
data,total=await service.get_offers(offer_id,status,inbox_id,top,skip)
|
data,total=await service.get_offers(offer_id,status,inbox_id,job_post_id,top,skip)
|
||||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -137,3 +160,37 @@ async def issue_offer(
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/offers/jobs/candidates/lists")
|
||||||
|
async def list_offer_candidates(
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.OFFERS_VIEW)),
|
||||||
|
search: str | None = Query(None),
|
||||||
|
top: int | None = Query(None),
|
||||||
|
skip: int = Query(0,ge=0),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=Offer(session=session)
|
||||||
|
data,total=await service.list_candidates(current_user,search,top,skip)
|
||||||
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/offers/jobs/sent")
|
||||||
|
async def send_job_offer(
|
||||||
|
payload: OfferSent,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.OFFERS_CREATE)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=Offer(session=session)
|
||||||
|
data=await service.send_offer(payload.model_dump(exclude_unset=True),current_user)
|
||||||
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,11 @@ class Offers(SQLModel, table=True):
|
||||||
__tablename__ = "offers"
|
__tablename__ = "offers"
|
||||||
|
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
inbox_id: int = Field(index=True, foreign_key="inbox.id")
|
inbox_id: int | None = Field(default=None, index=True, foreign_key="inbox.id")
|
||||||
|
manual_upload_candidate_id: uuid.UUID | None = Field(
|
||||||
|
default=None, index=True, foreign_key="manual_upload_candidate.id",
|
||||||
|
)
|
||||||
|
form_data_id: uuid.UUID | None = Field(default=None, index=True, foreign_key="form_data.id")
|
||||||
job_post_id: uuid.UUID = Field(foreign_key="job_posts.id")
|
job_post_id: uuid.UUID = Field(foreign_key="job_posts.id")
|
||||||
candidate_user_id: uuid.UUID = Field(foreign_key="users.id")
|
candidate_user_id: uuid.UUID = Field(foreign_key="users.id")
|
||||||
status: str = Field(default="draft")
|
status: str = Field(default="draft")
|
||||||
|
|
@ -67,6 +71,7 @@ class Offers(SQLModel, table=True):
|
||||||
*,
|
*,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
inbox_id: int | None = None,
|
inbox_id: int | None = None,
|
||||||
|
job_post_id=None,
|
||||||
top: int | None = None,
|
top: int | None = None,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
):
|
):
|
||||||
|
|
@ -75,6 +80,9 @@ class Offers(SQLModel, table=True):
|
||||||
statement = statement.where(cls.status == status)
|
statement = statement.where(cls.status == status)
|
||||||
if inbox_id is not None:
|
if inbox_id is not None:
|
||||||
statement = statement.where(cls.inbox_id == int(inbox_id))
|
statement = statement.where(cls.inbox_id == int(inbox_id))
|
||||||
|
jid = cls._as_uuid(job_post_id) if job_post_id is not None else None
|
||||||
|
if jid is not None:
|
||||||
|
statement = statement.where(cls.job_post_id == jid)
|
||||||
count_statement = select(func.count()).select_from(statement.subquery())
|
count_statement = select(func.count()).select_from(statement.subquery())
|
||||||
total = (await session.execute(count_statement)).scalar_one()
|
total = (await session.execute(count_statement)).scalar_one()
|
||||||
statement = statement.order_by(cls.created_at.desc())
|
statement = statement.order_by(cls.created_at.desc())
|
||||||
|
|
@ -125,7 +133,7 @@ class Offers(SQLModel, table=True):
|
||||||
statement = statement.where(JobPosts.department == department)
|
statement = statement.where(JobPosts.department == department)
|
||||||
rid = cls._as_uuid(recruiter_id)
|
rid = cls._as_uuid(recruiter_id)
|
||||||
if rid is not None:
|
if rid is not None:
|
||||||
statement = statement.where(JobPosts.current_recruiter_id == rid)
|
statement = statement.where(JobPosts.has_recruiter(rid))
|
||||||
return statement
|
return statement
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -147,6 +155,48 @@ class Offers(SQLModel, table=True):
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return int(result.scalar_one() or 0)
|
return int(result.scalar_one() or 0)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_open_for_candidate_job(cls, session: AsyncSession, candidate_user_id, job_post_id):
|
||||||
|
uid = cls._as_uuid(candidate_user_id)
|
||||||
|
jid = cls._as_uuid(job_post_id)
|
||||||
|
if uid is None or jid is None:
|
||||||
|
return None
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(cls.candidate_user_id == uid, cls.job_post_id == jid)
|
||||||
|
.where(cls.status.in_(("sent", "negotiating")))
|
||||||
|
.order_by(cls.created_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_failed_for_candidate_job(cls, session: AsyncSession, candidate_user_id, job_post_id):
|
||||||
|
uid = cls._as_uuid(candidate_user_id)
|
||||||
|
jid = cls._as_uuid(job_post_id)
|
||||||
|
if uid is None or jid is None:
|
||||||
|
return None
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(cls.candidate_user_id == uid, cls.job_post_id == jid)
|
||||||
|
.where(cls.status == "failed")
|
||||||
|
.order_by(cls.created_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_draft_or_failed_for_candidate_job(cls, session: AsyncSession, candidate_user_id, job_post_id):
|
||||||
|
uid = cls._as_uuid(candidate_user_id)
|
||||||
|
jid = cls._as_uuid(job_post_id)
|
||||||
|
if uid is None or jid is None:
|
||||||
|
return None
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(cls.candidate_user_id == uid, cls.job_post_id == jid)
|
||||||
|
.where(cls.status.in_(("draft", "failed")))
|
||||||
|
.order_by(cls.created_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
class OfferStatusHistory(SQLModel, table=True):
|
class OfferStatusHistory(SQLModel, table=True):
|
||||||
__tablename__ = "offer_status_history"
|
__tablename__ = "offer_status_history"
|
||||||
|
|
@ -190,3 +240,5 @@ class OfferStatusHistory(SQLModel, table=True):
|
||||||
return row
|
return row
|
||||||
|
|
||||||
import users.models as _users_models # noqa: E402, F401
|
import users.models as _users_models # noqa: E402, F401
|
||||||
|
import job.candidate.models as _manual_models # noqa: E402, F401
|
||||||
|
import g_sheet.models as _form_models # noqa: E402, F401
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,145 @@
|
||||||
|
"""Offer helpers — compensation field list, offer-email template, Teams mail send.
|
||||||
|
|
||||||
|
Pure module: no FastAPI imports and no HTTPException.
|
||||||
|
|
||||||
|
`send_offer_mail` intentionally duplicates
|
||||||
|
`notifications.plugins.send_confirmation_mail` rather than importing it: that
|
||||||
|
helper is domain-named, and each domain owns its own mail copy and env reads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from candidate_forms.plugins import FORM_READY_STATUSES
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
TEAMS_MAIL_API_URL = os.getenv("TEAMS_MAIL_API_URL")
|
||||||
|
TEAMS_API_TOKEN = os.getenv("TEAMS_API_TOKEN")
|
||||||
|
MAIL_ACCEPTED_STATUSES = {200, 202}
|
||||||
|
|
||||||
|
INTERVIEW_PLUS = FORM_READY_STATUSES
|
||||||
|
OFFER_SUBJECT = "Offer from UtopiaBrands Recruitement team"
|
||||||
|
|
||||||
|
|
||||||
def non_validation_values():
|
def non_validation_values():
|
||||||
fields=("base_salary","currency","salary_period","signing_bonus","annual_bonus_pct",
|
fields=("base_salary","currency","salary_period","signing_bonus","annual_bonus_pct",
|
||||||
"equity_units","equity_instrument","start_date","expiry_date",
|
"equity_units","equity_instrument","start_date","expiry_date",
|
||||||
"cadre","gross_salary_in_words","subsidized_services","probation_period",
|
"cadre","gross_salary_in_words","subsidized_services","probation_period",
|
||||||
"notice_period","work_location","work_timings")
|
"notice_period","work_location","work_timings")
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def email_key(value) -> str:
|
||||||
|
return (value or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def stage_value(value) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return (value.value if hasattr(value,"value") else str(value)).strip().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def is_interview_plus(value) -> bool:
|
||||||
|
return stage_value(value) in INTERVIEW_PLUS
|
||||||
|
|
||||||
|
|
||||||
|
def parse_offer_datetime(value):
|
||||||
|
if value in (None,""):
|
||||||
|
return None
|
||||||
|
if isinstance(value,datetime):
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value
|
||||||
|
text=str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text.endswith("Z"):
|
||||||
|
text=text[:-1]+"+00:00"
|
||||||
|
try:
|
||||||
|
parsed=datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _period_label(period) -> str:
|
||||||
|
raw=(period or "year").strip().lower()
|
||||||
|
if raw in ("annual","year"):
|
||||||
|
return "per year"
|
||||||
|
if raw=="month":
|
||||||
|
return "per month"
|
||||||
|
if raw=="hour":
|
||||||
|
return "per hour"
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _money_line(amount, currency) -> str:
|
||||||
|
cur=(currency or "USD").strip() or "USD"
|
||||||
|
try:
|
||||||
|
n=float(amount)
|
||||||
|
pretty=f"{n:,.0f}" if n==int(n) else f"{n:,.2f}"
|
||||||
|
except (TypeError,ValueError):
|
||||||
|
pretty=str(amount)
|
||||||
|
return f"{cur} {pretty}"
|
||||||
|
|
||||||
|
|
||||||
|
def render_offer_email(
|
||||||
|
candidate_name,
|
||||||
|
base_salary,
|
||||||
|
currency="USD",
|
||||||
|
salary_period="year",
|
||||||
|
*,
|
||||||
|
annual_bonus_pct=None,
|
||||||
|
signing_bonus=None,
|
||||||
|
equity_units=None,
|
||||||
|
equity_instrument=None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
name=(candidate_name or "").strip() or "Candidate"
|
||||||
|
salary=_money_line(base_salary,currency)
|
||||||
|
period=_period_label(salary_period)
|
||||||
|
html=(
|
||||||
|
f"<p>Dear {name},</p>"
|
||||||
|
"<p>We are pleased to extend an offer of employment from UtopiaBrands.</p>"
|
||||||
|
f"<p>Base salary: <strong>{salary} {period}</strong>.</p>"
|
||||||
|
)
|
||||||
|
if annual_bonus_pct not in (None,""):
|
||||||
|
html+=f"<p>Annual bonus: <strong>{annual_bonus_pct}%</strong>.</p>"
|
||||||
|
if signing_bonus not in (None,""):
|
||||||
|
html+=f"<p>Signing bonus: <strong>{_money_line(signing_bonus,currency)}</strong>.</p>"
|
||||||
|
if equity_units not in (None,"",0):
|
||||||
|
instrument=(equity_instrument or "RSU").strip() or "RSU"
|
||||||
|
html+=f"<p>Equity: <strong>{equity_units} {instrument}</strong>.</p>"
|
||||||
|
html+="<p>Please reply to this email if you have questions about the offer.</p>"
|
||||||
|
return OFFER_SUBJECT,html
|
||||||
|
|
||||||
|
|
||||||
|
async def send_offer_mail(to_email: str, subject: str, html: str) -> None:
|
||||||
|
if not TEAMS_MAIL_API_URL or not TEAMS_API_TOKEN:
|
||||||
|
raise RuntimeError("TEAMS_MAIL_API_URL and TEAMS_API_TOKEN must be set")
|
||||||
|
fields = [
|
||||||
|
("subject", (None, subject)),
|
||||||
|
("body", (None, html)),
|
||||||
|
("content_type", (None, "html")),
|
||||||
|
("save_to_sent_items", (None, "false")),
|
||||||
|
("to", (None, to_email)),
|
||||||
|
]
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
TEAMS_MAIL_API_URL,
|
||||||
|
files=fields,
|
||||||
|
headers={"Authorization": f"Bearer {TEAMS_API_TOKEN}"},
|
||||||
|
)
|
||||||
|
if response.status_code not in MAIL_ACCEPTED_STATUSES:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
response.text,
|
||||||
|
request=response.request,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
def serialize_offer(row) -> dict:
|
def serialize_offer(row, candidate_name=None, created_by_name=None) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(row.id) if row.id else None,
|
"id": str(row.id) if row.id else None,
|
||||||
"inbox_id": row.inbox_id,
|
"inbox_id": row.inbox_id,
|
||||||
|
"manual_upload_candidate_id": (
|
||||||
|
str(row.manual_upload_candidate_id) if row.manual_upload_candidate_id else None
|
||||||
|
),
|
||||||
|
"form_data_id": str(row.form_data_id) if row.form_data_id else None,
|
||||||
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
|
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
|
||||||
"candidate_user_id": str(row.candidate_user_id) if row.candidate_user_id else None,
|
"candidate_user_id": str(row.candidate_user_id) if row.candidate_user_id else None,
|
||||||
|
"candidate_name": candidate_name,
|
||||||
"status": row.status,
|
"status": row.status,
|
||||||
"base_salary": row.base_salary,
|
"base_salary": row.base_salary,
|
||||||
"currency": row.currency,
|
"currency": row.currency,
|
||||||
|
|
@ -26,6 +31,7 @@ def serialize_offer(row) -> dict:
|
||||||
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
|
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
|
||||||
"issued_by": str(row.issued_by) if row.issued_by else None,
|
"issued_by": str(row.issued_by) if row.issued_by else None,
|
||||||
"created_by": str(row.created_by) if row.created_by else None,
|
"created_by": str(row.created_by) if row.created_by else None,
|
||||||
|
"created_by_name": created_by_name,
|
||||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
@ -43,3 +49,23 @@ def serialize_offer_history(row) -> dict:
|
||||||
"actor_kind": row.actor_kind,
|
"actor_kind": row.actor_kind,
|
||||||
"change_reason": row.change_reason,
|
"change_reason": row.change_reason,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_offer_candidate(item) -> dict:
|
||||||
|
manual=item.get("manual_upload_candidate_id")
|
||||||
|
form_id=item.get("form_data_id")
|
||||||
|
user_id=item.get("user_id")
|
||||||
|
job_id=item.get("job_post_id")
|
||||||
|
return {
|
||||||
|
"inbox_id": item.get("inbox_id"),
|
||||||
|
"manual_upload_candidate_id": str(manual) if manual else None,
|
||||||
|
"form_data_id": str(form_id) if form_id else None,
|
||||||
|
"source": item.get("source"),
|
||||||
|
"user_id": str(user_id) if user_id else None,
|
||||||
|
"name": item.get("name"),
|
||||||
|
"email": item.get("email"),
|
||||||
|
"job_post_id": str(job_id) if job_id else None,
|
||||||
|
"job_title": item.get("job_title"),
|
||||||
|
"application_status": item.get("application_status"),
|
||||||
|
"stage": item.get("stage") or item.get("application_status"),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,37 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime,timezone
|
from datetime import datetime,timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.models import Offers,OfferStatusHistory
|
||||||
from offer.serializers import serialize_offer
|
from offer.plugins import (
|
||||||
from offer.plugins import non_validation_values
|
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):
|
def _as_uuid(value):
|
||||||
if value in (None,""):
|
if value in (None,""):
|
||||||
|
|
@ -26,59 +51,243 @@ def _user_id(current_user):
|
||||||
return uid
|
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:
|
class Offer:
|
||||||
def __init__(self,session:AsyncSession):
|
def __init__(self,session:AsyncSession):
|
||||||
self.session=session
|
self.session=session
|
||||||
|
|
||||||
async def get_offers(self,offer_id=None,status=None,inbox_id=None,top=None,skip=0):
|
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:
|
if offer_id is not None:
|
||||||
row=await Offers.get_offer_by_id(self.session,offer_id)
|
row=await Offers.get_offer_by_id(self.session,offer_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404,detail="Offer not found")
|
raise HTTPException(status_code=404,detail="Offer not found")
|
||||||
return serialize_offer(row),1
|
return (await self._hydrate_offers([row]))[0],1
|
||||||
rows,total=await Offers.fetch_offers(
|
rows,total=await Offers.fetch_offers(
|
||||||
self.session,
|
self.session,
|
||||||
status=status,
|
status=status,
|
||||||
inbox_id=inbox_id,
|
inbox_id=inbox_id,
|
||||||
|
job_post_id=job_post_id,
|
||||||
top=top,
|
top=top,
|
||||||
skip=skip or 0,
|
skip=skip or 0,
|
||||||
)
|
)
|
||||||
return [serialize_offer(r) for r in rows],total
|
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)<SOURCE_RANK.get(existing.get("source"),9):
|
||||||
|
if not item.get("form_data_id"):
|
||||||
|
item["form_data_id"]=existing.get("form_data_id")
|
||||||
|
if not item.get("name"):
|
||||||
|
item["name"]=existing.get("name")
|
||||||
|
merged[key]=item
|
||||||
|
return
|
||||||
|
if not existing.get("form_data_id"):
|
||||||
|
existing["form_data_id"]=item.get("form_data_id")
|
||||||
|
if not existing.get("name"):
|
||||||
|
existing["name"]=item.get("name")
|
||||||
|
|
||||||
async def create_offer(self,payload,current_user):
|
async def create_offer(self,payload,current_user):
|
||||||
if not payload.get("inbox_id"):
|
|
||||||
raise HTTPException(status_code=422,detail="inbox_id is required")
|
|
||||||
job_post_id=_as_uuid(payload.get("job_post_id"))
|
|
||||||
if job_post_id is None:
|
|
||||||
raise HTTPException(status_code=422,detail="job_post_id is required")
|
|
||||||
candidate_user_id=_as_uuid(payload.get("candidate_user_id"))
|
|
||||||
if candidate_user_id is None:
|
|
||||||
raise HTTPException(status_code=422,detail="candidate_user_id is required")
|
|
||||||
created_by=_user_id(current_user)
|
created_by=_user_id(current_user)
|
||||||
status=payload.get("status") or "draft"
|
app=await self._resolve_application(payload)
|
||||||
|
await self._assert_offer_job(current_user,app["job_post_id"])
|
||||||
|
candidate_user_id=app["candidate_user_id"]
|
||||||
|
job_post_id=app["job_post_id"]
|
||||||
|
open_row=await Offers.get_open_for_candidate_job(self.session,candidate_user_id,job_post_id)
|
||||||
|
retry_id=_as_uuid(payload.get("offer_id"))
|
||||||
|
if open_row and (retry_id is None or open_row.id!=retry_id):
|
||||||
|
raise HTTPException(status_code=409,detail="An offer is already in progress for this candidate and job")
|
||||||
|
|
||||||
fields={
|
fields=_comp_fields(payload)
|
||||||
"inbox_id": int(payload["inbox_id"]),
|
fields.update({
|
||||||
|
"inbox_id": app.get("inbox_id"),
|
||||||
|
"manual_upload_candidate_id": app.get("manual_upload_id"),
|
||||||
|
"form_data_id": app.get("form_data_id"),
|
||||||
"job_post_id": job_post_id,
|
"job_post_id": job_post_id,
|
||||||
"candidate_user_id": candidate_user_id,
|
"candidate_user_id": candidate_user_id,
|
||||||
"created_by": created_by,
|
"status": "draft",
|
||||||
"status": status,
|
})
|
||||||
}
|
if fields.get("equity_units") in (None,0):
|
||||||
for key in non_validation_values():
|
fields["equity_instrument"]=None
|
||||||
if key in payload and payload[key] is not None:
|
|
||||||
fields[key]=payload[key]
|
|
||||||
|
|
||||||
|
if retry_id is not None:
|
||||||
|
row=await Offers.get_offer_by_id(self.session,retry_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404,detail="Offer not found")
|
||||||
|
if row.status in ("sent","negotiating"):
|
||||||
|
raise HTTPException(status_code=409,detail="This offer has already been sent")
|
||||||
|
from_status=row.status
|
||||||
|
row=await Offers.update_offer(self.session,retry_id,fields)
|
||||||
|
if from_status!="draft":
|
||||||
|
await OfferStatusHistory.insert_history(self.session,{
|
||||||
|
"offer_id": row.id,
|
||||||
|
"from_status": from_status,
|
||||||
|
"to_status": "draft",
|
||||||
|
"changed_by": created_by,
|
||||||
|
"actor_kind": "user",
|
||||||
|
"change_reason": payload.get("change_reason") or "saved",
|
||||||
|
})
|
||||||
|
return (await self._hydrate_offers([row]))[0]
|
||||||
|
|
||||||
|
existing=await Offers.get_draft_or_failed_for_candidate_job(
|
||||||
|
self.session,candidate_user_id,job_post_id,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
from_status=existing.status
|
||||||
|
row=await Offers.update_offer(self.session,existing.id,fields)
|
||||||
|
if from_status!="draft":
|
||||||
|
await OfferStatusHistory.insert_history(self.session,{
|
||||||
|
"offer_id": row.id,
|
||||||
|
"from_status": from_status,
|
||||||
|
"to_status": "draft",
|
||||||
|
"changed_by": created_by,
|
||||||
|
"actor_kind": "user",
|
||||||
|
"change_reason": payload.get("change_reason") or "saved",
|
||||||
|
})
|
||||||
|
return (await self._hydrate_offers([row]))[0]
|
||||||
|
|
||||||
|
fields["created_by"]=created_by
|
||||||
row=await Offers.insert_offer(self.session,fields)
|
row=await Offers.insert_offer(self.session,fields)
|
||||||
history_data={
|
await OfferStatusHistory.insert_history(self.session,{
|
||||||
"offer_id": row.id,
|
"offer_id": row.id,
|
||||||
"from_status": None,
|
"from_status": None,
|
||||||
"to_status": status,
|
"to_status": "draft",
|
||||||
"changed_by": created_by,
|
"changed_by": created_by,
|
||||||
"actor_kind": "user",
|
"actor_kind": "user",
|
||||||
"change_reason": payload.get("change_reason"),
|
"change_reason": payload.get("change_reason"),
|
||||||
}
|
})
|
||||||
await OfferStatusHistory.insert_history(self.session,history_data)
|
return (await self._hydrate_offers([row]))[0]
|
||||||
return serialize_offer(row)
|
|
||||||
|
|
||||||
async def update_offer(self,offer_id,payload,current_user):
|
async def update_offer(self,offer_id,payload,current_user):
|
||||||
row=await Offers.get_offer_by_id(self.session,offer_id)
|
row=await Offers.get_offer_by_id(self.session,offer_id)
|
||||||
|
|
@ -146,3 +355,279 @@ class Offer:
|
||||||
}
|
}
|
||||||
await OfferStatusHistory.insert_history(self.session,history_data)
|
await OfferStatusHistory.insert_history(self.session,history_data)
|
||||||
return serialize_offer(updated)
|
return serialize_offer(updated)
|
||||||
|
|
||||||
|
async def send_offer(self,payload,current_user):
|
||||||
|
created_by=_user_id(current_user)
|
||||||
|
payload=await self._payload_for_send(payload)
|
||||||
|
app=await self._resolve_application(payload)
|
||||||
|
await self._assert_offer_job(current_user,app["job_post_id"])
|
||||||
|
candidate_user_id=app["candidate_user_id"]
|
||||||
|
job_post_id=app["job_post_id"]
|
||||||
|
if payload.get("base_salary") in (None,""):
|
||||||
|
raise HTTPException(status_code=422,detail="base_salary is required")
|
||||||
|
|
||||||
|
open_row=await Offers.get_open_for_candidate_job(self.session,candidate_user_id,job_post_id)
|
||||||
|
retry_id=_as_uuid(payload.get("offer_id"))
|
||||||
|
resend=False
|
||||||
|
if open_row and retry_id is not None and open_row.id==retry_id:
|
||||||
|
resend=open_row.status in ("sent","negotiating")
|
||||||
|
elif open_row and (retry_id is None or open_row.id!=retry_id):
|
||||||
|
raise HTTPException(status_code=409,detail="An offer is already in progress for this candidate and job")
|
||||||
|
|
||||||
|
fields=_comp_fields(payload)
|
||||||
|
fields.update({
|
||||||
|
"inbox_id": app.get("inbox_id"),
|
||||||
|
"manual_upload_candidate_id": app.get("manual_upload_id"),
|
||||||
|
"form_data_id": app.get("form_data_id"),
|
||||||
|
"job_post_id": job_post_id,
|
||||||
|
"candidate_user_id": candidate_user_id,
|
||||||
|
})
|
||||||
|
if not resend:
|
||||||
|
fields["status"]="draft"
|
||||||
|
if fields.get("equity_units") in (None,0):
|
||||||
|
fields["equity_instrument"]=None
|
||||||
|
|
||||||
|
row=None
|
||||||
|
if retry_id is not None:
|
||||||
|
row=await Offers.get_offer_by_id(self.session,retry_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404,detail="Offer not found")
|
||||||
|
if row.status in ("accepted","declined"):
|
||||||
|
raise HTTPException(status_code=409,detail="This offer is already closed")
|
||||||
|
row=await Offers.update_offer(self.session,retry_id,fields)
|
||||||
|
else:
|
||||||
|
existing=await Offers.get_draft_or_failed_for_candidate_job(
|
||||||
|
self.session,candidate_user_id,job_post_id,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
row=await Offers.update_offer(self.session,existing.id,fields)
|
||||||
|
else:
|
||||||
|
fields["created_by"]=created_by
|
||||||
|
fields["status"]="draft"
|
||||||
|
row=await Offers.insert_offer(self.session,fields)
|
||||||
|
await OfferStatusHistory.insert_history(self.session,{
|
||||||
|
"offer_id": row.id,
|
||||||
|
"from_status": None,
|
||||||
|
"to_status": "draft",
|
||||||
|
"changed_by": created_by,
|
||||||
|
"actor_kind": "user",
|
||||||
|
"change_reason": payload.get("change_reason"),
|
||||||
|
})
|
||||||
|
|
||||||
|
name,email,job_title=await self._offer_mail_context(row,app)
|
||||||
|
subject,html=render_offer_email(
|
||||||
|
name,row.base_salary,row.currency,row.salary_period,
|
||||||
|
annual_bonus_pct=row.annual_bonus_pct,
|
||||||
|
signing_bonus=row.signing_bonus,
|
||||||
|
equity_units=row.equity_units,
|
||||||
|
equity_instrument=row.equity_instrument,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if not email:
|
||||||
|
raise RuntimeError("candidate has no email")
|
||||||
|
await send_offer_mail(email,subject,html)
|
||||||
|
except (httpx.HTTPError,RuntimeError) as e:
|
||||||
|
if row.status not in ("sent","negotiating"):
|
||||||
|
await Offers.update_offer(self.session,row.id,{"status":"failed"})
|
||||||
|
row=await Offers.get_offer_by_id(self.session,row.id)
|
||||||
|
await self._notify_send_failed(created_by,name,job_title,row)
|
||||||
|
raise HTTPException(status_code=502,detail=MAIL_FAIL_DETAIL) from e
|
||||||
|
|
||||||
|
now=datetime.now(timezone.utc)
|
||||||
|
from_status=row.status
|
||||||
|
updated=await Offers.update_offer(self.session,row.id,{
|
||||||
|
"status": "sent",
|
||||||
|
"sent_at": now,
|
||||||
|
"issued_by": created_by,
|
||||||
|
})
|
||||||
|
await OfferStatusHistory.insert_history(self.session,{
|
||||||
|
"offer_id": updated.id,
|
||||||
|
"from_status": from_status,
|
||||||
|
"to_status": "sent",
|
||||||
|
"changed_by": created_by,
|
||||||
|
"actor_kind": "user",
|
||||||
|
"change_reason": "sent",
|
||||||
|
})
|
||||||
|
await self._advance_pipeline(app,current_user)
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.OFFER_SENT.value,
|
||||||
|
current_user=current_user,
|
||||||
|
user_id=candidate_user_id,
|
||||||
|
inbox_id=app.get("inbox_id"),
|
||||||
|
manual_upload_candidate_id=app.get("manual_upload_id"),
|
||||||
|
entity_type="offer",
|
||||||
|
entity_id=updated.id,
|
||||||
|
to_value="OFFER",
|
||||||
|
description=f"Offer sent to {name}",
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
return (await self._hydrate_offers([updated]))[0]
|
||||||
|
|
||||||
|
async def _payload_for_send(self,payload):
|
||||||
|
data=dict(payload or {})
|
||||||
|
offer_id=_as_uuid(data.get("offer_id"))
|
||||||
|
if offer_id is None:
|
||||||
|
return data
|
||||||
|
row=await Offers.get_offer_by_id(self.session,offer_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404,detail="Offer not found")
|
||||||
|
if data.get("inbox_id") in (None,"") and not data.get("manual_upload_candidate_id") and not data.get("form_data_id"):
|
||||||
|
if row.inbox_id is not None:
|
||||||
|
data["inbox_id"]=row.inbox_id
|
||||||
|
elif row.manual_upload_candidate_id is not None:
|
||||||
|
data["manual_upload_candidate_id"]=str(row.manual_upload_candidate_id)
|
||||||
|
elif row.form_data_id is not None:
|
||||||
|
data["form_data_id"]=str(row.form_data_id)
|
||||||
|
if data.get("job_post_id") in (None,""):
|
||||||
|
data["job_post_id"]=str(row.job_post_id) if row.job_post_id else None
|
||||||
|
if data.get("candidate_user_id") in (None,""):
|
||||||
|
data["candidate_user_id"]=str(row.candidate_user_id) if row.candidate_user_id else None
|
||||||
|
if data.get("base_salary") in (None,""):
|
||||||
|
data["base_salary"]=row.base_salary
|
||||||
|
for key in ("currency","salary_period","signing_bonus","annual_bonus_pct",
|
||||||
|
"equity_units","equity_instrument","start_date","expiry_date"):
|
||||||
|
if key not in data or data.get(key) in (None,""):
|
||||||
|
data[key]=getattr(row,key)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def _resolve_application(self,payload):
|
||||||
|
inbox_id=payload.get("inbox_id")
|
||||||
|
manual_id=_as_uuid(payload.get("manual_upload_candidate_id"))
|
||||||
|
form_id=_as_uuid(payload.get("form_data_id"))
|
||||||
|
requested_job=_as_uuid(payload.get("job_post_id"))
|
||||||
|
present=sum(1 for v in (inbox_id not in (None,""), manual_id is not None, form_id is not None) if v)
|
||||||
|
if present!=1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="Exactly one of inbox_id, manual_upload_candidate_id or form_data_id is required",
|
||||||
|
)
|
||||||
|
if inbox_id not in (None,""):
|
||||||
|
return await self._resolve_inbox(inbox_id,requested_job)
|
||||||
|
if form_id is not None:
|
||||||
|
return await self._resolve_form(form_id,requested_job)
|
||||||
|
return await self._resolve_manual(manual_id,requested_job)
|
||||||
|
|
||||||
|
async def _resolve_inbox(self,inbox_id,requested_job):
|
||||||
|
inbox=await Inbox.get_inbox_with_message(self.session,inbox_id)
|
||||||
|
if not inbox or not inbox.messages:
|
||||||
|
raise HTTPException(status_code=404,detail="Inbox not found")
|
||||||
|
message=inbox.messages
|
||||||
|
job_id=message.assigned_job_post_id
|
||||||
|
if job_id is None:
|
||||||
|
raise HTTPException(status_code=422,detail="Candidate is not assigned to a job")
|
||||||
|
if requested_job is not None and job_id!=requested_job:
|
||||||
|
raise HTTPException(status_code=422,detail="job_post_id does not match the application")
|
||||||
|
if not is_interview_plus(message.application_status):
|
||||||
|
raise HTTPException(status_code=422,detail="Candidate is not at interview stage or later")
|
||||||
|
if not inbox.user_id:
|
||||||
|
raise HTTPException(status_code=422,detail="candidate_user_id is required")
|
||||||
|
return {
|
||||||
|
"inbox_id": inbox.id,
|
||||||
|
"manual_upload_id": None,
|
||||||
|
"form_data_id": None,
|
||||||
|
"job_post_id": job_id,
|
||||||
|
"candidate_user_id": inbox.user_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _resolve_manual(self,manual_id,requested_job,require_interview=True):
|
||||||
|
row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,manual_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404,detail="Manual upload candidate not found")
|
||||||
|
if not row.job_post_id:
|
||||||
|
raise HTTPException(status_code=422,detail="Candidate is not assigned to a job")
|
||||||
|
if requested_job is not None and row.job_post_id!=requested_job:
|
||||||
|
raise HTTPException(status_code=422,detail="job_post_id does not match the application")
|
||||||
|
if require_interview and not is_interview_plus(row.status):
|
||||||
|
raise HTTPException(status_code=422,detail="Candidate is not at interview stage or later")
|
||||||
|
if not row.user_id:
|
||||||
|
raise HTTPException(status_code=422,detail="candidate_user_id is required")
|
||||||
|
form_ids=await FormData.form_ids_by_manual_ids(self.session,[row.id])
|
||||||
|
return {
|
||||||
|
"inbox_id": None,
|
||||||
|
"manual_upload_id": row.id,
|
||||||
|
"form_data_id": _as_uuid(form_ids.get(str(row.id))),
|
||||||
|
"job_post_id": row.job_post_id,
|
||||||
|
"candidate_user_id": row.user_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _resolve_form(self,form_id,requested_job):
|
||||||
|
form_row=await FormData.get_form_data_by_id(self.session,form_id)
|
||||||
|
if not form_row:
|
||||||
|
raise HTTPException(status_code=404,detail="Form applicant not found")
|
||||||
|
job_id=form_row.assigned_job_post_id or form_row.job_post_id
|
||||||
|
if job_id is None:
|
||||||
|
raise HTTPException(status_code=422,detail="Candidate is not assigned to a job")
|
||||||
|
if requested_job is not None and job_id!=requested_job:
|
||||||
|
raise HTTPException(status_code=422,detail="job_post_id does not match the application")
|
||||||
|
if form_row.is_duplicate:
|
||||||
|
raise HTTPException(status_code=422,detail="Duplicate form applicants cannot receive an offer")
|
||||||
|
if (form_row.processing_state or "").strip().lower()=="rejected":
|
||||||
|
raise HTTPException(status_code=422,detail="Rejected form applicants cannot receive an offer")
|
||||||
|
if not form_row.job_post_id and form_row.assigned_job_post_id:
|
||||||
|
form_row.job_post_id=form_row.assigned_job_post_id
|
||||||
|
self.session.add(form_row)
|
||||||
|
await self.session.commit()
|
||||||
|
if form_row.manual_upload_candidate_id:
|
||||||
|
return await self._resolve_manual(
|
||||||
|
form_row.manual_upload_candidate_id,job_id,require_interview=False,
|
||||||
|
)
|
||||||
|
from g_sheet.views import SheetFormData
|
||||||
|
promoted=await SheetFormData(session=self.session)._promote_to_application(form_row)
|
||||||
|
if not promoted or not promoted.user_id:
|
||||||
|
raise HTTPException(status_code=422,detail="Could not promote this form applicant")
|
||||||
|
return {
|
||||||
|
"inbox_id": None,
|
||||||
|
"manual_upload_id": promoted.id,
|
||||||
|
"form_data_id": form_row.id,
|
||||||
|
"job_post_id": promoted.job_post_id or job_id,
|
||||||
|
"candidate_user_id": promoted.user_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _offer_mail_context(self,row,app):
|
||||||
|
names=await Users.names_by_ids(self.session,[row.candidate_user_id])
|
||||||
|
name=names.get(str(row.candidate_user_id))
|
||||||
|
email=None
|
||||||
|
result=await self.session.execute(
|
||||||
|
select(Users.email,Users.name).where(Users.id==row.candidate_user_id)
|
||||||
|
)
|
||||||
|
pair=result.first()
|
||||||
|
if pair:
|
||||||
|
email=(pair[0] or "").strip().lower() or None
|
||||||
|
name=name or pair[1]
|
||||||
|
job=await JobPosts.get_job_post_by_id(self.session,row.job_post_id)
|
||||||
|
title=job.title if job else None
|
||||||
|
return name or "Candidate",email,title
|
||||||
|
|
||||||
|
async def _advance_pipeline(self,app,current_user):
|
||||||
|
pipeline=Pipeline(session=self.session)
|
||||||
|
try:
|
||||||
|
if app.get("inbox_id") is not None:
|
||||||
|
await pipeline.change_stage(
|
||||||
|
"OFFER",current_user,inbox_id=app["inbox_id"],
|
||||||
|
change_reason="Offer sent",
|
||||||
|
)
|
||||||
|
elif app.get("manual_upload_id") is not None:
|
||||||
|
await pipeline.change_stage(
|
||||||
|
"OFFER",current_user,manual_upload_id=app["manual_upload_id"],
|
||||||
|
change_reason="Offer sent",
|
||||||
|
)
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code!=400:
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _notify_send_failed(self,user_id,candidate_name,job_title,row):
|
||||||
|
try:
|
||||||
|
bits=[candidate_name] if candidate_name else []
|
||||||
|
if job_title:
|
||||||
|
bits.append(job_title)
|
||||||
|
body=" · ".join(bits) if bits else MAIL_FAIL_DETAIL
|
||||||
|
await Notifications.insert_notification(self.session,{
|
||||||
|
"user_id": user_id,
|
||||||
|
"kind": "message",
|
||||||
|
"title": MAIL_FAIL_DETAIL,
|
||||||
|
"body": body,
|
||||||
|
"link_path": f"/offers?offer={row.id}",
|
||||||
|
"job_post_id": row.job_post_id,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from db_setup import get_session
|
from db_setup import get_session
|
||||||
from org_settings.views import OrgSetting
|
from org_settings.views import Exclusion, OrgSetting
|
||||||
from users.permissions import PermissionTag, require_permission
|
from users.permissions import PermissionTag, require_permission
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -52,3 +52,227 @@ async def update_org_settings(
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class ExcludeUniversityCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ExcludeUniversityUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExcludeCompanyCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
linkedin_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExcludeCompanyUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
linkedin_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExcludeUniversityBatchCreate(BaseModel):
|
||||||
|
universities: list[ExcludeUniversityCreate]
|
||||||
|
|
||||||
|
|
||||||
|
class ExcludeCompanyBatchCreate(BaseModel):
|
||||||
|
companies: list[ExcludeCompanyCreate]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/org-settings/exclude-university/fetch")
|
||||||
|
async def fetch_exclude_universities(
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service = Exclusion(session=session)
|
||||||
|
data, total = await service.get_universities()
|
||||||
|
return JSONResponse(content={"data": data, "total": total, "status_code": 200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/org-settings/exclude-university/create")
|
||||||
|
async def create_exclude_university(
|
||||||
|
payload: ExcludeUniversityCreate,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service = Exclusion(session=session)
|
||||||
|
data = await service.create_university(payload.model_dump(exclude_unset=True), current_user)
|
||||||
|
return JSONResponse(content={"data": data, "status_code": 200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/org-settings/exclude-university/create-batch")
|
||||||
|
async def create_exclude_universities_batch(
|
||||||
|
payload: ExcludeUniversityBatchCreate,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
if not payload.universities:
|
||||||
|
raise HTTPException(status_code=400, detail="universities is required")
|
||||||
|
service = Exclusion(session=session)
|
||||||
|
data = []
|
||||||
|
failed = []
|
||||||
|
for item in payload.universities:
|
||||||
|
try:
|
||||||
|
row = await service.create_university(
|
||||||
|
item.model_dump(exclude_unset=True), current_user
|
||||||
|
)
|
||||||
|
data.append(row)
|
||||||
|
except HTTPException as e:
|
||||||
|
failed.append({
|
||||||
|
"name": (item.name or "").strip(),
|
||||||
|
"detail": e.detail if isinstance(e.detail, str) else str(e.detail),
|
||||||
|
"status_code": e.status_code,
|
||||||
|
})
|
||||||
|
return JSONResponse(
|
||||||
|
content={"data": data, "total": len(data), "failed": failed, "status_code": 200}
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/org-settings/exclude-university/update")
|
||||||
|
async def update_exclude_university(
|
||||||
|
payload: ExcludeUniversityUpdate,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
|
||||||
|
record_id: str = Query(...),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service = Exclusion(session=session)
|
||||||
|
data = await service.update_university(
|
||||||
|
record_id, payload.model_dump(exclude_unset=True), current_user
|
||||||
|
)
|
||||||
|
return JSONResponse(content={"data": data, "status_code": 200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/org-settings/exclude-university/delete")
|
||||||
|
async def delete_exclude_university(
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
|
||||||
|
record_id: str = Query(...),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service = Exclusion(session=session)
|
||||||
|
data = await service.delete_university(record_id, current_user)
|
||||||
|
return JSONResponse(content={"data": data, "status_code": 200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/org-settings/exclude-company/fetch")
|
||||||
|
async def fetch_exclude_companies(
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service = Exclusion(session=session)
|
||||||
|
data, total = await service.get_companies()
|
||||||
|
return JSONResponse(content={"data": data, "total": total, "status_code": 200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/org-settings/exclude-company/create")
|
||||||
|
async def create_exclude_company(
|
||||||
|
payload: ExcludeCompanyCreate,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service = Exclusion(session=session)
|
||||||
|
data = await service.create_company(payload.model_dump(exclude_unset=True), current_user)
|
||||||
|
return JSONResponse(content={"data": data, "status_code": 200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/org-settings/exclude-company/create-batch")
|
||||||
|
async def create_exclude_companies_batch(
|
||||||
|
payload: ExcludeCompanyBatchCreate,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
if not payload.companies:
|
||||||
|
raise HTTPException(status_code=400, detail="companies is required")
|
||||||
|
service = Exclusion(session=session)
|
||||||
|
data = []
|
||||||
|
failed = []
|
||||||
|
for item in payload.companies:
|
||||||
|
try:
|
||||||
|
row = await service.create_company(
|
||||||
|
item.model_dump(exclude_unset=True), current_user
|
||||||
|
)
|
||||||
|
data.append(row)
|
||||||
|
except HTTPException as e:
|
||||||
|
failed.append({
|
||||||
|
"name": (item.name or "").strip(),
|
||||||
|
"detail": e.detail if isinstance(e.detail, str) else str(e.detail),
|
||||||
|
"status_code": e.status_code,
|
||||||
|
})
|
||||||
|
return JSONResponse(
|
||||||
|
content={"data": data, "total": len(data), "failed": failed, "status_code": 200}
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/org-settings/exclude-company/update")
|
||||||
|
async def update_exclude_company(
|
||||||
|
payload: ExcludeCompanyUpdate,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
|
||||||
|
record_id: str = Query(...),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service = Exclusion(session=session)
|
||||||
|
data = await service.update_company(
|
||||||
|
record_id, payload.model_dump(exclude_unset=True), current_user
|
||||||
|
)
|
||||||
|
return JSONResponse(content={"data": data, "status_code": 200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/org-settings/exclude-company/delete")
|
||||||
|
async def delete_exclude_company(
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
|
||||||
|
record_id: str = Query(...),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service = Exclusion(session=session)
|
||||||
|
data = await service.delete_company(record_id, current_user)
|
||||||
|
return JSONResponse(content={"data": data, "status_code": 200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from typing import Any
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import DateTime, JSON
|
from sqlalchemy import DateTime, JSON, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import Field, SQLModel, select
|
from sqlmodel import Field, SQLModel, select
|
||||||
|
|
||||||
|
|
@ -76,4 +76,188 @@ class OrgSettings(SQLModel, table=True):
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
class ExcludeUniversity(SQLModel, table=True):
|
||||||
|
__tablename__ = "exclude_university"
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
name: str = Field(index=True)
|
||||||
|
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||||
|
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
is_deleted: bool = Field(default=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||||
|
if record_id in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(record_id))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_id(cls, session: AsyncSession, record_id):
|
||||||
|
uid = cls._as_uuid(record_id)
|
||||||
|
if uid is None:
|
||||||
|
return None
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_name(cls, session: AsyncSession, name: str, *, exclude_id=None):
|
||||||
|
cleaned = (name or "").strip()
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
statement = select(cls).where(
|
||||||
|
func.lower(cls.name) == cleaned.lower(),
|
||||||
|
cls.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
if exclude_id is not None:
|
||||||
|
uid = cls._as_uuid(exclude_id)
|
||||||
|
if uid is not None:
|
||||||
|
statement = statement.where(cls.id != uid)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def fetch_rows(cls, session: AsyncSession):
|
||||||
|
statement = select(cls).where(cls.is_deleted == False).order_by(cls.name.asc()) # noqa: E712
|
||||||
|
result = await session.execute(statement)
|
||||||
|
rows = list(result.scalars().all())
|
||||||
|
return rows, len(rows)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def fetch_names(cls, session: AsyncSession) -> list[str]:
|
||||||
|
rows, _ = await cls.fetch_rows(session)
|
||||||
|
return [r.name for r in rows if r.name]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def insert_row(cls, session: AsyncSession, fields: dict):
|
||||||
|
row = cls(**fields)
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return await cls.get_by_id(session, row.id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def update_row(cls, session: AsyncSession, record_id, fields: dict):
|
||||||
|
row = await cls.get_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
for key, value in fields.items():
|
||||||
|
setattr(row, key, value)
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def soft_delete_row(cls, session: AsyncSession, record_id):
|
||||||
|
row = await cls.get_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.is_deleted = True
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
class ExcludeCompany(SQLModel, table=True):
|
||||||
|
__tablename__ = "exclude_company"
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
name: str = Field(index=True)
|
||||||
|
linkedin_url: str | None = Field(default=None)
|
||||||
|
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||||
|
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
is_deleted: bool = Field(default=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||||
|
if record_id in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(record_id))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_id(cls, session: AsyncSession, record_id):
|
||||||
|
uid = cls._as_uuid(record_id)
|
||||||
|
if uid is None:
|
||||||
|
return None
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_name(cls, session: AsyncSession, name: str, *, exclude_id=None):
|
||||||
|
cleaned = (name or "").strip()
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
statement = select(cls).where(
|
||||||
|
func.lower(cls.name) == cleaned.lower(),
|
||||||
|
cls.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
if exclude_id is not None:
|
||||||
|
uid = cls._as_uuid(exclude_id)
|
||||||
|
if uid is not None:
|
||||||
|
statement = statement.where(cls.id != uid)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def fetch_rows(cls, session: AsyncSession):
|
||||||
|
statement = select(cls).where(cls.is_deleted == False).order_by(cls.name.asc()) # noqa: E712
|
||||||
|
result = await session.execute(statement)
|
||||||
|
rows = list(result.scalars().all())
|
||||||
|
return rows, len(rows)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def fetch_names(cls, session: AsyncSession) -> list[str]:
|
||||||
|
rows, _ = await cls.fetch_rows(session)
|
||||||
|
return [r.name for r in rows if r.name]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def fetch_linkedin_urls(cls, session: AsyncSession) -> list[str]:
|
||||||
|
rows, _ = await cls.fetch_rows(session)
|
||||||
|
return [r.linkedin_url for r in rows if r.linkedin_url]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def insert_row(cls, session: AsyncSession, fields: dict):
|
||||||
|
row = cls(**fields)
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return await cls.get_by_id(session, row.id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def update_row(cls, session: AsyncSession, record_id, fields: dict):
|
||||||
|
row = await cls.get_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
for key, value in fields.items():
|
||||||
|
setattr(row, key, value)
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def soft_delete_row(cls, session: AsyncSession, record_id):
|
||||||
|
row = await cls.get_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.is_deleted = True
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
import users.models as _users_models # noqa: E402, F401
|
import users.models as _users_models # noqa: E402, F401
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,22 @@ def serialize_org_setting(row) -> dict:
|
||||||
"category": row.category,
|
"category": row.category,
|
||||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_exclude_university(row) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(row.id) if row.id else None,
|
||||||
|
"name": row.name,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_exclude_company(row) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(row.id) if row.id else None,
|
||||||
|
"name": row.name,
|
||||||
|
"linkedin_url": row.linkedin_url,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,12 @@ import uuid
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from org_settings.models import OrgSettings
|
from org_settings.models import ExcludeCompany, ExcludeUniversity, OrgSettings
|
||||||
from org_settings.serializers import serialize_org_setting
|
from org_settings.serializers import (
|
||||||
|
serialize_exclude_company,
|
||||||
|
serialize_exclude_university,
|
||||||
|
serialize_org_setting,
|
||||||
|
)
|
||||||
|
|
||||||
VALID_CATEGORIES = (
|
VALID_CATEGORIES = (
|
||||||
"general",
|
"general",
|
||||||
|
|
@ -70,3 +74,111 @@ class OrgSetting:
|
||||||
})
|
})
|
||||||
rows = await OrgSettings.upsert_settings(self.session, cleaned, _user_id(current_user))
|
rows = await OrgSettings.upsert_settings(self.session, cleaned, _user_id(current_user))
|
||||||
return [serialize_org_setting(r) for r in rows]
|
return [serialize_org_setting(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
MAX_NAME_LEN = 200
|
||||||
|
MAX_URL_LEN = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_name(value, *, label="name"):
|
||||||
|
name = (value or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=422, detail=f"{label} is required")
|
||||||
|
if len(name) > MAX_NAME_LEN:
|
||||||
|
raise HTTPException(status_code=422, detail=f"{label} must be {MAX_NAME_LEN} characters or fewer")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_linkedin_url(value):
|
||||||
|
url = (value or "").strip() or None
|
||||||
|
if url is None:
|
||||||
|
return None
|
||||||
|
if len(url) > MAX_URL_LEN:
|
||||||
|
raise HTTPException(status_code=422, detail=f"linkedin_url must be {MAX_URL_LEN} characters or fewer")
|
||||||
|
lowered = url.lower()
|
||||||
|
if not (lowered.startswith("http://") or lowered.startswith("https://")):
|
||||||
|
raise HTTPException(status_code=422, detail="linkedin_url must be an http(s) URL")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
class Exclusion:
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get_universities(self):
|
||||||
|
rows, total = await ExcludeUniversity.fetch_rows(self.session)
|
||||||
|
return [serialize_exclude_university(r) for r in rows], total
|
||||||
|
|
||||||
|
async def create_university(self, payload, current_user):
|
||||||
|
name = _clean_name(payload.get("name"), label="name")
|
||||||
|
if await ExcludeUniversity.get_by_name(self.session, name):
|
||||||
|
raise HTTPException(status_code=409, detail="That university is already excluded")
|
||||||
|
row = await ExcludeUniversity.insert_row(self.session, {
|
||||||
|
"name": name,
|
||||||
|
"created_by": _user_id(current_user),
|
||||||
|
})
|
||||||
|
return serialize_exclude_university(row)
|
||||||
|
|
||||||
|
async def update_university(self, record_id, payload, current_user):
|
||||||
|
_user_id(current_user)
|
||||||
|
fields = {}
|
||||||
|
if "name" in payload:
|
||||||
|
name = _clean_name(payload.get("name"), label="name")
|
||||||
|
existing = await ExcludeUniversity.get_by_name(self.session, name, exclude_id=record_id)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="That university is already excluded")
|
||||||
|
fields["name"] = name
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
row = await ExcludeUniversity.update_row(self.session, record_id, fields)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Excluded university not found")
|
||||||
|
return serialize_exclude_university(row)
|
||||||
|
|
||||||
|
async def delete_university(self, record_id, current_user):
|
||||||
|
_user_id(current_user)
|
||||||
|
row = await ExcludeUniversity.soft_delete_row(self.session, record_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Excluded university not found")
|
||||||
|
return {"id": str(row.id), "deleted": True}
|
||||||
|
|
||||||
|
async def get_companies(self):
|
||||||
|
rows, total = await ExcludeCompany.fetch_rows(self.session)
|
||||||
|
return [serialize_exclude_company(r) for r in rows], total
|
||||||
|
|
||||||
|
async def create_company(self, payload, current_user):
|
||||||
|
name = _clean_name(payload.get("name"), label="name")
|
||||||
|
linkedin_url = _clean_linkedin_url(payload.get("linkedin_url"))
|
||||||
|
if await ExcludeCompany.get_by_name(self.session, name):
|
||||||
|
raise HTTPException(status_code=409, detail="That company is already excluded")
|
||||||
|
row = await ExcludeCompany.insert_row(self.session, {
|
||||||
|
"name": name,
|
||||||
|
"linkedin_url": linkedin_url,
|
||||||
|
"created_by": _user_id(current_user),
|
||||||
|
})
|
||||||
|
return serialize_exclude_company(row)
|
||||||
|
|
||||||
|
async def update_company(self, record_id, payload, current_user):
|
||||||
|
_user_id(current_user)
|
||||||
|
fields = {}
|
||||||
|
if "name" in payload:
|
||||||
|
name = _clean_name(payload.get("name"), label="name")
|
||||||
|
existing = await ExcludeCompany.get_by_name(self.session, name, exclude_id=record_id)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="That company is already excluded")
|
||||||
|
fields["name"] = name
|
||||||
|
if "linkedin_url" in payload:
|
||||||
|
fields["linkedin_url"] = _clean_linkedin_url(payload.get("linkedin_url"))
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
row = await ExcludeCompany.update_row(self.session, record_id, fields)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Excluded company not found")
|
||||||
|
return serialize_exclude_company(row)
|
||||||
|
|
||||||
|
async def delete_company(self, record_id, current_user):
|
||||||
|
_user_id(current_user)
|
||||||
|
row = await ExcludeCompany.soft_delete_row(self.session, record_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Excluded company not found")
|
||||||
|
return {"id": str(row.id), "deleted": True}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,11 @@ class RolePermissionTagsUpdate(BaseModel):
|
||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RoleMatrixUpdate(BaseModel):
|
||||||
|
"""Exact tag ids for one role. Saved onto that role's overlay bundle."""
|
||||||
|
permission_tags: list[int]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/roles/fetch")
|
@router.get("/roles/fetch")
|
||||||
async def fetch_roles(
|
async def fetch_roles(
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_VIEW)),
|
||||||
|
|
@ -174,6 +179,23 @@ async def update_permission(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
@router.put("/roles/matrix/update")
|
||||||
|
async def update_role_matrix(
|
||||||
|
payload: RoleMatrixUpdate,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_EDIT)),
|
||||||
|
record_id: int = Query(...),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=Role(session=session)
|
||||||
|
data=await service.set_role_matrix(record_id,payload.permission_tags)
|
||||||
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/roles/permission-tags/update")
|
@router.put("/roles/permission-tags/update")
|
||||||
async def update_role_permission_tags(
|
async def update_role_permission_tags(
|
||||||
payload: RolePermissionTagsUpdate,
|
payload: RolePermissionTagsUpdate,
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,46 @@ class Role:
|
||||||
updated = await Roles.update_role(self.session, int(record_id), fields)
|
updated = await Roles.update_role(self.session, int(record_id), fields)
|
||||||
return await self._role_payload(updated)
|
return await self._role_payload(updated)
|
||||||
|
|
||||||
|
async def set_role_matrix(self, record_id, permission_tags):
|
||||||
|
"""Write the Access Control grid onto one overlay bundle for this role.
|
||||||
|
|
||||||
|
Shared system bundles are not mutated. The role then points at that
|
||||||
|
overlay only, so a ticked cell is the grant and an unticked cell is not.
|
||||||
|
"""
|
||||||
|
role = await Roles.get_role_by_id(self.session, int(record_id))
|
||||||
|
if not role or role.is_deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Role not found")
|
||||||
|
tag_ids = sorted({int(i) for i in (permission_tags or [])})
|
||||||
|
found = await PermissionTags.get_permission_tags_by_ids(self.session, tag_ids)
|
||||||
|
unknown = sorted(set(tag_ids) - {t.id for t in found})
|
||||||
|
if unknown:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Unknown or inactive permission tag ids: {unknown}",
|
||||||
|
)
|
||||||
|
overlay_name = f"role_{role.id}_matrix"
|
||||||
|
bundle = await Permissions.get_permission_by_name(self.session, overlay_name)
|
||||||
|
if bundle is None:
|
||||||
|
bundle = await Permissions.insert_permission(
|
||||||
|
self.session,
|
||||||
|
{
|
||||||
|
"name": overlay_name,
|
||||||
|
"description": f"Access Control matrix for {role.role_name}",
|
||||||
|
"permission_tags": tag_ids,
|
||||||
|
"is_system": False,
|
||||||
|
"is_active": True,
|
||||||
|
"is_deleted": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await Permissions.update_permission(
|
||||||
|
self.session, int(bundle.id), {"permission_tags": tag_ids},
|
||||||
|
)
|
||||||
|
updated = await Roles.update_role(
|
||||||
|
self.session, int(record_id), {"permissions": [int(bundle.id)]},
|
||||||
|
)
|
||||||
|
return await self._role_payload(updated)
|
||||||
|
|
||||||
async def delete_role(self, record_id):
|
async def delete_role(self, record_id):
|
||||||
role = await Roles.get_role_by_id(self.session, int(record_id))
|
role = await Roles.get_role_by_id(self.session, int(record_id))
|
||||||
if not role or role.is_deleted:
|
if not role or role.is_deleted:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
"""Summary-gate adapter and its process-wide instance.
|
||||||
|
|
||||||
|
Pure module: no FastAPI imports and no HTTPException.
|
||||||
|
|
||||||
|
Mirrors inbox_classifier/agent_setup.py: responses.parse into a Pydantic
|
||||||
|
verdict, delivery-status branching, shared AsyncOpenAI client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
from summary_gate.models import SummarySuitabilityVerdict
|
||||||
|
from summary_gate.plugins import PROMPT_CACHE_KEY, get_gate_settings
|
||||||
|
from summary_gate.prompt import SYSTEM_PROMPT, build_input
|
||||||
|
|
||||||
|
from app.core.config import supports_reasoning
|
||||||
|
from app.core.errors import ModelRefusedError, ModelResponseInvalidError, ModelUnavailableError
|
||||||
|
|
||||||
|
logger=logging.getLogger("summary.gate")
|
||||||
|
|
||||||
|
_TRUNCATED="max_output_tokens"
|
||||||
|
_FILTERED="content_filter"
|
||||||
|
|
||||||
|
|
||||||
|
def _first_refusal(response):
|
||||||
|
for item in getattr(response,"output",None) or []:
|
||||||
|
for part in getattr(item,"content",None) or []:
|
||||||
|
if getattr(part,"type",None)=="refusal":
|
||||||
|
refusal=getattr(part,"refusal",None)
|
||||||
|
return str(refusal) if refusal else "refused"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryGate:
|
||||||
|
def __init__(self, client:AsyncOpenAI, model, max_output_tokens, effort, enable_cache=True):
|
||||||
|
self._client=client
|
||||||
|
self._model=model
|
||||||
|
self._max_output_tokens=max_output_tokens
|
||||||
|
self._effort=effort
|
||||||
|
self._enable_cache=enable_cache
|
||||||
|
self._supports_reasoning=supports_reasoning(model)
|
||||||
|
|
||||||
|
async def classify(self, job_description, summary) -> SummarySuitabilityVerdict:
|
||||||
|
kwargs={
|
||||||
|
"model":self._model,
|
||||||
|
"instructions":SYSTEM_PROMPT,
|
||||||
|
"input":build_input(job_description,summary),
|
||||||
|
"text_format":SummarySuitabilityVerdict,
|
||||||
|
"max_output_tokens":self._max_output_tokens,
|
||||||
|
}
|
||||||
|
if self._supports_reasoning:
|
||||||
|
kwargs["reasoning"]={"effort":self._effort}
|
||||||
|
if self._enable_cache:
|
||||||
|
kwargs["prompt_cache_key"]=PROMPT_CACHE_KEY
|
||||||
|
|
||||||
|
response=await self._client.responses.parse(**kwargs)
|
||||||
|
|
||||||
|
status=getattr(response,"status",None)
|
||||||
|
self._log_usage(response,status)
|
||||||
|
|
||||||
|
if status=="failed":
|
||||||
|
raise ModelUnavailableError("provider reported a failed response")
|
||||||
|
|
||||||
|
if status=="incomplete":
|
||||||
|
reason=getattr(getattr(response,"incomplete_details",None),"reason",None)
|
||||||
|
if reason==_FILTERED:
|
||||||
|
raise ModelRefusedError("content filter blocked the response")
|
||||||
|
if reason==_TRUNCATED:
|
||||||
|
raise ModelResponseInvalidError("response truncated at max_output_tokens")
|
||||||
|
raise ModelResponseInvalidError(f"incomplete response: {reason}")
|
||||||
|
|
||||||
|
if _first_refusal(response) is not None:
|
||||||
|
raise ModelRefusedError("model declined to classify this summary")
|
||||||
|
|
||||||
|
parsed=getattr(response,"output_parsed",None)
|
||||||
|
if not isinstance(parsed,SummarySuitabilityVerdict):
|
||||||
|
raise ModelResponseInvalidError("response did not parse into SummarySuitabilityVerdict")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
def _log_usage(self, response, status):
|
||||||
|
usage=getattr(response,"usage",None)
|
||||||
|
input_details=getattr(usage,"input_tokens_details",None)
|
||||||
|
output_details=getattr(usage,"output_tokens_details",None)
|
||||||
|
logger.info(
|
||||||
|
"summary gate: model=%s status=%s request_id=%s in=%s out=%s cached=%s reasoning=%s",
|
||||||
|
self._model,
|
||||||
|
status,
|
||||||
|
getattr(response,"id",None),
|
||||||
|
getattr(usage,"input_tokens",None),
|
||||||
|
getattr(usage,"output_tokens",None),
|
||||||
|
getattr(input_details,"cached_tokens",None),
|
||||||
|
getattr(output_details,"reasoning_tokens",None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_gate=None
|
||||||
|
|
||||||
|
|
||||||
|
def get_gate() -> SummaryGate:
|
||||||
|
global _gate
|
||||||
|
if _gate is None:
|
||||||
|
from llm_setup import get_client
|
||||||
|
|
||||||
|
settings=get_gate_settings()
|
||||||
|
_gate=SummaryGate(
|
||||||
|
get_client(),
|
||||||
|
model=settings.openai_model,
|
||||||
|
max_output_tokens=settings.openai_max_output_tokens,
|
||||||
|
effort=settings.openai_effort,
|
||||||
|
enable_cache=settings.openai_enable_prompt_cache,
|
||||||
|
)
|
||||||
|
return _gate
|
||||||
|
|
||||||
|
|
||||||
|
def close_gate():
|
||||||
|
global _gate
|
||||||
|
_gate=None
|
||||||
|
logger.info("summary gate closed")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue