RBAC, Permissions, and Export/Import
This commit is contained in:
287
DEPLOYMENT_GUIDE.md
Normal file
287
DEPLOYMENT_GUIDE.md
Normal 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.
Binary file not shown.
131
auth.py
131
auth.py
@@ -1,5 +1,5 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
||||
import os
|
||||
import secrets
|
||||
from database import get_db
|
||||
from models import User, UserRole
|
||||
from models import User, UserRole, Permission, RolePermission, Role
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
security = HTTPBearer()
|
||||
@@ -50,6 +50,24 @@ def verify_reset_token(token, db):
|
||||
|
||||
return user
|
||||
|
||||
def get_user_role_code(user: User) -> str:
|
||||
"""
|
||||
Get user's role code from either dynamic role system or legacy enum.
|
||||
Supports backward compatibility during migration (Phase 3).
|
||||
|
||||
Args:
|
||||
user: User object
|
||||
|
||||
Returns:
|
||||
Role code string (e.g., "superadmin", "admin", "member", "guest")
|
||||
"""
|
||||
# Prefer dynamic role if set (Phase 3+)
|
||||
if user.role_id is not None and user.role_obj is not None:
|
||||
return user.role_obj.code
|
||||
|
||||
# Fallback to legacy enum (Phase 1-2)
|
||||
return user.role.value
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
@@ -100,7 +118,9 @@ async def get_current_user(
|
||||
return user
|
||||
|
||||
async def get_current_admin_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
if current_user.role != UserRole.admin:
|
||||
"""Require user to be admin or superadmin"""
|
||||
role_code = get_user_role_code(current_user)
|
||||
if role_code not in ["admin", "superadmin"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
@@ -117,10 +137,113 @@ async def get_active_member(current_user: User = Depends(get_current_user)) -> U
|
||||
detail="Active membership required. Please complete payment."
|
||||
)
|
||||
|
||||
if current_user.role not in [UserRole.member, UserRole.admin]:
|
||||
role_code = get_user_role_code(current_user)
|
||||
if role_code not in ["member", "admin", "superadmin"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Member access only"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
|
||||
# ============================================================
|
||||
# RBAC Permission System
|
||||
# ============================================================
|
||||
|
||||
async def get_user_permissions(user: User, db: Session) -> List[str]:
|
||||
"""
|
||||
Get all permission codes for user's role.
|
||||
Superadmin automatically gets all permissions.
|
||||
Uses request-level caching to avoid repeated DB queries.
|
||||
Supports both dynamic roles (role_id) and legacy enum (role).
|
||||
|
||||
Args:
|
||||
user: Current authenticated user
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
List of permission code strings (e.g., ["users.view", "events.create"])
|
||||
"""
|
||||
# Check if permissions are already cached for this request
|
||||
if hasattr(user, '_permission_cache'):
|
||||
return user._permission_cache
|
||||
|
||||
# Get role code using helper
|
||||
role_code = get_user_role_code(user)
|
||||
|
||||
# Superadmin gets all permissions automatically
|
||||
if role_code == "superadmin":
|
||||
all_perms = db.query(Permission.code).all()
|
||||
permissions = [p[0] for p in all_perms]
|
||||
else:
|
||||
# Fetch permissions assigned to this role
|
||||
# Prefer dynamic role_id, fallback to enum
|
||||
if user.role_id is not None:
|
||||
# Use role_id for dynamic roles
|
||||
permissions = db.query(Permission.code)\
|
||||
.join(RolePermission)\
|
||||
.filter(RolePermission.role_id == user.role_id)\
|
||||
.all()
|
||||
else:
|
||||
# Fallback to legacy enum
|
||||
permissions = db.query(Permission.code)\
|
||||
.join(RolePermission)\
|
||||
.filter(RolePermission.role == user.role)\
|
||||
.all()
|
||||
permissions = [p[0] for p in permissions]
|
||||
|
||||
# Cache permissions on user object for this request
|
||||
user._permission_cache = permissions
|
||||
return permissions
|
||||
|
||||
|
||||
def require_permission(permission_code: str):
|
||||
"""
|
||||
Dependency injection for permission-based access control.
|
||||
|
||||
Usage:
|
||||
@app.get("/admin/users", dependencies=[Depends(require_permission("users.view"))])
|
||||
async def get_users():
|
||||
...
|
||||
|
||||
Args:
|
||||
permission_code: Permission code to check (e.g., "users.create")
|
||||
|
||||
Returns:
|
||||
Async function that checks if current user has the permission
|
||||
|
||||
Raises:
|
||||
HTTPException 403 if user lacks the required permission
|
||||
"""
|
||||
async def permission_checker(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
# Get user's permissions
|
||||
user_perms = await get_user_permissions(current_user, db)
|
||||
|
||||
# Check if user has the required permission
|
||||
if permission_code not in user_perms:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Permission required: {permission_code}"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
return permission_checker
|
||||
|
||||
|
||||
async def get_current_superadmin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""
|
||||
Require user to be superadmin.
|
||||
Used for endpoints that should only be accessible to superadmins.
|
||||
"""
|
||||
role_code = get_user_role_code(current_user)
|
||||
if role_code != "superadmin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Superadmin access required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
71
deploy_rbac.sh
Executable file
71
deploy_rbac.sh
Executable 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
439
docs/status_definitions.md
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
145
migrate_role_permissions_to_dynamic_roles.py
Normal file
145
migrate_role_permissions_to_dynamic_roles.py
Normal 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()
|
||||
141
migrate_users_to_dynamic_roles.py
Normal file
141
migrate_users_to_dynamic_roles.py
Normal 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()
|
||||
20
migrations/001_add_member_since_field.sql
Normal file
20
migrations/001_add_member_since_field.sql
Normal 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;
|
||||
59
migrations/002_rename_approval_to_validation.sql
Normal file
59
migrations/002_rename_approval_to_validation.sql
Normal 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.
|
||||
23
migrations/003_add_tos_acceptance.sql
Normal file
23
migrations/003_add_tos_acceptance.sql
Normal 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;
|
||||
39
migrations/004_add_reminder_tracking_fields.sql
Normal file
39
migrations/004_add_reminder_tracking_fields.sql
Normal 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;
|
||||
187
migrations/005_add_rbac_and_invitations.sql
Normal file
187
migrations/005_add_rbac_and_invitations.sql
Normal 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.
|
||||
91
migrations/006_add_dynamic_roles.sql
Normal file
91
migrations/006_add_dynamic_roles.sql
Normal 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;
|
||||
@@ -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
159
models.py
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum, Text, Integer, BigInteger, ForeignKey, JSON
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum, Text, Integer, BigInteger, ForeignKey, JSON, Index
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
@@ -8,16 +8,20 @@ from database import Base
|
||||
|
||||
class UserStatus(enum.Enum):
|
||||
pending_email = "pending_email"
|
||||
pending_approval = "pending_approval"
|
||||
pre_approved = "pre_approved"
|
||||
pending_validation = "pending_validation"
|
||||
pre_validated = "pre_validated"
|
||||
payment_pending = "payment_pending"
|
||||
active = "active"
|
||||
inactive = "inactive"
|
||||
canceled = "canceled" # User or admin canceled membership
|
||||
expired = "expired" # Subscription ended without renewal
|
||||
abandoned = "abandoned" # Incomplete registration (no verification/event/payment)
|
||||
|
||||
class UserRole(enum.Enum):
|
||||
guest = "guest"
|
||||
member = "member"
|
||||
admin = "admin"
|
||||
superadmin = "superadmin"
|
||||
|
||||
class RSVPStatus(enum.Enum):
|
||||
yes = "yes"
|
||||
@@ -50,7 +54,8 @@ class User(Base):
|
||||
partner_plan_to_become_member = Column(Boolean, default=False)
|
||||
referred_by_member_name = Column(String, nullable=True)
|
||||
status = Column(SQLEnum(UserStatus), default=UserStatus.pending_email, nullable=False)
|
||||
role = Column(SQLEnum(UserRole), default=UserRole.guest, nullable=False)
|
||||
role = Column(SQLEnum(UserRole), default=UserRole.guest, nullable=False) # Legacy enum, kept for backward compatibility
|
||||
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=True) # New dynamic role FK
|
||||
email_verified = Column(Boolean, default=False)
|
||||
email_verification_token = Column(String, nullable=True)
|
||||
newsletter_subscribed = Column(Boolean, default=False)
|
||||
@@ -89,10 +94,31 @@ class User(Base):
|
||||
social_media_twitter = Column(String, nullable=True)
|
||||
social_media_linkedin = Column(String, nullable=True)
|
||||
|
||||
# Terms of Service Acceptance (Step 4)
|
||||
accepts_tos = Column(Boolean, default=False, nullable=False)
|
||||
tos_accepted_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Member Since Date - Editable by admins for imported users
|
||||
member_since = Column(DateTime, nullable=True, comment="Date when user became a member - editable by admins for imported users")
|
||||
|
||||
# Reminder Tracking - for admin dashboard visibility
|
||||
email_verification_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of email verification reminders sent")
|
||||
last_email_verification_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last verification reminder")
|
||||
|
||||
event_attendance_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of event attendance reminders sent")
|
||||
last_event_attendance_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last event attendance reminder")
|
||||
|
||||
payment_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of payment reminders sent")
|
||||
last_payment_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last payment reminder")
|
||||
|
||||
renewal_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of renewal reminders sent")
|
||||
last_renewal_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last renewal reminder")
|
||||
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Relationships
|
||||
role_obj = relationship("Role", back_populates="users", foreign_keys=[role_id])
|
||||
events_created = relationship("Event", back_populates="creator")
|
||||
rsvps = relationship("EventRSVP", back_populates="user")
|
||||
subscriptions = relationship("Subscription", back_populates="user", foreign_keys="Subscription.user_id")
|
||||
@@ -271,3 +297,128 @@ class StorageUsage(Base):
|
||||
total_bytes_used = Column(BigInteger, default=0)
|
||||
max_bytes_allowed = Column(BigInteger, nullable=False) # From .env
|
||||
last_updated = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# ============================================================
|
||||
# RBAC Permission Management Models
|
||||
# ============================================================
|
||||
|
||||
class Permission(Base):
|
||||
"""Granular permissions for role-based access control"""
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
code = Column(String, unique=True, nullable=False, index=True) # "users.create", "events.edit"
|
||||
name = Column(String, nullable=False) # "Create Users", "Edit Events"
|
||||
description = Column(Text, nullable=True)
|
||||
module = Column(String, nullable=False, index=True) # "users", "events", "subscriptions"
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Relationships
|
||||
role_permissions = relationship("RolePermission", back_populates="permission", cascade="all, delete-orphan")
|
||||
|
||||
class Role(Base):
|
||||
"""Dynamic roles that can be created by admins"""
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
code = Column(String, unique=True, nullable=False, index=True) # "superadmin", "finance", "custom_role_1"
|
||||
name = Column(String, nullable=False) # "Superadmin", "Finance Manager", "Custom Role"
|
||||
description = Column(Text, nullable=True)
|
||||
is_system_role = Column(Boolean, default=False, nullable=False) # True for Superadmin, Member, Guest (non-deletable)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
users = relationship("User", back_populates="role_obj", foreign_keys="User.role_id")
|
||||
role_permissions = relationship("RolePermission", back_populates="role_obj", cascade="all, delete-orphan")
|
||||
creator = relationship("User", foreign_keys=[created_by])
|
||||
|
||||
class RolePermission(Base):
|
||||
"""Junction table linking roles to permissions"""
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
role = Column(SQLEnum(UserRole), nullable=False, index=True) # Legacy enum, kept for backward compatibility
|
||||
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=True, index=True) # New dynamic role FK
|
||||
permission_id = Column(UUID(as_uuid=True), ForeignKey("permissions.id"), nullable=False)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
role_obj = relationship("Role", back_populates="role_permissions")
|
||||
permission = relationship("Permission", back_populates="role_permissions")
|
||||
creator = relationship("User", foreign_keys=[created_by])
|
||||
|
||||
# Composite unique index
|
||||
__table_args__ = (
|
||||
Index('idx_role_permission', 'role', 'permission_id', unique=True),
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# User Invitation Models
|
||||
# ============================================================
|
||||
|
||||
class InvitationStatus(enum.Enum):
|
||||
pending = "pending"
|
||||
accepted = "accepted"
|
||||
expired = "expired"
|
||||
revoked = "revoked"
|
||||
|
||||
class UserInvitation(Base):
|
||||
"""Email-based user invitations with tokens"""
|
||||
__tablename__ = "user_invitations"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email = Column(String, nullable=False, index=True)
|
||||
token = Column(String, unique=True, nullable=False, index=True)
|
||||
role = Column(SQLEnum(UserRole), nullable=False)
|
||||
status = Column(SQLEnum(InvitationStatus), default=InvitationStatus.pending, nullable=False)
|
||||
|
||||
# Optional pre-filled information
|
||||
first_name = Column(String, nullable=True)
|
||||
last_name = Column(String, nullable=True)
|
||||
phone = Column(String, nullable=True)
|
||||
|
||||
# Invitation tracking
|
||||
invited_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
invited_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
accepted_at = Column(DateTime, nullable=True)
|
||||
accepted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
inviter = relationship("User", foreign_keys=[invited_by])
|
||||
accepted_user = relationship("User", foreign_keys=[accepted_by])
|
||||
|
||||
# ============================================================
|
||||
# CSV Import/Export Models
|
||||
# ============================================================
|
||||
|
||||
class ImportJobStatus(enum.Enum):
|
||||
processing = "processing"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
partial = "partial"
|
||||
|
||||
class ImportJob(Base):
|
||||
"""Track CSV import jobs with error handling"""
|
||||
__tablename__ = "import_jobs"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
filename = Column(String, nullable=False)
|
||||
file_key = Column(String, nullable=True) # R2 object key for uploaded CSV
|
||||
total_rows = Column(Integer, nullable=False)
|
||||
processed_rows = Column(Integer, default=0, nullable=False)
|
||||
successful_rows = Column(Integer, default=0, nullable=False)
|
||||
failed_rows = Column(Integer, default=0, nullable=False)
|
||||
status = Column(SQLEnum(ImportJobStatus), default=ImportJobStatus.processing, nullable=False)
|
||||
errors = Column(JSON, default=list, nullable=False) # [{row: 5, field: "email", error: "Invalid format"}]
|
||||
|
||||
# Tracking
|
||||
imported_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
importer = relationship("User", foreign_keys=[imported_by])
|
||||
|
||||
549
permissions_seed.py
Normal file
549
permissions_seed.py
Normal 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
487
reminder_emails.py
Normal 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
147
roles_seed.py
Normal 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()
|
||||
4652
server.py.bak
Normal file
4652
server.py.bak
Normal file
File diff suppressed because it is too large
Load Diff
624
status_transitions.py
Normal file
624
status_transitions.py
Normal 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
115
update_permissions.py
Normal 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
113
verify_admin_account.py
Normal 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()
|
||||
Reference in New Issue
Block a user