| """ |
| API router for tasks endpoints. |
| """ |
| from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form |
| from app.core.logging import get_logger |
| from app.dependencies.auth import get_current_user, TokenUser |
| from app.nosql import get_database |
| from app.tracker.tasks.service import TaskService, get_task_service |
| from app.tracker.tasks.schemas import ( |
| TodayTasksResponse, |
| ErrorResponse, |
| UpdateTaskStatusRequest, |
| UpdateTaskStatusResponse, |
| AttachmentType, |
| UploadAttachmentResponse |
| ) |
|
|
| logger = get_logger(__name__) |
|
|
| router = APIRouter(prefix="/tasks", tags=["Tasks"]) |
|
|
|
|
| @router.get( |
| "/today", |
| response_model=TodayTasksResponse, |
| status_code=status.HTTP_200_OK, |
| responses={ |
| 200: {"description": "Today's tasks retrieved successfully"}, |
| 401: {"description": "Unauthorized"}, |
| 500: {"model": ErrorResponse, "description": "Internal server error"} |
| }, |
| summary="Get Today's Tasks", |
| description=""" |
| Fetch all tasks assigned to the authenticated employee for today. |
| |
| - Returns tasks ordered by scheduled time |
| - Includes task status, location, and scheduled time |
| - Status: not_started, in_progress, completed |
| """ |
| ) |
| async def get_today_tasks( |
| current_user: TokenUser = Depends(get_current_user) |
| ) -> TodayTasksResponse: |
| """ |
| Get all tasks assigned to the authenticated employee for today. |
| |
| Args: |
| current_user: Authenticated user from JWT token |
| |
| Returns: |
| TodayTasksResponse with list of tasks and count |
| |
| Raises: |
| HTTPException 500: If internal error occurs |
| """ |
| try: |
| |
| service = get_task_service(get_database()) |
| |
| |
| tasks = await service.get_today_tasks( |
| employee_id=current_user.user_id, |
| merchant_id=current_user.merchant_id |
| ) |
| |
| logger.info( |
| "Today's tasks retrieved successfully", |
| extra={ |
| "employee_id": current_user.user_id, |
| "merchant_id": current_user.merchant_id, |
| "task_count": len(tasks) |
| } |
| ) |
| |
| return TodayTasksResponse( |
| success=True, |
| tasks=tasks, |
| count=len(tasks) |
| ) |
| |
| except Exception as e: |
| |
| logger.error( |
| "Failed to retrieve today's tasks", |
| extra={ |
| "employee_id": current_user.user_id, |
| "error": str(e) |
| }, |
| exc_info=e |
| ) |
| raise HTTPException( |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, |
| detail={ |
| "success": False, |
| "error": "Internal server error", |
| "detail": "An unexpected error occurred while retrieving tasks" |
| } |
| ) |
|
|
|
|
| @router.patch( |
| "/{task_id}/status", |
| response_model=UpdateTaskStatusResponse, |
| status_code=status.HTTP_200_OK, |
| responses={ |
| 200: {"description": "Task status updated successfully"}, |
| 400: {"model": ErrorResponse, "description": "Bad request - invalid status transition"}, |
| 401: {"description": "Unauthorized"}, |
| 404: {"model": ErrorResponse, "description": "Task not found or not authorized"}, |
| 422: {"description": "Validation error"}, |
| 500: {"model": ErrorResponse, "description": "Internal server error"} |
| }, |
| summary="Update Task Status", |
| description=""" |
| Update the status of a task with location and timestamp tracking. |
| |
| - Status: not_started, in_progress, completed |
| - Location and timestamp are mandatory |
| - Only assigned employee can update |
| - Captures started_at and completed_at timestamps |
| """ |
| ) |
| async def update_task_status( |
| task_id: str, |
| payload: UpdateTaskStatusRequest, |
| current_user: TokenUser = Depends(get_current_user) |
| ) -> UpdateTaskStatusResponse: |
| """ |
| Update the status of a task. |
| |
| Args: |
| task_id: Task UUID |
| payload: Update request with status, timestamp, and location |
| current_user: Authenticated user from JWT token |
| |
| Returns: |
| UpdateTaskStatusResponse with success status |
| |
| Raises: |
| HTTPException 400: If invalid status transition |
| HTTPException 404: If task not found or not authorized |
| HTTPException 500: If internal error occurs |
| """ |
| try: |
| |
| service = get_task_service(get_database()) |
| |
| |
| await service.update_task_status( |
| task_id=task_id, |
| employee_id=current_user.user_id, |
| merchant_id=current_user.merchant_id, |
| payload=payload |
| ) |
| |
| logger.info( |
| "Task status updated successfully", |
| extra={ |
| "task_id": task_id, |
| "employee_id": current_user.user_id, |
| "new_status": payload.status.value |
| } |
| ) |
| |
| return UpdateTaskStatusResponse( |
| success=True, |
| message="Task status updated successfully" |
| ) |
| |
| except ValueError as e: |
| |
| error_message = str(e) |
| |
| |
| if "not found" in error_message.lower(): |
| status_code = status.HTTP_404_NOT_FOUND |
| elif "not authorized" in error_message.lower(): |
| status_code = status.HTTP_404_NOT_FOUND |
| elif "transition" in error_message.lower(): |
| status_code = status.HTTP_400_BAD_REQUEST |
| else: |
| status_code = status.HTTP_400_BAD_REQUEST |
| |
| logger.warning( |
| f"Task status update validation failed: {error_message}", |
| extra={ |
| "task_id": task_id, |
| "employee_id": current_user.user_id, |
| "error": error_message |
| } |
| ) |
| |
| raise HTTPException( |
| status_code=status_code, |
| detail={ |
| "success": False, |
| "error": error_message, |
| "detail": error_message |
| } |
| ) |
| |
| except Exception as e: |
| |
| logger.error( |
| "Task status update failed with unexpected error", |
| extra={ |
| "task_id": task_id, |
| "employee_id": current_user.user_id, |
| "error": str(e) |
| }, |
| exc_info=e |
| ) |
| raise HTTPException( |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, |
| detail={ |
| "success": False, |
| "error": "Internal server error", |
| "detail": "An unexpected error occurred while updating task status" |
| } |
| ) |
|
|
|
|
| @router.post( |
| "/{task_id}/attachments", |
| response_model=UploadAttachmentResponse, |
| status_code=status.HTTP_201_CREATED, |
| responses={ |
| 201: {"description": "Attachment uploaded successfully"}, |
| 400: {"model": ErrorResponse, "description": "Bad request - invalid file type or size"}, |
| 401: {"description": "Unauthorized"}, |
| 404: {"model": ErrorResponse, "description": "Task not found or not authorized"}, |
| 413: {"model": ErrorResponse, "description": "File too large"}, |
| 415: {"model": ErrorResponse, "description": "Unsupported media type"}, |
| 422: {"description": "Validation error"}, |
| 500: {"model": ErrorResponse, "description": "Internal server error"} |
| }, |
| summary="Upload Task Attachment", |
| description=""" |
| Upload an attachment (photo or signature) for a task. |
| |
| **Attachment Types:** |
| - photo: Site proof or progress photos |
| - signature: Customer confirmation signature |
| |
| **File Requirements:** |
| - Images only (JPEG, PNG, WebP) |
| - Maximum file size: 10MB |
| - Multipart/form-data encoding required |
| |
| **Rules:** |
| - Only the assigned employee can upload attachments |
| - Task must belong to employee's merchant |
| - Files are stored in MinIO object storage |
| - Public URL is returned for accessing the file |
| |
| **Request Format:** |
| - Content-Type: multipart/form-data |
| - file: Binary file data (image) |
| - type: "photo" or "signature" |
| |
| **Authentication:** |
| - Requires valid JWT token |
| - Task must be assigned to the authenticated employee |
| |
| **Storage:** |
| - Files stored in MinIO bucket: cutra-tracker-attachments |
| - Path format: {merchant_id}/tasks/{task_id}/{type}_{id}_{filename} |
| - Database record created in trans.scm_task_attachments |
| """, |
| ) |
| async def upload_attachment( |
| task_id: str, |
| file: UploadFile = File(..., description="Image file to upload"), |
| type: AttachmentType = Form(..., description="Attachment type (photo or signature)"), |
| current_user: TokenUser = Depends(get_current_user) |
| ) -> UploadAttachmentResponse: |
| """ |
| Upload an attachment for a task. |
| |
| Args: |
| task_id: Task UUID |
| file: Uploaded file (image) |
| type: Attachment type (photo or signature) |
| current_user: Authenticated user from JWT token |
| |
| Returns: |
| UploadAttachmentResponse with success status and file URL |
| |
| Raises: |
| HTTPException 400: If invalid file type or size |
| HTTPException 404: If task not found or not authorized |
| HTTPException 413: If file too large |
| HTTPException 415: If unsupported media type |
| HTTPException 500: If internal error occurs |
| """ |
| try: |
| |
| file_data = await file.read() |
| |
| |
| file_name = file.filename or "attachment" |
| mime_type = file.content_type or "application/octet-stream" |
| |
| logger.info( |
| "Processing attachment upload", |
| extra={ |
| "task_id": task_id, |
| "employee_id": current_user.user_id, |
| "file_name": file_name, |
| "mime_type": mime_type, |
| "file_size": len(file_data), |
| "attachment_type": type.value |
| } |
| ) |
| |
| |
| service = get_task_service(get_database()) |
| |
| |
| result = await service.upload_attachment( |
| task_id=task_id, |
| employee_id=current_user.user_id, |
| merchant_id=current_user.merchant_id, |
| file_data=file_data, |
| file_name=file_name, |
| mime_type=mime_type, |
| attachment_type=type |
| ) |
| |
| logger.info( |
| "Attachment uploaded successfully", |
| extra={ |
| "task_id": task_id, |
| "attachment_id": result['id'], |
| "file_url": result['url'] |
| } |
| ) |
| |
| return UploadAttachmentResponse( |
| success=True, |
| url=result['url'], |
| attachment_id=result['id'], |
| file_name=result['file_name'] |
| ) |
| |
| except ValueError as e: |
| |
| error_message = str(e) |
| |
| |
| if "not found" in error_message.lower(): |
| status_code = status.HTTP_404_NOT_FOUND |
| elif "not authorized" in error_message.lower(): |
| status_code = status.HTTP_404_NOT_FOUND |
| elif "exceeds maximum" in error_message.lower(): |
| status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE |
| elif "not allowed" in error_message.lower() or "only image" in error_message.lower(): |
| status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE |
| else: |
| status_code = status.HTTP_400_BAD_REQUEST |
| |
| logger.warning( |
| f"Attachment upload validation failed: {error_message}", |
| extra={ |
| "task_id": task_id, |
| "employee_id": current_user.user_id, |
| "error": error_message |
| } |
| ) |
| |
| raise HTTPException( |
| status_code=status_code, |
| detail={ |
| "success": False, |
| "error": error_message, |
| "detail": error_message |
| } |
| ) |
| |
| except Exception as e: |
| |
| logger.error( |
| "Attachment upload failed with unexpected error", |
| extra={ |
| "task_id": task_id, |
| "employee_id": current_user.user_id, |
| "error": str(e) |
| }, |
| exc_info=e |
| ) |
| raise HTTPException( |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, |
| detail={ |
| "success": False, |
| "error": "Internal server error", |
| "detail": "An unexpected error occurred while uploading attachment" |
| } |
| ) |
|
|