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: services:
qunatum-backend: quantum-backend:
build: build:
context: . context: .
environment: 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 collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from connections.db import create_tables, engine, get_db from api_endpoint.health_api import router as health_router
from connections.keycloak import ( from api_endpoint.teams_api import router as team_router
get_current_token_payload, from api_endpoint.user_api import router as user_router
get_current_user, from config.logging_config import logger
get_keycloak_admin, from connections.db import create_tables, engine
) from fastapi import FastAPI
from crud.usercrud import get_or_create_user, update_user_profile
from fastapi import Depends, FastAPI
from fastapi.exceptions import HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
# from fastapi.requests import Request # from fastapi.requests import Request
from fastapi_cache import FastAPICache from fastapi_cache import FastAPICache
from fastapi_cache.backends.redis import RedisBackend from fastapi_cache.backends.redis import RedisBackend
from fastapi_cache.decorator import cache
from redis import asyncio as aioredis from redis import asyncio as aioredis
from rest_models.user_models import ( from sql_models.models import Permission
PublicUserResponse, from sqlalchemy.ext.asyncio.session import AsyncSession
UserResponse, from sqlalchemy.sql import select
UserUpdateRequest,
) # Define your permissions with IDs for consistency
from sql_models.models import User DEFAULT_PERMISSIONS = [
from sqlalchemy.ext.asyncio import AsyncSession {"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 @asynccontextmanager
@@ -32,6 +77,8 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
FastAPICache.init(RedisBackend(redis), prefix="fastapi-cache") FastAPICache.init(RedisBackend(redis), prefix="fastapi-cache")
await create_tables() await create_tables()
async with AsyncSession(engine) as session:
await seed_permissions(session)
yield yield
@@ -54,95 +101,7 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Include routers
@app.get( app.include_router(health_router, prefix="", tags=["Health"])
"/health", app.include_router(user_router, prefix="/user", tags=["User"])
responses={ app.include_router(team_router, prefix="/team", tags=["Team"])
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,
)

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 from pydantic_settings import BaseSettings
# Use asyncpg for async PostgreSQL
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@db:5432/fastapi_db"
class KeycloakSettings(BaseSettings):
class Settings(BaseSettings):
keycloak_server_url: str = os.environ["KEYCLOAK_URL"] keycloak_server_url: str = os.environ["KEYCLOAK_URL"]
keycloak_realm: str = os.environ["KEYCLOAK_REALM"] keycloak_realm: str = os.environ["KEYCLOAK_REALM"]
keycloak_client_id: str = os.environ["KEYCLOAK_CLIENT_ID"] keycloak_client_id: str = os.environ["KEYCLOAK_CLIENT_ID"]
@@ -20,5 +17,5 @@ class Settings(BaseSettings):
@lru_cache @lru_cache
def get_settings() -> Settings: def get_keycloak_settings() -> KeycloakSettings:
return Settings() 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.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base

View File

@@ -1,16 +1,14 @@
# services/keycloak.py # 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 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 import Depends, HTTPException, status
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
from fastapi.security.http import HTTPAuthorizationCredentials from fastapi.security.http import HTTPAuthorizationCredentials
from keycloak.exceptions import KeycloakAuthenticationError, KeycloakGetError from keycloak.exceptions import KeycloakAuthenticationError, KeycloakGetError
from logging_config import logger from rest_models.user_models import UserResponse
from rest_models.request_response_models import UserResponse
from sql_models.models import User
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from keycloak import KeycloakAdmin, KeycloakOpenID from keycloak import KeycloakAdmin, KeycloakOpenID
@@ -19,7 +17,7 @@ from keycloak import KeycloakAdmin, KeycloakOpenID
class KeycloakOpenIDService: class KeycloakOpenIDService:
"""OpenID Connect operations - token validation, userinfo""" """OpenID Connect operations - token validation, userinfo"""
def __init__(self, settings: Settings): def __init__(self, settings: KeycloakSettings):
self.keycloak_openid = KeycloakOpenID( self.keycloak_openid = KeycloakOpenID(
server_url=settings.keycloak_server_url, server_url=settings.keycloak_server_url,
client_id=settings.keycloak_client_id, client_id=settings.keycloak_client_id,
@@ -43,7 +41,6 @@ class KeycloakOpenIDService:
payload = self.keycloak_openid.decode_token(token) payload = self.keycloak_openid.decode_token(token)
return payload return payload
except Exception as e: except Exception as e:
logger.error(self.keycloak_openid.public_key())
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {str(e)}", detail=f"Invalid token: {str(e)}",
@@ -73,7 +70,7 @@ class KeycloakOpenIDService:
class KeycloakAdminService: class KeycloakAdminService:
"""Admin operations - user lookup by email, etc.""" """Admin operations - user lookup by email, etc."""
def __init__(self, settings: Settings): def __init__(self, settings: KeycloakSettings):
self.settings = settings self.settings = settings
self._admin: Optional[KeycloakAdmin] = None self._admin: Optional[KeycloakAdmin] = None
@@ -99,10 +96,39 @@ class KeycloakAdminService:
# The get_users method accepts query parameters as a dict # The get_users method accepts query parameters as a dict
users = admin.get_users(query={"email": email, "exact": True}) users = admin.get_users(query={"email": email, "exact": True})
return users[0] if users else None return users[0] if users else None
except KeycloakGetError as e: except KeycloakGetError:
print(f"Keycloak admin error: {e}")
return None 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() security = HTTPBearer()
@@ -112,7 +138,7 @@ _keycloak_admin: Optional[KeycloakAdminService] = None
def get_keycloak_openid( def get_keycloak_openid(
settings: Settings = Depends(get_settings), settings: KeycloakSettings = Depends(get_keycloak_settings),
) -> KeycloakOpenIDService: ) -> KeycloakOpenIDService:
"""Dependency for OpenID service""" """Dependency for OpenID service"""
global _keycloak_openid global _keycloak_openid
@@ -122,7 +148,7 @@ def get_keycloak_openid(
def get_keycloak_admin( def get_keycloak_admin(
settings: Settings = Depends(get_settings), settings: KeycloakSettings = Depends(get_keycloak_settings),
) -> KeycloakAdminService: ) -> KeycloakAdminService:
"""Dependency for Admin service""" """Dependency for Admin service"""
global _keycloak_admin 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 profile_picture_path: Optional[str] = None
# That's it! No first_name, last_name, email, username # That's it! No first_name, last_name, email, username
# Those are managed ONLY in Keycloak # 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 datetime import datetime
from typing import List
from connections.db import Base from connections.db import Base
from sqlalchemy import Boolean, DateTime, String from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column 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): class User(Base):
@@ -11,9 +96,22 @@ class User(Base):
keycloak_id: Mapped[str] = mapped_column( keycloak_id: Mapped[str] = mapped_column(
String(36), primary_key=True, index=True, nullable=False String(36), primary_key=True, index=True, nullable=False
) )
profile_picture_path: Mapped[str] = mapped_column( profile_picture_path: Mapped[str] = mapped_column(String(500), nullable=True)
String(500), nullable=True
) # The only extra field
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Timestamps for auditing
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) 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
)