- Profile Picture\

Donation Tracking\
Validation Rejection\
Subscription Data Export\
Admin Dashboard Logo\
Admin Navbar Reorganization
This commit is contained in:
Koncept Kit
2025-12-18 17:04:00 +07:00
parent b7ab1a897f
commit db13f0e9de
13 changed files with 1915 additions and 103 deletions

631
server.py
View File

@@ -17,7 +17,7 @@ import csv
import io
from database import engine, get_db, Base
from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus
from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus, Donation, DonationType, DonationStatus
from auth import (
get_password_hash,
verify_password,
@@ -208,6 +208,8 @@ class UserResponse(BaseModel):
role: str
email_verified: bool
created_at: datetime
# Profile
profile_photo_url: Optional[str] = None
# Subscription info (optional)
subscription_start_date: Optional[datetime] = None
subscription_end_date: Optional[datetime] = None
@@ -764,6 +766,20 @@ async def get_my_permissions(
"role": get_user_role_code(current_user)
}
@api_router.get("/config")
async def get_config():
"""
Get public configuration values
Returns: max_file_size_bytes, max_file_size_mb
"""
max_file_size_bytes = int(os.getenv('MAX_FILE_SIZE_BYTES', 52428800)) # Default 50MB
max_file_size_mb = max_file_size_bytes / (1024 * 1024)
return {
"max_file_size_bytes": max_file_size_bytes,
"max_file_size_mb": int(max_file_size_mb)
}
# User Profile Routes
@api_router.get("/users/profile", response_model=UserResponse)
async def get_profile(current_user: User = Depends(get_current_user)):
@@ -2104,6 +2120,7 @@ async def get_user_by_id(
"state": user.state,
"zipcode": user.zipcode,
"date_of_birth": user.date_of_birth.isoformat() if user.date_of_birth else None,
"profile_photo_url": user.profile_photo_url,
"partner_first_name": user.partner_first_name,
"partner_last_name": user.partner_last_name,
"partner_is_member": user.partner_is_member,
@@ -2194,6 +2211,52 @@ async def update_user_status(
except ValueError:
raise HTTPException(status_code=400, detail="Invalid status")
@api_router.post("/admin/users/{user_id}/reject")
async def reject_user(
user_id: str,
rejection_data: dict,
current_user: User = Depends(require_permission("users.approve")),
db: Session = Depends(get_db)
):
"""Reject a user's membership application with mandatory reason"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
reason = rejection_data.get("reason", "").strip()
if not reason:
raise HTTPException(status_code=400, detail="Rejection reason is required")
# Update user status to rejected
user.status = UserStatus.rejected
user.rejection_reason = reason
user.rejected_at = datetime.now(timezone.utc)
user.rejected_by = current_user.id
user.updated_at = datetime.now(timezone.utc)
db.commit()
# Send rejection email
try:
from email_service import send_rejection_email
await send_rejection_email(
user.email,
user.first_name,
reason
)
logger.info(f"Rejection email sent to {user.email}")
except Exception as e:
logger.error(f"Failed to send rejection email to {user.email}: {str(e)}")
# Don't fail the request if email fails
logger.info(f"Admin {current_user.email} rejected user {user.email}")
return {
"message": "User rejected successfully",
"user_id": str(user.id),
"status": user.status.value
}
@api_router.post("/admin/users/{user_id}/activate-payment")
async def activate_payment_manually(
user_id: str,
@@ -2356,6 +2419,114 @@ async def admin_resend_verification(
return {"message": f"Verification email resent to {user.email}"}
@api_router.post("/admin/users/{user_id}/upload-photo")
async def admin_upload_user_profile_photo(
user_id: str,
file: UploadFile = File(...),
current_user: User = Depends(require_permission("users.edit")),
db: Session = Depends(get_db)
):
"""Admin uploads profile photo for a specific user"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User 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))
# Delete old profile photo if exists
if user.profile_photo_url:
old_key = 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)
storage.total_bytes_used -= old_size
except:
pass
# Upload new photo
try:
public_url, object_key, file_size = await r2.upload_file(
file=file,
folder="profiles",
max_size_bytes=max_file_size
)
user.profile_photo_url = public_url
storage.total_bytes_used += file_size
storage.last_updated = datetime.now(timezone.utc)
db.commit()
logger.info(f"Admin {current_user.email} uploaded profile photo for user {user.email}: {file_size} bytes")
return {
"message": "Profile photo uploaded successfully",
"profile_photo_url": public_url
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
@api_router.delete("/admin/users/{user_id}/delete-photo")
async def admin_delete_user_profile_photo(
user_id: str,
current_user: User = Depends(require_permission("users.edit")),
db: Session = Depends(get_db)
):
"""Admin deletes profile photo for a specific user"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.profile_photo_url:
raise HTTPException(status_code=404, detail="User has no profile photo")
r2 = get_r2_storage()
# Extract object key from URL
object_key = user.profile_photo_url.split('/')[-1]
object_key = f"profiles/{object_key}"
try:
# Get file size before deletion for storage tracking
storage = db.query(StorageUsage).first()
if storage:
try:
file_size = await r2.get_file_size(object_key)
storage.total_bytes_used -= file_size
storage.last_updated = datetime.now(timezone.utc)
except:
pass
# Delete from R2
await r2.delete_file(object_key)
# Remove URL from user record
user.profile_photo_url = None
db.commit()
logger.info(f"Admin {current_user.email} deleted profile photo for user {user.email}")
return {"message": "Profile photo deleted successfully"}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}")
# ============================================================
# User Creation & Invitation Endpoints
# ============================================================
@@ -3648,6 +3819,287 @@ async def cancel_subscription(
return {"message": "Subscription cancelled successfully"}
@api_router.get("/admin/subscriptions/export")
async def export_subscriptions(
status: Optional[str] = None,
plan_id: Optional[str] = None,
search: Optional[str] = None,
current_user: User = Depends(require_permission("subscriptions.export")),
db: Session = Depends(get_db)
):
"""Export subscriptions to CSV for financial records"""
# Build query with same logic as get_all_subscriptions
query = db.query(Subscription).join(Subscription.user).join(Subscription.plan)
# Apply filters
if status:
query = query.filter(Subscription.status == status)
if plan_id:
query = query.filter(Subscription.plan_id == plan_id)
if search:
search_term = f"%{search}%"
query = query.filter(
(User.first_name.ilike(search_term)) |
(User.last_name.ilike(search_term)) |
(User.email.ilike(search_term))
)
subscriptions = query.order_by(Subscription.created_at.desc()).all()
# Create CSV
output = io.StringIO()
writer = csv.writer(output)
# Header row
writer.writerow([
'Subscription ID', 'Member Name', 'Email', 'Plan Name', 'Billing Cycle',
'Status', 'Base Amount', 'Donation Amount', 'Total Amount', 'Payment Method',
'Start Date', 'End Date', 'Stripe Subscription ID', 'Created At', 'Updated At'
])
# Data rows
for sub in subscriptions:
user = sub.user
plan = sub.plan
writer.writerow([
str(sub.id),
f"{user.first_name} {user.last_name}",
user.email,
plan.name,
plan.billing_cycle,
sub.status.value,
f"${sub.base_subscription_cents / 100:.2f}",
f"${sub.donation_cents / 100:.2f}" if sub.donation_cents else "$0.00",
f"${sub.amount_paid_cents / 100:.2f}" if sub.amount_paid_cents else "$0.00",
sub.payment_method or 'Stripe',
sub.start_date.isoformat() if sub.start_date else '',
sub.end_date.isoformat() if sub.end_date else '',
sub.stripe_subscription_id or '',
sub.created_at.isoformat() if sub.created_at else '',
sub.updated_at.isoformat() if sub.updated_at else ''
])
# Return CSV
filename = f"subscriptions_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
# ============================================================================
# Admin Donation Management Routes
# ============================================================================
@api_router.get("/admin/donations")
async def get_donations(
donation_type: Optional[str] = None,
status: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
search: Optional[str] = None,
current_user: User = Depends(require_permission("donations.view")),
db: Session = Depends(get_db)
):
"""Get all donations with optional filters."""
query = db.query(Donation).outerjoin(User, Donation.user_id == User.id)
# Apply filters
if donation_type:
try:
query = query.filter(Donation.donation_type == DonationType[donation_type])
except KeyError:
raise HTTPException(status_code=400, detail=f"Invalid donation type: {donation_type}")
if status:
try:
query = query.filter(Donation.status == DonationStatus[status])
except KeyError:
raise HTTPException(status_code=400, detail=f"Invalid status: {status}")
if start_date:
try:
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
query = query.filter(Donation.created_at >= start_dt)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid start_date format")
if end_date:
try:
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
query = query.filter(Donation.created_at <= end_dt)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid end_date format")
if search:
search_term = f"%{search}%"
query = query.filter(
(Donation.donor_email.ilike(search_term)) |
(Donation.donor_name.ilike(search_term)) |
(User.first_name.ilike(search_term)) |
(User.last_name.ilike(search_term))
)
donations = query.order_by(Donation.created_at.desc()).all()
return [{
"id": str(d.id),
"amount_cents": d.amount_cents,
"amount": f"${d.amount_cents / 100:.2f}",
"donation_type": d.donation_type.value,
"status": d.status.value,
"donor_name": d.donor_name if d.donation_type == DonationType.public else (f"{d.user.first_name} {d.user.last_name}" if d.user else d.donor_name),
"donor_email": d.donor_email or (d.user.email if d.user else None),
"payment_method": d.payment_method,
"notes": d.notes,
"created_at": d.created_at.isoformat()
} for d in donations]
@api_router.get("/admin/donations/stats")
async def get_donation_stats(
current_user: User = Depends(require_permission("donations.view")),
db: Session = Depends(get_db)
):
"""Get donation statistics."""
from sqlalchemy import func
# Total donations
total_donations = db.query(Donation).filter(
Donation.status == DonationStatus.completed
).count()
# Member donations
member_donations = db.query(Donation).filter(
Donation.status == DonationStatus.completed,
Donation.donation_type == DonationType.member
).count()
# Public donations
public_donations = db.query(Donation).filter(
Donation.status == DonationStatus.completed,
Donation.donation_type == DonationType.public
).count()
# Total amount
total_amount = db.query(func.sum(Donation.amount_cents)).filter(
Donation.status == DonationStatus.completed
).scalar() or 0
# This month
now = datetime.now(timezone.utc)
this_month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
this_month_amount = db.query(func.sum(Donation.amount_cents)).filter(
Donation.status == DonationStatus.completed,
Donation.created_at >= this_month_start
).scalar() or 0
this_month_count = db.query(Donation).filter(
Donation.status == DonationStatus.completed,
Donation.created_at >= this_month_start
).count()
return {
"total_donations": total_donations,
"member_donations": member_donations,
"public_donations": public_donations,
"total_amount_cents": total_amount,
"total_amount": f"${total_amount / 100:.2f}",
"this_month_amount_cents": this_month_amount,
"this_month_amount": f"${this_month_amount / 100:.2f}",
"this_month_count": this_month_count
}
@api_router.get("/admin/donations/export")
async def export_donations(
donation_type: Optional[str] = None,
status: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
search: Optional[str] = None,
current_user: User = Depends(require_permission("donations.export")),
db: Session = Depends(get_db)
):
"""Export donations to CSV."""
import io
import csv
from fastapi.responses import StreamingResponse
# Build query (same as get_donations)
query = db.query(Donation).outerjoin(User, Donation.user_id == User.id)
if donation_type:
try:
query = query.filter(Donation.donation_type == DonationType[donation_type])
except KeyError:
raise HTTPException(status_code=400, detail=f"Invalid donation type: {donation_type}")
if status:
try:
query = query.filter(Donation.status == DonationStatus[status])
except KeyError:
raise HTTPException(status_code=400, detail=f"Invalid status: {status}")
if start_date:
try:
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
query = query.filter(Donation.created_at >= start_dt)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid start_date format")
if end_date:
try:
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
query = query.filter(Donation.created_at <= end_dt)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid end_date format")
if search:
search_term = f"%{search}%"
query = query.filter(
(Donation.donor_email.ilike(search_term)) |
(Donation.donor_name.ilike(search_term)) |
(User.first_name.ilike(search_term)) |
(User.last_name.ilike(search_term))
)
donations = query.order_by(Donation.created_at.desc()).all()
# Create CSV
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
'Donation ID', 'Date', 'Donor Name', 'Donor Email', 'Type',
'Amount', 'Status', 'Payment Method', 'Stripe Payment Intent',
'Notes'
])
for d in donations:
donor_name = d.donor_name if d.donation_type == DonationType.public else (f"{d.user.first_name} {d.user.last_name}" if d.user else d.donor_name)
donor_email = d.donor_email or (d.user.email if d.user else '')
writer.writerow([
str(d.id),
d.created_at.strftime('%Y-%m-%d %H:%M:%S'),
donor_name or '',
donor_email,
d.donation_type.value,
f"${d.amount_cents / 100:.2f}",
d.status.value,
d.payment_method or '',
d.stripe_payment_intent_id or '',
d.notes or ''
])
filename = f"donations_export_{datetime.now().strftime('%Y%m%d')}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
# ============================================================================
# Admin Document Management Routes
# ============================================================================
@@ -4761,14 +5213,39 @@ async def create_checkout(
@api_router.post("/donations/checkout")
async def create_donation_checkout(
request: DonationCheckoutRequest,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(lambda: None) # Optional authentication
):
"""Create Stripe Checkout session for one-time donation."""
# Get frontend URL from env
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
# Check if user is authenticated (from header if present)
try:
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
# Try to get token from request if available
# For now, we'll make this work for both authenticated and anonymous
pass
except:
pass
try:
# Create donation record first
donation = Donation(
amount_cents=request.amount_cents,
donation_type=DonationType.member if current_user else DonationType.public,
user_id=current_user.id if current_user else None,
donor_email=current_user.email if current_user else None,
donor_name=f"{current_user.first_name} {current_user.last_name}" if current_user else None,
status=DonationStatus.pending
)
db.add(donation)
db.commit()
db.refresh(donation)
# Create Stripe Checkout Session for one-time payment
import stripe
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
@@ -4788,17 +5265,28 @@ async def create_donation_checkout(
}],
mode='payment', # One-time payment (not subscription)
success_url=f"{frontend_url}/membership/donation-success?session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=f"{frontend_url}/membership/donate"
cancel_url=f"{frontend_url}/membership/donate",
metadata={
'donation_id': str(donation.id),
'donation_type': donation.donation_type.value,
'user_id': str(current_user.id) if current_user else None
}
)
logger.info(f"Donation checkout created: ${request.amount_cents/100:.2f}")
# Update donation with session ID
donation.stripe_checkout_session_id = checkout_session.id
db.commit()
logger.info(f"Donation checkout created: ${request.amount_cents/100:.2f} (ID: {donation.id})")
return {"checkout_url": checkout_session.url}
except stripe.error.StripeError as e:
db.rollback()
logger.error(f"Stripe error creating donation checkout: {str(e)}")
raise HTTPException(status_code=500, detail=f"Payment processing error: {str(e)}")
except Exception as e:
db.rollback()
logger.error(f"Error creating donation checkout: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to create donation checkout")
@@ -4911,64 +5399,95 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
# Handle checkout.session.completed event
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
metadata = session.get("metadata", {})
# 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
set_user_role(user, UserRole.member, db)
user.updated_at = datetime.now(timezone.utc)
# Check if this is a donation (has donation_id in metadata)
if "donation_id" in metadata:
donation_id = uuid.UUID(metadata["donation_id"])
donation = db.query(Donation).filter(Donation.id == donation_id).first()
if donation:
donation.status = DonationStatus.completed
donation.stripe_payment_intent_id = session.get('payment_intent')
donation.payment_method = 'card'
donation.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}"
)
# Send thank you email
try:
from email_service import send_donation_thank_you_email
donor_first_name = donation.donor_name.split()[0] if donation.donor_name else "Friend"
await send_donation_thank_you_email(
donation.donor_email,
donor_first_name,
donation.amount_cents
)
except Exception as e:
logger.error(f"Failed to send donation thank you email: {str(e)}")
logger.info(f"Donation completed: ${donation.amount_cents/100:.2f} (ID: {donation.id})")
else:
logger.info(f"Subscription already exists for session {session.get('id')}")
logger.error(f"Donation not found: {donation_id}")
# Otherwise handle subscription payment (existing logic)
else:
logger.error(f"User or plan not found: user_id={user_id}, plan_id={plan_id}")
# Get metadata
user_id = metadata.get("user_id")
plan_id = metadata.get("plan_id")
base_amount = int(metadata.get("base_amount", 0))
donation_amount = int(metadata.get("donation_amount", 0))
total_amount = int(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
set_user_role(user, UserRole.member, db)
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"}