Coverage for src / idx_api / routers / newsletter.py: 42%
137 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 endpoints."""
3from datetime import datetime, timezone
4from typing import Optional
6from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request
7from fastapi.responses import HTMLResponse
8from pydantic import BaseModel, EmailStr
9from sqlalchemy import func, select
10from sqlalchemy.orm import Session
12from idx_api.auth import AdminUser
13from idx_api.database import get_db
14from idx_api.models.newsletter import NewsletterSubscription, generate_unsubscribe_token
17router = APIRouter()
20# ============================================================================
21# Pydantic Models
22# ============================================================================
25class NewsletterSubscribeRequest(BaseModel):
26 """Newsletter subscription request."""
28 email: EmailStr
29 source: Optional[str] = None # Where they signed up (blog, footer, etc.)
32class NewsletterSubscribeResponse(BaseModel):
33 """Newsletter subscription response."""
35 success: bool
36 message: str
39class NewsletterUnsubscribeRequest(BaseModel):
40 """Unsubscribe feedback request."""
42 feedback: Optional[str] = None
45class SubscriberResponse(BaseModel):
46 """Subscriber response for admin API."""
48 id: int
49 email: str
50 status: str
51 source: Optional[str] = None
52 created_at: datetime
53 updated_at: datetime
54 unsubscribed_at: Optional[datetime] = None
55 unsubscribe_feedback: Optional[str] = None
57 class Config:
58 from_attributes = True
61class SubscriberListResponse(BaseModel):
62 """Paginated list of subscribers."""
64 items: list[SubscriberResponse]
65 total: int
66 page: int
67 page_size: int
68 pages: int
71class NewsletterStatsResponse(BaseModel):
72 """Newsletter statistics."""
74 total_subscribers: int
75 active_subscribers: int
76 unsubscribed: int
77 this_week: int
78 this_month: int
79 sources: dict[str, int]
82# ============================================================================
83# HTML Templates (inline for simplicity)
84# ============================================================================
87def get_unsubscribe_page_html(email: str, token: str, error: str = "", success: bool = False) -> str:
88 """Generate unsubscribe confirmation HTML page."""
89 if success:
90 content = """
91 <div class="success-box">
92 <h2>Successfully Unsubscribed</h2>
93 <p>You've been removed from our mailing list.</p>
94 <p>We're sorry to see you go! If you change your mind, you can always subscribe again on our website.</p>
95 </div>
96 """
97 else:
98 error_html = f'<div class="error-box">{error}</div>' if error else ""
99 content = f"""
100 {error_html}
101 <p>You're unsubscribing <strong>{email}</strong> from our newsletter.</p>
102 <form method="post" class="unsubscribe-form">
103 <label for="feedback">We'd love to know why you're leaving (optional):</label>
104 <textarea name="feedback" id="feedback" rows="4" placeholder="Your feedback helps us improve..."></textarea>
105 <button type="submit" class="btn-unsubscribe">Confirm Unsubscribe</button>
106 </form>
107 <p class="small">Changed your mind? Just close this page.</p>
108 """
110 return f"""<!DOCTYPE html>
111<html lang="en">
112<head>
113 <meta charset="UTF-8">
114 <meta name="viewport" content="width=device-width, initial-scale=1.0">
115 <title>Unsubscribe - Elevate Idaho</title>
116 <style>
117 :root {{
118 --primary: #ee7711;
119 --primary-dark: #b94409;
120 --background: #0f172a;
121 --foreground: #f8fafc;
122 --muted: #64748b;
123 --card: #1e293b;
124 --border: #334155;
125 }}
126 * {{
127 margin: 0;
128 padding: 0;
129 box-sizing: border-box;
130 }}
131 body {{
132 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
133 background: var(--background);
134 color: var(--foreground);
135 min-height: 100vh;
136 display: flex;
137 flex-direction: column;
138 align-items: center;
139 justify-content: center;
140 padding: 2rem;
141 }}
142 .container {{
143 background: var(--card);
144 border: 1px solid var(--border);
145 border-radius: 1rem;
146 padding: 2.5rem;
147 max-width: 480px;
148 width: 100%;
149 text-align: center;
150 }}
151 h1 {{
152 color: var(--primary);
153 margin-bottom: 0.5rem;
154 font-size: 1.75rem;
155 }}
156 .tagline {{
157 color: var(--muted);
158 margin-bottom: 2rem;
159 font-size: 0.875rem;
160 }}
161 .unsubscribe-form {{
162 text-align: left;
163 }}
164 label {{
165 display: block;
166 margin-bottom: 0.5rem;
167 color: var(--foreground);
168 font-size: 0.875rem;
169 }}
170 textarea {{
171 width: 100%;
172 padding: 0.75rem;
173 border: 1px solid var(--border);
174 border-radius: 0.5rem;
175 background: var(--background);
176 color: var(--foreground);
177 font-family: inherit;
178 font-size: 0.875rem;
179 resize: vertical;
180 margin-bottom: 1rem;
181 }}
182 textarea:focus {{
183 outline: none;
184 border-color: var(--primary);
185 }}
186 .btn-unsubscribe {{
187 width: 100%;
188 padding: 0.75rem 1.5rem;
189 background: var(--primary);
190 color: white;
191 border: none;
192 border-radius: 0.5rem;
193 font-size: 1rem;
194 font-weight: 500;
195 cursor: pointer;
196 transition: background 0.2s;
197 }}
198 .btn-unsubscribe:hover {{
199 background: var(--primary-dark);
200 }}
201 .small {{
202 margin-top: 1rem;
203 font-size: 0.75rem;
204 color: var(--muted);
205 }}
206 .error-box {{
207 background: rgba(239, 68, 68, 0.1);
208 border: 1px solid rgba(239, 68, 68, 0.3);
209 color: #fca5a5;
210 padding: 1rem;
211 border-radius: 0.5rem;
212 margin-bottom: 1rem;
213 text-align: left;
214 }}
215 .success-box {{
216 background: rgba(34, 197, 94, 0.1);
217 border: 1px solid rgba(34, 197, 94, 0.3);
218 color: #86efac;
219 padding: 1.5rem;
220 border-radius: 0.5rem;
221 }}
222 .success-box h2 {{
223 color: #22c55e;
224 margin-bottom: 0.5rem;
225 }}
226 .logo {{
227 margin-bottom: 1rem;
228 }}
229 a {{
230 color: var(--primary);
231 }}
232 </style>
233</head>
234<body>
235 <div class="container">
236 <div class="logo">
237 <img src="https://t2.realgeeks.media/thumbnail/7hM4aSqRlLXIfQuccklpUwlp7hI=/fit-in/200x43/filters:format(png)/u.realgeeks.media/elevateidaho/Elevate_logo_white_square.png"
238 alt="Elevate Idaho" height="43">
239 </div>
240 <h1>Newsletter Preferences</h1>
241 <p class="tagline">Raising Real Estate Excellence</p>
242 {content}
243 </div>
244</body>
245</html>"""
248# ============================================================================
249# Public Endpoints
250# ============================================================================
253@router.post("/newsletter/subscribe", response_model=NewsletterSubscribeResponse)
254async def subscribe_to_newsletter(
255 request: NewsletterSubscribeRequest,
256 http_request: Request,
257 db: Session = Depends(get_db),
258):
259 """
260 Subscribe to the newsletter.
262 Public endpoint - no authentication required.
263 """
264 email = request.email.lower().strip()
266 # Check if already subscribed
267 existing = db.scalar(
268 select(NewsletterSubscription).where(NewsletterSubscription.email == email)
269 )
271 if existing:
272 if existing.status == "active":
273 # Already subscribed, just return success (don't reveal subscription status)
274 return NewsletterSubscribeResponse(
275 success=True,
276 message="Thanks for subscribing! You'll receive our latest updates soon.",
277 )
278 else:
279 # Re-subscribe: reactivate existing subscription
280 existing.status = "active"
281 existing.unsubscribed_at = None
282 existing.unsubscribe_token = generate_unsubscribe_token()
283 db.commit()
284 return NewsletterSubscribeResponse(
285 success=True,
286 message="Welcome back! You've been re-subscribed to our newsletter.",
287 )
289 # Get IP address (partial for privacy)
290 ip_address = None
291 if http_request.client:
292 ip_address = http_request.client.host
294 # Create new subscription (with race condition handling)
295 try:
296 subscription = NewsletterSubscription(
297 email=email,
298 source=request.source,
299 ip_address=ip_address,
300 )
301 db.add(subscription)
302 db.commit()
303 except Exception:
304 # Race condition: another request created the subscription first
305 db.rollback()
306 # Just return success - the subscription exists now
307 return NewsletterSubscribeResponse(
308 success=True,
309 message="Thanks for subscribing! You'll receive our latest updates soon.",
310 )
312 return NewsletterSubscribeResponse(
313 success=True,
314 message="Thanks for subscribing! You'll receive our latest updates soon.",
315 )
318@router.get("/newsletter/unsubscribe/{token}", response_class=HTMLResponse)
319async def show_unsubscribe_page(
320 token: str,
321 db: Session = Depends(get_db),
322):
323 """
324 Show unsubscribe confirmation page.
326 Public endpoint - uses secure token for verification.
327 """
328 subscription = db.scalar(
329 select(NewsletterSubscription).where(
330 NewsletterSubscription.unsubscribe_token == token
331 )
332 )
334 if not subscription:
335 raise HTTPException(status_code=404, detail="Invalid or expired unsubscribe link")
337 if subscription.status == "unsubscribed":
338 # Already unsubscribed, show success page
339 return HTMLResponse(
340 content=get_unsubscribe_page_html(subscription.email, token, success=True)
341 )
343 return HTMLResponse(
344 content=get_unsubscribe_page_html(subscription.email, token)
345 )
348@router.post("/newsletter/unsubscribe/{token}", response_class=HTMLResponse)
349async def confirm_unsubscribe(
350 token: str,
351 feedback: str = Form(default=""),
352 db: Session = Depends(get_db),
353):
354 """
355 Confirm newsletter unsubscription with optional feedback.
357 Public endpoint - uses secure token for verification.
358 """
359 subscription = db.scalar(
360 select(NewsletterSubscription).where(
361 NewsletterSubscription.unsubscribe_token == token
362 )
363 )
365 if not subscription:
366 raise HTTPException(status_code=404, detail="Invalid or expired unsubscribe link")
368 if subscription.status == "unsubscribed":
369 # Already unsubscribed
370 return HTMLResponse(
371 content=get_unsubscribe_page_html(subscription.email, token, success=True)
372 )
374 # Process unsubscription
375 subscription.status = "unsubscribed"
376 subscription.unsubscribed_at = datetime.now(timezone.utc)
377 if feedback.strip():
378 subscription.unsubscribe_feedback = feedback.strip()
380 db.commit()
382 return HTMLResponse(
383 content=get_unsubscribe_page_html(subscription.email, token, success=True)
384 )
387# ============================================================================
388# Admin Endpoints
389# ============================================================================
392@router.get("/admin/newsletter/subscribers", response_model=SubscriberListResponse)
393async def list_subscribers(
394 user: AdminUser,
395 db: Session = Depends(get_db),
396 page: int = Query(1, ge=1),
397 page_size: int = Query(20, ge=1, le=100),
398 status: Optional[str] = Query(None, description="Filter by status: active, unsubscribed"),
399):
400 """
401 List newsletter subscribers with pagination.
403 Admin only.
404 """
405 # Build query
406 query = select(NewsletterSubscription)
408 if status:
409 query = query.where(NewsletterSubscription.status == status)
411 # Count total
412 count_query = select(func.count()).select_from(query.subquery())
413 total = db.scalar(count_query) or 0
415 # Get paginated results
416 offset = (page - 1) * page_size
417 items = db.scalars(
418 query.order_by(NewsletterSubscription.created_at.desc())
419 .offset(offset)
420 .limit(page_size)
421 ).all()
423 total_pages = (total + page_size - 1) // page_size if total > 0 else 1
425 return SubscriberListResponse(
426 items=[SubscriberResponse.model_validate(item) for item in items],
427 total=total,
428 page=page,
429 page_size=page_size,
430 pages=total_pages,
431 )
434@router.get("/admin/newsletter/stats", response_model=NewsletterStatsResponse)
435async def get_newsletter_stats(
436 user: AdminUser,
437 db: Session = Depends(get_db),
438):
439 """
440 Get newsletter subscription statistics.
442 Admin only.
443 """
444 now = datetime.now(timezone.utc)
446 # Calculate time boundaries
447 from datetime import timedelta
449 week_ago = now - timedelta(days=7)
450 month_ago = now - timedelta(days=30)
452 # Get counts
453 total = db.scalar(select(func.count()).select_from(NewsletterSubscription)) or 0
455 active = db.scalar(
456 select(func.count())
457 .select_from(NewsletterSubscription)
458 .where(NewsletterSubscription.status == "active")
459 ) or 0
461 unsubscribed = db.scalar(
462 select(func.count())
463 .select_from(NewsletterSubscription)
464 .where(NewsletterSubscription.status == "unsubscribed")
465 ) or 0
467 this_week = db.scalar(
468 select(func.count())
469 .select_from(NewsletterSubscription)
470 .where(
471 NewsletterSubscription.created_at >= week_ago,
472 NewsletterSubscription.status == "active",
473 )
474 ) or 0
476 this_month = db.scalar(
477 select(func.count())
478 .select_from(NewsletterSubscription)
479 .where(
480 NewsletterSubscription.created_at >= month_ago,
481 NewsletterSubscription.status == "active",
482 )
483 ) or 0
485 # Get sources breakdown
486 source_results = db.execute(
487 select(
488 NewsletterSubscription.source,
489 func.count().label("count"),
490 )
491 .where(NewsletterSubscription.status == "active")
492 .group_by(NewsletterSubscription.source)
493 ).all()
495 sources = {}
496 for source, count in source_results:
497 sources[source or "unknown"] = count
499 return NewsletterStatsResponse(
500 total_subscribers=total,
501 active_subscribers=active,
502 unsubscribed=unsubscribed,
503 this_week=this_week,
504 this_month=this_month,
505 sources=sources,
506 )
509@router.delete("/admin/newsletter/subscribers/{subscriber_id}")
510async def delete_subscriber(
511 subscriber_id: int,
512 user: AdminUser,
513 db: Session = Depends(get_db),
514):
515 """
516 Delete a subscriber (hard delete).
518 Admin only.
519 """
520 subscription = db.get(NewsletterSubscription, subscriber_id)
522 if not subscription:
523 raise HTTPException(status_code=404, detail="Subscriber not found")
525 db.delete(subscription)
526 db.commit()
528 return {"success": True, "message": "Subscriber deleted"}
531@router.get("/admin/newsletter/feedback")
532async def get_unsubscribe_feedback(
533 user: AdminUser,
534 db: Session = Depends(get_db),
535 page: int = Query(1, ge=1),
536 page_size: int = Query(20, ge=1, le=100),
537):
538 """
539 Get unsubscribe feedback from users who left comments.
541 Admin only.
542 """
543 query = (
544 select(NewsletterSubscription)
545 .where(
546 NewsletterSubscription.status == "unsubscribed",
547 NewsletterSubscription.unsubscribe_feedback.isnot(None),
548 )
549 .order_by(NewsletterSubscription.unsubscribed_at.desc())
550 )
552 # Count total
553 count_query = select(func.count()).select_from(query.subquery())
554 total = db.scalar(count_query) or 0
556 # Get paginated results
557 offset = (page - 1) * page_size
558 items = db.scalars(query.offset(offset).limit(page_size)).all()
560 total_pages = (total + page_size - 1) // page_size if total > 0 else 1
562 return {
563 "items": [
564 {
565 "id": item.id,
566 "email": item.email,
567 "feedback": item.unsubscribe_feedback,
568 "unsubscribed_at": item.unsubscribed_at,
569 }
570 for item in items
571 ],
572 "total": total,
573 "page": page,
574 "page_size": page_size,
575 "pages": total_pages,
576 }