Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added migrations/__init__.py
Empty file.
112 changes: 112 additions & 0 deletions migrations/add_communication_preferences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""
Add communication preference columns to the user table.

Required when upgrading from <=0.1.27 to >0.1.27. The comm_opt_in,
comm_updates, and comm_marketing columns were introduced in 0.1.28, and
SQLModel create_all() does not alter existing tables. Run this against any
local or deployed database that predates the columns.

Usage:
uv run python -m migrations.add_communication_preferences .env
uv run python -m migrations.add_communication_preferences .env --apply
"""

from __future__ import annotations

import argparse
from dataclasses import dataclass

from dotenv import load_dotenv
from sqlalchemy import text
from sqlmodel import Session, create_engine

from utils.core.db import get_connection_url

COLUMNS = ("comm_opt_in", "comm_updates", "comm_marketing")


@dataclass
class MigrationStats:
missing_columns: tuple[str, ...] = ()
all_present: bool = False


def _column_exists(session: Session, column_name: str) -> bool:
result = session.connection().execute(
text(
"""
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'user'
AND column_name = :column_name
"""
),
{"column_name": column_name},
)
return result.first() is not None


def add_communication_preference_columns(env_file: str, apply: bool) -> MigrationStats:
load_dotenv(env_file, override=True)
engine = create_engine(get_connection_url())
stats = MigrationStats()

try:
with Session(engine) as session:
missing = tuple(
column for column in COLUMNS if not _column_exists(session, column)
)
stats.missing_columns = missing
stats.all_present = not missing

if apply and missing:
session.connection().execute(
text(
"""
ALTER TABLE "user"
ADD COLUMN IF NOT EXISTS comm_opt_in BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS comm_updates BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS comm_marketing BOOLEAN NOT NULL DEFAULT FALSE
"""
)
)
session.commit()
else:
session.rollback()
finally:
engine.dispose()

return stats


def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Add comm_opt_in, comm_updates, and comm_marketing to the user table. "
"Without --apply, runs in dry-run mode."
)
)
parser.add_argument("env", help="Env file to use (e.g. .env)")
parser.add_argument(
"--apply",
action="store_true",
help="Apply the schema change (default is dry-run).",
)
args = parser.parse_args()

stats = add_communication_preference_columns(env_file=args.env, apply=args.apply)
mode = "APPLY" if args.apply else "DRY-RUN"
if stats.all_present:
print(f"[{mode}] All communication preference columns already exist.")
return

print(f"[{mode}] missing_columns={list(stats.missing_columns)}")
if args.apply:
print(f"[{mode}] Columns added successfully.")
else:
print("Dry-run only. Re-run with --apply to add columns.")


if __name__ == "__main__":
main()
13 changes: 13 additions & 0 deletions routers/core/account.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# auth.py
import os
from logging import getLogger
from typing import Optional, Tuple
from urllib.parse import urlparse
Expand Down Expand Up @@ -75,6 +76,10 @@
login_email_limiter,
)
from utils.core.htmx import is_htmx_request, toast_response, set_flash_cookie
from utils.core.communication_preferences import (
parse_communication_preferences,
apply_communication_preferences,
)

logger = getLogger("uvicorn.error")

Expand Down Expand Up @@ -200,6 +205,7 @@ async def read_register(
"password_pattern": HTML_PASSWORD_PATTERN,
"email": email,
"invitation_token": invitation_token,
"host_name": os.getenv("HOST_NAME", "our platform"),
"invitation_token_warning": invitation_token_warning,
},
)
Expand Down Expand Up @@ -289,6 +295,9 @@ async def register(
title="Invitation token",
description="Optional invitation token to join an organization",
),
comm_opt_in: Optional[str] = Form(None),
comm_updates: Optional[str] = Form(None),
comm_marketing: Optional[str] = Form(None),
) -> Response:
"""
Register a new user account, optionally processing an invitation.
Expand Down Expand Up @@ -330,6 +339,10 @@ async def register(
raise DataIntegrityError(resource="Account ID generation")

new_user = User(name=name, account_id=account.id) # Use account.id
apply_communication_preferences(
new_user,
parse_communication_preferences(comm_opt_in, comm_updates, comm_marketing),
)
session.add(new_user)

# Create the primary AccountEmail entry
Expand Down
33 changes: 32 additions & 1 deletion routers/core/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Optional, List
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import selectinload
import os
from utils.core.models import (
User,
UserAvatar,
Expand Down Expand Up @@ -33,7 +34,11 @@
OrganizationNotFoundError,
)
from routers.core.organization import router as organization_router
from utils.core.htmx import is_htmx_request, append_toast
from utils.core.htmx import is_htmx_request, append_toast, toast_response
from utils.core.communication_preferences import (
parse_communication_preferences,
apply_communication_preferences,
)

router = APIRouter(prefix="/user", tags=["user"])
templates = Jinja2Templates(directory="templates")
Expand Down Expand Up @@ -68,6 +73,7 @@ async def read_profile(
"user": user,
"account_emails": account_emails,
"max_emails": MAX_EMAILS_PER_ACCOUNT,
"host_name": os.getenv("HOST_NAME", "our platform"),
},
)

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


@router.post("/communication-preferences", response_class=RedirectResponse)
async def update_communication_preferences(
request: Request,
comm_opt_in: Optional[str] = Form(None),
comm_updates: Optional[str] = Form(None),
comm_marketing: Optional[str] = Form(None),
user: User = Depends(get_authenticated_user),
session: Session = Depends(get_session),
) -> Response:
apply_communication_preferences(
user,
parse_communication_preferences(comm_opt_in, comm_updates, comm_marketing),
)
session.commit()
session.refresh(user)

if is_htmx_request(request):
return toast_response(
request,
templates,
"Communication preferences updated.",
)
return RedirectResponse(url=router.url_path_for("read_profile"), status_code=303)


@router.get("/avatar")
async def get_avatar(user: User = Depends(get_authenticated_user)):
"""Serve avatar image from database"""
Expand Down
2 changes: 2 additions & 0 deletions templates/account/register.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
</div>
</div>

{% include 'partials/communication_preferences_fields.html' with context %}

<!-- Submit Button -->
<div class="d-grid">
<button type="submit" class="btn btn-primary"
Expand Down
77 changes: 77 additions & 0 deletions templates/partials/communication_preferences_fields.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{# Communication preference checkboxes. Pass user (optional) and host_name. #}
{% set id_prefix = id_prefix | default('') %}
<div class="communication-preferences" data-comm-prefs-root>
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="{{ id_prefix }}comm_opt_in" name="comm_opt_in"
{% if user and user.comm_opt_in %}checked{% endif %}>
<label class="form-check-label" for="{{ id_prefix }}comm_opt_in">
Send me optional emails from {{ host_name }}
</label>
</div>
<p class="form-text ms-1 mb-2">
Account related emails (verification, security, invitations) are always sent.
You can update these preferences anytime in your profile.
See our <a href="{{ url_for('read_static_page', page_name='privacy-policy') }}">Privacy Policy</a>.
</p>

<div id="{{ id_prefix }}comm_sub_prefs" class="ms-4 mb-2"
{% if not (user and user.comm_opt_in) %}style="display: none;"{% endif %}>
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="{{ id_prefix }}comm_updates" name="comm_updates"
{% if user and user.comm_updates %}checked{% endif %}>
<label class="form-check-label" for="{{ id_prefix }}comm_updates">
Product updates and new features
</label>
</div>
<p class="form-text mb-2">Announcements when we ship new capabilities.</p>

<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="{{ id_prefix }}comm_marketing" name="comm_marketing"
{% if user and user.comm_marketing %}checked{% endif %}>
<label class="form-check-label" for="{{ id_prefix }}comm_marketing">
Tips, offers, and promotional content
</label>
</div>
<p class="form-text mb-0">Occasional marketing about our products and services.</p>
</div>
</div>

<script>
(function () {
const roots = document.querySelectorAll('[data-comm-prefs-root]');
roots.forEach(function (root) {
if (root.dataset.commPrefsInit) {
return;
}
root.dataset.commPrefsInit = 'true';

const master = root.querySelector('input[name="comm_opt_in"]');
const updates = root.querySelector('input[name="comm_updates"]');
const marketing = root.querySelector('input[name="comm_marketing"]');
const subPrefs = root.querySelector('[id$="comm_sub_prefs"]');

if (!master || !updates || !marketing || !subPrefs) {
return;
}

function syncSubPreferences() {
if (master.checked) {
subPrefs.style.display = '';
} else {
subPrefs.style.display = 'none';
updates.checked = false;
marketing.checked = false;
}
}

master.addEventListener('change', function () {
if (master.checked) {
subPrefs.style.display = '';
updates.checked = true;
} else {
syncSubPreferences();
}
});
});
})();
</script>
14 changes: 14 additions & 0 deletions templates/users/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ <h1 class="mb-4">User Profile</h1>
{% include 'users/partials/profile_display.html' %}
</div>

<!-- Communication Preferences -->
<div class="card mb-4" id="communication-preferences-card">
<div class="card-header">
Communication Preferences
</div>
<div class="card-body">
<form action="{{ url_for('update_communication_preferences') }}" method="post"
hx-post="{{ url_for('update_communication_preferences') }}" hx-swap="none">
{% include 'partials/communication_preferences_fields.html' with context %}
<button type="submit" class="btn btn-primary mt-3">Save Preferences</button>
</form>
</div>
</div>

<!-- Email Addresses -->
<div class="card mb-4">
<div class="card-header">
Expand Down
Loading
Loading