kubiya-control-plane-api 0.1.0__py3-none-any.whl → 0.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of kubiya-control-plane-api might be problematic. Click here for more details.
- control_plane_api/README.md +266 -0
- control_plane_api/__init__.py +0 -0
- control_plane_api/__version__.py +1 -0
- control_plane_api/alembic/README +1 -0
- control_plane_api/alembic/env.py +98 -0
- control_plane_api/alembic/script.py.mako +28 -0
- control_plane_api/alembic/versions/1382bec74309_initial_migration_with_all_models.py +251 -0
- control_plane_api/alembic/versions/1f54bc2a37e3_add_analytics_tables.py +162 -0
- control_plane_api/alembic/versions/2e4cb136dc10_rename_toolset_ids_to_skill_ids_in_teams.py +30 -0
- control_plane_api/alembic/versions/31cd69a644ce_add_skill_templates_table.py +28 -0
- control_plane_api/alembic/versions/89e127caa47d_add_jobs_and_job_executions_tables.py +161 -0
- control_plane_api/alembic/versions/add_llm_models_table.py +51 -0
- control_plane_api/alembic/versions/b0e10697f212_add_runtime_column_to_teams_simple.py +42 -0
- control_plane_api/alembic/versions/ce43b24b63bf_add_execution_trigger_source_and_fix_.py +155 -0
- control_plane_api/alembic/versions/d4eaf16e3f8d_rename_toolsets_to_skills.py +84 -0
- control_plane_api/alembic/versions/efa2dc427da1_rename_metadata_to_custom_metadata.py +32 -0
- control_plane_api/alembic/versions/f973b431d1ce_add_workflow_executor_to_skill_types.py +44 -0
- control_plane_api/alembic.ini +148 -0
- control_plane_api/api/index.py +12 -0
- control_plane_api/app/__init__.py +11 -0
- control_plane_api/app/activities/__init__.py +20 -0
- control_plane_api/app/activities/agent_activities.py +379 -0
- control_plane_api/app/activities/team_activities.py +410 -0
- control_plane_api/app/activities/temporal_cloud_activities.py +577 -0
- control_plane_api/app/config/__init__.py +35 -0
- control_plane_api/app/config/api_config.py +354 -0
- control_plane_api/app/config/model_pricing.py +318 -0
- control_plane_api/app/config.py +95 -0
- control_plane_api/app/database.py +135 -0
- control_plane_api/app/exceptions.py +408 -0
- control_plane_api/app/lib/__init__.py +11 -0
- control_plane_api/app/lib/job_executor.py +312 -0
- control_plane_api/app/lib/kubiya_client.py +235 -0
- control_plane_api/app/lib/litellm_pricing.py +166 -0
- control_plane_api/app/lib/planning_tools/__init__.py +22 -0
- control_plane_api/app/lib/planning_tools/agents.py +155 -0
- control_plane_api/app/lib/planning_tools/base.py +189 -0
- control_plane_api/app/lib/planning_tools/environments.py +214 -0
- control_plane_api/app/lib/planning_tools/resources.py +240 -0
- control_plane_api/app/lib/planning_tools/teams.py +198 -0
- control_plane_api/app/lib/policy_enforcer_client.py +939 -0
- control_plane_api/app/lib/redis_client.py +436 -0
- control_plane_api/app/lib/supabase.py +71 -0
- control_plane_api/app/lib/temporal_client.py +138 -0
- control_plane_api/app/lib/validation/__init__.py +20 -0
- control_plane_api/app/lib/validation/runtime_validation.py +287 -0
- control_plane_api/app/main.py +128 -0
- control_plane_api/app/middleware/__init__.py +8 -0
- control_plane_api/app/middleware/auth.py +513 -0
- control_plane_api/app/middleware/exception_handler.py +267 -0
- control_plane_api/app/middleware/rate_limiting.py +384 -0
- control_plane_api/app/middleware/request_id.py +202 -0
- control_plane_api/app/models/__init__.py +27 -0
- control_plane_api/app/models/agent.py +79 -0
- control_plane_api/app/models/analytics.py +206 -0
- control_plane_api/app/models/associations.py +81 -0
- control_plane_api/app/models/environment.py +63 -0
- control_plane_api/app/models/execution.py +93 -0
- control_plane_api/app/models/job.py +179 -0
- control_plane_api/app/models/llm_model.py +75 -0
- control_plane_api/app/models/presence.py +49 -0
- control_plane_api/app/models/project.py +47 -0
- control_plane_api/app/models/session.py +38 -0
- control_plane_api/app/models/team.py +66 -0
- control_plane_api/app/models/workflow.py +55 -0
- control_plane_api/app/policies/README.md +121 -0
- control_plane_api/app/policies/approved_users.rego +62 -0
- control_plane_api/app/policies/business_hours.rego +51 -0
- control_plane_api/app/policies/rate_limiting.rego +100 -0
- control_plane_api/app/policies/tool_restrictions.rego +86 -0
- control_plane_api/app/routers/__init__.py +4 -0
- control_plane_api/app/routers/agents.py +364 -0
- control_plane_api/app/routers/agents_v2.py +1260 -0
- control_plane_api/app/routers/analytics.py +1014 -0
- control_plane_api/app/routers/context_manager.py +562 -0
- control_plane_api/app/routers/environment_context.py +270 -0
- control_plane_api/app/routers/environments.py +715 -0
- control_plane_api/app/routers/execution_environment.py +517 -0
- control_plane_api/app/routers/executions.py +1911 -0
- control_plane_api/app/routers/health.py +92 -0
- control_plane_api/app/routers/health_v2.py +326 -0
- control_plane_api/app/routers/integrations.py +274 -0
- control_plane_api/app/routers/jobs.py +1344 -0
- control_plane_api/app/routers/models.py +82 -0
- control_plane_api/app/routers/models_v2.py +361 -0
- control_plane_api/app/routers/policies.py +639 -0
- control_plane_api/app/routers/presence.py +234 -0
- control_plane_api/app/routers/projects.py +902 -0
- control_plane_api/app/routers/runners.py +379 -0
- control_plane_api/app/routers/runtimes.py +172 -0
- control_plane_api/app/routers/secrets.py +155 -0
- control_plane_api/app/routers/skills.py +1001 -0
- control_plane_api/app/routers/skills_definitions.py +140 -0
- control_plane_api/app/routers/task_planning.py +1256 -0
- control_plane_api/app/routers/task_queues.py +654 -0
- control_plane_api/app/routers/team_context.py +270 -0
- control_plane_api/app/routers/teams.py +1400 -0
- control_plane_api/app/routers/worker_queues.py +1545 -0
- control_plane_api/app/routers/workers.py +935 -0
- control_plane_api/app/routers/workflows.py +204 -0
- control_plane_api/app/runtimes/__init__.py +6 -0
- control_plane_api/app/runtimes/validation.py +344 -0
- control_plane_api/app/schemas/job_schemas.py +295 -0
- control_plane_api/app/services/__init__.py +1 -0
- control_plane_api/app/services/agno_service.py +619 -0
- control_plane_api/app/services/litellm_service.py +190 -0
- control_plane_api/app/services/policy_service.py +525 -0
- control_plane_api/app/services/temporal_cloud_provisioning.py +150 -0
- control_plane_api/app/skills/__init__.py +44 -0
- control_plane_api/app/skills/base.py +229 -0
- control_plane_api/app/skills/business_intelligence.py +189 -0
- control_plane_api/app/skills/data_visualization.py +154 -0
- control_plane_api/app/skills/docker.py +104 -0
- control_plane_api/app/skills/file_generation.py +94 -0
- control_plane_api/app/skills/file_system.py +110 -0
- control_plane_api/app/skills/python.py +92 -0
- control_plane_api/app/skills/registry.py +65 -0
- control_plane_api/app/skills/shell.py +102 -0
- control_plane_api/app/skills/workflow_executor.py +469 -0
- control_plane_api/app/utils/workflow_executor.py +354 -0
- control_plane_api/app/workflows/__init__.py +11 -0
- control_plane_api/app/workflows/agent_execution.py +507 -0
- control_plane_api/app/workflows/agent_execution_with_skills.py +222 -0
- control_plane_api/app/workflows/namespace_provisioning.py +326 -0
- control_plane_api/app/workflows/team_execution.py +399 -0
- control_plane_api/scripts/seed_models.py +239 -0
- control_plane_api/worker/__init__.py +0 -0
- control_plane_api/worker/activities/__init__.py +0 -0
- control_plane_api/worker/activities/agent_activities.py +1241 -0
- control_plane_api/worker/activities/approval_activities.py +234 -0
- control_plane_api/worker/activities/runtime_activities.py +388 -0
- control_plane_api/worker/activities/skill_activities.py +267 -0
- control_plane_api/worker/activities/team_activities.py +1217 -0
- control_plane_api/worker/config/__init__.py +31 -0
- control_plane_api/worker/config/worker_config.py +275 -0
- control_plane_api/worker/control_plane_client.py +529 -0
- control_plane_api/worker/examples/analytics_integration_example.py +362 -0
- control_plane_api/worker/models/__init__.py +1 -0
- control_plane_api/worker/models/inputs.py +89 -0
- control_plane_api/worker/runtimes/__init__.py +31 -0
- control_plane_api/worker/runtimes/base.py +789 -0
- control_plane_api/worker/runtimes/claude_code_runtime.py +1443 -0
- control_plane_api/worker/runtimes/default_runtime.py +617 -0
- control_plane_api/worker/runtimes/factory.py +173 -0
- control_plane_api/worker/runtimes/validation.py +93 -0
- control_plane_api/worker/services/__init__.py +1 -0
- control_plane_api/worker/services/agent_executor.py +422 -0
- control_plane_api/worker/services/agent_executor_v2.py +383 -0
- control_plane_api/worker/services/analytics_collector.py +457 -0
- control_plane_api/worker/services/analytics_service.py +464 -0
- control_plane_api/worker/services/approval_tools.py +310 -0
- control_plane_api/worker/services/approval_tools_agno.py +207 -0
- control_plane_api/worker/services/cancellation_manager.py +177 -0
- control_plane_api/worker/services/data_visualization.py +827 -0
- control_plane_api/worker/services/jira_tools.py +257 -0
- control_plane_api/worker/services/runtime_analytics.py +328 -0
- control_plane_api/worker/services/session_service.py +194 -0
- control_plane_api/worker/services/skill_factory.py +175 -0
- control_plane_api/worker/services/team_executor.py +574 -0
- control_plane_api/worker/services/team_executor_v2.py +465 -0
- control_plane_api/worker/services/workflow_executor_tools.py +1418 -0
- control_plane_api/worker/tests/__init__.py +1 -0
- control_plane_api/worker/tests/e2e/__init__.py +0 -0
- control_plane_api/worker/tests/e2e/test_execution_flow.py +571 -0
- control_plane_api/worker/tests/integration/__init__.py +0 -0
- control_plane_api/worker/tests/integration/test_control_plane_integration.py +308 -0
- control_plane_api/worker/tests/unit/__init__.py +0 -0
- control_plane_api/worker/tests/unit/test_control_plane_client.py +401 -0
- control_plane_api/worker/utils/__init__.py +1 -0
- control_plane_api/worker/utils/chunk_batcher.py +305 -0
- control_plane_api/worker/utils/retry_utils.py +60 -0
- control_plane_api/worker/utils/streaming_utils.py +373 -0
- control_plane_api/worker/worker.py +753 -0
- control_plane_api/worker/workflows/__init__.py +0 -0
- control_plane_api/worker/workflows/agent_execution.py +589 -0
- control_plane_api/worker/workflows/team_execution.py +429 -0
- kubiya_control_plane_api-0.3.4.dist-info/METADATA +229 -0
- kubiya_control_plane_api-0.3.4.dist-info/RECORD +182 -0
- kubiya_control_plane_api-0.3.4.dist-info/entry_points.txt +2 -0
- kubiya_control_plane_api-0.3.4.dist-info/top_level.txt +1 -0
- kubiya_control_plane_api-0.1.0.dist-info/METADATA +0 -66
- kubiya_control_plane_api-0.1.0.dist-info/RECORD +0 -5
- kubiya_control_plane_api-0.1.0.dist-info/top_level.txt +0 -1
- {kubiya_control_plane_api-0.1.0.dist-info/licenses → control_plane_api}/LICENSE +0 -0
- {kubiya_control_plane_api-0.1.0.dist-info → kubiya_control_plane_api-0.3.4.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Projects router - Jira-style multi-project management.
|
|
3
|
+
|
|
4
|
+
This router handles project CRUD operations and manages associations
|
|
5
|
+
between projects, agents, and teams.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
import structlog
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
from control_plane_api.app.middleware.auth import get_current_organization
|
|
16
|
+
from control_plane_api.app.lib.supabase import get_supabase
|
|
17
|
+
|
|
18
|
+
logger = structlog.get_logger()
|
|
19
|
+
|
|
20
|
+
router = APIRouter()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Pydantic schemas
|
|
24
|
+
class ProjectCreate(BaseModel):
|
|
25
|
+
name: str = Field(..., description="Project name")
|
|
26
|
+
key: str = Field(..., description="Short project key (e.g., JIRA, PROJ)", min_length=2, max_length=50)
|
|
27
|
+
description: str | None = Field(None, description="Project description")
|
|
28
|
+
goals: str | None = Field(None, description="Project goals and objectives")
|
|
29
|
+
settings: dict = Field(default_factory=dict, description="Project settings")
|
|
30
|
+
visibility: str = Field("private", description="Project visibility: private or org")
|
|
31
|
+
restrict_to_environment: bool = Field(False, description="Restrict to specific runners/environment")
|
|
32
|
+
policy_ids: List[str] = Field(default_factory=list, description="List of OPA policy IDs for access control")
|
|
33
|
+
default_model: str | None = Field(None, description="Default LLM model for this project")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProjectUpdate(BaseModel):
|
|
37
|
+
name: str | None = None
|
|
38
|
+
key: str | None = None
|
|
39
|
+
description: str | None = None
|
|
40
|
+
goals: str | None = None
|
|
41
|
+
settings: dict | None = None
|
|
42
|
+
status: str | None = None
|
|
43
|
+
visibility: str | None = None
|
|
44
|
+
restrict_to_environment: bool | None = None
|
|
45
|
+
policy_ids: List[str] | None = None
|
|
46
|
+
default_model: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ProjectResponse(BaseModel):
|
|
50
|
+
id: str
|
|
51
|
+
organization_id: str
|
|
52
|
+
name: str
|
|
53
|
+
key: str
|
|
54
|
+
description: str | None
|
|
55
|
+
goals: str | None
|
|
56
|
+
settings: dict
|
|
57
|
+
status: str
|
|
58
|
+
visibility: str
|
|
59
|
+
owner_id: str | None
|
|
60
|
+
owner_email: str | None
|
|
61
|
+
restrict_to_environment: bool = False
|
|
62
|
+
policy_ids: List[str] = []
|
|
63
|
+
default_model: str | None = None
|
|
64
|
+
created_at: str
|
|
65
|
+
updated_at: str
|
|
66
|
+
archived_at: str | None
|
|
67
|
+
|
|
68
|
+
# Counts
|
|
69
|
+
agent_count: int = 0
|
|
70
|
+
team_count: int = 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ProjectAgentAdd(BaseModel):
|
|
74
|
+
agent_id: str = Field(..., description="Agent UUID to add to project")
|
|
75
|
+
role: str | None = Field(None, description="Agent role in project")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ProjectTeamAdd(BaseModel):
|
|
79
|
+
team_id: str = Field(..., description="Team UUID to add to project")
|
|
80
|
+
role: str | None = Field(None, description="Team role in project")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def ensure_default_project(organization: dict) -> Optional[dict]:
|
|
84
|
+
"""
|
|
85
|
+
Ensure the organization has a default project.
|
|
86
|
+
Creates one if it doesn't exist.
|
|
87
|
+
|
|
88
|
+
Returns the default project or None if creation failed.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
client = get_supabase()
|
|
92
|
+
|
|
93
|
+
# Check if default project exists
|
|
94
|
+
existing = (
|
|
95
|
+
client.table("projects")
|
|
96
|
+
.select("*")
|
|
97
|
+
.eq("organization_id", organization["id"])
|
|
98
|
+
.eq("key", "DEFAULT")
|
|
99
|
+
.execute()
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if existing.data:
|
|
103
|
+
return existing.data[0]
|
|
104
|
+
|
|
105
|
+
# Create default project
|
|
106
|
+
project_id = str(uuid.uuid4())
|
|
107
|
+
now = datetime.utcnow().isoformat()
|
|
108
|
+
|
|
109
|
+
default_project = {
|
|
110
|
+
"id": project_id,
|
|
111
|
+
"organization_id": organization["id"],
|
|
112
|
+
"name": "Default",
|
|
113
|
+
"key": "DEFAULT",
|
|
114
|
+
"description": "Default project for agents and teams",
|
|
115
|
+
"settings": {
|
|
116
|
+
"policy_ids": [],
|
|
117
|
+
"default_model": None,
|
|
118
|
+
"goals": None,
|
|
119
|
+
"restrict_to_environment": False
|
|
120
|
+
},
|
|
121
|
+
"status": "active",
|
|
122
|
+
"visibility": "org",
|
|
123
|
+
"owner_id": organization.get("user_id"),
|
|
124
|
+
"owner_email": organization.get("user_email"),
|
|
125
|
+
"created_at": now,
|
|
126
|
+
"updated_at": now,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
result = client.table("projects").insert(default_project).execute()
|
|
130
|
+
|
|
131
|
+
if result.data:
|
|
132
|
+
logger.info(
|
|
133
|
+
"default_project_created",
|
|
134
|
+
project_id=project_id,
|
|
135
|
+
org_id=organization["id"],
|
|
136
|
+
)
|
|
137
|
+
return result.data[0]
|
|
138
|
+
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.error("ensure_default_project_failed", error=str(e), org_id=organization.get("id"))
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_default_project_id(organization: dict) -> Optional[str]:
|
|
147
|
+
"""
|
|
148
|
+
Get the default project ID for an organization.
|
|
149
|
+
Creates the default project if it doesn't exist.
|
|
150
|
+
|
|
151
|
+
Returns the project ID or None if creation failed.
|
|
152
|
+
"""
|
|
153
|
+
project = ensure_default_project(organization)
|
|
154
|
+
return project["id"] if project else None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
|
|
158
|
+
async def create_project(
|
|
159
|
+
project_data: ProjectCreate,
|
|
160
|
+
request: Request,
|
|
161
|
+
organization: dict = Depends(get_current_organization),
|
|
162
|
+
):
|
|
163
|
+
"""Create a new project"""
|
|
164
|
+
try:
|
|
165
|
+
client = get_supabase()
|
|
166
|
+
|
|
167
|
+
# Check if key already exists for this organization
|
|
168
|
+
existing = (
|
|
169
|
+
client.table("projects")
|
|
170
|
+
.select("id")
|
|
171
|
+
.eq("organization_id", organization["id"])
|
|
172
|
+
.eq("key", project_data.key.upper())
|
|
173
|
+
.execute()
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if existing.data:
|
|
177
|
+
raise HTTPException(
|
|
178
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
179
|
+
detail=f"Project with key '{project_data.key.upper()}' already exists"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
project_id = str(uuid.uuid4())
|
|
183
|
+
now = datetime.utcnow().isoformat()
|
|
184
|
+
|
|
185
|
+
project_record = {
|
|
186
|
+
"id": project_id,
|
|
187
|
+
"organization_id": organization["id"],
|
|
188
|
+
"name": project_data.name,
|
|
189
|
+
"key": project_data.key.upper(),
|
|
190
|
+
"description": project_data.description,
|
|
191
|
+
# Store policy_ids, default_model, goals, and restrict_to_environment in settings JSON field
|
|
192
|
+
"settings": {
|
|
193
|
+
**project_data.settings,
|
|
194
|
+
"policy_ids": project_data.policy_ids,
|
|
195
|
+
"default_model": project_data.default_model,
|
|
196
|
+
"goals": project_data.goals,
|
|
197
|
+
"restrict_to_environment": project_data.restrict_to_environment
|
|
198
|
+
},
|
|
199
|
+
"status": "active",
|
|
200
|
+
"visibility": project_data.visibility,
|
|
201
|
+
"owner_id": organization.get("user_id"),
|
|
202
|
+
"owner_email": organization.get("user_email"),
|
|
203
|
+
"created_at": now,
|
|
204
|
+
"updated_at": now,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
result = client.table("projects").insert(project_record).execute()
|
|
208
|
+
|
|
209
|
+
if not result.data:
|
|
210
|
+
raise HTTPException(
|
|
211
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
212
|
+
detail="Failed to create project"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
project = result.data[0]
|
|
216
|
+
|
|
217
|
+
logger.info(
|
|
218
|
+
"project_created",
|
|
219
|
+
project_id=project_id,
|
|
220
|
+
project_key=project["key"],
|
|
221
|
+
org_id=organization["id"],
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Extract policy_ids, default_model, goals, and restrict_to_environment from settings for response
|
|
225
|
+
policy_ids = project.get("settings", {}).get("policy_ids", [])
|
|
226
|
+
default_model = project.get("settings", {}).get("default_model")
|
|
227
|
+
goals = project.get("settings", {}).get("goals")
|
|
228
|
+
restrict_to_environment = project.get("settings", {}).get("restrict_to_environment", False)
|
|
229
|
+
|
|
230
|
+
return ProjectResponse(
|
|
231
|
+
**{**project, "policy_ids": policy_ids, "default_model": default_model, "goals": goals, "restrict_to_environment": restrict_to_environment},
|
|
232
|
+
agent_count=0,
|
|
233
|
+
team_count=0,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
except HTTPException:
|
|
237
|
+
raise
|
|
238
|
+
except Exception as e:
|
|
239
|
+
logger.error("project_creation_failed", error=str(e), org_id=organization["id"])
|
|
240
|
+
raise HTTPException(
|
|
241
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
242
|
+
detail=f"Failed to create project: {str(e)}"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@router.get("/default", response_model=ProjectResponse)
|
|
247
|
+
async def get_default_project(
|
|
248
|
+
request: Request,
|
|
249
|
+
organization: dict = Depends(get_current_organization),
|
|
250
|
+
):
|
|
251
|
+
"""Get the default project for the organization (creates if doesn't exist)"""
|
|
252
|
+
try:
|
|
253
|
+
client = get_supabase()
|
|
254
|
+
|
|
255
|
+
# Ensure default project exists
|
|
256
|
+
default_project = ensure_default_project(organization)
|
|
257
|
+
|
|
258
|
+
if not default_project:
|
|
259
|
+
raise HTTPException(
|
|
260
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
261
|
+
detail="Failed to get or create default project"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Get counts for the default project
|
|
265
|
+
agent_count_result = (
|
|
266
|
+
client.table("project_agents")
|
|
267
|
+
.select("id", count="exact")
|
|
268
|
+
.eq("project_id", default_project["id"])
|
|
269
|
+
.execute()
|
|
270
|
+
)
|
|
271
|
+
agent_count = agent_count_result.count or 0
|
|
272
|
+
|
|
273
|
+
team_count_result = (
|
|
274
|
+
client.table("project_teams")
|
|
275
|
+
.select("id", count="exact")
|
|
276
|
+
.eq("project_id", default_project["id"])
|
|
277
|
+
.execute()
|
|
278
|
+
)
|
|
279
|
+
team_count = team_count_result.count or 0
|
|
280
|
+
|
|
281
|
+
# Extract settings fields
|
|
282
|
+
policy_ids = default_project.get("settings", {}).get("policy_ids", [])
|
|
283
|
+
default_model = default_project.get("settings", {}).get("default_model")
|
|
284
|
+
goals = default_project.get("settings", {}).get("goals")
|
|
285
|
+
restrict_to_environment = default_project.get("settings", {}).get("restrict_to_environment", False)
|
|
286
|
+
|
|
287
|
+
return ProjectResponse(
|
|
288
|
+
**{**default_project, "policy_ids": policy_ids, "default_model": default_model, "goals": goals, "restrict_to_environment": restrict_to_environment},
|
|
289
|
+
agent_count=agent_count,
|
|
290
|
+
team_count=team_count,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
except HTTPException:
|
|
294
|
+
raise
|
|
295
|
+
except Exception as e:
|
|
296
|
+
logger.error("get_default_project_failed", error=str(e), org_id=organization["id"])
|
|
297
|
+
raise HTTPException(
|
|
298
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
299
|
+
detail=f"Failed to get default project: {str(e)}"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@router.get("", response_model=List[ProjectResponse])
|
|
304
|
+
async def list_projects(
|
|
305
|
+
request: Request,
|
|
306
|
+
status_filter: str | None = None,
|
|
307
|
+
organization: dict = Depends(get_current_organization),
|
|
308
|
+
):
|
|
309
|
+
"""List all projects in the organization"""
|
|
310
|
+
try:
|
|
311
|
+
client = get_supabase()
|
|
312
|
+
|
|
313
|
+
# Ensure default project exists for this organization
|
|
314
|
+
ensure_default_project(organization)
|
|
315
|
+
|
|
316
|
+
# Query projects
|
|
317
|
+
query = client.table("projects").select("*").eq("organization_id", organization["id"])
|
|
318
|
+
|
|
319
|
+
if status_filter:
|
|
320
|
+
query = query.eq("status", status_filter)
|
|
321
|
+
|
|
322
|
+
query = query.order("created_at", desc=True)
|
|
323
|
+
result = query.execute()
|
|
324
|
+
|
|
325
|
+
if not result.data:
|
|
326
|
+
return []
|
|
327
|
+
|
|
328
|
+
# Batch fetch all agent counts in one query
|
|
329
|
+
project_ids = [project["id"] for project in result.data]
|
|
330
|
+
agent_counts_result = (
|
|
331
|
+
client.table("project_agents")
|
|
332
|
+
.select("project_id")
|
|
333
|
+
.in_("project_id", project_ids)
|
|
334
|
+
.execute()
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Count agents per project
|
|
338
|
+
agent_count_map = {}
|
|
339
|
+
for item in agent_counts_result.data or []:
|
|
340
|
+
project_id = item["project_id"]
|
|
341
|
+
agent_count_map[project_id] = agent_count_map.get(project_id, 0) + 1
|
|
342
|
+
|
|
343
|
+
# Batch fetch all team counts in one query
|
|
344
|
+
team_counts_result = (
|
|
345
|
+
client.table("project_teams")
|
|
346
|
+
.select("project_id")
|
|
347
|
+
.in_("project_id", project_ids)
|
|
348
|
+
.execute()
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Count teams per project
|
|
352
|
+
team_count_map = {}
|
|
353
|
+
for item in team_counts_result.data or []:
|
|
354
|
+
project_id = item["project_id"]
|
|
355
|
+
team_count_map[project_id] = team_count_map.get(project_id, 0) + 1
|
|
356
|
+
|
|
357
|
+
# Build response with pre-fetched counts
|
|
358
|
+
projects = []
|
|
359
|
+
for project in result.data:
|
|
360
|
+
# Extract policy_ids, default_model, goals, and restrict_to_environment from settings for response
|
|
361
|
+
policy_ids = project.get("settings", {}).get("policy_ids", [])
|
|
362
|
+
default_model = project.get("settings", {}).get("default_model")
|
|
363
|
+
goals = project.get("settings", {}).get("goals")
|
|
364
|
+
restrict_to_environment = project.get("settings", {}).get("restrict_to_environment", False)
|
|
365
|
+
|
|
366
|
+
projects.append(
|
|
367
|
+
ProjectResponse(
|
|
368
|
+
**{**project, "policy_ids": policy_ids, "default_model": default_model, "goals": goals, "restrict_to_environment": restrict_to_environment},
|
|
369
|
+
agent_count=agent_count_map.get(project["id"], 0),
|
|
370
|
+
team_count=team_count_map.get(project["id"], 0),
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
logger.info(
|
|
375
|
+
"projects_listed",
|
|
376
|
+
count=len(projects),
|
|
377
|
+
org_id=organization["id"],
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
return projects
|
|
381
|
+
|
|
382
|
+
except Exception as e:
|
|
383
|
+
logger.error("projects_list_failed", error=str(e), org_id=organization["id"])
|
|
384
|
+
raise HTTPException(
|
|
385
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
386
|
+
detail=f"Failed to list projects: {str(e)}"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@router.get("/{project_id}", response_model=ProjectResponse)
|
|
391
|
+
async def get_project(
|
|
392
|
+
project_id: str,
|
|
393
|
+
request: Request,
|
|
394
|
+
organization: dict = Depends(get_current_organization),
|
|
395
|
+
):
|
|
396
|
+
"""Get a specific project by ID"""
|
|
397
|
+
try:
|
|
398
|
+
client = get_supabase()
|
|
399
|
+
|
|
400
|
+
result = (
|
|
401
|
+
client.table("projects")
|
|
402
|
+
.select("*")
|
|
403
|
+
.eq("id", project_id)
|
|
404
|
+
.eq("organization_id", organization["id"])
|
|
405
|
+
.single()
|
|
406
|
+
.execute()
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
if not result.data:
|
|
410
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
411
|
+
|
|
412
|
+
project = result.data
|
|
413
|
+
|
|
414
|
+
# Get counts
|
|
415
|
+
agent_count_result = (
|
|
416
|
+
client.table("project_agents")
|
|
417
|
+
.select("id", count="exact")
|
|
418
|
+
.eq("project_id", project_id)
|
|
419
|
+
.execute()
|
|
420
|
+
)
|
|
421
|
+
agent_count = agent_count_result.count or 0
|
|
422
|
+
|
|
423
|
+
team_count_result = (
|
|
424
|
+
client.table("project_teams")
|
|
425
|
+
.select("id", count="exact")
|
|
426
|
+
.eq("project_id", project_id)
|
|
427
|
+
.execute()
|
|
428
|
+
)
|
|
429
|
+
team_count = team_count_result.count or 0
|
|
430
|
+
|
|
431
|
+
# Extract policy_ids, default_model, goals, and restrict_to_environment from settings for response
|
|
432
|
+
policy_ids = project.get("settings", {}).get("policy_ids", [])
|
|
433
|
+
default_model = project.get("settings", {}).get("default_model")
|
|
434
|
+
goals = project.get("settings", {}).get("goals")
|
|
435
|
+
restrict_to_environment = project.get("settings", {}).get("restrict_to_environment", False)
|
|
436
|
+
|
|
437
|
+
return ProjectResponse(
|
|
438
|
+
**{**project, "policy_ids": policy_ids, "default_model": default_model, "goals": goals, "restrict_to_environment": restrict_to_environment},
|
|
439
|
+
agent_count=agent_count,
|
|
440
|
+
team_count=team_count,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
except HTTPException:
|
|
444
|
+
raise
|
|
445
|
+
except Exception as e:
|
|
446
|
+
logger.error("project_get_failed", error=str(e), project_id=project_id)
|
|
447
|
+
raise HTTPException(
|
|
448
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
449
|
+
detail=f"Failed to get project: {str(e)}"
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@router.patch("/{project_id}", response_model=ProjectResponse)
|
|
454
|
+
async def update_project(
|
|
455
|
+
project_id: str,
|
|
456
|
+
project_data: ProjectUpdate,
|
|
457
|
+
request: Request,
|
|
458
|
+
organization: dict = Depends(get_current_organization),
|
|
459
|
+
):
|
|
460
|
+
"""Update a project"""
|
|
461
|
+
try:
|
|
462
|
+
client = get_supabase()
|
|
463
|
+
|
|
464
|
+
# Check if project exists
|
|
465
|
+
existing = (
|
|
466
|
+
client.table("projects")
|
|
467
|
+
.select("id")
|
|
468
|
+
.eq("id", project_id)
|
|
469
|
+
.eq("organization_id", organization["id"])
|
|
470
|
+
.execute()
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
if not existing.data:
|
|
474
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
475
|
+
|
|
476
|
+
# Build update dict
|
|
477
|
+
update_data = project_data.model_dump(exclude_unset=True)
|
|
478
|
+
|
|
479
|
+
# Handle policy_ids, default_model, goals, and restrict_to_environment - store in settings if provided
|
|
480
|
+
settings_updates = {}
|
|
481
|
+
if "policy_ids" in update_data:
|
|
482
|
+
settings_updates["policy_ids"] = update_data.pop("policy_ids")
|
|
483
|
+
if "default_model" in update_data:
|
|
484
|
+
settings_updates["default_model"] = update_data.pop("default_model")
|
|
485
|
+
if "goals" in update_data:
|
|
486
|
+
settings_updates["goals"] = update_data.pop("goals")
|
|
487
|
+
if "restrict_to_environment" in update_data:
|
|
488
|
+
settings_updates["restrict_to_environment"] = update_data.pop("restrict_to_environment")
|
|
489
|
+
|
|
490
|
+
# Apply settings updates if any
|
|
491
|
+
if settings_updates:
|
|
492
|
+
if "settings" in update_data:
|
|
493
|
+
update_data["settings"].update(settings_updates)
|
|
494
|
+
else:
|
|
495
|
+
# Need to merge with existing settings
|
|
496
|
+
existing_project = (
|
|
497
|
+
client.table("projects")
|
|
498
|
+
.select("settings")
|
|
499
|
+
.eq("id", project_id)
|
|
500
|
+
.single()
|
|
501
|
+
.execute()
|
|
502
|
+
)
|
|
503
|
+
existing_settings = existing_project.data.get("settings", {}) if existing_project.data else {}
|
|
504
|
+
update_data["settings"] = {**existing_settings, **settings_updates}
|
|
505
|
+
|
|
506
|
+
# Uppercase key if provided
|
|
507
|
+
if "key" in update_data:
|
|
508
|
+
update_data["key"] = update_data["key"].upper()
|
|
509
|
+
|
|
510
|
+
update_data["updated_at"] = datetime.utcnow().isoformat()
|
|
511
|
+
|
|
512
|
+
# Update project
|
|
513
|
+
result = (
|
|
514
|
+
client.table("projects")
|
|
515
|
+
.update(update_data)
|
|
516
|
+
.eq("id", project_id)
|
|
517
|
+
.eq("organization_id", organization["id"])
|
|
518
|
+
.execute()
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
if not result.data:
|
|
522
|
+
raise HTTPException(
|
|
523
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
524
|
+
detail="Failed to update project"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
project = result.data[0]
|
|
528
|
+
|
|
529
|
+
# Get counts
|
|
530
|
+
agent_count_result = (
|
|
531
|
+
client.table("project_agents")
|
|
532
|
+
.select("id", count="exact")
|
|
533
|
+
.eq("project_id", project_id)
|
|
534
|
+
.execute()
|
|
535
|
+
)
|
|
536
|
+
agent_count = agent_count_result.count or 0
|
|
537
|
+
|
|
538
|
+
team_count_result = (
|
|
539
|
+
client.table("project_teams")
|
|
540
|
+
.select("id", count="exact")
|
|
541
|
+
.eq("project_id", project_id)
|
|
542
|
+
.execute()
|
|
543
|
+
)
|
|
544
|
+
team_count = team_count_result.count or 0
|
|
545
|
+
|
|
546
|
+
logger.info(
|
|
547
|
+
"project_updated",
|
|
548
|
+
project_id=project_id,
|
|
549
|
+
org_id=organization["id"],
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
# Extract policy_ids, default_model, goals, and restrict_to_environment from settings for response
|
|
553
|
+
policy_ids = project.get("settings", {}).get("policy_ids", [])
|
|
554
|
+
default_model = project.get("settings", {}).get("default_model")
|
|
555
|
+
goals = project.get("settings", {}).get("goals")
|
|
556
|
+
restrict_to_environment = project.get("settings", {}).get("restrict_to_environment", False)
|
|
557
|
+
|
|
558
|
+
return ProjectResponse(
|
|
559
|
+
**{**project, "policy_ids": policy_ids, "default_model": default_model, "goals": goals, "restrict_to_environment": restrict_to_environment},
|
|
560
|
+
agent_count=agent_count,
|
|
561
|
+
team_count=team_count,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
except HTTPException:
|
|
565
|
+
raise
|
|
566
|
+
except Exception as e:
|
|
567
|
+
logger.error("project_update_failed", error=str(e), project_id=project_id)
|
|
568
|
+
raise HTTPException(
|
|
569
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
570
|
+
detail=f"Failed to update project: {str(e)}"
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
575
|
+
async def delete_project(
|
|
576
|
+
project_id: str,
|
|
577
|
+
request: Request,
|
|
578
|
+
organization: dict = Depends(get_current_organization),
|
|
579
|
+
):
|
|
580
|
+
"""Delete a project (cascades to associations)"""
|
|
581
|
+
try:
|
|
582
|
+
client = get_supabase()
|
|
583
|
+
|
|
584
|
+
result = (
|
|
585
|
+
client.table("projects")
|
|
586
|
+
.delete()
|
|
587
|
+
.eq("id", project_id)
|
|
588
|
+
.eq("organization_id", organization["id"])
|
|
589
|
+
.execute()
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
if not result.data:
|
|
593
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
594
|
+
|
|
595
|
+
logger.info("project_deleted", project_id=project_id, org_id=organization["id"])
|
|
596
|
+
|
|
597
|
+
return None
|
|
598
|
+
|
|
599
|
+
except HTTPException:
|
|
600
|
+
raise
|
|
601
|
+
except Exception as e:
|
|
602
|
+
logger.error("project_delete_failed", error=str(e), project_id=project_id)
|
|
603
|
+
raise HTTPException(
|
|
604
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
605
|
+
detail=f"Failed to delete project: {str(e)}"
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
# Agent associations
|
|
610
|
+
@router.post("/{project_id}/agents", status_code=status.HTTP_201_CREATED)
|
|
611
|
+
async def add_agent_to_project(
|
|
612
|
+
project_id: str,
|
|
613
|
+
agent_data: ProjectAgentAdd,
|
|
614
|
+
request: Request,
|
|
615
|
+
organization: dict = Depends(get_current_organization),
|
|
616
|
+
):
|
|
617
|
+
"""Add an agent to a project"""
|
|
618
|
+
try:
|
|
619
|
+
client = get_supabase()
|
|
620
|
+
|
|
621
|
+
# Verify project exists
|
|
622
|
+
project_check = (
|
|
623
|
+
client.table("projects")
|
|
624
|
+
.select("id")
|
|
625
|
+
.eq("id", project_id)
|
|
626
|
+
.eq("organization_id", organization["id"])
|
|
627
|
+
.execute()
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
if not project_check.data:
|
|
631
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
632
|
+
|
|
633
|
+
# Verify agent exists and belongs to org
|
|
634
|
+
agent_check = (
|
|
635
|
+
client.table("agents")
|
|
636
|
+
.select("id")
|
|
637
|
+
.eq("id", agent_data.agent_id)
|
|
638
|
+
.eq("organization_id", organization["id"])
|
|
639
|
+
.execute()
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
if not agent_check.data:
|
|
643
|
+
raise HTTPException(status_code=404, detail="Agent not found")
|
|
644
|
+
|
|
645
|
+
# Add association
|
|
646
|
+
association = {
|
|
647
|
+
"id": str(uuid.uuid4()),
|
|
648
|
+
"project_id": project_id,
|
|
649
|
+
"agent_id": agent_data.agent_id,
|
|
650
|
+
"role": agent_data.role,
|
|
651
|
+
"added_at": datetime.utcnow().isoformat(),
|
|
652
|
+
"added_by": organization.get("user_id"),
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
result = client.table("project_agents").insert(association).execute()
|
|
656
|
+
|
|
657
|
+
if not result.data:
|
|
658
|
+
raise HTTPException(
|
|
659
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
660
|
+
detail="Failed to add agent to project"
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
logger.info(
|
|
664
|
+
"agent_added_to_project",
|
|
665
|
+
project_id=project_id,
|
|
666
|
+
agent_id=agent_data.agent_id,
|
|
667
|
+
org_id=organization["id"],
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
return result.data[0]
|
|
671
|
+
|
|
672
|
+
except HTTPException:
|
|
673
|
+
raise
|
|
674
|
+
except Exception as e:
|
|
675
|
+
logger.error("add_agent_to_project_failed", error=str(e))
|
|
676
|
+
raise HTTPException(
|
|
677
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
678
|
+
detail=f"Failed to add agent: {str(e)}"
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
@router.get("/{project_id}/agents")
|
|
683
|
+
async def list_project_agents(
|
|
684
|
+
project_id: str,
|
|
685
|
+
request: Request,
|
|
686
|
+
organization: dict = Depends(get_current_organization),
|
|
687
|
+
):
|
|
688
|
+
"""List all agents in a project"""
|
|
689
|
+
try:
|
|
690
|
+
client = get_supabase()
|
|
691
|
+
|
|
692
|
+
# Get project agents with agent details
|
|
693
|
+
result = (
|
|
694
|
+
client.table("project_agents")
|
|
695
|
+
.select("*, agents(*)")
|
|
696
|
+
.eq("project_id", project_id)
|
|
697
|
+
.execute()
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
logger.info(
|
|
701
|
+
"project_agents_listed",
|
|
702
|
+
project_id=project_id,
|
|
703
|
+
count=len(result.data),
|
|
704
|
+
org_id=organization["id"],
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
return result.data
|
|
708
|
+
|
|
709
|
+
except Exception as e:
|
|
710
|
+
logger.error("list_project_agents_failed", error=str(e))
|
|
711
|
+
raise HTTPException(
|
|
712
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
713
|
+
detail=f"Failed to list agents: {str(e)}"
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
@router.delete("/{project_id}/agents/{agent_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
718
|
+
async def remove_agent_from_project(
|
|
719
|
+
project_id: str,
|
|
720
|
+
agent_id: str,
|
|
721
|
+
request: Request,
|
|
722
|
+
organization: dict = Depends(get_current_organization),
|
|
723
|
+
):
|
|
724
|
+
"""Remove an agent from a project"""
|
|
725
|
+
try:
|
|
726
|
+
client = get_supabase()
|
|
727
|
+
|
|
728
|
+
result = (
|
|
729
|
+
client.table("project_agents")
|
|
730
|
+
.delete()
|
|
731
|
+
.eq("project_id", project_id)
|
|
732
|
+
.eq("agent_id", agent_id)
|
|
733
|
+
.execute()
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
if not result.data:
|
|
737
|
+
raise HTTPException(status_code=404, detail="Association not found")
|
|
738
|
+
|
|
739
|
+
logger.info(
|
|
740
|
+
"agent_removed_from_project",
|
|
741
|
+
project_id=project_id,
|
|
742
|
+
agent_id=agent_id,
|
|
743
|
+
org_id=organization["id"],
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
return None
|
|
747
|
+
|
|
748
|
+
except HTTPException:
|
|
749
|
+
raise
|
|
750
|
+
except Exception as e:
|
|
751
|
+
logger.error("remove_agent_from_project_failed", error=str(e))
|
|
752
|
+
raise HTTPException(
|
|
753
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
754
|
+
detail=f"Failed to remove agent: {str(e)}"
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
# Team associations (similar to agents)
|
|
759
|
+
@router.post("/{project_id}/teams", status_code=status.HTTP_201_CREATED)
|
|
760
|
+
async def add_team_to_project(
|
|
761
|
+
project_id: str,
|
|
762
|
+
team_data: ProjectTeamAdd,
|
|
763
|
+
request: Request,
|
|
764
|
+
organization: dict = Depends(get_current_organization),
|
|
765
|
+
):
|
|
766
|
+
"""Add a team to a project"""
|
|
767
|
+
try:
|
|
768
|
+
client = get_supabase()
|
|
769
|
+
|
|
770
|
+
# Verify project and team exist
|
|
771
|
+
project_check = (
|
|
772
|
+
client.table("projects")
|
|
773
|
+
.select("id")
|
|
774
|
+
.eq("id", project_id)
|
|
775
|
+
.eq("organization_id", organization["id"])
|
|
776
|
+
.execute()
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
if not project_check.data:
|
|
780
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
781
|
+
|
|
782
|
+
team_check = (
|
|
783
|
+
client.table("teams")
|
|
784
|
+
.select("id")
|
|
785
|
+
.eq("id", team_data.team_id)
|
|
786
|
+
.eq("organization_id", organization["id"])
|
|
787
|
+
.execute()
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
if not team_check.data:
|
|
791
|
+
raise HTTPException(status_code=404, detail="Team not found")
|
|
792
|
+
|
|
793
|
+
# Add association
|
|
794
|
+
association = {
|
|
795
|
+
"id": str(uuid.uuid4()),
|
|
796
|
+
"project_id": project_id,
|
|
797
|
+
"team_id": team_data.team_id,
|
|
798
|
+
"role": team_data.role,
|
|
799
|
+
"added_at": datetime.utcnow().isoformat(),
|
|
800
|
+
"added_by": organization.get("user_id"),
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
result = client.table("project_teams").insert(association).execute()
|
|
804
|
+
|
|
805
|
+
if not result.data:
|
|
806
|
+
raise HTTPException(
|
|
807
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
808
|
+
detail="Failed to add team to project"
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
logger.info(
|
|
812
|
+
"team_added_to_project",
|
|
813
|
+
project_id=project_id,
|
|
814
|
+
team_id=team_data.team_id,
|
|
815
|
+
org_id=organization["id"],
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
return result.data[0]
|
|
819
|
+
|
|
820
|
+
except HTTPException:
|
|
821
|
+
raise
|
|
822
|
+
except Exception as e:
|
|
823
|
+
logger.error("add_team_to_project_failed", error=str(e))
|
|
824
|
+
raise HTTPException(
|
|
825
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
826
|
+
detail=f"Failed to add team: {str(e)}"
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
@router.get("/{project_id}/teams")
|
|
831
|
+
async def list_project_teams(
|
|
832
|
+
project_id: str,
|
|
833
|
+
request: Request,
|
|
834
|
+
organization: dict = Depends(get_current_organization),
|
|
835
|
+
):
|
|
836
|
+
"""List all teams in a project"""
|
|
837
|
+
try:
|
|
838
|
+
client = get_supabase()
|
|
839
|
+
|
|
840
|
+
result = (
|
|
841
|
+
client.table("project_teams")
|
|
842
|
+
.select("*, teams(*)")
|
|
843
|
+
.eq("project_id", project_id)
|
|
844
|
+
.execute()
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
logger.info(
|
|
848
|
+
"project_teams_listed",
|
|
849
|
+
project_id=project_id,
|
|
850
|
+
count=len(result.data),
|
|
851
|
+
org_id=organization["id"],
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
return result.data
|
|
855
|
+
|
|
856
|
+
except Exception as e:
|
|
857
|
+
logger.error("list_project_teams_failed", error=str(e))
|
|
858
|
+
raise HTTPException(
|
|
859
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
860
|
+
detail=f"Failed to list teams: {str(e)}"
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
@router.delete("/{project_id}/teams/{team_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
865
|
+
async def remove_team_from_project(
|
|
866
|
+
project_id: str,
|
|
867
|
+
team_id: str,
|
|
868
|
+
request: Request,
|
|
869
|
+
organization: dict = Depends(get_current_organization),
|
|
870
|
+
):
|
|
871
|
+
"""Remove a team from a project"""
|
|
872
|
+
try:
|
|
873
|
+
client = get_supabase()
|
|
874
|
+
|
|
875
|
+
result = (
|
|
876
|
+
client.table("project_teams")
|
|
877
|
+
.delete()
|
|
878
|
+
.eq("project_id", project_id)
|
|
879
|
+
.eq("team_id", team_id)
|
|
880
|
+
.execute()
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
if not result.data:
|
|
884
|
+
raise HTTPException(status_code=404, detail="Association not found")
|
|
885
|
+
|
|
886
|
+
logger.info(
|
|
887
|
+
"team_removed_from_project",
|
|
888
|
+
project_id=project_id,
|
|
889
|
+
team_id=team_id,
|
|
890
|
+
org_id=organization["id"],
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
return None
|
|
894
|
+
|
|
895
|
+
except HTTPException:
|
|
896
|
+
raise
|
|
897
|
+
except Exception as e:
|
|
898
|
+
logger.error("remove_team_from_project_failed", error=str(e))
|
|
899
|
+
raise HTTPException(
|
|
900
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
901
|
+
detail=f"Failed to remove team: {str(e)}"
|
|
902
|
+
)
|