Add admin interface, auto-promote admin email, fix sender name
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/98c1f90b-0287-4908-a0be-ad066c453cc5 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -1,12 +1,27 @@
|
||||
"""Admin endpoints"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
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
|
||||
from app.models.database_models import (
|
||||
User,
|
||||
MailAccount,
|
||||
ProcessingRun,
|
||||
SubscriptionPlan,
|
||||
SubscriptionTier,
|
||||
)
|
||||
from app.models.schemas import (
|
||||
AdminUserListResponse,
|
||||
AdminUserUpdate,
|
||||
UserDetailResponse,
|
||||
SubscriptionPlanResponse,
|
||||
SubscriptionPlanCreate,
|
||||
SubscriptionPlanUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -35,3 +50,235 @@ async def get_admin_stats(
|
||||
"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()
|
||||
|
||||
@@ -57,6 +57,10 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
),
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
is_active=True,
|
||||
is_superuser=(
|
||||
settings.ADMIN_EMAIL is not None
|
||||
and user_in.email.lower() == settings.ADMIN_EMAIL.lower()
|
||||
),
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
@@ -101,6 +105,16 @@ async def login(
|
||||
|
||||
# Update last login
|
||||
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
|
||||
# Auto-promote to superuser if this is the configured admin email
|
||||
if (
|
||||
settings.ADMIN_EMAIL is not None
|
||||
and user.email.lower() == settings.ADMIN_EMAIL.lower()
|
||||
and not user.is_superuser
|
||||
):
|
||||
user.is_superuser = True # type: ignore[assignment]
|
||||
logger.info(f"Auto-promoted admin user: {user.email}")
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Create tokens
|
||||
@@ -149,6 +163,15 @@ async def google_oauth(
|
||||
# Update last login
|
||||
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
|
||||
# Auto-promote to superuser if this is the configured admin email
|
||||
if (
|
||||
settings.ADMIN_EMAIL is not None
|
||||
and user.email.lower() == settings.ADMIN_EMAIL.lower()
|
||||
and not user.is_superuser
|
||||
):
|
||||
user.is_superuser = True # type: ignore[assignment]
|
||||
logger.info(f"Auto-promoted admin user via Google OAuth: {user.email}")
|
||||
|
||||
logger.info(f"Existing user logged in with Google: {user.email}")
|
||||
else:
|
||||
# Create new user
|
||||
@@ -160,6 +183,10 @@ async def google_oauth(
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
is_active=True,
|
||||
last_login_at=datetime.now(timezone.utc),
|
||||
is_superuser=(
|
||||
settings.ADMIN_EMAIL is not None
|
||||
and email.lower() == settings.ADMIN_EMAIL.lower()
|
||||
),
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ class Settings(BaseSettings):
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
# Admin
|
||||
ADMIN_EMAIL: Optional[str] = None
|
||||
ADMIN_EMAIL: Optional[str] = "christianlouis@gmail.com"
|
||||
ADMIN_PASSWORD: Optional[str] = None
|
||||
|
||||
# Mail Server Presets
|
||||
|
||||
@@ -74,6 +74,7 @@ class UserDetailResponse(UserResponse):
|
||||
stripe_customer_id: Optional[str] = None
|
||||
subscription_expires_at: Optional[datetime] = None
|
||||
last_login_at: Optional[datetime] = None
|
||||
is_superuser: bool = False
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -377,3 +378,59 @@ class GmailAuthorizeResponse(BaseModel):
|
||||
class GmailCallbackRequest(BaseModel):
|
||||
code: str
|
||||
redirect_uri: str
|
||||
|
||||
|
||||
# Admin Schemas
|
||||
|
||||
|
||||
class AdminUserListResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
full_name: Optional[str] = None
|
||||
is_active: bool
|
||||
is_superuser: bool
|
||||
subscription_tier: SubscriptionTier
|
||||
subscription_status: str
|
||||
google_id: Optional[str] = None
|
||||
oauth_provider: Optional[str] = None
|
||||
last_login_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
mail_account_count: int = 0
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AdminUserUpdate(BaseModel):
|
||||
full_name: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_superuser: Optional[bool] = None
|
||||
subscription_tier: Optional[SubscriptionTier] = None
|
||||
subscription_status: Optional[str] = None
|
||||
|
||||
|
||||
class SubscriptionPlanCreate(BaseModel):
|
||||
tier: SubscriptionTier
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
price_monthly: float = 0.0
|
||||
price_yearly: Optional[float] = None
|
||||
max_mail_accounts: int = 1
|
||||
max_emails_per_day: int = 1000
|
||||
check_interval_minutes: int = 5
|
||||
support_level: str = "community"
|
||||
features: Optional[Dict[str, Any]] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class SubscriptionPlanUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
price_monthly: Optional[float] = None
|
||||
price_yearly: Optional[float] = None
|
||||
max_mail_accounts: Optional[int] = None
|
||||
max_emails_per_day: Optional[int] = None
|
||||
check_interval_minutes: Optional[int] = None
|
||||
support_level: Optional[str] = None
|
||||
features: Optional[Dict[str, Any]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
@@ -276,12 +276,12 @@ class GmailService:
|
||||
delivery is functioning as expected. Feel free to delete it.
|
||||
|
||||
Best regards,
|
||||
Christian Loris
|
||||
Christian Krakau-Louis
|
||||
DocuElevate
|
||||
""")
|
||||
|
||||
msg = MIMEText(body, "plain", "utf-8")
|
||||
msg["From"] = "Christian Loris <christian@docuelevate.org>"
|
||||
msg["From"] = "Christian Krakau-Louis <christian@docuelevate.org>"
|
||||
msg["To"] = recipient_email
|
||||
msg["Subject"] = subject
|
||||
msg["Date"] = format_datetime(now)
|
||||
|
||||
Reference in New Issue
Block a user