from datetime import date, time import logging import os import uuid import httpx from dotenv import load_dotenv from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, model_validator from inbox.models import Inbox_Messages from job.job_post.models import JobPosts,SocialPlatform from users.models import Users from job.job_post.plugins import ( BufferError, create_buffer_post, list_buffer_channels, local_status, normalize_platform, parse_buffer_datetime, render_job_post, resolve_channel, ) from job.job_post.serializers import serialize_job_post, serialize_job_row load_dotenv() logger=logging.getLogger("job.job_post") class JobPostCreate(BaseModel): title: str experience_min: int | None = None experience_max: int | None = None requirements: list[str] = [] optional_skills: list[str] = [] salary: str = "Anonymous" location: str | None = None employment_type: str | None = None department: str | None = None vacancies: int = 1 description: str | None = None platform: str | None = None channel_id: str | None = None mode: str = "addToQueue" scheduler_time: time | None = time(0, 0, 0) scheduler_date: date | None = None due_at: str | None = None @model_validator(mode="after") def validate_mode_and_due_at(self): allowed = {"addToQueue", "shareNow", "customScheduled"} if self.mode not in allowed: raise ValueError(f"mode must be one of {sorted(allowed)}") if self.mode == "customScheduled" and not self.due_at and not self.scheduler_date: raise ValueError("due_at or scheduler_date is required when mode is customScheduled") return self class JobPost: def __init__(self,session:AsyncSession): self.session=session self.buffer_api=os.getenv("BUFFER_API") self.channel_id=os.getenv("BUFFER_CHANNEL_ID") async def _resolve_target(self,payload,aliases=None): """Pick the Buffer channel to post to, and the service it belongs to. Precedence: an explicit channel_id, then the requested platform, then the configured default channel. Returns (channel_id, service) where service is None if we did not have to look the channel up. """ if payload.get("channel_id"): return payload["channel_id"],None if payload.get("platform"): channel=await resolve_channel(payload["platform"],aliases=aliases) return channel["id"],channel.get("service") if self.channel_id: return self.channel_id,None raise HTTPException( status_code=400, detail="Provide channel_id or platform, or configure BUFFER_CHANNEL_ID", ) async def post_job(self,payload,current_user): aliases=await SocialPlatform.alias_map(self.session) # No channel_id and no platform means an internal-only requisition: save the # row for the board/dashboard and never touch Buffer. The env-default channel # fallback only applies when the caller explicitly asked to publish. publish=bool(payload.get("channel_id") or payload.get("platform")) if publish: try: channel_id,service=await self._resolve_target(payload,aliases) except (httpx.HTTPError,BufferError,RuntimeError) as e: raise HTTPException(status_code=502,detail=f"Failed to resolve Buffer channel: {e}") from e else: channel_id,service="",None text=render_job_post(payload) fields={ "title":payload.get("title"), "employment_type":payload.get("employment_type"), "location":payload.get("location"), "experience_min":payload.get("experience_min"), "experience_max":payload.get("experience_max"), "requirements":list(payload.get("requirements") or []), "optional_skills":list(payload.get("optional_skills") or []), "salary":payload.get("salary") or "Anonymous", # department is NOT NULL with a server_default of "" — pass "", never None. "department":payload.get("department") or "", "vacancies":payload.get("vacancies") or 1, "description":payload.get("description"), "post_text":text, "channel_id":channel_id, "status":"draft", "created_by":current_user["id"], } # Only set platform when it is actually known: passing None would override the # column default and break the NOT NULL constraint. Buffer's channelService # replaces this with the authoritative value once the post is created. known_platform=service or normalize_platform(payload.get("platform"),aliases) if known_platform: fields["platform"]=known_platform elif not publish: # Column default is "linkedin"; an unpublished requisition must not # masquerade as a LinkedIn post. fields["platform"]="internal" row=await JobPosts.insert_job_post(self.session,fields) if not publish: return serialize_job_post(row) try: post=await create_buffer_post( text, channel_id, mode=payload.get("mode") or "addToQueue", due_at=payload.get("due_at"), ) except (httpx.HTTPError,BufferError,RuntimeError) as e: await JobPosts.mark_failed(self.session,str(row.id),str(e)) raise HTTPException(status_code=502,detail=f"Failed to publish job post to Buffer: {e}") from e saved=await JobPosts.mark_buffer_result( self.session, str(row.id), buffer_post_id=post["id"], status=local_status(post.get("status")), external_link=post.get("externalLink"), sent_at=parse_buffer_datetime(post.get("sentAt")), platform=post.get("channelService"), ) return serialize_job_post(saved) async def list_channels(self): try: return await list_buffer_channels() except (httpx.HTTPError,BufferError,RuntimeError) as e: raise HTTPException(status_code=502,detail="Failed to list Buffer channels") from e async def fetch_job_posts(self,search=None,top=None,skip=0,ids=None,active_only=True): rows,total=await JobPosts.fetch_job_posts( self.session, search=search, top=top, skip=skip, ids=ids, active_only=active_only, ) return [serialize_job_post(r) for r in rows],total async def fetch_jobs(self,search=None,department=None,requisition_status=None, employment_type=None,top=None,skip=0,active_only=True): rows,total=await JobPosts.fetch_job_posts( self.session,search=search,top=top,skip=skip,active_only=active_only, department=department,requisition_status=requisition_status, employment_type=employment_type, ) names=await Users.names_by_ids( self.session,[r.current_recruiter_id for r in rows], ) counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows]) return [ serialize_job_row( r, recruiter_name=names.get(str(r.current_recruiter_id)), applicant_count=counts.get(str(r.id),0), ) for r in rows ],total async def _job_row(self,row): names=await Users.names_by_ids( self.session,[row.current_recruiter_id] if row.current_recruiter_id else [], ) return serialize_job_row(row,recruiter_name=names.get(str(row.current_recruiter_id))) async def update_job(self,job_post_id,payload,current_user): if not current_user: raise HTTPException(status_code=401,detail="Not authenticated") allowed=("title","department","location","employment_type","vacancies", "salary","experience_min","experience_max","description") fields={k:payload[k] for k in allowed if k in payload} if "salary" not in fields and ("salary_min" in payload or "salary_max" in payload): low=payload.get("salary_min") high=payload.get("salary_max") if low is not None and high is not None: fields["salary"]=f"{low} - {high}" elif low is not None: fields["salary"]=str(low) elif high is not None: fields["salary"]=str(high) if "department" in fields and fields["department"] is None: fields["department"]="" if not fields: raise HTTPException(status_code=400,detail="No fields to update") row=await JobPosts.update_job_post(self.session,job_post_id,fields) if not row: raise HTTPException(status_code=404,detail="Job post not found") return await self._job_row(row) async def delete_job(self,job_post_id,current_user): if not current_user: raise HTTPException(status_code=401,detail="Not authenticated") row=await JobPosts.soft_delete_job_post(self.session,job_post_id) if not row: raise HTTPException(status_code=404,detail="Job post not found") return {"id":str(row.id),"deleted":True} async def set_job_status(self,job_post_id,payload,current_user): if not current_user: raise HTTPException(status_code=401,detail="Not authenticated") status=(payload.get("requisition_status") or "").strip() allowed=("open","closed","on_hold") if status not in allowed: raise HTTPException(status_code=422,detail=f"requisition_status must be one of {', '.join(allowed)}") row=await JobPosts.set_requisition_status(self.session,job_post_id,status) if not row: raise HTTPException(status_code=404,detail="Job post not found") if status=="closed": try: from notifications.models import Notifications raw=row.current_recruiter_id or (current_user.get("id") if current_user else None) recipient=uuid.UUID(str(raw)) if raw else None if recipient: await Notifications.insert_notification(self.session,{ "user_id":recipient, "kind":"approval", "title":"Requisition closed", "body":f"{row.title} was closed", "link_path":"/jobs", "job_post_id":row.id, }) except Exception as exc: logger.warning("notification insert skipped: %s",exc) return await self._job_row(row)