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
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 11:09 -0700
1"""Newsletter subscription model."""
3import secrets
4from datetime import datetime
5from typing import Optional
7from sqlalchemy import DateTime, String, Text
8from sqlalchemy.orm import Mapped, mapped_column
10from idx_api.models.base import Base, TimestampMixin
13def generate_unsubscribe_token() -> str:
14 """Generate a cryptographically secure unsubscribe token."""
15 return secrets.token_urlsafe(32)
18class NewsletterSubscription(Base, TimestampMixin):
19 """Newsletter subscription tracking."""
21 __tablename__ = "newsletter_subscriptions"
23 id: Mapped[int] = mapped_column(primary_key=True)
25 # Subscriber info
26 email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
28 # Subscription status: active, unsubscribed, bounced
29 status: Mapped[str] = mapped_column(
30 String(20), nullable=False, default="active", index=True
31 )
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 )
38 # When they unsubscribed (if applicable)
39 unsubscribed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
41 # Feedback when unsubscribing
42 unsubscribe_feedback: Mapped[Optional[str]] = mapped_column(Text)
44 # Optional: source of subscription (blog, footer, popup, etc.)
45 source: Mapped[Optional[str]] = mapped_column(String(50))
47 # IP address for audit (hashed or partial for privacy)
48 ip_address: Mapped[Optional[str]] = mapped_column(String(45)) # IPv6 max length
50 def __repr__(self) -> str:
51 return f"<NewsletterSubscription(id={self.id}, email='{self.email}', status='{self.status}')>"