HR-ATS-Portal/backend/job/job_post/views.py

308 lines
13 KiB
Python

from datetime import date, time
import logging
import os
import uuid
from pathlib import Path
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 JobPostImages,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")
# 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
@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 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()
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)