From e6a82aaa23803015fb6df70f036855c4265ee231 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Thu, 27 Aug 2026 16:17:55 +0500 Subject: [PATCH] Job posts: persist and display the cover image The create modal collected an image but dropped it. Now POST /job/image/upload stores it on disk keyed by the post id (uuid-validated, 5 MB / png-jpg-webp-gif, replace-on-reupload; no DB migration) and GET /job/image/fetch serves it. The create flow uploads right after the row exists, image failure downgrades to a toast instead of failing the create, and Job Details renders the cover via an authorized blob fetch. E2E-verified: upload 200, fetch 200, cover renders in Job Details. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 ++ backend/job/app.py | 47 +++++++++++++++++++++++++++ backend/job/job_post/views.py | 58 ++++++++++++++++++++++++++++++++++ frontend/dist/index.html | 4 +-- frontend/src/api/jobs.js | 15 ++++++++- frontend/src/lib/apiClient.js | 51 ++++++++++++++++++++++++++++++ frontend/src/screens/Jobs.jsx | 48 ++++++++++++++++++++++++---- frontend/src/styles/styles.css | 3 ++ 8 files changed, 220 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index d189398..83a125d 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,9 @@ temp/ node_modules/ frontend/dist/ +# Uploaded content (job cover images, …) — user data, never in git +backend/uploads/ + **.pdf # Per-machine alembic autogen revisions only — the old bare `**_**_**.py` # also swallowed any module with two underscores (e.g. test_talent_plugins.py). diff --git a/backend/job/app.py b/backend/job/app.py index 3653429..9c6d2e6 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -296,6 +296,53 @@ async def post_job( raise HTTPException(status_code=500,detail=str(e)) +@router.post("/job/image/upload") +async def upload_job_image( + job_post_id: str = Form(...), + file: UploadFile = File(...), + current_user: dict = Depends(require_permission( + PermissionTag.JOB_BOARD_CREATE, PermissionTag.JOBS_EDIT, require_all=False, + )), + session: AsyncSession = Depends(get_session), +): + """Attach (or replace) the cover image of a job post. Stored on disk keyed + by the post id; the create flow calls this right after /job/post-job.""" + try: + content=await file.read() + service=JobPost(session=session) + data=await service.save_job_image( + job_post_id,file.filename,file.content_type,content,current_user, + ) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/job/image/fetch") +async def fetch_job_image( + job_post_id: str = Query(...), + current_user: dict = Depends(require_permission( + PermissionTag.JOBS_VIEW, PermissionTag.JOB_BOARD_VIEW, require_all=False, + )), + session: AsyncSession = Depends(get_session), +): + """The stored cover image, served inline; 404 when the post has none.""" + try: + service=JobPost(session=session) + path,media_type=await service.get_job_image(job_post_id) + return FileResponse( + path=str(path), + media_type=media_type, + content_disposition_type="inline", + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + class JobAssistRequest(BaseModel): field: Literal[ "title", "department", "location", "salary", diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index b983477..3a47a14 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -2,6 +2,7 @@ from datetime import date, time import logging import os import uuid +from pathlib import Path import httpx from dotenv import load_dotenv @@ -24,6 +25,31 @@ from job.job_post.serializers import serialize_job_post, serialize_job_row load_dotenv() logger=logging.getLogger("job.job_post") +# Cover images are stored on disk keyed by the job post id — no DB column, so +# no migration. One image per post: uploading again replaces the previous file. +JOB_IMAGE_DIR=Path(os.getenv("JOB_IMAGE_DIR") or Path(__file__).resolve().parents[2]/"uploads"/"job_images") +IMAGE_EXT_BY_TYPE={"image/png":"png","image/jpeg":"jpg","image/webp":"webp","image/gif":"gif"} +IMAGE_MEDIA_BY_EXT={"png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","webp":"image/webp","gif":"image/gif"} +MAX_JOB_IMAGE_BYTES=5*1024*1024 + + +def _job_image_key(job_post_id) -> str: + """The id is used as a filename — parse it as a UUID so a crafted value can + never traverse out of the image directory.""" + try: + return str(uuid.UUID(str(job_post_id))) + except ValueError as e: + raise HTTPException(status_code=422,detail="job_post_id must be a UUID") from e + + +def find_job_image(job_post_id) -> Path | None: + key=_job_image_key(job_post_id) + for ext in IMAGE_MEDIA_BY_EXT: + p=JOB_IMAGE_DIR/f"{key}.{ext}" + if p.exists(): + return p + return None + class JobPostCreate(BaseModel): title: str @@ -224,6 +250,38 @@ class JobPost: raise HTTPException(status_code=404,detail="Job post not found") return {"id":str(row.id),"deleted":True} + async def save_job_image(self,job_post_id,filename,content_type,content,current_user): + if not current_user: + raise HTTPException(status_code=401,detail="Not authenticated") + key=_job_image_key(job_post_id) + ext=IMAGE_EXT_BY_TYPE.get((content_type or "").lower()) + if not ext: + # Fall back to the filename extension; browsers occasionally send + # application/octet-stream for perfectly valid images. + suffix=Path((filename or "").replace("\\","/")).suffix.lstrip(".").lower() + ext=suffix if suffix in IMAGE_MEDIA_BY_EXT else None + if not ext: + raise HTTPException(status_code=415,detail="Image must be PNG, JPG, WEBP or GIF") + if not content: + raise HTTPException(status_code=400,detail="Empty image upload") + if len(content)>MAX_JOB_IMAGE_BYTES: + raise HTTPException(status_code=413,detail="Image must be under 5 MB") + rows,total=await JobPosts.fetch_job_posts(self.session,ids=[key],active_only=False) + if not total: + raise HTTPException(status_code=404,detail="Job post not found") + JOB_IMAGE_DIR.mkdir(parents=True,exist_ok=True) + # Replace, never accumulate: drop any previous image regardless of format. + for old_ext in IMAGE_MEDIA_BY_EXT: + (JOB_IMAGE_DIR/f"{key}.{old_ext}").unlink(missing_ok=True) + (JOB_IMAGE_DIR/f"{key}.{ext}").write_bytes(content) + return {"job_post_id":key,"has_image":True} + + async def get_job_image(self,job_post_id): + path=find_job_image(job_post_id) + if not path: + raise HTTPException(status_code=404,detail="No image for this job post") + return path,IMAGE_MEDIA_BY_EXT[path.suffix.lstrip(".").lower()] + async def set_job_status(self,job_post_id,payload,current_user): if not current_user: raise HTTPException(status_code=401,detail="Not authenticated") diff --git a/frontend/dist/index.html b/frontend/dist/index.html index d870204..81a6875 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -24,8 +24,8 @@ - - + +
diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js index d733a51..4a268ea 100644 --- a/frontend/src/api/jobs.js +++ b/frontend/src/api/jobs.js @@ -1,4 +1,4 @@ -import { downloadFile, request } from '../lib/apiClient' +import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient' /** * Job requisitions — backend/job/app.py `GET /jobs/fetch`. @@ -96,6 +96,19 @@ export function remove(jobPostId) { }) } +/** Attach or replace a job post's cover image — POST /job/image/upload (multipart). */ +export function uploadImage(jobPostId, file) { + const fd = new FormData() + fd.append('job_post_id', jobPostId) + fd.append('file', file) + return request('/job/image/upload', { method: 'POST', body: fd }) +} + +/** Object URL of the cover image, or null when the post has none. Caller revokes. */ +export function fetchImageUrl(jobPostId) { + return fetchBlobUrl('/job/image/fetch', { params: { job_post_id: jobPostId } }) +} + /** `status` is the Jobs UI label (Open / Closed / On Hold) or a raw requisition_status. */ export function setStatus(jobPostId, status) { const requisition_status = LABEL_TO_STATUS[status] ?? status diff --git a/frontend/src/lib/apiClient.js b/frontend/src/lib/apiClient.js index 6d452da..9cf0bea 100644 --- a/frontend/src/lib/apiClient.js +++ b/frontend/src/lib/apiClient.js @@ -187,6 +187,57 @@ export async function downloadFile(path, { params, auth = true, filename } = {}) URL.revokeObjectURL(url) } +/** + * Authenticated binary GET returning an object URL for /