Skip to content

Commit

Permalink
#835 added endpoint for sending verification emails
Browse files Browse the repository at this point in the history
  • Loading branch information
Bubomira committed Jan 25, 2025
1 parent 29df86f commit 964bab7
Show file tree
Hide file tree
Showing 8 changed files with 113 additions and 16 deletions.
5 changes: 5 additions & 0 deletions services/py-api/src/database/model/participant_model.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, Any, Optional, Union

from pydantic import EmailStr
Expand All @@ -15,6 +16,7 @@ class Participant(BaseDbModel):
is_admin: bool
email_verified: bool = field(default=False)
team_id: Optional[SerializableObjectId]
last_sent_email: Optional[datetime] = field(default=None)

def dump_as_mongo_db_document(self) -> Dict[str, Any]:
return {
Expand All @@ -26,6 +28,7 @@ def dump_as_mongo_db_document(self) -> Dict[str, Any]:
"team_id": self.team_id,
"created_at": self.created_at,
"updated_at": self.updated_at,
"last_sent_email": self.last_sent_email,
}

def dump_as_json(self) -> Dict[str, Any]:
Expand All @@ -36,6 +39,7 @@ def dump_as_json(self) -> Dict[str, Any]:
"is_admin": self.is_admin,
"email_verified": self.email_verified,
"team_id": str(self.team_id) if self.team_id else None,
"last_sent_email": self.last_sent_email.strftime("%Y-%m-%d %H:%M:%S") if self.last_sent_email else None,
"created_at": self.created_at.strftime("%Y-%m-%d %H:%M:%S"),
"updated_at": self.updated_at.strftime("%Y-%m-%d %H:%M:%S"),
}
Expand All @@ -53,3 +57,4 @@ class UpdateParticipantParams(UpdateParams):
email_verified: Union[bool, None] = None
is_admin: Union[bool, None] = None
team_id: Union[str, None] = None
last_sent_email: Union[datetime, None] = None
14 changes: 14 additions & 0 deletions services/py-api/src/server/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ class ParticipantNotFoundError(CustomError):
status_code = status.HTTP_404_NOT_FOUND


class EmailRateLimitExceededError(CustomError):
"""Exception raised when the user has exceeded the rate limit for sending emails"""

message = "The rate limit for sending emails has been exceeded"
status_code = status.HTTP_400_BAD_REQUEST


class ParticipantAlreadyVerifiedError(CustomError):
"""Exception raised when the user has already been verified"""

message = "You have already been verified"
status_code = status.HTTP_400_BAD_REQUEST


class TeamNotFoundError(CustomError):
"""Exception raised when there are no teams that match the query to the database"""

Expand Down
19 changes: 16 additions & 3 deletions services/py-api/src/server/handlers/verification_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
from src.service.participants_verification_service import ParticipantVerificationService
from starlette import status
from src.utils import JwtUtility
from src.server.schemas.response_schemas.schemas import ParticipantVerifiedResponse, Response
from src.server.schemas.response_schemas.schemas import (
ParticipantVerifiedResponse,
Response,
VerificationEmailSentSuccessfullyResponse,
)
from src.server.schemas.jwt_schemas.schemas import JwtParticipantVerificationData


Expand Down Expand Up @@ -36,5 +40,14 @@ async def verify_participant(self, jwt_token: str) -> Response:
status_code=status.HTTP_200_OK,
)

# TODO: Create a method named `send_verification_email` that calls the send_verification_email in the Verification Service layer
# The handler method just like the other handler methods catches the errors and passes them to the error handler.
async def send_verification_email(self, participant_id: str) -> Response:

result = await self._service.send_verification_email(participant_id=participant_id)

if is_err(result):
return self.handle_error(result.err_value)

return Response(
response_model=VerificationEmailSentSuccessfullyResponse(participant=result.ok_value),
status_code=status.HTTP_200_OK,
)
16 changes: 12 additions & 4 deletions services/py-api/src/server/routes/verification_routes.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
from fastapi import APIRouter, Depends
from src.server.handlers.verification_handlers import VerificationHandlers
from src.server.schemas.response_schemas.schemas import ErrResponse, ParticipantVerifiedResponse, Response
from src.server.schemas.request_schemas.schemas import ResendEmailParticipantData
from src.server.schemas.response_schemas.schemas import (
ErrResponse,
ParticipantVerifiedResponse,
Response,
VerificationEmailSentSuccessfullyResponse,
)
from src.service.participants_verification_service import ParticipantVerificationService
from src.service.hackathon_service import HackathonService
from src.server.routes.dependency_factory import _h_service
Expand Down Expand Up @@ -29,8 +35,10 @@ async def verify_participant(jwt_token: str, _handler: VerificationHandlers = De
@verification_router.post(
"/send-email",
status_code=200,
responses={200: {"model": ParticipantVerifiedResponse}, 404: {"model": ErrResponse}},
responses={200: {"model": VerificationEmailSentSuccessfullyResponse}, 404: {"model": ErrResponse}},
)
# TODO: Connect it with the proper method in the handlers layer
async def send_verification_email(jwt_token: str, _handler: VerificationHandlers = Depends(_handler)) -> Response:
return {"message": "Should be working!"}
async def send_verification_email(
participant_id_body: ResendEmailParticipantData, _handler: VerificationHandlers = Depends(_handler)
) -> Response:
return await _handler.send_verification_email(participant_id=participant_id_body.participant_id)
4 changes: 4 additions & 0 deletions services/py-api/src/server/schemas/request_schemas/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
# https://docs.pydantic.dev/latest/concepts/unions/#discriminated-unions


class ResendEmailParticipantData(BaseModel):
participant_id: str


class BaseParticipantData(BaseModel):
# Forbid extra fields
model_config = ConfigDict(extra="forbid")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,9 @@ class ParticipantVerifiedResponse(ParticipantRegisteredResponse):


class VerificationEmailSentSuccessfullyResponse(ParticipantDeletedResponse):
pass
# TODO: Add a docstring to describe the response
"""This response includes the updated body of the participant
after successfully resending a verification email
"""


class TeamDeletedResponse(BaseModel):
Expand Down
40 changes: 40 additions & 0 deletions services/py-api/src/service/hackathon_service.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from math import ceil
from typing import Final, Optional, Tuple
from datetime import datetime, timedelta

from motor.motor_asyncio import AsyncIOMotorClientSession
from result import Err, is_err, Ok, Result
Expand All @@ -12,6 +13,7 @@
from src.server.exception import (
DuplicateTeamNameError,
DuplicateEmailError,
ParticipantAlreadyVerifiedError,
ParticipantNotFoundError,
TeamNameMissmatchError,
TeamNotFoundError,
Expand All @@ -30,6 +32,7 @@ class HackathonService:

MAX_NUMBER_OF_TEAM_MEMBERS: Final[int] = 6
MAX_NUMBER_OF_VERIFIED_TEAMS_IN_HACKATHON: Final[int] = 12
RATE_LIMIT_SECONDS: Final[int] = 90

def __init__(
self,
Expand Down Expand Up @@ -223,3 +226,40 @@ async def delete_participant(

async def delete_team(self, team_id: str) -> Result[Team, TeamNotFoundError | Exception]:
return await self._team_repo.delete(obj_id=team_id)

async def check_send_verification_email_rate_limit(
self, participant_id: str
) -> Result[bool, ParticipantNotFoundError | ParticipantAlreadyVerifiedError | Exception]:
"""Check if the verification email rate limit has been reached"""
participant_exists = await self._participant_repo.fetch_by_id(obj_id=participant_id)

if is_err(participant_exists):
return participant_exists

if participant_exists.ok_value.email_verified:
return Err(ParticipantAlreadyVerifiedError())

# If there hasn't been sent an email then we should return True
# so we can invoke send_verification_email from the verification service

if participant_exists.ok_value.last_sent_email is None:
return Ok(value=True)

return Ok(
value=datetime.now() - participant_exists.ok_value.last_sent_email
>= timedelta(seconds=self.RATE_LIMIT_SECONDS)
)

async def send_verification_email(
self, participant_id: str
) -> Result[Participant, ParticipantNotFoundError | Exception]:
# TODO: send email like a background task (part of another issue)

update_result = await self._participant_repo.update(
obj_id=participant_id, obj_fields=UpdateParticipantParams(last_sent_email=datetime.now())
)

if is_err(update_result):
return update_result

return Ok(update_result.ok_value)
26 changes: 19 additions & 7 deletions services/py-api/src/service/participants_verification_service.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
from typing import Tuple
from result import Err, Result
from result import Err, Result, is_err
from src.database.model.participant_model import Participant
from src.database.model.team_model import Team
from src.server.exception import HackathonCapacityExceededError, ParticipantNotFoundError, TeamNotFoundError
from src.server.schemas.jwt_schemas.schemas import JwtParticipantVerificationData
from src.service.hackathon_service import HackathonService
from src.server.exception import (
HackathonCapacityExceededError,
ParticipantNotFoundError,
TeamNotFoundError,
EmailRateLimitExceededError,
)


class ParticipantVerificationService:
Expand Down Expand Up @@ -33,8 +38,15 @@ async def verify_random_participant(self, jwt_data: JwtParticipantVerificationDa

return await self._hackathon_service.verify_random_participant(jwt_data=jwt_data)

# TODO: Create a method send_verification_email that checks if the rate limit is not exceeded and passes the control
# to the hackathon servcie. The hackathon service should implement a method called or something along these lines
# check_send_verification_email_rate_limit() that checks the if the rate limit is exceeded or not.
# if the check passes you pass the control to the HackathonService.send_verification_email() that should send the email
# like a background task and update the participant with the last_email_sent.
async def send_verification_email(
self, participant_id: str
) -> Result[Participant, EmailRateLimitExceededError | Exception]:
result = await self._hackathon_service.check_send_verification_email_rate_limit(participant_id=participant_id)

if is_err(result):
return result

if result.ok_value is True:
return await self._hackathon_service.send_verification_email(participant_id=participant_id)

return Err(EmailRateLimitExceededError())

0 comments on commit 964bab7

Please sign in to comment.