Files
membership-be/models.py
2025-12-16 20:03:50 +07:00

425 lines
21 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)
class UserRole(enum.Enum):
guest = "guest"
member = "member"
admin = "admin"
superadmin = "superadmin"
class RSVPStatus(enum.Enum):
yes = "yes"
no = "no"
maybe = "maybe"
class SubscriptionStatus(enum.Enum):
active = "active"
expired = "expired"
cancelled = "cancelled"
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)
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")
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")
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
# 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 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"
class ImportJob(Base):
"""Track CSV import jobs with error handling"""
__tablename__ = "import_jobs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
filename = Column(String, nullable=False)
file_key = Column(String, nullable=True) # R2 object key for uploaded CSV
total_rows = Column(Integer, nullable=False)
processed_rows = Column(Integer, default=0, nullable=False)
successful_rows = Column(Integer, default=0, nullable=False)
failed_rows = Column(Integer, default=0, nullable=False)
status = Column(SQLEnum(ImportJobStatus), default=ImportJobStatus.processing, nullable=False)
errors = Column(JSON, default=list, nullable=False) # [{row: 5, field: "email", error: "Invalid format"}]
# Tracking
imported_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
completed_at = Column(DateTime, nullable=True)
# Relationships
importer = relationship("User", foreign_keys=[imported_by])