from datetime import date, time import logging import os import uuid from pathlib import Path from uuid import UUID import httpx from dotenv import load_dotenv from fastapi import HTTPException from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, model_validator from inbox.models import Inbox_Messages from job.assignment.views import Assignment from job.job_post.enums import RequisitionStatus from job.job_post.models import JobPostImages,JobPostStatusHistory,JobPosts,SocialPlatform from role.models import EnumRoles 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, serialize_job_stats, serialize_status_history load_dotenv() logger=logging.getLogger("job.job_post") # Cover images live in the job_post_images table (bytea), NOT on disk: # production containers have ephemeral filesystems, so a file-backed image # would vanish on every redeploy. One row per post; re-upload replaces it. ALLOWED_IMAGE_TYPES={"image/png","image/jpeg","image/webp","image/gif"} IMAGE_TYPE_BY_EXT={"png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","webp":"image/webp","gif":"image/gif"} MAX_JOB_IMAGE_BYTES=5*1024*1024 def _job_image_key(job_post_id) -> uuid.UUID: try: return uuid.UUID(str(job_post_id)) except ValueError as e: raise HTTPException(status_code=422,detail="job_post_id must be a UUID") from e 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 hiring_manager_id: UUID | None = None current_recruiter_id: UUID | None = None requisition_id: UUID | 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" assignment=Assignment(self.session) hm=None if payload.get("hiring_manager_id"): hm=await assignment.require_role( payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id", ) fields["hiring_manager_id"]=hm.id rec=None if payload.get("current_recruiter_id"): rec=await assignment.require_role( payload.get("current_recruiter_id"),EnumRoles.RECRUITER,"current_recruiter_id", ) fields["current_recruiter_id"]=rec.id if payload.get("requisition_id"): from candidate_forms.models import Requisition req=await Requisition.get_form_by_id( self.session, record_id=str(payload["requisition_id"]), ) if not req: raise HTTPException(status_code=404, detail="Requisition not found") held=await JobPosts.get_by_requisition_id(self.session, req.id) if held: raise HTTPException( status_code=409, detail="This requisition is already linked to a job post", ) fields["requisition_id"]=req.id try: row=await JobPosts.insert_job_post(self.session,fields) except IntegrityError as e: orig=str(getattr(e,"orig",e)).lower() if "requisition" in orig: raise HTTPException( status_code=409,detail="This requisition is already linked to a job post", ) from e raise assigned_by=current_user.get("id") if isinstance(current_user,dict) else None if hm: await assignment.record_job_owner(row.id,hm.id,"hiring_manager",assigned_by) if rec: await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by) 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_job_stats(self,job_post_id=None,search=None,ids=None,top=None,skip=0,active_only=False): uid=None if job_post_id not in (None,""): uid=JobPosts._as_uuid(job_post_id) if uid is None: raise HTTPException(status_code=422,detail="job_post_id must be a UUID") rows,total=await JobPosts.fetch_job_stats( self.session, job_post_id=uid, search=search, ids=ids, top=top, skip=skip, active_only=active_only, ) data=[serialize_job_stats(r) for r in rows] if uid is not None: if not data: raise HTTPException(status_code=404,detail="Job post not found") return data[0],1 return data,total async def fetch_departments(self,active_only=False): return await JobPosts.list_departments(self.session,active_only=active_only) async def fetch_requisition_statuses(self): return RequisitionStatus.as_list() async def fetch_status_history(self,job_post_id): job=await JobPosts.get_job_post_by_id(self.session,job_post_id) if not job or job.is_deleted: raise HTTPException(status_code=404,detail="Job post not found") rows=await JobPostStatusHistory.fetch_by_job(self.session,job_post_id) names=await Users.names_by_ids(self.session,[r.changed_by for r in rows]) return [ serialize_status_history(r,changed_by_name=names.get(str(r.changed_by))) for r in rows ] async def fetch_jobs(self,search=None,department=None,requisition_status=None, employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True): hm_uid=None if hiring_manager_id: hm_uid=JobPosts._as_uuid(hiring_manager_id) if hm_uid is None: raise HTTPException(status_code=422,detail="hiring_manager_id must be a UUID") 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,hiring_manager_id=hm_uid, ) names=await Users.names_by_ids( self.session, [r.current_recruiter_id for r in rows]+[r.hiring_manager_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)), hiring_manager_name=names.get(str(r.hiring_manager_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,row.hiring_manager_id], ) return serialize_job_row( row, recruiter_name=names.get(str(row.current_recruiter_id)), hiring_manager_name=names.get(str(row.hiring_manager_id)), ) async def update_job(self,job_post_id,payload,current_user): if not current_user: raise HTTPException(status_code=401,detail="Not authenticated") existing=await JobPosts.get_job_post_by_id(self.session,job_post_id) if not existing or existing.is_deleted: raise HTTPException(status_code=404,detail="Job post not found") 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"]="" assignment=Assignment(self.session) assigned_by=current_user.get("id") if isinstance(current_user,dict) else None hm_changed=False rec_changed=False if "hiring_manager_id" in payload: raw=payload.get("hiring_manager_id") if raw is None or raw=="": fields["hiring_manager_id"]=None hm_changed=existing.hiring_manager_id is not None else: hm=await assignment.require_role(raw,EnumRoles.HIRING_MANAGER,"hiring_manager_id") fields["hiring_manager_id"]=hm.id hm_changed=str(existing.hiring_manager_id)!=str(hm.id) if "requisition_id" in payload: raw=payload.get("requisition_id") if raw is None or raw=="": fields["requisition_id"]=None else: from candidate_forms.models import Requisition req=await Requisition.get_form_by_id(self.session,record_id=str(raw)) if not req: raise HTTPException(status_code=404,detail="Requisition not found") held=await JobPosts.get_by_requisition_id(self.session, req.id) if held and str(held.id)!=str(existing.id): raise HTTPException( status_code=409, detail="This requisition is already linked to a job post", ) fields["requisition_id"]=req.id if "current_recruiter_id" in payload: raw=payload.get("current_recruiter_id") if raw is None or raw=="": fields["current_recruiter_id"]=None rec_changed=existing.current_recruiter_id is not None else: rec=await assignment.require_role(raw,EnumRoles.RECRUITER,"current_recruiter_id") fields["current_recruiter_id"]=rec.id rec_changed=str(existing.current_recruiter_id)!=str(rec.id) if not fields: raise HTTPException(status_code=400,detail="No fields to update") try: row=await JobPosts.update_job_post(self.session,job_post_id,fields) except IntegrityError as e: orig=str(getattr(e,"orig",e)).lower() if "requisition" in orig: raise HTTPException( status_code=409,detail="This requisition is already linked to a job post", ) from e raise if not row: raise HTTPException(status_code=404,detail="Job post not found") if hm_changed: await assignment.record_job_owner( job_post_id,fields["hiring_manager_id"],"hiring_manager",assigned_by, ) if rec_changed: await assignment.record_job_owner( job_post_id,fields.get("current_recruiter_id"),"primary_recruiter",assigned_by, ) 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 save_job_image(self,job_post_id,filename,content_type,content,current_user): if not current_user: raise HTTPException(status_code=401,detail="Not authenticated") key=_job_image_key(job_post_id) media=(content_type or "").lower() if media not in ALLOWED_IMAGE_TYPES: # Fall back to the filename extension; browsers occasionally send # application/octet-stream for perfectly valid images. suffix=Path((filename or "").replace("\\","/")).suffix.lstrip(".").lower() media=IMAGE_TYPE_BY_EXT.get(suffix) if not media: raise HTTPException(status_code=415,detail="Image must be PNG, JPG, WEBP or GIF") if not content: raise HTTPException(status_code=400,detail="Empty image upload") if len(content)>MAX_JOB_IMAGE_BYTES: raise HTTPException(status_code=413,detail="Image must be under 5 MB") rows,total=await JobPosts.fetch_job_posts(self.session,ids=[str(key)],active_only=False) if not total: raise HTTPException(status_code=404,detail="Job post not found") raw_user=(current_user or {}).get("id") uploaded_by=uuid.UUID(str(raw_user)) if raw_user else None await JobPostImages.upsert( self.session,key, content_type=media, file_name=Path((filename or "").replace("\\","/")).name or None, data=content, uploaded_by=uploaded_by, ) return {"job_post_id":str(key),"has_image":True} async def get_job_image(self,job_post_id): key=_job_image_key(job_post_id) row=await JobPostImages.get(self.session,key) if not row: raise HTTPException(status_code=404,detail="No image for this job post") return row.data,row.content_type 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() parsed=RequisitionStatus.parse(status) if parsed is None: raise HTTPException( status_code=422, detail=f"requisition_status must be one of {', '.join(RequisitionStatus.values())}", ) status=parsed.value actor=current_user.get("id") if isinstance(current_user,dict) else None row=await JobPosts.set_requisition_status( self.session,job_post_id,status,changed_by=actor, ) if not row: raise HTTPException(status_code=404,detail="Job post not found") if status==RequisitionStatus.CLOSED.value: 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)