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 also has current_recruiter_id # and hiring_manager_id into this table, so this relation has to say 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 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 ) ) 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 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_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) 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() # 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 import job.candidate.models as _candidate_models # noqa: E402, F401