311 lines
13 KiB
Python
311 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_departments(self,active_only=False):
|
|
return await JobPosts.list_departments(self.session,active_only=active_only)
|
|
|
|
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)
|