From ea87b3f6ee2da3bdc846e6f573956eaf60ca42c7 Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:06:22 +0700 Subject: [PATCH 01/10] - Add Dockerfile and .dockerignore- Fix initial DB creation- Fix seed permission --- .dockerignore | 83 +++++++++++++++++ .gitignore | 3 + Dockerfile | 40 +++++++++ create_superadmin.py | 141 ++++++++++++++++++----------- migrations/000_initial_schema.sql | 144 ++++++++++++++++++++++-------- seed_permissions_rbac.py | 74 +++++++++++++-- server.py | 9 ++ 7 files changed, 402 insertions(+), 92 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..58de578 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,83 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# Environment files (will be mounted or passed via env vars) +.env +.env.local +.env.*.local +*.env + +# Logs +*.log +logs/ + +# Database +*.db +*.sqlite3 + +# Alembic +alembic/versions/__pycache__/ + +# Docker +Dockerfile +docker-compose*.yml +.docker/ + +# Documentation +*.md +docs/ + +# Temporary files +tmp/ +temp/ +*.tmp + +# OS files +.DS_Store +Thumbs.db + +# Uploads (will be mounted as volume) +uploads/ diff --git a/.gitignore b/.gitignore index fc7287d..7e6ee36 100644 --- a/.gitignore +++ b/.gitignore @@ -245,6 +245,9 @@ temp_uploads/ tmp/ temporary/ +# Generated SQL files (from scripts) +create_superadmin.sql + # CSV imports imports/*.csv !imports/.gitkeep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e533c87 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# Backend Dockerfile - FastAPI with Python +FROM python:3.11-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PYTHONPATH=/app + +# Set work directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create non-root user for security +RUN adduser --disabled-password --gecos '' appuser && \ + chown -R appuser:appuser /app +USER appuser + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Run the application +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/create_superadmin.py b/create_superadmin.py index 7644bc9..2b6e68c 100644 --- a/create_superadmin.py +++ b/create_superadmin.py @@ -1,38 +1,15 @@ #!/usr/bin/env python3 """ Create Superadmin User Script -Generates a superadmin user with hashed password for LOAF membership platform +Directly creates a superadmin user in the database for LOAF membership platform """ -import bcrypt import sys import os from getpass import getpass -def generate_password_hash(password: str) -> str: - """Generate bcrypt hash for password""" - return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() - -def generate_sql(email: str, password_hash: str, first_name: str, last_name: str) -> str: - """Generate SQL INSERT statement""" - return f""" --- Create Superadmin User -INSERT INTO users ( - id, email, password_hash, first_name, last_name, - status, role, email_verified, created_at, updated_at -) VALUES ( - gen_random_uuid(), - '{email}', - '{password_hash}', - '{first_name}', - '{last_name}', - 'active', - 'superadmin', - true, - NOW(), - NOW() -); -""" +# Add the backend directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) def main(): print("=" * 70) @@ -40,6 +17,15 @@ def main(): print("=" * 70) print() + # Check for DATABASE_URL + from dotenv import load_dotenv + load_dotenv() + + database_url = os.getenv("DATABASE_URL") + if not database_url: + print("❌ DATABASE_URL not found in environment or .env file") + sys.exit(1) + # Get user input email = input("Email address: ").strip() if not email or '@' not in email: @@ -68,31 +54,89 @@ def main(): sys.exit(1) print() - print("Generating password hash...") - password_hash = generate_password_hash(password) + print("Creating superadmin user...") - print("✅ Password hash generated") - print() - print("=" * 70) - print("SQL STATEMENT") - print("=" * 70) + try: + # Import database dependencies + from sqlalchemy import create_engine, text + from passlib.context import CryptContext - sql = generate_sql(email, password_hash, first_name, last_name) - print(sql) + # Create password hash + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + password_hash = pwd_context.hash(password) - # Save to file - output_file = "create_superadmin.sql" - with open(output_file, 'w') as f: - f.write(sql) + # Connect to database + engine = create_engine(database_url) - print("=" * 70) - print(f"✅ SQL saved to: {output_file}") - print() - print("Run this command to create the user:") - print(f" psql -U postgres -d loaf_new -f {output_file}") - print() - print("Or copy the SQL above and run it directly in psql") - print("=" * 70) + with engine.connect() as conn: + # Check if user already exists + result = conn.execute( + text("SELECT id FROM users WHERE email = :email"), + {"email": email} + ) + if result.fetchone(): + print(f"❌ User with email '{email}' already exists") + sys.exit(1) + + # Insert superadmin user + conn.execute( + text(""" + INSERT INTO users ( + id, email, password_hash, first_name, last_name, + phone, address, city, state, zipcode, date_of_birth, + status, role, email_verified, + newsletter_subscribed, accepts_tos, + created_at, updated_at + ) VALUES ( + gen_random_uuid(), + :email, + :password_hash, + :first_name, + :last_name, + '', + '', + '', + '', + '', + '1990-01-01', + 'active', + 'superadmin', + true, + false, + true, + NOW(), + NOW() + ) + """), + { + "email": email, + "password_hash": password_hash, + "first_name": first_name, + "last_name": last_name + } + ) + conn.commit() + + print() + print("=" * 70) + print("✅ Superadmin user created successfully!") + print("=" * 70) + print() + print(f" Email: {email}") + print(f" Name: {first_name} {last_name}") + print(f" Role: superadmin") + print(f" Status: active") + print() + print("You can now log in with these credentials.") + print("=" * 70) + + except ImportError as e: + print(f"❌ Missing dependency: {e}") + print(" Run: pip install sqlalchemy psycopg2-binary passlib python-dotenv") + sys.exit(1) + except Exception as e: + print(f"❌ Database error: {e}") + sys.exit(1) if __name__ == "__main__": try: @@ -100,6 +144,3 @@ if __name__ == "__main__": except KeyboardInterrupt: print("\n\n❌ Cancelled by user") sys.exit(1) - except Exception as e: - print(f"\n❌ Error: {e}") - sys.exit(1) diff --git a/migrations/000_initial_schema.sql b/migrations/000_initial_schema.sql index 87c5f8a..bf84705 100644 --- a/migrations/000_initial_schema.sql +++ b/migrations/000_initial_schema.sql @@ -94,6 +94,30 @@ BEGIN; -- SECTION 2: Create Core Tables -- ============================================================================ +-- Import Jobs table (must be created before users due to FK reference) +CREATE TABLE IF NOT EXISTS import_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + filename VARCHAR NOT NULL, + status importjobstatus NOT NULL DEFAULT 'processing', + total_rows INTEGER DEFAULT 0, + processed_rows INTEGER DEFAULT 0, + success_count INTEGER DEFAULT 0, + error_count INTEGER DEFAULT 0, + error_log JSONB DEFAULT '[]'::jsonb, + + -- WordPress import enhancements + field_mapping JSONB DEFAULT '{}'::jsonb, + wordpress_metadata JSONB DEFAULT '{}'::jsonb, + imported_user_ids JSONB DEFAULT '[]'::jsonb, + rollback_at TIMESTAMP WITH TIME ZONE, + rollback_by UUID, -- Will be updated with FK after users table exists + + started_by UUID, -- Will be updated with FK after users table exists + started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP WITH TIME ZONE +); + -- Users table CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -103,6 +127,7 @@ CREATE TABLE IF NOT EXISTS users ( password_hash VARCHAR NOT NULL, email_verified BOOLEAN NOT NULL DEFAULT FALSE, email_verification_token VARCHAR UNIQUE, + email_verification_expires TIMESTAMP WITH TIME ZONE, -- Personal Information first_name VARCHAR NOT NULL, @@ -113,7 +138,6 @@ CREATE TABLE IF NOT EXISTS users ( state VARCHAR(2), zipcode VARCHAR(10), date_of_birth DATE, - bio TEXT, -- Profile profile_photo_url VARCHAR, @@ -137,20 +161,44 @@ CREATE TABLE IF NOT EXISTS users ( -- Status & Role status userstatus NOT NULL DEFAULT 'pending_email', role userrole NOT NULL DEFAULT 'guest', - role_id UUID, -- For dynamic RBAC (added in later migration) + role_id UUID, -- For dynamic RBAC - -- Rejection Tracking - rejection_reason TEXT, - rejected_at TIMESTAMP WITH TIME ZONE, - rejected_by UUID REFERENCES users(id), + -- Newsletter Preferences + newsletter_subscribed BOOLEAN DEFAULT TRUE, + newsletter_publish_name BOOLEAN DEFAULT FALSE NOT NULL, + newsletter_publish_photo BOOLEAN DEFAULT FALSE NOT NULL, + newsletter_publish_birthday BOOLEAN DEFAULT FALSE NOT NULL, + newsletter_publish_none BOOLEAN DEFAULT FALSE NOT NULL, + + -- Volunteer Interests + volunteer_interests JSONB DEFAULT '[]'::jsonb, + + -- Scholarship Request + scholarship_requested BOOLEAN DEFAULT FALSE NOT NULL, + scholarship_reason TEXT, + + -- Directory Settings + show_in_directory BOOLEAN DEFAULT FALSE NOT NULL, + directory_email VARCHAR, + directory_bio TEXT, + directory_address VARCHAR, + directory_phone VARCHAR, + directory_dob DATE, + directory_partner_name VARCHAR, + + -- Password Reset + password_reset_token VARCHAR, + password_reset_expires TIMESTAMP WITH TIME ZONE, + force_password_change BOOLEAN DEFAULT FALSE NOT NULL, + + -- Terms of Service + accepts_tos BOOLEAN DEFAULT FALSE NOT NULL, + tos_accepted_at TIMESTAMP WITH TIME ZONE, -- Membership member_since DATE, - accepts_tos BOOLEAN DEFAULT FALSE, - tos_accepted_at TIMESTAMP WITH TIME ZONE, - newsletter_subscribed BOOLEAN DEFAULT TRUE, - -- Reminder Tracking (from migration 004) + -- Reminder Tracking email_verification_reminders_sent INTEGER DEFAULT 0 NOT NULL, last_email_verification_reminder_at TIMESTAMP WITH TIME ZONE, event_attendance_reminders_sent INTEGER DEFAULT 0 NOT NULL, @@ -160,12 +208,21 @@ CREATE TABLE IF NOT EXISTS users ( renewal_reminders_sent INTEGER DEFAULT 0 NOT NULL, last_renewal_reminder_at TIMESTAMP WITH TIME ZONE, + -- Rejection Tracking + rejection_reason TEXT, + rejected_at TIMESTAMP WITH TIME ZONE, + rejected_by UUID REFERENCES users(id), + -- WordPress Import Tracking import_source VARCHAR(50), import_job_id UUID REFERENCES import_jobs(id), wordpress_user_id BIGINT, wordpress_registered_date TIMESTAMP WITH TIME ZONE, + -- Role Change Audit Trail + role_changed_at TIMESTAMP WITH TIME ZONE, + role_changed_by UUID REFERENCES users(id) ON DELETE SET NULL, + -- Timestamps created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP @@ -255,11 +312,23 @@ CREATE TABLE IF NOT EXISTS subscription_plans ( name VARCHAR NOT NULL, description TEXT, price_cents INTEGER NOT NULL, - billing_cycle VARCHAR NOT NULL DEFAULT 'annual', + billing_cycle VARCHAR NOT NULL DEFAULT 'yearly', + stripe_price_id VARCHAR, -- Legacy, deprecated -- Configuration active BOOLEAN NOT NULL DEFAULT TRUE, - features JSONB DEFAULT '[]'::jsonb, + + -- Custom billing cycle fields (for recurring date ranges like Jan 1 - Dec 31) + custom_cycle_enabled BOOLEAN DEFAULT FALSE NOT NULL, + custom_cycle_start_month INTEGER, + custom_cycle_start_day INTEGER, + custom_cycle_end_month INTEGER, + custom_cycle_end_day INTEGER, + + -- Dynamic pricing fields + minimum_price_cents INTEGER DEFAULT 3000 NOT NULL, + suggested_price_cents INTEGER, + allow_donation BOOLEAN DEFAULT TRUE NOT NULL, -- Timestamps created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, @@ -281,13 +350,21 @@ CREATE TABLE IF NOT EXISTS subscriptions ( status subscriptionstatus DEFAULT 'active', start_date TIMESTAMP WITH TIME ZONE NOT NULL, end_date TIMESTAMP WITH TIME ZONE, - next_billing_date TIMESTAMP WITH TIME ZONE, -- Payment Details amount_paid_cents INTEGER, base_subscription_cents INTEGER NOT NULL, donation_cents INTEGER DEFAULT 0 NOT NULL, + -- Stripe transaction metadata (for validation and audit) + stripe_payment_intent_id VARCHAR, + stripe_charge_id VARCHAR, + stripe_invoice_id VARCHAR, + payment_completed_at TIMESTAMP WITH TIME ZONE, + card_last4 VARCHAR(4), + card_brand VARCHAR(20), + stripe_receipt_url VARCHAR, + -- Manual Payment Support manual_payment BOOLEAN DEFAULT FALSE NOT NULL, manual_payment_notes TEXT, @@ -319,6 +396,14 @@ CREATE TABLE IF NOT EXISTS donations ( stripe_payment_intent_id VARCHAR, payment_method VARCHAR, + -- Stripe transaction metadata (for validation and audit) + stripe_charge_id VARCHAR, + stripe_customer_id VARCHAR, + payment_completed_at TIMESTAMP WITH TIME ZONE, + card_last4 VARCHAR(4), + card_brand VARCHAR(20), + stripe_receipt_url VARCHAR, + -- Metadata notes TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, @@ -466,29 +551,10 @@ CREATE TABLE IF NOT EXISTS user_invitations ( created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); --- Import Jobs table -CREATE TABLE IF NOT EXISTS import_jobs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - filename VARCHAR NOT NULL, - status importjobstatus NOT NULL DEFAULT 'processing', - total_rows INTEGER DEFAULT 0, - processed_rows INTEGER DEFAULT 0, - success_count INTEGER DEFAULT 0, - error_count INTEGER DEFAULT 0, - error_log JSONB DEFAULT '[]'::jsonb, - - -- WordPress import enhancements - field_mapping JSONB DEFAULT '{}'::jsonb, - wordpress_metadata JSONB DEFAULT '{}'::jsonb, - imported_user_ids JSONB DEFAULT '[]'::jsonb, - rollback_at TIMESTAMP WITH TIME ZONE, - rollback_by UUID REFERENCES users(id), - - started_by UUID REFERENCES users(id), - started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - completed_at TIMESTAMP WITH TIME ZONE -); +-- Add FK constraints to import_jobs (now that users table exists) +ALTER TABLE import_jobs + ADD CONSTRAINT fk_import_jobs_rollback_by FOREIGN KEY (rollback_by) REFERENCES users(id), + ADD CONSTRAINT fk_import_jobs_started_by FOREIGN KEY (started_by) REFERENCES users(id); -- Import Rollback Audit table (for tracking rollback operations) CREATE TABLE IF NOT EXISTS import_rollback_audit ( @@ -542,12 +608,18 @@ CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id); CREATE INDEX IF NOT EXISTS idx_subscriptions_plan_id ON subscriptions(plan_id); CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status); CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_subscription_id ON subscriptions(stripe_subscription_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_payment_intent ON subscriptions(stripe_payment_intent_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_charge_id ON subscriptions(stripe_charge_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_invoice_id ON subscriptions(stripe_invoice_id); -- Donations indexes CREATE INDEX IF NOT EXISTS idx_donation_user ON donations(user_id); CREATE INDEX IF NOT EXISTS idx_donation_type ON donations(donation_type); CREATE INDEX IF NOT EXISTS idx_donation_status ON donations(status); CREATE INDEX IF NOT EXISTS idx_donation_created ON donations(created_at); +CREATE INDEX IF NOT EXISTS idx_donation_payment_intent ON donations(stripe_payment_intent_id); +CREATE INDEX IF NOT EXISTS idx_donation_charge_id ON donations(stripe_charge_id); +CREATE INDEX IF NOT EXISTS idx_donation_customer_id ON donations(stripe_customer_id); -- Import Jobs indexes CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status); diff --git a/seed_permissions_rbac.py b/seed_permissions_rbac.py index 6e0529b..1b810a3 100755 --- a/seed_permissions_rbac.py +++ b/seed_permissions_rbac.py @@ -118,6 +118,40 @@ PERMISSIONS = [ {"code": "permissions.audit", "name": "View Permission Audit Log", "description": "View permission change audit logs", "module": "permissions"}, ] +# Default system roles that must exist +DEFAULT_ROLES = [ + { + "code": "guest", + "name": "Guest", + "description": "Default role for new registrations with no special permissions", + "is_system_role": True + }, + { + "code": "member", + "name": "Member", + "description": "Active paying members with access to member-only content", + "is_system_role": True + }, + { + "code": "finance", + "name": "Finance", + "description": "Financial management role with access to payments, subscriptions, and reports", + "is_system_role": True + }, + { + "code": "admin", + "name": "Admin", + "description": "Board members with full management access except RBAC", + "is_system_role": True + }, + { + "code": "superadmin", + "name": "Superadmin", + "description": "Full system access including RBAC management", + "is_system_role": True + }, +] + # Default permission assignments for dynamic roles DEFAULT_ROLE_PERMISSIONS = { "guest": [], # Guests have no permissions @@ -196,7 +230,34 @@ def seed_permissions(): print(f"\n⚠️ WARNING: Tables not fully cleared! Stopping.") return - # Step 2: Create permissions + # Step 2: Create default system roles + print(f"\n👤 Creating {len(DEFAULT_ROLES)} system roles...") + role_map = {} + + for role_data in DEFAULT_ROLES: + # Check if role already exists + existing_role = db.query(Role).filter(Role.code == role_data["code"]).first() + if existing_role: + print(f" • {role_data['name']}: Already exists, updating...") + existing_role.name = role_data["name"] + existing_role.description = role_data["description"] + existing_role.is_system_role = role_data["is_system_role"] + role_map[role_data["code"]] = existing_role + else: + print(f" • {role_data['name']}: Creating...") + role = Role( + code=role_data["code"], + name=role_data["name"], + description=role_data["description"], + is_system_role=role_data["is_system_role"] + ) + db.add(role) + role_map[role_data["code"]] = role + + db.commit() + print(f"✓ Created/updated {len(DEFAULT_ROLES)} system roles") + + # Step 3: Create permissions print(f"\n📝 Creating {len(PERMISSIONS)} permissions...") permission_map = {} # Map code to permission object @@ -213,13 +274,13 @@ def seed_permissions(): db.commit() print(f"✓ Created {len(PERMISSIONS)} permissions") - # Step 3: Get all roles from database - print("\n🔍 Fetching dynamic roles...") + # Step 4: Verify roles exist + print("\n🔍 Verifying dynamic 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())}") - # Step 4: Assign permissions to roles + # Step 5: Assign permissions to roles print("\n🔐 Assigning permissions to roles...") from models import UserRole # Import for enum mapping @@ -258,7 +319,7 @@ def seed_permissions(): db.commit() print(f" ✓ {role.name}: Assigned {len(permission_codes)} permissions") - # Step 5: Summary + # Step 6: Summary print("\n" + "=" * 80) print("📊 SEEDING SUMMARY") print("=" * 80) @@ -273,7 +334,8 @@ def seed_permissions(): for module, count in sorted(modules.items()): print(f" • {module.capitalize()}: {count} permissions") - print(f"\nTotal permissions created: {len(PERMISSIONS)}") + print(f"\nTotal system roles created: {len(DEFAULT_ROLES)}") + print(f"Total permissions created: {len(PERMISSIONS)}") print(f"Total role-permission mappings: {total_assigned}") print("\n✅ Permission seeding completed successfully!") print("\nNext step: Restart backend server") diff --git a/server.py b/server.py index 00676ec..3ed669e 100644 --- a/server.py +++ b/server.py @@ -97,6 +97,15 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) +# ============================================================ +# Health Check Endpoint (for Kubernetes probes) +# ============================================================ + +@app.get("/health") +async def health_check(): + """Health check endpoint for Kubernetes liveness/readiness probes.""" + return {"status": "healthy", "service": "membership-backend"} + # ============================================================ # Helper Functions # ============================================================ -- 2.39.5 From ab0f098f99f2ef6122ec43e0a8a409cb0c21abd3 Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:15:44 +0700 Subject: [PATCH 02/10] - Fix Member Directory to include staff- Implement Member Tiers settings endpoints --- auth.py | 4 +- migrations/000_initial_schema.sql | 4 +- server.py | 284 ++++++++++++++++++++++++------ 3 files changed, 235 insertions(+), 57 deletions(-) diff --git a/auth.py b/auth.py index 3371604..f25a1d6 100644 --- a/auth.py +++ b/auth.py @@ -128,7 +128,7 @@ async def get_current_admin_user(current_user: User = Depends(get_current_user)) return current_user async def get_active_member(current_user: User = Depends(get_current_user)) -> User: - """Require user to be active member with valid payment""" + """Require user to be active member or staff with valid status""" from models import UserStatus if current_user.status != UserStatus.active: @@ -138,7 +138,7 @@ async def get_active_member(current_user: User = Depends(get_current_user)) -> U ) role_code = get_user_role_code(current_user) - if role_code not in ["member", "admin", "superadmin"]: + if role_code not in ["member", "admin", "superadmin", "finance"]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Member access only" diff --git a/migrations/000_initial_schema.sql b/migrations/000_initial_schema.sql index bf84705..8ed4677 100644 --- a/migrations/000_initial_schema.sql +++ b/migrations/000_initial_schema.sql @@ -530,7 +530,7 @@ CREATE TABLE IF NOT EXISTS storage_usage ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), total_bytes_used BIGINT NOT NULL DEFAULT 0, - max_bytes_allowed BIGINT NOT NULL DEFAULT 10737418240, -- 10GB + max_bytes_allowed BIGINT NOT NULL DEFAULT 1073741824, -- 1GB last_updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); @@ -659,7 +659,7 @@ INSERT INTO storage_usage (id, total_bytes_used, max_bytes_allowed, last_updated SELECT gen_random_uuid(), 0, - 10737418240, -- 10GB + 1073741824, -- 1GB CURRENT_TIMESTAMP WHERE NOT EXISTS (SELECT 1 FROM storage_usage); diff --git a/server.py b/server.py index 3ed669e..ec068e7 100644 --- a/server.py +++ b/server.py @@ -17,7 +17,7 @@ import csv import io from database import engine, get_db, Base -from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus, ImportRollbackAudit, Donation, DonationType, DonationStatus +from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus, ImportRollbackAudit, Donation, DonationType, DonationStatus, SystemSettings from auth import ( get_password_hash, verify_password, @@ -976,32 +976,53 @@ async def update_profile( # Member Directory Routes @api_router.get("/members/directory") async def get_member_directory( + search: Optional[str] = None, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): - """Get list of all members who opted into the directory""" - directory_members = db.query(User).filter( + """ + Get list of all active users (members and staff) who opted into the directory. + + Includes members, admins, finance, and superadmins who have: + - show_in_directory = True + - status = active + """ + query = db.query(User).filter( User.show_in_directory == True, - User.role == UserRole.member, User.status == UserStatus.active - ).all() + ) + + # Optional search filter + if search: + search_term = f"%{search}%" + query = query.filter( + or_( + User.first_name.ilike(search_term), + User.last_name.ilike(search_term), + User.directory_bio.ilike(search_term) + ) + ) + + directory_members = query.order_by(User.first_name, User.last_name).all() return [{ "id": str(member.id), "first_name": member.first_name, "last_name": member.last_name, + "role": member.role.value if member.role else None, "profile_photo_url": member.profile_photo_url, "directory_email": member.directory_email, "directory_bio": member.directory_bio, "directory_address": member.directory_address, "directory_phone": member.directory_phone, - "directory_dob": member.directory_dob, + "directory_dob": member.directory_dob.isoformat() if member.directory_dob else None, "directory_partner_name": member.directory_partner_name, "volunteer_interests": member.volunteer_interests or [], "social_media_facebook": member.social_media_facebook, "social_media_instagram": member.social_media_instagram, "social_media_twitter": member.social_media_twitter, "social_media_linkedin": member.social_media_linkedin, + "member_since": member.member_since.isoformat() if member.member_since else None, "created_at": member.created_at.isoformat() if member.created_at else None } for member in directory_members] @@ -1011,11 +1032,10 @@ async def get_directory_member_profile( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): - """Get public directory profile of a specific member""" + """Get public directory profile of a specific member or staff""" member = db.query(User).filter( User.id == user_id, User.show_in_directory == True, - User.role == UserRole.member, User.status == UserStatus.active ).first() @@ -1026,18 +1046,20 @@ async def get_directory_member_profile( "id": str(member.id), "first_name": member.first_name, "last_name": member.last_name, + "role": member.role.value if member.role else None, "profile_photo_url": member.profile_photo_url, "directory_email": member.directory_email, "directory_bio": member.directory_bio, "directory_address": member.directory_address, "directory_phone": member.directory_phone, - "directory_dob": member.directory_dob, + "directory_dob": member.directory_dob.isoformat() if member.directory_dob else None, "directory_partner_name": member.directory_partner_name, "volunteer_interests": member.volunteer_interests or [], "social_media_facebook": member.social_media_facebook, "social_media_instagram": member.social_media_instagram, "social_media_twitter": member.social_media_twitter, "social_media_linkedin": member.social_media_linkedin, + "member_since": member.member_since.isoformat() if member.member_since else None, "created_at": member.created_at.isoformat() if member.created_at else None } @@ -1269,50 +1291,6 @@ async def get_calendar_events( return result -# Members Directory Route -@api_router.get("/members/directory") -async def get_members_directory( - search: Optional[str] = None, - current_user: User = Depends(get_active_member), - db: Session = Depends(get_db) -): - """Get members directory - only shows active members who opted in""" - query = db.query(User).filter( - User.show_in_directory == True, - User.status == UserStatus.active - ) - - if search: - search_term = f"%{search}%" - query = query.filter( - or_( - User.first_name.ilike(search_term), - User.last_name.ilike(search_term), - User.directory_bio.ilike(search_term) - ) - ) - - members = query.order_by(User.first_name, User.last_name).all() - - return [ - { - "id": str(member.id), - "first_name": member.first_name, - "last_name": member.last_name, - "profile_photo_url": member.profile_photo_url, - "directory_email": member.directory_email, - "directory_bio": member.directory_bio, - "directory_address": member.directory_address, - "directory_phone": member.directory_phone, - "directory_partner_name": member.directory_partner_name, - "social_media_facebook": member.social_media_facebook, - "social_media_instagram": member.social_media_instagram, - "social_media_twitter": member.social_media_twitter, - "social_media_linkedin": member.social_media_linkedin - } - for member in members - ] - # Admin Calendar Sync Routes @api_router.post("/admin/calendar/sync/{event_id}") async def sync_event_to_microsoft( @@ -7032,6 +7010,206 @@ async def update_stripe_settings( ) +# ============================================================================ +# Member Tiers Settings +# ============================================================================ + +# Default tier configuration +DEFAULT_MEMBER_TIERS = { + "tiers": [ + { + "id": "new_member", + "label": "New Member", + "minYears": 0, + "maxYears": 0.999, + "iconKey": "sparkle", + "badgeClass": "bg-blue-100 text-blue-800 border-blue-200" + }, + { + "id": "member_1_year", + "label": "1 Year Member", + "minYears": 1, + "maxYears": 2.999, + "iconKey": "star", + "badgeClass": "bg-green-100 text-green-800 border-green-200" + }, + { + "id": "member_3_year", + "label": "3+ Year Member", + "minYears": 3, + "maxYears": 4.999, + "iconKey": "award", + "badgeClass": "bg-purple-100 text-purple-800 border-purple-200" + }, + { + "id": "veteran", + "label": "Veteran Member", + "minYears": 5, + "maxYears": 999, + "iconKey": "crown", + "badgeClass": "bg-amber-100 text-amber-800 border-amber-200" + } + ] +} + + +class MemberTier(BaseModel): + """Single tier definition""" + id: str = Field(..., min_length=1, max_length=50) + label: str = Field(..., min_length=1, max_length=100) + minYears: float = Field(..., ge=0) + maxYears: float = Field(..., gt=0) + iconKey: str = Field(..., min_length=1, max_length=50) + badgeClass: str = Field(..., min_length=1, max_length=200) + + +class MemberTiersConfig(BaseModel): + """Member tiers configuration""" + tiers: List[MemberTier] = Field(..., min_length=1, max_length=10) + + @validator('tiers') + def validate_tiers_no_overlap(cls, tiers): + """Ensure tiers are sorted and don't overlap""" + sorted_tiers = sorted(tiers, key=lambda t: t.minYears) + + for i in range(len(sorted_tiers) - 1): + current = sorted_tiers[i] + next_tier = sorted_tiers[i + 1] + if current.maxYears >= next_tier.minYears: + raise ValueError( + f"Tier '{current.label}' (max: {current.maxYears}) overlaps with " + f"'{next_tier.label}' (min: {next_tier.minYears})" + ) + + return sorted_tiers + + +@api_router.get("/settings/member-tiers") +async def get_member_tiers_public( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Get member tier configuration (for active members/staff). + + Returns the tier definitions used to display membership badges. + """ + import json + + tiers_json = get_setting(db, 'member_tiers') + + if tiers_json: + try: + return json.loads(tiers_json) + except json.JSONDecodeError: + # Fall back to default if stored JSON is invalid + return DEFAULT_MEMBER_TIERS + + return DEFAULT_MEMBER_TIERS + + +@api_router.get("/admin/settings/member-tiers") +async def get_member_tiers_admin( + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Get member tier configuration (admin view). + + Returns the tier definitions along with metadata about last update. + """ + import json + + tiers_json = get_setting(db, 'member_tiers') + + # Get the setting record for metadata + setting = db.query(SystemSettings).filter( + SystemSettings.setting_key == 'member_tiers' + ).first() + + config = DEFAULT_MEMBER_TIERS + if tiers_json: + try: + config = json.loads(tiers_json) + except json.JSONDecodeError: + pass + + return { + "config": config, + "is_default": tiers_json is None, + "updated_at": setting.updated_at.isoformat() if setting else None, + "updated_by": f"{setting.updater.first_name} {setting.updater.last_name}" if setting and setting.updater else None + } + + +@api_router.put("/admin/settings/member-tiers") +async def update_member_tiers( + request: MemberTiersConfig, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Update member tier configuration (admin only). + + Validates tier definitions to ensure: + - No overlapping year ranges + - All required fields present + - Tiers are sorted by minYears + """ + import json + + try: + # Convert to dict for JSON storage + tiers_dict = {"tiers": [tier.dict() for tier in request.tiers]} + tiers_json = json.dumps(tiers_dict) + + # Store using set_setting helper + set_setting( + db=db, + key='member_tiers', + value=tiers_json, + user_id=str(current_user.id), + setting_type='json', + description='Member tier badge configuration', + is_sensitive=False + ) + + return { + "success": True, + "message": "Member tiers updated successfully", + "config": tiers_dict, + "updated_at": datetime.now(timezone.utc).isoformat(), + "updated_by": f"{current_user.first_name} {current_user.last_name}" + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to update member tiers: {str(e)}" + ) + + +@api_router.post("/admin/settings/member-tiers/reset") +async def reset_member_tiers( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Reset member tiers to default configuration (superadmin only). + """ + # Delete the setting to revert to defaults + db.query(SystemSettings).filter( + SystemSettings.setting_key == 'member_tiers' + ).delete() + db.commit() + + return { + "success": True, + "message": "Member tiers reset to defaults", + "config": DEFAULT_MEMBER_TIERS + } + + # Include the router in the main app app.include_router(api_router) -- 2.39.5 From 03e5dd8bdabafd38945c1cc648648339d4aba765 Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:31:17 +0700 Subject: [PATCH 03/10] - 7 new API endpoints\ - Public theme config endpoint for frontend initialization (with 5-min cache)/- Admin CRUD for theme settings (get, update, reset)/- Logo and favicon upload/delete via Cloudflare R2 storage --- __pycache__/auth.cpython-312.pyc | Bin 10475 -> 10492 bytes __pycache__/r2_storage.cpython-312.pyc | Bin 8607 -> 8859 bytes __pycache__/server.cpython-312.pyc | Bin 308458 -> 330986 bytes r2_storage.py | 15 + server.py | 505 +++++++++++++++++++++++++ 5 files changed, 520 insertions(+) diff --git a/__pycache__/auth.cpython-312.pyc b/__pycache__/auth.cpython-312.pyc index 6cbc9138c13a0055d64f8d9a7f423f06be72dad3..87c5b3911e77a8afffb17286252373aa07be6e09 100644 GIT binary patch delta 191 zcmaDI_$QF}G%qg~0}%YIt;n?5$m=Q2XgxVxTu~~&NTIkSF)dA@JhLQ2p)4^cGX*42 zQd&H@OWa?ElUa6$+m!qE)dZ(Sxa4(an@!J^~H<;MHfGw delta 174 zcmewp_&SjHG%qg~0}wa}Ic92Z7qEL`nnVXtdGI^4? zzcBMHw%pX*q|~CKT%e|+JRmXouXrJ2)#QAMAQsDu?3SC4N_=EtteL!Bwv(}Wv!mQp z7RKhu*OjyxTP8Cr-(zb5Y3rE$Tv>&&b2E?1Bu2*W#q(7e87n3)Q0o?`0?9Umh)xjE QJ=t1amT~6hNcF{x0Io?kNB{r; diff --git a/__pycache__/r2_storage.cpython-312.pyc b/__pycache__/r2_storage.cpython-312.pyc index 9ffbb1705ded15d22d94ca0902563e6007b1f3cb..863f47649898b297b5241d814ae4b78d06471dfe 100644 GIT binary patch delta 1151 zcmZ8gOH30{6rI-&r5~m(rQgAhRUoBQ@e}`|rV0W|suA#0ja7M%u?j7|Rz%eJ2{A4) zFD4jGbg6FC%*s8kjEU}=u8awBThxUiG4bB1qH!kg%(>_O&a{~)`yTbU-zkbyg6q!g zRPK`Y+I^Rhs(3)UZ(68Tw@_Ipv<{9Nj_0wQu~9EDJeWbX5!8C>n~^ehhvbmxO6q40 z8emQuWG>baf!QrCsFOx!>M|axLVcKdM`hYPBWD_^y3rJzAglnv z_q#xWI0|voKMfkh{;N%im^fw0hnJkW@v}NxcVU9*OO6_!$m>hSXN#4bnU*_?lX^nt z4X~Um2yTESrB(>JO z7OeMVvf0Sn^?F}B2o>kVso|Pgmnn(MckVtTg9MvkJbu8 zg|MO*p0WQf-XUIj{?{8b;wLK+y^Sn_*j5AWj|{G*BV7ZI}*;?oeAhTI4MVF@Wcmzx_oE z2-u0qgW`TDN(}KbG(^^jpT39@4SyrH6PVj2f@;!+PQi4qy1L0U>pIWQ(q*gSx*BdX z9m;<(9 z$ADJkbA`efKZ)u=1csmkJeT+yU4vRd^@5ZcRa$)|m_lHb`VWFueQ;-mJ$WiB!2ZxDA^erzq_s^!fU zx6VpgzA;-Y6*zn;{0gLhh-=p{86vZeQC4dJ|j0YluF{y_N-Dxc;ZJphQ*o(#x zW8h*kF){Jrg$sHz8xqgP6UGAvFME=B@`h;Q#e?sy1c+|4zrO#Q|IB-DvM;Z^n(}}2 z`Md%=h0crRz4%-I6GGne=Vbb!e@)0aJ%UHjVwyr-T7bH>cIu(t2)MN%M6w-q&{pb( zpIj7C7rk0&ttIEvI>FyT{Y6RZT9a~ZhuRG~3|cwVGUy2CzzKg8bi4GREfeSKj%v8+ zEw3z==F1MyG#N9Cfs}*HnH#|mq;(Xn7XS-vjNb|MT|W!) zY{tp#N5!V12k=e!PFmV&0ivU$sSwG(irOCQ^5VDAW1eb#Vi=1gj z`7>8!Xc)Gtm`)(#fGx-IS-JxfcnU7g@I<(qZ1Rcl0vX^N!HD@L{DU}e;o2p>8O=Je zYZ%~jwC9H1xO|T;YX7xuxf#{#wW>}}3?LmBWq0uydkUNqrob^~Vp+0n>amz(5GyWR z%}Dm`uspi$?(I9tfjnxPzw1tuUH+w;kszNxm0MzIbh{9EZ>$AiNm{vJSYo+qSS?1Q zUZHH>PA}DJ6?PkKGl)e*0k9>o0gUZikugF9;MIQ#d@?cXI8gYf#B14h*@n~nvAUb8 zqW5U4c#XbV1)_nltS^}_R0fIqvNKWKo^VE_OC diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index d4011c16249b49269cfc85c4863a6b39ed8e0c89..9eb9b40c20ddf5f4759b3baa8979cf1d493c22f5 100644 GIT binary patch delta 73007 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 T_xT3@w)SFce%9J`TK@k4qdItf delta 54455 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

Oq+o#L$N*^c&iw#Pq{d6m&N)ZEacFs{V}U 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 dict: + """Get theme config with caching.""" + import json + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + + # Check cache + if _theme_cache["config"] and _theme_cache["expires_at"] and _theme_cache["expires_at"] > now: + return _theme_cache["config"] + + # Build config from settings + config = dict(DEFAULT_THEME_CONFIG) + + # Fetch all theme.* settings + theme_settings = db.query(SystemSettings).filter( + SystemSettings.setting_key.like('theme.%') + ).all() + + for setting in theme_settings: + key = setting.setting_key.replace('theme.', '') + value = setting.setting_value + + if key == 'colors' and value: + try: + config['colors'] = json.loads(value) + except json.JSONDecodeError: + pass + elif key in config: + config[key] = value + + # Update cache + _theme_cache["config"] = config + _theme_cache["expires_at"] = now + timedelta(seconds=THEME_CACHE_TTL_SECONDS) + + return config + + +def invalidate_theme_cache(): + """Invalidate the theme config cache.""" + _theme_cache["config"] = None + _theme_cache["expires_at"] = None + + +@api_router.get("/config/theme") +async def get_theme_config(db: Session = Depends(get_db)): + """ + Get public theme configuration. + + This endpoint is public (no authentication required) and returns + the theme configuration for frontend initialization. + + Returns cached config with 5-minute TTL for performance. + """ + return get_theme_config_cached(db) + + +@api_router.get("/admin/settings/theme") +async def get_theme_settings_admin( + current_user: User = Depends(require_permission("settings.view")), + db: Session = Depends(get_db) +): + """ + Get theme settings with metadata (admin view). + + Returns the full theme configuration along with: + - Whether using default values + - Last update timestamp + - Who made the last update + """ + import json + + config = dict(DEFAULT_THEME_CONFIG) + is_default = True + updated_at = None + updated_by = None + + # Fetch all theme.* settings + theme_settings = db.query(SystemSettings).filter( + SystemSettings.setting_key.like('theme.%') + ).all() + + if theme_settings: + is_default = False + + # Find the most recent update + latest_setting = max(theme_settings, key=lambda s: s.updated_at or s.created_at) + updated_at = latest_setting.updated_at or latest_setting.created_at + if latest_setting.updater: + updated_by = f"{latest_setting.updater.first_name} {latest_setting.updater.last_name}" + + for setting in theme_settings: + key = setting.setting_key.replace('theme.', '') + value = setting.setting_value + + if key == 'colors' and value: + try: + config['colors'] = json.loads(value) + except json.JSONDecodeError: + pass + elif key in config: + config[key] = value + + return { + "config": config, + "is_default": is_default, + "updated_at": updated_at.isoformat() if updated_at else None, + "updated_by": updated_by + } + + +class ThemeSettingsUpdate(BaseModel): + """Request model for updating theme settings""" + site_name: Optional[str] = Field(None, max_length=200, description="Full site name") + site_short_name: Optional[str] = Field(None, max_length=50, description="Short name for PWA") + site_description: Optional[str] = Field(None, max_length=500, description="Site description for SEO meta tag") + colors: Optional[dict] = Field(None, description="Color scheme as HSL values") + meta_theme_color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$', description="PWA theme color (hex)") + + +@api_router.put("/admin/settings/theme") +async def update_theme_settings( + request: ThemeSettingsUpdate, + current_user: User = Depends(require_permission("settings.edit")), + db: Session = Depends(get_db) +): + """ + Update theme settings (admin only). + + Updates one or more theme settings. Only provided fields are updated. + Changes are applied immediately and the cache is invalidated. + """ + import json + + updates = {} + + if request.site_name is not None: + set_setting( + db=db, + key='theme.site_name', + value=request.site_name, + user_id=str(current_user.id), + setting_type='plaintext', + description='Site name displayed in title and navigation', + is_sensitive=False + ) + updates['site_name'] = request.site_name + + if request.site_short_name is not None: + set_setting( + db=db, + key='theme.site_short_name', + value=request.site_short_name, + user_id=str(current_user.id), + setting_type='plaintext', + description='Short site name for PWA manifest', + is_sensitive=False + ) + updates['site_short_name'] = request.site_short_name + + if request.site_description is not None: + set_setting( + db=db, + key='theme.site_description', + value=request.site_description, + user_id=str(current_user.id), + setting_type='plaintext', + description='Site description for SEO meta tag', + is_sensitive=False + ) + updates['site_description'] = request.site_description + + if request.colors is not None: + set_setting( + db=db, + key='theme.colors', + value=json.dumps(request.colors), + user_id=str(current_user.id), + setting_type='json', + description='Theme color scheme as HSL values', + is_sensitive=False + ) + updates['colors'] = request.colors + + if request.meta_theme_color is not None: + set_setting( + db=db, + key='theme.meta_theme_color', + value=request.meta_theme_color, + user_id=str(current_user.id), + setting_type='plaintext', + description='PWA theme-color meta tag value', + is_sensitive=False + ) + updates['meta_theme_color'] = request.meta_theme_color + + # Invalidate cache + invalidate_theme_cache() + + return { + "success": True, + "message": "Theme settings updated successfully", + "updated_fields": list(updates.keys()), + "updated_at": datetime.now(timezone.utc).isoformat(), + "updated_by": f"{current_user.first_name} {current_user.last_name}" + } + + +@api_router.post("/admin/settings/theme/logo") +async def upload_theme_logo( + file: UploadFile = File(...), + current_user: User = Depends(require_permission("settings.edit")), + db: Session = Depends(get_db) +): + """ + Upload organization logo (admin only). + + Accepts PNG, JPEG, WebP, or SVG images. + Replaces any existing logo. + """ + r2 = get_r2_storage() + + # Get current logo key for deletion + old_logo_key = get_setting(db, 'theme.logo_key') + + # Delete old logo if exists + if old_logo_key: + try: + await r2.delete_file(old_logo_key) + except Exception as e: + print(f"Warning: Failed to delete old logo: {e}") + + # Upload new logo + try: + public_url, object_key, file_size = await r2.upload_file( + file=file, + folder="branding", + allowed_types=r2.ALLOWED_BRANDING_TYPES, + max_size_bytes=5 * 1024 * 1024 # 5MB limit for logos + ) + + # Store URL and key in settings + set_setting( + db=db, + key='theme.logo_url', + value=public_url, + user_id=str(current_user.id), + setting_type='plaintext', + description='Organization logo URL', + is_sensitive=False + ) + + set_setting( + db=db, + key='theme.logo_key', + value=object_key, + user_id=str(current_user.id), + setting_type='plaintext', + description='R2 object key for logo (for deletion)', + is_sensitive=False + ) + + # Invalidate cache + invalidate_theme_cache() + + return { + "success": True, + "message": "Logo uploaded successfully", + "logo_url": public_url, + "file_size": file_size + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to upload logo: {str(e)}" + ) + + +@api_router.post("/admin/settings/theme/favicon") +async def upload_theme_favicon( + file: UploadFile = File(...), + current_user: User = Depends(require_permission("settings.edit")), + db: Session = Depends(get_db) +): + """ + Upload site favicon (admin only). + + Accepts ICO, PNG, or SVG images. + Replaces any existing favicon. + """ + r2 = get_r2_storage() + + # Get current favicon key for deletion + old_favicon_key = get_setting(db, 'theme.favicon_key') + + # Delete old favicon if exists + if old_favicon_key: + try: + await r2.delete_file(old_favicon_key) + except Exception as e: + print(f"Warning: Failed to delete old favicon: {e}") + + # Upload new favicon + try: + public_url, object_key, file_size = await r2.upload_file( + file=file, + folder="branding", + allowed_types=r2.ALLOWED_FAVICON_TYPES, + max_size_bytes=1 * 1024 * 1024 # 1MB limit for favicons + ) + + # Store URL and key in settings + set_setting( + db=db, + key='theme.favicon_url', + value=public_url, + user_id=str(current_user.id), + setting_type='plaintext', + description='Site favicon URL', + is_sensitive=False + ) + + set_setting( + db=db, + key='theme.favicon_key', + value=object_key, + user_id=str(current_user.id), + setting_type='plaintext', + description='R2 object key for favicon (for deletion)', + is_sensitive=False + ) + + # Invalidate cache + invalidate_theme_cache() + + return { + "success": True, + "message": "Favicon uploaded successfully", + "favicon_url": public_url, + "file_size": file_size + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to upload favicon: {str(e)}" + ) + + +@api_router.delete("/admin/settings/theme/logo") +async def delete_theme_logo( + current_user: User = Depends(require_permission("settings.edit")), + db: Session = Depends(get_db) +): + """ + Delete organization logo (admin only). + + Removes the logo from R2 storage and clears the settings, + reverting to the default logo. + """ + r2 = get_r2_storage() + + # Get current logo key for deletion + logo_key = get_setting(db, 'theme.logo_key') + + if logo_key: + try: + await r2.delete_file(logo_key) + except Exception as e: + print(f"Warning: Failed to delete logo from R2: {e}") + + # Delete the settings + db.query(SystemSettings).filter( + SystemSettings.setting_key.in_(['theme.logo_url', 'theme.logo_key']) + ).delete(synchronize_session=False) + db.commit() + + # Invalidate cache + invalidate_theme_cache() + + return { + "success": True, + "message": "Logo deleted successfully" + } + + +@api_router.delete("/admin/settings/theme/favicon") +async def delete_theme_favicon( + current_user: User = Depends(require_permission("settings.edit")), + db: Session = Depends(get_db) +): + """ + Delete site favicon (admin only). + + Removes the favicon from R2 storage and clears the settings, + reverting to the default favicon. + """ + r2 = get_r2_storage() + + # Get current favicon key for deletion + favicon_key = get_setting(db, 'theme.favicon_key') + + if favicon_key: + try: + await r2.delete_file(favicon_key) + except Exception as e: + print(f"Warning: Failed to delete favicon from R2: {e}") + + # Delete the settings + db.query(SystemSettings).filter( + SystemSettings.setting_key.in_(['theme.favicon_url', 'theme.favicon_key']) + ).delete(synchronize_session=False) + db.commit() + + # Invalidate cache + invalidate_theme_cache() + + return { + "success": True, + "message": "Favicon deleted successfully" + } + + +@api_router.post("/admin/settings/theme/reset") +async def reset_theme_settings( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Reset all theme settings to defaults (superadmin only). + + Deletes all theme.* settings from the database and removes + any uploaded logo/favicon from R2 storage. + """ + r2 = get_r2_storage() + + # Get keys for uploaded files + logo_key = get_setting(db, 'theme.logo_key') + favicon_key = get_setting(db, 'theme.favicon_key') + + # Delete files from R2 + if logo_key: + try: + await r2.delete_file(logo_key) + except Exception as e: + print(f"Warning: Failed to delete logo from R2: {e}") + + if favicon_key: + try: + await r2.delete_file(favicon_key) + except Exception as e: + print(f"Warning: Failed to delete favicon from R2: {e}") + + # Delete all theme settings + deleted_count = db.query(SystemSettings).filter( + SystemSettings.setting_key.like('theme.%') + ).delete(synchronize_session=False) + db.commit() + + # Invalidate cache + invalidate_theme_cache() + + return { + "success": True, + "message": "Theme settings reset to defaults", + "deleted_settings_count": deleted_count, + "config": DEFAULT_THEME_CONFIG + } + + # Include the router in the main app app.include_router(api_router) -- 2.39.5 From 9754f2db6e3412c7a4cbcec811f4e02f098181df Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Sat, 31 Jan 2026 01:03:17 +0700 Subject: [PATCH 04/10] 1. Models (backend/models.py)- Added PaymentMethodType enum (card, cash, bank_transfer, check)- Added stripe_customer_id column to User model- Created new PaymentMethod model with all fields specified in the plan2. Alembic Migration (backend/alembic/versions/add_payment_methods.py)- Creates payment_methods table- Adds stripe_customer_id to users table- Creates appropriate indexes3. API Endpoints (backend/server.py)Added 12 new endpoints:Member Endpoints:- GET /api/payment-methods - List user's payment methods- POST /api/payment-methods/setup-intent - Create Stripe SetupIntent- POST /api/payment-methods - Save payment method after setup- PUT /api/payment-methods/{id}/default - Set as default- DELETE /api/payment-methods/{id} - Remove payment methodAdmin Endpoints:- GET /api/admin/users/{user_id}/payment-methods - List user's methods (masked)- POST /api/admin/users/{user_id}/payment-methods/reveal - Reveal sensitive details (requires password)- POST /api/admin/users/{user_id}/payment-methods/setup-intent - Create SetupIntent for user- POST /api/admin/users/{user_id}/payment-methods - Save method on behalf- POST /api/admin/users/{user_id}/payment-methods/manual - Record manual method (cash/check)- PUT /api/admin/users/{user_id}/payment-methods/{id}/default - Set default- DELETE /api/admin/users/{user_id}/payment-methods/{id} - Delete method4. Permissions (backend/permissions_seed.py)Added 5 new permissions:- payment_methods.view- payment_methods.view_sensitive- payment_methods.create- payment_methods.delete- payment_methods.set_default --- __pycache__/models.cpython-312.pyc | Bin 33150 -> 36353 bytes __pycache__/server.cpython-312.pyc | Bin 330986 -> 361724 bytes alembic/versions/add_payment_methods.py | 86 +++ models.py | 56 ++ permissions_seed.py | 66 +++ server.py | 689 +++++++++++++++++++++++- 6 files changed, 896 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/add_payment_methods.py diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc index 5c0f75bf9f3e828c63c50d81b6b460a09fa6aa73..9b570f464fcc36a4ead9a47515330b67ccb28144 100644 GIT binary patch delta 7970 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 Y_YNi51@jBd_YClJZ=VqIOjv~f1!>R=*8l(j delta 5758 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 sIgQ%E-b`O7{A}W{Fa%GthLg|DH+u*u97 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 RPNGxH`R`9^t8?R4#p=^E0VQ{xa=`xSh5 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 delta 51448 zcmce<2Y3`!7dM`n+1d2o8|gsk9TY)8KtK#Apb~`;vI_*#m`x}_4fSDlm9wl?NYUWjJM3Y)RWzr7!wmJs>FB) zEPcyaP(Ckx+dN+#p5N@PTKaC3+z&*Vp=O%jsYzXJ-ZoF+vfeQ52lb(zm+uJtYuW}iBe#-g|pM!zBI&;meY5QrQr`nso z$nEK~&Q*J|8gpcfr|-HLPd{(=ZJI<^dx|;8mECnJ=MAL1L5{pGP903CLma7VIdv$d z4s)cQ#;L<8b%Z1JbWR;fsiPdJ|Kij`DD_a8S_EQh*KzV_NGP>Uc_>;7C22Q;(q3iH_9uoH~h8Cp%Kl;nX83b&4bPTuz-zsncYtB=9^=K8lj3 zI|~2X+@NQ7ozHnQC~u}C?*dLehEk7pq;BBUS(G~4k$NGg&Y{%f9H|#^>hY9%f+N-B z)VY*eD^n$b7jyE7lzft-@FnIrcXroC&a0!m1&%zysr8h)(2;s6r#4Wk$B}v&r+O)M zkt6kTPF+l?OB|`2IJJ>dm&#Q3Ftt~BJ2oidu~Ie5{J~wEe+rwY%hSA0xBA0X-j0wZ zkB%+MzdBf?%~9kU3rm`3d8i9er_#JN=Gx4Pbf2T_wN_b|r`_9S?usqWKQ)MRg`?bc zRyngzT(S9QY+U*(NA~r0c6(fL{%JuBosJ?maFJ_I*Rst=<5H9wb6jG6Nt5?iTqSZY@kR@XW(-eMIspN-GaH`}kj#uw?g zTCbUBL}_nqLa~0E#X;&B5p5AKT9fw-^O}TQeT#+3d^sV_d}2agtmawoE%aV(?oTMx zZ@03QbItoE^eQ>ed$mIb|8`J(hgH-(H!+1URhSPYChK=vbOe(NsO>Y&l%!mJtF4Ez zNyTW}e11}Dmgiyz*1J$MN={d1nPV%el&j6H$+@xjI?$(I?UWO#jtdq$`Mt(L*?krP=8-9qu2m_?y5CN| zl9LNct_+je4GtXl+m!v5Ql`!F++@zoFG+QIP{s7ddTwS-OiL|{-R_{&>`F}|Ha4VI zg;BS~f%yT(%mz$_3G?lQxvb(g!h5rWoH)-Nj!F+&v?t$5GCemWQ}g}_1I+W%lJ$qI z0sc$; zd^Rgbf6jjWH4BP+5sEWgQxei&a@2a>E;ctiyx1#_VlUXm9?UK!n|U`ozwB!06ufr0#qU{3Ta#VcDt|)TysHLqry$+WZ-nOa+=;>uf z!Y(EZU6SV=2f9~mbk(^%2;CjIxzuMD*u%Wh zua4sH*u`fTmJq|c3d?jBqIr2?VeGre4mtUk3*p;{fG})-IIz8EV;fNf@og=tQiR!3 zl&imQXI&M_D!J5R!o5N9#5gd0U>A=o&SXsGJ*kXJ+O^D_-Ze*^U6NRH=Gbg=bunn& zT2x*crIPNb^dW4Pt9)8qR2UowQJq*vnU8E@2K6XWHkmVfWa}T>iKq4$sLZ;ktTF+S zf$Aoa`MujCU;o6yomyh`Sc>A@W6fzL#rmhA66t9NX?lKnuaafE?R6JRZQlE$Ln~kM;WDM*x2D^yHbnn#!^Su8dnD70MCDa6ZFK}S^!fxk_(p

q~$fd0Gnw!WvN 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 None: + # Create PaymentMethodType enum + paymentmethodtype = postgresql.ENUM( + 'card', 'cash', 'bank_transfer', 'check', + name='paymentmethodtype', + create_type=False + ) + paymentmethodtype.create(op.get_bind(), checkfirst=True) + + # Add stripe_customer_id to users table + op.add_column('users', sa.Column( + 'stripe_customer_id', + sa.String(), + nullable=True, + comment='Stripe Customer ID for payment method management' + )) + op.create_index('ix_users_stripe_customer_id', 'users', ['stripe_customer_id']) + + # Create payment_methods table + op.create_table( + 'payment_methods', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('stripe_payment_method_id', sa.String(), nullable=True, unique=True, comment='Stripe pm_xxx reference'), + sa.Column('card_brand', sa.String(20), nullable=True, comment='Card brand: visa, mastercard, amex, etc.'), + sa.Column('card_last4', sa.String(4), nullable=True, comment='Last 4 digits of card'), + sa.Column('card_exp_month', sa.Integer(), nullable=True, comment='Card expiration month'), + sa.Column('card_exp_year', sa.Integer(), nullable=True, comment='Card expiration year'), + sa.Column('card_funding', sa.String(20), nullable=True, comment='Card funding type: credit, debit, prepaid'), + sa.Column('payment_type', paymentmethodtype, nullable=False, server_default='card'), + sa.Column('is_default', sa.Boolean(), nullable=False, server_default='false', comment='Whether this is the default payment method for auto-renewals'), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='Soft delete flag - False means removed'), + sa.Column('is_manual', sa.Boolean(), nullable=False, server_default='false', comment='True for manually recorded methods (cash/check)'), + sa.Column('manual_notes', sa.Text(), nullable=True, comment='Admin notes for manual payment methods'), + sa.Column('created_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, comment='Admin who added this on behalf of user'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + ) + + # Create indexes + op.create_index('ix_payment_methods_user_id', 'payment_methods', ['user_id']) + op.create_index('ix_payment_methods_stripe_pm_id', 'payment_methods', ['stripe_payment_method_id']) + op.create_index('idx_payment_method_user_default', 'payment_methods', ['user_id', 'is_default']) + op.create_index('idx_payment_method_active', 'payment_methods', ['user_id', 'is_active']) + + +def downgrade() -> None: + # Drop indexes + op.drop_index('idx_payment_method_active', table_name='payment_methods') + op.drop_index('idx_payment_method_user_default', table_name='payment_methods') + op.drop_index('ix_payment_methods_stripe_pm_id', table_name='payment_methods') + op.drop_index('ix_payment_methods_user_id', table_name='payment_methods') + + # Drop payment_methods table + op.drop_table('payment_methods') + + # Drop stripe_customer_id from users + op.drop_index('ix_users_stripe_customer_id', table_name='users') + op.drop_column('users', 'stripe_customer_id') + + # Drop PaymentMethodType enum + paymentmethodtype = postgresql.ENUM( + 'card', 'cash', 'bank_transfer', 'check', + name='paymentmethodtype' + ) + paymentmethodtype.drop(op.get_bind(), checkfirst=True) diff --git a/models.py b/models.py index 930ce27..4e3a4c6 100644 --- a/models.py +++ b/models.py @@ -44,6 +44,13 @@ class DonationStatus(enum.Enum): completed = "completed" failed = "failed" + +class PaymentMethodType(enum.Enum): + card = "card" + cash = "cash" + bank_transfer = "bank_transfer" + check = "check" + class User(Base): __tablename__ = "users" @@ -141,6 +148,9 @@ class User(Base): role_changed_at = Column(DateTime(timezone=True), nullable=True, comment="Timestamp when role was last changed") role_changed_by = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='SET NULL'), nullable=True, comment="Admin who changed the role") + # 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") + 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)) @@ -150,6 +160,52 @@ class User(Base): rsvps = relationship("EventRSVP", back_populates="user") subscriptions = relationship("Subscription", back_populates="user", foreign_keys="Subscription.user_id") role_changer = relationship("User", foreign_keys=[role_changed_by], remote_side="User.id", post_update=True) + payment_methods = relationship("PaymentMethod", back_populates="user", foreign_keys="PaymentMethod.user_id") + + +class PaymentMethod(Base): + """Stored payment methods for users (Stripe or manual records)""" + __tablename__ = "payment_methods" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + + # Stripe payment method reference + stripe_payment_method_id = Column(String, nullable=True, unique=True, index=True, comment="Stripe pm_xxx reference") + + # Card details (stored for display purposes - PCI compliant) + card_brand = Column(String(20), nullable=True, comment="Card brand: visa, mastercard, amex, etc.") + card_last4 = Column(String(4), nullable=True, comment="Last 4 digits of card") + card_exp_month = Column(Integer, nullable=True, comment="Card expiration month") + card_exp_year = Column(Integer, nullable=True, comment="Card expiration year") + card_funding = Column(String(20), nullable=True, comment="Card funding type: credit, debit, prepaid") + + # Payment type classification + payment_type = Column(SQLEnum(PaymentMethodType), default=PaymentMethodType.card, nullable=False) + + # Status flags + is_default = Column(Boolean, default=False, nullable=False, comment="Whether this is the default payment method for auto-renewals") + is_active = Column(Boolean, default=True, nullable=False, comment="Soft delete flag - False means removed") + is_manual = Column(Boolean, default=False, nullable=False, comment="True for manually recorded methods (cash/check)") + + # Manual payment notes (for cash/check records) + manual_notes = Column(Text, nullable=True, comment="Admin notes for manual payment methods") + + # Audit trail + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, comment="Admin who added this on behalf of user") + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + + # Relationships + user = relationship("User", back_populates="payment_methods", foreign_keys=[user_id]) + creator = relationship("User", foreign_keys=[created_by]) + + # Composite index for efficient queries + __table_args__ = ( + Index('idx_payment_method_user_default', 'user_id', 'is_default'), + Index('idx_payment_method_active', 'user_id', 'is_active'), + ) + class Event(Base): __tablename__ = "events" diff --git a/permissions_seed.py b/permissions_seed.py index 13e42f6..4206238 100644 --- a/permissions_seed.py +++ b/permissions_seed.py @@ -327,6 +327,38 @@ PERMISSIONS = [ "module": "gallery" }, + # ========== PAYMENT METHODS MODULE ========== + { + "code": "payment_methods.view", + "name": "View Payment Methods", + "description": "View user payment methods (masked)", + "module": "payment_methods" + }, + { + "code": "payment_methods.view_sensitive", + "name": "View Sensitive Payment Details", + "description": "View full payment method details including Stripe IDs (requires password)", + "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": "Delete user payment methods", + "module": "payment_methods" + }, + { + "code": "payment_methods.set_default", + "name": "Set Default Payment Method", + "description": "Set a user's default payment method", + "module": "payment_methods" + }, + # ========== SETTINGS MODULE ========== { "code": "settings.view", @@ -453,6 +485,10 @@ DEFAULT_ROLE_PERMISSIONS = { "gallery.edit", "gallery.delete", "gallery.moderate", + "payment_methods.view", + "payment_methods.create", + "payment_methods.delete", + "payment_methods.set_default", "settings.view", "settings.edit", "settings.email_templates", @@ -460,6 +496,36 @@ DEFAULT_ROLE_PERMISSIONS = { "settings.logs", ], + UserRole.finance: [ + # Finance role has all admin permissions plus sensitive payment access + "users.view", + "users.export", + "events.view", + "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", + "bylaws.view", + "gallery.view", + "payment_methods.view", + "payment_methods.view_sensitive", # Finance can view sensitive payment details + "payment_methods.create", + "payment_methods.delete", + "payment_methods.set_default", + "settings.view", + ], + # Superadmin gets all permissions automatically in code, # so we don't need to explicitly assign them UserRole.superadmin: [] diff --git a/server.py b/server.py index 3706a38..ff70028 100644 --- a/server.py +++ b/server.py @@ -17,7 +17,7 @@ import csv import io from database import engine, get_db, Base -from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus, ImportRollbackAudit, Donation, DonationType, DonationStatus, SystemSettings +from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus, ImportRollbackAudit, Donation, DonationType, DonationStatus, SystemSettings, PaymentMethod, PaymentMethodType from auth import ( get_password_hash, verify_password, @@ -6448,6 +6448,693 @@ async def create_donation_checkout( logger.error(f"Error creating donation checkout: {str(e)}") raise HTTPException(status_code=500, detail="Failed to create donation checkout") +# ============================================================ +# Payment Method Management API Endpoints +# ============================================================ + +class PaymentMethodResponse(BaseModel): + id: str + card_brand: Optional[str] = None + card_last4: Optional[str] = None + card_exp_month: Optional[int] = None + card_exp_year: Optional[int] = None + card_funding: Optional[str] = None + payment_type: str + is_default: bool + is_manual: bool + manual_notes: Optional[str] = None + created_at: datetime + + model_config = {"from_attributes": True} + +class PaymentMethodSaveRequest(BaseModel): + stripe_payment_method_id: str + set_as_default: bool = False + +class AdminManualPaymentMethodRequest(BaseModel): + payment_type: Literal["cash", "bank_transfer", "check"] + manual_notes: Optional[str] = None + set_as_default: bool = False + +class AdminRevealRequest(BaseModel): + password: str + + +def get_or_create_stripe_customer(user: User, db: Session) -> str: + """Get existing or create new Stripe customer for user.""" + import stripe + + # Get Stripe API key + stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True) + if not stripe_key: + stripe_key = os.getenv("STRIPE_SECRET_KEY") + if not stripe_key: + raise HTTPException(status_code=500, detail="Stripe API key not configured") + stripe.api_key = stripe_key + + if user.stripe_customer_id: + # Verify customer still exists in Stripe + try: + customer = stripe.Customer.retrieve(user.stripe_customer_id) + # Check if customer was deleted using getattr (Stripe SDK doesn't expose 'deleted' directly) + if getattr(customer, 'deleted', False) or customer.get('deleted', False): + # Customer was deleted, create a new one + user.stripe_customer_id = None + else: + return user.stripe_customer_id + except stripe.error.InvalidRequestError: + # Customer doesn't exist, create a new one + user.stripe_customer_id = None + except Exception as e: + logger.warning(f"Error retrieving Stripe customer {user.stripe_customer_id}: {str(e)}") + user.stripe_customer_id = None + + # Create new Stripe customer + customer = stripe.Customer.create( + email=user.email, + name=f"{user.first_name} {user.last_name}", + metadata={"user_id": str(user.id)} + ) + + user.stripe_customer_id = customer.id + db.commit() + logger.info(f"Created Stripe customer {customer.id} for user {user.id}") + + return customer.id + + +@api_router.get("/payment-methods") +async def list_payment_methods( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """List current user's saved payment methods.""" + methods = db.query(PaymentMethod).filter( + PaymentMethod.user_id == current_user.id, + PaymentMethod.is_active == True + ).order_by(PaymentMethod.is_default.desc(), PaymentMethod.created_at.desc()).all() + + return [{ + "id": str(m.id), + "card_brand": m.card_brand, + "card_last4": m.card_last4, + "card_exp_month": m.card_exp_month, + "card_exp_year": m.card_exp_year, + "card_funding": m.card_funding, + "payment_type": m.payment_type.value, + "is_default": m.is_default, + "is_manual": m.is_manual, + "manual_notes": m.manual_notes if m.is_manual else None, + "created_at": m.created_at.isoformat() + } for m in methods] + + +@api_router.post("/payment-methods/setup-intent") +async def create_setup_intent( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Create a Stripe SetupIntent for adding a new payment method.""" + import stripe + + # Get or create Stripe customer + customer_id = get_or_create_stripe_customer(current_user, db) + + # Get Stripe API key + stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True) + if not stripe_key: + stripe_key = os.getenv("STRIPE_SECRET_KEY") + stripe.api_key = stripe_key + + # Create SetupIntent + setup_intent = stripe.SetupIntent.create( + customer=customer_id, + payment_method_types=["card"], + metadata={"user_id": str(current_user.id)} + ) + + logger.info(f"Created SetupIntent for user {current_user.id}") + + return { + "client_secret": setup_intent.client_secret, + "setup_intent_id": setup_intent.id + } + + +@api_router.post("/payment-methods") +async def save_payment_method( + request: PaymentMethodSaveRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Save a payment method after successful SetupIntent confirmation.""" + import stripe + + # Get Stripe API key + stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True) + if not stripe_key: + stripe_key = os.getenv("STRIPE_SECRET_KEY") + stripe.api_key = stripe_key + + # Refresh user from DB to get latest stripe_customer_id + db.refresh(current_user) + + # Retrieve payment method from Stripe + try: + pm = stripe.PaymentMethod.retrieve(request.stripe_payment_method_id) + except stripe.error.InvalidRequestError as e: + logger.error(f"Invalid payment method ID: {request.stripe_payment_method_id}, error: {str(e)}") + raise HTTPException(status_code=400, detail="Invalid payment method ID") + + # Verify ownership - payment method must be attached to user's customer + pm_customer = pm.customer if hasattr(pm, 'customer') else None + logger.info(f"Verifying PM ownership: pm.customer={pm_customer}, user.stripe_customer_id={current_user.stripe_customer_id}") + + if not current_user.stripe_customer_id: + raise HTTPException(status_code=403, detail="User does not have a Stripe customer ID") + + if not pm_customer: + raise HTTPException(status_code=403, detail="Payment method is not attached to any customer") + + if pm_customer != current_user.stripe_customer_id: + raise HTTPException(status_code=403, detail="Payment method not owned by user") + + # Check for duplicate + existing = db.query(PaymentMethod).filter( + PaymentMethod.stripe_payment_method_id == request.stripe_payment_method_id, + PaymentMethod.is_active == True + ).first() + + if existing: + raise HTTPException(status_code=400, detail="Payment method already saved") + + # Handle default setting - unset others if setting this as default + if request.set_as_default: + db.query(PaymentMethod).filter( + PaymentMethod.user_id == current_user.id, + PaymentMethod.is_active == True + ).update({"is_default": False}) + + # Extract card details + card = pm.card if pm.type == "card" else None + + # Create payment method record + payment_method = PaymentMethod( + user_id=current_user.id, + stripe_payment_method_id=request.stripe_payment_method_id, + card_brand=card.brand if card else None, + card_last4=card.last4 if card else None, + card_exp_month=card.exp_month if card else None, + card_exp_year=card.exp_year if card else None, + card_funding=card.funding if card else None, + payment_type=PaymentMethodType.card, + is_default=request.set_as_default, + is_active=True, + is_manual=False + ) + + db.add(payment_method) + db.commit() + db.refresh(payment_method) + + logger.info(f"Saved payment method {payment_method.id} for user {current_user.id}") + + return { + "id": str(payment_method.id), + "card_brand": payment_method.card_brand, + "card_last4": payment_method.card_last4, + "card_exp_month": payment_method.card_exp_month, + "card_exp_year": payment_method.card_exp_year, + "is_default": payment_method.is_default, + "message": "Payment method saved successfully" + } + + +@api_router.put("/payment-methods/{payment_method_id}/default") +async def set_default_payment_method( + payment_method_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Set a payment method as the default for auto-renewals.""" + try: + pm_uuid = uuid.UUID(payment_method_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid payment method ID") + + payment_method = db.query(PaymentMethod).filter( + PaymentMethod.id == pm_uuid, + PaymentMethod.user_id == current_user.id, + PaymentMethod.is_active == True + ).first() + + if not payment_method: + raise HTTPException(status_code=404, detail="Payment method not found") + + # Unset all other defaults + db.query(PaymentMethod).filter( + PaymentMethod.user_id == current_user.id, + PaymentMethod.is_active == True + ).update({"is_default": False}) + + # Set this one as default + payment_method.is_default = True + db.commit() + + logger.info(f"Set default payment method {payment_method_id} for user {current_user.id}") + + return {"message": "Default payment method updated", "id": str(payment_method.id)} + + +@api_router.delete("/payment-methods/{payment_method_id}") +async def delete_payment_method( + payment_method_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Delete (soft-delete) a saved payment method.""" + import stripe + + try: + pm_uuid = uuid.UUID(payment_method_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid payment method ID") + + payment_method = db.query(PaymentMethod).filter( + PaymentMethod.id == pm_uuid, + PaymentMethod.user_id == current_user.id, + PaymentMethod.is_active == True + ).first() + + if not payment_method: + raise HTTPException(status_code=404, detail="Payment method not found") + + # Detach from Stripe if it's a Stripe payment method + if payment_method.stripe_payment_method_id: + try: + stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True) + if not stripe_key: + stripe_key = os.getenv("STRIPE_SECRET_KEY") + stripe.api_key = stripe_key + + stripe.PaymentMethod.detach(payment_method.stripe_payment_method_id) + logger.info(f"Detached Stripe payment method {payment_method.stripe_payment_method_id}") + except stripe.error.StripeError as e: + logger.warning(f"Failed to detach Stripe payment method: {str(e)}") + + # Soft delete + payment_method.is_active = False + payment_method.is_default = False + db.commit() + + logger.info(f"Deleted payment method {payment_method_id} for user {current_user.id}") + + return {"message": "Payment method deleted"} + + +# ============================================================ +# Admin Payment Method Management Endpoints +# ============================================================ + +@api_router.get("/admin/users/{user_id}/payment-methods") +async def admin_list_user_payment_methods( + user_id: str, + current_user: User = Depends(require_permission("payment_methods.view")), + db: Session = Depends(get_db) +): + """Admin: List a user's payment methods (masked).""" + try: + user_uuid = uuid.UUID(user_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid user ID") + + user = db.query(User).filter(User.id == user_uuid).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + methods = db.query(PaymentMethod).filter( + PaymentMethod.user_id == user_uuid, + PaymentMethod.is_active == True + ).order_by(PaymentMethod.is_default.desc(), PaymentMethod.created_at.desc()).all() + + return [{ + "id": str(m.id), + "card_brand": m.card_brand, + "card_last4": m.card_last4, + "card_exp_month": m.card_exp_month, + "card_exp_year": m.card_exp_year, + "card_funding": m.card_funding, + "payment_type": m.payment_type.value, + "is_default": m.is_default, + "is_manual": m.is_manual, + "manual_notes": m.manual_notes if m.is_manual else None, + "created_at": m.created_at.isoformat(), + # Sensitive data masked + "stripe_payment_method_id": None + } for m in methods] + + +@api_router.post("/admin/users/{user_id}/payment-methods/reveal") +async def admin_reveal_payment_details( + user_id: str, + request: AdminRevealRequest, + current_user: User = Depends(require_permission("payment_methods.view_sensitive")), + db: Session = Depends(get_db) +): + """Admin: Reveal full payment method details (requires password confirmation).""" + try: + user_uuid = uuid.UUID(user_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid user ID") + + # Verify admin's password + if not verify_password(request.password, current_user.password_hash): + logger.warning(f"Admin {current_user.email} failed password verification for payment reveal") + raise HTTPException(status_code=401, detail="Invalid password") + + user = db.query(User).filter(User.id == user_uuid).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + methods = db.query(PaymentMethod).filter( + PaymentMethod.user_id == user_uuid, + PaymentMethod.is_active == True + ).order_by(PaymentMethod.is_default.desc(), PaymentMethod.created_at.desc()).all() + + # Log sensitive access + logger.info(f"Admin {current_user.email} revealed payment details for user {user_id}") + + return [{ + "id": str(m.id), + "card_brand": m.card_brand, + "card_last4": m.card_last4, + "card_exp_month": m.card_exp_month, + "card_exp_year": m.card_exp_year, + "card_funding": m.card_funding, + "payment_type": m.payment_type.value, + "is_default": m.is_default, + "is_manual": m.is_manual, + "manual_notes": m.manual_notes, + "created_at": m.created_at.isoformat(), + "stripe_payment_method_id": m.stripe_payment_method_id + } for m in methods] + + +@api_router.post("/admin/users/{user_id}/payment-methods/setup-intent") +async def admin_create_setup_intent_for_user( + user_id: str, + current_user: User = Depends(require_permission("payment_methods.create")), + db: Session = Depends(get_db) +): + """Admin: Create a SetupIntent for adding a card on behalf of a user.""" + import stripe + + try: + user_uuid = uuid.UUID(user_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid user ID") + + user = db.query(User).filter(User.id == user_uuid).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Get or create Stripe customer for the target user + customer_id = get_or_create_stripe_customer(user, db) + + # Get Stripe API key + stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True) + if not stripe_key: + stripe_key = os.getenv("STRIPE_SECRET_KEY") + stripe.api_key = stripe_key + + # Create SetupIntent + setup_intent = stripe.SetupIntent.create( + customer=customer_id, + payment_method_types=["card"], + metadata={ + "user_id": str(user.id), + "created_by_admin": str(current_user.id) + } + ) + + logger.info(f"Admin {current_user.email} created SetupIntent for user {user_id}") + + return { + "client_secret": setup_intent.client_secret, + "setup_intent_id": setup_intent.id + } + + +@api_router.post("/admin/users/{user_id}/payment-methods") +async def admin_save_payment_method_for_user( + user_id: str, + request: PaymentMethodSaveRequest, + current_user: User = Depends(require_permission("payment_methods.create")), + db: Session = Depends(get_db) +): + """Admin: Save a payment method on behalf of a user.""" + import stripe + + try: + user_uuid = uuid.UUID(user_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid user ID") + + user = db.query(User).filter(User.id == user_uuid).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Refresh user to get latest data + db.refresh(user) + + # Get Stripe API key + stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True) + if not stripe_key: + stripe_key = os.getenv("STRIPE_SECRET_KEY") + stripe.api_key = stripe_key + + # Retrieve payment method from Stripe + try: + pm = stripe.PaymentMethod.retrieve(request.stripe_payment_method_id) + except stripe.error.InvalidRequestError as e: + logger.error(f"Invalid payment method ID: {request.stripe_payment_method_id}, error: {str(e)}") + raise HTTPException(status_code=400, detail="Invalid payment method ID") + + # Verify ownership - payment method must be attached to user's customer + pm_customer = pm.customer if hasattr(pm, 'customer') else None + logger.info(f"Admin verifying PM ownership: pm.customer={pm_customer}, user.stripe_customer_id={user.stripe_customer_id}") + + if not user.stripe_customer_id: + raise HTTPException(status_code=403, detail="User does not have a Stripe customer ID") + + if not pm_customer: + raise HTTPException(status_code=403, detail="Payment method is not attached to any customer") + + if pm_customer != user.stripe_customer_id: + raise HTTPException(status_code=403, detail="Payment method not attached to user's Stripe customer") + + # Check for duplicate + existing = db.query(PaymentMethod).filter( + PaymentMethod.stripe_payment_method_id == request.stripe_payment_method_id, + PaymentMethod.is_active == True + ).first() + + if existing: + raise HTTPException(status_code=400, detail="Payment method already saved") + + # Handle default setting + if request.set_as_default: + db.query(PaymentMethod).filter( + PaymentMethod.user_id == user.id, + PaymentMethod.is_active == True + ).update({"is_default": False}) + + # Extract card details + card = pm.card if pm.type == "card" else None + + # Create payment method record + payment_method = PaymentMethod( + user_id=user.id, + stripe_payment_method_id=request.stripe_payment_method_id, + card_brand=card.brand if card else None, + card_last4=card.last4 if card else None, + card_exp_month=card.exp_month if card else None, + card_exp_year=card.exp_year if card else None, + card_funding=card.funding if card else None, + payment_type=PaymentMethodType.card, + is_default=request.set_as_default, + is_active=True, + is_manual=False, + created_by=current_user.id + ) + + db.add(payment_method) + db.commit() + db.refresh(payment_method) + + logger.info(f"Admin {current_user.email} saved payment method {payment_method.id} for user {user_id}") + + return { + "id": str(payment_method.id), + "card_brand": payment_method.card_brand, + "card_last4": payment_method.card_last4, + "message": "Payment method saved successfully" + } + + +@api_router.post("/admin/users/{user_id}/payment-methods/manual") +async def admin_record_manual_payment_method( + user_id: str, + request: AdminManualPaymentMethodRequest, + current_user: User = Depends(require_permission("payment_methods.create")), + db: Session = Depends(get_db) +): + """Admin: Record a manual payment method (cash, check, bank transfer).""" + try: + user_uuid = uuid.UUID(user_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid user ID") + + user = db.query(User).filter(User.id == user_uuid).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Map payment type string to enum + payment_type_map = { + "cash": PaymentMethodType.cash, + "bank_transfer": PaymentMethodType.bank_transfer, + "check": PaymentMethodType.check + } + payment_type = payment_type_map.get(request.payment_type) + if not payment_type: + raise HTTPException(status_code=400, detail="Invalid payment type") + + # Handle default setting + if request.set_as_default: + db.query(PaymentMethod).filter( + PaymentMethod.user_id == user.id, + PaymentMethod.is_active == True + ).update({"is_default": False}) + + # Create manual payment method record + payment_method = PaymentMethod( + user_id=user.id, + stripe_payment_method_id=None, + payment_type=payment_type, + is_default=request.set_as_default, + is_active=True, + is_manual=True, + manual_notes=request.manual_notes, + created_by=current_user.id + ) + + db.add(payment_method) + db.commit() + db.refresh(payment_method) + + logger.info(f"Admin {current_user.email} recorded manual payment method {payment_method.id} ({payment_type.value}) for user {user_id}") + + return { + "id": str(payment_method.id), + "payment_type": payment_method.payment_type.value, + "message": "Manual payment method recorded successfully" + } + + +@api_router.put("/admin/users/{user_id}/payment-methods/{payment_method_id}/default") +async def admin_set_default_payment_method( + user_id: str, + payment_method_id: str, + current_user: User = Depends(require_permission("payment_methods.set_default")), + db: Session = Depends(get_db) +): + """Admin: Set a user's payment method as default.""" + try: + user_uuid = uuid.UUID(user_id) + pm_uuid = uuid.UUID(payment_method_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid ID format") + + user = db.query(User).filter(User.id == user_uuid).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + payment_method = db.query(PaymentMethod).filter( + PaymentMethod.id == pm_uuid, + PaymentMethod.user_id == user_uuid, + PaymentMethod.is_active == True + ).first() + + if not payment_method: + raise HTTPException(status_code=404, detail="Payment method not found") + + # Unset all other defaults + db.query(PaymentMethod).filter( + PaymentMethod.user_id == user_uuid, + PaymentMethod.is_active == True + ).update({"is_default": False}) + + # Set this one as default + payment_method.is_default = True + db.commit() + + logger.info(f"Admin {current_user.email} set default payment method {payment_method_id} for user {user_id}") + + return {"message": "Default payment method updated", "id": str(payment_method.id)} + + +@api_router.delete("/admin/users/{user_id}/payment-methods/{payment_method_id}") +async def admin_delete_payment_method( + user_id: str, + payment_method_id: str, + current_user: User = Depends(require_permission("payment_methods.delete")), + db: Session = Depends(get_db) +): + """Admin: Delete a user's payment method.""" + import stripe + + try: + user_uuid = uuid.UUID(user_id) + pm_uuid = uuid.UUID(payment_method_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid ID format") + + user = db.query(User).filter(User.id == user_uuid).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + payment_method = db.query(PaymentMethod).filter( + PaymentMethod.id == pm_uuid, + PaymentMethod.user_id == user_uuid, + PaymentMethod.is_active == True + ).first() + + if not payment_method: + raise HTTPException(status_code=404, detail="Payment method not found") + + # Detach from Stripe if it's a Stripe payment method + if payment_method.stripe_payment_method_id: + try: + stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True) + if not stripe_key: + stripe_key = os.getenv("STRIPE_SECRET_KEY") + stripe.api_key = stripe_key + + stripe.PaymentMethod.detach(payment_method.stripe_payment_method_id) + logger.info(f"Detached Stripe payment method {payment_method.stripe_payment_method_id}") + except stripe.error.StripeError as e: + logger.warning(f"Failed to detach Stripe payment method: {str(e)}") + + # Soft delete + payment_method.is_active = False + payment_method.is_default = False + db.commit() + + logger.info(f"Admin {current_user.email} deleted payment method {payment_method_id} for user {user_id}") + + return {"message": "Payment method deleted"} + + @api_router.post("/contact") async def submit_contact_form( request: ContactFormRequest, -- 2.39.5 From 6f8ec1d254d2f0866daa2c19a6bd14c5c038d177 Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Sat, 31 Jan 2026 01:16:02 +0700 Subject: [PATCH 05/10] make the migration idempotetnt --- alembic/versions/add_payment_methods.py | 82 +++++++++++++++---------- 1 file changed, 48 insertions(+), 34 deletions(-) diff --git a/alembic/versions/add_payment_methods.py b/alembic/versions/add_payment_methods.py index b9984d1..9723d1c 100644 --- a/alembic/versions/add_payment_methods.py +++ b/alembic/versions/add_payment_methods.py @@ -19,49 +19,63 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: + conn = op.get_bind() + # Create PaymentMethodType enum paymentmethodtype = postgresql.ENUM( 'card', 'cash', 'bank_transfer', 'check', name='paymentmethodtype', create_type=False ) - paymentmethodtype.create(op.get_bind(), checkfirst=True) + paymentmethodtype.create(conn, checkfirst=True) - # Add stripe_customer_id to users table - op.add_column('users', sa.Column( - 'stripe_customer_id', - sa.String(), - nullable=True, - comment='Stripe Customer ID for payment method management' - )) - op.create_index('ix_users_stripe_customer_id', 'users', ['stripe_customer_id']) + # Check if stripe_customer_id column exists on users table + result = conn.execute(sa.text(""" + SELECT column_name FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'stripe_customer_id' + """)) + if result.fetchone() is None: + # Add stripe_customer_id to users table + op.add_column('users', sa.Column( + 'stripe_customer_id', + sa.String(), + nullable=True, + comment='Stripe Customer ID for payment method management' + )) + op.create_index('ix_users_stripe_customer_id', 'users', ['stripe_customer_id']) - # Create payment_methods table - op.create_table( - 'payment_methods', - sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True), - sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), - sa.Column('stripe_payment_method_id', sa.String(), nullable=True, unique=True, comment='Stripe pm_xxx reference'), - sa.Column('card_brand', sa.String(20), nullable=True, comment='Card brand: visa, mastercard, amex, etc.'), - sa.Column('card_last4', sa.String(4), nullable=True, comment='Last 4 digits of card'), - sa.Column('card_exp_month', sa.Integer(), nullable=True, comment='Card expiration month'), - sa.Column('card_exp_year', sa.Integer(), nullable=True, comment='Card expiration year'), - sa.Column('card_funding', sa.String(20), nullable=True, comment='Card funding type: credit, debit, prepaid'), - sa.Column('payment_type', paymentmethodtype, nullable=False, server_default='card'), - sa.Column('is_default', sa.Boolean(), nullable=False, server_default='false', comment='Whether this is the default payment method for auto-renewals'), - sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='Soft delete flag - False means removed'), - sa.Column('is_manual', sa.Boolean(), nullable=False, server_default='false', comment='True for manually recorded methods (cash/check)'), - sa.Column('manual_notes', sa.Text(), nullable=True, comment='Admin notes for manual payment methods'), - sa.Column('created_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, comment='Admin who added this on behalf of user'), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), - ) + # Check if payment_methods table exists + result = conn.execute(sa.text(""" + SELECT table_name FROM information_schema.tables + WHERE table_name = 'payment_methods' + """)) + if result.fetchone() is None: + # Create payment_methods table + op.create_table( + 'payment_methods', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('stripe_payment_method_id', sa.String(), nullable=True, unique=True, comment='Stripe pm_xxx reference'), + sa.Column('card_brand', sa.String(20), nullable=True, comment='Card brand: visa, mastercard, amex, etc.'), + sa.Column('card_last4', sa.String(4), nullable=True, comment='Last 4 digits of card'), + sa.Column('card_exp_month', sa.Integer(), nullable=True, comment='Card expiration month'), + sa.Column('card_exp_year', sa.Integer(), nullable=True, comment='Card expiration year'), + sa.Column('card_funding', sa.String(20), nullable=True, comment='Card funding type: credit, debit, prepaid'), + sa.Column('payment_type', paymentmethodtype, nullable=False, server_default='card'), + sa.Column('is_default', sa.Boolean(), nullable=False, server_default='false', comment='Whether this is the default payment method for auto-renewals'), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='Soft delete flag - False means removed'), + sa.Column('is_manual', sa.Boolean(), nullable=False, server_default='false', comment='True for manually recorded methods (cash/check)'), + sa.Column('manual_notes', sa.Text(), nullable=True, comment='Admin notes for manual payment methods'), + sa.Column('created_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, comment='Admin who added this on behalf of user'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + ) - # Create indexes - op.create_index('ix_payment_methods_user_id', 'payment_methods', ['user_id']) - op.create_index('ix_payment_methods_stripe_pm_id', 'payment_methods', ['stripe_payment_method_id']) - op.create_index('idx_payment_method_user_default', 'payment_methods', ['user_id', 'is_default']) - op.create_index('idx_payment_method_active', 'payment_methods', ['user_id', 'is_active']) + # Create indexes + op.create_index('ix_payment_methods_user_id', 'payment_methods', ['user_id']) + op.create_index('ix_payment_methods_stripe_pm_id', 'payment_methods', ['stripe_payment_method_id']) + op.create_index('idx_payment_method_user_default', 'payment_methods', ['user_id', 'is_default']) + op.create_index('idx_payment_method_active', 'payment_methods', ['user_id', 'is_active']) def downgrade() -> None: -- 2.39.5 From a053075a302b2c3d73ce513ff62995e2d93141d6 Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Sat, 31 Jan 2026 01:26:12 +0700 Subject: [PATCH 06/10] Fix permission rbac --- seed_permissions_rbac.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/seed_permissions_rbac.py b/seed_permissions_rbac.py index 1b810a3..a4c407a 100755 --- a/seed_permissions_rbac.py +++ b/seed_permissions_rbac.py @@ -2,7 +2,7 @@ """ Permission Seeding Script for Dynamic RBAC System -This script populates the database with 59 granular permissions and assigns them +This script populates the database with 65 granular permissions and assigns them to the appropriate dynamic roles (not the old enum roles). Usage: @@ -33,7 +33,7 @@ engine = create_engine(DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) # ============================================================ -# Permission Definitions (59 permissions across 10 modules) +# Permission Definitions (65 permissions across 11 modules) # ============================================================ PERMISSIONS = [ @@ -116,6 +116,13 @@ PERMISSIONS = [ {"code": "permissions.assign", "name": "Assign Permissions", "description": "Assign permissions to roles", "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"}, + + # ========== PAYMENT METHODS MODULE (5) ========== + {"code": "payment_methods.view", "name": "View Payment Methods", "description": "View user payment methods (masked)", "module": "payment_methods"}, + {"code": "payment_methods.view_sensitive", "name": "View Sensitive Payment Details", "description": "View full Stripe payment method IDs (requires password)", "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.set_default", "name": "Set Default Payment Method", "description": "Set default payment method for users", "module": "payment_methods"}, ] # Default system roles that must exist @@ -170,6 +177,9 @@ DEFAULT_ROLE_PERMISSIONS = { "subscriptions.cancel", "subscriptions.activate", "subscriptions.plans", "subscriptions.export", "donations.view", "donations.export", + # Payment methods - finance can view sensitive details + "payment_methods.view", "payment_methods.view_sensitive", + "payment_methods.create", "payment_methods.delete", "payment_methods.set_default", ], "admin": [ @@ -191,6 +201,9 @@ DEFAULT_ROLE_PERMISSIONS = { "gallery.view", "gallery.upload", "gallery.edit", "gallery.delete", "gallery.moderate", "settings.view", "settings.edit", "settings.email_templates", "settings.storage", "settings.logs", + # Payment methods - admin can manage but not view sensitive details + "payment_methods.view", "payment_methods.create", + "payment_methods.delete", "payment_methods.set_default", ], "superadmin": [ -- 2.39.5 From 1c262c4804364f125bffe90a7d4c0611bb145366 Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:43:28 +0700 Subject: [PATCH 07/10] 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 --- __pycache__/models.cpython-312.pyc | Bin 36353 -> 36509 bytes __pycache__/server.cpython-312.pyc | Bin 361724 -> 388200 bytes add_registration_permissions.py | 141 +++ .../014_add_custom_registration_data.py | 39 + models.py | 4 + seed_permissions_rbac.py | 6 + server.py | 807 ++++++++++++++++-- 7 files changed, 926 insertions(+), 71 deletions(-) create mode 100644 add_registration_permissions.py create mode 100644 alembic/versions/014_add_custom_registration_data.py diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc index 9b570f464fcc36a4ead9a47515330b67ccb28144..1527758b533cdbe49740cef9fad5eddc54706980 100644 GIT binary patch delta 2774 zcmZ`*4Nz276y9?eR$T!V1Q!B%E(RT}UVgO*Fxu zYEENie`G&1C7JZHrlv+rYf`Jper;zevFTrNoUyVQ$GYb}!<8Y1`R3g7``vTzJ#RNY z$)0+OMH~+gHyilh;|1NG@cRZMM$D{r0uC=L*UBbWnYyEu)pDquIxuB#2zeO{)z!?P zgeXz6t;aAhaWA`~U2{d7)?<+8a7+4L;}uH{x75lRGAPMz7|0ye*9rYxdEP+QXcmXi z&6l(P)7g2!wL+OAXUjJPg*%1Tpxo&Od4bFZaz_c)3rimNUl`=?>Re>Y*Qz7W8*lAe zUfkK@Z1%V$RdIWKe%0yscw3|fkJ2P_m9N#?;!}K*&+kIChrB&QKRz`>#@ zEi+{^gAeg_+Z(2%yxr=@`0V0de9|@pj^eaA=izfSrax%RXo8PCm{!hXI+JX9OQ)c5d_`Nv=w9og1sJ(dzoA!sHzjJvYaBfjFI?=*)V z!^!FKIJ`a%rTQ>DwmR%NmSbdp>3PpBh+-E<#kh+UuMi{=G!Qfq9LBYIWt{rHyuztt zYW{-%KFoL%i{53ZA2*QLYGXSqZa9QiPF%lR6 zp31andzMBR;Rx?({#4P(N+NQc;27?|w+A;A$C#E-XgzK$+eu3(;6O3%BK!7gQutNGgFu@D3F-5m;%dv!Z1Z zEhlm*5ZdDPD?W8h;|0rb@;oitm`45t#oABh{lL=nHukSv2yg}iw@Yvlzu)j6_Ep3{ zCq7$Yfj{u=iXym#mn$kKi{#=G{epF?63k~PF@YC1uX>Fq_&zgMTUlvk&?ioIpz1o< z79 zq-1(~xiVmC_R8%|ihh3%;%;}E*+m`%@8T!!&CI2jYi`V7rZ32(zn09qBj8KXTU`p= zosfEnXY2~A^eJkl>TOaS9$9}w4HPy?O+A5$O))@plBcPSWD6pN%}q0B>nA{@uO7U( zTE(Qhi$a>km2IRo!Ex=QrYZ&vc)d3(k{nj4H*=|S|Vs!@jz>Ark~1Oy%59XBS;(~$xT)*eJob7~A$8YXDq1$$}~7 zB+OAuQtzRVcnV%el9ePeCTEk>K{|pzagREib?9wBrRFkz%9i&1>EZmdtk#bP>74n} zZj;74>ST=Th=&f$?RePSMY#zsX`gjifOYA_Le@RL_yIC4qOAD@TWQ%#&_}S1U<}bM zq$7yKZ`NhAEjrQU&RNFvMgL1NWju;0xck1U>qS06V;eCDBiO_tU{X}otNPS;`2sEL zUp4Qb$^=Q;iS;Xh?a)gv=&oe247YDQlvQ>u0TB??sMzsXSouhAf`b^oX`XoxRUo(t zOE(>6d#F#jSk#lB`YKuW6ATiJ>B&CQ5me!eJ&CYS`=I9lV~tGwa4>JRFwdwM=OK<) zzmcEPeszxc!`9iBA-=ZTW{$@+1xtF97m8X$ClKaS{C-c1+u`qQRn!qmMDHbFZg1s3 zKor?g-x+j)hwz2oG|)%AMtlkC@NDlH&@j;VL$~ JYNxj+!oM?;`fC6H delta 2629 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 delta 53026 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 RPx*$jgUfT13M*2A{ud~ZuO|Qi diff --git a/add_registration_permissions.py b/add_registration_permissions.py new file mode 100644 index 0000000..9fd39f0 --- /dev/null +++ b/add_registration_permissions.py @@ -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() diff --git a/alembic/versions/014_add_custom_registration_data.py b/alembic/versions/014_add_custom_registration_data.py new file mode 100644 index 0000000..962318e --- /dev/null +++ b/alembic/versions/014_add_custom_registration_data.py @@ -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') diff --git a/models.py b/models.py index 4e3a4c6..f256cd7 100644 --- a/models.py +++ b/models.py @@ -151,6 +151,10 @@ class User(Base): # 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") + # 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)) updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) diff --git a/seed_permissions_rbac.py b/seed_permissions_rbac.py index a4c407a..68806fc 100755 --- a/seed_permissions_rbac.py +++ b/seed_permissions_rbac.py @@ -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.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"}, + + # ========== 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 @@ -204,6 +208,8 @@ DEFAULT_ROLE_PERMISSIONS = { # Payment methods - admin can manage but not view sensitive details "payment_methods.view", "payment_methods.create", "payment_methods.delete", "payment_methods.set_default", + # Registration form management + "registration.view", "registration.manage", ], "superadmin": [ diff --git a/server.py b/server.py index ff70028..3bf2f27 100644 --- a/server.py +++ b/server.py @@ -134,16 +134,23 @@ def set_user_role(user: User, role_enum: UserRole, db: Session): # Pydantic Models # ============================================================ class RegisterRequest(BaseModel): - # Step 1: Personal & Partner Information + """Dynamic registration request - validates against registration schema""" + + # Fixed required fields (always present) first_name: str last_name: str - phone: str - address: str - city: str - state: str - zipcode: str - date_of_birth: datetime - lead_sources: List[str] + email: EmailStr + password: str = Field(min_length=6) + accepts_tos: bool = False + + # Step 1: Personal & Partner Information (optional for dynamic schema) + phone: Optional[str] = None + 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_last_name: Optional[str] = None partner_is_member: Optional[bool] = False @@ -151,16 +158,16 @@ class RegisterRequest(BaseModel): # Step 2: Newsletter, Volunteer & Scholarship referred_by_member_name: Optional[str] = None - newsletter_publish_name: bool - newsletter_publish_photo: bool - newsletter_publish_birthday: bool - newsletter_publish_none: bool - volunteer_interests: List[str] = [] - scholarship_requested: bool = False + newsletter_publish_name: Optional[bool] = False + newsletter_publish_photo: Optional[bool] = False + newsletter_publish_birthday: Optional[bool] = False + newsletter_publish_none: Optional[bool] = False + volunteer_interests: Optional[List[str]] = [] + scholarship_requested: Optional[bool] = False scholarship_reason: Optional[str] = None # Step 3: Directory Settings - show_in_directory: bool = False + show_in_directory: Optional[bool] = False directory_email: Optional[str] = None directory_bio: Optional[str] = None directory_address: Optional[str] = None @@ -168,10 +175,9 @@ class RegisterRequest(BaseModel): directory_dob: Optional[datetime] = None directory_partner_name: Optional[str] = None - # Step 4: Account Credentials - email: EmailStr - password: str = Field(min_length=6) - accepts_tos: bool = False + # Allow extra fields for custom registration data + class Config: + extra = 'allow' @validator('accepts_tos') 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') return v - @validator('newsletter_publish_none') - def validate_newsletter_preferences(cls, v, values): - """At least one newsletter preference must be selected""" - name = values.get('newsletter_publish_name', False) - photo = values.get('newsletter_publish_photo', False) - birthday = values.get('newsletter_publish_birthday', False) - - if not (name or photo or birthday or v): - raise ValueError('At least one newsletter publication preference must be selected') - return v - - @validator('scholarship_reason') - def validate_scholarship_reason(cls, v, values): - """If scholarship requested, reason must be provided""" - requested = values.get('scholarship_requested', False) - if requested and not v: - raise ValueError('Scholarship reason is required when requesting scholarship') - return v - class LoginRequest(BaseModel): email: EmailStr password: str @@ -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() if existing_user: 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 verification_token = secrets.token_urlsafe(32) - - # Create user + + # Create user with known fields user = User( # Account credentials (Step 4) email=request.email, @@ -573,65 +577,68 @@ async def register(request: RegisterRequest, db: Session = Depends(get_db)): # Personal information (Step 1) first_name=request.first_name, last_name=request.last_name, - phone=request.phone, - address=request.address, - city=request.city, - state=request.state, - zipcode=request.zipcode, - date_of_birth=request.date_of_birth, - lead_sources=request.lead_sources, + phone=user_data.get('phone') or request.phone, + address=user_data.get('address') or request.address, + city=user_data.get('city') or request.city, + state=user_data.get('state') or request.state, + zipcode=user_data.get('zipcode') or request.zipcode, + date_of_birth=user_data.get('date_of_birth') or request.date_of_birth, + lead_sources=user_data.get('lead_sources') or request.lead_sources or [], # Partner information (Step 1) - partner_first_name=request.partner_first_name, - partner_last_name=request.partner_last_name, - partner_is_member=request.partner_is_member, - partner_plan_to_become_member=request.partner_plan_to_become_member, + partner_first_name=user_data.get('partner_first_name') or request.partner_first_name, + partner_last_name=user_data.get('partner_last_name') or request.partner_last_name, + partner_is_member=user_data.get('partner_is_member', request.partner_is_member) or False, + partner_plan_to_become_member=user_data.get('partner_plan_to_become_member', request.partner_plan_to_become_member) or False, # 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_publish_name=request.newsletter_publish_name, - newsletter_publish_photo=request.newsletter_publish_photo, - newsletter_publish_birthday=request.newsletter_publish_birthday, - newsletter_publish_none=request.newsletter_publish_none, + newsletter_publish_name=user_data.get('newsletter_publish_name', request.newsletter_publish_name) or False, + newsletter_publish_photo=user_data.get('newsletter_publish_photo', request.newsletter_publish_photo) or False, + newsletter_publish_birthday=user_data.get('newsletter_publish_birthday', request.newsletter_publish_birthday) or False, + newsletter_publish_none=user_data.get('newsletter_publish_none', request.newsletter_publish_none) or False, # 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_requested=request.scholarship_requested, - scholarship_reason=request.scholarship_reason, + scholarship_requested=user_data.get('scholarship_requested', request.scholarship_requested) or False, + scholarship_reason=user_data.get('scholarship_reason') or request.scholarship_reason, # Directory settings (Step 3) - show_in_directory=request.show_in_directory, - directory_email=request.directory_email, - directory_bio=request.directory_bio, - directory_address=request.directory_address, - directory_phone=request.directory_phone, - directory_dob=request.directory_dob, - directory_partner_name=request.directory_partner_name, + show_in_directory=user_data.get('show_in_directory', request.show_in_directory) or False, + directory_email=user_data.get('directory_email') or request.directory_email, + directory_bio=user_data.get('directory_bio') or request.directory_bio, + directory_address=user_data.get('directory_address') or request.directory_address, + directory_phone=user_data.get('directory_phone') or request.directory_phone, + directory_dob=user_data.get('directory_dob') or request.directory_dob, + directory_partner_name=user_data.get('directory_partner_name') or request.directory_partner_name, # Terms of Service acceptance (Step 4) accepts_tos=request.accepts_tos, tos_accepted_at=datetime.now(timezone.utc) if request.accepts_tos else None, + # Custom registration data for dynamic fields + custom_registration_data=custom_data if custom_data else {}, + # Status fields status=UserStatus.pending_email, role=UserRole.guest, email_verified=False, email_verification_token=verification_token ) - + db.add(user) db.commit() db.refresh(user) - + # Send verification email await send_verification_email(user.email, verification_token) - + logger.info(f"User registered: {user.email}") - + return {"message": "Registration successful. Please check your email to verify your account."} @api_router.get("/auth/verify-email") @@ -2062,6 +2069,664 @@ async def get_config_limits(): "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 # ============================================================================ -- 2.39.5 From dd41cf773ba4623123fc0363f459705763ab9628 Mon Sep 17 00:00:00 2001 From: Andika Date: Mon, 2 Feb 2026 17:05:27 +0700 Subject: [PATCH 08/10] =?UTF-8?q?-=20Added=20DEFAULT=5FDIRECTORY=5FCONFIG?= =?UTF-8?q?=20constant=20with=20all=20directory=20fields=20-=20Added=20get?= =?UTF-8?q?=5Fdirectory=5Fconfig()=20and=20save=5Fdirectory=5Fconfig()=20h?= =?UTF-8?q?elper=20functions=20-=20Created=204=20new=20endpoints:=20=09-?= =?UTF-8?q?=20GET=20/api/directory/config=20-=20Public=20endpoint=20for=20?= =?UTF-8?q?frontend=20=09-=20GET=20/api/admin/directory/config=20-=20Admin?= =?UTF-8?q?=20view=20with=20metadata=20=09-=20PUT=20/api/admin/directory/c?= =?UTF-8?q?onfig=20-=20Update=20configuration=20=09-=20POST=20/api/admin/d?= =?UTF-8?q?irectory/config/reset=20-=20Reset=20to=20defaults=20-=20Fixed?= =?UTF-8?q?=20a=20bug:=20Changed=20SystemSettings.key=20=E2=86=92=20System?= =?UTF-8?q?Settings.setting=5Fkey=20(correct=20column=20name)=20-=20Added?= =?UTF-8?q?=20JSON=20serialization/deserialization=20for=20storing=20confi?= =?UTF-8?q?g=20in=20Text=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __pycache__/server.cpython-312.pyc | Bin 388200 -> 394626 bytes seed_permissions_rbac.py | 6 + server.py | 210 +++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+) diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index e8c4dfa239ae3fbb3a345e58b2aa2a78fbb6e719..7eb827466589d23af089e82c667db4f01ed95601 100644 GIT binary patch delta 40314 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 N^pg`qdK3f;{|8u>&I|wm delta 35150 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

0Sgp!f~I)W-axO$s1Et95c)kQ*0SutXyOteF4Gy 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 n2mT~|2Xn7S?B(z1Nci*PcXSMV&2Ypm8W81XsT dict: + """Get directory configuration from database or return default""" + setting = db.query(SystemSettings).filter( + SystemSettings.setting_key == "directory.config" + ).first() + + if setting and setting.setting_value: + try: + return json.loads(setting.setting_value) + except json.JSONDecodeError: + return DEFAULT_DIRECTORY_CONFIG + return DEFAULT_DIRECTORY_CONFIG + + +def save_directory_config(db: Session, config: dict, user_id) -> dict: + """Save directory configuration to database""" + setting = db.query(SystemSettings).filter( + SystemSettings.setting_key == "directory.config" + ).first() + + config_json = json.dumps(config) + + if setting: + setting.setting_value = config_json + setting.updated_by = user_id + setting.updated_at = datetime.now(timezone.utc) + else: + from models import SettingType + setting = SystemSettings( + setting_key="directory.config", + setting_value=config_json, + setting_type=SettingType.json, + description="Member directory field configuration", + updated_by=user_id + ) + db.add(setting) + + db.commit() + db.refresh(setting) + return json.loads(setting.setting_value) + + +# Public endpoint - get directory field configuration +@api_router.get("/directory/config") +async def get_public_directory_config(db: Session = Depends(get_db)): + """Get directory field configuration (public endpoint for Profile page)""" + config = get_directory_config(db) + # Return only the fields and their enabled status for frontend + return { + "fields": { + field_id: { + "enabled": field_data.get("enabled", True), + "label": field_data.get("label", field_id), + "required": field_data.get("required", False) + } + for field_id, field_data in config.get("fields", {}).items() + } + } + + +# Admin endpoint - get full directory configuration with metadata +@api_router.get("/admin/directory/config") +async def get_admin_directory_config( + current_user: User = Depends(require_permission("directory.view")), + db: Session = Depends(get_db) +): + """Get full directory configuration for admin""" + config = get_directory_config(db) + + # Get metadata + setting = db.query(SystemSettings).filter( + SystemSettings.setting_key == "directory.config" + ).first() + + return { + "config": config, + "metadata": { + "updated_at": setting.updated_at.isoformat() if setting and setting.updated_at else None, + "updated_by": setting.updated_by if setting else None, + "is_default": setting is None + } + } + + +# Admin endpoint - update directory configuration +@api_router.put("/admin/directory/config") +async def update_directory_config( + request: Request, + current_user: User = Depends(require_permission("directory.manage")), + db: Session = Depends(get_db) +): + """Update directory field configuration""" + try: + body = await request.json() + config = body.get("config", {}) + + # Validate config structure + if "fields" not in config: + raise HTTPException(status_code=400, detail="Config must contain 'fields' object") + + # Ensure show_in_directory is always enabled (core functionality) + if "show_in_directory" in config["fields"]: + config["fields"]["show_in_directory"]["enabled"] = True + + # Add version if not present + if "version" not in config: + config["version"] = "1.0" + + saved_config = save_directory_config(db, config, current_user.id) + + return { + "message": "Directory configuration updated successfully", + "config": saved_config + } + except Exception as e: + logger.error(f"Error updating directory config: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + + +# Admin endpoint - reset directory configuration to default +@api_router.post("/admin/directory/config/reset") +async def reset_directory_config( + current_user: User = Depends(require_permission("directory.manage")), + db: Session = Depends(get_db) +): + """Reset directory configuration to default""" + saved_config = save_directory_config(db, DEFAULT_DIRECTORY_CONFIG, current_user.id) + + return { + "message": "Directory configuration reset to default", + "config": saved_config + } + + # ============================================================================ # Admin Routes # ============================================================================ -- 2.39.5 From 0cd5350a7b1a5453c4c3bd496faf4324fec2a727 Mon Sep 17 00:00:00 2001 From: Andika Date: Mon, 2 Feb 2026 17:06:36 +0700 Subject: [PATCH 09/10] no message --- add_directory_permissions.py | 141 +++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 add_directory_permissions.py diff --git a/add_directory_permissions.py b/add_directory_permissions.py new file mode 100644 index 0000000..2818bd4 --- /dev/null +++ b/add_directory_permissions.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +Add Directory Permissions Script + +This script adds the new directory.view and directory.manage permissions +without clearing existing permissions. + +Usage: + python add_directory_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 directory permissions +NEW_PERMISSIONS = [ + {"code": "directory.view", "name": "View Directory Settings", "description": "View member directory field configuration", "module": "directory"}, + {"code": "directory.manage", "name": "Manage Directory Fields", "description": "Enable/disable directory fields shown in Profile and Directory pages", "module": "directory"}, +] + +# Roles that should have these permissions +ROLE_PERMISSION_MAP = { + "directory.view": ["admin", "superadmin"], + "directory.manage": ["admin", "superadmin"], +} + + +def add_directory_permissions(): + """Add directory permissions and assign to appropriate roles""" + db = SessionLocal() + + try: + print("=" * 60) + print("Adding Directory 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("Directory 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_directory_permissions() -- 2.39.5 From e7f6e9c20a060783f1116bacef26df6e6fbf3adc Mon Sep 17 00:00:00 2001 From: Andika Date: Mon, 2 Feb 2026 17:52:25 +0700 Subject: [PATCH 10/10] Update Stripe publishable key storage in Stripe Settings 1. Updated UpdateStripeSettingsRequest - Added publishable_key field 2. Updated update_stripe_settings endpoint - Now validates and stores: - stripe_publishable_key (not encrypted - it's public) - stripe_secret_key (encrypted) - stripe_webhook_secret (encrypted) - Also validates that publishable and secret keys are from the same environment (both test or both live) 3. Added new public endpoint GET /api/config/stripe - Returns the publishable key to the frontend (no auth required since it's meant to be public) 4. Updated get_stripe_status endpoint - Now includes publishable_key_prefix and publishable_key_set in the response --- __pycache__/server.cpython-312.pyc | Bin 394626 -> 396746 bytes server.py | 58 ++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index 7eb827466589d23af089e82c667db4f01ed95601..0c295b80153e70acdaff12c07a8d386ce2e1e9f4 100644 GIT binary patch delta 5524 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 P)HJXbj|qRUG|cjU!cGv5 diff --git a/server.py b/server.py index 71530b4..2aaf852 100644 --- a/server.py +++ b/server.py @@ -8395,6 +8395,29 @@ def set_setting( db.commit() +@api_router.get("/config/stripe") +async def get_stripe_public_config(db: Session = Depends(get_db)): + """ + Get Stripe publishable key for frontend (public endpoint). + + This endpoint provides the publishable key needed for Stripe.js + to initialize payment forms. No authentication required since + publishable keys are meant to be public. + """ + publishable_key = get_setting(db, 'stripe_publishable_key', decrypt=False) + + if not publishable_key: + raise HTTPException( + status_code=503, + detail="Stripe is not configured. Please contact the administrator." + ) + + return { + "publishable_key": publishable_key, + "environment": "test" if publishable_key.startswith('pk_test_') else "live" + } + + @api_router.get("/admin/settings/stripe/status") async def get_stripe_status( current_user: User = Depends(get_current_superadmin), @@ -8405,6 +8428,7 @@ async def get_stripe_status( Returns: - configured: Whether credentials exist in database + - publishable_key_prefix: First 12 chars of publishable key - secret_key_prefix: First 10 chars of secret key (for verification) - webhook_configured: Whether webhook secret exists - environment: test or live (based on key prefix) @@ -8413,10 +8437,11 @@ async def get_stripe_status( import os # Read from database + publishable_key = get_setting(db, 'stripe_publishable_key', decrypt=False) secret_key = get_setting(db, 'stripe_secret_key', decrypt=True) webhook_secret = get_setting(db, 'stripe_webhook_secret', decrypt=True) - configured = bool(secret_key) + configured = bool(secret_key) and bool(publishable_key) environment = 'unknown' if secret_key: @@ -8436,6 +8461,8 @@ async def get_stripe_status( return { "configured": configured, + "publishable_key_prefix": publishable_key[:12] if publishable_key else None, + "publishable_key_set": bool(publishable_key), "secret_key_prefix": secret_key[:10] if secret_key else None, "secret_key_set": bool(secret_key), "webhook_secret_set": bool(webhook_secret), @@ -8444,6 +8471,7 @@ async def get_stripe_status( "instructions": { "location": "Database (system_settings table)", "required_settings": [ + "stripe_publishable_key (pk_test_... or pk_live_...)", "stripe_secret_key (sk_test_... or sk_live_...)", "stripe_webhook_secret (whsec_...)" ], @@ -8501,6 +8529,7 @@ async def test_stripe_connection( class UpdateStripeSettingsRequest(BaseModel): """Request model for updating Stripe settings""" + publishable_key: str = Field(..., min_length=1, description="Stripe publishable key (pk_test_... or pk_live_...)") secret_key: str = Field(..., min_length=1, description="Stripe secret key (sk_test_... or sk_live_...)") webhook_secret: str = Field(..., min_length=1, description="Stripe webhook secret (whsec_...)") @@ -8517,6 +8546,13 @@ async def update_stripe_settings( Stores Stripe credentials encrypted in the database. Changes take effect immediately without server restart. """ + # Validate publishable key format + if not (request.publishable_key.startswith('pk_test_') or request.publishable_key.startswith('pk_live_')): + raise HTTPException( + status_code=400, + detail="Invalid Stripe publishable key format. Must start with 'pk_test_' or 'pk_live_'" + ) + # Validate secret key format if not (request.secret_key.startswith('sk_test_') or request.secret_key.startswith('sk_live_')): raise HTTPException( @@ -8531,7 +8567,27 @@ async def update_stripe_settings( detail="Invalid Stripe webhook secret format. Must start with 'whsec_'" ) + # Validate key environment consistency (publishable and secret should match) + pk_is_live = request.publishable_key.startswith('pk_live_') + sk_is_live = request.secret_key.startswith('sk_live_') + if pk_is_live != sk_is_live: + raise HTTPException( + status_code=400, + detail="Publishable key and Secret key must be from the same environment (both test or both live)" + ) + try: + # Store publishable key (NOT encrypted - it's meant to be public) + set_setting( + db=db, + key='stripe_publishable_key', + value=request.publishable_key, + user_id=str(current_user.id), + description='Stripe publishable key for frontend payment forms', + is_sensitive=False, + encrypt=False + ) + # Store secret key (encrypted) set_setting( db=db, -- 2.39.5