From cdfea6d3f0de9367b2e4875cef4a99bd3ea71b32 Mon Sep 17 00:00:00 2001 From: Rafiuzzaman Khan Date: Thu, 18 Jun 2026 17:26:38 -0400 Subject: [PATCH 1/3] add delete invite feature --- exceptions/http_exceptions.py | 7 + routers/core/invitation.py | 73 +++++++- static/css/extras.css | 15 ++ .../organization/modals/members_card.html | 22 +-- .../partials/invitations_list.html | 19 +- .../organization/partials/members_table.html | 21 +-- tests/routers/core/test_invitation.py | 177 ++++++++++++++++++ tests/test_htmx.py | 62 ++++++ 8 files changed, 358 insertions(+), 38 deletions(-) diff --git a/exceptions/http_exceptions.py b/exceptions/http_exceptions.py index 6f4dcf1..f155ce5 100644 --- a/exceptions/http_exceptions.py +++ b/exceptions/http_exceptions.py @@ -177,6 +177,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..4ea601b 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,6 +24,7 @@ OrganizationNotFoundError, InvitationEmailSendError, InvalidInvitationTokenError, + InvitationNotFoundError, ) from exceptions.exceptions import EmailSendFailedError from utils.core.htmx import is_htmx_request, append_toast @@ -32,6 +34,7 @@ from routers.core.organization import ( router as org_router, ) # Already imported, check usage +from routers.core.user import _load_org_for_members_partial # Setup logger logger = getLogger("uvicorn.error") @@ -161,11 +164,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 +186,59 @@ 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 HTTPException( + status_code=403, + detail="You don't have permission to manage 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/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..8c3174d 100644 --- a/templates/organization/modals/members_card.html +++ b/templates/organization/modals/members_card.html @@ -76,23 +76,13 @@ {% 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..21abd3a 100644 --- a/templates/organization/partials/invitations_list.html +++ b/templates/organization/partials/invitations_list.html @@ -1,8 +1,21 @@ {# Partial:
  • items for pending invitations. Swapped into