Files
membership-be/models.py
Koncept Kit 9754f2db6e 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
2026-01-31 01:03:17 +07:00

623 lines
31 KiB
Python

from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum, Text, Integer, BigInteger, ForeignKey, JSON, Index
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
import uuid
import enum
from database import Base
class UserStatus(enum.Enum):
pending_email = "pending_email"
pending_validation = "pending_validation"
pre_validated = "pre_validated"
payment_pending = "payment_pending"
active = "active"
inactive = "inactive"
canceled = "canceled" # User or admin canceled membership
expired = "expired" # Subscription ended without renewal
abandoned = "abandoned" # Incomplete registration (no verification/event/payment)
rejected = "rejected" # Application rejected by admin
class UserRole(enum.Enum):
guest = "guest"
member = "member"
admin = "admin"
superadmin = "superadmin"
finance = "finance"
class RSVPStatus(enum.Enum):
yes = "yes"
no = "no"
maybe = "maybe"
class SubscriptionStatus(enum.Enum):
active = "active"
expired = "expired"
cancelled = "cancelled"
class DonationType(enum.Enum):
member = "member"
public = "public"
class DonationStatus(enum.Enum):
pending = "pending"
completed = "completed"
failed = "failed"
class PaymentMethodType(enum.Enum):
card = "card"
cash = "cash"
bank_transfer = "bank_transfer"
check = "check"
class User(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String, unique=True, nullable=False, index=True)
password_hash = Column(String, nullable=False)
first_name = Column(String, nullable=False)
last_name = Column(String, nullable=False)
phone = Column(String, nullable=False)
address = Column(String, nullable=False)
city = Column(String, nullable=False)
state = Column(String, nullable=False)
zipcode = Column(String, nullable=False)
date_of_birth = Column(DateTime, nullable=False)
lead_sources = Column(JSON, default=list)
partner_first_name = Column(String, nullable=True)
partner_last_name = Column(String, nullable=True)
partner_is_member = Column(Boolean, default=False)
partner_plan_to_become_member = Column(Boolean, default=False)
referred_by_member_name = Column(String, nullable=True)
status = Column(SQLEnum(UserStatus), default=UserStatus.pending_email, nullable=False)
role = Column(SQLEnum(UserRole), default=UserRole.guest, nullable=False) # Legacy enum, kept for backward compatibility
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=True) # New dynamic role FK
email_verified = Column(Boolean, default=False)
email_verification_token = Column(String, nullable=True)
email_verification_expires = Column(DateTime, nullable=True)
newsletter_subscribed = Column(Boolean, default=False)
# Newsletter Publication Preferences (Step 2)
newsletter_publish_name = Column(Boolean, default=False, nullable=False)
newsletter_publish_photo = Column(Boolean, default=False, nullable=False)
newsletter_publish_birthday = Column(Boolean, default=False, nullable=False)
newsletter_publish_none = Column(Boolean, default=False, nullable=False)
# Volunteer Interests (Step 2)
volunteer_interests = Column(JSON, default=list)
# Scholarship Request (Step 2)
scholarship_requested = Column(Boolean, default=False, nullable=False)
scholarship_reason = Column(Text, nullable=True)
# Directory Settings (Step 3)
show_in_directory = Column(Boolean, default=False, nullable=False)
directory_email = Column(String, nullable=True)
directory_bio = Column(Text, nullable=True)
directory_address = Column(String, nullable=True)
directory_phone = Column(String, nullable=True)
directory_dob = Column(DateTime, nullable=True)
directory_partner_name = Column(String, nullable=True)
# Password Reset Fields
password_reset_token = Column(String, nullable=True)
password_reset_expires = Column(DateTime, nullable=True)
force_password_change = Column(Boolean, default=False, nullable=False)
# Members Only - Profile Photo & Social Media
profile_photo_url = Column(String, nullable=True) # Cloudflare R2 URL
social_media_facebook = Column(String, nullable=True)
social_media_instagram = Column(String, nullable=True)
social_media_twitter = Column(String, nullable=True)
social_media_linkedin = Column(String, nullable=True)
# Terms of Service Acceptance (Step 4)
accepts_tos = Column(Boolean, default=False, nullable=False)
tos_accepted_at = Column(DateTime, nullable=True)
# Member Since Date - Editable by admins for imported users
member_since = Column(DateTime, nullable=True, comment="Date when user became a member - editable by admins for imported users")
# Reminder Tracking - for admin dashboard visibility
email_verification_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of email verification reminders sent")
last_email_verification_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last verification reminder")
event_attendance_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of event attendance reminders sent")
last_event_attendance_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last event attendance reminder")
payment_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of payment reminders sent")
last_payment_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last payment reminder")
renewal_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of renewal reminders sent")
last_renewal_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last renewal reminder")
# Rejection Tracking
rejection_reason = Column(Text, nullable=True, comment="Reason provided when application was rejected")
rejected_at = Column(DateTime(timezone=True), nullable=True, comment="Timestamp when application was rejected")
rejected_by = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=True, comment="Admin who rejected the application")
# WordPress Import Tracking
import_source = Column(String(50), nullable=True, comment="Source of user creation: wordpress, manual, registration")
import_job_id = Column(UUID(as_uuid=True), ForeignKey('import_jobs.id'), nullable=True, comment="Import job that created this user")
wordpress_user_id = Column(BigInteger, nullable=True, comment="Original WordPress user ID")
wordpress_registered_date = Column(DateTime(timezone=True), nullable=True, comment="Original WordPress registration date")
# Role Change Audit Trail
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))
# Relationships
role_obj = relationship("Role", back_populates="users", foreign_keys=[role_id])
events_created = relationship("Event", back_populates="creator")
rsvps = relationship("EventRSVP", back_populates="user")
subscriptions = relationship("Subscription", back_populates="user", foreign_keys="Subscription.user_id")
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"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String, nullable=False)
description = Column(Text, nullable=True)
start_at = Column(DateTime, nullable=False)
end_at = Column(DateTime, nullable=False)
location = Column(String, nullable=False)
capacity = Column(Integer, nullable=True)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
published = Column(Boolean, default=False)
# Members Only - Universal Calendar Export
calendar_uid = Column(String, nullable=True) # Unique iCalendar UID (UUID4-based)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Relationships
creator = relationship("User", back_populates="events_created")
rsvps = relationship("EventRSVP", back_populates="event", cascade="all, delete-orphan")
gallery_images = relationship("EventGallery", back_populates="event", cascade="all, delete-orphan")
class EventRSVP(Base):
__tablename__ = "event_rsvps"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
event_id = Column(UUID(as_uuid=True), ForeignKey("events.id"), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
rsvp_status = Column(SQLEnum(RSVPStatus), default=RSVPStatus.maybe, nullable=False)
attended = Column(Boolean, default=False)
attended_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Relationships
event = relationship("Event", back_populates="rsvps")
user = relationship("User", back_populates="rsvps")
class SubscriptionPlan(Base):
__tablename__ = "subscription_plans"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
price_cents = Column(Integer, nullable=False) # Price in cents (legacy, kept for backward compatibility)
billing_cycle = Column(String, default="yearly", nullable=False) # yearly, monthly, quarterly, lifetime, custom
stripe_price_id = Column(String, nullable=True) # Stripe Price ID (legacy, deprecated)
active = Column(Boolean, default=True)
# Custom billing cycle fields (for recurring date ranges like Jan 1 - Dec 31)
custom_cycle_enabled = Column(Boolean, default=False, nullable=False)
custom_cycle_start_month = Column(Integer, nullable=True) # 1-12
custom_cycle_start_day = Column(Integer, nullable=True) # 1-31
custom_cycle_end_month = Column(Integer, nullable=True) # 1-12
custom_cycle_end_day = Column(Integer, nullable=True) # 1-31
# Dynamic pricing fields
minimum_price_cents = Column(Integer, default=3000, nullable=False) # $30 minimum
suggested_price_cents = Column(Integer, nullable=True) # Suggested price (can be higher than minimum)
allow_donation = Column(Boolean, default=True, nullable=False) # Allow members to add donations
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Relationships
subscriptions = relationship("Subscription", back_populates="plan")
class Subscription(Base):
__tablename__ = "subscriptions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id"), nullable=False)
stripe_subscription_id = Column(String, nullable=True) # Stripe Subscription ID
stripe_customer_id = Column(String, nullable=True) # Stripe Customer ID
status = Column(SQLEnum(SubscriptionStatus), default=SubscriptionStatus.active, nullable=False)
start_date = Column(DateTime, nullable=False)
end_date = Column(DateTime, nullable=True)
amount_paid_cents = Column(Integer, nullable=True) # Total amount paid in cents (base + donation)
# Donation tracking fields (for transparency and tax reporting)
base_subscription_cents = Column(Integer, nullable=False) # Plan base price (minimum)
donation_cents = Column(Integer, default=0, nullable=False) # Additional donation amount
# Note: amount_paid_cents = base_subscription_cents + donation_cents
# Stripe transaction metadata (for validation and audit)
stripe_payment_intent_id = Column(String, nullable=True, index=True) # Initial payment transaction ID
stripe_charge_id = Column(String, nullable=True, index=True) # Actual charge reference
stripe_invoice_id = Column(String, nullable=True, index=True) # Invoice reference
payment_completed_at = Column(DateTime(timezone=True), nullable=True) # Exact payment timestamp from Stripe
card_last4 = Column(String(4), nullable=True) # Last 4 digits of card
card_brand = Column(String(20), nullable=True) # Visa, Mastercard, etc.
stripe_receipt_url = Column(String, nullable=True) # Link to Stripe receipt
# Manual payment fields
manual_payment = Column(Boolean, default=False, nullable=False) # Whether this was a manual offline payment
manual_payment_notes = Column(Text, nullable=True) # Admin notes about the payment
manual_payment_admin_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) # Admin who processed the payment
manual_payment_date = Column(DateTime, nullable=True) # Date payment was received
payment_method = Column(String, nullable=True) # Payment method: stripe, cash, bank_transfer, check, other
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Relationships
user = relationship("User", back_populates="subscriptions", foreign_keys=[user_id])
plan = relationship("SubscriptionPlan", back_populates="subscriptions")
class Donation(Base):
__tablename__ = "donations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Donation details
amount_cents = Column(Integer, nullable=False)
donation_type = Column(SQLEnum(DonationType), nullable=False, default=DonationType.public)
status = Column(SQLEnum(DonationStatus), nullable=False, default=DonationStatus.pending)
# Donor information
user_id = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=True) # NULL for public donations
donor_email = Column(String, nullable=True) # For non-members
donor_name = Column(String, nullable=True) # For non-members
# Payment details
stripe_checkout_session_id = Column(String, nullable=True)
stripe_payment_intent_id = Column(String, nullable=True, index=True)
payment_method = Column(String, nullable=True) # card, bank_transfer, etc.
# Stripe transaction metadata (for validation and audit)
stripe_charge_id = Column(String, nullable=True, index=True) # Actual charge reference
stripe_customer_id = Column(String, nullable=True, index=True) # Customer ID if created
payment_completed_at = Column(DateTime(timezone=True), nullable=True) # Exact payment timestamp from Stripe
card_last4 = Column(String(4), nullable=True) # Last 4 digits of card
card_brand = Column(String(20), nullable=True) # Visa, Mastercard, etc.
stripe_receipt_url = Column(String, nullable=True) # Link to Stripe receipt
# Metadata
notes = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), onupdate=lambda: datetime.now(timezone.utc))
# Relationship
user = relationship("User", backref="donations", foreign_keys=[user_id])
__table_args__ = (
Index('idx_donation_user', 'user_id'),
Index('idx_donation_type', 'donation_type'),
Index('idx_donation_status', 'status'),
Index('idx_donation_created', 'created_at'),
)
class EventGallery(Base):
__tablename__ = "event_galleries"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
event_id = Column(UUID(as_uuid=True), ForeignKey("events.id"), nullable=False)
image_url = Column(String, nullable=False) # Cloudflare R2 URL
image_key = Column(String, nullable=False) # R2 object key for deletion
caption = Column(Text, nullable=True)
uploaded_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
file_size_bytes = Column(Integer, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
# Relationships
event = relationship("Event", back_populates="gallery_images")
uploader = relationship("User")
class NewsletterArchive(Base):
__tablename__ = "newsletter_archives"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String, nullable=False)
description = Column(Text, nullable=True)
published_date = Column(DateTime, nullable=False)
document_url = Column(String, nullable=False) # Google Docs URL or R2 URL
document_type = Column(String, default="google_docs") # google_docs, pdf, upload
file_size_bytes = Column(Integer, nullable=True) # For uploaded files
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Relationships
creator = relationship("User")
class FinancialReport(Base):
__tablename__ = "financial_reports"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
year = Column(Integer, nullable=False)
title = Column(String, nullable=False) # e.g., "2024 Annual Report"
document_url = Column(String, nullable=False) # Google Drive URL or R2 URL
document_type = Column(String, default="google_drive") # google_drive, pdf, upload
file_size_bytes = Column(Integer, nullable=True) # For uploaded files
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Relationships
creator = relationship("User")
class BylawsDocument(Base):
__tablename__ = "bylaws_documents"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String, nullable=False)
version = Column(String, nullable=False) # e.g., "v1.0", "v2.0"
effective_date = Column(DateTime, nullable=False)
document_url = Column(String, nullable=False) # Google Drive URL or R2 URL
document_type = Column(String, default="google_drive") # google_drive, pdf, upload
file_size_bytes = Column(Integer, nullable=True) # For uploaded files
is_current = Column(Boolean, default=True) # Only one should be current
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
# Relationships
creator = relationship("User")
class StorageUsage(Base):
__tablename__ = "storage_usage"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
total_bytes_used = Column(BigInteger, default=0)
max_bytes_allowed = Column(BigInteger, nullable=False) # From .env
last_updated = Column(DateTime, default=lambda: datetime.now(timezone.utc))
# ============================================================
# RBAC Permission Management Models
# ============================================================
class Permission(Base):
"""Granular permissions for role-based access control"""
__tablename__ = "permissions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
code = Column(String, unique=True, nullable=False, index=True) # "users.create", "events.edit"
name = Column(String, nullable=False) # "Create Users", "Edit Events"
description = Column(Text, nullable=True)
module = Column(String, nullable=False, index=True) # "users", "events", "subscriptions"
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
# Relationships
role_permissions = relationship("RolePermission", back_populates="permission", cascade="all, delete-orphan")
class Role(Base):
"""Dynamic roles that can be created by admins"""
__tablename__ = "roles"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
code = Column(String, unique=True, nullable=False, index=True) # "superadmin", "finance", "custom_role_1"
name = Column(String, nullable=False) # "Superadmin", "Finance Manager", "Custom Role"
description = Column(Text, nullable=True)
is_system_role = Column(Boolean, default=False, nullable=False) # True for Superadmin, Member, Guest (non-deletable)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# Relationships
users = relationship("User", back_populates="role_obj", foreign_keys="User.role_id")
role_permissions = relationship("RolePermission", back_populates="role_obj", cascade="all, delete-orphan")
creator = relationship("User", foreign_keys=[created_by])
class RolePermission(Base):
"""Junction table linking roles to permissions"""
__tablename__ = "role_permissions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
role = Column(SQLEnum(UserRole), nullable=False, index=True) # Legacy enum, kept for backward compatibility
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=True, index=True) # New dynamic role FK
permission_id = Column(UUID(as_uuid=True), ForeignKey("permissions.id"), nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# Relationships
role_obj = relationship("Role", back_populates="role_permissions")
permission = relationship("Permission", back_populates="role_permissions")
creator = relationship("User", foreign_keys=[created_by])
# Composite unique index
__table_args__ = (
Index('idx_role_permission', 'role', 'permission_id', unique=True),
)
# ============================================================
# User Invitation Models
# ============================================================
class InvitationStatus(enum.Enum):
pending = "pending"
accepted = "accepted"
expired = "expired"
revoked = "revoked"
class UserInvitation(Base):
"""Email-based user invitations with tokens"""
__tablename__ = "user_invitations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String, nullable=False, index=True)
token = Column(String, unique=True, nullable=False, index=True)
role = Column(SQLEnum(UserRole), nullable=False)
status = Column(SQLEnum(InvitationStatus), default=InvitationStatus.pending, nullable=False)
# Optional pre-filled information
first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True)
phone = Column(String, nullable=True)
# Invitation tracking
invited_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
invited_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
expires_at = Column(DateTime, nullable=False)
accepted_at = Column(DateTime, nullable=True)
accepted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# Relationships
inviter = relationship("User", foreign_keys=[invited_by])
accepted_user = relationship("User", foreign_keys=[accepted_by])
# ============================================================
# CSV Import/Export Models
# ============================================================
class ImportJobStatus(enum.Enum):
processing = "processing"
completed = "completed"
failed = "failed"
partial = "partial"
validating = "validating"
preview_ready = "preview_ready"
rolled_back = "rolled_back"
class ImportJob(Base):
"""Track CSV import jobs with error handling"""
__tablename__ = "import_jobs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
filename = Column(String, nullable=False)
file_key = Column(String, nullable=True) # R2 object key for uploaded CSV
total_rows = Column(Integer, nullable=False)
processed_rows = Column(Integer, default=0, nullable=False)
successful_rows = Column(Integer, default=0, nullable=False)
failed_rows = Column(Integer, default=0, nullable=False)
status = Column(SQLEnum(ImportJobStatus), default=ImportJobStatus.processing, nullable=False)
errors = Column(JSON, default=list, nullable=False) # [{row: 5, field: "email", error: "Invalid format"}]
# WordPress import enhancements
field_mapping = Column(JSON, default=dict, nullable=False) # Maps CSV columns to DB fields
wordpress_metadata = Column(JSON, default=dict, nullable=False) # Preview data, validation results
imported_user_ids = Column(JSON, default=list, nullable=False) # User IDs for rollback
rollback_at = Column(DateTime, nullable=True)
rollback_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# Tracking
imported_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
completed_at = Column(DateTime, nullable=True)
# Relationships
importer = relationship("User", foreign_keys=[imported_by])
rollback_user = relationship("User", foreign_keys=[rollback_by])
class ImportRollbackAudit(Base):
"""Audit trail for import rollback operations"""
__tablename__ = "import_rollback_audit"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
import_job_id = Column(UUID(as_uuid=True), ForeignKey("import_jobs.id"), nullable=False)
rolled_back_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
rolled_back_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
deleted_user_count = Column(Integer, nullable=False)
deleted_user_ids = Column(JSON, nullable=False) # List of deleted user UUIDs
reason = Column(Text, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
# Relationships
import_job = relationship("ImportJob")
admin_user = relationship("User", foreign_keys=[rolled_back_by])
# ============================================================
# System Settings Models
# ============================================================
class SettingType(enum.Enum):
plaintext = "plaintext"
encrypted = "encrypted"
json = "json"
class SystemSettings(Base):
"""System-wide configuration settings stored in database"""
__tablename__ = "system_settings"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
setting_key = Column(String(100), unique=True, nullable=False, index=True)
setting_value = Column(Text, nullable=True)
setting_type = Column(SQLEnum(SettingType), default=SettingType.plaintext, nullable=False)
description = Column(Text, nullable=True)
updated_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
is_sensitive = Column(Boolean, default=False, nullable=False)
# Relationships
updater = relationship("User", foreign_keys=[updated_by])
# Index on updated_at for audit queries
__table_args__ = (
Index('idx_system_settings_updated_at', 'updated_at'),
)