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

1"""Property schemas for API requests and responses.""" 

2 

3import json 

4from datetime import datetime 

5from typing import Any 

6 

7from pydantic import BaseModel, Field, model_validator 

8 

9 

10class PropertyListItem(BaseModel): 

11 """Property summary for list views.""" 

12 

13 id: int 

14 listing_id: str 

15 standard_status: str | None = None 

16 list_price: float | None = None 

17 

18 # Semantic search score (only populated for semantic search results) 

19 similarity_score: float | None = None 

20 

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 

30 

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 

37 

38 # Media 

39 primary_photo_url: str | None = None 

40 photo_count: int = 0 

41 

42 class Config: 

43 from_attributes = True 

44 

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() 

56 

57 

58class PropertyDetail(PropertyListItem): 

59 """Full property details.""" 

60 

61 listing_key: str | None = None 

62 mls_status: str | None = None 

63 list_date: datetime | None = None 

64 modification_timestamp: datetime | None = None 

65 

66 # Price details 

67 original_list_price: float | None = None 

68 close_price: float | None = None 

69 

70 # Location details 

71 unit_number: str | None = None 

72 county_or_parish: str | None = None 

73 country: str = "US" 

74 

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 

82 

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] = [] 

90 

91 # Descriptions 

92 public_remarks: str | None = None 

93 

94 # Photos array (full URLs) 

95 photos: list[str] = [] 

96 

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 

119 

120 # Agent info 

121 list_agent_mls_id: str | None = None 

122 list_office_mls_id: str | None = None 

123 

124 

125class PropertySearchParams(BaseModel): 

126 """Search parameters for property queries.""" 

127 

128 # Location filters 

129 city: str | None = None 

130 state_or_province: str | None = None 

131 postal_code: str | None = None 

132 

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 

138 

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) 

143 

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 

155 

156 # Status 

157 status: str | None = Field(default="Active") 

158 

159 # Pagination 

160 page: int = Field(default=1, ge=1) 

161 page_size: int = Field(default=20, ge=1, le=100) 

162 

163 # Sorting 

164 sort_by: str = Field(default="list_date") 

165 sort_order: str = Field(default="desc") 

166 

167 

168class SemanticSearchParams(BaseModel): 

169 """Parameters for semantic/natural language search.""" 

170 

171 query: str = Field(..., min_length=3, max_length=500) 

172 

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 

178 

179 # Results 

180 limit: int = Field(default=20, ge=1, le=50) 

181 

182 

183class PropertySearchResponse(BaseModel): 

184 """Paginated search response.""" 

185 

186 items: list[PropertyListItem] 

187 total: int 

188 page: int 

189 page_size: int 

190 total_pages: int 

191 

192 # Search metadata 

193 query_time_ms: float | None = None 

194 search_type: str = "filter" # "filter", "semantic", "geo"