with filepath and fiolename
parent
5e8bc23ce7
commit
d38b21bb1a
|
|
@ -110,11 +110,16 @@ async def create_manual_candidate(
|
|||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
saved_path=None
|
||||
try:
|
||||
file_content = await file.read()
|
||||
logger.info(f"Received file: {file.filename} ({len(file_content)} bytes)")
|
||||
reader=FileRead(session=session,filename=file.filename,file=file_content)
|
||||
# Parse first: an unreadable PDF is a 400, and doing it before the write
|
||||
# keeps a file that can never back a row off the disk entirely.
|
||||
parsed=await reader.injest_manual_upload()
|
||||
saved=await reader.save_manual_upload()
|
||||
saved_path=saved.get("file_path")
|
||||
service=CandidateView(session=session)
|
||||
data=await service.create_candidate(
|
||||
candidate_email=candidate_email,
|
||||
|
|
@ -126,13 +131,19 @@ async def create_manual_candidate(
|
|||
experience=experience,
|
||||
status=status,
|
||||
referral_by=referral_by,
|
||||
file_name=saved.get("file_name"),
|
||||
file_path=saved_path,
|
||||
full_text=parsed.get("text") or "",
|
||||
current_user=current_user.get("id"),
|
||||
)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
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
|
||||
except Exception as e:
|
||||
FileRead.discard_upload(saved_path)
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
# DEFAULT backfills them. Pass the bare "" — SQLAlchemy quotes a plain string
|
||||
# into DEFAULT '', whereas "''" would render DEFAULT '''''' instead.
|
||||
referral_by: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||
# The CV as uploaded: file_name is the recruiter-facing original, file_path
|
||||
# the absolute location under inbox/decoded_attachments. They differ on
|
||||
# purpose — the stored basename is uniquified so two candidates uploading
|
||||
# "resume.pdf" cannot overwrite one another (see FileRead.save_manual_upload).
|
||||
# Same ALTER-on-a-populated-table reasoning as referral_by above, so both
|
||||
# carry a server default.
|
||||
file_name: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||
file_path: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
|
||||
|
|
@ -95,6 +103,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
experience=(fields.get("experience") or "").strip(),
|
||||
status=(fields.get("status") or "").strip(),
|
||||
referral_by=(fields.get("referral_by") or "").strip(),
|
||||
file_name=(fields.get("file_name") or "").strip(),
|
||||
file_path=(fields.get("file_path") or "").strip(),
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
|
|||
"experience":row.experience,
|
||||
"status":row.status,
|
||||
"referral_by":row.referral_by,
|
||||
"file_name":row.file_name,
|
||||
"file_path":row.file_path,
|
||||
"created_at":row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at":row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import base64,io,logging,os,uuid
|
||||
from datetime import datetime,timezone
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import HTTPException
|
||||
from pypdf import PdfReader
|
||||
|
|
@ -55,6 +56,56 @@ class FileRead:
|
|||
except Exception as e:
|
||||
raise HTTPException(status_code=400,detail=str(e))
|
||||
|
||||
async def save_manual_upload(self):
|
||||
"""Write the uploaded CV under inbox/decoded_attachments.
|
||||
|
||||
Returns ``{"file_name", "file_path"}``: the recruiter-facing original
|
||||
name, and the absolute path actually written.
|
||||
|
||||
Those two differ deliberately. decode_attachment writes ``Path(name).name``
|
||||
with plain ``write_bytes`` — no collision handling — so two candidates
|
||||
uploading "resume.pdf" would silently clobber each other and the first
|
||||
row's file_path would then serve the second candidate's CV. Prefixing the
|
||||
stored basename with a uuid makes every upload its own file, while
|
||||
file_name keeps what the recruiter recognises. resolve_attachment_path
|
||||
handles the result either way: the stored absolute path wins, and its
|
||||
basename-under-attachments fallback still finds the prefixed name.
|
||||
"""
|
||||
from inbox.file_decoder import AttachmentDecodeError,decode_attachment
|
||||
|
||||
# Separators normalized before taking the basename: a Windows client can
|
||||
# send "C:\Users\x\cv.pdf", whose Path(...).name on Linux is the whole
|
||||
# string. Same reasoning as inbox.plugins.resolve_attachment_path.
|
||||
original=Path((self.filename or "resume.pdf").replace("\\","/")).name or "resume.pdf"
|
||||
stored=f"{uuid.uuid4().hex}-{original}"
|
||||
try:
|
||||
paths=await decode_attachment([{
|
||||
"name":stored,
|
||||
"contentBytes":base64.b64encode(self.file).decode("ascii"),
|
||||
}])
|
||||
except AttachmentDecodeError as e:
|
||||
raise HTTPException(status_code=400,detail=str(e))
|
||||
if not paths:
|
||||
# decode_attachment skips rather than raises on an unsupported
|
||||
# extension, so an empty list is the only signal that nothing landed.
|
||||
raise HTTPException(status_code=400,detail="attachment could not be saved")
|
||||
return {"file_name":original,"file_path":paths[0]}
|
||||
|
||||
@staticmethod
|
||||
def discard_upload(file_path):
|
||||
"""Best-effort removal of a saved CV whose row never got created.
|
||||
|
||||
Called on the failure path so a rejected request (a missing email, a DB
|
||||
error) does not leave an orphan PDF behind. Failure to delete is logged
|
||||
and swallowed — it must never mask the error that got us here.
|
||||
"""
|
||||
if not file_path:
|
||||
return
|
||||
try:
|
||||
Path(file_path).unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.warning("could not remove orphaned upload %s: %s",file_path,e)
|
||||
|
||||
async def ingest_upload(self,candidate_email=None,candidate_name=None):
|
||||
"""Persist a recruiter-uploaded CV with full email-ingestion parity."""
|
||||
from inbox.file_decoder import AttachmentDecodeError,decode_attachment
|
||||
|
|
@ -182,7 +233,7 @@ class CandidateView:
|
|||
def __init__(self,session:AsyncSession):
|
||||
self.session=session
|
||||
|
||||
async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,platform=None,experience=None,status=None,referral_by=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,platform=None,experience=None,status=None,referral_by=None,file_name=None,file_path=None,full_text=None,current_user=None):
|
||||
try:
|
||||
email=(candidate_email or "").strip().lower()
|
||||
if not email:
|
||||
|
|
@ -199,6 +250,8 @@ class CandidateView:
|
|||
"experience":(experience or "").strip(),
|
||||
"status":(status or "").strip(),
|
||||
"referral_by":(referral_by or "").strip(),
|
||||
"file_name":(file_name or "").strip(),
|
||||
"file_path":(file_path or "").strip(),
|
||||
"full_text":full_text or "",
|
||||
"created_by":current_user,
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue