"""Project tools for MCP.""" from datetime import datetime from typing import Optional from channels.db import database_sync_to_async from core.mcp.auth import MCPContext, Role, execute_graphql from core.mcp.base import mcp, json_response, error_response @mcp.tool() async def list_projects( limit: int = 25, customer_id: Optional[str] = None, status: Optional[str] = None, date: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None ) -> str: """ List projects with optional filters. - ADMIN/TEAM_LEADER: See all projects - TEAM_MEMBER: See only assigned projects Args: limit: Maximum projects to return (default 25) customer_id: Optional customer UUID to filter by status: Optional status filter (SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED) date: Optional exact date in YYYY-MM-DD format start_date: Optional range start date end_date: Optional range end date Returns: JSON array of project objects with full context """ profile = MCPContext.get_profile() if not profile: return error_response("No active profile. Call set_active_profile first.") from core.models import Project @database_sync_to_async def fetch(): qs = Project.objects.select_related( 'customer', 'account_address__account' ).prefetch_related('team_members') if profile.role == Role.TEAM_MEMBER.value: qs = qs.filter(team_members__id=profile.id) if customer_id: qs = qs.filter(customer_id=customer_id) if status: qs = qs.filter(status=status) if date: qs = qs.filter(date=datetime.strptime(date, "%Y-%m-%d").date()) if start_date: qs = qs.filter(date__gte=datetime.strptime(start_date, "%Y-%m-%d").date()) if end_date: qs = qs.filter(date__lte=datetime.strptime(end_date, "%Y-%m-%d").date()) qs = qs.order_by('-date')[:limit] results = [] for p in qs: if p.account_address: addr = p.account_address location = addr.name or "Primary" address = f"{addr.street_address}, {addr.city}, {addr.state} {addr.zip_code}" account = addr.account.name else: location = None address = f"{p.street_address}, {p.city}, {p.state} {p.zip_code}" account = None results.append({ "id": str(p.id), "name": p.name, "date": str(p.date), "status": p.status, "customer": p.customer.name, "account": account, "location": location, "address": address, "labor": float(p.labor), "amount": float(p.amount), "team_members": [ f"{t.first_name} {t.last_name}".strip() for t in p.team_members.all() if t.role != 'ADMIN' ], "notes": p.notes or "" }) return results projects = await fetch() return json_response(projects) @mcp.tool() async def get_project(project_id: str) -> str: """ Get detailed project information including scope and tasks. - ADMIN/TEAM_LEADER: Any project - TEAM_MEMBER: Only if assigned Args: project_id: UUID of the project Returns: JSON object with full project details """ profile = MCPContext.get_profile() if not profile: return error_response("No active profile. Call set_active_profile first.") from core.models import Project @database_sync_to_async def fetch(): try: p = Project.objects.select_related( 'customer', 'account_address__account' ).prefetch_related( 'team_members', 'scope__categories__tasks' ).get(pk=project_id) if profile.role == Role.TEAM_MEMBER.value: if not p.team_members.filter(id=profile.id).exists(): return {"error": "Access denied. You are not assigned to this project."} if p.account_address: addr = p.account_address location = { "id": str(addr.id), "name": addr.name or "Primary", "street_address": addr.street_address, "city": addr.city, "state": addr.state, "zip_code": addr.zip_code } account = {"id": str(addr.account.id), "name": addr.account.name} else: location = { "street_address": p.street_address, "city": p.city, "state": p.state, "zip_code": p.zip_code } account = None scope_data = None if hasattr(p, 'scope') and p.scope: scope = p.scope scope_data = { "id": str(scope.id), "categories": [ { "id": str(cat.id), "name": cat.name, "tasks": [ { "id": str(task.id), "description": task.checklist_description, "is_completed": task.is_completed } for task in cat.tasks.all() ] } for cat in scope.categories.all() ] } return { "id": str(p.id), "name": p.name, "date": str(p.date), "status": p.status, "labor": float(p.labor), "amount": float(p.amount), "notes": p.notes, "customer": { "id": str(p.customer.id), "name": p.customer.name }, "account": account, "location": location, "team_members": [ { "id": str(t.id), "name": f"{t.first_name} {t.last_name}".strip(), "email": t.email, "phone": t.phone } for t in p.team_members.all() if t.role != 'ADMIN' ], "scope": scope_data } except Project.DoesNotExist: return {"error": f"Project {project_id} not found"} result = await fetch() if "error" in result: return error_response(result["error"]) return json_response(result) @mcp.tool() async def create_project( customer_id: str, name: str, date: str, labor: float, amount: float = 0, account_address_id: Optional[str] = None, street_address: Optional[str] = None, city: Optional[str] = None, state: Optional[str] = None, zip_code: Optional[str] = None, team_member_ids: Optional[str] = None, notes: Optional[str] = None ) -> str: """ Create a new project. Requires ADMIN role. Args: customer_id: UUID of the customer name: Project name date: Project date in YYYY-MM-DD format labor: Labor cost amount: Total amount (default 0) account_address_id: UUID of account address (OR use freeform address below) street_address: Freeform street address city: Freeform city state: Freeform state zip_code: Freeform zip code team_member_ids: Comma-separated UUIDs of team members notes: Optional notes Returns: JSON object with created project """ profile = MCPContext.get_profile() if not profile: return error_response("No active profile. Call set_active_profile first.") if profile.role != Role.ADMIN.value: return error_response("Access denied. ADMIN role required.") mutation = """ mutation CreateProject($input: ProjectInput!) { createProject(input: $input) { id name date status labor amount } } """ input_data = { "customerId": customer_id, "name": name, "date": date, "labor": str(labor), "amount": str(amount), "notes": notes } if account_address_id: input_data["accountAddressId"] = account_address_id else: input_data["streetAddress"] = street_address input_data["city"] = city input_data["state"] = state input_data["zipCode"] = zip_code if team_member_ids: input_data["teamMemberIds"] = [ tid.strip() for tid in team_member_ids.split(",") ] result = await execute_graphql(mutation, {"input": input_data}) if "errors" in result: return json_response(result) return json_response({"success": True, "project": result["data"]["createProject"]}) @mcp.tool() async def update_project( project_id: str, name: Optional[str] = None, date: Optional[str] = None, status: Optional[str] = None, labor: Optional[float] = None, amount: Optional[float] = None, team_member_ids: Optional[str] = None, notes: Optional[str] = None ) -> str: """ Update an existing project. Requires ADMIN role. Args: project_id: UUID of the project to update name: New project name date: New date in YYYY-MM-DD format status: New status labor: New labor cost amount: New total amount team_member_ids: Comma-separated UUIDs of team members (replaces existing) notes: Updated notes Returns: JSON object with updated project """ profile = MCPContext.get_profile() if not profile: return error_response("No active profile. Call set_active_profile first.") if profile.role != Role.ADMIN.value: return error_response("Access denied. ADMIN role required.") mutation = """ mutation UpdateProject($input: ProjectUpdateInput!) { updateProject(input: $input) { id name date status labor amount } } """ input_data = {"id": project_id} if name: input_data["name"] = name if date: input_data["date"] = date if status: input_data["status"] = status if labor is not None: input_data["labor"] = str(labor) if amount is not None: input_data["amount"] = str(amount) if notes is not None: input_data["notes"] = notes if team_member_ids: input_data["teamMemberIds"] = [ tid.strip() for tid in team_member_ids.split(",") ] result = await execute_graphql(mutation, {"input": input_data}) if "errors" in result: return json_response(result) return json_response({"success": True, "project": result["data"]["updateProject"]}) @mcp.tool() async def delete_project(project_id: str) -> str: """ Delete a project. Requires ADMIN role. WARNING: This is a destructive action that cannot be undone. Args: project_id: UUID of the project to delete Returns: JSON object confirming deletion """ profile = MCPContext.get_profile() if not profile: return error_response("No active profile. Call set_active_profile first.") if profile.role != Role.ADMIN.value: return error_response("Access denied. ADMIN role required.") # First get project details for confirmation message from core.models import Project @database_sync_to_async def get_project_info(): try: p = Project.objects.select_related('customer').get(pk=project_id) return { "name": p.name, "date": str(p.date), "customer": p.customer.name, "status": p.status } except Project.DoesNotExist: return None project_info = await get_project_info() if not project_info: return error_response(f"Project {project_id} not found") mutation = """ mutation DeleteProject($id: ID!) { deleteProject(id: $id) } """ result = await execute_graphql(mutation, {"id": project_id}) if "errors" in result: return json_response(result) return json_response({ "success": True, "deleted_project": { "id": project_id, **project_info } })