HR-ATS-Portal/backend/talent/models.py

299 lines
12 KiB
Python

"""Talent sourcing tables: Apify actor runs and the LinkedIn profiles they find.
`talent_runs` is one row per paid actor run (vendor-id trio mirrors the Buffer
columns on job_posts). `talent_profiles` is deduped per job by normalized
LinkedIn URL across re-runs; `raw` keeps the full dataset item verbatim because
actor output fields vary between actors and versions.
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, JSON, UniqueConstraint, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
def _now() -> datetime:
return datetime.now(timezone.utc)
# Local run lifecycle. `pending` exists only between row insert and the Apify
# start call succeeding; everything after start is driven by Apify's status.
TERMINAL_RUN_STATUSES = ("succeeded", "failed", "timed_out", "aborted")
# Profile fields refreshed when a later run re-finds the same person. Kept at
# module level: an underscore-prefixed class attribute on a SQLModel becomes a
# Pydantic ModelPrivateAttr, which is not iterable. `is_deleted` is deliberately
# absent — a dismissed profile stays dismissed.
MUTABLE_PROFILE_FIELDS = (
"public_id", "full_name", "headline", "location",
"current_title", "current_company", "avatar_url", "summary", "skills",
"match_score", "raw",
)
class TalentRuns(SQLModel, table=True):
__tablename__ = "talent_runs"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
job_post_id: uuid.UUID = Field(index=True, foreign_key="job_posts.id")
requested_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
status: str = Field(default="pending")
actor_id: str = Field(default="")
search_input: dict = Field(default_factory=dict, sa_type=JSON)
max_results: int = Field(default=0)
apify_run_id: str | None = Field(default=None)
apify_dataset_id: str | None = Field(default=None)
apify_error: str | None = Field(default=None)
profiles_found: int = Field(default=0)
started_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
finished_at: datetime | None = Field(default=None, 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))
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
statement = select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712
result = await session.execute(statement)
return result.scalars().first()
@classmethod
async def fetch_runs(cls, session: AsyncSession, *, job_post_id):
jid = cls._as_uuid(job_post_id)
if jid is None:
return [], 0
statement = select(cls).where(
cls.job_post_id == jid, cls.is_deleted == False # noqa: E712
)
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())
result = await session.execute(statement)
return list(result.scalars().all()), total
@classmethod
async def latest_active_run(cls, session: AsyncSession, job_post_id):
jid = cls._as_uuid(job_post_id)
if jid is None:
return None
statement = (
select(cls)
.where(
cls.job_post_id == jid,
cls.is_deleted == False, # noqa: E712
cls.status.not_in(TERMINAL_RUN_STATUSES),
)
.order_by(cls.created_at.desc())
)
result = await session.execute(statement)
return result.scalars().first()
@classmethod
async def insert_run(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(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 mark_started(cls, session: AsyncSession, record_id, *, apify_run_id, apify_dataset_id):
return await cls._update(session, record_id, {
"status": "running",
"apify_run_id": apify_run_id,
"apify_dataset_id": apify_dataset_id,
"started_at": _now(),
})
@classmethod
async def mark_rearmed(
cls, session: AsyncSession, record_id, *,
apify_run_id, apify_dataset_id, search_input: dict, found_so_far: int,
):
"""Point the SAME run row at a broadened follow-up actor run.
Status stays "running" so the frontend keeps polling and the 409
active-run guard keeps holding; profiles_found accumulates across
the ladder's batches.
"""
return await cls._update(session, record_id, {
"status": "running",
"apify_run_id": apify_run_id,
"apify_dataset_id": apify_dataset_id,
"search_input": search_input,
"profiles_found": found_so_far,
})
@classmethod
async def mark_status(cls, session: AsyncSession, record_id, status: str):
fields: dict = {"status": status}
if status in TERMINAL_RUN_STATUSES:
fields["finished_at"] = _now()
return await cls._update(session, record_id, fields)
@classmethod
async def mark_failed(cls, session: AsyncSession, record_id, error: str, *, status: str = "failed"):
return await cls._update(session, record_id, {
"status": status,
"apify_error": (error or "")[:2000],
"finished_at": _now(),
})
@classmethod
async def mark_succeeded(cls, session: AsyncSession, record_id, *, profiles_found: int):
return await cls._update(session, record_id, {
"status": "succeeded",
"profiles_found": profiles_found,
"apify_error": None,
"finished_at": _now(),
})
class TalentProfiles(SQLModel, table=True):
__tablename__ = "talent_profiles"
__table_args__ = (
UniqueConstraint("job_post_id", "linkedin_url", name="uq_talent_profiles_job_url"),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
job_post_id: uuid.UUID = Field(index=True, foreign_key="job_posts.id")
run_id: uuid.UUID = Field(foreign_key="talent_runs.id")
last_run_id: uuid.UUID | None = Field(default=None)
linkedin_url: str
public_id: str | None = Field(default=None)
full_name: str | None = Field(default=None)
headline: str | None = Field(default=None)
location: str | None = Field(default=None)
current_title: str | None = Field(default=None)
current_company: str | None = Field(default=None)
avatar_url: str | None = Field(default=None)
summary: str | None = Field(default=None)
skills: list = Field(default_factory=list, sa_type=JSON)
match_score: int | None = Field(default=None)
raw: dict = Field(default_factory=dict, sa_type=JSON)
first_seen_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
last_seen_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))
is_deleted: bool = Field(default=False)
@classmethod
async def fetch_profiles(
cls, session: AsyncSession, *, job_post_id, search=None, top=None, skip=0
):
jid = TalentRuns._as_uuid(job_post_id)
if jid is None:
return [], 0
statement = select(cls).where(
cls.job_post_id == jid, cls.is_deleted == False # noqa: E712
)
if search:
pattern = f"%{search}%"
statement = statement.where(
cls.full_name.ilike(pattern)
| cls.headline.ilike(pattern)
| cls.current_company.ilike(pattern)
)
count_statement = select(func.count()).select_from(statement.subquery())
total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(
cls.match_score.desc().nulls_last(),
cls.last_seen_at.desc(),
cls.created_at.desc(),
)
if skip:
statement = statement.offset(skip)
if top:
statement = statement.limit(top)
result = await session.execute(statement)
return list(result.scalars().all()), total
@classmethod
async def upsert_from_items(
cls, session: AsyncSession, *, job_post_id, run_id, normalized_items: list[dict]
) -> int:
"""Insert new profiles, refresh re-found ones. One commit for the batch.
Dedupe key is (job_post_id, linkedin_url); dismissed rows are refreshed
too but keep is_deleted=True so a re-run cannot resurrect them.
"""
jid = TalentRuns._as_uuid(job_post_id)
rid = TalentRuns._as_uuid(run_id)
persisted = 0
for item in normalized_items:
url = item.get("linkedin_url")
if not url:
continue
statement = select(cls).where(
cls.job_post_id == jid, cls.linkedin_url == url
)
existing = (await session.execute(statement)).scalars().first()
if existing:
for key in MUTABLE_PROFILE_FIELDS:
if item.get(key) is not None:
setattr(existing, key, item[key])
existing.last_run_id = rid
existing.last_seen_at = _now()
existing.updated_at = _now()
session.add(existing)
else:
session.add(cls(
job_post_id=jid,
run_id=rid,
last_run_id=rid,
linkedin_url=url,
**{key: item.get(key) for key in MUTABLE_PROFILE_FIELDS},
))
persisted += 1
await session.commit()
return persisted
@classmethod
async def get_profile_by_id(cls, session: AsyncSession, record_id):
uid = TalentRuns._as_uuid(record_id)
if uid is None:
return None
statement = select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712
return (await session.execute(statement)).scalars().first()
@classmethod
async def soft_delete_profile(cls, session: AsyncSession, record_id):
row = await cls.get_profile_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 job.job_post.models as _job_post_models # noqa: E402, F401
import users.models as _users_models # noqa: E402, F401