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
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:
1
src/guardden/dashboard/__init__.py
Normal file
1
src/guardden/dashboard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Dashboard application package."""
|
||||
267
src/guardden/dashboard/analytics.py
Normal file
267
src/guardden/dashboard/analytics.py
Normal 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
|
||||
78
src/guardden/dashboard/auth.py
Normal file
78
src/guardden/dashboard/auth.py
Normal 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")
|
||||
68
src/guardden/dashboard/config.py
Normal file
68
src/guardden/dashboard/config.py
Normal 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()
|
||||
298
src/guardden/dashboard/config_management.py
Normal file
298
src/guardden/dashboard/config_management.py
Normal 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
|
||||
24
src/guardden/dashboard/db.py
Normal file
24
src/guardden/dashboard/db.py
Normal 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
|
||||
121
src/guardden/dashboard/main.py
Normal file
121
src/guardden/dashboard/main.py
Normal 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()
|
||||
87
src/guardden/dashboard/routes.py
Normal file
87
src/guardden/dashboard/routes.py
Normal 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
|
||||
163
src/guardden/dashboard/schemas.py
Normal file
163
src/guardden/dashboard/schemas.py
Normal 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]
|
||||
246
src/guardden/dashboard/users.py
Normal file
246
src/guardden/dashboard/users.py
Normal 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
|
||||
221
src/guardden/dashboard/websocket.py
Normal file
221
src/guardden/dashboard/websocket.py
Normal 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)
|
||||
Reference in New Issue
Block a user