Skip to content

Commit

Permalink
#117 Add Pydantic models for legacy v2 SMS post_sms_request (#164)
Browse files Browse the repository at this point in the history
Co-authored-by: Chris Johnson <[email protected]>
  • Loading branch information
ChrisJohnson-CDJ and Chris Johnson authored Feb 12, 2025
1 parent 8851571 commit 4686327
Show file tree
Hide file tree
Showing 5 changed files with 107 additions and 57 deletions.
12 changes: 9 additions & 3 deletions app/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@
RESPONSE_500 = 'Server error'


class AttachmentType(StrEnum):
"""Types of file attachment methods that can be used."""

ATTACH = 'attach'
LINK = 'link'


class IdentifierType(StrEnum):
"""Types of Identifiers that can be used."""

Expand Down Expand Up @@ -47,8 +54,7 @@ class OSPlatformType(StrEnum):
IOS = 'IOS'


class USNumberType(PhoneNumber):
"""Annotated type for US phone numbers."""
class PhoneNumberE164(PhoneNumber):
"""Annotated type for phone numbers in E164 format."""

supported_regions = ['US'] # noqa: RUF012
phone_format: str = PhoneNumberFormat.to_string(PhoneNumberFormat.E164)
12 changes: 6 additions & 6 deletions app/legacy/v2/notifications/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@

from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Request, status
from loguru import logger
from pydantic import HttpUrl

from app.auth import JWTBearer
from app.constants import USNumberType
from app.constants import PhoneNumberE164
from app.dao.notifications_dao import dao_create_notification
from app.db.models import Notification, Template
from app.legacy.v2.notifications.route_schema import (
HttpsUrl,
V2PostPushRequestModel,
V2PostPushResponseModel,
V2PostSmsRequestModel,
Expand Down Expand Up @@ -114,16 +114,16 @@ async def create_sms_notification(
return V2PostSmsResponseModel(
id=uuid4(),
billing_code='123456',
callback_url=HttpUrl('https://example.com'),
callback_url=HttpsUrl('https://example.com'),
reference='123456',
template=V2Template(
id=uuid4(),
uri=HttpUrl('https://example.com'),
uri=HttpsUrl('https://example.com'),
version=1,
),
uri=HttpUrl('https://example.com'),
uri=HttpsUrl('https://example.com'),
content=V2SmsContentModel(
body='example',
from_number=USNumberType('+18005550101'),
from_number=PhoneNumberE164('+18005550101'),
),
)
110 changes: 72 additions & 38 deletions app/legacy/v2/notifications/route_schema.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,47 @@
"""Request and Response bodies for /v2/notifications."""

from typing import ClassVar, Collection, Literal

from pydantic import UUID4, AwareDatetime, BaseModel, EmailStr, Field, HttpUrl, model_validator
from typing import Annotated, ClassVar, Collection, Literal

from pydantic import (
UUID4,
AwareDatetime,
BaseModel,
ConfigDict,
EmailStr,
Field,
HttpUrl,
UrlConstraints,
model_validator,
)
from typing_extensions import Self

from app.constants import IdentifierType, MobileAppType, NotificationType, USNumberType
from app.constants import IdentifierType, MobileAppType, NotificationType, PhoneNumberE164


class StrictBaseModel(BaseModel):
"""Base model to enforce strict mode."""

model_config = ConfigDict(strict=True)


class HttpsUrl(HttpUrl):
"""Enforced additional constraints on HttpUrl."""

_constraints = UrlConstraints(max_length=255, allowed_schemes=['https'])


class V2Template(BaseModel):
class V2Template(StrictBaseModel):
"""V2 templates have an associated version to conform to the notification-api database schema."""

id: UUID4
id: Annotated[UUID4, Field(strict=False)]
uri: HttpUrl
version: int


class RecipientIdentifierModel(BaseModel):
class RecipientIdentifierModel(StrictBaseModel):
"""Used to look up contact information from VA Profile or MPI."""

id_type: str
id_type: Annotated[IdentifierType, Field(strict=False)]
id_value: str


Expand All @@ -28,24 +50,24 @@ class RecipientIdentifierModel(BaseModel):
##################################################


class V2PostPushRequestModel(BaseModel):
class V2PostPushRequestModel(StrictBaseModel):
"""Request model for the v2 push notification endpoint."""

class ICNRecipientIdentifierModel(BaseModel):
class ICNRecipientIdentifierModel(StrictBaseModel):
"""Model for ICN recipient identifier."""

# Created a specific enum for ICN so api spec is clear, and only "ICN" is allowed.
id_type: Literal[IdentifierType.ICN]
id_value: str

mobile_app: MobileAppType
mobile_app: Annotated[MobileAppType, Field(strict=False)]
# This is a string in the Flask API. It will be a UUID4 in v3.
template_id: str
recipient_identifier: ICNRecipientIdentifierModel
personalisation: dict[str, str | int | float] | None = None


class V2PostPushResponseModel(BaseModel):
class V2PostPushResponseModel(StrictBaseModel):
"""Response model for v2 push notification endpoint."""

result: str = 'success'
Expand All @@ -56,13 +78,13 @@ class V2PostPushResponseModel(BaseModel):
##################################################


class V2GetNotificationResponseModel(BaseModel):
class V2GetNotificationResponseModel(StrictBaseModel):
"""Common attributes for the GET /v2/notifications/<:id> route response."""

id: UUID4
id: Annotated[UUID4, Field(strict=False)]
billing_code: str | None = Field(max_length=256, default=None)
body: str
callback_url: HttpUrl | None = Field(max_length=255, default=None)
callback_url: HttpsUrl | None = None
completed_at: AwareDatetime | None
cost_in_millicents: float
created_at: AwareDatetime
Expand All @@ -76,7 +98,7 @@ class V2GetNotificationResponseModel(BaseModel):
status: str
status_reason: str | None
template: V2Template
type: NotificationType
type: Annotated[NotificationType, Field(strict=False)]


class V2GetEmailNotificationResponseModel(V2GetNotificationResponseModel):
Expand All @@ -92,9 +114,8 @@ class V2GetSmsNotificationResponseModel(V2GetNotificationResponseModel):
"""Additional attributes when getting an SMS notification."""

email_address: None
# Restrict this to a valid phone number, in the 'US' region.
phone_number: USNumberType
sms_sender_id: UUID4
phone_number: PhoneNumberE164
sms_sender_id: Annotated[UUID4, Field(strict=False)]
subject: None


Expand All @@ -103,22 +124,36 @@ class V2GetSmsNotificationResponseModel(V2GetNotificationResponseModel):
##################################################


class V2PostNotificationRequestModel(BaseModel):
class PersonalisationFileObject(StrictBaseModel):
"""Personalisation file attachment object."""

file: str
filename: str = Field(..., min_length=3, max_length=255)
# Note: Annotated strEnum SHOULD work but doesn't here
# a) This object is used for email attachments.
# b) This should be revisitied when email is worked.
# sending_method: Annotated[AttachmentType, Field(strict=False)] | None = None
sending_method: Literal['attach', 'link'] | None = None


class V2PostNotificationRequestModel(StrictBaseModel):
"""Common attributes for the POST /v2/notifications/<:notification_type> routes request."""

billing_code: str | None = Field(max_length=256, default=None)
callback_url: HttpUrl | None = Field(max_length=255, default=None)
personalisation: dict[str, str | int | float] | None = None
callback_url: HttpsUrl | None = None
personalisation: dict[str, str | int | float | list[str | int | float] | PersonalisationFileObject] | None = None

recipient_identifier: RecipientIdentifierModel | None = None
reference: str | None = None
template_id: UUID4
template_id: Annotated[UUID4, Field(strict=False)]
scheduled_for: Annotated[AwareDatetime, Field(strict=False)] | None = None
email_reply_to_id: Annotated[UUID4, Field(strict=False)] | None = None


class V2PostEmailRequestModel(V2PostNotificationRequestModel):
"""Attributes specific to requests to send e-mail notifications."""

email_address: EmailStr | None = None
email_reply_to_id: UUID4

@model_validator(mode='after')
def email_or_recipient_id(self) -> Self:
Expand All @@ -141,8 +176,8 @@ def email_or_recipient_id(self) -> Self:
class V2PostSmsRequestModel(V2PostNotificationRequestModel):
"""Attributes specific to requests to send SMS notifications."""

phone_number: USNumberType | None = None
sms_sender_id: UUID4
phone_number: PhoneNumberE164 | None = None
sms_sender_id: Annotated[UUID4, Field(strict=False)] | None = None

json_schema_extra: ClassVar[dict[str, dict[str, Collection[str]]]] = {
'examples': {
Expand Down Expand Up @@ -185,7 +220,7 @@ class V2PostSmsRequestModel(V2PostNotificationRequestModel):

@model_validator(mode='after')
def phone_number_or_recipient_id(self) -> Self:
"""One, and only one, of "phone_number" or "recipient_identifier" must not be None.
"""At least one, of 'phone_number' or 'recipient_identifier' must not be None.
Raises:
ValueError: Bad input
Expand All @@ -194,10 +229,8 @@ def phone_number_or_recipient_id(self) -> Self:
Self: this instance
"""
if (self.phone_number is None and self.recipient_identifier is None) or (
self.phone_number is not None and self.recipient_identifier is not None
):
raise ValueError('You must specify one of "phone_number" or "recipient identifier".')
if self.phone_number is None and self.recipient_identifier is None:
raise ValueError('You must specify at least one of "phone_number" or "recipient identifier".')
return self


Expand All @@ -206,18 +239,19 @@ def phone_number_or_recipient_id(self) -> Self:
##################################################


class V2PostNotificationResponseModel(BaseModel):
class V2PostNotificationResponseModel(StrictBaseModel):
"""Common attributes for the POST /v2/notifications/<:notification_type> routes response."""

id: UUID4
id: Annotated[UUID4, Field(strict=False)]
billing_code: str | None = Field(max_length=256, default=None)
callback_url: HttpUrl | None = Field(max_length=255, default=None)
callback_url: HttpsUrl | None = None
reference: str | None
template: V2Template
uri: HttpUrl
uri: HttpsUrl
scheduled_for: Annotated[AwareDatetime, Field(strict=False)] | None = None


class V2EmailContentModel(BaseModel):
class V2EmailContentModel(StrictBaseModel):
"""The content body of a response for sending an e-mail notification."""

body: str
Expand All @@ -230,11 +264,11 @@ class V2PostEmailResponseModel(V2PostNotificationResponseModel):
content: V2EmailContentModel


class V2SmsContentModel(BaseModel):
class V2SmsContentModel(StrictBaseModel):
"""The content body of a response for sending an SMS notification."""

body: str
from_number: USNumberType
from_number: PhoneNumberE164


class V2PostSmsResponseModel(V2PostNotificationResponseModel):
Expand Down
4 changes: 2 additions & 2 deletions tests/app/legacy/v2/notifications/test_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from fastapi import BackgroundTasks, status
from fastapi.encoders import jsonable_encoder

from app.constants import IdentifierType, MobileAppType, USNumberType
from app.constants import IdentifierType, MobileAppType, PhoneNumberE164
from app.db.models import Template
from app.legacy.v2.notifications.route_schema import (
V2PostPushRequestModel,
Expand Down Expand Up @@ -188,7 +188,7 @@ async def test_happy_path(
request = V2PostSmsRequestModel(
reference='test',
template_id=template_id,
phone_number=USNumberType('+18005550101'),
phone_number=PhoneNumberE164('+18005550101'),
sms_sender_id=sms_sender_id,
)
payload = jsonable_encoder(request)
Expand Down
26 changes: 18 additions & 8 deletions tests/app/legacy/v2/notifications/test_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@
import pytest
from pydantic import ValidationError

from app.legacy.v2.notifications.route_schema import V2PostEmailRequestModel, V2PostSmsRequestModel
from app.constants import IdentifierType
from app.legacy.v2.notifications.route_schema import (
V2PostEmailRequestModel,
V2PostSmsRequestModel,
)

VALID_PHONE_NUMBER = '+17045555555'
INVALID_PHONE_NUMBER = '+5555555555'

######################################################################
# Test POST e-mail schemas
Expand All @@ -19,7 +26,7 @@
'data',
[
{'email_address': '[email protected]'},
{'recipient_identifier': {'id_type': 'ICN', 'id_value': 'test'}},
{'recipient_identifier': {'id_type': IdentifierType.ICN, 'id_value': 'test'}},
],
ids=(
'e-mail address',
Expand Down Expand Up @@ -61,16 +68,21 @@ def test_v2_post_email_request_model_invalid(data: dict[str, str | dict[str, str
@pytest.mark.parametrize(
'data',
[
{'phone_number': '+17045555555'},
{'recipient_identifier': {'id_type': 'ICN', 'id_value': 'test'}},
{'phone_number': VALID_PHONE_NUMBER},
{'recipient_identifier': {'id_type': IdentifierType.ICN, 'id_value': 'test'}},
{
'phone_number': VALID_PHONE_NUMBER,
'recipient_identifier': {'id_type': IdentifierType.ICN, 'id_value': 'test'},
},
],
ids=(
'phone number',
'recipient ID',
'phone number and recipient ID',
),
)
def test_v2_post_sms_request_model_valid(data: dict[str, str | dict[str, str]]) -> None:
"""Valid data with an e-mail address should not raise ValidationError."""
"""Valid required data should not raise ValidationError."""
data['sms_sender_id'] = str(uuid4())
data['template_id'] = str(uuid4())
assert isinstance(V2PostSmsRequestModel.model_validate(data), V2PostSmsRequestModel)
Expand All @@ -80,12 +92,10 @@ def test_v2_post_sms_request_model_valid(data: dict[str, str | dict[str, str]])
'data',
[
{},
{'phone_number': '+17045555555', 'recipient_identifier': {'id_type': 'ICN', 'id_value': 'test'}},
{'phone_number': '+5555555555'},
{'phone_number': INVALID_PHONE_NUMBER},
],
ids=(
'neither phone number nor recipient ID',
'phone number and recipient ID',
'invalid us phone number',
),
)
Expand Down

0 comments on commit 4686327

Please sign in to comment.