bbd2febfc1
- Add name + apprise_url columns to NotificationConfig model
- Add AdminNotificationConfig model for system-wide alert channels
- Create NotificationService with send_user_notification,
send_admin_notification, and test_notification helpers
- Replace stub notifications.py with full CRUD + /test endpoint
- Add admin notification config CRUD + /test to admin.py
- Update NotificationConfig schemas: name required, apprise_url
optional, config optional (default {})
- Add AdminNotificationConfig* schemas
- Integrate send_user_notification into process_mail_account task
for Gmail revocation, forwarding failures, and exceptions
- Update CHANGELOG.md
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
403 lines
14 KiB
Python
403 lines
14 KiB
Python
"""Admin endpoints"""
|
|
|
|
from typing import List
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func
|
|
|
|
from app.core.database import get_db
|
|
from app.core.deps import get_current_superuser
|
|
from app.models.database_models import (
|
|
User,
|
|
MailAccount,
|
|
ProcessingRun,
|
|
SubscriptionPlan,
|
|
SubscriptionTier,
|
|
AdminNotificationConfig,
|
|
)
|
|
from app.models.schemas import (
|
|
AdminUserListResponse,
|
|
AdminUserUpdate,
|
|
UserDetailResponse,
|
|
SubscriptionPlanResponse,
|
|
SubscriptionPlanCreate,
|
|
SubscriptionPlanUpdate,
|
|
AdminNotificationConfigCreate,
|
|
AdminNotificationConfigUpdate,
|
|
AdminNotificationConfigResponse,
|
|
NotificationTestRequest,
|
|
NotificationTestResponse,
|
|
)
|
|
from app.services.notification_service import test_notification
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/stats")
|
|
async def get_admin_stats(
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get overall system statistics (admin only)"""
|
|
|
|
# Count users
|
|
user_count = await db.execute(select(func.count(User.id)))
|
|
total_users = user_count.scalar()
|
|
|
|
# Count accounts
|
|
account_count = await db.execute(select(func.count(MailAccount.id)))
|
|
total_accounts = account_count.scalar()
|
|
|
|
# Count processing runs
|
|
run_count = await db.execute(select(func.count(ProcessingRun.id)))
|
|
total_runs = run_count.scalar()
|
|
|
|
return {
|
|
"total_users": total_users,
|
|
"total_mail_accounts": total_accounts,
|
|
"total_processing_runs": total_runs,
|
|
}
|
|
|
|
|
|
# ── User management ────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/users", response_model=List[AdminUserListResponse])
|
|
async def list_users(
|
|
skip: int = 0,
|
|
limit: int = 100,
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List all users with their mail account counts (admin only)"""
|
|
result = await db.execute(
|
|
select(User).order_by(User.created_at.desc()).offset(skip).limit(limit)
|
|
)
|
|
users = result.scalars().all()
|
|
|
|
# Fetch mail account counts per user in one query
|
|
counts_result = await db.execute(
|
|
select(MailAccount.user_id, func.count(MailAccount.id).label("cnt")).group_by(
|
|
MailAccount.user_id
|
|
)
|
|
)
|
|
counts = {row.user_id: row.cnt for row in counts_result}
|
|
|
|
response = []
|
|
for u in users:
|
|
response.append(
|
|
AdminUserListResponse(
|
|
id=u.id, # type: ignore[arg-type]
|
|
email=u.email, # type: ignore[arg-type]
|
|
full_name=u.full_name, # type: ignore[arg-type]
|
|
is_active=u.is_active, # type: ignore[arg-type]
|
|
is_superuser=u.is_superuser, # type: ignore[arg-type]
|
|
subscription_tier=u.subscription_tier, # type: ignore[arg-type]
|
|
subscription_status=u.subscription_status, # type: ignore[arg-type]
|
|
google_id=u.google_id, # type: ignore[arg-type]
|
|
oauth_provider=u.oauth_provider, # type: ignore[arg-type]
|
|
last_login_at=u.last_login_at, # type: ignore[arg-type]
|
|
created_at=u.created_at, # type: ignore[arg-type]
|
|
mail_account_count=counts.get(u.id, 0), # type: ignore[arg-type]
|
|
)
|
|
)
|
|
return response
|
|
|
|
|
|
@router.get("/users/{user_id}", response_model=UserDetailResponse)
|
|
async def get_user(
|
|
user_id: int,
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get a specific user's details (admin only)"""
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
|
)
|
|
return user
|
|
|
|
|
|
@router.put("/users/{user_id}", response_model=UserDetailResponse)
|
|
async def update_user(
|
|
user_id: int,
|
|
user_update: AdminUserUpdate,
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update a user's details, plan, or admin status (admin only)"""
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
|
)
|
|
|
|
if user_update.full_name is not None:
|
|
user.full_name = user_update.full_name # type: ignore[assignment]
|
|
if user_update.email is not None:
|
|
user.email = user_update.email # type: ignore[assignment]
|
|
if user_update.is_active is not None:
|
|
user.is_active = user_update.is_active # type: ignore[assignment]
|
|
if user_update.is_superuser is not None:
|
|
user.is_superuser = user_update.is_superuser # type: ignore[assignment]
|
|
if user_update.subscription_tier is not None:
|
|
user.subscription_tier = SubscriptionTier(user_update.subscription_tier.value) # type: ignore[assignment]
|
|
if user_update.subscription_status is not None:
|
|
user.subscription_status = user_update.subscription_status # type: ignore[assignment]
|
|
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return user
|
|
|
|
|
|
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_user(
|
|
user_id: int,
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete a user and all their data (admin only)"""
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
|
)
|
|
if user.id == current_user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot delete your own account via admin endpoint",
|
|
)
|
|
await db.delete(user)
|
|
await db.commit()
|
|
|
|
|
|
# ── Plan management ────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/plans", response_model=List[SubscriptionPlanResponse])
|
|
async def list_all_plans(
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List all subscription plans including inactive ones (admin only)"""
|
|
result = await db.execute(select(SubscriptionPlan).order_by(SubscriptionPlan.tier))
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.post(
|
|
"/plans",
|
|
response_model=SubscriptionPlanResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_plan(
|
|
plan_in: SubscriptionPlanCreate,
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Create a new subscription plan (admin only)"""
|
|
existing = await db.execute(
|
|
select(SubscriptionPlan).where(
|
|
SubscriptionPlan.tier == SubscriptionTier(plan_in.tier.value)
|
|
)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"A plan for tier '{plan_in.tier.value}' already exists",
|
|
)
|
|
|
|
plan = SubscriptionPlan(
|
|
tier=SubscriptionTier(plan_in.tier.value),
|
|
name=plan_in.name,
|
|
description=plan_in.description,
|
|
price_monthly=plan_in.price_monthly,
|
|
price_yearly=plan_in.price_yearly,
|
|
max_mail_accounts=plan_in.max_mail_accounts,
|
|
max_emails_per_day=plan_in.max_emails_per_day,
|
|
check_interval_minutes=plan_in.check_interval_minutes,
|
|
support_level=plan_in.support_level,
|
|
features=plan_in.features,
|
|
is_active=plan_in.is_active,
|
|
)
|
|
db.add(plan)
|
|
await db.commit()
|
|
await db.refresh(plan)
|
|
return plan
|
|
|
|
|
|
@router.put("/plans/{plan_id}", response_model=SubscriptionPlanResponse)
|
|
async def update_plan(
|
|
plan_id: int,
|
|
plan_update: SubscriptionPlanUpdate,
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update a subscription plan's limits or pricing (admin only)"""
|
|
result = await db.execute(
|
|
select(SubscriptionPlan).where(SubscriptionPlan.id == plan_id)
|
|
)
|
|
plan = result.scalar_one_or_none()
|
|
if not plan:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found"
|
|
)
|
|
|
|
if plan_update.name is not None:
|
|
plan.name = plan_update.name # type: ignore[assignment]
|
|
if plan_update.description is not None:
|
|
plan.description = plan_update.description # type: ignore[assignment]
|
|
if plan_update.price_monthly is not None:
|
|
plan.price_monthly = plan_update.price_monthly # type: ignore[assignment]
|
|
if plan_update.price_yearly is not None:
|
|
plan.price_yearly = plan_update.price_yearly # type: ignore[assignment]
|
|
if plan_update.max_mail_accounts is not None:
|
|
plan.max_mail_accounts = plan_update.max_mail_accounts # type: ignore[assignment]
|
|
if plan_update.max_emails_per_day is not None:
|
|
plan.max_emails_per_day = plan_update.max_emails_per_day # type: ignore[assignment]
|
|
if plan_update.check_interval_minutes is not None:
|
|
plan.check_interval_minutes = plan_update.check_interval_minutes # type: ignore[assignment]
|
|
if plan_update.support_level is not None:
|
|
plan.support_level = plan_update.support_level # type: ignore[assignment]
|
|
if plan_update.features is not None:
|
|
plan.features = plan_update.features # type: ignore[assignment]
|
|
if plan_update.is_active is not None:
|
|
plan.is_active = plan_update.is_active # type: ignore[assignment]
|
|
|
|
await db.commit()
|
|
await db.refresh(plan)
|
|
return plan
|
|
|
|
|
|
@router.delete("/plans/{plan_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_plan(
|
|
plan_id: int,
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete a subscription plan (admin only)"""
|
|
result = await db.execute(
|
|
select(SubscriptionPlan).where(SubscriptionPlan.id == plan_id)
|
|
)
|
|
plan = result.scalar_one_or_none()
|
|
if not plan:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found"
|
|
)
|
|
await db.delete(plan)
|
|
await db.commit()
|
|
|
|
|
|
# ── Admin notification config management ──────────────────────────────────────
|
|
|
|
|
|
@router.get("/notifications", response_model=List[AdminNotificationConfigResponse])
|
|
async def list_admin_notification_configs(
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List all admin notification configurations (admin only)"""
|
|
result = await db.execute(select(AdminNotificationConfig))
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.post(
|
|
"/notifications",
|
|
response_model=AdminNotificationConfigResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_admin_notification_config(
|
|
config_in: AdminNotificationConfigCreate,
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Create a new admin notification configuration (admin only)"""
|
|
config = AdminNotificationConfig(**config_in.dict())
|
|
db.add(config)
|
|
await db.commit()
|
|
await db.refresh(config)
|
|
return config
|
|
|
|
|
|
@router.get(
|
|
"/notifications/{config_id}", response_model=AdminNotificationConfigResponse
|
|
)
|
|
async def get_admin_notification_config(
|
|
config_id: int,
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get a specific admin notification configuration (admin only)"""
|
|
result = await db.execute(
|
|
select(AdminNotificationConfig).where(AdminNotificationConfig.id == config_id)
|
|
)
|
|
config = result.scalar_one_or_none()
|
|
if not config:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Admin notification config not found",
|
|
)
|
|
return config
|
|
|
|
|
|
@router.put(
|
|
"/notifications/{config_id}", response_model=AdminNotificationConfigResponse
|
|
)
|
|
async def update_admin_notification_config(
|
|
config_id: int,
|
|
config_in: AdminNotificationConfigUpdate,
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update an admin notification configuration (admin only)"""
|
|
result = await db.execute(
|
|
select(AdminNotificationConfig).where(AdminNotificationConfig.id == config_id)
|
|
)
|
|
config = result.scalar_one_or_none()
|
|
if not config:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Admin notification config not found",
|
|
)
|
|
|
|
update_data = config_in.dict(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(config, field, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(config)
|
|
return config
|
|
|
|
|
|
@router.delete("/notifications/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_admin_notification_config(
|
|
config_id: int,
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete an admin notification configuration (admin only)"""
|
|
result = await db.execute(
|
|
select(AdminNotificationConfig).where(AdminNotificationConfig.id == config_id)
|
|
)
|
|
config = result.scalar_one_or_none()
|
|
if not config:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Admin notification config not found",
|
|
)
|
|
await db.delete(config)
|
|
await db.commit()
|
|
|
|
|
|
@router.post("/notifications/test", response_model=NotificationTestResponse)
|
|
async def test_admin_notification_config(
|
|
request: NotificationTestRequest,
|
|
current_user: User = Depends(get_current_superuser),
|
|
):
|
|
"""Test an admin notification channel by sending a test message (admin only)"""
|
|
success, message = await test_notification(request.apprise_url)
|
|
return NotificationTestResponse(success=success, message=message)
|