"""Shift handover: the document CX fills in at the end of every shift.""" from __future__ import annotations import datetime as dt from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session from ..auth import current_user from ..db import get_db from ..models import (Handover, HandoverItem, HandoverStatus, ItemState, RunpodColour, RunpodHost, ShiftName, User) router = APIRouter(prefix="/api/handover", tags=["handover"]) class HandoverBody(BaseModel): shift_date: str | None = None shift: str = "APAC" handing_to: str = "" team_members: str = "" significant_issues_checked: bool = False hubspot_checked: bool = False total_open_tickets: int | None = None member_checks: list[dict] = [] other_comments: str = "" reviewed_by: str = "" reviewed_at_utc: str = "" following_shift_checked: bool = False class ItemBody(BaseModel): title: str = "" zendesk_tickets: str = "" jira_key: str = "" jira_status: str = "" body: str = "" links: list[str] = [] state: str = "in_progress" remove_at_end_of_shift: bool = False position: int | None = None def _get(db: Session, handover_id: int) -> Handover: found = db.get(Handover, handover_id) if not found: raise HTTPException(status.HTTP_404_NOT_FOUND, "No such handover") return found @router.get("") def list_handovers(limit: int = 30, db: Session = Depends(get_db), user: User = Depends(current_user)): rows = db.scalars(select(Handover).order_by(Handover.shift_date.desc(), Handover.id.desc()) .limit(max(1, min(limit, 200)))).all() return { "handovers": [h.to_json() for h in rows], "shifts": [s.value for s in ShiftName], "states": [s.value for s in ItemState], } @router.get("/current") def current(db: Session = Depends(get_db), user: User = Depends(current_user)): """The newest draft, or the newest handover of any kind.""" row = db.scalars(select(Handover).where(Handover.status == HandoverStatus.DRAFT) .order_by(Handover.shift_date.desc(), Handover.id.desc()).limit(1)).first() if row is None: row = db.scalars(select(Handover).order_by(Handover.shift_date.desc(), Handover.id.desc()).limit(1)).first() if row is None: return {"handover": None} return {"handover": row.to_json(with_items=True), "runpod": _runpod_section(db)} @router.get("/{handover_id}") def get_one(handover_id: int, db: Session = Depends(get_db), user: User = Depends(current_user)): return {"handover": _get(db, handover_id).to_json(with_items=True), "runpod": _runpod_section(db)} def _runpod_section(db: Session) -> list[dict]: """The RunPod table on the handover, straight from live host state. This is the part that used to be retyped by hand every shift. """ rows = db.scalars(select(RunpodHost).where(RunpodHost.listed.is_(False)) .order_by(RunpodHost.unlist_count.desc(), RunpodHost.name)).all() return [{ "machine_id": h.machine_id, "name": h.name, "colour": h.colour.value, "zendesk_ticket": h.zendesk_ticket, "jira_key": h.jira_key, "jira_status": h.jira_status, "gpu_reserved": h.gpu_reserved, "gpu_total": h.gpu_total, "last_error": h.last_error, "next_steps": h.next_steps, "hours_unlisted": h.hours_unlisted, "unlist_count": h.unlist_count, } for h in rows] @router.post("") def create(body: HandoverBody, db: Session = Depends(get_db), user: User = Depends(current_user)): shift_date = dt.date.fromisoformat(body.shift_date) if body.shift_date else dt.date.today() try: shift = ShiftName(body.shift) except ValueError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unknown shift '{body.shift}'") from exc existing = db.scalars(select(Handover).where(Handover.shift_date == shift_date, Handover.shift == shift)).first() if existing: raise HTTPException(status.HTTP_409_CONFLICT, f"A {shift.value} handover already exists for {shift_date}") row = Handover(shift_date=shift_date, shift=shift, handing_to=body.handing_to, team_members=body.team_members, created_by_id=user.id) db.add(row) db.commit() return row.to_json(with_items=True) @router.post("/{handover_id}") def update(handover_id: int, body: HandoverBody, db: Session = Depends(get_db), user: User = Depends(current_user)): row = _get(db, handover_id) for field in ("handing_to", "team_members", "significant_issues_checked", "hubspot_checked", "total_open_tickets", "member_checks", "other_comments", "reviewed_by", "reviewed_at_utc", "following_shift_checked"): setattr(row, field, getattr(body, field)) if body.shift_date: row.shift_date = dt.date.fromisoformat(body.shift_date) db.commit() return row.to_json(with_items=True) @router.post("/{handover_id}/hand-over") def hand_over(handover_id: int, db: Session = Depends(get_db), user: User = Depends(current_user)): """Close the shift and start the next one, carrying the live items forward. Items flagged "remove at end of shift", and anything already done, are left behind - which is the manual step this replaces. """ row = _get(db, handover_id) row.status = HandoverStatus.HANDED_OVER order = [ShiftName.APAC, ShiftName.EMEA, ShiftName.AMER] next_shift = order[(order.index(row.shift) + 1) % len(order)] next_date = row.shift_date + dt.timedelta(days=1) if next_shift == ShiftName.APAC else row.shift_date following = db.scalars(select(Handover).where(Handover.shift_date == next_date, Handover.shift == next_shift)).first() if following is None: following = Handover(shift_date=next_date, shift=next_shift, handing_to=order[(order.index(next_shift) + 1) % len(order)].value, created_by_id=user.id) db.add(following) db.flush() carried = 0 closed = {ItemState.DONE, ItemState.NO_FURTHER_ENGAGEMENT} for item in row.items: if item.remove_at_end_of_shift or item.state in closed: continue following.items.append(HandoverItem( position=item.position, title=item.title, zendesk_tickets=item.zendesk_tickets, jira_key=item.jira_key, jira_status=item.jira_status, body=item.body, links=list(item.links or []), state=item.state, carried_from_id=item.id, first_raised_on=item.first_raised_on or row.shift_date, )) carried += 1 db.commit() return {"closed": row.to_json(), "next": following.to_json(with_items=True), "carried": carried} @router.post("/{handover_id}/items") def add_item(handover_id: int, body: ItemBody, db: Session = Depends(get_db), user: User = Depends(current_user)): row = _get(db, handover_id) try: state = ItemState(body.state) except ValueError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unknown state '{body.state}'") from exc item = HandoverItem( position=body.position if body.position is not None else len(row.items), title=body.title, zendesk_tickets=body.zendesk_tickets, jira_key=body.jira_key, jira_status=body.jira_status, body=body.body, links=body.links, state=state, remove_at_end_of_shift=body.remove_at_end_of_shift, first_raised_on=row.shift_date, ) row.items.append(item) db.commit() return row.to_json(with_items=True) @router.post("/{handover_id}/items/{item_id}") def update_item(handover_id: int, item_id: int, body: ItemBody, db: Session = Depends(get_db), user: User = Depends(current_user)): row = _get(db, handover_id) item = next((i for i in row.items if i.id == item_id), None) if item is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "No such item on this handover") try: item.state = ItemState(body.state) except ValueError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unknown state '{body.state}'") from exc for field in ("title", "zendesk_tickets", "jira_key", "jira_status", "body", "links", "remove_at_end_of_shift"): setattr(item, field, getattr(body, field)) if body.position is not None: item.position = body.position db.commit() return row.to_json(with_items=True) @router.delete("/{handover_id}/items/{item_id}") def delete_item(handover_id: int, item_id: int, db: Session = Depends(get_db), user: User = Depends(current_user)): row = _get(db, handover_id) item = next((i for i in row.items if i.id == item_id), None) if item is not None: db.delete(item) db.commit() return _get(db, handover_id).to_json(with_items=True)