From 20d1835db0b1d2a45fa9cc299a458e850b444daf Mon Sep 17 00:00:00 2001 From: Rafiuzzaman Khan Date: Fri, 19 Jun 2026 15:26:30 -0400 Subject: [PATCH 1/7] add communication preferences --- migrations/__init__.py | 0 migrations/add_communication_preferences.py | 112 ++++++++++++++++++ routers/core/account.py | 13 ++ routers/core/user.py | 33 +++++- templates/account/register.html | 2 + .../communication_preferences_fields.html | 78 ++++++++++++ templates/users/profile.html | 14 +++ tests/routers/core/test_account.py | 91 +++++++++++++- tests/routers/core/test_user.py | 83 +++++++++++++ tests/utils/test_communication_preferences.py | 33 ++++++ utils/core/communication_preferences.py | 32 +++++ utils/core/models.py | 3 + 12 files changed, 490 insertions(+), 4 deletions(-) create mode 100644 migrations/__init__.py create mode 100644 migrations/add_communication_preferences.py create mode 100644 templates/partials/communication_preferences_fields.html create mode 100644 tests/utils/test_communication_preferences.py create mode 100644 utils/core/communication_preferences.py 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..7a5f96d --- /dev/null +++ b/migrations/add_communication_preferences.py @@ -0,0 +1,112 @@ +""" +Add communication preference columns to the user table. + +SQLModel create_all() does not alter existing tables. Run this after pulling +#189 changes if your local or deployed database predates comm_opt_in 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 ee4f047..55a49da 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 @@ -71,6 +72,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") @@ -178,6 +183,7 @@ async def read_register( "password_pattern": HTML_PASSWORD_PATTERN, "email": email, "invitation_token": invitation_token, + "host_name": os.getenv("HOST_NAME", "our platform"), }, ) @@ -266,6 +272,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. @@ -295,6 +304,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 1d04b93..2f6bec5 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, @@ -34,7 +35,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") @@ -95,6 +100,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"), }, ) @@ -199,6 +205,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 46b5adf..6a4c016 100644 --- a/templates/account/register.html +++ b/templates/account/register.html @@ -56,6 +56,8 @@ + {% include 'partials/communication_preferences_fields.html' with context %} +
diff --git a/templates/partials/communication_preferences_fields.html b/templates/partials/communication_preferences_fields.html new file mode 100644 index 0000000..461e6d7 --- /dev/null +++ b/templates/partials/communication_preferences_fields.html @@ -0,0 +1,78 @@ +{# Communication preference checkboxes. Pass user (optional) and host_name. #} +{% set id_prefix = id_prefix | default('') %} +
+
+ + +
+

+ Account related emails (verification, security, invitations) are always sent. + You can update these preferences anytime in your profile. + See our Privacy Policy. +

+ +
+
+ + +
+

Announcements when we ship new capabilities.

+ +
+ + +
+

Occasional marketing about our products and services.

+
+
+ + diff --git a/templates/users/profile.html b/templates/users/profile.html index 2ec4521..11f82e3 100644 --- a/templates/users/profile.html +++ b/templates/users/profile.html @@ -12,6 +12,20 @@

User Profile

{% include 'users/partials/profile_display.html' %}
+ +
+
+ Communication Preferences +
+
+
+ {% include 'partials/communication_preferences_fields.html' with context %} + +
+
+
+
diff --git a/tests/routers/core/test_account.py b/tests/routers/core/test_account.py index 003067a..22548a9 100644 --- a/tests/routers/core/test_account.py +++ b/tests/routers/core/test_account.py @@ -85,6 +85,91 @@ def test_register_endpoint(unauth_client: TestClient, session: Session): user = session.exec(select(User).where(User.account_id == account.id)).first() assert user is not None assert user.name == "New User" + assert user.comm_opt_in is False + assert user.comm_updates is False + assert user.comm_marketing is False + + +def test_register_with_communication_preferences( + unauth_client: TestClient, session: Session +): + response = unauth_client.post( + app.url_path_for("register"), + data={ + "name": "Comm Prefs User", + "email": "commprefs@example.com", + "password": "NewPass123!@#", + "confirm_password": "NewPass123!@#", + "comm_opt_in": "on", + "comm_updates": "on", + "comm_marketing": "on", + }, + ) + assert response.status_code == 303 + + account = session.exec( + select(Account).where(Account.email == "commprefs@example.com") + ).first() + assert account is not None + user = session.exec(select(User).where(User.account_id == account.id)).first() + assert user is not None + assert user.comm_opt_in is True + assert user.comm_updates is True + assert user.comm_marketing is True + + +def test_register_communication_preferences_master_off_ignores_subs( + unauth_client: TestClient, session: Session +): + response = unauth_client.post( + app.url_path_for("register"), + data={ + "name": "Tampered Subs User", + "email": "tamperedsubs@example.com", + "password": "NewPass123!@#", + "confirm_password": "NewPass123!@#", + "comm_updates": "on", + "comm_marketing": "on", + }, + ) + assert response.status_code == 303 + + account = session.exec( + select(Account).where(Account.email == "tamperedsubs@example.com") + ).first() + assert account is not None + user = session.exec(select(User).where(User.account_id == account.id)).first() + assert user is not None + assert user.comm_opt_in is False + assert user.comm_updates is False + assert user.comm_marketing is False + + +def test_register_with_communication_preferences_updates_only( + unauth_client: TestClient, session: Session +): + response = unauth_client.post( + app.url_path_for("register"), + data={ + "name": "Updates Only User", + "email": "updatesonly@example.com", + "password": "NewPass123!@#", + "confirm_password": "NewPass123!@#", + "comm_opt_in": "on", + "comm_updates": "on", + }, + ) + assert response.status_code == 303 + + account = session.exec( + select(Account).where(Account.email == "updatesonly@example.com") + ).first() + assert account is not None + user = session.exec(select(User).where(User.account_id == account.id)).first() + assert user is not None + assert user.comm_opt_in is True + assert user.comm_updates is True + assert user.comm_marketing is False def test_register_creates_account_email_row( @@ -362,9 +447,9 @@ def test_register_page_shows_password_requirements(unauth_client: TestClient): assert "special" in html.lower(), ( "Page should mention special character requirement" ) - - -def test_register_page_confirm_password_has_autocomplete(unauth_client: TestClient): + assert 'name="comm_opt_in"' in html + assert 'name="comm_updates"' in html + assert 'name="comm_marketing"' in html """Issue #156: Both password fields must have autocomplete='new-password' for Chrome autofill.""" response = unauth_client.get(app.url_path_for("read_register")) assert response.status_code == 200 diff --git a/tests/routers/core/test_user.py b/tests/routers/core/test_user.py index b71e357..dcbc457 100644 --- a/tests/routers/core/test_user.py +++ b/tests/routers/core/test_user.py @@ -278,3 +278,86 @@ def test_profile_no_organizations( # Should show "no organizations" message assert "You are not a member of any organizations" in response.text + + +def test_read_profile_shows_communication_preferences( + auth_client: TestClient, test_user: User +): + response = auth_client.get(app.url_path_for("read_profile")) + assert response.status_code == 200 + html = response.text + assert "Communication Preferences" in html + assert 'name="comm_opt_in"' in html + assert 'name="comm_updates"' in html + assert 'name="comm_marketing"' in html + assert "Save Preferences" in html + + +def test_update_communication_preferences_unauthorized(unauth_client: TestClient): + response = unauth_client.post( + app.url_path_for("update_communication_preferences"), + data={"comm_opt_in": "on"}, + ) + assert response.status_code == 303 + assert response.headers["location"] == app.url_path_for("read_login") + + +def test_update_communication_preferences( + auth_client: TestClient, test_user: User, session: Session +): + response = auth_client.post( + app.url_path_for("update_communication_preferences"), + data={ + "comm_opt_in": "on", + "comm_updates": "on", + "comm_marketing": "on", + }, + ) + assert response.status_code == 303 + assert response.headers["location"] == app.url_path_for("read_profile") + + session.refresh(test_user) + assert test_user.comm_opt_in is True + assert test_user.comm_updates is True + assert test_user.comm_marketing is True + + +def test_update_communication_preferences_master_off_clears_subs( + auth_client: TestClient, test_user: User, session: Session +): + test_user.comm_opt_in = True + test_user.comm_updates = True + test_user.comm_marketing = True + session.add(test_user) + session.commit() + + response = auth_client.post( + app.url_path_for("update_communication_preferences"), + data={}, + ) + assert response.status_code == 303 + + session.refresh(test_user) + assert test_user.comm_opt_in is False + assert test_user.comm_updates is False + assert test_user.comm_marketing is False + + +def test_update_communication_preferences_htmx( + auth_client: TestClient, test_user: User, session: Session +): + response = auth_client.post( + app.url_path_for("update_communication_preferences"), + data={ + "comm_opt_in": "on", + "comm_updates": "on", + }, + headers={"HX-Request": "true"}, + ) + assert response.status_code == 200 + assert "Communication preferences updated." in response.text + + session.refresh(test_user) + assert test_user.comm_opt_in is True + assert test_user.comm_updates is True + assert test_user.comm_marketing is False diff --git a/tests/utils/test_communication_preferences.py b/tests/utils/test_communication_preferences.py new file mode 100644 index 0000000..6058084 --- /dev/null +++ b/tests/utils/test_communication_preferences.py @@ -0,0 +1,33 @@ +from utils.core.communication_preferences import parse_communication_preferences + + +def test_parse_communication_preferences_all_off_by_default(): + prefs = parse_communication_preferences() + assert prefs == (False, False, False) + + +def test_parse_communication_preferences_master_off_ignores_subs(): + prefs = parse_communication_preferences( + comm_opt_in=None, + comm_updates="on", + comm_marketing="on", + ) + assert prefs == (False, False, False) + + +def test_parse_communication_preferences_master_on_with_subs(): + prefs = parse_communication_preferences( + comm_opt_in="on", + comm_updates="on", + comm_marketing="on", + ) + assert prefs == (True, True, True) + + +def test_parse_communication_preferences_master_on_updates_only(): + prefs = parse_communication_preferences( + comm_opt_in="on", + comm_updates="on", + comm_marketing=None, + ) + assert prefs == (True, True, False) diff --git a/utils/core/communication_preferences.py b/utils/core/communication_preferences.py new file mode 100644 index 0000000..58024a8 --- /dev/null +++ b/utils/core/communication_preferences.py @@ -0,0 +1,32 @@ +from typing import NamedTuple, Optional + +from utils.core.models import User + + +class CommunicationPreferences(NamedTuple): + comm_opt_in: bool + comm_updates: bool + comm_marketing: bool + + +def parse_communication_preferences( + comm_opt_in: Optional[str] = None, + comm_updates: Optional[str] = None, + comm_marketing: Optional[str] = None, +) -> CommunicationPreferences: + """Parse HTML checkbox form values into communication preference booleans.""" + if comm_opt_in != "on": + return CommunicationPreferences(False, False, False) + return CommunicationPreferences( + True, + comm_updates == "on", + comm_marketing == "on", + ) + + +def apply_communication_preferences( + user: User, prefs: CommunicationPreferences +) -> None: + user.comm_opt_in = prefs.comm_opt_in + user.comm_updates = prefs.comm_updates + user.comm_marketing = prefs.comm_marketing diff --git a/utils/core/models.py b/utils/core/models.py index 7181fd0..7def365 100644 --- a/utils/core/models.py +++ b/utils/core/models.py @@ -174,6 +174,9 @@ class RolePermissionLink(SQLModel, table=True): class UserBase(SQLModel): name: Optional[str] = None + comm_opt_in: bool = Field(default=False) + comm_updates: bool = Field(default=False) + comm_marketing: bool = Field(default=False) class UserAvatar(SQLModel, table=True): From 2b08d29ac29bb60ced291b6610f0ce2f250a2013 Mon Sep 17 00:00:00 2001 From: Rafi Khan Date: Mon, 22 Jun 2026 21:07:52 -0400 Subject: [PATCH 2/7] add delete invite feature (#193) * add delete invite feature * Add hx-confirm for destructive org actions and standardize invitation exceptions. Mirror formettle PR #101 follow-up: require confirmation before cancelling invitations or removing members, and use InsufficientPermissionsError and RoleNotFoundError in the invitation router. Co-authored-by: Cursor * Move private helper to shared utils and add custom exception message --------- Co-authored-by: chriscarrollsmith Co-authored-by: Cursor --- exceptions/http_exceptions.py | 16 +- routers/core/invitation.py | 87 +++++++-- routers/core/role.py | 28 +-- routers/core/user.py | 33 +--- static/css/extras.css | 15 ++ .../organization/modals/members_card.html | 25 +-- .../partials/invitations_list.html | 20 +- .../organization/partials/member_row.html | 3 +- .../organization/partials/members_table.html | 24 +-- tests/routers/core/test_invitation.py | 177 ++++++++++++++++++ tests/test_htmx.py | 62 ++++++ tests/test_templates.py | 17 ++ utils/core/organizations.py | 51 +++++ 13 files changed, 450 insertions(+), 108 deletions(-) create mode 100644 utils/core/organizations.py diff --git a/exceptions/http_exceptions.py b/exceptions/http_exceptions.py index 6f4dcf1..1aabdc9 100644 --- a/exceptions/http_exceptions.py +++ b/exceptions/http_exceptions.py @@ -43,10 +43,11 @@ def __init__(self, field: str, message: str): class InsufficientPermissionsError(HTTPException): - def __init__(self): - super().__init__( - status_code=403, detail="You don't have permission to perform this action" - ) + def __init__( + self, + message: str = "You don't have permission to perform this action", + ): + super().__init__(status_code=403, detail=message) class OrganizationSetupError(HTTPException): @@ -177,6 +178,13 @@ def __init__(self): ) +class InvitationNotFoundError(HTTPException): + """Raised when an invitation ID does not exist.""" + + def __init__(self): + super().__init__(status_code=404, detail="Invitation not found") + + class InvalidInvitationTokenError(HTTPException): """Raised when an invitation token is invalid, expired, or not found.""" diff --git a/routers/core/invitation.py b/routers/core/invitation.py index da85913..07f4554 100644 --- a/routers/core/invitation.py +++ b/routers/core/invitation.py @@ -1,7 +1,7 @@ from uuid import uuid4 from typing import Optional from fastapi import APIRouter, Depends, Form, Query, Request, status -from fastapi.responses import RedirectResponse +from fastapi.responses import RedirectResponse, Response from fastapi.templating import Jinja2Templates from fastapi.exceptions import HTTPException from pydantic import EmailStr @@ -15,6 +15,7 @@ ) from utils.core.models import User, Role, Account, Invitation, Organization from utils.core.enums import ValidPermissions +from utils.app.enums import AppPermissions from utils.core.invitations import send_invitation_email, process_invitation from exceptions.http_exceptions import ( UserIsAlreadyMemberError, @@ -23,15 +24,15 @@ OrganizationNotFoundError, InvitationEmailSendError, InvalidInvitationTokenError, + InvitationNotFoundError, + InsufficientPermissionsError, + RoleNotFoundError, ) from exceptions.exceptions import EmailSendFailedError from utils.core.htmx import is_htmx_request, append_toast - -# Import the account router to generate URLs for login/register +from utils.core.organizations import load_org_for_members_partial from routers.core.account import router as account_router -from routers.core.organization import ( - router as org_router, -) # Already imported, check usage +from routers.core.organization import router as org_router # Setup logger logger = getLogger("uvicorn.error") @@ -80,15 +81,14 @@ async def create_invitation( # Check if the current user has permission to invite users to this organization if not current_user.has_permission(ValidPermissions.INVITE_USER, organization): - raise HTTPException( - status_code=403, - detail="You don't have permission to invite users to this organization", + raise InsufficientPermissionsError( + "You don't have permission to invite users to this organization" ) # Verify the role exists and belongs to this organization role = session.get(Role, role_id) if not role: - raise HTTPException(status_code=404, detail="Role not found") + raise RoleNotFoundError() if role.organization_id != organization_id: raise InvalidRoleForOrganizationError() @@ -161,11 +161,20 @@ async def create_invitation( # HTMX: return partial; non-HTMX: PRG redirect if is_htmx_request(request): - active_invitations = Invitation.get_active_for_org(session, organization_id) + organization, user_permissions, active_invitations = ( + load_org_for_members_partial(session, organization_id, current_user) + ) response = templates.TemplateResponse( request, - "organization/partials/invitations_list.html", - {"active_invitations": active_invitations}, + "organization/partials/members_table.html", + { + "organization": organization, + "active_invitations": active_invitations, + "user": current_user, + "user_permissions": user_permissions, + "ValidPermissions": ValidPermissions, + "all_permissions": list(ValidPermissions) + list(AppPermissions), + }, ) response.headers["HX-Trigger"] = "modalDismiss" return append_toast( @@ -174,6 +183,58 @@ async def create_invitation( return RedirectResponse(url=f"/organizations/{organization_id}", status_code=303) +@router.post("/delete", name="delete_invitation", response_class=RedirectResponse) +async def delete_invitation( + request: Request, + current_user: User = Depends(get_authenticated_user), + session: Session = Depends(get_session), + invitation_id: int = Form( + ..., title="Invitation ID", description="ID of the invitation to delete" + ), + organization_id: int = Form( + ..., + title="Organization ID", + description="ID of the organization the invitation belongs to", + ), +) -> Response: + organization = session.get(Organization, organization_id) + if not organization: + raise OrganizationNotFoundError() + + if not current_user.has_permission(ValidPermissions.INVITE_USER, organization): + raise InsufficientPermissionsError( + "You don't have permission to cancel invitations for this organization" + ) + + invitation = session.get(Invitation, invitation_id) + if not invitation or invitation.organization_id != organization_id: + raise InvitationNotFoundError() + + session.delete(invitation) + session.commit() + + if is_htmx_request(request): + organization, user_permissions, active_invitations = ( + load_org_for_members_partial(session, organization_id, current_user) + ) + response = templates.TemplateResponse( + request, + "organization/partials/members_table.html", + { + "organization": organization, + "active_invitations": active_invitations, + "user": current_user, + "user_permissions": user_permissions, + "ValidPermissions": ValidPermissions, + "all_permissions": list(ValidPermissions) + list(AppPermissions), + }, + ) + return append_toast( + response, request, templates, "Invitation cancelled successfully." + ) + return RedirectResponse(url=f"/organizations/{organization_id}", status_code=303) + + @router.get("/accept", name="accept_invitation") async def accept_invitation( invitation: Invitation = Depends(get_valid_invitation), diff --git a/routers/core/role.py b/routers/core/role.py index 89c038c..dd57649 100644 --- a/routers/core/role.py +++ b/routers/core/role.py @@ -15,8 +15,8 @@ utc_now, User, DataIntegrityError, - Organization, ) +from utils.core.organizations import load_org_for_roles_partial from utils.core.enums import ValidPermissions from utils.app.enums import AppPermissions from exceptions.http_exceptions import ( @@ -36,26 +36,6 @@ templates = Jinja2Templates(directory="templates") -def _load_org_for_roles_partial( - session: Session, organization_id: int, user: User -) -> tuple: - """Re-query org with roles/users/permissions and compute user_permissions.""" - organization = session.exec( - select(Organization) - .where(Organization.id == organization_id) - .options( - selectinload(Organization.roles).selectinload(Role.users), - selectinload(Organization.roles).selectinload(Role.permissions), - ) - ).first() - user_permissions = set() - for role in user.roles: - if role.organization_id == organization_id: - for permission in role.permissions: - user_permissions.add(permission.name) - return organization, user_permissions - - # --- Routes --- @@ -114,7 +94,7 @@ def create_role( raise RoleAlreadyExistsError() if is_htmx_request(request): - organization, user_permissions = _load_org_for_roles_partial( + organization, user_permissions = load_org_for_roles_partial( session, organization_id, user ) response = templates.TemplateResponse( @@ -221,7 +201,7 @@ def update_role( session.refresh(db_role) if is_htmx_request(request): - organization, user_permissions = _load_org_for_roles_partial( + organization, user_permissions = load_org_for_roles_partial( session, organization_id, user ) response = templates.TemplateResponse( @@ -282,7 +262,7 @@ def delete_role( session.commit() if is_htmx_request(request): - organization, user_permissions = _load_org_for_roles_partial( + organization, user_permissions = load_org_for_roles_partial( session, organization_id, user ) response = templates.TemplateResponse( diff --git a/routers/core/user.py b/routers/core/user.py index 2f6bec5..e490488 100644 --- a/routers/core/user.py +++ b/routers/core/user.py @@ -11,9 +11,8 @@ AccountEmail, DataIntegrityError, Organization, - Role, - Invitation, ) +from utils.core.organizations import load_org_for_members_partial from utils.core.auth import MAX_EMAILS_PER_ACCOUNT from utils.core.dependencies import ( get_authenticated_user, @@ -45,32 +44,6 @@ templates = Jinja2Templates(directory="templates") -def _load_org_for_members_partial( - session: Session, organization_id: int, user: User -) -> tuple: - """Re-query org with members fully loaded and compute user_permissions.""" - organization = session.exec( - select(Organization) - .where(Organization.id == organization_id) - .options( - selectinload(Organization.roles) - .selectinload(Role.users) - .selectinload(User.account), - selectinload(Organization.roles) - .selectinload(Role.users) - .selectinload(User.roles), - selectinload(Organization.roles).selectinload(Role.permissions), - ) - ).first() - user_permissions = set() - for role in user.roles: - if role.organization_id == organization_id: - for permission in role.permissions: - user_permissions.add(permission.name) - active_invitations = Invitation.get_active_for_org(session, organization_id) - return organization, user_permissions, active_invitations - - # --- Routes --- @@ -298,7 +271,7 @@ def update_user_role( if is_htmx_request(request): organization, user_permissions, active_invitations = ( - _load_org_for_members_partial(session, organization_id, user) + load_org_for_members_partial(session, organization_id, user) ) response = templates.TemplateResponse( request, @@ -372,7 +345,7 @@ def remove_user_from_organization( if is_htmx_request(request): organization, user_permissions, active_invitations = ( - _load_org_for_members_partial(session, organization_id, user) + load_org_for_members_partial(session, organization_id, user) ) response = templates.TemplateResponse( request, diff --git a/static/css/extras.css b/static/css/extras.css index b126461..7822548 100644 --- a/static/css/extras.css +++ b/static/css/extras.css @@ -5,4 +5,19 @@ input:invalid:not(:placeholder-shown) + .invalid-feedback { display: block; +} + +/* Align pending-invitation Cancel buttons with card-header actions (e.g. Invite Member). */ +.card-body .invitation-list.list-group-flush { + margin-left: calc(-1 * var(--bs-card-spacer-x)); + margin-right: calc(-1 * var(--bs-card-spacer-x)); +} + +.card-body .invitation-list .invitation-list-item { + padding-left: var(--bs-card-spacer-x); + padding-right: var(--bs-card-spacer-x); +} + +.invitation-cancel-form .btn { + min-width: 6.75rem; } \ No newline at end of file diff --git a/templates/organization/modals/members_card.html b/templates/organization/modals/members_card.html index dd91bf5..76fa7a9 100644 --- a/templates/organization/modals/members_card.html +++ b/templates/organization/modals/members_card.html @@ -59,7 +59,8 @@
+ hx-swap="innerHTML" + hx-confirm="Remove {{ member.name }} from this organization?">
{% endif %} - {# Pending Invitations Section #}
-

Pending Invitations

- {% if active_invitations %} - - {% else %} - - {% endif %} +

Pending Invitations

+
diff --git a/templates/organization/partials/invitations_list.html b/templates/organization/partials/invitations_list.html index bbde63d..52503b4 100644 --- a/templates/organization/partials/invitations_list.html +++ b/templates/organization/partials/invitations_list.html @@ -1,8 +1,22 @@ {# Partial:
  • items for pending invitations. Swapped into