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

1"""Newsletter subscription endpoints.""" 

2 

3from datetime import datetime, timezone 

4from typing import Optional 

5 

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 

11 

12from idx_api.auth import AdminUser 

13from idx_api.database import get_db 

14from idx_api.models.newsletter import NewsletterSubscription, generate_unsubscribe_token 

15 

16 

17router = APIRouter() 

18 

19 

20# ============================================================================ 

21# Pydantic Models 

22# ============================================================================ 

23 

24 

25class NewsletterSubscribeRequest(BaseModel): 

26 """Newsletter subscription request.""" 

27 

28 email: EmailStr 

29 source: Optional[str] = None # Where they signed up (blog, footer, etc.) 

30 

31 

32class NewsletterSubscribeResponse(BaseModel): 

33 """Newsletter subscription response.""" 

34 

35 success: bool 

36 message: str 

37 

38 

39class NewsletterUnsubscribeRequest(BaseModel): 

40 """Unsubscribe feedback request.""" 

41 

42 feedback: Optional[str] = None 

43 

44 

45class SubscriberResponse(BaseModel): 

46 """Subscriber response for admin API.""" 

47 

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 

56 

57 class Config: 

58 from_attributes = True 

59 

60 

61class SubscriberListResponse(BaseModel): 

62 """Paginated list of subscribers.""" 

63 

64 items: list[SubscriberResponse] 

65 total: int 

66 page: int 

67 page_size: int 

68 pages: int 

69 

70 

71class NewsletterStatsResponse(BaseModel): 

72 """Newsletter statistics.""" 

73 

74 total_subscribers: int 

75 active_subscribers: int 

76 unsubscribed: int 

77 this_week: int 

78 this_month: int 

79 sources: dict[str, int] 

80 

81 

82# ============================================================================ 

83# HTML Templates (inline for simplicity) 

84# ============================================================================ 

85 

86 

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 """ 

109 

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>""" 

246 

247 

248# ============================================================================ 

249# Public Endpoints 

250# ============================================================================ 

251 

252 

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. 

261 

262 Public endpoint - no authentication required. 

263 """ 

264 email = request.email.lower().strip() 

265 

266 # Check if already subscribed 

267 existing = db.scalar( 

268 select(NewsletterSubscription).where(NewsletterSubscription.email == email) 

269 ) 

270 

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 ) 

288 

289 # Get IP address (partial for privacy) 

290 ip_address = None 

291 if http_request.client: 

292 ip_address = http_request.client.host 

293 

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 ) 

311 

312 return NewsletterSubscribeResponse( 

313 success=True, 

314 message="Thanks for subscribing! You'll receive our latest updates soon.", 

315 ) 

316 

317 

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. 

325 

326 Public endpoint - uses secure token for verification. 

327 """ 

328 subscription = db.scalar( 

329 select(NewsletterSubscription).where( 

330 NewsletterSubscription.unsubscribe_token == token 

331 ) 

332 ) 

333 

334 if not subscription: 

335 raise HTTPException(status_code=404, detail="Invalid or expired unsubscribe link") 

336 

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 ) 

342 

343 return HTMLResponse( 

344 content=get_unsubscribe_page_html(subscription.email, token) 

345 ) 

346 

347 

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. 

356 

357 Public endpoint - uses secure token for verification. 

358 """ 

359 subscription = db.scalar( 

360 select(NewsletterSubscription).where( 

361 NewsletterSubscription.unsubscribe_token == token 

362 ) 

363 ) 

364 

365 if not subscription: 

366 raise HTTPException(status_code=404, detail="Invalid or expired unsubscribe link") 

367 

368 if subscription.status == "unsubscribed": 

369 # Already unsubscribed 

370 return HTMLResponse( 

371 content=get_unsubscribe_page_html(subscription.email, token, success=True) 

372 ) 

373 

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() 

379 

380 db.commit() 

381 

382 return HTMLResponse( 

383 content=get_unsubscribe_page_html(subscription.email, token, success=True) 

384 ) 

385 

386 

387# ============================================================================ 

388# Admin Endpoints 

389# ============================================================================ 

390 

391 

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. 

402 

403 Admin only. 

404 """ 

405 # Build query 

406 query = select(NewsletterSubscription) 

407 

408 if status: 

409 query = query.where(NewsletterSubscription.status == status) 

410 

411 # Count total 

412 count_query = select(func.count()).select_from(query.subquery()) 

413 total = db.scalar(count_query) or 0 

414 

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() 

422 

423 total_pages = (total + page_size - 1) // page_size if total > 0 else 1 

424 

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 ) 

432 

433 

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. 

441 

442 Admin only. 

443 """ 

444 now = datetime.now(timezone.utc) 

445 

446 # Calculate time boundaries 

447 from datetime import timedelta 

448 

449 week_ago = now - timedelta(days=7) 

450 month_ago = now - timedelta(days=30) 

451 

452 # Get counts 

453 total = db.scalar(select(func.count()).select_from(NewsletterSubscription)) or 0 

454 

455 active = db.scalar( 

456 select(func.count()) 

457 .select_from(NewsletterSubscription) 

458 .where(NewsletterSubscription.status == "active") 

459 ) or 0 

460 

461 unsubscribed = db.scalar( 

462 select(func.count()) 

463 .select_from(NewsletterSubscription) 

464 .where(NewsletterSubscription.status == "unsubscribed") 

465 ) or 0 

466 

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 

475 

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 

484 

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() 

494 

495 sources = {} 

496 for source, count in source_results: 

497 sources[source or "unknown"] = count 

498 

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 ) 

507 

508 

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). 

517 

518 Admin only. 

519 """ 

520 subscription = db.get(NewsletterSubscription, subscriber_id) 

521 

522 if not subscription: 

523 raise HTTPException(status_code=404, detail="Subscriber not found") 

524 

525 db.delete(subscription) 

526 db.commit() 

527 

528 return {"success": True, "message": "Subscriber deleted"} 

529 

530 

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. 

540 

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 ) 

551 

552 # Count total 

553 count_query = select(func.count()).select_from(query.subquery()) 

554 total = db.scalar(count_query) or 0 

555 

556 # Get paginated results 

557 offset = (page - 1) * page_size 

558 items = db.scalars(query.offset(offset).limit(page_size)).all() 

559 

560 total_pages = (total + page_size - 1) // page_size if total > 0 else 1 

561 

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 }