Files
nadlan-mcp/nadlan_mcp/govmap/models.py
T
Nitzan Pomerantz ff8c7e6509 Complete Phase 4.1 test suite updates - all 174 tests passing
Fixed all remaining test failures after Pydantic v2 migration:

Core fixes:
- Date handling: Convert date objects to ISO strings across 4 files
- Model serialization: Use model_dump(mode='json') for JSON compatibility
- Optional fields: Made time_period_months Optional[int] in models
- Dict access: Replace .get() with getattr() for dynamic attributes

Test updates:
- Updated 50+ test fixtures from dicts to Deal models
- Fixed date-based tests to use recent dates for time filtering
- Added missing imports (CoordinatePoint, MarketActivityScore, DealStatistics)
- Updated assertions from dict keys to model attributes (snake_case)

Files modified:
- nadlan_mcp/govmap/market_analysis.py
- nadlan_mcp/govmap/statistics.py
- nadlan_mcp/govmap/models.py
- nadlan_mcp/fastmcp_server.py
- tests/test_govmap_client.py
- tests/test_fastmcp_tools.py
- .cursor/plans/TEST-UPDATE-STATUS.md (comprehensive documentation)

Result: 174/174 tests passing (100%) 

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 23:55:42 +02:00

346 lines
14 KiB
Python

"""
Pydantic models for Govmap API data structures.
This module defines type-safe models for all data structures used in the
Israeli real estate MCP system. Models provide validation, serialization,
and type safety throughout the codebase.
"""
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator, computed_field, ConfigDict
class CoordinatePoint(BaseModel):
"""
ITM (Israeli Transverse Mercator) coordinate point.
Attributes:
longitude: X coordinate in ITM projection (meters)
latitude: Y coordinate in ITM projection (meters)
"""
longitude: float = Field(..., description="X coordinate in ITM projection (meters)")
latitude: float = Field(..., description="Y coordinate in ITM projection (meters)")
model_config = ConfigDict(frozen=True) # Immutable coordinates
class Address(BaseModel):
"""
Israeli address with coordinates and metadata.
Attributes:
text: Full address text (Hebrew or English)
id: Unique identifier for the address
type: Address type (e.g., 'address', 'street', 'city')
score: Relevance score from autocomplete
coordinates: ITM coordinate point
"""
text: str = Field(..., description="Full address text")
id: str = Field(..., description="Unique address identifier")
type: str = Field(..., description="Address type")
score: float = Field(default=0, description="Relevance score")
coordinates: Optional[CoordinatePoint] = Field(default=None, description="ITM coordinates")
class AutocompleteResult(BaseModel):
"""
Single result from address autocomplete API.
Attributes:
text: Display text for the address
id: Unique identifier
type: Result type (address, street, city, etc.)
score: Relevance score
coordinates: Optional coordinate point
shape: Original WKT shape string from API
"""
text: str
id: str
type: str
score: float = 0
coordinates: Optional[CoordinatePoint] = None
shape: Optional[str] = None # Original WKT POINT string from API
class AutocompleteResponse(BaseModel):
"""
Response from address autocomplete API.
Attributes:
results_count: Number of results returned
results: List of autocomplete results
"""
results_count: int = Field(alias="resultsCount")
results: List[AutocompleteResult] = Field(default_factory=list)
model_config = ConfigDict(populate_by_name=True)
class Deal(BaseModel):
"""
Real estate deal from Govmap API.
Represents a single property transaction with all available details.
Most fields are optional as the API doesn't guarantee all data.
Attributes:
objectid: Unique deal identifier
deal_amount: Transaction amount in NIS
deal_date: Date of transaction
asset_area: Property area in square meters
settlement_name_heb: City/settlement name in Hebrew
property_type_description: Type of property (דירה, בית, etc.)
neighborhood: Neighborhood name
street_name: Street name
house_number: House number
floor: Floor description (may be Hebrew text)
floor_number: Parsed numeric floor number
rooms: Number of rooms
priority: Priority level for sorting (0=same building, 1=street, 2=neighborhood)
shape: WKT geometry (usually MULTIPOLYGON)
source_polygon_id: Source polygon ID
sourceorder: Source ordering
"""
# Required fields
objectid: int = Field(..., description="Unique deal identifier")
deal_amount: float = Field(..., alias="dealAmount", description="Transaction amount in NIS")
deal_date: date = Field(..., alias="dealDate", description="Transaction date")
# Common optional fields
asset_area: Optional[float] = Field(None, alias="assetArea", description="Property area in sqm")
settlement_name_heb: Optional[str] = Field(None, alias="settlementNameHeb", description="City name in Hebrew")
property_type_description: Optional[str] = Field(None, alias="propertyTypeDescription", description="Property type")
neighborhood: Optional[str] = Field(None, description="Neighborhood name")
street_name: Optional[str] = Field(None, alias="streetName", description="Street name")
house_number: Optional[str] = Field(None, alias="houseNumber", description="House number")
# Floor information
floor: Optional[str] = Field(None, description="Floor description (may be Hebrew)")
floor_number: Optional[int] = Field(None, alias="floorNumber", description="Numeric floor number")
# Additional details
rooms: Optional[float] = Field(None, description="Number of rooms")
# Priority and metadata (added by our system, not from API)
priority: Optional[int] = Field(None, description="Priority for sorting (0=same building, 1=street, 2=neighborhood)")
# Geometry and internal fields (often not useful for analysis)
shape: Optional[str] = Field(None, description="WKT geometry")
source_polygon_id: Optional[str] = Field(None, alias="sourcePolygonId", description="Source polygon ID")
sourceorder: Optional[int] = Field(None, description="Source ordering")
model_config = ConfigDict(
populate_by_name=True, # Allow both alias and field name
extra='allow' # Allow extra fields from API that we don't model
)
@computed_field
@property
def price_per_sqm(self) -> Optional[float]:
"""
Calculated price per square meter.
Returns:
Price per sqm in NIS, or None if area is missing/zero
"""
if self.asset_area and self.asset_area > 0:
return round(self.deal_amount / self.asset_area, 2)
return None
@field_validator('deal_date', mode='before')
@classmethod
def parse_deal_date(cls, v: Any) -> date:
"""Parse deal date string into a date object."""
if isinstance(v, date):
return v
if isinstance(v, datetime):
return v.date()
if isinstance(v, str):
# Handle ISO format with optional time and timezone
if 'T' in v:
v = v.split('T')[0]
try:
return date.fromisoformat(v)
except ValueError:
raise ValueError(f"Invalid date format: {v}")
raise TypeError(f"Unsupported type for date parsing: {type(v)}")
class DealStatistics(BaseModel):
"""
Statistical analysis of real estate deals.
Attributes:
total_deals: Total number of deals analyzed
price_statistics: Statistics for deal prices
area_statistics: Statistics for property areas
price_per_sqm_statistics: Statistics for price per sqm
property_type_distribution: Count by property type
date_range: Earliest and latest deal dates
"""
total_deals: int = Field(..., description="Total number of deals analyzed")
# Price statistics
price_statistics: Dict[str, float] = Field(
default_factory=dict,
description="Price stats (mean, median, std_dev, min, max, percentiles)"
)
# Area statistics
area_statistics: Dict[str, float] = Field(
default_factory=dict,
description="Area stats (mean, median, std_dev, min, max)"
)
# Price per sqm statistics
price_per_sqm_statistics: Dict[str, float] = Field(
default_factory=dict,
description="Price/sqm stats (mean, median, std_dev, min, max)"
)
# Distribution by property type
property_type_distribution: Dict[str, int] = Field(
default_factory=dict,
description="Count of deals by property type"
)
# Date range
date_range: Optional[Dict[str, str]] = Field(
None,
description="Earliest and latest deal dates"
)
class MarketActivityScore(BaseModel):
"""
Market activity scoring metrics.
Attributes:
activity_score: Overall activity score (0-100)
total_deals: Total number of deals in period
deals_per_month: Average deals per month
trend: Market trend (increasing, stable, decreasing)
time_period_months: Analysis period in months
monthly_distribution: Deals per month breakdown
"""
activity_score: float = Field(..., description="Overall activity score (0-100)", ge=0, le=100)
total_deals: int = Field(..., description="Total deals in period")
deals_per_month: float = Field(..., description="Average deals per month")
trend: str = Field(..., description="Market trend (increasing, stable, decreasing)")
time_period_months: Optional[int] = Field(None, description="Analysis period in months (None = all data)")
monthly_distribution: Dict[str, int] = Field(
default_factory=dict,
description="Deals per month (YYYY-MM: count)"
)
class InvestmentAnalysis(BaseModel):
"""
Investment potential analysis metrics.
Attributes:
investment_score: Overall investment score (0-100)
price_trend: Price trend direction
price_appreciation_rate: Annualized price appreciation rate (%)
price_volatility: Price volatility score (0-100, lower is more stable)
market_stability: Stability rating description
avg_price_per_sqm: Average price per square meter
price_change_pct: Total price change percentage
total_deals: Total deals analyzed (sample size)
data_quality: Data quality assessment
"""
investment_score: float = Field(..., description="Overall investment score (0-100)", ge=0, le=100)
price_trend: str = Field(..., description="Price trend (increasing, stable, decreasing)")
price_appreciation_rate: float = Field(..., description="Annual price growth rate (%)")
price_volatility: float = Field(..., description="Price volatility score (0-100)", ge=0, le=100)
market_stability: str = Field(..., description="Market stability rating")
avg_price_per_sqm: float = Field(..., description="Average price per sqm")
price_change_pct: float = Field(..., description="Total price change percentage")
total_deals: int = Field(..., description="Total deals analyzed (sample size)")
data_quality: str = Field(..., description="Data quality (excellent, good, fair, limited)")
class LiquidityMetrics(BaseModel):
"""
Market liquidity metrics.
Attributes:
liquidity_score: Overall liquidity score (0-100, based on velocity)
total_deals: Total deals in period
time_period_months: Analysis period in months
avg_deals_per_month: Average deals per month
liquidity_rating: Market liquidity rating
trend_direction: Liquidity trend direction
"""
liquidity_score: float = Field(..., description="Overall liquidity score (0-100)", ge=0, le=100)
total_deals: int = Field(..., description="Total deals in period")
time_period_months: Optional[int] = Field(None, description="Analysis period in months (None = all data)")
avg_deals_per_month: float = Field(..., description="Average deals per month")
deal_velocity: float = Field(..., description="Deal velocity (deals per month)")
market_activity_level: str = Field(..., description="Activity level (very_high, high, moderate, low, very_low)")
class DealFilters(BaseModel):
"""
Filtering criteria for real estate deals.
All fields are optional - only specified filters are applied.
Attributes:
property_type: Filter by property type (דירה, בית, etc.)
min_rooms: Minimum number of rooms
max_rooms: Maximum number of rooms
min_price: Minimum deal amount (NIS)
max_price: Maximum deal amount (NIS)
min_area: Minimum asset area (sqm)
max_area: Maximum asset area (sqm)
min_floor: Minimum floor number
max_floor: Maximum floor number
"""
property_type: Optional[str] = Field(None, description="Property type filter")
min_rooms: Optional[float] = Field(None, description="Minimum rooms", ge=0)
max_rooms: Optional[float] = Field(None, description="Maximum rooms", ge=0)
min_price: Optional[float] = Field(None, description="Minimum price (NIS)", ge=0)
max_price: Optional[float] = Field(None, description="Maximum price (NIS)", ge=0)
min_area: Optional[float] = Field(None, description="Minimum area (sqm)", ge=0)
max_area: Optional[float] = Field(None, description="Maximum area (sqm)", ge=0)
min_floor: Optional[int] = Field(None, description="Minimum floor")
max_floor: Optional[int] = Field(None, description="Maximum floor")
@field_validator('max_rooms')
@classmethod
def validate_max_rooms(cls, v: Optional[float], info) -> Optional[float]:
"""Ensure max_rooms >= min_rooms if both specified."""
if v is not None and info.data.get('min_rooms') is not None:
if v < info.data['min_rooms']:
raise ValueError("max_rooms must be >= min_rooms")
return v
@field_validator('max_price')
@classmethod
def validate_max_price(cls, v: Optional[float], info) -> Optional[float]:
"""Ensure max_price >= min_price if both specified."""
if v is not None and info.data.get('min_price') is not None:
if v < info.data['min_price']:
raise ValueError("max_price must be >= min_price")
return v
@field_validator('max_area')
@classmethod
def validate_max_area(cls, v: Optional[float], info) -> Optional[float]:
"""Ensure max_area >= min_area if both specified."""
if v is not None and info.data.get('min_area') is not None:
if v < info.data['min_area']:
raise ValueError("max_area must be >= min_area")
return v
@field_validator('max_floor')
@classmethod
def validate_max_floor(cls, v: Optional[int], info) -> Optional[int]:
"""Ensure max_floor >= min_floor if both specified."""
if v is not None and info.data.get('min_floor') is not None:
if v < info.data['min_floor']:
raise ValueError("max_floor must be >= min_floor")
return v