added keycloak integration, user endpoints, env file
This commit is contained in:
4
.env.template
Normal file
4
.env.template
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
KEYCLOAK_URL=http://auth.example.com
|
||||||
|
KEYCLOAK_REALM=quant_sim-realm
|
||||||
|
KEYCLOAK_CLIENT_ID=quantum-backend
|
||||||
|
KEYCLOAK_CLIENT_SECRET=!!!REPLACE_ME
|
||||||
@@ -4,13 +4,34 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
environment:
|
environment:
|
||||||
PORT: 1656
|
PORT: 1656
|
||||||
|
extra_hosts:
|
||||||
|
- "auth.localhost:host-gateway"
|
||||||
ports:
|
ports:
|
||||||
- 1656:1656
|
- 1656:1656
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: "redis:alpine"
|
image: "redis:alpine"
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:18.3
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: fastapi_db
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
fastapi[all]==0.121.3
|
fastapi[all]==0.121.3
|
||||||
fastapi-cache2==0.2.2
|
fastapi-cache2==0.2.2
|
||||||
redis==7.1.0
|
redis==7.1.0
|
||||||
|
|
||||||
|
sqlalchemy==2.0.0
|
||||||
|
asyncpg==0.31.0
|
||||||
|
|
||||||
|
python-keycloak==7.1.1
|
||||||
|
|
||||||
|
pydantic==2.13.1
|
||||||
|
|||||||
151
src/app.py
151
src/app.py
@@ -1,26 +1,42 @@
|
|||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
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 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.responses import JSONResponse
|
|
||||||
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 fastapi_cache.decorator import cache
|
||||||
from logging_config import logger
|
|
||||||
from redis import asyncio as aioredis
|
from redis import asyncio as aioredis
|
||||||
from request_response_models import BasicError, ConvertRequest, MolFileModel
|
from rest_models.user_models import (
|
||||||
|
PublicUserResponse,
|
||||||
|
UserResponse,
|
||||||
|
UserUpdateRequest,
|
||||||
|
)
|
||||||
|
from sql_models.models import User
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||||
redis = aioredis.from_url("redis://redis:6379")
|
redis = aioredis.from_url("redis://redis:6379")
|
||||||
FastAPICache.init(RedisBackend(redis), prefix="fastapi-cache")
|
FastAPICache.init(RedisBackend(redis), prefix="fastapi-cache")
|
||||||
|
|
||||||
|
await create_tables()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
@@ -39,54 +55,6 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post(
|
|
||||||
"/convert",
|
|
||||||
responses={
|
|
||||||
200: {"description": "Coversion Successful", "model": MolFileModel},
|
|
||||||
400: {"description": "Item created", "model": BasicError},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def convert_molecule(req: ConvertRequest):
|
|
||||||
try:
|
|
||||||
# Rad the string and format from request
|
|
||||||
mol = pybel.readstring(req.format, req.text)
|
|
||||||
|
|
||||||
if req.add_hydrogen:
|
|
||||||
mol.addh()
|
|
||||||
if req.convert_3d:
|
|
||||||
mol.make3D()
|
|
||||||
if req.optimize_geometry:
|
|
||||||
mol.localopt()
|
|
||||||
|
|
||||||
logger.info(f"Converting from format {req.format}")
|
|
||||||
|
|
||||||
# To compute the Number of electrons and orbitals
|
|
||||||
atoms = [(atom.atomicnum, atom.coords) for atom in mol.atoms]
|
|
||||||
|
|
||||||
mol_pyscf = gto.Mole()
|
|
||||||
mol_pyscf.atom = atoms
|
|
||||||
mol_pyscf.basis = "sto-3g" # simple basis
|
|
||||||
mol_pyscf.charge = mol.charge # default net charge
|
|
||||||
mol_pyscf.spin = mol.spin - 1 # multiplicity - 1
|
|
||||||
mol_pyscf.build()
|
|
||||||
|
|
||||||
# Export the resulting molecule
|
|
||||||
mol.OBMol.SetTitle(
|
|
||||||
f"Charge={mol.charge} Multiplicity={mol.spin} Electrons={mol_pyscf.nelectron} Orbitals={mol_pyscf.nao_nr()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
mol2string = mol.write("xyz")
|
|
||||||
return JSONResponse({"molfile": mol2string}, 200)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail={"error": str(e)})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/informats")
|
|
||||||
@cache()
|
|
||||||
async def get_informats():
|
|
||||||
return pybel.informats
|
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
"/health",
|
"/health",
|
||||||
responses={
|
responses={
|
||||||
@@ -99,3 +67,82 @@ async def get_informats():
|
|||||||
@cache()
|
@cache()
|
||||||
async def health_check():
|
async def health_check():
|
||||||
return {"status": "healthy"}
|
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,
|
||||||
|
)
|
||||||
|
|||||||
24
src/config.py
Normal file
24
src/config.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# config.py
|
||||||
|
import os
|
||||||
|
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):
|
||||||
|
keycloak_server_url: str = os.environ["KEYCLOAK_URL"]
|
||||||
|
keycloak_realm: str = os.environ["KEYCLOAK_REALM"]
|
||||||
|
keycloak_client_id: str = os.environ["KEYCLOAK_CLIENT_ID"]
|
||||||
|
keycloak_client_secret: str = os.environ["KEYCLOAK_CLIENT_SECRET"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def issuer(self) -> str:
|
||||||
|
return f"{self.keycloak_server_url}/realms/{self.keycloak_realm}"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
41
src/connections/db.py
Normal file
41
src/connections/db.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from config import DATABASE_URL
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
|
# Create async engine
|
||||||
|
engine = create_async_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
echo=True, # Set to False in production
|
||||||
|
pool_size=5,
|
||||||
|
max_overflow=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create async session factory
|
||||||
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autoflush=False,
|
||||||
|
autocommit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
# Create tables (async version)
|
||||||
|
async def create_tables():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
|
||||||
|
# Dependency to get database session
|
||||||
|
async def get_db():
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
170
src/connections/keycloak.py
Normal file
170
src/connections/keycloak.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# services/keycloak.py
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from config import Settings, get_settings
|
||||||
|
from connections.db import get_db
|
||||||
|
from crud.usercrud 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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from keycloak import KeycloakAdmin, KeycloakOpenID
|
||||||
|
|
||||||
|
|
||||||
|
class KeycloakOpenIDService:
|
||||||
|
"""OpenID Connect operations - token validation, userinfo"""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings):
|
||||||
|
self.keycloak_openid = KeycloakOpenID(
|
||||||
|
server_url=settings.keycloak_server_url,
|
||||||
|
client_id=settings.keycloak_client_id,
|
||||||
|
realm_name=settings.keycloak_realm,
|
||||||
|
client_secret_key=settings.keycloak_client_secret,
|
||||||
|
verify=True,
|
||||||
|
)
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
def get_well_known(self) -> dict:
|
||||||
|
"""Get OpenID configuration"""
|
||||||
|
return self.keycloak_openid.well_known()
|
||||||
|
|
||||||
|
def decode_token(self, token: str) -> dict:
|
||||||
|
"""
|
||||||
|
Decode and validate JWT token using Keycloak's public keys.
|
||||||
|
This does NOT call Keycloak - it validates locally using cached keys.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get public keys and decode
|
||||||
|
payload = self.keycloak_openid.decode_token(token)
|
||||||
|
return payload
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(self.keycloak_openid.public_key())
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=f"Invalid token: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_userinfo(self, token: str) -> dict | bytes:
|
||||||
|
"""Get user info from Keycloak using the access token"""
|
||||||
|
try:
|
||||||
|
return self.keycloak_openid.userinfo(token)
|
||||||
|
except KeycloakAuthenticationError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=f"Failed to get user info: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def introspect_token(self, token: str) -> dict:
|
||||||
|
"""Introspect token (calls Keycloak) - use for revocation checks"""
|
||||||
|
try:
|
||||||
|
return self.keycloak_openid.introspect(token)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Token introspection failed: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class KeycloakAdminService:
|
||||||
|
"""Admin operations - user lookup by email, etc."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings):
|
||||||
|
self.settings = settings
|
||||||
|
self._admin: Optional[KeycloakAdmin] = None
|
||||||
|
|
||||||
|
def _get_admin(self) -> KeycloakAdmin:
|
||||||
|
"""Get or create admin client connection"""
|
||||||
|
if self._admin is None:
|
||||||
|
self._admin = KeycloakAdmin(
|
||||||
|
server_url=self.settings.keycloak_server_url,
|
||||||
|
realm_name=self.settings.keycloak_realm,
|
||||||
|
client_id=self.settings.keycloak_client_id,
|
||||||
|
client_secret_key=self.settings.keycloak_client_secret,
|
||||||
|
)
|
||||||
|
return self._admin
|
||||||
|
|
||||||
|
def get_user_by_email(self, email: str) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Find user by exact email match.
|
||||||
|
Uses the Keycloak Admin API.
|
||||||
|
"""
|
||||||
|
admin = self._get_admin()
|
||||||
|
try:
|
||||||
|
# Query users with email filter
|
||||||
|
# The get_users method accepts query parameters as a dict
|
||||||
|
users = admin.get_users(query={"email": email, "exact": True})
|
||||||
|
return users[0] if users else None
|
||||||
|
except KeycloakGetError as e:
|
||||||
|
print(f"Keycloak admin error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
# Singleton service instances
|
||||||
|
_keycloak_openid: Optional[KeycloakOpenIDService] = None
|
||||||
|
_keycloak_admin: Optional[KeycloakAdminService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_keycloak_openid(
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
) -> KeycloakOpenIDService:
|
||||||
|
"""Dependency for OpenID service"""
|
||||||
|
global _keycloak_openid
|
||||||
|
if _keycloak_openid is None:
|
||||||
|
_keycloak_openid = KeycloakOpenIDService(settings)
|
||||||
|
return _keycloak_openid
|
||||||
|
|
||||||
|
|
||||||
|
def get_keycloak_admin(
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
) -> KeycloakAdminService:
|
||||||
|
"""Dependency for Admin service"""
|
||||||
|
global _keycloak_admin
|
||||||
|
if _keycloak_admin is None:
|
||||||
|
_keycloak_admin = KeycloakAdminService(settings)
|
||||||
|
return _keycloak_admin
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_token_payload(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
keycloak_openid: KeycloakOpenIDService = Depends(get_keycloak_openid),
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Extract and validate JWT token.
|
||||||
|
Returns decoded token payload.
|
||||||
|
"""
|
||||||
|
token = credentials.credentials
|
||||||
|
return keycloak_openid.decode_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
payload: Dict = Depends(get_current_token_payload),
|
||||||
|
) -> UserResponse:
|
||||||
|
"""
|
||||||
|
Get current user from database (creates if doesn't exist).
|
||||||
|
This is the main dependency for authenticated endpoints.
|
||||||
|
"""
|
||||||
|
keycloak_id = payload.get("sub")
|
||||||
|
if not keycloak_id:
|
||||||
|
raise HTTPException(401, "Invalid token: missing sub claim")
|
||||||
|
|
||||||
|
email = payload.get("email")
|
||||||
|
username = payload.get("preferred_username") or payload.get("username")
|
||||||
|
if not email or not username:
|
||||||
|
raise Exception("Error, user found but no email / username")
|
||||||
|
|
||||||
|
user = await get_or_create_user(db, keycloak_id=keycloak_id)
|
||||||
|
return UserResponse(
|
||||||
|
keycloak_id=user.keycloak_id,
|
||||||
|
email=email,
|
||||||
|
username=username,
|
||||||
|
created_at=user.created_at,
|
||||||
|
profile_picture_path=user.profile_picture_path,
|
||||||
|
)
|
||||||
0
src/connections/rabbitmq.py
Normal file
0
src/connections/rabbitmq.py
Normal file
55
src/crud/usercrud.py
Normal file
55
src/crud/usercrud.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sql_models.models import User
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_create_user(
|
||||||
|
db: AsyncSession, keycloak_id: str, profile_picture_path: Optional[str] = None
|
||||||
|
) -> User:
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
insert(User)
|
||||||
|
.values(
|
||||||
|
keycloak_id=keycloak_id,
|
||||||
|
profile_picture_path=profile_picture_path,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.on_conflict_do_nothing(index_elements=["keycloak_id"])
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(stmt)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Now fetch (exists after this point)
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.keycloak_id == keycloak_id, User.is_deleted == False)
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def soft_delete_user(db: AsyncSession, keycloak_id: str) -> bool:
|
||||||
|
user = await get_or_create_user(db, keycloak_id)
|
||||||
|
|
||||||
|
user.is_deleted = True
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def update_user_profile(
|
||||||
|
db: AsyncSession, keycloak_id: str, profile_picture_path: Optional[str]
|
||||||
|
) -> User:
|
||||||
|
"""Update only local DB fields"""
|
||||||
|
|
||||||
|
user = await get_or_create_user(db, keycloak_id)
|
||||||
|
|
||||||
|
if profile_picture_path is not None:
|
||||||
|
user.profile_picture_path = profile_picture_path
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class ConvertRequest(BaseModel):
|
|
||||||
text: str
|
|
||||||
format: str # e.g. "mol", "smi", "sdf", "inchi", etc.
|
|
||||||
add_hydrogen: bool = False
|
|
||||||
convert_3d: bool = False
|
|
||||||
optimize_geometry: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class MolFileModel(BaseModel):
|
|
||||||
molfile: str
|
|
||||||
|
|
||||||
|
|
||||||
class BasicError(BaseModel):
|
|
||||||
error: str
|
|
||||||
35
src/rest_models/user_models.py
Executable file
35
src/rest_models/user_models.py
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
# schemas.py
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
# GET /me - Complete user profile (read-only from Keycloak)
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
# From Keycloak (read-only)
|
||||||
|
keycloak_id: str
|
||||||
|
email: EmailStr
|
||||||
|
username: str
|
||||||
|
# From your database (writeable)
|
||||||
|
profile_picture_path: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# PUT /me - What user can actually change
|
||||||
|
class UserUpdateRequest(BaseModel):
|
||||||
|
profile_picture_path: Optional[str] = None
|
||||||
|
# That's it! No first_name, last_name, email, username
|
||||||
|
# Those are managed ONLY in Keycloak
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
19
src/sql_models/models.py
Normal file
19
src/sql_models/models.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from connections.db import Base
|
||||||
|
from sqlalchemy import Boolean, DateTime, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
keycloak_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), primary_key=True, index=True, nullable=False
|
||||||
|
)
|
||||||
|
profile_picture_path: Mapped[str] = mapped_column(
|
||||||
|
String(500), nullable=True
|
||||||
|
) # The only extra field
|
||||||
|
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
# Timestamps for auditing
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
||||||
Reference in New Issue
Block a user