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" finance = "finance" 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])