""" Signin Task CRUD router. All endpoints require JWT authentication and enforce resource ownership. """ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from croniter import croniter import redis.asyncio as aioredis import json from shared.models import get_db, Account, Task, User from shared.config import shared_settings from shared.response import success_response from api_service.app.dependencies import get_current_user from api_service.app.schemas.task import ( TaskCreate, TaskUpdate, TaskResponse, ) router = APIRouter(prefix="/api/v1", tags=["tasks"]) def _task_to_dict(task: Task) -> dict: return TaskResponse.model_validate(task).model_dump(mode="json") async def _get_owned_account( account_id: str, user: User, db: AsyncSession, ) -> Account: """Fetch an account and verify it belongs to the current user.""" result = await db.execute(select(Account).where(Account.id == account_id)) account = result.scalar_one_or_none() if account is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") if account.user_id != user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") return account async def _get_owned_task( task_id: str, user: User, db: AsyncSession, ) -> Task: """Fetch a task and verify it belongs to the current user.""" from sqlalchemy.orm import selectinload result = await db.execute( select(Task) .options(selectinload(Task.account)) .where(Task.id == task_id) ) task = result.scalar_one_or_none() if task is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") # Verify ownership through account if task.account.user_id != user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") return task def _validate_cron_expression(cron_expr: str) -> None: """Validate cron expression format using croniter.""" try: croniter(cron_expr) except (ValueError, KeyError) as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid cron expression: {str(e)}" ) async def _notify_scheduler(action: str, task_data: dict) -> None: """Notify Task_Scheduler via Redis pub/sub about task changes.""" try: redis_client = aioredis.from_url( shared_settings.REDIS_URL, encoding="utf-8", decode_responses=True ) message = { "action": action, # "create", "update", "delete" "task": task_data } await redis_client.publish("task_updates", json.dumps(message)) await redis_client.close() except Exception as e: # Log but don't fail the request if notification fails print(f"Warning: Failed to notify scheduler: {e}") # ---- CREATE TASK ---- @router.post("/accounts/{account_id}/tasks", status_code=status.HTTP_201_CREATED) async def create_task( account_id: str, body: TaskCreate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Create a new signin task for the specified account.""" # Verify account ownership account = await _get_owned_account(account_id, user, db) # Validate cron expression _validate_cron_expression(body.cron_expression) # Create task task = Task( account_id=account.id, cron_expression=body.cron_expression, is_enabled=True, ) db.add(task) await db.commit() await db.refresh(task) # Notify scheduler await _notify_scheduler("create", _task_to_dict(task)) return success_response(_task_to_dict(task), "Task created") # ---- LIST TASKS FOR ACCOUNT ---- @router.get("/accounts/{account_id}/tasks") async def list_tasks( account_id: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get all tasks for the specified account.""" # Verify account ownership account = await _get_owned_account(account_id, user, db) # Fetch tasks result = await db.execute( select(Task).where(Task.account_id == account.id) ) tasks = result.scalars().all() return success_response( [_task_to_dict(t) for t in tasks], "Tasks retrieved", ) # ---- UPDATE TASK ---- @router.put("/tasks/{task_id}") async def update_task( task_id: str, body: TaskUpdate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Update task (enable/disable).""" task = await _get_owned_task(task_id, user, db) if body.is_enabled is not None: task.is_enabled = body.is_enabled await db.commit() await db.refresh(task) # Notify scheduler await _notify_scheduler("update", _task_to_dict(task)) return success_response(_task_to_dict(task), "Task updated") # ---- DELETE TASK ---- @router.delete("/tasks/{task_id}") async def delete_task( task_id: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Delete a task.""" task = await _get_owned_task(task_id, user, db) task_data = _task_to_dict(task) await db.delete(task) await db.commit() # Notify scheduler await _notify_scheduler("delete", task_data) return success_response(None, "Task deleted")