01: Initial commit with neo4j

This commit is contained in:
exolonConfidental
2026-02-02 23:45:28 +05:30
commit 61162e01fb
15 changed files with 709 additions and 0 deletions

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

View 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
View 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
View 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")

View 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"]
}

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

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

View 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