nexus-5/core/mcp/tools/projects.py
2026-01-26 11:09:40 -05:00

425 lines
13 KiB
Python

"""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
}
})