added team management and routing
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
services:
|
services:
|
||||||
qunatum-backend:
|
quantum-backend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
18
src/api_endpoint/health_api.py
Normal file
18
src/api_endpoint/health_api.py
Normal 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"}
|
||||||
359
src/api_endpoint/teams_api.py
Normal file
359
src/api_endpoint/teams_api.py
Normal 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")
|
||||||
97
src/api_endpoint/user_api.py
Normal file
97
src/api_endpoint/user_api.py
Normal 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,
|
||||||
|
)
|
||||||
177
src/app.py
177
src/app.py
@@ -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,
|
|
||||||
)
|
|
||||||
|
|||||||
2
src/config/database_config.py
Normal file
2
src/config/database_config.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Use asyncpg for async PostgreSQL
|
||||||
|
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@db:5432/fastapi_db"
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
282
src/crud/team_crud.py
Normal 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
|
||||||
67
src/rest_models/team_models.py
Normal file
67
src/rest_models/team_models.py
Normal 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
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user