370 lines
14 KiB
Python
370 lines
14 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 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) -> 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,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
|