feat: add validation models (locale, username, groups, password) and error helper
Add BCP 47 locale validator to ProfileUpdate, UsernameInput model, GroupListInput model, PasswordSet/PasswordChange with zxcvbn strength checking, and shared format_validation_errors HTML helper. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2f8cca3f41
commit
aff6ddb99b
2 changed files with 333 additions and 2 deletions
|
|
@ -1,8 +1,10 @@
|
|||
import re
|
||||
from typing import Annotated
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
from pydantic import BaseModel, EmailStr, Field, ValidationError, field_validator, model_validator
|
||||
from pydantic_extra_types.phone_numbers import PhoneNumberValidator
|
||||
from zxcvbn import zxcvbn
|
||||
|
||||
E164Phone = Annotated[str, PhoneNumberValidator(number_format="E164")]
|
||||
|
||||
|
|
@ -40,3 +42,131 @@ class ProfileUpdate(BaseModel):
|
|||
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
|
||||
|
||||
@field_validator("locale", mode="before")
|
||||
@classmethod
|
||||
def validate_locale(cls, v: str) -> str:
|
||||
if isinstance(v, str):
|
||||
v = v.strip()
|
||||
if v == "":
|
||||
return ""
|
||||
if not re.match(r"^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$", v):
|
||||
raise ValueError("Locale must be a valid BCP 47 language tag (e.g. en, sv-SE, zh-Hans-CN)")
|
||||
return v
|
||||
|
||||
|
||||
class UsernameInput(BaseModel):
|
||||
username: str = Field(max_length=255)
|
||||
|
||||
@field_validator("username", mode="before")
|
||||
@classmethod
|
||||
def validate_username(cls, v: str) -> str:
|
||||
if isinstance(v, str):
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("Username is required")
|
||||
if not re.match(r"^[a-zA-Z0-9_.@-]+$", v):
|
||||
raise ValueError("Username may only contain letters, digits, dots, hyphens, underscores, and @")
|
||||
return v
|
||||
|
||||
|
||||
class GroupListInput(BaseModel):
|
||||
groups: str = ""
|
||||
|
||||
@property
|
||||
def group_list(self) -> list[str]:
|
||||
"""Parse comma-separated groups into a deduplicated list."""
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for g in (g.strip() for g in self.groups.split(",") if g.strip()):
|
||||
if g not in seen:
|
||||
seen.add(g)
|
||||
result.append(g)
|
||||
return result
|
||||
|
||||
@field_validator("groups", mode="before")
|
||||
@classmethod
|
||||
def validate_groups(cls, v: str) -> str:
|
||||
if isinstance(v, str):
|
||||
names = [g.strip() for g in v.split(",") if g.strip()]
|
||||
for name in names:
|
||||
if not re.match(r"^[a-z0-9_-]{1,64}$", name):
|
||||
raise ValueError(
|
||||
f"Invalid group name '{name}'. "
|
||||
"Groups must be 1-64 lowercase letters, digits, hyphens, or underscores."
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class PasswordSet(BaseModel):
|
||||
password: str = Field(min_length=8, max_length=256)
|
||||
confirm: str
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_password(self) -> "PasswordSet":
|
||||
if self.password != self.confirm:
|
||||
raise ValueError("Passwords do not match")
|
||||
result = zxcvbn(self.password)
|
||||
if result["score"] < 2:
|
||||
feedback = result.get("feedback", {})
|
||||
warning = feedback.get("warning", "")
|
||||
suggestions = feedback.get("suggestions", [])
|
||||
msg = "Password is too easily guessed."
|
||||
if warning:
|
||||
msg += f" {warning}."
|
||||
if suggestions:
|
||||
msg += " " + " ".join(suggestions)
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
|
||||
class PasswordChange(PasswordSet):
|
||||
current_password: str
|
||||
|
||||
@field_validator("current_password", mode="before")
|
||||
@classmethod
|
||||
def validate_current_password(cls, v: str) -> str:
|
||||
if isinstance(v, str) and v.strip() == "":
|
||||
raise ValueError("Current password is required")
|
||||
return v
|
||||
|
||||
|
||||
FIELD_LABELS: dict[str, str] = {
|
||||
"given_name": "Given name",
|
||||
"family_name": "Family name",
|
||||
"preferred_username": "Display name",
|
||||
"email": "Email",
|
||||
"phone_number": "Phone number",
|
||||
"picture": "Picture URL",
|
||||
"locale": "Locale",
|
||||
"username": "Username",
|
||||
"groups": "Groups",
|
||||
"password": "Password",
|
||||
"confirm": "Confirm password",
|
||||
"current_password": "Current password",
|
||||
}
|
||||
|
||||
|
||||
def format_validation_errors(exc: ValidationError) -> str:
|
||||
"""Format Pydantic ValidationError into user-friendly HTML."""
|
||||
messages: list[str] = []
|
||||
for error in exc.errors():
|
||||
field = str(error["loc"][-1]) if error["loc"] else "input"
|
||||
label = FIELD_LABELS.get(field, field)
|
||||
msg = error["msg"]
|
||||
if error["type"] == "value_error":
|
||||
raw = msg.removeprefix("Value error, ")
|
||||
# If the message already starts with the label, don't duplicate it
|
||||
if raw.startswith(label):
|
||||
display_msg = raw
|
||||
else:
|
||||
display_msg = f"{label}: {raw}"
|
||||
else:
|
||||
display_msg = f"{label}: {msg}"
|
||||
messages.append(display_msg)
|
||||
|
||||
if len(messages) == 1:
|
||||
return f'<div role="alert">{messages[0]}</div>'
|
||||
|
||||
items = "".join(f"<li>{m}</li>" for m in messages)
|
||||
return f'<div role="alert"><ul>{items}</ul></div>'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue