feat: add ProfileUpdate pydantic model with email and phone validation

This commit is contained in:
Johan Lundberg 2026-02-20 15:21:28 +01:00
parent 428c17c4e3
commit 7c9e426bb8
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
4 changed files with 156 additions and 0 deletions

View file

@ -20,6 +20,7 @@ dependencies = [
"httpx>=0.28", "httpx>=0.28",
"itsdangerous>=2.2.0", "itsdangerous>=2.2.0",
"typer>=0.15", "typer>=0.15",
"pydantic[email]>=2.12.5",
] ]
[project.scripts] [project.scripts]

View file

@ -0,0 +1,42 @@
from typing import Annotated
from urllib.parse import urlparse
from pydantic import BaseModel, EmailStr, Field, field_validator
from pydantic_extra_types.phone_numbers import PhoneNumberValidator
E164Phone = Annotated[str, PhoneNumberValidator(number_format="E164")]
class ProfileUpdate(BaseModel):
given_name: str = Field(default="", max_length=255)
family_name: str = Field(default="", max_length=255)
preferred_username: str = Field(default="", max_length=255)
email: EmailStr | None = None
phone_number: E164Phone | None = None
picture: str | None = Field(default=None, max_length=2048)
locale: str = Field(default="", max_length=20)
@field_validator("email", mode="before")
@classmethod
def empty_email_to_none(cls, v: str) -> str | None:
if isinstance(v, str) and v.strip() == "":
return None
return v
@field_validator("phone_number", mode="before")
@classmethod
def empty_phone_to_none(cls, v: str) -> str | None:
if isinstance(v, str) and v.strip() == "":
return None
return v
@field_validator("picture", mode="before")
@classmethod
def validate_picture_url(cls, v: str) -> str | None:
if isinstance(v, str) and v.strip() == "":
return None
if isinstance(v, str):
parsed = urlparse(v)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise ValueError("Picture URL must be a valid HTTP or HTTPS URL")
return v

93
tests/test_validation.py Normal file
View file

@ -0,0 +1,93 @@
import pytest
from pydantic import ValidationError
from porchlight.validation import ProfileUpdate
class TestProfileUpdateEmail:
def test_valid_email(self) -> None:
p = ProfileUpdate(email="user@example.com")
assert p.email == "user@example.com"
def test_empty_email_becomes_none(self) -> None:
p = ProfileUpdate(email="")
assert p.email is None
def test_invalid_email_no_at(self) -> None:
with pytest.raises(ValidationError, match="email"):
ProfileUpdate(email="not-an-email")
def test_invalid_email_no_domain(self) -> None:
with pytest.raises(ValidationError, match="email"):
ProfileUpdate(email="user@")
class TestProfileUpdatePhone:
def test_valid_e164(self) -> None:
p = ProfileUpdate(phone_number="+46701234567")
assert p.phone_number == "+46701234567"
def test_valid_e164_with_spaces_normalized(self) -> None:
p = ProfileUpdate(phone_number="+46 70 123 45 67")
assert p.phone_number == "+46701234567"
def test_empty_phone_becomes_none(self) -> None:
p = ProfileUpdate(phone_number="")
assert p.phone_number is None
def test_invalid_phone(self) -> None:
with pytest.raises(ValidationError, match="phone"):
ProfileUpdate(phone_number="not-a-phone")
def test_invalid_phone_no_plus(self) -> None:
with pytest.raises(ValidationError, match="phone"):
ProfileUpdate(phone_number="46701234567")
class TestProfileUpdatePicture:
def test_valid_https_url(self) -> None:
p = ProfileUpdate(picture="https://example.com/pic.jpg")
assert p.picture == "https://example.com/pic.jpg"
def test_valid_http_url(self) -> None:
p = ProfileUpdate(picture="http://example.com/pic.jpg")
assert p.picture == "http://example.com/pic.jpg"
def test_empty_picture_becomes_none(self) -> None:
p = ProfileUpdate(picture="")
assert p.picture is None
def test_invalid_picture_not_url(self) -> None:
with pytest.raises(ValidationError, match="picture"):
ProfileUpdate(picture="not-a-url")
def test_invalid_picture_ftp_scheme(self) -> None:
with pytest.raises(ValidationError, match="picture"):
ProfileUpdate(picture="ftp://example.com/pic.jpg")
class TestProfileUpdateFieldLengths:
def test_given_name_too_long(self) -> None:
with pytest.raises(ValidationError, match="given_name"):
ProfileUpdate(given_name="x" * 256)
def test_phone_number_max_length(self) -> None:
"""E.164 max is 15 digits + plus sign = 16 chars."""
p = ProfileUpdate(phone_number="+431234567890123")
assert p.phone_number == "+431234567890123"
def test_locale_too_long(self) -> None:
with pytest.raises(ValidationError, match="locale"):
ProfileUpdate(locale="x" * 21)
class TestProfileUpdateDefaults:
def test_all_defaults(self) -> None:
p = ProfileUpdate()
assert p.given_name == ""
assert p.family_name == ""
assert p.preferred_username == ""
assert p.email is None
assert p.phone_number is None
assert p.picture is None
assert p.locale == ""

20
uv.lock generated
View file

@ -279,6 +279,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
] ]
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.128.8" version = "0.128.8"
@ -558,6 +571,7 @@ dependencies = [
{ name = "jinja2" }, { name = "jinja2" },
{ name = "motor" }, { name = "motor" },
{ name = "proquint" }, { name = "proquint" },
{ name = "pydantic", extra = ["email"] },
{ name = "pydantic-extra-types", extra = ["phonenumbers"] }, { name = "pydantic-extra-types", extra = ["phonenumbers"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-multipart" }, { name = "python-multipart" },
@ -585,6 +599,7 @@ requires-dist = [
{ name = "jinja2", specifier = ">=3.1" }, { name = "jinja2", specifier = ">=3.1" },
{ name = "motor", specifier = ">=3.7" }, { name = "motor", specifier = ">=3.7" },
{ name = "proquint", specifier = ">=0.2" }, { name = "proquint", specifier = ">=0.2" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.12.5" },
{ name = "pydantic-extra-types", extras = ["phonenumbers"], specifier = ">=2.0" }, { name = "pydantic-extra-types", extras = ["phonenumbers"], specifier = ">=2.0" },
{ name = "pydantic-settings", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.7" },
{ name = "python-multipart", specifier = ">=0.0.20" }, { name = "python-multipart", specifier = ">=0.0.20" },
@ -630,6 +645,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
] ]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]] [[package]]
name = "pydantic-core" name = "pydantic-core"
version = "2.41.5" version = "2.41.5"