806 lines
33 KiB
Python
806 lines
33 KiB
Python
import logging
|
|
import os
|
|
from shlex import join
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any, List, Optional
|
|
|
|
from dotenv import load_dotenv
|
|
from fastapi import HTTPException
|
|
from inbox.enums import Candidate_application_Status
|
|
from role.models import EnumRoles, Roles
|
|
from sqlalchemy import Column, DateTime, func, or_, update
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
from sqlmodel import Field, Relationship, SQLModel, select, true
|
|
|
|
from job.candidate.models import Activity, Feedback, Interviews
|
|
from users.models import Users
|
|
from users.plugins import hash_password
|
|
|
|
load_dotenv()
|
|
logger = logging.getLogger("inbox.models")
|
|
|
|
# Placeholder only. The account lands inactive and the candidate is mailed a
|
|
# confirmation link; the real password comes from the reset flow afterwards.
|
|
DEFAULT_CANDIDATE_PASSWORD = os.getenv("DEFAULT_CANDIDATE_PASSWORD", "Utopia!@#")
|
|
CANDIDATE_ROLE_ID_FALLBACK = 8 # mirrors users/views.py:signup_user
|
|
SKIP_SENDER_PREFIXES = ("noreply", "no-reply", "donotreply", "do-not-reply",
|
|
"mailer-daemon", "postmaster", "bounce")
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
class Inbox(SQLModel, table=True):
|
|
__tablename__ = "inbox"
|
|
|
|
id: int | None = Field(default=None, primary_key=True)
|
|
user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
|
|
|
alert_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_alerts.id")
|
|
alerts: Optional["Inbox_Alerts"] = Relationship(back_populates="inbox")
|
|
|
|
message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id")
|
|
messages: Optional["Inbox_Messages"] = Relationship(back_populates="inbox")
|
|
|
|
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
|
|
|
favorite: Optional[bool] = Field(default=False)
|
|
rating: Optional[float] = Field(default=0.0)
|
|
|
|
ats_id: uuid.UUID | None = Field(default=None, foreign_key="ats_results.id")
|
|
|
|
interviews: List["Interviews"] = Relationship(
|
|
back_populates="inbox",
|
|
sa_relationship_kwargs={"lazy": "selectin"},
|
|
)
|
|
activity: List["Activity"] = Relationship(
|
|
back_populates="inbox",
|
|
sa_relationship_kwargs={"lazy": "selectin"},
|
|
)
|
|
feedback: List["Feedback"] = Relationship(
|
|
back_populates="inbox",
|
|
sa_relationship_kwargs={"lazy": "selectin"},
|
|
)
|
|
user: Optional["Users"] = Relationship(
|
|
back_populates="inbox",
|
|
sa_relationship_kwargs={"lazy": "joined"},
|
|
)
|
|
|
|
@classmethod
|
|
async def get_all(cls,session:AsyncSession):
|
|
try:
|
|
from job.job_post.models import JobPosts
|
|
qry=(
|
|
select(
|
|
cls.id.label("inbox_id"),
|
|
cls.user_id,
|
|
Users.name,
|
|
Users.email,
|
|
Inbox_Messages.candidate_phone_number.label("phone"),
|
|
Inbox_Messages.assigned_job_post_id,
|
|
Inbox_Messages.application_status,
|
|
JobPosts.title,
|
|
AtsResults.id.label("ats_result_id"),
|
|
AtsResults.overall_score,
|
|
AtsResults.band,
|
|
AtsResults.job_post_id.label("ats_job_post_id"),
|
|
AtsResults.computed_at,
|
|
AtsResults.candidate_id,
|
|
AtsResults.user_id.label("ats_user_id"),
|
|
)
|
|
.join(Users,cls.user_id==Users.id)
|
|
.join(Roles,Users.role_id==Roles.id)
|
|
.join(Inbox_Messages,cls.message_id==Inbox_Messages.id)
|
|
.join(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
|
.outerjoin(AtsResults,cls.ats_id==AtsResults.id)
|
|
.where(Inbox_Messages.assigned_job_post_id.is_not(None))
|
|
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
|
|
.order_by(cls.created_at.desc())
|
|
)
|
|
result=await session.execute(qry)
|
|
rows=[]
|
|
for row in result.mappings().all():
|
|
ats=None
|
|
if row["ats_result_id"] is not None:
|
|
ats={
|
|
"id":str(row["ats_result_id"]),
|
|
"overall_score":row["overall_score"],
|
|
"band":row["band"] or None,
|
|
"job_post_id":str(row["ats_job_post_id"]) if row["ats_job_post_id"] else None,
|
|
"computed_at":row["computed_at"].isoformat() if row["computed_at"] else None,
|
|
"candidate_id":str(row["candidate_id"]) if row["candidate_id"] else None,
|
|
"user_id":str(row["ats_user_id"]) if row["ats_user_id"] else None,
|
|
}
|
|
rows.append({
|
|
"inbox_id":row["inbox_id"],
|
|
"user_id":str(row["user_id"]) if row["user_id"] else None,
|
|
"name":row["name"],
|
|
"email":row["email"],
|
|
"application_status":row["application_status"].value if row["application_status"] else None,
|
|
"phone":row["phone"],
|
|
"assigned_job_post_id":str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None,
|
|
"title":row["title"],
|
|
"ats_result":ats,
|
|
})
|
|
return rows
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
@classmethod
|
|
def _candidate_search_filter(cls, search: str):
|
|
pattern = f"%{search}%"
|
|
return or_(Users.name.ilike(pattern), Users.email.ilike(pattern))
|
|
|
|
@classmethod
|
|
async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0,search:str|None=None):
|
|
try:
|
|
options=[selectinload(cls.messages)]
|
|
if user_id:
|
|
options.extend([
|
|
selectinload(cls.interviews),
|
|
selectinload(cls.activity),
|
|
selectinload(cls.feedback),
|
|
])
|
|
qry = (
|
|
select(cls)
|
|
.options(*options)
|
|
.join(Users, cls.user_id == Users.id)
|
|
.join(Roles, Users.role_id == Roles.id)
|
|
.where(Roles.role_name == EnumRoles.CANDIDATE.value)
|
|
)
|
|
if user_id:
|
|
qry = qry.where(cls.user_id == user_id)
|
|
if search:
|
|
qry = qry.where(cls._candidate_search_filter(search))
|
|
qry = qry.limit(limit).offset(offset)
|
|
result = await session.execute(qry)
|
|
rows = result.scalars().all()
|
|
if user_id and len(rows) == 1:
|
|
return rows[0]
|
|
return rows
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
@classmethod
|
|
async def count_candidate_profiles(cls,session:AsyncSession,user_id:uuid.UUID|None=None,search:str|None=None):
|
|
"""Result-set size for the same predicate get_candidate_profile pages over."""
|
|
try:
|
|
qry = (
|
|
select(func.count())
|
|
.select_from(cls)
|
|
.join(Users, cls.user_id == Users.id)
|
|
.join(Roles, Users.role_id == Roles.id)
|
|
.where(Roles.role_name == EnumRoles.CANDIDATE.value)
|
|
)
|
|
if user_id:
|
|
qry = qry.where(cls.user_id == user_id)
|
|
if search:
|
|
qry = qry.where(cls._candidate_search_filter(search))
|
|
result = await session.execute(qry)
|
|
return result.scalar_one()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
@classmethod
|
|
async def get_inbox_by_id(cls,session:AsyncSession,record_id:int|str|None):
|
|
if record_id is None:
|
|
return None
|
|
try:
|
|
iid=int(record_id)
|
|
except (TypeError,ValueError):
|
|
return None
|
|
result=await session.execute(select(cls).where(cls.id==iid))
|
|
return result.scalars().first()
|
|
|
|
@classmethod
|
|
async def get_inbox_with_message(cls,session:AsyncSession,record_id:int|str|None):
|
|
"""Inbox row with `messages` selectin-loaded for stage / application writers."""
|
|
if record_id is None:
|
|
return None
|
|
try:
|
|
iid=int(record_id)
|
|
except (TypeError,ValueError):
|
|
return None
|
|
result=await session.execute(
|
|
select(cls).options(selectinload(cls.messages)).where(cls.id==iid)
|
|
)
|
|
return result.scalars().first()
|
|
|
|
@classmethod
|
|
async def get_inbox_by_message_id(cls,session:AsyncSession,message_id):
|
|
try:
|
|
mid=uuid.UUID(str(message_id))
|
|
except ValueError:
|
|
return None
|
|
result=await session.execute(
|
|
select(cls).where(cls.message_id==mid).order_by(cls.created_at.desc())
|
|
)
|
|
return result.scalars().first()
|
|
|
|
@classmethod
|
|
async def get_inbox_by_user_id(cls,session:AsyncSession,user_id):
|
|
try:
|
|
uid=uuid.UUID(str(user_id))
|
|
except ValueError:
|
|
return None
|
|
result=await session.execute(
|
|
select(cls).where(cls.user_id==uid).order_by(cls.created_at.desc())
|
|
)
|
|
return result.scalars().first()
|
|
|
|
@classmethod
|
|
async def update_inbox(cls,session:AsyncSession,record_id,fields:dict):
|
|
row=await cls.get_inbox_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
|
|
|
|
|
|
class Inbox_Alerts(SQLModel, table=True):
|
|
__tablename__ = "inbox_alerts"
|
|
|
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
alert_sender_name: str
|
|
alert_sender_email: str
|
|
is_read: bool = Field(default=False)
|
|
recieve_time: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
|
|
|
inbox: list[Inbox] = Relationship(back_populates="alerts")
|
|
|
|
|
|
class Inbox_Messages(SQLModel, table=True):
|
|
__tablename__ = "inbox_messages"
|
|
|
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
message_id: str | None = Field(default=None, index=True, unique=True)
|
|
full_email_response: dict[str, Any] | None = Field(
|
|
default=None, sa_column=Column(JSONB)
|
|
)
|
|
application_status: Candidate_application_Status = Field(default=Candidate_application_Status.CLOSED)
|
|
message_subject: str
|
|
message_body: str
|
|
message_sent_time: str
|
|
message_received_time: str
|
|
message_from: str
|
|
message_to: str
|
|
message_cc: str | None = Field(default=None)
|
|
message_bcc: str | None = Field(default=None)
|
|
message_read: bool = Field(default=False)
|
|
attachment: bool = Field(default=False)
|
|
message_reply: str | None = Field(default=None)
|
|
file_name: str | None = Field(default=None)
|
|
file_path: str | None = Field(default=None)
|
|
resume_text: str | None = Field(default=None)
|
|
experience: str | None = Field(default=None)
|
|
suggested_job_post_ids: list[str] | None = Field(default=None, sa_column=Column(JSONB))
|
|
assigned_job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id", index=True)
|
|
|
|
match_summary: str | None = Field(default=None)
|
|
match_reasoning: str | None = Field(default=None)
|
|
match_status: str | None = Field(default=None)
|
|
match_error: str | None = Field(default=None)
|
|
matched_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
|
candidate_phone_number: str | None = Field(default="xxx-xxx-xxxx")
|
|
candidate_education: str | None = Field(default=None)
|
|
current_employment: str | None = Field(default=None)
|
|
current_title: str | None = Field(default=None)
|
|
# Denormalised dashboard / list-screen fields. server_default is load-bearing
|
|
# for every NOT NULL column — these arrive as ALTERs on a populated table.
|
|
ats_score: float | None = Field(default=None)
|
|
ats_band: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
|
recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
|
is_duplicate: bool = Field(default=False, sa_column_kwargs={"server_default": "false"})
|
|
source_channel_id: int | None = Field(default=None, foreign_key="source_channels.id")
|
|
processing_state: str = Field(default="unread", sa_column_kwargs={"server_default": "unread"})
|
|
inbox: list[Inbox] = Relationship(back_populates="messages")
|
|
|
|
@staticmethod
|
|
def _body_text(email_data: dict) -> str:
|
|
body = email_data.get("body")
|
|
if isinstance(body, dict):
|
|
return body.get("content") or ""
|
|
if isinstance(body, str):
|
|
return body
|
|
return email_data.get("bodyPreview") or ""
|
|
|
|
|
|
@classmethod
|
|
async def get_all_applications(cls,session:AsyncSession,message_id:uuid.UUID|int|None=None):
|
|
try:
|
|
qry=select(cls.message_id,cls.full_email_response,cls.message_subject,cls.message_from,cls.message_to,cls.message_sent_time,cls.message_read,cls.attachment)
|
|
if message_id:
|
|
qry=qry.where(cls.message_id==message_id)
|
|
result=await session.execute(qry)
|
|
return result.scalars().all()
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
@classmethod
|
|
async def set_match_result(
|
|
cls,
|
|
session: AsyncSession,
|
|
record_id,
|
|
*,
|
|
resume_text=None,
|
|
experience=None,
|
|
candidate_education=None,
|
|
candidate_phone_number=None,
|
|
current_employment=None,
|
|
current_title=None,
|
|
suggested_job_post_ids=None,
|
|
summary="",
|
|
reasoning="",
|
|
status="",
|
|
error="",
|
|
):
|
|
"""Persist agent output onto one inbox row; returns the row or None."""
|
|
row = await cls.get_inbox_message_by_id(session, record_id)
|
|
if not row:
|
|
return None
|
|
if resume_text is not None:
|
|
row.resume_text = resume_text
|
|
if candidate_phone_number is not None:
|
|
row.candidate_phone_number = candidate_phone_number
|
|
if candidate_education is not None:
|
|
row.candidate_education = candidate_education
|
|
if current_employment is not None:
|
|
row.current_employment = current_employment
|
|
if current_title is not None:
|
|
row.current_title = current_title
|
|
row.suggested_job_post_ids = suggested_job_post_ids
|
|
row.match_summary = summary or None
|
|
row.match_reasoning = reasoning or None
|
|
row.match_status = status or None
|
|
row.match_error = error or None
|
|
row.experience = experience or None
|
|
row.matched_at = datetime.now(timezone.utc)
|
|
session.add(row)
|
|
await session.commit()
|
|
await session.refresh(row)
|
|
return row
|
|
|
|
@classmethod
|
|
def _fields_from_email(cls, email_data: dict, file_path: list[str] | None = None) -> dict:
|
|
return {
|
|
"message_subject": email_data.get("subject") or "",
|
|
"message_body": cls._body_text(email_data),
|
|
"message_sent_time": email_data.get("sentDateTime") or "",
|
|
"message_read": bool(email_data.get("isRead")),
|
|
"message_received_time": email_data.get("receivedDateTime") or "",
|
|
"message_from": email_data.get("from", {})
|
|
.get("emailAddress", {})
|
|
.get("address", ""),
|
|
"message_to": ",".join(
|
|
[r["emailAddress"]["address"] for r in email_data.get("toRecipients", [])]
|
|
),
|
|
"message_cc": ",".join(
|
|
[r["emailAddress"]["address"] for r in email_data.get("ccRecipients", [])]
|
|
),
|
|
"message_bcc": ",".join(
|
|
[r["emailAddress"]["address"] for r in email_data.get("bccRecipients", [])]
|
|
),
|
|
"attachment": bool(email_data.get("hasAttachments")),
|
|
"message_reply": ",".join(
|
|
[r["emailAddress"]["address"] for r in email_data.get("replyTo", [])]
|
|
),
|
|
"message_id": email_data.get("id"),
|
|
"file_name": ",".join(
|
|
[r.get("name") for r in email_data.get("attachments", [])]
|
|
),
|
|
"file_path": ",".join(file_path) if file_path else None,
|
|
"full_email_response": email_data,
|
|
}
|
|
|
|
@classmethod
|
|
def _sender_address(cls, email_data: dict) -> str:
|
|
return (
|
|
email_data.get("from", {})
|
|
.get("emailAddress", {})
|
|
.get("address", "")
|
|
or ""
|
|
).strip().lower()
|
|
|
|
@classmethod
|
|
def _sender_display_name(cls, email_data: dict, address: str) -> str:
|
|
name = (
|
|
email_data.get("from", {})
|
|
.get("emailAddress", {})
|
|
.get("name")
|
|
or ""
|
|
).strip()
|
|
if name:
|
|
return name
|
|
return address.split("@", 1)[0] if address else "candidate"
|
|
|
|
@classmethod
|
|
def _is_linkable_sender(cls, address: str) -> bool:
|
|
if not address or "@" not in address:
|
|
return False
|
|
local = address.split("@", 1)[0]
|
|
return not local.startswith(SKIP_SENDER_PREFIXES)
|
|
|
|
@classmethod
|
|
async def _link_sender(cls,session:AsyncSession,email_data:dict,email):
|
|
address=cls._sender_address(email_data)
|
|
if not cls._is_linkable_sender(address):
|
|
return None
|
|
try:
|
|
# id-only: avoid Users.job_posts selectin / role lazy loads under asyncio
|
|
user_id=(await session.execute(
|
|
select(Users.id).where(func.lower(Users.email)==address)
|
|
)).scalar_one_or_none()
|
|
|
|
if user_id is None:
|
|
role=await Roles.get_role_by_name(session,EnumRoles.CANDIDATE.value)
|
|
user=Users(
|
|
name=cls._sender_display_name(email_data,address),
|
|
email=address,
|
|
role_id=role.id if role else CANDIDATE_ROLE_ID_FALLBACK,
|
|
password=hash_password(DEFAULT_CANDIDATE_PASSWORD),
|
|
)
|
|
session.add(user)
|
|
# autoflush=False: flush so users.id exists before inbox FK insert
|
|
# (Relationship helps ordering, but flush keeps this path explicit).
|
|
await session.flush()
|
|
session.add(Inbox(user_id=user.id,message_id=email.id))
|
|
await session.commit()
|
|
return address
|
|
|
|
link=(await session.execute(
|
|
select(Inbox.id).where(Inbox.message_id==email.id,Inbox.user_id==user_id)
|
|
)).scalar_one_or_none()
|
|
if link is None:
|
|
session.add(Inbox(user_id=user_id,message_id=email.id))
|
|
await session.commit()
|
|
return None
|
|
except IntegrityError:
|
|
await session.rollback()
|
|
return None
|
|
except Exception as e:
|
|
await session.rollback()
|
|
logger.warning("sender link failed for %s: %s",address,e)
|
|
return None
|
|
|
|
@classmethod
|
|
async def insert_email(
|
|
cls,
|
|
session: AsyncSession,
|
|
email_data: dict,
|
|
file_path: list[str] | None = None,
|
|
):
|
|
"""Returns (row, new_user_email). new_user_email is set only when this call
|
|
created the sender's Users row."""
|
|
fields = cls._fields_from_email(email_data, file_path)
|
|
external_id = fields.get("message_id")
|
|
link_user=None
|
|
if external_id:
|
|
existing = (
|
|
await session.execute(
|
|
select(cls).where(cls.message_id == external_id)
|
|
)
|
|
).scalars().first()
|
|
if existing:
|
|
for key, value in fields.items():
|
|
setattr(existing, key, value)
|
|
session.add(existing)
|
|
await session.commit()
|
|
await session.refresh(existing)
|
|
|
|
if fields.get("attachment"):
|
|
link_user=await cls._link_sender(session, email_data, existing)
|
|
# _link_sender may rollback (IntegrityError); that expires this row
|
|
await session.refresh(existing)
|
|
return existing, link_user
|
|
|
|
email = cls(**fields)
|
|
session.add(email)
|
|
await session.commit()
|
|
await session.refresh(email)
|
|
|
|
if fields.get("attachment"):
|
|
link_user=await cls._link_sender(session, email_data, email)
|
|
await session.refresh(email)
|
|
return email, link_user
|
|
|
|
@classmethod
|
|
def _search_filter(cls, search: str):
|
|
pattern = f"%{search}%"
|
|
return or_(
|
|
cls.message_subject.ilike(pattern),
|
|
cls.message_from.ilike(pattern),
|
|
cls.message_body.ilike(pattern),
|
|
)
|
|
|
|
@classmethod
|
|
async def get_inbox_messages(
|
|
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None
|
|
):
|
|
statement = select(cls).order_by(cls.message_received_time.desc())
|
|
if search:
|
|
statement = statement.where(cls._search_filter(search))
|
|
|
|
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
|
statement = statement.where(cls.application_status==application_status)
|
|
|
|
if assigned is True:
|
|
statement = statement.where(cls.assigned_job_post_id.is_not(None))
|
|
elif assigned is False:
|
|
statement = statement.where(cls.assigned_job_post_id.is_(None))
|
|
|
|
if skip:
|
|
statement = statement.offset(skip)
|
|
|
|
if top is not None:
|
|
statement = statement.limit(top)
|
|
|
|
if isread==False:
|
|
statement = statement.where(cls.message_read==False)
|
|
result = await session.execute(statement)
|
|
return result.scalars().all()
|
|
|
|
@classmethod
|
|
async def get_inbox_message_by_id(cls, session: AsyncSession, record_id: str):
|
|
try:
|
|
uid = uuid.UUID(str(record_id))
|
|
except ValueError:
|
|
return None
|
|
result = await session.execute(select(cls).where(cls.id == uid))
|
|
return result.scalars().first()
|
|
|
|
@classmethod
|
|
async def set_ats_score(cls, session: AsyncSession, record_id, score, band):
|
|
row = await cls.get_inbox_message_by_id(session, record_id)
|
|
if not row:
|
|
return None
|
|
row.ats_score = float(score)
|
|
row.ats_band = band or ""
|
|
session.add(row)
|
|
await session.commit()
|
|
await session.refresh(row)
|
|
return row
|
|
|
|
@classmethod
|
|
async def set_assigned_job_post(cls, session: AsyncSession, record_id, job_post_id):
|
|
"""Set or clear assigned_job_post_id; returns the row or None if missing."""
|
|
row = await cls.get_inbox_message_by_id(session, record_id)
|
|
if not row:
|
|
return None
|
|
if job_post_id is None:
|
|
row.assigned_job_post_id = None
|
|
else:
|
|
try:
|
|
row.assigned_job_post_id = uuid.UUID(str(job_post_id))
|
|
except ValueError:
|
|
return None
|
|
session.add(row)
|
|
await session.commit()
|
|
await session.refresh(row)
|
|
return row
|
|
|
|
@classmethod
|
|
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None):
|
|
statement = select(func.count()).select_from(cls)
|
|
if search:
|
|
statement = statement.where(cls._search_filter(search))
|
|
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
|
statement = statement.where(cls.application_status==application_status)
|
|
if assigned is True:
|
|
statement = statement.where(cls.assigned_job_post_id.is_not(None))
|
|
elif assigned is False:
|
|
statement = statement.where(cls.assigned_job_post_id.is_(None))
|
|
if isread==False:
|
|
statement = statement.where(cls.message_read==False)
|
|
result = await session.execute(statement)
|
|
return result.scalar_one()
|
|
|
|
@classmethod
|
|
async def apply_read_status(cls, session: AsyncSession, changes) -> int:
|
|
"""[{id, isRead, ...}] -> bulk UPDATE message_read. Returns rows touched.
|
|
|
|
read is a ONE-WAY LATCH: only false -> true is applied, never the reverse.
|
|
mark_message_read writes the local column only — nothing pushes the state
|
|
back to Outlook — so upstream keeps reporting isRead=false and the
|
|
every-minute sync_read_status sweep would otherwise revert a mail the user
|
|
just opened. Cost of the latch: un-reading a mail in Outlook no longer
|
|
propagates here.
|
|
"""
|
|
if not changes:
|
|
return 0
|
|
read_ids=[c.get("id") for c in changes if c.get("id") and c.get("isRead")]
|
|
if not read_ids:
|
|
return 0
|
|
result=await session.execute(
|
|
update(cls).where(cls.message_id.in_(read_ids)).values(message_read=True)
|
|
)
|
|
await session.commit()
|
|
return result.rowcount or 0
|
|
|
|
@classmethod
|
|
async def mark_message_read(cls, session: AsyncSession, record_id):
|
|
row=await cls.get_inbox_message_by_id(session,record_id)
|
|
if not row:
|
|
return None
|
|
row.message_read=True
|
|
session.add(row)
|
|
await session.commit()
|
|
await session.refresh(row)
|
|
return row
|
|
|
|
|
|
class SourceChannels(SQLModel, table=True):
|
|
__tablename__ = "source_channels"
|
|
|
|
id: int | None = Field(default=None, primary_key=True)
|
|
key: str = Field(max_length=40, unique=True, index=True)
|
|
label: str
|
|
is_active: bool = Field(default=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))
|
|
|
|
@classmethod
|
|
async def get_by_id(cls, session: AsyncSession, record_id: int):
|
|
result = await session.execute(select(cls).where(cls.id == record_id))
|
|
return result.scalars().first()
|
|
|
|
@classmethod
|
|
async def get_by_key(cls, session: AsyncSession, key: str):
|
|
result = await session.execute(select(cls).where(cls.key == key))
|
|
return result.scalars().first()
|
|
|
|
@classmethod
|
|
async def list_active(cls, session: AsyncSession):
|
|
result = await session.execute(
|
|
select(cls).where(cls.is_active == True).order_by(cls.id.asc()) # noqa: E712
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
class AtsResults(SQLModel, table=True):
|
|
__tablename__ = "ats_results"
|
|
|
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
# Inbox applications link here; NULL for upload-sourced scores, which have no
|
|
# inbox row. Identity is XOR: matching users.email -> user_id (candidate_id
|
|
# NULL); otherwise candidate_id (user_id NULL). Blank email is the latter.
|
|
inbox_id: int | None = Field(default=None, index=True, foreign_key="inbox.id")
|
|
candidate_id: uuid.UUID | None = Field(default=None, index=True, foreign_key="candidates.id")
|
|
user_id: uuid.UUID | None = Field(default=None, index=True, foreign_key="users.id")
|
|
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
|
|
overall_score: float = Field(default=0.0)
|
|
band: str = Field(default="")
|
|
is_current: bool = Field(default=True)
|
|
superseded_by_id: uuid.UUID | None = Field(default=None, foreign_key="ats_results.id")
|
|
model_name: str | None = Field(default=None)
|
|
# default_factory was datetime.now: a naive LOCAL value bound to a timestamptz
|
|
# column, which asyncpg reads as UTC. That silently backdated every row by the
|
|
# host's offset (+5 h here) instead of raising, unlike the naive-column case.
|
|
computed_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
|
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
|
|
|
@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_current_for_inbox(cls, session: AsyncSession, inbox_id: int):
|
|
result = await session.execute(
|
|
select(cls)
|
|
.where(cls.inbox_id == int(inbox_id), cls.is_current == True) # noqa: E712
|
|
.order_by(cls.computed_at.desc())
|
|
)
|
|
return result.scalars().first()
|
|
|
|
@classmethod
|
|
async def get_for_inbox_job(cls, session: AsyncSession, inbox_id, job_post_id):
|
|
"""Any score for this application against this job — current or superseded.
|
|
|
|
Inbox-side idempotency is (inbox_id, job_post_id) only. candidate_id may
|
|
be NULL when the CV email matched a user; do not key off it here.
|
|
"""
|
|
jid = cls._as_uuid(job_post_id)
|
|
if jid is None:
|
|
return None
|
|
result = await session.execute(
|
|
select(cls)
|
|
.where(cls.inbox_id == int(inbox_id), cls.job_post_id == jid)
|
|
.order_by(cls.computed_at.desc())
|
|
)
|
|
return result.scalars().first()
|
|
|
|
@classmethod
|
|
async def resolve_identity(cls, session: AsyncSession, email, candidate_id):
|
|
"""XOR identity for a score row from the scored candidate's email.
|
|
|
|
Matching users.email (case-insensitive) -> user_id, candidate_id NULL.
|
|
Missing/blank email or no user -> candidate_id, user_id NULL.
|
|
"""
|
|
normalized = (email or "").strip().lower()
|
|
if normalized:
|
|
user_id = (
|
|
await session.execute(
|
|
select(Users.id).where(func.lower(Users.email) == normalized)
|
|
)
|
|
).scalar_one_or_none()
|
|
if user_id is not None:
|
|
return {"candidate_id": None, "user_id": user_id}
|
|
return {"candidate_id": candidate_id, "user_id": None}
|
|
|
|
@classmethod
|
|
async def get_current_for_candidate(cls, session: AsyncSession, candidate_id):
|
|
"""Current row for an upload-sourced score, chained per candidates row —
|
|
stable across re-scores because upsert_candidate keeps the same id for
|
|
the same (job, content_sha256)."""
|
|
result = await session.execute(
|
|
select(cls)
|
|
.where(cls.candidate_id == candidate_id, cls.is_current == True) # noqa: E712
|
|
.order_by(cls.computed_at.desc())
|
|
)
|
|
return result.scalars().first()
|
|
|
|
@classmethod
|
|
async def get_current_for_user(cls, session: AsyncSession, user_id, job_post_id=None):
|
|
"""Current upload-sourced score for a matched user, scoped per job."""
|
|
uid = cls._as_uuid(user_id)
|
|
if uid is None:
|
|
return None
|
|
qry = select(cls).where(cls.user_id == uid, cls.is_current == True) # noqa: E712
|
|
jid = cls._as_uuid(job_post_id) if job_post_id is not None else None
|
|
if jid is not None:
|
|
qry = qry.where(cls.job_post_id == jid)
|
|
result = await session.execute(qry.order_by(cls.computed_at.desc()))
|
|
return result.scalars().first()
|
|
|
|
@classmethod
|
|
async def insert_result(cls, session: AsyncSession, fields: dict):
|
|
"""Insert a score row and supersede the previous current one.
|
|
|
|
Inbox scores chain on inbox_id and repoint inbox.ats_id (candidate_id
|
|
may be NULL). Upload scores chain on candidate_id, or on (user_id,
|
|
job_post_id) when identity resolved to a user. Flush the INSERT first:
|
|
with no relationship() edge the unit of work emits the UPDATEs first,
|
|
and the FKs reject a pointer to a row not yet inserted."""
|
|
inbox_id = fields.get("inbox_id")
|
|
candidate_id = fields.get("candidate_id")
|
|
user_id = fields.get("user_id")
|
|
job_post_id = fields.get("job_post_id")
|
|
if inbox_id is not None:
|
|
prev = await cls.get_current_for_inbox(session, inbox_id)
|
|
elif candidate_id is not None:
|
|
prev = await cls.get_current_for_candidate(session, candidate_id)
|
|
elif user_id is not None:
|
|
prev = await cls.get_current_for_user(session, user_id, job_post_id)
|
|
else:
|
|
prev = None
|
|
row = cls(**fields)
|
|
session.add(row)
|
|
await session.flush()
|
|
if prev is not None:
|
|
prev.is_current = False
|
|
prev.superseded_by_id = row.id
|
|
session.add(prev)
|
|
if inbox_id is not None:
|
|
link = await Inbox.get_inbox_by_id(session, inbox_id)
|
|
if link is not None:
|
|
link.ats_id = row.id
|
|
link.updated_at = _now()
|
|
session.add(link)
|
|
await session.commit()
|
|
return row
|