Warnings fixed: - 3.1: _compute_display_name stale-data bug on all-names-clear - 3.3: Location getValue unsafe type cast replaced with typed helper - 3.5: Explicit updated_at timestamp refresh in locations router - 3.6: Drop deprecated relationship column (migration 021, model, schema, TS type) Suggestions fixed: - 4.1: CategoryAutocomplete keyboard navigation (ArrowUp/Down, Enter, Escape) - 4.2: Mobile detail panel backdrop click-to-close on both pages - 4.3: PersonCreate whitespace bypass in require_some_name validator - 4.5/4.6: Extract SortIcon, DataRow, SectionHeader from EntityTable render body - 4.8: PersonForm sends null instead of empty string for birthday - 4.10: Remove unnecessary executeDelete wrapper in EntityDetailPanel Also includes previously completed fixes from prior session: - 2.1: Remove Z suffix from naive timestamp in formatUpdatedAt - 3.2: Drag-then-click conflict prevention in SortableCategoryChip - 3.4: localStorage JSON shape validation in useCategoryOrder - 4.4: Category chip styling consistency (both pages use inline hsl styles) - 4.9: restrictToHorizontalAxis modifier on CategoryFilterBar drag Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
88 lines
2.7 KiB
Python
88 lines
2.7 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 and self.name.strip(),
|
|
self.first_name and self.first_name.strip(),
|
|
self.last_name and self.last_name.strip(),
|
|
self.nickname and self.nickname.strip(),
|
|
]):
|
|
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]
|
|
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)
|