Files
membership-be/server.py

3190 lines
109 KiB
Python

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
user.email_verification_token = None
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)})
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,
"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
@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 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."""
# 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")
@app.post("/api/webhooks/stripe")
async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
"""Handle Stripe webhook events. Note: This endpoint is NOT on the api_router to avoid /api/api prefix."""
# Get raw payload and signature
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
if not sig_header:
raise HTTPException(status_code=400, detail="Missing stripe-signature header")
try:
# Verify webhook signature
event = verify_webhook_signature(payload, sig_header)
except ValueError as e:
logger.error(f"Webhook signature verification failed: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
# Handle checkout.session.completed event
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
# Get metadata
user_id = session["metadata"].get("user_id")
plan_id = session["metadata"].get("plan_id")
base_amount = int(session["metadata"].get("base_amount", 0))
donation_amount = int(session["metadata"].get("donation_amount", 0))
total_amount = int(session["metadata"].get("total_amount", session.get("amount_total", 0)))
if not user_id or not plan_id:
logger.error("Missing user_id or plan_id in webhook metadata")
return {"status": "error", "message": "Missing metadata"}
# Get user and plan
user = db.query(User).filter(User.id == user_id).first()
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first()
if user and plan:
# Check if subscription already exists (idempotency)
existing_subscription = db.query(Subscription).filter(
Subscription.stripe_subscription_id == session.get("subscription")
).first()
if not existing_subscription:
# Calculate subscription period using custom billing cycle if enabled
from payment_service import calculate_subscription_period
start_date, end_date = calculate_subscription_period(plan)
# Create subscription record with donation tracking
subscription = Subscription(
user_id=user.id,
plan_id=plan.id,
stripe_subscription_id=session.get("subscription"),
stripe_customer_id=session.get("customer"),
status=SubscriptionStatus.active,
start_date=start_date,
end_date=end_date,
amount_paid_cents=total_amount,
base_subscription_cents=base_amount or plan.minimum_price_cents,
donation_cents=donation_amount,
payment_method="stripe"
)
db.add(subscription)
# Update user status and role
user.status = UserStatus.active
user.role = UserRole.member
user.updated_at = datetime.now(timezone.utc)
db.commit()
logger.info(
f"Subscription created for user {user.email}: "
f"${base_amount/100:.2f} base + ${donation_amount/100:.2f} donation = ${total_amount/100:.2f}"
)
else:
logger.info(f"Subscription already exists for session {session.get('id')}")
else:
logger.error(f"User or plan not found: user_id={user_id}, plan_id={plan_id}")
return {"status": "success"}
# Include the router in the main app
app.include_router(api_router)
app.add_middleware(
CORSMiddleware,
allow_credentials=True,
allow_origins=os.environ.get('CORS_ORIGINS', '*').split(','),
allow_methods=["*"],
allow_headers=["*"],
)