326 lines
13 KiB
Python
326 lines
13 KiB
Python
import logging
|
|
import uuid
|
|
from datetime import timezone
|
|
|
|
import httpx
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
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:
|
|
inbox_by_id = {row.id: row for row in await Inbox.get_by_ids(self.session, inbox_ids)}
|
|
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:
|
|
manual_by_id = {
|
|
row.id: row for row in await Manual_UPLOAD_CANDIDATE.get_by_ids(self.session, manual_ids)
|
|
}
|
|
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:
|
|
jobs_by_id = {
|
|
row.id: row for row in await JobPosts.get_by_ids(self.session, uids, active_only=False)
|
|
}
|
|
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"<p>This is a reminder that your {row.assessment_type} assessment is pending"
|
|
f"{' for ' + name if name else ''}.</p>"
|
|
)
|
|
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
|