added team management and routing

This commit is contained in:
2026-04-30 11:12:05 +03:00
parent 54de86fac8
commit 861741bde1
15 changed files with 1041 additions and 145 deletions

View File

@@ -1,5 +1,5 @@
services:
qunatum-backend:
quantum-backend:
build:
context: .
environment:

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,359 @@
import math
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_team,
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),
) -> 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)
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,
)

View File

@@ -1,29 +1,74 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from connections.db import create_tables, engine, get_db
from connections.keycloak import (
get_current_token_payload,
get_current_user,
get_keycloak_admin,
)
from crud.usercrud import get_or_create_user, update_user_profile
from fastapi import Depends, FastAPI
from fastapi.exceptions import HTTPException
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 fastapi_cache.decorator import cache
from redis import asyncio as aioredis
from rest_models.user_models import (
PublicUserResponse,
UserResponse,
UserUpdateRequest,
)
from sql_models.models import User
from sqlalchemy.ext.asyncio import AsyncSession
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
@@ -32,6 +77,8 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
FastAPICache.init(RedisBackend(redis), prefix="fastapi-cache")
await create_tables()
async with AsyncSession(engine) as session:
await seed_permissions(session)
yield
@@ -54,95 +101,7 @@ app.add_middleware(
allow_headers=["*"],
)
@app.get(
"/health",
responses={
200: {
"description": "Is the service running?",
"content": {"application/json": {"example": {"status": "healthy"}}},
},
},
)
@cache()
async def health_check():
return {"status": "healthy"}
# GET /me - Get current user (from JWT + local DB)
@app.get("/user", 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)
@app.put("/user", 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,
)
@app.get("/user/{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, f"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,
)
# 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"])

View File

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

View File

@@ -4,11 +4,8 @@ from functools import lru_cache
from pydantic_settings import BaseSettings
# Use asyncpg for async PostgreSQL
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@db:5432/fastapi_db"
class Settings(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"]
@@ -20,5 +17,5 @@ class Settings(BaseSettings):
@lru_cache
def get_settings() -> Settings:
return Settings()
def get_keycloak_settings() -> KeycloakSettings:
return KeycloakSettings()

View File

@@ -1,4 +1,4 @@
from config import DATABASE_URL
from config.database_config import DATABASE_URL
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import declarative_base

View File

@@ -1,16 +1,14 @@
# services/keycloak.py
from typing import Dict, Optional
from typing import Dict, List, Optional
from config import Settings, get_settings
from config.keycloak_config import KeycloakSettings, get_keycloak_settings
from connections.db import get_db
from crud.usercrud import get_or_create_user
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 logging_config import logger
from rest_models.request_response_models import UserResponse
from sql_models.models import User
from rest_models.user_models import UserResponse
from sqlalchemy.ext.asyncio import AsyncSession
from keycloak import KeycloakAdmin, KeycloakOpenID
@@ -19,7 +17,7 @@ from keycloak import KeycloakAdmin, KeycloakOpenID
class KeycloakOpenIDService:
"""OpenID Connect operations - token validation, userinfo"""
def __init__(self, settings: Settings):
def __init__(self, settings: KeycloakSettings):
self.keycloak_openid = KeycloakOpenID(
server_url=settings.keycloak_server_url,
client_id=settings.keycloak_client_id,
@@ -43,7 +41,6 @@ class KeycloakOpenIDService:
payload = self.keycloak_openid.decode_token(token)
return payload
except Exception as e:
logger.error(self.keycloak_openid.public_key())
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {str(e)}",
@@ -73,7 +70,7 @@ class KeycloakOpenIDService:
class KeycloakAdminService:
"""Admin operations - user lookup by email, etc."""
def __init__(self, settings: Settings):
def __init__(self, settings: KeycloakSettings):
self.settings = settings
self._admin: Optional[KeycloakAdmin] = None
@@ -99,10 +96,39 @@ class KeycloakAdminService:
# 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 as e:
print(f"Keycloak admin error: {e}")
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()
@@ -112,7 +138,7 @@ _keycloak_admin: Optional[KeycloakAdminService] = None
def get_keycloak_openid(
settings: Settings = Depends(get_settings),
settings: KeycloakSettings = Depends(get_keycloak_settings),
) -> KeycloakOpenIDService:
"""Dependency for OpenID service"""
global _keycloak_openid
@@ -122,7 +148,7 @@ def get_keycloak_openid(
def get_keycloak_admin(
settings: Settings = Depends(get_settings),
settings: KeycloakSettings = Depends(get_keycloak_settings),
) -> KeycloakAdminService:
"""Dependency for Admin service"""
global _keycloak_admin

282
src/crud/team_crud.py Normal file
View File

@@ -0,0 +1,282 @@
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
) -> tuple[List[Team], int | None]:
"""Get paginated teams a user belongs to and return total count"""
# Get paginated teams
result = await db.execute(
select(Team)
.join(TeamMember)
.where(TeamMember.user_id == user_id)
.options(selectinload(Team.team_memberships))
.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(
select(func.count())
.select_from(Team)
.join(TeamMember)
.where(TeamMember.user_id == user_id)
)
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) -> None:
"""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 1
return 0
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,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

@@ -24,12 +24,3 @@ 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
# GET /user/{email} - Public profile
class PublicUserResponse(BaseModel):
keycloak_id: str
username: str
email: EmailStr
profile_picture_path: Optional[str] = None
# No email exposed, no internal IDs

View File

@@ -1,8 +1,93 @@
from datetime import datetime
from typing import List
from connections.db import Base
from sqlalchemy import Boolean, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
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):
@@ -11,9 +96,22 @@ class User(Base):
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
) # The only extra field
profile_picture_path: Mapped[str] = mapped_column(String(500), nullable=True)
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Timestamps for auditing
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
)