RBAC, Permissions, and Export/Import

This commit is contained in:
Koncept Kit
2025-12-16 20:03:50 +07:00
parent b268c3fff8
commit ed5526e27b
27 changed files with 10284 additions and 73 deletions

287
DEPLOYMENT_GUIDE.md Normal file
View File

@@ -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 <username> -d <database_name> -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: <uuid>
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 <token>" \
http://your-server/api/admin/roles
# Get all permissions
curl -H "Authorization: Bearer <token>" \
http://your-server/api/admin/permissions
# Test export (the issue we just fixed)
curl -H "Authorization: Bearer <token>" \
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 <username>;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO <username>;
```
---
## 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

131
auth.py
View File

@@ -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

71
deploy_rbac.sh Executable file
View File

@@ -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 ""

439
docs/status_definitions.md Normal file
View File

@@ -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)

View File

@@ -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"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: 'Nunito Sans', Arial, sans-serif; line-height: 1.6; color: #422268; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #644c9f 0%, #48286e 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
.header h1 {{ color: white; margin: 0; font-family: 'Inter', sans-serif; }}
.content {{ background: #FFFFFF; padding: 30px; border-radius: 0 0 10px 10px; }}
.button {{ display: inline-block; background: #ff9e77; color: #FFFFFF; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
.button:hover {{ background: #e88d66; }}
.info-box {{ background: #f1eef9; padding: 20px; border-radius: 8px; margin: 20px 0; border: 2px solid #ddd8eb; }}
.note {{ background: #FFEBEE; border-left: 4px solid #ff9e77; padding: 15px; margin: 20px 0; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎉 You're Invited!</h1>
</div>
<div class="content">
<p><strong>{inviter_name}</strong> has invited you to join the LOAF community with <strong>{role_description}</strong>.</p>
<div class="info-box">
<p style="margin: 0;"><strong>Your Role:</strong> {role.capitalize()}</p>
<p style="margin: 10px 0 0 0;"><strong>Invited By:</strong> {inviter_name}</p>
</div>
<p>Click the button below to accept your invitation and create your account:</p>
<p style="text-align: center;">
<a href="{invitation_url}" class="button">Accept Invitation</a>
</p>
<div class="note">
<p style="margin: 0; font-size: 14px;"><strong>⏰ This invitation expires in 7 days.</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">If you didn't expect this invitation, you can safely ignore this email.</p>
</div>
<p style="margin-top: 20px; color: #664fa3; font-size: 14px;">
Or copy and paste this link into your browser:<br>
<span style="word-break: break-all;">{invitation_url}</span>
</p>
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd8eb; color: #664fa3; font-size: 14px;">
Questions? Contact us at support@loaf.org
</p>
</div>
</div>
</body>
</html>
"""
return await send_email(to_email, subject, html_content)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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;

View File

@@ -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.

View File

@@ -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;

View File

@@ -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;

View File

@@ -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.

View File

@@ -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;

View File

@@ -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.
```

159
models.py
View File

@@ -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])

549
permissions_seed.py Normal file
View File

@@ -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()

487
reminder_emails.py Normal file
View File

@@ -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"""
<h2>Final Reminder: Complete Your LOAF Registration</h2>
<p>Hi {user.first_name},</p>
<p>This is your final reminder to verify your email address and complete your LOAF membership registration.</p>
<p>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.</p>
<p>Click the link below to verify your email:</p>
<p><a href="{email_service.get_verification_link(user)}">Verify Email Address</a></p>
<p>Need help? Reply to this email or contact us at info@loaftx.org</p>
<p>Best regards,<br>LOAF Team</p>
"""
else:
message = f"""
<h2>Reminder: Verify Your Email Address</h2>
<p>Hi {user.first_name},</p>
<p>You registered for LOAF membership {days_elapsed} days ago but haven't verified your email yet.</p>
<p>Click the link below to verify your email and continue your membership journey:</p>
<p><a href="{email_service.get_verification_link(user)}">Verify Email Address</a></p>
<p>Once verified, you'll receive our monthly newsletter with event announcements!</p>
<p>Best regards,<br>LOAF Team</p>
"""
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"""
<h2>Final Reminder: Only {days_remaining} Days to Attend an Event!</h2>
<p>Hi {user.first_name},</p>
<p><strong>Important:</strong> You have only {days_remaining} days left to attend a LOAF event
and complete your membership application.</p>
<p>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.</p>
<p>Check out our upcoming events in the monthly newsletter or visit our events page!</p>
<p>Need help finding an event? Reply to this email or contact us at info@loaftx.org</p>
<p>We'd love to meet you soon!</p>
<p>Best regards,<br>LOAF Team</p>
"""
elif days_elapsed >= 80:
# 10 days left
message = f"""
<h2>Reminder: {days_remaining} Days to Attend a LOAF Event</h2>
<p>Hi {user.first_name},</p>
<p>Just a friendly reminder that you have {days_remaining} days left to attend a LOAF event
and complete your membership application.</p>
<p>Per LOAF policy, new applicants must attend an event within 90 days of email verification
to continue the membership process.</p>
<p>Check your newsletter for upcoming events, and we look forward to meeting you soon!</p>
<p>Best regards,<br>LOAF Team</p>
"""
elif days_elapsed >= 60:
# 30 days left
message = f"""
<h2>Reminder: {days_remaining} Days to Attend a LOAF Event</h2>
<p>Hi {user.first_name},</p>
<p>You have {days_remaining} days remaining to attend a LOAF event as part of your membership application.</p>
<p>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!</p>
<p>We look forward to seeing you soon!</p>
<p>Best regards,<br>LOAF Team</p>
"""
else:
# 60 days left
message = f"""
<h2>Reminder: Attend a LOAF Event (60 Days Remaining)</h2>
<p>Hi {user.first_name},</p>
<p>Welcome to LOAF! As part of your membership application, you have 90 days to attend one of our events.</p>
<p>You have {days_remaining} days remaining to attend an event and continue your membership journey.</p>
<p>Check out the events listed in your monthly newsletter. We can't wait to meet you!</p>
<p>Best regards,<br>LOAF Team</p>
"""
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"""
<h2>Final Payment Reminder</h2>
<p>Hi {user.first_name},</p>
<p>Congratulations again on being validated for LOAF membership!</p>
<p>This is a final reminder to complete your membership payment. It's been {days_elapsed} days
since your application was validated.</p>
<p>Your payment link is still active. Click below to complete your payment and activate your membership:</p>
<p><a href="{email_service.get_payment_link(user)}">Complete Payment</a></p>
<p>Once payment is complete, you'll gain full access to all member benefits!</p>
<p>Questions? Contact us at info@loaftx.org</p>
<p>Best regards,<br>LOAF Team</p>
"""
elif days_elapsed >= 45:
message = f"""
<h2>Payment Reminder - Complete Your Membership</h2>
<p>Hi {user.first_name},</p>
<p>Your LOAF membership application was validated and is ready for payment!</p>
<p>Complete your payment to activate your membership and gain access to all member benefits:</p>
<p><a href="{email_service.get_payment_link(user)}">Complete Payment</a></p>
<p>We're excited to welcome you as a full member!</p>
<p>Best regards,<br>LOAF Team</p>
"""
else:
message = f"""
<h2>Payment Reminder</h2>
<p>Hi {user.first_name},</p>
<p>This is a friendly reminder to complete your LOAF membership payment.</p>
<p>Your application was validated {days_elapsed} days ago. Click below to complete payment:</p>
<p><a href="{email_service.get_payment_link(user)}">Complete Payment</a></p>
<p>Questions about payment options? Contact us at info@loaftx.org</p>
<p>Best regards,<br>LOAF Team</p>
"""
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"""
<h2>Final Reminder: Renew Your LOAF Membership</h2>
<p>Hi {user.first_name},</p>
<p><strong>Your LOAF membership expires in {days_until_expiration} days!</strong></p>
<p>Don't lose access to member benefits. Renew now to continue enjoying:</p>
<ul>
<li>Exclusive member events</li>
<li>Member directory access</li>
<li>Monthly newsletter</li>
<li>Community connection</li>
</ul>
<p><a href="{email_service.get_renewal_link(user)}">Renew Your Membership Now</a></p>
<p>Questions? Contact us at info@loaftx.org</p>
<p>Best regards,<br>LOAF Team</p>
"""
else:
message = f"""
<h2>Reminder: Renew Your LOAF Membership</h2>
<p>Hi {user.first_name},</p>
<p>Your LOAF membership will expire in {days_until_expiration} days.</p>
<p>Renew now to continue enjoying all member benefits without interruption:</p>
<p><a href="{email_service.get_renewal_link(user)}">Renew Your Membership</a></p>
<p>Thank you for being part of the LOAF community!</p>
<p>Best regards,<br>LOAF Team</p>
"""
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"""
<h2>We Miss You at LOAF!</h2>
<p>Hi {user.first_name},</p>
<p>Your LOAF membership expired {days_since_expiration} days ago, and we'd love to have you back!</p>
<p>Rejoin the community and reconnect with friends:</p>
<p><a href="{email_service.get_renewal_link(user)}">Renew Your Membership</a></p>
<p>Questions? We're here to help: info@loaftx.org</p>
<p>Best regards,<br>LOAF Team</p>
"""
elif days_since_expiration >= 30:
message = f"""
<h2>Renew Your LOAF Membership</h2>
<p>Hi {user.first_name},</p>
<p>Your LOAF membership expired {days_since_expiration} days ago.</p>
<p>We'd love to have you back! Renew today to regain access to:</p>
<ul>
<li>Member events and gatherings</li>
<li>Member directory</li>
<li>Community connection</li>
</ul>
<p><a href="{email_service.get_renewal_link(user)}">Renew Your Membership</a></p>
<p>Best regards,<br>LOAF Team</p>
"""
else:
# 7 days after expiration
message = f"""
<h2>Your LOAF Membership Has Expired</h2>
<p>Hi {user.first_name},</p>
<p>Your LOAF membership expired recently. We hope it was just an oversight!</p>
<p>Renew now to restore your access to all member benefits:</p>
<p><a href="{email_service.get_renewal_link(user)}">Renew Your Membership</a></p>
<p>We look forward to seeing you at upcoming events!</p>
<p>Best regards,<br>LOAF Team</p>
"""
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

147
roles_seed.py Normal file
View File

@@ -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()

1596
server.py

File diff suppressed because it is too large Load Diff

4652
server.py.bak Normal file

File diff suppressed because it is too large Load Diff

624
status_transitions.py Normal file
View File

@@ -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

115
update_permissions.py Normal file
View File

@@ -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.')

113
verify_admin_account.py Normal file
View File

@@ -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()