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

244 lines
8.8 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_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlmodel import Field, Relationship, SQLModel, select
from role.models import 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"}
)
# selectin, not joined: this is a one-to-many, so a joined load would repeat the
# user row once per post. Without an explicit strategy the default is a lazy load,
# which raises MissingGreenlet the moment anything touches it under asyncio.
# foreign_keys must match the other side: job_posts.current_recruiter_id is a
# second FK into this table, so this relation has to say it means created_by.
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)
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())
)
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 names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
"""Resolve {user_id: name} in a single query.
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 {}
result = await session.execute(
select(cls.id, cls.name).where(cls.id.in_(uids))
)
return {str(uid): name for uid, name in result.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)
result = await session.execute(statement)
return result.scalars().first()
@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 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())
)
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()
return await cls.get_user_by_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
import job.candidate.models as _candidate_models # noqa: E402, F401