432 lines
15 KiB
Python
432 lines
15 KiB
Python
from fastapi import APIRouter,Depends,HTTPException,Query
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
import uuid
|
|
|
|
from db_setup import get_session
|
|
from g_sheet.views import (
|
|
SheetFormData,
|
|
SheetHealth,
|
|
SheetImport,
|
|
SheetRead,
|
|
SheetWrite,
|
|
)
|
|
from users.permissions import PermissionTag,require_permission
|
|
from dotenv import load_dotenv
|
|
load_dotenv()
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _city_values(city: str | None):
|
|
if not city or not str(city).strip():
|
|
return None
|
|
parts=[p.strip() for p in str(city).split(",") if p.strip()]
|
|
return parts or None
|
|
|
|
|
|
def _job_ids(value: str | None):
|
|
if not value or not str(value).strip():
|
|
return None
|
|
out=[]
|
|
for part in str(value).split(","):
|
|
text=part.strip()
|
|
if not text:
|
|
continue
|
|
try:
|
|
out.append(uuid.UUID(text))
|
|
except ValueError:
|
|
continue
|
|
return out or None
|
|
|
|
|
|
class AppendRowsBody(BaseModel):
|
|
rows: list[list[str]]
|
|
|
|
|
|
class UpdateRangeBody(BaseModel):
|
|
cell_range: str
|
|
rows: list[list[str]]
|
|
|
|
|
|
class ClearRangeBody(BaseModel):
|
|
cell_range: str
|
|
|
|
|
|
@router.get("/sheet/health")
|
|
async def sheet_health():
|
|
"""Liveness for the Sheets integration — credentials + spreadsheet reachability.
|
|
|
|
Unauthenticated like GET /health in main.py, and never 500s: an unreachable sheet
|
|
comes back as {"status":"error"} so a probe can read the reason.
|
|
"""
|
|
try:
|
|
service=SheetHealth()
|
|
data=await service.health_check()
|
|
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("/sheet/metadata")
|
|
async def fetch_sheet_metadata(
|
|
spreadsheet_id: str | None = Query(None),
|
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)),
|
|
):
|
|
try:
|
|
service=SheetRead(spreadsheet_id=spreadsheet_id)
|
|
data=await service.get_metadata()
|
|
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("/sheet/tabs")
|
|
async def fetch_sheet_tabs(
|
|
spreadsheet_id: str | None = Query(None),
|
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)),
|
|
):
|
|
try:
|
|
service=SheetRead(spreadsheet_id=spreadsheet_id)
|
|
items=await service.list_tabs()
|
|
return JSONResponse(content={"data":items,"total":len(items),"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.get("/sheet/fetch")
|
|
async def fetch_sheet(
|
|
tab: str | None = Query(None),
|
|
cell_range: str | None = Query(None),
|
|
raw: bool = Query(False),
|
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)),
|
|
spreadsheet_id: str | None = Query(None),
|
|
):
|
|
"""No tab -> every tab as records. With a tab -> that tab, header-mapped unless
|
|
raw=true, which returns the rows exactly as the sheet stores them."""
|
|
try:
|
|
service=SheetRead(spreadsheet_id=spreadsheet_id)
|
|
if not tab:
|
|
data=await service.read_all()
|
|
return JSONResponse(content={"data":data["sheets"],"total":data["total"],"status_code":200})
|
|
if raw or cell_range:
|
|
data=await service.read_range(tab,cell_range)
|
|
return JSONResponse(content={"data":data,"total":data["row_count"],"status_code":200})
|
|
data=await service.read_records(tab)
|
|
return JSONResponse(content={"data":data["records"],"total":data["total"],"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.post("/sheet/import")
|
|
async def import_all_sheets(
|
|
tab: str | None = Query(None),
|
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_EDIT)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""No tab -> every tab. With a tab -> that sheet only. Poll GET /sheet/import/fetch."""
|
|
try:
|
|
service=SheetImport(session=session)
|
|
data=await service.start_import(current_user=current_user,tab=tab)
|
|
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("/sheet/{tab}/import")
|
|
async def import_one_sheet(
|
|
tab: str,
|
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_EDIT)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Enqueue a single-tab import. Poll GET /sheet/import/fetch for status."""
|
|
try:
|
|
service=SheetImport(session=session)
|
|
data=await service.start_import(current_user=current_user,tab=tab)
|
|
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("/sheet/import/fetch")
|
|
async def fetch_sheet_import(
|
|
run_id: str | None = Query(None),
|
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=SheetImport(session=session)
|
|
data=await service.get_import_run(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))
|
|
|
|
|
|
# Form-data reads are shared by Settings (import UI) and Inbox (form applicants).
|
|
_FORM_DATA_READ = require_permission(
|
|
PermissionTag.INBOX_VIEW, PermissionTag.SETTINGS_VIEW, require_all=False,
|
|
)
|
|
_FORM_DATA_EDIT = require_permission(
|
|
PermissionTag.INBOX_EDIT, PermissionTag.SETTINGS_EDIT, require_all=False,
|
|
)
|
|
|
|
|
|
class AssignFormJobPostBody(BaseModel):
|
|
job_post_id: str | None = None
|
|
|
|
|
|
class FormProcessingStateBody(BaseModel):
|
|
processing_state: str
|
|
|
|
|
|
class FormDuplicateBody(BaseModel):
|
|
is_duplicate: bool
|
|
|
|
|
|
@router.get("/sheet/form-data/sheets")
|
|
async def fetch_form_data_sheets(
|
|
current_user: dict = Depends(_FORM_DATA_READ),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=SheetFormData(session=session)
|
|
data=await service.get_imported_sheets()
|
|
return JSONResponse(content={"data":data,"total":data["total"],"status_code":200})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,detail=str(e))
|
|
|
|
|
|
@router.get("/sheet/form-data/fetch")
|
|
async def fetch_form_data(
|
|
sheet: str | None = Query(None),
|
|
search: str | None = Query(None),
|
|
processing_state: str | None = Query(None),
|
|
is_duplicate: bool | None = Query(None),
|
|
has_linkedin: bool | None = Query(None),
|
|
has_resume: bool | None = Query(None),
|
|
city: str | None = Query(None),
|
|
source: str | None = Query(None),
|
|
assigned: bool | None = Query(None),
|
|
no_suggestions: bool | None = Query(None),
|
|
has_suggestions: bool | None = Query(None),
|
|
job_post_ids: str | None = Query(None),
|
|
offset: int = Query(0,ge=0),
|
|
limit: int | None = Query(None,ge=1,le=500),
|
|
current_user: dict = Depends(_FORM_DATA_READ),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=SheetFormData(session=session)
|
|
items,total=await service.get_form_data(
|
|
sheet=sheet,search=search,offset=offset,limit=limit,
|
|
processing_state=processing_state,is_duplicate=is_duplicate,
|
|
has_linkedin=has_linkedin,has_resume=has_resume,
|
|
city=_city_values(city),source=(source or "").strip() or None,
|
|
assigned=assigned,no_suggestions=no_suggestions,
|
|
has_suggestions=has_suggestions,job_post_ids=_job_ids(job_post_ids),
|
|
)
|
|
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("/sheet/form-data/counts")
|
|
async def fetch_form_data_counts(
|
|
sheet: str | None = Query(None),
|
|
# The badges narrow with the list. Without these the tab counts describe the
|
|
# whole sheet while the rows beneath them describe a filtered slice.
|
|
# processing_state and is_duplicate are absent on purpose: those two ARE the
|
|
# tabs, so passing them would make every badge report the current tab.
|
|
search: str | None = Query(None),
|
|
has_linkedin: bool | None = Query(None),
|
|
has_resume: bool | None = Query(None),
|
|
city: str | None = Query(None),
|
|
source: str | None = Query(None),
|
|
assigned: bool | None = Query(None),
|
|
job_post_ids: str | None = Query(None),
|
|
current_user: dict = Depends(_FORM_DATA_READ),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=SheetFormData(session=session)
|
|
data=await service.get_counts(
|
|
sheet=sheet,search=search,has_linkedin=has_linkedin,has_resume=has_resume,
|
|
city=_city_values(city),source=(source or "").strip() or None,assigned=assigned,
|
|
job_post_ids=_job_ids(job_post_ids),
|
|
)
|
|
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("/sheet/form-data/count")
|
|
async def count_form_data(
|
|
sheet: str | None = Query(None),
|
|
current_user: dict = Depends(_FORM_DATA_READ),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Unfiltered form_data total for a sheet. Called once when Sheet Forms opens."""
|
|
try:
|
|
service=SheetFormData(session=session)
|
|
total=await service.count_rows(sheet=sheet)
|
|
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("/sheet/form-data/{record_id}")
|
|
async def fetch_form_data_by_id(
|
|
record_id: str,
|
|
current_user: dict = Depends(_FORM_DATA_READ),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=SheetFormData(session=session)
|
|
data=await service.get_form_data_by_id(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.patch("/sheet/form-data/{record_id}/assign-job-post")
|
|
async def assign_form_job_post(
|
|
record_id: str,
|
|
payload: AssignFormJobPostBody,
|
|
current_user: dict = Depends(_FORM_DATA_EDIT),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=SheetFormData(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.patch("/sheet/form-data/{record_id}/processing-state")
|
|
async def set_form_processing_state(
|
|
record_id: str,
|
|
payload: FormProcessingStateBody,
|
|
current_user: dict = Depends(_FORM_DATA_EDIT),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=SheetFormData(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("/sheet/form-data/{record_id}/duplicate")
|
|
async def set_form_duplicate(
|
|
record_id: str,
|
|
payload: FormDuplicateBody,
|
|
current_user: dict = Depends(_FORM_DATA_EDIT),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=SheetFormData(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.delete("/sheet/form-data/{tab}/delete")
|
|
async def delete_form_data_sheet(
|
|
tab: str,
|
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_DELETE)),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
service=SheetFormData(session=session)
|
|
data=await service.delete_sheet_data(tab)
|
|
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("/sheet/{tab}/append")
|
|
async def append_sheet_rows(
|
|
tab: str,
|
|
payload: AppendRowsBody,
|
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_EDIT)),
|
|
spreadsheet_id: str | None = Query(None),
|
|
):
|
|
try:
|
|
service=SheetWrite(spreadsheet_id=spreadsheet_id)
|
|
data=await service.append_rows(tab,payload.rows)
|
|
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("/sheet/{tab}/update")
|
|
async def update_sheet_range(
|
|
tab: str,
|
|
payload: UpdateRangeBody,
|
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_EDIT)),
|
|
spreadsheet_id: str | None = Query(None),
|
|
):
|
|
try:
|
|
service=SheetWrite(spreadsheet_id=spreadsheet_id)
|
|
data=await service.update_range(tab,payload.cell_range,payload.rows)
|
|
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("/sheet/{tab}/clear")
|
|
async def clear_sheet_range(
|
|
tab: str,
|
|
payload: ClearRangeBody,
|
|
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_EDIT)),
|
|
spreadsheet_id: str | None = Query(None),
|
|
):
|
|
try:
|
|
service=SheetWrite(spreadsheet_id=spreadsheet_id)
|
|
data=await service.clear_range(tab,payload.cell_range)
|
|
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))
|