forked from andika/membership-be
1. Database Migration (backend/alembic/versions/014_add_custom_registration_data.py)- Adds custom_registration_data JSON column to users table for storing dynamic field responses2. User Model (backend/models.py)- Added custom_registration_data = Column(JSON, default=dict, nullable=False) to User model3. New API Endpoints (backend/server.py)- GET /api/registration/schema - Public endpoint returning form schema- GET /api/admin/registration/schema - Admin view with metadata- PUT /api/admin/registration/schema - Update schema- POST /api/admin/registration/schema/validate - Validate schema structure- POST /api/admin/registration/schema/reset - Reset to default- GET /api/admin/registration/field-types - Get available field types4. Validation Functions- validate_dynamic_registration() - Validates form data against schema- split_registration_data() - Splits data between User columns and custom_registration_data- evaluate_conditional_rules() - Evaluates show/hide rules5. Permissions (backend/seed_permissions_rbac.py)- Added registration.view and registration.manage permissions
This commit is contained in:
Binary file not shown.
Binary file not shown.
141
add_registration_permissions.py
Normal file
141
add_registration_permissions.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Add Registration Permissions Script
|
||||||
|
|
||||||
|
This script adds the new registration.view and registration.manage permissions
|
||||||
|
without clearing existing permissions.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python add_registration_permissions.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from database import Base
|
||||||
|
from models import Permission, 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)
|
||||||
|
|
||||||
|
# New registration permissions
|
||||||
|
NEW_PERMISSIONS = [
|
||||||
|
{"code": "registration.view", "name": "View Registration Settings", "description": "View registration form schema and settings", "module": "registration"},
|
||||||
|
{"code": "registration.manage", "name": "Manage Registration Form", "description": "Edit registration form schema, steps, and fields", "module": "registration"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Roles that should have these permissions
|
||||||
|
ROLE_PERMISSION_MAP = {
|
||||||
|
"registration.view": ["admin", "superadmin"],
|
||||||
|
"registration.manage": ["admin", "superadmin"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def add_registration_permissions():
|
||||||
|
"""Add registration permissions and assign to appropriate roles"""
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("=" * 60)
|
||||||
|
print("Adding Registration Permissions")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Step 1: Add permissions if they don't exist
|
||||||
|
print("\n1. Adding permissions...")
|
||||||
|
permission_map = {}
|
||||||
|
|
||||||
|
for perm_data in NEW_PERMISSIONS:
|
||||||
|
existing = db.query(Permission).filter(Permission.code == perm_data["code"]).first()
|
||||||
|
if existing:
|
||||||
|
print(f" - {perm_data['code']}: Already exists")
|
||||||
|
permission_map[perm_data["code"]] = existing
|
||||||
|
else:
|
||||||
|
permission = Permission(
|
||||||
|
code=perm_data["code"],
|
||||||
|
name=perm_data["name"],
|
||||||
|
description=perm_data["description"],
|
||||||
|
module=perm_data["module"]
|
||||||
|
)
|
||||||
|
db.add(permission)
|
||||||
|
db.flush() # Get the ID
|
||||||
|
permission_map[perm_data["code"]] = permission
|
||||||
|
print(f" - {perm_data['code']}: Created")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Step 2: Get roles
|
||||||
|
print("\n2. Fetching roles...")
|
||||||
|
roles = db.query(Role).all()
|
||||||
|
role_map = {role.code: role for role in roles}
|
||||||
|
print(f" Found {len(roles)} roles: {', '.join(role_map.keys())}")
|
||||||
|
|
||||||
|
# Enum mapping for backward compatibility
|
||||||
|
role_enum_map = {
|
||||||
|
'guest': UserRole.guest,
|
||||||
|
'member': UserRole.member,
|
||||||
|
'admin': UserRole.admin,
|
||||||
|
'superadmin': UserRole.superadmin,
|
||||||
|
'finance': UserRole.finance
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 3: Assign permissions to roles
|
||||||
|
print("\n3. Assigning permissions to roles...")
|
||||||
|
for perm_code, role_codes in ROLE_PERMISSION_MAP.items():
|
||||||
|
permission = permission_map.get(perm_code)
|
||||||
|
if not permission:
|
||||||
|
print(f" Warning: Permission {perm_code} not found")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for role_code in role_codes:
|
||||||
|
role = role_map.get(role_code)
|
||||||
|
if not role:
|
||||||
|
print(f" Warning: Role {role_code} not found")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if mapping already exists
|
||||||
|
existing_mapping = db.query(RolePermission).filter(
|
||||||
|
RolePermission.role_id == role.id,
|
||||||
|
RolePermission.permission_id == permission.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_mapping:
|
||||||
|
print(f" - {role_code} -> {perm_code}: Already assigned")
|
||||||
|
else:
|
||||||
|
role_enum = role_enum_map.get(role_code, UserRole.guest)
|
||||||
|
mapping = RolePermission(
|
||||||
|
role=role_enum,
|
||||||
|
role_id=role.id,
|
||||||
|
permission_id=permission.id
|
||||||
|
)
|
||||||
|
db.add(mapping)
|
||||||
|
print(f" - {role_code} -> {perm_code}: Assigned")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Registration permissions added successfully!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
print(f"\nError: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
add_registration_permissions()
|
||||||
39
alembic/versions/014_add_custom_registration_data.py
Normal file
39
alembic/versions/014_add_custom_registration_data.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""add_custom_registration_data
|
||||||
|
|
||||||
|
Revision ID: 014_custom_registration
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2026-02-01 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '014_custom_registration'
|
||||||
|
down_revision: Union[str, None] = 'a1b2c3d4e5f6'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add custom_registration_data column to users table
|
||||||
|
# This stores dynamic registration field responses as JSON
|
||||||
|
op.add_column('users', sa.Column(
|
||||||
|
'custom_registration_data',
|
||||||
|
sa.JSON,
|
||||||
|
nullable=False,
|
||||||
|
server_default='{}'
|
||||||
|
))
|
||||||
|
|
||||||
|
# Add comment for documentation
|
||||||
|
op.execute("""
|
||||||
|
COMMENT ON COLUMN users.custom_registration_data IS
|
||||||
|
'Dynamic registration field responses stored as JSON for custom form fields';
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('users', 'custom_registration_data')
|
||||||
@@ -151,6 +151,10 @@ class User(Base):
|
|||||||
# Stripe Customer ID - Centralized for payment method management
|
# Stripe Customer ID - Centralized for payment method management
|
||||||
stripe_customer_id = Column(String, nullable=True, index=True, comment="Stripe Customer ID for payment method management")
|
stripe_customer_id = Column(String, nullable=True, index=True, comment="Stripe Customer ID for payment method management")
|
||||||
|
|
||||||
|
# Dynamic Registration Form - Custom field responses
|
||||||
|
custom_registration_data = Column(JSON, default=dict, nullable=False,
|
||||||
|
comment="Dynamic registration field responses stored as JSON for custom form fields")
|
||||||
|
|
||||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
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))
|
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ PERMISSIONS = [
|
|||||||
{"code": "payment_methods.create", "name": "Create Payment Methods", "description": "Add payment methods on behalf of users", "module": "payment_methods"},
|
{"code": "payment_methods.create", "name": "Create Payment Methods", "description": "Add payment methods on behalf of users", "module": "payment_methods"},
|
||||||
{"code": "payment_methods.delete", "name": "Delete Payment Methods", "description": "Remove user payment methods", "module": "payment_methods"},
|
{"code": "payment_methods.delete", "name": "Delete Payment Methods", "description": "Remove user payment methods", "module": "payment_methods"},
|
||||||
{"code": "payment_methods.set_default", "name": "Set Default Payment Method", "description": "Set default payment method for users", "module": "payment_methods"},
|
{"code": "payment_methods.set_default", "name": "Set Default Payment Method", "description": "Set default payment method for users", "module": "payment_methods"},
|
||||||
|
|
||||||
|
# ========== REGISTRATION MODULE (2) ==========
|
||||||
|
{"code": "registration.view", "name": "View Registration Settings", "description": "View registration form schema and settings", "module": "registration"},
|
||||||
|
{"code": "registration.manage", "name": "Manage Registration Form", "description": "Edit registration form schema, steps, and fields", "module": "registration"},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Default system roles that must exist
|
# Default system roles that must exist
|
||||||
@@ -204,6 +208,8 @@ DEFAULT_ROLE_PERMISSIONS = {
|
|||||||
# Payment methods - admin can manage but not view sensitive details
|
# Payment methods - admin can manage but not view sensitive details
|
||||||
"payment_methods.view", "payment_methods.create",
|
"payment_methods.view", "payment_methods.create",
|
||||||
"payment_methods.delete", "payment_methods.set_default",
|
"payment_methods.delete", "payment_methods.set_default",
|
||||||
|
# Registration form management
|
||||||
|
"registration.view", "registration.manage",
|
||||||
],
|
],
|
||||||
|
|
||||||
"superadmin": [
|
"superadmin": [
|
||||||
|
|||||||
807
server.py
807
server.py
@@ -134,16 +134,23 @@ def set_user_role(user: User, role_enum: UserRole, db: Session):
|
|||||||
# Pydantic Models
|
# Pydantic Models
|
||||||
# ============================================================
|
# ============================================================
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
# Step 1: Personal & Partner Information
|
"""Dynamic registration request - validates against registration schema"""
|
||||||
|
|
||||||
|
# Fixed required fields (always present)
|
||||||
first_name: str
|
first_name: str
|
||||||
last_name: str
|
last_name: str
|
||||||
phone: str
|
email: EmailStr
|
||||||
address: str
|
password: str = Field(min_length=6)
|
||||||
city: str
|
accepts_tos: bool = False
|
||||||
state: str
|
|
||||||
zipcode: str
|
# Step 1: Personal & Partner Information (optional for dynamic schema)
|
||||||
date_of_birth: datetime
|
phone: Optional[str] = None
|
||||||
lead_sources: List[str]
|
address: Optional[str] = None
|
||||||
|
city: Optional[str] = None
|
||||||
|
state: Optional[str] = None
|
||||||
|
zipcode: Optional[str] = None
|
||||||
|
date_of_birth: Optional[datetime] = None
|
||||||
|
lead_sources: Optional[List[str]] = None
|
||||||
partner_first_name: Optional[str] = None
|
partner_first_name: Optional[str] = None
|
||||||
partner_last_name: Optional[str] = None
|
partner_last_name: Optional[str] = None
|
||||||
partner_is_member: Optional[bool] = False
|
partner_is_member: Optional[bool] = False
|
||||||
@@ -151,16 +158,16 @@ class RegisterRequest(BaseModel):
|
|||||||
|
|
||||||
# Step 2: Newsletter, Volunteer & Scholarship
|
# Step 2: Newsletter, Volunteer & Scholarship
|
||||||
referred_by_member_name: Optional[str] = None
|
referred_by_member_name: Optional[str] = None
|
||||||
newsletter_publish_name: bool
|
newsletter_publish_name: Optional[bool] = False
|
||||||
newsletter_publish_photo: bool
|
newsletter_publish_photo: Optional[bool] = False
|
||||||
newsletter_publish_birthday: bool
|
newsletter_publish_birthday: Optional[bool] = False
|
||||||
newsletter_publish_none: bool
|
newsletter_publish_none: Optional[bool] = False
|
||||||
volunteer_interests: List[str] = []
|
volunteer_interests: Optional[List[str]] = []
|
||||||
scholarship_requested: bool = False
|
scholarship_requested: Optional[bool] = False
|
||||||
scholarship_reason: Optional[str] = None
|
scholarship_reason: Optional[str] = None
|
||||||
|
|
||||||
# Step 3: Directory Settings
|
# Step 3: Directory Settings
|
||||||
show_in_directory: bool = False
|
show_in_directory: Optional[bool] = False
|
||||||
directory_email: Optional[str] = None
|
directory_email: Optional[str] = None
|
||||||
directory_bio: Optional[str] = None
|
directory_bio: Optional[str] = None
|
||||||
directory_address: Optional[str] = None
|
directory_address: Optional[str] = None
|
||||||
@@ -168,10 +175,9 @@ class RegisterRequest(BaseModel):
|
|||||||
directory_dob: Optional[datetime] = None
|
directory_dob: Optional[datetime] = None
|
||||||
directory_partner_name: Optional[str] = None
|
directory_partner_name: Optional[str] = None
|
||||||
|
|
||||||
# Step 4: Account Credentials
|
# Allow extra fields for custom registration data
|
||||||
email: EmailStr
|
class Config:
|
||||||
password: str = Field(min_length=6)
|
extra = 'allow'
|
||||||
accepts_tos: bool = False
|
|
||||||
|
|
||||||
@validator('accepts_tos')
|
@validator('accepts_tos')
|
||||||
def tos_must_be_accepted(cls, v):
|
def tos_must_be_accepted(cls, v):
|
||||||
@@ -179,25 +185,6 @@ class RegisterRequest(BaseModel):
|
|||||||
raise ValueError('You must accept the Terms of Service to register')
|
raise ValueError('You must accept the Terms of Service to register')
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@validator('newsletter_publish_none')
|
|
||||||
def validate_newsletter_preferences(cls, v, values):
|
|
||||||
"""At least one newsletter preference must be selected"""
|
|
||||||
name = values.get('newsletter_publish_name', False)
|
|
||||||
photo = values.get('newsletter_publish_photo', False)
|
|
||||||
birthday = values.get('newsletter_publish_birthday', False)
|
|
||||||
|
|
||||||
if not (name or photo or birthday or v):
|
|
||||||
raise ValueError('At least one newsletter publication preference must be selected')
|
|
||||||
return v
|
|
||||||
|
|
||||||
@validator('scholarship_reason')
|
|
||||||
def validate_scholarship_reason(cls, v, values):
|
|
||||||
"""If scholarship requested, reason must be provided"""
|
|
||||||
requested = values.get('scholarship_requested', False)
|
|
||||||
if requested and not v:
|
|
||||||
raise ValueError('Scholarship reason is required when requesting scholarship')
|
|
||||||
return v
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str
|
||||||
@@ -560,11 +547,28 @@ async def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
|||||||
existing_user = db.query(User).filter(User.email == request.email).first()
|
existing_user = db.query(User).filter(User.email == request.email).first()
|
||||||
if existing_user:
|
if existing_user:
|
||||||
raise HTTPException(status_code=400, detail="Email already registered")
|
raise HTTPException(status_code=400, detail="Email already registered")
|
||||||
|
|
||||||
|
# Get registration schema for dynamic validation
|
||||||
|
schema = get_registration_schema(db)
|
||||||
|
|
||||||
|
# Convert request to dict for dynamic validation
|
||||||
|
request_data = request.dict(exclude_unset=False)
|
||||||
|
|
||||||
|
# Perform dynamic schema validation
|
||||||
|
is_valid, validation_errors = validate_dynamic_registration(request_data, schema)
|
||||||
|
if not is_valid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"message": "Validation failed", "errors": validation_errors}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Split data into User model fields and custom fields
|
||||||
|
user_data, custom_data = split_registration_data(request_data, schema)
|
||||||
|
|
||||||
# Generate verification token
|
# Generate verification token
|
||||||
verification_token = secrets.token_urlsafe(32)
|
verification_token = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
# Create user
|
# Create user with known fields
|
||||||
user = User(
|
user = User(
|
||||||
# Account credentials (Step 4)
|
# Account credentials (Step 4)
|
||||||
email=request.email,
|
email=request.email,
|
||||||
@@ -573,65 +577,68 @@ async def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
|||||||
# Personal information (Step 1)
|
# Personal information (Step 1)
|
||||||
first_name=request.first_name,
|
first_name=request.first_name,
|
||||||
last_name=request.last_name,
|
last_name=request.last_name,
|
||||||
phone=request.phone,
|
phone=user_data.get('phone') or request.phone,
|
||||||
address=request.address,
|
address=user_data.get('address') or request.address,
|
||||||
city=request.city,
|
city=user_data.get('city') or request.city,
|
||||||
state=request.state,
|
state=user_data.get('state') or request.state,
|
||||||
zipcode=request.zipcode,
|
zipcode=user_data.get('zipcode') or request.zipcode,
|
||||||
date_of_birth=request.date_of_birth,
|
date_of_birth=user_data.get('date_of_birth') or request.date_of_birth,
|
||||||
lead_sources=request.lead_sources,
|
lead_sources=user_data.get('lead_sources') or request.lead_sources or [],
|
||||||
|
|
||||||
# Partner information (Step 1)
|
# Partner information (Step 1)
|
||||||
partner_first_name=request.partner_first_name,
|
partner_first_name=user_data.get('partner_first_name') or request.partner_first_name,
|
||||||
partner_last_name=request.partner_last_name,
|
partner_last_name=user_data.get('partner_last_name') or request.partner_last_name,
|
||||||
partner_is_member=request.partner_is_member,
|
partner_is_member=user_data.get('partner_is_member', request.partner_is_member) or False,
|
||||||
partner_plan_to_become_member=request.partner_plan_to_become_member,
|
partner_plan_to_become_member=user_data.get('partner_plan_to_become_member', request.partner_plan_to_become_member) or False,
|
||||||
|
|
||||||
# Referral (Step 2)
|
# Referral (Step 2)
|
||||||
referred_by_member_name=request.referred_by_member_name,
|
referred_by_member_name=user_data.get('referred_by_member_name') or request.referred_by_member_name,
|
||||||
|
|
||||||
# Newsletter publication preferences (Step 2)
|
# Newsletter publication preferences (Step 2)
|
||||||
newsletter_publish_name=request.newsletter_publish_name,
|
newsletter_publish_name=user_data.get('newsletter_publish_name', request.newsletter_publish_name) or False,
|
||||||
newsletter_publish_photo=request.newsletter_publish_photo,
|
newsletter_publish_photo=user_data.get('newsletter_publish_photo', request.newsletter_publish_photo) or False,
|
||||||
newsletter_publish_birthday=request.newsletter_publish_birthday,
|
newsletter_publish_birthday=user_data.get('newsletter_publish_birthday', request.newsletter_publish_birthday) or False,
|
||||||
newsletter_publish_none=request.newsletter_publish_none,
|
newsletter_publish_none=user_data.get('newsletter_publish_none', request.newsletter_publish_none) or False,
|
||||||
|
|
||||||
# Volunteer interests (Step 2)
|
# Volunteer interests (Step 2)
|
||||||
volunteer_interests=request.volunteer_interests,
|
volunteer_interests=user_data.get('volunteer_interests') or request.volunteer_interests or [],
|
||||||
|
|
||||||
# Scholarship (Step 2)
|
# Scholarship (Step 2)
|
||||||
scholarship_requested=request.scholarship_requested,
|
scholarship_requested=user_data.get('scholarship_requested', request.scholarship_requested) or False,
|
||||||
scholarship_reason=request.scholarship_reason,
|
scholarship_reason=user_data.get('scholarship_reason') or request.scholarship_reason,
|
||||||
|
|
||||||
# Directory settings (Step 3)
|
# Directory settings (Step 3)
|
||||||
show_in_directory=request.show_in_directory,
|
show_in_directory=user_data.get('show_in_directory', request.show_in_directory) or False,
|
||||||
directory_email=request.directory_email,
|
directory_email=user_data.get('directory_email') or request.directory_email,
|
||||||
directory_bio=request.directory_bio,
|
directory_bio=user_data.get('directory_bio') or request.directory_bio,
|
||||||
directory_address=request.directory_address,
|
directory_address=user_data.get('directory_address') or request.directory_address,
|
||||||
directory_phone=request.directory_phone,
|
directory_phone=user_data.get('directory_phone') or request.directory_phone,
|
||||||
directory_dob=request.directory_dob,
|
directory_dob=user_data.get('directory_dob') or request.directory_dob,
|
||||||
directory_partner_name=request.directory_partner_name,
|
directory_partner_name=user_data.get('directory_partner_name') or request.directory_partner_name,
|
||||||
|
|
||||||
# Terms of Service acceptance (Step 4)
|
# Terms of Service acceptance (Step 4)
|
||||||
accepts_tos=request.accepts_tos,
|
accepts_tos=request.accepts_tos,
|
||||||
tos_accepted_at=datetime.now(timezone.utc) if request.accepts_tos else None,
|
tos_accepted_at=datetime.now(timezone.utc) if request.accepts_tos else None,
|
||||||
|
|
||||||
|
# Custom registration data for dynamic fields
|
||||||
|
custom_registration_data=custom_data if custom_data else {},
|
||||||
|
|
||||||
# Status fields
|
# Status fields
|
||||||
status=UserStatus.pending_email,
|
status=UserStatus.pending_email,
|
||||||
role=UserRole.guest,
|
role=UserRole.guest,
|
||||||
email_verified=False,
|
email_verified=False,
|
||||||
email_verification_token=verification_token
|
email_verification_token=verification_token
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
|
|
||||||
# Send verification email
|
# Send verification email
|
||||||
await send_verification_email(user.email, verification_token)
|
await send_verification_email(user.email, verification_token)
|
||||||
|
|
||||||
logger.info(f"User registered: {user.email}")
|
logger.info(f"User registered: {user.email}")
|
||||||
|
|
||||||
return {"message": "Registration successful. Please check your email to verify your account."}
|
return {"message": "Registration successful. Please check your email to verify your account."}
|
||||||
|
|
||||||
@api_router.get("/auth/verify-email")
|
@api_router.get("/auth/verify-email")
|
||||||
@@ -2062,6 +2069,664 @@ async def get_config_limits():
|
|||||||
"max_storage_bytes": int(os.getenv('MAX_STORAGE_BYTES', 1073741824))
|
"max_storage_bytes": int(os.getenv('MAX_STORAGE_BYTES', 1073741824))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Registration Form Schema Routes
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Default registration schema matching current 4-step form
|
||||||
|
DEFAULT_REGISTRATION_SCHEMA = {
|
||||||
|
"version": "1.0",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "step_personal",
|
||||||
|
"title": "Personal Information",
|
||||||
|
"description": "Please provide your personal details and tell us how you heard about us.",
|
||||||
|
"order": 1,
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"id": "section_personal_info",
|
||||||
|
"title": "Personal Information",
|
||||||
|
"order": 1,
|
||||||
|
"fields": [
|
||||||
|
{"id": "first_name", "type": "text", "label": "First Name", "required": True, "is_fixed": True, "mapping": "first_name", "validation": {"minLength": 1, "maxLength": 100}, "width": "half", "order": 1},
|
||||||
|
{"id": "last_name", "type": "text", "label": "Last Name", "required": True, "is_fixed": True, "mapping": "last_name", "validation": {"minLength": 1, "maxLength": 100}, "width": "half", "order": 2},
|
||||||
|
{"id": "phone", "type": "phone", "label": "Phone", "required": True, "is_fixed": False, "mapping": "phone", "width": "half", "order": 3},
|
||||||
|
{"id": "date_of_birth", "type": "date", "label": "Date of Birth", "required": True, "is_fixed": False, "mapping": "date_of_birth", "width": "half", "order": 4},
|
||||||
|
{"id": "address", "type": "text", "label": "Address", "required": True, "is_fixed": False, "mapping": "address", "width": "full", "order": 5},
|
||||||
|
{"id": "city", "type": "text", "label": "City", "required": True, "is_fixed": False, "mapping": "city", "width": "third", "order": 6},
|
||||||
|
{"id": "state", "type": "text", "label": "State", "required": True, "is_fixed": False, "mapping": "state", "width": "third", "order": 7},
|
||||||
|
{"id": "zipcode", "type": "text", "label": "Zipcode", "required": True, "is_fixed": False, "mapping": "zipcode", "width": "third", "order": 8}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "section_lead_sources",
|
||||||
|
"title": "How Did You Hear About Us?",
|
||||||
|
"order": 2,
|
||||||
|
"fields": [
|
||||||
|
{"id": "lead_sources", "type": "multiselect", "label": "How did you hear about us?", "required": True, "is_fixed": False, "mapping": "lead_sources", "width": "full", "order": 1, "options": [
|
||||||
|
{"value": "Current member", "label": "Current member"},
|
||||||
|
{"value": "Friend", "label": "Friend"},
|
||||||
|
{"value": "OutSmart Magazine", "label": "OutSmart Magazine"},
|
||||||
|
{"value": "Search engine (Google etc.)", "label": "Search engine (Google etc.)"},
|
||||||
|
{"value": "I've known about LOAF for a long time", "label": "I've known about LOAF for a long time"},
|
||||||
|
{"value": "Other", "label": "Other"}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "section_partner",
|
||||||
|
"title": "Partner Information (Optional)",
|
||||||
|
"order": 3,
|
||||||
|
"fields": [
|
||||||
|
{"id": "partner_first_name", "type": "text", "label": "Partner First Name", "required": False, "is_fixed": False, "mapping": "partner_first_name", "width": "half", "order": 1},
|
||||||
|
{"id": "partner_last_name", "type": "text", "label": "Partner Last Name", "required": False, "is_fixed": False, "mapping": "partner_last_name", "width": "half", "order": 2},
|
||||||
|
{"id": "partner_is_member", "type": "checkbox", "label": "Is your partner already a member?", "required": False, "is_fixed": False, "mapping": "partner_is_member", "width": "full", "order": 3},
|
||||||
|
{"id": "partner_plan_to_become_member", "type": "checkbox", "label": "Does your partner plan to become a member?", "required": False, "is_fixed": False, "mapping": "partner_plan_to_become_member", "width": "full", "order": 4}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "step_newsletter",
|
||||||
|
"title": "Newsletter & Volunteer",
|
||||||
|
"description": "Tell us about your newsletter preferences and volunteer interests.",
|
||||||
|
"order": 2,
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"id": "section_referral",
|
||||||
|
"title": "Referral",
|
||||||
|
"order": 1,
|
||||||
|
"fields": [
|
||||||
|
{"id": "referred_by_member_name", "type": "text", "label": "If referred by a current member, please provide their name", "required": False, "is_fixed": False, "mapping": "referred_by_member_name", "width": "full", "order": 1, "placeholder": "Enter member name or email"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "section_newsletter_prefs",
|
||||||
|
"title": "Newsletter Publication Preferences",
|
||||||
|
"description": "Select what you would like published in our newsletter.",
|
||||||
|
"order": 2,
|
||||||
|
"fields": [
|
||||||
|
{"id": "newsletter_publish_name", "type": "checkbox", "label": "Publish my name", "required": False, "is_fixed": False, "mapping": "newsletter_publish_name", "width": "full", "order": 1},
|
||||||
|
{"id": "newsletter_publish_photo", "type": "checkbox", "label": "Publish my photo", "required": False, "is_fixed": False, "mapping": "newsletter_publish_photo", "width": "full", "order": 2},
|
||||||
|
{"id": "newsletter_publish_birthday", "type": "checkbox", "label": "Publish my birthday", "required": False, "is_fixed": False, "mapping": "newsletter_publish_birthday", "width": "full", "order": 3},
|
||||||
|
{"id": "newsletter_publish_none", "type": "checkbox", "label": "Don't publish anything about me", "required": False, "is_fixed": False, "mapping": "newsletter_publish_none", "width": "full", "order": 4}
|
||||||
|
],
|
||||||
|
"validation": {"atLeastOne": True, "message": "Please select at least one newsletter publication preference"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "section_volunteer",
|
||||||
|
"title": "Volunteer Interests",
|
||||||
|
"order": 3,
|
||||||
|
"fields": [
|
||||||
|
{"id": "volunteer_interests", "type": "multiselect", "label": "Select areas where you would like to volunteer", "required": False, "is_fixed": False, "mapping": "volunteer_interests", "width": "full", "order": 1, "options": [
|
||||||
|
{"value": "Events", "label": "Events"},
|
||||||
|
{"value": "Hospitality", "label": "Hospitality"},
|
||||||
|
{"value": "Newsletter", "label": "Newsletter"},
|
||||||
|
{"value": "Board", "label": "Board"},
|
||||||
|
{"value": "Community Outreach", "label": "Community Outreach"},
|
||||||
|
{"value": "Other", "label": "Other"}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "section_scholarship",
|
||||||
|
"title": "Scholarship Request",
|
||||||
|
"order": 4,
|
||||||
|
"fields": [
|
||||||
|
{"id": "scholarship_requested", "type": "checkbox", "label": "I would like to request a scholarship", "required": False, "is_fixed": False, "mapping": "scholarship_requested", "width": "full", "order": 1},
|
||||||
|
{"id": "scholarship_reason", "type": "textarea", "label": "Please explain why you are requesting a scholarship", "required": False, "is_fixed": False, "mapping": "scholarship_reason", "width": "full", "order": 2, "rows": 4}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "step_directory",
|
||||||
|
"title": "Member Directory",
|
||||||
|
"description": "Choose what information to display in the member directory.",
|
||||||
|
"order": 3,
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"id": "section_directory_settings",
|
||||||
|
"title": "Directory Settings",
|
||||||
|
"order": 1,
|
||||||
|
"fields": [
|
||||||
|
{"id": "show_in_directory", "type": "checkbox", "label": "Show my profile in the member directory", "required": False, "is_fixed": False, "mapping": "show_in_directory", "width": "full", "order": 1},
|
||||||
|
{"id": "directory_email", "type": "email", "label": "Directory Email (if different from account email)", "required": False, "is_fixed": False, "mapping": "directory_email", "width": "full", "order": 2},
|
||||||
|
{"id": "directory_bio", "type": "textarea", "label": "Bio for directory", "required": False, "is_fixed": False, "mapping": "directory_bio", "width": "full", "order": 3, "rows": 4},
|
||||||
|
{"id": "directory_address", "type": "text", "label": "Address to display in directory", "required": False, "is_fixed": False, "mapping": "directory_address", "width": "full", "order": 4},
|
||||||
|
{"id": "directory_phone", "type": "phone", "label": "Phone to display in directory", "required": False, "is_fixed": False, "mapping": "directory_phone", "width": "half", "order": 5},
|
||||||
|
{"id": "directory_dob", "type": "date", "label": "Birthday to display in directory", "required": False, "is_fixed": False, "mapping": "directory_dob", "width": "half", "order": 6},
|
||||||
|
{"id": "directory_partner_name", "type": "text", "label": "Partner name to display in directory", "required": False, "is_fixed": False, "mapping": "directory_partner_name", "width": "full", "order": 7}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "step_account",
|
||||||
|
"title": "Account Setup",
|
||||||
|
"description": "Create your account credentials and accept the terms of service.",
|
||||||
|
"order": 4,
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"id": "section_credentials",
|
||||||
|
"title": "Account Credentials",
|
||||||
|
"order": 1,
|
||||||
|
"fields": [
|
||||||
|
{"id": "email", "type": "email", "label": "Email Address", "required": True, "is_fixed": True, "mapping": "email", "width": "full", "order": 1},
|
||||||
|
{"id": "password", "type": "password", "label": "Password", "required": True, "is_fixed": True, "mapping": "password", "validation": {"minLength": 6}, "width": "half", "order": 2},
|
||||||
|
{"id": "confirmPassword", "type": "password", "label": "Confirm Password", "required": True, "is_fixed": True, "client_only": True, "width": "half", "order": 3, "validation": {"matchField": "password"}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "section_tos",
|
||||||
|
"title": "Terms of Service",
|
||||||
|
"order": 2,
|
||||||
|
"fields": [
|
||||||
|
{"id": "accepts_tos", "type": "checkbox", "label": "I accept the Terms of Service and Privacy Policy", "required": True, "is_fixed": True, "mapping": "accepts_tos", "width": "full", "order": 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditional_rules": [
|
||||||
|
{
|
||||||
|
"id": "rule_scholarship_reason",
|
||||||
|
"trigger_field": "scholarship_requested",
|
||||||
|
"trigger_operator": "equals",
|
||||||
|
"trigger_value": True,
|
||||||
|
"action": "show",
|
||||||
|
"target_fields": ["scholarship_reason"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fixed_fields": ["email", "password", "first_name", "last_name", "accepts_tos"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Supported field types with their validation options
|
||||||
|
FIELD_TYPES = {
|
||||||
|
"text": {
|
||||||
|
"name": "Text Input",
|
||||||
|
"validation_options": ["required", "minLength", "maxLength", "pattern"],
|
||||||
|
"properties": ["placeholder", "width"]
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "Email Input",
|
||||||
|
"validation_options": ["required"],
|
||||||
|
"properties": ["placeholder"]
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"name": "Phone Input",
|
||||||
|
"validation_options": ["required"],
|
||||||
|
"properties": ["placeholder"]
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "Date Input",
|
||||||
|
"validation_options": ["required", "min_date", "max_date"],
|
||||||
|
"properties": []
|
||||||
|
},
|
||||||
|
"dropdown": {
|
||||||
|
"name": "Dropdown Select",
|
||||||
|
"validation_options": ["required"],
|
||||||
|
"properties": ["options", "placeholder"]
|
||||||
|
},
|
||||||
|
"checkbox": {
|
||||||
|
"name": "Checkbox",
|
||||||
|
"validation_options": ["required"],
|
||||||
|
"properties": []
|
||||||
|
},
|
||||||
|
"radio": {
|
||||||
|
"name": "Radio Group",
|
||||||
|
"validation_options": ["required"],
|
||||||
|
"properties": ["options"]
|
||||||
|
},
|
||||||
|
"multiselect": {
|
||||||
|
"name": "Multi-Select",
|
||||||
|
"validation_options": ["required", "min_selections", "max_selections"],
|
||||||
|
"properties": ["options"]
|
||||||
|
},
|
||||||
|
"address_group": {
|
||||||
|
"name": "Address Group",
|
||||||
|
"validation_options": ["required"],
|
||||||
|
"properties": []
|
||||||
|
},
|
||||||
|
"textarea": {
|
||||||
|
"name": "Text Area",
|
||||||
|
"validation_options": ["required", "minLength", "maxLength"],
|
||||||
|
"properties": ["rows", "placeholder"]
|
||||||
|
},
|
||||||
|
"file_upload": {
|
||||||
|
"name": "File Upload",
|
||||||
|
"validation_options": ["required", "file_types", "max_size"],
|
||||||
|
"properties": ["allowed_types", "max_size_mb"]
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "Password Input",
|
||||||
|
"validation_options": ["required", "minLength"],
|
||||||
|
"properties": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationSchemaRequest(BaseModel):
|
||||||
|
"""Request model for updating registration schema"""
|
||||||
|
schema_data: dict
|
||||||
|
|
||||||
|
|
||||||
|
def get_registration_schema(db: Session) -> dict:
|
||||||
|
"""Get the current registration form schema from database or return default"""
|
||||||
|
setting = db.query(SystemSettings).filter(
|
||||||
|
SystemSettings.setting_key == "registration.form_schema"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if setting and setting.setting_value:
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
return json.loads(setting.setting_value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error("Failed to parse registration schema from database")
|
||||||
|
return DEFAULT_REGISTRATION_SCHEMA.copy()
|
||||||
|
|
||||||
|
return DEFAULT_REGISTRATION_SCHEMA.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def save_registration_schema(db: Session, schema: dict, user_id: Optional[uuid.UUID] = None) -> None:
|
||||||
|
"""Save registration schema to database"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
setting = db.query(SystemSettings).filter(
|
||||||
|
SystemSettings.setting_key == "registration.form_schema"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
schema_json = json.dumps(schema)
|
||||||
|
|
||||||
|
if setting:
|
||||||
|
setting.setting_value = schema_json
|
||||||
|
setting.updated_by = user_id
|
||||||
|
setting.updated_at = datetime.now(timezone.utc)
|
||||||
|
else:
|
||||||
|
from models import SettingType
|
||||||
|
setting = SystemSettings(
|
||||||
|
setting_key="registration.form_schema",
|
||||||
|
setting_value=schema_json,
|
||||||
|
setting_type=SettingType.json,
|
||||||
|
description="Dynamic registration form schema defining steps, fields, and validation rules",
|
||||||
|
updated_by=user_id,
|
||||||
|
is_sensitive=False
|
||||||
|
)
|
||||||
|
db.add(setting)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_schema(schema: dict) -> tuple[bool, list[str]]:
|
||||||
|
"""Validate registration schema structure"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Check version
|
||||||
|
if "version" not in schema:
|
||||||
|
errors.append("Schema must have a version field")
|
||||||
|
|
||||||
|
# Check steps
|
||||||
|
if "steps" not in schema or not isinstance(schema.get("steps"), list):
|
||||||
|
errors.append("Schema must have a steps array")
|
||||||
|
return False, errors
|
||||||
|
|
||||||
|
if len(schema["steps"]) == 0:
|
||||||
|
errors.append("Schema must have at least one step")
|
||||||
|
|
||||||
|
if len(schema["steps"]) > 10:
|
||||||
|
errors.append("Schema cannot have more than 10 steps")
|
||||||
|
|
||||||
|
# Check fixed fields exist
|
||||||
|
fixed_fields = schema.get("fixed_fields", ["email", "password", "first_name", "last_name", "accepts_tos"])
|
||||||
|
all_field_ids = set()
|
||||||
|
|
||||||
|
for step in schema.get("steps", []):
|
||||||
|
if "id" not in step:
|
||||||
|
errors.append(f"Step missing id")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "sections" not in step or not isinstance(step.get("sections"), list):
|
||||||
|
errors.append(f"Step {step.get('id')} must have sections array")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for section in step.get("sections", []):
|
||||||
|
if "fields" not in section or not isinstance(section.get("fields"), list):
|
||||||
|
errors.append(f"Section {section.get('id')} must have fields array")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for field in section.get("fields", []):
|
||||||
|
if "id" not in field:
|
||||||
|
errors.append(f"Field missing id in section {section.get('id')}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_field_ids.add(field["id"])
|
||||||
|
|
||||||
|
if "type" not in field:
|
||||||
|
errors.append(f"Field {field['id']} missing type")
|
||||||
|
|
||||||
|
if field.get("type") not in FIELD_TYPES:
|
||||||
|
errors.append(f"Field {field['id']} has invalid type: {field.get('type')}")
|
||||||
|
|
||||||
|
# Verify fixed fields are present
|
||||||
|
for fixed_field in fixed_fields:
|
||||||
|
if fixed_field not in all_field_ids:
|
||||||
|
errors.append(f"Fixed field '{fixed_field}' must be present in schema")
|
||||||
|
|
||||||
|
# Field limit check
|
||||||
|
if len(all_field_ids) > 100:
|
||||||
|
errors.append("Schema cannot have more than 100 fields")
|
||||||
|
|
||||||
|
return len(errors) == 0, errors
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_conditional_rules(form_data: dict, rules: list) -> set:
|
||||||
|
"""Evaluate conditional rules and return set of visible field IDs"""
|
||||||
|
visible_fields = set()
|
||||||
|
|
||||||
|
# Start with all fields visible
|
||||||
|
for rule in rules:
|
||||||
|
target_fields = rule.get("target_fields", [])
|
||||||
|
if rule.get("action") == "hide":
|
||||||
|
visible_fields.update(target_fields)
|
||||||
|
|
||||||
|
# Apply rules
|
||||||
|
for rule in rules:
|
||||||
|
trigger_field = rule.get("trigger_field")
|
||||||
|
trigger_value = rule.get("trigger_value")
|
||||||
|
trigger_operator = rule.get("trigger_operator", "equals")
|
||||||
|
action = rule.get("action", "show")
|
||||||
|
target_fields = rule.get("target_fields", [])
|
||||||
|
|
||||||
|
field_value = form_data.get(trigger_field)
|
||||||
|
|
||||||
|
# Evaluate condition
|
||||||
|
condition_met = False
|
||||||
|
if trigger_operator == "equals":
|
||||||
|
condition_met = field_value == trigger_value
|
||||||
|
elif trigger_operator == "not_equals":
|
||||||
|
condition_met = field_value != trigger_value
|
||||||
|
elif trigger_operator == "contains":
|
||||||
|
condition_met = trigger_value in (field_value or []) if isinstance(field_value, list) else trigger_value in str(field_value or "")
|
||||||
|
elif trigger_operator == "not_empty":
|
||||||
|
condition_met = bool(field_value)
|
||||||
|
elif trigger_operator == "empty":
|
||||||
|
condition_met = not bool(field_value)
|
||||||
|
|
||||||
|
# Apply action
|
||||||
|
if condition_met:
|
||||||
|
if action == "show":
|
||||||
|
visible_fields.update(target_fields)
|
||||||
|
elif action == "hide":
|
||||||
|
visible_fields -= set(target_fields)
|
||||||
|
|
||||||
|
return visible_fields
|
||||||
|
|
||||||
|
|
||||||
|
def validate_field_by_type(field: dict, value) -> list[str]:
|
||||||
|
"""Validate a field value based on its type and validation rules"""
|
||||||
|
errors = []
|
||||||
|
field_type = field.get("type")
|
||||||
|
validation = field.get("validation", {})
|
||||||
|
label = field.get("label", field.get("id"))
|
||||||
|
|
||||||
|
if field_type == "text" or field_type == "textarea":
|
||||||
|
if not isinstance(value, str):
|
||||||
|
errors.append(f"{label} must be text")
|
||||||
|
return errors
|
||||||
|
if "minLength" in validation and len(value) < validation["minLength"]:
|
||||||
|
errors.append(f"{label} must be at least {validation['minLength']} characters")
|
||||||
|
if "maxLength" in validation and len(value) > validation["maxLength"]:
|
||||||
|
errors.append(f"{label} must be at most {validation['maxLength']} characters")
|
||||||
|
if "pattern" in validation:
|
||||||
|
import re
|
||||||
|
if not re.match(validation["pattern"], value):
|
||||||
|
errors.append(f"{label} format is invalid")
|
||||||
|
|
||||||
|
elif field_type == "email":
|
||||||
|
import re
|
||||||
|
email_pattern = r'^[^\s@]+@[^\s@]+\.[^\s@]+$'
|
||||||
|
if not re.match(email_pattern, str(value)):
|
||||||
|
errors.append(f"{label} must be a valid email address")
|
||||||
|
|
||||||
|
elif field_type == "phone":
|
||||||
|
if not isinstance(value, str) or len(value) < 7:
|
||||||
|
errors.append(f"{label} must be a valid phone number")
|
||||||
|
|
||||||
|
elif field_type == "date":
|
||||||
|
# Date validation happens during parsing
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif field_type == "dropdown" or field_type == "radio":
|
||||||
|
options = [opt.get("value") for opt in field.get("options", [])]
|
||||||
|
if value not in options:
|
||||||
|
errors.append(f"{label} must be one of the available options")
|
||||||
|
|
||||||
|
elif field_type == "multiselect":
|
||||||
|
if not isinstance(value, list):
|
||||||
|
errors.append(f"{label} must be a list of selections")
|
||||||
|
else:
|
||||||
|
options = [opt.get("value") for opt in field.get("options", [])]
|
||||||
|
for v in value:
|
||||||
|
if v not in options:
|
||||||
|
errors.append(f"{label} contains invalid option: {v}")
|
||||||
|
if "min_selections" in validation and len(value) < validation["min_selections"]:
|
||||||
|
errors.append(f"{label} requires at least {validation['min_selections']} selections")
|
||||||
|
if "max_selections" in validation and len(value) > validation["max_selections"]:
|
||||||
|
errors.append(f"{label} allows at most {validation['max_selections']} selections")
|
||||||
|
|
||||||
|
elif field_type == "checkbox":
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
errors.append(f"{label} must be true or false")
|
||||||
|
|
||||||
|
elif field_type == "password":
|
||||||
|
if not isinstance(value, str):
|
||||||
|
errors.append(f"{label} must be text")
|
||||||
|
elif "minLength" in validation and len(value) < validation["minLength"]:
|
||||||
|
errors.append(f"{label} must be at least {validation['minLength']} characters")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_dynamic_registration(data: dict, schema: dict) -> tuple[bool, list[str]]:
|
||||||
|
"""Validate registration data against dynamic schema"""
|
||||||
|
errors = []
|
||||||
|
conditional_rules = schema.get("conditional_rules", [])
|
||||||
|
|
||||||
|
# Get all fields and their visibility based on conditional rules
|
||||||
|
hidden_fields = set()
|
||||||
|
for rule in conditional_rules:
|
||||||
|
if rule.get("action") == "show":
|
||||||
|
# Fields are hidden by default if they have a "show" rule
|
||||||
|
hidden_fields.update(rule.get("target_fields", []))
|
||||||
|
|
||||||
|
# Evaluate which hidden fields should now be visible
|
||||||
|
visible_conditional_fields = evaluate_conditional_rules(data, conditional_rules)
|
||||||
|
hidden_fields -= visible_conditional_fields
|
||||||
|
|
||||||
|
for step in schema.get("steps", []):
|
||||||
|
for section in step.get("sections", []):
|
||||||
|
# Check section-level validation
|
||||||
|
section_validation = section.get("validation", {})
|
||||||
|
if section_validation.get("atLeastOne"):
|
||||||
|
field_ids = [f["id"] for f in section.get("fields", [])]
|
||||||
|
has_value = any(data.get(fid) for fid in field_ids)
|
||||||
|
if not has_value:
|
||||||
|
errors.append(section_validation.get("message", f"At least one field in {section.get('title', 'this section')} is required"))
|
||||||
|
|
||||||
|
for field in section.get("fields", []):
|
||||||
|
field_id = field.get("id")
|
||||||
|
|
||||||
|
# Skip hidden fields
|
||||||
|
if field_id in hidden_fields:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip client-only fields (like confirmPassword)
|
||||||
|
if field.get("client_only"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = data.get(field_id)
|
||||||
|
|
||||||
|
# Required check
|
||||||
|
if field.get("required"):
|
||||||
|
if value is None or value == "" or (isinstance(value, list) and len(value) == 0):
|
||||||
|
errors.append(f"{field.get('label', field_id)} is required")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Type-specific validation
|
||||||
|
if value is not None and value != "":
|
||||||
|
field_errors = validate_field_by_type(field, value)
|
||||||
|
errors.extend(field_errors)
|
||||||
|
|
||||||
|
return len(errors) == 0, errors
|
||||||
|
|
||||||
|
|
||||||
|
def split_registration_data(data: dict, schema: dict) -> tuple[dict, dict]:
|
||||||
|
"""Split registration data into User model fields and custom fields"""
|
||||||
|
user_data = {}
|
||||||
|
custom_data = {}
|
||||||
|
|
||||||
|
# Get field mappings from schema
|
||||||
|
field_mappings = {}
|
||||||
|
for step in schema.get("steps", []):
|
||||||
|
for section in step.get("sections", []):
|
||||||
|
for field in section.get("fields", []):
|
||||||
|
if field.get("mapping"):
|
||||||
|
field_mappings[field["id"]] = field["mapping"]
|
||||||
|
|
||||||
|
# User model fields that have direct column mappings
|
||||||
|
user_model_fields = {
|
||||||
|
"email", "password", "first_name", "last_name", "phone", "address",
|
||||||
|
"city", "state", "zipcode", "date_of_birth", "lead_sources",
|
||||||
|
"partner_first_name", "partner_last_name", "partner_is_member",
|
||||||
|
"partner_plan_to_become_member", "referred_by_member_name",
|
||||||
|
"newsletter_publish_name", "newsletter_publish_photo",
|
||||||
|
"newsletter_publish_birthday", "newsletter_publish_none",
|
||||||
|
"volunteer_interests", "scholarship_requested", "scholarship_reason",
|
||||||
|
"show_in_directory", "directory_email", "directory_bio",
|
||||||
|
"directory_address", "directory_phone", "directory_dob",
|
||||||
|
"directory_partner_name", "accepts_tos"
|
||||||
|
}
|
||||||
|
|
||||||
|
for field_id, value in data.items():
|
||||||
|
mapping = field_mappings.get(field_id, field_id)
|
||||||
|
|
||||||
|
# Skip client-only fields
|
||||||
|
if field_id == "confirmPassword":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if mapping in user_model_fields:
|
||||||
|
user_data[mapping] = value
|
||||||
|
else:
|
||||||
|
custom_data[field_id] = value
|
||||||
|
|
||||||
|
return user_data, custom_data
|
||||||
|
|
||||||
|
|
||||||
|
# Public endpoint - returns schema for registration form
|
||||||
|
@api_router.get("/registration/schema")
|
||||||
|
async def get_public_registration_schema(db: Session = Depends(get_db)):
|
||||||
|
"""Get registration form schema for public registration page"""
|
||||||
|
schema = get_registration_schema(db)
|
||||||
|
# Return a clean version without internal metadata
|
||||||
|
return {
|
||||||
|
"version": schema.get("version"),
|
||||||
|
"steps": schema.get("steps", []),
|
||||||
|
"conditional_rules": schema.get("conditional_rules", []),
|
||||||
|
"fixed_fields": schema.get("fixed_fields", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Admin endpoint - returns schema with metadata
|
||||||
|
@api_router.get("/admin/registration/schema")
|
||||||
|
async def get_admin_registration_schema(
|
||||||
|
current_user: User = Depends(require_permission("registration.view")),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get registration form schema with admin metadata"""
|
||||||
|
schema = get_registration_schema(db)
|
||||||
|
|
||||||
|
# Get version info
|
||||||
|
setting = db.query(SystemSettings).filter(
|
||||||
|
SystemSettings.setting_key == "registration.form_schema"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema": schema,
|
||||||
|
"metadata": {
|
||||||
|
"last_updated": setting.updated_at.isoformat() if setting else None,
|
||||||
|
"updated_by": str(setting.updated_by) if setting and setting.updated_by else None,
|
||||||
|
"is_default": setting is None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Admin endpoint - update schema
|
||||||
|
@api_router.put("/admin/registration/schema")
|
||||||
|
async def update_registration_schema(
|
||||||
|
request: RegistrationSchemaRequest,
|
||||||
|
current_user: User = Depends(require_permission("registration.manage")),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Update registration form schema"""
|
||||||
|
schema = request.schema_data
|
||||||
|
|
||||||
|
# Validate schema structure
|
||||||
|
is_valid, errors = validate_schema(schema)
|
||||||
|
if not is_valid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"message": "Invalid schema", "errors": errors}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save schema
|
||||||
|
save_registration_schema(db, schema, current_user.id)
|
||||||
|
|
||||||
|
logger.info(f"Registration schema updated by user {current_user.email}")
|
||||||
|
|
||||||
|
return {"message": "Registration schema updated successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
# Admin endpoint - validate schema without saving
|
||||||
|
@api_router.post("/admin/registration/schema/validate")
|
||||||
|
async def validate_registration_schema_endpoint(
|
||||||
|
request: RegistrationSchemaRequest,
|
||||||
|
current_user: User = Depends(require_permission("registration.manage")),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Validate registration form schema without saving"""
|
||||||
|
schema = request.schema_data
|
||||||
|
is_valid, errors = validate_schema(schema)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": is_valid,
|
||||||
|
"errors": errors
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Admin endpoint - reset schema to default
|
||||||
|
@api_router.post("/admin/registration/schema/reset")
|
||||||
|
async def reset_registration_schema(
|
||||||
|
current_user: User = Depends(require_permission("registration.manage")),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Reset registration form schema to default"""
|
||||||
|
save_registration_schema(db, DEFAULT_REGISTRATION_SCHEMA.copy(), current_user.id)
|
||||||
|
|
||||||
|
logger.info(f"Registration schema reset to default by user {current_user.email}")
|
||||||
|
|
||||||
|
return {"message": "Registration schema reset to default"}
|
||||||
|
|
||||||
|
|
||||||
|
# Admin endpoint - get available field types
|
||||||
|
@api_router.get("/admin/registration/field-types")
|
||||||
|
async def get_field_types(
|
||||||
|
current_user: User = Depends(require_permission("registration.view")),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get available field types for registration form builder"""
|
||||||
|
return FIELD_TYPES
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Admin Routes
|
# Admin Routes
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user