From ed5526e27b5d1517ed889b355706c2df5c6187b5 Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Tue, 16 Dec 2025 20:03:50 +0700 Subject: [PATCH] RBAC, Permissions, and Export/Import --- DEPLOYMENT_GUIDE.md | 287 + __pycache__/auth.cpython-312.pyc | Bin 6302 -> 10475 bytes __pycache__/email_service.cpython-312.pyc | Bin 20333 -> 24024 bytes __pycache__/models.cpython-312.pyc | Bin 17251 -> 25268 bytes __pycache__/server.cpython-312.pyc | Bin 154812 -> 216383 bytes auth.py | 131 +- deploy_rbac.sh | 71 + docs/status_definitions.md | 439 ++ email_service.py | 74 + migrate_role_permissions_to_dynamic_roles.py | 145 + migrate_users_to_dynamic_roles.py | 141 + migrations/001_add_member_since_field.sql | 20 + .../002_rename_approval_to_validation.sql | 59 + migrations/003_add_tos_acceptance.sql | 23 + .../004_add_reminder_tracking_fields.sql | 39 + migrations/005_add_rbac_and_invitations.sql | 187 + migrations/006_add_dynamic_roles.sql | 91 + migrations/README.md | 208 + models.py | 159 +- permissions_seed.py | 549 ++ reminder_emails.py | 487 ++ roles_seed.py | 147 + server.py | 1596 +++++- server.py.bak | 4652 +++++++++++++++++ status_transitions.py | 624 +++ update_permissions.py | 115 + verify_admin_account.py | 113 + 27 files changed, 10284 insertions(+), 73 deletions(-) create mode 100644 DEPLOYMENT_GUIDE.md create mode 100755 deploy_rbac.sh create mode 100644 docs/status_definitions.md create mode 100644 migrate_role_permissions_to_dynamic_roles.py create mode 100644 migrate_users_to_dynamic_roles.py create mode 100644 migrations/001_add_member_since_field.sql create mode 100644 migrations/002_rename_approval_to_validation.sql create mode 100644 migrations/003_add_tos_acceptance.sql create mode 100644 migrations/004_add_reminder_tracking_fields.sql create mode 100644 migrations/005_add_rbac_and_invitations.sql create mode 100644 migrations/006_add_dynamic_roles.sql create mode 100644 permissions_seed.py create mode 100644 reminder_emails.py create mode 100644 roles_seed.py create mode 100644 server.py.bak create mode 100644 status_transitions.py create mode 100644 update_permissions.py create mode 100644 verify_admin_account.py diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..cd9da8f --- /dev/null +++ b/DEPLOYMENT_GUIDE.md @@ -0,0 +1,287 @@ +# Deployment Guide - Dynamic RBAC System + +This guide covers deploying the dynamic Role-Based Access Control (RBAC) system to your dev server. + +## Overview + +The RBAC migration consists of 4 phases: +- **Phase 1**: Add new database tables and columns (schema changes) +- **Phase 2**: Seed system roles +- **Phase 3**: Migrate existing data +- **Phase 4**: System is fully operational with dynamic roles + +## Prerequisites + +- Database backup completed ✓ +- PostgreSQL access credentials +- Python 3.8+ environment +- All dependencies installed (`pip install -r requirements.txt`) + +--- + +## Step-by-Step Deployment + +### Step 1: Run Schema Migration (Phase 1) + +This creates the new `roles` table and adds `role_id` columns to `users` and `role_permissions` tables. + +```bash +# Connect to your database +psql -U -d -f migrations/006_add_dynamic_roles.sql +``` + +**What this does:** +- Creates `roles` table +- Adds `role_id` column to `users` (nullable) +- Adds `role_id` column to `role_permissions` (nullable) +- Legacy `role` enum columns remain for backward compatibility + +**Expected output:** +``` +Step 1 completed: roles table created +Step 2 completed: role_id column added to users table +Step 3 completed: role_id column added to role_permissions table +Migration 006 completed successfully! +``` + +--- + +### Step 2: Seed System Roles (Phase 2) + +This creates the 5 system roles: Superadmin, Admin, Finance, Member, Guest. + +```bash +cd backend +python3 roles_seed.py +``` + +**Expected output:** +``` +Starting roles seeding... +Created role: Superadmin (superadmin) - System role +Created role: Admin (admin) - System role +Created role: Finance (finance) - System role +Created role: Member (member) - System role +Created role: Guest (guest) - System role + +Roles seeding completed! +Total roles created: 5 +``` + +--- + +### Step 3: Migrate Existing Users (Phase 3a) + +This migrates all existing users from enum roles to the new dynamic role system. + +```bash +python3 migrate_users_to_dynamic_roles.py +``` + +**Expected output:** +``` +Starting user migration to dynamic roles... +Processing user: admin@loaf.org (superadmin) + ✓ Mapped to role: Superadmin +Processing user: finance@loaf.org (finance) + ✓ Mapped to role: Finance +... +User migration completed successfully! +Total users migrated: X +``` + +--- + +### Step 4: Migrate Role Permissions (Phase 3b) + +This migrates all existing role-permission mappings to use role_id. + +```bash +python3 migrate_role_permissions_to_dynamic_roles.py +``` + +**Expected output:** +``` +Starting role permissions migration to dynamic roles... +Migrating permissions for role: guest + ✓ Migrated: events.view (X permissions) +Migrating permissions for role: member + ✓ Migrated: events.create (X permissions) +... +Role permissions migration completed successfully! +Total role_permission records migrated: X +``` + +--- + +### Step 5: Verify Migration + +Run this verification script to ensure everything migrated correctly: + +```bash +python3 verify_admin_account.py +``` + +**Expected output:** +``` +================================================================================ +VERIFYING admin@loaf.org ACCOUNT +================================================================================ + +✅ User found: Admin User + Email: admin@loaf.org + Status: UserStatus.active + Email Verified: True + +📋 Legacy Role (enum): superadmin +✅ Dynamic Role: Superadmin (code: superadmin) + Role ID: + Is System Role: True + +🔐 Checking Permissions: + Total permissions assigned to role: 56 + +🎯 Access Check: + ✅ User should have admin access (based on legacy enum) + ✅ User should have admin access (based on dynamic role) + +================================================================================ +VERIFICATION COMPLETE +================================================================================ +``` + +--- + +### Step 6: Deploy Backend Code + +```bash +# Pull latest code +git pull origin main + +# Restart backend server +# (adjust based on your deployment method) +systemctl restart membership-backend +# OR +pm2 restart membership-backend +# OR +supervisorctl restart membership-backend +``` + +--- + +### Step 7: Verify API Endpoints + +Test the role management endpoints: + +```bash +# Get all roles +curl -H "Authorization: Bearer " \ + http://your-server/api/admin/roles + +# Get all permissions +curl -H "Authorization: Bearer " \ + http://your-server/api/admin/permissions + +# Test export (the issue we just fixed) +curl -H "Authorization: Bearer " \ + http://your-server/api/admin/users/export +``` + +--- + +## Rollback Plan (If Needed) + +If something goes wrong, you can rollback: + +```sql +BEGIN; + +-- Remove new columns +ALTER TABLE users DROP COLUMN IF EXISTS role_id; +ALTER TABLE role_permissions DROP COLUMN IF EXISTS role_id; + +-- Drop roles table +DROP TABLE IF EXISTS roles CASCADE; + +COMMIT; +``` + +Then restore from your backup if needed. + +--- + +## Post-Deployment Checklist + +- [ ] Schema migration completed without errors +- [ ] System roles seeded (5 roles created) +- [ ] All users migrated to dynamic roles +- [ ] All role permissions migrated +- [ ] Admin account verified +- [ ] Backend server restarted +- [ ] Export endpoint working (no 500 error) +- [ ] Admin can view roles in UI (/admin/permissions) +- [ ] Admin can create/edit roles +- [ ] Admin can assign permissions to roles +- [ ] Staff invitation uses dynamic roles + +--- + +## Troubleshooting + +### Issue: Migration script fails + +**Solution:** Check your `.env` file has correct `DATABASE_URL`: +``` +DATABASE_URL=postgresql://user:password@host:port/database +``` + +### Issue: "role_id column already exists" + +**Solution:** This is safe to ignore. The migration uses `IF NOT EXISTS` clauses. + +### Issue: "No roles found" when migrating users + +**Solution:** Make sure you ran Step 2 (roles_seed.py) before Step 3. + +### Issue: Export still returns 500 error + +**Solution:** +1. Verify backend code is latest version +2. Check server.py has export route BEFORE {user_id} route (around line 1965) +3. Restart backend server + +### Issue: Permission denied errors + +**Solution:** Make sure your database user has permissions: +```sql +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ; +``` + +--- + +## Files Involved + +### Migration Files +- `migrations/006_add_dynamic_roles.sql` - Schema changes +- `roles_seed.py` - Seed system roles +- `migrate_users_to_dynamic_roles.py` - Migrate user data +- `migrate_role_permissions_to_dynamic_roles.py` - Migrate permission data +- `verify_admin_account.py` - Verification script + +### Code Changes +- `server.py` - Route reordering (export before {user_id}) +- `auth.py` - get_user_role_code() helper +- `models.py` - Role model, role_id columns +- Frontend: AdminRoles.js, InviteStaffDialog.js, AdminStaff.js, Navbar.js, Login.js + +--- + +## Support + +If you encounter issues during deployment, check: +1. Backend logs: `tail -f /path/to/backend.log` +2. Database logs: Check PostgreSQL error logs +3. Frontend console: Browser developer tools + +For questions, refer to the CLAUDE.md file for system architecture details. diff --git a/__pycache__/auth.cpython-312.pyc b/__pycache__/auth.cpython-312.pyc index b35714820513cc05039186fd11b7edd8c1b2a7ca..6cbc9138c13a0055d64f8d9a7f423f06be72dad3 100644 GIT binary patch literal 10475 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 diff --git a/__pycache__/email_service.cpython-312.pyc b/__pycache__/email_service.cpython-312.pyc index a409ff7325b94ac8c5342e2b7ffdb457d3d9c051..4a6965b66201d12a550f3a04bfbca1c4fa421ff4 100644 GIT binary patch delta 1564 zcmZ8hO>7%Q6yEV~;v~+$kh+iqkxv{sP-&q(aD%t&I1OEE-n{wwKEL

W9F>VmmpQW0f5H60np9XR90kywnVYNj=wk|dlUiA6#&Z_SyT zl@!G>C6P$UqL$8tcp)LmVoud42nXEHHYRwPUm{prCO9eaT3n%tOuxlhz&sCG7qw-q zrd5q3I@U!2P=qE#RfXeFgZ8|u5J}!?q4OR!1?!fd@$n7+3$OThsmI%=n^apxJU&SJ z`-dTHNuliS@jCYBxjRS6z<|)#H;l6$eCX>G<+#G$Ki|&Ax-PN#@j8}`v@!4Sy;|em zd+%aXK}w3^v12{2PG`nOx*!)tUJB!7UM(B}5pY^bnR>05;+i5@0v{-ct#n8Bftl<9 z2Yb}xXYz@%QZtZ_MP(zvs(O9w;b={udpHm-A|McoUx5N0IzZygEYnZavkzW!G9_Bs zIfQ$$=jERmUlvygwnHcBZDQh5jr|&nt9V)H%%v)e6{&3 zEC@u6Q88&g7HYog`6=23JJk{}#nPf76I-vsC0!8Z96URPhsL_4G!Bbd2vz+NoIkjK zZgGZ}tM2{g+_%#YrgwYK>nFz_F6rJ&wO(=+YhaMmgsX{?-us zr6Kgi)vxEioV$NrAHK5Na8-BI84jPn$p~@H!H>dva8;K!_0_DwP21l@69~92pegi2 zZYQ@p@tQt<&EU`+x{UTNzpl@`VQ{Vm^a79{>(HA!49?RzWYlrPQKSz>VKj-H#w_Zs zE4elGRARYSzrO}ze>>6{=|?-=D00fN)8hm`7-H{S{JfDa!YXxx$=gL)qoy`A28Kna z=4~|2KASq?S%)#Gz^RY6zn|K&4LvE1AT(-RuuVX>=dCvErt^DEz2P(+h`-)%xGlGY OEAu`rHC?&}4F3U}3-sCm delta 111 zcmcbyoAK>DM!wU$yj%=GQ2E(Bv%`5Jp9G`DMs-V8mP$rVI6&W{jT8#QlZSk&)51 IC>tmQ081?&y#N3J diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc index 0b9446ab1dc4aaa44b2ca4efabe72aaa27aef45d..9abfe4fe4139427b85d3d3c51f9abe9cfe146ac8 100644 GIT binary patch literal 25268 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 B^_~C# delta 5033 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 aAxZM=;DUgIg&pjDDaGsBP97%ssQ-Uf6)#@^ diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index fde64855486414ba7b26d9bdbd214ed00c2d1e8a..095a9386e873626b66b8f75f09510067d9e7d11e 100644 GIT binary patch delta 80691 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πF-)fPiCPmtO#2)7z)(K52tN}UrBav_0 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 C#*-`n delta 36112 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 J3lX@Y{6E;7*-HQb diff --git a/auth.py b/auth.py index 9d92d08..3371604 100644 --- a/auth.py +++ b/auth.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta, timezone -from typing import Optional +from typing import Optional, List from jose import JWTError, jwt from passlib.context import CryptContext from fastapi import Depends, HTTPException, status @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session import os import secrets from database import get_db -from models import User, UserRole +from models import User, UserRole, Permission, RolePermission, Role pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") security = HTTPBearer() @@ -50,6 +50,24 @@ def verify_reset_token(token, db): return user +def get_user_role_code(user: User) -> str: + """ + Get user's role code from either dynamic role system or legacy enum. + Supports backward compatibility during migration (Phase 3). + + Args: + user: User object + + Returns: + Role code string (e.g., "superadmin", "admin", "member", "guest") + """ + # Prefer dynamic role if set (Phase 3+) + if user.role_id is not None and user.role_obj is not None: + return user.role_obj.code + + # Fallback to legacy enum (Phase 1-2) + return user.role.value + def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): to_encode = data.copy() if expires_delta: @@ -100,7 +118,9 @@ async def get_current_user( return user async def get_current_admin_user(current_user: User = Depends(get_current_user)) -> User: - if current_user.role != UserRole.admin: + """Require user to be admin or superadmin""" + role_code = get_user_role_code(current_user) + if role_code not in ["admin", "superadmin"]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions" @@ -117,10 +137,113 @@ async def get_active_member(current_user: User = Depends(get_current_user)) -> U detail="Active membership required. Please complete payment." ) - if current_user.role not in [UserRole.member, UserRole.admin]: + role_code = get_user_role_code(current_user) + if role_code not in ["member", "admin", "superadmin"]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Member access only" ) return current_user + + +# ============================================================ +# RBAC Permission System +# ============================================================ + +async def get_user_permissions(user: User, db: Session) -> List[str]: + """ + Get all permission codes for user's role. + Superadmin automatically gets all permissions. + Uses request-level caching to avoid repeated DB queries. + Supports both dynamic roles (role_id) and legacy enum (role). + + Args: + user: Current authenticated user + db: Database session + + Returns: + List of permission code strings (e.g., ["users.view", "events.create"]) + """ + # Check if permissions are already cached for this request + if hasattr(user, '_permission_cache'): + return user._permission_cache + + # Get role code using helper + role_code = get_user_role_code(user) + + # Superadmin gets all permissions automatically + if role_code == "superadmin": + all_perms = db.query(Permission.code).all() + permissions = [p[0] for p in all_perms] + else: + # Fetch permissions assigned to this role + # Prefer dynamic role_id, fallback to enum + if user.role_id is not None: + # Use role_id for dynamic roles + permissions = db.query(Permission.code)\ + .join(RolePermission)\ + .filter(RolePermission.role_id == user.role_id)\ + .all() + else: + # Fallback to legacy enum + permissions = db.query(Permission.code)\ + .join(RolePermission)\ + .filter(RolePermission.role == user.role)\ + .all() + permissions = [p[0] for p in permissions] + + # Cache permissions on user object for this request + user._permission_cache = permissions + return permissions + + +def require_permission(permission_code: str): + """ + Dependency injection for permission-based access control. + + Usage: + @app.get("/admin/users", dependencies=[Depends(require_permission("users.view"))]) + async def get_users(): + ... + + Args: + permission_code: Permission code to check (e.g., "users.create") + + Returns: + Async function that checks if current user has the permission + + Raises: + HTTPException 403 if user lacks the required permission + """ + async def permission_checker( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) + ) -> User: + # Get user's permissions + user_perms = await get_user_permissions(current_user, db) + + # Check if user has the required permission + if permission_code not in user_perms: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Permission required: {permission_code}" + ) + + return current_user + + return permission_checker + + +async def get_current_superadmin(current_user: User = Depends(get_current_user)) -> User: + """ + Require user to be superadmin. + Used for endpoints that should only be accessible to superadmins. + """ + role_code = get_user_role_code(current_user) + if role_code != "superadmin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Superadmin access required" + ) + return current_user diff --git a/deploy_rbac.sh b/deploy_rbac.sh new file mode 100755 index 0000000..0d7e078 --- /dev/null +++ b/deploy_rbac.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# Quick deployment script for RBAC system +# Run this on your dev server after pulling latest code + +set -e # Exit on any error + +echo "========================================" +echo "RBAC System Deployment Script" +echo "========================================" +echo "" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Check if .env exists +if [ ! -f .env ]; then + echo -e "${RED}Error: .env file not found${NC}" + echo "Please create .env file with DATABASE_URL" + exit 1 +fi + +# Load environment variables +source .env + +# Function to check command success +check_success() { + if [ $? -eq 0 ]; then + echo -e "${GREEN}✓ $1${NC}" + else + echo -e "${RED}✗ $1 failed${NC}" + exit 1 + fi +} + +echo -e "${YELLOW}Step 1: Running schema migration...${NC}" +psql $DATABASE_URL -f migrations/006_add_dynamic_roles.sql +check_success "Schema migration" + +echo "" +echo -e "${YELLOW}Step 2: Seeding system roles...${NC}" +python3 roles_seed.py +check_success "Roles seeding" + +echo "" +echo -e "${YELLOW}Step 3: Migrating users to dynamic roles...${NC}" +python3 migrate_users_to_dynamic_roles.py +check_success "User migration" + +echo "" +echo -e "${YELLOW}Step 4: Migrating role permissions...${NC}" +python3 migrate_role_permissions_to_dynamic_roles.py +check_success "Role permissions migration" + +echo "" +echo -e "${YELLOW}Step 5: Verifying admin account...${NC}" +python3 verify_admin_account.py +check_success "Admin account verification" + +echo "" +echo "========================================" +echo -e "${GREEN}✓ RBAC System Deployment Complete!${NC}" +echo "========================================" +echo "" +echo "Next steps:" +echo "1. Restart your backend server" +echo "2. Test the /api/admin/users/export endpoint" +echo "3. Login as admin and check /admin/permissions page" +echo "" diff --git a/docs/status_definitions.md b/docs/status_definitions.md new file mode 100644 index 0000000..dcde749 --- /dev/null +++ b/docs/status_definitions.md @@ -0,0 +1,439 @@ +# Membership Status Definitions & Transitions + +This document defines all user membership statuses, their meanings, valid transitions, and automated rules. + +## Status Overview + +| Status | Type | Description | Member Access | +|--------|------|-------------|---------------| +| `pending_email` | Registration | User registered, awaiting email verification | None | +| `pending_validation` | Registration | Email verified, awaiting event attendance | Newsletter only | +| `pre_validated` | Registration | Attended event or referred, ready for admin validation | Newsletter only | +| `payment_pending` | Registration | Admin validated, awaiting payment | Newsletter only | +| `active` | Active | Payment completed, full member access | Full access | +| `inactive` | Inactive | Membership deactivated manually | None | +| `canceled` | Terminated | User or admin canceled membership | None | +| `expired` | Terminated | Subscription ended without renewal | Limited (historical) | +| `abandoned` | Terminated | Incomplete registration after reminders | None | + +--- + +## Detailed Status Definitions + +### 1. pending_email + +**Definition:** User has registered but not verified their email address. + +**How User Enters:** +- User completes registration form (Step 1-4) +- System creates user account with `pending_email` status + +**Valid Transitions:** +- → `pending_validation` (email verified) +- → `pre_validated` (email verified + referred by member) +- → `abandoned` (optional: 30 days without verification after reminders) + +**Member Access:** +- Cannot login +- Cannot access any member features +- Not subscribed to newsletter + +**Reminder Schedule:** +- Day 3: First reminder email +- Day 7: Second reminder email +- Day 14: Third reminder email +- Day 30: Final reminder (optional: transition to abandoned) + +**Admin Actions:** +- Can manually resend verification email +- Can manually verify email (bypass) +- Can delete user account + +--- + +### 2. pending_validation + +**Definition:** Email verified, user needs to attend an event within 90 days (per LOAF policy). + +**How User Enters:** +- Email verification successful (from `pending_email`) +- 90-day countdown timer starts + +**Valid Transitions:** +- → `pre_validated` (attended event marked by admin) +- → `abandoned` (90 days without event attendance - per policy) + +**Member Access:** +- Can login to view dashboard +- Subscribed to newsletter +- Cannot access member-only features +- Can view public events + +**Reminder Schedule:** +- Day 30: "You have 60 days remaining to attend an event" +- Day 60: "You have 30 days remaining to attend an event" +- Day 80: "Reminder: 10 days left to attend an event" +- Day 85: "Final reminder: 5 days left" +- Day 90: Transition to `abandoned`, remove from newsletter + +**Admin Actions:** +- Can mark event attendance (triggers transition to `pre_validated`) +- Can manually transition to `pre_validated` (bypass event requirement) +- Can extend deadline + +--- + +### 3. pre_validated + +**Definition:** User attended event or was referred, awaiting admin validation. + +**How User Enters:** +- Admin marked event attendance (from `pending_validation`) +- User registered with valid member referral (skipped `pending_validation`) + +**Valid Transitions:** +- → `payment_pending` (admin validates application) +- → `inactive` (admin rejects application - rare) + +**Member Access:** +- Can login to view dashboard +- Subscribed to newsletter +- Cannot access member-only features +- Can view public events + +**Automated Rules:** +- None (requires admin action) + +**Admin Actions:** +- Review application in Validation Queue +- Validate → transition to `payment_pending` (sends payment email) +- Reject → transition to `inactive` (sends rejection email) + +--- + +### 4. payment_pending + +**Definition:** Admin validated application, user needs to complete payment. + +**How User Enters:** +- Admin validates application (from `pre_validated`) +- Payment email sent with Stripe Checkout link + +**Valid Transitions:** +- → `active` (payment successful via Stripe webhook) +- → `abandoned` (optional: 60 days without payment after reminders) + +**Member Access:** +- Can login to view dashboard +- Subscribed to newsletter +- Cannot access member-only features +- Can view subscription plans page + +**Reminder Schedule:** +- Day 7: First payment reminder +- Day 14: Second payment reminder +- Day 21: Third payment reminder +- Day 30: Fourth payment reminder +- Day 45: Fifth payment reminder +- Day 60: Final reminder (optional: transition to abandoned) + +**Note:** Since admin already validated this user, consider keeping them in this status indefinitely rather than auto-abandoning. + +**Admin Actions:** +- Can manually activate membership (for offline payments: cash, check, bank transfer) +- Can resend payment email + +--- + +### 5. active + +**Definition:** Payment completed, full membership access granted. + +**How User Enters:** +- Stripe payment successful (from `payment_pending`) +- Admin manually activated (offline payment) + +**Valid Transitions:** +- → `expired` (subscription end date reached without renewal) +- → `canceled` (user or admin cancels membership) +- → `inactive` (admin manually deactivates) + +**Member Access:** +- Full member dashboard access +- All member-only features +- Event RSVP and attendance tracking +- Member directory listing +- Newsletter subscribed + +**Renewal Reminder Schedule:** +- 60 days before expiration: First renewal reminder +- 30 days before expiration: Second renewal reminder +- 14 days before expiration: Third renewal reminder +- 7 days before expiration: Final renewal reminder +- On expiration: Transition to `expired` + +**Admin Actions:** +- Can cancel membership → `canceled` +- Can manually deactivate → `inactive` +- Can extend subscription end_date + +--- + +### 6. inactive + +**Definition:** Membership manually deactivated by admin. + +**How User Enters:** +- Admin manually sets status to `inactive` +- Used for temporary suspensions or admin rejections + +**Valid Transitions:** +- → `active` (admin reactivates) +- → `payment_pending` (admin prompts for payment) + +**Member Access:** +- Can login but no member features +- Not subscribed to newsletter +- Cannot access member-only content + +**Automated Rules:** +- None (requires admin action to exit) + +**Admin Actions:** +- Reactivate membership → `active` +- Prompt for payment → `payment_pending` +- Delete user account + +--- + +### 7. canceled + +**Definition:** Membership canceled by user or admin. + +**How User Enters:** +- User cancels subscription via Stripe portal +- Admin cancels membership +- Stripe webhook: `customer.subscription.deleted` + +**Valid Transitions:** +- → `payment_pending` (user requests to rejoin) +- → `active` (admin reactivates with new subscription) + +**Member Access:** +- Can login to view dashboard (historical data) +- Not subscribed to newsletter +- Cannot access current member-only features +- Can view historical event attendance + +**Automated Rules:** +- Stripe webhook triggers automatic transition + +**Admin Actions:** +- Can invite user to rejoin → `payment_pending` +- Can manually reactivate → `active` (if subscription still valid) + +--- + +### 8. expired + +**Definition:** Subscription ended without renewal. + +**How User Enters:** +- Subscription `end_date` reached without renewal +- Automated check runs daily + +**Valid Transitions:** +- → `payment_pending` (user chooses to renew) +- → `active` (admin manually renews/extends) + +**Member Access:** +- Can login to view dashboard (historical data) +- Not subscribed to newsletter +- Cannot access current member-only features +- Can view historical event attendance +- Shown renewal prompts + +**Automated Rules:** +- Daily check for subscriptions past `end_date` → transition to `expired` +- Send renewal invitation email on transition + +**Post-Expiration Reminders:** +- Immediate: Expiration notification + renewal link +- 7 days after: Renewal reminder +- 30 days after: Final renewal reminder +- 90 days after: Optional cleanup/archive + +**Admin Actions:** +- Manually extend subscription → `active` +- Send renewal invitation → `payment_pending` + +--- + +### 9. abandoned + +**Definition:** User failed to complete registration process after multiple reminders. + +**How User Enters:** +- From `pending_email`: 30 days without verification (optional - after 4 reminders) +- From `pending_validation`: 90 days without event attendance (after 4 reminders) +- From `payment_pending`: 60 days without payment (optional - after 6 reminders) + +**Valid Transitions:** +- → `pending_email` (admin resets application, resends verification) +- → `pending_validation` (admin resets, manually verifies email) +- → `payment_pending` (admin resets, bypasses requirements) + +**Member Access:** +- Cannot login +- Not subscribed to newsletter +- All access revoked + +**Automated Rules:** +- Send "incomplete application" notification email on transition +- Optional: Purge from database after 180 days (configurable) + +**Admin Actions:** +- Can reset application → return to appropriate pending state +- Can delete user account +- Can view abandoned applications in admin dashboard + +--- + +## State Transition Diagram + +``` +┌──────────────┐ +│ Registration │ +│ (Guest) │ +└──────────────┘ + │ + ↓ +┌───────────────┐ (30 days) ┌──────────┐ +│ pending_email │──────────────────→│abandoned │ +└───────────────┘ └──────────┘ + │ ↑ + (verify email) │ + │ │ + ↓ │ +┌────────────────────┐ (90 days) │ +│pending_validation │───────────────────┘ +│ (or pre_validated) │ +└────────────────────┘ + │ + (event/admin) + │ + ↓ +┌────────────────┐ +│ pre_validated │ +└────────────────┘ + │ + (admin validates) + │ + ↓ +┌─────────────────┐ (60 days) ┌──────────┐ +│payment_pending │──────────────────→│abandoned │ +└─────────────────┘ └──────────┘ + │ + (payment) + │ + ↓ + ┌─────────┐ + │ active │←────────────┐ + └─────────┘ │ + │ │ + ├────(expires)────→┌─────────┐ + │ │expired │ + ├────(cancels)────→├─────────┤ + │ │canceled │ + └──(deactivate)───→├─────────┤ + │inactive │ + └─────────┘ + │ + (renew/reactivate) + │ + └──────────┘ +``` + +--- + +## Email Notification Summary + +| Trigger | Emails Sent | +|---------|-------------| +| Registration complete | Verification email (immediate) | +| pending_email day 3, 7, 14, 30 | Verification reminders | +| Email verified | Welcome + event attendance instructions | +| pending_validation day 30, 60, 80, 85 | Event attendance reminders | +| Admin validates | Payment instructions | +| payment_pending day 7, 14, 21, 30, 45, 60 | Payment reminders | +| Payment successful | Membership activation confirmation | +| active: 60, 30, 14, 7 days before expiry | Renewal reminders | +| Subscription expires | Expiration notice + renewal link | +| expired: 7, 30, 90 days after | Post-expiration renewal reminders | +| Status → abandoned | Incomplete application notice | +| Admin cancels | Cancellation confirmation | + +--- + +## Implementation Notes + +### Configuration Options + +All timeout periods should be configurable via environment variables: + +```bash +# Abandonment timeouts (in days, 0 = never auto-abandon) +EMAIL_VERIFICATION_TIMEOUT=30 +EVENT_ATTENDANCE_TIMEOUT=90 +PAYMENT_TIMEOUT=0 # Don't auto-abandon payment_pending + +# Reminder schedules (comma-separated days) +EMAIL_REMINDERS=3,7,14,30 +EVENT_REMINDERS=30,60,80,85 +PAYMENT_REMINDERS=7,14,21,30,45,60 +RENEWAL_REMINDERS=60,30,14,7 +EXPIRED_REMINDERS=7,30,90 +``` + +### Background Jobs Required + +1. **Daily Status Check** (runs at 00:00 UTC) + - Check for expired subscriptions → `expired` + - Check for abandonment timeouts (if enabled) + +2. **Hourly Reminder Check** (runs every hour) + - Calculate days since status change + - Send appropriate reminder emails based on schedule + +### Database Indexes + +```sql +CREATE INDEX idx_users_status ON users(status); +CREATE INDEX idx_users_created_at ON users(created_at); +CREATE INDEX idx_users_updated_at ON users(updated_at); +CREATE INDEX idx_subscriptions_end_date ON subscriptions(end_date) WHERE status = 'active'; +``` + +--- + +## Testing Checklist + +- [ ] Reminder emails sent on correct schedule +- [ ] Abandonment timeouts respect configuration +- [ ] Manual status transitions work correctly +- [ ] Role updates on status change +- [ ] Newsletter subscription/unsubscription on status change +- [ ] Email notifications use correct templates +- [ ] Stripe webhook integration for cancellations/expirations +- [ ] Admin can bypass requirements and manually transition +- [ ] Users can complete registration even after reminders stop + +--- + +## Future Enhancements + +1. **Audit Logging**: Create `user_status_log` table to track all transitions +2. **Re-engagement Campaigns**: Target abandoned users with special offers +3. **Flexible Timeout Periods**: Per-user timeout overrides for special cases +4. **A/B Testing**: Test different reminder schedules for better completion rates +5. **SMS Reminders**: Optional SMS for critical reminders (payment due, expiration) diff --git a/email_service.py b/email_service.py index 735c27b..588086a 100644 --- a/email_service.py +++ b/email_service.py @@ -376,3 +376,77 @@ async def send_admin_password_reset_email( """ return await send_email(to_email, subject, html_content) + + +async def send_invitation_email( + to_email: str, + inviter_name: str, + invitation_url: str, + role: str +): + """Send invitation email to new user""" + subject = f"You've Been Invited to Join LOAF - {role.capitalize()} Access" + + role_descriptions = { + "member": "full member access to our community", + "admin": "administrative access to manage the platform", + "superadmin": "full administrative access with system-wide permissions" + } + + role_description = role_descriptions.get(role.lower(), "access to our platform") + + html_content = f""" + + + + + + +

+
+

🎉 You're Invited!

+
+
+

{inviter_name} has invited you to join the LOAF community with {role_description}.

+ +
+

Your Role: {role.capitalize()}

+

Invited By: {inviter_name}

+
+ +

Click the button below to accept your invitation and create your account:

+ +

+ Accept Invitation +

+ +
+

⏰ This invitation expires in 7 days.

+

If you didn't expect this invitation, you can safely ignore this email.

+
+ +

+ Or copy and paste this link into your browser:
+ {invitation_url} +

+ +

+ Questions? Contact us at support@loaf.org +

+
+
+ + + """ + + return await send_email(to_email, subject, html_content) diff --git a/migrate_role_permissions_to_dynamic_roles.py b/migrate_role_permissions_to_dynamic_roles.py new file mode 100644 index 0000000..05b722d --- /dev/null +++ b/migrate_role_permissions_to_dynamic_roles.py @@ -0,0 +1,145 @@ +""" +Role Permissions Migration Script (Phase 3) + +This script migrates role_permissions from the legacy role enum to the new dynamic role system. +For each role_permission, it maps the current role enum value to the corresponding role_id. + +Usage: + python migrate_role_permissions_to_dynamic_roles.py + +Environment Variables: + DATABASE_URL - PostgreSQL connection string +""" + +import os +import sys +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from database import Base +from models import RolePermission, Role, UserRole +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Database connection +DATABASE_URL = os.getenv("DATABASE_URL") +if not DATABASE_URL: + print("Error: DATABASE_URL environment variable not set") + sys.exit(1) + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def migrate_role_permissions(): + """Migrate role_permissions from enum role to role_id""" + db = SessionLocal() + + try: + print("🚀 Starting role_permissions migration (Phase 3)...") + print("="*60) + + # Step 1: Load all roles into a map + print("\n📋 Loading roles from database...") + roles = db.query(Role).all() + role_map = {role.code: role for role in roles} + + print(f"✓ Loaded {len(roles)} roles:") + for role in roles: + print(f" • {role.name} ({role.code}) - ID: {role.id}") + + # Step 2: Get all role_permissions + print("\n🔐 Loading role_permissions...") + role_permissions = db.query(RolePermission).all() + print(f"✓ Found {len(role_permissions)} role_permission records to migrate") + + if not role_permissions: + print("\n✅ No role_permissions to migrate!") + return + + # Step 3: Check if any role_permissions already have role_id set + perms_with_role_id = [rp for rp in role_permissions if rp.role_id is not None] + if perms_with_role_id: + print(f"\n⚠️ Warning: {len(perms_with_role_id)} role_permissions already have role_id set") + response = input("Do you want to re-migrate these records? (yes/no): ") + if response.lower() != 'yes': + print("Skipping role_permissions that already have role_id set...") + role_permissions = [rp for rp in role_permissions if rp.role_id is None] + print(f"Will migrate {len(role_permissions)} role_permissions without role_id") + + if not role_permissions: + print("\n✅ No role_permissions to migrate!") + return + + # Step 4: Migrate role_permissions + print(f"\n🔄 Migrating {len(role_permissions)} role_permission records...") + + migration_stats = { + UserRole.guest: 0, + UserRole.member: 0, + UserRole.admin: 0, + UserRole.superadmin: 0 + } + + for rp in role_permissions: + # Get the enum role code (e.g., "guest", "member", "admin", "superadmin") + role_code = rp.role.value + + # Find the matching role in the roles table + if role_code not in role_map: + print(f" ⚠️ Warning: No matching role found for '{role_code}' (permission_id: {rp.permission_id})") + continue + + # Set the role_id + rp.role_id = role_map[role_code].id + migration_stats[rp.role] = migration_stats.get(rp.role, 0) + 1 + + # Commit all changes + db.commit() + print(f"✓ Migrated {len(role_permissions)} role_permission records") + + # Step 5: Display migration summary + print("\n" + "="*60) + print("📊 Migration Summary:") + print("="*60) + print("\nRole permissions migrated by role:") + for role_enum, count in migration_stats.items(): + if count > 0: + print(f" • {role_enum.value}: {count} permissions") + + # Step 6: Verify migration + print("\n🔍 Verifying migration...") + perms_without_role_id = db.query(RolePermission).filter(RolePermission.role_id == None).count() + perms_with_role_id = db.query(RolePermission).filter(RolePermission.role_id != None).count() + + print(f" • Role permissions with role_id: {perms_with_role_id}") + print(f" • Role permissions without role_id: {perms_without_role_id}") + + if perms_without_role_id > 0: + print(f"\n⚠️ Warning: {perms_without_role_id} role_permissions still don't have role_id set!") + else: + print("\n✅ All role_permissions successfully migrated!") + + print("\n" + "="*60) + print("✅ Role permissions migration completed successfully!") + print("="*60) + + print("\n📝 Next Steps:") + print(" 1. Update auth.py to use dynamic roles") + print(" 2. Update server.py role checks") + print(" 3. Verify system still works with new roles") + print(" 4. In Phase 4, remove legacy enum columns") + + except Exception as e: + db.rollback() + print(f"\n❌ Error migrating role_permissions: {str(e)}") + import traceback + traceback.print_exc() + raise + finally: + db.close() + + +if __name__ == "__main__": + migrate_role_permissions() diff --git a/migrate_users_to_dynamic_roles.py b/migrate_users_to_dynamic_roles.py new file mode 100644 index 0000000..75d7c78 --- /dev/null +++ b/migrate_users_to_dynamic_roles.py @@ -0,0 +1,141 @@ +""" +User Role Migration Script (Phase 3) + +This script migrates existing users from the legacy role enum to the new dynamic role system. +For each user, it maps their current role enum value to the corresponding role_id in the roles table. + +Usage: + python migrate_users_to_dynamic_roles.py + +Environment Variables: + DATABASE_URL - PostgreSQL connection string +""" + +import os +import sys +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from database import Base +from models import User, Role, UserRole +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Database connection +DATABASE_URL = os.getenv("DATABASE_URL") +if not DATABASE_URL: + print("Error: DATABASE_URL environment variable not set") + sys.exit(1) + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def migrate_users(): + """Migrate users from enum role to role_id""" + db = SessionLocal() + + try: + print("🚀 Starting user role migration (Phase 3)...") + print("="*60) + + # Step 1: Load all roles into a map + print("\n📋 Loading roles from database...") + roles = db.query(Role).all() + role_map = {role.code: role for role in roles} + + print(f"✓ Loaded {len(roles)} roles:") + for role in roles: + print(f" • {role.name} ({role.code}) - ID: {role.id}") + + # Step 2: Get all users + print("\n👥 Loading users...") + users = db.query(User).all() + print(f"✓ Found {len(users)} users to migrate") + + # Step 3: Check if any users already have role_id set + users_with_role_id = [u for u in users if u.role_id is not None] + if users_with_role_id: + print(f"\n⚠️ Warning: {len(users_with_role_id)} users already have role_id set") + response = input("Do you want to re-migrate these users? (yes/no): ") + if response.lower() != 'yes': + print("Skipping users that already have role_id set...") + users = [u for u in users if u.role_id is None] + print(f"Will migrate {len(users)} users without role_id") + + if not users: + print("\n✅ No users to migrate!") + return + + # Step 4: Migrate users + print(f"\n🔄 Migrating {len(users)} users...") + + migration_stats = { + UserRole.guest: 0, + UserRole.member: 0, + UserRole.admin: 0, + UserRole.superadmin: 0 + } + + for user in users: + # Get the enum role code (e.g., "guest", "member", "admin", "superadmin") + role_code = user.role.value + + # Find the matching role in the roles table + if role_code not in role_map: + print(f" ⚠️ Warning: No matching role found for '{role_code}' (user: {user.email})") + continue + + # Set the role_id + user.role_id = role_map[role_code].id + migration_stats[user.role] = migration_stats.get(user.role, 0) + 1 + + # Commit all changes + db.commit() + print(f"✓ Migrated {len(users)} users") + + # Step 5: Display migration summary + print("\n" + "="*60) + print("📊 Migration Summary:") + print("="*60) + print("\nUsers migrated by role:") + for role_enum, count in migration_stats.items(): + if count > 0: + print(f" • {role_enum.value}: {count} users") + + # Step 6: Verify migration + print("\n🔍 Verifying migration...") + users_without_role_id = db.query(User).filter(User.role_id == None).count() + users_with_role_id = db.query(User).filter(User.role_id != None).count() + + print(f" • Users with role_id: {users_with_role_id}") + print(f" • Users without role_id: {users_without_role_id}") + + if users_without_role_id > 0: + print(f"\n⚠️ Warning: {users_without_role_id} users still don't have role_id set!") + else: + print("\n✅ All users successfully migrated!") + + print("\n" + "="*60) + print("✅ User migration completed successfully!") + print("="*60) + + print("\n📝 Next Steps:") + print(" 1. Migrate role_permissions table") + print(" 2. Update auth.py to use dynamic roles") + print(" 3. Update server.py role checks") + print(" 4. Verify system still works with new roles") + + except Exception as e: + db.rollback() + print(f"\n❌ Error migrating users: {str(e)}") + import traceback + traceback.print_exc() + raise + finally: + db.close() + + +if __name__ == "__main__": + migrate_users() diff --git a/migrations/001_add_member_since_field.sql b/migrations/001_add_member_since_field.sql new file mode 100644 index 0000000..86aa105 --- /dev/null +++ b/migrations/001_add_member_since_field.sql @@ -0,0 +1,20 @@ +-- Migration: Add member_since field to users table +-- +-- This field allows admins to manually set historical membership dates +-- for users imported from the old WordPress site. +-- +-- For new users, it can be left NULL and will default to created_at when displayed. +-- For imported users, admins can set it to the actual date they became a member. + +-- Add member_since column (nullable timestamp with timezone) +ALTER TABLE users +ADD COLUMN member_since TIMESTAMP WITH TIME ZONE; + +-- Backfill existing active members: use created_at as default +-- This is reasonable since they became members when they created their account +UPDATE users +SET member_since = created_at +WHERE status = 'active' AND member_since IS NULL; + +-- Success message +SELECT 'Migration completed: member_since field added to users table' AS result; diff --git a/migrations/002_rename_approval_to_validation.sql b/migrations/002_rename_approval_to_validation.sql new file mode 100644 index 0000000..65d5d68 --- /dev/null +++ b/migrations/002_rename_approval_to_validation.sql @@ -0,0 +1,59 @@ +-- Migration: Rename approval terminology to validation in database +-- +-- Updates all user status values from: +-- - pending_approval → pending_validation +-- - pre_approved → pre_validated +-- +-- This migration aligns with the client's request to change all "approval" +-- terminology to "validation" throughout the application. +-- +-- IMPORTANT: This migration uses multiple transactions because PostgreSQL +-- requires enum values to be committed before they can be used. + +-- ============================================================ +-- TRANSACTION 1: Add new enum values +-- ============================================================ + +-- Add renamed values (approval → validation) +ALTER TYPE userstatus ADD VALUE IF NOT EXISTS 'pending_validation'; +ALTER TYPE userstatus ADD VALUE IF NOT EXISTS 'pre_validated'; + +-- Add new status types from Phase 4 (if they don't already exist) +ALTER TYPE userstatus ADD VALUE IF NOT EXISTS 'canceled'; +ALTER TYPE userstatus ADD VALUE IF NOT EXISTS 'expired'; +ALTER TYPE userstatus ADD VALUE IF NOT EXISTS 'abandoned'; + +-- Commit the enum additions so they can be used +COMMIT; + +-- Display progress +SELECT 'Step 1 completed: New enum values added' AS progress; + +-- ============================================================ +-- TRANSACTION 2: Update existing data +-- ============================================================ + +-- Start a new transaction +BEGIN; + +-- Update pending_approval to pending_validation +UPDATE users +SET status = 'pending_validation' +WHERE status = 'pending_approval'; + +-- Update pre_approved to pre_validated +UPDATE users +SET status = 'pre_validated' +WHERE status = 'pre_approved'; + +-- Commit the data updates +COMMIT; + +-- Success message +SELECT 'Migration completed: approval terminology updated to validation' AS result; + +-- Note: All API endpoints and frontend components must also be updated +-- to use 'validation' terminology instead of 'approval' +-- +-- Note: The old enum values 'pending_approval' and 'pre_approved' will remain +-- in the enum type but will not be used. This is normal PostgreSQL behavior. diff --git a/migrations/003_add_tos_acceptance.sql b/migrations/003_add_tos_acceptance.sql new file mode 100644 index 0000000..c151231 --- /dev/null +++ b/migrations/003_add_tos_acceptance.sql @@ -0,0 +1,23 @@ +-- Migration: Add Terms of Service acceptance fields to users table +-- +-- This migration adds: +-- - accepts_tos: Boolean field to track ToS acceptance +-- - tos_accepted_at: Timestamp of when user accepted ToS + +-- Add accepts_tos column (Boolean, default False) +ALTER TABLE users +ADD COLUMN accepts_tos BOOLEAN DEFAULT FALSE NOT NULL; + +-- Add tos_accepted_at column (nullable timestamp) +ALTER TABLE users +ADD COLUMN tos_accepted_at TIMESTAMP WITH TIME ZONE; + +-- Backfill existing users: mark as accepted with created_at date +-- This is reasonable since existing users registered before ToS requirement +UPDATE users +SET accepts_tos = TRUE, + tos_accepted_at = created_at +WHERE created_at IS NOT NULL; + +-- Success message +SELECT 'Migration completed: ToS acceptance fields added to users table' AS result; diff --git a/migrations/004_add_reminder_tracking_fields.sql b/migrations/004_add_reminder_tracking_fields.sql new file mode 100644 index 0000000..f258424 --- /dev/null +++ b/migrations/004_add_reminder_tracking_fields.sql @@ -0,0 +1,39 @@ +-- Migration: Add Reminder Tracking Fields to User Model +-- +-- This migration adds fields to track reminder emails sent to users, +-- allowing admins to see how many reminders each user has received +-- and when the last reminder was sent. +-- +-- This is especially helpful for older members who may need personal outreach. + +-- Add email verification reminder tracking +ALTER TABLE users +ADD COLUMN email_verification_reminders_sent INTEGER DEFAULT 0 NOT NULL; + +ALTER TABLE users +ADD COLUMN last_email_verification_reminder_at TIMESTAMP WITH TIME ZONE; + +-- Add event attendance reminder tracking +ALTER TABLE users +ADD COLUMN event_attendance_reminders_sent INTEGER DEFAULT 0 NOT NULL; + +ALTER TABLE users +ADD COLUMN last_event_attendance_reminder_at TIMESTAMP WITH TIME ZONE; + +-- Add payment reminder tracking +ALTER TABLE users +ADD COLUMN payment_reminders_sent INTEGER DEFAULT 0 NOT NULL; + +ALTER TABLE users +ADD COLUMN last_payment_reminder_at TIMESTAMP WITH TIME ZONE; + +-- Add renewal reminder tracking +ALTER TABLE users +ADD COLUMN renewal_reminders_sent INTEGER DEFAULT 0 NOT NULL; + +ALTER TABLE users +ADD COLUMN last_renewal_reminder_at TIMESTAMP WITH TIME ZONE; + +-- Success message +SELECT 'Migration completed: Reminder tracking fields added to users table' AS result; +SELECT 'Admins can now track reminder counts in the dashboard' AS note; diff --git a/migrations/005_add_rbac_and_invitations.sql b/migrations/005_add_rbac_and_invitations.sql new file mode 100644 index 0000000..d4e2f6c --- /dev/null +++ b/migrations/005_add_rbac_and_invitations.sql @@ -0,0 +1,187 @@ +-- Migration 005: Add RBAC Permission Management, User Invitations, and Import Jobs +-- +-- This migration adds: +-- 1. Superadmin role to UserRole enum +-- 2. Permission and RolePermission tables for RBAC +-- 3. UserInvitation table for email-based invitations +-- 4. ImportJob table for CSV import tracking +-- +-- IMPORTANT: PostgreSQL requires enum values to be committed before they can be used, +-- so this migration uses multiple transactions. + +-- ============================================================ +-- TRANSACTION 1: Add new enum values +-- ============================================================ + +-- Add 'superadmin' to UserRole enum +ALTER TYPE userrole ADD VALUE IF NOT EXISTS 'superadmin'; + +COMMIT; + +-- Display progress +SELECT 'Step 1 completed: UserRole enum updated with superadmin' AS progress; + +-- ============================================================ +-- TRANSACTION 2: Create new enum types +-- ============================================================ + +BEGIN; + +-- Create InvitationStatus enum +DO $$ BEGIN + CREATE TYPE invitationstatus AS ENUM ('pending', 'accepted', 'expired', 'revoked'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Create ImportJobStatus enum +DO $$ BEGIN + CREATE TYPE importjobstatus AS ENUM ('processing', 'completed', 'failed', 'partial'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +COMMIT; + +-- Display progress +SELECT 'Step 2 completed: New enum types created' AS progress; + +-- ============================================================ +-- TRANSACTION 3: Create Permission and RolePermission tables +-- ============================================================ + +BEGIN; + +-- Create permissions table +CREATE TABLE IF NOT EXISTS permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR NOT NULL UNIQUE, + name VARCHAR NOT NULL, + description TEXT, + module VARCHAR NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes for permissions +CREATE INDEX IF NOT EXISTS idx_permissions_code ON permissions(code); +CREATE INDEX IF NOT EXISTS idx_permissions_module ON permissions(module); + +-- Create role_permissions junction table +CREATE TABLE IF NOT EXISTS role_permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role userrole NOT NULL, + permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES users(id) ON DELETE SET NULL +); + +-- Create indexes for role_permissions +CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(role); +CREATE UNIQUE INDEX IF NOT EXISTS idx_role_permission ON role_permissions(role, permission_id); + +COMMIT; + +-- Display progress +SELECT 'Step 3 completed: Permission tables created' AS progress; + +-- ============================================================ +-- TRANSACTION 4: Create UserInvitation table +-- ============================================================ + +BEGIN; + +-- Create user_invitations table +CREATE TABLE IF NOT EXISTS user_invitations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR NOT NULL, + token VARCHAR NOT NULL UNIQUE, + role userrole NOT NULL, + status invitationstatus NOT NULL DEFAULT 'pending', + + -- Optional pre-filled information + first_name VARCHAR, + last_name VARCHAR, + phone VARCHAR, + + -- Invitation tracking + invited_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + invited_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + accepted_at TIMESTAMP WITH TIME ZONE, + accepted_by UUID REFERENCES users(id) ON DELETE SET NULL +); + +-- Create indexes for user_invitations +CREATE INDEX IF NOT EXISTS idx_user_invitations_email ON user_invitations(email); +CREATE INDEX IF NOT EXISTS idx_user_invitations_token ON user_invitations(token); +CREATE INDEX IF NOT EXISTS idx_user_invitations_status ON user_invitations(status); + +COMMIT; + +-- Display progress +SELECT 'Step 4 completed: UserInvitation table created' AS progress; + +-- ============================================================ +-- TRANSACTION 5: Create ImportJob table +-- ============================================================ + +BEGIN; + +-- Create import_jobs table +CREATE TABLE IF NOT EXISTS import_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + filename VARCHAR NOT NULL, + file_key VARCHAR, + total_rows INTEGER NOT NULL, + processed_rows INTEGER NOT NULL DEFAULT 0, + successful_rows INTEGER NOT NULL DEFAULT 0, + failed_rows INTEGER NOT NULL DEFAULT 0, + status importjobstatus NOT NULL DEFAULT 'processing', + errors JSONB NOT NULL DEFAULT '[]'::jsonb, + + -- Tracking + imported_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP WITH TIME ZONE +); + +-- Create indexes for import_jobs +CREATE INDEX IF NOT EXISTS idx_import_jobs_imported_by ON import_jobs(imported_by); +CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status); +CREATE INDEX IF NOT EXISTS idx_import_jobs_started_at ON import_jobs(started_at DESC); + +COMMIT; + +-- Success message +SELECT 'Migration 005 completed successfully: RBAC, Invitations, and Import Jobs tables created' AS result; + +-- ============================================================ +-- Verification Queries +-- ============================================================ + +-- Verify UserRole enum includes superadmin +SELECT enumlabel FROM pg_enum +WHERE enumtypid = 'userrole'::regtype +ORDER BY enumlabel; + +-- Verify new tables exist +SELECT table_name FROM information_schema.tables +WHERE table_schema = 'public' +AND table_name IN ('permissions', 'role_permissions', 'user_invitations', 'import_jobs') +ORDER BY table_name; + +-- ============================================================ +-- Rollback Instructions (if needed) +-- ============================================================ + +-- To rollback this migration, run: +-- +-- DROP TABLE IF EXISTS import_jobs CASCADE; +-- DROP TABLE IF EXISTS user_invitations CASCADE; +-- DROP TABLE IF EXISTS role_permissions CASCADE; +-- DROP TABLE IF EXISTS permissions CASCADE; +-- DROP TYPE IF EXISTS importjobstatus; +-- DROP TYPE IF EXISTS invitationstatus; +-- +-- Note: Cannot remove 'superadmin' from UserRole enum without recreating the entire enum +-- and updating all dependent tables. Only do this if no users have the superadmin role. diff --git a/migrations/006_add_dynamic_roles.sql b/migrations/006_add_dynamic_roles.sql new file mode 100644 index 0000000..b46ba5e --- /dev/null +++ b/migrations/006_add_dynamic_roles.sql @@ -0,0 +1,91 @@ +-- Migration 006: Add Dynamic Roles System (Phase 1) +-- +-- This migration adds support for dynamic role creation: +-- 1. Creates the 'roles' table for dynamic role management +-- 2. Adds 'role_id' column to 'users' table (nullable for backward compatibility) +-- 3. Adds 'role_id' column to 'role_permissions' table (nullable for backward compatibility) +-- +-- IMPORTANT: This is Phase 1 of the migration. The old 'role' enum columns are kept +-- for backward compatibility. They will be removed in Phase 4 after data migration. + +-- ============================================================ +-- TRANSACTION 1: Create roles table +-- ============================================================ + +BEGIN; + +-- Create roles table +CREATE TABLE IF NOT EXISTS roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR NOT NULL UNIQUE, + name VARCHAR NOT NULL, + description TEXT, + is_system_role BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES users(id) ON DELETE SET NULL +); + +-- Create indexes for roles +CREATE INDEX IF NOT EXISTS idx_roles_code ON roles(code); +CREATE INDEX IF NOT EXISTS idx_roles_is_system_role ON roles(is_system_role); + +COMMIT; + +-- Display progress +SELECT 'Step 1 completed: roles table created' AS progress; + +-- ============================================================ +-- TRANSACTION 2: Add role_id column to users table +-- ============================================================ + +BEGIN; + +-- Add role_id column to users table (nullable for Phase 1) +ALTER TABLE users +ADD COLUMN IF NOT EXISTS role_id UUID REFERENCES roles(id) ON DELETE SET NULL; + +-- Create index for role_id +CREATE INDEX IF NOT EXISTS idx_users_role_id ON users(role_id); + +COMMIT; + +-- Display progress +SELECT 'Step 2 completed: role_id column added to users table' AS progress; + +-- ============================================================ +-- TRANSACTION 3: Add role_id column to role_permissions table +-- ============================================================ + +BEGIN; + +-- Add role_id column to role_permissions table (nullable for Phase 1) +ALTER TABLE role_permissions +ADD COLUMN IF NOT EXISTS role_id UUID REFERENCES roles(id) ON DELETE CASCADE; + +-- Create index for role_id +CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id); + +COMMIT; + +-- Display progress +SELECT 'Step 3 completed: role_id column added to role_permissions table' AS progress; + +-- ============================================================ +-- Migration Complete +-- ============================================================ + +SELECT ' +Migration 006 completed successfully! + +Next steps: +1. Run Phase 2: Create seed script to populate system roles (Superadmin, Finance, Member, Guest) +2. Run Phase 3: Migrate existing data from enum to role_id +3. Run Phase 4: Remove old enum columns (after verifying data migration) + +Current status: +- roles table created ✓ +- users.role_id added (nullable) ✓ +- role_permissions.role_id added (nullable) ✓ +- Legacy enum columns retained for backward compatibility ✓ +' AS migration_status; diff --git a/migrations/README.md b/migrations/README.md index 4670569..d2e49c7 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -136,3 +136,211 @@ DROP TABLE IF EXISTS financial_reports; DROP TABLE IF EXISTS bylaws_documents; DROP TABLE IF EXISTS storage_usage; ``` + +--- + +## Running Phase 1-4.5 Migrations (December 2025) + +These migrations add features from client feedback phases 1-4.5: +- Member Since field for imported users +- Approval → Validation terminology update +- Terms of Service acceptance tracking +- Reminder email tracking for admin dashboard + +### Quick Start + +Run all migrations at once: + +```bash +cd backend/migrations +psql $DATABASE_URL -f run_all_migrations.sql +``` + +### Individual Migration Files + +The migrations are numbered in the order they should be run: + +1. **001_add_member_since_field.sql** - Adds editable `member_since` field for imported users +2. **002_rename_approval_to_validation.sql** - Updates terminology from "approval" to "validation" +3. **003_add_tos_acceptance.sql** - Adds Terms of Service acceptance tracking +4. **004_add_reminder_tracking_fields.sql** - Adds reminder email tracking for admin dashboard + +### Run Individual Migrations + +```bash +cd backend/migrations + +# Run migrations one by one +psql $DATABASE_URL -f 001_add_member_since_field.sql +psql $DATABASE_URL -f 002_rename_approval_to_validation.sql +psql $DATABASE_URL -f 003_add_tos_acceptance.sql +psql $DATABASE_URL -f 004_add_reminder_tracking_fields.sql +``` + +### Using psql Interactive Mode + +```bash +# Connect to your database +psql $DATABASE_URL + +# Inside psql, run: +\i backend/migrations/001_add_member_since_field.sql +\i backend/migrations/002_rename_approval_to_validation.sql +\i backend/migrations/003_add_tos_acceptance.sql +\i backend/migrations/004_add_reminder_tracking_fields.sql +``` + +### What Each Migration Adds + +**Migration 001 - Member Since Field:** +- Adds `member_since` column (nullable timestamp) +- Backfills active members with their `created_at` date +- Allows admins to edit dates for imported users + +**Migration 002 - Approval → Validation Terminology:** +- Updates `pending_approval` → `pending_validation` +- Updates `pre_approved` → `pre_validated` +- Aligns database with client's terminology requirements + +**Migration 003 - ToS Acceptance:** +- Adds `accepts_tos` boolean field (default false) +- Adds `tos_accepted_at` timestamp field +- Backfills existing users as having accepted ToS + +**Migration 004 - Reminder Tracking:** +- Adds 8 fields to track reminder emails: + - `email_verification_reminders_sent` + `last_email_verification_reminder_at` + - `event_attendance_reminders_sent` + `last_event_attendance_reminder_at` + - `payment_reminders_sent` + `last_payment_reminder_at` + - `renewal_reminders_sent` + `last_renewal_reminder_at` +- Enables admin dashboard to show users needing personal outreach + +### Verification + +After running migrations, verify they completed successfully: + +```sql +-- Check if new columns exist +SELECT column_name, data_type +FROM information_schema.columns +WHERE table_name = 'users' + AND column_name IN ( + 'member_since', + 'accepts_tos', + 'tos_accepted_at', + 'email_verification_reminders_sent', + 'last_email_verification_reminder_at', + 'event_attendance_reminders_sent', + 'last_event_attendance_reminder_at', + 'payment_reminders_sent', + 'last_payment_reminder_at', + 'renewal_reminders_sent', + 'last_renewal_reminder_at' + ) +ORDER BY column_name; + +-- Check status values were updated +SELECT status, COUNT(*) +FROM users +GROUP BY status; +``` + +### Rollback Phase 1-4.5 Migrations (If Needed) + +```sql +-- Rollback 004: Remove reminder tracking fields +ALTER TABLE users +DROP COLUMN IF EXISTS email_verification_reminders_sent, +DROP COLUMN IF EXISTS last_email_verification_reminder_at, +DROP COLUMN IF EXISTS event_attendance_reminders_sent, +DROP COLUMN IF EXISTS last_event_attendance_reminder_at, +DROP COLUMN IF EXISTS payment_reminders_sent, +DROP COLUMN IF EXISTS last_payment_reminder_at, +DROP COLUMN IF EXISTS renewal_reminders_sent, +DROP COLUMN IF EXISTS last_renewal_reminder_at; + +-- Rollback 003: Remove ToS fields +ALTER TABLE users +DROP COLUMN IF EXISTS accepts_tos, +DROP COLUMN IF EXISTS tos_accepted_at; + +-- Rollback 002: Revert validation to approval +UPDATE users SET status = 'pending_approval' WHERE status = 'pending_validation'; +UPDATE users SET status = 'pre_approved' WHERE status = 'pre_validated'; + +-- Rollback 001: Remove member_since field +ALTER TABLE users DROP COLUMN IF EXISTS member_since; +``` + +--- + +## Running Phase RBAC Migration (December 2025) + +This migration adds RBAC permission management, user invitations, and CSV import tracking capabilities. + +### Quick Start + +```bash +cd backend/migrations +psql $DATABASE_URL -f 005_add_rbac_and_invitations.sql +``` + +### What This Migration Adds + +**UserRole Enum Update:** +- Adds `superadmin` role to UserRole enum + +**New Tables:** +1. **permissions** - Granular permission definitions (60+ permissions) +2. **role_permissions** - Junction table linking roles to permissions +3. **user_invitations** - Email-based invitation tracking with tokens +4. **import_jobs** - CSV import job tracking with error logging + +**New Enum Types:** +- `invitationstatus` (pending, accepted, expired, revoked) +- `importjobstatus` (processing, completed, failed, partial) + +### Verification + +After running the migration, verify it completed successfully: + +```sql +-- Check if superadmin role exists +SELECT enumlabel FROM pg_enum +WHERE enumtypid = 'userrole'::regtype +ORDER BY enumlabel; + +-- Check if new tables exist +SELECT table_name FROM information_schema.tables +WHERE table_schema = 'public' +AND table_name IN ('permissions', 'role_permissions', 'user_invitations', 'import_jobs') +ORDER BY table_name; + +-- Check table structures +\d permissions +\d role_permissions +\d user_invitations +\d import_jobs +``` + +### Next Steps After Migration + +1. **Seed Permissions**: Run `permissions_seed.py` to populate default permissions +2. **Upgrade Admin to Superadmin**: Update existing admin users to superadmin role +3. **Assign Permissions**: Configure permissions for admin, member, and guest roles + +### Rollback (If Needed) + +```sql +-- Remove all RBAC tables and enums +DROP TABLE IF EXISTS import_jobs CASCADE; +DROP TABLE IF EXISTS user_invitations CASCADE; +DROP TABLE IF EXISTS role_permissions CASCADE; +DROP TABLE IF EXISTS permissions CASCADE; +DROP TYPE IF EXISTS importjobstatus; +DROP TYPE IF EXISTS invitationstatus; + +-- Note: Cannot remove 'superadmin' from UserRole enum without recreating +-- the entire enum. Only rollback if no users have the superadmin role. +``` + diff --git a/models.py b/models.py index 0e1fe35..79dedf9 100644 --- a/models.py +++ b/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum, Text, Integer, BigInteger, ForeignKey, JSON +from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum, Text, Integer, BigInteger, ForeignKey, JSON, Index from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from datetime import datetime, timezone @@ -8,16 +8,20 @@ from database import Base class UserStatus(enum.Enum): pending_email = "pending_email" - pending_approval = "pending_approval" - pre_approved = "pre_approved" + pending_validation = "pending_validation" + pre_validated = "pre_validated" payment_pending = "payment_pending" active = "active" inactive = "inactive" + canceled = "canceled" # User or admin canceled membership + expired = "expired" # Subscription ended without renewal + abandoned = "abandoned" # Incomplete registration (no verification/event/payment) class UserRole(enum.Enum): guest = "guest" member = "member" admin = "admin" + superadmin = "superadmin" class RSVPStatus(enum.Enum): yes = "yes" @@ -50,7 +54,8 @@ class User(Base): partner_plan_to_become_member = Column(Boolean, default=False) referred_by_member_name = Column(String, nullable=True) status = Column(SQLEnum(UserStatus), default=UserStatus.pending_email, nullable=False) - role = Column(SQLEnum(UserRole), default=UserRole.guest, nullable=False) + role = Column(SQLEnum(UserRole), default=UserRole.guest, nullable=False) # Legacy enum, kept for backward compatibility + role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=True) # New dynamic role FK email_verified = Column(Boolean, default=False) email_verification_token = Column(String, nullable=True) newsletter_subscribed = Column(Boolean, default=False) @@ -89,10 +94,31 @@ class User(Base): social_media_twitter = Column(String, nullable=True) social_media_linkedin = Column(String, nullable=True) + # Terms of Service Acceptance (Step 4) + accepts_tos = Column(Boolean, default=False, nullable=False) + tos_accepted_at = Column(DateTime, nullable=True) + + # Member Since Date - Editable by admins for imported users + member_since = Column(DateTime, nullable=True, comment="Date when user became a member - editable by admins for imported users") + + # Reminder Tracking - for admin dashboard visibility + email_verification_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of email verification reminders sent") + last_email_verification_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last verification reminder") + + event_attendance_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of event attendance reminders sent") + last_event_attendance_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last event attendance reminder") + + payment_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of payment reminders sent") + last_payment_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last payment reminder") + + renewal_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of renewal reminders sent") + last_renewal_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last renewal reminder") + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) # Relationships + role_obj = relationship("Role", back_populates="users", foreign_keys=[role_id]) events_created = relationship("Event", back_populates="creator") rsvps = relationship("EventRSVP", back_populates="user") subscriptions = relationship("Subscription", back_populates="user", foreign_keys="Subscription.user_id") @@ -271,3 +297,128 @@ class StorageUsage(Base): total_bytes_used = Column(BigInteger, default=0) max_bytes_allowed = Column(BigInteger, nullable=False) # From .env last_updated = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + +# ============================================================ +# RBAC Permission Management Models +# ============================================================ + +class Permission(Base): + """Granular permissions for role-based access control""" + __tablename__ = "permissions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + code = Column(String, unique=True, nullable=False, index=True) # "users.create", "events.edit" + name = Column(String, nullable=False) # "Create Users", "Edit Events" + description = Column(Text, nullable=True) + module = Column(String, nullable=False, index=True) # "users", "events", "subscriptions" + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + # Relationships + role_permissions = relationship("RolePermission", back_populates="permission", cascade="all, delete-orphan") + +class Role(Base): + """Dynamic roles that can be created by admins""" + __tablename__ = "roles" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + code = Column(String, unique=True, nullable=False, index=True) # "superadmin", "finance", "custom_role_1" + name = Column(String, nullable=False) # "Superadmin", "Finance Manager", "Custom Role" + description = Column(Text, nullable=True) + is_system_role = Column(Boolean, default=False, nullable=False) # True for Superadmin, Member, Guest (non-deletable) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + + # Relationships + users = relationship("User", back_populates="role_obj", foreign_keys="User.role_id") + role_permissions = relationship("RolePermission", back_populates="role_obj", cascade="all, delete-orphan") + creator = relationship("User", foreign_keys=[created_by]) + +class RolePermission(Base): + """Junction table linking roles to permissions""" + __tablename__ = "role_permissions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + role = Column(SQLEnum(UserRole), nullable=False, index=True) # Legacy enum, kept for backward compatibility + role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=True, index=True) # New dynamic role FK + permission_id = Column(UUID(as_uuid=True), ForeignKey("permissions.id"), nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + + # Relationships + role_obj = relationship("Role", back_populates="role_permissions") + permission = relationship("Permission", back_populates="role_permissions") + creator = relationship("User", foreign_keys=[created_by]) + + # Composite unique index + __table_args__ = ( + Index('idx_role_permission', 'role', 'permission_id', unique=True), + ) + +# ============================================================ +# User Invitation Models +# ============================================================ + +class InvitationStatus(enum.Enum): + pending = "pending" + accepted = "accepted" + expired = "expired" + revoked = "revoked" + +class UserInvitation(Base): + """Email-based user invitations with tokens""" + __tablename__ = "user_invitations" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email = Column(String, nullable=False, index=True) + token = Column(String, unique=True, nullable=False, index=True) + role = Column(SQLEnum(UserRole), nullable=False) + status = Column(SQLEnum(InvitationStatus), default=InvitationStatus.pending, nullable=False) + + # Optional pre-filled information + first_name = Column(String, nullable=True) + last_name = Column(String, nullable=True) + phone = Column(String, nullable=True) + + # Invitation tracking + invited_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + invited_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + expires_at = Column(DateTime, nullable=False) + accepted_at = Column(DateTime, nullable=True) + accepted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + + # Relationships + inviter = relationship("User", foreign_keys=[invited_by]) + accepted_user = relationship("User", foreign_keys=[accepted_by]) + +# ============================================================ +# CSV Import/Export Models +# ============================================================ + +class ImportJobStatus(enum.Enum): + processing = "processing" + completed = "completed" + failed = "failed" + partial = "partial" + +class ImportJob(Base): + """Track CSV import jobs with error handling""" + __tablename__ = "import_jobs" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + filename = Column(String, nullable=False) + file_key = Column(String, nullable=True) # R2 object key for uploaded CSV + total_rows = Column(Integer, nullable=False) + processed_rows = Column(Integer, default=0, nullable=False) + successful_rows = Column(Integer, default=0, nullable=False) + failed_rows = Column(Integer, default=0, nullable=False) + status = Column(SQLEnum(ImportJobStatus), default=ImportJobStatus.processing, nullable=False) + errors = Column(JSON, default=list, nullable=False) # [{row: 5, field: "email", error: "Invalid format"}] + + # Tracking + imported_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + completed_at = Column(DateTime, nullable=True) + + # Relationships + importer = relationship("User", foreign_keys=[imported_by]) diff --git a/permissions_seed.py b/permissions_seed.py new file mode 100644 index 0000000..13e42f6 --- /dev/null +++ b/permissions_seed.py @@ -0,0 +1,549 @@ +""" +Permission Seeding Script + +This script populates the database with 60+ granular permissions for RBAC. +Permissions are organized into 9 modules: users, events, subscriptions, +financials, newsletters, bylaws, gallery, settings, and permissions. + +Usage: + python permissions_seed.py + +Environment Variables: + DATABASE_URL - PostgreSQL connection string +""" + +import os +import sys +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from database import Base +from models import Permission, RolePermission, UserRole +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Database connection +DATABASE_URL = os.getenv("DATABASE_URL") +if not DATABASE_URL: + print("Error: DATABASE_URL environment variable not set") + sys.exit(1) + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# ============================================================ +# Permission Definitions +# ============================================================ + +PERMISSIONS = [ + # ========== USERS MODULE ========== + { + "code": "users.view", + "name": "View Users", + "description": "View user list and user profiles", + "module": "users" + }, + { + "code": "users.create", + "name": "Create Users", + "description": "Create new users and send invitations", + "module": "users" + }, + { + "code": "users.edit", + "name": "Edit Users", + "description": "Edit user profiles and information", + "module": "users" + }, + { + "code": "users.delete", + "name": "Delete Users", + "description": "Delete user accounts", + "module": "users" + }, + { + "code": "users.status", + "name": "Change User Status", + "description": "Change user status (active, inactive, etc.)", + "module": "users" + }, + { + "code": "users.approve", + "name": "Approve/Validate Users", + "description": "Approve or validate user applications", + "module": "users" + }, + { + "code": "users.export", + "name": "Export Users", + "description": "Export user data to CSV", + "module": "users" + }, + { + "code": "users.import", + "name": "Import Users", + "description": "Import users from CSV", + "module": "users" + }, + { + "code": "users.reset_password", + "name": "Reset User Password", + "description": "Reset user passwords via email", + "module": "users" + }, + { + "code": "users.resend_verification", + "name": "Resend Verification Email", + "description": "Resend email verification links", + "module": "users" + }, + + # ========== EVENTS MODULE ========== + { + "code": "events.view", + "name": "View Events", + "description": "View event list and event details", + "module": "events" + }, + { + "code": "events.create", + "name": "Create Events", + "description": "Create new events", + "module": "events" + }, + { + "code": "events.edit", + "name": "Edit Events", + "description": "Edit existing events", + "module": "events" + }, + { + "code": "events.delete", + "name": "Delete Events", + "description": "Delete events", + "module": "events" + }, + { + "code": "events.publish", + "name": "Publish Events", + "description": "Publish or unpublish events", + "module": "events" + }, + { + "code": "events.attendance", + "name": "Mark Event Attendance", + "description": "Mark user attendance for events", + "module": "events" + }, + { + "code": "events.rsvps", + "name": "View Event RSVPs", + "description": "View and manage event RSVPs", + "module": "events" + }, + { + "code": "events.calendar_export", + "name": "Export Event Calendar", + "description": "Export events to iCal format", + "module": "events" + }, + + # ========== SUBSCRIPTIONS MODULE ========== + { + "code": "subscriptions.view", + "name": "View Subscriptions", + "description": "View subscription list and details", + "module": "subscriptions" + }, + { + "code": "subscriptions.create", + "name": "Create Subscriptions", + "description": "Create manual subscriptions for users", + "module": "subscriptions" + }, + { + "code": "subscriptions.edit", + "name": "Edit Subscriptions", + "description": "Edit subscription details", + "module": "subscriptions" + }, + { + "code": "subscriptions.cancel", + "name": "Cancel Subscriptions", + "description": "Cancel user subscriptions", + "module": "subscriptions" + }, + { + "code": "subscriptions.activate", + "name": "Activate Subscriptions", + "description": "Manually activate subscriptions", + "module": "subscriptions" + }, + { + "code": "subscriptions.plans", + "name": "Manage Subscription Plans", + "description": "Create and edit subscription plans", + "module": "subscriptions" + }, + + # ========== FINANCIALS MODULE ========== + { + "code": "financials.view", + "name": "View Financial Reports", + "description": "View financial reports and dashboards", + "module": "financials" + }, + { + "code": "financials.create", + "name": "Create Financial Reports", + "description": "Upload and create financial reports", + "module": "financials" + }, + { + "code": "financials.edit", + "name": "Edit Financial Reports", + "description": "Edit existing financial reports", + "module": "financials" + }, + { + "code": "financials.delete", + "name": "Delete Financial Reports", + "description": "Delete financial reports", + "module": "financials" + }, + { + "code": "financials.export", + "name": "Export Financial Data", + "description": "Export financial data to CSV/PDF", + "module": "financials" + }, + { + "code": "financials.payments", + "name": "View Payment Details", + "description": "View detailed payment information", + "module": "financials" + }, + + # ========== NEWSLETTERS MODULE ========== + { + "code": "newsletters.view", + "name": "View Newsletters", + "description": "View newsletter archives", + "module": "newsletters" + }, + { + "code": "newsletters.create", + "name": "Create Newsletters", + "description": "Upload and create newsletters", + "module": "newsletters" + }, + { + "code": "newsletters.edit", + "name": "Edit Newsletters", + "description": "Edit existing newsletters", + "module": "newsletters" + }, + { + "code": "newsletters.delete", + "name": "Delete Newsletters", + "description": "Delete newsletter archives", + "module": "newsletters" + }, + { + "code": "newsletters.send", + "name": "Send Newsletters", + "description": "Send newsletter emails to subscribers", + "module": "newsletters" + }, + { + "code": "newsletters.subscribers", + "name": "Manage Newsletter Subscribers", + "description": "View and manage newsletter subscribers", + "module": "newsletters" + }, + + # ========== BYLAWS MODULE ========== + { + "code": "bylaws.view", + "name": "View Bylaws", + "description": "View organization bylaws documents", + "module": "bylaws" + }, + { + "code": "bylaws.create", + "name": "Create Bylaws", + "description": "Upload new bylaws documents", + "module": "bylaws" + }, + { + "code": "bylaws.edit", + "name": "Edit Bylaws", + "description": "Edit existing bylaws documents", + "module": "bylaws" + }, + { + "code": "bylaws.delete", + "name": "Delete Bylaws", + "description": "Delete bylaws documents", + "module": "bylaws" + }, + { + "code": "bylaws.publish", + "name": "Publish Bylaws", + "description": "Mark bylaws as current/published version", + "module": "bylaws" + }, + + # ========== GALLERY MODULE ========== + { + "code": "gallery.view", + "name": "View Event Gallery", + "description": "View event gallery photos", + "module": "gallery" + }, + { + "code": "gallery.upload", + "name": "Upload Photos", + "description": "Upload photos to event galleries", + "module": "gallery" + }, + { + "code": "gallery.edit", + "name": "Edit Photos", + "description": "Edit photo captions and details", + "module": "gallery" + }, + { + "code": "gallery.delete", + "name": "Delete Photos", + "description": "Delete photos from galleries", + "module": "gallery" + }, + { + "code": "gallery.moderate", + "name": "Moderate Gallery Content", + "description": "Approve/reject uploaded photos", + "module": "gallery" + }, + + # ========== SETTINGS MODULE ========== + { + "code": "settings.view", + "name": "View Settings", + "description": "View application settings", + "module": "settings" + }, + { + "code": "settings.edit", + "name": "Edit Settings", + "description": "Edit application settings", + "module": "settings" + }, + { + "code": "settings.email_templates", + "name": "Manage Email Templates", + "description": "Edit email templates and notifications", + "module": "settings" + }, + { + "code": "settings.storage", + "name": "Manage Storage", + "description": "View and manage storage usage", + "module": "settings" + }, + { + "code": "settings.backup", + "name": "Backup & Restore", + "description": "Create and restore database backups", + "module": "settings" + }, + { + "code": "settings.logs", + "name": "View System Logs", + "description": "View application and audit logs", + "module": "settings" + }, + + # ========== PERMISSIONS MODULE (SUPERADMIN ONLY) ========== + { + "code": "permissions.view", + "name": "View Permissions", + "description": "View permission definitions and assignments", + "module": "permissions" + }, + { + "code": "permissions.assign", + "name": "Assign Permissions", + "description": "Assign permissions to roles (SUPERADMIN ONLY)", + "module": "permissions" + }, + { + "code": "permissions.manage_roles", + "name": "Manage Roles", + "description": "Create and manage user roles", + "module": "permissions" + }, + { + "code": "permissions.audit", + "name": "View Permission Audit Log", + "description": "View permission change audit logs", + "module": "permissions" + }, +] + +# Default permission assignments for each role +DEFAULT_ROLE_PERMISSIONS = { + UserRole.guest: [], # Guests have no admin permissions + + UserRole.member: [ + # Members can view public content + "events.view", + "events.rsvps", + "events.calendar_export", + "newsletters.view", + "bylaws.view", + "gallery.view", + ], + + UserRole.admin: [ + # Admins have most permissions except RBAC management + "users.view", + "users.create", + "users.edit", + "users.status", + "users.approve", + "users.export", + "users.import", + "users.reset_password", + "users.resend_verification", + "events.view", + "events.create", + "events.edit", + "events.delete", + "events.publish", + "events.attendance", + "events.rsvps", + "events.calendar_export", + "subscriptions.view", + "subscriptions.create", + "subscriptions.edit", + "subscriptions.cancel", + "subscriptions.activate", + "subscriptions.plans", + "financials.view", + "financials.create", + "financials.edit", + "financials.delete", + "financials.export", + "financials.payments", + "newsletters.view", + "newsletters.create", + "newsletters.edit", + "newsletters.delete", + "newsletters.send", + "newsletters.subscribers", + "bylaws.view", + "bylaws.create", + "bylaws.edit", + "bylaws.delete", + "bylaws.publish", + "gallery.view", + "gallery.upload", + "gallery.edit", + "gallery.delete", + "gallery.moderate", + "settings.view", + "settings.edit", + "settings.email_templates", + "settings.storage", + "settings.logs", + ], + + # Superadmin gets all permissions automatically in code, + # so we don't need to explicitly assign them + UserRole.superadmin: [] +} + + +def seed_permissions(): + """Seed permissions and default role assignments""" + db = SessionLocal() + + try: + print("🌱 Starting permission seeding...") + + # Step 1: Clear existing permissions and role_permissions + print("\n📦 Clearing existing permissions and role assignments...") + db.query(RolePermission).delete() + db.query(Permission).delete() + db.commit() + print("✓ Cleared existing data") + + # Step 2: Create permissions + print(f"\n📝 Creating {len(PERMISSIONS)} permissions...") + permission_map = {} # Map code to permission object + + for perm_data in PERMISSIONS: + permission = Permission( + code=perm_data["code"], + name=perm_data["name"], + description=perm_data["description"], + module=perm_data["module"] + ) + db.add(permission) + permission_map[perm_data["code"]] = permission + + db.commit() + print(f"✓ Created {len(PERMISSIONS)} permissions") + + # Step 3: Assign default permissions to roles + print("\n🔐 Assigning default permissions to roles...") + + for role, permission_codes in DEFAULT_ROLE_PERMISSIONS.items(): + if not permission_codes: + print(f" • {role.value}: No default permissions (handled in code)") + continue + + for code in permission_codes: + if code not in permission_map: + print(f" ⚠️ Warning: Permission '{code}' not found for role {role.value}") + continue + + role_permission = RolePermission( + role=role, + permission_id=permission_map[code].id + ) + db.add(role_permission) + + db.commit() + print(f" ✓ {role.value}: Assigned {len(permission_codes)} permissions") + + # Step 4: Summary + print("\n" + "="*60) + print("📊 Seeding Summary:") + print("="*60) + + # Count permissions by module + modules = {} + for perm in PERMISSIONS: + module = perm["module"] + modules[module] = modules.get(module, 0) + 1 + + print("\nPermissions by module:") + for module, count in sorted(modules.items()): + print(f" • {module.capitalize()}: {count} permissions") + + print(f"\nTotal permissions: {len(PERMISSIONS)}") + print("\n✅ Permission seeding completed successfully!") + + except Exception as e: + db.rollback() + print(f"\n❌ Error seeding permissions: {str(e)}") + raise + finally: + db.close() + + +if __name__ == "__main__": + seed_permissions() diff --git a/reminder_emails.py b/reminder_emails.py new file mode 100644 index 0000000..e53e188 --- /dev/null +++ b/reminder_emails.py @@ -0,0 +1,487 @@ +""" +Reminder Email System + +This module handles all reminder emails sent before status transitions. +Ensures users receive multiple reminders before any auto-abandonment occurs. +""" + +from datetime import datetime, timezone, timedelta +from typing import Dict, List, Optional +import logging + +logger = logging.getLogger(__name__) + +# Reminder schedules (in days since status started) +REMINDER_SCHEDULES = { + 'email_verification': [3, 7, 14, 30], # Before potential abandonment + 'event_attendance': [30, 60, 80, 85], # Before 90-day deadline + 'payment_pending': [7, 14, 21, 30, 45, 60], # Before potential abandonment + 'renewal': [60, 30, 14, 7], # Before expiration + 'post_expiration': [7, 30, 90] # After expiration +} + + +def get_days_since_status_change(user, current_status: str) -> int: + """ + Calculate number of days since user entered current status. + + Args: + user: User object + current_status: Current status to check + + Returns: + Number of days since status change + """ + if not user.updated_at: + return 0 + + delta = datetime.now(timezone.utc) - user.updated_at + return delta.days + + +def should_send_reminder(days_elapsed: int, schedule: List[int], last_reminder_day: Optional[int] = None) -> Optional[int]: + """ + Determine if a reminder should be sent based on elapsed days. + + Args: + days_elapsed: Days since status change + schedule: List of reminder days + last_reminder_day: Day of last reminder sent (optional) + + Returns: + Reminder day if should send, None otherwise + """ + for reminder_day in schedule: + if days_elapsed >= reminder_day: + # Check if we haven't sent this reminder yet + if last_reminder_day is None or last_reminder_day < reminder_day: + return reminder_day + + return None + + +def send_email_verification_reminder(user, days_elapsed: int, email_service, db_session=None): + """ + Send email verification reminder. + + Args: + user: User object + days_elapsed: Days since registration + email_service: Email service instance + db_session: Database session (optional, for tracking) + + Returns: + True if email sent successfully + """ + reminder_number = REMINDER_SCHEDULES['email_verification'].index(days_elapsed) + 1 if days_elapsed in REMINDER_SCHEDULES['email_verification'] else 0 + + subject = f"Reminder: Verify your email to complete registration" + + if reminder_number == 4: + # Final reminder + message = f""" +

Final Reminder: Complete Your LOAF Registration

+

Hi {user.first_name},

+

This is your final reminder to verify your email address and complete your LOAF membership registration.

+

It's been {days_elapsed} days since you registered. If you don't verify your email soon, + your application will be marked as abandoned and you'll need to contact us to restart the process.

+

Click the link below to verify your email:

+

Verify Email Address

+

Need help? Reply to this email or contact us at info@loaftx.org

+

Best regards,
LOAF Team

+ """ + else: + message = f""" +

Reminder: Verify Your Email Address

+

Hi {user.first_name},

+

You registered for LOAF membership {days_elapsed} days ago but haven't verified your email yet.

+

Click the link below to verify your email and continue your membership journey:

+

Verify Email Address

+

Once verified, you'll receive our monthly newsletter with event announcements!

+

Best regards,
LOAF Team

+ """ + + try: + email_service.send_email(user.email, subject, message) + logger.info(f"Sent email verification reminder #{reminder_number} to user {user.id} (day {days_elapsed})") + + # Track reminder in database for admin visibility + if db_session: + user.email_verification_reminders_sent = (user.email_verification_reminders_sent or 0) + 1 + user.last_email_verification_reminder_at = datetime.now(timezone.utc) + db_session.commit() + logger.info(f"Updated reminder tracking: user {user.id} has received {user.email_verification_reminders_sent} verification reminders") + + return True + except Exception as e: + logger.error(f"Failed to send email verification reminder to user {user.id}: {str(e)}") + return False + + +def send_event_attendance_reminder(user, days_elapsed: int, email_service, db_session=None): + """ + Send event attendance reminder. + + Args: + user: User object + days_elapsed: Days since email verification + email_service: Email service instance + db_session: Database session (optional, for tracking) + + Returns: + True if email sent successfully + """ + days_remaining = 90 - days_elapsed + + subject = f"Reminder: Attend a LOAF event ({days_remaining} days remaining)" + + if days_elapsed >= 85: + # Final reminder (5 days left) + message = f""" +

Final Reminder: Only {days_remaining} Days to Attend an Event!

+

Hi {user.first_name},

+

Important: You have only {days_remaining} days left to attend a LOAF event + and complete your membership application.

+

If you don't attend an event within the 90-day period, your application will be marked as + abandoned per LOAF policy, and you'll need to contact us to restart.

+

Check out our upcoming events in the monthly newsletter or visit our events page!

+

Need help finding an event? Reply to this email or contact us at info@loaftx.org

+

We'd love to meet you soon!

+

Best regards,
LOAF Team

+ """ + elif days_elapsed >= 80: + # 10 days left + message = f""" +

Reminder: {days_remaining} Days to Attend a LOAF Event

+

Hi {user.first_name},

+

Just a friendly reminder that you have {days_remaining} days left to attend a LOAF event + and complete your membership application.

+

Per LOAF policy, new applicants must attend an event within 90 days of email verification + to continue the membership process.

+

Check your newsletter for upcoming events, and we look forward to meeting you soon!

+

Best regards,
LOAF Team

+ """ + elif days_elapsed >= 60: + # 30 days left + message = f""" +

Reminder: {days_remaining} Days to Attend a LOAF Event

+

Hi {user.first_name},

+

You have {days_remaining} days remaining to attend a LOAF event as part of your membership application.

+

Attending an event is a great way to meet other members and learn more about LOAF. + Check out the upcoming events in your monthly newsletter!

+

We look forward to seeing you soon!

+

Best regards,
LOAF Team

+ """ + else: + # 60 days left + message = f""" +

Reminder: Attend a LOAF Event (60 Days Remaining)

+

Hi {user.first_name},

+

Welcome to LOAF! As part of your membership application, you have 90 days to attend one of our events.

+

You have {days_remaining} days remaining to attend an event and continue your membership journey.

+

Check out the events listed in your monthly newsletter. We can't wait to meet you!

+

Best regards,
LOAF Team

+ """ + + try: + email_service.send_email(user.email, subject, message) + logger.info(f"Sent event attendance reminder to user {user.id} (day {days_elapsed}, {days_remaining} days left)") + + # Track reminder in database for admin visibility + if db_session: + user.event_attendance_reminders_sent = (user.event_attendance_reminders_sent or 0) + 1 + user.last_event_attendance_reminder_at = datetime.now(timezone.utc) + db_session.commit() + logger.info(f"Updated reminder tracking: user {user.id} has received {user.event_attendance_reminders_sent} event attendance reminders") + + return True + except Exception as e: + logger.error(f"Failed to send event attendance reminder to user {user.id}: {str(e)}") + return False + + +def send_payment_reminder(user, days_elapsed: int, email_service, db_session=None): + """ + Send payment reminder. + + Args: + user: User object + days_elapsed: Days since admin validation + email_service: Email service instance + db_session: Database session (optional, for tracking) + + Returns: + True if email sent successfully + """ + reminder_count = sum(1 for day in REMINDER_SCHEDULES['payment_pending'] if day <= days_elapsed) + + subject = f"Reminder: Complete your LOAF membership payment" + + if days_elapsed >= 60: + # Final reminder + message = f""" +

Final Payment Reminder

+

Hi {user.first_name},

+

Congratulations again on being validated for LOAF membership!

+

This is a final reminder to complete your membership payment. It's been {days_elapsed} days + since your application was validated.

+

Your payment link is still active. Click below to complete your payment and activate your membership:

+

Complete Payment

+

Once payment is complete, you'll gain full access to all member benefits!

+

Questions? Contact us at info@loaftx.org

+

Best regards,
LOAF Team

+ """ + elif days_elapsed >= 45: + message = f""" +

Payment Reminder - Complete Your Membership

+

Hi {user.first_name},

+

Your LOAF membership application was validated and is ready for payment!

+

Complete your payment to activate your membership and gain access to all member benefits:

+

Complete Payment

+

We're excited to welcome you as a full member!

+

Best regards,
LOAF Team

+ """ + else: + message = f""" +

Payment Reminder

+

Hi {user.first_name},

+

This is a friendly reminder to complete your LOAF membership payment.

+

Your application was validated {days_elapsed} days ago. Click below to complete payment:

+

Complete Payment

+

Questions about payment options? Contact us at info@loaftx.org

+

Best regards,
LOAF Team

+ """ + + try: + email_service.send_email(user.email, subject, message) + logger.info(f"Sent payment reminder #{reminder_count} to user {user.id} (day {days_elapsed})") + + # Track reminder in database for admin visibility + if db_session: + user.payment_reminders_sent = (user.payment_reminders_sent or 0) + 1 + user.last_payment_reminder_at = datetime.now(timezone.utc) + db_session.commit() + logger.info(f"Updated reminder tracking: user {user.id} has received {user.payment_reminders_sent} payment reminders") + + return True + except Exception as e: + logger.error(f"Failed to send payment reminder to user {user.id}: {str(e)}") + return False + + +def send_renewal_reminder(user, subscription, days_until_expiration: int, email_service, db_session=None): + """ + Send membership renewal reminder. + + Args: + user: User object + subscription: Subscription object + days_until_expiration: Days until subscription expires + email_service: Email service instance + db_session: Database session (optional, for tracking) + + Returns: + True if email sent successfully + """ + subject = f"Reminder: Your LOAF membership expires in {days_until_expiration} days" + + if days_until_expiration <= 7: + # Final reminder + message = f""" +

Final Reminder: Renew Your LOAF Membership

+

Hi {user.first_name},

+

Your LOAF membership expires in {days_until_expiration} days!

+

Don't lose access to member benefits. Renew now to continue enjoying:

+
    +
  • Exclusive member events
  • +
  • Member directory access
  • +
  • Monthly newsletter
  • +
  • Community connection
  • +
+

Renew Your Membership Now

+

Questions? Contact us at info@loaftx.org

+

Best regards,
LOAF Team

+ """ + else: + message = f""" +

Reminder: Renew Your LOAF Membership

+

Hi {user.first_name},

+

Your LOAF membership will expire in {days_until_expiration} days.

+

Renew now to continue enjoying all member benefits without interruption:

+

Renew Your Membership

+

Thank you for being part of the LOAF community!

+

Best regards,
LOAF Team

+ """ + + try: + email_service.send_email(user.email, subject, message) + logger.info(f"Sent renewal reminder to user {user.id} ({days_until_expiration} days until expiration)") + + # Track reminder in database for admin visibility + if db_session: + user.renewal_reminders_sent = (user.renewal_reminders_sent or 0) + 1 + user.last_renewal_reminder_at = datetime.now(timezone.utc) + db_session.commit() + logger.info(f"Updated reminder tracking: user {user.id} has received {user.renewal_reminders_sent} renewal reminders") + + return True + except Exception as e: + logger.error(f"Failed to send renewal reminder to user {user.id}: {str(e)}") + return False + + +def send_post_expiration_reminder(user, days_since_expiration: int, email_service): + """ + Send reminder to renew after membership has expired. + + Args: + user: User object + days_since_expiration: Days since expiration + email_service: Email service instance + + Returns: + True if email sent successfully + """ + subject = "We'd love to have you back at LOAF!" + + if days_since_expiration >= 90: + # Final reminder + message = f""" +

We Miss You at LOAF!

+

Hi {user.first_name},

+

Your LOAF membership expired {days_since_expiration} days ago, and we'd love to have you back!

+

Rejoin the community and reconnect with friends:

+

Renew Your Membership

+

Questions? We're here to help: info@loaftx.org

+

Best regards,
LOAF Team

+ """ + elif days_since_expiration >= 30: + message = f""" +

Renew Your LOAF Membership

+

Hi {user.first_name},

+

Your LOAF membership expired {days_since_expiration} days ago.

+

We'd love to have you back! Renew today to regain access to:

+
    +
  • Member events and gatherings
  • +
  • Member directory
  • +
  • Community connection
  • +
+

Renew Your Membership

+

Best regards,
LOAF Team

+ """ + else: + # 7 days after expiration + message = f""" +

Your LOAF Membership Has Expired

+

Hi {user.first_name},

+

Your LOAF membership expired recently. We hope it was just an oversight!

+

Renew now to restore your access to all member benefits:

+

Renew Your Membership

+

We look forward to seeing you at upcoming events!

+

Best regards,
LOAF Team

+ """ + + try: + email_service.send_email(user.email, subject, message) + logger.info(f"Sent post-expiration reminder to user {user.id} ({days_since_expiration} days since expiration)") + return True + except Exception as e: + logger.error(f"Failed to send post-expiration reminder to user {user.id}: {str(e)}") + return False + + +# Background job for sending reminder emails +def process_reminder_emails(db_session, email_service): + """ + Process and send all due reminder emails. + + This should be run as an hourly background job. + + Args: + db_session: Database session + email_service: Email service instance + + Returns: + Dictionary with counts of emails sent + """ + from models import User, UserStatus, Subscription + from datetime import date + + results = { + 'email_verification': 0, + 'event_attendance': 0, + 'payment': 0, + 'renewal': 0, + 'post_expiration': 0 + } + + # 1. Email Verification Reminders + for reminder_day in REMINDER_SCHEDULES['email_verification']: + users = db_session.query(User).filter( + User.status == UserStatus.pending_email, + User.email_verified == False + ).all() + + for user in users: + days_elapsed = get_days_since_status_change(user, 'pending_email') + if days_elapsed == reminder_day: + if send_email_verification_reminder(user, days_elapsed, email_service, db_session): + results['email_verification'] += 1 + + # 2. Event Attendance Reminders + for reminder_day in REMINDER_SCHEDULES['event_attendance']: + users = db_session.query(User).filter( + User.status == UserStatus.pending_validation + ).all() + + for user in users: + days_elapsed = get_days_since_status_change(user, 'pending_validation') + if days_elapsed == reminder_day: + if send_event_attendance_reminder(user, days_elapsed, email_service, db_session): + results['event_attendance'] += 1 + + # 3. Payment Reminders + for reminder_day in REMINDER_SCHEDULES['payment_pending']: + users = db_session.query(User).filter( + User.status == UserStatus.payment_pending + ).all() + + for user in users: + days_elapsed = get_days_since_status_change(user, 'payment_pending') + if days_elapsed == reminder_day: + if send_payment_reminder(user, days_elapsed, email_service, db_session): + results['payment'] += 1 + + # 4. Renewal Reminders (before expiration) + for days_before in REMINDER_SCHEDULES['renewal']: + # Find active subscriptions expiring in X days + target_date = date.today() + timedelta(days=days_before) + + subscriptions = db_session.query(User, Subscription).join( + Subscription, User.id == Subscription.user_id + ).filter( + User.status == UserStatus.active, + Subscription.end_date == target_date + ).all() + + for user, subscription in subscriptions: + if send_renewal_reminder(user, subscription, days_before, email_service, db_session): + results['renewal'] += 1 + + # 5. Post-Expiration Reminders + for days_after in REMINDER_SCHEDULES['post_expiration']: + target_date = date.today() - timedelta(days=days_after) + + subscriptions = db_session.query(User, Subscription).join( + Subscription, User.id == Subscription.user_id + ).filter( + User.status == UserStatus.expired, + Subscription.end_date == target_date + ).all() + + for user, subscription in subscriptions: + if send_post_expiration_reminder(user, days_after, email_service): + results['post_expiration'] += 1 + + logger.info(f"Reminder email batch complete: {results}") + return results diff --git a/roles_seed.py b/roles_seed.py new file mode 100644 index 0000000..c824302 --- /dev/null +++ b/roles_seed.py @@ -0,0 +1,147 @@ +""" +Role Seeding Script + +This script populates the database with system roles for the dynamic RBAC system. +Creates 4 system roles: Superadmin, Finance, Member, and Guest. + +Usage: + python roles_seed.py + +Environment Variables: + DATABASE_URL - PostgreSQL connection string +""" + +import os +import sys +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from database import Base +from models import Role +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Database connection +DATABASE_URL = os.getenv("DATABASE_URL") +if not DATABASE_URL: + print("Error: DATABASE_URL environment variable not set") + sys.exit(1) + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# ============================================================ +# System Role Definitions +# ============================================================ + +SYSTEM_ROLES = [ + { + "code": "superadmin", + "name": "Superadmin", + "description": "Full system access with all permissions. Can manage roles, permissions, and all platform features.", + "is_system_role": True + }, + { + "code": "admin", + "name": "Admin", + "description": "Administrative access to most platform features. Can manage users, events, and content.", + "is_system_role": True + }, + { + "code": "finance", + "name": "Finance Manager", + "description": "Access to financial features including subscriptions, payments, and financial reports.", + "is_system_role": True + }, + { + "code": "member", + "name": "Member", + "description": "Standard member access. Can view events, manage profile, and participate in community features.", + "is_system_role": True + }, + { + "code": "guest", + "name": "Guest", + "description": "Limited access for unverified or pending users. Can view basic information and complete registration.", + "is_system_role": True + } +] + + +def seed_roles(): + """Seed system roles into the database""" + db = SessionLocal() + + try: + print("🌱 Starting role seeding...") + print("="*60) + + # Check if roles already exist + existing_roles = db.query(Role).filter(Role.is_system_role == True).all() + if existing_roles: + print(f"\n⚠️ Found {len(existing_roles)} existing system roles:") + for role in existing_roles: + print(f" • {role.name} ({role.code})") + + response = input("\nDo you want to recreate system roles? This will delete existing system roles. (yes/no): ") + if response.lower() != 'yes': + print("\n❌ Seeding cancelled by user") + return + + print("\n🗑️ Deleting existing system roles...") + for role in existing_roles: + db.delete(role) + db.commit() + print("✓ Deleted existing system roles") + + # Create system roles + print(f"\n📝 Creating {len(SYSTEM_ROLES)} system roles...") + created_roles = [] + + for role_data in SYSTEM_ROLES: + role = Role( + code=role_data["code"], + name=role_data["name"], + description=role_data["description"], + is_system_role=role_data["is_system_role"], + created_by=None # System roles have no creator + ) + db.add(role) + created_roles.append(role) + print(f" ✓ Created: {role.name} ({role.code})") + + db.commit() + print(f"\n✅ Created {len(created_roles)} system roles") + + # Display summary + print("\n" + "="*60) + print("📊 Seeding Summary:") + print("="*60) + print("\nSystem Roles Created:") + for role in created_roles: + print(f"\n • {role.name} ({role.code})") + print(f" {role.description}") + + print("\n" + "="*60) + print("✅ Role seeding completed successfully!") + print("="*60) + + print("\n📝 Next Steps:") + print(" 1. Migrate existing users to use role_id (Phase 3)") + print(" 2. Migrate role_permissions to use role_id (Phase 3)") + print(" 3. Update authentication logic to use dynamic roles (Phase 3)") + print(" 4. Remove legacy enum columns (Phase 4)") + + except Exception as e: + db.rollback() + print(f"\n❌ Error seeding roles: {str(e)}") + import traceback + traceback.print_exc() + raise + finally: + db.close() + + +if __name__ == "__main__": + seed_roles() diff --git a/server.py b/server.py index e51023a..75d92c6 100644 --- a/server.py +++ b/server.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, APIRouter, Depends, HTTPException, status, Request, UploadFile, File, Form +from fastapi import FastAPI, APIRouter, Depends, HTTPException, status, Request, UploadFile, File, Form, Path as PathParam from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session @@ -13,18 +13,24 @@ import os import logging import uuid import secrets +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 +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 auth import ( get_password_hash, verify_password, create_access_token, get_current_user, get_current_admin_user, + get_current_superadmin, get_active_member, + get_user_permissions, + require_permission, create_password_reset_token, - verify_reset_token + verify_reset_token, + get_user_role_code ) from email_service import ( send_verification_email, @@ -72,7 +78,33 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) +# ============================================================ +# Helper Functions +# ============================================================ + +def set_user_role(user: User, role_enum: UserRole, db: Session): + """ + Set user's role in both legacy enum and dynamic role_id. + Ensures consistency between old and new role systems during Phase 3 migration. + + Args: + user: User object to update + role_enum: UserRole enum value + db: Database session + """ + # Set legacy enum + user.role = role_enum + + # Set dynamic role_id + role = db.query(Role).filter(Role.code == role_enum.value).first() + if role: + user.role_id = role.id + else: + logger.warning(f"Role not found for code: {role_enum.value}") + +# ============================================================ # Pydantic Models +# ============================================================ class RegisterRequest(BaseModel): # Step 1: Personal & Partner Information first_name: str @@ -111,6 +143,13 @@ class RegisterRequest(BaseModel): # Step 4: Account Credentials email: EmailStr password: str = Field(min_length=6) + accepts_tos: bool = False + + @validator('accepts_tos') + def tos_must_be_accepted(cls, v): + if not v: + raise ValueError('You must accept the Terms of Service to register') + return v @validator('newsletter_publish_none') def validate_newsletter_preferences(cls, v, values): @@ -342,6 +381,115 @@ class ManualPaymentRequest(BaseModel): raise ValueError('Amount must be at least $30 (3000 cents)') return v +# ============================================================ +# Permission Management Pydantic Models +# ============================================================ + +class PermissionResponse(BaseModel): + id: str + code: str + name: str + description: Optional[str] + module: str + created_at: datetime + + class Config: + from_attributes = True + +class AssignPermissionsRequest(BaseModel): + permission_codes: List[str] = Field(..., description="List of permission codes to assign to the role") + +# ============================================================ +# Role Management Pydantic Models +# ============================================================ + +class RoleResponse(BaseModel): + id: str + code: str + name: str + description: Optional[str] + is_system_role: bool + created_at: datetime + updated_at: datetime + permission_count: Optional[int] = 0 # Number of permissions assigned to this role + + class Config: + from_attributes = True + +class CreateRoleRequest(BaseModel): + code: str = Field(..., min_length=2, max_length=50, description="Unique role code (e.g., 'finance', 'editor')") + name: str = Field(..., min_length=2, max_length=100, description="Display name (e.g., 'Finance Manager')") + description: Optional[str] = Field(None, description="Role description") + permission_codes: List[str] = Field(default=[], description="List of permission codes to assign") + +class UpdateRoleRequest(BaseModel): + name: Optional[str] = Field(None, min_length=2, max_length=100) + description: Optional[str] = None + +class AssignRolePermissionsRequest(BaseModel): + permission_codes: List[str] = Field(..., description="List of permission codes to assign to the role") + +# ============================================================ +# User Creation & Invitation Pydantic Models +# ============================================================ + +class CreateUserRequest(BaseModel): + email: EmailStr + password: str = Field(..., min_length=8) + first_name: str + last_name: str + phone: str + role: str # "member", "admin", "superadmin" + + # Optional member fields + address: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + zipcode: Optional[str] = None + date_of_birth: Optional[datetime] = None + member_since: Optional[datetime] = None + +class InviteUserRequest(BaseModel): + email: EmailStr + role: str # "member", "admin", "superadmin" + + # Optional pre-fill information + first_name: Optional[str] = None + last_name: Optional[str] = None + phone: Optional[str] = None + +class InvitationResponse(BaseModel): + id: str + email: str + role: str + status: str + first_name: Optional[str] + last_name: Optional[str] + phone: Optional[str] + invited_by: str + invited_at: datetime + expires_at: datetime + accepted_at: Optional[datetime] + + class Config: + from_attributes = True + +class AcceptInvitationRequest(BaseModel): + token: str + password: str = Field(..., min_length=8) + + # Complete profile information + first_name: str + last_name: str + phone: str + + # Member-specific fields (optional for staff) + address: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + zipcode: Optional[str] = None + date_of_birth: Optional[datetime] = None + # Auth Routes @api_router.post("/auth/register") async def register(request: RegisterRequest, db: Session = Depends(get_db)): @@ -401,6 +549,10 @@ async def register(request: RegisterRequest, db: Session = Depends(get_db)): directory_dob=request.directory_dob, directory_partner_name=request.directory_partner_name, + # Terms of Service acceptance (Step 4) + accepts_tos=request.accepts_tos, + tos_accepted_at=datetime.now(timezone.utc) if request.accepts_tos else None, + # Status fields status=UserStatus.pending_email, role=UserRole.guest, @@ -448,11 +600,11 @@ async def verify_email(token: str, db: Session = Depends(get_db)): ).first() if referrer: - user.status = UserStatus.pre_approved + user.status = UserStatus.pre_validated else: - user.status = UserStatus.pending_approval + user.status = UserStatus.pending_validation else: - user.status = UserStatus.pending_approval + user.status = UserStatus.pending_validation user.email_verified = True # Don't clear token immediately - keeps endpoint idempotent for React StrictMode double-calls @@ -514,7 +666,7 @@ async def login(request: LoginRequest, db: Session = Depends(get_db)): "first_name": user.first_name, "last_name": user.last_name, "status": user.status.value, - "role": user.role.value, + "role": get_user_role_code(user), "email_verified": user.email_verified, "force_password_change": user.force_password_change } @@ -589,7 +741,7 @@ async def get_me(current_user: User = Depends(get_current_user), db: Session = D zipcode=current_user.zipcode, date_of_birth=current_user.date_of_birth, status=current_user.status.value, - role=current_user.role.value, + role=get_user_role_code(current_user), email_verified=current_user.email_verified, created_at=current_user.created_at, subscription_start_date=active_subscription.start_date if active_subscription else None, @@ -597,6 +749,21 @@ async def get_me(current_user: User = Depends(get_current_user), db: Session = D subscription_status=active_subscription.status.value if active_subscription else None ) +@api_router.get("/auth/permissions") +async def get_my_permissions( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Get current user's permissions based on their role + Returns list of permission codes (e.g., ['users.view', 'events.create']) + """ + permissions = await get_user_permissions(current_user, db) + return { + "permissions": permissions, + "role": get_user_role_code(current_user) + } + # User Profile Routes @api_router.get("/users/profile", response_model=UserResponse) async def get_profile(current_user: User = Depends(get_current_user)): @@ -773,7 +940,7 @@ async def get_enhanced_profile( "directory_dob": current_user.directory_dob, "directory_partner_name": current_user.directory_partner_name, "status": current_user.status.value, - "role": current_user.role.value + "role": get_user_role_code(current_user) } @api_router.put("/members/profile") @@ -1018,7 +1185,7 @@ async def get_members_directory( @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), + current_user: User = Depends(require_permission("events.edit")), db: Session = Depends(get_db) ): """Sync event to Microsoft Calendar""" @@ -1055,7 +1222,7 @@ async def sync_event_to_microsoft( @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), + current_user: User = Depends(require_permission("events.edit")), db: Session = Depends(get_db) ): """Remove event from Microsoft Calendar""" @@ -1155,7 +1322,7 @@ async def upload_event_gallery_image( event_id: str, file: UploadFile = File(...), caption: Optional[str] = None, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("gallery.upload")), db: Session = Depends(get_db) ): """Upload image to event gallery (Admin only)""" @@ -1236,7 +1403,7 @@ async def upload_event_gallery_image( @api_router.delete("/admin/event-gallery/{image_id}") async def delete_gallery_image( image_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("gallery.delete")), db: Session = Depends(get_db) ): """Delete image from event gallery (Admin only)""" @@ -1271,7 +1438,7 @@ async def delete_gallery_image( async def update_gallery_image_caption( image_id: str, caption: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("gallery.edit")), db: Session = Depends(get_db) ): """Update gallery image caption (Admin only)""" @@ -1659,7 +1826,7 @@ async def get_config_limits(): # ============================================================================ @api_router.get("/admin/storage/usage") async def get_storage_usage( - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("settings.storage")), db: Session = Depends(get_db) ): """Get current storage usage statistics""" @@ -1688,7 +1855,7 @@ async def get_storage_usage( @api_router.get("/admin/storage/breakdown") async def get_storage_breakdown( - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("settings.storage")), db: Session = Depends(get_db) ): """Get storage usage breakdown by category""" @@ -1696,7 +1863,8 @@ async def get_storage_breakdown( 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 + # Note: profile_photos removed - User.profile_photo_size field doesn't exist + # If needed in future, add profile_photo_size column to User model 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' @@ -1710,13 +1878,12 @@ async def get_storage_breakdown( return { "breakdown": { - "profile_photos": profile_photos, "gallery_images": gallery_images, "newsletters": newsletters, "financials": financials, "bylaws": bylaws }, - "total": profile_photos + gallery_images + newsletters + financials + bylaws + "total": gallery_images + newsletters + financials + bylaws } @@ -1724,7 +1891,7 @@ async def get_storage_breakdown( async def get_all_users( status: Optional[str] = None, db: Session = Depends(get_db), - current_user: User = Depends(get_current_admin_user) + current_user: User = Depends(require_permission("users.view")) ): query = db.query(User) @@ -1745,7 +1912,7 @@ async def get_all_users( "last_name": user.last_name, "phone": user.phone, "status": user.status.value, - "role": user.role.value, + "role": get_user_role_code(user), "email_verified": user.email_verified, "created_at": user.created_at.isoformat(), "lead_sources": user.lead_sources, @@ -1754,11 +1921,172 @@ async def get_all_users( for user in users ] +# IMPORTANT: All specific routes (/create, /invite, /invitations, /export, /import) +# must be defined ABOVE this {user_id} route to avoid path conflicts + +@api_router.get("/admin/users/invitations") +async def get_invitations( + status: Optional[str] = None, + current_user: User = Depends(require_permission("users.view")), + db: Session = Depends(get_db) +): + """ + List all invitations with optional status filter + Admin/Superadmin only + """ + query = db.query(UserInvitation) + + if status: + try: + status_enum = InvitationStatus[status] + query = query.filter(UserInvitation.status == status_enum) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid status: {status}") + + invitations = query.order_by(UserInvitation.invited_at.desc()).all() + + return [ + { + "id": str(inv.id), + "email": inv.email, + "role": inv.role.value, + "status": inv.status.value, + "first_name": inv.first_name, + "last_name": inv.last_name, + "phone": inv.phone, + "invited_by": str(inv.invited_by), + "invited_at": inv.invited_at.isoformat(), + "expires_at": inv.expires_at.isoformat(), + "accepted_at": inv.accepted_at.isoformat() if inv.accepted_at else None + } + for inv in invitations + ] + +@api_router.get("/admin/users/export") +async def export_users_csv( + status: Optional[str] = None, + role: Optional[str] = None, + email_verified: Optional[bool] = None, + search: Optional[str] = None, + current_user: User = Depends(require_permission("users.export")), + db: Session = Depends(get_db) +): + """ + Export users to CSV with optional filters + Admin/Superadmin only + Requires permission: users.export + """ + # Build query + query = db.query(User) + + # Apply filters + if status: + try: + status_enum = UserStatus[status] + query = query.filter(User.status == status_enum) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid status: {status}") + + if role: + try: + role_enum = UserRole[role] + query = query.filter(User.role == role_enum) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {role}") + + if email_verified is not None: + query = query.filter(User.email_verified == email_verified) + + if search: + search_filter = or_( + User.email.ilike(f"%{search}%"), + User.first_name.ilike(f"%{search}%"), + User.last_name.ilike(f"%{search}%") + ) + query = query.filter(search_filter) + + # Get all matching users + users = query.order_by(User.created_at.desc()).all() + + # Create CSV in memory + output = io.StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow([ + 'ID', + 'Email', + 'First Name', + 'Last Name', + 'Phone', + 'Role', + 'Status', + 'Email Verified', + 'Address', + 'City', + 'State', + 'Zipcode', + 'Date of Birth', + 'Member Since', + 'Partner First Name', + 'Partner Last Name', + 'Partner Is Member', + 'Partner Plan to Become Member', + 'Referred By Member Name', + 'Lead Sources', + 'Created At', + 'Updated At' + ]) + + # Write data rows + for user in users: + writer.writerow([ + str(user.id), + user.email, + user.first_name, + user.last_name, + user.phone, + get_user_role_code(user), + user.status.value, + 'Yes' if user.email_verified else 'No', + user.address or '', + user.city or '', + user.state or '', + user.zipcode or '', + user.date_of_birth.strftime('%Y-%m-%d') if user.date_of_birth else '', + user.member_since.strftime('%Y-%m-%d') if user.member_since else '', + user.partner_first_name or '', + user.partner_last_name or '', + 'Yes' if user.partner_is_member else 'No', + 'Yes' if user.partner_plan_to_become_member else 'No', + user.referred_by_member_name or '', + ','.join(user.lead_sources) if user.lead_sources else '', + user.created_at.strftime('%Y-%m-%d %H:%M:%S'), + user.updated_at.strftime('%Y-%m-%d %H:%M:%S') if user.updated_at else '' + ]) + + # Prepare response + output.seek(0) + + # Generate filename with timestamp + timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') + filename = f"members_export_{timestamp}.csv" + + logger.info(f"Admin {current_user.email} exported {len(users)} users to CSV") + + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={ + "Content-Disposition": f"attachment; filename={filename}" + } + ) + @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) + current_user: User = Depends(require_permission("users.view")) ): """Get specific user by ID (admin only)""" user = db.query(User).filter(User.id == user_id).first() @@ -1782,7 +2110,7 @@ async def get_user_by_id( "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, + "role": get_user_role_code(user), "email_verified": user.email_verified, "newsletter_subscribed": user.newsletter_subscribed, "lead_sources": user.lead_sources, @@ -1790,12 +2118,12 @@ async def get_user_by_id( "updated_at": user.updated_at.isoformat() if user.updated_at else None } -@api_router.put("/admin/users/{user_id}/approve") -async def approve_user( +@api_router.put("/admin/users/{user_id}/validate") +async def validate_user( user_id: str, bypass_email_verification: bool = False, db: Session = Depends(get_db), - current_user: User = Depends(get_current_admin_user) + current_user: User = Depends(require_permission("users.approve")) ): user = db.query(User).filter(User.id == user_id).first() if not user: @@ -1816,14 +2144,14 @@ async def approve_user( ), User.status == UserStatus.active ).first() - user.status = UserStatus.pre_approved if referrer else UserStatus.pending_approval + user.status = UserStatus.pre_validated if referrer else UserStatus.pending_validation else: - user.status = UserStatus.pending_approval + user.status = UserStatus.pending_validation 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]: + # Validate user status - must be pending_validation or pre_validated + if user.status not in [UserStatus.pending_validation, UserStatus.pre_validated]: raise HTTPException( status_code=400, detail=f"User must have verified email first. Current: {user.status.value}" @@ -1839,16 +2167,16 @@ async def approve_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}") + logger.info(f"User validated (payment pending): {user.email} by admin: {current_user.email}") - return {"message": "User approved - payment email sent"} + return {"message": "User validated - 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) + current_user: User = Depends(require_permission("users.status")) ): user = db.query(User).filter(User.id == user_id).first() if not user: @@ -1871,7 +2199,7 @@ async def activate_payment_manually( user_id: str, request: ManualPaymentRequest, db: Session = Depends(get_db), - current_user: User = Depends(get_current_admin_user) + current_user: User = Depends(require_permission("subscriptions.activate")) ): """Manually activate user who paid offline (cash, bank transfer, etc.)""" @@ -1941,7 +2269,7 @@ async def activate_payment_manually( # 6. Activate user user.status = UserStatus.active - user.role = UserRole.member + set_user_role(user, UserRole.member, db) user.updated_at = datetime.now(timezone.utc) # 7. Commit @@ -1965,7 +2293,7 @@ async def activate_payment_manually( async def admin_reset_user_password( user_id: str, request: AdminPasswordUpdateRequest, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("users.reset_password")), db: Session = Depends(get_db) ): """Admin resets user password - generates temp password and emails it""" @@ -2000,7 +2328,7 @@ async def admin_reset_user_password( @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), + current_user: User = Depends(require_permission("users.resend_verification")), db: Session = Depends(get_db) ): """Admin resends verification email for any user""" @@ -2028,10 +2356,627 @@ async def admin_resend_verification( return {"message": f"Verification email resent to {user.email}"} +# ============================================================ +# User Creation & Invitation Endpoints +# ============================================================ + +@api_router.post("/admin/users/create") +async def create_user_directly( + request: CreateUserRequest, + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Create user account directly (without invitation) + Admin/Superadmin only + """ + # 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") + + # Validate role + try: + role_enum = UserRole[request.role] + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {request.role}") + + # Only superadmin can create superadmin users + if role_enum == UserRole.superadmin and current_user.role != UserRole.superadmin: + raise HTTPException(status_code=403, detail="Only superadmin can create superadmin users") + + # Create user + new_user = User( + email=request.email, + password_hash=get_password_hash(request.password), + first_name=request.first_name, + last_name=request.last_name, + phone=request.phone, + role=role_enum, + email_verified=True, # Admin-created users are pre-verified + status=UserStatus.active if role_enum in [UserRole.admin, UserRole.superadmin] else UserStatus.payment_pending, + + # Optional member fields + address=request.address or "", + city=request.city or "", + state=request.state or "", + zipcode=request.zipcode or "", + date_of_birth=request.date_of_birth or datetime.now(timezone.utc), + member_since=request.member_since, + ) + + db.add(new_user) + db.commit() + db.refresh(new_user) + + logger.info(f"Admin {current_user.email} created user: {new_user.email} with role {request.role}") + + return { + "message": "User created successfully", + "user_id": str(new_user.id), + "email": new_user.email, + "role": get_user_role_code(new_user) + } + +@api_router.post("/admin/users/invite") +async def send_user_invitation( + request: InviteUserRequest, + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Send email invitation to new user + Admin/Superadmin only + """ + # 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") + + # Check for pending invitation + existing_invitation = db.query(UserInvitation).filter( + UserInvitation.email == request.email, + UserInvitation.status == InvitationStatus.pending + ).first() + if existing_invitation: + raise HTTPException(status_code=400, detail="Pending invitation already exists for this email") + + # Validate role + try: + role_enum = UserRole[request.role] + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {request.role}") + + # Only superadmin can invite superadmin users + if role_enum == UserRole.superadmin and current_user.role != UserRole.superadmin: + raise HTTPException(status_code=403, detail="Only superadmin can invite superadmin users") + + # Generate secure token + token = secrets.token_urlsafe(32) + + # Create invitation (expires in 7 days) + invitation = UserInvitation( + email=request.email, + token=token, + role=role_enum, + status=InvitationStatus.pending, + first_name=request.first_name, + last_name=request.last_name, + phone=request.phone, + invited_by=current_user.id, + expires_at=datetime.now(timezone.utc) + timedelta(days=7) + ) + + db.add(invitation) + db.commit() + db.refresh(invitation) + + # Send invitation email + from email_service import send_invitation_email + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") + invitation_url = f"{frontend_url}/accept-invitation?token={token}" + + try: + await send_invitation_email( + to_email=request.email, + inviter_name=f"{current_user.first_name} {current_user.last_name}", + invitation_url=invitation_url, + role=request.role + ) + except Exception as e: + logger.error(f"Failed to send invitation email: {str(e)}") + # Continue anyway - admin can resend later + + logger.info(f"Admin {current_user.email} invited {request.email} as {request.role}") + + return { + "message": "Invitation sent successfully", + "invitation_id": str(invitation.id), + "email": invitation.email, + "expires_at": invitation.expires_at.isoformat(), + "invitation_url": invitation_url + } + +@api_router.post("/admin/users/invitations/{invitation_id}/resend") +async def resend_invitation( + invitation_id: str, + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Resend invitation email (extends expiry by 7 days) + Admin/Superadmin only + """ + invitation = db.query(UserInvitation).filter(UserInvitation.id == invitation_id).first() + if not invitation: + raise HTTPException(status_code=404, detail="Invitation not found") + + if invitation.status != InvitationStatus.pending: + raise HTTPException(status_code=400, detail=f"Cannot resend invitation with status: {invitation.status.value}") + + # Extend expiry by 7 days from now + invitation.expires_at = datetime.now(timezone.utc) + timedelta(days=7) + db.commit() + + # Resend email + from email_service import send_invitation_email + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") + invitation_url = f"{frontend_url}/accept-invitation?token={invitation.token}" + + try: + await send_invitation_email( + to_email=invitation.email, + inviter_name=f"{current_user.first_name} {current_user.last_name}", + invitation_url=invitation_url, + role=invitation.role.value + ) + except Exception as e: + logger.error(f"Failed to resend invitation email: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to send email") + + logger.info(f"Admin {current_user.email} resent invitation to {invitation.email}") + + return { + "message": "Invitation resent successfully", + "expires_at": invitation.expires_at.isoformat() + } + +@api_router.delete("/admin/users/invitations/{invitation_id}") +async def revoke_invitation( + invitation_id: str, + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Revoke pending invitation + Admin/Superadmin only + """ + invitation = db.query(UserInvitation).filter(UserInvitation.id == invitation_id).first() + if not invitation: + raise HTTPException(status_code=404, detail="Invitation not found") + + if invitation.status != InvitationStatus.pending: + raise HTTPException(status_code=400, detail=f"Cannot revoke invitation with status: {invitation.status.value}") + + invitation.status = InvitationStatus.revoked + db.commit() + + logger.info(f"Admin {current_user.email} revoked invitation for {invitation.email}") + + return {"message": "Invitation revoked successfully"} + +# ============================================================ +# Public Invitation Endpoints +# ============================================================ + +@api_router.get("/invitations/verify/{token}") +async def verify_invitation_token( + token: str, + db: Session = Depends(get_db) +): + """ + Verify invitation token and return invitation details + Public endpoint - no authentication required + """ + invitation = db.query(UserInvitation).filter( + UserInvitation.token == token, + UserInvitation.status == InvitationStatus.pending + ).first() + + if not invitation: + raise HTTPException(status_code=404, detail="Invalid or expired invitation token") + + # Check expiry + if invitation.expires_at < datetime.now(timezone.utc): + invitation.status = InvitationStatus.expired + db.commit() + raise HTTPException(status_code=400, detail="Invitation has expired") + + return { + "email": invitation.email, + "role": invitation.role.value, + "first_name": invitation.first_name, + "last_name": invitation.last_name, + "phone": invitation.phone, + "expires_at": invitation.expires_at.isoformat() + } + +@api_router.post("/invitations/accept") +async def accept_invitation( + request: AcceptInvitationRequest, + db: Session = Depends(get_db) +): + """ + Accept invitation and create user account + Public endpoint - no authentication required + """ + # Verify invitation + invitation = db.query(UserInvitation).filter( + UserInvitation.token == request.token, + UserInvitation.status == InvitationStatus.pending + ).first() + + if not invitation: + raise HTTPException(status_code=404, detail="Invalid or expired invitation token") + + # Check expiry + if invitation.expires_at < datetime.now(timezone.utc): + invitation.status = InvitationStatus.expired + db.commit() + raise HTTPException(status_code=400, detail="Invitation has expired") + + # Check if email already registered + existing_user = db.query(User).filter(User.email == invitation.email).first() + if existing_user: + raise HTTPException(status_code=400, detail="Email already registered") + + # Create user account + new_user = User( + email=invitation.email, + password_hash=get_password_hash(request.password), + first_name=request.first_name, + last_name=request.last_name, + phone=request.phone, + role=invitation.role, + email_verified=True, # Invited users are pre-verified + status=UserStatus.active if invitation.role in [UserRole.admin, UserRole.superadmin] else UserStatus.payment_pending, + + # Optional fields + address=request.address or "", + city=request.city or "", + state=request.state or "", + zipcode=request.zipcode or "", + date_of_birth=request.date_of_birth or datetime.now(timezone.utc), + ) + + db.add(new_user) + + # Update invitation status + invitation.status = InvitationStatus.accepted + invitation.accepted_at = datetime.now(timezone.utc) + invitation.accepted_by = new_user.id + + db.commit() + db.refresh(new_user) + + # Generate JWT token for auto-login + access_token = create_access_token(data={"sub": str(new_user.id)}) + + logger.info(f"User {new_user.email} accepted invitation and created account with role {get_user_role_code(new_user)}") + + return { + "message": "Invitation accepted successfully", + "access_token": access_token, + "token_type": "bearer", + "user": { + "id": str(new_user.id), + "email": new_user.email, + "first_name": new_user.first_name, + "last_name": new_user.last_name, + "role": get_user_role_code(new_user), + "status": new_user.status.value + } + } + + +# ============================================================ +# CSV IMPORT ENDPOINTS +# Note: Export endpoint has been moved above {user_id} route +# ============================================================ + +@api_router.post("/admin/users/import") +async def import_users_csv( + file: UploadFile = File(...), + update_existing: bool = Form(False), + current_user: User = Depends(require_permission("users.import")), + db: Session = Depends(get_db) +): + """ + Import users from CSV file + Admin/Superadmin only + Requires permission: users.import + + CSV Format: + Email,First Name,Last Name,Phone,Role,Status,Address,City,State,Zipcode,Date of Birth,Member Since + """ + # Validate file type + if not file.filename.endswith('.csv'): + raise HTTPException(status_code=400, detail="Only CSV files are supported") + + # Read file content + try: + contents = await file.read() + decoded = contents.decode('utf-8') + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to read CSV file: {str(e)}") + + # Parse CSV + csv_reader = csv.DictReader(io.StringIO(decoded)) + + # Validate required columns + required_columns = {'Email', 'First Name', 'Last Name', 'Phone', 'Role'} + if not required_columns.issubset(set(csv_reader.fieldnames or [])): + missing = required_columns - set(csv_reader.fieldnames or []) + raise HTTPException( + status_code=400, + detail=f"Missing required columns: {', '.join(missing)}" + ) + + # Count total rows + rows = list(csv_reader) + total_rows = len(rows) + + # Create import job + import_job = ImportJob( + filename=file.filename, + total_rows=total_rows, + imported_by=current_user.id, + status=ImportJobStatus.processing + ) + db.add(import_job) + db.commit() + db.refresh(import_job) + + # Process rows + successful_rows = 0 + failed_rows = 0 + errors = [] + + for idx, row in enumerate(rows, start=1): + try: + # Validate required fields + email = row.get('Email', '').strip() + first_name = row.get('First Name', '').strip() + last_name = row.get('Last Name', '').strip() + phone = row.get('Phone', '').strip() + role_str = row.get('Role', '').strip() + + if not all([email, first_name, last_name, phone, role_str]): + raise ValueError("Missing required fields") + + # Validate email format (basic check) + if '@' not in email: + raise ValueError("Invalid email format") + + # Validate role + try: + role_enum = UserRole[role_str.lower()] + except KeyError: + raise ValueError(f"Invalid role: {role_str}. Must be one of: guest, member, admin, superadmin") + + # Only superadmin can import superadmin users + if role_enum == UserRole.superadmin and current_user.role != UserRole.superadmin: + raise ValueError("Only superadmin can import superadmin users") + + # Check if user exists + existing_user = db.query(User).filter(User.email == email).first() + + if existing_user: + if update_existing: + # Update existing user + existing_user.first_name = first_name + existing_user.last_name = last_name + existing_user.phone = phone + set_user_role(existing_user, role_enum, db) + + # Update optional fields if provided + if row.get('Address'): + existing_user.address = row['Address'].strip() + if row.get('City'): + existing_user.city = row['City'].strip() + if row.get('State'): + existing_user.state = row['State'].strip() + if row.get('Zipcode'): + existing_user.zipcode = row['Zipcode'].strip() + if row.get('Status'): + try: + existing_user.status = UserStatus[row['Status'].strip().lower()] + except KeyError: + pass # Skip invalid status + if row.get('Date of Birth'): + try: + existing_user.date_of_birth = datetime.strptime(row['Date of Birth'].strip(), '%Y-%m-%d') + except ValueError: + pass # Skip invalid date + if row.get('Member Since'): + try: + existing_user.member_since = datetime.strptime(row['Member Since'].strip(), '%Y-%m-%d') + except ValueError: + pass # Skip invalid date + + successful_rows += 1 + else: + # Skip duplicate + errors.append({ + "row": idx, + "email": email, + "error": "Email already exists (use update_existing=true to update)" + }) + failed_rows += 1 + continue + else: + # Create new user + # Generate temporary password (admin will reset it) + temp_password = secrets.token_urlsafe(16) + + new_user = User( + email=email, + password_hash=get_password_hash(temp_password), + first_name=first_name, + last_name=last_name, + phone=phone, + role=role_enum, + email_verified=True, # Imported users are pre-verified + status=UserStatus[row.get('Status', 'payment_pending').strip().lower()] if row.get('Status') else UserStatus.payment_pending, + address=row.get('Address', '').strip(), + city=row.get('City', '').strip(), + state=row.get('State', '').strip(), + zipcode=row.get('Zipcode', '').strip(), + ) + + # Parse optional dates + if row.get('Date of Birth'): + try: + new_user.date_of_birth = datetime.strptime(row['Date of Birth'].strip(), '%Y-%m-%d') + except ValueError: + pass # Use default + + if row.get('Member Since'): + try: + new_user.member_since = datetime.strptime(row['Member Since'].strip(), '%Y-%m-%d') + except ValueError: + pass # Leave as None + + db.add(new_user) + successful_rows += 1 + + # Commit every 50 rows for performance + if idx % 50 == 0: + db.commit() + + except Exception as e: + failed_rows += 1 + errors.append({ + "row": idx, + "email": row.get('Email', 'N/A'), + "error": str(e) + }) + continue + + # Final commit + db.commit() + + # Update import job + import_job.processed_rows = total_rows + import_job.successful_rows = successful_rows + import_job.failed_rows = failed_rows + import_job.errors = errors + import_job.completed_at = datetime.now(timezone.utc) + + if failed_rows == 0: + import_job.status = ImportJobStatus.completed + elif successful_rows == 0: + import_job.status = ImportJobStatus.failed + else: + import_job.status = ImportJobStatus.partial + + db.commit() + db.refresh(import_job) + + logger.info(f"Admin {current_user.email} imported {successful_rows}/{total_rows} users from CSV") + + return { + "message": "Import completed", + "import_job_id": str(import_job.id), + "total_rows": total_rows, + "successful_rows": successful_rows, + "failed_rows": failed_rows, + "status": import_job.status.value, + "errors": errors[:10] # Return first 10 errors only (full list available in job details) + } + + +@api_router.get("/admin/users/import-jobs") +async def get_import_jobs( + status: Optional[str] = None, + current_user: User = Depends(require_permission("users.view")), + db: Session = Depends(get_db) +): + """ + List all import jobs with optional status filter + Admin/Superadmin only + Requires permission: users.view + """ + query = db.query(ImportJob) + + if status: + try: + status_enum = ImportJobStatus[status] + query = query.filter(ImportJob.status == status_enum) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid status: {status}") + + jobs = query.order_by(ImportJob.started_at.desc()).all() + + return [ + { + "id": str(job.id), + "filename": job.filename, + "total_rows": job.total_rows, + "processed_rows": job.processed_rows, + "successful_rows": job.successful_rows, + "failed_rows": job.failed_rows, + "status": job.status.value, + "imported_by": str(job.imported_by), + "started_at": job.started_at.isoformat(), + "completed_at": job.completed_at.isoformat() if job.completed_at else None, + "error_count": len(job.errors) if job.errors else 0 + } + for job in jobs + ] + + +@api_router.get("/admin/users/import-jobs/{job_id}") +async def get_import_job_details( + job_id: str, + current_user: User = Depends(require_permission("users.view")), + db: Session = Depends(get_db) +): + """ + Get detailed information about a specific import job + Admin/Superadmin only + Requires permission: users.view + """ + job = db.query(ImportJob).filter(ImportJob.id == job_id).first() + if not job: + raise HTTPException(status_code=404, detail="Import job not found") + + # Get importer details + importer = db.query(User).filter(User.id == job.imported_by).first() + + return { + "id": str(job.id), + "filename": job.filename, + "total_rows": job.total_rows, + "processed_rows": job.processed_rows, + "successful_rows": job.successful_rows, + "failed_rows": job.failed_rows, + "status": job.status.value, + "imported_by": { + "id": str(importer.id), + "email": importer.email, + "name": f"{importer.first_name} {importer.last_name}" + } if importer else None, + "started_at": job.started_at.isoformat(), + "completed_at": job.completed_at.isoformat() if job.completed_at else None, + "errors": job.errors or [] # Full error list + } + + @api_router.post("/admin/events", response_model=EventResponse) async def create_event( request: EventCreate, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("events.create")), db: Session = Depends(get_db) ): event = Event( @@ -2069,7 +3014,7 @@ async def create_event( async def update_event( event_id: str, request: EventUpdate, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("events.edit")), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() @@ -2101,7 +3046,7 @@ async def update_event( @api_router.get("/admin/events/{event_id}/rsvps") async def get_event_rsvps( event_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("events.rsvps")), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() @@ -2129,7 +3074,7 @@ async def get_event_rsvps( async def mark_attendance( event_id: str, request: AttendanceUpdate, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("events.attendance")), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() @@ -2148,11 +3093,11 @@ async def mark_attendance( 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 user attended and they were pending validation, 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 + if user and user.status == UserStatus.pending_validation: + user.status = UserStatus.pre_validated user.updated_at = datetime.now(timezone.utc) db.commit() @@ -2161,7 +3106,7 @@ async def mark_attendance( @api_router.get("/admin/events") async def get_admin_events( - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("events.view")), db: Session = Depends(get_db) ): """Get all events for admin (including unpublished)""" @@ -2193,7 +3138,7 @@ async def get_admin_events( @api_router.delete("/admin/events/{event_id}") async def delete_event( event_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("events.delete")), db: Session = Depends(get_db) ): """Delete an event (cascade deletes RSVPs)""" @@ -2306,7 +3251,7 @@ async def get_subscription_plans(db: Session = Depends(get_db)): @api_router.get("/admin/subscriptions/plans") async def get_all_plans_admin( - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("subscriptions.view")), db: Session = Depends(get_db) ): """Get all subscription plans for admin (including inactive) with subscriber counts.""" @@ -2337,7 +3282,7 @@ async def get_all_plans_admin( @api_router.get("/admin/subscriptions/plans/{plan_id}") async def get_plan_admin( plan_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("subscriptions.view")), db: Session = Depends(get_db) ): """Get single plan details with subscriber count.""" @@ -2367,7 +3312,7 @@ async def get_plan_admin( @api_router.post("/admin/subscriptions/plans") async def create_plan( request: PlanCreateRequest, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("subscriptions.plans")), db: Session = Depends(get_db) ): """Create new subscription plan.""" @@ -2444,7 +3389,7 @@ async def create_plan( async def update_plan( plan_id: str, request: PlanCreateRequest, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("subscriptions.plans")), db: Session = Depends(get_db) ): """Update subscription plan.""" @@ -2522,7 +3467,7 @@ async def update_plan( @api_router.delete("/admin/subscriptions/plans/{plan_id}") async def delete_plan( plan_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("subscriptions.plans")), db: Session = Depends(get_db) ): """Soft delete plan (set active = False).""" @@ -2559,7 +3504,8 @@ async def delete_plan( async def get_all_subscriptions( status: Optional[str] = None, plan_id: Optional[str] = None, - current_user: User = Depends(get_current_admin_user), + user_id: Optional[str] = None, + current_user: User = Depends(require_permission("subscriptions.view")), db: Session = Depends(get_db) ): """Get all subscriptions with optional filters.""" @@ -2570,6 +3516,8 @@ async def get_all_subscriptions( query = query.filter(Subscription.status == status) if plan_id: query = query.filter(Subscription.plan_id == plan_id) + if user_id: + query = query.filter(Subscription.user_id == user_id) subscriptions = query.order_by(Subscription.created_at.desc()).all() @@ -2600,7 +3548,7 @@ async def get_all_subscriptions( @api_router.get("/admin/subscriptions/stats") async def get_subscription_stats( - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("subscriptions.view")), db: Session = Depends(get_db) ): """Get subscription statistics for admin dashboard.""" @@ -2637,7 +3585,7 @@ async def get_subscription_stats( async def update_subscription( subscription_id: str, request: UpdateSubscriptionRequest, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("subscriptions.edit")), db: Session = Depends(get_db) ): """Update subscription details (status, dates).""" @@ -2674,7 +3622,7 @@ async def update_subscription( @api_router.post("/admin/subscriptions/{subscription_id}/cancel") async def cancel_subscription( subscription_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("subscriptions.cancel")), db: Session = Depends(get_db) ): """Cancel a subscription.""" @@ -2713,7 +3661,7 @@ async def create_newsletter( document_type: str = Form("google_docs"), document_url: str = Form(None), file: Optional[UploadFile] = File(None), - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("newsletters.create")), db: Session = Depends(get_db) ): """ @@ -2773,7 +3721,7 @@ async def update_newsletter( document_type: str = Form("google_docs"), document_url: str = Form(None), file: Optional[UploadFile] = File(None), - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("newsletters.edit")), db: Session = Depends(get_db) ): """ @@ -2830,7 +3778,7 @@ async def update_newsletter( @api_router.delete("/admin/newsletters/{newsletter_id}") async def delete_newsletter( newsletter_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("newsletters.delete")), db: Session = Depends(get_db) ): """ @@ -2859,7 +3807,7 @@ async def create_financial_report( document_type: str = Form("google_drive"), document_url: str = Form(None), file: Optional[UploadFile] = File(None), - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("financials.create")), db: Session = Depends(get_db) ): """ @@ -2917,7 +3865,7 @@ async def update_financial_report( document_type: str = Form("google_drive"), document_url: str = Form(None), file: Optional[UploadFile] = File(None), - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("financials.edit")), db: Session = Depends(get_db) ): """ @@ -2973,7 +3921,7 @@ async def update_financial_report( @api_router.delete("/admin/financials/{report_id}") async def delete_financial_report( report_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("financials.delete")), db: Session = Depends(get_db) ): """ @@ -3004,7 +3952,7 @@ async def create_bylaws( document_url: str = Form(None), is_current: bool = Form(True), file: Optional[UploadFile] = File(None), - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("bylaws.create")), db: Session = Depends(get_db) ): """ @@ -3071,7 +4019,7 @@ async def update_bylaws( document_url: str = Form(None), is_current: bool = Form(False), file: Optional[UploadFile] = File(None), - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("bylaws.edit")), db: Session = Depends(get_db) ): """ @@ -3135,7 +4083,7 @@ async def update_bylaws( @api_router.delete("/admin/bylaws/{bylaws_id}") async def delete_bylaws( bylaws_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("bylaws.delete")), db: Session = Depends(get_db) ): """ @@ -3156,6 +4104,524 @@ async def delete_bylaws( return {"message": "Bylaws deleted successfully"} +# ============================================================ +# Role Management Endpoints (Superadmin Only) +# ============================================================ + +@api_router.get("/admin/roles", response_model=List[RoleResponse]) +async def get_all_roles( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get all roles in the system with permission counts + Superadmin only + """ + from sqlalchemy import func + + # Query roles with permission counts + roles_query = db.query( + Role, + func.count(RolePermission.id).label('permission_count') + ).outerjoin(RolePermission, Role.id == RolePermission.role_id)\ + .group_by(Role.id)\ + .order_by(Role.is_system_role.desc(), Role.name) + + roles_with_counts = roles_query.all() + + return [ + { + "id": str(role.id), + "code": role.code, + "name": role.name, + "description": role.description, + "is_system_role": role.is_system_role, + "created_at": role.created_at, + "updated_at": role.updated_at, + "permission_count": count + } + for role, count in roles_with_counts + ] + +@api_router.post("/admin/roles", response_model=RoleResponse) +async def create_role( + request: CreateRoleRequest, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Create a new custom role + Superadmin only + """ + # Check if role code already exists + existing_role = db.query(Role).filter(Role.code == request.code).first() + if existing_role: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Role with code '{request.code}' already exists" + ) + + # Create role + new_role = Role( + code=request.code.lower().strip(), + name=request.name.strip(), + description=request.description, + is_system_role=False, # Custom roles are never system roles + created_by=current_user.id + ) + db.add(new_role) + db.flush() # Flush to get the role ID + + # Assign permissions if provided + if request.permission_codes: + # Map role code to enum (for backward compatibility) + role_enum_map = { + 'guest': UserRole.guest, + 'member': UserRole.member, + 'admin': UserRole.admin, + 'superadmin': UserRole.superadmin + } + role_enum = role_enum_map.get(new_role.code, UserRole.guest) + + for perm_code in request.permission_codes: + permission = db.query(Permission).filter(Permission.code == perm_code).first() + if permission: + role_perm = RolePermission( + role=role_enum, # Set legacy enum for backward compatibility + role_id=new_role.id, + permission_id=permission.id, + created_by=current_user.id + ) + db.add(role_perm) + + db.commit() + db.refresh(new_role) + + # Get permission count + perm_count = db.query(RolePermission).filter(RolePermission.role_id == new_role.id).count() + + return { + "id": str(new_role.id), + "code": new_role.code, + "name": new_role.name, + "description": new_role.description, + "is_system_role": new_role.is_system_role, + "created_at": new_role.created_at, + "updated_at": new_role.updated_at, + "permission_count": perm_count + } + +@api_router.get("/admin/roles/{role_id}", response_model=RoleResponse) +async def get_role( + role_id: str, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get role details by ID + Superadmin only + """ + role = db.query(Role).filter(Role.id == role_id).first() + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + perm_count = db.query(RolePermission).filter(RolePermission.role_id == role.id).count() + + return { + "id": str(role.id), + "code": role.code, + "name": role.name, + "description": role.description, + "is_system_role": role.is_system_role, + "created_at": role.created_at, + "updated_at": role.updated_at, + "permission_count": perm_count + } + +@api_router.put("/admin/roles/{role_id}", response_model=RoleResponse) +async def update_role( + role_id: str, + request: UpdateRoleRequest, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Update role details (name, description) + Cannot update system roles or role code + Superadmin only + """ + role = db.query(Role).filter(Role.id == role_id).first() + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + if role.is_system_role: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot update system roles" + ) + + # Update fields + if request.name: + role.name = request.name.strip() + if request.description is not None: + role.description = request.description + + role.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(role) + + perm_count = db.query(RolePermission).filter(RolePermission.role_id == role.id).count() + + return { + "id": str(role.id), + "code": role.code, + "name": role.name, + "description": role.description, + "is_system_role": role.is_system_role, + "created_at": role.created_at, + "updated_at": role.updated_at, + "permission_count": perm_count + } + +@api_router.delete("/admin/roles/{role_id}") +async def delete_role( + role_id: str, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Delete a custom role + Cannot delete system roles or roles assigned to users + Superadmin only + """ + role = db.query(Role).filter(Role.id == role_id).first() + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + if role.is_system_role: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot delete system roles" + ) + + # Check if any users have this role + users_with_role = db.query(User).filter(User.role_id == role_id).count() + if users_with_role > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot delete role: {users_with_role} user(s) are assigned this role" + ) + + # Delete role permissions first (CASCADE should handle this, but being explicit) + db.query(RolePermission).filter(RolePermission.role_id == role_id).delete() + + # Delete role + db.delete(role) + db.commit() + + return {"message": f"Role '{role.name}' deleted successfully"} + +@api_router.get("/admin/roles/{role_id}/permissions") +async def get_role_permissions( + role_id: str, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get all permissions assigned to a role + Superadmin only + """ + role = db.query(Role).filter(Role.id == role_id).first() + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + permissions = db.query(Permission)\ + .join(RolePermission)\ + .filter(RolePermission.role_id == role_id)\ + .order_by(Permission.module, Permission.code)\ + .all() + + return { + "role_id": str(role.id), + "role_name": role.name, + "permissions": [ + { + "id": str(perm.id), + "code": perm.code, + "name": perm.name, + "description": perm.description, + "module": perm.module + } + for perm in permissions + ] + } + +@api_router.put("/admin/roles/{role_id}/permissions") +async def assign_role_permissions( + role_id: str, + request: AssignRolePermissionsRequest, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Assign permissions to a role (replaces existing permissions) + Superadmin only + """ + role = db.query(Role).filter(Role.id == role_id).first() + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + # Remove existing permissions + db.query(RolePermission).filter(RolePermission.role_id == role_id).delete() + + # Map role code to enum (for backward compatibility with legacy role column) + role_enum_map = { + 'guest': UserRole.guest, + 'member': UserRole.member, + 'admin': UserRole.admin, + 'superadmin': UserRole.superadmin + } + role_enum = role_enum_map.get(role.code, UserRole.guest) # Default to guest if custom role + + # Add new permissions + for perm_code in request.permission_codes: + permission = db.query(Permission).filter(Permission.code == perm_code).first() + if not permission: + logger.warning(f"Permission code '{perm_code}' not found, skipping") + continue + + role_perm = RolePermission( + role=role_enum, # Set legacy enum for backward compatibility + role_id=role.id, + permission_id=permission.id, + created_by=current_user.id + ) + db.add(role_perm) + + db.commit() + + return { + "message": f"Assigned {len(request.permission_codes)} permissions to role '{role.name}'", + "role_id": str(role.id), + "permission_codes": request.permission_codes + } + +# ============================================================ +# Permission Management Endpoints (Superadmin Only) +# ============================================================ + +@api_router.get("/admin/permissions", response_model=List[PermissionResponse]) +async def get_all_permissions( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get all permissions in the system + Superadmin only + """ + permissions = db.query(Permission).order_by(Permission.module, Permission.code).all() + + return [ + { + "id": str(perm.id), + "code": perm.code, + "name": perm.name, + "description": perm.description, + "module": perm.module, + "created_at": perm.created_at + } + for perm in permissions + ] + +@api_router.get("/admin/permissions/modules") +async def get_permission_modules( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get all permission modules with permission counts + Superadmin only + """ + from sqlalchemy import func + + # Get all permissions grouped by module + modules = db.query( + Permission.module, + func.count(Permission.id).label('permission_count') + ).group_by(Permission.module).all() + + # Get permissions for each module + result = [] + for module_name, count in modules: + permissions = db.query(Permission)\ + .filter(Permission.module == module_name)\ + .order_by(Permission.code)\ + .all() + + result.append({ + "module": module_name, + "permission_count": count, + "permissions": [ + { + "id": str(p.id), + "code": p.code, + "name": p.name, + "description": p.description + } + for p in permissions + ] + }) + + return result + +@api_router.get("/admin/permissions/roles/{role}") +async def get_role_permissions( + role: str, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get permissions assigned to a specific role + Superadmin only + """ + # Validate role exists + try: + role_enum = UserRole[role] + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {role}") + + # Superadmin always has all permissions (enforced in code, not stored) + if role_enum == UserRole.superadmin: + all_permissions = db.query(Permission).all() + return { + "role": role, + "permissions": [ + { + "id": str(p.id), + "code": p.code, + "name": p.name, + "description": p.description, + "module": p.module + } + for p in all_permissions + ], + "note": "Superadmin automatically has all permissions" + } + + # Get permissions for other roles + permissions = db.query(Permission)\ + .join(RolePermission)\ + .filter(RolePermission.role == role_enum)\ + .order_by(Permission.module, Permission.code)\ + .all() + + return { + "role": role, + "permissions": [ + { + "id": str(p.id), + "code": p.code, + "name": p.name, + "description": p.description, + "module": p.module + } + for p in permissions + ] + } + +@api_router.put("/admin/permissions/roles/{role}") +async def assign_role_permissions( + role: str, + request: AssignPermissionsRequest, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Assign permissions to a role (cherry-pick permissions) + Superadmin only + + This replaces all existing permissions for the role with the provided list. + """ + # Validate role exists + try: + role_enum = UserRole[role] + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {role}") + + # Prevent modifying superadmin permissions + if role_enum == UserRole.superadmin: + raise HTTPException( + status_code=403, + detail="Cannot modify superadmin permissions. Superadmin always has all permissions." + ) + + # Validate all permission codes exist + permissions_to_assign = [] + for code in request.permission_codes: + permission = db.query(Permission).filter(Permission.code == code).first() + if not permission: + raise HTTPException(status_code=400, detail=f"Invalid permission code: {code}") + permissions_to_assign.append(permission) + + # Remove existing permissions for this role + db.query(RolePermission).filter(RolePermission.role == role_enum).delete() + + # Add new permissions + for permission in permissions_to_assign: + role_permission = RolePermission( + role=role_enum, + permission_id=permission.id, + created_by=current_user.id + ) + db.add(role_permission) + + db.commit() + + return { + "message": f"Successfully assigned {len(permissions_to_assign)} permissions to {role}", + "role": role, + "permission_count": len(permissions_to_assign) + } + +@api_router.post("/admin/permissions/seed") +async def seed_permissions( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Seed default permissions into the database + Superadmin only + + WARNING: This will clear all existing permissions and role assignments + """ + import subprocess + import sys + + try: + # Run the permissions_seed.py script + result = subprocess.run( + [sys.executable, "permissions_seed.py"], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + raise HTTPException( + status_code=500, + detail=f"Failed to seed permissions: {result.stderr}" + ) + + return { + "message": "Permissions seeded successfully", + "output": result.stdout + } + + except subprocess.TimeoutExpired: + raise HTTPException(status_code=500, detail="Permission seeding timed out") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error seeding permissions: {str(e)}") + @api_router.post("/subscriptions/checkout") async def create_checkout( request: CheckoutRequest, @@ -3490,7 +4956,7 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)): # Update user status and role user.status = UserStatus.active - user.role = UserRole.member + set_user_role(user, UserRole.member, db) user.updated_at = datetime.now(timezone.utc) db.commit() diff --git a/server.py.bak b/server.py.bak new file mode 100644 index 0000000..2de7645 --- /dev/null +++ b/server.py.bak @@ -0,0 +1,4652 @@ +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 +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 auth import ( + get_password_hash, + verify_password, + create_access_token, + get_current_user, + get_current_admin_user, + get_current_superadmin, + get_active_member, + get_user_permissions, + require_permission, + create_password_reset_token, + verify_reset_token, + get_user_role_code +) +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__) + +# ============================================================ +# Helper Functions +# ============================================================ + +def set_user_role(user: User, role_enum: UserRole, db: Session): + """ + Set user's role in both legacy enum and dynamic role_id. + Ensures consistency between old and new role systems during Phase 3 migration. + + Args: + user: User object to update + role_enum: UserRole enum value + db: Database session + """ + # Set legacy enum + user.role = role_enum + + # Set dynamic role_id + role = db.query(Role).filter(Role.code == role_enum.value).first() + if role: + user.role_id = role.id + else: + logger.warning(f"Role not found for code: {role_enum.value}") + +# ============================================================ +# 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) + accepts_tos: bool = False + + @validator('accepts_tos') + def tos_must_be_accepted(cls, v): + if not v: + raise ValueError('You must accept the Terms of Service to register') + return v + + @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 + +# ============================================================ +# Permission Management Pydantic Models +# ============================================================ + +class PermissionResponse(BaseModel): + id: str + code: str + name: str + description: Optional[str] + module: str + created_at: datetime + + class Config: + from_attributes = True + +class AssignPermissionsRequest(BaseModel): + permission_codes: List[str] = Field(..., description="List of permission codes to assign to the role") + +# ============================================================ +# User Creation & Invitation Pydantic Models +# ============================================================ + +class CreateUserRequest(BaseModel): + email: EmailStr + password: str = Field(..., min_length=8) + first_name: str + last_name: str + phone: str + role: str # "member", "admin", "superadmin" + + # Optional member fields + address: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + zipcode: Optional[str] = None + date_of_birth: Optional[datetime] = None + member_since: Optional[datetime] = None + +class InviteUserRequest(BaseModel): + email: EmailStr + role: str # "member", "admin", "superadmin" + + # Optional pre-fill information + first_name: Optional[str] = None + last_name: Optional[str] = None + phone: Optional[str] = None + +class InvitationResponse(BaseModel): + id: str + email: str + role: str + status: str + first_name: Optional[str] + last_name: Optional[str] + phone: Optional[str] + invited_by: str + invited_at: datetime + expires_at: datetime + accepted_at: Optional[datetime] + + class Config: + from_attributes = True + +class AcceptInvitationRequest(BaseModel): + token: str + password: str = Field(..., min_length=8) + + # Complete profile information + first_name: str + last_name: str + phone: str + + # Member-specific fields (optional for staff) + address: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + zipcode: Optional[str] = None + date_of_birth: Optional[datetime] = None + +# 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, + + # Terms of Service acceptance (Step 4) + accepts_tos=request.accepts_tos, + tos_accepted_at=datetime.now(timezone.utc) if request.accepts_tos else None, + + # 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_validated + else: + user.status = UserStatus.pending_validation + else: + user.status = UserStatus.pending_validation + + 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": get_user_role_code(user), + "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 + ) + +@api_router.get("/auth/permissions") +async def get_my_permissions( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Get current user's permissions based on their role + Returns list of permission codes (e.g., ['users.view', 'events.create']) + """ + permissions = await get_user_permissions(current_user, db) + return { + "permissions": permissions, + "role": current_user.role.value + } + +# 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(require_permission("events.edit")), + 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(require_permission("events.edit")), + 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(require_permission("gallery.upload")), + 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(require_permission("gallery.delete")), + 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(require_permission("gallery.edit")), + 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(require_permission("settings.storage")), + 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(require_permission("settings.storage")), + 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 + # Note: profile_photos removed - User.profile_photo_size field doesn't exist + # If needed in future, add profile_photo_size column to User model + 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": { + "gallery_images": gallery_images, + "newsletters": newsletters, + "financials": financials, + "bylaws": bylaws + }, + "total": 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(require_permission("users.view")) +): + 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 + ] + +# IMPORTANT: All specific routes (/create, /invite, /invitations, /export, /import) +# must be defined ABOVE this {user_id} route to avoid path conflicts + +@api_router.get("/admin/users/invitations") +async def get_invitations( + status: Optional[str] = None, + current_user: User = Depends(require_permission("users.view")), + db: Session = Depends(get_db) +): + """ + List all invitations with optional status filter + Admin/Superadmin only + """ + query = db.query(UserInvitation) + + if status: + try: + status_enum = InvitationStatus[status] + query = query.filter(UserInvitation.status == status_enum) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid status: {status}") + + invitations = query.order_by(UserInvitation.invited_at.desc()).all() + + return [ + { + "id": str(inv.id), + "email": inv.email, + "role": inv.role.value, + "status": inv.status.value, + "first_name": inv.first_name, + "last_name": inv.last_name, + "phone": inv.phone, + "invited_by": str(inv.invited_by), + "invited_at": inv.invited_at.isoformat(), + "expires_at": inv.expires_at.isoformat(), + "accepted_at": inv.accepted_at.isoformat() if inv.accepted_at else None + } + for inv in invitations + ] + +@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(require_permission("users.view")) +): + """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}/validate") +async def validate_user( + user_id: str, + bypass_email_verification: bool = False, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("users.approve")) +): + 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_validated if referrer else UserStatus.pending_validation + else: + user.status = UserStatus.pending_validation + + logger.info(f"Admin {current_user.email} bypassed email verification for {user.email}") + + # Validate user status - must be pending_validation or pre_validated + if user.status not in [UserStatus.pending_validation, UserStatus.pre_validated]: + 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 (payment pending): {user.email} by admin: {current_user.email}") + + return {"message": "User validated - 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(require_permission("users.status")) +): + 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(require_permission("subscriptions.activate")) +): + """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 + set_user_role(user, UserRole.member, db) + 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(require_permission("users.reset_password")), + 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(require_permission("users.resend_verification")), + 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}"} + +# ============================================================ +# User Creation & Invitation Endpoints +# ============================================================ + +@api_router.post("/admin/users/create") +async def create_user_directly( + request: CreateUserRequest, + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Create user account directly (without invitation) + Admin/Superadmin only + """ + # 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") + + # Validate role + try: + role_enum = UserRole[request.role] + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {request.role}") + + # Only superadmin can create superadmin users + if role_enum == UserRole.superadmin and current_user.role != UserRole.superadmin: + raise HTTPException(status_code=403, detail="Only superadmin can create superadmin users") + + # Create user + new_user = User( + email=request.email, + password_hash=get_password_hash(request.password), + first_name=request.first_name, + last_name=request.last_name, + phone=request.phone, + role=role_enum, + email_verified=True, # Admin-created users are pre-verified + status=UserStatus.active if role_enum in [UserRole.admin, UserRole.superadmin] else UserStatus.payment_pending, + + # Optional member fields + address=request.address or "", + city=request.city or "", + state=request.state or "", + zipcode=request.zipcode or "", + date_of_birth=request.date_of_birth or datetime.now(timezone.utc), + member_since=request.member_since, + ) + + db.add(new_user) + db.commit() + db.refresh(new_user) + + logger.info(f"Admin {current_user.email} created user: {new_user.email} with role {request.role}") + + return { + "message": "User created successfully", + "user_id": str(new_user.id), + "email": new_user.email, + "role": new_user.role.value + } + +@api_router.post("/admin/users/invite") +async def send_user_invitation( + request: InviteUserRequest, + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Send email invitation to new user + Admin/Superadmin only + """ + # 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") + + # Check for pending invitation + existing_invitation = db.query(UserInvitation).filter( + UserInvitation.email == request.email, + UserInvitation.status == InvitationStatus.pending + ).first() + if existing_invitation: + raise HTTPException(status_code=400, detail="Pending invitation already exists for this email") + + # Validate role + try: + role_enum = UserRole[request.role] + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {request.role}") + + # Only superadmin can invite superadmin users + if role_enum == UserRole.superadmin and current_user.role != UserRole.superadmin: + raise HTTPException(status_code=403, detail="Only superadmin can invite superadmin users") + + # Generate secure token + token = secrets.token_urlsafe(32) + + # Create invitation (expires in 7 days) + invitation = UserInvitation( + email=request.email, + token=token, + role=role_enum, + status=InvitationStatus.pending, + first_name=request.first_name, + last_name=request.last_name, + phone=request.phone, + invited_by=current_user.id, + expires_at=datetime.now(timezone.utc) + timedelta(days=7) + ) + + db.add(invitation) + db.commit() + db.refresh(invitation) + + # Send invitation email + from email_service import send_invitation_email + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") + invitation_url = f"{frontend_url}/accept-invitation?token={token}" + + try: + await send_invitation_email( + to_email=request.email, + inviter_name=f"{current_user.first_name} {current_user.last_name}", + invitation_url=invitation_url, + role=request.role + ) + except Exception as e: + logger.error(f"Failed to send invitation email: {str(e)}") + # Continue anyway - admin can resend later + + logger.info(f"Admin {current_user.email} invited {request.email} as {request.role}") + + return { + "message": "Invitation sent successfully", + "invitation_id": str(invitation.id), + "email": invitation.email, + "expires_at": invitation.expires_at.isoformat(), + "invitation_url": invitation_url + } + +@api_router.post("/admin/users/invitations/{invitation_id}/resend") +async def resend_invitation( + invitation_id: str, + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Resend invitation email (extends expiry by 7 days) + Admin/Superadmin only + """ + invitation = db.query(UserInvitation).filter(UserInvitation.id == invitation_id).first() + if not invitation: + raise HTTPException(status_code=404, detail="Invitation not found") + + if invitation.status != InvitationStatus.pending: + raise HTTPException(status_code=400, detail=f"Cannot resend invitation with status: {invitation.status.value}") + + # Extend expiry by 7 days from now + invitation.expires_at = datetime.now(timezone.utc) + timedelta(days=7) + db.commit() + + # Resend email + from email_service import send_invitation_email + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") + invitation_url = f"{frontend_url}/accept-invitation?token={invitation.token}" + + try: + await send_invitation_email( + to_email=invitation.email, + inviter_name=f"{current_user.first_name} {current_user.last_name}", + invitation_url=invitation_url, + role=invitation.role.value + ) + except Exception as e: + logger.error(f"Failed to resend invitation email: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to send email") + + logger.info(f"Admin {current_user.email} resent invitation to {invitation.email}") + + return { + "message": "Invitation resent successfully", + "expires_at": invitation.expires_at.isoformat() + } + +@api_router.delete("/admin/users/invitations/{invitation_id}") +async def revoke_invitation( + invitation_id: str, + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Revoke pending invitation + Admin/Superadmin only + """ + invitation = db.query(UserInvitation).filter(UserInvitation.id == invitation_id).first() + if not invitation: + raise HTTPException(status_code=404, detail="Invitation not found") + + if invitation.status != InvitationStatus.pending: + raise HTTPException(status_code=400, detail=f"Cannot revoke invitation with status: {invitation.status.value}") + + invitation.status = InvitationStatus.revoked + db.commit() + + logger.info(f"Admin {current_user.email} revoked invitation for {invitation.email}") + + return {"message": "Invitation revoked successfully"} + +# ============================================================ +# Public Invitation Endpoints +# ============================================================ + +@api_router.get("/invitations/verify/{token}") +async def verify_invitation_token( + token: str, + db: Session = Depends(get_db) +): + """ + Verify invitation token and return invitation details + Public endpoint - no authentication required + """ + invitation = db.query(UserInvitation).filter( + UserInvitation.token == token, + UserInvitation.status == InvitationStatus.pending + ).first() + + if not invitation: + raise HTTPException(status_code=404, detail="Invalid or expired invitation token") + + # Check expiry + if invitation.expires_at < datetime.now(timezone.utc): + invitation.status = InvitationStatus.expired + db.commit() + raise HTTPException(status_code=400, detail="Invitation has expired") + + return { + "email": invitation.email, + "role": invitation.role.value, + "first_name": invitation.first_name, + "last_name": invitation.last_name, + "phone": invitation.phone, + "expires_at": invitation.expires_at.isoformat() + } + +@api_router.post("/invitations/accept") +async def accept_invitation( + request: AcceptInvitationRequest, + db: Session = Depends(get_db) +): + """ + Accept invitation and create user account + Public endpoint - no authentication required + """ + # Verify invitation + invitation = db.query(UserInvitation).filter( + UserInvitation.token == request.token, + UserInvitation.status == InvitationStatus.pending + ).first() + + if not invitation: + raise HTTPException(status_code=404, detail="Invalid or expired invitation token") + + # Check expiry + if invitation.expires_at < datetime.now(timezone.utc): + invitation.status = InvitationStatus.expired + db.commit() + raise HTTPException(status_code=400, detail="Invitation has expired") + + # Check if email already registered + existing_user = db.query(User).filter(User.email == invitation.email).first() + if existing_user: + raise HTTPException(status_code=400, detail="Email already registered") + + # Create user account + new_user = User( + email=invitation.email, + password_hash=get_password_hash(request.password), + first_name=request.first_name, + last_name=request.last_name, + phone=request.phone, + role=invitation.role, + email_verified=True, # Invited users are pre-verified + status=UserStatus.active if invitation.role in [UserRole.admin, UserRole.superadmin] else UserStatus.payment_pending, + + # Optional fields + address=request.address or "", + city=request.city or "", + state=request.state or "", + zipcode=request.zipcode or "", + date_of_birth=request.date_of_birth or datetime.now(timezone.utc), + ) + + db.add(new_user) + + # Update invitation status + invitation.status = InvitationStatus.accepted + invitation.accepted_at = datetime.now(timezone.utc) + invitation.accepted_by = new_user.id + + db.commit() + db.refresh(new_user) + + # Generate JWT token for auto-login + access_token = create_access_token(data={"sub": str(new_user.id)}) + + logger.info(f"User {new_user.email} accepted invitation and created account with role {new_user.role.value}") + + return { + "message": "Invitation accepted successfully", + "access_token": access_token, + "token_type": "bearer", + "user": { + "id": str(new_user.id), + "email": new_user.email, + "first_name": new_user.first_name, + "last_name": new_user.last_name, + "role": new_user.role.value, + "status": new_user.status.value + } + } + + +# ============================================================ +# CSV EXPORT/IMPORT ENDPOINTS +# ============================================================ + +@api_router.get("/admin/users/export") +async def export_users_csv( + status: Optional[str] = None, + role: Optional[str] = None, + email_verified: Optional[bool] = None, + search: Optional[str] = None, + current_user: User = Depends(require_permission("users.export")), + db: Session = Depends(get_db) +): + """ + Export users to CSV with optional filters + Admin/Superadmin only + Requires permission: users.export + """ + # Build query + query = db.query(User) + + # Apply filters + if status: + try: + status_enum = UserStatus[status] + query = query.filter(User.status == status_enum) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid status: {status}") + + if role: + try: + role_enum = UserRole[role] + query = query.filter(User.role == role_enum) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {role}") + + if email_verified is not None: + query = query.filter(User.email_verified == email_verified) + + if search: + search_filter = or_( + User.email.ilike(f"%{search}%"), + User.first_name.ilike(f"%{search}%"), + User.last_name.ilike(f"%{search}%") + ) + query = query.filter(search_filter) + + # Get all matching users + users = query.order_by(User.created_at.desc()).all() + + # Create CSV in memory + output = io.StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow([ + 'ID', + 'Email', + 'First Name', + 'Last Name', + 'Phone', + 'Role', + 'Status', + 'Email Verified', + 'Address', + 'City', + 'State', + 'Zipcode', + 'Date of Birth', + 'Member Since', + 'Partner First Name', + 'Partner Last Name', + 'Partner Is Member', + 'Partner Plan to Become Member', + 'Referred By Member Name', + 'Lead Sources', + 'Created At', + 'Updated At' + ]) + + # Write data rows + for user in users: + writer.writerow([ + str(user.id), + user.email, + user.first_name, + user.last_name, + user.phone, + user.role.value, + user.status.value, + 'Yes' if user.email_verified else 'No', + user.address or '', + user.city or '', + user.state or '', + user.zipcode or '', + user.date_of_birth.strftime('%Y-%m-%d') if user.date_of_birth else '', + user.member_since.strftime('%Y-%m-%d') if user.member_since else '', + user.partner_first_name or '', + user.partner_last_name or '', + 'Yes' if user.partner_is_member else 'No', + 'Yes' if user.partner_plan_to_become_member else 'No', + user.referred_by_member_name or '', + ','.join(user.lead_sources) if user.lead_sources else '', + user.created_at.strftime('%Y-%m-%d %H:%M:%S'), + user.updated_at.strftime('%Y-%m-%d %H:%M:%S') if user.updated_at else '' + ]) + + # Prepare response + output.seek(0) + + # Generate filename with timestamp + timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') + filename = f"members_export_{timestamp}.csv" + + logger.info(f"Admin {current_user.email} exported {len(users)} users to CSV") + + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={ + "Content-Disposition": f"attachment; filename={filename}" + } + ) + + +@api_router.post("/admin/users/import") +async def import_users_csv( + file: UploadFile = File(...), + update_existing: bool = Form(False), + current_user: User = Depends(require_permission("users.import")), + db: Session = Depends(get_db) +): + """ + Import users from CSV file + Admin/Superadmin only + Requires permission: users.import + + CSV Format: + Email,First Name,Last Name,Phone,Role,Status,Address,City,State,Zipcode,Date of Birth,Member Since + """ + # Validate file type + if not file.filename.endswith('.csv'): + raise HTTPException(status_code=400, detail="Only CSV files are supported") + + # Read file content + try: + contents = await file.read() + decoded = contents.decode('utf-8') + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to read CSV file: {str(e)}") + + # Parse CSV + csv_reader = csv.DictReader(io.StringIO(decoded)) + + # Validate required columns + required_columns = {'Email', 'First Name', 'Last Name', 'Phone', 'Role'} + if not required_columns.issubset(set(csv_reader.fieldnames or [])): + missing = required_columns - set(csv_reader.fieldnames or []) + raise HTTPException( + status_code=400, + detail=f"Missing required columns: {', '.join(missing)}" + ) + + # Count total rows + rows = list(csv_reader) + total_rows = len(rows) + + # Create import job + import_job = ImportJob( + filename=file.filename, + total_rows=total_rows, + imported_by=current_user.id, + status=ImportJobStatus.processing + ) + db.add(import_job) + db.commit() + db.refresh(import_job) + + # Process rows + successful_rows = 0 + failed_rows = 0 + errors = [] + + for idx, row in enumerate(rows, start=1): + try: + # Validate required fields + email = row.get('Email', '').strip() + first_name = row.get('First Name', '').strip() + last_name = row.get('Last Name', '').strip() + phone = row.get('Phone', '').strip() + role_str = row.get('Role', '').strip() + + if not all([email, first_name, last_name, phone, role_str]): + raise ValueError("Missing required fields") + + # Validate email format (basic check) + if '@' not in email: + raise ValueError("Invalid email format") + + # Validate role + try: + role_enum = UserRole[role_str.lower()] + except KeyError: + raise ValueError(f"Invalid role: {role_str}. Must be one of: guest, member, admin, superadmin") + + # Only superadmin can import superadmin users + if role_enum == UserRole.superadmin and current_user.role != UserRole.superadmin: + raise ValueError("Only superadmin can import superadmin users") + + # Check if user exists + existing_user = db.query(User).filter(User.email == email).first() + + if existing_user: + if update_existing: + # Update existing user + existing_user.first_name = first_name + existing_user.last_name = last_name + existing_user.phone = phone + set_user_role(existing_user, role_enum, db) + + # Update optional fields if provided + if row.get('Address'): + existing_user.address = row['Address'].strip() + if row.get('City'): + existing_user.city = row['City'].strip() + if row.get('State'): + existing_user.state = row['State'].strip() + if row.get('Zipcode'): + existing_user.zipcode = row['Zipcode'].strip() + if row.get('Status'): + try: + existing_user.status = UserStatus[row['Status'].strip().lower()] + except KeyError: + pass # Skip invalid status + if row.get('Date of Birth'): + try: + existing_user.date_of_birth = datetime.strptime(row['Date of Birth'].strip(), '%Y-%m-%d') + except ValueError: + pass # Skip invalid date + if row.get('Member Since'): + try: + existing_user.member_since = datetime.strptime(row['Member Since'].strip(), '%Y-%m-%d') + except ValueError: + pass # Skip invalid date + + successful_rows += 1 + else: + # Skip duplicate + errors.append({ + "row": idx, + "email": email, + "error": "Email already exists (use update_existing=true to update)" + }) + failed_rows += 1 + continue + else: + # Create new user + # Generate temporary password (admin will reset it) + temp_password = secrets.token_urlsafe(16) + + new_user = User( + email=email, + password_hash=get_password_hash(temp_password), + first_name=first_name, + last_name=last_name, + phone=phone, + role=role_enum, + email_verified=True, # Imported users are pre-verified + status=UserStatus[row.get('Status', 'payment_pending').strip().lower()] if row.get('Status') else UserStatus.payment_pending, + address=row.get('Address', '').strip(), + city=row.get('City', '').strip(), + state=row.get('State', '').strip(), + zipcode=row.get('Zipcode', '').strip(), + ) + + # Parse optional dates + if row.get('Date of Birth'): + try: + new_user.date_of_birth = datetime.strptime(row['Date of Birth'].strip(), '%Y-%m-%d') + except ValueError: + pass # Use default + + if row.get('Member Since'): + try: + new_user.member_since = datetime.strptime(row['Member Since'].strip(), '%Y-%m-%d') + except ValueError: + pass # Leave as None + + db.add(new_user) + successful_rows += 1 + + # Commit every 50 rows for performance + if idx % 50 == 0: + db.commit() + + except Exception as e: + failed_rows += 1 + errors.append({ + "row": idx, + "email": row.get('Email', 'N/A'), + "error": str(e) + }) + continue + + # Final commit + db.commit() + + # Update import job + import_job.processed_rows = total_rows + import_job.successful_rows = successful_rows + import_job.failed_rows = failed_rows + import_job.errors = errors + import_job.completed_at = datetime.now(timezone.utc) + + if failed_rows == 0: + import_job.status = ImportJobStatus.completed + elif successful_rows == 0: + import_job.status = ImportJobStatus.failed + else: + import_job.status = ImportJobStatus.partial + + db.commit() + db.refresh(import_job) + + logger.info(f"Admin {current_user.email} imported {successful_rows}/{total_rows} users from CSV") + + return { + "message": "Import completed", + "import_job_id": str(import_job.id), + "total_rows": total_rows, + "successful_rows": successful_rows, + "failed_rows": failed_rows, + "status": import_job.status.value, + "errors": errors[:10] # Return first 10 errors only (full list available in job details) + } + + +@api_router.get("/admin/users/import-jobs") +async def get_import_jobs( + status: Optional[str] = None, + current_user: User = Depends(require_permission("users.view")), + db: Session = Depends(get_db) +): + """ + List all import jobs with optional status filter + Admin/Superadmin only + Requires permission: users.view + """ + query = db.query(ImportJob) + + if status: + try: + status_enum = ImportJobStatus[status] + query = query.filter(ImportJob.status == status_enum) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid status: {status}") + + jobs = query.order_by(ImportJob.started_at.desc()).all() + + return [ + { + "id": str(job.id), + "filename": job.filename, + "total_rows": job.total_rows, + "processed_rows": job.processed_rows, + "successful_rows": job.successful_rows, + "failed_rows": job.failed_rows, + "status": job.status.value, + "imported_by": str(job.imported_by), + "started_at": job.started_at.isoformat(), + "completed_at": job.completed_at.isoformat() if job.completed_at else None, + "error_count": len(job.errors) if job.errors else 0 + } + for job in jobs + ] + + +@api_router.get("/admin/users/import-jobs/{job_id}") +async def get_import_job_details( + job_id: str, + current_user: User = Depends(require_permission("users.view")), + db: Session = Depends(get_db) +): + """ + Get detailed information about a specific import job + Admin/Superadmin only + Requires permission: users.view + """ + job = db.query(ImportJob).filter(ImportJob.id == job_id).first() + if not job: + raise HTTPException(status_code=404, detail="Import job not found") + + # Get importer details + importer = db.query(User).filter(User.id == job.imported_by).first() + + return { + "id": str(job.id), + "filename": job.filename, + "total_rows": job.total_rows, + "processed_rows": job.processed_rows, + "successful_rows": job.successful_rows, + "failed_rows": job.failed_rows, + "status": job.status.value, + "imported_by": { + "id": str(importer.id), + "email": importer.email, + "name": f"{importer.first_name} {importer.last_name}" + } if importer else None, + "started_at": job.started_at.isoformat(), + "completed_at": job.completed_at.isoformat() if job.completed_at else None, + "errors": job.errors or [] # Full error list + } + + +@api_router.post("/admin/events", response_model=EventResponse) +async def create_event( + request: EventCreate, + current_user: User = Depends(require_permission("events.create")), + 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(require_permission("events.edit")), + 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(require_permission("events.rsvps")), + 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(require_permission("events.attendance")), + 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 validation, update their status + if request.attended: + user = db.query(User).filter(User.id == request.user_id).first() + if user and user.status == UserStatus.pending_validation: + user.status = UserStatus.pre_validated + 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(require_permission("events.view")), + 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(require_permission("events.delete")), + db: Session = Depends(get_db) +): + """Delete an event (cascade deletes RSVPs)""" + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + db.delete(event) + db.commit() + + return {"message": "Event deleted successfully"} + +# ==================== PAYMENT & SUBSCRIPTION ENDPOINTS ==================== + +# Pydantic model for checkout request +class CheckoutRequest(BaseModel): + plan_id: str + amount_cents: int = Field(..., ge=3000, description="Total amount in cents (minimum $30)") + + @validator('amount_cents') + def validate_amount(cls, v): + if v < 3000: + raise ValueError('Amount must be at least $30 (3000 cents)') + return v + +# Pydantic model for plan CRUD +class PlanCreateRequest(BaseModel): + name: str = Field(min_length=1, max_length=100) + description: Optional[str] = Field(None, max_length=500) + price_cents: int = Field(ge=0, le=100000000) # Legacy field, kept for backward compatibility + billing_cycle: Literal["monthly", "quarterly", "yearly", "lifetime", "custom"] + stripe_price_id: Optional[str] = None # Deprecated, no longer required + active: bool = True + + # Custom billing cycle fields (for recurring date ranges like Jan 1 - Dec 31) + custom_cycle_enabled: bool = False + custom_cycle_start_month: Optional[int] = Field(None, ge=1, le=12) + custom_cycle_start_day: Optional[int] = Field(None, ge=1, le=31) + custom_cycle_end_month: Optional[int] = Field(None, ge=1, le=12) + custom_cycle_end_day: Optional[int] = Field(None, ge=1, le=31) + + # Dynamic pricing fields + minimum_price_cents: int = Field(3000, ge=3000, le=100000000) # $30 minimum + suggested_price_cents: Optional[int] = Field(None, ge=3000, le=100000000) + allow_donation: bool = True + + @validator('name') + def validate_name(cls, v): + if not v.strip(): + raise ValueError('Name cannot be empty or whitespace') + return v.strip() + + @validator('custom_cycle_start_month', 'custom_cycle_end_month') + def validate_months(cls, v): + if v is not None and (v < 1 or v > 12): + raise ValueError('Month must be between 1 and 12') + return v + + @validator('custom_cycle_start_day', 'custom_cycle_end_day') + def validate_days(cls, v): + if v is not None and (v < 1 or v > 31): + raise ValueError('Day must be between 1 and 31') + return v + + @validator('suggested_price_cents') + def validate_suggested_price(cls, v, values): + if v is not None and 'minimum_price_cents' in values: + if v < values['minimum_price_cents']: + raise ValueError('Suggested price must be >= minimum price') + return v + +# Pydantic model for updating subscriptions +class UpdateSubscriptionRequest(BaseModel): + status: Optional[str] = Field(None, pattern="^(active|expired|cancelled)$") + end_date: Optional[datetime] = None + +# Pydantic model for donation checkout +class DonationCheckoutRequest(BaseModel): + amount_cents: int = Field(..., ge=100, description="Donation amount in cents (minimum $1.00)") + + @validator('amount_cents') + def validate_amount(cls, v): + if v < 100: + raise ValueError('Donation must be at least $1.00 (100 cents)') + return v + +# Pydantic model for contact form +class ContactFormRequest(BaseModel): + first_name: str = Field(..., min_length=1, max_length=100) + last_name: str = Field(..., min_length=1, max_length=100) + email: str = Field(..., min_length=1, max_length=255) + subject: str = Field(..., min_length=1, max_length=200) + message: str = Field(..., min_length=1, max_length=2000) + + @validator('email') + def validate_email(cls, v): + import re + email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + if not re.match(email_regex, v): + raise ValueError('Invalid email address') + return v + +@api_router.get("/subscriptions/plans") +async def get_subscription_plans(db: Session = Depends(get_db)): + """Get all active subscription plans.""" + plans = db.query(SubscriptionPlan).filter(SubscriptionPlan.active == True).all() + return plans + +# ==================== ADMIN PLAN CRUD ENDPOINTS ==================== + +@api_router.get("/admin/subscriptions/plans") +async def get_all_plans_admin( + current_user: User = Depends(require_permission("subscriptions.view")), + 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(require_permission("subscriptions.view")), + 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(require_permission("subscriptions.plans")), + 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(require_permission("subscriptions.plans")), + 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(require_permission("subscriptions.plans")), + db: Session = Depends(get_db) +): + """Soft delete plan (set active = False).""" + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first() + + if not plan: + raise HTTPException(status_code=404, detail="Plan not found") + + # Check if plan has active subscriptions + active_subs = db.query(Subscription).filter( + Subscription.plan_id == plan_id, + Subscription.status == SubscriptionStatus.active + ).count() + + if active_subs > 0: + raise HTTPException( + status_code=400, + detail=f"Cannot delete plan with {active_subs} active subscriptions" + ) + + plan.active = False + plan.updated_at = datetime.now(timezone.utc) + db.commit() + + logger.info(f"Admin {current_user.email} deactivated plan: {plan.name}") + + return {"message": "Plan deactivated successfully"} + +# ============================================================================ +# Admin Subscription Management Routes +# ============================================================================ + +@api_router.get("/admin/subscriptions") +async def get_all_subscriptions( + status: Optional[str] = None, + plan_id: Optional[str] = None, + user_id: Optional[str] = None, + current_user: User = Depends(require_permission("subscriptions.view")), + db: Session = Depends(get_db) +): + """Get all subscriptions with optional filters.""" + # Use explicit join to avoid ambiguous foreign key error + query = db.query(Subscription).join(Subscription.user).join(Subscription.plan) + + if status: + query = query.filter(Subscription.status == status) + if plan_id: + query = query.filter(Subscription.plan_id == plan_id) + if user_id: + query = query.filter(Subscription.user_id == user_id) + + subscriptions = query.order_by(Subscription.created_at.desc()).all() + + return [{ + "id": str(sub.id), + "user": { + "id": str(sub.user.id), + "first_name": sub.user.first_name, + "last_name": sub.user.last_name, + "email": sub.user.email + }, + "plan": { + "id": str(sub.plan.id), + "name": sub.plan.name, + "billing_cycle": sub.plan.billing_cycle + }, + "status": sub.status.value, + "start_date": sub.start_date, + "end_date": sub.end_date, + "amount_paid_cents": sub.amount_paid_cents, + "base_subscription_cents": sub.base_subscription_cents, + "donation_cents": sub.donation_cents, + "payment_method": sub.payment_method, + "stripe_subscription_id": sub.stripe_subscription_id, + "created_at": sub.created_at, + "updated_at": sub.updated_at + } for sub in subscriptions] + +@api_router.get("/admin/subscriptions/stats") +async def get_subscription_stats( + current_user: User = Depends(require_permission("subscriptions.view")), + db: Session = Depends(get_db) +): + """Get subscription statistics for admin dashboard.""" + from sqlalchemy import func + + total = db.query(Subscription).count() + active = db.query(Subscription).filter( + Subscription.status == SubscriptionStatus.active + ).count() + cancelled = db.query(Subscription).filter( + Subscription.status == SubscriptionStatus.cancelled + ).count() + expired = db.query(Subscription).filter( + Subscription.status == SubscriptionStatus.expired + ).count() + + revenue_data = db.query( + func.sum(Subscription.amount_paid_cents).label('total_revenue'), + func.sum(Subscription.base_subscription_cents).label('total_base'), + func.sum(Subscription.donation_cents).label('total_donations') + ).first() + + return { + "total": total, + "active": active, + "cancelled": cancelled, + "expired": expired, + "total_revenue": revenue_data.total_revenue or 0, + "total_base": revenue_data.total_base or 0, + "total_donations": revenue_data.total_donations or 0 + } + +@api_router.put("/admin/subscriptions/{subscription_id}") +async def update_subscription( + subscription_id: str, + request: UpdateSubscriptionRequest, + current_user: User = Depends(require_permission("subscriptions.edit")), + db: Session = Depends(get_db) +): + """Update subscription details (status, dates).""" + subscription = db.query(Subscription).filter( + Subscription.id == subscription_id + ).first() + + if not subscription: + raise HTTPException(status_code=404, detail="Subscription not found") + + # Update fields if provided + if request.status: + subscription.status = SubscriptionStatus[request.status] + if request.end_date: + subscription.end_date = request.end_date + + subscription.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(subscription) + + logger.info(f"Admin {current_user.email} updated subscription {subscription_id}") + + return { + "id": str(subscription.id), + "user_id": str(subscription.user_id), + "plan_id": str(subscription.plan_id), + "status": subscription.status.value, + "start_date": subscription.start_date, + "end_date": subscription.end_date, + "amount_paid_cents": subscription.amount_paid_cents, + "updated_at": subscription.updated_at + } + +@api_router.post("/admin/subscriptions/{subscription_id}/cancel") +async def cancel_subscription( + subscription_id: str, + current_user: User = Depends(require_permission("subscriptions.cancel")), + db: Session = Depends(get_db) +): + """Cancel a subscription.""" + subscription = db.query(Subscription).filter( + Subscription.id == subscription_id + ).first() + + if not subscription: + raise HTTPException(status_code=404, detail="Subscription not found") + + subscription.status = SubscriptionStatus.cancelled + subscription.updated_at = datetime.now(timezone.utc) + + # Also update user status if currently active + user = subscription.user + if user.status == UserStatus.active: + user.status = UserStatus.inactive + user.updated_at = datetime.now(timezone.utc) + + db.commit() + + logger.info(f"Admin {current_user.email} cancelled subscription {subscription_id} for user {user.email}") + + return {"message": "Subscription cancelled successfully"} + +# ============================================================================ +# Admin Document Management Routes +# ============================================================================ + +# Newsletter Archive Admin Routes +@api_router.post("/admin/newsletters") +async def create_newsletter( + title: str = Form(...), + description: str = Form(None), + published_date: str = Form(...), + document_type: str = Form("google_docs"), + document_url: str = Form(None), + file: Optional[UploadFile] = File(None), + current_user: User = Depends(require_permission("newsletters.create")), + 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(require_permission("newsletters.edit")), + 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(require_permission("newsletters.delete")), + 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(require_permission("financials.create")), + 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(require_permission("financials.edit")), + 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(require_permission("financials.delete")), + 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(require_permission("bylaws.create")), + 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(require_permission("bylaws.edit")), + 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(require_permission("bylaws.delete")), + 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"} + +# ============================================================ +# Permission Management Endpoints (Superadmin Only) +# ============================================================ + +@api_router.get("/admin/permissions", response_model=List[PermissionResponse]) +async def get_all_permissions( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get all permissions in the system + Superadmin only + """ + permissions = db.query(Permission).order_by(Permission.module, Permission.code).all() + + return [ + { + "id": str(perm.id), + "code": perm.code, + "name": perm.name, + "description": perm.description, + "module": perm.module, + "created_at": perm.created_at + } + for perm in permissions + ] + +@api_router.get("/admin/permissions/modules") +async def get_permission_modules( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get all permission modules with permission counts + Superadmin only + """ + from sqlalchemy import func + + # Get all permissions grouped by module + modules = db.query( + Permission.module, + func.count(Permission.id).label('permission_count') + ).group_by(Permission.module).all() + + # Get permissions for each module + result = [] + for module_name, count in modules: + permissions = db.query(Permission)\ + .filter(Permission.module == module_name)\ + .order_by(Permission.code)\ + .all() + + result.append({ + "module": module_name, + "permission_count": count, + "permissions": [ + { + "id": str(p.id), + "code": p.code, + "name": p.name, + "description": p.description + } + for p in permissions + ] + }) + + return result + +@api_router.get("/admin/permissions/roles/{role}") +async def get_role_permissions( + role: str, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get permissions assigned to a specific role + Superadmin only + """ + # Validate role exists + try: + role_enum = UserRole[role] + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {role}") + + # Superadmin always has all permissions (enforced in code, not stored) + if role_enum == UserRole.superadmin: + all_permissions = db.query(Permission).all() + return { + "role": role, + "permissions": [ + { + "id": str(p.id), + "code": p.code, + "name": p.name, + "description": p.description, + "module": p.module + } + for p in all_permissions + ], + "note": "Superadmin automatically has all permissions" + } + + # Get permissions for other roles + permissions = db.query(Permission)\ + .join(RolePermission)\ + .filter(RolePermission.role == role_enum)\ + .order_by(Permission.module, Permission.code)\ + .all() + + return { + "role": role, + "permissions": [ + { + "id": str(p.id), + "code": p.code, + "name": p.name, + "description": p.description, + "module": p.module + } + for p in permissions + ] + } + +@api_router.put("/admin/permissions/roles/{role}") +async def assign_role_permissions( + role: str, + request: AssignPermissionsRequest, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Assign permissions to a role (cherry-pick permissions) + Superadmin only + + This replaces all existing permissions for the role with the provided list. + """ + # Validate role exists + try: + role_enum = UserRole[role] + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {role}") + + # Prevent modifying superadmin permissions + if role_enum == UserRole.superadmin: + raise HTTPException( + status_code=403, + detail="Cannot modify superadmin permissions. Superadmin always has all permissions." + ) + + # Validate all permission codes exist + permissions_to_assign = [] + for code in request.permission_codes: + permission = db.query(Permission).filter(Permission.code == code).first() + if not permission: + raise HTTPException(status_code=400, detail=f"Invalid permission code: {code}") + permissions_to_assign.append(permission) + + # Remove existing permissions for this role + db.query(RolePermission).filter(RolePermission.role == role_enum).delete() + + # Add new permissions + for permission in permissions_to_assign: + role_permission = RolePermission( + role=role_enum, + permission_id=permission.id, + created_by=current_user.id + ) + db.add(role_permission) + + db.commit() + + return { + "message": f"Successfully assigned {len(permissions_to_assign)} permissions to {role}", + "role": role, + "permission_count": len(permissions_to_assign) + } + +@api_router.post("/admin/permissions/seed") +async def seed_permissions( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Seed default permissions into the database + Superadmin only + + WARNING: This will clear all existing permissions and role assignments + """ + import subprocess + import sys + + try: + # Run the permissions_seed.py script + result = subprocess.run( + [sys.executable, "permissions_seed.py"], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + raise HTTPException( + status_code=500, + detail=f"Failed to seed permissions: {result.stderr}" + ) + + return { + "message": "Permissions seeded successfully", + "output": result.stdout + } + + except subprocess.TimeoutExpired: + raise HTTPException(status_code=500, detail="Permission seeding timed out") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error seeding permissions: {str(e)}") + +@api_router.post("/subscriptions/checkout") +async def create_checkout( + request: CheckoutRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Create Stripe Checkout session with dynamic pricing and donation tracking.""" + + # Status validation - only allow payment_pending and inactive users + allowed_statuses = [UserStatus.payment_pending, UserStatus.inactive] + if current_user.status not in allowed_statuses: + raise HTTPException( + status_code=403, + detail=f"Cannot proceed with payment. User status is '{current_user.status.value}'. " + f"Please complete email verification and admin approval first." + ) + + # Get plan + plan = db.query(SubscriptionPlan).filter( + SubscriptionPlan.id == request.plan_id + ).first() + + if not plan: + raise HTTPException(status_code=404, detail="Plan not found") + + if not plan.active: + raise HTTPException(status_code=400, detail="This plan is no longer available for subscription") + + # Validate amount against plan minimum + if request.amount_cents < plan.minimum_price_cents: + raise HTTPException( + status_code=400, + detail=f"Amount must be at least ${plan.minimum_price_cents / 100:.2f}" + ) + + # Calculate donation split + base_amount = plan.minimum_price_cents + donation_amount = request.amount_cents - base_amount + + # Check if plan allows donations + if donation_amount > 0 and not plan.allow_donation: + raise HTTPException( + status_code=400, + detail="This plan does not accept donations above the minimum price" + ) + + # Get frontend URL from env + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") + + try: + # Build line items for Stripe checkout + line_items = [] + + # Add base subscription line item with dynamic pricing + from payment_service import get_stripe_interval + stripe_interval = get_stripe_interval(plan.billing_cycle) + + if stripe_interval: # Recurring subscription + line_items.append({ + "price_data": { + "currency": "usd", + "unit_amount": base_amount, + "recurring": {"interval": stripe_interval}, + "product_data": { + "name": plan.name, + "description": plan.description or f"{plan.name} membership" + } + }, + "quantity": 1 + }) + else: # One-time payment (lifetime) + line_items.append({ + "price_data": { + "currency": "usd", + "unit_amount": base_amount, + "product_data": { + "name": plan.name, + "description": plan.description or f"{plan.name} membership" + } + }, + "quantity": 1 + }) + + # Add donation line item if applicable + if donation_amount > 0: + line_items.append({ + "price_data": { + "currency": "usd", + "unit_amount": donation_amount, + "product_data": { + "name": "Donation", + "description": f"Additional donation to support {plan.name}" + } + }, + "quantity": 1 + }) + + # Create Stripe Checkout Session + import stripe + stripe.api_key = os.getenv("STRIPE_SECRET_KEY") + + mode = "subscription" if stripe_interval else "payment" + + session = stripe.checkout.Session.create( + customer_email=current_user.email, + payment_method_types=["card"], + line_items=line_items, + mode=mode, + success_url=f"{frontend_url}/payment-success?session_id={{CHECKOUT_SESSION_ID}}", + cancel_url=f"{frontend_url}/payment-cancel", + metadata={ + "user_id": str(current_user.id), + "plan_id": str(plan.id), + "base_amount": str(base_amount), + "donation_amount": str(donation_amount), + "total_amount": str(request.amount_cents) + }, + subscription_data={ + "metadata": { + "user_id": str(current_user.id), + "plan_id": str(plan.id), + "base_amount": str(base_amount), + "donation_amount": str(donation_amount) + } + } if mode == "subscription" else None + ) + + return {"checkout_url": session.url} + + except stripe.error.StripeError as e: + logger.error(f"Stripe error creating checkout session: {str(e)}") + raise HTTPException(status_code=500, detail=f"Payment processing error: {str(e)}") + except Exception as e: + logger.error(f"Error creating checkout session: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to create checkout session") + +@api_router.post("/donations/checkout") +async def create_donation_checkout( + request: DonationCheckoutRequest, + db: Session = Depends(get_db) +): + """Create Stripe Checkout session for one-time donation.""" + + # Get frontend URL from env + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") + + try: + # Create Stripe Checkout Session for one-time payment + import stripe + stripe.api_key = os.getenv("STRIPE_SECRET_KEY") + + checkout_session = stripe.checkout.Session.create( + payment_method_types=['card'], + line_items=[{ + 'price_data': { + 'currency': 'usd', + 'unit_amount': request.amount_cents, + 'product_data': { + 'name': 'Donation to LOAF', + 'description': 'Thank you for supporting our community!' + } + }, + 'quantity': 1 + }], + mode='payment', # One-time payment (not subscription) + success_url=f"{frontend_url}/membership/donation-success?session_id={{CHECKOUT_SESSION_ID}}", + cancel_url=f"{frontend_url}/membership/donate" + ) + + logger.info(f"Donation checkout created: ${request.amount_cents/100:.2f}") + + return {"checkout_url": checkout_session.url} + + except stripe.error.StripeError as e: + logger.error(f"Stripe error creating donation checkout: {str(e)}") + raise HTTPException(status_code=500, detail=f"Payment processing error: {str(e)}") + except Exception as e: + logger.error(f"Error creating donation checkout: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to create donation checkout") + +@api_router.post("/contact") +async def submit_contact_form( + request: ContactFormRequest, + db: Session = Depends(get_db) +): + """Handle contact form submission and send email to admin.""" + + try: + # Get admin email from environment or use default + admin_email = os.getenv("ADMIN_EMAIL", "info@loaftx.org") + + # Create email content + subject = f"New Contact Form Submission: {request.subject}" + + html_content = f""" + + + + + + +
+
+

New Contact Form Submission

+
+
+
+
From:
+
{request.first_name} {request.last_name}
+
+ +
+
Email:
+
{request.email}
+
+ +
+
Subject:
+
{request.subject}
+
+ +
+
Message:
+
{request.message}
+
+ +

+ Reply directly to this email to respond to {request.first_name}. +

+
+
+ + + """ + + # Import send_email from email_service + from email_service import send_email + + # Send email to admin + email_sent = await send_email(admin_email, subject, html_content) + + if not email_sent: + logger.error(f"Failed to send contact form email from {request.email}") + raise HTTPException(status_code=500, detail="Failed to send contact form. Please try again later.") + + logger.info(f"Contact form submitted by {request.first_name} {request.last_name} ({request.email})") + + return { + "message": "Contact form submitted successfully. We'll get back to you soon!", + "success": True + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error processing contact form: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to process contact form") + +@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 + 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"} + +# 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=["*"], +) \ No newline at end of file diff --git a/status_transitions.py b/status_transitions.py new file mode 100644 index 0000000..8949048 --- /dev/null +++ b/status_transitions.py @@ -0,0 +1,624 @@ +""" +Membership Status Transition Logic + +This module handles all user status transitions, validation, and automated rules. +Ensures state machine integrity and prevents invalid status changes. +""" + +from models import UserStatus, UserRole +from typing import Optional, Dict, List +from datetime import datetime, timezone +import logging + +# Configure logging +logger = logging.getLogger(__name__) + +# Define valid status transitions (state machine) +ALLOWED_TRANSITIONS: Dict[UserStatus, List[UserStatus]] = { + UserStatus.pending_email: [ + UserStatus.pending_validation, # Email verified (normal flow) + UserStatus.pre_validated, # Email verified + referred by member + UserStatus.abandoned, # Timeout without verification (optional) + ], + UserStatus.pending_validation: [ + UserStatus.pre_validated, # Attended event + UserStatus.abandoned, # 90-day timeout without event + ], + UserStatus.pre_validated: [ + UserStatus.payment_pending, # Admin validates application + UserStatus.inactive, # Admin rejects (rare) + ], + UserStatus.payment_pending: [ + UserStatus.active, # Payment successful + UserStatus.abandoned, # Timeout without payment (optional) + ], + UserStatus.active: [ + UserStatus.expired, # Subscription ended + UserStatus.canceled, # User/admin cancels + UserStatus.inactive, # Admin deactivates + ], + UserStatus.inactive: [ + UserStatus.active, # Admin reactivates + UserStatus.payment_pending, # Admin prompts for payment + ], + UserStatus.canceled: [ + UserStatus.payment_pending, # User requests to rejoin + UserStatus.active, # Admin reactivates with subscription + ], + UserStatus.expired: [ + UserStatus.payment_pending, # User chooses to renew + UserStatus.active, # Admin manually renews + ], + UserStatus.abandoned: [ + UserStatus.pending_email, # Admin resets - resend verification + UserStatus.pending_validation, # Admin resets - manual email verify + UserStatus.payment_pending, # Admin resets - bypass requirements + ], +} + +# Define role mappings for each status +STATUS_ROLE_MAP: Dict[UserStatus, UserRole] = { + UserStatus.pending_email: UserRole.guest, + UserStatus.pending_validation: UserRole.guest, + UserStatus.pre_validated: UserRole.guest, + UserStatus.payment_pending: UserRole.guest, + UserStatus.active: UserRole.member, + UserStatus.inactive: UserRole.guest, + UserStatus.canceled: UserRole.guest, + UserStatus.expired: UserRole.guest, + UserStatus.abandoned: UserRole.guest, +} + +# Define newsletter subscription rules for each status +NEWSLETTER_SUBSCRIBED_STATUSES = { + UserStatus.pending_validation, + UserStatus.pre_validated, + UserStatus.payment_pending, + UserStatus.active, +} + + +class StatusTransitionError(Exception): + """Raised when an invalid status transition is attempted""" + pass + + +def is_transition_allowed(from_status: UserStatus, to_status: UserStatus) -> bool: + """ + Check if a status transition is allowed by the state machine. + + Args: + from_status: Current user status + to_status: Target user status + + Returns: + True if transition is allowed, False otherwise + """ + if from_status not in ALLOWED_TRANSITIONS: + logger.warning(f"Unknown source status: {from_status}") + return False + + return to_status in ALLOWED_TRANSITIONS[from_status] + + +def get_allowed_transitions(current_status: UserStatus) -> List[UserStatus]: + """ + Get list of allowed next statuses for the current status. + + Args: + current_status: Current user status + + Returns: + List of allowed target statuses + """ + return ALLOWED_TRANSITIONS.get(current_status, []) + + +def get_role_for_status(status: UserStatus) -> UserRole: + """ + Get the appropriate role for a given status. + + Args: + status: User status + + Returns: + Corresponding UserRole + """ + return STATUS_ROLE_MAP.get(status, UserRole.guest) + + +def should_subscribe_newsletter(status: UserStatus) -> bool: + """ + Determine if user should be subscribed to newsletter for given status. + + Args: + status: User status + + Returns: + True if user should receive newsletter + """ + return status in NEWSLETTER_SUBSCRIBED_STATUSES + + +def transition_user_status( + user, + new_status: UserStatus, + reason: Optional[str] = None, + admin_id: Optional[str] = None, + db_session = None, + send_notification: bool = True +) -> Dict[str, any]: + """ + Transition a user to a new status with validation and side effects. + + Args: + user: User object (SQLAlchemy model instance) + new_status: Target status to transition to + reason: Optional reason for the transition + admin_id: Optional admin user ID if transition is manual + db_session: SQLAlchemy database session + send_notification: Whether to send email notification (default True) + + Returns: + Dictionary with transition details: + { + 'success': bool, + 'old_status': str, + 'new_status': str, + 'role_changed': bool, + 'newsletter_changed': bool, + 'message': str + } + + Raises: + StatusTransitionError: If transition is not allowed + """ + old_status = user.status + old_role = user.role + old_newsletter = user.newsletter_subscribed + + # Validate transition + if not is_transition_allowed(old_status, new_status): + allowed = get_allowed_transitions(old_status) + allowed_names = [s.value for s in allowed] + error_msg = ( + f"Invalid status transition: {old_status.value} → {new_status.value}. " + f"Allowed transitions from {old_status.value}: {allowed_names}" + ) + logger.error(error_msg) + raise StatusTransitionError(error_msg) + + # Update status + user.status = new_status + + # Update role based on new status + new_role = get_role_for_status(new_status) + role_changed = new_role != old_role + if role_changed: + user.role = new_role + + # Update newsletter subscription + should_subscribe = should_subscribe_newsletter(new_status) + newsletter_changed = should_subscribe != old_newsletter + if newsletter_changed: + user.newsletter_subscribed = should_subscribe + + # Update timestamp + user.updated_at = datetime.now(timezone.utc) + + # Log the transition + logger.info( + f"Status transition: user_id={user.id}, " + f"{old_status.value} → {new_status.value}, " + f"reason={reason}, admin_id={admin_id}" + ) + + # Commit to database if session provided + if db_session: + db_session.commit() + + # Prepare notification email (actual sending should be done by caller) + # This is just a flag - the API endpoint should handle the actual email + notification_needed = send_notification + + # Build result + result = { + 'success': True, + 'old_status': old_status.value, + 'new_status': new_status.value, + 'old_role': old_role.value, + 'new_role': new_role.value, + 'role_changed': role_changed, + 'old_newsletter': old_newsletter, + 'new_newsletter': should_subscribe, + 'newsletter_changed': newsletter_changed, + 'message': f'Successfully transitioned from {old_status.value} to {new_status.value}', + 'notification_needed': notification_needed, + 'reason': reason, + 'admin_id': admin_id + } + + logger.info(f"Transition result: {result}") + return result + + +def get_status_metadata(status: UserStatus) -> Dict[str, any]: + """ + Get metadata about a status including permissions and properties. + + Args: + status: User status + + Returns: + Dictionary with status metadata + """ + return { + 'status': status.value, + 'role': get_role_for_status(status).value, + 'newsletter_subscribed': should_subscribe_newsletter(status), + 'allowed_transitions': [s.value for s in get_allowed_transitions(status)], + 'can_login': status != UserStatus.pending_email and status != UserStatus.abandoned, + 'has_member_access': status == UserStatus.active, + 'is_pending': status in { + UserStatus.pending_email, + UserStatus.pending_validation, + UserStatus.pre_validated, + UserStatus.payment_pending + }, + 'is_terminated': status in { + UserStatus.canceled, + UserStatus.expired, + UserStatus.abandoned, + UserStatus.inactive + } + } + + +# Helper functions for common transitions + +def verify_email(user, db_session=None, is_referred: bool = False): + """ + Transition user after email verification. + + Args: + user: User object + db_session: Database session + is_referred: Whether user was referred by a member + + Returns: + Transition result dict + """ + target_status = UserStatus.pre_validated if is_referred else UserStatus.pending_validation + return transition_user_status( + user=user, + new_status=target_status, + reason="Email verified" + (" (referred by member)" if is_referred else ""), + db_session=db_session, + send_notification=True + ) + + +def mark_event_attendance(user, admin_id: str, db_session=None): + """ + Transition user after attending an event. + + Args: + user: User object + admin_id: ID of admin marking attendance + db_session: Database session + + Returns: + Transition result dict + """ + return transition_user_status( + user=user, + new_status=UserStatus.pre_validated, + reason="Attended event", + admin_id=admin_id, + db_session=db_session, + send_notification=False # Event attendance doesn't need immediate email + ) + + +def validate_application(user, admin_id: str, db_session=None): + """ + Admin validates user application (formerly "approve"). + + Args: + user: User object + admin_id: ID of admin validating application + db_session: Database session + + Returns: + Transition result dict + """ + return transition_user_status( + user=user, + new_status=UserStatus.payment_pending, + reason="Application validated by admin", + admin_id=admin_id, + db_session=db_session, + send_notification=True # Send payment instructions email + ) + + +def activate_membership(user, admin_id: Optional[str] = None, db_session=None): + """ + Activate membership after payment or manual activation. + + Args: + user: User object + admin_id: Optional ID of admin (for manual activation) + db_session: Database session + + Returns: + Transition result dict + """ + reason = "Payment successful" if not admin_id else "Manually activated by admin" + return transition_user_status( + user=user, + new_status=UserStatus.active, + reason=reason, + admin_id=admin_id, + db_session=db_session, + send_notification=True # Send welcome email + ) + + +def cancel_membership(user, admin_id: Optional[str] = None, reason: str = None, db_session=None): + """ + Cancel membership. + + Args: + user: User object + admin_id: Optional ID of admin (if admin canceled) + reason: Optional cancellation reason + db_session: Database session + + Returns: + Transition result dict + """ + cancel_reason = reason or ("Canceled by admin" if admin_id else "Canceled by user") + return transition_user_status( + user=user, + new_status=UserStatus.canceled, + reason=cancel_reason, + admin_id=admin_id, + db_session=db_session, + send_notification=True # Send cancellation confirmation + ) + + +def expire_membership(user, db_session=None): + """ + Expire membership when subscription ends. + + Args: + user: User object + db_session: Database session + + Returns: + Transition result dict + """ + return transition_user_status( + user=user, + new_status=UserStatus.expired, + reason="Subscription ended", + db_session=db_session, + send_notification=True # Send renewal prompt email + ) + + +def abandon_application(user, reason: str, db_session=None): + """ + Mark application as abandoned due to timeout. + + Args: + user: User object + reason: Reason for abandonment (e.g., "Email verification timeout") + db_session: Database session + + Returns: + Transition result dict + """ + return transition_user_status( + user=user, + new_status=UserStatus.abandoned, + reason=reason, + db_session=db_session, + send_notification=True # Send "incomplete application" notice + ) + + +def reactivate_user(user, target_status: UserStatus, admin_id: str, reason: str = None, db_session=None): + """ + Reactivate user from terminated status (admin action). + + Args: + user: User object + target_status: Status to transition to + admin_id: ID of admin performing reactivation + reason: Optional reason for reactivation + db_session: Database session + + Returns: + Transition result dict + """ + reactivation_reason = reason or f"Reactivated by admin to {target_status.value}" + return transition_user_status( + user=user, + new_status=target_status, + reason=reactivation_reason, + admin_id=admin_id, + db_session=db_session, + send_notification=True + ) + + +# Background job functions (to be called by scheduler) + +def check_pending_email_timeouts(db_session, timeout_days: int = 30): + """ + Check for users in pending_email status past timeout and transition to abandoned. + + This should be run as a daily background job. + + Args: + db_session: Database session + timeout_days: Number of days before abandonment (0 = disabled) + + Returns: + Number of users transitioned + """ + if timeout_days <= 0: + return 0 + + from datetime import timedelta + from models import User + + cutoff_date = datetime.now(timezone.utc) - timedelta(days=timeout_days) + + # Find users in pending_email status created before cutoff + timeout_users = db_session.query(User).filter( + User.status == UserStatus.pending_email, + User.created_at < cutoff_date, + User.email_verified == False + ).all() + + count = 0 + for user in timeout_users: + try: + abandon_application( + user=user, + reason=f"Email verification timeout ({timeout_days} days)", + db_session=db_session + ) + count += 1 + logger.info(f"Abandoned user {user.id} due to email verification timeout") + except Exception as e: + logger.error(f"Error abandoning user {user.id}: {str(e)}") + + return count + + +def check_event_attendance_timeouts(db_session, timeout_days: int = 90): + """ + Check for users in pending_validation status past 90-day timeout. + + This should be run as a daily background job. + + Args: + db_session: Database session + timeout_days: Number of days before abandonment (default 90 per policy) + + Returns: + Number of users transitioned + """ + from datetime import timedelta + from models import User + + cutoff_date = datetime.now(timezone.utc) - timedelta(days=timeout_days) + + # Find users in pending_validation status past deadline + # Note: We check updated_at (when they entered this status) not created_at + timeout_users = db_session.query(User).filter( + User.status == UserStatus.pending_validation, + User.updated_at < cutoff_date + ).all() + + count = 0 + for user in timeout_users: + try: + abandon_application( + user=user, + reason=f"Event attendance timeout ({timeout_days} days)", + db_session=db_session + ) + count += 1 + logger.info(f"Abandoned user {user.id} due to event attendance timeout") + except Exception as e: + logger.error(f"Error abandoning user {user.id}: {str(e)}") + + return count + + +def check_payment_timeouts(db_session, timeout_days: int = 0): + """ + Check for users in payment_pending status past timeout. + + This should be run as a daily background job. + Default timeout_days=0 means never auto-abandon (recommended). + + Args: + db_session: Database session + timeout_days: Number of days before abandonment (0 = disabled) + + Returns: + Number of users transitioned + """ + if timeout_days <= 0: + return 0 # Disabled by default + + from datetime import timedelta + from models import User + + cutoff_date = datetime.now(timezone.utc) - timedelta(days=timeout_days) + + timeout_users = db_session.query(User).filter( + User.status == UserStatus.payment_pending, + User.updated_at < cutoff_date + ).all() + + count = 0 + for user in timeout_users: + try: + abandon_application( + user=user, + reason=f"Payment timeout ({timeout_days} days)", + db_session=db_session + ) + count += 1 + logger.info(f"Abandoned user {user.id} due to payment timeout") + except Exception as e: + logger.error(f"Error abandoning user {user.id}: {str(e)}") + + return count + + +def check_subscription_expirations(db_session): + """ + Check for active subscriptions past end_date and transition to expired. + + This should be run as a daily background job. + + Args: + db_session: Database session + + Returns: + Number of users transitioned + """ + from models import User, Subscription + from sqlalchemy import and_ + + today = datetime.now(timezone.utc).date() + + # Find active users with expired subscriptions + expired_subs = db_session.query(User, Subscription).join( + Subscription, User.id == Subscription.user_id + ).filter( + and_( + User.status == UserStatus.active, + Subscription.end_date < today + ) + ).all() + + count = 0 + for user, subscription in expired_subs: + try: + expire_membership(user=user, db_session=db_session) + count += 1 + logger.info(f"Expired user {user.id} - subscription ended {subscription.end_date}") + except Exception as e: + logger.error(f"Error expiring user {user.id}: {str(e)}") + + return count diff --git a/update_permissions.py b/update_permissions.py new file mode 100644 index 0000000..c177c7b --- /dev/null +++ b/update_permissions.py @@ -0,0 +1,115 @@ +""" +Script to update admin endpoints with permission checks +Replaces get_current_admin_user with require_permission calls +""" + +import re + +# Mapping of endpoint patterns to permissions +ENDPOINT_PERMISSIONS = { + # Calendar + r'POST /admin/calendar/sync': 'events.edit', + r'DELETE /admin/calendar/unsync': 'events.edit', + + # Event Gallery + r'POST /admin/events/\{event_id\}/gallery': 'gallery.upload', + r'DELETE /admin/event-gallery': 'gallery.delete', + r'PUT /admin/event-gallery': 'gallery.edit', + + # Storage + r'GET /admin/storage/usage': 'settings.storage', + r'GET /admin/storage/breakdown': 'settings.storage', + + # User Management (remaining) + r'PUT /admin/users/\{user_id\}/reset-password': 'users.reset_password', + r'POST /admin/users/\{user_id\}/resend-verification': 'users.resend_verification', + + # Events + r'POST /admin/events(?!/)': 'events.create', # Not followed by / + r'PUT /admin/events/\{event_id\}': 'events.edit', + r'GET /admin/events/\{event_id\}/rsvps': 'events.rsvps', + r'PUT /admin/events/\{event_id\}/attendance': 'events.attendance', + r'GET /admin/events(?!/)': 'events.view', # Not followed by / + r'DELETE /admin/events': 'events.delete', + + # Subscriptions + r'GET /admin/subscriptions/plans(?!/)': 'subscriptions.view', + r'GET /admin/subscriptions/plans/\{plan_id\}': 'subscriptions.view', + r'POST /admin/subscriptions/plans': 'subscriptions.plans', + r'PUT /admin/subscriptions/plans': 'subscriptions.plans', + r'DELETE /admin/subscriptions/plans': 'subscriptions.plans', + r'GET /admin/subscriptions/stats': 'subscriptions.view', + r'GET /admin/subscriptions(?!/)': 'subscriptions.view', + r'PUT /admin/subscriptions/\{subscription_id\}': 'subscriptions.edit', + r'POST /admin/subscriptions/\{subscription_id\}/cancel': 'subscriptions.cancel', + + # Newsletters + r'POST /admin/newsletters': 'newsletters.create', + r'PUT /admin/newsletters': 'newsletters.edit', + r'DELETE /admin/newsletters': 'newsletters.delete', + + # Financials + r'POST /admin/financials': 'financials.create', + r'PUT /admin/financials': 'financials.edit', + r'DELETE /admin/financials': 'financials.delete', + + # Bylaws + r'POST /admin/bylaws': 'bylaws.create', + r'PUT /admin/bylaws': 'bylaws.edit', + r'DELETE /admin/bylaws': 'bylaws.delete', +} + +def update_server_file(): + """Read server.py, update permissions, write back""" + + with open('server.py', 'r') as f: + content = f.read() + + # Track changes + changes_made = 0 + + # Find all admin endpoints that still use get_current_admin_user + pattern = r'(@api_router\.(get|post|put|delete)\("(/admin/[^"]+)"\)[^@]+?)current_user: User = Depends\(get_current_admin_user\)' + + def replace_permission(match): + nonlocal changes_made + full_match = match.group(0) + method = match.group(2).upper() + route = match.group(3) + endpoint_pattern = f'{method} {route}' + + # Find matching permission + permission = None + for pattern_key, perm_value in ENDPOINT_PERMISSIONS.items(): + if re.search(pattern_key, endpoint_pattern): + permission = perm_value + break + + if permission: + changes_made += 1 + replacement = full_match.replace( + 'current_user: User = Depends(get_current_admin_user)', + f'current_user: User = Depends(require_permission("{permission}"))' + ) + print(f'✓ Updated {endpoint_pattern} → {permission}') + return replacement + else: + print(f'⚠ No permission mapping for: {endpoint_pattern}') + return full_match + + # Perform replacements + new_content = re.sub(pattern, replace_permission, content, flags=re.DOTALL) + + if changes_made > 0: + with open('server.py', 'w') as f: + f.write(new_content) + print(f'\n✅ Updated {changes_made} endpoints with permission checks') + else: + print('\n⚠ No changes made') + + return changes_made + +if __name__ == '__main__': + print('🔧 Updating admin endpoints with permission checks...\n') + changes = update_server_file() + print(f'\nDone! Updated {changes} endpoints.') diff --git a/verify_admin_account.py b/verify_admin_account.py new file mode 100644 index 0000000..2c02fe3 --- /dev/null +++ b/verify_admin_account.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +Script to verify admin@loaf.org account configuration after RBAC migration +""" +import sys +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from dotenv import load_dotenv + +# Add parent directory to path to import models +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from models import User, Role, Permission, RolePermission +from database import DATABASE_URL + +# Load environment variables +load_dotenv() + +# Create database engine and session +engine = create_engine(DATABASE_URL) +Session = sessionmaker(bind=engine) +db = Session() + +def verify_admin_account(): + print("=" * 80) + print("VERIFYING admin@loaf.org ACCOUNT") + print("=" * 80) + + # Find the user + user = db.query(User).filter(User.email == "admin@loaf.org").first() + + if not user: + print("\n❌ ERROR: User 'admin@loaf.org' not found in database!") + return + + print(f"\n✅ User found: {user.first_name} {user.last_name}") + print(f" Email: {user.email}") + print(f" Status: {user.status}") + print(f" Email Verified: {user.email_verified}") + + # Check legacy role enum + print(f"\n📋 Legacy Role (enum): {user.role.value if user.role else 'None'}") + + # Check new dynamic role + if user.role_id: + role = db.query(Role).filter(Role.id == user.role_id).first() + if role: + print(f"✅ Dynamic Role: {role.name} (code: {role.code})") + print(f" Role ID: {role.id}") + print(f" Is System Role: {role.is_system_role}") + else: + print(f"❌ ERROR: role_id set to {user.role_id} but role not found!") + else: + print("⚠️ WARNING: No dynamic role_id set") + + # Check permissions + print("\n🔐 Checking Permissions:") + + # Get all permissions for this role + if user.role_id: + role_perms = db.query(RolePermission).filter( + RolePermission.role_id == user.role_id + ).all() + + print(f" Total permissions assigned to role: {len(role_perms)}") + + if len(role_perms) > 0: + print("\n Sample permissions:") + for rp in role_perms[:10]: # Show first 10 + perm = db.query(Permission).filter(Permission.id == rp.permission_id).first() + if perm: + print(f" - {perm.code}: {perm.name}") + if len(role_perms) > 10: + print(f" ... and {len(role_perms) - 10} more") + else: + print(" ⚠️ WARNING: No permissions assigned to this role!") + else: + # Check legacy role permissions + from auth import UserRole + role_enum = user.role + legacy_perms = db.query(RolePermission).filter( + RolePermission.role == role_enum + ).all() + print(f" Legacy permissions (via enum): {len(legacy_perms)}") + + # Check if user should have access + print("\n🎯 Access Check:") + if user.role and user.role.value in ['admin', 'superadmin']: + print(" ✅ User should have admin access (based on legacy enum)") + else: + print(" ❌ User does NOT have admin access (based on legacy enum)") + + if user.role_id: + role = db.query(Role).filter(Role.id == user.role_id).first() + if role and role.code in ['admin', 'superadmin']: + print(" ✅ User should have admin access (based on dynamic role)") + else: + print(" ❌ User does NOT have admin access (based on dynamic role)") + + print("\n" + "=" * 80) + print("VERIFICATION COMPLETE") + print("=" * 80) + +if __name__ == "__main__": + try: + verify_admin_account() + except Exception as e: + print(f"\n❌ ERROR: {e}") + import traceback + traceback.print_exc() + finally: + db.close()