356 lines
13 KiB
Python
356 lines
13 KiB
Python
"""Buffer GraphQL helpers and LinkedIn job-post copy rendering.
|
||
|
||
Pure module: no FastAPI imports and no HTTPException.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
from datetime import datetime
|
||
|
||
import httpx
|
||
from dotenv import load_dotenv
|
||
|
||
load_dotenv()
|
||
|
||
BUFFER_API = os.getenv("BUFFER_API")
|
||
BUFFER_API_URL = os.getenv("BUFFER_API_URL", "https://api.buffer.com")
|
||
BUFFER_CHANNEL_ID = os.getenv("BUFFER_CHANNEL_ID")
|
||
LINKEDIN_POST_MAX_CHARS = 3000
|
||
|
||
# Buffer's PostStatus -> the job_posts.status lifecycle. Only `sent` means the post is
|
||
# actually live on the network: the default `addToQueue` mode comes back as `scheduled`,
|
||
# so treating any successful mutation as "published" would record a post that nobody
|
||
# outside Buffer can see yet.
|
||
BUFFER_STATUS_TO_LOCAL = {
|
||
"sent": "published",
|
||
"sending": "publishing",
|
||
"scheduled": "scheduled",
|
||
"draft": "draft",
|
||
"needs_approval": "needs_approval",
|
||
"error": "failed",
|
||
}
|
||
|
||
|
||
def local_status(buffer_status) -> str:
|
||
"""Map a Buffer PostStatus onto our own. Unknown values stay uncommitted."""
|
||
return BUFFER_STATUS_TO_LOCAL.get(buffer_status or "", "scheduled")
|
||
|
||
|
||
def normalize_platform(value, aliases=None) -> str:
|
||
"""Case/punctuation-insensitive key for comparing a requested platform.
|
||
|
||
`aliases` is an optional alias→Buffer-service map (from SocialPlatform). Missing
|
||
keys fall through to the normalized key so Buffer's live channel list remains the
|
||
real allowlist — DB rows are spelling tolerance only.
|
||
"""
|
||
key = re.sub(r"[^a-z0-9]", "", str(value or "").lower())
|
||
if aliases:
|
||
return aliases.get(key, key)
|
||
return key
|
||
|
||
|
||
async def resolve_channel(platform, channels=None, aliases=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, aliases)
|
||
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), aliases) == 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:
|
||
return None
|
||
try:
|
||
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
class BufferError(RuntimeError):
|
||
def __init__(self, message: str, *, code: str | None = None):
|
||
super().__init__(message)
|
||
self.code = code
|
||
|
||
|
||
def render_job_post(payload) -> str:
|
||
title = (payload.get("title") or "").strip() or "Open Role"
|
||
location = (payload.get("location") or "").strip()
|
||
employment_type = (payload.get("employment_type") or "").strip()
|
||
experience_min = payload.get("experience_min")
|
||
experience_max = payload.get("experience_max")
|
||
requirements = [str(r).strip() for r in (payload.get("requirements") or []) if str(r).strip()]
|
||
optional_skills = [str(s).strip() for s in (payload.get("optional_skills") or []) if str(s).strip()]
|
||
description = (payload.get("description") or "").strip()
|
||
|
||
lines = [f"We're hiring: {title}", ""]
|
||
|
||
meta = []
|
||
if location:
|
||
meta.append(location)
|
||
if employment_type:
|
||
meta.append(employment_type)
|
||
if meta:
|
||
lines.append(" · ".join(meta))
|
||
lines.append("")
|
||
|
||
if experience_min is not None and experience_max is not None:
|
||
lines.append(f"Experience: {experience_min}–{experience_max} years")
|
||
lines.append("")
|
||
elif experience_min is not None:
|
||
lines.append(f"Experience: {experience_min}+ years")
|
||
lines.append("")
|
||
elif experience_max is not None:
|
||
lines.append(f"Experience: up to {experience_max} years")
|
||
lines.append("")
|
||
|
||
if requirements:
|
||
lines.append("Requirements:")
|
||
for item in requirements:
|
||
lines.append(f"• {item}")
|
||
lines.append("")
|
||
|
||
if optional_skills:
|
||
lines.append("Nice to have:")
|
||
for item in optional_skills:
|
||
lines.append(f"• {item}")
|
||
lines.append("")
|
||
|
||
if description:
|
||
lines.append(description)
|
||
lines.append("")
|
||
|
||
lines.append("Interested? Apply via our careers page or reply to this post.")
|
||
lines.append("")
|
||
|
||
tags = []
|
||
for item in requirements:
|
||
tag = re.sub(r"[^A-Za-z0-9]+", "", item)
|
||
if tag:
|
||
tags.append(f"#{tag}")
|
||
if tags:
|
||
lines.append(" ".join(tags))
|
||
|
||
text = "\n".join(lines).strip()
|
||
if len(text) > LINKEDIN_POST_MAX_CHARS:
|
||
text = text[: LINKEDIN_POST_MAX_CHARS - 1].rstrip() + "…"
|
||
return text
|
||
|
||
|
||
def build_create_post_query(text, channel_id, *, mode="addToQueue", due_at=None) -> str:
|
||
fields = [
|
||
f"text: {json.dumps(text)}",
|
||
f"channelId: {json.dumps(channel_id)}",
|
||
"schedulingType: automatic",
|
||
f"mode: {mode}",
|
||
]
|
||
if mode == "customScheduled" and due_at:
|
||
fields.append(f"dueAt: {json.dumps(due_at)}")
|
||
input_block = ",\n ".join(fields)
|
||
return (
|
||
"mutation CreatePost {\n"
|
||
" createPost(input: {\n"
|
||
f" {input_block}\n"
|
||
" }) {\n"
|
||
" ... on PostActionSuccess {\n"
|
||
" post { id text status sentAt externalLink channelService }\n"
|
||
" }\n"
|
||
" ... on MutationError { message }\n"
|
||
" }\n"
|
||
"}"
|
||
)
|
||
|
||
|
||
async def create_buffer_post(text, channel_id, *, mode="addToQueue", due_at=None) -> dict:
|
||
if not BUFFER_API:
|
||
raise RuntimeError("BUFFER_API is not configured")
|
||
query = build_create_post_query(text, channel_id, mode=mode, due_at=due_at)
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
response = await client.post(
|
||
BUFFER_API_URL,
|
||
json={"query": query},
|
||
headers={
|
||
"Authorization": f"Bearer {BUFFER_API}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
)
|
||
if response.status_code != 200:
|
||
raise httpx.HTTPStatusError(
|
||
response.text,
|
||
request=response.request,
|
||
response=response,
|
||
)
|
||
body = response.json()
|
||
errors = body.get("errors")
|
||
if errors:
|
||
first = errors[0] if isinstance(errors, list) and errors else {}
|
||
msg = first.get("message") or "Buffer GraphQL error"
|
||
code = (first.get("extensions") or {}).get("code")
|
||
raise BufferError(msg, code=code)
|
||
create_post = (body.get("data") or {}).get("createPost") or {}
|
||
if "message" in create_post and "post" not in create_post:
|
||
raise BufferError(create_post.get("message") or "Buffer mutation error")
|
||
post = create_post.get("post")
|
||
if not post or not post.get("id"):
|
||
raise BufferError("Buffer did not return a post id")
|
||
return post
|
||
|
||
|
||
async def list_buffer_channels() -> list[dict]:
|
||
if not BUFFER_API:
|
||
raise RuntimeError("BUFFER_API is not configured")
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
orgs_response = await client.post(
|
||
BUFFER_API_URL,
|
||
json={"query": "query { account { organizations { id name } } }"},
|
||
headers={
|
||
"Authorization": f"Bearer {BUFFER_API}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
)
|
||
if orgs_response.status_code != 200:
|
||
raise httpx.HTTPStatusError(
|
||
orgs_response.text,
|
||
request=orgs_response.request,
|
||
response=orgs_response,
|
||
)
|
||
orgs_body = orgs_response.json()
|
||
if orgs_body.get("errors"):
|
||
first = orgs_body["errors"][0]
|
||
raise BufferError(
|
||
first.get("message") or "Buffer GraphQL error",
|
||
code=(first.get("extensions") or {}).get("code"),
|
||
)
|
||
organizations = ((orgs_body.get("data") or {}).get("account") or {}).get("organizations") or []
|
||
channels: list[dict] = []
|
||
for org in organizations:
|
||
org_id = org.get("id")
|
||
if not org_id:
|
||
continue
|
||
channels_query = (
|
||
"query GetChannels {\n"
|
||
f' channels(input:{{organizationId:{json.dumps(org_id)}}}) {{\n'
|
||
" id name displayName service isQueuePaused\n"
|
||
" }\n"
|
||
"}"
|
||
)
|
||
channels_response = await client.post(
|
||
BUFFER_API_URL,
|
||
json={"query": channels_query},
|
||
headers={
|
||
"Authorization": f"Bearer {BUFFER_API}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
)
|
||
if channels_response.status_code != 200:
|
||
raise httpx.HTTPStatusError(
|
||
channels_response.text,
|
||
request=channels_response.request,
|
||
response=channels_response,
|
||
)
|
||
channels_body = channels_response.json()
|
||
if channels_body.get("errors"):
|
||
first = channels_body["errors"][0]
|
||
raise BufferError(
|
||
first.get("message") or "Buffer GraphQL error",
|
||
code=(first.get("extensions") or {}).get("code"),
|
||
)
|
||
for channel in (channels_body.get("data") or {}).get("channels") or []:
|
||
channels.append({
|
||
**channel,
|
||
"organization_id": org_id,
|
||
"organization_name": org.get("name"),
|
||
})
|
||
return channels
|
||
|
||
|
||
# ats_results.band labels, best first — the same cut-offs CandidateView._recommendation writes.
|
||
MATCH_BANDS = ("Strong Match", "Potential Match", "Weak Match")
|
||
TOP_MATCH_BAND = MATCH_BANDS[0]
|
||
|
||
|
||
def suggested_source(inbox_id, form_data_id, candidate_source=None) -> str:
|
||
"""Where a suggested candidate's score came from: inbox | form | upload | bank."""
|
||
if inbox_id is not None:
|
||
return "inbox"
|
||
if form_data_id is not None:
|
||
return "form"
|
||
return (candidate_source or "").strip() or "upload"
|
||
|
||
|
||
def _skill_key(value) -> str:
|
||
return " ".join(re.sub(r"[^a-z0-9+#]+", " ", str(value or "").lower()).split())
|
||
|
||
|
||
def optional_skill_hits(optional_skills, matched_keywords) -> list[str]:
|
||
"""Job optional skills the candidate's matched keywords cover, in the job's order.
|
||
|
||
A hit is an exact normalized match, or one side containing the other as whole
|
||
words ("Salesforce" covers "CRM (Salesforce)"). Keywords are verified against the
|
||
resume upstream, so this only has to line up two spellings of the same skill.
|
||
"""
|
||
keys = [k for k in (_skill_key(m) for m in matched_keywords or []) if k]
|
||
hits = []
|
||
for skill in optional_skills or []:
|
||
target = _skill_key(skill)
|
||
if not target or skill in hits:
|
||
continue
|
||
padded = f" {target} "
|
||
if any(k == target or f" {k} " in padded or padded in f" {k} " for k in keys):
|
||
hits.append(skill)
|
||
return hits
|
||
|
||
|
||
def suggested_summary(candidates) -> dict:
|
||
"""Header stats for a job's suggested candidates: count, top-band count, best score."""
|
||
bands = {band: 0 for band in MATCH_BANDS}
|
||
scores = []
|
||
for c in candidates or []:
|
||
if c.get("band") in bands:
|
||
bands[c["band"]] += 1
|
||
if c.get("match_score") is not None:
|
||
scores.append(c["match_score"])
|
||
return {
|
||
"suggested": len(candidates or []),
|
||
"top_match": bands[TOP_MATCH_BAND],
|
||
"top_score": max(scores) if scores else None,
|
||
"bands": bands,
|
||
}
|
||
|
||
|
||
def job_people_of(row, people) -> dict:
|
||
"""One job's slice of a page-wide {"recruiters": …, "hiring_manager": …} lookup.
|
||
|
||
Users.job_people resolves every id on the page in two queries; this picks out
|
||
the names belonging to one row, keeping the two roles in their own maps.
|
||
"""
|
||
from job.job_post.models import JobPosts
|
||
|
||
recruiters = (people or {}).get("recruiters") or {}
|
||
managers = (people or {}).get("hiring_manager") or {}
|
||
manager_id = getattr(row, "hiring_manager_id", None)
|
||
key = str(manager_id) if manager_id else None
|
||
return {
|
||
"recruiters": {rid: recruiters.get(rid) for rid in JobPosts.recruiter_ids_of(row)},
|
||
"hiring_manager": {key: managers[key]} if key and key in managers else {},
|
||
}
|