with filepath and fiolename

pull/10/head
ahmed.mujtaba 2026-08-12 13:47:19 +05:00
parent 5e8bc23ce7
commit d38b21bb1a
4 changed files with 77 additions and 1 deletions

View File

@ -110,11 +110,16 @@ 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:
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
# 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)
data=await service.create_candidate( data=await service.create_candidate(
candidate_email=candidate_email, candidate_email=candidate_email,
@ -126,13 +131,19 @@ 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_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"),
) )
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))

View File

@ -46,6 +46,14 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
# DEFAULT backfills them. Pass the bare "" — SQLAlchemy quotes a plain string # DEFAULT backfills them. Pass the bare "" — SQLAlchemy quotes a plain string
# into DEFAULT '', whereas "''" would render DEFAULT '''''' instead. # into DEFAULT '', whereas "''" would render DEFAULT '''''' instead.
referral_by: str = Field(default="", sa_column_kwargs={"server_default": ""}) 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)) created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_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(), experience=(fields.get("experience") or "").strip(),
status=(fields.get("status") or "").strip(), status=(fields.get("status") or "").strip(),
referral_by=(fields.get("referral_by") 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) session.add(row)
await session.commit() await session.commit()

View File

@ -22,6 +22,8 @@ def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
"experience":row.experience, "experience":row.experience,
"status":row.status, "status":row.status,
"referral_by":row.referral_by, "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, "created_at":row.created_at.isoformat() if row.created_at else None,
"updated_at":row.updated_at.isoformat() if row.updated_at else None, "updated_at":row.updated_at.isoformat() if row.updated_at else None,
} }

View File

@ -1,6 +1,7 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import base64,io,logging,os,uuid import base64,io,logging,os,uuid
from datetime import datetime,timezone from datetime import datetime,timezone
from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import HTTPException from fastapi import HTTPException
from pypdf import PdfReader from pypdf import PdfReader
@ -55,6 +56,56 @@ class FileRead:
except Exception as e: except Exception as e:
raise HTTPException(status_code=400,detail=str(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): async def ingest_upload(self,candidate_email=None,candidate_name=None):
"""Persist a recruiter-uploaded CV with full email-ingestion parity.""" """Persist a recruiter-uploaded CV with full email-ingestion parity."""
from inbox.file_decoder import AttachmentDecodeError,decode_attachment from inbox.file_decoder import AttachmentDecodeError,decode_attachment
@ -182,7 +233,7 @@ class CandidateView:
def __init__(self,session:AsyncSession): def __init__(self,session:AsyncSession):
self.session=session 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: try:
email=(candidate_email or "").strip().lower() email=(candidate_email or "").strip().lower()
if not email: if not email:
@ -199,6 +250,8 @@ 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_path":(file_path or "").strip(),
"full_text":full_text or "", "full_text":full_text or "",
"created_by":current_user, "created_by":current_user,