diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/migrations/add_communication_preferences.py b/migrations/add_communication_preferences.py new file mode 100644 index 0000000..cfe5540 --- /dev/null +++ b/migrations/add_communication_preferences.py @@ -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() diff --git a/routers/core/account.py b/routers/core/account.py index 7d93e7c..bef6c09 100644 --- a/routers/core/account.py +++ b/routers/core/account.py @@ -1,4 +1,5 @@ # auth.py +import os from logging import getLogger from typing import Optional, Tuple from urllib.parse import urlparse @@ -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") @@ -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, }, ) @@ -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. @@ -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 diff --git a/routers/core/user.py b/routers/core/user.py index fffebfe..cdf3b33 100644 --- a/routers/core/user.py +++ b/routers/core/user.py @@ -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, @@ -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") @@ -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"), }, ) @@ -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""" diff --git a/templates/account/register.html b/templates/account/register.html index 78d6a10..82d6314 100644 --- a/templates/account/register.html +++ b/templates/account/register.html @@ -65,6 +65,8 @@ + {% include 'partials/communication_preferences_fields.html' with context %} +