From 8a161593c990ccc9cfb8add015654c11635cee3c Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Sat, 19 Apr 2025 19:47:47 -0400 Subject: [PATCH 1/6] Implemented invitation creation and listing --- exceptions/exceptions.py | 6 + exceptions/http_exceptions.py | 42 +- main.py | 3 +- routers/invitations.py | 113 ++++ routers/organization.py | 28 +- routers/role.py | 4 +- templates/emails/organization_invite.html | 30 + .../organization/modals/members_card.html | 40 +- tests/conftest.py | 39 +- tests/routers/test_account.py | 32 - tests/routers/test_invitation.py | 577 ++++++++++++++++++ utils/invitations.py | 102 ++++ utils/models.py | 82 ++- 13 files changed, 1030 insertions(+), 68 deletions(-) create mode 100644 routers/invitations.py create mode 100644 templates/emails/organization_invite.html create mode 100644 tests/routers/test_invitation.py create mode 100644 utils/invitations.py diff --git a/exceptions/exceptions.py b/exceptions/exceptions.py index 65637f3..3bf28cd 100644 --- a/exceptions/exceptions.py +++ b/exceptions/exceptions.py @@ -6,3 +6,9 @@ def __init__(self, user: User, access_token: str, refresh_token: str): self.user = user self.access_token = access_token self.refresh_token = refresh_token + + +# Define custom exception for email sending failure +class EmailSendFailedError(Exception): + """Custom exception for email sending failures.""" + pass \ No newline at end of file diff --git a/exceptions/http_exceptions.py b/exceptions/http_exceptions.py index c6c1494..834c914 100644 --- a/exceptions/http_exceptions.py +++ b/exceptions/http_exceptions.py @@ -144,4 +144,44 @@ class InvalidImageError(HTTPException): """Raised when an invalid image is uploaded""" def __init__(self, message: str = "Invalid image file"): - super().__init__(status_code=400, detail=message) \ No newline at end of file + super().__init__(status_code=400, detail=message) + + +# --- Invitation-specific Errors --- + +class UserIsAlreadyMemberError(HTTPException): + """Raised when trying to invite a user who is already a member of the organization.""" + def __init__(self): + super().__init__( + status_code=409, + detail="This user is already a member of the organization." + ) + + +class ActiveInvitationExistsError(HTTPException): + """Raised when trying to invite a user for whom an active invitation already exists.""" + def __init__(self): + super().__init__( + status_code=409, + detail="An active invitation already exists for this email address in this organization." + ) + + +class InvalidRoleForOrganizationError(HTTPException): + """Raised when a role provided does not belong to the target organization. + Note: If the role ID simply doesn't exist, a standard 404 RoleNotFoundError should be raised. + """ + def __init__(self): + super().__init__( + status_code=400, + detail="The selected role does not belong to this organization." + ) + + +class InvitationEmailSendError(HTTPException): + """Raised when the invitation email fails to send.""" + def __init__(self): + super().__init__( + status_code=500, # Internal Server Error seems appropriate + detail="Failed to send invitation email. Please try again later or contact support." + ) \ No newline at end of file diff --git a/main.py b/main.py index b7afbfd..2d2ea1d 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,7 @@ from fastapi.templating import Jinja2Templates from fastapi.exceptions import RequestValidationError from starlette.exceptions import HTTPException as StarletteHTTPException -from routers import account, dashboard, organization, role, user, static_pages +from routers import account, dashboard, organization, role, user, static_pages, invitations from utils.dependencies import ( get_optional_user ) @@ -46,6 +46,7 @@ async def lifespan(app: FastAPI): app.include_router(account.router) app.include_router(dashboard.router) +app.include_router(invitations.router) app.include_router(organization.router) app.include_router(role.router) app.include_router(static_pages.router) diff --git a/routers/invitations.py b/routers/invitations.py new file mode 100644 index 0000000..994dc04 --- /dev/null +++ b/routers/invitations.py @@ -0,0 +1,113 @@ +from uuid import uuid4 +from fastapi import APIRouter, Depends, Form +from fastapi.responses import RedirectResponse +from fastapi.exceptions import HTTPException +from pydantic import EmailStr +from sqlmodel import Session, select +from logging import getLogger + +from utils.dependencies import get_authenticated_user +from utils.db import get_session +from utils.models import User, Role, Account, Invitation, ValidPermissions, Organization +from utils.invitations import send_invitation_email +from exceptions.http_exceptions import ( + UserIsAlreadyMemberError, + ActiveInvitationExistsError, + InvalidRoleForOrganizationError, + OrganizationNotFoundError, + InvitationEmailSendError, +) +from exceptions.exceptions import EmailSendFailedError + +# Setup logger +logger = getLogger("uvicorn.error") + +router = APIRouter( + prefix="/invitations", + tags=["invitations"], +) + + +@router.post("/", name="create_invitation") +async def create_invitation( + current_user: User = Depends(get_authenticated_user), + session: Session = Depends(get_session), + invitee_email: EmailStr = Form(...), + role_id: int = Form(...), + organization_id: int = Form(...), +): + # Fetch the organization + organization = session.get(Organization, organization_id) + if not organization: + raise OrganizationNotFoundError() + + # 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") + + # 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") + if role.organization_id != organization_id: + raise InvalidRoleForOrganizationError() + + # Check if invitee is already a member of the organization + existing_account = session.exec(select(Account).where(Account.email == invitee_email)).first() + if existing_account: + # Check if any user with this account is already a member + existing_user = session.exec(select(User).where(User.account_id == existing_account.id)).first() + if existing_user: + # Check if user has any role in this organization + if any(role.organization_id == organization_id for role in existing_user.roles): + raise UserIsAlreadyMemberError() + + # Check for active invitations with the same email + active_invitations = Invitation.get_active_for_org(session, organization_id) + if any(invitation.invitee_email == invitee_email for invitation in active_invitations): + raise ActiveInvitationExistsError() + + # Create the invitation + token = str(uuid4()) + invitation = Invitation( + organization_id=organization_id, + role_id=role_id, + invitee_email=invitee_email, + token=token, + ) + + session.add(invitation) + + try: + # Refresh to ensure relationships are loaded *before* sending email + session.flush() # Ensure invitation gets an ID if needed by email sender, flush changes + session.refresh(invitation) + # Ensure organization is loaded before passing to email function + # (May already be loaded, but explicit refresh is safer) + if not invitation.organization: + session.refresh(organization) # Refresh the org object fetched earlier + invitation.organization = organization # Assign if needed + + # Send email synchronously BEFORE committing + send_invitation_email(invitation, session) + + # Commit *only* if email sending was successful + session.commit() + session.refresh(invitation) # Refresh again after commit if needed elsewhere + + except EmailSendFailedError as e: + logger.error(f"Invitation email failed for {invitee_email} in org {organization_id}: {e}") + session.rollback() # Rollback the invitation creation + raise InvitationEmailSendError() # Raise HTTP 500 + except Exception as e: + # Catch any other unexpected errors during flush/refresh/email/commit + logger.error( + f"Unexpected error during invitation creation/sending for {invitee_email} " + f"in org {organization_id}: {e}", + exc_info=True + ) + session.rollback() + raise HTTPException(status_code=500, detail="An unexpected error occurred.") + + # Redirect back to organization page (PRG pattern) + return RedirectResponse(url=f"/organizations/{organization_id}", status_code=303) diff --git a/routers/organization.py b/routers/organization.py index dc8b4d7..e3989a1 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import selectinload from utils.db import get_session, create_default_roles from utils.dependencies import get_authenticated_user, get_user_with_relations -from utils.models import Organization, User, Role, Account, utc_time +from utils.models import Organization, User, Role, Account, utc_now, Invitation from utils.enums import ValidPermissions from exceptions.http_exceptions import ( OrganizationNotFoundError, OrganizationNameTakenError, @@ -58,6 +58,9 @@ async def read_organization( ) ).first() + # Fetch active invitations for the organization + active_invitations = Invitation.get_active_for_org(session, org_id) + # Pass all required context to the template return templates.TemplateResponse( request, @@ -66,7 +69,8 @@ async def read_organization( "organization": organization, "user": user, "user_permissions": user_permissions, - "ValidPermissions": ValidPermissions + "ValidPermissions": ValidPermissions, + "active_invitations": active_invitations } ) @@ -176,7 +180,7 @@ def update_organization( # Update organization name organization.name = name - organization.updated_at = utc_time() + organization.updated_at = utc_now() session.add(organization) session.commit() @@ -229,10 +233,10 @@ def invite_member( selectinload(Organization.roles).selectinload(Role.users) ) ).first() - + if not organization: raise OrganizationNotFoundError() - + # Find the account and associated user by email account = session.exec( select(Account) @@ -244,28 +248,28 @@ def invite_member( if not account or not account.user: raise UserNotFoundError() - + invited_user = account.user - + # Check if user is already a member of this organization is_already_member = False for role in organization.roles: if invited_user.id in [u.id for u in role.users]: is_already_member = True break - + if is_already_member: raise UserAlreadyMemberError() - + # Find the default "Member" role for this organization member_role = next( (role for role in organization.roles if role.name == "Member"), None ) - + if not member_role: raise DataIntegrityError(resource="Organization roles") - + # Add the invited user to the Member role try: member_role.users.append(invited_user) @@ -273,7 +277,7 @@ def invite_member( except Exception as e: session.rollback() raise - + # Return to the organization page return RedirectResponse( url=router.url_path_for("read_organization", org_id=org_id), diff --git a/routers/role.py b/routers/role.py index 95c5b1e..7e9aa11 100644 --- a/routers/role.py +++ b/routers/role.py @@ -9,7 +9,7 @@ from sqlalchemy.exc import IntegrityError from utils.db import get_session from utils.dependencies import get_authenticated_user -from utils.models import Role, Permission, ValidPermissions, utc_time, User, DataIntegrityError +from utils.models import Role, Permission, ValidPermissions, utc_now, User, DataIntegrityError from exceptions.http_exceptions import InsufficientPermissionsError, InvalidPermissionError, RoleAlreadyExistsError, RoleNotFoundError, RoleHasUsersError, CannotModifyDefaultRoleError from routers.organization import router as organization_router @@ -128,7 +128,7 @@ def update_role( # Update role name and updated_at timestamp db_role.name = name - db_role.updated_at = utc_time() + db_role.updated_at = utc_now() try: session.commit() diff --git a/templates/emails/organization_invite.html b/templates/emails/organization_invite.html new file mode 100644 index 0000000..454900d --- /dev/null +++ b/templates/emails/organization_invite.html @@ -0,0 +1,30 @@ +{% extends "emails/base_email.html" %} + +{% block email_title %} +You're Invited to Join {{ organization_name }}! +{% endblock %} + +{% block email_content %} +

Hello,

+

You have been invited to join the organization {{ organization_name }}.

+

To accept this invitation and join the organization, please click the button below:

+ + + + + + +
+ + + + + + +
Accept Invitation
+
+

If you did not expect this invitation, you can safely ignore this email.

+

This invitation link will expire in 7 days.

+

Best regards,

+

The Team

+{% endblock %} \ No newline at end of file diff --git a/templates/organization/modals/members_card.html b/templates/organization/modals/members_card.html index 7fdd93c..fa1adc7 100644 --- a/templates/organization/modals/members_card.html +++ b/templates/organization/modals/members_card.html @@ -72,26 +72,54 @@ {% endif %} + + {# Pending Invitations Section - Added #} +
{# Optional separator #} +

Pending Invitations

+ {% if active_invitations %} + + {% else %} +

No pending invitations.

+ {% endif %} -{# Invite Member Modal #} +{# Invite Member Modal - Modified #} {% if ValidPermissions.INVITE_USER in user_permissions %}