diff --git a/docker-compose.yaml b/docker-compose.yaml index 9489b09..6c09aa9 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,5 @@ services: - qunatum-backend: + quantum-backend: build: context: . environment: diff --git a/src/api_endpoint/health_api.py b/src/api_endpoint/health_api.py new file mode 100644 index 0000000..47424ad --- /dev/null +++ b/src/api_endpoint/health_api.py @@ -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"} diff --git a/src/api_endpoint/teams_api.py b/src/api_endpoint/teams_api.py new file mode 100644 index 0000000..143d2d8 --- /dev/null +++ b/src/api_endpoint/teams_api.py @@ -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") diff --git a/src/api_endpoint/user_api.py b/src/api_endpoint/user_api.py new file mode 100644 index 0000000..1307a55 --- /dev/null +++ b/src/api_endpoint/user_api.py @@ -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, + ) diff --git a/src/app.py b/src/app.py index be69b0f..7846d53 100755 --- a/src/app.py +++ b/src/app.py @@ -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"]) diff --git a/src/config/database_config.py b/src/config/database_config.py new file mode 100644 index 0000000..490c464 --- /dev/null +++ b/src/config/database_config.py @@ -0,0 +1,2 @@ +# Use asyncpg for async PostgreSQL +DATABASE_URL = "postgresql+asyncpg://postgres:postgres@db:5432/fastapi_db" diff --git a/src/config.py b/src/config/keycloak_config.py similarity index 71% rename from src/config.py rename to src/config/keycloak_config.py index 8177e00..3fee2b7 100644 --- a/src/config.py +++ b/src/config/keycloak_config.py @@ -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() diff --git a/src/logging_config.py b/src/config/logging_config.py similarity index 100% rename from src/logging_config.py rename to src/config/logging_config.py diff --git a/src/connections/db.py b/src/connections/db.py index 9f8f449..c96eb5e 100644 --- a/src/connections/db.py +++ b/src/connections/db.py @@ -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 diff --git a/src/connections/keycloak.py b/src/connections/keycloak.py index e5bfd2e..f62f0fb 100644 --- a/src/connections/keycloak.py +++ b/src/connections/keycloak.py @@ -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 diff --git a/src/crud/team_crud.py b/src/crud/team_crud.py new file mode 100644 index 0000000..fb5623f --- /dev/null +++ b/src/crud/team_crud.py @@ -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 diff --git a/src/crud/usercrud.py b/src/crud/user_crud.py similarity index 100% rename from src/crud/usercrud.py rename to src/crud/user_crud.py diff --git a/src/rest_models/team_models.py b/src/rest_models/team_models.py new file mode 100644 index 0000000..5e32a27 --- /dev/null +++ b/src/rest_models/team_models.py @@ -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 diff --git a/src/rest_models/user_models.py b/src/rest_models/user_models.py index b3d5f5a..f15062d 100755 --- a/src/rest_models/user_models.py +++ b/src/rest_models/user_models.py @@ -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 diff --git a/src/sql_models/models.py b/src/sql_models/models.py index 15a097c..8428b7b 100644 --- a/src/sql_models/models.py +++ b/src/sql_models/models.py @@ -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 + )