Coverage for src / idx_api / schemas / property.py: 88%
113 statements
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 11:09 -0700
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 11:09 -0700
1"""Property schemas for API requests and responses."""
3import json
4from datetime import datetime
5from typing import Any
7from pydantic import BaseModel, Field, model_validator
10class PropertyListItem(BaseModel):
11 """Property summary for list views."""
13 id: int
14 listing_id: str
15 standard_status: str | None = None
16 list_price: float | None = None
18 # Semantic search score (only populated for semantic search results)
19 similarity_score: float | None = None
21 # Location
22 street_number: str | None = None
23 street_name: str | None = None
24 street_suffix: str | None = None
25 city: str | None = None
26 state_or_province: str | None = None
27 postal_code: str | None = None
28 latitude: float | None = None
29 longitude: float | None = None
31 # Key details
32 property_type: str | None = None
33 bedrooms_total: int | None = None
34 bathrooms_total_integer: int | None = None
35 living_area: float | None = None
36 year_built: int | None = None
38 # Media
39 primary_photo_url: str | None = None
40 photo_count: int = 0
42 class Config:
43 from_attributes = True
45 @property
46 def full_address(self) -> str:
47 """Formatted full address."""
48 parts = [
49 self.street_number,
50 self.street_name,
51 self.street_suffix,
52 ]
53 street = " ".join(p for p in parts if p)
54 city_state = f"{self.city}, {self.state_or_province}" if self.city else ""
55 return f"{street}, {city_state} {self.postal_code or ''}".strip()
58class PropertyDetail(PropertyListItem):
59 """Full property details."""
61 listing_key: str | None = None
62 mls_status: str | None = None
63 list_date: datetime | None = None
64 modification_timestamp: datetime | None = None
66 # Price details
67 original_list_price: float | None = None
68 close_price: float | None = None
70 # Location details
71 unit_number: str | None = None
72 county_or_parish: str | None = None
73 country: str = "US"
75 # Property details
76 property_sub_type: str | None = None
77 bathrooms_full: int | None = None
78 bathrooms_half: int | None = None
79 lot_size_area: float | None = None
80 stories: int | None = None
81 garage_spaces: int | None = None
83 # Features
84 appliances: list[str] = []
85 interior_features: list[str] = []
86 exterior_features: list[str] = []
87 heating: list[str] = []
88 cooling: list[str] = []
89 architectural_style: list[str] = []
91 # Descriptions
92 public_remarks: str | None = None
94 # Photos array (full URLs)
95 photos: list[str] = []
97 @model_validator(mode="before")
98 @classmethod
99 def parse_json_arrays(cls, data: Any) -> Any:
100 """Parse JSON string arrays from SQLite into Python lists."""
101 if isinstance(data, dict):
102 json_fields = [
103 "appliances",
104 "interior_features",
105 "exterior_features",
106 "heating",
107 "cooling",
108 "architectural_style",
109 "photos",
110 ]
111 for field in json_fields:
112 value = data.get(field)
113 if isinstance(value, str):
114 try:
115 data[field] = json.loads(value)
116 except json.JSONDecodeError:
117 data[field] = []
118 return data
120 # Agent info
121 list_agent_mls_id: str | None = None
122 list_office_mls_id: str | None = None
125class PropertySearchParams(BaseModel):
126 """Search parameters for property queries."""
128 # Location filters
129 city: str | None = None
130 state_or_province: str | None = None
131 postal_code: str | None = None
133 # Bounding box (for map search)
134 min_lat: float | None = None
135 max_lat: float | None = None
136 min_lng: float | None = None
137 max_lng: float | None = None
139 # Radius search
140 center_lat: float | None = None
141 center_lng: float | None = None
142 radius_miles: float | None = Field(default=None, le=50)
144 # Property filters
145 property_type: str | None = None
146 min_price: float | None = None
147 max_price: float | None = None
148 min_beds: int | None = None
149 max_beds: int | None = None
150 min_baths: int | None = None
151 min_sqft: float | None = None
152 max_sqft: float | None = None
153 min_year: int | None = None
154 max_year: int | None = None
156 # Status
157 status: str | None = Field(default="Active")
159 # Pagination
160 page: int = Field(default=1, ge=1)
161 page_size: int = Field(default=20, ge=1, le=100)
163 # Sorting
164 sort_by: str = Field(default="list_date")
165 sort_order: str = Field(default="desc")
168class SemanticSearchParams(BaseModel):
169 """Parameters for semantic/natural language search."""
171 query: str = Field(..., min_length=3, max_length=500)
173 # Optional filters to combine with semantic search
174 city: str | None = None
175 min_price: float | None = None
176 max_price: float | None = None
177 min_beds: int | None = None
179 # Results
180 limit: int = Field(default=20, ge=1, le=50)
183class PropertySearchResponse(BaseModel):
184 """Paginated search response."""
186 items: list[PropertyListItem]
187 total: int
188 page: int
189 page_size: int
190 total_pages: int
192 # Search metadata
193 query_time_ms: float | None = None
194 search_type: str = "filter" # "filter", "semantic", "geo"