added comp_systems and all databases

This commit is contained in:
2026-05-06 14:46:50 +03:00
parent 861741bde1
commit fad9fcefac
24 changed files with 1403 additions and 243 deletions

19
dockerfile_build/Dockerfile Executable file
View File

@@ -0,0 +1,19 @@
FROM ubuntu:22.04
# Install openbabel and python
RUN apt-get update \
&& apt-get install --yes --quiet --no-install-recommends \
python3 \
python3-pip \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /var/local
COPY requirements.txt .
# Install dependencies
RUN /usr/bin/python3 -m pip install --no-cache-dir -r requirements.txt
COPY src .
CMD uvicorn app:app --host 0.0.0.0 --port ${PORT} --log-level warning

View File

@@ -0,0 +1,10 @@
fastapi[all]==0.121.3
fastapi-cache2==0.2.2
redis==7.1.0
sqlalchemy==2.0.0
asyncpg==0.31.0
python-keycloak==7.1.1
pydantic==2.13.1

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter
from fastapi_cache.decorator import cache
router = APIRouter()
@router.get(
"/health",
responses={
200: {
"description": "Is the service running?",
"content": {"application/json": {"example": {"status": "healthy"}}},
},
},
)
@cache()
async def health_check():
return {"status": "healthy"}

View File

@@ -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"}

View File

@@ -0,0 +1,358 @@
from typing import List
from connections.db import get_db
from connections.keycloak import (
KeycloakAdminService,
get_current_token_payload,
get_keycloak_admin,
)
from crud.team_crud import (
add_team_member,
check_team_permission,
create_team,
delete_team,
delete_team_member,
get_user_teams,
update_team,
)
from fastapi import Depends
from fastapi.param_functions import Path
from fastapi.routing import APIRouter
from rest_models.team_models import (
TeamCreateRequest,
TeamCreateResponse,
TeamDeleteRequest,
TeamEditRequest,
TeamListResponse,
TeamMemberAddRequest,
TeamMemberDeleteRequest,
TeamMemberResponse,
TeamResponse,
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=TeamCreateResponse)
async def create_team_request(
create_data: TeamCreateRequest,
db: AsyncSession = Depends(get_db),
payload: dict = Depends(get_current_token_payload),
) -> TeamCreateResponse:
"""Update user profile (only profile_picture_path)"""
keycloak_id = payload.get("sub")
if not keycloak_id:
raise HTTPException(403, "permission denied")
team = await create_team(db, create_data.name, keycloak_id, create_data.description)
return TeamCreateResponse(team_id=team.id)
@router.put("", response_model=TeamEditRequest)
async def edit_team_request(
create_data: TeamEditRequest,
db: AsyncSession = Depends(get_db),
payload: dict = Depends(get_current_token_payload),
) -> TeamEditRequest:
"""Update user profile (only profile_picture_path)"""
keycloak_id = payload.get("sub")
if not keycloak_id:
raise HTTPException(403, "permission denied")
team = await update_team(
db, create_data.team_id, create_data.name, create_data.description
)
return TeamEditRequest(
team_id=team.id, name=team.name, description=team.description
)
@router.get("", response_model=TeamListResponse)
async def get_team_list_request(
page_num: int,
page_size: int = 6,
db: AsyncSession = Depends(get_db),
payload: dict = Depends(get_current_token_payload),
keycloak_admin: KeycloakAdminService = Depends(get_keycloak_admin),
) -> TeamListResponse:
"""Get all teams for the current user with user details from Keycloak"""
keycloak_id = payload.get("sub")
if not keycloak_id:
raise HTTPException(403, "permission denied")
# Calculate offset
offset = (page_num - 1) * page_size
[teams, total_count] = await get_user_teams(db, keycloak_id, offset, page_size)
if not total_count:
raise HTTPException(404, "Error getting list")
# Collect all unique user IDs from teams (creator + members)
user_ids = set()
for team in teams:
user_ids.add(team.creator_id)
for member in team.team_memberships:
user_ids.add(member.user_id)
# Batch fetch Keycloak user info
keycloak_users = {}
for uid in user_ids:
user_data = keycloak_admin.get_user_by_id(uid)
if user_data:
keycloak_users[uid] = user_data
result = []
for team in teams:
creator_response = None
# Build members response
members_response = []
for member in team.team_memberships:
member_kc_data = keycloak_users.get(member.user_id, {})
if member.user.keycloak_id == team.creator.keycloak_id:
# Build creator response
creator_response = TeamMemberResponse(
user=UserResponse(
keycloak_id=member.user.keycloak_id,
email=member_kc_data.get("email", ""),
username=member_kc_data.get("username", ""),
profile_picture_path=member.user.profile_picture_path,
created_at=member.user.created_at,
),
joined_at=member.joined_at,
permissions=[p.name for p in member.permissions],
)
members_response.append(
TeamMemberResponse(
user=UserResponse(
keycloak_id=member.user.keycloak_id,
email=member_kc_data.get("email", ""),
username=member_kc_data.get("username", ""),
profile_picture_path=member.user.profile_picture_path,
created_at=member.user.created_at,
),
joined_at=member.joined_at,
permissions=[p.name for p in member.permissions],
)
)
if not creator_response:
raise HTTPException(404, "Creator not found in members")
result.append(
TeamResponse(
id=team.id,
name=team.name,
description=team.description,
created_at=team.created_at,
creator=creator_response,
members=members_response,
)
)
return TeamListResponse(
teams=result,
cur_page=page_num,
total_teams=total_count,
page_size=page_size,
)
@router.get("/short_list", response_model=List[TeamsShortListResponse])
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")
if not keycloak_id:
raise HTTPException(403, "permission denied")
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)
@router.delete("")
async def delete_team_request(
delete_data: TeamDeleteRequest,
db: AsyncSession = Depends(get_db),
payload: dict = Depends(get_current_token_payload),
):
"""Update user profile (only profile_picture_path)"""
keycloak_id = payload.get("sub")
if not keycloak_id:
raise HTTPException(403, "permission denied")
team = await check_team_permission(
["delete_team"], delete_data.team_id, db, keycloak_id
)
deleted = await delete_team(db, team.id)
if not deleted:
raise HTTPException(404, "Team not found")
return None
@router.get("/{team_id}", response_model=TeamResponse)
async def get_team_request(
team_id: int = Path(...),
db: AsyncSession = Depends(get_db),
payload: dict = Depends(get_current_token_payload),
keycloak_admin: KeycloakAdminService = Depends(get_keycloak_admin),
) -> TeamResponse:
"""Get all teams for the current user with user details from Keycloak"""
keycloak_id = payload.get("sub")
if not keycloak_id:
raise HTTPException(403, "permission denied")
team = await check_team_permission([], team_id, db, keycloak_id)
if not team:
raise HTTPException(404, "Not found")
# Collect all unique user IDs from teams (creator + members)
user_ids = set()
user_ids.add(team.creator_id)
for member in team.team_memberships:
user_ids.add(member.user_id)
# Batch fetch Keycloak user info
keycloak_users = {}
for uid in user_ids:
user_data = keycloak_admin.get_user_by_id(uid)
if user_data:
keycloak_users[uid] = user_data
creator_response = None
# Build members response
members_response = []
for member in team.team_memberships:
member_kc_data = keycloak_users.get(member.user_id, {})
if member.user.keycloak_id == team.creator.keycloak_id:
# Build creator response
creator_response = TeamMemberResponse(
user=UserResponse(
keycloak_id=member.user.keycloak_id,
email=member_kc_data.get("email", ""),
username=member_kc_data.get("username", ""),
profile_picture_path=member.user.profile_picture_path,
created_at=member.user.created_at,
),
joined_at=member.joined_at,
permissions=[p.name for p in member.permissions],
)
members_response.append(
TeamMemberResponse(
user=UserResponse(
keycloak_id=member.user.keycloak_id,
email=member_kc_data.get("email", ""),
username=member_kc_data.get("username", ""),
profile_picture_path=member.user.profile_picture_path,
created_at=member.user.created_at,
),
joined_at=member.joined_at,
permissions=[p.name for p in member.permissions],
)
)
if not creator_response:
raise HTTPException(404, "Creator not found in members")
return TeamResponse(
id=team.id,
name=team.name,
description=team.description,
created_at=team.created_at,
creator=creator_response,
members=members_response,
)
@router.put("/user", response_model=TeamMemberResponse)
async def add_member_request(
user_data: TeamMemberAddRequest,
db: AsyncSession = Depends(get_db),
payload: dict = Depends(get_current_token_payload),
keycloak_admin: KeycloakAdminService = Depends(get_keycloak_admin),
) -> TeamMemberResponse:
"""Update user profile (only profile_picture_path)"""
keycloak_id = payload.get("sub")
if not keycloak_id:
raise HTTPException(403, "permission denied")
team = await check_team_permission(
["manage_members"], user_data.team_id, db, keycloak_id
)
if user_data.user_id == team.creator.keycloak_id:
raise HTTPException(404, "Cannot change the creator's permissions")
added = await add_team_member(db, team.id, user_data.user_id, user_data.permissions)
if not added:
raise HTTPException(404, "Error adding member to team")
user = keycloak_admin.get_user_by_id(added.user_id)
if not user:
raise HTTPException(404, "User not found")
return TeamMemberResponse(
user=UserResponse(
keycloak_id=added.user.keycloak_id,
email=user.get("email", ""),
username=user.get("username", ""),
profile_picture_path=added.user.profile_picture_path,
created_at=added.user.created_at,
),
joined_at=added.joined_at,
permissions=list(i.name for i in added.permissions),
)
@router.delete("/user")
async def delete_member_request(
user_data: TeamMemberDeleteRequest,
db: AsyncSession = Depends(get_db),
payload: dict = Depends(get_current_token_payload),
keycloak_admin: KeycloakAdminService = Depends(get_keycloak_admin),
):
"""Update user profile (only profile_picture_path)"""
keycloak_id = payload.get("sub")
if not keycloak_id:
raise HTTPException(403, "permission denied")
if keycloak_id == user_data.user_id:
# User wishes to delete themselves
team = await check_team_permission([], user_data.team_id, db, keycloak_id)
else:
# User wishes to remove another member
team = await check_team_permission(
["manage_members"], user_data.team_id, db, keycloak_id
)
if user_data.user_id == team.creator.keycloak_id:
raise HTTPException(404, "Cannot delete team creator")
deleted = await delete_team_member(db, team.id, user_data.user_id)
if not deleted:
raise HTTPException(404, "User not found")

View File

@@ -0,0 +1,97 @@
from connections.db import get_db
from connections.keycloak import (
get_current_token_payload,
get_current_user,
get_keycloak_admin,
)
from crud.user_crud import get_or_create_user, update_user_profile
from fastapi import Depends
from fastapi.exceptions import HTTPException
from fastapi.routing import APIRouter
from rest_models.user_models import (
UserResponse,
UserUpdateRequest,
)
from sql_models.models import User
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter()
# GET /me - Get current user (from JWT + local DB)
@router.get("", response_model=UserResponse)
async def get_me(
user: User = Depends(get_current_user),
payload: dict = Depends(get_current_token_payload),
) -> UserResponse:
"""Get current user profile"""
return UserResponse(
keycloak_id=user.keycloak_id,
email=payload.get("email", ""),
username=payload.get("preferred_username") or payload.get("username", ""),
profile_picture_path=user.profile_picture_path,
created_at=user.created_at,
)
# PUT /me - Update user (local DB only)
@router.put("", response_model=UserResponse)
async def update_me(
update_data: UserUpdateRequest,
db: AsyncSession = Depends(get_db),
payload: dict = Depends(get_current_token_payload),
) -> UserResponse:
"""Update user profile (only profile_picture_path)"""
keycloak_id = payload.get("sub")
if not keycloak_id:
raise HTTPException(403, "permission denied")
updated_user = await update_user_profile(
db, keycloak_id, update_data.profile_picture_path
)
return UserResponse(
keycloak_id=updated_user.keycloak_id,
email=payload.get("email", ""),
username=payload.get("preferred_username") or payload.get("username", ""),
profile_picture_path=updated_user.profile_picture_path,
created_at=updated_user.created_at,
)
@router.get("/{email}", response_model=UserResponse)
async def get_user_by_email(
email: str,
db: AsyncSession = Depends(get_db),
keycloak_admin=Depends(get_keycloak_admin),
current_user: User = Depends(get_current_user), # Require auth
) -> UserResponse:
"""
Get public user profile by email.
Requires authentication to prevent email enumeration.
"""
# Find user in Keycloak
keycloak_user = keycloak_admin.get_user_by_email(email)
if not keycloak_user:
raise HTTPException(404, f"User with email '{email}' not found")
keycloak_id = keycloak_user.get("id")
local_user = await get_or_create_user(db, keycloak_id=keycloak_id)
pfp = local_user.profile_picture_path
created_at = local_user.created_at
if not local_user:
raise HTTPException(404, "Error getting user data")
return UserResponse(
keycloak_id=keycloak_user.get("id"),
username=keycloak_user.get("username", ""),
email=keycloak_user.get("email", ""),
profile_picture_path=pfp, # Would need separate DB lookup
created_at=created_at,
)

54
dockerfile_build/src/app.py Executable file
View File

@@ -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"])

View File

@@ -0,0 +1,2 @@
# Use asyncpg for async PostgreSQL
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@db:5432/fastapi_db"

View File

@@ -0,0 +1,21 @@
# config.py
import os
from functools import lru_cache
from pydantic_settings import BaseSettings
class KeycloakSettings(BaseSettings):
keycloak_server_url: str = os.environ["KEYCLOAK_URL"]
keycloak_realm: str = os.environ["KEYCLOAK_REALM"]
keycloak_client_id: str = os.environ["KEYCLOAK_CLIENT_ID"]
keycloak_client_secret: str = os.environ["KEYCLOAK_CLIENT_SECRET"]
@property
def issuer(self) -> str:
return f"{self.keycloak_server_url}/realms/{self.keycloak_realm}"
@lru_cache
def get_keycloak_settings() -> KeycloakSettings:
return KeycloakSettings()

View File

@@ -0,0 +1,38 @@
import logging
import logging.config
import os
ROOT_LEVEL = os.environ.get("PROD", "INFO")
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": True,
"formatters": {
"standard": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"},
},
"handlers": {
"default": {
"formatter": "standard",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout", # Default is stderr
},
},
"loggers": {
"": { # root logger
"level": ROOT_LEVEL, # "INFO",
"handlers": ["default"],
"propagate": False,
},
"uvicorn.error": {
"level": "WARNING",
"handlers": ["default"],
},
"uvicorn.access": {
"level": "DEBUG",
"handlers": ["default"],
},
},
}
logging.config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger(__name__)

View File

@@ -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

View File

@@ -0,0 +1,41 @@
from config.database_config import DATABASE_URL
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import declarative_base
# Create async engine
engine = create_async_engine(
DATABASE_URL,
echo=True, # Set to False in production
pool_size=5,
max_overflow=10,
)
# Create async session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
Base = declarative_base()
# Create tables (async version)
async def create_tables():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Dependency to get database session
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

View File

@@ -0,0 +1,196 @@
# services/keycloak.py
from typing import Dict, List, Optional
from config.keycloak_config import KeycloakSettings, get_keycloak_settings
from connections.db import get_db
from crud.user_crud import get_or_create_user
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer
from fastapi.security.http import HTTPAuthorizationCredentials
from keycloak.exceptions import KeycloakAuthenticationError, KeycloakGetError
from rest_models.user_models import UserResponse
from sqlalchemy.ext.asyncio import AsyncSession
from keycloak import KeycloakAdmin, KeycloakOpenID
class KeycloakOpenIDService:
"""OpenID Connect operations - token validation, userinfo"""
def __init__(self, settings: KeycloakSettings):
self.keycloak_openid = KeycloakOpenID(
server_url=settings.keycloak_server_url,
client_id=settings.keycloak_client_id,
realm_name=settings.keycloak_realm,
client_secret_key=settings.keycloak_client_secret,
verify=True,
)
self.settings = settings
def get_well_known(self) -> dict:
"""Get OpenID configuration"""
return self.keycloak_openid.well_known()
def decode_token(self, token: str) -> dict:
"""
Decode and validate JWT token using Keycloak's public keys.
This does NOT call Keycloak - it validates locally using cached keys.
"""
try:
# Get public keys and decode
payload = self.keycloak_openid.decode_token(token)
return payload
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {str(e)}",
)
def get_userinfo(self, token: str) -> dict | bytes:
"""Get user info from Keycloak using the access token"""
try:
return self.keycloak_openid.userinfo(token)
except KeycloakAuthenticationError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Failed to get user info: {str(e)}",
)
def introspect_token(self, token: str) -> dict:
"""Introspect token (calls Keycloak) - use for revocation checks"""
try:
return self.keycloak_openid.introspect(token)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Token introspection failed: {str(e)}",
)
class KeycloakAdminService:
"""Admin operations - user lookup by email, etc."""
def __init__(self, settings: KeycloakSettings):
self.settings = settings
self._admin: Optional[KeycloakAdmin] = None
def _get_admin(self) -> KeycloakAdmin:
"""Get or create admin client connection"""
if self._admin is None:
self._admin = KeycloakAdmin(
server_url=self.settings.keycloak_server_url,
realm_name=self.settings.keycloak_realm,
client_id=self.settings.keycloak_client_id,
client_secret_key=self.settings.keycloak_client_secret,
)
return self._admin
def get_user_by_email(self, email: str) -> Optional[Dict]:
"""
Find user by exact email match.
Uses the Keycloak Admin API.
"""
admin = self._get_admin()
try:
# Query users with email filter
# The get_users method accepts query parameters as a dict
users = admin.get_users(query={"email": email, "exact": True})
return users[0] if users else None
except KeycloakGetError:
return None
def get_user_by_id(self, user_id: str) -> Optional[Dict]:
"""
Find user by Keycloak ID.
Uses the Keycloak Admin API.
"""
admin = self._get_admin()
try:
# KeycloakAdmin has a specific method for getting user by ID
# The get_users with query filter may not work for 'sub'
user = admin.get_user(user_id)
return user
except KeycloakGetError:
return None
def get_users_batch(self, user_ids: List[str]) -> List[Dict]:
"""
Get multiple users by their IDs.
Useful for batch loading user info.
"""
admin = self._get_admin()
users = []
for user_id in user_ids:
try:
user = admin.get_user(user_id)
if user:
users.append(user)
except KeycloakGetError:
continue
return users
security = HTTPBearer()
# Singleton service instances
_keycloak_openid: Optional[KeycloakOpenIDService] = None
_keycloak_admin: Optional[KeycloakAdminService] = None
def get_keycloak_openid(
settings: KeycloakSettings = Depends(get_keycloak_settings),
) -> KeycloakOpenIDService:
"""Dependency for OpenID service"""
global _keycloak_openid
if _keycloak_openid is None:
_keycloak_openid = KeycloakOpenIDService(settings)
return _keycloak_openid
def get_keycloak_admin(
settings: KeycloakSettings = Depends(get_keycloak_settings),
) -> KeycloakAdminService:
"""Dependency for Admin service"""
global _keycloak_admin
if _keycloak_admin is None:
_keycloak_admin = KeycloakAdminService(settings)
return _keycloak_admin
async def get_current_token_payload(
credentials: HTTPAuthorizationCredentials = Depends(security),
keycloak_openid: KeycloakOpenIDService = Depends(get_keycloak_openid),
) -> Dict:
"""
Extract and validate JWT token.
Returns decoded token payload.
"""
token = credentials.credentials
return keycloak_openid.decode_token(token)
async def get_current_user(
db: AsyncSession = Depends(get_db),
payload: Dict = Depends(get_current_token_payload),
) -> UserResponse:
"""
Get current user from database (creates if doesn't exist).
This is the main dependency for authenticated endpoints.
"""
keycloak_id = payload.get("sub")
if not keycloak_id:
raise HTTPException(401, "Invalid token: missing sub claim")
email = payload.get("email")
username = payload.get("preferred_username") or payload.get("username")
if not email or not username:
raise Exception("Error, user found but no email / username")
user = await get_or_create_user(db, keycloak_id=keycloak_id)
return UserResponse(
keycloak_id=user.keycloak_id,
email=email,
username=username,
created_at=user.created_at,
profile_picture_path=user.profile_picture_path,
)

View File

@@ -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

View File

@@ -0,0 +1,297 @@
from typing import List, Optional
from sql_models.models import Permission, Team, TeamMember
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.sql.expression import func
from starlette.exceptions import HTTPException
async def create_team(
db: AsyncSession,
name: str,
creator_id: str,
description: Optional[str] = None,
) -> Team:
team = Team(
name=name,
description=description,
creator_id=creator_id,
)
db.add(team)
await db.flush() # Get team.id
# Get all available permissions
all_permissions_result = await db.execute(select(Permission))
all_permissions = all_permissions_result.scalars().all()
# Add creator as a team member with all permissions
team_member = TeamMember(
team_id=team.id,
user_id=creator_id,
permissions=all_permissions,
)
db.add(team_member)
await db.commit()
await db.refresh(team, attribute_names=["team_memberships", "creator"])
return team
async def update_team(
db: AsyncSession,
team_id: int,
name: str,
description: Optional[str] = None,
) -> Team:
# Get the existing team
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")
# Update fields
team.name = name
team.description = description
# Add updated_at if you have such a field
# team.updated_at = datetime.utcnow()
db.add(team)
await db.commit()
await db.refresh(team, attribute_names=["team_memberships", "creator"])
return team
async def get_team(db: AsyncSession, team_id: int) -> Optional[Team]:
"""Get team by ID with memberships loaded"""
result = await db.execute(
select(Team)
.where(Team.id == team_id)
.options(
selectinload(Team.team_memberships).selectinload(TeamMember.permissions)
)
)
return result.scalar_one_or_none()
async def get_user_teams(
db: AsyncSession,
user_id: str,
offset: int,
limit: int,
permission: str | None = None,
) -> tuple[List[Team], int | None]:
# 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(
query.options(
selectinload(Team.team_memberships).selectinload(TeamMember.permissions)
)
.order_by(Team.created_at.desc())
.offset(offset)
.limit(limit)
)
teams = list(result.scalars().all())
# Get total count of teams for this user
count_result = await db.execute(count_query)
total_count = count_result.scalar()
return teams, total_count
async def add_team_member(
db: AsyncSession,
team_id: int,
user_id: str,
permissions: List[str] = [],
) -> TeamMember:
"""Add or replace a team member with specified permissions (PUT semantics)"""
# Check if team member already exists
result = await db.execute(
select(TeamMember).where(
TeamMember.team_id == team_id, TeamMember.user_id == user_id
)
)
team_member = result.scalar_one_or_none()
# Get permission objects
permission_objs = []
if permissions:
result = await db.execute(
select(Permission).where(Permission.name.in_(permissions))
)
permission_objs = result.scalars().all()
if team_member:
# Replace existing: update permissions
team_member.permissions = list(permission_objs)
# If you have other fields to update, add them here
else:
# Create new team member
team_member = TeamMember(
team_id=team_id,
user_id=user_id,
permissions=permission_objs,
)
db.add(team_member)
await db.commit()
await db.refresh(team_member)
return team_member
async def get_team_members(db: AsyncSession, team_id: int) -> List[TeamMember]:
"""Get all members of a team with their permissions"""
result = await db.execute(
select(TeamMember)
.where(TeamMember.team_id == team_id)
.options(selectinload(TeamMember.permissions))
)
return list(result.scalars().all())
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(
TeamMember.team_id == team_id, TeamMember.user_id == user_id
)
)
team_member = result.scalar_one_or_none()
if team_member:
await db.delete(team_member)
await db.commit()
return True
return False
async def update_team_member_permissions(
db: AsyncSession,
team_id: int,
user_id: str,
permissions: List[str],
) -> None:
"""Update a member's permissions"""
result = await db.execute(
select(TeamMember)
.where(TeamMember.team_id == team_id, TeamMember.user_id == user_id)
.options(selectinload(TeamMember.permissions))
)
team_member = result.scalar_one_or_none()
if team_member:
# Get new permission objects
perm_result = await db.execute(
select(Permission).where(Permission.name.in_(permissions))
)
new_permissions = perm_result.scalars().all()
# Update permissions
team_member.permissions = list(new_permissions)
await db.commit()
async def delete_team(
db: AsyncSession,
team_id: int,
) -> bool:
"""
Delete a team by ID.
Returns True if deleted, False if team not found.
"""
# Get the team
result = await db.execute(select(Team).where(Team.id == team_id))
team = result.scalar_one_or_none()
if not team:
return False
# Delete the team (cascade will delete team_memberships automatically)
await db.delete(team)
await db.commit()
return True
async def check_team_permission(
required_permissions: List[str],
team_id: int,
db: AsyncSession,
current_user_id,
) -> Team:
"""
Check if current user has required permissions for a team.
Usage:
@router.delete("/teams/{team_id}")
async def delete_team(
team: Team = Depends(check_team_permission(["delete_team"]))
):
# Permission already checked
pass
"""
# Fetch team with members and permissions
result = await db.execute(
select(Team)
.where(Team.id == team_id)
.options(
selectinload(Team.team_memberships).selectinload(TeamMember.permissions)
)
)
team = result.scalar_one_or_none()
if not team:
raise HTTPException(status_code=404, detail="Team not found")
# Team creator has all permissions
if team.creator_id == current_user_id:
return team
# Find user's membership
user_membership = None
for member in team.team_memberships:
if member.user_id == current_user_id:
user_membership = member
break
if not user_membership:
raise HTTPException(status_code=403, detail="You are not a member of this team")
# Check required permissions
user_permissions = {p.name for p in user_membership.permissions}
for perm in required_permissions:
if perm not in user_permissions:
raise HTTPException(
status_code=403, detail=f"Missing required permission: {perm}"
)
return team

View File

@@ -0,0 +1,55 @@
from typing import Optional
from sql_models.models import User
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
async def get_or_create_user(
db: AsyncSession, keycloak_id: str, profile_picture_path: Optional[str] = None
) -> User:
stmt = (
insert(User)
.values(
keycloak_id=keycloak_id,
profile_picture_path=profile_picture_path,
is_deleted=False,
)
.on_conflict_do_nothing(index_elements=["keycloak_id"])
)
await db.execute(stmt)
await db.flush()
# Now fetch (exists after this point)
result = await db.execute(
select(User).where(User.keycloak_id == keycloak_id, User.is_deleted == False)
)
return result.scalar_one()
async def soft_delete_user(db: AsyncSession, keycloak_id: str) -> bool:
user = await get_or_create_user(db, keycloak_id)
user.is_deleted = True
await db.flush()
await db.refresh(user)
return user
async def update_user_profile(
db: AsyncSession, keycloak_id: str, profile_picture_path: Optional[str]
) -> User:
"""Update only local DB fields"""
user = await get_or_create_user(db, keycloak_id)
if profile_picture_path is not None:
user.profile_picture_path = profile_picture_path
await db.flush()
await db.refresh(user)
return user

View File

@@ -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

View File

@@ -0,0 +1,67 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel
from rest_models.user_models import UserResponse
class TeamCreateRequest(BaseModel):
name: str
description: Optional[str] = None
class TeamMemberAddRequest(BaseModel):
team_id: int
user_id: str
permissions: List[str] = []
class TeamMemberDeleteRequest(BaseModel):
team_id: int
user_id: str
class TeamDeleteRequest(BaseModel):
team_id: int
class TeamEditRequest(BaseModel):
team_id: int
name: str
description: Optional[str] = None
class TeamListRequest(BaseModel):
page_num: int
page_size: int = 6
class TeamCreateResponse(BaseModel):
team_id: int
class TeamsShortListResponse(BaseModel):
team_id: int
team_name: str
class TeamMemberResponse(BaseModel):
user: UserResponse
joined_at: datetime
permissions: List[str]
class TeamResponse(BaseModel):
id: int
name: str
description: Optional[str]
created_at: datetime
members: List[TeamMemberResponse] # List of member ids
creator: TeamMemberResponse
class TeamListResponse(BaseModel):
teams: List[TeamResponse]
cur_page: int
total_teams: int
page_size: int

View File

@@ -0,0 +1,26 @@
# schemas.py
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
# GET /me - Complete user profile (read-only from Keycloak)
class UserResponse(BaseModel):
# From Keycloak (read-only)
keycloak_id: str
email: EmailStr
username: str
# From your database (writeable)
profile_picture_path: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# PUT /me - What user can actually change
class UserUpdateRequest(BaseModel):
profile_picture_path: Optional[str] = None
# That's it! No first_name, last_name, email, username
# Those are managed ONLY in Keycloak

View File

@@ -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",
)