Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from app.admin.setup import setup_admin

__all__ = ["setup_admin"]
64 changes: 64 additions & 0 deletions app/admin/auth.py
Original file line number Diff line number Diff line change
@@ -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()
24 changes: 24 additions & 0 deletions app/admin/setup.py
Original file line number Diff line number Diff line change
@@ -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)
93 changes: 93 additions & 0 deletions app/admin/views.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions app/api/openapi.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from fastapi import FastAPI
from fastapi.openapi.docs import get_redoc_html
from fastapi.openapi.utils import get_openapi


Expand Down Expand Up @@ -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"
),
)
16 changes: 5 additions & 11 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,19 +16,13 @@
redoc_url=None,
)

setup_admin(app)

configure_security_middlewares(app)

app.include_router(auth_router, prefix=settings.API_V1_PREFIX)
app.include_router(feature_spec_router, prefix=settings.API_V1_PREFIX)
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)
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading