From f884ae1d1d006835ab2d2c4fa9cfc874c887af45 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 5 Aug 2026 20:41:39 +0500 Subject: [PATCH] Api for @router.post("/job/post-job") done --- backend/job/app.py | 13 ++++++++ backend/job/job_post/plugins.py | 56 +++++++++++++++++++++++++++++++++ backend/job/job_post/views.py | 44 ++++++++++++++++++++++---- 3 files changed, 107 insertions(+), 6 deletions(-) diff --git a/backend/job/app.py b/backend/job/app.py index a1df207..62e22cb 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, model_validator from users.permissions import PermissionTag, require_permission from job.job_post.views import JobPost +from job.job_post.plugins import PlatformAlias from dotenv import load_dotenv load_dotenv() @@ -21,7 +22,9 @@ class JobPostCreate(BaseModel): salary: str = "Anonymous" location: str | None = None employment_type: str | None = None + platform: str = "linkedin" description: str | None = None + platform: str | None = None channel_id: str | None = None mode: str = "addToQueue" due_at: str | None = None @@ -35,6 +38,16 @@ class JobPostCreate(BaseModel): raise ValueError("due_at is required when mode is customScheduled") return self +@router.get("/jobs/alias") +async def get_job_alias(): + try: + alias_lst=[k.name for k in PlatformAlias] + return JSONResponse(content={"data":alias_lst,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + @router.post("/candidate/cv_upload") async def cv_upload( diff --git a/backend/job/job_post/plugins.py b/backend/job/job_post/plugins.py index 3c53fb4..b19fa26 100644 --- a/backend/job/job_post/plugins.py +++ b/backend/job/job_post/plugins.py @@ -9,6 +9,7 @@ import json import os import re from datetime import datetime +from enum import Enum import httpx from dotenv import load_dotenv @@ -39,6 +40,61 @@ def local_status(buffer_status) -> str: return BUFFER_STATUS_TO_LOCAL.get(buffer_status or "", "scheduled") +class PlatformAlias(str, Enum): + """Shorthands people type, mapped to Buffer's own `Service` values. + + Member name = what arrives in the request, value = what Buffer calls it. This is + spelling tolerance only, *not* the list of supported networks: anything absent here + still resolves, because `resolve_channel` matches against the services Buffer + actually reports. A newly connected network needs no entry. + + Names that share a value (ig/insta) become Enum aliases, which is exactly the + intent -- lookup is by member name via `__members__`. + """ + + fb = "facebook" + ig = "instagram" + insta = "instagram" + li = "linkedin" + x = "twitter" + tweet = "twitter" + yt = "youtube" + gbp = "googlebusiness" + google = "googlebusiness" + googlebusinessprofile = "googlebusiness" + + +def normalize_platform(value) -> str: + """Case/punctuation-insensitive key for comparing a requested platform.""" + key = re.sub(r"[^a-z0-9]", "", str(value or "").lower()) + alias = PlatformAlias.__members__.get(key) + return alias.value if alias else key + + +async def resolve_channel(platform, channels=None) -> dict: + """Return the connected channel for `platform`. + + Nothing is special-cased per network: the request is matched against the services + Buffer reports, so any platform Buffer supports and the account has connected will + resolve. Falls back to matching the channel's handle or display name so + "ahmedmujtababaig" works as well as "linkedin". + """ + wanted = normalize_platform(platform) + if not wanted: + raise BufferError("No platform given") + if channels is None: + channels = await list_buffer_channels() + for field in ("service", "name", "displayName"): + for channel in channels: + if normalize_platform(channel.get(field)) == wanted: + return channel + available = sorted({c.get("service") for c in channels if c.get("service")}) + raise BufferError( + f"No Buffer channel connected for platform {platform!r}. " + f"Connected: {', '.join(available) if available else 'none'}" + ) + + def parse_buffer_datetime(value): """Buffer sends ISO 8601 with a trailing `Z`, which fromisoformat wants as +00:00.""" if not value: diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 3b9977f..b80d985 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -11,8 +11,10 @@ from job.job_post.plugins import ( 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 @@ -25,13 +27,33 @@ class JobPost: self.buffer_api=os.getenv("BUFFER_API") self.channel_id=os.getenv("BUFFER_CHANNEL_ID") + async def _resolve_target(self,payload): + """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"]) + 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): - channel_id=payload.get("channel_id") or self.channel_id - if not channel_id: - raise HTTPException(status_code=500,detail="BUFFER_CHANNEL_ID is not configured") + try: + channel_id,service=await self._resolve_target(payload) + except (httpx.HTTPError,BufferError,RuntimeError) as e: + raise HTTPException(status_code=502,detail=f"Failed to resolve Buffer channel: {e}") from e text=render_job_post(payload) - row=await JobPosts.insert_job_post(self.session,{ + fields={ "title":payload.get("title"), "employment_type":payload.get("employment_type"), "location":payload.get("location"), @@ -45,7 +67,14 @@ class JobPost: "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")) + if known_platform: + fields["platform"]=known_platform + row=await JobPosts.insert_job_post(self.session,fields) try: post=await create_buffer_post( @@ -55,8 +84,11 @@ class JobPost: due_at=payload.get("due_at"), ) except (httpx.HTTPError,BufferError,RuntimeError) as e: + # Include the reason: Buffer's rejections are actionable (duplicate text, + # daily limit, disconnected channel) and an opaque 502 sends the caller + # digging through job_posts.buffer_error to find out. await JobPosts.mark_failed(self.session,str(row.id),str(e)) - raise HTTPException(status_code=502,detail="Failed to publish job post to Buffer") from e + raise HTTPException(status_code=502,detail=f"Failed to publish job post to Buffer: {e}") from e # Buffer accepting the mutation is not the same as the network publishing it: # the default addToQueue mode returns `scheduled`, so the row only reads