UMBRA/backend/app/schemas/person.py
Kyle Pope 1b78dadf75 Fix bugs and action remaining QA suggestions
Bugs fixed:
- formatUpdatedAt treats naive UTC timestamps as UTC (append Z before parsing)
- PersonForm/LocationForm X button now inline with star toggle, matching panel style
- LocationForm contact placeholder changed from +44 to +61

QA suggestions actioned:
- CategoryAutocomplete: replace blur setTimeout with onPointerDown preventDefault
- CategoryFilterBar: replace hardcoded 600px maxWidth with 100vw
- Location "other" category shows dash instead of styled badge
- Delete dead legacy constants files (people/constants.ts, locations/constants.ts)
- EntityTable rows: add tabIndex, Enter/Space keyboard navigation, focus ring
- Replace Record<string, unknown> casts with typed keyof accessors
- Add email validation (field_validator) to Person and Location schemas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:46:38 +08:00

84 lines
2.6 KiB
Python

import re
from pydantic import BaseModel, ConfigDict, model_validator, field_validator
from datetime import datetime, date
from typing import Optional
_EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
class PersonCreate(BaseModel):
name: Optional[str] = None # legacy fallback — auto-split into first/last if provided alone
first_name: Optional[str] = None
last_name: Optional[str] = None
nickname: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
mobile: Optional[str] = None
address: Optional[str] = None
birthday: Optional[date] = None
category: Optional[str] = None
is_favourite: bool = False
company: Optional[str] = None
job_title: Optional[str] = None
notes: Optional[str] = None
@model_validator(mode='after')
def require_some_name(self) -> 'PersonCreate':
if not any([self.name, self.first_name, self.last_name, self.nickname]):
raise ValueError('At least one name field is required')
return self
@field_validator('email')
@classmethod
def validate_email(cls, v: str | None) -> str | None:
if v and not _EMAIL_RE.match(v):
raise ValueError('Invalid email address')
return v
class PersonUpdate(BaseModel):
# name is intentionally omitted — always computed from first/last/nickname
first_name: Optional[str] = None
last_name: Optional[str] = None
nickname: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
mobile: Optional[str] = None
address: Optional[str] = None
birthday: Optional[date] = None
category: Optional[str] = None
is_favourite: Optional[bool] = None
company: Optional[str] = None
job_title: Optional[str] = None
notes: Optional[str] = None
@field_validator('email')
@classmethod
def validate_email(cls, v: str | None) -> str | None:
if v and not _EMAIL_RE.match(v):
raise ValueError('Invalid email address')
return v
class PersonResponse(BaseModel):
id: int
name: str
first_name: Optional[str]
last_name: Optional[str]
nickname: Optional[str]
email: Optional[str]
phone: Optional[str]
mobile: Optional[str]
address: Optional[str]
birthday: Optional[date]
category: Optional[str]
relationship: Optional[str] # deprecated — kept for legacy calendar/birthday compat, will be removed
is_favourite: bool
company: Optional[str]
job_title: Optional[str]
notes: Optional[str]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)