Coverage for src / idx_api / models / newsletter.py: 94%

18 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-28 11:09 -0700

1"""Newsletter subscription model.""" 

2 

3import secrets 

4from datetime import datetime 

5from typing import Optional 

6 

7from sqlalchemy import DateTime, String, Text 

8from sqlalchemy.orm import Mapped, mapped_column 

9 

10from idx_api.models.base import Base, TimestampMixin 

11 

12 

13def generate_unsubscribe_token() -> str: 

14 """Generate a cryptographically secure unsubscribe token.""" 

15 return secrets.token_urlsafe(32) 

16 

17 

18class NewsletterSubscription(Base, TimestampMixin): 

19 """Newsletter subscription tracking.""" 

20 

21 __tablename__ = "newsletter_subscriptions" 

22 

23 id: Mapped[int] = mapped_column(primary_key=True) 

24 

25 # Subscriber info 

26 email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True) 

27 

28 # Subscription status: active, unsubscribed, bounced 

29 status: Mapped[str] = mapped_column( 

30 String(20), nullable=False, default="active", index=True 

31 ) 

32 

33 # Unsubscribe token for secure one-click unsubscribe 

34 unsubscribe_token: Mapped[str] = mapped_column( 

35 String(64), nullable=False, unique=True, index=True, default=generate_unsubscribe_token 

36 ) 

37 

38 # When they unsubscribed (if applicable) 

39 unsubscribed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) 

40 

41 # Feedback when unsubscribing 

42 unsubscribe_feedback: Mapped[Optional[str]] = mapped_column(Text) 

43 

44 # Optional: source of subscription (blog, footer, popup, etc.) 

45 source: Mapped[Optional[str]] = mapped_column(String(50)) 

46 

47 # IP address for audit (hashed or partial for privacy) 

48 ip_address: Mapped[Optional[str]] = mapped_column(String(45)) # IPv6 max length 

49 

50 def __repr__(self) -> str: 

51 return f"<NewsletterSubscription(id={self.id}, email='{self.email}', status='{self.status}')>"