diff --git a/app/admin/__init__.py b/app/admin/__init__.py new file mode 100644 index 0000000..c24bd4b --- /dev/null +++ b/app/admin/__init__.py @@ -0,0 +1,3 @@ +from app.admin.setup import setup_admin + +__all__ = ["setup_admin"] diff --git a/app/admin/auth.py b/app/admin/auth.py new file mode 100644 index 0000000..d6cd6e6 --- /dev/null +++ b/app/admin/auth.py @@ -0,0 +1,64 @@ +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy import select +from sqladmin.authentication import AuthenticationBackend +from starlette.requests import Request + +from app.core.database import AsyncSessionLocal +from app.core.settings import settings +from app.modules.auth.infrastructure.fastapi_users_adapter import ( + UserManager, + UsernameAwareUserDatabase, +) +from app.modules.auth.models import User + + +class AdminAuthBackend(AuthenticationBackend): + def __init__(self) -> None: + super().__init__(secret_key=settings.SECRET_KEY) + + async def login(self, request: Request) -> bool: + form = await request.form() + username = str(form.get("username", "")).strip() + password = str(form.get("password", "")).strip() + if not username or not password: + return False + + credentials = OAuth2PasswordRequestForm( + username=username, + password=password, + scope="", + client_id=None, + client_secret=None, + ) + + async with AsyncSessionLocal() as session: + user_db = UsernameAwareUserDatabase(session, User) + user_manager = UserManager(user_db) + user = await user_manager.authenticate(credentials) + + if user is None or not user.is_active or not user.is_superuser: + return False + + request.session.update({"admin_user_id": user.id}) + return True + + async def logout(self, request: Request) -> bool: + request.session.clear() + return True + + async def authenticate(self, request: Request) -> bool: + user_id = request.session.get("admin_user_id") + if user_id is None: + return False + + async with AsyncSessionLocal() as session: + statement = select(User).where(User.id == int(user_id)) + user = await session.scalar(statement) + + if user is None or not user.is_active or not user.is_superuser: + request.session.clear() + return False + return True + + +admin_auth_backend = AdminAuthBackend() diff --git a/app/admin/setup.py b/app/admin/setup.py new file mode 100644 index 0000000..dfd0b61 --- /dev/null +++ b/app/admin/setup.py @@ -0,0 +1,24 @@ +from fastapi import FastAPI +from sqladmin import Admin + +from app.admin.auth import admin_auth_backend +from app.admin.views import ( + FeatureSpecRunAdmin, + PromptTemplateAdmin, + RefreshTokenAdmin, + UserAdmin, +) +from app.core.database import engine + + +def setup_admin(app: FastAPI) -> None: + admin = Admin( + app=app, + engine=engine, + authentication_backend=admin_auth_backend, + title="Specification Generator Admin", + ) + admin.add_view(UserAdmin) + admin.add_view(RefreshTokenAdmin) + admin.add_view(PromptTemplateAdmin) + admin.add_view(FeatureSpecRunAdmin) diff --git a/app/admin/views.py b/app/admin/views.py new file mode 100644 index 0000000..3848b64 --- /dev/null +++ b/app/admin/views.py @@ -0,0 +1,93 @@ +from sqladmin import ModelView + +from app.modules.auth.models import RefreshToken, User +from app.modules.feature_spec.models import FeatureSpecRun, PromptTemplate + + +class UserAdmin(ModelView, model=User): + name = "User" + name_plural = "Users" + icon = "fa-solid fa-user" + + column_list = [ + User.id, + User.username, + User.email, + User.is_active, + User.is_superuser, + User.is_verified, + User.created_at, + ] + column_searchable_list = [User.username, User.email] + column_filters = [User.is_active, User.is_superuser, User.is_verified, User.created_at] + column_sortable_list = [User.id, User.username, User.email, User.created_at] + + form_excluded_columns = [User.hashed_password, User.created_at] + can_create = False + can_delete = False + + +class RefreshTokenAdmin(ModelView, model=RefreshToken): + name = "Refresh Token" + name_plural = "Refresh Tokens" + icon = "fa-solid fa-key" + + column_list = [ + RefreshToken.id, + RefreshToken.user_id, + RefreshToken.expires_at, + RefreshToken.created_at, + RefreshToken.revoked_at, + ] + column_searchable_list = [RefreshToken.user_id] + column_filters = [RefreshToken.expires_at, RefreshToken.created_at, RefreshToken.revoked_at] + column_sortable_list = [RefreshToken.id, RefreshToken.user_id, RefreshToken.expires_at] + + form_excluded_columns = [RefreshToken.token_hash, RefreshToken.created_at] + can_create = False + can_edit = False + + +class PromptTemplateAdmin(ModelView, model=PromptTemplate): + name = "Prompt Template" + name_plural = "Prompt Templates" + icon = "fa-solid fa-file-lines" + + column_list = [ + PromptTemplate.id, + PromptTemplate.is_active, + PromptTemplate.updated_at, + PromptTemplate.feature_to_feature_summary, + ] + column_searchable_list = [PromptTemplate.feature_to_feature_summary] + column_filters = [PromptTemplate.is_active, PromptTemplate.updated_at] + column_sortable_list = [PromptTemplate.id, PromptTemplate.updated_at] + + can_create = False + can_delete = False + + +class FeatureSpecRunAdmin(ModelView, model=FeatureSpecRun): + name = "Feature Spec Run" + name_plural = "Feature Spec Runs" + icon = "fa-solid fa-wand-magic-sparkles" + + column_list = [ + FeatureSpecRun.id, + FeatureSpecRun.user_id, + FeatureSpecRun.status, + FeatureSpecRun.feature_idea, + FeatureSpecRun.created_at, + FeatureSpecRun.updated_at, + ] + column_searchable_list = [FeatureSpecRun.feature_idea, FeatureSpecRun.status] + column_filters = [FeatureSpecRun.status, FeatureSpecRun.created_at, FeatureSpecRun.updated_at] + column_sortable_list = [ + FeatureSpecRun.id, + FeatureSpecRun.user_id, + FeatureSpecRun.status, + FeatureSpecRun.created_at, + ] + + can_create = False + can_delete = False diff --git a/app/api/openapi.py b/app/api/openapi.py index d9350a8..1de66dc 100644 --- a/app/api/openapi.py +++ b/app/api/openapi.py @@ -1,4 +1,5 @@ from fastapi import FastAPI +from fastapi.openapi.docs import get_redoc_html from fastapi.openapi.utils import get_openapi @@ -43,3 +44,15 @@ def custom_openapi() -> dict: return app.openapi_schema app.openapi = custom_openapi + + +def configure_redoc_route(app: FastAPI) -> None: + @app.get("/redoc", include_in_schema=False) + async def redoc_html(): + return get_redoc_html( + openapi_url=app.openapi_url, + title=f"{app.title} - ReDoc", + redoc_js_url=( + "https://cdn.jsdelivr.net/npm/redoc@2.1.5/bundles/redoc.standalone.js" + ), + ) diff --git a/app/main.py b/app/main.py index dd4a95f..0543db8 100644 --- a/app/main.py +++ b/app/main.py @@ -1,8 +1,8 @@ from fastapi import FastAPI -from fastapi.openapi.docs import get_redoc_html +from app.admin import setup_admin from app.api.health import router as health_router -from app.api.openapi import configure_openapi_bearer_auth +from app.api.openapi import configure_openapi_bearer_auth, configure_redoc_route from app.core.settings import settings from app.core.startup import lifespan from app.middlewares import configure_security_middlewares @@ -16,6 +16,8 @@ redoc_url=None, ) +setup_admin(app) + configure_security_middlewares(app) app.include_router(auth_router, prefix=settings.API_V1_PREFIX) @@ -23,12 +25,4 @@ app.include_router(health_router) configure_openapi_bearer_auth(app) - - -@app.get("/redoc", include_in_schema=False) -async def redoc_html(): - return get_redoc_html( - openapi_url=app.openapi_url, - title=f"{app.title} - ReDoc", - redoc_js_url="https://cdn.jsdelivr.net/npm/redoc@2.1.5/bundles/redoc.standalone.js", - ) +configure_redoc_route(app) diff --git a/requirements.txt b/requirements.txt index c55ea22..89bbdb0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,3 +28,7 @@ python-dotenv==1.0.1 # HTTP requests httpx==0.27.0 + +# Admin panel +sqladmin==0.17.0 +itsdangerous==2.2.0