301 lines
12 KiB
Python
301 lines
12 KiB
Python
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 |