1
1
from fastapi import APIRouter , Depends , HTTPException , status
2
2
from sqlalchemy .orm import Session
3
3
from typing import List
4
+ import requests
5
+ import asyncpg
6
+ import logging
4
7
5
8
from app .db .database import get_db
6
9
from app .api .auth import get_current_user_from_auth
7
- from app .schemas .models import Region , RegionCreate , RegionResponse , User , RegionUpdate
8
- from app .db .models import DBRegion , DBPrivateAIKey
10
+ from app .schemas .models import Region , RegionCreate , RegionResponse , User , RegionUpdate , TeamSummary
11
+ from app .db .models import DBRegion , DBPrivateAIKey , DBTeamRegion , DBTeam
12
+ from app .core .security import check_system_admin
13
+
14
+ logger = logging .getLogger (__name__ )
9
15
10
16
router = APIRouter (
11
17
tags = ["regions" ]
12
18
)
13
19
14
- @router .post ("" , response_model = Region )
15
- @router .post ("/" , response_model = Region )
20
+ async def validate_litellm_endpoint (api_url : str , api_key : str ) -> bool :
21
+ """
22
+ Validate LiteLLM endpoint by making a test request to the health endpoint.
23
+
24
+ Args:
25
+ api_url: The LiteLLM API URL
26
+ api_key: The LiteLLM API key
27
+
28
+ Returns:
29
+ bool: True if validation succeeds, raises HTTPException if it fails
30
+ """
31
+ try :
32
+ # Test the LiteLLM health endpoint
33
+ response = requests .get (
34
+ f"{ api_url } /health/liveliness" ,
35
+ headers = {"Authorization" : f"Bearer { api_key } " },
36
+ timeout = 10
37
+ )
38
+ response .raise_for_status ()
39
+ logger .info (f"LiteLLM endpoint validation successful for { api_url } " )
40
+ return True
41
+ except requests .exceptions .RequestException as e :
42
+ error_msg = str (e )
43
+ if hasattr (e , 'response' ) and e .response is not None :
44
+ try :
45
+ error_details = e .response .json ()
46
+ error_msg = f"Status { e .response .status_code } : { error_details } "
47
+ except ValueError :
48
+ error_msg = f"Status { e .response .status_code } : { e .response .text } "
49
+ logger .error (f"LiteLLM endpoint validation failed for { api_url } : { error_msg } " )
50
+ raise HTTPException (
51
+ status_code = status .HTTP_400_BAD_REQUEST ,
52
+ detail = f"LiteLLM endpoint validation failed: { error_msg } "
53
+ )
54
+
55
+ async def validate_database_connection (host : str , port : int , user : str , password : str ) -> bool :
56
+ """
57
+ Validate database connection by attempting to connect to PostgreSQL.
58
+
59
+ Args:
60
+ host: Database host
61
+ port: Database port
62
+ user: Database admin user
63
+ password: Database admin password
64
+
65
+ Returns:
66
+ bool: True if validation succeeds, raises HTTPException if it fails
67
+ """
68
+ try :
69
+ # Attempt to connect to the database
70
+ conn = await asyncpg .connect (
71
+ host = host ,
72
+ port = port ,
73
+ user = user ,
74
+ password = password
75
+ )
76
+ await conn .close ()
77
+ logger .info (f"Database connection validation successful for { host } :{ port } " )
78
+ return True
79
+ except asyncpg .exceptions .PostgresError as e :
80
+ logger .error (f"Database connection validation failed for { host } :{ port } : { str (e )} " )
81
+ raise HTTPException (
82
+ status_code = status .HTTP_400_BAD_REQUEST ,
83
+ detail = f"Database connection validation failed: { str (e )} "
84
+ )
85
+ except Exception as e :
86
+ logger .error (f"Unexpected error during database validation for { host } :{ port } : { str (e )} " )
87
+ raise HTTPException (
88
+ status_code = status .HTTP_400_BAD_REQUEST ,
89
+ detail = f"Database connection validation failed: { str (e )} "
90
+ )
91
+
92
+ @router .post ("" , response_model = Region , dependencies = [Depends (check_system_admin )])
93
+ @router .post ("/" , response_model = Region , dependencies = [Depends (check_system_admin )])
16
94
async def create_region (
17
95
region : RegionCreate ,
18
- current_user : User = Depends (get_current_user_from_auth ),
19
96
db : Session = Depends (get_db )
20
97
):
21
- if not current_user .is_admin :
22
- raise HTTPException (
23
- status_code = status .HTTP_403_FORBIDDEN ,
24
- detail = "Only administrators can create regions"
25
- )
26
-
27
98
# Check if region with this name already exists
28
99
existing_region = db .query (DBRegion ).filter (DBRegion .name == region .name ).first ()
29
100
if existing_region :
@@ -32,6 +103,17 @@ async def create_region(
32
103
detail = f"A region with the name '{ region .name } ' already exists"
33
104
)
34
105
106
+ # Validate LiteLLM endpoint
107
+ await validate_litellm_endpoint (region .litellm_api_url , region .litellm_api_key )
108
+
109
+ # Validate database connection
110
+ await validate_database_connection (
111
+ region .postgres_host ,
112
+ region .postgres_port ,
113
+ region .postgres_admin_user ,
114
+ region .postgres_admin_password
115
+ )
116
+
35
117
db_region = DBRegion (** region .model_dump ())
36
118
db .add (db_region )
37
119
try :
@@ -51,24 +133,40 @@ async def list_regions(
51
133
current_user : User = Depends (get_current_user_from_auth ),
52
134
db : Session = Depends (get_db )
53
135
):
54
- return db .query (DBRegion ).filter (DBRegion .is_active == True ).all ()
136
+ # System admin users can see all regions
137
+ if current_user .is_admin :
138
+ return db .query (DBRegion ).filter (DBRegion .is_active == True ).all ()
139
+
140
+ # Regular users can only see non-dedicated regions
141
+ if not current_user .team_id :
142
+ return db .query (DBRegion ).filter (
143
+ DBRegion .is_active == True ,
144
+ DBRegion .is_dedicated == False
145
+ ).all ()
55
146
56
- @router .get ("/admin" , response_model = List [Region ])
147
+ # Team members can see non-dedicated regions plus their team's dedicated regions
148
+ team_dedicated_regions = db .query (DBRegion ).join (DBTeamRegion ).filter (
149
+ DBRegion .is_active == True ,
150
+ DBRegion .is_dedicated == True ,
151
+ DBTeamRegion .team_id == current_user .team_id
152
+ ).all ()
153
+
154
+ non_dedicated_regions = db .query (DBRegion ).filter (
155
+ DBRegion .is_active == True ,
156
+ DBRegion .is_dedicated == False
157
+ ).all ()
158
+
159
+ return non_dedicated_regions + team_dedicated_regions
160
+
161
+ @router .get ("/admin" , response_model = List [Region ], dependencies = [Depends (check_system_admin )])
57
162
async def list_admin_regions (
58
- current_user : User = Depends (get_current_user_from_auth ),
59
163
db : Session = Depends (get_db )
60
164
):
61
- if not current_user .is_admin :
62
- raise HTTPException (
63
- status_code = status .HTTP_403_FORBIDDEN ,
64
- detail = "Only administrators can access this endpoint"
65
- )
66
165
return db .query (DBRegion ).all ()
67
166
68
- @router .get ("/{region_id}" , response_model = RegionResponse )
167
+ @router .get ("/{region_id}" , response_model = RegionResponse , dependencies = [ Depends ( check_system_admin )] )
69
168
async def get_region (
70
169
region_id : int ,
71
- current_user : User = Depends (get_current_user_from_auth ),
72
170
db : Session = Depends (get_db )
73
171
):
74
172
region = db .query (DBRegion ).filter (DBRegion .id == region_id ).first ()
@@ -79,31 +177,24 @@ async def get_region(
79
177
)
80
178
return region
81
179
82
- @router .delete ("/{region_id}" )
180
+ @router .delete ("/{region_id}" , dependencies = [ Depends ( check_system_admin )] )
83
181
async def delete_region (
84
182
region_id : int ,
85
- current_user : User = Depends (get_current_user_from_auth ),
86
183
db : Session = Depends (get_db )
87
184
):
88
- if not current_user .is_admin :
89
- raise HTTPException (
90
- status_code = status .HTTP_403_FORBIDDEN ,
91
- detail = "Only administrators can delete regions"
92
- )
93
-
94
185
region = db .query (DBRegion ).filter (DBRegion .id == region_id ).first ()
95
186
if not region :
96
187
raise HTTPException (
97
188
status_code = status .HTTP_404_NOT_FOUND ,
98
189
detail = "Region not found"
99
190
)
100
191
101
- # Check if there are any databases using this region
102
- existing_databases = db .query (DBPrivateAIKey ).filter (DBPrivateAIKey .region_id == region_id ).count ()
103
- if existing_databases > 0 :
192
+ # Check if there are any keys using this region
193
+ existing_keys = db .query (DBPrivateAIKey ).filter (DBPrivateAIKey .region_id == region_id ).count ()
194
+ if existing_keys > 0 :
104
195
raise HTTPException (
105
196
status_code = status .HTTP_400_BAD_REQUEST ,
106
- detail = f"Cannot delete region: { existing_databases } database (s) are currently using this region. Please delete these databases first."
197
+ detail = f"Cannot delete region: { existing_keys } keys (s) are currently using this region. Please delete these keys first."
107
198
)
108
199
109
200
# Instead of deleting, mark as inactive
@@ -118,18 +209,12 @@ async def delete_region(
118
209
)
119
210
return {"message" : "Region deleted successfully" }
120
211
121
- @router .put ("/{region_id}" , response_model = Region )
212
+ @router .put ("/{region_id}" , response_model = Region , dependencies = [ Depends ( check_system_admin )] )
122
213
async def update_region (
123
214
region_id : int ,
124
215
region : RegionUpdate ,
125
- current_user : User = Depends (get_current_user_from_auth ),
126
216
db : Session = Depends (get_db )
127
217
):
128
- if not current_user .is_admin :
129
- raise HTTPException (
130
- status_code = status .HTTP_403_FORBIDDEN ,
131
- detail = "Only administrators can update regions"
132
- )
133
218
134
219
db_region = db .query (DBRegion ).filter (DBRegion .id == region_id ).first ()
135
220
if not db_region :
@@ -164,4 +249,126 @@ async def update_region(
164
249
status_code = status .HTTP_400_BAD_REQUEST ,
165
250
detail = f"Failed to update region: { str (e )} "
166
251
)
167
- return db_region
252
+ return db_region
253
+
254
+ @router .post ("/{region_id}/teams/{team_id}" , dependencies = [Depends (check_system_admin )])
255
+ async def associate_team_with_region (
256
+ region_id : int ,
257
+ team_id : int ,
258
+ db : Session = Depends (get_db )
259
+ ):
260
+ """Associate a team with a dedicated region. Only system admins can do this."""
261
+
262
+ # Check if region exists and is dedicated
263
+ region = db .query (DBRegion ).filter (DBRegion .id == region_id ).first ()
264
+ if not region :
265
+ raise HTTPException (
266
+ status_code = status .HTTP_404_NOT_FOUND ,
267
+ detail = "Region not found"
268
+ )
269
+
270
+ if not region .is_dedicated :
271
+ raise HTTPException (
272
+ status_code = status .HTTP_400_BAD_REQUEST ,
273
+ detail = "Can only associate teams with dedicated regions"
274
+ )
275
+
276
+ # Check if team exists
277
+ team = db .query (DBTeam ).filter (DBTeam .id == team_id ).first ()
278
+ if not team :
279
+ raise HTTPException (
280
+ status_code = status .HTTP_404_NOT_FOUND ,
281
+ detail = "Team not found"
282
+ )
283
+
284
+ # Check if association already exists
285
+ existing_association = db .query (DBTeamRegion ).filter (
286
+ DBTeamRegion .team_id == team_id ,
287
+ DBTeamRegion .region_id == region_id
288
+ ).first ()
289
+
290
+ if existing_association :
291
+ raise HTTPException (
292
+ status_code = status .HTTP_400_BAD_REQUEST ,
293
+ detail = "Team is already associated with this region"
294
+ )
295
+
296
+ # Create the association
297
+ team_region = DBTeamRegion (
298
+ team_id = team_id ,
299
+ region_id = region_id
300
+ )
301
+ db .add (team_region )
302
+
303
+ try :
304
+ db .commit ()
305
+ except Exception as e :
306
+ db .rollback ()
307
+ raise HTTPException (
308
+ status_code = status .HTTP_500_INTERNAL_SERVER_ERROR ,
309
+ detail = f"Failed to associate team with region: { str (e )} "
310
+ )
311
+
312
+ return {"message" : "Team associated with region successfully" }
313
+
314
+ @router .delete ("/{region_id}/teams/{team_id}" , dependencies = [Depends (check_system_admin )])
315
+ async def disassociate_team_from_region (
316
+ region_id : int ,
317
+ team_id : int ,
318
+ db : Session = Depends (get_db )
319
+ ):
320
+ """Disassociate a team from a dedicated region. Only system admins can do this."""
321
+
322
+ # Check if association exists
323
+ association = db .query (DBTeamRegion ).filter (
324
+ DBTeamRegion .team_id == team_id ,
325
+ DBTeamRegion .region_id == region_id
326
+ ).first ()
327
+
328
+ if not association :
329
+ raise HTTPException (
330
+ status_code = status .HTTP_404_NOT_FOUND ,
331
+ detail = "Team-region association not found"
332
+ )
333
+
334
+ # Remove the association
335
+ db .delete (association )
336
+
337
+ try :
338
+ db .commit ()
339
+ except Exception as e :
340
+ db .rollback ()
341
+ raise HTTPException (
342
+ status_code = status .HTTP_500_INTERNAL_SERVER_ERROR ,
343
+ detail = f"Failed to disassociate team from region: { str (e )} "
344
+ )
345
+
346
+ return {"message" : "Team disassociated from region successfully" }
347
+
348
+ @router .get ("/{region_id}/teams" , response_model = List [TeamSummary ], dependencies = [Depends (check_system_admin )])
349
+ async def list_teams_for_region (
350
+ region_id : int ,
351
+ db : Session = Depends (get_db )
352
+ ):
353
+ """List teams associated with a dedicated region. Only system admins can do this."""
354
+
355
+ # Check if region exists and is dedicated
356
+ region = db .query (DBRegion ).filter (DBRegion .id == region_id ).first ()
357
+ if not region :
358
+ raise HTTPException (
359
+ status_code = status .HTTP_404_NOT_FOUND ,
360
+ detail = "Region not found"
361
+ )
362
+
363
+ if not region .is_dedicated :
364
+ raise HTTPException (
365
+ status_code = status .HTTP_400_BAD_REQUEST ,
366
+ detail = "Can only list teams for dedicated regions"
367
+ )
368
+
369
+ # Get associated teams
370
+ teams = db .query (DBTeam ).join (DBTeamRegion ).filter (
371
+ DBTeamRegion .region_id == region_id
372
+ ).all ()
373
+
374
+ return teams
0 commit comments