quick commit
Some checks failed
CI/CD Pipeline / Code Quality Checks (push) Failing after 6m9s
CI/CD Pipeline / Security Scanning (push) Successful in 26s
CI/CD Pipeline / Tests (3.11) (push) Failing after 5m24s
CI/CD Pipeline / Tests (3.12) (push) Failing after 5m23s
CI/CD Pipeline / Build Docker Image (push) Has been skipped
CI/CD Pipeline / Deploy to Staging (push) Has been skipped
CI/CD Pipeline / Deploy to Production (push) Has been skipped
CI/CD Pipeline / Notification (push) Successful in 1s

This commit is contained in:
2026-01-17 20:24:43 +01:00
parent 95cc3cdb8f
commit 831eed8dbc
82 changed files with 8860 additions and 167 deletions

View File

@@ -0,0 +1 @@
"""Dashboard application package."""

View File

@@ -0,0 +1,267 @@
"""Analytics API routes for the GuardDen dashboard."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from guardden.dashboard.auth import require_owner
from guardden.dashboard.config import DashboardSettings
from guardden.dashboard.db import DashboardDatabase
from guardden.dashboard.schemas import (
AIPerformanceStats,
AnalyticsSummary,
ModerationStats,
TimeSeriesDataPoint,
UserActivityStats,
)
from guardden.models import AICheck, MessageActivity, ModerationLog, UserActivity
def create_analytics_router(
settings: DashboardSettings,
database: DashboardDatabase,
) -> APIRouter:
"""Create the analytics API router."""
router = APIRouter(prefix="/api/analytics")
async def get_session() -> AsyncIterator[AsyncSession]:
async for session in database.session():
yield session
def require_owner_dep(request: Request) -> None:
require_owner(settings, request)
@router.get(
"/summary",
response_model=AnalyticsSummary,
dependencies=[Depends(require_owner_dep)],
)
async def analytics_summary(
guild_id: int | None = Query(default=None),
days: int = Query(default=7, ge=1, le=90),
session: AsyncSession = Depends(get_session),
) -> AnalyticsSummary:
"""Get analytics summary for the specified time period."""
start_date = datetime.now() - timedelta(days=days)
# Moderation stats
mod_query = select(ModerationLog).where(ModerationLog.created_at >= start_date)
if guild_id:
mod_query = mod_query.where(ModerationLog.guild_id == guild_id)
mod_result = await session.execute(mod_query)
mod_logs = mod_result.scalars().all()
total_actions = len(mod_logs)
actions_by_type: dict[str, int] = {}
automatic_count = 0
manual_count = 0
for log in mod_logs:
actions_by_type[log.action] = actions_by_type.get(log.action, 0) + 1
if log.is_automatic:
automatic_count += 1
else:
manual_count += 1
# Time series data (group by day)
time_series: dict[str, int] = {}
for log in mod_logs:
day_key = log.created_at.strftime("%Y-%m-%d")
time_series[day_key] = time_series.get(day_key, 0) + 1
actions_over_time = [
TimeSeriesDataPoint(timestamp=datetime.strptime(day, "%Y-%m-%d"), value=count)
for day, count in sorted(time_series.items())
]
moderation_stats = ModerationStats(
total_actions=total_actions,
actions_by_type=actions_by_type,
actions_over_time=actions_over_time,
automatic_vs_manual={"automatic": automatic_count, "manual": manual_count},
)
# User activity stats
activity_query = select(MessageActivity).where(MessageActivity.date >= start_date)
if guild_id:
activity_query = activity_query.where(MessageActivity.guild_id == guild_id)
activity_result = await session.execute(activity_query)
activities = activity_result.scalars().all()
total_messages = sum(a.total_messages for a in activities)
active_users = max((a.active_users for a in activities), default=0)
# New joins
today = datetime.now().date()
week_ago = today - timedelta(days=7)
new_joins_today = sum(a.new_joins for a in activities if a.date.date() == today)
new_joins_week = sum(a.new_joins for a in activities if a.date.date() >= week_ago)
user_activity = UserActivityStats(
active_users=active_users,
total_messages=total_messages,
new_joins_today=new_joins_today,
new_joins_week=new_joins_week,
)
# AI performance stats
ai_query = select(AICheck).where(AICheck.created_at >= start_date)
if guild_id:
ai_query = ai_query.where(AICheck.guild_id == guild_id)
ai_result = await session.execute(ai_query)
ai_checks = ai_result.scalars().all()
total_checks = len(ai_checks)
flagged_content = sum(1 for c in ai_checks if c.flagged)
avg_confidence = (
sum(c.confidence for c in ai_checks) / total_checks if total_checks > 0 else 0.0
)
false_positives = sum(1 for c in ai_checks if c.is_false_positive)
avg_response_time = (
sum(c.response_time_ms for c in ai_checks) / total_checks if total_checks > 0 else 0.0
)
ai_performance = AIPerformanceStats(
total_checks=total_checks,
flagged_content=flagged_content,
avg_confidence=avg_confidence,
false_positives=false_positives,
avg_response_time_ms=avg_response_time,
)
return AnalyticsSummary(
moderation_stats=moderation_stats,
user_activity=user_activity,
ai_performance=ai_performance,
)
@router.get(
"/moderation-stats",
response_model=ModerationStats,
dependencies=[Depends(require_owner_dep)],
)
async def moderation_stats(
guild_id: int | None = Query(default=None),
days: int = Query(default=30, ge=1, le=90),
session: AsyncSession = Depends(get_session),
) -> ModerationStats:
"""Get detailed moderation statistics."""
start_date = datetime.now() - timedelta(days=days)
query = select(ModerationLog).where(ModerationLog.created_at >= start_date)
if guild_id:
query = query.where(ModerationLog.guild_id == guild_id)
result = await session.execute(query)
logs = result.scalars().all()
total_actions = len(logs)
actions_by_type: dict[str, int] = {}
automatic_count = 0
manual_count = 0
for log in logs:
actions_by_type[log.action] = actions_by_type.get(log.action, 0) + 1
if log.is_automatic:
automatic_count += 1
else:
manual_count += 1
# Time series data
time_series: dict[str, int] = {}
for log in logs:
day_key = log.created_at.strftime("%Y-%m-%d")
time_series[day_key] = time_series.get(day_key, 0) + 1
actions_over_time = [
TimeSeriesDataPoint(timestamp=datetime.strptime(day, "%Y-%m-%d"), value=count)
for day, count in sorted(time_series.items())
]
return ModerationStats(
total_actions=total_actions,
actions_by_type=actions_by_type,
actions_over_time=actions_over_time,
automatic_vs_manual={"automatic": automatic_count, "manual": manual_count},
)
@router.get(
"/user-activity",
response_model=UserActivityStats,
dependencies=[Depends(require_owner_dep)],
)
async def user_activity_stats(
guild_id: int | None = Query(default=None),
days: int = Query(default=7, ge=1, le=90),
session: AsyncSession = Depends(get_session),
) -> UserActivityStats:
"""Get user activity statistics."""
start_date = datetime.now() - timedelta(days=days)
query = select(MessageActivity).where(MessageActivity.date >= start_date)
if guild_id:
query = query.where(MessageActivity.guild_id == guild_id)
result = await session.execute(query)
activities = result.scalars().all()
total_messages = sum(a.total_messages for a in activities)
active_users = max((a.active_users for a in activities), default=0)
today = datetime.now().date()
week_ago = today - timedelta(days=7)
new_joins_today = sum(a.new_joins for a in activities if a.date.date() == today)
new_joins_week = sum(a.new_joins for a in activities if a.date.date() >= week_ago)
return UserActivityStats(
active_users=active_users,
total_messages=total_messages,
new_joins_today=new_joins_today,
new_joins_week=new_joins_week,
)
@router.get(
"/ai-performance",
response_model=AIPerformanceStats,
dependencies=[Depends(require_owner_dep)],
)
async def ai_performance_stats(
guild_id: int | None = Query(default=None),
days: int = Query(default=30, ge=1, le=90),
session: AsyncSession = Depends(get_session),
) -> AIPerformanceStats:
"""Get AI moderation performance statistics."""
start_date = datetime.now() - timedelta(days=days)
query = select(AICheck).where(AICheck.created_at >= start_date)
if guild_id:
query = query.where(AICheck.guild_id == guild_id)
result = await session.execute(query)
checks = result.scalars().all()
total_checks = len(checks)
flagged_content = sum(1 for c in checks if c.flagged)
avg_confidence = (
sum(c.confidence for c in checks) / total_checks if total_checks > 0 else 0.0
)
false_positives = sum(1 for c in checks if c.is_false_positive)
avg_response_time = (
sum(c.response_time_ms for c in checks) / total_checks if total_checks > 0 else 0.0
)
return AIPerformanceStats(
total_checks=total_checks,
flagged_content=flagged_content,
avg_confidence=avg_confidence,
false_positives=false_positives,
avg_response_time_ms=avg_response_time,
)
return router

View File

@@ -0,0 +1,78 @@
"""Authentication helpers for the dashboard."""
from typing import Any
from urllib.parse import urlencode
import httpx
from authlib.integrations.starlette_client import OAuth
from fastapi import HTTPException, Request, status
from guardden.dashboard.config import DashboardSettings
def build_oauth(settings: DashboardSettings) -> OAuth:
"""Build OAuth client registrations."""
oauth = OAuth()
oauth.register(
name="entra",
client_id=settings.entra_client_id,
client_secret=settings.entra_client_secret.get_secret_value(),
server_metadata_url=(
"https://login.microsoftonline.com/"
f"{settings.entra_tenant_id}/v2.0/.well-known/openid-configuration"
),
client_kwargs={"scope": "openid profile email"},
)
return oauth
def discord_authorize_url(settings: DashboardSettings, state: str) -> str:
"""Generate the Discord OAuth authorization URL."""
query = urlencode(
{
"client_id": settings.discord_client_id,
"redirect_uri": settings.callback_url("discord"),
"response_type": "code",
"scope": "identify",
"state": state,
}
)
return f"https://discord.com/oauth2/authorize?{query}"
async def exchange_discord_code(settings: DashboardSettings, code: str) -> dict[str, Any]:
"""Exchange a Discord OAuth code for a user profile."""
async with httpx.AsyncClient(timeout=10.0) as client:
token_response = await client.post(
"https://discord.com/api/oauth2/token",
data={
"client_id": settings.discord_client_id,
"client_secret": settings.discord_client_secret.get_secret_value(),
"grant_type": "authorization_code",
"code": code,
"redirect_uri": settings.callback_url("discord"),
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
token_response.raise_for_status()
token_data = token_response.json()
user_response = await client.get(
"https://discord.com/api/users/@me",
headers={"Authorization": f"Bearer {token_data['access_token']}"},
)
user_response.raise_for_status()
return user_response.json()
def require_owner(settings: DashboardSettings, request: Request) -> None:
"""Ensure the current session is the configured owner."""
session = request.session
entra_oid = session.get("entra_oid")
discord_id = session.get("discord_id")
if not entra_oid or not discord_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
if str(entra_oid) != settings.owner_entra_object_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
if int(discord_id) != settings.owner_discord_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")

View File

@@ -0,0 +1,68 @@
"""Configuration for the GuardDen dashboard."""
from pathlib import Path
from typing import Any
from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class DashboardSettings(BaseSettings):
"""Dashboard settings loaded from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
env_prefix="GUARDDEN_DASHBOARD_",
)
database_url: SecretStr = Field(
validation_alias="GUARDDEN_DATABASE_URL",
description="Database connection URL",
)
base_url: str = Field(
default="http://localhost:8080",
description="Base URL for OAuth callbacks",
)
secret_key: SecretStr = Field(
default=SecretStr("change-me"),
description="Session secret key",
)
entra_tenant_id: str = Field(description="Entra ID tenant ID")
entra_client_id: str = Field(description="Entra ID application client ID")
entra_client_secret: SecretStr = Field(description="Entra ID application client secret")
discord_client_id: str = Field(description="Discord OAuth client ID")
discord_client_secret: SecretStr = Field(description="Discord OAuth client secret")
owner_discord_id: int = Field(description="Discord user ID allowed to access dashboard")
owner_entra_object_id: str = Field(description="Entra ID object ID allowed to access")
cors_origins: list[str] = Field(default_factory=list, description="Allowed CORS origins")
static_dir: Path = Field(
default=Path("dashboard/frontend/dist"),
description="Directory containing built frontend assets",
)
@field_validator("cors_origins", mode="before")
@classmethod
def _parse_origins(cls, value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
text = str(value).strip()
if not text:
return []
return [item.strip() for item in text.split(",") if item.strip()]
def callback_url(self, provider: str) -> str:
return f"{self.base_url}/auth/{provider}/callback"
def get_dashboard_settings() -> DashboardSettings:
"""Load dashboard settings from environment."""
return DashboardSettings()

View File

@@ -0,0 +1,298 @@
"""Configuration management API routes for the GuardDen dashboard."""
import json
from collections.abc import AsyncIterator
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from guardden.dashboard.auth import require_owner
from guardden.dashboard.config import DashboardSettings
from guardden.dashboard.db import DashboardDatabase
from guardden.dashboard.schemas import AutomodRuleConfig, ConfigExport, GuildSettings
from guardden.models import Guild
from guardden.models import GuildSettings as GuildSettingsModel
def create_config_router(
settings: DashboardSettings,
database: DashboardDatabase,
) -> APIRouter:
"""Create the configuration management API router."""
router = APIRouter(prefix="/api/guilds")
async def get_session() -> AsyncIterator[AsyncSession]:
async for session in database.session():
yield session
def require_owner_dep(request: Request) -> None:
require_owner(settings, request)
@router.get(
"/{guild_id}/settings",
response_model=GuildSettings,
dependencies=[Depends(require_owner_dep)],
)
async def get_guild_settings(
guild_id: int = Path(...),
session: AsyncSession = Depends(get_session),
) -> GuildSettings:
"""Get guild settings."""
query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id)
result = await session.execute(query)
guild_settings = result.scalar_one_or_none()
if not guild_settings:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Guild settings not found",
)
return GuildSettings(
guild_id=guild_settings.guild_id,
prefix=guild_settings.prefix,
log_channel_id=guild_settings.log_channel_id,
automod_enabled=guild_settings.automod_enabled,
ai_moderation_enabled=guild_settings.ai_moderation_enabled,
ai_sensitivity=guild_settings.ai_sensitivity,
verification_enabled=guild_settings.verification_enabled,
verification_role_id=guild_settings.verified_role_id,
max_warns_before_action=3, # Default value, could be derived from strike_actions
)
@router.put(
"/{guild_id}/settings",
response_model=GuildSettings,
dependencies=[Depends(require_owner_dep)],
)
async def update_guild_settings(
guild_id: int = Path(...),
settings_data: GuildSettings = ...,
session: AsyncSession = Depends(get_session),
) -> GuildSettings:
"""Update guild settings."""
query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id)
result = await session.execute(query)
guild_settings = result.scalar_one_or_none()
if not guild_settings:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Guild settings not found",
)
# Update settings
if settings_data.prefix is not None:
guild_settings.prefix = settings_data.prefix
if settings_data.log_channel_id is not None:
guild_settings.log_channel_id = settings_data.log_channel_id
guild_settings.automod_enabled = settings_data.automod_enabled
guild_settings.ai_moderation_enabled = settings_data.ai_moderation_enabled
guild_settings.ai_sensitivity = settings_data.ai_sensitivity
guild_settings.verification_enabled = settings_data.verification_enabled
if settings_data.verification_role_id is not None:
guild_settings.verified_role_id = settings_data.verification_role_id
await session.commit()
await session.refresh(guild_settings)
return GuildSettings(
guild_id=guild_settings.guild_id,
prefix=guild_settings.prefix,
log_channel_id=guild_settings.log_channel_id,
automod_enabled=guild_settings.automod_enabled,
ai_moderation_enabled=guild_settings.ai_moderation_enabled,
ai_sensitivity=guild_settings.ai_sensitivity,
verification_enabled=guild_settings.verification_enabled,
verification_role_id=guild_settings.verified_role_id,
max_warns_before_action=3,
)
@router.get(
"/{guild_id}/automod",
response_model=AutomodRuleConfig,
dependencies=[Depends(require_owner_dep)],
)
async def get_automod_config(
guild_id: int = Path(...),
session: AsyncSession = Depends(get_session),
) -> AutomodRuleConfig:
"""Get automod rule configuration."""
query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id)
result = await session.execute(query)
guild_settings = result.scalar_one_or_none()
if not guild_settings:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Guild settings not found",
)
return AutomodRuleConfig(
guild_id=guild_settings.guild_id,
banned_words_enabled=True, # Derived from automod_enabled
scam_detection_enabled=guild_settings.automod_enabled,
spam_detection_enabled=guild_settings.anti_spam_enabled,
invite_filter_enabled=guild_settings.link_filter_enabled,
max_mentions=guild_settings.mention_limit,
max_emojis=10, # Default value
spam_threshold=guild_settings.message_rate_limit,
)
@router.put(
"/{guild_id}/automod",
response_model=AutomodRuleConfig,
dependencies=[Depends(require_owner_dep)],
)
async def update_automod_config(
guild_id: int = Path(...),
automod_data: AutomodRuleConfig = ...,
session: AsyncSession = Depends(get_session),
) -> AutomodRuleConfig:
"""Update automod rule configuration."""
query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id)
result = await session.execute(query)
guild_settings = result.scalar_one_or_none()
if not guild_settings:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Guild settings not found",
)
# Update automod settings
guild_settings.automod_enabled = automod_data.scam_detection_enabled
guild_settings.anti_spam_enabled = automod_data.spam_detection_enabled
guild_settings.link_filter_enabled = automod_data.invite_filter_enabled
guild_settings.mention_limit = automod_data.max_mentions
guild_settings.message_rate_limit = automod_data.spam_threshold
await session.commit()
await session.refresh(guild_settings)
return AutomodRuleConfig(
guild_id=guild_settings.guild_id,
banned_words_enabled=automod_data.banned_words_enabled,
scam_detection_enabled=guild_settings.automod_enabled,
spam_detection_enabled=guild_settings.anti_spam_enabled,
invite_filter_enabled=guild_settings.link_filter_enabled,
max_mentions=guild_settings.mention_limit,
max_emojis=10,
spam_threshold=guild_settings.message_rate_limit,
)
@router.get(
"/{guild_id}/export",
dependencies=[Depends(require_owner_dep)],
)
async def export_config(
guild_id: int = Path(...),
session: AsyncSession = Depends(get_session),
) -> StreamingResponse:
"""Export guild configuration as JSON."""
query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id)
result = await session.execute(query)
guild_settings = result.scalar_one_or_none()
if not guild_settings:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Guild settings not found",
)
# Build export data
export_data = ConfigExport(
version="1.0",
guild_settings=GuildSettings(
guild_id=guild_settings.guild_id,
prefix=guild_settings.prefix,
log_channel_id=guild_settings.log_channel_id,
automod_enabled=guild_settings.automod_enabled,
ai_moderation_enabled=guild_settings.ai_moderation_enabled,
ai_sensitivity=guild_settings.ai_sensitivity,
verification_enabled=guild_settings.verification_enabled,
verification_role_id=guild_settings.verified_role_id,
max_warns_before_action=3,
),
automod_rules=AutomodRuleConfig(
guild_id=guild_settings.guild_id,
banned_words_enabled=True,
scam_detection_enabled=guild_settings.automod_enabled,
spam_detection_enabled=guild_settings.anti_spam_enabled,
invite_filter_enabled=guild_settings.link_filter_enabled,
max_mentions=guild_settings.mention_limit,
max_emojis=10,
spam_threshold=guild_settings.message_rate_limit,
),
exported_at=datetime.now(),
)
# Convert to JSON
json_data = export_data.model_dump_json(indent=2)
return StreamingResponse(
iter([json_data]),
media_type="application/json",
headers={"Content-Disposition": f"attachment; filename=guild_{guild_id}_config.json"},
)
@router.post(
"/{guild_id}/import",
response_model=GuildSettings,
dependencies=[Depends(require_owner_dep)],
)
async def import_config(
guild_id: int = Path(...),
config_data: ConfigExport = ...,
session: AsyncSession = Depends(get_session),
) -> GuildSettings:
"""Import guild configuration from JSON."""
query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id)
result = await session.execute(query)
guild_settings = result.scalar_one_or_none()
if not guild_settings:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Guild settings not found",
)
# Import settings
settings = config_data.guild_settings
if settings.prefix is not None:
guild_settings.prefix = settings.prefix
if settings.log_channel_id is not None:
guild_settings.log_channel_id = settings.log_channel_id
guild_settings.automod_enabled = settings.automod_enabled
guild_settings.ai_moderation_enabled = settings.ai_moderation_enabled
guild_settings.ai_sensitivity = settings.ai_sensitivity
guild_settings.verification_enabled = settings.verification_enabled
if settings.verification_role_id is not None:
guild_settings.verified_role_id = settings.verification_role_id
# Import automod rules
automod = config_data.automod_rules
guild_settings.anti_spam_enabled = automod.spam_detection_enabled
guild_settings.link_filter_enabled = automod.invite_filter_enabled
guild_settings.mention_limit = automod.max_mentions
guild_settings.message_rate_limit = automod.spam_threshold
await session.commit()
await session.refresh(guild_settings)
return GuildSettings(
guild_id=guild_settings.guild_id,
prefix=guild_settings.prefix,
log_channel_id=guild_settings.log_channel_id,
automod_enabled=guild_settings.automod_enabled,
ai_moderation_enabled=guild_settings.ai_moderation_enabled,
ai_sensitivity=guild_settings.ai_sensitivity,
verification_enabled=guild_settings.verification_enabled,
verification_role_id=guild_settings.verified_role_id,
max_warns_before_action=3,
)
return router

View File

@@ -0,0 +1,24 @@
"""Database helpers for the dashboard."""
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from guardden.dashboard.config import DashboardSettings
class DashboardDatabase:
"""Async database session factory for the dashboard."""
def __init__(self, settings: DashboardSettings) -> None:
db_url = settings.database_url.get_secret_value()
if db_url.startswith("postgresql://"):
db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1)
self._engine = create_async_engine(db_url, pool_pre_ping=True)
self._sessionmaker = async_sessionmaker(self._engine, expire_on_commit=False)
async def session(self) -> AsyncIterator[AsyncSession]:
"""Yield a database session."""
async with self._sessionmaker() as session:
yield session

View File

@@ -0,0 +1,121 @@
"""FastAPI app for the GuardDen dashboard."""
import logging
import secrets
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from starlette.staticfiles import StaticFiles
from guardden.dashboard.analytics import create_analytics_router
from guardden.dashboard.auth import (
build_oauth,
discord_authorize_url,
exchange_discord_code,
require_owner,
)
from guardden.dashboard.config import DashboardSettings, get_dashboard_settings
from guardden.dashboard.config_management import create_config_router
from guardden.dashboard.db import DashboardDatabase
from guardden.dashboard.routes import create_api_router
from guardden.dashboard.users import create_users_router
from guardden.dashboard.websocket import create_websocket_router
logger = logging.getLogger(__name__)
def create_app() -> FastAPI:
settings = get_dashboard_settings()
database = DashboardDatabase(settings)
oauth = build_oauth(settings)
app = FastAPI(title="GuardDen Dashboard")
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key.get_secret_value())
if settings.cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def require_owner_dep(request: Request) -> None:
require_owner(settings, request)
@app.get("/api/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/me")
async def me(request: Request) -> dict[str, bool | str | None]:
entra_oid = request.session.get("entra_oid")
discord_id = request.session.get("discord_id")
owner = str(entra_oid) == settings.owner_entra_object_id and str(discord_id) == str(
settings.owner_discord_id
)
return {
"entra": bool(entra_oid),
"discord": bool(discord_id),
"owner": owner,
"entra_oid": entra_oid,
"discord_id": discord_id,
}
@app.get("/auth/entra/login")
async def entra_login(request: Request) -> RedirectResponse:
redirect_uri = settings.callback_url("entra")
return await oauth.entra.authorize_redirect(request, redirect_uri)
@app.get("/auth/entra/callback")
async def entra_callback(request: Request) -> RedirectResponse:
token = await oauth.entra.authorize_access_token(request)
user = await oauth.entra.parse_id_token(request, token)
request.session["entra_oid"] = user.get("oid")
return RedirectResponse(url="/")
@app.get("/auth/discord/login")
async def discord_login(request: Request) -> RedirectResponse:
state = secrets.token_urlsafe(16)
request.session["discord_state"] = state
return RedirectResponse(url=discord_authorize_url(settings, state))
@app.get("/auth/discord/callback")
async def discord_callback(request: Request) -> RedirectResponse:
params = dict(request.query_params)
code = params.get("code")
state = params.get("state")
if not code or not state:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing code")
if state != request.session.get("discord_state"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid state")
profile = await exchange_discord_code(settings, code)
request.session["discord_id"] = profile.get("id")
return RedirectResponse(url="/")
@app.get("/auth/logout")
async def logout(request: Request) -> RedirectResponse:
request.session.clear()
return RedirectResponse(url="/")
# Include all API routers
app.include_router(create_api_router(settings, database))
app.include_router(create_analytics_router(settings, database))
app.include_router(create_users_router(settings, database))
app.include_router(create_config_router(settings, database))
app.include_router(create_websocket_router(settings))
static_dir = Path(settings.static_dir)
if static_dir.exists():
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
else:
logger.warning("Static directory not found: %s", static_dir)
return app
app = create_app()

View File

@@ -0,0 +1,87 @@
"""API routes for the GuardDen dashboard."""
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from guardden.dashboard.auth import require_owner
from guardden.dashboard.config import DashboardSettings
from guardden.dashboard.db import DashboardDatabase
from guardden.dashboard.schemas import GuildSummary, ModerationLogEntry, PaginatedLogs
from guardden.models import Guild, ModerationLog
def create_api_router(
settings: DashboardSettings,
database: DashboardDatabase,
) -> APIRouter:
"""Create the dashboard API router."""
router = APIRouter(prefix="/api")
async def get_session() -> AsyncIterator[AsyncSession]:
async for session in database.session():
yield session
def require_owner_dep(request: Request) -> None:
require_owner(settings, request)
@router.get("/guilds", response_model=list[GuildSummary], dependencies=[Depends(require_owner_dep)])
async def list_guilds(
session: AsyncSession = Depends(get_session),
) -> list[GuildSummary]:
result = await session.execute(select(Guild).order_by(Guild.name.asc()))
guilds = result.scalars().all()
return [
GuildSummary(id=g.id, name=g.name, owner_id=g.owner_id, premium=g.premium)
for g in guilds
]
@router.get(
"/moderation/logs",
response_model=PaginatedLogs,
dependencies=[Depends(require_owner_dep)],
)
async def list_moderation_logs(
guild_id: int | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
session: AsyncSession = Depends(get_session),
) -> PaginatedLogs:
query = select(ModerationLog)
count_query = select(func.count(ModerationLog.id))
if guild_id:
query = query.where(ModerationLog.guild_id == guild_id)
count_query = count_query.where(ModerationLog.guild_id == guild_id)
query = query.order_by(ModerationLog.created_at.desc()).offset(offset).limit(limit)
total_result = await session.execute(count_query)
total = int(total_result.scalar() or 0)
result = await session.execute(query)
logs = result.scalars().all()
items = [
ModerationLogEntry(
id=log.id,
guild_id=log.guild_id,
target_id=log.target_id,
target_name=log.target_name,
moderator_id=log.moderator_id,
moderator_name=log.moderator_name,
action=log.action,
reason=log.reason,
duration=log.duration,
expires_at=log.expires_at,
channel_id=log.channel_id,
message_id=log.message_id,
message_content=log.message_content,
is_automatic=log.is_automatic,
created_at=log.created_at,
)
for log in logs
]
return PaginatedLogs(total=total, items=items)
return router

View File

@@ -0,0 +1,163 @@
"""Pydantic schemas for dashboard APIs."""
from datetime import datetime
from pydantic import BaseModel, Field
class GuildSummary(BaseModel):
id: int
name: str
owner_id: int
premium: bool
class ModerationLogEntry(BaseModel):
id: int
guild_id: int
target_id: int
target_name: str
moderator_id: int
moderator_name: str
action: str
reason: str | None
duration: int | None
expires_at: datetime | None
channel_id: int | None
message_id: int | None
message_content: str | None
is_automatic: bool
created_at: datetime
class PaginatedLogs(BaseModel):
total: int
items: list[ModerationLogEntry]
# Analytics Schemas
class TimeSeriesDataPoint(BaseModel):
timestamp: datetime
value: int
class ModerationStats(BaseModel):
total_actions: int
actions_by_type: dict[str, int]
actions_over_time: list[TimeSeriesDataPoint]
automatic_vs_manual: dict[str, int]
class UserActivityStats(BaseModel):
active_users: int
total_messages: int
new_joins_today: int
new_joins_week: int
class AIPerformanceStats(BaseModel):
total_checks: int
flagged_content: int
avg_confidence: float
false_positives: int = 0
avg_response_time_ms: float = 0.0
class AnalyticsSummary(BaseModel):
moderation_stats: ModerationStats
user_activity: UserActivityStats
ai_performance: AIPerformanceStats
# User Management Schemas
class UserProfile(BaseModel):
user_id: int
username: str
strike_count: int
total_warnings: int
total_kicks: int
total_bans: int
total_timeouts: int
first_seen: datetime
last_action: datetime | None
class UserNote(BaseModel):
id: int
user_id: int
guild_id: int
moderator_id: int
moderator_name: str
content: str
created_at: datetime
class CreateUserNote(BaseModel):
content: str = Field(min_length=1, max_length=2000)
class BulkModerationAction(BaseModel):
action: str = Field(pattern="^(ban|kick|timeout|warn)$")
user_ids: list[int] = Field(min_length=1, max_length=100)
reason: str | None = None
duration: int | None = None
class BulkActionResult(BaseModel):
success_count: int
failed_count: int
errors: dict[int, str]
# Configuration Schemas
class GuildSettings(BaseModel):
guild_id: int
prefix: str | None = None
log_channel_id: int | None = None
automod_enabled: bool = True
ai_moderation_enabled: bool = False
ai_sensitivity: int = Field(ge=0, le=100, default=50)
verification_enabled: bool = False
verification_role_id: int | None = None
max_warns_before_action: int = Field(ge=1, le=10, default=3)
class AutomodRuleConfig(BaseModel):
guild_id: int
banned_words_enabled: bool = True
scam_detection_enabled: bool = True
spam_detection_enabled: bool = True
invite_filter_enabled: bool = False
max_mentions: int = Field(ge=1, le=20, default=5)
max_emojis: int = Field(ge=1, le=50, default=10)
spam_threshold: int = Field(ge=1, le=20, default=5)
class ConfigExport(BaseModel):
version: str = "1.0"
guild_settings: GuildSettings
automod_rules: AutomodRuleConfig
exported_at: datetime
# WebSocket Event Schemas
class WebSocketEvent(BaseModel):
type: str
guild_id: int
timestamp: datetime
data: dict[str, object]
class ModerationEvent(WebSocketEvent):
type: str = "moderation_action"
data: dict[str, object]
class UserJoinEvent(WebSocketEvent):
type: str = "user_join"
data: dict[str, object]
class AIAlertEvent(WebSocketEvent):
type: str = "ai_alert"
data: dict[str, object]

View File

@@ -0,0 +1,246 @@
"""User management API routes for the GuardDen dashboard."""
from collections.abc import AsyncIterator
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from guardden.dashboard.auth import require_owner
from guardden.dashboard.config import DashboardSettings
from guardden.dashboard.db import DashboardDatabase
from guardden.dashboard.schemas import CreateUserNote, UserNote, UserProfile
from guardden.models import ModerationLog, UserActivity
from guardden.models import UserNote as UserNoteModel
def create_users_router(
settings: DashboardSettings,
database: DashboardDatabase,
) -> APIRouter:
"""Create the user management API router."""
router = APIRouter(prefix="/api/users")
async def get_session() -> AsyncIterator[AsyncSession]:
async for session in database.session():
yield session
def require_owner_dep(request: Request) -> None:
require_owner(settings, request)
@router.get(
"/search",
response_model=list[UserProfile],
dependencies=[Depends(require_owner_dep)],
)
async def search_users(
guild_id: int = Query(...),
username: str | None = Query(default=None),
min_strikes: int | None = Query(default=None, ge=0),
limit: int = Query(default=50, ge=1, le=200),
session: AsyncSession = Depends(get_session),
) -> list[UserProfile]:
"""Search for users in a guild with optional filters."""
query = select(UserActivity).where(UserActivity.guild_id == guild_id)
if username:
query = query.where(UserActivity.username.ilike(f"%{username}%"))
if min_strikes is not None:
query = query.where(UserActivity.strike_count >= min_strikes)
query = query.order_by(UserActivity.last_seen.desc()).limit(limit)
result = await session.execute(query)
users = result.scalars().all()
# Get last moderation action for each user
profiles = []
for user in users:
last_action_query = (
select(ModerationLog.created_at)
.where(ModerationLog.guild_id == guild_id)
.where(ModerationLog.target_id == user.user_id)
.order_by(ModerationLog.created_at.desc())
.limit(1)
)
last_action_result = await session.execute(last_action_query)
last_action = last_action_result.scalar()
profiles.append(
UserProfile(
user_id=user.user_id,
username=user.username,
strike_count=user.strike_count,
total_warnings=user.warning_count,
total_kicks=user.kick_count,
total_bans=user.ban_count,
total_timeouts=user.timeout_count,
first_seen=user.first_seen,
last_action=last_action,
)
)
return profiles
@router.get(
"/{user_id}/profile",
response_model=UserProfile,
dependencies=[Depends(require_owner_dep)],
)
async def get_user_profile(
user_id: int = Path(...),
guild_id: int = Query(...),
session: AsyncSession = Depends(get_session),
) -> UserProfile:
"""Get detailed profile for a specific user."""
query = (
select(UserActivity)
.where(UserActivity.guild_id == guild_id)
.where(UserActivity.user_id == user_id)
)
result = await session.execute(query)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found in this guild",
)
# Get last moderation action
last_action_query = (
select(ModerationLog.created_at)
.where(ModerationLog.guild_id == guild_id)
.where(ModerationLog.target_id == user_id)
.order_by(ModerationLog.created_at.desc())
.limit(1)
)
last_action_result = await session.execute(last_action_query)
last_action = last_action_result.scalar()
return UserProfile(
user_id=user.user_id,
username=user.username,
strike_count=user.strike_count,
total_warnings=user.warning_count,
total_kicks=user.kick_count,
total_bans=user.ban_count,
total_timeouts=user.timeout_count,
first_seen=user.first_seen,
last_action=last_action,
)
@router.get(
"/{user_id}/notes",
response_model=list[UserNote],
dependencies=[Depends(require_owner_dep)],
)
async def get_user_notes(
user_id: int = Path(...),
guild_id: int = Query(...),
session: AsyncSession = Depends(get_session),
) -> list[UserNote]:
"""Get all notes for a specific user."""
query = (
select(UserNoteModel)
.where(UserNoteModel.guild_id == guild_id)
.where(UserNoteModel.user_id == user_id)
.order_by(UserNoteModel.created_at.desc())
)
result = await session.execute(query)
notes = result.scalars().all()
return [
UserNote(
id=note.id,
user_id=note.user_id,
guild_id=note.guild_id,
moderator_id=note.moderator_id,
moderator_name=note.moderator_name,
content=note.content,
created_at=note.created_at,
)
for note in notes
]
@router.post(
"/{user_id}/notes",
response_model=UserNote,
dependencies=[Depends(require_owner_dep)],
)
async def create_user_note(
user_id: int = Path(...),
guild_id: int = Query(...),
note_data: CreateUserNote = ...,
request: Request = ...,
session: AsyncSession = Depends(get_session),
) -> UserNote:
"""Create a new note for a user."""
# Get moderator info from session
moderator_id = request.session.get("discord_id")
if not moderator_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Discord authentication required",
)
# Create the note
new_note = UserNoteModel(
user_id=user_id,
guild_id=guild_id,
moderator_id=int(moderator_id),
moderator_name="Dashboard User", # TODO: Fetch actual username
content=note_data.content,
created_at=datetime.now(),
)
session.add(new_note)
await session.commit()
await session.refresh(new_note)
return UserNote(
id=new_note.id,
user_id=new_note.user_id,
guild_id=new_note.guild_id,
moderator_id=new_note.moderator_id,
moderator_name=new_note.moderator_name,
content=new_note.content,
created_at=new_note.created_at,
)
@router.delete(
"/{user_id}/notes/{note_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_owner_dep)],
)
async def delete_user_note(
user_id: int = Path(...),
note_id: int = Path(...),
guild_id: int = Query(...),
session: AsyncSession = Depends(get_session),
) -> None:
"""Delete a user note."""
query = (
select(UserNoteModel)
.where(UserNoteModel.id == note_id)
.where(UserNoteModel.guild_id == guild_id)
.where(UserNoteModel.user_id == user_id)
)
result = await session.execute(query)
note = result.scalar_one_or_none()
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Note not found",
)
await session.delete(note)
await session.commit()
return router

View File

@@ -0,0 +1,221 @@
"""WebSocket support for real-time dashboard updates."""
import asyncio
import logging
from datetime import datetime
from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from guardden.dashboard.config import DashboardSettings
from guardden.dashboard.schemas import WebSocketEvent
logger = logging.getLogger(__name__)
class ConnectionManager:
"""Manage WebSocket connections for real-time updates."""
def __init__(self) -> None:
self.active_connections: dict[int, list[WebSocket]] = {}
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket, guild_id: int) -> None:
"""Accept a new WebSocket connection."""
await websocket.accept()
async with self._lock:
if guild_id not in self.active_connections:
self.active_connections[guild_id] = []
self.active_connections[guild_id].append(websocket)
logger.info("New WebSocket connection for guild %s", guild_id)
async def disconnect(self, websocket: WebSocket, guild_id: int) -> None:
"""Remove a WebSocket connection."""
async with self._lock:
if guild_id in self.active_connections:
if websocket in self.active_connections[guild_id]:
self.active_connections[guild_id].remove(websocket)
if not self.active_connections[guild_id]:
del self.active_connections[guild_id]
logger.info("WebSocket disconnected for guild %s", guild_id)
async def broadcast_to_guild(self, guild_id: int, event: WebSocketEvent) -> None:
"""Broadcast an event to all connections for a specific guild."""
async with self._lock:
connections = self.active_connections.get(guild_id, []).copy()
if not connections:
return
# Convert event to JSON
message = event.model_dump_json()
# Send to all connections
dead_connections = []
for connection in connections:
try:
await connection.send_text(message)
except Exception as e:
logger.warning("Failed to send message to WebSocket: %s", e)
dead_connections.append(connection)
# Clean up dead connections
if dead_connections:
async with self._lock:
if guild_id in self.active_connections:
for conn in dead_connections:
if conn in self.active_connections[guild_id]:
self.active_connections[guild_id].remove(conn)
if not self.active_connections[guild_id]:
del self.active_connections[guild_id]
async def broadcast_to_all(self, event: WebSocketEvent) -> None:
"""Broadcast an event to all connections."""
async with self._lock:
all_guilds = list(self.active_connections.keys())
for guild_id in all_guilds:
await self.broadcast_to_guild(guild_id, event)
def get_connection_count(self, guild_id: int | None = None) -> int:
"""Get the number of active connections."""
if guild_id is not None:
return len(self.active_connections.get(guild_id, []))
return sum(len(conns) for conns in self.active_connections.values())
# Global connection manager
connection_manager = ConnectionManager()
def create_websocket_router(settings: DashboardSettings) -> APIRouter:
"""Create the WebSocket API router."""
router = APIRouter()
@router.websocket("/ws/events")
async def websocket_events(websocket: WebSocket, guild_id: int) -> None:
"""WebSocket endpoint for real-time events."""
await connection_manager.connect(websocket, guild_id)
try:
# Send initial connection confirmation
await websocket.send_json(
{
"type": "connected",
"guild_id": guild_id,
"timestamp": datetime.now().isoformat(),
"data": {"message": "Connected to real-time events"},
}
)
# Keep connection alive and handle incoming messages
while True:
try:
# Wait for messages from client (ping/pong, etc.)
data = await asyncio.wait_for(websocket.receive_text(), timeout=30.0)
# Echo back as heartbeat
if data == "ping":
await websocket.send_text("pong")
except asyncio.TimeoutError:
# Send periodic ping to keep connection alive
await websocket.send_json(
{
"type": "ping",
"guild_id": guild_id,
"timestamp": datetime.now().isoformat(),
"data": {},
}
)
except WebSocketDisconnect:
logger.info("Client disconnected from WebSocket for guild %s", guild_id)
except Exception as e:
logger.error("WebSocket error for guild %s: %s", guild_id, e)
finally:
await connection_manager.disconnect(websocket, guild_id)
return router
# Helper functions to broadcast events from other parts of the application
async def broadcast_moderation_action(
guild_id: int,
action: str,
target_id: int,
target_name: str,
moderator_name: str,
reason: str | None = None,
) -> None:
"""Broadcast a moderation action event."""
event = WebSocketEvent(
type="moderation_action",
guild_id=guild_id,
timestamp=datetime.now(),
data={
"action": action,
"target_id": target_id,
"target_name": target_name,
"moderator_name": moderator_name,
"reason": reason,
},
)
await connection_manager.broadcast_to_guild(guild_id, event)
async def broadcast_user_join(
guild_id: int,
user_id: int,
username: str,
) -> None:
"""Broadcast a user join event."""
event = WebSocketEvent(
type="user_join",
guild_id=guild_id,
timestamp=datetime.now(),
data={
"user_id": user_id,
"username": username,
},
)
await connection_manager.broadcast_to_guild(guild_id, event)
async def broadcast_ai_alert(
guild_id: int,
user_id: int,
severity: str,
category: str,
confidence: float,
) -> None:
"""Broadcast an AI moderation alert."""
event = WebSocketEvent(
type="ai_alert",
guild_id=guild_id,
timestamp=datetime.now(),
data={
"user_id": user_id,
"severity": severity,
"category": category,
"confidence": confidence,
},
)
await connection_manager.broadcast_to_guild(guild_id, event)
async def broadcast_system_event(
event_type: str,
data: dict[str, Any],
guild_id: int | None = None,
) -> None:
"""Broadcast a generic system event."""
event = WebSocketEvent(
type=event_type,
guild_id=guild_id or 0,
timestamp=datetime.now(),
data=data,
)
if guild_id:
await connection_manager.broadcast_to_guild(guild_id, event)
else:
await connection_manager.broadcast_to_all(event)