487 lines
18 KiB
Python
487 lines
18 KiB
Python
import hmac
|
|
import os
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter,Depends, Query
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi import HTTPException
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
from pydantic import BaseModel
|
|
from db_setup import get_session
|
|
from inbox.enums import Candidate_application_Status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from inbox.views import Email
|
|
from users.permissions import PermissionTag, get_current_user, require_permission
|
|
from dotenv import load_dotenv
|
|
load_dotenv()
|
|
|
|
router = APIRouter()
|
|
_optional_bearer=HTTPBearer(auto_error=False)
|
|
|
|
|
|
def _cron_inbox_sync_token_ok(provided: str) -> bool:
|
|
expected=(os.getenv("CRON_INBOX_SYNC_TOKEN") or "").strip()
|
|
token=(provided or "").strip()
|
|
if not expected or not token or len(expected)!=len(token):
|
|
return False
|
|
return hmac.compare_digest(token, expected)
|
|
|
|
|
|
async def inbox_sync_caller(
|
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_optional_bearer)],
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""JWT with inbox.edit, or CRON_INBOX_SYNC_TOKEN for the daily scheduler."""
|
|
token=credentials.credentials if credentials else ""
|
|
if _cron_inbox_sync_token_ok(token):
|
|
return None
|
|
if credentials is None:
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Could not validate credentials",
|
|
headers={"WWW-Authenticate":"Bearer"},
|
|
)
|
|
current_user=await get_current_user(credentials,session)
|
|
checker=require_permission(PermissionTag.INBOX_EDIT)
|
|
return await checker(current_user)
|
|
|
|
|
|
class AssignJobPostBody(BaseModel):
|
|
job_post_id: str | None = None
|
|
|
|
|
|
class ProcessingStateBody(BaseModel):
|
|
processing_state: str
|
|
|
|
|
|
class DuplicateBody(BaseModel):
|
|
is_duplicate: bool
|
|
|
|
|
|
class ReadBody(BaseModel):
|
|
read: bool = True
|
|
|
|
|
|
class BulkReadBody(BaseModel):
|
|
record_ids: list[str]
|
|
read: bool = True
|
|
|
|
|
|
class ReadAllBody(BaseModel):
|
|
"""The caller's CURRENT list filter, echoed back so the update narrows the same way.
|
|
|
|
Every field defaults to the same "no filter" value the list endpoint uses, so an
|
|
empty body means "the All Applications tab" — exactly what GET
|
|
/inbox/all-applications returns with no query params.
|
|
"""
|
|
|
|
read: bool = True
|
|
search: str | None = None
|
|
isread: bool = True
|
|
application_status: Candidate_application_Status = Candidate_application_Status.CLOSED
|
|
assigned: bool | None = None
|
|
is_duplicate: bool | None = None
|
|
processing_state: str | None = None
|
|
|
|
|
|
class TriageOverrideBody(BaseModel):
|
|
is_application: bool
|
|
|
|
|
|
class EmailSendBody(BaseModel):
|
|
to: str
|
|
subject: str
|
|
body: str
|
|
content_type: str | None = "html"
|
|
inbox_id: int | None = None
|
|
|
|
|
|
class EmailReplyBody(BaseModel):
|
|
record_id: str
|
|
body: str
|
|
|
|
@router.get("/email/fetch")
|
|
async def fetch_email(
|
|
top:int=Query(100,ge=1,le=100),
|
|
skip:int=Query(0,ge=0),
|
|
test_on: bool = Query(True),
|
|
token: str | None = Query(None),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Synchronous one-shot sync — kept for scripts/compat. UI uses POST /email/sync."""
|
|
try:
|
|
service=Email(session=session,token=token)
|
|
if not service.token:
|
|
raise HTTPException(status_code=401,detail="Unauthorized")
|
|
summary=await service.run_mailbox_sync_page(top=top,skip=skip,test_on=test_on)
|
|
if test_on:
|
|
return JSONResponse(content={
|
|
"data":summary["entries"],
|
|
"triage":summary["triage"],
|
|
"status_code":200,
|
|
})
|
|
return JSONResponse(content={
|
|
"data":summary["entries"],
|
|
"account_setup":summary["account_setup"],
|
|
"triage":summary["triage"],
|
|
"status_code":200,
|
|
})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.post("/email/sync")
|
|
async def start_email_sync(
|
|
top:int=Query(100,ge=1,le=100),
|
|
skip:int=Query(0,ge=0),
|
|
test_on: bool = Query(True),
|
|
current_user: dict | None = Depends(inbox_sync_caller),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Enqueue mailbox sync on the dedicated mailbox_sync Taskiq queue.
|
|
|
|
Returns immediately with a run id. Poll GET /email/sync/fetch until completed.
|
|
Closing the browser does not cancel the worker. The daily cron uses the same
|
|
route with CRON_INBOX_SYNC_TOKEN instead of a recruiter JWT.
|
|
"""
|
|
try:
|
|
service=Email(session=session)
|
|
if not service.token:
|
|
raise HTTPException(status_code=401,detail="Unauthorized")
|
|
data=await service.start_mailbox_sync(
|
|
current_user=current_user,top=top,skip=skip,test_on=test_on,
|
|
)
|
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.get("/email/sync/fetch")
|
|
async def fetch_email_sync(
|
|
run_id: str | None = Query(None),
|
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=Email(session=session)
|
|
data=await service.get_mailbox_sync(run_id=run_id)
|
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.get("/inbox/fetch")
|
|
async def fetch_inbox(
|
|
record_id: str | None = Query(None),
|
|
search: str | None = Query(None),
|
|
top: int | None = Query(None, ge=1, le=500),
|
|
skip: int = Query(0, ge=0),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=Email(session=session)
|
|
if record_id:
|
|
item=await service.get_inbox_message_by_id(record_id)
|
|
return JSONResponse(content={"data":item,"total":1,"status_code":200})
|
|
|
|
items=await service.get_inbox_messages(top,skip,search)
|
|
total=await service.count_inbox_messages(search)
|
|
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.post("/inbox/{record_id}/match")
|
|
async def rematch_inbox(
|
|
record_id: str,
|
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=Email(session=session)
|
|
queued_id=await service.queue_rematch(record_id)
|
|
task_ids=await service.enqueue_matching([queued_id], force=True)
|
|
return JSONResponse(content={"data":{"queued":True,"task_ids":task_ids},"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.patch("/inbox/{record_id}/assign-job-post")
|
|
async def assign_job_post(
|
|
record_id: str,
|
|
payload: AssignJobPostBody,
|
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=Email(session=session)
|
|
data=await service.assign_job_post(record_id,payload.job_post_id)
|
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.post("/inbox/{record_id}/read")
|
|
async def mark_inbox_read(
|
|
record_id: str,
|
|
payload: ReadBody | None = None,
|
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Flip one row. The body is OPTIONAL and defaults to read=true, so the original
|
|
bodyless POST this route shipped with keeps working unchanged."""
|
|
try:
|
|
service=Email(session=session)
|
|
data=await service.mark_read(record_id,payload.read if payload else True)
|
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.patch("/inbox/read")
|
|
async def bulk_mark_inbox_read(
|
|
payload: BulkReadBody,
|
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Selected rows -> read/unread. Single segment after /inbox, so it never collides
|
|
with the two-segment /inbox/{record_id}/read above."""
|
|
try:
|
|
service=Email(session=session)
|
|
data=await service.set_read_bulk(payload.record_ids,payload.read)
|
|
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.patch("/inbox/read-all")
|
|
async def mark_all_inbox_read(
|
|
payload: ReadAllBody,
|
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Every row matching the caller's current list filter -> read/unread."""
|
|
try:
|
|
service=Email(session=session)
|
|
data=await service.set_read_all(
|
|
payload.read,
|
|
search=payload.search,
|
|
isread=payload.isread,
|
|
application_status=payload.application_status,
|
|
assigned=payload.assigned,
|
|
is_duplicate=payload.is_duplicate,
|
|
processing_state=payload.processing_state,
|
|
)
|
|
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.get("/inbox/{record_id}/read-status")
|
|
async def get_inbox_read_status(
|
|
record_id: str,
|
|
token: str | None = Query(None),
|
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=Email(session=session,token=token)
|
|
data=await service.refresh_read_status(record_id)
|
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
@router.get("/inbox/all-applications")
|
|
async def get_all_applications(
|
|
record_id: str | None = Query(None),
|
|
application_status: Candidate_application_Status = Query(default=Candidate_application_Status.CLOSED),
|
|
isread: bool = Query(default=True),
|
|
assigned: bool | None = Query(default=None),
|
|
is_duplicate: bool | None = Query(default=None),
|
|
no_suggestions: bool | None = Query(default=None),
|
|
processing_state: str | None = Query(default=None),
|
|
search: str | None = Query(None),
|
|
# Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged.
|
|
top: int | None = Query(None, ge=1, le=500),
|
|
skip: int = Query(0, ge=0),
|
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=Email(session=session)
|
|
|
|
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
|
|
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
|
|
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
|
|
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
|
if isread==False:
|
|
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
|
|
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
|
|
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
|
if record_id:
|
|
item=await service.get_application_by_id(record_id)
|
|
return JSONResponse(content={"data":item,"total":1,"status_code":200})
|
|
|
|
items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
|
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
|
|
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.get("/inbox/all-applications/count")
|
|
async def count_all_applications(
|
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Unfiltered application total. Called once when Inbox Email opens."""
|
|
try:
|
|
service=Email(session=session)
|
|
total=await service.count_inbox_messages()
|
|
return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.get("/inbox/counts")
|
|
async def get_inbox_counts(
|
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=Email(session=session)
|
|
data=await service.get_counts()
|
|
return JSONResponse(content={"data":data,"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.get("/inbox/triage")
|
|
async def fetch_triage(
|
|
search: str | None = Query(None),
|
|
is_application: bool | None = Query(None),
|
|
status: str | None = Query(None),
|
|
top: int = Query(100),
|
|
skip: int = Query(0, ge=0),
|
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=Email(session=session)
|
|
data=await service.get_triage_messages(top,skip,search,is_application,status)
|
|
total=await service.count_triage(search,is_application,status)
|
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.patch("/inbox/triage/{record_id}/override")
|
|
async def override_triage(
|
|
record_id: str,
|
|
payload: TriageOverrideBody,
|
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=Email(session=session)
|
|
data=await service.override_triage(record_id,payload.is_application,current_user)
|
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.patch("/inbox/{record_id}/processing-state")
|
|
async def set_processing_state(
|
|
record_id: str,
|
|
payload: ProcessingStateBody,
|
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=Email(session=session)
|
|
data=await service.set_processing_state(record_id,payload.processing_state,current_user)
|
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.patch("/inbox/{record_id}/duplicate")
|
|
async def set_duplicate(
|
|
record_id: str,
|
|
payload: DuplicateBody,
|
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=Email(session=session)
|
|
data=await service.set_duplicate(record_id,payload.is_duplicate)
|
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.post("/email/send")
|
|
async def send_email(
|
|
payload: EmailSendBody,
|
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=Email(session=session)
|
|
data=await service.send_email(payload.model_dump(exclude_unset=True),current_user)
|
|
return JSONResponse(content={"data":data,"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.post("/email/reply")
|
|
async def reply_email(
|
|
payload: EmailReplyBody,
|
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=Email(session=session)
|
|
data=await service.reply_email(payload.model_dump(exclude_unset=True),current_user)
|
|
return JSONResponse(content={"data":data,"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|