settings: digest opt-in + tone (PATCH /api/settings/digest + UI)
Adds DigestPrefsIn/Out models, PATCH /api/settings/digest endpoint, email digest section in settings.html, and last_email_send context in pages.py. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5c89f4d04a
commit
14fe47103f
4 changed files with 176 additions and 1 deletions
|
|
@ -8,6 +8,7 @@ from __future__ import annotations
|
||||||
import calendar as _cal
|
import calendar as _cal
|
||||||
import re
|
import re
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
|
@ -809,3 +810,33 @@ async def chat(
|
||||||
"prompt_tokens": result.prompt_tokens,
|
"prompt_tokens": result.prompt_tokens,
|
||||||
"completion_tokens": result.completion_tokens,
|
"completion_tokens": result.completion_tokens,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings — digest preferences
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class DigestPrefsIn(BaseModel):
|
||||||
|
opt_in: bool
|
||||||
|
tone: Literal["NOVICE", "INTERMEDIATE"]
|
||||||
|
|
||||||
|
|
||||||
|
class DigestPrefsOut(BaseModel):
|
||||||
|
opt_in: bool
|
||||||
|
tone: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/settings/digest", response_model=DigestPrefsOut)
|
||||||
|
async def patch_digest_prefs(
|
||||||
|
payload: DigestPrefsIn,
|
||||||
|
principal: CurrentUser = Depends(require_token),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> DigestPrefsOut:
|
||||||
|
if principal.user is None:
|
||||||
|
# Admin bearer-token path — no per-user row to persist to.
|
||||||
|
raise HTTPException(status_code=400, detail="no_user_context")
|
||||||
|
principal.user.email_digest_opt_in = payload.opt_in
|
||||||
|
principal.user.digest_tone = payload.tone
|
||||||
|
await session.commit()
|
||||||
|
return DigestPrefsOut(opt_in=payload.opt_in, tone=payload.tone)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.auth import CurrentUser, maybe_current_user, require_auth, require_token
|
from app.auth import CurrentUser, maybe_current_user, require_auth, require_token
|
||||||
from app.config import get_settings, load_groups
|
from app.config import get_settings, load_groups
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models import Referral, StrategicLog, User
|
from app.models import EmailSend, Referral, StrategicLog, User
|
||||||
from app.services.access import paid_status
|
from app.services.access import paid_status
|
||||||
from app.services.referral_service import assign_code_if_missing
|
from app.services.referral_service import assign_code_if_missing
|
||||||
from app.templates_env import templates
|
from app.templates_env import templates
|
||||||
|
|
@ -147,6 +147,13 @@ async def settings_page(
|
||||||
|
|
||||||
invite_url = str(request.url_for("login_page")) + f"?ref={user.referral_code}"
|
invite_url = str(request.url_for("login_page")) + f"?ref={user.referral_code}"
|
||||||
|
|
||||||
|
last_email_send = (await session.execute(
|
||||||
|
select(EmailSend)
|
||||||
|
.where(EmailSend.user_id == user.id)
|
||||||
|
.order_by(desc(EmailSend.sent_at))
|
||||||
|
.limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "settings.html",
|
request, "settings.html",
|
||||||
{
|
{
|
||||||
|
|
@ -155,5 +162,6 @@ async def settings_page(
|
||||||
"pending_count": int(pending_count),
|
"pending_count": int(pending_count),
|
||||||
"converted_count": int(converted_count),
|
"converted_count": int(converted_count),
|
||||||
"paid": paid_status(user),
|
"paid": paid_status(user),
|
||||||
|
"last_email_send": last_email_send,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,71 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# --- Email digests block ------------------------------------------ #}
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__head">Email digests</div>
|
||||||
|
<p class="settings-section__lede">
|
||||||
|
Editorial commentary delivered to your inbox. Daily for paid (Mon–Sat) plus the Sunday recap; free tier gets the Sunday recap.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-row__label">Subscription</div>
|
||||||
|
<div class="settings-row__value">
|
||||||
|
<label style="display:block; margin-bottom:8px;">
|
||||||
|
<input type="checkbox" id="digest-opt-in"
|
||||||
|
{% if user.email_digest_opt_in %}checked{% endif %}>
|
||||||
|
Send me digests
|
||||||
|
</label>
|
||||||
|
<div class="settings-row__hint" style="margin-bottom:8px;">
|
||||||
|
One-click unsubscribe in every email.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-row__label">Reading level</div>
|
||||||
|
<div class="settings-row__value">
|
||||||
|
<div style="display:flex; gap:14px;">
|
||||||
|
<label><input type="radio" name="digest-tone" value="NOVICE"
|
||||||
|
{% if (user.digest_tone or 'INTERMEDIATE') == 'NOVICE' %}checked{% endif %}> Novice</label>
|
||||||
|
<label><input type="radio" name="digest-tone" value="INTERMEDIATE"
|
||||||
|
{% if (user.digest_tone or 'INTERMEDIATE') == 'INTERMEDIATE' %}checked{% endif %}> Intermediate</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-row__label">Last delivery</div>
|
||||||
|
<div class="settings-row__value settings-row__hint">
|
||||||
|
<span id="digest-last">{% if last_email_send %}{{ last_email_send.sent_at.strftime("%Y-%m-%d %H:%M") }} UTC — {{ last_email_send.status }}{% else %}—{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="digest-feedback" class="settings-row__hint" style="margin-top:6px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const opt = document.getElementById('digest-opt-in');
|
||||||
|
const tones = document.querySelectorAll('input[name="digest-tone"]');
|
||||||
|
const fb = document.getElementById('digest-feedback');
|
||||||
|
if (!opt || !fb) return;
|
||||||
|
function patch() {
|
||||||
|
fb.textContent = 'Saving…';
|
||||||
|
const tone = Array.from(tones).find(t => t.checked)?.value || 'INTERMEDIATE';
|
||||||
|
fetch('/api/settings/digest', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ opt_in: opt.checked, tone: tone }),
|
||||||
|
}).then(r => {
|
||||||
|
fb.textContent = r.ok ? 'Saved.' : 'Could not save — try again.';
|
||||||
|
}).catch(() => { fb.textContent = 'Network error.'; });
|
||||||
|
}
|
||||||
|
opt.addEventListener('change', patch);
|
||||||
|
tones.forEach(t => t.addEventListener('change', patch));
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
{# --- Cloud sync block --------------------------------------------- #}
|
{# --- Cloud sync block --------------------------------------------- #}
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="settings-section__head">Cloud sync (encrypted)</div>
|
<div class="settings-section__head">Cloud sync (encrypted)</div>
|
||||||
|
|
|
||||||
71
tests/test_settings_digest_api.py
Normal file
71
tests/test_settings_digest_api.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""PATCH /api/settings/digest persists opt-in + tone."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
def _build(tmp_path):
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app import db as db_mod
|
||||||
|
from app.auth import sign_session
|
||||||
|
from app.db import Base
|
||||||
|
from app.models import User
|
||||||
|
from app.routers import api as api_router
|
||||||
|
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/s.db")
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
db_mod._engine = engine
|
||||||
|
db_mod._session_factory = factory
|
||||||
|
|
||||||
|
async def _seed():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
async with factory() as s:
|
||||||
|
s.add(User(id=1, email="u@x", tier="paid",
|
||||||
|
email_digest_opt_in=True))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
asyncio.run(_seed())
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(api_router.router, prefix="/api")
|
||||||
|
return TestClient(app), sign_session(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_round_trip(tmp_path):
|
||||||
|
client, sess = _build(tmp_path)
|
||||||
|
r = client.patch(
|
||||||
|
"/api/settings/digest",
|
||||||
|
json={"opt_in": False, "tone": "NOVICE"},
|
||||||
|
cookies={"cassandra_session": sess},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json() == {"opt_in": False, "tone": "NOVICE"}
|
||||||
|
|
||||||
|
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, 1)
|
||||||
|
assert u.email_digest_opt_in is False
|
||||||
|
assert u.digest_tone == "NOVICE"
|
||||||
|
asyncio.run(_check())
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_rejects_invalid_tone(tmp_path):
|
||||||
|
client, sess = _build(tmp_path)
|
||||||
|
r = client.patch(
|
||||||
|
"/api/settings/digest",
|
||||||
|
json={"opt_in": True, "tone": "PRO"},
|
||||||
|
cookies={"cassandra_session": sess},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_requires_auth(tmp_path):
|
||||||
|
client, _ = _build(tmp_path)
|
||||||
|
r = client.patch("/api/settings/digest",
|
||||||
|
json={"opt_in": True, "tone": "NOVICE"})
|
||||||
|
assert r.status_code in (401, 303)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue