beta-launch: respect returning-user opt-out + show digest job in ops LEDs
- verify_submit now applies the subscribe checkbox only at first sign-up. Returning users keep whatever they set via Settings or the one-click unsubscribe link — previously, every login silently re-enrolled them. - JOB_NAMES gains email_digest_job so the ops footer reflects its health. Adds tests/test_verify_subscribe.py::test_returning_user_login_preserves_unsubscribe.
This commit is contained in:
parent
5046be915b
commit
e338650dfa
3 changed files with 54 additions and 3 deletions
|
|
@ -50,7 +50,8 @@ from app.schemas import (
|
||||||
router = APIRouter(dependencies=[Depends(require_token)])
|
router = APIRouter(dependencies=[Depends(require_token)])
|
||||||
|
|
||||||
JOB_NAMES = ("market_job", "news_job", "ai_log_job", "rollup_job",
|
JOB_NAMES = ("market_job", "news_job", "ai_log_job", "rollup_job",
|
||||||
"indicator_summary_job", "universe_flush_job")
|
"indicator_summary_job", "universe_flush_job",
|
||||||
|
"email_digest_job")
|
||||||
JOB_STALE_HOURS = 2.0 # job is "warn" if its last success was >2h ago
|
JOB_STALE_HOURS = 2.0 # job is "warn" if its last success was >2h ago
|
||||||
|
|
||||||
# Per-group expected freshness — bonds and intraday tape want daily data,
|
# Per-group expected freshness — bonds and intraday tape want daily data,
|
||||||
|
|
|
||||||
|
|
@ -240,9 +240,14 @@ async def verify_submit(
|
||||||
if user is None:
|
if user is None:
|
||||||
# User row vanished between cookie issue and verify. Restart flow.
|
# User row vanished between cookie issue and verify. Restart flow.
|
||||||
return RedirectResponse(url="/login", status_code=303)
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
is_first_login = user.last_login_at is None
|
||||||
user.last_login_at = utcnow()
|
user.last_login_at = utcnow()
|
||||||
# An unchecked HTML checkbox sends NO field; that means "opt out".
|
# Apply the verify-page subscribe checkbox ONLY at first sign-up. After
|
||||||
user.email_digest_opt_in = subscribe_to_digests is not None
|
# that, Settings (and the one-click unsubscribe link) own the preference
|
||||||
|
# — re-applying on every login would silently re-subscribe users who
|
||||||
|
# explicitly opted out.
|
||||||
|
if is_first_login:
|
||||||
|
user.email_digest_opt_in = subscribe_to_digests is not None
|
||||||
await session.commit()
|
await session.commit()
|
||||||
log.info("user.login", user_id=user.id, email=email)
|
log.info("user.login", user_id=user.id, email=email)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,3 +83,48 @@ def test_verify_with_checked_subscribe_keeps_opt_in(tmp_path, monkeypatch):
|
||||||
u = await s.get(User, 10)
|
u = await s.get(User, 10)
|
||||||
assert u.email_digest_opt_in is True
|
assert u.email_digest_opt_in is True
|
||||||
asyncio.run(_check())
|
asyncio.run(_check())
|
||||||
|
|
||||||
|
|
||||||
|
def test_returning_user_login_preserves_unsubscribe(tmp_path, monkeypatch):
|
||||||
|
"""A user who unsubscribed (via Settings or the one-click link) must
|
||||||
|
not be silently re-enrolled when they log in again, even if the verify
|
||||||
|
page's default-checked checkbox gets submitted."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.services import otp_service
|
||||||
|
|
||||||
|
async def _ok(*args, **kwargs):
|
||||||
|
return None
|
||||||
|
monkeypatch.setattr(otp_service, "verify", _ok)
|
||||||
|
|
||||||
|
client, pending = _build(tmp_path)
|
||||||
|
|
||||||
|
# Simulate a returning user: previously logged in, then opted out.
|
||||||
|
async def _make_returning():
|
||||||
|
from app import db as db_mod
|
||||||
|
from app.models import User
|
||||||
|
async with db_mod._session_factory() as s:
|
||||||
|
u = await s.get(User, 10)
|
||||||
|
u.last_login_at = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc)
|
||||||
|
u.email_digest_opt_in = False
|
||||||
|
await s.commit()
|
||||||
|
asyncio.run(_make_returning())
|
||||||
|
|
||||||
|
# They log in again. The verify checkbox is default-checked in the
|
||||||
|
# template, so the form will submit "on" — but the handler must NOT
|
||||||
|
# apply that to a returning user.
|
||||||
|
r = client.post(
|
||||||
|
"/verify",
|
||||||
|
data={"code": "000000", "subscribe_to_digests": "on"},
|
||||||
|
cookies={"cassandra_pending": pending},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
async def _check():
|
||||||
|
from app import db as db_mod
|
||||||
|
from app.models import User
|
||||||
|
async with db_mod._session_factory() as s:
|
||||||
|
u = await s.get(User, 10)
|
||||||
|
assert u.email_digest_opt_in is False, "returning user re-enrolled"
|
||||||
|
asyncio.run(_check())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue