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}