diff --git a/backend/job/app.py b/backend/job/app.py index 6b405e9..4568129 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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)) diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index a6fc3c8..c41a97e 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -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() diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index f2f9a28..267c00a 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -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, } diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 1e9a148..4e9b87f 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -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,