forked from andika/membership-be
Donation Tracking\ Validation Rejection\ Subscription Data Export\ Admin Dashboard Logo\ Admin Navbar Reorganization
476 lines
23 KiB
Python
476 lines
23 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 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")
|
|
|
|
# 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")
|
|
|
|
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 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)
|
|
payment_method = Column(String, nullable=True) # card, bank_transfer, etc.
|
|
|
|
# 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"
|
|
|
|
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])
|