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}