- Implemented a new bottom bar feature in `bottom-bar.js` that fetches and displays various notifications and statuses in real-time. - Added functions for handling visibility, state updates, and user interactions within the bottom bar. - Introduced WebSocket connection for real-time updates and fallback polling mechanism. - Created a manual testing script `test_manual.py` to validate API endpoints for the manual module. - Included tests for various paths to ensure expected responses from the server.
289 lines
10 KiB
Python
289 lines
10 KiB
Python
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from pydantic import BaseModel
|
|
|
|
from app.core.auth_service import AuthService
|
|
from app.core.auth_dependencies import get_current_user
|
|
from app.core.database import execute_query, execute_query_single
|
|
|
|
from .service import build_bottom_bar_state
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class BossAssignPayload(BaseModel):
|
|
case_id: int
|
|
assignee_user_id: int
|
|
|
|
|
|
class BossAssignNextPayload(BaseModel):
|
|
assignee_user_id: int
|
|
|
|
|
|
def _resolve_user_id_from_request(request: Request) -> Optional[int]:
|
|
state_user_id = getattr(request.state, "user_id", None)
|
|
if state_user_id is not None:
|
|
try:
|
|
return int(state_user_id)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
user_id_param = request.query_params.get("user_id")
|
|
if user_id_param:
|
|
try:
|
|
return int(user_id_param)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
token = (request.cookies.get("access_token") or "").strip() or None
|
|
payload = AuthService.verify_token(token) if token else None
|
|
sub_claim = payload.get("sub") if payload else None
|
|
if sub_claim is not None:
|
|
try:
|
|
return int(sub_claim)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return None
|
|
|
|
@router.get("/state")
|
|
async def get_bottom_bar_state(request: Request, current_user: dict = Depends(get_current_user)):
|
|
current_user_id = current_user.get("id")
|
|
if current_user_id is None:
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
user_id = int(current_user_id)
|
|
force_boss_access = bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin"))
|
|
context_path = request.query_params.get("context") or ""
|
|
return build_bottom_bar_state(user_id, context_path=context_path, force_boss_access=force_boss_access)
|
|
|
|
from app.services.task_routing import TaskRouter
|
|
from app.services.m365_calendar import M365CalendarService
|
|
|
|
|
|
def _has_boss_access(current_user: dict) -> bool:
|
|
if bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin")):
|
|
return True
|
|
|
|
current_user_id = current_user.get("id")
|
|
if current_user_id is None:
|
|
return False
|
|
|
|
rows = execute_query(
|
|
"""
|
|
SELECT LOWER(g.name) AS name
|
|
FROM user_groups ug
|
|
JOIN groups g ON g.id = ug.group_id
|
|
WHERE ug.user_id = %s
|
|
""",
|
|
(int(current_user_id),),
|
|
) or []
|
|
names = [str(r.get("name") or "") for r in rows]
|
|
tokens = ("admin", "manager", "leder", "chef", "teknik", "technician", "support")
|
|
return any(any(token in name for token in tokens) for name in names)
|
|
|
|
|
|
def _ensure_user_exists(user_id: int) -> None:
|
|
user = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="Bruger ikke fundet")
|
|
|
|
|
|
def _get_next_unassigned_case() -> Optional[dict]:
|
|
return execute_query_single(
|
|
"""
|
|
SELECT id, titel, priority
|
|
FROM sag_sager
|
|
WHERE deleted_at IS NULL
|
|
AND ansvarlig_bruger_id IS NULL
|
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
|
ORDER BY
|
|
CASE
|
|
WHEN LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0
|
|
WHEN LOWER(COALESCE(priority, 'normal')) IN ('high', 'høj') THEN 1
|
|
ELSE 2
|
|
END,
|
|
COALESCE(updated_at, created_at) ASC,
|
|
id ASC
|
|
LIMIT 1
|
|
"""
|
|
)
|
|
|
|
@router.post("/next_task")
|
|
async def assign_next_task(
|
|
request: Request,
|
|
user_id: int | None = Query(default=None),
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
# Prefer authenticated user context; allow explicit user_id for controlled testing.
|
|
current_user_id = current_user.get("id")
|
|
resolved_user_id = user_id
|
|
if resolved_user_id is None and current_user_id is not None:
|
|
resolved_user_id = int(current_user_id)
|
|
if resolved_user_id is None:
|
|
raise HTTPException(status_code=401, detail="Authentication required for task assignment")
|
|
|
|
# Kombinerer de nye services
|
|
router_svc = TaskRouter()
|
|
cal = M365CalendarService()
|
|
|
|
# Henter hvor meget fri tid medarbejderen har lige nu
|
|
free_mins = await cal.get_user_free_time("now", 2)
|
|
|
|
# Bed the engine allocate the next best task
|
|
task = await router_svc.get_next_best_task(resolved_user_id)
|
|
task = task or {}
|
|
|
|
return {
|
|
"status": "assigned",
|
|
"task": task,
|
|
"free_time_calculated": free_mins,
|
|
"message": f"Fandt Næste Opgave (SLA: {task.get('assigned_reason')} - {task.get('estimated_minutes')}m. Du har {free_mins}m frit). "
|
|
}
|
|
|
|
|
|
@router.post("/boss/auto-assign-next")
|
|
async def boss_auto_assign_next(current_user: dict = Depends(get_current_user)):
|
|
if not _has_boss_access(current_user):
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
|
|
|
|
next_case = _get_next_unassigned_case()
|
|
if not next_case:
|
|
return {
|
|
"status": "noop",
|
|
"message": "Ingen ufordelte åbne sager at fordele.",
|
|
}
|
|
|
|
assignee = execute_query_single(
|
|
"""
|
|
SELECT
|
|
u.user_id,
|
|
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
|
|
COUNT(s.id)::int AS open_cases,
|
|
COUNT(CASE WHEN LOWER(COALESCE(s.priority, 'normal')) IN ('urgent', 'critical', 'kritisk', 'high', 'høj') THEN 1 END)::int AS hot_cases
|
|
FROM users u
|
|
JOIN user_groups ug ON ug.user_id = u.user_id
|
|
JOIN groups g ON g.id = ug.group_id
|
|
LEFT JOIN sag_sager s
|
|
ON s.ansvarlig_bruger_id = u.user_id
|
|
AND s.deleted_at IS NULL
|
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
|
WHERE LOWER(g.name) LIKE ANY(ARRAY['%admin%', '%manager%', '%leder%', '%chef%', '%teknik%', '%technician%', '%support%'])
|
|
GROUP BY u.user_id, u.full_name, u.username
|
|
ORDER BY hot_cases ASC, open_cases ASC, owner_name ASC
|
|
LIMIT 1
|
|
"""
|
|
)
|
|
if not assignee:
|
|
raise HTTPException(status_code=409, detail="Ingen kvalificeret medarbejder fundet til auto-fordeling")
|
|
|
|
updated = execute_query_single(
|
|
"""
|
|
UPDATE sag_sager
|
|
SET ansvarlig_bruger_id = %s,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
RETURNING id, titel, priority, ansvarlig_bruger_id
|
|
""",
|
|
(int(assignee["user_id"]), int(next_case["id"])),
|
|
)
|
|
if not updated:
|
|
raise HTTPException(status_code=500, detail="Kunne ikke opdatere sag")
|
|
|
|
return {
|
|
"status": "assigned",
|
|
"message": "Sagen blev auto-fordelt.",
|
|
"case": {
|
|
"id": updated.get("id"),
|
|
"title": updated.get("titel") or f"Sag #{updated.get('id')}",
|
|
"priority": updated.get("priority") or "normal",
|
|
},
|
|
"assignee": {
|
|
"user_id": assignee.get("user_id"),
|
|
"name": assignee.get("owner_name") or f"Bruger #{assignee.get('user_id')}",
|
|
},
|
|
}
|
|
|
|
|
|
@router.post("/boss/assign-case")
|
|
async def boss_assign_case(payload: BossAssignPayload, current_user: dict = Depends(get_current_user)):
|
|
if not _has_boss_access(current_user):
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
|
|
|
|
_ensure_user_exists(int(payload.assignee_user_id))
|
|
|
|
case_row = execute_query_single(
|
|
"""
|
|
SELECT id, titel, priority
|
|
FROM sag_sager
|
|
WHERE id = %s
|
|
AND deleted_at IS NULL
|
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
|
""",
|
|
(int(payload.case_id),),
|
|
)
|
|
if not case_row:
|
|
raise HTTPException(status_code=404, detail="Sag ikke fundet eller er afsluttet")
|
|
|
|
updated = execute_query_single(
|
|
"""
|
|
UPDATE sag_sager
|
|
SET ansvarlig_bruger_id = %s,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
RETURNING id, titel, priority, ansvarlig_bruger_id
|
|
""",
|
|
(int(payload.assignee_user_id), int(payload.case_id)),
|
|
)
|
|
if not updated:
|
|
raise HTTPException(status_code=500, detail="Kunne ikke tildele sag")
|
|
|
|
return {
|
|
"status": "assigned",
|
|
"message": "Sagen blev tildelt.",
|
|
"case": {
|
|
"id": updated.get("id"),
|
|
"title": updated.get("titel") or f"Sag #{updated.get('id')}",
|
|
"priority": updated.get("priority") or "normal",
|
|
},
|
|
"assignee_user_id": int(payload.assignee_user_id),
|
|
}
|
|
|
|
|
|
@router.post("/boss/assign-next-to-user")
|
|
async def boss_assign_next_to_user(payload: BossAssignNextPayload, current_user: dict = Depends(get_current_user)):
|
|
if not _has_boss_access(current_user):
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
|
|
|
|
_ensure_user_exists(int(payload.assignee_user_id))
|
|
|
|
next_case = _get_next_unassigned_case()
|
|
if not next_case:
|
|
return {
|
|
"status": "noop",
|
|
"message": "Ingen ufordelte åbne sager at tildele.",
|
|
}
|
|
|
|
updated = execute_query_single(
|
|
"""
|
|
UPDATE sag_sager
|
|
SET ansvarlig_bruger_id = %s,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
RETURNING id, titel, priority, ansvarlig_bruger_id
|
|
""",
|
|
(int(payload.assignee_user_id), int(next_case["id"])),
|
|
)
|
|
if not updated:
|
|
raise HTTPException(status_code=500, detail="Kunne ikke tildele næste sag")
|
|
|
|
return {
|
|
"status": "assigned",
|
|
"message": "Næste ufordelte sag blev tildelt.",
|
|
"case": {
|
|
"id": updated.get("id"),
|
|
"title": updated.get("titel") or f"Sag #{updated.get('id')}",
|
|
"priority": updated.get("priority") or "normal",
|
|
},
|
|
"assignee_user_id": int(payload.assignee_user_id),
|
|
}
|