HR-ATS-Portal/backend/tasks/views.py

223 lines
9.2 KiB
Python

import uuid
from datetime import timezone
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from role.models import Roles
from tasks.models import Tasks
from tasks.serializers import serialize_task
from users.models import Users
VALID_STATUS = ("open", "done")
VALID_PRIORITY = ("high", "medium", "low")
# Role names, not ids: the ids are resolved from the roles table at request
# time so a re-seeded database that renumbers roles cannot silently shift who
# may do what. recruiter is role_id 3 on the current seed.
ASSIGNEE_ROLE = "recruiter"
CREATOR_ROLES = ("system_administrator", "hr_administrator", "recruiter")
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):
"""Date-only payloads parse to naive midnight; stored naive, asyncpg would
silently read them as local-shifted UTC (see backend/README timezone note)."""
if value is not None and value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
class Task:
def __init__(self, session: AsyncSession):
self.session = session
async def _users_map(self, ids):
ids = [i for i in set(ids) if i]
if not ids:
return {}
result = await self.session.execute(
select(Users).options(selectinload(Users.role)).where(Users.id.in_(ids))
)
return {u.id: u for u in result.scalars().all()}
async def _validate_assignee(self, assignee_id):
"""Assignees must be recruiter-role accounts (role resolved from the DB):
existing, not deleted, role_name == ASSIGNEE_ROLE."""
user = await Users.get_user_by_id(self.session, str(assignee_id))
if user is None or user.is_deleted:
raise HTTPException(status_code=422, detail="Assignee not found")
role_name = user.role.role_name if user.role else None
if role_name != ASSIGNEE_ROLE:
raise HTTPException(
status_code=422,
detail=f"Tasks can only be assigned to {ASSIGNEE_ROLE} accounts",
)
return user
async def _require_creator_role(self, current_user):
"""Creation is limited to system admin / HR admin / recruiter. The role
ids are looked up from the roles table, and the permission-tag guard on
the route (tasks.create) still applies on top of this."""
result = await self.session.execute(
select(Roles).where(Roles.role_name.in_(CREATOR_ROLES))
)
allowed_ids = {r.id for r in result.scalars().all()}
if current_user.get("role_id") not in allowed_ids:
raise HTTPException(
status_code=403,
detail="Only system administrators, HR administrators and recruiters can create tasks",
)
async def get_assignees(self):
"""The assignee picker: every active recruiter-role user. Separate from
/users/fetch so callers do not need rbac_users.view to assign a task."""
role = await Roles.get_role_by_name(self.session, ASSIGNEE_ROLE)
if role is None:
raise HTTPException(status_code=500, detail=f"Role {ASSIGNEE_ROLE} is not seeded")
rows = await Users.get_users(self.session, top=500, role_id=role.id)
data = [
{
"id": str(u.id),
"name": u.name,
"email": u.email,
"role_id": u.role_id,
"role_name": role.role_name,
"is_active": u.is_active,
}
for u in rows
]
return data, len(data)
async def get_tasks(self, status=None, priority=None, assignee_id=None, top=None, skip=0):
if status and status not in VALID_STATUS:
raise HTTPException(status_code=422, detail=f"status must be one of {', '.join(VALID_STATUS)}")
if priority and priority not in VALID_PRIORITY:
raise HTTPException(status_code=422, detail=f"priority must be one of {', '.join(VALID_PRIORITY)}")
aid = None
if assignee_id:
aid = _as_uuid(assignee_id)
if aid is None:
raise HTTPException(status_code=422, detail="Invalid assignee_id")
rows, total = await Tasks.fetch_tasks(
self.session,
status=status,
priority=priority,
assignee_id=aid,
top=top,
skip=skip or 0,
)
users_by_id = await self._users_map([r.assignee_id for r in rows])
return [serialize_task(r, users_by_id) for r in rows], total
async def create_task(self, payload, current_user):
await self._require_creator_role(current_user)
title = (payload.get("title") or "").strip()
if not title:
raise HTTPException(status_code=422, detail="title is required")
priority = payload.get("priority") or "medium"
if priority not in VALID_PRIORITY:
raise HTTPException(status_code=422, detail=f"priority must be one of {', '.join(VALID_PRIORITY)}")
creator = _user_id(current_user)
# Assignee must be a recruiter. Omitting it only works when the caller
# IS a recruiter (assign to self); an admin must pick one explicitly.
if payload.get("assignee_id"):
assignee = _as_uuid(payload["assignee_id"])
if assignee is None:
raise HTTPException(status_code=422, detail="Invalid assignee_id")
elif current_user.get("role_name") == ASSIGNEE_ROLE:
assignee = creator
else:
raise HTTPException(status_code=422, detail="assignee_id is required")
await self._validate_assignee(assignee)
fields = {
"title": title,
"priority": priority,
"status": "open",
"assignee_id": assignee,
"created_by": creator,
}
if payload.get("due_date") is not None:
fields["due_date"] = _aware(payload["due_date"])
if payload.get("inbox_id") is not None:
fields["inbox_id"] = int(payload["inbox_id"])
if payload.get("job_post_id"):
jid = _as_uuid(payload["job_post_id"])
if jid is None:
raise HTTPException(status_code=422, detail="Invalid job_post_id")
fields["job_post_id"] = jid
row = await Tasks.insert_task(self.session, fields)
users_by_id = await self._users_map([row.assignee_id])
return serialize_task(row, users_by_id)
async def update_task(self, task_id, payload, current_user):
row = await Tasks.get_task_by_id(self.session, task_id)
if not row:
raise HTTPException(status_code=404, detail="Task not found")
_user_id(current_user)
fields = {}
for key in ("title", "status", "priority", "due_date", "assignee_id", "inbox_id", "job_post_id"):
if key not in payload:
continue
value = payload[key]
if key == "title":
value = (value or "").strip()
if not value:
raise HTTPException(status_code=422, detail="title cannot be blank")
elif key == "status":
if value not in VALID_STATUS:
raise HTTPException(status_code=422, detail=f"status must be one of {', '.join(VALID_STATUS)}")
elif key == "priority":
if value not in VALID_PRIORITY:
raise HTTPException(status_code=422, detail=f"priority must be one of {', '.join(VALID_PRIORITY)}")
elif key == "assignee_id":
value = _as_uuid(value)
if value is None:
raise HTTPException(status_code=422, detail="Invalid assignee_id")
await self._validate_assignee(value)
elif key == "due_date":
value = _aware(value)
elif key == "job_post_id" and value is not None:
value = _as_uuid(value)
if value is None:
raise HTTPException(status_code=422, detail="Invalid job_post_id")
fields[key] = value
if not fields:
raise HTTPException(status_code=400, detail="No fields to update")
updated = await Tasks.update_task(self.session, task_id, fields)
if not updated:
raise HTTPException(status_code=404, detail="Task not found")
users_by_id = await self._users_map([updated.assignee_id])
return serialize_task(updated, users_by_id)
async def delete_task(self, task_id, current_user):
_user_id(current_user)
row = await Tasks.soft_delete_task(self.session, task_id)
if not row:
raise HTTPException(status_code=404, detail="Task not found")
return {"id": str(row.id), "deleted": True}