fix(models): coerce Contact.birthday + relax Table.owner_display_name

Two upstream Pydantic ValidationErrors that took down whole list responses.

#704: Contact.birthday is declared str, but vobject parses BDAY as a
datetime.date — any contact with a populated BDAY broke nc_contacts_list_contacts
entirely. Add a field_validator(mode="before") that coerces date / datetime
to ISO strings. Strings and None pass through unchanged. Defense in depth:
existing call sites already coerce, but the model is now correct on its own
so any future code path that constructs Contact from raw vobject output
stays safe.

#728: Tables app v2.0.1 stopped emitting owner_display_name on the top-level
table payload (still present inside views via get_schema), so list_tables
failed for every user with a Pydantic ValidationError. Make the field
Optional[str] = None — captures the value when present, won't blow up when
missing.

Six new direct-construction unit tests in tests/unit/test_response_models.py
pin both fixes (date / datetime / str / None for birthday; with / without
owner_display_name for Table) so the regressions can't recur silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-04-26 17:14:08 +02:00
co-authored by Claude Opus 4.7
parent 4e669781ee
commit 06f895f5b9
3 changed files with 98 additions and 3 deletions
+17 -1
View File
@@ -1,8 +1,9 @@
"""Pydantic models for Contacts app responses."""
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from .base import BaseResponse, StatusResponse
@@ -57,6 +58,21 @@ class Contact(BaseModel):
)
etag: Optional[str] = Field(None, description="ETag for versioning")
@field_validator("birthday", mode="before")
@classmethod
def _coerce_birthday(cls, value: Any) -> Any:
"""Accept ``date``/``datetime`` from vobject and serialise to ISO string.
vobject parses BDAY into ``datetime.date`` (or ``datetime.datetime``);
the Pydantic model stores it as ``str``. Without this coercion any
contact with a populated BDAY blew up the entire ``list_contacts``
response (#704, #672). Strings and ``None`` pass through unchanged so
callers can still construct contacts with raw ISO input.
"""
if isinstance(value, (date, datetime)):
return value.isoformat()
return value
@property
def primary_email(self) -> Optional[str]:
"""Get the primary email address."""
+6 -1
View File
@@ -72,7 +72,12 @@ class Table(BaseModel):
title: str = Field(description="Table title")
emoji: Optional[str] = Field(None, description="Table emoji")
ownership: str = Field(description="Table ownership")
owner_display_name: str = Field(description="Display name of table owner")
# Tables app v2.0.1 stopped emitting owner_display_name at the top level
# (still present inside views via get_schema). Optional avoids a 100% failure
# rate on list_tables — see #728.
owner_display_name: Optional[str] = Field(
None, description="Display name of table owner"
)
created_by: Optional[str] = Field(None, description="User who created the table")
created_at: Optional[str] = Field(None, description="Table creation timestamp")
last_edit_by: Optional[str] = Field(