Skip to content

Commit 8487f5a

Browse files
Merge remote-tracking branch 'origin/main' into hetzner
2 parents 07e86d3 + 2155abc commit 8487f5a

15 files changed

Lines changed: 452 additions & 110 deletions

File tree

exceptions/http_exceptions.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@ def __init__(self, field: str, message: str):
4343

4444

4545
class InsufficientPermissionsError(HTTPException):
46-
def __init__(self):
47-
super().__init__(
48-
status_code=403, detail="You don't have permission to perform this action"
49-
)
46+
def __init__(
47+
self,
48+
message: str = "You don't have permission to perform this action",
49+
):
50+
super().__init__(status_code=403, detail=message)
5051

5152

5253
class OrganizationSetupError(HTTPException):
@@ -177,6 +178,13 @@ def __init__(self):
177178
)
178179

179180

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

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastapi-jinja2-postgres-webapp"
3-
version = "0.1.24"
3+
version = "0.1.25"
44
description = "A template webapp with a pure-Python FastAPI backend, frontend templating with Jinja2, and a Postgres database to power user auth"
55
readme = "README.md"
66
package-mode = false

routers/core/invitation.py

Lines changed: 74 additions & 13 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,15 +24,15 @@
2324
OrganizationNotFoundError,
2425
InvitationEmailSendError,
2526
InvalidInvitationTokenError,
27+
InvitationNotFoundError,
28+
InsufficientPermissionsError,
29+
RoleNotFoundError,
2630
)
2731
from exceptions.exceptions import EmailSendFailedError
2832
from utils.core.htmx import is_htmx_request, append_toast
29-
30-
# Import the account router to generate URLs for login/register
33+
from utils.core.organizations import load_org_for_members_partial
3134
from routers.core.account import router as account_router
32-
from routers.core.organization import (
33-
router as org_router,
34-
) # Already imported, check usage
35+
from routers.core.organization import router as org_router
3536

3637
# Setup logger
3738
logger = getLogger("uvicorn.error")
@@ -80,15 +81,14 @@ async def create_invitation(
8081

8182
# Check if the current user has permission to invite users to this organization
8283
if not current_user.has_permission(ValidPermissions.INVITE_USER, organization):
83-
raise HTTPException(
84-
status_code=403,
85-
detail="You don't have permission to invite users to this organization",
84+
raise InsufficientPermissionsError(
85+
"You don't have permission to invite users to this organization"
8686
)
8787

8888
# Verify the role exists and belongs to this organization
8989
role = session.get(Role, role_id)
9090
if not role:
91-
raise HTTPException(status_code=404, detail="Role not found")
91+
raise RoleNotFoundError()
9292
if role.organization_id != organization_id:
9393
raise InvalidRoleForOrganizationError()
9494

@@ -161,11 +161,20 @@ async def create_invitation(
161161

162162
# HTMX: return partial; non-HTMX: PRG redirect
163163
if is_htmx_request(request):
164-
active_invitations = Invitation.get_active_for_org(session, organization_id)
164+
organization, user_permissions, active_invitations = (
165+
load_org_for_members_partial(session, organization_id, current_user)
166+
)
165167
response = templates.TemplateResponse(
166168
request,
167-
"organization/partials/invitations_list.html",
168-
{"active_invitations": active_invitations},
169+
"organization/partials/members_table.html",
170+
{
171+
"organization": organization,
172+
"active_invitations": active_invitations,
173+
"user": current_user,
174+
"user_permissions": user_permissions,
175+
"ValidPermissions": ValidPermissions,
176+
"all_permissions": list(ValidPermissions) + list(AppPermissions),
177+
},
169178
)
170179
response.headers["HX-Trigger"] = "modalDismiss"
171180
return append_toast(
@@ -174,6 +183,58 @@ async def create_invitation(
174183
return RedirectResponse(url=f"/organizations/{organization_id}", status_code=303)
175184

176185

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

routers/core/role.py

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
utc_now,
1616
User,
1717
DataIntegrityError,
18-
Organization,
1918
)
19+
from utils.core.organizations import load_org_for_roles_partial
2020
from utils.core.enums import ValidPermissions
2121
from utils.app.enums import AppPermissions
2222
from exceptions.http_exceptions import (
@@ -36,26 +36,6 @@
3636
templates = Jinja2Templates(directory="templates")
3737

3838

39-
def _load_org_for_roles_partial(
40-
session: Session, organization_id: int, user: User
41-
) -> tuple:
42-
"""Re-query org with roles/users/permissions and compute user_permissions."""
43-
organization = session.exec(
44-
select(Organization)
45-
.where(Organization.id == organization_id)
46-
.options(
47-
selectinload(Organization.roles).selectinload(Role.users),
48-
selectinload(Organization.roles).selectinload(Role.permissions),
49-
)
50-
).first()
51-
user_permissions = set()
52-
for role in user.roles:
53-
if role.organization_id == organization_id:
54-
for permission in role.permissions:
55-
user_permissions.add(permission.name)
56-
return organization, user_permissions
57-
58-
5939
# --- Routes ---
6040

6141

@@ -114,7 +94,7 @@ def create_role(
11494
raise RoleAlreadyExistsError()
11595

11696
if is_htmx_request(request):
117-
organization, user_permissions = _load_org_for_roles_partial(
97+
organization, user_permissions = load_org_for_roles_partial(
11898
session, organization_id, user
11999
)
120100
response = templates.TemplateResponse(
@@ -221,7 +201,7 @@ def update_role(
221201
session.refresh(db_role)
222202

223203
if is_htmx_request(request):
224-
organization, user_permissions = _load_org_for_roles_partial(
204+
organization, user_permissions = load_org_for_roles_partial(
225205
session, organization_id, user
226206
)
227207
response = templates.TemplateResponse(
@@ -282,7 +262,7 @@ def delete_role(
282262
session.commit()
283263

284264
if is_htmx_request(request):
285-
organization, user_permissions = _load_org_for_roles_partial(
265+
organization, user_permissions = load_org_for_roles_partial(
286266
session, organization_id, user
287267
)
288268
response = templates.TemplateResponse(

routers/core/user.py

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@
1010
AccountEmail,
1111
DataIntegrityError,
1212
Organization,
13-
Role,
14-
Invitation,
1513
)
14+
from utils.core.organizations import load_org_for_members_partial
1615
from utils.core.auth import MAX_EMAILS_PER_ACCOUNT
1716
from utils.core.dependencies import (
1817
get_authenticated_user,
@@ -40,32 +39,6 @@
4039
templates = Jinja2Templates(directory="templates")
4140

4241

43-
def _load_org_for_members_partial(
44-
session: Session, organization_id: int, user: User
45-
) -> tuple:
46-
"""Re-query org with members fully loaded and compute user_permissions."""
47-
organization = session.exec(
48-
select(Organization)
49-
.where(Organization.id == organization_id)
50-
.options(
51-
selectinload(Organization.roles)
52-
.selectinload(Role.users)
53-
.selectinload(User.account),
54-
selectinload(Organization.roles)
55-
.selectinload(Role.users)
56-
.selectinload(User.roles),
57-
selectinload(Organization.roles).selectinload(Role.permissions),
58-
)
59-
).first()
60-
user_permissions = set()
61-
for role in user.roles:
62-
if role.organization_id == organization_id:
63-
for permission in role.permissions:
64-
user_permissions.add(permission.name)
65-
active_invitations = Invitation.get_active_for_org(session, organization_id)
66-
return organization, user_permissions, active_invitations
67-
68-
6942
# --- Routes ---
7043

7144

@@ -267,7 +240,7 @@ def update_user_role(
267240

268241
if is_htmx_request(request):
269242
organization, user_permissions, active_invitations = (
270-
_load_org_for_members_partial(session, organization_id, user)
243+
load_org_for_members_partial(session, organization_id, user)
271244
)
272245
response = templates.TemplateResponse(
273246
request,
@@ -341,7 +314,7 @@ def remove_user_from_organization(
341314

342315
if is_htmx_request(request):
343316
organization, user_permissions, active_invitations = (
344-
_load_org_for_members_partial(session, organization_id, user)
317+
load_org_for_members_partial(session, organization_id, user)
345318
)
346319
response = templates.TemplateResponse(
347320
request,

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: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@
5959
<form method="POST" action="{{ url_for('remove_user_from_organization') }}" class="d-inline"
6060
hx-post="{{ url_for('remove_user_from_organization') }}"
6161
hx-target="#members-card-content"
62-
hx-swap="innerHTML">
62+
hx-swap="innerHTML"
63+
hx-confirm="Remove {{ member.name }} from this organization?">
6364
<input type="hidden" name="user_id" value="{{ member.id }}">
6465
<input type="hidden" name="organization_id" value="{{ organization.id }}">
6566
<button type="submit" class="btn btn-sm btn-outline-danger" {% if member.id == user.id %}disabled{% endif %}>
@@ -76,23 +77,13 @@
7677
</div>
7778
{% endif %}
7879

79-
{# Pending Invitations Section #}
8080
<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 %}
81+
<h4 class="mb-3">Pending Invitations</h4>
82+
<ul class="list-group list-group-flush invitation-list" id="invitations-list">
83+
{% with show_invitation_cancel=true %}
84+
{% include 'organization/partials/invitations_list.html' %}
85+
{% endwith %}
86+
</ul>
9687
</div>
9788
</div>
9889

templates/organization/partials/invitations_list.html

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
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+
hx-confirm="Cancel invitation for {{ inv.invitee_email }}?">
14+
<input type="hidden" name="invitation_id" value="{{ inv.id }}">
15+
<input type="hidden" name="organization_id" value="{{ organization.id }}">
16+
<button type="submit" class="btn btn-sm btn-outline-danger">Cancel</button>
17+
</form>
18+
{% endif %}
19+
</div>
620
</li>
721
{% else %}
822
<li class="list-group-item text-muted">No pending invitations.</li>

0 commit comments

Comments
 (0)