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:
copilot-swe-agent[bot]
2026-03-26 15:53:24 +00:00
parent 37b0af2eb7
commit 979b7b6cec
12 changed files with 1393 additions and 10 deletions
+249 -2
View File
@@ -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()
+27
View File
@@ -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)
+1 -1
View File
@@ -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
+57
View File
@@ -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
+2 -2
View File
@@ -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)