forked from andika/membership-be
3197 lines
109 KiB
Python
3197 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
|
|
# Don't clear token immediately - keeps endpoint idempotent for React StrictMode double-calls
|
|
# Token will be cleared on first successful login
|
|
|
|
db.commit()
|
|
db.refresh(user)
|
|
|
|
logger.info(f"Email verified for user: {user.email}")
|
|
|
|
return {"message": "Email verified successfully", "status": user.status.value}
|
|
|
|
@api_router.post("/auth/resend-verification-email")
|
|
async def resend_verification_email(
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""User requests to resend their verification email"""
|
|
|
|
# Check if email already verified
|
|
if current_user.email_verified:
|
|
raise HTTPException(status_code=400, detail="Email is already verified")
|
|
|
|
# Generate new token
|
|
verification_token = secrets.token_urlsafe(32)
|
|
current_user.email_verification_token = verification_token
|
|
db.commit()
|
|
|
|
# Send verification email
|
|
await send_verification_email(current_user.email, verification_token)
|
|
|
|
logger.info(f"Verification email resent to: {current_user.email}")
|
|
|
|
return {"message": "Verification email has been resent. Please check your inbox."}
|
|
|
|
@api_router.post("/auth/login", response_model=LoginResponse)
|
|
async def login(request: LoginRequest, db: Session = Depends(get_db)):
|
|
user = db.query(User).filter(User.email == request.email).first()
|
|
|
|
if not user or not verify_password(request.password, user.password_hash):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Incorrect email or password"
|
|
)
|
|
|
|
access_token = create_access_token(data={"sub": str(user.id)})
|
|
|
|
# Clear verification token on first successful login after verification
|
|
if user.email_verified and user.email_verification_token:
|
|
user.email_verification_token = None
|
|
db.commit()
|
|
|
|
return {
|
|
"access_token": access_token,
|
|
"token_type": "bearer",
|
|
"user": {
|
|
"id": str(user.id),
|
|
"email": user.email,
|
|
"first_name": user.first_name,
|
|
"last_name": user.last_name,
|
|
"status": user.status.value,
|
|
"role": user.role.value,
|
|
"email_verified": user.email_verified,
|
|
"force_password_change": user.force_password_change
|
|
}
|
|
}
|
|
|
|
@api_router.post("/auth/forgot-password")
|
|
async def forgot_password(request: ForgotPasswordRequest, db: Session = Depends(get_db)):
|
|
"""Request password reset - sends email with reset link"""
|
|
user = db.query(User).filter(User.email == request.email).first()
|
|
|
|
# Always return success (security: don't reveal if email exists)
|
|
if user:
|
|
token = create_password_reset_token(user, db)
|
|
reset_url = f"{os.getenv('FRONTEND_URL')}/reset-password?token={token}"
|
|
|
|
await send_password_reset_email(user.email, user.first_name, reset_url)
|
|
|
|
return {"message": "If email exists, reset link has been sent"}
|
|
|
|
@api_router.post("/auth/reset-password")
|
|
async def reset_password(request: ResetPasswordRequest, db: Session = Depends(get_db)):
|
|
"""Complete password reset using token"""
|
|
user = verify_reset_token(request.token, db)
|
|
|
|
if not user:
|
|
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
|
|
|
|
# Update password
|
|
user.password_hash = get_password_hash(request.new_password)
|
|
user.password_reset_token = None
|
|
user.password_reset_expires = None
|
|
user.force_password_change = False # Reset flag if it was set
|
|
db.commit()
|
|
|
|
return {"message": "Password reset successful"}
|
|
|
|
@api_router.put("/users/change-password")
|
|
async def change_password(
|
|
request: ChangePasswordRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""User changes their own password"""
|
|
# Verify current password
|
|
if not verify_password(request.current_password, current_user.password_hash):
|
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
|
|
|
# Update password
|
|
current_user.password_hash = get_password_hash(request.new_password)
|
|
current_user.force_password_change = False # Clear flag if set
|
|
db.commit()
|
|
|
|
return {"message": "Password changed successfully"}
|
|
|
|
@api_router.get("/auth/me", response_model=UserResponse)
|
|
async def get_me(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
# Get user's active subscription if exists
|
|
active_subscription = db.query(Subscription).filter(
|
|
Subscription.user_id == current_user.id,
|
|
Subscription.status == SubscriptionStatus.active
|
|
).first()
|
|
|
|
return UserResponse(
|
|
id=str(current_user.id),
|
|
email=current_user.email,
|
|
first_name=current_user.first_name,
|
|
last_name=current_user.last_name,
|
|
phone=current_user.phone,
|
|
address=current_user.address,
|
|
city=current_user.city,
|
|
state=current_user.state,
|
|
zipcode=current_user.zipcode,
|
|
date_of_birth=current_user.date_of_birth,
|
|
status=current_user.status.value,
|
|
role=current_user.role.value,
|
|
email_verified=current_user.email_verified,
|
|
created_at=current_user.created_at,
|
|
subscription_start_date=active_subscription.start_date if active_subscription else None,
|
|
subscription_end_date=active_subscription.end_date if active_subscription else None,
|
|
subscription_status=active_subscription.status.value if active_subscription else None
|
|
)
|
|
|
|
# User Profile Routes
|
|
@api_router.get("/users/profile", response_model=UserResponse)
|
|
async def get_profile(current_user: User = Depends(get_current_user)):
|
|
# Use from_attributes to automatically map all User fields to UserResponse
|
|
return UserResponse.model_validate(current_user)
|
|
|
|
@api_router.put("/users/profile")
|
|
async def update_profile(
|
|
request: UpdateProfileRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Update user profile with basic info, partner details, newsletter prefs, volunteer interests, and directory settings."""
|
|
|
|
# Basic personal information
|
|
if request.first_name is not None:
|
|
current_user.first_name = request.first_name
|
|
if request.last_name is not None:
|
|
current_user.last_name = request.last_name
|
|
if request.phone is not None:
|
|
current_user.phone = request.phone
|
|
if request.address is not None:
|
|
current_user.address = request.address
|
|
if request.city is not None:
|
|
current_user.city = request.city
|
|
if request.state is not None:
|
|
current_user.state = request.state
|
|
if request.zipcode is not None:
|
|
current_user.zipcode = request.zipcode
|
|
|
|
# Partner information
|
|
if request.partner_first_name is not None:
|
|
current_user.partner_first_name = request.partner_first_name
|
|
if request.partner_last_name is not None:
|
|
current_user.partner_last_name = request.partner_last_name
|
|
if request.partner_is_member is not None:
|
|
current_user.partner_is_member = request.partner_is_member
|
|
if request.partner_plan_to_become_member is not None:
|
|
current_user.partner_plan_to_become_member = request.partner_plan_to_become_member
|
|
|
|
# Newsletter preferences
|
|
if request.newsletter_publish_name is not None:
|
|
current_user.newsletter_publish_name = request.newsletter_publish_name
|
|
if request.newsletter_publish_photo is not None:
|
|
current_user.newsletter_publish_photo = request.newsletter_publish_photo
|
|
if request.newsletter_publish_birthday is not None:
|
|
current_user.newsletter_publish_birthday = request.newsletter_publish_birthday
|
|
if request.newsletter_publish_none is not None:
|
|
current_user.newsletter_publish_none = request.newsletter_publish_none
|
|
|
|
# Volunteer interests (array)
|
|
if request.volunteer_interests is not None:
|
|
current_user.volunteer_interests = request.volunteer_interests
|
|
|
|
# Directory settings
|
|
if request.show_in_directory is not None:
|
|
current_user.show_in_directory = request.show_in_directory
|
|
if request.directory_email is not None:
|
|
current_user.directory_email = request.directory_email
|
|
if request.directory_bio is not None:
|
|
current_user.directory_bio = request.directory_bio
|
|
if request.directory_address is not None:
|
|
current_user.directory_address = request.directory_address
|
|
if request.directory_phone is not None:
|
|
current_user.directory_phone = request.directory_phone
|
|
if request.directory_dob is not None:
|
|
current_user.directory_dob = request.directory_dob
|
|
if request.directory_partner_name is not None:
|
|
current_user.directory_partner_name = request.directory_partner_name
|
|
|
|
current_user.updated_at = datetime.now(timezone.utc)
|
|
|
|
db.commit()
|
|
db.refresh(current_user)
|
|
|
|
return {"message": "Profile updated successfully"}
|
|
|
|
# ==================== MEMBERS ONLY ROUTES ====================
|
|
|
|
# Member Directory Routes
|
|
@api_router.get("/members/directory")
|
|
async def get_member_directory(
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get list of all members who opted into the directory"""
|
|
directory_members = db.query(User).filter(
|
|
User.show_in_directory == True,
|
|
User.role == UserRole.member,
|
|
User.status == UserStatus.active
|
|
).all()
|
|
|
|
return [{
|
|
"id": str(member.id),
|
|
"first_name": member.first_name,
|
|
"last_name": member.last_name,
|
|
"profile_photo_url": member.profile_photo_url,
|
|
"directory_email": member.directory_email,
|
|
"directory_bio": member.directory_bio,
|
|
"directory_address": member.directory_address,
|
|
"directory_phone": member.directory_phone,
|
|
"directory_dob": member.directory_dob,
|
|
"directory_partner_name": member.directory_partner_name,
|
|
"volunteer_interests": member.volunteer_interests or [],
|
|
"social_media_facebook": member.social_media_facebook,
|
|
"social_media_instagram": member.social_media_instagram,
|
|
"social_media_twitter": member.social_media_twitter,
|
|
"social_media_linkedin": member.social_media_linkedin
|
|
} for member in directory_members]
|
|
|
|
@api_router.get("/members/directory/{user_id}")
|
|
async def get_directory_member_profile(
|
|
user_id: str,
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get public directory profile of a specific member"""
|
|
member = db.query(User).filter(
|
|
User.id == user_id,
|
|
User.show_in_directory == True,
|
|
User.role == UserRole.member,
|
|
User.status == UserStatus.active
|
|
).first()
|
|
|
|
if not member:
|
|
raise HTTPException(status_code=404, detail="Member not found in directory")
|
|
|
|
return {
|
|
"id": str(member.id),
|
|
"first_name": member.first_name,
|
|
"last_name": member.last_name,
|
|
"profile_photo_url": member.profile_photo_url,
|
|
"directory_email": member.directory_email,
|
|
"directory_bio": member.directory_bio,
|
|
"directory_address": member.directory_address,
|
|
"directory_phone": member.directory_phone,
|
|
"directory_dob": member.directory_dob,
|
|
"directory_partner_name": member.directory_partner_name,
|
|
"volunteer_interests": member.volunteer_interests or [],
|
|
"social_media_facebook": member.social_media_facebook,
|
|
"social_media_instagram": member.social_media_instagram,
|
|
"social_media_twitter": member.social_media_twitter,
|
|
"social_media_linkedin": member.social_media_linkedin
|
|
}
|
|
|
|
# Enhanced Profile Routes (Active Members Only)
|
|
@api_router.get("/members/profile")
|
|
async def get_enhanced_profile(
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get enhanced profile with all member-only fields"""
|
|
return {
|
|
"id": str(current_user.id),
|
|
"email": current_user.email,
|
|
"first_name": current_user.first_name,
|
|
"last_name": current_user.last_name,
|
|
"phone": current_user.phone,
|
|
"address": current_user.address,
|
|
"city": current_user.city,
|
|
"state": current_user.state,
|
|
"zipcode": current_user.zipcode,
|
|
"date_of_birth": current_user.date_of_birth,
|
|
"profile_photo_url": current_user.profile_photo_url,
|
|
"social_media_facebook": current_user.social_media_facebook,
|
|
"social_media_instagram": current_user.social_media_instagram,
|
|
"social_media_twitter": current_user.social_media_twitter,
|
|
"social_media_linkedin": current_user.social_media_linkedin,
|
|
"show_in_directory": current_user.show_in_directory,
|
|
"directory_email": current_user.directory_email,
|
|
"directory_bio": current_user.directory_bio,
|
|
"directory_address": current_user.directory_address,
|
|
"directory_phone": current_user.directory_phone,
|
|
"directory_dob": current_user.directory_dob,
|
|
"directory_partner_name": current_user.directory_partner_name,
|
|
"status": current_user.status.value,
|
|
"role": current_user.role.value
|
|
}
|
|
|
|
@api_router.put("/members/profile")
|
|
async def update_enhanced_profile(
|
|
request: EnhancedProfileUpdateRequest,
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Update enhanced profile with social media and directory settings"""
|
|
if request.social_media_facebook is not None:
|
|
current_user.social_media_facebook = request.social_media_facebook
|
|
if request.social_media_instagram is not None:
|
|
current_user.social_media_instagram = request.social_media_instagram
|
|
if request.social_media_twitter is not None:
|
|
current_user.social_media_twitter = request.social_media_twitter
|
|
if request.social_media_linkedin is not None:
|
|
current_user.social_media_linkedin = request.social_media_linkedin
|
|
if request.show_in_directory is not None:
|
|
current_user.show_in_directory = request.show_in_directory
|
|
if request.directory_email is not None:
|
|
current_user.directory_email = request.directory_email
|
|
if request.directory_bio is not None:
|
|
current_user.directory_bio = request.directory_bio
|
|
if request.directory_address is not None:
|
|
current_user.directory_address = request.directory_address
|
|
if request.directory_phone is not None:
|
|
current_user.directory_phone = request.directory_phone
|
|
if request.directory_dob is not None:
|
|
current_user.directory_dob = request.directory_dob
|
|
if request.directory_partner_name is not None:
|
|
current_user.directory_partner_name = request.directory_partner_name
|
|
|
|
current_user.updated_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
db.refresh(current_user)
|
|
|
|
return {"message": "Enhanced profile updated successfully"}
|
|
|
|
@api_router.post("/members/profile/upload-photo")
|
|
async def upload_profile_photo(
|
|
file: UploadFile = File(...),
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Upload profile photo to Cloudflare R2"""
|
|
r2 = get_r2_storage()
|
|
|
|
# Get storage quota
|
|
storage = db.query(StorageUsage).first()
|
|
if not storage:
|
|
# Initialize storage tracking
|
|
storage = StorageUsage(
|
|
total_bytes_used=0,
|
|
max_bytes_allowed=int(os.getenv('MAX_STORAGE_BYTES', 10737418240))
|
|
)
|
|
db.add(storage)
|
|
db.commit()
|
|
db.refresh(storage)
|
|
|
|
# Get max file size from env
|
|
max_file_size = int(os.getenv('MAX_FILE_SIZE_BYTES', 52428800))
|
|
|
|
# Delete old profile photo if exists
|
|
if current_user.profile_photo_url:
|
|
# Extract object key from URL
|
|
old_key = current_user.profile_photo_url.split('/')[-1]
|
|
old_key = f"profiles/{old_key}"
|
|
try:
|
|
old_size = await r2.get_file_size(old_key)
|
|
await r2.delete_file(old_key)
|
|
# Update storage usage
|
|
storage.total_bytes_used -= old_size
|
|
except:
|
|
pass # File might not exist
|
|
|
|
# Upload new photo
|
|
try:
|
|
public_url, object_key, file_size = await r2.upload_file(
|
|
file=file,
|
|
folder="profiles",
|
|
allowed_types=r2.ALLOWED_IMAGE_TYPES,
|
|
max_size_bytes=max_file_size
|
|
)
|
|
|
|
# Check storage quota
|
|
if storage.total_bytes_used + file_size > storage.max_bytes_allowed:
|
|
# Rollback upload
|
|
await r2.delete_file(object_key)
|
|
raise HTTPException(
|
|
status_code=507,
|
|
detail=f"Storage limit exceeded. Used: {storage.total_bytes_used / (1024**3):.2f}GB, Limit: {storage.max_bytes_allowed / (1024**3):.2f}GB"
|
|
)
|
|
|
|
# Update user profile
|
|
current_user.profile_photo_url = public_url
|
|
current_user.updated_at = datetime.now(timezone.utc)
|
|
|
|
# Update storage usage
|
|
storage.total_bytes_used += file_size
|
|
storage.last_updated = datetime.now(timezone.utc)
|
|
|
|
db.commit()
|
|
db.refresh(current_user)
|
|
|
|
logger.info(f"Profile photo uploaded for user {current_user.email}: {file_size} bytes")
|
|
|
|
return {
|
|
"message": "Profile photo uploaded successfully",
|
|
"profile_photo_url": public_url
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error uploading profile photo: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
|
|
|
@api_router.delete("/members/profile/delete-photo")
|
|
async def delete_profile_photo(
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Delete profile photo from R2 and profile"""
|
|
if not current_user.profile_photo_url:
|
|
raise HTTPException(status_code=404, detail="No profile photo to delete")
|
|
|
|
r2 = get_r2_storage()
|
|
storage = db.query(StorageUsage).first()
|
|
|
|
# Extract object key from URL
|
|
object_key = current_user.profile_photo_url.split('/')[-1]
|
|
object_key = f"profiles/{object_key}"
|
|
|
|
try:
|
|
file_size = await r2.get_file_size(object_key)
|
|
await r2.delete_file(object_key)
|
|
|
|
# Update storage usage
|
|
if storage:
|
|
storage.total_bytes_used -= file_size
|
|
storage.last_updated = datetime.now(timezone.utc)
|
|
|
|
# Update user profile
|
|
current_user.profile_photo_url = None
|
|
current_user.updated_at = datetime.now(timezone.utc)
|
|
|
|
db.commit()
|
|
|
|
logger.info(f"Profile photo deleted for user {current_user.email}")
|
|
|
|
return {"message": "Profile photo deleted successfully"}
|
|
except Exception as e:
|
|
logger.error(f"Error deleting profile photo: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"Deletion failed: {str(e)}")
|
|
|
|
# Calendar Routes (Active Members Only)
|
|
@api_router.get("/members/calendar/events", response_model=List[CalendarEventResponse])
|
|
async def get_calendar_events(
|
|
start_date: Optional[datetime] = None,
|
|
end_date: Optional[datetime] = None,
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get calendar events with user RSVP status"""
|
|
query = db.query(Event).filter(Event.published == True)
|
|
|
|
if start_date:
|
|
query = query.filter(Event.start_at >= start_date)
|
|
if end_date:
|
|
query = query.filter(Event.end_at <= end_date)
|
|
|
|
events = query.order_by(Event.start_at).all()
|
|
|
|
result = []
|
|
for event in events:
|
|
# Get user's RSVP status for this event
|
|
rsvp = db.query(EventRSVP).filter(
|
|
EventRSVP.event_id == event.id,
|
|
EventRSVP.user_id == current_user.id
|
|
).first()
|
|
|
|
user_rsvp_status = rsvp.rsvp_status.value if rsvp else None
|
|
|
|
result.append(CalendarEventResponse(
|
|
id=str(event.id),
|
|
title=event.title,
|
|
description=event.description,
|
|
start_at=event.start_at,
|
|
end_at=event.end_at,
|
|
location=event.location,
|
|
capacity=event.capacity,
|
|
user_rsvp_status=user_rsvp_status,
|
|
microsoft_calendar_synced=event.microsoft_calendar_sync_enabled
|
|
))
|
|
|
|
return result
|
|
|
|
# Members Directory Route
|
|
@api_router.get("/members/directory")
|
|
async def get_members_directory(
|
|
search: Optional[str] = None,
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get members directory - only shows active members who opted in"""
|
|
query = db.query(User).filter(
|
|
User.show_in_directory == True,
|
|
User.status == UserStatus.active
|
|
)
|
|
|
|
if search:
|
|
search_term = f"%{search}%"
|
|
query = query.filter(
|
|
or_(
|
|
User.first_name.ilike(search_term),
|
|
User.last_name.ilike(search_term),
|
|
User.directory_bio.ilike(search_term)
|
|
)
|
|
)
|
|
|
|
members = query.order_by(User.first_name, User.last_name).all()
|
|
|
|
return [
|
|
{
|
|
"id": str(member.id),
|
|
"first_name": member.first_name,
|
|
"last_name": member.last_name,
|
|
"profile_photo_url": member.profile_photo_url,
|
|
"directory_email": member.directory_email,
|
|
"directory_bio": member.directory_bio,
|
|
"directory_address": member.directory_address,
|
|
"directory_phone": member.directory_phone,
|
|
"directory_partner_name": member.directory_partner_name,
|
|
"social_media_facebook": member.social_media_facebook,
|
|
"social_media_instagram": member.social_media_instagram,
|
|
"social_media_twitter": member.social_media_twitter,
|
|
"social_media_linkedin": member.social_media_linkedin
|
|
}
|
|
for member in members
|
|
]
|
|
|
|
# Admin Calendar Sync Routes
|
|
@api_router.post("/admin/calendar/sync/{event_id}")
|
|
async def sync_event_to_microsoft(
|
|
event_id: str,
|
|
current_user: User = Depends(get_current_admin_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Sync event to Microsoft Calendar"""
|
|
event = db.query(Event).filter(Event.id == event_id).first()
|
|
if not event:
|
|
raise HTTPException(status_code=404, detail="Event not found")
|
|
|
|
ms_calendar = get_ms_calendar_service()
|
|
|
|
try:
|
|
# Sync event
|
|
ms_event_id = await ms_calendar.sync_event(
|
|
loaf_event=event,
|
|
existing_ms_event_id=event.microsoft_calendar_id
|
|
)
|
|
|
|
# Update event with MS Calendar ID
|
|
event.microsoft_calendar_id = ms_event_id
|
|
event.microsoft_calendar_sync_enabled = True
|
|
event.updated_at = datetime.now(timezone.utc)
|
|
|
|
db.commit()
|
|
|
|
logger.info(f"Event {event.title} synced to Microsoft Calendar by {current_user.email}")
|
|
|
|
return {
|
|
"message": "Event synced to Microsoft Calendar successfully",
|
|
"microsoft_calendar_id": ms_event_id
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error syncing event to Microsoft Calendar: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}")
|
|
|
|
@api_router.delete("/admin/calendar/unsync/{event_id}")
|
|
async def unsync_event_from_microsoft(
|
|
event_id: str,
|
|
current_user: User = Depends(get_current_admin_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Remove event from Microsoft Calendar"""
|
|
event = db.query(Event).filter(Event.id == event_id).first()
|
|
if not event:
|
|
raise HTTPException(status_code=404, detail="Event not found")
|
|
|
|
if not event.microsoft_calendar_id:
|
|
raise HTTPException(status_code=400, detail="Event is not synced to Microsoft Calendar")
|
|
|
|
ms_calendar = get_ms_calendar_service()
|
|
|
|
try:
|
|
# Delete from Microsoft Calendar
|
|
await ms_calendar.delete_event(event.microsoft_calendar_id)
|
|
|
|
# Update event
|
|
event.microsoft_calendar_id = None
|
|
event.microsoft_calendar_sync_enabled = False
|
|
event.updated_at = datetime.now(timezone.utc)
|
|
|
|
db.commit()
|
|
|
|
logger.info(f"Event {event.title} unsynced from Microsoft Calendar by {current_user.email}")
|
|
|
|
return {"message": "Event removed from Microsoft Calendar successfully"}
|
|
except Exception as e:
|
|
logger.error(f"Error removing event from Microsoft Calendar: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"Unsync failed: {str(e)}")
|
|
|
|
# Event Gallery Routes (Members Only)
|
|
@api_router.get("/members/gallery")
|
|
async def get_events_with_galleries(
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get all events that have gallery images"""
|
|
# Get events that have at least one gallery image
|
|
events_with_galleries = db.query(Event).join(EventGallery).filter(
|
|
Event.published == True
|
|
).distinct().order_by(Event.start_at.desc()).all()
|
|
|
|
result = []
|
|
for event in events_with_galleries:
|
|
gallery_count = db.query(EventGallery).filter(
|
|
EventGallery.event_id == event.id
|
|
).count()
|
|
|
|
# Get first image as thumbnail
|
|
first_image = db.query(EventGallery).filter(
|
|
EventGallery.event_id == event.id
|
|
).order_by(EventGallery.created_at).first()
|
|
|
|
result.append({
|
|
"id": str(event.id),
|
|
"title": event.title,
|
|
"description": event.description,
|
|
"start_at": event.start_at,
|
|
"location": event.location,
|
|
"gallery_count": gallery_count,
|
|
"thumbnail_url": first_image.image_url if first_image else None
|
|
})
|
|
|
|
return result
|
|
|
|
@api_router.get("/events/{event_id}/gallery")
|
|
async def get_event_gallery(
|
|
event_id: str,
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get all gallery images for a specific event"""
|
|
event = db.query(Event).filter(Event.id == event_id).first()
|
|
if not event:
|
|
raise HTTPException(status_code=404, detail="Event not found")
|
|
|
|
gallery_images = db.query(EventGallery).filter(
|
|
EventGallery.event_id == event_id
|
|
).order_by(EventGallery.created_at.desc()).all()
|
|
|
|
return [
|
|
{
|
|
"id": str(img.id),
|
|
"image_url": img.image_url,
|
|
"image_key": img.image_key,
|
|
"caption": img.caption,
|
|
"uploaded_by": str(img.uploaded_by),
|
|
"file_size_bytes": img.file_size_bytes,
|
|
"created_at": img.created_at
|
|
}
|
|
for img in gallery_images
|
|
]
|
|
|
|
# Admin Event Gallery Routes
|
|
@api_router.post("/admin/events/{event_id}/gallery")
|
|
async def upload_event_gallery_image(
|
|
event_id: str,
|
|
file: UploadFile = File(...),
|
|
caption: Optional[str] = None,
|
|
current_user: User = Depends(get_current_admin_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Upload image to event gallery (Admin only)"""
|
|
# Validate event exists
|
|
event = db.query(Event).filter(Event.id == event_id).first()
|
|
if not event:
|
|
raise HTTPException(status_code=404, detail="Event not found")
|
|
|
|
r2 = get_r2_storage()
|
|
|
|
# Get storage quota
|
|
storage = db.query(StorageUsage).first()
|
|
if not storage:
|
|
storage = StorageUsage(
|
|
total_bytes_used=0,
|
|
max_bytes_allowed=int(os.getenv('MAX_STORAGE_BYTES', 10737418240))
|
|
)
|
|
db.add(storage)
|
|
db.commit()
|
|
db.refresh(storage)
|
|
|
|
# Get max file size from env
|
|
max_file_size = int(os.getenv('MAX_FILE_SIZE_BYTES', 52428800))
|
|
|
|
try:
|
|
# Upload to R2
|
|
public_url, object_key, file_size = await r2.upload_file(
|
|
file=file,
|
|
folder=f"gallery/{event_id}",
|
|
allowed_types=r2.ALLOWED_IMAGE_TYPES,
|
|
max_size_bytes=max_file_size
|
|
)
|
|
|
|
# Check storage quota
|
|
if storage.total_bytes_used + file_size > storage.max_bytes_allowed:
|
|
# Rollback upload
|
|
await r2.delete_file(object_key)
|
|
raise HTTPException(
|
|
status_code=507,
|
|
detail=f"Storage limit exceeded. Used: {storage.total_bytes_used / (1024**3):.2f}GB, Limit: {storage.max_bytes_allowed / (1024**3):.2f}GB"
|
|
)
|
|
|
|
# Create gallery record
|
|
gallery_image = EventGallery(
|
|
event_id=event.id,
|
|
image_url=public_url,
|
|
image_key=object_key,
|
|
caption=caption,
|
|
uploaded_by=current_user.id,
|
|
file_size_bytes=file_size
|
|
)
|
|
db.add(gallery_image)
|
|
|
|
# Update storage usage
|
|
storage.total_bytes_used += file_size
|
|
storage.last_updated = datetime.now(timezone.utc)
|
|
|
|
db.commit()
|
|
db.refresh(gallery_image)
|
|
|
|
logger.info(f"Gallery image uploaded for event {event.title} by {current_user.email}: {file_size} bytes")
|
|
|
|
return {
|
|
"message": "Image uploaded successfully",
|
|
"image": {
|
|
"id": str(gallery_image.id),
|
|
"image_url": gallery_image.image_url,
|
|
"caption": gallery_image.caption,
|
|
"file_size_bytes": gallery_image.file_size_bytes
|
|
}
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error uploading gallery image: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
|
|
|
@api_router.delete("/admin/event-gallery/{image_id}")
|
|
async def delete_gallery_image(
|
|
image_id: str,
|
|
current_user: User = Depends(get_current_admin_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Delete image from event gallery (Admin only)"""
|
|
gallery_image = db.query(EventGallery).filter(EventGallery.id == image_id).first()
|
|
if not gallery_image:
|
|
raise HTTPException(status_code=404, detail="Gallery image not found")
|
|
|
|
r2 = get_r2_storage()
|
|
storage = db.query(StorageUsage).first()
|
|
|
|
try:
|
|
# Delete from R2
|
|
await r2.delete_file(gallery_image.image_key)
|
|
|
|
# Update storage usage
|
|
if storage:
|
|
storage.total_bytes_used -= gallery_image.file_size_bytes
|
|
storage.last_updated = datetime.now(timezone.utc)
|
|
|
|
# Delete from database
|
|
db.delete(gallery_image)
|
|
db.commit()
|
|
|
|
logger.info(f"Gallery image deleted by {current_user.email}: {gallery_image.image_key}")
|
|
|
|
return {"message": "Image deleted successfully"}
|
|
except Exception as e:
|
|
logger.error(f"Error deleting gallery image: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"Deletion failed: {str(e)}")
|
|
|
|
@api_router.put("/admin/event-gallery/{image_id}")
|
|
async def update_gallery_image_caption(
|
|
image_id: str,
|
|
caption: str,
|
|
current_user: User = Depends(get_current_admin_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Update gallery image caption (Admin only)"""
|
|
gallery_image = db.query(EventGallery).filter(EventGallery.id == image_id).first()
|
|
if not gallery_image:
|
|
raise HTTPException(status_code=404, detail="Gallery image not found")
|
|
|
|
gallery_image.caption = caption
|
|
db.commit()
|
|
db.refresh(gallery_image)
|
|
|
|
return {
|
|
"message": "Caption updated successfully",
|
|
"image": {
|
|
"id": str(gallery_image.id),
|
|
"caption": gallery_image.caption
|
|
}
|
|
}
|
|
|
|
# Event Routes
|
|
@api_router.get("/events", response_model=List[EventResponse])
|
|
async def get_events(
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
# Get published events for all users
|
|
events = db.query(Event).filter(Event.published == True).order_by(Event.start_at).all()
|
|
|
|
result = []
|
|
for event in events:
|
|
rsvp_count = db.query(EventRSVP).filter(
|
|
EventRSVP.event_id == event.id,
|
|
EventRSVP.rsvp_status == RSVPStatus.yes
|
|
).count()
|
|
|
|
# No user_rsvp_status in public endpoint
|
|
result.append(EventResponse(
|
|
id=str(event.id),
|
|
title=event.title,
|
|
description=event.description,
|
|
start_at=event.start_at,
|
|
end_at=event.end_at,
|
|
location=event.location,
|
|
capacity=event.capacity,
|
|
published=event.published,
|
|
created_by=str(event.created_by),
|
|
created_at=event.created_at,
|
|
rsvp_count=rsvp_count,
|
|
user_rsvp_status=None
|
|
))
|
|
|
|
return result
|
|
|
|
@api_router.get("/events/{event_id}", response_model=EventResponse)
|
|
async def get_event(
|
|
event_id: str,
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
event = db.query(Event).filter(Event.id == event_id).first()
|
|
if not event:
|
|
raise HTTPException(status_code=404, detail="Event not found")
|
|
|
|
rsvp_count = db.query(EventRSVP).filter(
|
|
EventRSVP.event_id == event.id,
|
|
EventRSVP.rsvp_status == RSVPStatus.yes
|
|
).count()
|
|
|
|
# No user_rsvp_status in public endpoint
|
|
user_rsvp = None
|
|
|
|
return EventResponse(
|
|
id=str(event.id),
|
|
title=event.title,
|
|
description=event.description,
|
|
start_at=event.start_at,
|
|
end_at=event.end_at,
|
|
location=event.location,
|
|
capacity=event.capacity,
|
|
published=event.published,
|
|
created_by=str(event.created_by),
|
|
created_at=event.created_at,
|
|
rsvp_count=rsvp_count,
|
|
user_rsvp_status=user_rsvp
|
|
)
|
|
|
|
@api_router.post("/events/{event_id}/rsvp")
|
|
async def rsvp_to_event(
|
|
event_id: str,
|
|
request: RSVPRequest,
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
event = db.query(Event).filter(Event.id == event_id).first()
|
|
if not event:
|
|
raise HTTPException(status_code=404, detail="Event not found")
|
|
|
|
# Check if RSVP already exists
|
|
existing_rsvp = db.query(EventRSVP).filter(
|
|
EventRSVP.event_id == event_id,
|
|
EventRSVP.user_id == current_user.id
|
|
).first()
|
|
|
|
if existing_rsvp:
|
|
existing_rsvp.rsvp_status = RSVPStatus(request.rsvp_status)
|
|
existing_rsvp.updated_at = datetime.now(timezone.utc)
|
|
else:
|
|
rsvp = EventRSVP(
|
|
event_id=event.id,
|
|
user_id=current_user.id,
|
|
rsvp_status=RSVPStatus(request.rsvp_status)
|
|
)
|
|
db.add(rsvp)
|
|
|
|
db.commit()
|
|
|
|
return {"message": "RSVP updated successfully"}
|
|
|
|
# ============================================================================
|
|
# Calendar Export Endpoints (Universal iCalendar .ics format)
|
|
# ============================================================================
|
|
|
|
@api_router.get("/events/{event_id}/download.ics")
|
|
async def download_event_ics(
|
|
event_id: str,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Download single event as .ics file (RFC 5545 iCalendar format)
|
|
No authentication required for published events
|
|
Works with Google Calendar, Apple Calendar, Microsoft Outlook, etc.
|
|
"""
|
|
event = db.query(Event).filter(
|
|
Event.id == event_id,
|
|
Event.published == True
|
|
).first()
|
|
|
|
if not event:
|
|
raise HTTPException(status_code=404, detail="Event not found")
|
|
|
|
# Generate UID if not exists
|
|
if not event.calendar_uid:
|
|
event.calendar_uid = calendar_service.generate_event_uid()
|
|
db.commit()
|
|
|
|
ics_content = calendar_service.create_single_event_calendar(event)
|
|
|
|
# Sanitize filename
|
|
safe_filename = "".join(c for c in event.title if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
|
safe_filename = safe_filename.replace(' ', '_') or 'event'
|
|
|
|
return StreamingResponse(
|
|
iter([ics_content]),
|
|
media_type="text/calendar",
|
|
headers={
|
|
"Content-Disposition": f"attachment; filename={safe_filename}.ics",
|
|
"Cache-Control": "public, max-age=300" # Cache for 5 minutes
|
|
}
|
|
)
|
|
|
|
@api_router.get("/calendars/subscribe.ics")
|
|
async def subscribe_calendar(
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Subscribe to user's RSVP'd events (live calendar feed)
|
|
Auto-syncs events marked as "Yes" RSVP
|
|
Use webcal:// protocol for auto-sync in calendar apps
|
|
"""
|
|
# Get all upcoming events user RSVP'd "yes" to
|
|
rsvps = db.query(EventRSVP).filter(
|
|
EventRSVP.user_id == current_user.id,
|
|
EventRSVP.rsvp_status == RSVPStatus.yes
|
|
).join(Event).filter(
|
|
Event.start_at > datetime.now(timezone.utc),
|
|
Event.published == True
|
|
).all()
|
|
|
|
events = [rsvp.event for rsvp in rsvps]
|
|
|
|
# Generate UIDs for events that don't have them
|
|
for event in events:
|
|
if not event.calendar_uid:
|
|
event.calendar_uid = calendar_service.generate_event_uid()
|
|
db.commit()
|
|
|
|
feed_name = f"{current_user.first_name}'s LOAF Events"
|
|
ics_content = calendar_service.create_subscription_feed(events, feed_name)
|
|
|
|
return StreamingResponse(
|
|
iter([ics_content]),
|
|
media_type="text/calendar",
|
|
headers={
|
|
"Content-Disposition": "inline; filename=loaf-events.ics",
|
|
"Cache-Control": "public, max-age=3600", # Cache for 1 hour
|
|
"ETag": f'"{hash(ics_content)}"'
|
|
}
|
|
)
|
|
|
|
@api_router.get("/calendars/all-events.ics")
|
|
async def download_all_events(
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Download all upcoming published events as .ics file (one-time download)
|
|
Useful for importing all events at once
|
|
"""
|
|
events = db.query(Event).filter(
|
|
Event.published == True,
|
|
Event.start_at > datetime.now(timezone.utc)
|
|
).order_by(Event.start_at).all()
|
|
|
|
# Generate UIDs
|
|
for event in events:
|
|
if not event.calendar_uid:
|
|
event.calendar_uid = calendar_service.generate_event_uid()
|
|
db.commit()
|
|
|
|
ics_content = calendar_service.create_subscription_feed(events, "All LOAF Events")
|
|
|
|
return StreamingResponse(
|
|
iter([ics_content]),
|
|
media_type="text/calendar",
|
|
headers={
|
|
"Content-Disposition": "attachment; filename=loaf-all-events.ics",
|
|
"Cache-Control": "public, max-age=600" # Cache for 10 minutes
|
|
}
|
|
)
|
|
|
|
# ============================================================================
|
|
# Newsletter Archive Routes (Members Only)
|
|
# ============================================================================
|
|
@api_router.get("/newsletters")
|
|
async def get_newsletters(
|
|
year: Optional[int] = None,
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Get all newsletters, optionally filtered by year
|
|
Members only
|
|
"""
|
|
from models import NewsletterArchive
|
|
|
|
query = db.query(NewsletterArchive)
|
|
|
|
if year:
|
|
query = query.filter(
|
|
db.func.extract('year', NewsletterArchive.published_date) == year
|
|
)
|
|
|
|
newsletters = query.order_by(NewsletterArchive.published_date.desc()).all()
|
|
|
|
return [{
|
|
"id": str(n.id),
|
|
"title": n.title,
|
|
"description": n.description,
|
|
"published_date": n.published_date.isoformat(),
|
|
"document_url": n.document_url,
|
|
"document_type": n.document_type,
|
|
"file_size_bytes": n.file_size_bytes,
|
|
"created_at": n.created_at.isoformat()
|
|
} for n in newsletters]
|
|
|
|
@api_router.get("/newsletters/years")
|
|
async def get_newsletter_years(
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Get list of years that have newsletters
|
|
Members only
|
|
"""
|
|
from models import NewsletterArchive
|
|
|
|
years = db.query(
|
|
db.func.extract('year', NewsletterArchive.published_date).label('year')
|
|
).distinct().order_by(db.text('year DESC')).all()
|
|
|
|
return [int(y.year) for y in years]
|
|
|
|
# ============================================================================
|
|
# Financial Reports Routes (Members Only)
|
|
# ============================================================================
|
|
@api_router.get("/financials")
|
|
async def get_financial_reports(
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Get all financial reports sorted by year (newest first)
|
|
Members only
|
|
"""
|
|
from models import FinancialReport
|
|
|
|
reports = db.query(FinancialReport).order_by(
|
|
FinancialReport.year.desc()
|
|
).all()
|
|
|
|
return [{
|
|
"id": str(r.id),
|
|
"year": r.year,
|
|
"title": r.title,
|
|
"document_url": r.document_url,
|
|
"document_type": r.document_type,
|
|
"file_size_bytes": r.file_size_bytes,
|
|
"created_at": r.created_at.isoformat()
|
|
} for r in reports]
|
|
|
|
# ============================================================================
|
|
# Bylaws Routes (Members Only)
|
|
# ============================================================================
|
|
@api_router.get("/bylaws/current")
|
|
async def get_current_bylaws(
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Get current bylaws document
|
|
Members only
|
|
"""
|
|
from models import BylawsDocument
|
|
|
|
bylaws = db.query(BylawsDocument).filter(
|
|
BylawsDocument.is_current == True
|
|
).first()
|
|
|
|
if not bylaws:
|
|
raise HTTPException(status_code=404, detail="No current bylaws found")
|
|
|
|
return {
|
|
"id": str(bylaws.id),
|
|
"title": bylaws.title,
|
|
"version": bylaws.version,
|
|
"effective_date": bylaws.effective_date.isoformat(),
|
|
"document_url": bylaws.document_url,
|
|
"document_type": bylaws.document_type,
|
|
"file_size_bytes": bylaws.file_size_bytes,
|
|
"is_current": bylaws.is_current,
|
|
"created_at": bylaws.created_at.isoformat()
|
|
}
|
|
|
|
@api_router.get("/bylaws/history")
|
|
async def get_bylaws_history(
|
|
current_user: User = Depends(get_active_member),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Get all bylaws versions (historical)
|
|
Members only
|
|
"""
|
|
from models import BylawsDocument
|
|
|
|
history = db.query(BylawsDocument).order_by(
|
|
BylawsDocument.effective_date.desc()
|
|
).all()
|
|
|
|
return [{
|
|
"id": str(b.id),
|
|
"title": b.title,
|
|
"version": b.version,
|
|
"effective_date": b.effective_date.isoformat(),
|
|
"document_url": b.document_url,
|
|
"document_type": b.document_type,
|
|
"file_size_bytes": b.file_size_bytes,
|
|
"is_current": b.is_current,
|
|
"created_at": b.created_at.isoformat()
|
|
} for b in history]
|
|
|
|
# ============================================================================
|
|
# Configuration Endpoints
|
|
# ============================================================================
|
|
@api_router.get("/config/limits")
|
|
async def get_config_limits():
|
|
"""Get configuration limits for file uploads"""
|
|
return {
|
|
"max_file_size_bytes": int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)),
|
|
"max_storage_bytes": int(os.getenv('MAX_STORAGE_BYTES', 1073741824))
|
|
}
|
|
|
|
# ============================================================================
|
|
# Admin Routes
|
|
# ============================================================================
|
|
@api_router.get("/admin/storage/usage")
|
|
async def get_storage_usage(
|
|
current_user: User = Depends(get_current_admin_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get current storage usage statistics"""
|
|
from models import StorageUsage
|
|
|
|
storage = db.query(StorageUsage).first()
|
|
|
|
if not storage:
|
|
# Initialize if doesn't exist
|
|
storage = StorageUsage(
|
|
total_bytes_used=0,
|
|
max_bytes_allowed=int(os.getenv('MAX_STORAGE_BYTES', 1073741824))
|
|
)
|
|
db.add(storage)
|
|
db.commit()
|
|
db.refresh(storage)
|
|
|
|
percentage = (storage.total_bytes_used / storage.max_bytes_allowed) * 100 if storage.max_bytes_allowed > 0 else 0
|
|
|
|
return {
|
|
"total_bytes_used": storage.total_bytes_used,
|
|
"max_bytes_allowed": storage.max_bytes_allowed,
|
|
"percentage": round(percentage, 2),
|
|
"available_bytes": storage.max_bytes_allowed - storage.total_bytes_used
|
|
}
|
|
|
|
@api_router.get("/admin/storage/breakdown")
|
|
async def get_storage_breakdown(
|
|
current_user: User = Depends(get_current_admin_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get storage usage breakdown by category"""
|
|
from sqlalchemy import func
|
|
from models import User, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument
|
|
|
|
# Count storage by category
|
|
profile_photos = db.query(func.coalesce(func.sum(User.profile_photo_size), 0)).scalar() or 0
|
|
gallery_images = db.query(func.coalesce(func.sum(EventGallery.file_size_bytes), 0)).scalar() or 0
|
|
newsletters = db.query(func.coalesce(func.sum(NewsletterArchive.file_size_bytes), 0)).filter(
|
|
NewsletterArchive.document_type == 'upload'
|
|
).scalar() or 0
|
|
financials = db.query(func.coalesce(func.sum(FinancialReport.file_size_bytes), 0)).filter(
|
|
FinancialReport.document_type == 'upload'
|
|
).scalar() or 0
|
|
bylaws = db.query(func.coalesce(func.sum(BylawsDocument.file_size_bytes), 0)).filter(
|
|
BylawsDocument.document_type == 'upload'
|
|
).scalar() or 0
|
|
|
|
return {
|
|
"breakdown": {
|
|
"profile_photos": profile_photos,
|
|
"gallery_images": gallery_images,
|
|
"newsletters": newsletters,
|
|
"financials": financials,
|
|
"bylaws": bylaws
|
|
},
|
|
"total": profile_photos + gallery_images + newsletters + financials + bylaws
|
|
}
|
|
|
|
|
|
@api_router.get("/admin/users")
|
|
async def get_all_users(
|
|
status: Optional[str] = None,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin_user)
|
|
):
|
|
query = db.query(User)
|
|
|
|
if status:
|
|
try:
|
|
status_enum = UserStatus(status)
|
|
query = query.filter(User.status == status_enum)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid status")
|
|
|
|
users = query.order_by(User.created_at.desc()).all()
|
|
|
|
return [
|
|
{
|
|
"id": str(user.id),
|
|
"email": user.email,
|
|
"first_name": user.first_name,
|
|
"last_name": user.last_name,
|
|
"phone": user.phone,
|
|
"status": user.status.value,
|
|
"role": user.role.value,
|
|
"email_verified": user.email_verified,
|
|
"created_at": user.created_at.isoformat(),
|
|
"lead_sources": user.lead_sources,
|
|
"referred_by_member_name": user.referred_by_member_name
|
|
}
|
|
for user in users
|
|
]
|
|
|
|
@api_router.get("/admin/users/{user_id}")
|
|
async def get_user_by_id(
|
|
user_id: str,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin_user)
|
|
):
|
|
"""Get specific user by ID (admin only)"""
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
return {
|
|
"id": str(user.id),
|
|
"email": user.email,
|
|
"first_name": user.first_name,
|
|
"last_name": user.last_name,
|
|
"phone": user.phone,
|
|
"address": user.address,
|
|
"city": user.city,
|
|
"state": user.state,
|
|
"zipcode": user.zipcode,
|
|
"date_of_birth": user.date_of_birth.isoformat() if user.date_of_birth else None,
|
|
"partner_first_name": user.partner_first_name,
|
|
"partner_last_name": user.partner_last_name,
|
|
"partner_is_member": user.partner_is_member,
|
|
"partner_plan_to_become_member": user.partner_plan_to_become_member,
|
|
"referred_by_member_name": user.referred_by_member_name,
|
|
"status": user.status.value,
|
|
"role": user.role.value,
|
|
"email_verified": user.email_verified,
|
|
"newsletter_subscribed": user.newsletter_subscribed,
|
|
"lead_sources": user.lead_sources,
|
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
|
"updated_at": user.updated_at.isoformat() if user.updated_at else None
|
|
}
|
|
|
|
@api_router.put("/admin/users/{user_id}/approve")
|
|
async def approve_user(
|
|
user_id: str,
|
|
bypass_email_verification: bool = False,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin_user)
|
|
):
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
# Handle bypass email verification for pending_email users
|
|
if bypass_email_verification and user.status == UserStatus.pending_email:
|
|
# Verify email manually
|
|
user.email_verified = True
|
|
user.email_verification_token = None
|
|
|
|
# Determine status based on referral
|
|
if user.referred_by_member_name:
|
|
referrer = db.query(User).filter(
|
|
or_(
|
|
User.first_name + ' ' + User.last_name == user.referred_by_member_name,
|
|
User.email == user.referred_by_member_name
|
|
),
|
|
User.status == UserStatus.active
|
|
).first()
|
|
user.status = UserStatus.pre_approved if referrer else UserStatus.pending_approval
|
|
else:
|
|
user.status = UserStatus.pending_approval
|
|
|
|
logger.info(f"Admin {current_user.email} bypassed email verification for {user.email}")
|
|
|
|
# Validate user status - must be pending_approval or pre_approved
|
|
if user.status not in [UserStatus.pending_approval, UserStatus.pre_approved]:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"User must have verified email first. Current: {user.status.value}"
|
|
)
|
|
|
|
# Set to payment_pending - user becomes active after payment via webhook
|
|
user.status = UserStatus.payment_pending
|
|
user.updated_at = datetime.now(timezone.utc)
|
|
|
|
db.commit()
|
|
db.refresh(user)
|
|
|
|
# Send payment prompt email
|
|
await send_payment_prompt_email(user.email, user.first_name)
|
|
|
|
logger.info(f"User validated and approved (payment pending): {user.email} by admin: {current_user.email}")
|
|
|
|
return {"message": "User approved - payment email sent"}
|
|
|
|
@api_router.put("/admin/users/{user_id}/status")
|
|
async def update_user_status(
|
|
user_id: str,
|
|
request: UpdateUserStatusRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin_user)
|
|
):
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
try:
|
|
new_status = UserStatus(request.status)
|
|
user.status = new_status
|
|
user.updated_at = datetime.now(timezone.utc)
|
|
|
|
db.commit()
|
|
db.refresh(user)
|
|
|
|
return {"message": "User status updated successfully"}
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid status")
|
|
|
|
@api_router.post("/admin/users/{user_id}/activate-payment")
|
|
async def activate_payment_manually(
|
|
user_id: str,
|
|
request: ManualPaymentRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin_user)
|
|
):
|
|
"""Manually activate user who paid offline (cash, bank transfer, etc.)"""
|
|
|
|
# 1. Find user
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
# 2. Validate status
|
|
if user.status != UserStatus.payment_pending:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"User must be in payment_pending status. Current: {user.status.value}"
|
|
)
|
|
|
|
# 3. Get subscription plan
|
|
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == request.plan_id).first()
|
|
if not plan:
|
|
raise HTTPException(status_code=404, detail="Subscription plan not found")
|
|
|
|
# 4. Validate amount against plan minimum
|
|
if request.amount_cents < plan.minimum_price_cents:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Amount must be at least ${plan.minimum_price_cents / 100:.2f}"
|
|
)
|
|
|
|
# 5. Calculate donation split
|
|
base_amount = plan.minimum_price_cents
|
|
donation_amount = request.amount_cents - base_amount
|
|
|
|
# 6. Calculate subscription period
|
|
from payment_service import calculate_subscription_period
|
|
|
|
if request.use_custom_period or request.override_plan_dates:
|
|
# Admin-specified custom dates override everything
|
|
if not request.custom_period_start or not request.custom_period_end:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Custom period start and end dates are required when use_custom_period or override_plan_dates is true"
|
|
)
|
|
period_start = request.custom_period_start
|
|
period_end = request.custom_period_end
|
|
else:
|
|
# Use plan's custom cycle or billing cycle
|
|
period_start, period_end = calculate_subscription_period(plan)
|
|
|
|
# 7. Create subscription record (manual payment) with donation tracking
|
|
subscription = Subscription(
|
|
user_id=user.id,
|
|
plan_id=plan.id,
|
|
stripe_subscription_id=None, # No Stripe involvement
|
|
stripe_customer_id=None,
|
|
status=SubscriptionStatus.active,
|
|
start_date=period_start,
|
|
end_date=period_end,
|
|
amount_paid_cents=request.amount_cents,
|
|
base_subscription_cents=base_amount,
|
|
donation_cents=donation_amount,
|
|
payment_method=request.payment_method,
|
|
manual_payment=True,
|
|
manual_payment_notes=request.notes,
|
|
manual_payment_admin_id=current_user.id,
|
|
manual_payment_date=request.payment_date
|
|
)
|
|
db.add(subscription)
|
|
|
|
# 6. Activate user
|
|
user.status = UserStatus.active
|
|
user.role = UserRole.member
|
|
user.updated_at = datetime.now(timezone.utc)
|
|
|
|
# 7. Commit
|
|
db.commit()
|
|
db.refresh(subscription)
|
|
|
|
# 8. Log admin action
|
|
logger.info(
|
|
f"Admin {current_user.email} manually activated payment for user {user.email} "
|
|
f"via {request.payment_method} for ${request.amount_cents/100:.2f} "
|
|
f"with plan {plan.name} ({period_start.date()} to {period_end.date()})"
|
|
)
|
|
|
|
return {
|
|
"message": "User payment activated successfully",
|
|
"user_id": str(user.id),
|
|
"subscription_id": str(subscription.id)
|
|
}
|
|
|
|
@api_router.put("/admin/users/{user_id}/reset-password")
|
|
async def admin_reset_user_password(
|
|
user_id: str,
|
|
request: AdminPasswordUpdateRequest,
|
|
current_user: User = Depends(get_current_admin_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Admin resets user password - generates temp password and emails it"""
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
# Generate random temporary password
|
|
temp_password = secrets.token_urlsafe(12)
|
|
|
|
# Update user
|
|
user.password_hash = get_password_hash(temp_password)
|
|
user.force_password_change = request.force_change
|
|
db.commit()
|
|
|
|
# Email user the temporary password
|
|
await send_admin_password_reset_email(
|
|
user.email,
|
|
user.first_name,
|
|
temp_password,
|
|
request.force_change
|
|
)
|
|
|
|
# Log admin action
|
|
logger.info(
|
|
f"Admin {current_user.email} reset password for user {user.email} "
|
|
f"(force_change={request.force_change})"
|
|
)
|
|
|
|
return {"message": f"Password reset for {user.email}. Temporary password emailed."}
|
|
|
|
@api_router.post("/admin/users/{user_id}/resend-verification")
|
|
async def admin_resend_verification(
|
|
user_id: str,
|
|
current_user: User = Depends(get_current_admin_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Admin resends verification email for any user"""
|
|
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
# Check if email already verified
|
|
if user.email_verified:
|
|
raise HTTPException(status_code=400, detail="User's email is already verified")
|
|
|
|
# Generate new token
|
|
verification_token = secrets.token_urlsafe(32)
|
|
user.email_verification_token = verification_token
|
|
db.commit()
|
|
|
|
# Send verification email
|
|
await send_verification_email(user.email, verification_token)
|
|
|
|
# Log admin action
|
|
logger.info(
|
|
f"Admin {current_user.email} resent verification email to user {user.email}"
|
|
)
|
|
|
|
return {"message": f"Verification email resent to {user.email}"}
|
|
|
|
@api_router.post("/admin/events", response_model=EventResponse)
|
|
async def create_event(
|
|
request: EventCreate,
|
|
current_user: User = Depends(get_current_admin_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
event = Event(
|
|
title=request.title,
|
|
description=request.description,
|
|
start_at=request.start_at,
|
|
end_at=request.end_at,
|
|
location=request.location,
|
|
capacity=request.capacity,
|
|
published=request.published,
|
|
created_by=current_user.id
|
|
)
|
|
|
|
db.add(event)
|
|
db.commit()
|
|
db.refresh(event)
|
|
|
|
logger.info(f"Event created: {event.title} by {current_user.email}")
|
|
|
|
return EventResponse(
|
|
id=str(event.id),
|
|
title=event.title,
|
|
description=event.description,
|
|
start_at=event.start_at,
|
|
end_at=event.end_at,
|
|
location=event.location,
|
|
capacity=event.capacity,
|
|
published=event.published,
|
|
created_by=str(event.created_by),
|
|
created_at=event.created_at,
|
|
rsvp_count=0
|
|
)
|
|
|
|
@api_router.put("/admin/events/{event_id}")
|
|
async def update_event(
|
|
event_id: str,
|
|
request: EventUpdate,
|
|
current_user: User = Depends(get_current_admin_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
event = db.query(Event).filter(Event.id == event_id).first()
|
|
if not event:
|
|
raise HTTPException(status_code=404, detail="Event not found")
|
|
|
|
if request.title:
|
|
event.title = request.title
|
|
if request.description is not None:
|
|
event.description = request.description
|
|
if request.start_at:
|
|
event.start_at = request.start_at
|
|
if request.end_at:
|
|
event.end_at = request.end_at
|
|
if request.location:
|
|
event.location = request.location
|
|
if request.capacity is not None:
|
|
event.capacity = request.capacity
|
|
if request.published is not None:
|
|
event.published = request.published
|
|
|
|
event.updated_at = datetime.now(timezone.utc)
|
|
|
|
db.commit()
|
|
db.refresh(event)
|
|
|
|
return {"message": "Event updated successfully"}
|
|
|
|
@api_router.get("/admin/events/{event_id}/rsvps")
|
|
async def get_event_rsvps(
|
|
event_id: str,
|
|
current_user: User = Depends(get_current_admin_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
event = db.query(Event).filter(Event.id == event_id).first()
|
|
if not event:
|
|
raise HTTPException(status_code=404, detail="Event not found")
|
|
|
|
rsvps = db.query(EventRSVP).filter(EventRSVP.event_id == event_id).all()
|
|
|
|
result = []
|
|
for rsvp in rsvps:
|
|
user = db.query(User).filter(User.id == rsvp.user_id).first()
|
|
result.append({
|
|
"id": str(rsvp.id),
|
|
"user_id": str(rsvp.user_id),
|
|
"user_name": f"{user.first_name} {user.last_name}",
|
|
"user_email": user.email,
|
|
"rsvp_status": rsvp.rsvp_status.value,
|
|
"attended": rsvp.attended,
|
|
"attended_at": rsvp.attended_at.isoformat() if rsvp.attended_at else None
|
|
})
|
|
|
|
return result
|
|
|
|
@api_router.put("/admin/events/{event_id}/attendance")
|
|
async def mark_attendance(
|
|
event_id: str,
|
|
request: AttendanceUpdate,
|
|
current_user: User = Depends(get_current_admin_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
event = db.query(Event).filter(Event.id == event_id).first()
|
|
if not event:
|
|
raise HTTPException(status_code=404, detail="Event not found")
|
|
|
|
rsvp = db.query(EventRSVP).filter(
|
|
EventRSVP.event_id == event_id,
|
|
EventRSVP.user_id == request.user_id
|
|
).first()
|
|
|
|
if not rsvp:
|
|
raise HTTPException(status_code=404, detail="RSVP not found")
|
|
|
|
rsvp.attended = request.attended
|
|
rsvp.attended_at = datetime.now(timezone.utc) if request.attended else None
|
|
rsvp.updated_at = datetime.now(timezone.utc)
|
|
|
|
# If user attended and they were pending approval, update their status
|
|
if request.attended:
|
|
user = db.query(User).filter(User.id == request.user_id).first()
|
|
if user and user.status == UserStatus.pending_approval:
|
|
user.status = UserStatus.pre_approved
|
|
user.updated_at = datetime.now(timezone.utc)
|
|
|
|
db.commit()
|
|
|
|
return {"message": "Attendance marked successfully"}
|
|
|
|
@api_router.get("/admin/events")
|
|
async def get_admin_events(
|
|
current_user: User = Depends(get_current_admin_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get all events for admin (including unpublished)"""
|
|
events = db.query(Event).order_by(Event.start_at.desc()).all()
|
|
|
|
result = []
|
|
for event in events:
|
|
rsvp_count = db.query(EventRSVP).filter(
|
|
EventRSVP.event_id == event.id,
|
|
EventRSVP.rsvp_status == RSVPStatus.yes
|
|
).count()
|
|
|
|
result.append({
|
|
"id": str(event.id),
|
|
"title": event.title,
|
|
"description": event.description,
|
|
"start_at": event.start_at,
|
|
"end_at": event.end_at,
|
|
"location": event.location,
|
|
"capacity": event.capacity,
|
|
"published": event.published,
|
|
"created_by": str(event.created_by),
|
|
"created_at": event.created_at,
|
|
"rsvp_count": rsvp_count
|
|
})
|
|
|
|
return result
|
|
|
|
@api_router.delete("/admin/events/{event_id}")
|
|
async def delete_event(
|
|
event_id: str,
|
|
current_user: User = Depends(get_current_admin_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Delete an event (cascade deletes RSVPs)"""
|
|
event = db.query(Event).filter(Event.id == event_id).first()
|
|
if not event:
|
|
raise HTTPException(status_code=404, detail="Event not found")
|
|
|
|
db.delete(event)
|
|
db.commit()
|
|
|
|
return {"message": "Event deleted successfully"}
|
|
|
|
# ==================== PAYMENT & SUBSCRIPTION ENDPOINTS ====================
|
|
|
|
# Pydantic model for checkout request
|
|
class CheckoutRequest(BaseModel):
|
|
plan_id: str
|
|
amount_cents: int = Field(..., ge=3000, description="Total amount in cents (minimum $30)")
|
|
|
|
@validator('amount_cents')
|
|
def validate_amount(cls, v):
|
|
if v < 3000:
|
|
raise ValueError('Amount must be at least $30 (3000 cents)')
|
|
return v
|
|
|
|
# Pydantic model for plan CRUD
|
|
class PlanCreateRequest(BaseModel):
|
|
name: str = Field(min_length=1, max_length=100)
|
|
description: Optional[str] = Field(None, max_length=500)
|
|
price_cents: int = Field(ge=0, le=100000000) # Legacy field, kept for backward compatibility
|
|
billing_cycle: Literal["monthly", "quarterly", "yearly", "lifetime", "custom"]
|
|
stripe_price_id: Optional[str] = None # Deprecated, no longer required
|
|
active: bool = True
|
|
|
|
# Custom billing cycle fields (for recurring date ranges like Jan 1 - Dec 31)
|
|
custom_cycle_enabled: bool = False
|
|
custom_cycle_start_month: Optional[int] = Field(None, ge=1, le=12)
|
|
custom_cycle_start_day: Optional[int] = Field(None, ge=1, le=31)
|
|
custom_cycle_end_month: Optional[int] = Field(None, ge=1, le=12)
|
|
custom_cycle_end_day: Optional[int] = Field(None, ge=1, le=31)
|
|
|
|
# Dynamic pricing fields
|
|
minimum_price_cents: int = Field(3000, ge=3000, le=100000000) # $30 minimum
|
|
suggested_price_cents: Optional[int] = Field(None, ge=3000, le=100000000)
|
|
allow_donation: bool = True
|
|
|
|
@validator('name')
|
|
def validate_name(cls, v):
|
|
if not v.strip():
|
|
raise ValueError('Name cannot be empty or whitespace')
|
|
return v.strip()
|
|
|
|
@validator('custom_cycle_start_month', 'custom_cycle_end_month')
|
|
def validate_months(cls, v):
|
|
if v is not None and (v < 1 or v > 12):
|
|
raise ValueError('Month must be between 1 and 12')
|
|
return v
|
|
|
|
@validator('custom_cycle_start_day', 'custom_cycle_end_day')
|
|
def validate_days(cls, v):
|
|
if v is not None and (v < 1 or v > 31):
|
|
raise ValueError('Day must be between 1 and 31')
|
|
return v
|
|
|
|
@validator('suggested_price_cents')
|
|
def validate_suggested_price(cls, v, values):
|
|
if v is not None and 'minimum_price_cents' in values:
|
|
if v < values['minimum_price_cents']:
|
|
raise ValueError('Suggested price must be >= minimum price')
|
|
return v
|
|
|
|
@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=["*"],
|
|
) |