Skip to content

Commit 8a16159

Browse files
Implemented invitation creation and listing
1 parent 370b047 commit 8a16159

File tree

13 files changed

+1030
-68
lines changed

13 files changed

+1030
-68
lines changed

exceptions/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,9 @@ def __init__(self, user: User, access_token: str, refresh_token: str):
66
self.user = user
77
self.access_token = access_token
88
self.refresh_token = refresh_token
9+
10+
11+
# Define custom exception for email sending failure
12+
class EmailSendFailedError(Exception):
13+
"""Custom exception for email sending failures."""
14+
pass

exceptions/http_exceptions.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,44 @@ class InvalidImageError(HTTPException):
144144
"""Raised when an invalid image is uploaded"""
145145

146146
def __init__(self, message: str = "Invalid image file"):
147-
super().__init__(status_code=400, detail=message)
147+
super().__init__(status_code=400, detail=message)
148+
149+
150+
# --- Invitation-specific Errors ---
151+
152+
class UserIsAlreadyMemberError(HTTPException):
153+
"""Raised when trying to invite a user who is already a member of the organization."""
154+
def __init__(self):
155+
super().__init__(
156+
status_code=409,
157+
detail="This user is already a member of the organization."
158+
)
159+
160+
161+
class ActiveInvitationExistsError(HTTPException):
162+
"""Raised when trying to invite a user for whom an active invitation already exists."""
163+
def __init__(self):
164+
super().__init__(
165+
status_code=409,
166+
detail="An active invitation already exists for this email address in this organization."
167+
)
168+
169+
170+
class InvalidRoleForOrganizationError(HTTPException):
171+
"""Raised when a role provided does not belong to the target organization.
172+
Note: If the role ID simply doesn't exist, a standard 404 RoleNotFoundError should be raised.
173+
"""
174+
def __init__(self):
175+
super().__init__(
176+
status_code=400,
177+
detail="The selected role does not belong to this organization."
178+
)
179+
180+
181+
class InvitationEmailSendError(HTTPException):
182+
"""Raised when the invitation email fails to send."""
183+
def __init__(self):
184+
super().__init__(
185+
status_code=500, # Internal Server Error seems appropriate
186+
detail="Failed to send invitation email. Please try again later or contact support."
187+
)

main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from fastapi.templating import Jinja2Templates
88
from fastapi.exceptions import RequestValidationError
99
from starlette.exceptions import HTTPException as StarletteHTTPException
10-
from routers import account, dashboard, organization, role, user, static_pages
10+
from routers import account, dashboard, organization, role, user, static_pages, invitations
1111
from utils.dependencies import (
1212
get_optional_user
1313
)
@@ -46,6 +46,7 @@ async def lifespan(app: FastAPI):
4646

4747
app.include_router(account.router)
4848
app.include_router(dashboard.router)
49+
app.include_router(invitations.router)
4950
app.include_router(organization.router)
5051
app.include_router(role.router)
5152
app.include_router(static_pages.router)

routers/invitations.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
from uuid import uuid4
2+
from fastapi import APIRouter, Depends, Form
3+
from fastapi.responses import RedirectResponse
4+
from fastapi.exceptions import HTTPException
5+
from pydantic import EmailStr
6+
from sqlmodel import Session, select
7+
from logging import getLogger
8+
9+
from utils.dependencies import get_authenticated_user
10+
from utils.db import get_session
11+
from utils.models import User, Role, Account, Invitation, ValidPermissions, Organization
12+
from utils.invitations import send_invitation_email
13+
from exceptions.http_exceptions import (
14+
UserIsAlreadyMemberError,
15+
ActiveInvitationExistsError,
16+
InvalidRoleForOrganizationError,
17+
OrganizationNotFoundError,
18+
InvitationEmailSendError,
19+
)
20+
from exceptions.exceptions import EmailSendFailedError
21+
22+
# Setup logger
23+
logger = getLogger("uvicorn.error")
24+
25+
router = APIRouter(
26+
prefix="/invitations",
27+
tags=["invitations"],
28+
)
29+
30+
31+
@router.post("/", name="create_invitation")
32+
async def create_invitation(
33+
current_user: User = Depends(get_authenticated_user),
34+
session: Session = Depends(get_session),
35+
invitee_email: EmailStr = Form(...),
36+
role_id: int = Form(...),
37+
organization_id: int = Form(...),
38+
):
39+
# Fetch the organization
40+
organization = session.get(Organization, organization_id)
41+
if not organization:
42+
raise OrganizationNotFoundError()
43+
44+
# Check if the current user has permission to invite users to this organization
45+
if not current_user.has_permission(ValidPermissions.INVITE_USER, organization):
46+
raise HTTPException(status_code=403, detail="You don't have permission to invite users to this organization")
47+
48+
# Verify the role exists and belongs to this organization
49+
role = session.get(Role, role_id)
50+
if not role:
51+
raise HTTPException(status_code=404, detail="Role not found")
52+
if role.organization_id != organization_id:
53+
raise InvalidRoleForOrganizationError()
54+
55+
# Check if invitee is already a member of the organization
56+
existing_account = session.exec(select(Account).where(Account.email == invitee_email)).first()
57+
if existing_account:
58+
# Check if any user with this account is already a member
59+
existing_user = session.exec(select(User).where(User.account_id == existing_account.id)).first()
60+
if existing_user:
61+
# Check if user has any role in this organization
62+
if any(role.organization_id == organization_id for role in existing_user.roles):
63+
raise UserIsAlreadyMemberError()
64+
65+
# Check for active invitations with the same email
66+
active_invitations = Invitation.get_active_for_org(session, organization_id)
67+
if any(invitation.invitee_email == invitee_email for invitation in active_invitations):
68+
raise ActiveInvitationExistsError()
69+
70+
# Create the invitation
71+
token = str(uuid4())
72+
invitation = Invitation(
73+
organization_id=organization_id,
74+
role_id=role_id,
75+
invitee_email=invitee_email,
76+
token=token,
77+
)
78+
79+
session.add(invitation)
80+
81+
try:
82+
# Refresh to ensure relationships are loaded *before* sending email
83+
session.flush() # Ensure invitation gets an ID if needed by email sender, flush changes
84+
session.refresh(invitation)
85+
# Ensure organization is loaded before passing to email function
86+
# (May already be loaded, but explicit refresh is safer)
87+
if not invitation.organization:
88+
session.refresh(organization) # Refresh the org object fetched earlier
89+
invitation.organization = organization # Assign if needed
90+
91+
# Send email synchronously BEFORE committing
92+
send_invitation_email(invitation, session)
93+
94+
# Commit *only* if email sending was successful
95+
session.commit()
96+
session.refresh(invitation) # Refresh again after commit if needed elsewhere
97+
98+
except EmailSendFailedError as e:
99+
logger.error(f"Invitation email failed for {invitee_email} in org {organization_id}: {e}")
100+
session.rollback() # Rollback the invitation creation
101+
raise InvitationEmailSendError() # Raise HTTP 500
102+
except Exception as e:
103+
# Catch any other unexpected errors during flush/refresh/email/commit
104+
logger.error(
105+
f"Unexpected error during invitation creation/sending for {invitee_email} "
106+
f"in org {organization_id}: {e}",
107+
exc_info=True
108+
)
109+
session.rollback()
110+
raise HTTPException(status_code=500, detail="An unexpected error occurred.")
111+
112+
# Redirect back to organization page (PRG pattern)
113+
return RedirectResponse(url=f"/organizations/{organization_id}", status_code=303)

routers/organization.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from sqlalchemy.orm import selectinload
88
from utils.db import get_session, create_default_roles
99
from utils.dependencies import get_authenticated_user, get_user_with_relations
10-
from utils.models import Organization, User, Role, Account, utc_time
10+
from utils.models import Organization, User, Role, Account, utc_now, Invitation
1111
from utils.enums import ValidPermissions
1212
from exceptions.http_exceptions import (
1313
OrganizationNotFoundError, OrganizationNameTakenError,
@@ -58,6 +58,9 @@ async def read_organization(
5858
)
5959
).first()
6060

61+
# Fetch active invitations for the organization
62+
active_invitations = Invitation.get_active_for_org(session, org_id)
63+
6164
# Pass all required context to the template
6265
return templates.TemplateResponse(
6366
request,
@@ -66,7 +69,8 @@ async def read_organization(
6669
"organization": organization,
6770
"user": user,
6871
"user_permissions": user_permissions,
69-
"ValidPermissions": ValidPermissions
72+
"ValidPermissions": ValidPermissions,
73+
"active_invitations": active_invitations
7074
}
7175
)
7276

@@ -176,7 +180,7 @@ def update_organization(
176180

177181
# Update organization name
178182
organization.name = name
179-
organization.updated_at = utc_time()
183+
organization.updated_at = utc_now()
180184
session.add(organization)
181185
session.commit()
182186

@@ -229,10 +233,10 @@ def invite_member(
229233
selectinload(Organization.roles).selectinload(Role.users)
230234
)
231235
).first()
232-
236+
233237
if not organization:
234238
raise OrganizationNotFoundError()
235-
239+
236240
# Find the account and associated user by email
237241
account = session.exec(
238242
select(Account)
@@ -244,36 +248,36 @@ def invite_member(
244248

245249
if not account or not account.user:
246250
raise UserNotFoundError()
247-
251+
248252
invited_user = account.user
249-
253+
250254
# Check if user is already a member of this organization
251255
is_already_member = False
252256
for role in organization.roles:
253257
if invited_user.id in [u.id for u in role.users]:
254258
is_already_member = True
255259
break
256-
260+
257261
if is_already_member:
258262
raise UserAlreadyMemberError()
259-
263+
260264
# Find the default "Member" role for this organization
261265
member_role = next(
262266
(role for role in organization.roles if role.name == "Member"),
263267
None
264268
)
265-
269+
266270
if not member_role:
267271
raise DataIntegrityError(resource="Organization roles")
268-
272+
269273
# Add the invited user to the Member role
270274
try:
271275
member_role.users.append(invited_user)
272276
session.commit()
273277
except Exception as e:
274278
session.rollback()
275279
raise
276-
280+
277281
# Return to the organization page
278282
return RedirectResponse(
279283
url=router.url_path_for("read_organization", org_id=org_id),

routers/role.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from sqlalchemy.exc import IntegrityError
1010
from utils.db import get_session
1111
from utils.dependencies import get_authenticated_user
12-
from utils.models import Role, Permission, ValidPermissions, utc_time, User, DataIntegrityError
12+
from utils.models import Role, Permission, ValidPermissions, utc_now, User, DataIntegrityError
1313
from exceptions.http_exceptions import InsufficientPermissionsError, InvalidPermissionError, RoleAlreadyExistsError, RoleNotFoundError, RoleHasUsersError, CannotModifyDefaultRoleError
1414
from routers.organization import router as organization_router
1515

@@ -128,7 +128,7 @@ def update_role(
128128

129129
# Update role name and updated_at timestamp
130130
db_role.name = name
131-
db_role.updated_at = utc_time()
131+
db_role.updated_at = utc_now()
132132

133133
try:
134134
session.commit()
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{% extends "emails/base_email.html" %}
2+
3+
{% block email_title %}
4+
You're Invited to Join {{ organization_name }}!
5+
{% endblock %}
6+
7+
{% block email_content %}
8+
<p>Hello,</p>
9+
<p>You have been invited to join the organization <strong>{{ organization_name }}</strong>.</p>
10+
<p>To accept this invitation and join the organization, please click the button below:</p>
11+
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
12+
<tbody>
13+
<tr>
14+
<td align="left">
15+
<table border="0" cellpadding="0" cellspacing="0">
16+
<tbody>
17+
<tr>
18+
<td> <a href="{{ acceptance_link }}" target="_blank">Accept Invitation</a> </td>
19+
</tr>
20+
</tbody>
21+
</table>
22+
</td>
23+
</tr>
24+
</tbody>
25+
</table>
26+
<p>If you did not expect this invitation, you can safely ignore this email.</p>
27+
<p>This invitation link will expire in 7 days.</p>
28+
<p>Best regards,</p>
29+
<p>The Team</p>
30+
{% endblock %}

templates/organization/modals/members_card.html

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,26 +72,54 @@
7272
</table>
7373
</div>
7474
{% endif %}
75+
76+
{# Pending Invitations Section - Added #}
77+
<hr class="my-4"> {# Optional separator #}
78+
<h4>Pending Invitations</h4>
79+
{% if active_invitations %}
80+
<ul class="list-group list-group-flush">
81+
{% for inv in active_invitations %}
82+
<li class="list-group-item d-flex justify-content-between align-items-center">
83+
<span>{{ inv.invitee_email }} (Role: {{ inv.role.name }})</span>
84+
<small class="text-muted">Expires: {{ inv.expires_at.strftime('%Y-%m-%d') }}</small>
85+
</li>
86+
{% endfor %}
87+
</ul>
88+
{% else %}
89+
<p class="text-muted">No pending invitations.</p>
90+
{% endif %}
7591
</div>
7692
</div>
7793

78-
{# Invite Member Modal #}
94+
{# Invite Member Modal - Modified #}
7995
{% if ValidPermissions.INVITE_USER in user_permissions %}
8096
<div class="modal fade" id="inviteMemberModal" tabindex="-1" aria-labelledby="inviteMemberModalLabel" aria-hidden="true">
8197
<div class="modal-dialog">
8298
<div class="modal-content">
83-
<form method="POST" action="{{ url_for('invite_member', org_id=organization.id) }}">
99+
{# Modified form action and added role selection #}
100+
<form method="POST" action="{{ url_for('create_invitation') }}">
84101
<div class="modal-header">
85102
<h5 class="modal-title" id="inviteMemberModalLabel">Invite New Member</h5>
86103
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
87104
</div>
88105
<div class="modal-body">
89106
<div class="mb-3">
90-
<label for="email" class="form-label">Email Address</label>
91-
<input type="email" class="form-control" id="email" name="email" placeholder="[email protected]" required>
92-
<small class="form-text text-muted">Enter the email address of a user to invite them to this organization.</small>
107+
<label for="invitee_email" class="form-label">Email Address</label>
108+
{# Changed name attribute #}
109+
<input type="email" class="form-control" id="invitee_email" name="invitee_email" placeholder="[email protected]" required>
110+
</div>
111+
{# Added Role Selection Dropdown #}
112+
<div class="mb-3">
113+
<label for="role_id" class="form-label">Assign Role</label>
114+
<select class="form-select" id="role_id" name="role_id" required>
115+
<option value="" selected disabled>Select a role...</option>
116+
{% for role in organization.roles %}
117+
<option value="{{ role.id }}">{{ role.name }}</option>
118+
{% endfor %}
119+
</select>
120+
<small class="form-text text-muted">Select the role the invited user will have.</small>
93121
</div>
94-
<input type="hidden" name="organization_id" value="{{ organization.id }}">
122+
<input type="hidden" name="organization_id" value="{{ organization.id }}">
95123
</div>
96124
<div class="modal-footer">
97125
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>

0 commit comments

Comments
 (0)