from __future__ import annotations from datetime import date from fastapi import APIRouter, Form, Request from fastapi.responses import RedirectResponse from app.core.http_responses import forbidden_response, not_found_response from app.core.db.common import CommonSessionLocal from app.core.security.csrf import get_or_create_csrf_token, validate_csrf from app.core.security.session_auth import get_current_user from app.core.templating import templates from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from app.modules.services.models import ServiceTaskDocumentRequest from app.modules.work_detail.service import assign_task_to_consultant, create_document_request, get_task_for_action, load_unified_engagement_detail, review_consultant_submission, save_task_comment, save_task_status, update_document_request router = APIRouter(prefix="/work", tags=["unified-work-detail-ui"]) def _csrf_rejected(request: Request): from app.core.http_responses import forbidden_response return forbidden_response(request, "CSRF validation failed") def _base_ctx(request: Request, db, user, **ctx): base = { "request": request, "current_user": user, "current_user_roles": get_user_roles(db, user.id), "current_user_permissions": get_user_permissions(db, user.id), "csrf_token": get_or_create_csrf_token(request), } base.update(ctx) return base def _fallback_for_user(db, user) -> str: roles = set(get_user_roles(db, user.id)) if "Client" in roles: return "/client/compliance" if "Consultant" in roles: return "/consultant/work" if "Partner" in roles: return "/partner/reviews" if roles.intersection({"Manager", "Branch Manager"}): return "/manager/work" return "/employee/work" @router.get("") def unified_work_board(request: Request): """Compatibility alias for the common /work route. Existing functionality remains in the Services Work Tracker module. This route only prevents stale bookmarks/tests from receiving a 404. """ db = CommonSessionLocal() try: user = get_current_user(request, db=db) if not user: return RedirectResponse(url="/login", status_code=303) return RedirectResponse(url="/services/work-tracker", status_code=303) finally: db.close() # Phase 3 security hardening: /work/engagements is a listing alias, not a create endpoint. # Existing engagement creation remains in the services module. @router.post("/engagements") def unified_engagements_post_rejected(request: Request): return _csrf_rejected(request) @router.get("/engagements") def unified_engagements_index(request: Request): """Compatibility alias for /work/engagements. Engagement listing is maintained under /services/engagements. """ db = CommonSessionLocal() try: user = get_current_user(request, db=db) if not user: return RedirectResponse(url="/login", status_code=303) return RedirectResponse(url="/services/engagements", status_code=303) finally: db.close() @router.get("/engagements/{engagement_id}") def unified_engagement_detail(request: Request, engagement_id: int): db = CommonSessionLocal() try: user = get_current_user(request, db=db) if not user: return RedirectResponse(url="/login", status_code=303) if "Client" in set(get_user_roles(db, user.id)): return forbidden_response(request, "Access denied") detail = load_unified_engagement_detail(db, request=request, user=user, engagement_id=engagement_id) if not detail: return not_found_response(request, "Work engagement not found or access denied") return templates.TemplateResponse( "modules/work_detail/templates/work_detail/engagement_detail.html", _base_ctx(request, db, user, title="Work Details", **detail), ) finally: db.close() @router.post("/tasks/{task_id}/status") async def unified_task_status_update(request: Request, task_id: int, csrf_token: str = Form(...), status: str = Form(...), priority: str = Form("")): try: validate_csrf(request, csrf_token) except PermissionError: return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) if not user: return RedirectResponse(url="/login", status_code=303) task, access = get_task_for_action(db, user=user, task_id=task_id) if not task or not access: return RedirectResponse(url=_fallback_for_user(db, user), status_code=303) if not access.can_update_tasks: return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?error=not_allowed", status_code=303) save_task_status(db, task=task, status=status, priority=priority, user_id=user.id) db.commit() return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?updated=1", status_code=303) except Exception: db.rollback() raise finally: db.close() @router.post("/tasks/{task_id}/comment") async def unified_task_comment_add( request: Request, task_id: int, csrf_token: str = Form(...), comment_type: str = Form("internal_note"), visibility: str = Form("internal"), message: str = Form(""), ): try: validate_csrf(request, csrf_token) except PermissionError: return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) if not user: return RedirectResponse(url="/login", status_code=303) task, access = get_task_for_action(db, user=user, task_id=task_id) if not task or not access: return RedirectResponse(url=_fallback_for_user(db, user), status_code=303) if not access.can_comment: return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?error=comment_not_allowed", status_code=303) ok = save_task_comment(db, task=task, access=access, comment_type=comment_type, visibility=visibility, message=message, user_id=user.id) if ok: db.commit() return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?comment=sent", status_code=303) db.rollback() return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?error=empty_comment", status_code=303) except Exception: db.rollback() raise finally: db.close() @router.post("/tasks/{task_id}/document-requests") async def unified_document_request_add(request: Request, task_id: int, csrf_token: str = Form(...), title: str = Form(...), description: str = Form(""), requested_from: str = Form("client_and_consultant"), due_date: str = Form(""), request_type: str = Form("document")): try: validate_csrf(request, csrf_token) except PermissionError: return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) if not user: return RedirectResponse(url="/login", status_code=303) task, access = get_task_for_action(db, user=user, task_id=task_id) if not task or not access: return RedirectResponse(url=_fallback_for_user(db, user), status_code=303) parsed_due = date.fromisoformat(due_date) if due_date else None row = create_document_request(db, task=task, access=access, title=title, description=description, requested_from=requested_from, due_date=parsed_due, request_type=request_type, user_id=user.id) if not row: return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?error=request_not_allowed", status_code=303) db.commit() return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?request=created", status_code=303) except Exception: db.rollback(); raise finally: db.close() @router.post("/document-requests/{request_id}/status") async def unified_document_request_status(request: Request, request_id: int, csrf_token: str = Form(...), status: str = Form(...), response_note: str = Form("")): try: validate_csrf(request, csrf_token) except PermissionError: return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) if not user: return RedirectResponse(url="/login", status_code=303) row = db.get(ServiceTaskDocumentRequest, request_id) if not row or not row.is_active: return not_found_response(request, "Document request not found") task, access = get_task_for_action(db, user=user, task_id=row.task_instance_id) if not task or not access or not update_document_request(db, request_row=row, access=access, status=status, response_note=response_note, user_id=user.id): return RedirectResponse(url=_fallback_for_user(db, user), status_code=303) db.commit() return RedirectResponse(url=f"/work/engagements/{row.subscription_id}?request=updated", status_code=303) except Exception: db.rollback(); raise finally: db.close() @router.post("/tasks/{task_id}/consultant-assignment") async def unified_consultant_assignment_update(request: Request, task_id: int, csrf_token: str = Form(...), consultant_id: str = Form(""), consultant_due_date: str = Form("")): try: validate_csrf(request, csrf_token) except PermissionError: return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) if not user: return RedirectResponse(url="/login", status_code=303) task, access = get_task_for_action(db, user=user, task_id=task_id) if not task or not access: return RedirectResponse(url=_fallback_for_user(db, user), status_code=303) parsed_consultant_id = int(consultant_id) if consultant_id.strip() else None parsed_due = date.fromisoformat(consultant_due_date) if consultant_due_date else None if not assign_task_to_consultant(db, task=task, access=access, consultant_id=parsed_consultant_id, due_date=parsed_due, user_id=user.id): return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?error=consultant_assignment_not_allowed", status_code=303) db.commit() return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?consultant_assignment=updated", status_code=303) except (ValueError, TypeError): db.rollback() return RedirectResponse(url=f"/work/engagements/{task.subscription_id if 'task' in locals() and task else ''}?error=invalid_consultant_assignment", status_code=303) except Exception: db.rollback(); raise finally: db.close() @router.post("/tasks/{task_id}/consultant-review") async def unified_consultant_submission_review(request: Request, task_id: int, csrf_token: str = Form(...), action: str = Form(...), note: str = Form("")): try: validate_csrf(request, csrf_token) except PermissionError: return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) if not user: return RedirectResponse(url="/login", status_code=303) task, access = get_task_for_action(db, user=user, task_id=task_id) if not task or not access: return RedirectResponse(url=_fallback_for_user(db, user), status_code=303) if not review_consultant_submission(db, task=task, access=access, action=action, note=note, user_id=user.id): return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?error=consultant_review_not_allowed", status_code=303) db.commit() return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?consultant_review=updated", status_code=303) except Exception: db.rollback(); raise finally: db.close()