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)),
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue