Skip to content

Commit 20d1835

Browse files
committed
add communication preferences
1 parent 9cdd8b4 commit 20d1835

12 files changed

Lines changed: 490 additions & 4 deletions

File tree

migrations/__init__.py

Whitespace-only changes.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""
2+
Add communication preference columns to the user table.
3+
4+
SQLModel create_all() does not alter existing tables. Run this after pulling
5+
#189 changes if your local or deployed database predates comm_opt_in columns.
6+
7+
Usage:
8+
uv run python -m migrations.add_communication_preferences .env
9+
uv run python -m migrations.add_communication_preferences .env --apply
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import argparse
15+
from dataclasses import dataclass
16+
17+
from dotenv import load_dotenv
18+
from sqlalchemy import text
19+
from sqlmodel import Session, create_engine
20+
21+
from utils.core.db import get_connection_url
22+
23+
COLUMNS = ("comm_opt_in", "comm_updates", "comm_marketing")
24+
25+
26+
@dataclass
27+
class MigrationStats:
28+
missing_columns: tuple[str, ...] = ()
29+
all_present: bool = False
30+
31+
32+
def _column_exists(session: Session, column_name: str) -> bool:
33+
result = session.connection().execute(
34+
text(
35+
"""
36+
SELECT 1
37+
FROM information_schema.columns
38+
WHERE table_schema = 'public'
39+
AND table_name = 'user'
40+
AND column_name = :column_name
41+
"""
42+
),
43+
{"column_name": column_name},
44+
)
45+
return result.first() is not None
46+
47+
48+
def add_communication_preference_columns(env_file: str, apply: bool) -> MigrationStats:
49+
load_dotenv(env_file, override=True)
50+
engine = create_engine(get_connection_url())
51+
stats = MigrationStats()
52+
53+
try:
54+
with Session(engine) as session:
55+
missing = tuple(
56+
column for column in COLUMNS if not _column_exists(session, column)
57+
)
58+
stats.missing_columns = missing
59+
stats.all_present = not missing
60+
61+
if apply and missing:
62+
session.connection().execute(
63+
text(
64+
"""
65+
ALTER TABLE "user"
66+
ADD COLUMN IF NOT EXISTS comm_opt_in BOOLEAN NOT NULL DEFAULT FALSE,
67+
ADD COLUMN IF NOT EXISTS comm_updates BOOLEAN NOT NULL DEFAULT FALSE,
68+
ADD COLUMN IF NOT EXISTS comm_marketing BOOLEAN NOT NULL DEFAULT FALSE
69+
"""
70+
)
71+
)
72+
session.commit()
73+
else:
74+
session.rollback()
75+
finally:
76+
engine.dispose()
77+
78+
return stats
79+
80+
81+
def main() -> None:
82+
parser = argparse.ArgumentParser(
83+
description=(
84+
"Add comm_opt_in, comm_updates, and comm_marketing to the user table. "
85+
"Without --apply, runs in dry-run mode."
86+
)
87+
)
88+
parser.add_argument("env", help="Env file to use (e.g. .env)")
89+
parser.add_argument(
90+
"--apply",
91+
action="store_true",
92+
help="Apply the schema change (default is dry-run).",
93+
)
94+
args = parser.parse_args()
95+
96+
stats = add_communication_preference_columns(
97+
env_file=args.env, apply=args.apply
98+
)
99+
mode = "APPLY" if args.apply else "DRY-RUN"
100+
if stats.all_present:
101+
print(f"[{mode}] All communication preference columns already exist.")
102+
return
103+
104+
print(f"[{mode}] missing_columns={list(stats.missing_columns)}")
105+
if args.apply:
106+
print(f"[{mode}] Columns added successfully.")
107+
else:
108+
print("Dry-run only. Re-run with --apply to add columns.")
109+
110+
111+
if __name__ == "__main__":
112+
main()

routers/core/account.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# auth.py
2+
import os
23
from logging import getLogger
34
from typing import Optional, Tuple
45
from urllib.parse import urlparse
@@ -71,6 +72,10 @@
7172
login_email_limiter,
7273
)
7374
from utils.core.htmx import is_htmx_request, toast_response, set_flash_cookie
75+
from utils.core.communication_preferences import (
76+
parse_communication_preferences,
77+
apply_communication_preferences,
78+
)
7479

7580
logger = getLogger("uvicorn.error")
7681

@@ -178,6 +183,7 @@ async def read_register(
178183
"password_pattern": HTML_PASSWORD_PATTERN,
179184
"email": email,
180185
"invitation_token": invitation_token,
186+
"host_name": os.getenv("HOST_NAME", "our platform"),
181187
},
182188
)
183189

@@ -266,6 +272,9 @@ async def register(
266272
title="Invitation token",
267273
description="Optional invitation token to join an organization",
268274
),
275+
comm_opt_in: Optional[str] = Form(None),
276+
comm_updates: Optional[str] = Form(None),
277+
comm_marketing: Optional[str] = Form(None),
269278
) -> Response:
270279
"""
271280
Register a new user account, optionally processing an invitation.
@@ -295,6 +304,10 @@ async def register(
295304
raise DataIntegrityError(resource="Account ID generation")
296305

297306
new_user = User(name=name, account_id=account.id) # Use account.id
307+
apply_communication_preferences(
308+
new_user,
309+
parse_communication_preferences(comm_opt_in, comm_updates, comm_marketing),
310+
)
298311
session.add(new_user)
299312

300313
# Create the primary AccountEmail entry

routers/core/user.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Optional, List
55
from fastapi.templating import Jinja2Templates
66
from sqlalchemy.orm import selectinload
7+
import os
78
from utils.core.models import (
89
User,
910
UserAvatar,
@@ -34,7 +35,11 @@
3435
OrganizationNotFoundError,
3536
)
3637
from routers.core.organization import router as organization_router
37-
from utils.core.htmx import is_htmx_request, append_toast
38+
from utils.core.htmx import is_htmx_request, append_toast, toast_response
39+
from utils.core.communication_preferences import (
40+
parse_communication_preferences,
41+
apply_communication_preferences,
42+
)
3843

3944
router = APIRouter(prefix="/user", tags=["user"])
4045
templates = Jinja2Templates(directory="templates")
@@ -95,6 +100,7 @@ async def read_profile(
95100
"user": user,
96101
"account_emails": account_emails,
97102
"max_emails": MAX_EMAILS_PER_ACCOUNT,
103+
"host_name": os.getenv("HOST_NAME", "our platform"),
98104
},
99105
)
100106

@@ -199,6 +205,31 @@ async def update_profile(
199205
return RedirectResponse(url=router.url_path_for("read_profile"), status_code=303)
200206

201207

208+
@router.post("/communication-preferences", response_class=RedirectResponse)
209+
async def update_communication_preferences(
210+
request: Request,
211+
comm_opt_in: Optional[str] = Form(None),
212+
comm_updates: Optional[str] = Form(None),
213+
comm_marketing: Optional[str] = Form(None),
214+
user: User = Depends(get_authenticated_user),
215+
session: Session = Depends(get_session),
216+
) -> Response:
217+
apply_communication_preferences(
218+
user,
219+
parse_communication_preferences(comm_opt_in, comm_updates, comm_marketing),
220+
)
221+
session.commit()
222+
session.refresh(user)
223+
224+
if is_htmx_request(request):
225+
return toast_response(
226+
request,
227+
templates,
228+
"Communication preferences updated.",
229+
)
230+
return RedirectResponse(url=router.url_path_for("read_profile"), status_code=303)
231+
232+
202233
@router.get("/avatar")
203234
async def get_avatar(user: User = Depends(get_authenticated_user)):
204235
"""Serve avatar image from database"""

templates/account/register.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@
5656
</div>
5757
</div>
5858

59+
{% include 'partials/communication_preferences_fields.html' with context %}
60+
5961
<!-- Submit Button -->
6062
<div class="d-grid">
6163
<button type="submit" class="btn btn-primary">Register</button>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
{# Communication preference checkboxes. Pass user (optional) and host_name. #}
2+
{% set id_prefix = id_prefix | default('') %}
3+
<div class="communication-preferences" data-comm-prefs-root>
4+
<div class="form-check mb-2">
5+
<input type="checkbox" class="form-check-input" id="{{ id_prefix }}comm_opt_in" name="comm_opt_in"
6+
{% if user and user.comm_opt_in %}checked{% endif %}>
7+
<label class="form-check-label" for="{{ id_prefix }}comm_opt_in">
8+
Send me optional emails from {{ host_name }}
9+
</label>
10+
</div>
11+
<p class="form-text ms-1 mb-2">
12+
Account related emails (verification, security, invitations) are always sent.
13+
You can update these preferences anytime in your profile.
14+
See our <a href="{{ url_for('read_static_page', page_name='privacy-policy') }}">Privacy Policy</a>.
15+
</p>
16+
17+
<div id="{{ id_prefix }}comm_sub_prefs" class="ms-4 mb-2"
18+
{% if not (user and user.comm_opt_in) %}style="display: none;"{% endif %}>
19+
<div class="form-check mb-2">
20+
<input type="checkbox" class="form-check-input" id="{{ id_prefix }}comm_updates" name="comm_updates"
21+
{% if user and user.comm_updates %}checked{% endif %}>
22+
<label class="form-check-label" for="{{ id_prefix }}comm_updates">
23+
Product updates and new features
24+
</label>
25+
</div>
26+
<p class="form-text mb-2">Announcements when we ship new capabilities.</p>
27+
28+
<div class="form-check mb-2">
29+
<input type="checkbox" class="form-check-input" id="{{ id_prefix }}comm_marketing" name="comm_marketing"
30+
{% if user and user.comm_marketing %}checked{% endif %}>
31+
<label class="form-check-label" for="{{ id_prefix }}comm_marketing">
32+
Tips, offers, and promotional content
33+
</label>
34+
</div>
35+
<p class="form-text mb-0">Occasional marketing about our products and services.</p>
36+
</div>
37+
</div>
38+
39+
<script>
40+
(function () {
41+
const roots = document.querySelectorAll('[data-comm-prefs-root]');
42+
roots.forEach(function (root) {
43+
if (root.dataset.commPrefsInit) {
44+
return;
45+
}
46+
root.dataset.commPrefsInit = 'true';
47+
48+
const master = root.querySelector('input[name="comm_opt_in"]');
49+
const updates = root.querySelector('input[name="comm_updates"]');
50+
const marketing = root.querySelector('input[name="comm_marketing"]');
51+
const subPrefs = root.querySelector('[id$="comm_sub_prefs"]');
52+
53+
if (!master || !updates || !marketing || !subPrefs) {
54+
return;
55+
}
56+
57+
function syncSubPreferences() {
58+
if (master.checked) {
59+
subPrefs.style.display = '';
60+
} else {
61+
subPrefs.style.display = 'none';
62+
updates.checked = false;
63+
marketing.checked = false;
64+
}
65+
}
66+
67+
master.addEventListener('change', function () {
68+
if (master.checked) {
69+
subPrefs.style.display = '';
70+
updates.checked = true;
71+
marketing.checked = true;
72+
} else {
73+
syncSubPreferences();
74+
}
75+
});
76+
});
77+
})();
78+
</script>

templates/users/profile.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@ <h1 class="mb-4">User Profile</h1>
1212
{% include 'users/partials/profile_display.html' %}
1313
</div>
1414

15+
<!-- Communication Preferences -->
16+
<div class="card mb-4" id="communication-preferences-card">
17+
<div class="card-header">
18+
Communication Preferences
19+
</div>
20+
<div class="card-body">
21+
<form action="{{ url_for('update_communication_preferences') }}" method="post"
22+
hx-post="{{ url_for('update_communication_preferences') }}" hx-swap="none">
23+
{% include 'partials/communication_preferences_fields.html' with context %}
24+
<button type="submit" class="btn btn-primary mt-3">Save Preferences</button>
25+
</form>
26+
</div>
27+
</div>
28+
1529
<!-- Email Addresses -->
1630
<div class="card mb-4">
1731
<div class="card-header">

0 commit comments

Comments
 (0)