425 lines
13 KiB
Python
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
|
|
}
|
|
})
|