import uuid from datetime import datetime, timezone from typing import TYPE_CHECKING, Optional from sqlalchemy import DateTime, JSON, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import Field, Relationship, SQLModel, select if TYPE_CHECKING: # runtime import would be circular: users.models imports this module from users.models import Users def _now() -> datetime: return datetime.now(timezone.utc) class JobPosts(SQLModel, table=True): __tablename__ = "job_posts" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) title: str = Field(index=True) # foreign_keys is required, not decoration: current_recruiter_id below is a # SECOND foreign key into users.id, so the join condition is ambiguous without # it and every mapper fails to initialize. `user` is the AUTHOR of the post — # current_recruiter_id is deliberately a bare column with no relationship of # its own, because Users already carries five selectin relations that load on # every authenticated request. Same pairing as Notes.user / Notes.author. user: Optional["Users"] = Relationship( back_populates="job_posts", sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"}, ) platform: str = Field(default="linkedin") is_active: bool = Field(default=True) is_deleted: bool = Field(default=False) employment_type: str | None = Field(default=None) location: str | None = Field(default=None) experience_min: int | None = Field(default=None) experience_max: int | None = Field(default=None) requirements: list[str] = Field(default_factory=list, sa_type=JSON) optional_skills: list[str] = Field(default_factory=list, sa_type=JSON) salary: str = Field(default="Anonymous") description: str | None = Field(default=None) post_text: str channel_id: str buffer_post_id: str | None = Field(default=None) buffer_external_link: str | None = Field(default=None) buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) status: str = Field(default="draft") buffer_error: str | None = Field(default=None) # requisition_status is the hiring lifecycle (open/closed/on_hold). Distinct from # `status`, which tracks Buffer publishing (draft/scheduled/published/failed). # server_default is load-bearing: this column arrives as an ALTER on a populated table. requisition_status: str = Field(default="open", sa_column_kwargs={"server_default": "open"}) department: str = Field(default="", sa_column_kwargs={"server_default": ""}) vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"}) closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") created_by: uuid.UUID = Field(foreign_key="users.id") created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) @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_job_post_by_id(cls, session: AsyncSession, record_id: str): uid = cls._as_uuid(record_id) if uid is None: return None result = await session.execute(select(cls).where(cls.id == uid)) return result.scalars().first() @classmethod async def get_active_job_posts(cls, session: AsyncSession): result = await session.execute( select(cls).where(cls.is_active == True, cls.is_deleted == False) # noqa: E712 ) return result.scalars().all() @classmethod async def get_by_ids(cls, session: AsyncSession, ids: list[str], *, active_only: bool = True): uids = [] for raw in ids or []: uid = cls._as_uuid(raw) if uid is not None: uids.append(uid) if not uids: return [] statement = select(cls).where(cls.id.in_(uids)) if active_only: statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712 result = await session.execute(statement) rows = list(result.scalars().all()) by_id = {str(r.id): r for r in rows} # Preserve request order so suggestion ranks stay stable. return [by_id[str(u)] for u in uids if str(u) in by_id] @classmethod async def fetch_job_posts( cls, session: AsyncSession, *, search: str | None = None, top: int | None = None, skip: int = 0, ids: list[str] | None = None, active_only: bool = True, include_deleted: bool = False, department: str | None = None, requisition_status: str | None = None, employment_type: str | None = None, ): if ids: rows = await cls.get_by_ids(session, ids, active_only=active_only) return rows, len(rows) statement = select(cls) if active_only: statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712 elif not include_deleted: statement = statement.where(cls.is_deleted == False) # noqa: E712 if search: like = f"%{search.strip()}%" statement = statement.where( or_(cls.title.ilike(like), cls.location.ilike(like), cls.department.ilike(like)) ) if department: statement = statement.where(cls.department == department) if requisition_status: statement = statement.where(cls.requisition_status == requisition_status) if employment_type: statement = statement.where(cls.employment_type == employment_type) count_statement = select(func.count()).select_from(statement.subquery()) total = (await session.execute(count_statement)).scalar_one() statement = statement.order_by(cls.created_at.desc()) if skip: statement = statement.offset(skip) if top is not None: statement = statement.limit(top) result = await session.execute(statement) return list(result.scalars().all()), total @classmethod async def recruiter_names(cls, session: AsyncSession, recruiter_ids) -> dict[str, str]: """Resolve {recruiter_id: name} for a page of rows in a single query.""" # Local import and COLUMN select, both load-bearing: users.models imports # this module at its top, so a module-level import here is a startup cycle; # and a Users *entity* would drag in its five selectin relations for what is # a two-column lookup. from users.models import Users uids = {u for u in (recruiter_ids or []) if u} if not uids: return {} result = await session.execute( select(Users.id, Users.name).where(Users.id.in_(uids)) ) return {str(uid): name for uid, name in result.all()} @classmethod async def insert_job_post(cls, session: AsyncSession, fields: dict): row = cls(**fields) session.add(row) await session.commit() return await cls.get_job_post_by_id(session, row.id) @classmethod async def mark_buffer_result( cls, session: AsyncSession, record_id: str, *, buffer_post_id: str, status: str, external_link: str | None = None, sent_at: datetime | None = None, platform: str | None = None, ): """Record what Buffer reported. `status` is the mapped Buffer PostStatus, not an assumption: a queued post lands here as "scheduled" and only becomes "published" once Buffer says `sent`. """ row = await cls.get_job_post_by_id(session, record_id) if not row: return None row.status = status row.buffer_post_id = buffer_post_id row.buffer_external_link = external_link row.buffer_sent_at = sent_at if platform: row.platform = platform row.buffer_error = None row.updated_at = _now() session.add(row) await session.commit() await session.refresh(row) return row @classmethod async def mark_failed(cls, session: AsyncSession, record_id: str, error: str): row = await cls.get_job_post_by_id(session, record_id) if not row: return None row.status = "failed" row.buffer_error = error row.updated_at = _now() session.add(row) await session.commit() await session.refresh(row) return row @classmethod async def update_job_post(cls, session: AsyncSession, record_id: str, fields: dict): row = await cls.get_job_post_by_id(session, record_id) if not row or row.is_deleted: return None for key, value in fields.items(): setattr(row, key, value) row.updated_at = _now() session.add(row) await session.commit() return await cls.get_job_post_by_id(session, record_id) @classmethod async def soft_delete_job_post(cls, session: AsyncSession, record_id: str): row = await cls.get_job_post_by_id(session, record_id) if not row or row.is_deleted: return None row.is_deleted = True row.is_active = False row.updated_at = _now() session.add(row) await session.commit() await session.refresh(row) return row @classmethod async def set_requisition_status(cls, session: AsyncSession, record_id: str, status: str): row = await cls.get_job_post_by_id(session, record_id) if not row or row.is_deleted: return None previous = row.requisition_status row.requisition_status = status if status == "closed": if previous != "closed" or row.closed_at is None: row.closed_at = _now() else: row.closed_at = None row.updated_at = _now() session.add(row) await session.commit() return await cls.get_job_post_by_id(session, record_id) class SocialPlatform(SQLModel, table=True): """Buffer publish aliases (fb → facebook). Spelling tolerance + UI list, not an allowlist.""" __tablename__ = "social_platforms" id: int | None = Field(default=None, primary_key=True) alias: str = Field(max_length=40, unique=True, index=True) buffer_service: str 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_alias(cls, session: AsyncSession, alias: str): key = (alias or "").strip().lower() if not key: return None result = await session.execute(select(cls).where(cls.alias == 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()) @classmethod async def list_aliases(cls, session: AsyncSession): rows = await cls.list_active(session) return [r.alias for r in rows] @classmethod async def alias_map(cls, session: AsyncSession) -> dict[str, str]: """alias → Buffer service name for normalize_platform / resolve_channel.""" rows = await cls.list_active(session) return {r.alias: r.buffer_service for r in rows} import users.models as _users_models