diff --git a/docker-compose.yaml b/docker-compose.yaml index 6c09aa9..3fc1a8e 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,7 @@ services: quantum-backend: build: - context: . + context: ./dockerfile_build environment: PORT: 1656 extra_hosts: diff --git a/Dockerfile b/dockerfile_build/Dockerfile similarity index 100% rename from Dockerfile rename to dockerfile_build/Dockerfile diff --git a/requirements.txt b/dockerfile_build/requirements.txt similarity index 100% rename from requirements.txt rename to dockerfile_build/requirements.txt diff --git a/src/api_endpoint/health_api.py b/dockerfile_build/src/api_endpoint/health_api.py similarity index 100% rename from src/api_endpoint/health_api.py rename to dockerfile_build/src/api_endpoint/health_api.py diff --git a/dockerfile_build/src/api_endpoint/machine_api.py b/dockerfile_build/src/api_endpoint/machine_api.py new file mode 100644 index 0000000..b055cff --- /dev/null +++ b/dockerfile_build/src/api_endpoint/machine_api.py @@ -0,0 +1,500 @@ +from connections.db import get_db +from connections.keycloak import ( + KeycloakAdminService, + get_current_token_payload, + get_keycloak_admin, +) +from crud.machine_crud import ( + create_or_get_computational_system, + delete_computational_system, + get_computational_system, + get_system_status, + get_team_computational_systems, + get_user_computational_systems, + give_system_to_team, + remove_system_from_team, + update_computational_system, +) +from crud.team_crud import check_team_permission, get_team +from crud.user_crud import get_or_create_user +from fastapi import Depends, Query +from fastapi.routing import APIRouter +from rest_models.machine_models import ( + ComputationalSystemCreateRequest, + ComputationalSystemCreateResponse, + ComputationalSystemData, + ComputationalSystemDeleteRequest, + ComputationalSystemEditRequest, + ComputationalSystemListResponse, + GiveSystemToTeamRequest, + RemoveSystemFromTeamRequest, + SystemStatusResponse, + SystemTeamData, + SystemWithTeamResponse, +) +from rest_models.team_models import TeamsShortListResponse +from rest_models.user_models import UserResponse +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.exceptions import HTTPException + +router = APIRouter() + + +@router.post("", response_model=ComputationalSystemCreateResponse) +async def create_or_get_computational_system_request( + create_data: ComputationalSystemCreateRequest, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), +) -> ComputationalSystemCreateResponse: + """Create a new computational system or get existing one by name""" + keycloak_id = payload.get("sub") + + if not keycloak_id: + raise HTTPException(403, "Permission denied") + + system = await create_or_get_computational_system( + db=db, + user_id=keycloak_id, + system_name=create_data.system_name, + max_qubits=create_data.max_qubits, + ) + + return ComputationalSystemCreateResponse(system_id=system.id) + + +@router.put("", response_model=ComputationalSystemEditRequest) +async def edit_computational_system_request( + edit_data: ComputationalSystemEditRequest, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), +) -> ComputationalSystemEditRequest: + """Update computational system details (owner only)""" + keycloak_id = payload.get("sub") + + if not keycloak_id: + raise HTTPException(403, "Permission denied") + + # Get system first + system = await get_computational_system(db, edit_data.system_id) + if not system: + raise HTTPException(404, "Computational system not found") + + # Check ownership + if system.user_id != keycloak_id: + raise HTTPException(403, "Only the system owner can update system details") + + updated_system = await update_computational_system( + db=db, + system_id=system.id, + system_name=edit_data.system_name, + max_qubits=edit_data.max_qubits, + ) + + return ComputationalSystemEditRequest( + system_id=updated_system.id, + system_name=updated_system.system_name, + max_qubits=updated_system.max_qubits, + ) + + +@router.get("", response_model=ComputationalSystemListResponse) +async def get_my_systems_request( + page_num: int = Query(1, ge=1), + page_size: int = Query(8, ge=1, le=100), + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), + keycloak_admin: KeycloakAdminService = Depends(get_keycloak_admin), +) -> ComputationalSystemListResponse: + """Get all computational systems owned by the current user""" + keycloak_id = payload.get("sub") + + if not keycloak_id: + raise HTTPException(403, "Permission denied") + + offset = (page_num - 1) * page_size + + systems, total_count = await get_user_computational_systems( + db=db, user_id=keycloak_id, offset=offset, limit=page_size + ) + + # Get current user's full data from Keycloak + current_user_data = keycloak_admin.get_user_by_id(keycloak_id) + db_user = await get_or_create_user(db, keycloak_id) + owner_response = UserResponse( + keycloak_id=keycloak_id, + email=current_user_data.get("email", "") if current_user_data else "", + username=current_user_data.get("username", "") if current_user_data else "", + profile_picture_path=db_user.profile_picture_path, + created_at=db_user.created_at, + ) + + result = [] + for system in systems: + # Build teams list for this system + teams_list = [] + for team_system in system.team_systems: + team = await get_team(db, team_system.team_id) + if team: + teams_list.append( + SystemTeamData( + team=TeamsShortListResponse( + team_id=team.id, + team_name=team.name, + ), + num_qubits=team_system.qubits_given, + created_at=team_system.created_at, + ) + ) + + result.append( + SystemWithTeamResponse( + system=ComputationalSystemData( + id=system.id, + system_name=system.system_name, + max_qubits=system.max_qubits, + status=system.status.name if system.status else None, + last_updated=system.last_updated, + owner=owner_response, + created_at=system.created_at, + ), + teams=teams_list, + ) + ) + + return ComputationalSystemListResponse( + systems=result, + cur_page=page_num, + total_systems=total_count, + page_size=page_size, + ) + + +@router.get("/team", response_model=ComputationalSystemListResponse) +async def get_team_systems_request( + team_id: int = Query(), + page_num: int = Query(1, ge=1), + page_size: int = Query(10, ge=1), + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), + keycloak_admin: KeycloakAdminService = Depends(get_keycloak_admin), +) -> ComputationalSystemListResponse: + """Get all computational systems shared with a team (requires team membership)""" + keycloak_id = payload.get("sub") + + if not keycloak_id: + raise HTTPException(403, "Permission denied") + + # Check if user has access to this team (any permission works) + await check_team_permission([], team_id, db, keycloak_id) + + offset = (page_num - 1) * page_size + + systems, total_count = await get_team_computational_systems( + db=db, team_id=team_id, offset=offset, limit=page_size + ) + + # Get team info + team = await get_team(db, team_id) + if not team: + raise HTTPException(404, "Team not found") + + team_short = TeamsShortListResponse( + team_id=team.id, + team_name=team.name, + ) + + # Get owner info for each system + owner_ids = {system.user_id for system in systems} + keycloak_users = {} + for uid in owner_ids: + user_data = keycloak_admin.get_user_by_id(uid) + if user_data: + keycloak_users[uid] = user_data + + result = [] + for system in systems: + owner_data = keycloak_users.get(system.user_id, {}) + db_user = await get_or_create_user(db, system.user_id) + owner_response = UserResponse( + keycloak_id=system.user_id, + email=owner_data.get("email", ""), + username=owner_data.get("username", ""), + profile_picture_path=db_user.profile_picture_path, + created_at=db_user.created_at, + ) + + # Find the qubits given to this specific team + qubits_given = 0 + created_at = 0 + for ts in system.team_systems: + if ts.team_id == team_id: + qubits_given = ts.qubits_given + created_at = ts.created_at + break + + result.append( + SystemWithTeamResponse( + system=ComputationalSystemData( + id=system.id, + system_name=system.system_name, + max_qubits=system.max_qubits, + status=system.status.name if system.status else None, + last_updated=system.last_updated, + owner=owner_response, + created_at=system.created_at, + ), + teams=[ + SystemTeamData( + team=team_short, num_qubits=qubits_given, created_at=created_at + ) + ], + ) + ) + + return ComputationalSystemListResponse( + systems=result, + cur_page=page_num, + total_systems=total_count, + page_size=page_size, + ) + + +@router.get("/system", response_model=SystemWithTeamResponse) +async def get_computational_system_request( + system_id: int = Query(), + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), + keycloak_admin: KeycloakAdminService = Depends(get_keycloak_admin), +) -> SystemWithTeamResponse: + """Get computational system by ID with all teams that have access (requires ownership or team access)""" + keycloak_id = payload.get("sub") + + if not keycloak_id: + raise HTTPException(403, "Permission denied") + + system = await get_computational_system(db, system_id) + if not system: + raise HTTPException(404, "Computational system not found") + + # Get owner info + owner_data = keycloak_admin.get_user_by_id(system.user_id) + if not owner_data: + raise HTTPException(404, "Owner data not found") + db_user = await get_or_create_user(db, system.user_id) + owner_response = UserResponse( + keycloak_id=system.user_id, + email=owner_data.get("email", ""), + username=owner_data.get("username", ""), + profile_picture_path=db_user.profile_picture_path, + created_at=db_user.created_at, + ) + + # Check if user owns the system + if system.user_id == keycloak_id: + # Owner has full access - build complete team list + teams_list = [] + for team_system in system.team_systems: + team = await get_team(db, team_system.team_id) + if team: + teams_list.append( + SystemTeamData( + team=TeamsShortListResponse( + team_id=team.id, + team_name=team.name, + ), + num_qubits=team_system.qubits_given, + created_at=team_system.created_at, + ) + ) + + return SystemWithTeamResponse( + system=ComputationalSystemData( + id=system.id, + system_name=system.system_name, + max_qubits=system.max_qubits, + status=system.status.name if system.status else None, + last_updated=system.last_updated, + owner=owner_response, + created_at=system.created_at, + ), + teams=teams_list, + ) + + # Check if user has access through any team + has_access = False + accessible_teams = [] + for team_system in system.team_systems: + try: + # Check if user is a member of this team + await check_team_permission([], team_system.team_id, db, keycloak_id) + has_access = True + team = await get_team(db, team_system.team_id) + if team: + accessible_teams.append( + SystemTeamData( + team=TeamsShortListResponse( + team_id=team.id, + team_name=team.name, + ), + num_qubits=team_system.qubits_given, + created_at=team_system.created_at, + ) + ) + except HTTPException: + continue + + if not has_access: + raise HTTPException(403, "You don't have access to this computational system") + + return SystemWithTeamResponse( + system=ComputationalSystemData( + id=system.id, + system_name=system.system_name, + max_qubits=system.max_qubits, + status=system.status.name if system.status else None, + last_updated=system.last_updated, + owner=owner_response, + created_at=system.created_at, + ), + teams=accessible_teams, + ) + + +@router.get("/status", response_model=SystemStatusResponse) +async def get_system_status_request( + system_id: int = Query(), + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), +) -> SystemStatusResponse: + """Get the status of a computational system (requires ownership or team access)""" + keycloak_id = payload.get("sub") + + if not keycloak_id: + raise HTTPException(403, "Permission denied") + + system = await get_computational_system(db, system_id) + if not system: + raise HTTPException(404, "Computational system not found") + + # Check ownership or team access + if system.user_id != keycloak_id: + has_access = False + for team_system in system.team_systems: + try: + await check_team_permission([], team_system.team_id, db, keycloak_id) + has_access = True + break + except HTTPException: + continue + if not has_access: + raise HTTPException( + 403, "You don't have access to this computational system" + ) + + status = await get_system_status(db, system.id) + + return SystemStatusResponse( + system_id=system.id, + status=status.name, + description=status.description, + last_updated=system.last_updated, + ) + + +@router.put("/team", response_model=GiveSystemToTeamRequest) +async def give_system_to_team_request( + give_data: GiveSystemToTeamRequest, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), +) -> GiveSystemToTeamRequest: + """Give a team access to a computational system (requires manage_machines permission in the team)""" + keycloak_id = payload.get("sub") + + if not keycloak_id: + raise HTTPException(403, "Permission denied") + + # Check if user has manage_machines permission in the team + await check_team_permission(["manage_machines"], give_data.team_id, db, keycloak_id) + + # Get system and verify ownership + system = await get_computational_system(db, give_data.system_id) + if not system: + raise HTTPException(404, "Computational system not found") + + if system.user_id != keycloak_id: + raise HTTPException(403, "Only the system owner can grant team access") + + team_system = await give_system_to_team( + db=db, + system_id=system.id, + team_id=give_data.team_id, + qubits_given=give_data.qubits_given, + ) + + return GiveSystemToTeamRequest( + system_id=team_system.system_id, + team_id=team_system.team_id, + qubits_given=team_system.qubits_given, + ) + + +@router.delete("/team") +async def remove_system_from_team_request( + remove_data: RemoveSystemFromTeamRequest, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), +): + """Remove a system's access from a team (requires manage_machines permission in the team)""" + keycloak_id = payload.get("sub") + + if not keycloak_id: + raise HTTPException(403, "Permission denied") + + # Check if user has manage_machines permission in the team + await check_team_permission( + ["manage_machines"], remove_data.team_id, db, keycloak_id + ) + + # Get system and verify ownership + system = await get_computational_system(db, remove_data.system_id) + if not system: + raise HTTPException(404, "Computational system not found") + + deleted = await remove_system_from_team( + db=db, + system_id=system.id, + team_id=remove_data.team_id, + ) + + if not deleted: + raise HTTPException(404, "Team access not found for this system") + + return {"message": "System access removed from team successfully"} + + +@router.delete("") +async def delete_computational_system_request( + delete_data: ComputationalSystemDeleteRequest, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), +): + """Delete a computational system (owner only)""" + keycloak_id = payload.get("sub") + + if not keycloak_id: + raise HTTPException(403, "Permission denied") + + system = await get_computational_system(db, delete_data.system_id) + if not system: + raise HTTPException(404, "Computational system not found") + + # Check ownership + if system.user_id != keycloak_id: + raise HTTPException(403, "Only the system owner can delete the system") + + deleted = await delete_computational_system(db, system.id) + + if not deleted: + raise HTTPException(404, "Computational system not found") + + return {"message": "Computational system deleted successfully"} diff --git a/src/api_endpoint/teams_api.py b/dockerfile_build/src/api_endpoint/teams_api.py similarity index 98% rename from src/api_endpoint/teams_api.py rename to dockerfile_build/src/api_endpoint/teams_api.py index 143d2d8..47bc396 100644 --- a/src/api_endpoint/teams_api.py +++ b/dockerfile_build/src/api_endpoint/teams_api.py @@ -1,4 +1,3 @@ -import math from typing import List from connections.db import get_db @@ -13,7 +12,6 @@ from crud.team_crud import ( create_team, delete_team, delete_team_member, - get_team, get_user_teams, update_team, ) @@ -175,6 +173,7 @@ async def get_team_list_request( async def get_team_short_list_request( db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), + permission: str | None = None, # Optional query parameter ) -> List[TeamsShortListResponse]: """Get all teams for the current user with user details from Keycloak""" keycloak_id = payload.get("sub") @@ -182,7 +181,7 @@ async def get_team_short_list_request( if not keycloak_id: raise HTTPException(403, "permission denied") - [teams, count] = await get_user_teams(db, keycloak_id, 0, 256) + teams, count = await get_user_teams(db, keycloak_id, 0, 256, permission) return list(TeamsShortListResponse(team_id=i.id, team_name=i.name) for i in teams) diff --git a/src/api_endpoint/user_api.py b/dockerfile_build/src/api_endpoint/user_api.py similarity index 100% rename from src/api_endpoint/user_api.py rename to dockerfile_build/src/api_endpoint/user_api.py diff --git a/dockerfile_build/src/app.py b/dockerfile_build/src/app.py new file mode 100755 index 0000000..c6e95d9 --- /dev/null +++ b/dockerfile_build/src/app.py @@ -0,0 +1,54 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from api_endpoint.health_api import router as health_router +from api_endpoint.machine_api import router as machine_router +from api_endpoint.teams_api import router as team_router +from api_endpoint.user_api import router as user_router +from config.seeding import seed_permissions, seed_system_statuses +from connections.db import create_tables, engine +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +# from fastapi.requests import Request +from fastapi_cache import FastAPICache +from fastapi_cache.backends.redis import RedisBackend +from redis import asyncio as aioredis +from sqlalchemy.ext.asyncio.session import AsyncSession + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[None]: + redis = aioredis.from_url("redis://redis:6379") + FastAPICache.init(RedisBackend(redis), prefix="fastapi-cache") + + await create_tables() + async with AsyncSession(engine) as session: + await seed_permissions(session) + await seed_system_statuses(session) + + yield + await engine.dispose() + + +app = FastAPI(lifespan=lifespan) + + +origins = [ + "http://localhost", + "http://localhost:8001", +] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(health_router, prefix="", tags=["Health"]) +app.include_router(user_router, prefix="/user", tags=["User"]) +app.include_router(team_router, prefix="/team", tags=["Team"]) +app.include_router(machine_router, prefix="/machine", tags=["Machine"]) diff --git a/src/config/database_config.py b/dockerfile_build/src/config/database_config.py similarity index 100% rename from src/config/database_config.py rename to dockerfile_build/src/config/database_config.py diff --git a/src/config/keycloak_config.py b/dockerfile_build/src/config/keycloak_config.py similarity index 100% rename from src/config/keycloak_config.py rename to dockerfile_build/src/config/keycloak_config.py diff --git a/src/config/logging_config.py b/dockerfile_build/src/config/logging_config.py similarity index 100% rename from src/config/logging_config.py rename to dockerfile_build/src/config/logging_config.py diff --git a/dockerfile_build/src/config/seeding.py b/dockerfile_build/src/config/seeding.py new file mode 100644 index 0000000..141e371 --- /dev/null +++ b/dockerfile_build/src/config/seeding.py @@ -0,0 +1,80 @@ +from config.logging_config import logger +from sql_models.models import Permission, SystemStatus +from sqlalchemy.ext.asyncio.session import AsyncSession +from sqlalchemy.sql import select + +DEFAULT_PERMISSIONS = [ + {"id": 1, "name": "edit_team", "description": "Can edit team name and description"}, + {"id": 2, "name": "delete_team", "description": "Can delete the team"}, + { + "id": 3, + "name": "manage_members", + "description": "Can add and remove team members", + }, + { + "id": 4, + "name": "manage_machines", + "description": "Can add and remove team machines", + }, + { + "id": 5, + "name": "create_experiment", + "description": "Can create and run experiments", + }, +] + +DEFAULT_STATUSES = [ + { + "name": "ONLINE", + "description": "System is fully operational and available for use", + }, + { + "name": "OFFLINE", + "description": "System is currently offline or undergoing maintenance", + }, + {"name": "BUSY", "description": "System is busy processing other tasks"}, +] + + +async def seed_permissions(db: AsyncSession) -> bool: + """Seed default permissions into the database.""" + try: + result = await db.execute(select(Permission).limit(1)) + if result.scalar_one_or_none(): + logger.info("Permissions already seeded, skipping...") + return False + + permissions = [ + Permission(name=perm["name"], description=perm["description"]) + for perm in DEFAULT_PERMISSIONS + ] + db.add_all(permissions) + await db.commit() + logger.info(f"Seeded {len(permissions)} permissions") + return True + except Exception as e: + logger.error(f"Error seeding permissions: {e}") + await db.rollback() + raise + + +async def seed_system_statuses(db: AsyncSession) -> bool: + """Seed default system statuses into the database.""" + try: + result = await db.execute(select(SystemStatus).limit(1)) + if result.scalar_one_or_none(): + logger.info("System statuses already seeded, skipping...") + return False + + statuses = [ + SystemStatus(name=status["name"], description=status["description"]) + for status in DEFAULT_STATUSES + ] + db.add_all(statuses) + await db.commit() + logger.info(f"Seeded {len(statuses)} system statuses") + return True + except Exception as e: + logger.error(f"Error seeding system statuses: {e}") + await db.rollback() + raise diff --git a/src/connections/db.py b/dockerfile_build/src/connections/db.py similarity index 100% rename from src/connections/db.py rename to dockerfile_build/src/connections/db.py diff --git a/src/connections/keycloak.py b/dockerfile_build/src/connections/keycloak.py similarity index 100% rename from src/connections/keycloak.py rename to dockerfile_build/src/connections/keycloak.py diff --git a/src/connections/rabbitmq.py b/dockerfile_build/src/connections/rabbitmq.py similarity index 100% rename from src/connections/rabbitmq.py rename to dockerfile_build/src/connections/rabbitmq.py diff --git a/dockerfile_build/src/crud/machine_crud.py b/dockerfile_build/src/crud/machine_crud.py new file mode 100644 index 0000000..dec6005 --- /dev/null +++ b/dockerfile_build/src/crud/machine_crud.py @@ -0,0 +1,345 @@ +from datetime import datetime +from typing import List, Optional + +from sql_models.models import ComputationalSystem, SystemStatus, Team, TeamSystem, User +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from sqlalchemy.sql.expression import func +from starlette.exceptions import HTTPException + + +# Helper function to get status by name +async def get_status_by_name(db: AsyncSession, status_name: str) -> SystemStatus: + """Get system status by name, raise exception if not found""" + result = await db.execute( + select(SystemStatus).where(SystemStatus.name == status_name) + ) + status = result.scalar_one_or_none() + + if not status: + raise HTTPException( + status_code=400, + detail=f"Invalid status name: {status_name}. Valid statuses: ONLINE, OFFLINE, BUSY", + ) + return status + + +async def create_or_get_computational_system( + db: AsyncSession, + user_id: str, + system_name: str, + max_qubits: int, +) -> ComputationalSystem: + """Create a new computational system or return existing one by name""" + + # Check if system with this name already exists + existing_result = await db.execute( + select(ComputationalSystem).where( + ComputationalSystem.system_name == system_name + ) + ) + existing = existing_result.scalar_one_or_none() + + if existing: + return existing + + # Get the status (will raise exception if invalid) + status = await get_status_by_name(db, "ONLINE") + + # Create new system + system = ComputationalSystem( + user_id=user_id, + system_name=system_name, + status_id=status.id, + max_qubits=max_qubits, + last_updated=datetime.now(), + ) + db.add(system) + await db.commit() + await db.refresh(system, attribute_names=["user", "status"]) + + return system + + +async def update_computational_system( + db: AsyncSession, + system_id: int, + system_name: Optional[str] = None, + max_qubits: Optional[int] = None, +) -> ComputationalSystem: + """Update a computational system (PUT semantics)""" + + result = await db.execute( + select(ComputationalSystem).where(ComputationalSystem.id == system_id) + ) + system = result.scalar_one_or_none() + + if not system: + raise HTTPException(status_code=404, detail="Computational system not found") + + if system_name is not None: + # Check if new name conflicts with existing system + name_check = await db.execute( + select(ComputationalSystem).where( + and_( + ComputationalSystem.system_name == system_name, + ComputationalSystem.id != system_id, + ) + ) + ) + if name_check.scalar_one_or_none(): + raise HTTPException( + status_code=409, + detail=f"System with name '{system_name}' already exists", + ) + system.system_name = system_name + + if max_qubits is not None: + system.max_qubits = max_qubits + + await db.commit() + await db.refresh(system, attribute_names=["user", "status"]) + + return system + + +async def get_computational_system( + db: AsyncSession, system_id: int +) -> Optional[ComputationalSystem]: + """Get computational system by ID with relationships loaded""" + + result = await db.execute( + select(ComputationalSystem) + .where(ComputationalSystem.id == system_id) + .options( + selectinload(ComputationalSystem.user), + selectinload(ComputationalSystem.status), + selectinload(ComputationalSystem.team_systems).selectinload( + TeamSystem.team + ), + ) + ) + return result.scalar_one_or_none() + + +async def get_user_computational_systems( + db: AsyncSession, + user_id: str, + offset: int = 0, + limit: int = 256, +) -> tuple[List[ComputationalSystem], int]: + """Get paginated computational systems owned by a user, optionally filtered by status""" + + # Build query + query = select(ComputationalSystem).where(ComputationalSystem.user_id == user_id) + + # Get paginated systems + result = await db.execute( + query.options( + selectinload(ComputationalSystem.status), + selectinload(ComputationalSystem.team_systems), + ) + .order_by(ComputationalSystem.last_updated.desc()) + .offset(offset) + .limit(limit) + ) + systems = list(result.scalars().all()) + + # Get total count + count_query = ( + select(func.count()) + .select_from(ComputationalSystem) + .where(ComputationalSystem.user_id == user_id) + ) + + count_result = await db.execute(count_query) + total_count = count_result.scalar() + + return systems, total_count if total_count else 0 + + +async def get_team_computational_systems( + db: AsyncSession, + team_id: int, + offset: int = 0, + limit: int = 256, +) -> tuple[List[ComputationalSystem], int]: + """Get computational systems shared with a team, optionally filtered by status""" + + # Build query + query = ( + select(ComputationalSystem) + .join(TeamSystem) + .where(TeamSystem.team_id == team_id) + ) + + # Get paginated systems + result = await db.execute( + query.options( + selectinload(ComputationalSystem.user), + selectinload(ComputationalSystem.status), + selectinload(ComputationalSystem.team_systems), + ) + .order_by(ComputationalSystem.last_updated.desc()) + .offset(offset) + .limit(limit) + ) + systems = list(result.scalars().all()) + + # Get total count + count_query = ( + select(func.count()) + .select_from(ComputationalSystem) + .join(TeamSystem) + .where(TeamSystem.team_id == team_id) + ) + + count_result = await db.execute(count_query) + total_count = count_result.scalar() + + return systems, total_count if total_count else 0 + + +async def give_system_to_team( + db: AsyncSession, + system_id: int, + team_id: int, + qubits_given: int, +) -> TeamSystem: + """Give a team access to a computational system (PUT semantics)""" + + # Verify system exists + system_result = await db.execute( + select(ComputationalSystem).where(ComputationalSystem.id == system_id) + ) + system = system_result.scalar_one_or_none() + if not system: + raise HTTPException(status_code=404, detail="Computational system not found") + + # Verify team exists + team_result = await db.execute(select(Team).where(Team.id == team_id)) + team = team_result.scalar_one_or_none() + if not team: + raise HTTPException(status_code=404, detail="Team not found") + + # Verify qubits_given doesn't exceed system's max_qubits + if qubits_given > system.max_qubits: + raise HTTPException( + status_code=400, + detail=f"Cannot give {qubits_given} qubits. System only has {system.max_qubits} qubits available.", + ) + + # Check if relationship already exists + existing_result = await db.execute( + select(TeamSystem).where( + and_(TeamSystem.system_id == system_id, TeamSystem.team_id == team_id) + ) + ) + team_system = existing_result.scalar_one_or_none() + + if team_system: + # Update existing relationship + team_system.qubits_given = qubits_given + else: + # Create new relationship + team_system = TeamSystem( + team_id=team_id, system_id=system_id, qubits_given=qubits_given + ) + db.add(team_system) + + await db.commit() + await db.refresh(team_system, attribute_names=["team", "system"]) + + return team_system + + +async def remove_system_from_team( + db: AsyncSession, + system_id: int, + team_id: int, +) -> bool: + """Remove a system's access from a team (DELETE)""" + + result = await db.execute( + select(TeamSystem).where( + and_(TeamSystem.system_id == system_id, TeamSystem.team_id == team_id) + ) + ) + team_system = result.scalar_one_or_none() + + if not team_system: + return False + + await db.delete(team_system) + await db.commit() + + return True + + +async def delete_computational_system( + db: AsyncSession, + system_id: int, +) -> bool: + """Delete a computational system""" + + result = await db.execute( + select(ComputationalSystem).where(ComputationalSystem.id == system_id) + ) + system = result.scalar_one_or_none() + + if not system: + return False + + # Cascade will delete TeamSystem entries automatically + await db.delete(system) + await db.commit() + + return True + + +async def get_system_status( + db: AsyncSession, + system_id: int, +) -> SystemStatus: + """Get the status of a computational system""" + + result = await db.execute( + select(ComputationalSystem) + .where(ComputationalSystem.id == system_id) + .options(selectinload(ComputationalSystem.status)) + ) + system = result.scalar_one_or_none() + + if not system: + raise HTTPException(status_code=404, detail="Computational system not found") + + return system.status + + +async def update_system_status( + db: AsyncSession, + system_id: int, + status_name: str, +) -> ComputationalSystem: + + # Get the system + result = await db.execute( + select(ComputationalSystem).where(ComputationalSystem.id == system_id) + ) + system = result.scalar_one_or_none() + + if not system: + raise HTTPException(status_code=404, detail="Computational system not found") + + # Get the status (will raise exception if invalid) + status = await get_status_by_name(db, status_name) + + # Update system + system.status_id = status.id + system.last_updated = datetime.now() + + await db.commit() + await db.refresh(system, attribute_names=["user", "status"]) + + return system diff --git a/src/crud/team_crud.py b/dockerfile_build/src/crud/team_crud.py similarity index 90% rename from src/crud/team_crud.py rename to dockerfile_build/src/crud/team_crud.py index fb5623f..aeb3851 100644 --- a/src/crud/team_crud.py +++ b/dockerfile_build/src/crud/team_crud.py @@ -81,16 +81,36 @@ async def get_team(db: AsyncSession, team_id: int) -> Optional[Team]: async def get_user_teams( - db: AsyncSession, user_id: str, offset: int, limit: int + db: AsyncSession, + user_id: str, + offset: int, + limit: int, + permission: str | None = None, ) -> tuple[List[Team], int | None]: - """Get paginated teams a user belongs to and return total count""" + + # Base query for teams + query = select(Team).join(TeamMember).where(TeamMember.user_id == user_id) + + count_query = ( + select(func.count()) + .select_from(Team) + .join(TeamMember) + .where(TeamMember.user_id == user_id) + ) + + # Add permission filter if provided + if permission: + # Join with permissions table to filter by specific permission + query = query.join(TeamMember.permissions).where(Permission.name == permission) + count_query = count_query.join(TeamMember.permissions).where( + Permission.name == permission + ) # Get paginated teams result = await db.execute( - select(Team) - .join(TeamMember) - .where(TeamMember.user_id == user_id) - .options(selectinload(Team.team_memberships)) + query.options( + selectinload(Team.team_memberships).selectinload(TeamMember.permissions) + ) .order_by(Team.created_at.desc()) .offset(offset) .limit(limit) @@ -98,12 +118,7 @@ async def get_user_teams( teams = list(result.scalars().all()) # Get total count of teams for this user - count_result = await db.execute( - select(func.count()) - .select_from(Team) - .join(TeamMember) - .where(TeamMember.user_id == user_id) - ) + count_result = await db.execute(count_query) total_count = count_result.scalar() return teams, total_count @@ -162,7 +177,7 @@ async def get_team_members(db: AsyncSession, team_id: int) -> List[TeamMember]: return list(result.scalars().all()) -async def delete_team_member(db: AsyncSession, team_id: int, user_id: str) -> None: +async def delete_team_member(db: AsyncSession, team_id: int, user_id: str) -> bool: """Remove a user from a team""" result = await db.execute( select(TeamMember).where( @@ -174,8 +189,8 @@ async def delete_team_member(db: AsyncSession, team_id: int, user_id: str) -> No if team_member: await db.delete(team_member) await db.commit() - return 1 - return 0 + return True + return False async def update_team_member_permissions( diff --git a/src/crud/user_crud.py b/dockerfile_build/src/crud/user_crud.py similarity index 100% rename from src/crud/user_crud.py rename to dockerfile_build/src/crud/user_crud.py diff --git a/dockerfile_build/src/rest_models/machine_models.py b/dockerfile_build/src/rest_models/machine_models.py new file mode 100644 index 0000000..f3965f0 --- /dev/null +++ b/dockerfile_build/src/rest_models/machine_models.py @@ -0,0 +1,74 @@ +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel +from rest_models.team_models import ( + TeamsShortListResponse, +) +from rest_models.user_models import UserResponse + + +class ComputationalSystemCreateRequest(BaseModel): + system_name: str + max_qubits: int + status_name: Optional[str] = "ONLINE" + + +class ComputationalSystemCreateResponse(BaseModel): + system_id: int + + +class ComputationalSystemEditRequest(BaseModel): + system_id: int + system_name: Optional[str] = None + max_qubits: Optional[int] = None + + +class ComputationalSystemData(BaseModel): + id: int + system_name: str + max_qubits: int + status: Optional[str] = None + last_updated: datetime + owner: UserResponse + created_at: datetime + + +class SystemTeamData(BaseModel): + team: TeamsShortListResponse + num_qubits: int + created_at: datetime + + +class SystemWithTeamResponse(BaseModel): + system: ComputationalSystemData + teams: List[SystemTeamData] + + +class ComputationalSystemListResponse(BaseModel): + systems: List[SystemWithTeamResponse] + cur_page: int + total_systems: int + page_size: int + + +class GiveSystemToTeamRequest(BaseModel): + system_id: int + team_id: int + qubits_given: int + + +class RemoveSystemFromTeamRequest(BaseModel): + system_id: int + team_id: int + + +class SystemStatusResponse(BaseModel): + system_id: int + status: str + description: Optional[str] = None + last_updated: datetime + + +class ComputationalSystemDeleteRequest(BaseModel): + system_id: int diff --git a/src/rest_models/team_models.py b/dockerfile_build/src/rest_models/team_models.py similarity index 100% rename from src/rest_models/team_models.py rename to dockerfile_build/src/rest_models/team_models.py diff --git a/src/rest_models/user_models.py b/dockerfile_build/src/rest_models/user_models.py similarity index 100% rename from src/rest_models/user_models.py rename to dockerfile_build/src/rest_models/user_models.py diff --git a/dockerfile_build/src/sql_models/models.py b/dockerfile_build/src/sql_models/models.py new file mode 100644 index 0000000..b4a4376 --- /dev/null +++ b/dockerfile_build/src/sql_models/models.py @@ -0,0 +1,317 @@ +from datetime import datetime +from typing import List + +from connections.db import Base +from sqlalchemy import Boolean, DateTime, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm.properties import ForeignKey +from sqlalchemy.sql.schema import Column, Table +from typing_extensions import Optional + + +class Team(Base): + __tablename__ = "teams" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + + # 1:m relationship with creator + creator_id: Mapped[str] = mapped_column( + String(36), ForeignKey("users.keycloak_id"), nullable=False + ) + creator: Mapped["User"] = relationship( + "User", + foreign_keys=[creator_id], + back_populates="created_teams", + lazy="selectin", # Added + ) + + # Team memberships + team_memberships: Mapped[List["TeamMember"]] = relationship( + "TeamMember", + back_populates="team", + cascade="all, delete-orphan", + lazy="selectin", # Added + ) + + # Many-to-many with ComputationalSystem through TeamSystem + team_systems: Mapped[List["TeamSystem"]] = relationship( + "TeamSystem", + back_populates="team", + cascade="all, delete-orphan", + lazy="selectin", + ) + + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + + +class Permission(Base): + __tablename__ = "permissions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + description: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + + +team_member_permissions = Table( + "team_member_permissions", + Base.metadata, + Column("team_member_id", Integer, ForeignKey("team_members.id"), primary_key=True), + Column("permission_id", Integer, ForeignKey("permissions.id"), primary_key=True), + Column("granted_at", DateTime, default=datetime.now), +) + + +class TeamMember(Base): + __tablename__ = "team_members" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + team_id: Mapped[int] = mapped_column( + Integer, ForeignKey("teams.id"), nullable=False + ) + user_id: Mapped[str] = mapped_column( + String(36), ForeignKey("users.keycloak_id"), nullable=False + ) + + # Basic info without hardcoded permission levels + joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + + # Many-to-many with Permission + permissions: Mapped[List["Permission"]] = relationship( + "Permission", + secondary=team_member_permissions, + lazy="selectin", # Added - critical for many-to-many + ) + + # Relationships + team: Mapped["Team"] = relationship( + "Team", + back_populates="team_memberships", + lazy="selectin", # Added + ) + user: Mapped["User"] = relationship( + "User", + back_populates="team_memberships", + foreign_keys=[user_id], # Added foreign_keys to be explicit + lazy="selectin", # Added + ) + + +class SystemStatus(Base): + __tablename__ = "system_statuses" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + description: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + + +class ComputationalSystem(Base): + __tablename__ = "computational_systems" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[str] = mapped_column( + String(36), ForeignKey("users.keycloak_id"), nullable=False + ) + system_name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + status_id: Mapped[int] = mapped_column( + Integer, ForeignKey("system_statuses.id"), nullable=False + ) + max_qubits: Mapped[int] = mapped_column(Integer, nullable=False) + last_updated: Mapped[datetime] = mapped_column( + DateTime, default=datetime.now, onupdate=datetime.now + ) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + + # Relationships + user: Mapped["User"] = relationship( + "User", + back_populates="computational_systems", + foreign_keys=[user_id], + lazy="selectin", + ) + status: Mapped["SystemStatus"] = relationship( + "SystemStatus", + foreign_keys=[status_id], + lazy="selectin", + ) + + team_systems: Mapped[List["TeamSystem"]] = relationship( + "TeamSystem", + back_populates="system", + cascade="all, delete-orphan", + lazy="selectin", + ) + + +# Junction table for Team - ComputationalSystem many-to-many relationship +class TeamSystem(Base): + """Association table linking teams with computational systems""" + + __tablename__ = "team_systems" + + team_id: Mapped[int] = mapped_column( + Integer, ForeignKey("teams.id"), primary_key=True + ) + system_id: Mapped[int] = mapped_column( + Integer, ForeignKey("computational_systems.id"), primary_key=True + ) + qubits_given: Mapped[int] = mapped_column(Integer, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + + # Relationships + team: Mapped["Team"] = relationship( + "Team", back_populates="team_systems", lazy="selectin" + ) + system: Mapped["ComputationalSystem"] = relationship( + "ComputationalSystem", back_populates="team_systems", lazy="selectin" + ) + + +class User(Base): + __tablename__ = "users" + + keycloak_id: Mapped[str] = mapped_column( + String(36), primary_key=True, index=True, nullable=False + ) + profile_picture_path: Mapped[str] = mapped_column(String(500), nullable=True) + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + + # Teams this user created + created_teams: Mapped[List["Team"]] = relationship( + "Team", + foreign_keys=[Team.creator_id], + back_populates="creator", + lazy="selectin", + ) + + # Team memberships + team_memberships: Mapped[List["TeamMember"]] = relationship( + "TeamMember", + back_populates="user", + foreign_keys=[TeamMember.user_id], + lazy="selectin", + ) + + computational_systems: Mapped[List["ComputationalSystem"]] = relationship( + "ComputationalSystem", + back_populates="user", + foreign_keys=[ComputationalSystem.user_id], + lazy="selectin", + ) + + +# Add these new classes before your existing User class + + +class ExperimentType(Base): + __tablename__ = "experiment_types" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True) + description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + file_frontend: Mapped[str] = mapped_column( + String(255), nullable=False + ) # Path to frontend file + server_path: Mapped[str] = mapped_column(String(255), nullable=False) # Server path + file_comp_system: Mapped[str] = mapped_column( + String(255), nullable=False + ) # Path to computational system file + + +class Experiment(Base): + __tablename__ = "experiments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + team_id: Mapped[int] = mapped_column( + Integer, ForeignKey("teams.id"), nullable=False + ) + experiment_type_id: Mapped[int] = mapped_column( + Integer, ForeignKey("experiment_types.id"), nullable=False + ) + name: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + + # Relationships + team: Mapped["Team"] = relationship("Team", foreign_keys=[team_id], lazy="selectin") + experiment_type: Mapped["ExperimentType"] = relationship( + "ExperimentType", + foreign_keys=[experiment_type_id], + lazy="selectin", + ) + instances: Mapped[List["Instance"]] = relationship( + "Instance", + back_populates="experiment", + cascade="all, delete-orphan", + lazy="selectin", + ) + + +class SimulationStatus(Base): + __tablename__ = "simulation_statuses" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + description: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + + +class SimulationResult(Base): + __tablename__ = "simulation_results" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + comp_system_id: Mapped[int] = mapped_column( + Integer, ForeignKey("computational_systems.id"), nullable=False + ) + simulation_result_id: Mapped[int] = mapped_column( + Integer, nullable=False + ) # FK to another microservice + status_id: Mapped[int] = mapped_column( + Integer, ForeignKey("simulation_statuses.id"), nullable=False + ) + started_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, default=datetime.now + ) + ended_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Relationships + computational_system: Mapped["ComputationalSystem"] = relationship( + "ComputationalSystem", foreign_keys=[comp_system_id], lazy="selectin" + ) + status: Mapped["SimulationStatus"] = relationship( + "SimulationStatus", + foreign_keys=[status_id], + lazy="selectin", + ) + + +class Instance(Base): + __tablename__ = "instances" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + experiment_id: Mapped[int] = mapped_column( + Integer, ForeignKey("experiments.id"), nullable=False + ) + simulation_result_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("simulation_results.id"), nullable=True + ) + instance_data_id: Mapped[int] = mapped_column( + Integer, nullable=False + ) # FK to another microservice + + name: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True) + + # Relationships + experiment: Mapped["Experiment"] = relationship( + "Experiment", + back_populates="instances", + foreign_keys=[experiment_id], + lazy="selectin", + ) + simulation_result: Mapped[Optional["SimulationResult"]] = relationship( + "SimulationResult", + foreign_keys=[simulation_result_id], + lazy="selectin", + ) diff --git a/src/app.py b/src/app.py deleted file mode 100755 index 7846d53..0000000 --- a/src/app.py +++ /dev/null @@ -1,107 +0,0 @@ -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager - -from api_endpoint.health_api import router as health_router -from api_endpoint.teams_api import router as team_router -from api_endpoint.user_api import router as user_router -from config.logging_config import logger -from connections.db import create_tables, engine -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware - -# from fastapi.requests import Request -from fastapi_cache import FastAPICache -from fastapi_cache.backends.redis import RedisBackend -from redis import asyncio as aioredis -from sql_models.models import Permission -from sqlalchemy.ext.asyncio.session import AsyncSession -from sqlalchemy.sql import select - -# Define your permissions with IDs for consistency -DEFAULT_PERMISSIONS = [ - {"id": 1, "name": "edit_team", "description": "Can edit team name and description"}, - {"id": 2, "name": "delete_team", "description": "Can delete the team"}, - { - "id": 3, - "name": "manage_members", - "description": "Can add and remove team members", - }, - { - "id": 4, - "name": "manage_machines", - "description": "Can add and remove team machines", - }, - { - "id": 5, - "name": "create_experiment", - "description": "Can create and run experiments", - }, -] - - -async def seed_permissions(db: AsyncSession) -> bool: - """ - Seed default permissions into the database. - Returns True if seeded, False if already existed. - """ - try: - # Check if permissions already exist - result = await db.execute(select(Permission).limit(1)) - existing = result.scalar_one_or_none() - - if existing: - logger.info("Permissions already seeded, skipping...") - return False - - # Create permissions - permissions = [ - Permission(name=perm["name"], description=perm["description"]) - for perm in DEFAULT_PERMISSIONS - ] - - db.add_all(permissions) - await db.commit() - - logger.info(f"Seeded {len(permissions)} permissions") - return True - - except Exception as e: - logger.error(f"Error seeding permissions: {e}") - await db.rollback() - raise - - -@asynccontextmanager -async def lifespan(_: FastAPI) -> AsyncIterator[None]: - redis = aioredis.from_url("redis://redis:6379") - FastAPICache.init(RedisBackend(redis), prefix="fastapi-cache") - - await create_tables() - async with AsyncSession(engine) as session: - await seed_permissions(session) - - yield - - await engine.dispose() - - -app = FastAPI(lifespan=lifespan) - - -origins = [ - "http://localhost", - "http://localhost:8001", -] - -app.add_middleware( - CORSMiddleware, - allow_origins=origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Include routers -app.include_router(health_router, prefix="", tags=["Health"]) -app.include_router(user_router, prefix="/user", tags=["User"]) -app.include_router(team_router, prefix="/team", tags=["Team"]) diff --git a/src/sql_models/models.py b/src/sql_models/models.py deleted file mode 100644 index 8428b7b..0000000 --- a/src/sql_models/models.py +++ /dev/null @@ -1,117 +0,0 @@ -from datetime import datetime -from typing import List - -from connections.db import Base -from sqlalchemy import Boolean, DateTime, Integer, String -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.orm.properties import ForeignKey -from sqlalchemy.sql.schema import Column, Table -from typing_extensions import Optional - - -class Team(Base): - __tablename__ = "teams" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - name: Mapped[str] = mapped_column(String(100), nullable=False) - description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) - - # 1:m relationship with creator - creator_id: Mapped[str] = mapped_column( - String(36), ForeignKey("users.keycloak_id"), nullable=False - ) - creator: Mapped["User"] = relationship( - "User", - foreign_keys=[creator_id], - back_populates="created_teams", - lazy="selectin", # Added - ) - - # Team memberships - team_memberships: Mapped[List["TeamMember"]] = relationship( - "TeamMember", - back_populates="team", - cascade="all, delete-orphan", - lazy="selectin", # Added - ) - - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) - - -class Permission(Base): - __tablename__ = "permissions" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) - description: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) - - -team_member_permissions = Table( - "team_member_permissions", - Base.metadata, - Column("team_member_id", Integer, ForeignKey("team_members.id"), primary_key=True), - Column("permission_id", Integer, ForeignKey("permissions.id"), primary_key=True), - Column("granted_at", DateTime, default=datetime.now), -) - - -class TeamMember(Base): - __tablename__ = "team_members" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - team_id: Mapped[int] = mapped_column( - Integer, ForeignKey("teams.id"), nullable=False - ) - user_id: Mapped[str] = mapped_column( - String(36), ForeignKey("users.keycloak_id"), nullable=False - ) - - # Basic info without hardcoded permission levels - joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) - - # Many-to-many with Permission - permissions: Mapped[List["Permission"]] = relationship( - "Permission", - secondary=team_member_permissions, - lazy="selectin", # Added - critical for many-to-many - ) - - # Relationships - team: Mapped["Team"] = relationship( - "Team", - back_populates="team_memberships", - lazy="selectin", # Added - ) - user: Mapped["User"] = relationship( - "User", - back_populates="team_memberships", - foreign_keys=[user_id], # Added foreign_keys to be explicit - lazy="selectin", # Added - ) - - -class User(Base): - __tablename__ = "users" - - keycloak_id: Mapped[str] = mapped_column( - String(36), primary_key=True, index=True, nullable=False - ) - profile_picture_path: Mapped[str] = mapped_column(String(500), nullable=True) - is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) - - # Teams this user created - created_teams: Mapped[List["Team"]] = relationship( - "Team", - foreign_keys=[Team.creator_id], - back_populates="creator", - lazy="selectin", # Added - ) - - # Team memberships - team_memberships: Mapped[List["TeamMember"]] = relationship( - "TeamMember", - back_populates="user", - foreign_keys=[TeamMember.user_id], - lazy="selectin", # Added - )