01: Initial commit with neo4j
This commit is contained in:
35
app/api/v1/property_routes.py
Normal file
35
app/api/v1/property_routes.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.core.neo4j import get_driver
|
||||
from app.services.property_service import get_nearest_asset
|
||||
from app.schemas.location_request import NearestAssetRequest
|
||||
from app.schemas.property_owner_response import NearestAssetResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/properties", tags=["Geo Search"])
|
||||
|
||||
@router.post("/nearest", response_model=NearestAssetResponse)
|
||||
def nearest_property(payload: NearestAssetRequest):
|
||||
|
||||
logger.info(f"Incoming geo request lat={payload.lat}, lng={payload.lng}")
|
||||
|
||||
try:
|
||||
driver = get_driver()
|
||||
|
||||
result = get_nearest_asset(driver, payload.lat, payload.lng)
|
||||
|
||||
logger.info("Nearest asset query successful")
|
||||
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"No asset found: {str(e)}")
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Unexpected server error")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Internal Server Error"
|
||||
)
|
||||
5
app/api/v1/router.py
Normal file
5
app/api/v1/router.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1.property_routes import router as property_routes
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(property_routes)
|
||||
12
app/core/config.py
Normal file
12
app/core/config.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
NEO4J_URI: str
|
||||
NEO4J_USER: str
|
||||
NEO4J_PASSWORD: str
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = Settings()
|
||||
43
app/core/logging_config.py
Normal file
43
app/core/logging_config.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import logging
|
||||
from logging.config import dictConfig
|
||||
|
||||
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
|
||||
"formatters": {
|
||||
"default": {
|
||||
"format": "[%(asctime)s] %(levelname)s | %(name)s | %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S"
|
||||
},
|
||||
},
|
||||
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "default",
|
||||
},
|
||||
},
|
||||
|
||||
"root": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"]
|
||||
},
|
||||
|
||||
"loggers": {
|
||||
"uvicorn.error": {
|
||||
"level": "INFO"
|
||||
},
|
||||
"uvicorn.access": {
|
||||
"level": "INFO"
|
||||
},
|
||||
"neo4j": {
|
||||
"level": "WARNING"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def setup_logging():
|
||||
dictConfig(LOGGING_CONFIG)
|
||||
10
app/core/neo4j.py
Normal file
10
app/core/neo4j.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from neo4j import GraphDatabase
|
||||
from app.core.config import settings
|
||||
|
||||
driver = GraphDatabase.driver(
|
||||
settings.NEO4J_URI,
|
||||
auth=(settings.NEO4J_USER, settings.NEO4J_PASSWORD)
|
||||
)
|
||||
|
||||
def get_driver():
|
||||
return driver
|
||||
12
app/main.py
Normal file
12
app/main.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from fastapi import FastAPI
|
||||
from app.api.v1.router import api_router
|
||||
from app.core.logging_config import setup_logging
|
||||
|
||||
setup_logging()
|
||||
|
||||
app = FastAPI(
|
||||
title="Property CRM Graph API",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
110
app/repositories/property_repo.py
Normal file
110
app/repositories/property_repo.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from neo4j import Driver
|
||||
|
||||
QUERY_NEAREST = """
|
||||
WITH point({latitude: $lat, longitude: $lng}) AS userLocation
|
||||
|
||||
MATCH (o:Owner)-[:OWNS]->(p:Property)-[:LOCATED_AT]->(l:Location)
|
||||
WHERE l.location IS NOT NULL
|
||||
|
||||
WITH o, p,
|
||||
point.distance(l.location, userLocation) AS distanceMeters
|
||||
|
||||
ORDER BY distanceMeters ASC
|
||||
LIMIT 1
|
||||
|
||||
RETURN
|
||||
|
||||
// Owner Projection (API Safe)
|
||||
{
|
||||
owner_id: o.owner_id,
|
||||
full_name: o.full_name,
|
||||
email: o.email,
|
||||
phone: o.phone,
|
||||
entity_type: o.entity_type,
|
||||
mailing_address: o.mailing_address,
|
||||
credit_score_est: o.credit_score_est,
|
||||
income_bracket: o.income_bracket,
|
||||
net_worth_est: o.net_worth_est,
|
||||
portfolio_size: o.portfolio_size,
|
||||
min_price_expectation: o.min_price_expectation,
|
||||
preferred_close_days: o.preferred_close_days,
|
||||
urgency_score: o.urgency_score,
|
||||
is_absentee: o.is_absentee,
|
||||
willing_to_sell: o.willing_to_sell,
|
||||
willing_to_partner: o.willing_to_partner
|
||||
} AS owner,
|
||||
|
||||
// Property Projection (API Safe)
|
||||
{
|
||||
property_id: p.property_id,
|
||||
internal_asset_code: p.internal_asset_code,
|
||||
structure_type: p.structure_type,
|
||||
listing_status: p.listing_status,
|
||||
occupancy_status: p.occupancy_status,
|
||||
property_grade: p.property_grade,
|
||||
energy_rating: p.energy_rating,
|
||||
|
||||
year_built: p.year_built,
|
||||
floors_count: p.floors_count,
|
||||
bedrooms: p.bedrooms,
|
||||
bathrooms: p.bathrooms,
|
||||
total_built_sqft: p.total_built_sqft,
|
||||
lot_size_sqft: p.lot_size_sqft,
|
||||
garage_spaces: p.garage_spaces,
|
||||
|
||||
purchase_price: p.purchase_price,
|
||||
expected_sale_price: p.expected_sale_price,
|
||||
market_value_est: p.market_value_est,
|
||||
current_rent: p.current_rent,
|
||||
rental_yield_percent: p.rental_yield_percent,
|
||||
vacancy_days: p.vacancy_days,
|
||||
tenant_present: p.tenant_present,
|
||||
|
||||
exterior_condition: p.exterior_condition,
|
||||
foundation_type: p.foundation_type,
|
||||
roof_type: p.roof_type,
|
||||
roof_material: p.roof_material,
|
||||
roof_condition: p.roof_condition,
|
||||
roof_pitch: p.roof_pitch,
|
||||
roof_age_years: p.roof_age_years,
|
||||
siding_material: p.siding_material,
|
||||
gutter_status: p.gutter_status,
|
||||
hvac_type: p.hvac_type,
|
||||
electric_type: p.electric_type,
|
||||
plumbing_type: p.plumbing_type,
|
||||
solar_installed: p.solar_installed,
|
||||
|
||||
mold_risk_level: p.mold_risk_level,
|
||||
termite_risk_level: p.termite_risk_level,
|
||||
structural_risk_level: p.structural_risk_level,
|
||||
fire_damage_flag: p.fire_damage_flag,
|
||||
water_damage_flag: p.water_damage_flag,
|
||||
|
||||
created_at: toString(p.created_at),
|
||||
updated_at: toString(p.updated_at)
|
||||
|
||||
} AS property,
|
||||
|
||||
distanceMeters
|
||||
"""
|
||||
|
||||
|
||||
def find_nearest_property_and_owner(driver: Driver, lat: float, lng: float):
|
||||
|
||||
with driver.session(database="neo4j") as session:
|
||||
result = session.run(
|
||||
QUERY_NEAREST,
|
||||
lat=lat,
|
||||
lng=lng
|
||||
)
|
||||
|
||||
record = result.single()
|
||||
|
||||
if not record:
|
||||
return None
|
||||
|
||||
return {
|
||||
"owner": record["owner"],
|
||||
"property": record["property"],
|
||||
"distanceMeters": record["distanceMeters"]
|
||||
}
|
||||
6
app/schemas/location_request.py
Normal file
6
app/schemas/location_request.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class NearestAssetRequest(BaseModel):
|
||||
|
||||
lat: float = Field(..., ge=-90, le=90, example=34.01544)
|
||||
lng: float = Field(..., ge=-180, le=180, example=-118.2201)
|
||||
172
app/schemas/property_owner_response.py
Normal file
172
app/schemas/property_owner_response.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Owner Response Schema
|
||||
# -----------------------------
|
||||
|
||||
class OwnerResponse(BaseModel):
|
||||
|
||||
owner_id: str = Field(..., example="OWN_9001")
|
||||
|
||||
full_name: str = Field(..., example="Michael Anderson")
|
||||
|
||||
email: Optional[str] = Field(None, example="michael.anderson@email.com")
|
||||
|
||||
phone: Optional[str] = Field(None, example="+14155552671")
|
||||
|
||||
entity_type: str = Field(..., example="Individual")
|
||||
|
||||
mailing_address: Optional[str] = Field(
|
||||
None,
|
||||
example="742 Evergreen Terrace, San Diego, CA 92101"
|
||||
)
|
||||
|
||||
credit_score_est: Optional[int] = Field(None, ge=300, le=850, example=780)
|
||||
|
||||
income_bracket: Optional[str] = Field(None, example="$100k+")
|
||||
|
||||
net_worth_est: Optional[int] = Field(None, example=1250000)
|
||||
|
||||
portfolio_size: Optional[int] = Field(None, example=3)
|
||||
|
||||
min_price_expectation: Optional[int] = Field(None, example=650000)
|
||||
|
||||
preferred_close_days: Optional[int] = Field(None, example=30)
|
||||
|
||||
urgency_score: Optional[int] = Field(None, ge=0, le=100, example=72)
|
||||
|
||||
is_absentee: Optional[bool] = Field(None, example=True)
|
||||
|
||||
willing_to_sell: Optional[bool] = Field(None, example=True)
|
||||
|
||||
willing_to_partner: Optional[bool] = Field(None, example=False)
|
||||
|
||||
# ⚠ Recommended: Do not expose in public APIs
|
||||
tax_id_hash: Optional[str] = Field(
|
||||
None,
|
||||
example="HASHX9921",
|
||||
description="Hashed tax identifier (masked)"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Property Response Schema
|
||||
# -----------------------------
|
||||
|
||||
class PropertyResponse(BaseModel):
|
||||
|
||||
property_id: str = Field(..., example="PROP_1001")
|
||||
|
||||
internal_asset_code: str = Field(..., example="ASSET-LA-7782")
|
||||
|
||||
structure_type: str = Field(..., example="SingleFamily")
|
||||
|
||||
listing_status: str = Field(..., example="ForSale")
|
||||
|
||||
occupancy_status: str = Field(..., example="OwnerOccupied")
|
||||
|
||||
property_grade: str = Field(..., example="A")
|
||||
|
||||
energy_rating: Optional[str] = Field(None, example="B+")
|
||||
|
||||
year_built: int = Field(..., example=2006)
|
||||
|
||||
floors_count: int = Field(..., example=2)
|
||||
|
||||
bedrooms: int = Field(..., example=4)
|
||||
|
||||
bathrooms: int = Field(..., example=3)
|
||||
|
||||
total_built_sqft: int = Field(..., example=2480)
|
||||
|
||||
lot_size_sqft: Optional[int] = Field(None, example=6200)
|
||||
|
||||
garage_spaces: Optional[int] = Field(None, example=2)
|
||||
|
||||
purchase_price: Optional[int] = Field(None, example=590000)
|
||||
|
||||
expected_sale_price: Optional[int] = Field(None, example=740000)
|
||||
|
||||
market_value_est: Optional[int] = Field(None, example=685000)
|
||||
|
||||
current_rent: Optional[int] = Field(None, example=0)
|
||||
|
||||
rental_yield_percent: Optional[float] = Field(None, example=6.2)
|
||||
|
||||
vacancy_days: Optional[int] = Field(None, example=0)
|
||||
|
||||
tenant_present: Optional[bool] = Field(None, example=False)
|
||||
|
||||
# Structural / Condition
|
||||
|
||||
exterior_condition: Optional[str] = Field(None, example="Good")
|
||||
|
||||
foundation_type: Optional[str] = Field(None, example="Slab")
|
||||
|
||||
roof_type: Optional[str] = Field(None, example="Gable")
|
||||
|
||||
roof_material: Optional[str] = Field(None, example="Asphalt Shingle")
|
||||
|
||||
roof_condition: Optional[str] = Field(None, example="Good")
|
||||
|
||||
roof_pitch: Optional[str] = Field(None, example="Medium")
|
||||
|
||||
roof_age_years: Optional[int] = Field(None, example=7)
|
||||
|
||||
siding_material: Optional[str] = Field(None, example="Hardie Board")
|
||||
|
||||
gutter_status: Optional[str] = Field(None, example="Functional")
|
||||
|
||||
hvac_type: Optional[str] = Field(None, example="Central")
|
||||
|
||||
electric_type: Optional[str] = Field(None, example="Copper")
|
||||
|
||||
plumbing_type: Optional[str] = Field(None, example="PEX")
|
||||
|
||||
solar_installed: Optional[bool] = Field(None, example=False)
|
||||
|
||||
# Risk Indicators
|
||||
|
||||
mold_risk_level: Optional[str] = Field(None, example="Low")
|
||||
|
||||
termite_risk_level: Optional[str] = Field(None, example="Low")
|
||||
|
||||
structural_risk_level: Optional[str] = Field(None, example="Low")
|
||||
|
||||
fire_damage_flag: Optional[bool] = Field(None, example=False)
|
||||
|
||||
water_damage_flag: Optional[bool] = Field(None, example=False)
|
||||
|
||||
# Timestamps
|
||||
|
||||
created_at: Optional[datetime] = Field(
|
||||
None,
|
||||
example="2026-01-29T16:43:44.285Z"
|
||||
)
|
||||
|
||||
updated_at: Optional[datetime] = Field(
|
||||
None,
|
||||
example="2026-01-29T16:43:44.285Z"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Final Nearest Asset Response
|
||||
# -----------------------------
|
||||
|
||||
class NearestAssetResponse(BaseModel):
|
||||
|
||||
owner: OwnerResponse
|
||||
|
||||
property: PropertyResponse
|
||||
|
||||
distanceMeters: float = Field(..., example=342.55)
|
||||
10
app/services/property_service.py
Normal file
10
app/services/property_service.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from app.repositories.property_repo import find_nearest_property_and_owner
|
||||
|
||||
def get_nearest_asset(driver, lat: float, lng: float):
|
||||
|
||||
data = find_nearest_property_and_owner(driver, lat, lng)
|
||||
|
||||
if not data:
|
||||
raise ValueError("No nearby property found")
|
||||
|
||||
return data
|
||||
Reference in New Issue
Block a user