Skip to content

Commit 9a02a84

Browse files
committed
add delete invite feature
1 parent ac608d4 commit 9a02a84

8 files changed

Lines changed: 358 additions & 38 deletions

File tree

exceptions/http_exceptions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,13 @@ def __init__(self):
177177
)
178178

179179

180+
class InvitationNotFoundError(HTTPException):
181+
"""Raised when an invitation ID does not exist."""
182+
183+
def __init__(self):
184+
super().__init__(status_code=404, detail="Invitation not found")
185+
186+
180187
class InvalidInvitationTokenError(HTTPException):
181188
"""Raised when an invitation token is invalid, expired, or not found."""
182189

routers/core/invitation.py

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from uuid import uuid4
22
from typing import Optional
33
from fastapi import APIRouter, Depends, Form, Query, Request, status
4-
from fastapi.responses import RedirectResponse
4+
from fastapi.responses import RedirectResponse, Response
55
from fastapi.templating import Jinja2Templates
66
from fastapi.exceptions import HTTPException
77
from pydantic import EmailStr
@@ -15,6 +15,7 @@
1515
)
1616
from utils.core.models import User, Role, Account, Invitation, Organization
1717
from utils.core.enums import ValidPermissions
18+
from utils.app.enums import AppPermissions
1819
from utils.core.invitations import send_invitation_email, process_invitation
1920
from exceptions.http_exceptions import (
2021
UserIsAlreadyMemberError,
@@ -23,6 +24,7 @@
2324
OrganizationNotFoundError,
2425
InvitationEmailSendError,
2526
InvalidInvitationTokenError,
27+
InvitationNotFoundError,
2628
)
2729
from exceptions.exceptions import EmailSendFailedError
2830
from utils.core.htmx import is_htmx_request, append_toast
@@ -32,6 +34,7 @@
3234
from routers.core.organization import (
3335
router as org_router,
3436
) # Already imported, check usage
37+
from routers.core.user import _load_org_for_members_partial
3538

3639
# Setup logger
3740
logger = getLogger("uvicorn.error")
@@ -161,11 +164,20 @@ async def create_invitation(
161164

162165
# HTMX: return partial; non-HTMX: PRG redirect
163166
if is_htmx_request(request):
164-
active_invitations = Invitation.get_active_for_org(session, organization_id)
167+
organization, user_permissions, active_invitations = (
168+
_load_org_for_members_partial(session, organization_id, current_user)
169+
)
165170
response = templates.TemplateResponse(
166171
request,
167-
"organization/partials/invitations_list.html",
168-
{"active_invitations": active_invitations},
172+
"organization/partials/members_table.html",
173+
{
174+
"organization": organization,
175+
"active_invitations": active_invitations,
176+
"user": current_user,
177+
"user_permissions": user_permissions,
178+
"ValidPermissions": ValidPermissions,
179+
"all_permissions": list(ValidPermissions) + list(AppPermissions),
180+
},
169181
)
170182
response.headers["HX-Trigger"] = "modalDismiss"
171183
return append_toast(
@@ -174,6 +186,59 @@ async def create_invitation(
174186
return RedirectResponse(url=f"/organizations/{organization_id}", status_code=303)
175187

176188

189+
@router.post("/delete", name="delete_invitation", response_class=RedirectResponse)
190+
async def delete_invitation(
191+
request: Request,
192+
current_user: User = Depends(get_authenticated_user),
193+
session: Session = Depends(get_session),
194+
invitation_id: int = Form(
195+
..., title="Invitation ID", description="ID of the invitation to delete"
196+
),
197+
organization_id: int = Form(
198+
...,
199+
title="Organization ID",
200+
description="ID of the organization the invitation belongs to",
201+
),
202+
) -> Response:
203+
organization = session.get(Organization, organization_id)
204+
if not organization:
205+
raise OrganizationNotFoundError()
206+
207+
if not current_user.has_permission(ValidPermissions.INVITE_USER, organization):
208+
raise HTTPException(
209+
status_code=403,
210+
detail="You don't have permission to manage invitations for this organization",
211+
)
212+
213+
invitation = session.get(Invitation, invitation_id)
214+
if not invitation or invitation.organization_id != organization_id:
215+
raise InvitationNotFoundError()
216+
217+
session.delete(invitation)
218+
session.commit()
219+
220+
if is_htmx_request(request):
221+
organization, user_permissions, active_invitations = (
222+
_load_org_for_members_partial(session, organization_id, current_user)
223+
)
224+
response = templates.TemplateResponse(
225+
request,
226+
"organization/partials/members_table.html",
227+
{
228+
"organization": organization,
229+
"active_invitations": active_invitations,
230+
"user": current_user,
231+
"user_permissions": user_permissions,
232+
"ValidPermissions": ValidPermissions,
233+
"all_permissions": list(ValidPermissions) + list(AppPermissions),
234+
},
235+
)
236+
return append_toast(
237+
response, request, templates, "Invitation cancelled successfully."
238+
)
239+
return RedirectResponse(url=f"/organizations/{organization_id}", status_code=303)
240+
241+
177242
@router.get("/accept", name="accept_invitation")
178243
async def accept_invitation(
179244
invitation: Invitation = Depends(get_valid_invitation),

static/css/extras.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,19 @@
55

66
input:invalid:not(:placeholder-shown) + .invalid-feedback {
77
display: block;
8+
}
9+
10+
/* Align pending-invitation Cancel buttons with card-header actions (e.g. Invite Member). */
11+
.card-body .invitation-list.list-group-flush {
12+
margin-left: calc(-1 * var(--bs-card-spacer-x));
13+
margin-right: calc(-1 * var(--bs-card-spacer-x));
14+
}
15+
16+
.card-body .invitation-list .invitation-list-item {
17+
padding-left: var(--bs-card-spacer-x);
18+
padding-right: var(--bs-card-spacer-x);
19+
}
20+
21+
.invitation-cancel-form .btn {
22+
min-width: 6.75rem;
823
}

templates/organization/modals/members_card.html

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -76,23 +76,13 @@
7676
</div>
7777
{% endif %}
7878

79-
{# Pending Invitations Section #}
8079
<hr class="my-4">
81-
<h4>Pending Invitations</h4>
82-
{% if active_invitations %}
83-
<ul class="list-group list-group-flush" id="invitations-list">
84-
{% for inv in active_invitations %}
85-
<li class="list-group-item d-flex justify-content-between align-items-center">
86-
<span>{{ inv.invitee_email }} (Role: {{ inv.role.name }})</span>
87-
<small class="text-muted">Expires: {{ inv.expires_at.strftime('%Y-%m-%d') }}</small>
88-
</li>
89-
{% endfor %}
90-
</ul>
91-
{% else %}
92-
<ul class="list-group list-group-flush" id="invitations-list">
93-
<li class="list-group-item text-muted">No pending invitations.</li>
94-
</ul>
95-
{% endif %}
80+
<h4 class="mb-3">Pending Invitations</h4>
81+
<ul class="list-group list-group-flush invitation-list" id="invitations-list">
82+
{% with show_invitation_cancel=true %}
83+
{% include 'organization/partials/invitations_list.html' %}
84+
{% endwith %}
85+
</ul>
9686
</div>
9787
</div>
9888

templates/organization/partials/invitations_list.html

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
11
{# Partial: <li> items for pending invitations. Swapped into <ul id="invitations-list">. #}
2+
{# Set show_invitation_cancel=true via {% with %} to render Cancel controls (members card only). #}
23
{% for inv in active_invitations %}
3-
<li class="list-group-item d-flex justify-content-between align-items-center">
4-
<span>{{ inv.invitee_email }} (Role: {{ inv.role.name }})</span>
5-
<small class="text-muted">Expires: {{ inv.expires_at.strftime('%Y-%m-%d') }}</small>
4+
<li class="list-group-item invitation-list-item">
5+
<div class="d-flex align-items-center">
6+
<span class="me-auto">{{ inv.invitee_email }} (Role: {{ inv.role.name }})</span>
7+
<small class="text-muted invitation-list-expiry me-3">Expires: {{ inv.expires_at.strftime('%Y-%m-%d') }}</small>
8+
{% if show_invitation_cancel is defined and show_invitation_cancel and ValidPermissions is defined and user_permissions is defined and ValidPermissions.INVITE_USER in user_permissions %}
9+
<form method="POST" action="{{ url_for('delete_invitation') }}" class="invitation-cancel-form mb-0"
10+
hx-post="{{ url_for('delete_invitation') }}"
11+
hx-target="#members-card-content"
12+
hx-swap="innerHTML">
13+
<input type="hidden" name="invitation_id" value="{{ inv.id }}">
14+
<input type="hidden" name="organization_id" value="{{ organization.id }}">
15+
<button type="submit" class="btn btn-sm btn-outline-danger">Cancel</button>
16+
</form>
17+
{% endif %}
18+
</div>
619
</li>
720
{% else %}
821
<li class="list-group-item text-muted">No pending invitations.</li>

templates/organization/partials/members_table.html

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,21 +65,12 @@
6565
{% endif %}
6666

6767
<hr class="my-4">
68-
<h4>Pending Invitations</h4>
69-
{% if active_invitations %}
70-
<ul class="list-group list-group-flush" id="invitations-list">
71-
{% for inv in active_invitations %}
72-
<li class="list-group-item d-flex justify-content-between align-items-center">
73-
<span>{{ inv.invitee_email }} (Role: {{ inv.role.name }})</span>
74-
<small class="text-muted">Expires: {{ inv.expires_at.strftime('%Y-%m-%d') }}</small>
75-
</li>
76-
{% endfor %}
77-
</ul>
78-
{% else %}
79-
<ul class="list-group list-group-flush" id="invitations-list">
80-
<li class="list-group-item text-muted">No pending invitations.</li>
81-
</ul>
82-
{% endif %}
68+
<h4 class="mb-3">Pending Invitations</h4>
69+
<ul class="list-group list-group-flush invitation-list" id="invitations-list">
70+
{% with show_invitation_cancel=true %}
71+
{% include 'organization/partials/invitations_list.html' %}
72+
{% endwith %}
73+
</ul>
8374

8475
<div id="member-role-modals-container" hx-swap-oob="true">
8576
{% if ValidPermissions.EDIT_USER_ROLE in user_permissions %}

0 commit comments

Comments
 (0)