Skip to content

Commit c5797a0

Browse files
Merge remote-tracking branch 'origin/main' into hetzner
2 parents 423bc39 + 431c964 commit c5797a0

14 files changed

Lines changed: 483 additions & 3 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+
Required when upgrading from <=0.1.27 to >0.1.27. The comm_opt_in,
5+
comm_updates, and comm_marketing columns were introduced in 0.1.28, and
6+
SQLModel create_all() does not alter existing tables. Run this against any
7+
local or deployed database that predates the columns.
8+
9+
Usage:
10+
uv run python -m migrations.add_communication_preferences .env
11+
uv run python -m migrations.add_communication_preferences .env --apply
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import argparse
17+
from dataclasses import dataclass
18+
19+
from dotenv import load_dotenv
20+
from sqlalchemy import text
21+
from sqlmodel import Session, create_engine
22+
23+
from utils.core.db import get_connection_url
24+
25+
COLUMNS = ("comm_opt_in", "comm_updates", "comm_marketing")
26+
27+
28+
@dataclass
29+
class MigrationStats:
30+
missing_columns: tuple[str, ...] = ()
31+
all_present: bool = False
32+
33+
34+
def _column_exists(session: Session, column_name: str) -> bool:
35+
result = session.connection().execute(
36+
text(
37+
"""
38+
SELECT 1
39+
FROM information_schema.columns
40+
WHERE table_schema = 'public'
41+
AND table_name = 'user'
42+
AND column_name = :column_name
43+
"""
44+
),
45+
{"column_name": column_name},
46+
)
47+
return result.first() is not None
48+
49+
50+
def add_communication_preference_columns(env_file: str, apply: bool) -> MigrationStats:
51+
load_dotenv(env_file, override=True)
52+
engine = create_engine(get_connection_url())
53+
stats = MigrationStats()
54+
55+
try:
56+
with Session(engine) as session:
57+
missing = tuple(
58+
column for column in COLUMNS if not _column_exists(session, column)
59+
)
60+
stats.missing_columns = missing
61+
stats.all_present = not missing
62+
63+
if apply and missing:
64+
session.connection().execute(
65+
text(
66+
"""
67+
ALTER TABLE "user"
68+
ADD COLUMN IF NOT EXISTS comm_opt_in BOOLEAN NOT NULL DEFAULT FALSE,
69+
ADD COLUMN IF NOT EXISTS comm_updates BOOLEAN NOT NULL DEFAULT FALSE,
70+
ADD COLUMN IF NOT EXISTS comm_marketing BOOLEAN NOT NULL DEFAULT FALSE
71+
"""
72+
)
73+
)
74+
session.commit()
75+
else:
76+
session.rollback()
77+
finally:
78+
engine.dispose()
79+
80+
return stats
81+
82+
83+
def main() -> None:
84+
parser = argparse.ArgumentParser(
85+
description=(
86+
"Add comm_opt_in, comm_updates, and comm_marketing to the user table. "
87+
"Without --apply, runs in dry-run mode."
88+
)
89+
)
90+
parser.add_argument("env", help="Env file to use (e.g. .env)")
91+
parser.add_argument(
92+
"--apply",
93+
action="store_true",
94+
help="Apply the schema change (default is dry-run).",
95+
)
96+
args = parser.parse_args()
97+
98+
stats = add_communication_preference_columns(env_file=args.env, apply=args.apply)
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()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastapi-jinja2-postgres-webapp"
3-
version = "0.1.26"
3+
version = "0.1.27"
44
description = "A template webapp with a pure-Python FastAPI backend, frontend templating with Jinja2, and a Postgres database to power user auth"
55
readme = "README.md"
66
package-mode = false

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
@@ -75,6 +76,10 @@
7576
login_email_limiter,
7677
)
7778
from utils.core.htmx import is_htmx_request, toast_response, set_flash_cookie
79+
from utils.core.communication_preferences import (
80+
parse_communication_preferences,
81+
apply_communication_preferences,
82+
)
7883

7984
logger = getLogger("uvicorn.error")
8085

@@ -200,6 +205,7 @@ async def read_register(
200205
"password_pattern": HTML_PASSWORD_PATTERN,
201206
"email": email,
202207
"invitation_token": invitation_token,
208+
"host_name": os.getenv("HOST_NAME", "our platform"),
203209
"invitation_token_warning": invitation_token_warning,
204210
},
205211
)
@@ -289,6 +295,9 @@ async def register(
289295
title="Invitation token",
290296
description="Optional invitation token to join an organization",
291297
),
298+
comm_opt_in: Optional[str] = Form(None),
299+
comm_updates: Optional[str] = Form(None),
300+
comm_marketing: Optional[str] = Form(None),
292301
) -> Response:
293302
"""
294303
Register a new user account, optionally processing an invitation.
@@ -330,6 +339,10 @@ async def register(
330339
raise DataIntegrityError(resource="Account ID generation")
331340

332341
new_user = User(name=name, account_id=account.id) # Use account.id
342+
apply_communication_preferences(
343+
new_user,
344+
parse_communication_preferences(comm_opt_in, comm_updates, comm_marketing),
345+
)
333346
session.add(new_user)
334347

335348
# 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,
@@ -33,7 +34,11 @@
3334
OrganizationNotFoundError,
3435
)
3536
from routers.core.organization import router as organization_router
36-
from utils.core.htmx import is_htmx_request, append_toast
37+
from utils.core.htmx import is_htmx_request, append_toast, toast_response
38+
from utils.core.communication_preferences import (
39+
parse_communication_preferences,
40+
apply_communication_preferences,
41+
)
3742

3843
router = APIRouter(prefix="/user", tags=["user"])
3944
templates = Jinja2Templates(directory="templates")
@@ -68,6 +73,7 @@ async def read_profile(
6873
"user": user,
6974
"account_emails": account_emails,
7075
"max_emails": MAX_EMAILS_PER_ACCOUNT,
76+
"host_name": os.getenv("HOST_NAME", "our platform"),
7177
},
7278
)
7379

@@ -172,6 +178,31 @@ async def update_profile(
172178
return RedirectResponse(url=router.url_path_for("read_profile"), status_code=303)
173179

174180

181+
@router.post("/communication-preferences", response_class=RedirectResponse)
182+
async def update_communication_preferences(
183+
request: Request,
184+
comm_opt_in: Optional[str] = Form(None),
185+
comm_updates: Optional[str] = Form(None),
186+
comm_marketing: Optional[str] = Form(None),
187+
user: User = Depends(get_authenticated_user),
188+
session: Session = Depends(get_session),
189+
) -> Response:
190+
apply_communication_preferences(
191+
user,
192+
parse_communication_preferences(comm_opt_in, comm_updates, comm_marketing),
193+
)
194+
session.commit()
195+
session.refresh(user)
196+
197+
if is_htmx_request(request):
198+
return toast_response(
199+
request,
200+
templates,
201+
"Communication preferences updated.",
202+
)
203+
return RedirectResponse(url=router.url_path_for("read_profile"), status_code=303)
204+
205+
175206
@router.get("/avatar")
176207
async def get_avatar(user: User = Depends(get_authenticated_user)):
177208
"""Serve avatar image from database"""

templates/account/register.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@
6565
</div>
6666
</div>
6767

68+
{% include 'partials/communication_preferences_fields.html' with context %}
69+
6870
<!-- Submit Button -->
6971
<div class="d-grid">
7072
<button type="submit" class="btn btn-primary"
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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+
master.addEventListener('change', function () {
58+
if (master.checked) {
59+
subPrefs.style.display = '';
60+
updates.checked = true;
61+
} else {
62+
subPrefs.style.display = 'none';
63+
updates.checked = false;
64+
marketing.checked = false;
65+
}
66+
});
67+
});
68+
})();
69+
</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)