Files
sasa-membership/backend/app/models/models.py
T
nathanb 632e66e21d Add member profile questions, admin tooling, legal pages, and fast tests
- Add configurable profile questions with conditional visibility, admin-only fields, user answers, and seeded onboarding/volunteer questions
  - Add admin UI for managing profile questions and member profile answers
  - Add volunteer level/profile data support across backend schemas, models, API, and migration
  - Update dashboard/profile UI, super admin menu, membership service types, and related styling
  - Add privacy policy, terms of service, cookie notice, and footer links
  - Add frontend Vitest coverage for profile question logic
  - Add backend pytest coverage for profile answer normalization and validation
  - Update restart.sh to build, run frontend/backend unit tests, and restart only after tests pass
  - Refresh README, quickstart, project structure, instructions, and Square docs to match current app features
    - Protect feature flag reload behind super-admin access
    - Restrict admin-triggered password resets so admins can only reset member accounts
    - Replace email template HTML preview rendering with escaped text preview
    - Update docs for feature flag reload access, password reset scope, and email template preview safety

    -- test user questions are also made by AI and not very useful. but i didn't know what to put there so its good enough for a test
2026-05-04 22:05:58 +01:00

362 lines
16 KiB
Python

from sqlalchemy import (
Column, Integer, String, DateTime, Boolean, Enum as SQLEnum,
Float, Text, ForeignKey, Date, UniqueConstraint
)
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from ..core.database import Base
class UserRole(str, enum.Enum):
MEMBER = "member"
ADMIN = "admin"
SUPER_ADMIN = "super_admin"
class MembershipStatus(str, enum.Enum):
ACTIVE = "active"
EXPIRED = "expired"
PENDING = "pending"
CANCELLED = "cancelled"
class PaymentStatus(str, enum.Enum):
PENDING = "pending"
COMPLETED = "completed"
FAILED = "failed"
REFUNDED = "refunded"
class PaymentMethod(str, enum.Enum):
SQUARE = "square"
CASH = "cash"
CHECK = "check"
DUMMY = "dummy"
class EventStatus(str, enum.Enum):
DRAFT = "draft"
PUBLISHED = "published"
CANCELLED = "cancelled"
COMPLETED = "completed"
class RSVPStatus(str, enum.Enum):
PENDING = "pending"
ATTENDING = "attending"
NOT_ATTENDING = "not_attending"
MAYBE = "maybe"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
first_name = Column(String(100), nullable=False)
last_name = Column(String(100), nullable=False)
phone = Column(String(20), nullable=True)
address = Column(Text, nullable=True)
role = Column(SQLEnum(UserRole, values_callable=lambda x: [e.value for e in x]), default=UserRole.MEMBER, nullable=False)
volunteer_level = Column(String(50), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
last_login = Column(DateTime, nullable=True)
# Relationships
memberships = relationship("Membership", back_populates="user", cascade="all, delete-orphan")
payments = relationship("Payment", back_populates="user", cascade="all, delete-orphan")
event_rsvps = relationship("EventRSVP", back_populates="user", cascade="all, delete-orphan")
volunteer_assignments = relationship("VolunteerAssignment", back_populates="user", cascade="all, delete-orphan")
certificates = relationship("Certificate", back_populates="user", cascade="all, delete-orphan")
profile_answers = relationship(
"UserProfileAnswer",
back_populates="user",
cascade="all, delete-orphan",
foreign_keys="UserProfileAnswer.user_id"
)
class ProfileQuestion(Base):
__tablename__ = "profile_questions"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), unique=True, nullable=False, index=True)
label = Column(String(255), nullable=False)
help_text = Column(Text, nullable=True)
input_type = Column(String(30), nullable=False) # text, number, boolean, date, select
placeholder = Column(String(255), nullable=True)
options_json = Column(Text, nullable=True) # JSON array: [{"label":"Yes","value":"true"}]
is_required = Column(Boolean, default=False, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
admin_only_edit = Column(Boolean, default=False, nullable=False)
display_order = Column(Integer, default=0, nullable=False)
depends_on_question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=True)
depends_on_value = Column(String(255), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
depends_on_question = relationship("ProfileQuestion", remote_side=[id], backref="dependent_questions")
answers = relationship("UserProfileAnswer", back_populates="question", cascade="all, delete-orphan")
class UserProfileAnswer(Base):
__tablename__ = "user_profile_answers"
__table_args__ = (
UniqueConstraint("user_id", "question_id", name="uq_user_profile_answer"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=False, index=True)
value_text = Column(Text, nullable=True)
updated_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
user = relationship("User", foreign_keys=[user_id], back_populates="profile_answers")
question = relationship("ProfileQuestion", back_populates="answers")
updated_by_user = relationship("User", foreign_keys=[updated_by_user_id])
class MembershipTier(Base):
__tablename__ = "membership_tiers"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(Text, nullable=True)
annual_fee = Column(Float, nullable=False)
benefits = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
memberships = relationship("Membership", back_populates="tier")
class Membership(Base):
__tablename__ = "memberships"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
tier_id = Column(Integer, ForeignKey("membership_tiers.id"), nullable=False)
status = Column(SQLEnum(MembershipStatus, values_callable=lambda x: [e.value for e in x]), default=MembershipStatus.PENDING, nullable=False)
start_date = Column(Date, nullable=False)
end_date = Column(Date, nullable=False)
auto_renew = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", back_populates="memberships")
tier = relationship("MembershipTier", back_populates="memberships")
payments = relationship("Payment", back_populates="membership")
class Payment(Base):
__tablename__ = "payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
membership_id = Column(Integer, ForeignKey("memberships.id"), nullable=True)
amount = Column(Float, nullable=False)
payment_method = Column(SQLEnum(PaymentMethod, values_callable=lambda x: [e.value for e in x]), nullable=False)
status = Column(SQLEnum(PaymentStatus, values_callable=lambda x: [e.value for e in x]), default=PaymentStatus.PENDING, nullable=False)
transaction_id = Column(String(255), nullable=True)
payment_date = Column(DateTime, nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", back_populates="payments")
membership = relationship("Membership", back_populates="payments")
class Event(Base):
__tablename__ = "events"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
event_date = Column(DateTime, nullable=False)
event_time = Column(String(10), nullable=True) # HH:MM format
location = Column(String(255), nullable=True)
max_attendees = Column(Integer, nullable=True)
status = Column(SQLEnum(EventStatus, values_callable=lambda x: [e.value for e in x]), default=EventStatus.DRAFT, nullable=False)
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
rsvps = relationship("EventRSVP", back_populates="event", cascade="all, delete-orphan")
class EventRSVP(Base):
__tablename__ = "event_rsvps"
id = Column(Integer, primary_key=True, index=True)
event_id = Column(Integer, ForeignKey("events.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
status = Column(SQLEnum(RSVPStatus, values_callable=lambda x: [e.value for e in x]), default=RSVPStatus.PENDING, nullable=False)
attended = Column(Boolean, default=False, nullable=False)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
event = relationship("Event", back_populates="rsvps")
user = relationship("User", back_populates="event_rsvps")
class VolunteerRole(Base):
__tablename__ = "volunteer_roles"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
assignments = relationship("VolunteerAssignment", back_populates="role", cascade="all, delete-orphan")
class VolunteerAssignment(Base):
__tablename__ = "volunteer_assignments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
role_id = Column(Integer, ForeignKey("volunteer_roles.id"), nullable=False)
assigned_date = Column(Date, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", back_populates="volunteer_assignments")
role = relationship("VolunteerRole", back_populates="assignments")
schedules = relationship("VolunteerSchedule", back_populates="assignment", cascade="all, delete-orphan")
class VolunteerSchedule(Base):
__tablename__ = "volunteer_schedules"
id = Column(Integer, primary_key=True, index=True)
assignment_id = Column(Integer, ForeignKey("volunteer_assignments.id"), nullable=False)
schedule_date = Column(Date, nullable=False)
start_time = Column(DateTime, nullable=False)
end_time = Column(DateTime, nullable=False)
location = Column(String(255), nullable=True)
notes = Column(Text, nullable=True)
completed = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
assignment = relationship("VolunteerAssignment", back_populates="schedules")
class Certificate(Base):
__tablename__ = "certificates"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
certificate_name = Column(String(255), nullable=False)
issuing_organization = Column(String(255), nullable=True)
issue_date = Column(Date, nullable=False)
expiry_date = Column(Date, nullable=True)
certificate_number = Column(String(100), nullable=True)
file_path = Column(String(500), nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", back_populates="certificates")
class File(Base):
__tablename__ = "files"
id = Column(Integer, primary_key=True, index=True)
filename = Column(String(255), nullable=False)
original_filename = Column(String(255), nullable=False)
file_path = Column(String(500), nullable=False)
file_size = Column(Integer, nullable=False)
mime_type = Column(String(100), nullable=False)
min_tier_id = Column(Integer, ForeignKey("membership_tiers.id"), nullable=True)
description = Column(Text, nullable=True)
uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
class Notification(Base):
__tablename__ = "notifications"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
subject = Column(String(255), nullable=False)
message = Column(Text, nullable=False)
email_sent = Column(Boolean, default=False, nullable=False)
sent_at = Column(DateTime, nullable=True)
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
token = Column(String(255), unique=True, nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
used = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", backref="password_reset_tokens")
class EmailTemplate(Base):
__tablename__ = "email_templates"
id = Column(Integer, primary_key=True, index=True)
template_key = Column(String(100), unique=True, nullable=False, index=True)
name = Column(String(255), nullable=False)
subject = Column(String(255), nullable=False)
html_body = Column(Text, nullable=False)
text_body = Column(Text, nullable=True)
variables = Column(Text, nullable=True) # JSON string of available variables
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
class BounceType(str, enum.Enum):
HARD = "hard"
SOFT = "soft"
COMPLAINT = "complaint"
UNSUBSCRIBE = "unsubscribe"
class EmailBounce(Base):
__tablename__ = "email_bounces"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), nullable=False, index=True)
bounce_type = Column(SQLEnum(BounceType, values_callable=lambda x: [e.value for e in x]), nullable=False)
bounce_reason = Column(String(500), nullable=True)
smtp2go_message_id = Column(String(255), nullable=True, index=True)
bounce_date = Column(DateTime, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)