forked from andika/membership-be
Solution: Updated backend/r2_storage.py:
- Added ALLOWED_CSV_TYPES for CSV file validation
- Added upload_bytes() method for uploading raw bytes to R2
- Added download_file() method for retrieving files from R2
- Added delete_multiple() method for bulk file deletion
Comprehensive upload endpoint now stores CSVs in R2:
r2_storage = get_r2_storage()
for file_type, (content, filename) in file_contents.items():
_, r2_key, _ = await r2_storage.upload_bytes(
content=content,
folder=f"imports/{job_id}",
filename=f"{file_type}_{filename}",
content_type='text/csv'
)
r2_keys[file_type] = r2_key
---
2. Stripe Transaction ID Tracking
Solution: Updated subscription and donation imports to capture Stripe metadata:
Subscription fields:
- stripe_subscription_id
- stripe_customer_id
- stripe_payment_intent_id
- stripe_invoice_id
- stripe_charge_id
- stripe_receipt_url
- card_last4, card_brand, payment_method
Donation fields:
- stripe_payment_intent_id
- stripe_charge_id
- stripe_receipt_url
- card_last4, card_brand
---
3. Fixed JSON Serialization Error
Problem: Object of type datetime is not JSON serializable when saving import metadata.
Solution: Added serialize_for_json() helper in backend/server.py:
def serialize_for_json(obj):
"""Recursively convert datetime objects to ISO strings for JSON serialization."""
if isinstance(obj, (datetime, date)):
return obj.isoformat()
elif isinstance(obj, dict):
return {k: serialize_for_json(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [serialize_for_json(item) for item in obj]
# ... handles other types
---
4. Fixed Route Ordering (401 Unauthorized)
Problem: /admin/import/comprehensive/upload returned 401 because FastAPI matched "comprehensive" as a {job_id} parameter.
Solution: Moved comprehensive import routes BEFORE generic {job_id} routes in backend/server.py:
# Correct order:
@app.post("/api/admin/import/comprehensive/upload") # Specific route FIRST
# ... other comprehensive routes ...
@app.get("/api/admin/import/{job_id}/preview") # Generic route AFTER
---
5. Improved Date Parsing
Solution: Added additional date formats to backend/wordpress_parser.py:
formats = [
'%m/%d/%Y', '%Y-%m-%d', '%d/%m/%Y', '%B %d, %Y', '%b %d, %Y',
'%Y-%m-%d %H:%M:%S',
'%m/%Y', # Month/Year: 01/2020
'%m-%Y', # Month-Year: 01-2020
'%b-%Y', # Short month-Year: Jan-2020
'%B-%Y', # Full month-Year: January-2020
]
1134 lines
48 KiB
Python
1134 lines
48 KiB
Python
"""
|
|
CSV Import Templates Module
|
|
|
|
This module provides standardized CSV templates for importing data into the
|
|
membership platform. Templates are designed to be source-agnostic, allowing
|
|
clients to transform their data from any system to match our schema.
|
|
|
|
Supported Templates:
|
|
- users.csv - Core user data
|
|
- subscriptions.csv - Membership/plan history
|
|
- donations.csv - Donation history
|
|
- payments.csv - Payment transactions
|
|
- registration_data.csv - Custom registration fields
|
|
|
|
Author: Claude Code
|
|
Date: 2026-02-03
|
|
"""
|
|
|
|
import csv
|
|
import io
|
|
from typing import Dict, List, Any, Optional, Tuple
|
|
from datetime import datetime, date
|
|
import re
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# =============================================================================
|
|
# Template Definitions
|
|
# =============================================================================
|
|
|
|
TEMPLATES = {
|
|
"users": {
|
|
"name": "Users",
|
|
"description": "Core user data including personal information, contact details, and account settings",
|
|
"required": True,
|
|
"filename": "users_template.csv",
|
|
"columns": [
|
|
# Required fields
|
|
{"name": "email", "required": True, "type": "email", "description": "User's email address (unique identifier)"},
|
|
{"name": "first_name", "required": True, "type": "string", "description": "First name"},
|
|
{"name": "last_name", "required": True, "type": "string", "description": "Last name"},
|
|
|
|
# Account settings
|
|
{"name": "status", "required": False, "type": "enum", "description": "Account status",
|
|
"options": ["active", "inactive", "pending_email", "pending_validation", "pre_validated", "payment_pending", "canceled", "expired"],
|
|
"default": "active"},
|
|
{"name": "role", "required": False, "type": "enum", "description": "User role",
|
|
"options": ["member", "admin", "finance", "guest"],
|
|
"default": "member"},
|
|
|
|
# Contact info
|
|
{"name": "phone", "required": False, "type": "string", "description": "Phone number"},
|
|
{"name": "address", "required": False, "type": "string", "description": "Street address"},
|
|
{"name": "city", "required": False, "type": "string", "description": "City"},
|
|
{"name": "state", "required": False, "type": "string", "description": "State/Province"},
|
|
{"name": "zipcode", "required": False, "type": "string", "description": "ZIP/Postal code"},
|
|
|
|
# Personal info
|
|
{"name": "date_of_birth", "required": False, "type": "date", "description": "Date of birth (YYYY-MM-DD)"},
|
|
{"name": "member_since", "required": False, "type": "date", "description": "Member since date (YYYY-MM-DD)"},
|
|
|
|
# Partner info
|
|
{"name": "partner_first_name", "required": False, "type": "string", "description": "Partner's first name"},
|
|
{"name": "partner_last_name", "required": False, "type": "string", "description": "Partner's last name"},
|
|
{"name": "partner_is_member", "required": False, "type": "boolean", "description": "Is partner a member? (true/false)"},
|
|
|
|
# Referral
|
|
{"name": "referred_by_member_name", "required": False, "type": "string", "description": "Name of referring member"},
|
|
{"name": "lead_sources", "required": False, "type": "list", "description": "How they heard about us (comma-separated)"},
|
|
|
|
# Directory settings
|
|
{"name": "show_in_directory", "required": False, "type": "boolean", "description": "Show in member directory? (true/false)", "default": "false"},
|
|
{"name": "directory_display_name", "required": False, "type": "string", "description": "Display name for directory"},
|
|
{"name": "directory_bio", "required": False, "type": "string", "description": "Bio for directory"},
|
|
{"name": "directory_email", "required": False, "type": "boolean", "description": "Show email in directory? (true/false)"},
|
|
{"name": "directory_phone", "required": False, "type": "boolean", "description": "Show phone in directory? (true/false)"},
|
|
{"name": "directory_address", "required": False, "type": "boolean", "description": "Show address in directory? (true/false)"},
|
|
|
|
# Newsletter preferences
|
|
{"name": "newsletter_subscribed", "required": False, "type": "boolean", "description": "Subscribed to newsletter? (true/false)"},
|
|
{"name": "newsletter_publish_name", "required": False, "type": "boolean", "description": "Publish name in newsletter? (true/false)"},
|
|
{"name": "newsletter_publish_photo", "required": False, "type": "boolean", "description": "Publish photo in newsletter? (true/false)"},
|
|
{"name": "newsletter_publish_birthday", "required": False, "type": "boolean", "description": "Publish birthday in newsletter? (true/false)"},
|
|
|
|
# Volunteer interests
|
|
{"name": "volunteer_interests", "required": False, "type": "list", "description": "Volunteer interests (comma-separated)"},
|
|
|
|
# Scholarship
|
|
{"name": "scholarship_requested", "required": False, "type": "boolean", "description": "Requested scholarship? (true/false)"},
|
|
{"name": "scholarship_reason", "required": False, "type": "string", "description": "Reason for scholarship request"},
|
|
|
|
# Social media
|
|
{"name": "social_media_facebook", "required": False, "type": "string", "description": "Facebook profile URL"},
|
|
{"name": "social_media_instagram", "required": False, "type": "string", "description": "Instagram handle"},
|
|
{"name": "social_media_twitter", "required": False, "type": "string", "description": "Twitter/X handle"},
|
|
{"name": "social_media_linkedin", "required": False, "type": "string", "description": "LinkedIn profile URL"},
|
|
],
|
|
"example_rows": [
|
|
{
|
|
"email": "john.doe@example.com",
|
|
"first_name": "John",
|
|
"last_name": "Doe",
|
|
"status": "active",
|
|
"role": "member",
|
|
"phone": "555-123-4567",
|
|
"address": "123 Main St",
|
|
"city": "Houston",
|
|
"state": "TX",
|
|
"zipcode": "77001",
|
|
"date_of_birth": "1985-06-15",
|
|
"member_since": "2023-01-01",
|
|
"partner_first_name": "Jane",
|
|
"partner_last_name": "Doe",
|
|
"partner_is_member": "true",
|
|
"referred_by_member_name": "",
|
|
"lead_sources": "Friend,Social Media",
|
|
"show_in_directory": "true",
|
|
"directory_display_name": "John D.",
|
|
"directory_bio": "Active community member",
|
|
"directory_email": "true",
|
|
"directory_phone": "false",
|
|
"directory_address": "false",
|
|
"newsletter_subscribed": "true",
|
|
"newsletter_publish_name": "true",
|
|
"newsletter_publish_photo": "false",
|
|
"newsletter_publish_birthday": "true",
|
|
"volunteer_interests": "Events,Outreach",
|
|
"scholarship_requested": "false",
|
|
"scholarship_reason": "",
|
|
"social_media_facebook": "",
|
|
"social_media_instagram": "@johndoe",
|
|
"social_media_twitter": "",
|
|
"social_media_linkedin": "",
|
|
},
|
|
{
|
|
"email": "jane.smith@example.com",
|
|
"first_name": "Jane",
|
|
"last_name": "Smith",
|
|
"status": "active",
|
|
"role": "member",
|
|
"phone": "555-987-6543",
|
|
"address": "456 Oak Ave",
|
|
"city": "Dallas",
|
|
"state": "TX",
|
|
"zipcode": "75201",
|
|
"date_of_birth": "1990-03-22",
|
|
"member_since": "2024-06-15",
|
|
"partner_first_name": "",
|
|
"partner_last_name": "",
|
|
"partner_is_member": "false",
|
|
"referred_by_member_name": "John Doe",
|
|
"lead_sources": "Current member",
|
|
"show_in_directory": "false",
|
|
"directory_display_name": "",
|
|
"directory_bio": "",
|
|
"directory_email": "false",
|
|
"directory_phone": "false",
|
|
"directory_address": "false",
|
|
"newsletter_subscribed": "true",
|
|
"newsletter_publish_name": "false",
|
|
"newsletter_publish_photo": "false",
|
|
"newsletter_publish_birthday": "false",
|
|
"volunteer_interests": "",
|
|
"scholarship_requested": "false",
|
|
"scholarship_reason": "",
|
|
"social_media_facebook": "",
|
|
"social_media_instagram": "",
|
|
"social_media_twitter": "",
|
|
"social_media_linkedin": "",
|
|
},
|
|
]
|
|
},
|
|
|
|
"subscriptions": {
|
|
"name": "Subscriptions",
|
|
"description": "Membership and subscription history for users",
|
|
"required": False,
|
|
"filename": "subscriptions_template.csv",
|
|
"columns": [
|
|
{"name": "email", "required": True, "type": "email", "description": "User's email (must match users.csv)"},
|
|
{"name": "plan_name", "required": True, "type": "string", "description": "Name of the subscription plan"},
|
|
{"name": "status", "required": False, "type": "enum", "description": "Subscription status",
|
|
"options": ["active", "cancelled", "expired", "past_due"],
|
|
"default": "active"},
|
|
{"name": "amount", "required": False, "type": "decimal", "description": "Subscription amount paid"},
|
|
{"name": "currency", "required": False, "type": "string", "description": "Currency code (e.g., USD)", "default": "USD"},
|
|
{"name": "start_date", "required": False, "type": "date", "description": "Subscription start date (YYYY-MM-DD)"},
|
|
{"name": "end_date", "required": False, "type": "date", "description": "Subscription end date (YYYY-MM-DD)"},
|
|
{"name": "auto_renew", "required": False, "type": "boolean", "description": "Auto-renew enabled? (true/false)", "default": "false"},
|
|
{"name": "payment_method", "required": False, "type": "string", "description": "Payment method used"},
|
|
{"name": "notes", "required": False, "type": "string", "description": "Additional notes"},
|
|
# Stripe transaction fields
|
|
{"name": "stripe_subscription_id", "required": False, "type": "string", "description": "Stripe Subscription ID (sub_xxx)"},
|
|
{"name": "stripe_customer_id", "required": False, "type": "string", "description": "Stripe Customer ID (cus_xxx)"},
|
|
{"name": "stripe_payment_intent_id", "required": False, "type": "string", "description": "Stripe Payment Intent ID (pi_xxx)"},
|
|
{"name": "stripe_invoice_id", "required": False, "type": "string", "description": "Stripe Invoice ID (in_xxx)"},
|
|
{"name": "stripe_charge_id", "required": False, "type": "string", "description": "Stripe Charge ID (ch_xxx)"},
|
|
{"name": "stripe_receipt_url", "required": False, "type": "string", "description": "Stripe receipt URL"},
|
|
{"name": "card_last4", "required": False, "type": "string", "description": "Last 4 digits of card used"},
|
|
{"name": "card_brand", "required": False, "type": "string", "description": "Card brand (Visa, Mastercard, etc.)"},
|
|
],
|
|
"example_rows": [
|
|
{
|
|
"email": "john.doe@example.com",
|
|
"plan_name": "Annual Membership",
|
|
"status": "active",
|
|
"amount": "100.00",
|
|
"currency": "USD",
|
|
"start_date": "2024-01-01",
|
|
"end_date": "2024-12-31",
|
|
"auto_renew": "true",
|
|
"payment_method": "Credit Card",
|
|
"notes": "",
|
|
"stripe_subscription_id": "sub_1234567890",
|
|
"stripe_customer_id": "cus_1234567890",
|
|
"stripe_payment_intent_id": "pi_1234567890",
|
|
"stripe_invoice_id": "in_1234567890",
|
|
"stripe_charge_id": "ch_1234567890",
|
|
"stripe_receipt_url": "https://pay.stripe.com/receipts/...",
|
|
"card_last4": "4242",
|
|
"card_brand": "Visa",
|
|
},
|
|
{
|
|
"email": "john.doe@example.com",
|
|
"plan_name": "Annual Membership",
|
|
"status": "expired",
|
|
"amount": "85.00",
|
|
"currency": "USD",
|
|
"start_date": "2023-01-01",
|
|
"end_date": "2023-12-31",
|
|
"auto_renew": "false",
|
|
"payment_method": "Check",
|
|
"notes": "First year member discount",
|
|
"stripe_subscription_id": "",
|
|
"stripe_customer_id": "",
|
|
"stripe_payment_intent_id": "",
|
|
"stripe_invoice_id": "",
|
|
"stripe_charge_id": "",
|
|
"stripe_receipt_url": "",
|
|
"card_last4": "",
|
|
"card_brand": "",
|
|
},
|
|
{
|
|
"email": "jane.smith@example.com",
|
|
"plan_name": "Monthly Membership",
|
|
"status": "active",
|
|
"amount": "15.00",
|
|
"currency": "USD",
|
|
"start_date": "2024-06-15",
|
|
"end_date": "2024-07-15",
|
|
"auto_renew": "true",
|
|
"payment_method": "Credit Card",
|
|
"notes": "",
|
|
"stripe_subscription_id": "sub_0987654321",
|
|
"stripe_customer_id": "cus_0987654321",
|
|
"stripe_payment_intent_id": "pi_0987654321",
|
|
"stripe_invoice_id": "",
|
|
"stripe_charge_id": "ch_0987654321",
|
|
"stripe_receipt_url": "",
|
|
"card_last4": "1234",
|
|
"card_brand": "Mastercard",
|
|
},
|
|
]
|
|
},
|
|
|
|
"donations": {
|
|
"name": "Donations",
|
|
"description": "Donation history for users and anonymous donors",
|
|
"required": False,
|
|
"filename": "donations_template.csv",
|
|
"columns": [
|
|
{"name": "email", "required": False, "type": "email", "description": "Donor's email (leave blank for anonymous)"},
|
|
{"name": "donor_name", "required": False, "type": "string", "description": "Donor's name (for anonymous donations)"},
|
|
{"name": "amount", "required": True, "type": "decimal", "description": "Donation amount"},
|
|
{"name": "currency", "required": False, "type": "string", "description": "Currency code", "default": "USD"},
|
|
{"name": "date", "required": True, "type": "date", "description": "Donation date (YYYY-MM-DD)"},
|
|
{"name": "type", "required": False, "type": "enum", "description": "Donation type",
|
|
"options": ["member", "public"],
|
|
"default": "member"},
|
|
{"name": "status", "required": False, "type": "enum", "description": "Donation status",
|
|
"options": ["completed", "pending", "failed", "refunded"],
|
|
"default": "completed"},
|
|
{"name": "payment_method", "required": False, "type": "string", "description": "Payment method"},
|
|
{"name": "campaign", "required": False, "type": "string", "description": "Campaign or fund name"},
|
|
{"name": "notes", "required": False, "type": "string", "description": "Additional notes"},
|
|
{"name": "is_recurring", "required": False, "type": "boolean", "description": "Is this a recurring donation?", "default": "false"},
|
|
# Stripe transaction fields
|
|
{"name": "stripe_payment_intent_id", "required": False, "type": "string", "description": "Stripe Payment Intent ID (pi_xxx)"},
|
|
{"name": "stripe_charge_id", "required": False, "type": "string", "description": "Stripe Charge ID (ch_xxx)"},
|
|
{"name": "stripe_customer_id", "required": False, "type": "string", "description": "Stripe Customer ID (cus_xxx)"},
|
|
{"name": "stripe_checkout_session_id", "required": False, "type": "string", "description": "Stripe Checkout Session ID (cs_xxx)"},
|
|
{"name": "stripe_receipt_url", "required": False, "type": "string", "description": "Stripe receipt URL"},
|
|
{"name": "card_last4", "required": False, "type": "string", "description": "Last 4 digits of card used"},
|
|
{"name": "card_brand", "required": False, "type": "string", "description": "Card brand (Visa, Mastercard, etc.)"},
|
|
],
|
|
"example_rows": [
|
|
{
|
|
"email": "john.doe@example.com",
|
|
"donor_name": "",
|
|
"amount": "50.00",
|
|
"currency": "USD",
|
|
"date": "2024-03-15",
|
|
"type": "member",
|
|
"status": "completed",
|
|
"payment_method": "Credit Card",
|
|
"campaign": "Annual Fund",
|
|
"notes": "",
|
|
"is_recurring": "false",
|
|
"stripe_payment_intent_id": "pi_donation123",
|
|
"stripe_charge_id": "ch_donation123",
|
|
"stripe_customer_id": "cus_1234567890",
|
|
"stripe_checkout_session_id": "",
|
|
"stripe_receipt_url": "https://pay.stripe.com/receipts/...",
|
|
"card_last4": "4242",
|
|
"card_brand": "Visa",
|
|
},
|
|
{
|
|
"email": "",
|
|
"donor_name": "Anonymous Supporter",
|
|
"amount": "100.00",
|
|
"currency": "USD",
|
|
"date": "2024-04-01",
|
|
"type": "public",
|
|
"status": "completed",
|
|
"payment_method": "Check",
|
|
"campaign": "Building Fund",
|
|
"notes": "Mailed check",
|
|
"is_recurring": "false",
|
|
"stripe_payment_intent_id": "",
|
|
"stripe_charge_id": "",
|
|
"stripe_customer_id": "",
|
|
"stripe_checkout_session_id": "",
|
|
"stripe_receipt_url": "",
|
|
"card_last4": "",
|
|
"card_brand": "",
|
|
},
|
|
{
|
|
"email": "jane.smith@example.com",
|
|
"donor_name": "",
|
|
"amount": "25.00",
|
|
"currency": "USD",
|
|
"date": "2024-05-01",
|
|
"type": "member",
|
|
"status": "completed",
|
|
"payment_method": "Credit Card",
|
|
"campaign": "",
|
|
"notes": "Monthly recurring",
|
|
"is_recurring": "true",
|
|
"stripe_payment_intent_id": "pi_recurring456",
|
|
"stripe_charge_id": "ch_recurring456",
|
|
"stripe_customer_id": "cus_0987654321",
|
|
"stripe_checkout_session_id": "cs_checkout789",
|
|
"stripe_receipt_url": "",
|
|
"card_last4": "1234",
|
|
"card_brand": "Mastercard",
|
|
},
|
|
]
|
|
},
|
|
|
|
"payments": {
|
|
"name": "Payments",
|
|
"description": "Payment transaction history",
|
|
"required": False,
|
|
"filename": "payments_template.csv",
|
|
"columns": [
|
|
{"name": "email", "required": True, "type": "email", "description": "User's email (must match users.csv)"},
|
|
{"name": "amount", "required": True, "type": "decimal", "description": "Payment amount"},
|
|
{"name": "currency", "required": False, "type": "string", "description": "Currency code", "default": "USD"},
|
|
{"name": "date", "required": True, "type": "date", "description": "Payment date (YYYY-MM-DD)"},
|
|
{"name": "type", "required": False, "type": "enum", "description": "Payment type",
|
|
"options": ["subscription", "donation", "event", "other"],
|
|
"default": "subscription"},
|
|
{"name": "status", "required": False, "type": "enum", "description": "Payment status",
|
|
"options": ["completed", "pending", "failed", "refunded"],
|
|
"default": "completed"},
|
|
{"name": "payment_method", "required": False, "type": "string", "description": "Payment method (Credit Card, Check, Cash, etc.)"},
|
|
{"name": "description", "required": False, "type": "string", "description": "Payment description"},
|
|
{"name": "notes", "required": False, "type": "string", "description": "Internal notes"},
|
|
# Stripe transaction fields
|
|
{"name": "stripe_payment_intent_id", "required": False, "type": "string", "description": "Stripe Payment Intent ID (pi_xxx)"},
|
|
{"name": "stripe_charge_id", "required": False, "type": "string", "description": "Stripe Charge ID (ch_xxx)"},
|
|
{"name": "stripe_customer_id", "required": False, "type": "string", "description": "Stripe Customer ID (cus_xxx)"},
|
|
{"name": "stripe_invoice_id", "required": False, "type": "string", "description": "Stripe Invoice ID (in_xxx)"},
|
|
{"name": "stripe_receipt_url", "required": False, "type": "string", "description": "Stripe receipt URL"},
|
|
{"name": "card_last4", "required": False, "type": "string", "description": "Last 4 digits of card used"},
|
|
{"name": "card_brand", "required": False, "type": "string", "description": "Card brand (Visa, Mastercard, etc.)"},
|
|
],
|
|
"example_rows": [
|
|
{
|
|
"email": "john.doe@example.com",
|
|
"amount": "100.00",
|
|
"currency": "USD",
|
|
"date": "2024-01-01",
|
|
"type": "subscription",
|
|
"status": "completed",
|
|
"payment_method": "Credit Card",
|
|
"description": "Annual Membership 2024",
|
|
"notes": "",
|
|
"stripe_payment_intent_id": "pi_sub123",
|
|
"stripe_charge_id": "ch_sub123",
|
|
"stripe_customer_id": "cus_1234567890",
|
|
"stripe_invoice_id": "in_sub123",
|
|
"stripe_receipt_url": "https://pay.stripe.com/receipts/...",
|
|
"card_last4": "4242",
|
|
"card_brand": "Visa",
|
|
},
|
|
{
|
|
"email": "john.doe@example.com",
|
|
"amount": "50.00",
|
|
"currency": "USD",
|
|
"date": "2024-03-15",
|
|
"type": "donation",
|
|
"status": "completed",
|
|
"payment_method": "Credit Card",
|
|
"description": "Annual Fund Donation",
|
|
"notes": "",
|
|
"stripe_payment_intent_id": "pi_don456",
|
|
"stripe_charge_id": "ch_don456",
|
|
"stripe_customer_id": "cus_1234567890",
|
|
"stripe_invoice_id": "",
|
|
"stripe_receipt_url": "",
|
|
"card_last4": "4242",
|
|
"card_brand": "Visa",
|
|
},
|
|
{
|
|
"email": "jane.smith@example.com",
|
|
"amount": "15.00",
|
|
"currency": "USD",
|
|
"date": "2024-06-15",
|
|
"type": "subscription",
|
|
"status": "completed",
|
|
"payment_method": "Credit Card",
|
|
"description": "Monthly Membership - June 2024",
|
|
"notes": "",
|
|
"stripe_payment_intent_id": "pi_mon789",
|
|
"stripe_charge_id": "ch_mon789",
|
|
"stripe_customer_id": "cus_0987654321",
|
|
"stripe_invoice_id": "in_mon789",
|
|
"stripe_receipt_url": "https://pay.stripe.com/receipts/...",
|
|
"card_last4": "1234",
|
|
"card_brand": "Mastercard",
|
|
},
|
|
]
|
|
},
|
|
|
|
# Note: registration_data template is generated dynamically from the registration schema
|
|
# See generate_dynamic_registration_template() function
|
|
"registration_data": {
|
|
"name": "Custom Registration Data",
|
|
"description": "Custom registration fields based on current registration form configuration. This template is generated dynamically.",
|
|
"required": False,
|
|
"filename": "registration_data_template.csv",
|
|
"is_dynamic": True, # Flag to indicate this template is generated dynamically
|
|
"columns": [
|
|
{"name": "email", "required": True, "type": "email", "description": "User's email (must match users.csv)"},
|
|
# Additional columns are generated dynamically from registration schema
|
|
],
|
|
"example_rows": [
|
|
{
|
|
"email": "john.doe@example.com",
|
|
# Example values are generated dynamically
|
|
},
|
|
]
|
|
},
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# Template Generation Functions
|
|
# =============================================================================
|
|
|
|
def generate_template_csv(template_type: str, include_examples: bool = True) -> str:
|
|
"""
|
|
Generate a CSV template string for the given template type.
|
|
|
|
Args:
|
|
template_type: One of 'users', 'subscriptions', 'donations', 'payments', 'registration_data'
|
|
include_examples: Whether to include example data rows
|
|
|
|
Returns:
|
|
CSV string with headers and optionally example data
|
|
"""
|
|
if template_type not in TEMPLATES:
|
|
raise ValueError(f"Unknown template type: {template_type}")
|
|
|
|
template = TEMPLATES[template_type]
|
|
columns = template["columns"]
|
|
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
|
|
# Write header row
|
|
headers = [col["name"] for col in columns]
|
|
writer.writerow(headers)
|
|
|
|
# Write example rows if requested
|
|
if include_examples and template.get("example_rows"):
|
|
for example in template["example_rows"]:
|
|
row = [example.get(col["name"], "") for col in columns]
|
|
writer.writerow(row)
|
|
|
|
return output.getvalue()
|
|
|
|
|
|
def generate_template_documentation(template_type: str) -> Dict[str, Any]:
|
|
"""
|
|
Generate documentation for a template including field descriptions.
|
|
|
|
Args:
|
|
template_type: One of the template types
|
|
|
|
Returns:
|
|
Dictionary with template metadata and field documentation
|
|
"""
|
|
if template_type not in TEMPLATES:
|
|
raise ValueError(f"Unknown template type: {template_type}")
|
|
|
|
template = TEMPLATES[template_type]
|
|
|
|
return {
|
|
"type": template_type,
|
|
"name": template["name"],
|
|
"description": template["description"],
|
|
"required": template["required"],
|
|
"filename": template["filename"],
|
|
"fields": [
|
|
{
|
|
"name": col["name"],
|
|
"required": col["required"],
|
|
"type": col["type"],
|
|
"description": col["description"],
|
|
"options": col.get("options"),
|
|
"default": col.get("default"),
|
|
}
|
|
for col in template["columns"]
|
|
]
|
|
}
|
|
|
|
|
|
def get_all_templates_info() -> List[Dict[str, Any]]:
|
|
"""Get summary information about all available templates."""
|
|
return [
|
|
{
|
|
"type": key,
|
|
"name": template["name"],
|
|
"description": template["description"],
|
|
"required": template["required"],
|
|
"filename": template["filename"],
|
|
"field_count": len(template["columns"]),
|
|
"required_fields": [col["name"] for col in template["columns"] if col["required"]],
|
|
}
|
|
for key, template in TEMPLATES.items()
|
|
]
|
|
|
|
|
|
# =============================================================================
|
|
# CSV Parsing and Validation Functions
|
|
# =============================================================================
|
|
|
|
def parse_boolean(value: Any) -> Optional[bool]:
|
|
"""Parse a boolean value from CSV."""
|
|
if value is None or value == "":
|
|
return None
|
|
if isinstance(value, bool):
|
|
return value
|
|
str_val = str(value).lower().strip()
|
|
if str_val in ("true", "yes", "1", "y"):
|
|
return True
|
|
if str_val in ("false", "no", "0", "n"):
|
|
return False
|
|
return None
|
|
|
|
|
|
def parse_date(value: Any) -> Optional[date]:
|
|
"""Parse a date value from CSV (expects YYYY-MM-DD)."""
|
|
if value is None or value == "":
|
|
return None
|
|
try:
|
|
if isinstance(value, date):
|
|
return value
|
|
return datetime.strptime(str(value).strip(), "%Y-%m-%d").date()
|
|
except ValueError:
|
|
# Try alternative formats
|
|
for fmt in ["%m/%d/%Y", "%d/%m/%Y", "%Y/%m/%d"]:
|
|
try:
|
|
return datetime.strptime(str(value).strip(), fmt).date()
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def parse_decimal(value: Any) -> Optional[float]:
|
|
"""Parse a decimal value from CSV."""
|
|
if value is None or value == "":
|
|
return None
|
|
try:
|
|
# Remove currency symbols and commas
|
|
cleaned = re.sub(r'[,$€£]', '', str(value).strip())
|
|
return float(cleaned)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def parse_list(value: Any) -> List[str]:
|
|
"""Parse a comma-separated list from CSV."""
|
|
if value is None or value == "":
|
|
return []
|
|
return [item.strip() for item in str(value).split(",") if item.strip()]
|
|
|
|
|
|
def validate_email(email: str) -> bool:
|
|
"""Validate email format."""
|
|
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
|
return bool(re.match(pattern, email.strip()))
|
|
|
|
|
|
def validate_row(row: Dict[str, Any], template_type: str) -> Tuple[bool, List[str], Dict[str, Any]]:
|
|
"""
|
|
Validate a single row against the template schema.
|
|
|
|
Args:
|
|
row: Dictionary of column name -> value
|
|
template_type: The template type to validate against
|
|
|
|
Returns:
|
|
Tuple of (is_valid, errors, parsed_row)
|
|
"""
|
|
if template_type not in TEMPLATES:
|
|
return False, [f"Unknown template type: {template_type}"], {}
|
|
|
|
template = TEMPLATES[template_type]
|
|
columns = {col["name"]: col for col in template["columns"]}
|
|
errors = []
|
|
parsed_row = {}
|
|
|
|
# Check required fields
|
|
for col_name, col_def in columns.items():
|
|
value = row.get(col_name, "")
|
|
|
|
# Check required
|
|
if col_def["required"] and (value is None or str(value).strip() == ""):
|
|
errors.append(f"Required field '{col_name}' is missing")
|
|
continue
|
|
|
|
# Skip empty optional fields
|
|
if value is None or str(value).strip() == "":
|
|
parsed_row[col_name] = col_def.get("default")
|
|
continue
|
|
|
|
# Type validation and parsing
|
|
col_type = col_def["type"]
|
|
|
|
if col_type == "email":
|
|
if not validate_email(str(value)):
|
|
errors.append(f"Invalid email format in '{col_name}': {value}")
|
|
else:
|
|
parsed_row[col_name] = str(value).strip().lower()
|
|
|
|
elif col_type == "string":
|
|
parsed_row[col_name] = str(value).strip()
|
|
|
|
elif col_type == "boolean":
|
|
parsed = parse_boolean(value)
|
|
if parsed is None and str(value).strip():
|
|
errors.append(f"Invalid boolean in '{col_name}': {value} (use true/false)")
|
|
else:
|
|
parsed_row[col_name] = parsed
|
|
|
|
elif col_type == "date":
|
|
parsed = parse_date(value)
|
|
if parsed is None:
|
|
errors.append(f"Invalid date in '{col_name}': {value} (use YYYY-MM-DD)")
|
|
else:
|
|
parsed_row[col_name] = parsed
|
|
|
|
elif col_type == "decimal":
|
|
parsed = parse_decimal(value)
|
|
if parsed is None:
|
|
errors.append(f"Invalid number in '{col_name}': {value}")
|
|
else:
|
|
parsed_row[col_name] = parsed
|
|
|
|
elif col_type == "list":
|
|
parsed_row[col_name] = parse_list(value)
|
|
|
|
elif col_type == "enum":
|
|
str_val = str(value).strip().lower()
|
|
options = [opt.lower() for opt in col_def.get("options", [])]
|
|
if str_val and str_val not in options:
|
|
errors.append(f"Invalid value in '{col_name}': {value}. Must be one of: {', '.join(col_def['options'])}")
|
|
else:
|
|
parsed_row[col_name] = str_val if str_val else col_def.get("default")
|
|
else:
|
|
parsed_row[col_name] = value
|
|
|
|
return len(errors) == 0, errors, parsed_row
|
|
|
|
|
|
def parse_csv_file(file_content: bytes, template_type: str) -> Dict[str, Any]:
|
|
"""
|
|
Parse and validate a CSV file against a template.
|
|
|
|
Args:
|
|
file_content: Raw CSV file content
|
|
template_type: The template type to validate against
|
|
|
|
Returns:
|
|
Dictionary with parsing results:
|
|
{
|
|
'success': bool,
|
|
'total_rows': int,
|
|
'valid_rows': int,
|
|
'invalid_rows': int,
|
|
'rows': [...], # Parsed and validated rows
|
|
'errors': [...], # List of {row: int, errors: [...]}
|
|
'warnings': [...],
|
|
}
|
|
"""
|
|
result = {
|
|
'success': True,
|
|
'total_rows': 0,
|
|
'valid_rows': 0,
|
|
'invalid_rows': 0,
|
|
'rows': [],
|
|
'errors': [],
|
|
'warnings': [],
|
|
}
|
|
|
|
try:
|
|
# Decode file content
|
|
try:
|
|
content = file_content.decode('utf-8')
|
|
except UnicodeDecodeError:
|
|
content = file_content.decode('latin-1')
|
|
|
|
# Parse CSV
|
|
reader = csv.DictReader(io.StringIO(content))
|
|
|
|
# Check if headers match template
|
|
template = TEMPLATES.get(template_type)
|
|
if not template:
|
|
result['success'] = False
|
|
result['errors'].append({'row': 0, 'errors': [f"Unknown template type: {template_type}"]})
|
|
return result
|
|
|
|
expected_headers = {col["name"] for col in template["columns"]}
|
|
actual_headers = set(reader.fieldnames or [])
|
|
|
|
# Check for missing required headers
|
|
required_headers = {col["name"] for col in template["columns"] if col["required"]}
|
|
missing_required = required_headers - actual_headers
|
|
if missing_required:
|
|
result['success'] = False
|
|
result['errors'].append({
|
|
'row': 0,
|
|
'errors': [f"Missing required columns: {', '.join(missing_required)}"]
|
|
})
|
|
return result
|
|
|
|
# Warn about unexpected headers
|
|
unexpected = actual_headers - expected_headers
|
|
if unexpected:
|
|
result['warnings'].append(f"Unexpected columns will be ignored: {', '.join(unexpected)}")
|
|
|
|
# Parse and validate each row
|
|
for row_num, row in enumerate(reader, start=2): # Start at 2 (1 is header)
|
|
result['total_rows'] += 1
|
|
|
|
is_valid, errors, parsed_row = validate_row(row, template_type)
|
|
parsed_row['_row_number'] = row_num
|
|
|
|
if is_valid:
|
|
result['valid_rows'] += 1
|
|
result['rows'].append(parsed_row)
|
|
else:
|
|
result['invalid_rows'] += 1
|
|
result['errors'].append({
|
|
'row': row_num,
|
|
'errors': errors,
|
|
'data': {k: v for k, v in row.items() if k in expected_headers}
|
|
})
|
|
|
|
if result['invalid_rows'] > 0:
|
|
result['success'] = False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error parsing CSV: {str(e)}")
|
|
result['success'] = False
|
|
result['errors'].append({'row': 0, 'errors': [f"Error parsing CSV: {str(e)}"]})
|
|
|
|
return result
|
|
|
|
|
|
def cross_validate_files(
|
|
users_result: Dict[str, Any],
|
|
subscriptions_result: Optional[Dict[str, Any]] = None,
|
|
donations_result: Optional[Dict[str, Any]] = None,
|
|
payments_result: Optional[Dict[str, Any]] = None,
|
|
registration_data_result: Optional[Dict[str, Any]] = None,
|
|
existing_emails: Optional[set] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Cross-validate multiple CSV files to ensure referential integrity.
|
|
|
|
Checks:
|
|
- All emails in related files exist in users.csv
|
|
- No duplicate emails in users.csv
|
|
- Emails don't already exist in database (if existing_emails provided)
|
|
|
|
Returns:
|
|
Validation result with warnings and errors
|
|
"""
|
|
result = {
|
|
'valid': True,
|
|
'warnings': [],
|
|
'errors': [],
|
|
'summary': {
|
|
'total_users': 0,
|
|
'new_users': 0,
|
|
'duplicate_users': 0,
|
|
'total_subscriptions': 0,
|
|
'total_donations': 0,
|
|
'total_payments': 0,
|
|
'total_registration_fields': 0,
|
|
'orphaned_records': 0,
|
|
}
|
|
}
|
|
|
|
# Get all user emails from users.csv
|
|
user_emails = set()
|
|
duplicate_emails = set()
|
|
|
|
for row in users_result.get('rows', []):
|
|
email = row.get('email', '').lower()
|
|
if email:
|
|
if email in user_emails:
|
|
duplicate_emails.add(email)
|
|
user_emails.add(email)
|
|
|
|
result['summary']['total_users'] = len(user_emails)
|
|
result['summary']['duplicate_users'] = len(duplicate_emails)
|
|
|
|
if duplicate_emails:
|
|
result['errors'].append(f"Duplicate emails in users.csv: {', '.join(list(duplicate_emails)[:5])}{'...' if len(duplicate_emails) > 5 else ''}")
|
|
result['valid'] = False
|
|
|
|
# Check against existing database emails
|
|
if existing_emails:
|
|
existing_in_import = user_emails & existing_emails
|
|
new_emails = user_emails - existing_emails
|
|
result['summary']['new_users'] = len(new_emails)
|
|
|
|
if existing_in_import:
|
|
result['warnings'].append(
|
|
f"{len(existing_in_import)} email(s) already exist in database and will be updated: "
|
|
f"{', '.join(list(existing_in_import)[:3])}{'...' if len(existing_in_import) > 3 else ''}"
|
|
)
|
|
|
|
# Validate subscriptions
|
|
if subscriptions_result and subscriptions_result.get('rows'):
|
|
result['summary']['total_subscriptions'] = len(subscriptions_result['rows'])
|
|
orphaned = []
|
|
for row in subscriptions_result['rows']:
|
|
email = row.get('email', '').lower()
|
|
if email and email not in user_emails:
|
|
orphaned.append(email)
|
|
if orphaned:
|
|
result['warnings'].append(
|
|
f"{len(orphaned)} subscription(s) reference emails not in users.csv: "
|
|
f"{', '.join(orphaned[:3])}{'...' if len(orphaned) > 3 else ''}"
|
|
)
|
|
result['summary']['orphaned_records'] += len(orphaned)
|
|
|
|
# Validate donations (email is optional for anonymous)
|
|
if donations_result and donations_result.get('rows'):
|
|
result['summary']['total_donations'] = len(donations_result['rows'])
|
|
orphaned = []
|
|
for row in donations_result['rows']:
|
|
email = row.get('email', '').lower()
|
|
if email and email not in user_emails:
|
|
orphaned.append(email)
|
|
if orphaned:
|
|
result['warnings'].append(
|
|
f"{len(orphaned)} donation(s) reference emails not in users.csv: "
|
|
f"{', '.join(orphaned[:3])}{'...' if len(orphaned) > 3 else ''}"
|
|
)
|
|
result['summary']['orphaned_records'] += len(orphaned)
|
|
|
|
# Validate payments
|
|
if payments_result and payments_result.get('rows'):
|
|
result['summary']['total_payments'] = len(payments_result['rows'])
|
|
orphaned = []
|
|
for row in payments_result['rows']:
|
|
email = row.get('email', '').lower()
|
|
if email and email not in user_emails:
|
|
orphaned.append(email)
|
|
if orphaned:
|
|
result['warnings'].append(
|
|
f"{len(orphaned)} payment(s) reference emails not in users.csv: "
|
|
f"{', '.join(orphaned[:3])}{'...' if len(orphaned) > 3 else ''}"
|
|
)
|
|
result['summary']['orphaned_records'] += len(orphaned)
|
|
|
|
# Validate registration data
|
|
if registration_data_result and registration_data_result.get('rows'):
|
|
result['summary']['total_registration_fields'] = len(registration_data_result['rows'])
|
|
orphaned = []
|
|
for row in registration_data_result['rows']:
|
|
email = row.get('email', '').lower()
|
|
if email and email not in user_emails:
|
|
orphaned.append(email)
|
|
if orphaned:
|
|
result['warnings'].append(
|
|
f"{len(orphaned)} registration field(s) reference emails not in users.csv: "
|
|
f"{', '.join(orphaned[:3])}{'...' if len(orphaned) > 3 else ''}"
|
|
)
|
|
result['summary']['orphaned_records'] += len(orphaned)
|
|
|
|
return result
|
|
|
|
|
|
# =============================================================================
|
|
# Dynamic Registration Template Generator
|
|
# =============================================================================
|
|
|
|
# Standard User model fields that have direct mappings (not stored in custom_registration_data)
|
|
USER_MODEL_FIELDS = {
|
|
'email', '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_subscribed', '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', 'social_media_facebook',
|
|
'social_media_instagram', 'social_media_twitter', 'social_media_linkedin',
|
|
'accepts_tos', 'password'
|
|
}
|
|
|
|
|
|
def extract_custom_fields_from_schema(registration_schema: dict) -> List[Dict[str, Any]]:
|
|
"""
|
|
Extract custom fields from registration schema that should be stored in custom_registration_data.
|
|
|
|
Custom fields are those where:
|
|
- The field has no 'mapping' to a User model column, OR
|
|
- The field's 'mapping' is not in USER_MODEL_FIELDS
|
|
|
|
Args:
|
|
registration_schema: The registration form schema from SystemSettings
|
|
|
|
Returns:
|
|
List of custom field definitions with their metadata
|
|
"""
|
|
custom_fields = []
|
|
|
|
for step in registration_schema.get('steps', []):
|
|
for section in step.get('sections', []):
|
|
for field in section.get('fields', []):
|
|
field_id = field.get('id', '')
|
|
mapping = field.get('mapping', field_id)
|
|
|
|
# Skip fixed/core fields that map directly to User model
|
|
if mapping in USER_MODEL_FIELDS:
|
|
continue
|
|
|
|
# Skip email/password/tos which are handled specially
|
|
if field_id in ('email', 'password', 'confirm_password', 'accepts_tos'):
|
|
continue
|
|
|
|
# This is a custom field
|
|
field_type = field.get('type', 'text')
|
|
|
|
# Map registration field types to CSV column types
|
|
csv_type = 'string'
|
|
if field_type in ('number', 'currency'):
|
|
csv_type = 'decimal'
|
|
elif field_type == 'date':
|
|
csv_type = 'date'
|
|
elif field_type in ('checkbox', 'toggle'):
|
|
csv_type = 'boolean'
|
|
elif field_type in ('select', 'radio'):
|
|
csv_type = 'enum'
|
|
elif field_type in ('multiselect', 'checkbox_group'):
|
|
csv_type = 'list'
|
|
|
|
custom_field = {
|
|
'name': field_id,
|
|
'required': field.get('required', False),
|
|
'type': csv_type,
|
|
'label': field.get('label', field_id),
|
|
'description': field.get('placeholder', field.get('label', f'Custom field: {field_id}')),
|
|
'section': section.get('title', 'Unknown'),
|
|
'step': step.get('title', 'Unknown'),
|
|
}
|
|
|
|
# Add options for enum types
|
|
if field_type in ('select', 'radio', 'multiselect', 'checkbox_group'):
|
|
options = field.get('options', [])
|
|
if options:
|
|
custom_field['options'] = [opt.get('value', opt.get('label', '')) for opt in options]
|
|
|
|
custom_fields.append(custom_field)
|
|
|
|
return custom_fields
|
|
|
|
|
|
def generate_dynamic_registration_template(registration_schema: dict) -> Dict[str, Any]:
|
|
"""
|
|
Generate a dynamic registration_data template based on the current registration schema.
|
|
|
|
This creates a template where:
|
|
- email column is always first and required
|
|
- Each custom field becomes its own column (flat structure, not key-value pairs)
|
|
- Column names match the field IDs from the registration schema
|
|
|
|
Args:
|
|
registration_schema: The registration form schema
|
|
|
|
Returns:
|
|
Template definition compatible with TEMPLATES format
|
|
"""
|
|
custom_fields = extract_custom_fields_from_schema(registration_schema)
|
|
|
|
# Build columns list - email is always first
|
|
columns = [
|
|
{"name": "email", "required": True, "type": "email", "description": "User's email (must match users.csv)"}
|
|
]
|
|
|
|
# Add custom field columns
|
|
for field in custom_fields:
|
|
col = {
|
|
"name": field['name'],
|
|
"required": field['required'],
|
|
"type": field['type'],
|
|
"description": f"{field['label']} ({field['section']})",
|
|
}
|
|
if field.get('options'):
|
|
col['options'] = field['options']
|
|
columns.append(col)
|
|
|
|
# Generate example rows
|
|
example_rows = []
|
|
if custom_fields:
|
|
example_row = {"email": "john.doe@example.com"}
|
|
for field in custom_fields:
|
|
# Generate example value based on type
|
|
if field['type'] == 'boolean':
|
|
example_row[field['name']] = 'true'
|
|
elif field['type'] == 'date':
|
|
example_row[field['name']] = '2024-01-15'
|
|
elif field['type'] == 'decimal':
|
|
example_row[field['name']] = '100.00'
|
|
elif field['type'] == 'list':
|
|
opts = field.get('options', ['Option1', 'Option2'])
|
|
example_row[field['name']] = ','.join(opts[:2]) if len(opts) > 1 else opts[0] if opts else ''
|
|
elif field['type'] == 'enum':
|
|
opts = field.get('options', [])
|
|
example_row[field['name']] = opts[0] if opts else 'value'
|
|
else:
|
|
example_row[field['name']] = f'Example {field["label"]}'
|
|
example_rows.append(example_row)
|
|
|
|
# Add second example row
|
|
example_row2 = {"email": "jane.smith@example.com"}
|
|
for field in custom_fields:
|
|
if field['type'] == 'boolean':
|
|
example_row2[field['name']] = 'false'
|
|
elif field['type'] == 'date':
|
|
example_row2[field['name']] = '2023-06-20'
|
|
elif field['type'] == 'decimal':
|
|
example_row2[field['name']] = '50.00'
|
|
elif field['type'] == 'list':
|
|
opts = field.get('options', ['Option1'])
|
|
example_row2[field['name']] = opts[0] if opts else ''
|
|
elif field['type'] == 'enum':
|
|
opts = field.get('options', [])
|
|
example_row2[field['name']] = opts[1] if len(opts) > 1 else opts[0] if opts else ''
|
|
else:
|
|
example_row2[field['name']] = ''
|
|
example_rows.append(example_row2)
|
|
|
|
return {
|
|
"name": "Custom Registration Data",
|
|
"description": f"Custom registration fields from current form configuration ({len(custom_fields)} custom fields)",
|
|
"required": False,
|
|
"filename": "registration_data_template.csv",
|
|
"is_dynamic": True,
|
|
"columns": columns,
|
|
"example_rows": example_rows,
|
|
"custom_fields_info": [
|
|
{
|
|
"name": f['name'],
|
|
"label": f['label'],
|
|
"type": f['type'],
|
|
"required": f['required'],
|
|
"section": f['section'],
|
|
"step": f['step'],
|
|
}
|
|
for f in custom_fields
|
|
]
|
|
}
|
|
|
|
|
|
def generate_template_csv_dynamic(registration_schema: dict, include_examples: bool = True) -> str:
|
|
"""
|
|
Generate CSV content for the dynamic registration template.
|
|
|
|
Args:
|
|
registration_schema: The registration form schema
|
|
include_examples: Whether to include example data rows
|
|
|
|
Returns:
|
|
CSV string content
|
|
"""
|
|
template = generate_dynamic_registration_template(registration_schema)
|
|
columns = template["columns"]
|
|
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
|
|
# Write header row
|
|
headers = [col["name"] for col in columns]
|
|
writer.writerow(headers)
|
|
|
|
# Write example rows if requested
|
|
if include_examples and template.get("example_rows"):
|
|
for example in template["example_rows"]:
|
|
row = [example.get(col["name"], "") for col in columns]
|
|
writer.writerow(row)
|
|
|
|
return output.getvalue()
|