users.py
1 from fastapi import APIRouter 2 import traceback 3 import uuid 4 from unidecode import unidecode 5 from fastapi import Depends, HTTPException, Path, Request 6 import re 7 import logging 8 from datetime import timedelta 9 import secrets 10 from fastapi.responses import RedirectResponse 11 from restai import config 12 from restai.models.models import ( 13 ApiKeyCreate, 14 ApiKeyCreatedResponse, 15 ApiKeyResponse, 16 ApiKeyUpdate, 17 TOTPSetupResponse, 18 TOTPEnableRequest, 19 TOTPDisableRequest, 20 User, 21 UserCreate, 22 UserLogin, 23 UserUpdate, 24 UsersResponse, 25 LimitedUser, 26 ) 27 from restai.database import get_db_wrapper, DBWrapper 28 from restai.models.databasemodels import UserDatabase, ProjectDatabase, TeamDatabase, users_projects, teams_users, teams_admins 29 from restai.auth import ( 30 create_access_token, 31 get_current_username, 32 get_current_username_admin, 33 get_current_username_user, 34 ) 35 from ssl import CERT_REQUIRED, PROTOCOL_TLS 36 from ldap3 import Server, Connection, NONE, Tls 37 from ldap3.utils.conv import escape_filter_chars 38 from restai.utils.crypto import encrypt_api_key, hash_api_key, encrypt_totp_secret, decrypt_totp_secret, generate_recovery_codes, hash_recovery_code 39 40 router = APIRouter() 41 42 43 @router.post("/ldap") 44 async def ldap_auth(request: Request, form_data: UserLogin, db_wrapper: DBWrapper = Depends(get_db_wrapper)): 45 """Authenticate via LDAP and create session.""" 46 ENABLE_LDAP = config.ENABLE_LDAP 47 LDAP_SERVER_HOST = config.LDAP_SERVER_HOST 48 LDAP_SERVER_PORT = config.LDAP_SERVER_PORT 49 LDAP_ATTRIBUTE_FOR_MAIL = config.LDAP_ATTRIBUTE_FOR_MAIL 50 LDAP_ATTRIBUTE_FOR_USERNAME = config.LDAP_ATTRIBUTE_FOR_USERNAME 51 LDAP_SEARCH_BASE = config.LDAP_SEARCH_BASE 52 LDAP_SEARCH_FILTERS = config.LDAP_SEARCH_FILTERS 53 LDAP_APP_DN = config.LDAP_APP_DN 54 LDAP_APP_PASSWORD = config.LDAP_APP_PASSWORD 55 LDAP_USE_TLS = config.LDAP_USE_TLS 56 LDAP_CA_CERT_FILE = config.LDAP_CA_CERT_FILE 57 LDAP_CIPHERS = ( 58 config.LDAP_CIPHERS 59 if config.LDAP_CIPHERS 60 else "ALL" 61 ) 62 63 if not ENABLE_LDAP: 64 raise HTTPException(400, detail="LDAP authentication is not enabled") 65 66 try: 67 tls = Tls( 68 validate=CERT_REQUIRED, 69 version=PROTOCOL_TLS, 70 ca_certs_file=LDAP_CA_CERT_FILE, 71 ciphers=LDAP_CIPHERS, 72 ) 73 except Exception as e: 74 if isinstance(e, HTTPException): 75 raise e 76 logging.exception(e) 77 raise HTTPException(400, detail="LDAP authentication failed") 78 79 try: 80 server = Server( 81 host=LDAP_SERVER_HOST, 82 port=LDAP_SERVER_PORT, 83 get_info=NONE, 84 use_ssl=LDAP_USE_TLS, 85 tls=tls, 86 ) 87 connection_app = Connection( 88 server, 89 LDAP_APP_DN, 90 LDAP_APP_PASSWORD, 91 auto_bind="NONE", 92 authentication="SIMPLE", 93 ) 94 if not connection_app.bind(): 95 raise HTTPException(400, detail="Application account bind failed") 96 97 search_success = connection_app.search( 98 search_base=LDAP_SEARCH_BASE, 99 search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})", 100 attributes=[ 101 f"{LDAP_ATTRIBUTE_FOR_USERNAME}", 102 f"{LDAP_ATTRIBUTE_FOR_MAIL}", 103 "cn", 104 ], 105 ) 106 107 if not search_success: 108 raise HTTPException(400, detail="User not found in the LDAP server") 109 110 entry = connection_app.entries[0] 111 username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower() 112 mail = str(entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"]) 113 if not mail or mail == "" or mail == "[]": 114 raise HTTPException(400, f"User {form_data.user} does not have mail.") 115 cn = str(entry["cn"]) 116 user_dn = entry.entry_dn 117 118 if username == form_data.user.lower(): 119 connection_user = Connection( 120 server, 121 user_dn, 122 form_data.password, 123 auto_bind="NONE", 124 authentication="SIMPLE", 125 ) 126 if not connection_user.bind(): 127 raise HTTPException(400, f"Authentication failed for {form_data.user}") 128 129 130 user = db_wrapper.get_user_by_username(mail) 131 if user is None: 132 sso_restricted = db_wrapper.get_setting_value("sso_auto_restricted", "true").lower() in ("true", "1") 133 user = db_wrapper.create_user(mail, None, False, False, restricted=sso_restricted) 134 db_wrapper.db.commit() 135 sso_team_id = db_wrapper.get_setting_value("sso_auto_team_id", "") 136 if sso_team_id: 137 try: 138 team = db_wrapper.get_team_by_id(int(sso_team_id)) 139 if team: 140 db_wrapper.add_user_to_team(team, user) 141 except (ValueError, TypeError): 142 pass 143 144 new_token = create_access_token( 145 data={"username": user.username}, expires_delta=timedelta(minutes=1440) 146 ) 147 148 response = RedirectResponse("./admin") 149 response.set_cookie( 150 key="restai_token", value=new_token, samesite="strict", 151 expires=86400, httponly=True, 152 ) 153 154 return response 155 else: 156 raise HTTPException( 157 400, 158 f"User {form_data.user} does not match the record. Search result: {str(entry[f'{LDAP_ATTRIBUTE_FOR_USERNAME}'])}", 159 ) 160 except Exception as e: 161 if isinstance(e, HTTPException): 162 raise e 163 logging.exception(e) 164 raise HTTPException(400, detail="LDAP authentication failed") 165 166 167 @router.get("/users/{username}", response_model=User) 168 async def route_get_user_details( 169 username: str = Path(description="Username"), 170 _: User = Depends(get_current_username_user), 171 db_wrapper: DBWrapper = Depends(get_db_wrapper), 172 ): 173 """Get user details by username.""" 174 try: 175 user_db = db_wrapper.get_user_by_username(username) 176 user_model = User.model_validate(user_db) 177 return user_model 178 except Exception as e: 179 if isinstance(e, HTTPException): 180 raise e 181 logging.exception(e) 182 raise HTTPException(status_code=404, detail="User not found") 183 184 185 @router.post("/users/{username}/apikeys", response_model=ApiKeyCreatedResponse, status_code=201) 186 async def route_create_user_apikey( 187 username: str = Path(description="Username"), 188 body: ApiKeyCreate = ApiKeyCreate(), 189 _: User = Depends(get_current_username_user), 190 db_wrapper: DBWrapper = Depends(get_db_wrapper), 191 ): 192 """Create a new API key for a user.""" 193 try: 194 user = db_wrapper.get_user_by_username(username) 195 if user is None: 196 raise HTTPException(status_code=404, detail="User not found") 197 198 plaintext = uuid.uuid4().hex + secrets.token_urlsafe(32) 199 encrypted = encrypt_api_key(plaintext) 200 key_hash = hash_api_key(plaintext) 201 key_prefix = plaintext[:8] 202 203 allowed_projects_json = None 204 if body.allowed_projects is not None: 205 import json 206 # Validate the requesting user has access to all scoped projects 207 caller = db_wrapper.get_user_by_username(_.username) 208 for pid in body.allowed_projects: 209 if not _.is_admin: 210 has_access = any(p.id == pid for p in (caller.projects if caller else [])) 211 if not has_access and _.admin_teams: 212 project_db = db_wrapper.get_project_by_id(pid) 213 has_access = project_db and project_db.team_id and any( 214 t.id == project_db.team_id for t in _.admin_teams 215 ) 216 if not has_access: 217 raise HTTPException( 218 status_code=403, 219 detail=f"Cannot scope API key to project {pid}: access denied", 220 ) 221 allowed_projects_json = json.dumps(body.allowed_projects) 222 223 api_key_row = db_wrapper.create_api_key( 224 user_id=user.id, 225 encrypted_key=encrypted, 226 key_hash=key_hash, 227 key_prefix=key_prefix, 228 description=body.description, 229 allowed_projects=allowed_projects_json, 230 read_only=body.read_only, 231 ) 232 return ApiKeyCreatedResponse( 233 id=api_key_row.id, 234 api_key=plaintext, 235 key_prefix=key_prefix, 236 description=api_key_row.description, 237 created_at=api_key_row.created_at, 238 allowed_projects=body.allowed_projects, 239 read_only=body.read_only, 240 ) 241 except Exception as e: 242 if isinstance(e, HTTPException): 243 raise e 244 logging.exception(e) 245 raise HTTPException(status_code=500, detail="Internal server error") 246 247 248 @router.get("/users/{username}/apikeys", response_model=list[ApiKeyResponse]) 249 async def route_list_user_apikeys( 250 username: str = Path(description="Username"), 251 _: User = Depends(get_current_username_user), 252 db_wrapper: DBWrapper = Depends(get_db_wrapper), 253 ): 254 """List all API keys for a user.""" 255 try: 256 user = db_wrapper.get_user_by_username(username) 257 if user is None: 258 raise HTTPException(status_code=404, detail="User not found") 259 keys = db_wrapper.get_api_keys_for_user(user.id) 260 return [ApiKeyResponse.model_validate(k) for k in keys] 261 except Exception as e: 262 if isinstance(e, HTTPException): 263 raise e 264 logging.exception(e) 265 raise HTTPException(status_code=500, detail="Internal server error") 266 267 268 @router.patch("/users/{username}/apikeys/{key_id}", response_model=ApiKeyResponse) 269 async def route_update_user_apikey( 270 body: ApiKeyUpdate, 271 username: str = Path(description="Username"), 272 key_id: int = Path(description="API key ID"), 273 _: User = Depends(get_current_username_user), 274 db_wrapper: DBWrapper = Depends(get_db_wrapper), 275 ): 276 """Update an API key's description or monthly token quota. Setting 277 ``token_quota_monthly`` to ``0`` or ``null`` clears the cap 278 (unlimited). ``reset_usage=true`` zeros the current-month counter 279 and pushes ``quota_reset_at`` forward one calendar month.""" 280 from restai.models.databasemodels import ApiKeyDatabase 281 from restai.budget import _first_of_next_month 282 user = db_wrapper.get_user_by_username(username) 283 if user is None: 284 raise HTTPException(status_code=404, detail="User not found") 285 key = ( 286 db_wrapper.db.query(ApiKeyDatabase) 287 .filter(ApiKeyDatabase.id == key_id, ApiKeyDatabase.user_id == user.id) 288 .first() 289 ) 290 if key is None: 291 raise HTTPException(status_code=404, detail="API key not found") 292 293 if body.description is not None: 294 key.description = body.description 295 if body.token_quota_monthly is not None: 296 # Zero or explicit null → clear the cap (unlimited). 297 key.token_quota_monthly = body.token_quota_monthly or None 298 if body.reset_usage: 299 from datetime import datetime, timezone 300 key.tokens_used_this_month = 0 301 key.quota_reset_at = _first_of_next_month(datetime.now(timezone.utc)) 302 303 db_wrapper.db.commit() 304 db_wrapper.db.refresh(key) 305 return ApiKeyResponse.model_validate(key) 306 307 308 @router.delete("/users/{username}/apikeys/{key_id}") 309 async def route_delete_user_apikey( 310 username: str = Path(description="Username"), 311 key_id: int = Path(description="API key ID"), 312 _: User = Depends(get_current_username_user), 313 db_wrapper: DBWrapper = Depends(get_db_wrapper), 314 ): 315 """Delete an API key.""" 316 try: 317 user = db_wrapper.get_user_by_username(username) 318 if user is None: 319 raise HTTPException(status_code=404, detail="User not found") 320 deleted = db_wrapper.delete_api_key(key_id, user.id) 321 if not deleted: 322 raise HTTPException(status_code=404, detail="API key not found") 323 return {"deleted": key_id} 324 except Exception as e: 325 if isinstance(e, HTTPException): 326 raise e 327 logging.exception(e) 328 raise HTTPException(status_code=500, detail="Internal server error") 329 330 331 @router.get("/users", response_model=UsersResponse) 332 async def route_get_users( 333 user: User = Depends(get_current_username), 334 db_wrapper: DBWrapper = Depends(get_db_wrapper), 335 ): 336 """List all users. Admins see all, others see team members only.""" 337 # If user is admin, return all users 338 if user.is_admin: 339 users = db_wrapper.get_users() 340 users_final = [User.model_validate(user_obj) for user_obj in users] 341 # For regular users, only return users from their teams 342 else: 343 # Get the set of unique users from all teams the current user belongs to 344 team_users = set() 345 for team in user.teams: 346 # Add all users from this team to our set 347 for team_user in team.users: 348 team_users.add(team_user.id) 349 350 # Get all users and filter by the team user IDs 351 users = db_wrapper.get_users() 352 team_users_list = [] 353 for user_obj in users: 354 if user_obj.id in team_users: 355 team_users_list.append(user_obj) 356 357 # Convert to LimitedUser objects 358 users_final = [] 359 for user_obj in team_users_list: 360 user_model = User.model_validate(user_obj) 361 limited_user = LimitedUser( 362 id=user_model.id, 363 username=user_model.username 364 ) 365 users_final.append(limited_user) 366 367 return {"users": users_final} 368 369 370 @router.post("/users", status_code=201) 371 async def route_create_user( 372 user_create: UserCreate, 373 _: User = Depends(get_current_username_admin), 374 db_wrapper: DBWrapper = Depends(get_db_wrapper), 375 ): 376 """Create a new user (admin only).""" 377 try: 378 user_create.username = unidecode( 379 user_create.username.strip().lower().replace(" ", ".") 380 ) 381 user_create.username = re.sub(r"[^\w\-.@]+", "", user_create.username) 382 383 db_wrapper.create_user( 384 user_create.username, 385 user_create.password, 386 user_create.is_admin, 387 user_create.is_private, 388 user_create.is_restricted, 389 ) 390 return {"username": user_create.username} 391 except Exception as e: 392 logging.error(e) 393 traceback.print_tb(e.__traceback__) 394 raise HTTPException( 395 status_code=500, detail="Failed to create user " + user_create.username 396 ) 397 398 399 @router.patch("/users/{username}", response_model=User) 400 async def route_update_user( 401 username: str = Path(description="Username"), 402 user_update: UserUpdate = ..., 403 user: User = Depends(get_current_username_user), 404 db_wrapper: DBWrapper = Depends(get_db_wrapper), 405 ): 406 """Update user properties.""" 407 try: 408 if user.is_restricted and not user.is_admin: 409 raise HTTPException(status_code=403, detail="Restricted users cannot edit their profile") 410 411 user_to_update = db_wrapper.get_user_by_username(username) 412 if user_to_update is None: 413 raise HTTPException(status_code=404, detail="User not found") 414 415 if not user.is_admin and user_update.is_admin is True: 416 raise HTTPException(status_code=403, detail="Insuficient permissions") 417 418 if user_update.is_private is not None and user_update.is_private != user_to_update.is_private: 419 can_change = user.is_admin 420 if not can_change: 421 caller_db = db_wrapper.get_user_by_username(user.username) 422 for team in caller_db.admin_teams: 423 if user_to_update in team.users or user_to_update in team.admins: 424 can_change = True 425 break 426 if not can_change: 427 raise HTTPException(status_code=403, detail="Only platform admins or team admins can modify user privacy setting") 428 429 if not user.is_admin and user_update.is_restricted is not None: 430 raise HTTPException(status_code=403, detail="Only admins can modify restriction settings") 431 432 if not user.is_admin and user_update.projects is not None: 433 raise HTTPException(status_code=403, detail="Only admins can modify project assignments") 434 435 # If changing password and user has 2FA enabled, require TOTP code 436 if user_update.password and user_to_update.totp_enabled: 437 if not user_update.totp_code: 438 raise HTTPException(status_code=400, detail="TOTP code required to change password when 2FA is enabled") 439 import pyotp 440 secret = decrypt_totp_secret(user_to_update.totp_secret) 441 if not pyotp.TOTP(secret).verify(user_update.totp_code, valid_window=1): 442 raise HTTPException(status_code=400, detail="Invalid TOTP code") 443 444 db_wrapper.update_user(user_to_update, user_update) 445 446 if user_update.projects is not None: 447 user_to_update.projects = [] 448 449 for project in user_update.projects: 450 project_db = db_wrapper.get_project_by_name(project) 451 if project_db is not None: 452 user_to_update.projects.append(project_db) 453 db_wrapper.db.commit() 454 return User.model_validate(user_to_update) 455 except Exception as e: 456 if isinstance(e, HTTPException): 457 raise e 458 logging.exception(e) 459 raise HTTPException(status_code=500, detail="Internal server error") 460 461 462 @router.delete("/users/{username}") 463 async def route_delete_user( 464 username: str = Path(description="Username"), 465 _: User = Depends(get_current_username_admin), 466 db_wrapper: DBWrapper = Depends(get_db_wrapper), 467 ): 468 """Delete a user (admin only).""" 469 try: 470 user_link = db_wrapper.get_user_by_username(username) 471 if user_link is None: 472 raise HTTPException(status_code=404, detail="User not found") 473 db_wrapper.delete_user(user_link) 474 return {"deleted": username} 475 except Exception as e: 476 if isinstance(e, HTTPException): 477 raise e 478 logging.exception(e) 479 raise HTTPException(status_code=500, detail="Internal server error") 480 481 482 # --- TOTP 2FA Endpoints --- 483 484 @router.get("/users/{username}/totp/status") 485 async def totp_status( 486 username: str = Path(description="Username"), 487 user: User = Depends(get_current_username), 488 db_wrapper: DBWrapper = Depends(get_db_wrapper), 489 ): 490 """Check if 2FA is enabled for a user.""" 491 if not user.is_admin and user.username != username: 492 raise HTTPException(status_code=403, detail="Access denied") 493 user_db = db_wrapper.get_user_by_username(username) 494 if user_db is None: 495 raise HTTPException(status_code=404, detail="User not found") 496 return { 497 "enabled": bool(user_db.totp_enabled), 498 "enforced": db_wrapper.get_setting_value("enforce_2fa", "false").lower() in ("true", "1"), 499 } 500 501 502 @router.post("/users/{username}/totp/setup", response_model=TOTPSetupResponse) 503 async def totp_setup( 504 username: str = Path(description="Username"), 505 user: User = Depends(get_current_username), 506 db_wrapper: DBWrapper = Depends(get_db_wrapper), 507 ): 508 """Generate a new TOTP secret and recovery codes. Does NOT enable 2FA yet — call /enable with a valid code to activate.""" 509 import pyotp 510 import json 511 512 if not user.is_admin and user.username != username: 513 raise HTTPException(status_code=403, detail="Access denied") 514 user_db = db_wrapper.get_user_by_username(username) 515 if user_db is None: 516 raise HTTPException(status_code=404, detail="User not found") 517 518 # Generate secret and recovery codes 519 secret = pyotp.random_base32() 520 app_name = getattr(config, "RESTAI_NAME", "RESTai") or "RESTai" 521 provisioning_uri = pyotp.TOTP(secret).provisioning_uri(username, issuer_name=app_name) 522 recovery_codes = generate_recovery_codes() 523 524 # Store encrypted secret and hashed recovery codes (but don't enable yet) 525 user_db.totp_secret = encrypt_totp_secret(secret) 526 user_db.totp_recovery_codes = json.dumps([hash_recovery_code(c) for c in recovery_codes]) 527 db_wrapper.db.commit() 528 529 return TOTPSetupResponse( 530 secret=secret, 531 provisioning_uri=provisioning_uri, 532 recovery_codes=recovery_codes, 533 ) 534 535 536 @router.post("/users/{username}/totp/enable") 537 async def totp_enable( 538 body: TOTPEnableRequest, 539 username: str = Path(description="Username"), 540 user: User = Depends(get_current_username), 541 db_wrapper: DBWrapper = Depends(get_db_wrapper), 542 ): 543 """Activate 2FA by confirming a valid TOTP code and password.""" 544 import pyotp 545 from restai.database import verify_password 546 547 if not user.is_admin and user.username != username: 548 raise HTTPException(status_code=403, detail="Access denied") 549 user_db = db_wrapper.get_user_by_username(username) 550 if user_db is None: 551 raise HTTPException(status_code=404, detail="User not found") 552 if not user_db.totp_secret: 553 raise HTTPException(status_code=400, detail="Run /totp/setup first") 554 555 if not verify_password(body.password, user_db.hashed_password): 556 raise HTTPException(status_code=403, detail="Invalid password") 557 558 secret = decrypt_totp_secret(user_db.totp_secret) 559 totp = pyotp.TOTP(secret) 560 if not totp.verify(body.code, valid_window=1): 561 raise HTTPException(status_code=400, detail="Invalid TOTP code") 562 563 user_db.totp_enabled = True 564 db_wrapper.db.commit() 565 return {"message": "2FA enabled successfully."} 566 567 568 @router.post("/users/{username}/totp/disable") 569 async def totp_disable( 570 body: TOTPDisableRequest, 571 username: str = Path(description="Username"), 572 user: User = Depends(get_current_username), 573 db_wrapper: DBWrapper = Depends(get_db_wrapper), 574 ): 575 """Disable 2FA. Requires password confirmation. Blocked if admin enforces 2FA.""" 576 from restai.database import verify_password 577 578 if not user.is_admin and user.username != username: 579 raise HTTPException(status_code=403, detail="Access denied") 580 581 if db_wrapper.get_setting_value("enforce_2fa", "false").lower() in ("true", "1"): 582 raise HTTPException(status_code=403, detail="2FA is enforced by the administrator and cannot be disabled") 583 584 user_db = db_wrapper.get_user_by_username(username) 585 if user_db is None: 586 raise HTTPException(status_code=404, detail="User not found") 587 588 if not verify_password(body.password, user_db.hashed_password): 589 raise HTTPException(status_code=403, detail="Invalid password") 590 591 user_db.totp_enabled = False 592 user_db.totp_secret = None 593 user_db.totp_recovery_codes = None 594 db_wrapper.db.commit() 595 return {"message": "2FA disabled successfully."} 596 597 598 @router.get("/permissions/matrix", tags=["Admin"]) 599 async def get_permission_matrix( 600 user: User = Depends(get_current_username), 601 db_wrapper: DBWrapper = Depends(get_db_wrapper), 602 ): 603 """Return the users x projects permission matrix. 604 605 Admins see everything. Team leaders see only users and projects 606 belonging to their teams. Regular users get 403. 607 """ 608 admin_team_ids = {t.id for t in (user.admin_teams or [])} 609 610 if not user.is_admin and not admin_team_ids: 611 raise HTTPException(status_code=403, detail="Forbidden") 612 613 if user.is_admin: 614 all_users = ( 615 db_wrapper.db.query(UserDatabase) 616 .order_by(UserDatabase.username) 617 .all() 618 ) 619 all_projects = ( 620 db_wrapper.db.query(ProjectDatabase) 621 .outerjoin(TeamDatabase, ProjectDatabase.team_id == TeamDatabase.id) 622 .order_by(ProjectDatabase.name) 623 .all() 624 ) 625 rows = db_wrapper.db.query(users_projects).all() 626 else: 627 # Team leader: filter to their teams 628 all_projects = ( 629 db_wrapper.db.query(ProjectDatabase) 630 .outerjoin(TeamDatabase, ProjectDatabase.team_id == TeamDatabase.id) 631 .filter(ProjectDatabase.team_id.in_(admin_team_ids)) 632 .order_by(ProjectDatabase.name) 633 .all() 634 ) 635 project_ids = {p.id for p in all_projects} 636 637 # Users who belong to those teams (members + admins, single query via union) 638 from sqlalchemy import union 639 members_q = db_wrapper.db.query(teams_users.c.user_id).filter(teams_users.c.team_id.in_(admin_team_ids)) 640 admins_q = db_wrapper.db.query(teams_admins.c.user_id).filter(teams_admins.c.team_id.in_(admin_team_ids)) 641 team_user_ids = {r[0] for r in members_q.union(admins_q).all()} 642 643 all_users = ( 644 db_wrapper.db.query(UserDatabase) 645 .filter(UserDatabase.id.in_(team_user_ids)) 646 .order_by(UserDatabase.username) 647 .all() 648 ) 649 650 rows = ( 651 db_wrapper.db.query(users_projects) 652 .filter(users_projects.c.project_id.in_(project_ids)) 653 .filter(users_projects.c.user_id.in_(team_user_ids)) 654 .all() 655 ) 656 657 return { 658 "users": [ 659 { 660 "id": u.id, 661 "username": u.username, 662 "is_admin": bool(u.is_admin), 663 "is_restricted": bool(getattr(u, "is_restricted", False)), 664 } 665 for u in all_users 666 ], 667 "projects": [ 668 { 669 "id": p.id, 670 "name": p.name, 671 "team_id": p.team_id, 672 "team_name": p.team.name if p.team else None, 673 } 674 for p in all_projects 675 ], 676 "assignments": [ 677 {"user_id": row.user_id, "project_id": row.project_id} for row in rows 678 ], 679 }