""" Admin API endpoints for pounce administration. Provides admin-only access to: - User management - TLD price scraping - System statistics - Newsletter management - Domain/Portfolio overview """ from datetime import datetime, timedelta from typing import Optional from fastapi import APIRouter, HTTPException, status, BackgroundTasks, Depends from pydantic import BaseModel, EmailStr from sqlalchemy import select, func, desc from app.api.deps import Database, get_current_user from app.models.user import User from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG from app.models.domain import Domain from app.models.portfolio import PortfolioDomain from app.models.newsletter import NewsletterSubscriber from app.models.tld_price import TLDPrice, TLDInfo from app.models.auction import DomainAuction from app.models.price_alert import PriceAlert router = APIRouter() # ============== Admin Authentication ============== async def require_admin( current_user: User = Depends(get_current_user), ) -> User: """Dependency that requires admin privileges.""" if not current_user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required" ) return current_user # ============== Dashboard Stats ============== @router.get("/stats") async def get_admin_stats( db: Database, admin: User = Depends(require_admin) ): """Get comprehensive admin dashboard statistics.""" # User stats total_users = await db.execute(select(func.count(User.id))) total_users = total_users.scalar() active_users = await db.execute( select(func.count(User.id)).where(User.is_active == True) ) active_users = active_users.scalar() verified_users = await db.execute( select(func.count(User.id)).where(User.is_verified == True) ) verified_users = verified_users.scalar() # New users last 7 days week_ago = datetime.utcnow() - timedelta(days=7) new_users_week = await db.execute( select(func.count(User.id)).where(User.created_at >= week_ago) ) new_users_week = new_users_week.scalar() # Subscription stats subscriptions_by_tier = {} for tier in SubscriptionTier: count = await db.execute( select(func.count(Subscription.id)).where( Subscription.tier == tier, Subscription.status == SubscriptionStatus.ACTIVE ) ) subscriptions_by_tier[tier.value] = count.scalar() # Domain stats total_domains = await db.execute(select(func.count(Domain.id))) total_domains = total_domains.scalar() total_portfolio = await db.execute(select(func.count(PortfolioDomain.id))) total_portfolio = total_portfolio.scalar() # TLD stats total_tlds = await db.execute(select(func.count(func.distinct(TLDPrice.tld)))) total_tlds = total_tlds.scalar() total_price_records = await db.execute(select(func.count(TLDPrice.id))) total_price_records = total_price_records.scalar() # Newsletter stats newsletter_subscribers = await db.execute( select(func.count(NewsletterSubscriber.id)).where(NewsletterSubscriber.is_active == True) ) newsletter_subscribers = newsletter_subscribers.scalar() # Auction stats total_auctions = await db.execute(select(func.count(DomainAuction.id))) total_auctions = total_auctions.scalar() # Price alerts total_alerts = await db.execute( select(func.count(PriceAlert.id)).where(PriceAlert.is_active == True) ) total_alerts = total_alerts.scalar() return { "users": { "total": total_users, "active": active_users, "verified": verified_users, "new_this_week": new_users_week, }, "subscriptions": subscriptions_by_tier, "domains": { "watched": total_domains, "portfolio": total_portfolio, }, "tld_data": { "unique_tlds": total_tlds, "price_records": total_price_records, }, "newsletter_subscribers": newsletter_subscribers, "auctions": total_auctions, "price_alerts": total_alerts, } # ============== User Management ============== class UpdateUserRequest(BaseModel): """Schema for updating user.""" name: Optional[str] = None is_active: Optional[bool] = None is_verified: Optional[bool] = None is_admin: Optional[bool] = None class UpgradeUserRequest(BaseModel): """Request schema for upgrading a user.""" email: EmailStr tier: str # scout, trader, tycoon @router.get("/users") async def list_users( db: Database, admin: User = Depends(require_admin), limit: int = 50, offset: int = 0, search: Optional[str] = None, ): """List all users with pagination and search.""" query = select(User).order_by(desc(User.created_at)) if search: query = query.where( User.email.ilike(f"%{search}%") | User.name.ilike(f"%{search}%") ) query = query.offset(offset).limit(limit) result = await db.execute(query) users = result.scalars().all() # Get total count count_query = select(func.count(User.id)) if search: count_query = count_query.where( User.email.ilike(f"%{search}%") | User.name.ilike(f"%{search}%") ) total = await db.execute(count_query) total = total.scalar() user_list = [] for user in users: # Get subscription sub_result = await db.execute( select(Subscription).where(Subscription.user_id == user.id) ) subscription = sub_result.scalar_one_or_none() # Get domain count domain_count = await db.execute( select(func.count(Domain.id)).where(Domain.user_id == user.id) ) domain_count = domain_count.scalar() user_list.append({ "id": user.id, "email": user.email, "name": user.name, "is_active": user.is_active, "is_verified": user.is_verified, "is_admin": user.is_admin, "created_at": user.created_at.isoformat(), "last_login": user.last_login.isoformat() if user.last_login else None, "domain_count": domain_count, "subscription": { "tier": subscription.tier.value if subscription else "scout", "tier_name": TIER_CONFIG.get(subscription.tier, {}).get("name", "Scout") if subscription else "Scout", "status": subscription.status.value if subscription else None, "domain_limit": subscription.domain_limit if subscription else 5, } if subscription else { "tier": "scout", "tier_name": "Scout", "status": None, "domain_limit": 5, }, }) return { "users": user_list, "total": total, "limit": limit, "offset": offset, } # ============== User Export ============== # NOTE: This must come BEFORE /users/{user_id} to avoid route conflict @router.get("/users/export") async def export_users_csv( db: Database, admin: User = Depends(require_admin), ): """Export all users as CSV data.""" import csv import io result = await db.execute(select(User).order_by(User.created_at)) users_list = result.scalars().all() # Create CSV output = io.StringIO() writer = csv.writer(output) # Header writer.writerow([ "ID", "Email", "Name", "Active", "Verified", "Admin", "Created At", "Last Login", "Tier", "Domain Limit", "Domains Used" ]) for user in users_list: # Get subscription sub_result = await db.execute( select(Subscription).where(Subscription.user_id == user.id) ) subscription = sub_result.scalar_one_or_none() # Get domain count domain_count = await db.execute( select(func.count(Domain.id)).where(Domain.user_id == user.id) ) domain_count = domain_count.scalar() writer.writerow([ user.id, user.email, user.name or "", "Yes" if user.is_active else "No", "Yes" if user.is_verified else "No", "Yes" if user.is_admin else "No", user.created_at.strftime("%Y-%m-%d %H:%M"), user.last_login.strftime("%Y-%m-%d %H:%M") if user.last_login else "", subscription.tier.value if subscription else "scout", subscription.domain_limit if subscription else 5, domain_count, ]) return { "csv": output.getvalue(), "count": len(users_list), "exported_at": datetime.utcnow().isoformat(), } @router.get("/users/{user_id}") async def get_user( user_id: int, db: Database, admin: User = Depends(require_admin), ): """Get detailed user information.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") # Get subscription sub_result = await db.execute( select(Subscription).where(Subscription.user_id == user.id) ) subscription = sub_result.scalar_one_or_none() # Get domains domains_result = await db.execute( select(Domain).where(Domain.user_id == user.id) ) domains = domains_result.scalars().all() # Get portfolio portfolio_result = await db.execute( select(PortfolioDomain).where(PortfolioDomain.user_id == user.id) ) portfolio = portfolio_result.scalars().all() return { "id": user.id, "email": user.email, "name": user.name, "is_active": user.is_active, "is_verified": user.is_verified, "is_admin": user.is_admin, "stripe_customer_id": user.stripe_customer_id, "created_at": user.created_at.isoformat(), "updated_at": user.updated_at.isoformat(), "last_login": user.last_login.isoformat() if user.last_login else None, "subscription": { "tier": subscription.tier.value if subscription else None, "status": subscription.status.value if subscription else None, "domain_limit": subscription.domain_limit if subscription else 0, "stripe_subscription_id": subscription.stripe_subscription_id if subscription else None, } if subscription else None, "domains": [ { "id": d.id, "domain": d.domain, "status": d.status, "created_at": d.created_at.isoformat(), } for d in domains ], "portfolio": [ { "id": p.id, "domain": p.domain, "purchase_price": p.purchase_price, "estimated_value": p.estimated_value, } for p in portfolio ], } @router.patch("/users/{user_id}") async def update_user( user_id: int, request: UpdateUserRequest, db: Database, admin: User = Depends(require_admin), ): """Update user details.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") if request.name is not None: user.name = request.name if request.is_active is not None: user.is_active = request.is_active if request.is_verified is not None: user.is_verified = request.is_verified if request.is_admin is not None: user.is_admin = request.is_admin await db.commit() await db.refresh(user) return {"message": f"User {user.email} updated", "user_id": user.id} @router.delete("/users/{user_id}") async def delete_user( user_id: int, db: Database, admin: User = Depends(require_admin), ): """Delete a user and all their data.""" from app.models.blog import BlogPost from app.models.admin_log import AdminActivityLog result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") if user.is_admin: raise HTTPException(status_code=400, detail="Cannot delete admin user") user_email = user.email # Delete user's blog posts (or set author_id to NULL if you want to keep them) await db.execute( BlogPost.__table__.delete().where(BlogPost.author_id == user_id) ) # Delete user's admin activity logs (if any) await db.execute( AdminActivityLog.__table__.delete().where(AdminActivityLog.admin_id == user_id) ) # Now delete the user (cascades to domains, subscriptions, portfolio, price_alerts) await db.delete(user) await db.commit() # Log this action await log_admin_activity( db, admin.id, "user_delete", f"Deleted user {user_email} and all their data" ) return {"message": f"User {user_email} and all their data have been deleted"} @router.post("/users/{user_id}/upgrade") async def upgrade_user( user_id: int, tier: str, db: Database, admin: User = Depends(require_admin), ): """Upgrade a user's subscription tier.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") # Validate tier try: new_tier = SubscriptionTier(tier) except ValueError: raise HTTPException( status_code=400, detail=f"Invalid tier: {tier}. Valid: scout, trader, tycoon" ) # Find or create subscription sub_result = await db.execute( select(Subscription).where(Subscription.user_id == user.id) ) subscription = sub_result.scalar_one_or_none() config = TIER_CONFIG.get(new_tier, {}) if not subscription: subscription = Subscription( user_id=user.id, tier=new_tier, status=SubscriptionStatus.ACTIVE, domain_limit=config.get("domain_limit", 5), ) db.add(subscription) else: subscription.tier = new_tier subscription.domain_limit = config.get("domain_limit", 5) subscription.status = SubscriptionStatus.ACTIVE await db.commit() return { "message": f"User {user.email} upgraded to {config.get('name', tier)}", "tier": new_tier.value, } # ============== Newsletter Management ============== @router.get("/newsletter") async def list_newsletter_subscribers( db: Database, admin: User = Depends(require_admin), limit: int = 100, offset: int = 0, active_only: bool = True, ): """List newsletter subscribers.""" query = select(NewsletterSubscriber).order_by(desc(NewsletterSubscriber.subscribed_at)) if active_only: query = query.where(NewsletterSubscriber.is_active == True) query = query.offset(offset).limit(limit) result = await db.execute(query) subscribers = result.scalars().all() # Total count count_query = select(func.count(NewsletterSubscriber.id)) if active_only: count_query = count_query.where(NewsletterSubscriber.is_active == True) total = await db.execute(count_query) total = total.scalar() return { "subscribers": [ { "id": s.id, "email": s.email, "is_active": s.is_active, "subscribed_at": s.subscribed_at.isoformat(), "unsubscribed_at": s.unsubscribed_at.isoformat() if s.unsubscribed_at else None, } for s in subscribers ], "total": total, } @router.get("/newsletter/export") async def export_newsletter_emails( db: Database, admin: User = Depends(require_admin), ): """Export all active newsletter emails (for external tools).""" result = await db.execute( select(NewsletterSubscriber.email).where(NewsletterSubscriber.is_active == True) ) emails = [row[0] for row in result.fetchall()] return { "emails": emails, "count": len(emails), } # ============== TLD Management ============== @router.post("/scrape-tld-prices") async def trigger_tld_scrape( db: Database, admin: User = Depends(require_admin), ): """Manually trigger a TLD price scrape.""" from app.services.tld_scraper.aggregator import tld_aggregator result = await tld_aggregator.run_scrape(db) return { "message": "TLD price scrape completed", "status": result.status, "tlds_scraped": result.tlds_scraped, "prices_saved": result.prices_saved, "sources_succeeded": result.sources_succeeded, "sources_attempted": result.sources_attempted, "errors": result.errors, "started_at": result.started_at.isoformat(), "completed_at": result.completed_at.isoformat() if result.completed_at else None, } @router.get("/tld-prices/stats") async def get_tld_price_stats( db: Database, admin: User = Depends(require_admin), ): """Get TLD price data statistics.""" # Total records total = await db.execute(select(func.count(TLDPrice.id))) total_records = total.scalar() # Unique TLDs tlds = await db.execute(select(func.count(func.distinct(TLDPrice.tld)))) unique_tlds = tlds.scalar() # Unique registrars registrars = await db.execute(select(func.count(func.distinct(TLDPrice.registrar)))) unique_registrars = registrars.scalar() # Latest record latest = await db.execute( select(TLDPrice.recorded_at).order_by(desc(TLDPrice.recorded_at)).limit(1) ) latest_record = latest.scalar() # Oldest record oldest = await db.execute( select(TLDPrice.recorded_at).order_by(TLDPrice.recorded_at.asc()).limit(1) ) oldest_record = oldest.scalar() return { "total_records": total_records, "unique_tlds": unique_tlds, "unique_registrars": unique_registrars, "latest_record": latest_record.isoformat() if latest_record else None, "oldest_record": oldest_record.isoformat() if oldest_record else None, "data_range_days": (latest_record - oldest_record).days if latest_record and oldest_record else 0, } # ============== System ============== @router.get("/system/health") async def system_health( db: Database, admin: User = Depends(require_admin), ): """Check system health.""" # Test database try: await db.execute(select(1)) db_status = "healthy" except Exception as e: db_status = f"error: {str(e)}" # Check email config from app.services.email_service import EmailService email_configured = EmailService.is_configured() # Check Stripe config import os stripe_configured = bool(os.getenv("STRIPE_SECRET_KEY")) return { "status": "healthy" if db_status == "healthy" else "degraded", "database": db_status, "email_configured": email_configured, "stripe_configured": stripe_configured, "timestamp": datetime.utcnow().isoformat(), } @router.post("/system/make-admin") async def make_user_admin( email: str, db: Database, ): """ Make a user an admin. NOTE: This endpoint has NO authentication for initial setup! In production, disable after first admin is created. """ result = await db.execute(select(User).where(User.email == email)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") user.is_admin = True await db.commit() return {"message": f"User {email} is now an admin"} # ============== Price Alerts ============== @router.get("/price-alerts") async def list_price_alerts( db: Database, admin: User = Depends(require_admin), limit: int = 100, offset: int = 0, ): """List all active price alerts with user info.""" query = ( select(PriceAlert, User) .join(User, PriceAlert.user_id == User.id) .where(PriceAlert.is_active == True) .order_by(desc(PriceAlert.created_at)) .offset(offset) .limit(limit) ) result = await db.execute(query) alerts = result.all() # Total count count_query = select(func.count(PriceAlert.id)).where(PriceAlert.is_active == True) total = await db.execute(count_query) total = total.scalar() return { "alerts": [ { "id": alert.id, "tld": alert.tld, "target_price": float(alert.target_price) if alert.target_price else None, "alert_type": alert.alert_type, "created_at": alert.created_at.isoformat(), "user": { "id": user.id, "email": user.email, "name": user.name, } } for alert, user in alerts ], "total": total, } # ============== Domain Health ============== @router.post("/domains/check-all") async def trigger_domain_checks( background_tasks: BackgroundTasks, db: Database, admin: User = Depends(require_admin), ): """Manually trigger domain availability checks for all watched domains.""" from app.services.domain_checker import check_all_domains # Count domains to check total_domains = await db.execute(select(func.count(Domain.id))) total_domains = total_domains.scalar() if total_domains == 0: return {"message": "No domains to check", "domains_queued": 0} # Run in background background_tasks.add_task(check_all_domains, db) return { "message": "Domain checks started", "domains_queued": total_domains, "started_at": datetime.utcnow().isoformat(), } # ============== Email Test ============== @router.post("/system/test-email") async def test_email( db: Database, admin: User = Depends(require_admin), ): """Send a test email to the admin user.""" from app.services.email_service import email_service if not email_service.is_configured: raise HTTPException( status_code=400, detail="Email service is not configured. Check SMTP settings." ) try: await email_service.send_email( to_email=admin.email, subject="pounce Admin Panel - Test Email", html_content=f"""
This is a test email from the pounce Admin Panel.
If you received this, your SMTP configuration is working correctly.
Sent at: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}
Admin: {admin.email}