import logging import uuid from datetime import timezone import httpx from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from assessments.models import Assessments, _now from assessments.serializers import serialize_assessment from inbox.models import Inbox from inbox.plugins import send_mail from job.candidate.models import Manual_UPLOAD_CANDIDATE from job.job_post.models import JobPosts from notifications.models import Notifications from users.models import Users logger = logging.getLogger("assessments") VALID_TYPES = ( "Coding Challenge", "Take-home Project", "Cognitive Test", "Personality Assessment", "SQL Test", "Case Study", ) VALID_STATUS = ("pending", "in_progress", "completed", "expired") def _as_uuid(value): if value in (None, ""): return None try: return uuid.UUID(str(value)) except (TypeError, ValueError): return None def _user_id(current_user): if not current_user or not current_user.get("id"): raise HTTPException(status_code=401, detail="Not authenticated") uid = _as_uuid(current_user["id"]) if uid is None: raise HTTPException(status_code=401, detail="Invalid user id") return uid def _aware(value): if value is not None and getattr(value, "tzinfo", None) is None: return value.replace(tzinfo=timezone.utc) return value class Assessment: def __init__(self, session: AsyncSession): self.session = session async def _emit(self, current_user, kind, title, body, *, inbox_id=None, job_post_id=None, link_path=None): uid = _as_uuid(current_user.get("id") if current_user else None) if uid is None: return try: await Notifications.insert_notification(self.session, { "user_id": uid, "kind": kind, "title": title, "body": body, "link_path": link_path, "inbox_id": inbox_id, "job_post_id": job_post_id, }) except Exception as exc: logger.warning("notification insert skipped: %s", exc) async def _context_maps(self, rows): inbox_ids = [r.inbox_id for r in rows if r.inbox_id is not None] manual_ids = [r.manual_upload_candidate_id for r in rows if r.manual_upload_candidate_id] job_ids = [r.job_post_id for r in rows if r.job_post_id] inbox_by_id = {} if inbox_ids: result = await self.session.execute( select(Inbox) .options(selectinload(Inbox.messages), selectinload(Inbox.user)) .where(Inbox.id.in_(inbox_ids)) ) inbox_by_id = {row.id: row for row in result.scalars().all()} for row in inbox_by_id.values(): msg = row.messages if msg is not None and msg.assigned_job_post_id: job_ids.append(msg.assigned_job_post_id) manual_by_id = {} if manual_ids: result = await self.session.execute( select(Manual_UPLOAD_CANDIDATE).where(Manual_UPLOAD_CANDIDATE.id.in_(manual_ids)) ) manual_by_id = {row.id: row for row in result.scalars().all()} for row in manual_by_id.values(): if row.job_post_id: job_ids.append(row.job_post_id) jobs_by_id = {} uids = [j for j in set(job_ids) if j] if uids: result = await self.session.execute(select(JobPosts).where(JobPosts.id.in_(uids))) jobs_by_id = {row.id: row for row in result.scalars().all()} return inbox_by_id, manual_by_id, jobs_by_id def _labels(self, row, inbox_by_id, manual_by_id, jobs_by_id): candidate_name = None job_title = None if row.job_post_id and row.job_post_id in jobs_by_id: job_title = jobs_by_id[row.job_post_id].title if row.inbox_id is not None: link = inbox_by_id.get(row.inbox_id) if link is not None: if link.user is not None: candidate_name = link.user.name msg = link.messages if job_title is None and msg is not None and msg.assigned_job_post_id: job = jobs_by_id.get(msg.assigned_job_post_id) if job is not None: job_title = job.title if row.manual_upload_candidate_id: manual = manual_by_id.get(row.manual_upload_candidate_id) if manual is not None: candidate_name = candidate_name or manual.candidate_name or None if job_title is None and manual.job_post_id: job = jobs_by_id.get(manual.job_post_id) if job is not None: job_title = job.title return candidate_name, job_title async def _serialize_rows(self, rows): inbox_by_id, manual_by_id, jobs_by_id = await self._context_maps(rows) out = [] for row in rows: name, title = self._labels(row, inbox_by_id, manual_by_id, jobs_by_id) out.append(serialize_assessment(row, candidate_name=name, job_title=title)) return out async def _recipient(self, row): if row.inbox_id is not None: link = await Inbox.get_inbox_with_message(self.session, row.inbox_id) if link is None: return None, None, None user = link.user if user is None and link.user_id: user = await Users.get_user_by_id(self.session, str(link.user_id)) email = user.email if user else None name = user.name if user else None return email, name, link.id if row.manual_upload_candidate_id: manual = await Manual_UPLOAD_CANDIDATE.get_by_id( self.session, row.manual_upload_candidate_id ) if manual is None: return None, None, None return (manual.candidate_email or None), (manual.candidate_name or None), None return None, None, None async def get_assessments( self, assessment_id=None, inbox_id=None, manual_upload_candidate_id=None, job_post_id=None, assessment_status=None, top=None, skip=0, ): if assessment_status and assessment_status not in VALID_STATUS: raise HTTPException( status_code=422, detail=f"assessment_status must be one of {', '.join(VALID_STATUS)}" ) rows, total = await Assessments.fetch_assessments( self.session, assessment_id=assessment_id, inbox_id=inbox_id, manual_upload_candidate_id=manual_upload_candidate_id, job_post_id=job_post_id, assessment_status=assessment_status, top=top, skip=skip or 0, ) return await self._serialize_rows(rows), total async def get_counts(self): counts = await Assessments.count_by_status(self.session) return {status: int(counts.get(status, 0)) for status in VALID_STATUS} async def create_assessment(self, payload, current_user): assessment_type = (payload.get("assessment_type") or "").strip() if assessment_type not in VALID_TYPES: raise HTTPException( status_code=422, detail=f"assessment_type must be one of {', '.join(VALID_TYPES)}" ) inbox_id = payload.get("inbox_id") manual_id = _as_uuid(payload.get("manual_upload_candidate_id")) has_inbox = inbox_id is not None has_manual = manual_id is not None if has_inbox == has_manual: raise HTTPException( status_code=422, detail="Exactly one of inbox_id or manual_upload_candidate_id is required", ) if has_inbox: try: inbox_id = int(inbox_id) except (TypeError, ValueError): raise HTTPException(status_code=422, detail="Invalid inbox_id") link = await Inbox.get_inbox_by_id(self.session, inbox_id) if link is None: raise HTTPException(status_code=404, detail="Inbox record not found") else: inbox_id = None manual = await Manual_UPLOAD_CANDIDATE.get_by_id(self.session, manual_id) if manual is None: raise HTTPException(status_code=404, detail="Manual upload candidate not found") job_post_id = _as_uuid(payload.get("job_post_id")) if payload.get("job_post_id") and job_post_id is None: raise HTTPException(status_code=422, detail="Invalid job_post_id") if job_post_id is not None: post = await JobPosts.get_job_post_by_id(self.session, str(job_post_id)) if not post or post.is_deleted: raise HTTPException(status_code=404, detail="Job post not found") fields = { "inbox_id": inbox_id, "manual_upload_candidate_id": manual_id, "job_post_id": job_post_id, "assessment_type": assessment_type, "assessment_status": "pending", "duration_minutes": payload.get("duration_minutes"), "assigned_at": _now(), "created_by": _user_id(current_user), } if payload.get("due_at") is not None: fields["due_at"] = _aware(payload["due_at"]) row = await Assessments.insert_assessment(self.session, fields) data = (await self._serialize_rows([row]))[0] await self._emit( current_user, "assessment", "Assessment assigned", f"{assessment_type} assigned to {data.get('candidate_name') or 'a candidate'}", inbox_id=inbox_id, job_post_id=job_post_id, link_path="/assessments", ) return data async def update_assessment(self, assessment_id, payload, current_user): _user_id(current_user) row = await Assessments.get_assessment_by_id(self.session, assessment_id) if not row: raise HTTPException(status_code=404, detail="Assessment not found") fields = {} if "assessment_status" in payload: status = payload["assessment_status"] if status not in VALID_STATUS: raise HTTPException( status_code=422, detail=f"assessment_status must be one of {', '.join(VALID_STATUS)}" ) fields["assessment_status"] = status if status == "completed" and row.completed_at is None: fields["completed_at"] = _now() if "score" in payload: score = payload["score"] if score is not None and (not isinstance(score, int) or score < 0 or score > 100): raise HTTPException(status_code=422, detail="score must be an integer 0-100") fields["score"] = score if "section_scores" in payload: sections = payload["section_scores"] if sections is not None and not isinstance(sections, list): raise HTTPException(status_code=422, detail="section_scores must be a list") fields["section_scores"] = sections if "due_at" in payload: fields["due_at"] = _aware(payload["due_at"]) if not fields: raise HTTPException(status_code=400, detail="No fields to update") updated = await Assessments.update_assessment(self.session, assessment_id, fields) if not updated: raise HTTPException(status_code=404, detail="Assessment not found") return (await self._serialize_rows([updated]))[0] async def delete_assessment(self, assessment_id, current_user): _user_id(current_user) row = await Assessments.soft_delete_assessment(self.session, assessment_id) if not row: raise HTTPException(status_code=404, detail="Assessment not found") return {"id": str(row.id), "deleted": True} async def remind_assessment(self, assessment_id, current_user): _user_id(current_user) row = await Assessments.get_assessment_by_id(self.session, assessment_id) if not row: raise HTTPException(status_code=404, detail="Assessment not found") email, name, inbox_id = await self._recipient(row) if not email: raise HTTPException(status_code=422, detail="Candidate has no email address") subject = f"Reminder: {row.assessment_type}" body = ( f"

This is a reminder that your {row.assessment_type} assessment is pending" f"{' for ' + name if name else ''}.

" ) try: await send_mail(email, subject, body, content_type="html") except (httpx.HTTPError, RuntimeError) as e: raise HTTPException(status_code=502, detail="Failed to send reminder email") from e updated = await Assessments.update_assessment( self.session, assessment_id, {"reminded_at": _now()} ) data = (await self._serialize_rows([updated]))[0] await self._emit( current_user, "assessment", "Assessment reminder sent", f"Reminder sent for {row.assessment_type}", inbox_id=inbox_id, job_post_id=row.job_post_id, link_path="/assessments", ) return data