s3 lionk done
parent
7c9e18741d
commit
ad6f8ba121
|
|
@ -340,23 +340,23 @@ class SheetFormData(Sheet):
|
|||
"""FormData DB mirror — query / delete only (no Google client)."""
|
||||
|
||||
async def _hydrate_job_posts(self,items):
|
||||
"""Attach suggested job_posts, assigned_job_post, and per-job ATS scores.
|
||||
"""Attach suggested job titles, assigned_job_post, and per-job ATS scores.
|
||||
|
||||
Preferred source is suggested_job_post_ids (ILIKE matches stored on
|
||||
import). Legacy rows without that list still title-match. ATS is one
|
||||
current score per (form, job).
|
||||
current score per (form, job). Full JD loads when a card is expanded.
|
||||
"""
|
||||
if not 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.serializers import serialize_job_post
|
||||
from job.job_post.serializers import serialize_job_post_title
|
||||
|
||||
session=self._require_session()
|
||||
|
||||
def _job_payload(post):
|
||||
payload=serialize_job_post(post)
|
||||
payload=serialize_job_post_title(post)
|
||||
if post.is_deleted or not post.is_active:
|
||||
payload={**payload,"unavailable":True}
|
||||
return payload
|
||||
|
|
@ -374,7 +374,7 @@ class SheetFormData(Sheet):
|
|||
wanted=list(dict.fromkeys([*suggested_ids,*assigned_ids]))
|
||||
by_id={}
|
||||
if wanted:
|
||||
for post in await JobPosts.get_by_ids(session,wanted,active_only=False):
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
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]:
|
||||
"""Filename + public URL only. Open-resume uses the S3 link; do not pull bytes."""
|
||||
if not message.file_path:
|
||||
return []
|
||||
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}
|
||||
if path_str.lower().startswith("http://") or path_str.lower().startswith("https://"):
|
||||
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)
|
||||
return files
|
||||
|
||||
|
|
|
|||
|
|
@ -256,23 +256,7 @@ class Email:
|
|||
item["files"]=files
|
||||
from job.candidate.views import CandidateView
|
||||
cv=CandidateView(session=self.session)
|
||||
suggested=[]
|
||||
for job_id in item.get("suggested_job_post_ids") or []:
|
||||
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
|
||||
items=await self._paint_inbox_ats([item])
|
||||
items=await self._attach_job_posts([item])
|
||||
return await cv.attach_application_history(items[0])
|
||||
|
||||
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):
|
||||
|
|
@ -289,10 +273,11 @@ class Email:
|
|||
return await CandidateView(session=self.session).attach_application_history(items)
|
||||
|
||||
async def _attach_job_posts(self,items):
|
||||
"""List payload needs assigned + suggested job objects — export reads titles.
|
||||
"""List payload needs assigned + suggested titles — export reads names.
|
||||
|
||||
Detail hydrates one row; the queue used to ship ids only. Assigned job and
|
||||
Suggested jobs in the Inbox .xlsx were then blank for email applicants.
|
||||
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:
|
||||
|
|
@ -305,9 +290,9 @@ class Email:
|
|||
by_id={}
|
||||
if ids:
|
||||
from job.job_post.models import JobPosts
|
||||
from job.job_post.serializers import serialize_job_post
|
||||
for post in await JobPosts.get_by_ids(self.session,ids,active_only=False):
|
||||
payload=serialize_job_post(post)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Optional
|
|||
|
||||
from sqlalchemy import DateTime, JSON, Index, String, and_, case, cast, func, or_, union_all
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.orm import aliased, load_only
|
||||
from sqlmodel import Field, Relationship, SQLModel, select
|
||||
|
||||
from job.job_post.enums import RequisitionStatus
|
||||
|
|
@ -127,6 +127,31 @@ class JobPosts(SQLModel, table=True):
|
|||
# Preserve request order so suggestion ranks stay stable.
|
||||
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
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -8,6 +8,17 @@ def _status_label(value):
|
|||
return parsed.label if parsed else value
|
||||
|
||||
|
||||
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 serialize_job_post(row) -> dict:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export const qk = {
|
|||
jobPosts: {
|
||||
all: () => ['jobPosts'],
|
||||
list: (p = {}) => ['jobPosts', 'list', p],
|
||||
detail: (id) => ['jobPosts', 'detail', id],
|
||||
departments: (p = {}) => ['jobPosts', 'departments', p],
|
||||
},
|
||||
jobs: {
|
||||
|
|
|
|||
|
|
@ -71,22 +71,39 @@ const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS }
|
|||
/**
|
||||
* Last 10 opened applicant details. The queue list is a light row; GET-by-id
|
||||
* is the body, suggested roles, resume text. Re-opening one of these should
|
||||
* not pay that trip again. Cap is LRU — the 11th open drops the oldest from
|
||||
* the query cache. Mutations that write THIS row still invalidate its key.
|
||||
* not pay that trip again for 15 minutes. Cap is LRU — the 11th open drops
|
||||
* the oldest. After the TTL the snapshot is dropped so the next open refetches.
|
||||
* Mutations that write THIS row still invalidate its key.
|
||||
*/
|
||||
const OPENED_DETAIL_CAP = 10
|
||||
const OPENED_DETAIL_TTL_MS = 15 * 60_000
|
||||
const openedDetailLru = []
|
||||
|
||||
function detailQueryKey(kind, id) {
|
||||
return kind === 'form' ? qk.mailbox.formRow(id) : qk.mailbox.message(id)
|
||||
}
|
||||
|
||||
function pruneOpenedDetailLru(qc, keepSig) {
|
||||
const cutoff = Date.now() - OPENED_DETAIL_TTL_MS
|
||||
const next = []
|
||||
for (const x of openedDetailLru) {
|
||||
if (x.at > cutoff || x.sig === keepSig) {
|
||||
next.push(x)
|
||||
continue
|
||||
}
|
||||
qc.removeQueries({ queryKey: x.key })
|
||||
}
|
||||
openedDetailLru.length = 0
|
||||
openedDetailLru.push(...next)
|
||||
}
|
||||
|
||||
function rememberOpenedDetail(qc, kind, id) {
|
||||
if (!id) return
|
||||
const sig = `${kind}:${id}`
|
||||
const key = detailQueryKey(kind, id)
|
||||
pruneOpenedDetailLru(qc, sig)
|
||||
const next = openedDetailLru.filter((x) => x.sig !== sig)
|
||||
next.push({ sig, kind, id, key })
|
||||
next.push({ sig, kind, id, key, at: Date.now() })
|
||||
while (next.length > OPENED_DETAIL_CAP) {
|
||||
const dropped = next.shift()
|
||||
if (dropped && dropped.sig !== sig) {
|
||||
|
|
@ -98,9 +115,9 @@ function rememberOpenedDetail(qc, kind, id) {
|
|||
}
|
||||
|
||||
const DETAIL_CACHE = {
|
||||
staleTime: Infinity,
|
||||
gcTime: INBOX_GC_MS,
|
||||
refetchOnMount: false,
|
||||
staleTime: OPENED_DETAIL_TTL_MS,
|
||||
gcTime: OPENED_DETAIL_TTL_MS,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
}
|
||||
|
|
@ -1428,6 +1445,7 @@ export default function Inbox() {
|
|||
...DETAIL_CACHE,
|
||||
})
|
||||
useEffect(() => {
|
||||
pruneOpenedDetailLru(qc, selectedId ? `${selectedKind}:${selectedId}` : null)
|
||||
if (!selectedId || !detailQuery.isSuccess || !detailQuery.data) return
|
||||
rememberOpenedDetail(qc, selectedKind, selectedId)
|
||||
}, [qc, selectedId, selectedKind, detailQuery.isSuccess, detailQuery.data])
|
||||
|
|
@ -2381,6 +2399,7 @@ function FormApplicantDetail({
|
|||
badge={`Match #${rank}`}
|
||||
selected={String(selection) === String(post.id)}
|
||||
onSelect={(id) => setSelection(String(id))}
|
||||
lazy
|
||||
/>
|
||||
))}
|
||||
{manualPost && (
|
||||
|
|
@ -2390,6 +2409,7 @@ function FormApplicantDetail({
|
|||
manual
|
||||
selected={String(selection) === String(manualPost.id)}
|
||||
onSelect={(id) => setSelection(String(id))}
|
||||
lazy
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -2788,6 +2808,7 @@ function ApplicationDetail({
|
|||
selected={String(selection) === String(post.id)}
|
||||
onSelect={(id) => setSelection(String(id))}
|
||||
resumeText={resumeText}
|
||||
lazy
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
|
@ -2799,6 +2820,7 @@ function ApplicationDetail({
|
|||
selected={String(selection) === String(manualPost.id)}
|
||||
onSelect={(id) => setSelection(String(id))}
|
||||
resumeText={resumeText}
|
||||
lazy
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -30,25 +30,48 @@ function reqLabel(req) {
|
|||
return String(req)
|
||||
}
|
||||
|
||||
export function JobCard({ post, rank, selected, onSelect, resumeText, manual, badge }) {
|
||||
export function JobCard({ post, rank, selected, onSelect, resumeText, manual, badge, lazy = false }) {
|
||||
const unavailable = Boolean(post?.unavailable) || !post?.title
|
||||
const title = post?.title || 'Unavailable'
|
||||
const tag = badge ?? (manual ? 'Manual' : `AI #${rank}`)
|
||||
const [open, setOpen] = useState(false)
|
||||
const hasBody = Boolean(
|
||||
post?.location || post?.employment_type || post?.description
|
||||
|| (Array.isArray(post?.requirements) && post.requirements.length),
|
||||
)
|
||||
const detailQuery = useQuery({
|
||||
queryKey: qk.jobPosts.detail(post?.id),
|
||||
queryFn: async () => {
|
||||
const res = await jobPostsApi.list({ ids: [post.id], activeOnly: false })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows[0] || null
|
||||
},
|
||||
enabled: Boolean(lazy && open && post?.id && !unavailable && !hasBody),
|
||||
staleTime: 10 * 60_000,
|
||||
gcTime: 60 * 60_000,
|
||||
})
|
||||
const body = (!lazy || hasBody) ? post : (detailQuery.data || post)
|
||||
const meta = [
|
||||
post?.employment_type,
|
||||
post?.location,
|
||||
post?.experience_min != null || post?.experience_max != null
|
||||
? `${post?.experience_min ?? '?'}–${post?.experience_max ?? '?'} yrs`
|
||||
body?.employment_type,
|
||||
body?.location,
|
||||
body?.experience_min != null || body?.experience_max != null
|
||||
? `${body?.experience_min ?? '?'}–${body?.experience_max ?? '?'} yrs`
|
||||
: null,
|
||||
].filter(Boolean).join(' · ')
|
||||
const tag = badge ?? (manual ? 'Manual' : `AI #${rank}`)
|
||||
const showBody = !lazy || open
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-expanded={lazy ? open : undefined}
|
||||
tabIndex={0}
|
||||
className="list-row"
|
||||
onClick={() => !unavailable && onSelect(post.id)}
|
||||
onClick={() => {
|
||||
if (unavailable) return
|
||||
onSelect(post.id)
|
||||
if (lazy) setOpen(true)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (unavailable) return
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
|
@ -67,6 +90,20 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual, ba
|
|||
>
|
||||
<div className="lr-main" style={{ minWidth: 0 }}>
|
||||
<div className="flex items-center gap-8" style={{ marginBottom: 4, flexWrap: 'wrap' }}>
|
||||
{lazy && !unavailable && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
aria-label={open ? 'Hide role details' : 'Show role details'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen((v) => !v)
|
||||
}}
|
||||
style={{ padding: 4, minWidth: 28 }}
|
||||
>
|
||||
<Icon name={open ? 'chevron-down' : 'chevron-right'} />
|
||||
</button>
|
||||
)}
|
||||
<span className="tag">{tag}</span>
|
||||
<div className="lr-title">{title}</div>
|
||||
{unavailable ? (
|
||||
|
|
@ -77,10 +114,16 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual, ba
|
|||
{post?.overall_score != null && <ScoreChip score={post.overall_score} />}
|
||||
{selected && <Icon name="check-circle" />}
|
||||
</div>
|
||||
{meta && <div className="cell-sub">{meta}</div>}
|
||||
{!unavailable && Array.isArray(post.requirements) && post.requirements.length > 0 && (
|
||||
{showBody && detailQuery.isPending && lazy && !hasBody && (
|
||||
<div className="cell-sub">Loading role…</div>
|
||||
)}
|
||||
{showBody && detailQuery.isError && lazy && (
|
||||
<div className="cell-sub">{friendlyAuthError(detailQuery.error, 'Could not load this role.')}</div>
|
||||
)}
|
||||
{showBody && meta && <div className="cell-sub">{meta}</div>}
|
||||
{showBody && !unavailable && Array.isArray(body.requirements) && body.requirements.length > 0 && (
|
||||
<div className="k-tags" style={{ marginTop: 8 }}>
|
||||
{post.requirements.slice(0, 8).map((req, i) => {
|
||||
{body.requirements.slice(0, 8).map((req, i) => {
|
||||
const label = reqLabel(req)
|
||||
if (!label) return null
|
||||
const hit = reqInResume(req, resumeText)
|
||||
|
|
|
|||
Loading…
Reference in New Issue