Reply directly to this email to respond to {request.first_name}.
from fastapi import FastAPI, APIRouter, Depends, HTTPException, status, Request, UploadFile, File, Form from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from sqlalchemy import or_ from pydantic import BaseModel, EmailStr, Field, validator from typing import List, Optional, Literal from datetime import datetime, timedelta, timezone from dotenv import load_dotenv from pathlib import Path from contextlib import asynccontextmanager import os import logging import uuid import secrets from database import engine, get_db, Base from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument from auth import ( get_password_hash, verify_password, create_access_token, get_current_user, get_current_admin_user, get_active_member, create_password_reset_token, verify_reset_token ) from email_service import ( send_verification_email, send_approval_notification, send_payment_prompt_email, send_password_reset_email, send_admin_password_reset_email ) from payment_service import create_checkout_session, verify_webhook_signature, get_subscription_end_date from r2_storage import get_r2_storage from calendar_service import CalendarService # Load environment variables ROOT_DIR = Path(__file__).parent load_dotenv(ROOT_DIR / '.env') # Create database tables Base.metadata.create_all(bind=engine) # Lifespan event handler (replaces deprecated on_event) @asynccontextmanager async def lifespan(app: FastAPI): # Startup logger.info("Application started") yield # Shutdown logger.info("Application shutdown") # Create the main app app = FastAPI( lifespan=lifespan, root_path="/membership" # Configure for serving under /membership path ) # Create a router with the /api prefix api_router = APIRouter(prefix="/api") # Initialize calendar service calendar_service = CalendarService() # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) # Pydantic Models class RegisterRequest(BaseModel): # Step 1: Personal & Partner Information first_name: str last_name: str phone: str address: str city: str state: str zipcode: str date_of_birth: datetime lead_sources: List[str] partner_first_name: Optional[str] = None partner_last_name: Optional[str] = None partner_is_member: Optional[bool] = False partner_plan_to_become_member: Optional[bool] = False # Step 2: Newsletter, Volunteer & Scholarship referred_by_member_name: Optional[str] = None newsletter_publish_name: bool newsletter_publish_photo: bool newsletter_publish_birthday: bool newsletter_publish_none: bool volunteer_interests: List[str] = [] scholarship_requested: bool = False scholarship_reason: Optional[str] = None # Step 3: Directory Settings show_in_directory: bool = False directory_email: Optional[str] = None directory_bio: Optional[str] = None directory_address: Optional[str] = None directory_phone: Optional[str] = None directory_dob: Optional[datetime] = None directory_partner_name: Optional[str] = None # Step 4: Account Credentials email: EmailStr password: str = Field(min_length=6) @validator('newsletter_publish_none') def validate_newsletter_preferences(cls, v, values): """At least one newsletter preference must be selected""" name = values.get('newsletter_publish_name', False) photo = values.get('newsletter_publish_photo', False) birthday = values.get('newsletter_publish_birthday', False) if not (name or photo or birthday or v): raise ValueError('At least one newsletter publication preference must be selected') return v @validator('scholarship_reason') def validate_scholarship_reason(cls, v, values): """If scholarship requested, reason must be provided""" requested = values.get('scholarship_requested', False) if requested and not v: raise ValueError('Scholarship reason is required when requesting scholarship') return v class LoginRequest(BaseModel): email: EmailStr password: str class LoginResponse(BaseModel): access_token: str token_type: str user: dict class ForgotPasswordRequest(BaseModel): email: EmailStr class ResetPasswordRequest(BaseModel): token: str new_password: str = Field(min_length=6) class ChangePasswordRequest(BaseModel): current_password: str new_password: str = Field(min_length=6) class AdminPasswordUpdateRequest(BaseModel): force_change: bool = True class UserResponse(BaseModel): id: str email: str first_name: str last_name: str phone: str address: str city: str state: str zipcode: str date_of_birth: datetime status: str role: str email_verified: bool created_at: datetime # Subscription info (optional) subscription_start_date: Optional[datetime] = None subscription_end_date: Optional[datetime] = None subscription_status: Optional[str] = None # Partner information partner_first_name: Optional[str] = None partner_last_name: Optional[str] = None partner_is_member: Optional[bool] = None partner_plan_to_become_member: Optional[bool] = None # Newsletter preferences newsletter_publish_name: Optional[bool] = None newsletter_publish_photo: Optional[bool] = None newsletter_publish_birthday: Optional[bool] = None newsletter_publish_none: Optional[bool] = None # Volunteer interests volunteer_interests: Optional[list] = None # Directory settings show_in_directory: Optional[bool] = None directory_email: Optional[str] = None directory_bio: Optional[str] = None directory_address: Optional[str] = None directory_phone: Optional[str] = None directory_dob: Optional[datetime] = None directory_partner_name: Optional[str] = None model_config = {"from_attributes": True} @validator('id', 'status', 'role', pre=True) def convert_to_string(cls, v): """Convert UUID and Enum types to strings""" if hasattr(v, 'value'): return v.value return str(v) class UpdateProfileRequest(BaseModel): # Basic personal information first_name: Optional[str] = None last_name: Optional[str] = None phone: Optional[str] = None address: Optional[str] = None city: Optional[str] = None state: Optional[str] = None zipcode: Optional[str] = None # Partner information partner_first_name: Optional[str] = None partner_last_name: Optional[str] = None partner_is_member: Optional[bool] = None partner_plan_to_become_member: Optional[bool] = None # Newsletter preferences newsletter_publish_name: Optional[bool] = None newsletter_publish_photo: Optional[bool] = None newsletter_publish_birthday: Optional[bool] = None newsletter_publish_none: Optional[bool] = None # Volunteer interests (array of strings) volunteer_interests: Optional[list] = None # Directory settings show_in_directory: Optional[bool] = None directory_email: Optional[str] = None directory_bio: Optional[str] = None directory_address: Optional[str] = None directory_phone: Optional[str] = None directory_dob: Optional[datetime] = None directory_partner_name: Optional[str] = None @validator('directory_dob', pre=True) def empty_str_to_none(cls, v): """Convert empty string to None for optional datetime field""" if v == '' or v is None: return None return v class EnhancedProfileUpdateRequest(BaseModel): """Members Only - Enhanced profile update with social media and directory settings""" social_media_facebook: Optional[str] = None social_media_instagram: Optional[str] = None social_media_twitter: Optional[str] = None social_media_linkedin: Optional[str] = None show_in_directory: Optional[bool] = None directory_email: Optional[str] = None directory_bio: Optional[str] = None directory_address: Optional[str] = None directory_phone: Optional[str] = None directory_dob: Optional[datetime] = None directory_partner_name: Optional[str] = None @validator('directory_dob', pre=True) def empty_str_to_none(cls, v): """Convert empty string to None for optional datetime field""" if v == '' or v is None: return None return v class CalendarEventResponse(BaseModel): """Calendar view response with user RSVP status""" id: str title: str description: Optional[str] start_at: datetime end_at: datetime location: str capacity: Optional[int] user_rsvp_status: Optional[str] = None microsoft_calendar_synced: bool class SyncEventRequest(BaseModel): """Request to sync event to Microsoft Calendar""" event_id: str class EventCreate(BaseModel): title: str description: Optional[str] = None start_at: datetime end_at: datetime location: str capacity: Optional[int] = None published: bool = False class EventUpdate(BaseModel): title: Optional[str] = None description: Optional[str] = None start_at: Optional[datetime] = None end_at: Optional[datetime] = None location: Optional[str] = None capacity: Optional[int] = None published: Optional[bool] = None class EventResponse(BaseModel): id: str title: str description: Optional[str] start_at: datetime end_at: datetime location: str capacity: Optional[int] published: bool created_by: str created_at: datetime rsvp_count: Optional[int] = 0 user_rsvp_status: Optional[str] = None model_config = {"from_attributes": True} class RSVPRequest(BaseModel): rsvp_status: str class AttendanceUpdate(BaseModel): user_id: str attended: bool class UpdateUserStatusRequest(BaseModel): status: str class ManualPaymentRequest(BaseModel): plan_id: str = Field(..., description="Subscription plan ID") amount_cents: int = Field(..., ge=3000, description="Payment amount in cents (minimum $30)") payment_date: datetime = Field(..., description="Date payment was received") payment_method: str = Field(..., description="Payment method: cash, bank_transfer, check, other") use_custom_period: bool = Field(False, description="Whether to use custom dates instead of plan's billing cycle") custom_period_start: Optional[datetime] = Field(None, description="Custom subscription start date") custom_period_end: Optional[datetime] = Field(None, description="Custom subscription end date") override_plan_dates: bool = Field(False, description="Override plan's custom billing cycle with admin-specified dates") notes: Optional[str] = Field(None, description="Admin notes about payment") @validator('amount_cents') def validate_amount(cls, v): if v < 3000: raise ValueError('Amount must be at least $30 (3000 cents)') return v # Auth Routes @api_router.post("/auth/register") async def register(request: RegisterRequest, db: Session = Depends(get_db)): # Check if email already exists existing_user = db.query(User).filter(User.email == request.email).first() if existing_user: raise HTTPException(status_code=400, detail="Email already registered") # Generate verification token verification_token = secrets.token_urlsafe(32) # Create user user = User( # Account credentials (Step 4) email=request.email, password_hash=get_password_hash(request.password), # Personal information (Step 1) first_name=request.first_name, last_name=request.last_name, phone=request.phone, address=request.address, city=request.city, state=request.state, zipcode=request.zipcode, date_of_birth=request.date_of_birth, lead_sources=request.lead_sources, # Partner information (Step 1) partner_first_name=request.partner_first_name, partner_last_name=request.partner_last_name, partner_is_member=request.partner_is_member, partner_plan_to_become_member=request.partner_plan_to_become_member, # Referral (Step 2) referred_by_member_name=request.referred_by_member_name, # Newsletter publication preferences (Step 2) newsletter_publish_name=request.newsletter_publish_name, newsletter_publish_photo=request.newsletter_publish_photo, newsletter_publish_birthday=request.newsletter_publish_birthday, newsletter_publish_none=request.newsletter_publish_none, # Volunteer interests (Step 2) volunteer_interests=request.volunteer_interests, # Scholarship (Step 2) scholarship_requested=request.scholarship_requested, scholarship_reason=request.scholarship_reason, # Directory settings (Step 3) show_in_directory=request.show_in_directory, directory_email=request.directory_email, directory_bio=request.directory_bio, directory_address=request.directory_address, directory_phone=request.directory_phone, directory_dob=request.directory_dob, directory_partner_name=request.directory_partner_name, # Status fields status=UserStatus.pending_email, role=UserRole.guest, email_verified=False, email_verification_token=verification_token ) db.add(user) db.commit() db.refresh(user) # Send verification email await send_verification_email(user.email, verification_token) logger.info(f"User registered: {user.email}") return {"message": "Registration successful. Please check your email to verify your account."} @api_router.get("/auth/verify-email") async def verify_email(token: str, db: Session = Depends(get_db)): """Verify user email with token (idempotent - safe to call multiple times)""" user = db.query(User).filter(User.email_verification_token == token).first() if not user: raise HTTPException(status_code=400, detail="Invalid verification token") # If user is already verified, return success (idempotent behavior) # This handles React Strict Mode's double-execution in development if user.email_verified: logger.info(f"Email already verified for user: {user.email}") return { "message": "Email is already verified", "status": user.status.value } # Proceed with first-time verification # Check if referred by current member - skip validation requirement if user.referred_by_member_name: referrer = db.query(User).filter( or_( User.first_name + ' ' + User.last_name == user.referred_by_member_name, User.email == user.referred_by_member_name ), User.status == UserStatus.active ).first() if referrer: user.status = UserStatus.pre_approved else: user.status = UserStatus.pending_approval else: user.status = UserStatus.pending_approval user.email_verified = True # Don't clear token immediately - keeps endpoint idempotent for React StrictMode double-calls # Token will be cleared on first successful login db.commit() db.refresh(user) logger.info(f"Email verified for user: {user.email}") return {"message": "Email verified successfully", "status": user.status.value} @api_router.post("/auth/resend-verification-email") async def resend_verification_email( current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """User requests to resend their verification email""" # Check if email already verified if current_user.email_verified: raise HTTPException(status_code=400, detail="Email is already verified") # Generate new token verification_token = secrets.token_urlsafe(32) current_user.email_verification_token = verification_token db.commit() # Send verification email await send_verification_email(current_user.email, verification_token) logger.info(f"Verification email resent to: {current_user.email}") return {"message": "Verification email has been resent. Please check your inbox."} @api_router.post("/auth/login", response_model=LoginResponse) async def login(request: LoginRequest, db: Session = Depends(get_db)): user = db.query(User).filter(User.email == request.email).first() if not user or not verify_password(request.password, user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password" ) access_token = create_access_token(data={"sub": str(user.id)}) # Clear verification token on first successful login after verification if user.email_verified and user.email_verification_token: user.email_verification_token = None db.commit() return { "access_token": access_token, "token_type": "bearer", "user": { "id": str(user.id), "email": user.email, "first_name": user.first_name, "last_name": user.last_name, "status": user.status.value, "role": user.role.value, "email_verified": user.email_verified, "force_password_change": user.force_password_change } } @api_router.post("/auth/forgot-password") async def forgot_password(request: ForgotPasswordRequest, db: Session = Depends(get_db)): """Request password reset - sends email with reset link""" user = db.query(User).filter(User.email == request.email).first() # Always return success (security: don't reveal if email exists) if user: token = create_password_reset_token(user, db) reset_url = f"{os.getenv('FRONTEND_URL')}/reset-password?token={token}" await send_password_reset_email(user.email, user.first_name, reset_url) return {"message": "If email exists, reset link has been sent"} @api_router.post("/auth/reset-password") async def reset_password(request: ResetPasswordRequest, db: Session = Depends(get_db)): """Complete password reset using token""" user = verify_reset_token(request.token, db) if not user: raise HTTPException(status_code=400, detail="Invalid or expired reset token") # Update password user.password_hash = get_password_hash(request.new_password) user.password_reset_token = None user.password_reset_expires = None user.force_password_change = False # Reset flag if it was set db.commit() return {"message": "Password reset successful"} @api_router.put("/users/change-password") async def change_password( request: ChangePasswordRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """User changes their own password""" # Verify current password if not verify_password(request.current_password, current_user.password_hash): raise HTTPException(status_code=400, detail="Current password is incorrect") # Update password current_user.password_hash = get_password_hash(request.new_password) current_user.force_password_change = False # Clear flag if set db.commit() return {"message": "Password changed successfully"} @api_router.get("/auth/me", response_model=UserResponse) async def get_me(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): # Get user's active subscription if exists active_subscription = db.query(Subscription).filter( Subscription.user_id == current_user.id, Subscription.status == SubscriptionStatus.active ).first() return UserResponse( id=str(current_user.id), email=current_user.email, first_name=current_user.first_name, last_name=current_user.last_name, phone=current_user.phone, address=current_user.address, city=current_user.city, state=current_user.state, zipcode=current_user.zipcode, date_of_birth=current_user.date_of_birth, status=current_user.status.value, role=current_user.role.value, email_verified=current_user.email_verified, created_at=current_user.created_at, subscription_start_date=active_subscription.start_date if active_subscription else None, subscription_end_date=active_subscription.end_date if active_subscription else None, subscription_status=active_subscription.status.value if active_subscription else None ) # User Profile Routes @api_router.get("/users/profile", response_model=UserResponse) async def get_profile(current_user: User = Depends(get_current_user)): # Use from_attributes to automatically map all User fields to UserResponse return UserResponse.model_validate(current_user) @api_router.put("/users/profile") async def update_profile( request: UpdateProfileRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """Update user profile with basic info, partner details, newsletter prefs, volunteer interests, and directory settings.""" # Basic personal information if request.first_name is not None: current_user.first_name = request.first_name if request.last_name is not None: current_user.last_name = request.last_name if request.phone is not None: current_user.phone = request.phone if request.address is not None: current_user.address = request.address if request.city is not None: current_user.city = request.city if request.state is not None: current_user.state = request.state if request.zipcode is not None: current_user.zipcode = request.zipcode # Partner information if request.partner_first_name is not None: current_user.partner_first_name = request.partner_first_name if request.partner_last_name is not None: current_user.partner_last_name = request.partner_last_name if request.partner_is_member is not None: current_user.partner_is_member = request.partner_is_member if request.partner_plan_to_become_member is not None: current_user.partner_plan_to_become_member = request.partner_plan_to_become_member # Newsletter preferences if request.newsletter_publish_name is not None: current_user.newsletter_publish_name = request.newsletter_publish_name if request.newsletter_publish_photo is not None: current_user.newsletter_publish_photo = request.newsletter_publish_photo if request.newsletter_publish_birthday is not None: current_user.newsletter_publish_birthday = request.newsletter_publish_birthday if request.newsletter_publish_none is not None: current_user.newsletter_publish_none = request.newsletter_publish_none # Volunteer interests (array) if request.volunteer_interests is not None: current_user.volunteer_interests = request.volunteer_interests # Directory settings if request.show_in_directory is not None: current_user.show_in_directory = request.show_in_directory if request.directory_email is not None: current_user.directory_email = request.directory_email if request.directory_bio is not None: current_user.directory_bio = request.directory_bio if request.directory_address is not None: current_user.directory_address = request.directory_address if request.directory_phone is not None: current_user.directory_phone = request.directory_phone if request.directory_dob is not None: current_user.directory_dob = request.directory_dob if request.directory_partner_name is not None: current_user.directory_partner_name = request.directory_partner_name current_user.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(current_user) return {"message": "Profile updated successfully"} # ==================== MEMBERS ONLY ROUTES ==================== # Member Directory Routes @api_router.get("/members/directory") async def get_member_directory( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Get list of all members who opted into the directory""" directory_members = db.query(User).filter( User.show_in_directory == True, User.role == UserRole.member, User.status == UserStatus.active ).all() return [{ "id": str(member.id), "first_name": member.first_name, "last_name": member.last_name, "profile_photo_url": member.profile_photo_url, "directory_email": member.directory_email, "directory_bio": member.directory_bio, "directory_address": member.directory_address, "directory_phone": member.directory_phone, "directory_dob": member.directory_dob, "directory_partner_name": member.directory_partner_name, "volunteer_interests": member.volunteer_interests or [], "social_media_facebook": member.social_media_facebook, "social_media_instagram": member.social_media_instagram, "social_media_twitter": member.social_media_twitter, "social_media_linkedin": member.social_media_linkedin } for member in directory_members] @api_router.get("/members/directory/{user_id}") async def get_directory_member_profile( user_id: str, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Get public directory profile of a specific member""" member = db.query(User).filter( User.id == user_id, User.show_in_directory == True, User.role == UserRole.member, User.status == UserStatus.active ).first() if not member: raise HTTPException(status_code=404, detail="Member not found in directory") return { "id": str(member.id), "first_name": member.first_name, "last_name": member.last_name, "profile_photo_url": member.profile_photo_url, "directory_email": member.directory_email, "directory_bio": member.directory_bio, "directory_address": member.directory_address, "directory_phone": member.directory_phone, "directory_dob": member.directory_dob, "directory_partner_name": member.directory_partner_name, "volunteer_interests": member.volunteer_interests or [], "social_media_facebook": member.social_media_facebook, "social_media_instagram": member.social_media_instagram, "social_media_twitter": member.social_media_twitter, "social_media_linkedin": member.social_media_linkedin } # Enhanced Profile Routes (Active Members Only) @api_router.get("/members/profile") async def get_enhanced_profile( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Get enhanced profile with all member-only fields""" return { "id": str(current_user.id), "email": current_user.email, "first_name": current_user.first_name, "last_name": current_user.last_name, "phone": current_user.phone, "address": current_user.address, "city": current_user.city, "state": current_user.state, "zipcode": current_user.zipcode, "date_of_birth": current_user.date_of_birth, "profile_photo_url": current_user.profile_photo_url, "social_media_facebook": current_user.social_media_facebook, "social_media_instagram": current_user.social_media_instagram, "social_media_twitter": current_user.social_media_twitter, "social_media_linkedin": current_user.social_media_linkedin, "show_in_directory": current_user.show_in_directory, "directory_email": current_user.directory_email, "directory_bio": current_user.directory_bio, "directory_address": current_user.directory_address, "directory_phone": current_user.directory_phone, "directory_dob": current_user.directory_dob, "directory_partner_name": current_user.directory_partner_name, "status": current_user.status.value, "role": current_user.role.value } @api_router.put("/members/profile") async def update_enhanced_profile( request: EnhancedProfileUpdateRequest, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Update enhanced profile with social media and directory settings""" if request.social_media_facebook is not None: current_user.social_media_facebook = request.social_media_facebook if request.social_media_instagram is not None: current_user.social_media_instagram = request.social_media_instagram if request.social_media_twitter is not None: current_user.social_media_twitter = request.social_media_twitter if request.social_media_linkedin is not None: current_user.social_media_linkedin = request.social_media_linkedin if request.show_in_directory is not None: current_user.show_in_directory = request.show_in_directory if request.directory_email is not None: current_user.directory_email = request.directory_email if request.directory_bio is not None: current_user.directory_bio = request.directory_bio if request.directory_address is not None: current_user.directory_address = request.directory_address if request.directory_phone is not None: current_user.directory_phone = request.directory_phone if request.directory_dob is not None: current_user.directory_dob = request.directory_dob if request.directory_partner_name is not None: current_user.directory_partner_name = request.directory_partner_name current_user.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(current_user) return {"message": "Enhanced profile updated successfully"} @api_router.post("/members/profile/upload-photo") async def upload_profile_photo( file: UploadFile = File(...), current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Upload profile photo to Cloudflare R2""" r2 = get_r2_storage() # Get storage quota storage = db.query(StorageUsage).first() if not storage: # Initialize storage tracking storage = StorageUsage( total_bytes_used=0, max_bytes_allowed=int(os.getenv('MAX_STORAGE_BYTES', 10737418240)) ) db.add(storage) db.commit() db.refresh(storage) # Get max file size from env max_file_size = int(os.getenv('MAX_FILE_SIZE_BYTES', 52428800)) # Delete old profile photo if exists if current_user.profile_photo_url: # Extract object key from URL old_key = current_user.profile_photo_url.split('/')[-1] old_key = f"profiles/{old_key}" try: old_size = await r2.get_file_size(old_key) await r2.delete_file(old_key) # Update storage usage storage.total_bytes_used -= old_size except: pass # File might not exist # Upload new photo try: public_url, object_key, file_size = await r2.upload_file( file=file, folder="profiles", allowed_types=r2.ALLOWED_IMAGE_TYPES, max_size_bytes=max_file_size ) # Check storage quota if storage.total_bytes_used + file_size > storage.max_bytes_allowed: # Rollback upload await r2.delete_file(object_key) raise HTTPException( status_code=507, detail=f"Storage limit exceeded. Used: {storage.total_bytes_used / (1024**3):.2f}GB, Limit: {storage.max_bytes_allowed / (1024**3):.2f}GB" ) # Update user profile current_user.profile_photo_url = public_url current_user.updated_at = datetime.now(timezone.utc) # Update storage usage storage.total_bytes_used += file_size storage.last_updated = datetime.now(timezone.utc) db.commit() db.refresh(current_user) logger.info(f"Profile photo uploaded for user {current_user.email}: {file_size} bytes") return { "message": "Profile photo uploaded successfully", "profile_photo_url": public_url } except HTTPException: raise except Exception as e: logger.error(f"Error uploading profile photo: {str(e)}") raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") @api_router.delete("/members/profile/delete-photo") async def delete_profile_photo( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Delete profile photo from R2 and profile""" if not current_user.profile_photo_url: raise HTTPException(status_code=404, detail="No profile photo to delete") r2 = get_r2_storage() storage = db.query(StorageUsage).first() # Extract object key from URL object_key = current_user.profile_photo_url.split('/')[-1] object_key = f"profiles/{object_key}" try: file_size = await r2.get_file_size(object_key) await r2.delete_file(object_key) # Update storage usage if storage: storage.total_bytes_used -= file_size storage.last_updated = datetime.now(timezone.utc) # Update user profile current_user.profile_photo_url = None current_user.updated_at = datetime.now(timezone.utc) db.commit() logger.info(f"Profile photo deleted for user {current_user.email}") return {"message": "Profile photo deleted successfully"} except Exception as e: logger.error(f"Error deleting profile photo: {str(e)}") raise HTTPException(status_code=500, detail=f"Deletion failed: {str(e)}") # Calendar Routes (Active Members Only) @api_router.get("/members/calendar/events", response_model=List[CalendarEventResponse]) async def get_calendar_events( start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Get calendar events with user RSVP status""" query = db.query(Event).filter(Event.published == True) if start_date: query = query.filter(Event.start_at >= start_date) if end_date: query = query.filter(Event.end_at <= end_date) events = query.order_by(Event.start_at).all() result = [] for event in events: # Get user's RSVP status for this event rsvp = db.query(EventRSVP).filter( EventRSVP.event_id == event.id, EventRSVP.user_id == current_user.id ).first() user_rsvp_status = rsvp.rsvp_status.value if rsvp else None result.append(CalendarEventResponse( id=str(event.id), title=event.title, description=event.description, start_at=event.start_at, end_at=event.end_at, location=event.location, capacity=event.capacity, user_rsvp_status=user_rsvp_status, microsoft_calendar_synced=event.microsoft_calendar_sync_enabled )) return result # Members Directory Route @api_router.get("/members/directory") async def get_members_directory( search: Optional[str] = None, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Get members directory - only shows active members who opted in""" query = db.query(User).filter( User.show_in_directory == True, User.status == UserStatus.active ) if search: search_term = f"%{search}%" query = query.filter( or_( User.first_name.ilike(search_term), User.last_name.ilike(search_term), User.directory_bio.ilike(search_term) ) ) members = query.order_by(User.first_name, User.last_name).all() return [ { "id": str(member.id), "first_name": member.first_name, "last_name": member.last_name, "profile_photo_url": member.profile_photo_url, "directory_email": member.directory_email, "directory_bio": member.directory_bio, "directory_address": member.directory_address, "directory_phone": member.directory_phone, "directory_partner_name": member.directory_partner_name, "social_media_facebook": member.social_media_facebook, "social_media_instagram": member.social_media_instagram, "social_media_twitter": member.social_media_twitter, "social_media_linkedin": member.social_media_linkedin } for member in members ] # Admin Calendar Sync Routes @api_router.post("/admin/calendar/sync/{event_id}") async def sync_event_to_microsoft( event_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Sync event to Microsoft Calendar""" event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") ms_calendar = get_ms_calendar_service() try: # Sync event ms_event_id = await ms_calendar.sync_event( loaf_event=event, existing_ms_event_id=event.microsoft_calendar_id ) # Update event with MS Calendar ID event.microsoft_calendar_id = ms_event_id event.microsoft_calendar_sync_enabled = True event.updated_at = datetime.now(timezone.utc) db.commit() logger.info(f"Event {event.title} synced to Microsoft Calendar by {current_user.email}") return { "message": "Event synced to Microsoft Calendar successfully", "microsoft_calendar_id": ms_event_id } except Exception as e: logger.error(f"Error syncing event to Microsoft Calendar: {str(e)}") raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}") @api_router.delete("/admin/calendar/unsync/{event_id}") async def unsync_event_from_microsoft( event_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Remove event from Microsoft Calendar""" event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") if not event.microsoft_calendar_id: raise HTTPException(status_code=400, detail="Event is not synced to Microsoft Calendar") ms_calendar = get_ms_calendar_service() try: # Delete from Microsoft Calendar await ms_calendar.delete_event(event.microsoft_calendar_id) # Update event event.microsoft_calendar_id = None event.microsoft_calendar_sync_enabled = False event.updated_at = datetime.now(timezone.utc) db.commit() logger.info(f"Event {event.title} unsynced from Microsoft Calendar by {current_user.email}") return {"message": "Event removed from Microsoft Calendar successfully"} except Exception as e: logger.error(f"Error removing event from Microsoft Calendar: {str(e)}") raise HTTPException(status_code=500, detail=f"Unsync failed: {str(e)}") # Event Gallery Routes (Members Only) @api_router.get("/members/gallery") async def get_events_with_galleries( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Get all events that have gallery images""" # Get events that have at least one gallery image events_with_galleries = db.query(Event).join(EventGallery).filter( Event.published == True ).distinct().order_by(Event.start_at.desc()).all() result = [] for event in events_with_galleries: gallery_count = db.query(EventGallery).filter( EventGallery.event_id == event.id ).count() # Get first image as thumbnail first_image = db.query(EventGallery).filter( EventGallery.event_id == event.id ).order_by(EventGallery.created_at).first() result.append({ "id": str(event.id), "title": event.title, "description": event.description, "start_at": event.start_at, "location": event.location, "gallery_count": gallery_count, "thumbnail_url": first_image.image_url if first_image else None }) return result @api_router.get("/events/{event_id}/gallery") async def get_event_gallery( event_id: str, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Get all gallery images for a specific event""" event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") gallery_images = db.query(EventGallery).filter( EventGallery.event_id == event_id ).order_by(EventGallery.created_at.desc()).all() return [ { "id": str(img.id), "image_url": img.image_url, "image_key": img.image_key, "caption": img.caption, "uploaded_by": str(img.uploaded_by), "file_size_bytes": img.file_size_bytes, "created_at": img.created_at } for img in gallery_images ] # Admin Event Gallery Routes @api_router.post("/admin/events/{event_id}/gallery") async def upload_event_gallery_image( event_id: str, file: UploadFile = File(...), caption: Optional[str] = None, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Upload image to event gallery (Admin only)""" # Validate event exists event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") r2 = get_r2_storage() # Get storage quota storage = db.query(StorageUsage).first() if not storage: storage = StorageUsage( total_bytes_used=0, max_bytes_allowed=int(os.getenv('MAX_STORAGE_BYTES', 10737418240)) ) db.add(storage) db.commit() db.refresh(storage) # Get max file size from env max_file_size = int(os.getenv('MAX_FILE_SIZE_BYTES', 52428800)) try: # Upload to R2 public_url, object_key, file_size = await r2.upload_file( file=file, folder=f"gallery/{event_id}", allowed_types=r2.ALLOWED_IMAGE_TYPES, max_size_bytes=max_file_size ) # Check storage quota if storage.total_bytes_used + file_size > storage.max_bytes_allowed: # Rollback upload await r2.delete_file(object_key) raise HTTPException( status_code=507, detail=f"Storage limit exceeded. Used: {storage.total_bytes_used / (1024**3):.2f}GB, Limit: {storage.max_bytes_allowed / (1024**3):.2f}GB" ) # Create gallery record gallery_image = EventGallery( event_id=event.id, image_url=public_url, image_key=object_key, caption=caption, uploaded_by=current_user.id, file_size_bytes=file_size ) db.add(gallery_image) # Update storage usage storage.total_bytes_used += file_size storage.last_updated = datetime.now(timezone.utc) db.commit() db.refresh(gallery_image) logger.info(f"Gallery image uploaded for event {event.title} by {current_user.email}: {file_size} bytes") return { "message": "Image uploaded successfully", "image": { "id": str(gallery_image.id), "image_url": gallery_image.image_url, "caption": gallery_image.caption, "file_size_bytes": gallery_image.file_size_bytes } } except HTTPException: raise except Exception as e: logger.error(f"Error uploading gallery image: {str(e)}") raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") @api_router.delete("/admin/event-gallery/{image_id}") async def delete_gallery_image( image_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Delete image from event gallery (Admin only)""" gallery_image = db.query(EventGallery).filter(EventGallery.id == image_id).first() if not gallery_image: raise HTTPException(status_code=404, detail="Gallery image not found") r2 = get_r2_storage() storage = db.query(StorageUsage).first() try: # Delete from R2 await r2.delete_file(gallery_image.image_key) # Update storage usage if storage: storage.total_bytes_used -= gallery_image.file_size_bytes storage.last_updated = datetime.now(timezone.utc) # Delete from database db.delete(gallery_image) db.commit() logger.info(f"Gallery image deleted by {current_user.email}: {gallery_image.image_key}") return {"message": "Image deleted successfully"} except Exception as e: logger.error(f"Error deleting gallery image: {str(e)}") raise HTTPException(status_code=500, detail=f"Deletion failed: {str(e)}") @api_router.put("/admin/event-gallery/{image_id}") async def update_gallery_image_caption( image_id: str, caption: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Update gallery image caption (Admin only)""" gallery_image = db.query(EventGallery).filter(EventGallery.id == image_id).first() if not gallery_image: raise HTTPException(status_code=404, detail="Gallery image not found") gallery_image.caption = caption db.commit() db.refresh(gallery_image) return { "message": "Caption updated successfully", "image": { "id": str(gallery_image.id), "caption": gallery_image.caption } } # Event Routes @api_router.get("/events", response_model=List[EventResponse]) async def get_events( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): # Get published events for all users events = db.query(Event).filter(Event.published == True).order_by(Event.start_at).all() result = [] for event in events: rsvp_count = db.query(EventRSVP).filter( EventRSVP.event_id == event.id, EventRSVP.rsvp_status == RSVPStatus.yes ).count() # No user_rsvp_status in public endpoint result.append(EventResponse( id=str(event.id), title=event.title, description=event.description, start_at=event.start_at, end_at=event.end_at, location=event.location, capacity=event.capacity, published=event.published, created_by=str(event.created_by), created_at=event.created_at, rsvp_count=rsvp_count, user_rsvp_status=None )) return result @api_router.get("/events/{event_id}", response_model=EventResponse) async def get_event( event_id: str, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") rsvp_count = db.query(EventRSVP).filter( EventRSVP.event_id == event.id, EventRSVP.rsvp_status == RSVPStatus.yes ).count() # No user_rsvp_status in public endpoint user_rsvp = None return EventResponse( id=str(event.id), title=event.title, description=event.description, start_at=event.start_at, end_at=event.end_at, location=event.location, capacity=event.capacity, published=event.published, created_by=str(event.created_by), created_at=event.created_at, rsvp_count=rsvp_count, user_rsvp_status=user_rsvp ) @api_router.post("/events/{event_id}/rsvp") async def rsvp_to_event( event_id: str, request: RSVPRequest, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") # Check if RSVP already exists existing_rsvp = db.query(EventRSVP).filter( EventRSVP.event_id == event_id, EventRSVP.user_id == current_user.id ).first() if existing_rsvp: existing_rsvp.rsvp_status = RSVPStatus(request.rsvp_status) existing_rsvp.updated_at = datetime.now(timezone.utc) else: rsvp = EventRSVP( event_id=event.id, user_id=current_user.id, rsvp_status=RSVPStatus(request.rsvp_status) ) db.add(rsvp) db.commit() return {"message": "RSVP updated successfully"} # ============================================================================ # Calendar Export Endpoints (Universal iCalendar .ics format) # ============================================================================ @api_router.get("/events/{event_id}/download.ics") async def download_event_ics( event_id: str, db: Session = Depends(get_db) ): """ Download single event as .ics file (RFC 5545 iCalendar format) No authentication required for published events Works with Google Calendar, Apple Calendar, Microsoft Outlook, etc. """ event = db.query(Event).filter( Event.id == event_id, Event.published == True ).first() if not event: raise HTTPException(status_code=404, detail="Event not found") # Generate UID if not exists if not event.calendar_uid: event.calendar_uid = calendar_service.generate_event_uid() db.commit() ics_content = calendar_service.create_single_event_calendar(event) # Sanitize filename safe_filename = "".join(c for c in event.title if c.isalnum() or c in (' ', '-', '_')).rstrip() safe_filename = safe_filename.replace(' ', '_') or 'event' return StreamingResponse( iter([ics_content]), media_type="text/calendar", headers={ "Content-Disposition": f"attachment; filename={safe_filename}.ics", "Cache-Control": "public, max-age=300" # Cache for 5 minutes } ) @api_router.get("/calendars/subscribe.ics") async def subscribe_calendar( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """ Subscribe to user's RSVP'd events (live calendar feed) Auto-syncs events marked as "Yes" RSVP Use webcal:// protocol for auto-sync in calendar apps """ # Get all upcoming events user RSVP'd "yes" to rsvps = db.query(EventRSVP).filter( EventRSVP.user_id == current_user.id, EventRSVP.rsvp_status == RSVPStatus.yes ).join(Event).filter( Event.start_at > datetime.now(timezone.utc), Event.published == True ).all() events = [rsvp.event for rsvp in rsvps] # Generate UIDs for events that don't have them for event in events: if not event.calendar_uid: event.calendar_uid = calendar_service.generate_event_uid() db.commit() feed_name = f"{current_user.first_name}'s LOAF Events" ics_content = calendar_service.create_subscription_feed(events, feed_name) return StreamingResponse( iter([ics_content]), media_type="text/calendar", headers={ "Content-Disposition": "inline; filename=loaf-events.ics", "Cache-Control": "public, max-age=3600", # Cache for 1 hour "ETag": f'"{hash(ics_content)}"' } ) @api_router.get("/calendars/all-events.ics") async def download_all_events( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """ Download all upcoming published events as .ics file (one-time download) Useful for importing all events at once """ events = db.query(Event).filter( Event.published == True, Event.start_at > datetime.now(timezone.utc) ).order_by(Event.start_at).all() # Generate UIDs for event in events: if not event.calendar_uid: event.calendar_uid = calendar_service.generate_event_uid() db.commit() ics_content = calendar_service.create_subscription_feed(events, "All LOAF Events") return StreamingResponse( iter([ics_content]), media_type="text/calendar", headers={ "Content-Disposition": "attachment; filename=loaf-all-events.ics", "Cache-Control": "public, max-age=600" # Cache for 10 minutes } ) # ============================================================================ # Newsletter Archive Routes (Members Only) # ============================================================================ @api_router.get("/newsletters") async def get_newsletters( year: Optional[int] = None, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """ Get all newsletters, optionally filtered by year Members only """ from models import NewsletterArchive query = db.query(NewsletterArchive) if year: query = query.filter( db.func.extract('year', NewsletterArchive.published_date) == year ) newsletters = query.order_by(NewsletterArchive.published_date.desc()).all() return [{ "id": str(n.id), "title": n.title, "description": n.description, "published_date": n.published_date.isoformat(), "document_url": n.document_url, "document_type": n.document_type, "file_size_bytes": n.file_size_bytes, "created_at": n.created_at.isoformat() } for n in newsletters] @api_router.get("/newsletters/years") async def get_newsletter_years( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """ Get list of years that have newsletters Members only """ from models import NewsletterArchive years = db.query( db.func.extract('year', NewsletterArchive.published_date).label('year') ).distinct().order_by(db.text('year DESC')).all() return [int(y.year) for y in years] # ============================================================================ # Financial Reports Routes (Members Only) # ============================================================================ @api_router.get("/financials") async def get_financial_reports( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """ Get all financial reports sorted by year (newest first) Members only """ from models import FinancialReport reports = db.query(FinancialReport).order_by( FinancialReport.year.desc() ).all() return [{ "id": str(r.id), "year": r.year, "title": r.title, "document_url": r.document_url, "document_type": r.document_type, "file_size_bytes": r.file_size_bytes, "created_at": r.created_at.isoformat() } for r in reports] # ============================================================================ # Bylaws Routes (Members Only) # ============================================================================ @api_router.get("/bylaws/current") async def get_current_bylaws( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """ Get current bylaws document Members only """ from models import BylawsDocument bylaws = db.query(BylawsDocument).filter( BylawsDocument.is_current == True ).first() if not bylaws: raise HTTPException(status_code=404, detail="No current bylaws found") return { "id": str(bylaws.id), "title": bylaws.title, "version": bylaws.version, "effective_date": bylaws.effective_date.isoformat(), "document_url": bylaws.document_url, "document_type": bylaws.document_type, "file_size_bytes": bylaws.file_size_bytes, "is_current": bylaws.is_current, "created_at": bylaws.created_at.isoformat() } @api_router.get("/bylaws/history") async def get_bylaws_history( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """ Get all bylaws versions (historical) Members only """ from models import BylawsDocument history = db.query(BylawsDocument).order_by( BylawsDocument.effective_date.desc() ).all() return [{ "id": str(b.id), "title": b.title, "version": b.version, "effective_date": b.effective_date.isoformat(), "document_url": b.document_url, "document_type": b.document_type, "file_size_bytes": b.file_size_bytes, "is_current": b.is_current, "created_at": b.created_at.isoformat() } for b in history] # ============================================================================ # Configuration Endpoints # ============================================================================ @api_router.get("/config/limits") async def get_config_limits(): """Get configuration limits for file uploads""" return { "max_file_size_bytes": int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)), "max_storage_bytes": int(os.getenv('MAX_STORAGE_BYTES', 1073741824)) } # ============================================================================ # Admin Routes # ============================================================================ @api_router.get("/admin/storage/usage") async def get_storage_usage( current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Get current storage usage statistics""" from models import StorageUsage storage = db.query(StorageUsage).first() if not storage: # Initialize if doesn't exist storage = StorageUsage( total_bytes_used=0, max_bytes_allowed=int(os.getenv('MAX_STORAGE_BYTES', 1073741824)) ) db.add(storage) db.commit() db.refresh(storage) percentage = (storage.total_bytes_used / storage.max_bytes_allowed) * 100 if storage.max_bytes_allowed > 0 else 0 return { "total_bytes_used": storage.total_bytes_used, "max_bytes_allowed": storage.max_bytes_allowed, "percentage": round(percentage, 2), "available_bytes": storage.max_bytes_allowed - storage.total_bytes_used } @api_router.get("/admin/storage/breakdown") async def get_storage_breakdown( current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Get storage usage breakdown by category""" from sqlalchemy import func from models import User, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument # Count storage by category profile_photos = db.query(func.coalesce(func.sum(User.profile_photo_size), 0)).scalar() or 0 gallery_images = db.query(func.coalesce(func.sum(EventGallery.file_size_bytes), 0)).scalar() or 0 newsletters = db.query(func.coalesce(func.sum(NewsletterArchive.file_size_bytes), 0)).filter( NewsletterArchive.document_type == 'upload' ).scalar() or 0 financials = db.query(func.coalesce(func.sum(FinancialReport.file_size_bytes), 0)).filter( FinancialReport.document_type == 'upload' ).scalar() or 0 bylaws = db.query(func.coalesce(func.sum(BylawsDocument.file_size_bytes), 0)).filter( BylawsDocument.document_type == 'upload' ).scalar() or 0 return { "breakdown": { "profile_photos": profile_photos, "gallery_images": gallery_images, "newsletters": newsletters, "financials": financials, "bylaws": bylaws }, "total": profile_photos + gallery_images + newsletters + financials + bylaws } @api_router.get("/admin/users") async def get_all_users( status: Optional[str] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user) ): query = db.query(User) if status: try: status_enum = UserStatus(status) query = query.filter(User.status == status_enum) except ValueError: raise HTTPException(status_code=400, detail="Invalid status") users = query.order_by(User.created_at.desc()).all() return [ { "id": str(user.id), "email": user.email, "first_name": user.first_name, "last_name": user.last_name, "phone": user.phone, "status": user.status.value, "role": user.role.value, "email_verified": user.email_verified, "created_at": user.created_at.isoformat(), "lead_sources": user.lead_sources, "referred_by_member_name": user.referred_by_member_name } for user in users ] @api_router.get("/admin/users/{user_id}") async def get_user_by_id( user_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user) ): """Get specific user by ID (admin only)""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") return { "id": str(user.id), "email": user.email, "first_name": user.first_name, "last_name": user.last_name, "phone": user.phone, "address": user.address, "city": user.city, "state": user.state, "zipcode": user.zipcode, "date_of_birth": user.date_of_birth.isoformat() if user.date_of_birth else None, "partner_first_name": user.partner_first_name, "partner_last_name": user.partner_last_name, "partner_is_member": user.partner_is_member, "partner_plan_to_become_member": user.partner_plan_to_become_member, "referred_by_member_name": user.referred_by_member_name, "status": user.status.value, "role": user.role.value, "email_verified": user.email_verified, "newsletter_subscribed": user.newsletter_subscribed, "lead_sources": user.lead_sources, "created_at": user.created_at.isoformat() if user.created_at else None, "updated_at": user.updated_at.isoformat() if user.updated_at else None } @api_router.put("/admin/users/{user_id}/approve") async def approve_user( user_id: str, bypass_email_verification: bool = False, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user) ): user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") # Handle bypass email verification for pending_email users if bypass_email_verification and user.status == UserStatus.pending_email: # Verify email manually user.email_verified = True user.email_verification_token = None # Determine status based on referral if user.referred_by_member_name: referrer = db.query(User).filter( or_( User.first_name + ' ' + User.last_name == user.referred_by_member_name, User.email == user.referred_by_member_name ), User.status == UserStatus.active ).first() user.status = UserStatus.pre_approved if referrer else UserStatus.pending_approval else: user.status = UserStatus.pending_approval logger.info(f"Admin {current_user.email} bypassed email verification for {user.email}") # Validate user status - must be pending_approval or pre_approved if user.status not in [UserStatus.pending_approval, UserStatus.pre_approved]: raise HTTPException( status_code=400, detail=f"User must have verified email first. Current: {user.status.value}" ) # Set to payment_pending - user becomes active after payment via webhook user.status = UserStatus.payment_pending user.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(user) # Send payment prompt email await send_payment_prompt_email(user.email, user.first_name) logger.info(f"User validated and approved (payment pending): {user.email} by admin: {current_user.email}") return {"message": "User approved - payment email sent"} @api_router.put("/admin/users/{user_id}/status") async def update_user_status( user_id: str, request: UpdateUserStatusRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user) ): user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") try: new_status = UserStatus(request.status) user.status = new_status user.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(user) return {"message": "User status updated successfully"} except ValueError: raise HTTPException(status_code=400, detail="Invalid status") @api_router.post("/admin/users/{user_id}/activate-payment") async def activate_payment_manually( user_id: str, request: ManualPaymentRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user) ): """Manually activate user who paid offline (cash, bank transfer, etc.)""" # 1. Find user user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") # 2. Validate status if user.status != UserStatus.payment_pending: raise HTTPException( status_code=400, detail=f"User must be in payment_pending status. Current: {user.status.value}" ) # 3. Get subscription plan plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == request.plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Subscription plan not found") # 4. Validate amount against plan minimum if request.amount_cents < plan.minimum_price_cents: raise HTTPException( status_code=400, detail=f"Amount must be at least ${plan.minimum_price_cents / 100:.2f}" ) # 5. Calculate donation split base_amount = plan.minimum_price_cents donation_amount = request.amount_cents - base_amount # 6. Calculate subscription period from payment_service import calculate_subscription_period if request.use_custom_period or request.override_plan_dates: # Admin-specified custom dates override everything if not request.custom_period_start or not request.custom_period_end: raise HTTPException( status_code=400, detail="Custom period start and end dates are required when use_custom_period or override_plan_dates is true" ) period_start = request.custom_period_start period_end = request.custom_period_end else: # Use plan's custom cycle or billing cycle period_start, period_end = calculate_subscription_period(plan) # 7. Create subscription record (manual payment) with donation tracking subscription = Subscription( user_id=user.id, plan_id=plan.id, stripe_subscription_id=None, # No Stripe involvement stripe_customer_id=None, status=SubscriptionStatus.active, start_date=period_start, end_date=period_end, amount_paid_cents=request.amount_cents, base_subscription_cents=base_amount, donation_cents=donation_amount, payment_method=request.payment_method, manual_payment=True, manual_payment_notes=request.notes, manual_payment_admin_id=current_user.id, manual_payment_date=request.payment_date ) db.add(subscription) # 6. Activate user user.status = UserStatus.active user.role = UserRole.member user.updated_at = datetime.now(timezone.utc) # 7. Commit db.commit() db.refresh(subscription) # 8. Log admin action logger.info( f"Admin {current_user.email} manually activated payment for user {user.email} " f"via {request.payment_method} for ${request.amount_cents/100:.2f} " f"with plan {plan.name} ({period_start.date()} to {period_end.date()})" ) return { "message": "User payment activated successfully", "user_id": str(user.id), "subscription_id": str(subscription.id) } @api_router.put("/admin/users/{user_id}/reset-password") async def admin_reset_user_password( user_id: str, request: AdminPasswordUpdateRequest, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Admin resets user password - generates temp password and emails it""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") # Generate random temporary password temp_password = secrets.token_urlsafe(12) # Update user user.password_hash = get_password_hash(temp_password) user.force_password_change = request.force_change db.commit() # Email user the temporary password await send_admin_password_reset_email( user.email, user.first_name, temp_password, request.force_change ) # Log admin action logger.info( f"Admin {current_user.email} reset password for user {user.email} " f"(force_change={request.force_change})" ) return {"message": f"Password reset for {user.email}. Temporary password emailed."} @api_router.post("/admin/users/{user_id}/resend-verification") async def admin_resend_verification( user_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Admin resends verification email for any user""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") # Check if email already verified if user.email_verified: raise HTTPException(status_code=400, detail="User's email is already verified") # Generate new token verification_token = secrets.token_urlsafe(32) user.email_verification_token = verification_token db.commit() # Send verification email await send_verification_email(user.email, verification_token) # Log admin action logger.info( f"Admin {current_user.email} resent verification email to user {user.email}" ) return {"message": f"Verification email resent to {user.email}"} @api_router.post("/admin/events", response_model=EventResponse) async def create_event( request: EventCreate, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): event = Event( title=request.title, description=request.description, start_at=request.start_at, end_at=request.end_at, location=request.location, capacity=request.capacity, published=request.published, created_by=current_user.id ) db.add(event) db.commit() db.refresh(event) logger.info(f"Event created: {event.title} by {current_user.email}") return EventResponse( id=str(event.id), title=event.title, description=event.description, start_at=event.start_at, end_at=event.end_at, location=event.location, capacity=event.capacity, published=event.published, created_by=str(event.created_by), created_at=event.created_at, rsvp_count=0 ) @api_router.put("/admin/events/{event_id}") async def update_event( event_id: str, request: EventUpdate, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") if request.title: event.title = request.title if request.description is not None: event.description = request.description if request.start_at: event.start_at = request.start_at if request.end_at: event.end_at = request.end_at if request.location: event.location = request.location if request.capacity is not None: event.capacity = request.capacity if request.published is not None: event.published = request.published event.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(event) return {"message": "Event updated successfully"} @api_router.get("/admin/events/{event_id}/rsvps") async def get_event_rsvps( event_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") rsvps = db.query(EventRSVP).filter(EventRSVP.event_id == event_id).all() result = [] for rsvp in rsvps: user = db.query(User).filter(User.id == rsvp.user_id).first() result.append({ "id": str(rsvp.id), "user_id": str(rsvp.user_id), "user_name": f"{user.first_name} {user.last_name}", "user_email": user.email, "rsvp_status": rsvp.rsvp_status.value, "attended": rsvp.attended, "attended_at": rsvp.attended_at.isoformat() if rsvp.attended_at else None }) return result @api_router.put("/admin/events/{event_id}/attendance") async def mark_attendance( event_id: str, request: AttendanceUpdate, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") rsvp = db.query(EventRSVP).filter( EventRSVP.event_id == event_id, EventRSVP.user_id == request.user_id ).first() if not rsvp: raise HTTPException(status_code=404, detail="RSVP not found") rsvp.attended = request.attended rsvp.attended_at = datetime.now(timezone.utc) if request.attended else None rsvp.updated_at = datetime.now(timezone.utc) # If user attended and they were pending approval, update their status if request.attended: user = db.query(User).filter(User.id == request.user_id).first() if user and user.status == UserStatus.pending_approval: user.status = UserStatus.pre_approved user.updated_at = datetime.now(timezone.utc) db.commit() return {"message": "Attendance marked successfully"} @api_router.get("/admin/events") async def get_admin_events( current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Get all events for admin (including unpublished)""" events = db.query(Event).order_by(Event.start_at.desc()).all() result = [] for event in events: rsvp_count = db.query(EventRSVP).filter( EventRSVP.event_id == event.id, EventRSVP.rsvp_status == RSVPStatus.yes ).count() result.append({ "id": str(event.id), "title": event.title, "description": event.description, "start_at": event.start_at, "end_at": event.end_at, "location": event.location, "capacity": event.capacity, "published": event.published, "created_by": str(event.created_by), "created_at": event.created_at, "rsvp_count": rsvp_count }) return result @api_router.delete("/admin/events/{event_id}") async def delete_event( event_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Delete an event (cascade deletes RSVPs)""" event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") db.delete(event) db.commit() return {"message": "Event deleted successfully"} # ==================== PAYMENT & SUBSCRIPTION ENDPOINTS ==================== # Pydantic model for checkout request class CheckoutRequest(BaseModel): plan_id: str amount_cents: int = Field(..., ge=3000, description="Total amount in cents (minimum $30)") @validator('amount_cents') def validate_amount(cls, v): if v < 3000: raise ValueError('Amount must be at least $30 (3000 cents)') return v # Pydantic model for plan CRUD class PlanCreateRequest(BaseModel): name: str = Field(min_length=1, max_length=100) description: Optional[str] = Field(None, max_length=500) price_cents: int = Field(ge=0, le=100000000) # Legacy field, kept for backward compatibility billing_cycle: Literal["monthly", "quarterly", "yearly", "lifetime", "custom"] stripe_price_id: Optional[str] = None # Deprecated, no longer required active: bool = True # Custom billing cycle fields (for recurring date ranges like Jan 1 - Dec 31) custom_cycle_enabled: bool = False custom_cycle_start_month: Optional[int] = Field(None, ge=1, le=12) custom_cycle_start_day: Optional[int] = Field(None, ge=1, le=31) custom_cycle_end_month: Optional[int] = Field(None, ge=1, le=12) custom_cycle_end_day: Optional[int] = Field(None, ge=1, le=31) # Dynamic pricing fields minimum_price_cents: int = Field(3000, ge=3000, le=100000000) # $30 minimum suggested_price_cents: Optional[int] = Field(None, ge=3000, le=100000000) allow_donation: bool = True @validator('name') def validate_name(cls, v): if not v.strip(): raise ValueError('Name cannot be empty or whitespace') return v.strip() @validator('custom_cycle_start_month', 'custom_cycle_end_month') def validate_months(cls, v): if v is not None and (v < 1 or v > 12): raise ValueError('Month must be between 1 and 12') return v @validator('custom_cycle_start_day', 'custom_cycle_end_day') def validate_days(cls, v): if v is not None and (v < 1 or v > 31): raise ValueError('Day must be between 1 and 31') return v @validator('suggested_price_cents') def validate_suggested_price(cls, v, values): if v is not None and 'minimum_price_cents' in values: if v < values['minimum_price_cents']: raise ValueError('Suggested price must be >= minimum price') return v # Pydantic model for updating subscriptions class UpdateSubscriptionRequest(BaseModel): status: Optional[str] = Field(None, pattern="^(active|expired|cancelled)$") end_date: Optional[datetime] = None # Pydantic model for donation checkout class DonationCheckoutRequest(BaseModel): amount_cents: int = Field(..., ge=100, description="Donation amount in cents (minimum $1.00)") @validator('amount_cents') def validate_amount(cls, v): if v < 100: raise ValueError('Donation must be at least $1.00 (100 cents)') return v # Pydantic model for contact form class ContactFormRequest(BaseModel): first_name: str = Field(..., min_length=1, max_length=100) last_name: str = Field(..., min_length=1, max_length=100) email: str = Field(..., min_length=1, max_length=255) subject: str = Field(..., min_length=1, max_length=200) message: str = Field(..., min_length=1, max_length=2000) @validator('email') def validate_email(cls, v): import re email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' if not re.match(email_regex, v): raise ValueError('Invalid email address') return v @api_router.get("/subscriptions/plans") async def get_subscription_plans(db: Session = Depends(get_db)): """Get all active subscription plans.""" plans = db.query(SubscriptionPlan).filter(SubscriptionPlan.active == True).all() return plans # ==================== ADMIN PLAN CRUD ENDPOINTS ==================== @api_router.get("/admin/subscriptions/plans") async def get_all_plans_admin( current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Get all subscription plans for admin (including inactive) with subscriber counts.""" plans = db.query(SubscriptionPlan).order_by(SubscriptionPlan.created_at.desc()).all() result = [] for plan in plans: subscriber_count = db.query(Subscription).filter( Subscription.plan_id == plan.id, Subscription.status == SubscriptionStatus.active ).count() result.append({ "id": str(plan.id), "name": plan.name, "description": plan.description, "price_cents": plan.price_cents, "billing_cycle": plan.billing_cycle, "stripe_price_id": plan.stripe_price_id, "active": plan.active, "subscriber_count": subscriber_count, "created_at": plan.created_at, "updated_at": plan.updated_at }) return result @api_router.get("/admin/subscriptions/plans/{plan_id}") async def get_plan_admin( plan_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Get single plan details with subscriber count.""" plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") subscriber_count = db.query(Subscription).filter( Subscription.plan_id == plan.id, Subscription.status == SubscriptionStatus.active ).count() return { "id": str(plan.id), "name": plan.name, "description": plan.description, "price_cents": plan.price_cents, "billing_cycle": plan.billing_cycle, "stripe_price_id": plan.stripe_price_id, "active": plan.active, "subscriber_count": subscriber_count, "created_at": plan.created_at, "updated_at": plan.updated_at } @api_router.post("/admin/subscriptions/plans") async def create_plan( request: PlanCreateRequest, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Create new subscription plan.""" # Check for duplicate name existing = db.query(SubscriptionPlan).filter( SubscriptionPlan.name == request.name ).first() if existing: raise HTTPException( status_code=400, detail="A plan with this name already exists" ) # Validate custom cycle dates if enabled if request.custom_cycle_enabled: if not all([ request.custom_cycle_start_month, request.custom_cycle_start_day, request.custom_cycle_end_month, request.custom_cycle_end_day ]): raise HTTPException( status_code=400, detail="All custom cycle date fields must be provided when custom_cycle_enabled is true" ) plan = SubscriptionPlan( name=request.name, description=request.description, price_cents=request.price_cents, # Legacy field billing_cycle=request.billing_cycle, stripe_price_id=request.stripe_price_id, # Deprecated active=request.active, # Custom billing cycle fields custom_cycle_enabled=request.custom_cycle_enabled, custom_cycle_start_month=request.custom_cycle_start_month, custom_cycle_start_day=request.custom_cycle_start_day, custom_cycle_end_month=request.custom_cycle_end_month, custom_cycle_end_day=request.custom_cycle_end_day, # Dynamic pricing fields minimum_price_cents=request.minimum_price_cents, suggested_price_cents=request.suggested_price_cents, allow_donation=request.allow_donation ) db.add(plan) db.commit() db.refresh(plan) logger.info(f"Admin {current_user.email} created plan: {plan.name}") return { "id": str(plan.id), "name": plan.name, "description": plan.description, "price_cents": plan.price_cents, "billing_cycle": plan.billing_cycle, "stripe_price_id": plan.stripe_price_id, "active": plan.active, "custom_cycle_enabled": plan.custom_cycle_enabled, "custom_cycle_start_month": plan.custom_cycle_start_month, "custom_cycle_start_day": plan.custom_cycle_start_day, "custom_cycle_end_month": plan.custom_cycle_end_month, "custom_cycle_end_day": plan.custom_cycle_end_day, "minimum_price_cents": plan.minimum_price_cents, "suggested_price_cents": plan.suggested_price_cents, "allow_donation": plan.allow_donation, "subscriber_count": 0, "created_at": plan.created_at, "updated_at": plan.updated_at } @api_router.put("/admin/subscriptions/plans/{plan_id}") async def update_plan( plan_id: str, request: PlanCreateRequest, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Update subscription plan.""" plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") # Check for duplicate name (excluding current plan) existing = db.query(SubscriptionPlan).filter( SubscriptionPlan.name == request.name, SubscriptionPlan.id != plan_id ).first() if existing: raise HTTPException( status_code=400, detail="A plan with this name already exists" ) # Validate custom cycle dates if enabled if request.custom_cycle_enabled: if not all([ request.custom_cycle_start_month, request.custom_cycle_start_day, request.custom_cycle_end_month, request.custom_cycle_end_day ]): raise HTTPException( status_code=400, detail="All custom cycle date fields must be provided when custom_cycle_enabled is true" ) # Update fields plan.name = request.name plan.description = request.description plan.price_cents = request.price_cents # Legacy field plan.billing_cycle = request.billing_cycle plan.stripe_price_id = request.stripe_price_id # Deprecated plan.active = request.active # Custom billing cycle fields plan.custom_cycle_enabled = request.custom_cycle_enabled plan.custom_cycle_start_month = request.custom_cycle_start_month plan.custom_cycle_start_day = request.custom_cycle_start_day plan.custom_cycle_end_month = request.custom_cycle_end_month plan.custom_cycle_end_day = request.custom_cycle_end_day # Dynamic pricing fields plan.minimum_price_cents = request.minimum_price_cents plan.suggested_price_cents = request.suggested_price_cents plan.allow_donation = request.allow_donation plan.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(plan) logger.info(f"Admin {current_user.email} updated plan: {plan.name}") subscriber_count = db.query(Subscription).filter( Subscription.plan_id == plan.id, Subscription.status == SubscriptionStatus.active ).count() return { "id": str(plan.id), "name": plan.name, "description": plan.description, "price_cents": plan.price_cents, "billing_cycle": plan.billing_cycle, "stripe_price_id": plan.stripe_price_id, "active": plan.active, "subscriber_count": subscriber_count, "created_at": plan.created_at, "updated_at": plan.updated_at } @api_router.delete("/admin/subscriptions/plans/{plan_id}") async def delete_plan( plan_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Soft delete plan (set active = False).""" plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") # Check if plan has active subscriptions active_subs = db.query(Subscription).filter( Subscription.plan_id == plan_id, Subscription.status == SubscriptionStatus.active ).count() if active_subs > 0: raise HTTPException( status_code=400, detail=f"Cannot delete plan with {active_subs} active subscriptions" ) plan.active = False plan.updated_at = datetime.now(timezone.utc) db.commit() logger.info(f"Admin {current_user.email} deactivated plan: {plan.name}") return {"message": "Plan deactivated successfully"} # ============================================================================ # Admin Subscription Management Routes # ============================================================================ @api_router.get("/admin/subscriptions") async def get_all_subscriptions( status: Optional[str] = None, plan_id: Optional[str] = None, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Get all subscriptions with optional filters.""" # Use explicit join to avoid ambiguous foreign key error query = db.query(Subscription).join(Subscription.user).join(Subscription.plan) if status: query = query.filter(Subscription.status == status) if plan_id: query = query.filter(Subscription.plan_id == plan_id) subscriptions = query.order_by(Subscription.created_at.desc()).all() return [{ "id": str(sub.id), "user": { "id": str(sub.user.id), "first_name": sub.user.first_name, "last_name": sub.user.last_name, "email": sub.user.email }, "plan": { "id": str(sub.plan.id), "name": sub.plan.name, "billing_cycle": sub.plan.billing_cycle }, "status": sub.status.value, "start_date": sub.start_date, "end_date": sub.end_date, "amount_paid_cents": sub.amount_paid_cents, "base_subscription_cents": sub.base_subscription_cents, "donation_cents": sub.donation_cents, "payment_method": sub.payment_method, "stripe_subscription_id": sub.stripe_subscription_id, "created_at": sub.created_at, "updated_at": sub.updated_at } for sub in subscriptions] @api_router.get("/admin/subscriptions/stats") async def get_subscription_stats( current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Get subscription statistics for admin dashboard.""" from sqlalchemy import func total = db.query(Subscription).count() active = db.query(Subscription).filter( Subscription.status == SubscriptionStatus.active ).count() cancelled = db.query(Subscription).filter( Subscription.status == SubscriptionStatus.cancelled ).count() expired = db.query(Subscription).filter( Subscription.status == SubscriptionStatus.expired ).count() revenue_data = db.query( func.sum(Subscription.amount_paid_cents).label('total_revenue'), func.sum(Subscription.base_subscription_cents).label('total_base'), func.sum(Subscription.donation_cents).label('total_donations') ).first() return { "total": total, "active": active, "cancelled": cancelled, "expired": expired, "total_revenue": revenue_data.total_revenue or 0, "total_base": revenue_data.total_base or 0, "total_donations": revenue_data.total_donations or 0 } @api_router.put("/admin/subscriptions/{subscription_id}") async def update_subscription( subscription_id: str, request: UpdateSubscriptionRequest, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Update subscription details (status, dates).""" subscription = db.query(Subscription).filter( Subscription.id == subscription_id ).first() if not subscription: raise HTTPException(status_code=404, detail="Subscription not found") # Update fields if provided if request.status: subscription.status = SubscriptionStatus[request.status] if request.end_date: subscription.end_date = request.end_date subscription.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(subscription) logger.info(f"Admin {current_user.email} updated subscription {subscription_id}") return { "id": str(subscription.id), "user_id": str(subscription.user_id), "plan_id": str(subscription.plan_id), "status": subscription.status.value, "start_date": subscription.start_date, "end_date": subscription.end_date, "amount_paid_cents": subscription.amount_paid_cents, "updated_at": subscription.updated_at } @api_router.post("/admin/subscriptions/{subscription_id}/cancel") async def cancel_subscription( subscription_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Cancel a subscription.""" subscription = db.query(Subscription).filter( Subscription.id == subscription_id ).first() if not subscription: raise HTTPException(status_code=404, detail="Subscription not found") subscription.status = SubscriptionStatus.cancelled subscription.updated_at = datetime.now(timezone.utc) # Also update user status if currently active user = subscription.user if user.status == UserStatus.active: user.status = UserStatus.inactive user.updated_at = datetime.now(timezone.utc) db.commit() logger.info(f"Admin {current_user.email} cancelled subscription {subscription_id} for user {user.email}") return {"message": "Subscription cancelled successfully"} # ============================================================================ # Admin Document Management Routes # ============================================================================ # Newsletter Archive Admin Routes @api_router.post("/admin/newsletters") async def create_newsletter( title: str = Form(...), description: str = Form(None), published_date: str = Form(...), document_type: str = Form("google_docs"), document_url: str = Form(None), file: Optional[UploadFile] = File(None), current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Create newsletter record Admin only - supports both URL links and file uploads """ from models import NewsletterArchive, StorageUsage from r2_storage import get_r2_storage final_url = document_url file_size = None # If file uploaded, upload to R2 if file and document_type == 'upload': r2 = get_r2_storage() public_url, object_key, file_size_bytes = await r2.upload_file( file=file, folder="newsletters", allowed_types=r2.ALLOWED_DOCUMENT_TYPES, max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) ) final_url = public_url file_size = file_size_bytes # Update storage usage storage = db.query(StorageUsage).first() if storage: storage.total_bytes_used += file_size storage.last_updated = datetime.now(timezone.utc) db.commit() newsletter = NewsletterArchive( title=title, description=description, published_date=datetime.fromisoformat(published_date.replace('Z', '+00:00')), document_url=final_url, document_type=document_type, file_size_bytes=file_size, created_by=current_user.id ) db.add(newsletter) db.commit() db.refresh(newsletter) return { "id": str(newsletter.id), "message": "Newsletter created successfully" } @api_router.put("/admin/newsletters/{newsletter_id}") async def update_newsletter( newsletter_id: str, title: str = Form(...), description: str = Form(None), published_date: str = Form(...), document_type: str = Form("google_docs"), document_url: str = Form(None), file: Optional[UploadFile] = File(None), current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Update newsletter record Admin only - supports both URL links and file uploads """ from models import NewsletterArchive, StorageUsage from r2_storage import get_r2_storage newsletter = db.query(NewsletterArchive).filter( NewsletterArchive.id == newsletter_id ).first() if not newsletter: raise HTTPException(status_code=404, detail="Newsletter not found") final_url = document_url file_size = newsletter.file_size_bytes # If file uploaded, upload to R2 if file and document_type == 'upload': r2 = get_r2_storage() public_url, object_key, file_size_bytes = await r2.upload_file( file=file, folder="newsletters", allowed_types=r2.ALLOWED_DOCUMENT_TYPES, max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) ) final_url = public_url # Update storage usage (subtract old, add new) storage = db.query(StorageUsage).first() if storage and newsletter.file_size_bytes: storage.total_bytes_used -= newsletter.file_size_bytes if storage: storage.total_bytes_used += file_size_bytes storage.last_updated = datetime.now(timezone.utc) db.commit() file_size = file_size_bytes newsletter.title = title newsletter.description = description newsletter.published_date = datetime.fromisoformat(published_date.replace('Z', '+00:00')) newsletter.document_url = final_url newsletter.document_type = document_type newsletter.file_size_bytes = file_size newsletter.updated_at = datetime.now(timezone.utc) db.commit() return {"message": "Newsletter updated successfully"} @api_router.delete("/admin/newsletters/{newsletter_id}") async def delete_newsletter( newsletter_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Delete newsletter record Admin only """ from models import NewsletterArchive newsletter = db.query(NewsletterArchive).filter( NewsletterArchive.id == newsletter_id ).first() if not newsletter: raise HTTPException(status_code=404, detail="Newsletter not found") db.delete(newsletter) db.commit() return {"message": "Newsletter deleted successfully"} # Financial Reports Admin Routes @api_router.post("/admin/financials") async def create_financial_report( year: int = Form(...), title: str = Form(...), document_type: str = Form("google_drive"), document_url: str = Form(None), file: Optional[UploadFile] = File(None), current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Create financial report record Admin only - supports both URL links and file uploads """ from models import FinancialReport, StorageUsage from r2_storage import get_r2_storage final_url = document_url file_size = None # If file uploaded, upload to R2 if file and document_type == 'upload': r2 = get_r2_storage() public_url, object_key, file_size_bytes = await r2.upload_file( file=file, folder="financials", allowed_types=r2.ALLOWED_DOCUMENT_TYPES, max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) ) final_url = public_url file_size = file_size_bytes # Update storage usage storage = db.query(StorageUsage).first() if storage: storage.total_bytes_used += file_size storage.last_updated = datetime.now(timezone.utc) db.commit() report = FinancialReport( year=year, title=title, document_url=final_url, document_type=document_type, file_size_bytes=file_size, created_by=current_user.id ) db.add(report) db.commit() db.refresh(report) return { "id": str(report.id), "message": "Financial report created successfully" } @api_router.put("/admin/financials/{report_id}") async def update_financial_report( report_id: str, year: int = Form(...), title: str = Form(...), document_type: str = Form("google_drive"), document_url: str = Form(None), file: Optional[UploadFile] = File(None), current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Update financial report record Admin only - supports both URL links and file uploads """ from models import FinancialReport, StorageUsage from r2_storage import get_r2_storage report = db.query(FinancialReport).filter( FinancialReport.id == report_id ).first() if not report: raise HTTPException(status_code=404, detail="Financial report not found") final_url = document_url file_size = report.file_size_bytes # If file uploaded, upload to R2 if file and document_type == 'upload': r2 = get_r2_storage() public_url, object_key, file_size_bytes = await r2.upload_file( file=file, folder="financials", allowed_types=r2.ALLOWED_DOCUMENT_TYPES, max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) ) final_url = public_url # Update storage usage (subtract old, add new) storage = db.query(StorageUsage).first() if storage and report.file_size_bytes: storage.total_bytes_used -= report.file_size_bytes if storage: storage.total_bytes_used += file_size_bytes storage.last_updated = datetime.now(timezone.utc) db.commit() file_size = file_size_bytes report.year = year report.title = title report.document_url = final_url report.document_type = document_type report.file_size_bytes = file_size report.updated_at = datetime.now(timezone.utc) db.commit() return {"message": "Financial report updated successfully"} @api_router.delete("/admin/financials/{report_id}") async def delete_financial_report( report_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Delete financial report record Admin only """ from models import FinancialReport report = db.query(FinancialReport).filter( FinancialReport.id == report_id ).first() if not report: raise HTTPException(status_code=404, detail="Financial report not found") db.delete(report) db.commit() return {"message": "Financial report deleted successfully"} # Bylaws Admin Routes @api_router.post("/admin/bylaws") async def create_bylaws( title: str = Form(...), version: str = Form(...), effective_date: str = Form(...), document_type: str = Form("google_drive"), document_url: str = Form(None), is_current: bool = Form(True), file: Optional[UploadFile] = File(None), current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Create bylaws document If is_current=True, sets all others to is_current=False Admin only - supports both URL links and file uploads """ from models import BylawsDocument, StorageUsage from r2_storage import get_r2_storage final_url = document_url file_size = None # If file uploaded, upload to R2 if file and document_type == 'upload': r2 = get_r2_storage() public_url, object_key, file_size_bytes = await r2.upload_file( file=file, folder="bylaws", allowed_types=r2.ALLOWED_DOCUMENT_TYPES, max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) ) final_url = public_url file_size = file_size_bytes # Update storage usage storage = db.query(StorageUsage).first() if storage: storage.total_bytes_used += file_size storage.last_updated = datetime.now(timezone.utc) db.commit() if is_current: # Set all other bylaws to not current db.query(BylawsDocument).update({"is_current": False}) bylaws = BylawsDocument( title=title, version=version, effective_date=datetime.fromisoformat(effective_date.replace('Z', '+00:00')), document_url=final_url, document_type=document_type, is_current=is_current, file_size_bytes=file_size, created_by=current_user.id ) db.add(bylaws) db.commit() db.refresh(bylaws) return { "id": str(bylaws.id), "message": "Bylaws created successfully" } @api_router.put("/admin/bylaws/{bylaws_id}") async def update_bylaws( bylaws_id: str, title: str = Form(...), version: str = Form(...), effective_date: str = Form(...), document_type: str = Form("google_drive"), document_url: str = Form(None), is_current: bool = Form(False), file: Optional[UploadFile] = File(None), current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Update bylaws document If is_current=True, sets all others to is_current=False Admin only - supports both URL links and file uploads """ from models import BylawsDocument, StorageUsage from r2_storage import get_r2_storage bylaws = db.query(BylawsDocument).filter( BylawsDocument.id == bylaws_id ).first() if not bylaws: raise HTTPException(status_code=404, detail="Bylaws not found") final_url = document_url file_size = bylaws.file_size_bytes # If file uploaded, upload to R2 if file and document_type == 'upload': r2 = get_r2_storage() public_url, object_key, file_size_bytes = await r2.upload_file( file=file, folder="bylaws", allowed_types=r2.ALLOWED_DOCUMENT_TYPES, max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) ) final_url = public_url # Update storage usage (subtract old, add new) storage = db.query(StorageUsage).first() if storage and bylaws.file_size_bytes: storage.total_bytes_used -= bylaws.file_size_bytes if storage: storage.total_bytes_used += file_size_bytes storage.last_updated = datetime.now(timezone.utc) db.commit() file_size = file_size_bytes if is_current: # Set all other bylaws to not current db.query(BylawsDocument).filter( BylawsDocument.id != bylaws_id ).update({"is_current": False}) bylaws.title = title bylaws.version = version bylaws.effective_date = datetime.fromisoformat(effective_date.replace('Z', '+00:00')) bylaws.document_url = final_url bylaws.document_type = document_type bylaws.is_current = is_current bylaws.file_size_bytes = file_size db.commit() return {"message": "Bylaws updated successfully"} @api_router.delete("/admin/bylaws/{bylaws_id}") async def delete_bylaws( bylaws_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Delete bylaws document Admin only """ from models import BylawsDocument bylaws = db.query(BylawsDocument).filter( BylawsDocument.id == bylaws_id ).first() if not bylaws: raise HTTPException(status_code=404, detail="Bylaws not found") db.delete(bylaws) db.commit() return {"message": "Bylaws deleted successfully"} @api_router.post("/subscriptions/checkout") async def create_checkout( request: CheckoutRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """Create Stripe Checkout session with dynamic pricing and donation tracking.""" # Status validation - only allow payment_pending and inactive users allowed_statuses = [UserStatus.payment_pending, UserStatus.inactive] if current_user.status not in allowed_statuses: raise HTTPException( status_code=403, detail=f"Cannot proceed with payment. User status is '{current_user.status.value}'. " f"Please complete email verification and admin approval first." ) # Get plan plan = db.query(SubscriptionPlan).filter( SubscriptionPlan.id == request.plan_id ).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") if not plan.active: raise HTTPException(status_code=400, detail="This plan is no longer available for subscription") # Validate amount against plan minimum if request.amount_cents < plan.minimum_price_cents: raise HTTPException( status_code=400, detail=f"Amount must be at least ${plan.minimum_price_cents / 100:.2f}" ) # Calculate donation split base_amount = plan.minimum_price_cents donation_amount = request.amount_cents - base_amount # Check if plan allows donations if donation_amount > 0 and not plan.allow_donation: raise HTTPException( status_code=400, detail="This plan does not accept donations above the minimum price" ) # Get frontend URL from env frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") try: # Build line items for Stripe checkout line_items = [] # Add base subscription line item with dynamic pricing from payment_service import get_stripe_interval stripe_interval = get_stripe_interval(plan.billing_cycle) if stripe_interval: # Recurring subscription line_items.append({ "price_data": { "currency": "usd", "unit_amount": base_amount, "recurring": {"interval": stripe_interval}, "product_data": { "name": plan.name, "description": plan.description or f"{plan.name} membership" } }, "quantity": 1 }) else: # One-time payment (lifetime) line_items.append({ "price_data": { "currency": "usd", "unit_amount": base_amount, "product_data": { "name": plan.name, "description": plan.description or f"{plan.name} membership" } }, "quantity": 1 }) # Add donation line item if applicable if donation_amount > 0: line_items.append({ "price_data": { "currency": "usd", "unit_amount": donation_amount, "product_data": { "name": "Donation", "description": f"Additional donation to support {plan.name}" } }, "quantity": 1 }) # Create Stripe Checkout Session import stripe stripe.api_key = os.getenv("STRIPE_SECRET_KEY") mode = "subscription" if stripe_interval else "payment" session = stripe.checkout.Session.create( customer_email=current_user.email, payment_method_types=["card"], line_items=line_items, mode=mode, success_url=f"{frontend_url}/payment-success?session_id={{CHECKOUT_SESSION_ID}}", cancel_url=f"{frontend_url}/payment-cancel", metadata={ "user_id": str(current_user.id), "plan_id": str(plan.id), "base_amount": str(base_amount), "donation_amount": str(donation_amount), "total_amount": str(request.amount_cents) }, subscription_data={ "metadata": { "user_id": str(current_user.id), "plan_id": str(plan.id), "base_amount": str(base_amount), "donation_amount": str(donation_amount) } } if mode == "subscription" else None ) return {"checkout_url": session.url} except stripe.error.StripeError as e: logger.error(f"Stripe error creating checkout session: {str(e)}") raise HTTPException(status_code=500, detail=f"Payment processing error: {str(e)}") except Exception as e: logger.error(f"Error creating checkout session: {str(e)}") raise HTTPException(status_code=500, detail="Failed to create checkout session") @api_router.post("/donations/checkout") async def create_donation_checkout( request: DonationCheckoutRequest, db: Session = Depends(get_db) ): """Create Stripe Checkout session for one-time donation.""" # Get frontend URL from env frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") try: # Create Stripe Checkout Session for one-time payment import stripe stripe.api_key = os.getenv("STRIPE_SECRET_KEY") checkout_session = stripe.checkout.Session.create( payment_method_types=['card'], line_items=[{ 'price_data': { 'currency': 'usd', 'unit_amount': request.amount_cents, 'product_data': { 'name': 'Donation to LOAF', 'description': 'Thank you for supporting our community!' } }, 'quantity': 1 }], mode='payment', # One-time payment (not subscription) success_url=f"{frontend_url}/membership/donation-success?session_id={{CHECKOUT_SESSION_ID}}", cancel_url=f"{frontend_url}/membership/donate" ) logger.info(f"Donation checkout created: ${request.amount_cents/100:.2f}") return {"checkout_url": checkout_session.url} except stripe.error.StripeError as e: logger.error(f"Stripe error creating donation checkout: {str(e)}") raise HTTPException(status_code=500, detail=f"Payment processing error: {str(e)}") except Exception as e: logger.error(f"Error creating donation checkout: {str(e)}") raise HTTPException(status_code=500, detail="Failed to create donation checkout") @api_router.post("/contact") async def submit_contact_form( request: ContactFormRequest, db: Session = Depends(get_db) ): """Handle contact form submission and send email to admin.""" try: # Get admin email from environment or use default admin_email = os.getenv("ADMIN_EMAIL", "info@loaftx.org") # Create email content subject = f"New Contact Form Submission: {request.subject}" html_content = f"""
Reply directly to this email to respond to {request.first_name}.