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

412 lines
16 KiB
Python

from optparse import Option
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING,List,Optional
from sqlalchemy import DateTime, func, or_, update
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlmodel import Field, Relationship, SQLModel, select
from role.models import EnumRoles, Roles
from job.job_post.models import JobPosts
if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this module
from inbox.models import Inbox
from job.candidate.models import Feedback, Notes
def _now() -> datetime:
return datetime.now(timezone.utc)
class Users(SQLModel, table=True):
__tablename__ = "users"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
name: str
email: str = Field(unique=True)
role_id: int | None = Field(nullable=True, foreign_key="roles.id")
role: Roles | None = Relationship(back_populates="users",
sa_relationship_kwargs={"lazy": "selectin"}
)
job_posts: List[JobPosts] = Relationship(
back_populates="user",
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},
)
inbox: List["Inbox"] = Relationship(
back_populates="user",
sa_relationship_kwargs={"lazy": "selectin"},
)
feedback: List["Feedback"] = Relationship(
back_populates="user",
sa_relationship_kwargs={"lazy": "selectin"},
)
notes: List["Notes"] = Relationship(
back_populates="user",
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[Notes.user_id]"},
)
authored_notes: List["Notes"] = Relationship(
back_populates="author",
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[Notes.created_by]"},
)
password: str
# Public profile URL extracted from a CV at ingest. NULL until a CV
# mentions LinkedIn; never overwrite a stored value with empty.
linkedin_url: str | None = Field(default=None)
city: str | None = Field(default=None)
professional_summary: str | None = Field(default=None)
# Prior job_post_ids for this email across candidate tables. [] until a
# later application finds an already-linked job.
reapplied: list[str] = Field(default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"})
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_active: bool = Field(default=False)
is_approved: bool = Field(default=False)
is_deleted: bool = Field(default=False)
@classmethod
async def get_user_id(cls, session: AsyncSession, user_id: str):
uid = cls._as_uuid(user_id)
if uid is None:
return None
statement = select(cls).where(cls.id == uid)
result = await session.execute(statement)
return result.scalars().first()
@classmethod
def _search_filter(cls, search: str):
pattern = f"%{search}%"
return or_(
cls.name.ilike(pattern),
cls.email.ilike(pattern),
)
@staticmethod
def _as_uuid(record_id: str) -> uuid.UUID | None:
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_users(
cls, session: AsyncSession,
top: Optional[int]=None,
skip: Optional[int]=None,
search: Optional[str]=None,
role_id:Optional[int]=None
):
statement = (
select(cls)
.options(selectinload(cls.role))
.where(cls.is_deleted == False)
.order_by(cls.created_at.desc(),cls.id.desc())
)
if search:
statement = statement.where(cls._search_filter(search))
if skip:
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
if role_id:
statement = statement.where(cls.role_id == role_id)
if not role_id:
statement = statement.where(cls.role_id != 8)
result = await session.execute(statement)
return result.scalars().all()
@classmethod
async def list_by_role_name(cls, session: AsyncSession, role_name, user_id=None):
"""Active (non-deleted) users whose Roles.role_name matches. Optional id filter."""
statement = (
select(cls)
.join(Roles, cls.role_id == Roles.id)
.where(
Roles.role_name == role_name,
cls.is_deleted == False, # noqa: E712
)
.order_by(cls.created_at.desc(), cls.id.desc())
)
uid = cls._as_uuid(user_id) if user_id is not None else None
if uid is not None:
statement = statement.where(cls.id == uid)
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod
async def ids_by_role_names(cls, session: AsyncSession, role_names):
"""Non-deleted user ids whose Roles.role_name is in `role_names`."""
names = sorted({(n or "").strip() for n in (role_names or []) if (n or "").strip()})
if not names:
return []
lowers = [n.lower() for n in names]
result = await session.execute(
select(cls.id)
.join(Roles, cls.role_id == Roles.id)
.where(
func.lower(Roles.role_name).in_(lowers),
cls.is_deleted == False, # noqa: E712
)
)
return [row[0] for row in result.all()]
@classmethod
async def names_by_ids(cls, session: AsyncSession, user_ids,search=None,top=None,limit=None) -> dict[str, str]:
"""Resolve {user_id: name} in a single query, whatever role those ids hold.
Shared by departments, offers, history, notifications and the job payloads,
so it stays role-agnostic: a read-time role filter cannot fix bad data, it
only makes names disappear. Role is enforced on write by require_role.
COLUMN select, not the Users entity: `select(cls)` would pull the five
selectin relations (role, job_posts, inbox, feedback, notes) for a
two-column lookup.
"""
uids = {u for u in (user_ids or []) if u}
if not uids:
return {}
statement =(
select(cls.id, cls.name).where(cls.id.in_(uids))
)
if search:
statement = statement.where(cls.name.ilike(f"%{search}%"))
if top:
statement = statement.limit(top)
if limit:
statement = statement.limit(limit)
result = await session.execute(statement)
return {str(uid): name for uid, name in result.all()}
@classmethod
async def job_people(cls, session: AsyncSession, recruiter_ids, manager_ids=None) -> dict[str, dict[str, str]]:
"""{"recruiters": {id: name}, "hiring_manager": {id: name}} — the two job
ownership roles resolved apart, one query each.
They are different roles on a job post, so they never share a container.
The manager side is role-checked (hiring_manager, not deleted) because it
is a single stable owner; recruiters are validated on write. Both sides
take a list, so one call serves a whole page of jobs.
"""
data: dict[str, dict[str, str]] = {"recruiters": {}, "hiring_manager": {}}
rids = {u for u in (recruiter_ids or []) if u}
if rids:
result = await session.execute(select(cls.id, cls.name).where(cls.id.in_(rids)))
data["recruiters"] = {str(uid): name for uid, name in result.all()}
mids = {u for u in (manager_ids or []) if u}
if mids:
result = await session.execute(
select(cls.id, cls.name)
.join(Roles, Roles.id == cls.role_id)
.where(
cls.id.in_(mids),
Roles.role_name == EnumRoles.HIRING_MANAGER.value,
cls.is_deleted == False, # noqa: E712
)
)
data["hiring_manager"] = {str(uid): name for uid, name in result.all()}
return data
@classmethod
async def get_by_ids(cls, session: AsyncSession, ids):
"""Users with role selectin-loaded. UUID keys so callers can map by row.assignee_id."""
uids = []
for raw in ids or []:
uid = cls._as_uuid(raw)
if uid is not None:
uids.append(uid)
if not uids:
return []
result = await session.execute(
select(cls).options(selectinload(cls.role)).where(cls.id.in_(uids))
)
return list(result.scalars().all())
@classmethod
async def get_user_by_id(cls, session: AsyncSession, record_id: str):
uid = cls._as_uuid(record_id)
if uid is None:
return None
statement = select(cls).options(selectinload(cls.role)).where(cls.id == uid,cls.role_id != 8)
result = await session.execute(statement)
return result.scalars().first()
@classmethod
async def get_user_by_email(cls, session: AsyncSession, email: str):
statement = select(cls).options(selectinload(cls.role)).where(cls.email == email,cls.role_id != 8)
result = await session.execute(statement)
return result.scalars().first()
@classmethod
async def get_users_by_emails(cls, session: AsyncSession, emails):
"""Any account matching these addresses, including candidate role_id=8."""
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
if not lowers:
return []
statement = (
select(cls)
.where(func.lower(cls.email).in_(lowers), cls.is_deleted == False) # noqa: E712
)
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod
async def count_users(cls, session: AsyncSession, search: str | None = None, role_id: Optional[int] = None):
statement = (
select(func.count())
.select_from(cls)
.where(cls.is_deleted == False) # noqa: E712
)
if role_id:
statement = statement.where(cls.role_id == role_id)
else:
statement = statement.where(cls.role_id != 8)
if search:
statement = statement.where(cls._search_filter(search))
result = await session.execute(statement)
return result.scalar_one()
@classmethod
async def set_linkedin_url_if_empty(cls, session: AsyncSession, *, user_id=None, email=None, url=None) -> bool:
"""Write linkedin_url only when the user has none yet. Caller commits."""
value = (url or "").strip() or None
if not value:
return False
statement = select(cls)
if user_id is not None:
uid = cls._as_uuid(user_id)
if uid is None:
return False
statement = statement.where(cls.id == uid)
elif email:
statement = statement.where(func.lower(cls.email) == str(email).strip().lower())
else:
return False
user = (await session.execute(statement)).scalars().first()
if user is None or (user.linkedin_url or "").strip():
return False
user.linkedin_url = value
user.updated_at = _now()
session.add(user)
return True
@classmethod
async def set_city_if_empty(cls, session: AsyncSession, *, user_id=None, email=None, city=None) -> bool:
"""Write city only when the user has none yet. Caller commits."""
value = (city or "").strip() or None
if not value:
return False
statement = select(cls)
if user_id is not None:
uid = cls._as_uuid(user_id)
if uid is None:
return False
statement = statement.where(cls.id == uid)
elif email:
statement = statement.where(func.lower(cls.email) == str(email).strip().lower())
else:
return False
user = (await session.execute(statement)).scalars().first()
if user is None or (user.city or "").strip():
return False
user.city = value
user.updated_at = _now()
session.add(user)
return True
@classmethod
async def get_pending_approvals(cls, session: AsyncSession):
"""Email-confirmed staff accounts waiting on an admin to set is_approved."""
statement = (
select(cls)
.options(selectinload(cls.role))
.where(
cls.is_deleted == False, # noqa: E712
cls.is_active == True, # noqa: E712
cls.is_approved == False, # noqa: E712
cls.role_id != 8,
)
.order_by(cls.created_at.desc(), cls.id.desc())
)
result = await session.execute(statement)
return result.scalars().all()
@classmethod
async def insert_user(cls, session: AsyncSession, fields: dict):
"""`fields["password"]` is expected to be hashed already — see users.plugins."""
user = cls(**fields)
session.add(user)
await session.commit()
# get_user_by_id is staff-only (role_id != 8). Candidate inserts must
# still return the row — CV bank / Add Candidate call user.id next.
loaded = await cls.get_user_by_id(session, user.id)
if loaded is not None:
return loaded
return await cls.get_user_id(session, user.id)
@classmethod
async def update_user(cls, session: AsyncSession, record_id: str, fields: dict):
user = await cls.get_user_by_id(session, record_id)
if not user:
return None
for key, value in fields.items():
setattr(user, key, value)
user.updated_at = _now()
session.add(user)
await session.commit()
await session.refresh(user)
return await cls.get_user_by_id(session, user.id)
@classmethod
async def soft_delete_user(cls, session: AsyncSession, record_id: str):
user = await cls.get_user_by_id(session, record_id)
if not user:
return None
user.is_deleted = True
user.is_active = False
user.updated_at = _now()
session.add(user)
await session.commit()
await session.refresh(user)
return user
@classmethod
async def set_reapplied_by_emails(cls, session: AsyncSession, mapping):
"""Write reapplied job_post_id lists keyed by lowercased email."""
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.email)==key).values(reapplied=list(ids or []))
)
updated+=result.rowcount or 0
await session.commit()
return updated
@classmethod
async def set_professional_summary(cls, session: AsyncSession, user_id, summary):
uid = cls._as_uuid(user_id)
if uid is None:
return None
user = await cls.get_user_id(session, uid)
if user is None:
return None
user.professional_summary = (summary or "").strip() or None
user.updated_at = _now()
session.add(user)
await session.commit()
await session.refresh(user)
return user
import job.candidate.models as _candidate_models # noqa: E402, F401