canidate flow
parent
d47e90b9ec
commit
1ab52b7292
|
|
@ -123,5 +123,21 @@ UVICORN_WORKERS=2
|
||||||
# VITE_API_BASE=http://127.0.0.1:8000 (backend is published on BACKEND_PORT).
|
# VITE_API_BASE=http://127.0.0.1:8000 (backend is published on BACKEND_PORT).
|
||||||
VITE_API_BASE=
|
VITE_API_BASE=
|
||||||
|
|
||||||
|
# --- AWS S3 (s3/) — permanent public object URLs (not presigned) ------------
|
||||||
|
# Bucket from your console, e.g. hr-ats-416818527652-us-east-2-an
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_REGION=us-east-2
|
||||||
|
S3_BUCKET=
|
||||||
|
# Optional CDN / custom domain. Blank → https://{bucket}.s3.{region}.amazonaws.com/{key}
|
||||||
|
S3_PUBLIC_BASE_URL=
|
||||||
|
# Leave blank when ACLs are disabled (Object Ownership = Bucket owner enforced).
|
||||||
|
# Use public-read only if the bucket still allows ACLs.
|
||||||
|
S3_OBJECT_ACL=
|
||||||
|
# CV object keys (after DB row exists):
|
||||||
|
# Email/{inbox_messages.id}/{user_id}/{file}.pdf
|
||||||
|
# Manual/{manual_upload_candidate.id}/{user_id}/{file}.pdf
|
||||||
|
# Form/{form_data.id}/{recruiter_id}/{file}.pdf
|
||||||
|
|
||||||
LOG_FORMAT=json
|
LOG_FORMAT=json
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
|
||||||
|
|
@ -179,17 +179,22 @@ async def create_manual_candidate(
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
saved_path=None
|
|
||||||
try:
|
try:
|
||||||
|
# Gate before any parse / DB / S3 work — only PDFs proceed.
|
||||||
|
from s3.plugins import S3ServiceError,assert_pdf
|
||||||
|
try:
|
||||||
|
assert_pdf(file.filename or "resume.pdf",file.content_type)
|
||||||
|
except S3ServiceError as e:
|
||||||
|
raise HTTPException(status_code=e.status_code,detail=e.message) from e
|
||||||
|
|
||||||
file_content = await file.read()
|
file_content = await file.read()
|
||||||
logger.info(f"Received file: {file.filename} ({len(file_content)} bytes)")
|
logger.info(f"Received file: {file.filename} ({len(file_content)} bytes)")
|
||||||
reader=FileRead(session=session,filename=file.filename,file=file_content)
|
reader=FileRead(session=session,filename=file.filename,file=file_content)
|
||||||
# Parse first: an unreadable PDF is a 400, and doing it before the write
|
# Parse first: an unreadable PDF is a 400 before any table row exists.
|
||||||
# keeps a file that can never back a row off the disk entirely.
|
|
||||||
parsed=await reader.injest_manual_upload()
|
parsed=await reader.injest_manual_upload()
|
||||||
saved=await reader.save_manual_upload()
|
|
||||||
saved_path=saved.get("file_path")
|
|
||||||
service=CandidateView(session=session)
|
service=CandidateView(session=session)
|
||||||
|
# Atomicity lives in create_candidate: insert row → S3 Manual/{id}/{user_id}/
|
||||||
|
# → set file_path; on S3 failure the row is deleted.
|
||||||
data=await service.create_candidate(
|
data=await service.create_candidate(
|
||||||
candidate_email=candidate_email,
|
candidate_email=candidate_email,
|
||||||
candidate_name=candidate_name,
|
candidate_name=candidate_name,
|
||||||
|
|
@ -201,19 +206,16 @@ async def create_manual_candidate(
|
||||||
experience=experience,
|
experience=experience,
|
||||||
status=status,
|
status=status,
|
||||||
referral_by=referral_by,
|
referral_by=referral_by,
|
||||||
file_name=saved.get("file_name"),
|
file_name=file.filename,
|
||||||
file_path=saved_path,
|
|
||||||
full_text=parsed.get("text") or "",
|
full_text=parsed.get("text") or "",
|
||||||
current_user=current_user.get("id"),
|
current_user=current_user.get("id"),
|
||||||
|
file_bytes=file_content,
|
||||||
|
content_type=file.content_type,
|
||||||
)
|
)
|
||||||
return JSONResponse(content={"data":data,"status_code":200})
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
# create_candidate rejects a blank email with a 422 AFTER the file has
|
|
||||||
# landed, so without this every such attempt would leave an orphan PDF.
|
|
||||||
FileRead.discard_upload(saved_path)
|
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
FileRead.discard_upload(saved_path)
|
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
@router.get("/candidate/fetch/users")
|
@router.get("/candidate/fetch/users")
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,29 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def delete_by_id(cls, session: AsyncSession, record_id):
|
||||||
|
"""Hard-delete one row — used to roll back when S3 upload fails after insert."""
|
||||||
|
row=await cls.get_by_id(session,record_id)
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
session.delete(row)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def set_file_path(cls, session: AsyncSession, record_id, file_path, file_name=None):
|
||||||
|
row=await cls.get_by_id(session,record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.file_path=(file_path or "").strip()
|
||||||
|
if file_name is not None:
|
||||||
|
row.file_name=(file_name or "").strip()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_user_id(cls, session: AsyncSession, user_id):
|
async def get_by_user_id(cls, session: AsyncSession, user_id):
|
||||||
uid = cls._as_uuid(user_id)
|
uid = cls._as_uuid(user_id)
|
||||||
|
|
|
||||||
|
|
@ -540,13 +540,31 @@ class CandidateView:
|
||||||
band=(msg.ats_band or "").strip() or None
|
band=(msg.ats_band or "").strip() or None
|
||||||
return msg.ats_score,band or CandidateView._recommendation(msg.ats_score)
|
return msg.ats_score,band or CandidateView._recommendation(msg.ats_score)
|
||||||
|
|
||||||
async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,current_position=None,platform=None,experience=None,status=None,referral_by=None,file_name=None,file_path=None,full_text=None,current_user=None):
|
async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,current_position=None,platform=None,experience=None,status=None,referral_by=None,file_name=None,file_path=None,full_text=None,current_user=None,file_bytes=None,content_type=None):
|
||||||
|
"""Create manual_upload_candidate, then S3 upload under Manual/{id}/{user_id}/.
|
||||||
|
|
||||||
|
Atomicity: if S3 fails after the row insert, the row is deleted (rolled back).
|
||||||
|
PDF gate runs before any DB write when file_bytes is supplied.
|
||||||
|
"""
|
||||||
|
from s3.plugins import S3,S3ServiceError,S3Source,assert_pdf
|
||||||
|
|
||||||
|
row=None
|
||||||
try:
|
try:
|
||||||
email=(candidate_email or "").strip().lower()
|
email=(candidate_email or "").strip().lower()
|
||||||
if not email:
|
if not email:
|
||||||
raise HTTPException(status_code=422,detail="candidate_email is required")
|
raise HTTPException(status_code=422,detail="candidate_email is required")
|
||||||
if not current_user:
|
if not current_user:
|
||||||
raise HTTPException(status_code=400,detail="created_by is required")
|
raise HTTPException(status_code=400,detail="created_by is required")
|
||||||
|
|
||||||
|
original_name=(file_name or "").strip() or "resume.pdf"
|
||||||
|
if file_bytes is not None:
|
||||||
|
try:
|
||||||
|
original_name=assert_pdf(original_name,content_type)
|
||||||
|
except S3ServiceError as e:
|
||||||
|
raise HTTPException(status_code=e.status_code,detail=e.message) from e
|
||||||
|
if not file_bytes:
|
||||||
|
raise HTTPException(status_code=422,detail="file is empty")
|
||||||
|
|
||||||
data={
|
data={
|
||||||
"candidate_email":email,
|
"candidate_email":email,
|
||||||
"candidate_name":(candidate_name or "").strip(),
|
"candidate_name":(candidate_name or "").strip(),
|
||||||
|
|
@ -559,13 +577,36 @@ class CandidateView:
|
||||||
"experience":(experience or "").strip(),
|
"experience":(experience or "").strip(),
|
||||||
"status":(status or "").strip(),
|
"status":(status or "").strip(),
|
||||||
"referral_by":(referral_by or "").strip(),
|
"referral_by":(referral_by or "").strip(),
|
||||||
"file_name":(file_name or "").strip(),
|
"file_name":original_name,
|
||||||
"file_path":(file_path or "").strip(),
|
# path filled after S3 succeeds; never leave a local orphan path here
|
||||||
|
"file_path":(file_path or "").strip() if file_bytes is None else "",
|
||||||
"full_text":full_text or "",
|
"full_text":full_text or "",
|
||||||
"created_by":current_user,
|
"created_by":current_user,
|
||||||
|
|
||||||
}
|
}
|
||||||
row=await Manual_UPLOAD_CANDIDATE.create_manual_upload_candidate(session=self.session,fields=data)
|
row=await Manual_UPLOAD_CANDIDATE.create_manual_upload_candidate(session=self.session,fields=data)
|
||||||
|
|
||||||
|
if file_bytes is not None:
|
||||||
|
try:
|
||||||
|
uploaded=S3().upload_for_record(
|
||||||
|
file_bytes,
|
||||||
|
original_name,
|
||||||
|
source=S3Source.MANUAL,
|
||||||
|
record_id=row.id,
|
||||||
|
owner_id=row.user_id,
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
except S3ServiceError as e:
|
||||||
|
await Manual_UPLOAD_CANDIDATE.delete_by_id(self.session,row.id)
|
||||||
|
row=None
|
||||||
|
raise HTTPException(status_code=e.status_code,detail=e.message) from e
|
||||||
|
except Exception:
|
||||||
|
await Manual_UPLOAD_CANDIDATE.delete_by_id(self.session,row.id)
|
||||||
|
row=None
|
||||||
|
raise
|
||||||
|
row=await Manual_UPLOAD_CANDIDATE.set_file_path(
|
||||||
|
self.session,row.id,uploaded["url"],file_name=uploaded.get("filename") or original_name,
|
||||||
|
)
|
||||||
|
|
||||||
await HistoryRecorder(self.session).record(
|
await HistoryRecorder(self.session).record(
|
||||||
HistoryEvent.CANDIDATE_CREATED.value,
|
HistoryEvent.CANDIDATE_CREATED.value,
|
||||||
actor_id=current_user,user_id=row.user_id,
|
actor_id=current_user,user_id=row.user_id,
|
||||||
|
|
@ -574,7 +615,7 @@ class CandidateView:
|
||||||
to_value=row.candidate_email,
|
to_value=row.candidate_email,
|
||||||
description=(row.platform or "").strip() or "manual_upload",commit=True,
|
description=(row.platform or "").strip() or "manual_upload",commit=True,
|
||||||
)
|
)
|
||||||
if (row.file_name or "").strip():
|
if (row.file_name or "").strip() and (row.file_path or "").strip():
|
||||||
await HistoryRecorder(self.session).record(
|
await HistoryRecorder(self.session).record(
|
||||||
HistoryEvent.DOCUMENT_UPLOADED.value,
|
HistoryEvent.DOCUMENT_UPLOADED.value,
|
||||||
actor_id=current_user,user_id=row.user_id,
|
actor_id=current_user,user_id=row.user_id,
|
||||||
|
|
@ -586,6 +627,11 @@ class CandidateView:
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if row is not None:
|
||||||
|
try:
|
||||||
|
await Manual_UPLOAD_CANDIDATE.delete_by_id(self.session,row.id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("manual candidate rollback failed for %s",getattr(row,"id",None))
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None):
|
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None):
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from interview.app import router as interview_router
|
||||||
from talent.app import router as talent_router
|
from talent.app import router as talent_router
|
||||||
from candidate_forms.app import router as candidate_forms_router
|
from candidate_forms.app import router as candidate_forms_router
|
||||||
from g_sheet.app import router as g_sheet_router
|
from g_sheet.app import router as g_sheet_router
|
||||||
|
from s3.app import router as s3_router
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
|
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
|
||||||
logger=logging.getLogger("main")
|
logger=logging.getLogger("main")
|
||||||
|
|
@ -129,3 +130,4 @@ app.include_router(interview_router)
|
||||||
app.include_router(talent_router)
|
app.include_router(talent_router)
|
||||||
app.include_router(candidate_forms_router)
|
app.include_router(candidate_forms_router)
|
||||||
app.include_router(g_sheet_router)
|
app.include_router(g_sheet_router)
|
||||||
|
app.include_router(s3_router)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- 009_interviews_user_job.sql
|
||||||
|
-- Add optional user_id / job_post_id on interviews so scheduling can target a
|
||||||
|
-- candidate user without requiring an inbox application row. When inbox_id is
|
||||||
|
-- supplied, create resolves user_id + job_post_id from that application.
|
||||||
|
-- Applied at startup by alembic_setup.run_manual_sql().
|
||||||
|
|
||||||
|
ALTER TABLE app.interviews
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES app.users(id);
|
||||||
|
|
||||||
|
ALTER TABLE app.interviews
|
||||||
|
ADD COLUMN IF NOT EXISTS job_post_id UUID REFERENCES app.job_posts(id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_interviews_user_id ON app.interviews (user_id);
|
||||||
|
|
||||||
|
-- Backfill from existing inbox links.
|
||||||
|
UPDATE app.interviews AS i
|
||||||
|
SET user_id = inbox.user_id
|
||||||
|
FROM app.inbox AS inbox
|
||||||
|
WHERE i.inbox_id = inbox.id
|
||||||
|
AND i.user_id IS NULL
|
||||||
|
AND inbox.user_id IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE app.interviews AS i
|
||||||
|
SET job_post_id = m.assigned_job_post_id
|
||||||
|
FROM app.inbox AS inbox
|
||||||
|
JOIN app.inbox_messages AS m ON m.id = inbox.message_id
|
||||||
|
WHERE i.inbox_id = inbox.id
|
||||||
|
AND i.job_post_id IS NULL
|
||||||
|
AND m.assigned_job_post_id IS NOT NULL;
|
||||||
|
|
@ -50,3 +50,7 @@ openpyxl==3.1.5
|
||||||
google-api-python-client==2.198.0 # Sheets v4 client in g_sheet/plugins.py
|
google-api-python-client==2.198.0 # Sheets v4 client in g_sheet/plugins.py
|
||||||
google-auth==2.56.3 # ADC + refresh in g_sheet/plugins.py
|
google-auth==2.56.3 # ADC + refresh in g_sheet/plugins.py
|
||||||
google-auth-httplib2==0.4.1 # transport used by googleapiclient
|
google-auth-httplib2==0.4.1 # transport used by googleapiclient
|
||||||
|
|
||||||
|
# --- AWS S3 (s3/) ----------------------------------------------------------
|
||||||
|
boto3==1.40.49 # S3 PutObject / DeleteObject in s3/plugins.py
|
||||||
|
botocore==1.40.49 # ClientError mapping; pin matches aiobotocore's range
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
from fastapi import APIRouter,Depends,File,Form,HTTPException,Query,UploadFile
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from s3.views import S3Storage
|
||||||
|
from users.permissions import PermissionTag,require_permission
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
router=APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteObjectBody(BaseModel):
|
||||||
|
key: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/s3/health")
|
||||||
|
async def s3_health():
|
||||||
|
"""Bucket reachability — unauthenticated like GET /sheet/health."""
|
||||||
|
try:
|
||||||
|
service=S3Storage()
|
||||||
|
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.post("/s3/upload")
|
||||||
|
async def upload_s3_file(
|
||||||
|
file: UploadFile=File(...),
|
||||||
|
source: str=Form(...),
|
||||||
|
record_id: str=Form(...),
|
||||||
|
owner_id: str=Form(...),
|
||||||
|
current_user: dict=Depends(require_permission(PermissionTag.CANDIDATES_CREATE,PermissionTag.SETTINGS_EDIT,require_all=False)),
|
||||||
|
):
|
||||||
|
"""PDF only. Requires an existing table row — key is {source}/{record_id}/{owner_id}/{file}.pdf."""
|
||||||
|
try:
|
||||||
|
service=S3Storage()
|
||||||
|
data=await service.upload_for_record(file,source=source,record_id=record_id,owner_id=owner_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("/s3/url")
|
||||||
|
async def fetch_s3_url(
|
||||||
|
key: str=Query(...),
|
||||||
|
current_user: dict=Depends(require_permission(PermissionTag.CANDIDATES_VIEW,PermissionTag.SETTINGS_VIEW,require_all=False)),
|
||||||
|
):
|
||||||
|
"""Recompute the permanent URL for an existing key (no S3 round trip)."""
|
||||||
|
try:
|
||||||
|
service=S3Storage()
|
||||||
|
data=await service.object_url(key)
|
||||||
|
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("/s3/delete")
|
||||||
|
async def delete_s3_file(
|
||||||
|
payload: DeleteObjectBody,
|
||||||
|
current_user: dict=Depends(require_permission(PermissionTag.CANDIDATES_DELETE,PermissionTag.SETTINGS_DELETE,require_all=False)),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=S3Storage()
|
||||||
|
data=await service.delete_file(payload.key)
|
||||||
|
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))
|
||||||
|
|
@ -0,0 +1,268 @@
|
||||||
|
"""S3 helpers — boto3 client class, upload/delete, permanent object URLs.
|
||||||
|
|
||||||
|
No FastAPI imports (house rule). Raise S3ServiceError; s3/views.py maps to HTTPException.
|
||||||
|
|
||||||
|
Permanent links: we NEVER return expiring presigned URLs. The URL is the virtual-hosted
|
||||||
|
HTTPS object address, which stays valid until the object is deleted (or the bucket
|
||||||
|
policy stops public GetObject).
|
||||||
|
|
||||||
|
CV keys are record-scoped (atomicity): DB row is created first, then upload uses that id:
|
||||||
|
|
||||||
|
Email/{table_record_id}/{user_id}/{file_name}.pdf
|
||||||
|
Manual/{table_record_id}/{user_id}/{file_name}.pdf
|
||||||
|
Form/{table_record_id}/{recruiter_id}/{file_name}.pdf
|
||||||
|
|
||||||
|
Callers that create the row MUST delete it if upload_for_record fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.client import BaseClient
|
||||||
|
from botocore.exceptions import BotoCoreError,ClientError
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
logger=logging.getLogger("s3.plugins")
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID=os.getenv("AWS_ACCESS_KEY_ID","").strip()
|
||||||
|
AWS_SECRET_ACCESS_KEY=os.getenv("AWS_SECRET_ACCESS_KEY","").strip()
|
||||||
|
AWS_REGION=(os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION") or "us-east-2").strip()
|
||||||
|
S3_BUCKET=os.getenv("S3_BUCKET","").strip()
|
||||||
|
# Optional CDN / custom domain. Blank → https://{bucket}.s3.{region}.amazonaws.com/{key}
|
||||||
|
S3_PUBLIC_BASE_URL=os.getenv("S3_PUBLIC_BASE_URL","").strip().rstrip("/")
|
||||||
|
|
||||||
|
# modern buckets often have ACLs disabled; leave blank and rely on bucket policy.
|
||||||
|
S3_OBJECT_ACL=os.getenv("S3_OBJECT_ACL","").strip() # e.g. public-read
|
||||||
|
|
||||||
|
_SAFE_NAME=re.compile(r"[^A-Za-z0-9._-]+")
|
||||||
|
_PDF_MIME=frozenset({"application/pdf","application/x-pdf"})
|
||||||
|
|
||||||
|
|
||||||
|
class S3Source:
|
||||||
|
"""Top-level folder names — keep spelling exact for console browsing."""
|
||||||
|
EMAIL="Email"
|
||||||
|
MANUAL="Manual"
|
||||||
|
FORM="Form"
|
||||||
|
ALL=frozenset({EMAIL,MANUAL,FORM})
|
||||||
|
|
||||||
|
|
||||||
|
class S3ServiceError(Exception):
|
||||||
|
"""Raised for config / boto failures — views translate to HTTPException."""
|
||||||
|
|
||||||
|
def __init__(self,message,status_code=500):
|
||||||
|
super().__init__(message)
|
||||||
|
self.message=str(message)
|
||||||
|
self.status_code=int(status_code)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(name: str) -> str:
|
||||||
|
raw=(name or "").strip() or "file"
|
||||||
|
base=Path(raw).name
|
||||||
|
cleaned=_SAFE_NAME.sub("_",base).strip("._") or "file"
|
||||||
|
return cleaned[:180]
|
||||||
|
|
||||||
|
|
||||||
|
def guess_content_type(filename: str,fallback: str="application/octet-stream") -> str:
|
||||||
|
guessed,_=mimetypes.guess_type(filename or "")
|
||||||
|
return guessed or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def assert_pdf(filename: str,content_type: str | None=None) -> str:
|
||||||
|
"""Gate: only .pdf (and PDF MIME when provided). Returns sanitized basename."""
|
||||||
|
safe=sanitize_filename(filename)
|
||||||
|
if not safe.lower().endswith(".pdf"):
|
||||||
|
raise S3ServiceError("Only PDF files are allowed",status_code=415)
|
||||||
|
mime=(content_type or "").strip().lower().split(";")[0].strip()
|
||||||
|
# browsers sometimes send application/octet-stream for PDFs — allow that
|
||||||
|
# only when the extension already passed; reject every other non-PDF MIME.
|
||||||
|
if mime and mime not in _PDF_MIME and mime!="application/octet-stream":
|
||||||
|
raise S3ServiceError(f"Only PDF MIME types are allowed (got {mime})",status_code=415)
|
||||||
|
return safe
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_source(source: str) -> str:
|
||||||
|
raw=(source or "").strip()
|
||||||
|
if not raw:
|
||||||
|
raise S3ServiceError("source is required (Email|Manual|Form)",status_code=422)
|
||||||
|
# accept case-insensitive input, store canonical folder casing
|
||||||
|
for name in S3Source.ALL:
|
||||||
|
if raw.lower()==name.lower():
|
||||||
|
return name
|
||||||
|
raise S3ServiceError(
|
||||||
|
f"source must be one of {', '.join(sorted(S3Source.ALL))}",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class S3:
|
||||||
|
"""One boto3 client + bucket config — upload / delete / URL / health share this."""
|
||||||
|
|
||||||
|
def __init__(self,client: BaseClient | None=None):
|
||||||
|
self._require_config()
|
||||||
|
self.bucket=S3_BUCKET
|
||||||
|
self.region=AWS_REGION
|
||||||
|
self.public_base_url=S3_PUBLIC_BASE_URL
|
||||||
|
self.object_acl=S3_OBJECT_ACL
|
||||||
|
self.client=client or boto3.client(
|
||||||
|
"s3",
|
||||||
|
region_name=self.region,
|
||||||
|
aws_access_key_id=AWS_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _require_config():
|
||||||
|
missing=[name for name,val in (
|
||||||
|
("AWS_ACCESS_KEY_ID",AWS_ACCESS_KEY_ID),
|
||||||
|
("AWS_SECRET_ACCESS_KEY",AWS_SECRET_ACCESS_KEY),
|
||||||
|
("S3_BUCKET",S3_BUCKET),
|
||||||
|
) if not val]
|
||||||
|
if missing:
|
||||||
|
raise S3ServiceError(
|
||||||
|
f"S3 is not configured — set {', '.join(missing)} in backend/.env",
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _raise_boto(self,exc,action,key=None,status_code=502):
|
||||||
|
"""Map ClientError / BotoCoreError → S3ServiceError (single place)."""
|
||||||
|
if isinstance(exc,ClientError):
|
||||||
|
code=(exc.response or {}).get("Error",{}).get("Code") or ""
|
||||||
|
logger.exception("s3 %s failed key=%s code=%s",action,key,code)
|
||||||
|
raise S3ServiceError(f"S3 {action} failed: {code or exc}",status_code=status_code) from exc
|
||||||
|
logger.exception("s3 %s botocore failure key=%s",action,key)
|
||||||
|
raise S3ServiceError(f"S3 {action} failed: {exc}",status_code=status_code) from exc
|
||||||
|
|
||||||
|
def build_record_object_key(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
record_id,
|
||||||
|
owner_id,
|
||||||
|
filename: str,
|
||||||
|
) -> str:
|
||||||
|
"""{Email|Manual|Form}/{table_record_id}/{user_or_recruiter_id}/{file}.pdf"""
|
||||||
|
folder=normalize_source(source)
|
||||||
|
rid=str(record_id or "").strip()
|
||||||
|
oid=str(owner_id or "").strip()
|
||||||
|
if not rid:
|
||||||
|
raise S3ServiceError("table_record_id is required before S3 upload",status_code=422)
|
||||||
|
if not oid:
|
||||||
|
raise S3ServiceError("owner_id (user_id / recruiter_id) is required before S3 upload",status_code=422)
|
||||||
|
safe=assert_pdf(filename)
|
||||||
|
return f"{folder}/{rid}/{oid}/{safe}"
|
||||||
|
|
||||||
|
def permanent_object_url(self,key: str) -> str:
|
||||||
|
"""Stable HTTPS URL for a public object — does not expire."""
|
||||||
|
object_key=(key or "").lstrip("/")
|
||||||
|
if not object_key:
|
||||||
|
raise S3ServiceError("object key is required",status_code=422)
|
||||||
|
if self.public_base_url:
|
||||||
|
return f"{self.public_base_url}/{object_key}"
|
||||||
|
if not self.bucket:
|
||||||
|
raise S3ServiceError("S3_BUCKET is not configured",status_code=500)
|
||||||
|
return f"https://{self.bucket}.s3.{self.region}.amazonaws.com/{object_key}"
|
||||||
|
|
||||||
|
def upload_bytes(
|
||||||
|
self,
|
||||||
|
body: bytes,
|
||||||
|
filename: str,
|
||||||
|
*,
|
||||||
|
content_type: str | None=None,
|
||||||
|
key: str | None=None,
|
||||||
|
) -> dict:
|
||||||
|
"""PutObject + permanent URL. Prefer upload_for_record for CV flows."""
|
||||||
|
if body is None:
|
||||||
|
raise S3ServiceError("file body is required",status_code=422)
|
||||||
|
if not key:
|
||||||
|
raise S3ServiceError(
|
||||||
|
"object key is required — use upload_for_record after the DB row exists",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
safe=assert_pdf(filename,content_type)
|
||||||
|
object_key=key.lstrip("/")
|
||||||
|
ctype=content_type if (content_type or "").strip().lower().startswith("application/pdf") else "application/pdf"
|
||||||
|
extra={}
|
||||||
|
if self.object_acl:
|
||||||
|
extra["ACL"]=self.object_acl
|
||||||
|
try:
|
||||||
|
self.client.put_object(
|
||||||
|
Bucket=self.bucket,
|
||||||
|
Key=object_key,
|
||||||
|
Body=body,
|
||||||
|
ContentType=ctype,
|
||||||
|
**extra,
|
||||||
|
)
|
||||||
|
except (ClientError,BotoCoreError) as e:
|
||||||
|
self._raise_boto(e,"upload",key=object_key)
|
||||||
|
url=self.permanent_object_url(object_key)
|
||||||
|
return {
|
||||||
|
"bucket":self.bucket,
|
||||||
|
"key":object_key,
|
||||||
|
"url":url,
|
||||||
|
"content_type":ctype,
|
||||||
|
"size":len(body),
|
||||||
|
"filename":safe,
|
||||||
|
}
|
||||||
|
|
||||||
|
def upload_for_record(
|
||||||
|
self,
|
||||||
|
body: bytes,
|
||||||
|
filename: str,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
record_id,
|
||||||
|
owner_id,
|
||||||
|
content_type: str | None=None,
|
||||||
|
) -> dict:
|
||||||
|
"""Atomic CV path: requires an existing table row id, then PutObject.
|
||||||
|
|
||||||
|
Callers MUST roll back (delete) the table row if this raises.
|
||||||
|
"""
|
||||||
|
key=self.build_record_object_key(
|
||||||
|
source=source,
|
||||||
|
record_id=record_id,
|
||||||
|
owner_id=owner_id,
|
||||||
|
filename=filename,
|
||||||
|
)
|
||||||
|
result=self.upload_bytes(body,filename,content_type=content_type,key=key)
|
||||||
|
result["source"]=normalize_source(source)
|
||||||
|
result["record_id"]=str(record_id)
|
||||||
|
result["owner_id"]=str(owner_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def delete_object(self,key: str) -> dict:
|
||||||
|
"""DeleteObject — after this the permanent URL 404s."""
|
||||||
|
object_key=(key or "").lstrip("/")
|
||||||
|
if not object_key:
|
||||||
|
raise S3ServiceError("object key is required",status_code=422)
|
||||||
|
try:
|
||||||
|
self.client.delete_object(Bucket=self.bucket,Key=object_key)
|
||||||
|
except (ClientError,BotoCoreError) as e:
|
||||||
|
self._raise_boto(e,"delete",key=object_key)
|
||||||
|
return {"bucket":self.bucket,"key":object_key,"deleted":True}
|
||||||
|
|
||||||
|
def head_bucket(self) -> dict:
|
||||||
|
"""Reachability probe — credentials + bucket exist."""
|
||||||
|
try:
|
||||||
|
self.client.head_bucket(Bucket=self.bucket)
|
||||||
|
except ClientError as e:
|
||||||
|
code=(e.response or {}).get("Error",{}).get("Code") or ""
|
||||||
|
status=403 if code in ("403","AccessDenied","AllAccessDisabled") else 502
|
||||||
|
self._raise_boto(e,"head_bucket",status_code=status)
|
||||||
|
except BotoCoreError as e:
|
||||||
|
self._raise_boto(e,"head_bucket")
|
||||||
|
base=self.public_base_url or f"https://{self.bucket}.s3.{self.region}.amazonaws.com"
|
||||||
|
return {
|
||||||
|
"bucket":self.bucket,
|
||||||
|
"region":self.region,
|
||||||
|
"status":"ok",
|
||||||
|
"public_base_url":base,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""S3 response shapes. Plain dicts only — no DB, no Depends."""
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_upload(result: dict) -> dict:
|
||||||
|
"""upload result → API dict (permanent url, never a presign)."""
|
||||||
|
return {
|
||||||
|
"bucket": result.get("bucket"),
|
||||||
|
"key": result.get("key"),
|
||||||
|
"url": result.get("url"),
|
||||||
|
"content_type": result.get("content_type"),
|
||||||
|
"size": result.get("size"),
|
||||||
|
"filename": result.get("filename"),
|
||||||
|
"source": result.get("source"),
|
||||||
|
"record_id": result.get("record_id"),
|
||||||
|
"owner_id": result.get("owner_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_delete(result: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"bucket": result.get("bucket"),
|
||||||
|
"key": result.get("key"),
|
||||||
|
"deleted": bool(result.get("deleted")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_health(result: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"status": result.get("status") or "ok",
|
||||||
|
"bucket": result.get("bucket"),
|
||||||
|
"region": result.get("region"),
|
||||||
|
"public_base_url": result.get("public_base_url"),
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""S3 storage service — upload / delete / health over the plugins S3 class."""
|
||||||
|
|
||||||
|
from fastapi import HTTPException,UploadFile
|
||||||
|
|
||||||
|
from s3.plugins import S3,S3ServiceError,assert_pdf
|
||||||
|
from s3.serializers import serialize_delete,serialize_health,serialize_upload
|
||||||
|
|
||||||
|
|
||||||
|
class S3Storage:
|
||||||
|
"""No DB session — pure object storage against the configured bucket."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.s3=S3()
|
||||||
|
|
||||||
|
def _map(self,exc:S3ServiceError):
|
||||||
|
raise HTTPException(status_code=exc.status_code,detail=exc.message)
|
||||||
|
|
||||||
|
async def health_check(self):
|
||||||
|
try:
|
||||||
|
return serialize_health(self.s3.head_bucket())
|
||||||
|
except S3ServiceError as e:
|
||||||
|
self._map(e)
|
||||||
|
|
||||||
|
async def upload_for_record(self,file:UploadFile,source,record_id,owner_id):
|
||||||
|
"""PDF gate → PutObject under {source}/{record_id}/{owner_id}/{name}.pdf."""
|
||||||
|
if file is None:
|
||||||
|
raise HTTPException(status_code=422,detail="file is required")
|
||||||
|
filename=(file.filename or "").strip() or "resume.pdf"
|
||||||
|
try:
|
||||||
|
assert_pdf(filename,file.content_type)
|
||||||
|
except S3ServiceError as e:
|
||||||
|
self._map(e)
|
||||||
|
body=await file.read()
|
||||||
|
if not body:
|
||||||
|
raise HTTPException(status_code=422,detail="file is empty")
|
||||||
|
try:
|
||||||
|
result=self.s3.upload_for_record(
|
||||||
|
body,
|
||||||
|
filename,
|
||||||
|
source=source,
|
||||||
|
record_id=record_id,
|
||||||
|
owner_id=owner_id,
|
||||||
|
content_type=file.content_type,
|
||||||
|
)
|
||||||
|
return serialize_upload(result)
|
||||||
|
except S3ServiceError as e:
|
||||||
|
self._map(e)
|
||||||
|
|
||||||
|
async def upload_bytes_for_record(self,body,filename,source,record_id,owner_id,content_type=None):
|
||||||
|
try:
|
||||||
|
assert_pdf(filename or "resume.pdf",content_type)
|
||||||
|
result=self.s3.upload_for_record(
|
||||||
|
body,
|
||||||
|
filename or "resume.pdf",
|
||||||
|
source=source,
|
||||||
|
record_id=record_id,
|
||||||
|
owner_id=owner_id,
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
return serialize_upload(result)
|
||||||
|
except S3ServiceError as e:
|
||||||
|
self._map(e)
|
||||||
|
|
||||||
|
async def delete_file(self,key):
|
||||||
|
if not key or not str(key).strip():
|
||||||
|
raise HTTPException(status_code=422,detail="key is required")
|
||||||
|
try:
|
||||||
|
return serialize_delete(self.s3.delete_object(str(key).strip()))
|
||||||
|
except S3ServiceError as e:
|
||||||
|
self._map(e)
|
||||||
|
|
||||||
|
async def object_url(self,key):
|
||||||
|
if not key or not str(key).strip():
|
||||||
|
raise HTTPException(status_code=422,detail="key is required")
|
||||||
|
try:
|
||||||
|
url=self.s3.permanent_object_url(str(key).strip())
|
||||||
|
return {"key":str(key).strip(),"url":url}
|
||||||
|
except S3ServiceError as e:
|
||||||
|
self._map(e)
|
||||||
|
|
@ -778,6 +778,21 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
|
||||||
|
|
||||||
function pickFile(next) {
|
function pickFile(next) {
|
||||||
if (!next) return
|
if (!next) return
|
||||||
|
const name = (next.name || '').toLowerCase()
|
||||||
|
const mime = (next.type || '').toLowerCase()
|
||||||
|
// Gate at the picker — never hold a non-PDF in state or post it.
|
||||||
|
if (!name.endsWith('.pdf')) {
|
||||||
|
setCv(null)
|
||||||
|
form.setErrors((prev) => ({ ...prev, cv: 'Only PDF resumes are allowed' }))
|
||||||
|
toast('Only PDF files are allowed', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mime && mime !== 'application/pdf' && mime !== 'application/x-pdf') {
|
||||||
|
setCv(null)
|
||||||
|
form.setErrors((prev) => ({ ...prev, cv: 'Only PDF MIME types are allowed' }))
|
||||||
|
toast('Only PDF files are allowed', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
setCv(next)
|
setCv(next)
|
||||||
form.setErrors((prev) => {
|
form.setErrors((prev) => {
|
||||||
if (!prev.cv) return prev
|
if (!prev.cv) return prev
|
||||||
|
|
|
||||||
|
|
@ -99,10 +99,18 @@ export default function CvImport() {
|
||||||
toast('Select a job to score against first', 'warning')
|
toast('Select a job to score against first', 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const files = all.filter((f) => f.name.toLowerCase().endsWith('.pdf'))
|
const bad = all.filter((f) => {
|
||||||
const skipped = all.length - files.length
|
const name = (f.name || '').toLowerCase()
|
||||||
if (skipped) toast(`Only PDF resumes are supported — ${skipped} file(s) skipped`, 'warning')
|
const mime = (f.type || '').toLowerCase()
|
||||||
if (!files.length) return
|
if (!name.endsWith('.pdf')) return true
|
||||||
|
if (mime && mime !== 'application/pdf' && mime !== 'application/x-pdf') return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (bad.length) {
|
||||||
|
toast('Only PDF files are allowed — remove non-PDF uploads and try again', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const files = all
|
||||||
|
|
||||||
const items = files.map((f) => ({
|
const items = files.map((f) => ({
|
||||||
id: `UP-${++rowSeq}-${Date.now()}`,
|
id: `UP-${++rowSeq}-${Date.now()}`,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue