216 lines
8.9 KiB
Python
216 lines
8.9 KiB
Python
import uuid
|
|
from datetime import timezone
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
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 {}
|
|
return {u.id: u for u in await Users.get_by_ids(self.session, ids)}
|
|
|
|
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."""
|
|
rows = await Roles.get_by_names(self.session, CREATOR_ROLES)
|
|
allowed_ids = {r.id for r in rows}
|
|
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}
|