endpoint setup
This commit is contained in:
40
app/api/v1/endpoints/map.py
Normal file
40
app/api/v1/endpoints/map.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_async_session
|
||||
from app.services.location_service import LocationService
|
||||
from app.schemas.map_schema import MapQueryResponse
|
||||
|
||||
router = APIRouter(prefix="/map", tags=["Map"])
|
||||
|
||||
|
||||
@router.get("/query", response_model=MapQueryResponse)
|
||||
async def query_nearby_locations(
|
||||
lat: float = Query(..., ge=-90, le=90),
|
||||
lon: float = Query(..., ge=-180, le=180),
|
||||
radius: int = Query(2000, gt=0, le=50000),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Returns all locations within a given radius from a lat/lon point.
|
||||
Uses PostGIS ST_DWithin (geography).
|
||||
"""
|
||||
|
||||
try:
|
||||
locations = await LocationService.get_nearby_locations(
|
||||
session=session,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
radius= radius
|
||||
)
|
||||
|
||||
return MapQueryResponse(
|
||||
total=len(locations),
|
||||
locations=locations
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to fetch nearby locations"
|
||||
)
|
||||
93
app/api/v1/endpoints/property.py
Normal file
93
app/api/v1/endpoints/property.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
from app.db.session import get_async_session
|
||||
from app.services.property_service import PropertyService
|
||||
from app.schemas.property_schema import PropertyResponse
|
||||
from app.schemas.full_entry_schema import FullEntryCreateRequest
|
||||
from app.schemas.property_update_request import PropertyOwnerUpdateRequest
|
||||
|
||||
router = APIRouter(prefix="/property", tags=["Property"])
|
||||
|
||||
|
||||
@router.get("/by-location/{location_id}", response_model=PropertyResponse)
|
||||
async def get_property_by_location(
|
||||
location_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Fetch property + owner using location_id.
|
||||
Called when agent clicks map box.
|
||||
"""
|
||||
|
||||
property_obj = await PropertyService.get_property_by_location_id(
|
||||
session=session,
|
||||
location_id=location_id
|
||||
)
|
||||
|
||||
if not property_obj:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Property not found for given location"
|
||||
)
|
||||
|
||||
return property_obj
|
||||
|
||||
@router.post("/create-full", response_model=PropertyResponse)
|
||||
async def create_full_entry(
|
||||
payload: FullEntryCreateRequest,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Creates Location + Owner + Property in a single transaction.
|
||||
Frontend cannot send IDs.
|
||||
"""
|
||||
|
||||
try:
|
||||
property_obj = await PropertyService.create_full_entry(
|
||||
session=session,
|
||||
location_data=payload.location,
|
||||
owner_data=payload.owner,
|
||||
property_data=payload.property,
|
||||
)
|
||||
|
||||
return property_obj
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to create property entry"
|
||||
)
|
||||
|
||||
@router.put("/{property_id}", response_model=PropertyResponse)
|
||||
async def update_property_and_owner(
|
||||
property_id: int = Path(..., gt=0),
|
||||
payload: PropertyOwnerUpdateRequest = ...,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Update Property + Owner details.
|
||||
Partial updates supported.
|
||||
"""
|
||||
|
||||
if not payload.owner and not payload.property:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No update data provided"
|
||||
)
|
||||
|
||||
property_obj = await PropertyService.update_property_and_owner(
|
||||
session=session,
|
||||
property_id=property_id,
|
||||
owner_data=payload.owner.dict(exclude_unset=True) if payload.owner else None,
|
||||
property_data=payload.property.dict(exclude_unset=True) if payload.property else None,
|
||||
)
|
||||
|
||||
if not property_obj:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Property not found"
|
||||
)
|
||||
|
||||
return property_obj
|
||||
@@ -1,35 +0,0 @@
|
||||
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"
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1.property_routes import router as property_routes
|
||||
from app.api.v1.endpoints.map import router as map_routes
|
||||
from app.api.v1.endpoints.property import router as property_routes
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(map_routes)
|
||||
api_router.include_router(property_routes)
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
NEO4J_URI: str
|
||||
NEO4J_USER: str
|
||||
NEO4J_PASSWORD: str
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = Settings()
|
||||
@@ -1,10 +0,0 @@
|
||||
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
|
||||
83
app/db/models/insurance_details.py
Normal file
83
app/db/models/insurance_details.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from datetime import date
|
||||
from sqlalchemy import (
|
||||
String,
|
||||
Integer,
|
||||
Boolean,
|
||||
Date,
|
||||
ForeignKey,
|
||||
Text
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.db.models.base import Base
|
||||
|
||||
|
||||
class InsuranceDetails(Base):
|
||||
|
||||
__tablename__ = "insurance_details"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
# 🔗 Relation with Property
|
||||
property_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("properties.id", ondelete="CASCADE"),
|
||||
index=True
|
||||
)
|
||||
|
||||
# -----------------------------
|
||||
# REQUIRED FIELDS (Your spec)
|
||||
# -----------------------------
|
||||
|
||||
insurance_company: Mapped[str] = mapped_column(String(150))
|
||||
|
||||
claim_number: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
|
||||
date_of_loss: Mapped[date] = mapped_column(Date)
|
||||
|
||||
adjuster_name: Mapped[str] = mapped_column(String(150), nullable=True)
|
||||
adjuster_phone: Mapped[str] = mapped_column(String(30), nullable=True)
|
||||
adjuster_email: Mapped[str] = mapped_column(String(150), nullable=True)
|
||||
|
||||
claim_filed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
claim_approved: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# -----------------------------
|
||||
# 🇺🇸 US Insurance Standard Fields
|
||||
# -----------------------------
|
||||
|
||||
policy_number: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||
|
||||
coverage_type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=True
|
||||
)
|
||||
# e.g. Homeowners, Flood, Fire, Liability, Windstorm
|
||||
|
||||
claim_type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=True
|
||||
)
|
||||
# e.g. Roof Damage, Water Damage, Fire, Storm, Theft
|
||||
|
||||
deductible_amount: Mapped[int] = mapped_column(nullable=True)
|
||||
|
||||
claim_amount: Mapped[int] = mapped_column(nullable=True)
|
||||
|
||||
approved_amount: Mapped[int] = mapped_column(nullable=True)
|
||||
|
||||
payment_status: Mapped[str] = mapped_column(
|
||||
String(50), nullable=True
|
||||
)
|
||||
# Pending / Paid / Denied / Under Review
|
||||
|
||||
date_claim_filed: Mapped[date] = mapped_column(Date, nullable=True)
|
||||
|
||||
date_claim_closed: Mapped[date] = mapped_column(Date, nullable=True)
|
||||
|
||||
insurance_agent_name: Mapped[str] = mapped_column(String(150), nullable=True)
|
||||
insurance_agent_phone: Mapped[str] = mapped_column(String(30), nullable=True)
|
||||
|
||||
notes: Mapped[str] = mapped_column(Text, nullable=True)
|
||||
|
||||
# -----------------------------
|
||||
# Relationship
|
||||
# -----------------------------
|
||||
|
||||
property = relationship("Property", back_populates="insurance_records")
|
||||
@@ -1,4 +1,5 @@
|
||||
from sqlalchemy import String, Integer, Float, BigInteger
|
||||
from geoalchemy2 import Geography
|
||||
from sqlalchemy import String, Integer, Float, BigInteger, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.db.models.base import Base
|
||||
@@ -7,6 +8,9 @@ from app.db.models.base import Base
|
||||
class Location(Base):
|
||||
|
||||
__tablename__ = "locations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("osm_type", "osm_id", name="uq_osm_location"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
|
||||
@@ -38,6 +42,10 @@ class Location(Base):
|
||||
bbox_lon_min: Mapped[float] = mapped_column(Float)
|
||||
bbox_lon_max: Mapped[float] = mapped_column(Float)
|
||||
|
||||
geom: Mapped[str] = mapped_column(
|
||||
Geography("POINT", srid=4326)
|
||||
)
|
||||
|
||||
property = relationship(
|
||||
"Property",
|
||||
back_populates="location",
|
||||
|
||||
@@ -63,3 +63,9 @@ class Property(Base):
|
||||
back_populates="property",
|
||||
uselist=False,
|
||||
)
|
||||
|
||||
insurance_records = relationship(
|
||||
"InsuranceDetails",
|
||||
back_populates="property",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
@@ -8,6 +8,6 @@ AsyncSessionLocal = async_sessionmaker(
|
||||
)
|
||||
|
||||
|
||||
async def get_db():
|
||||
async def get_async_session():
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
50
app/repositories/location_repo.py
Normal file
50
app/repositories/location_repo.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.models.location import Location
|
||||
from geoalchemy2.shape import from_shape
|
||||
from shapely.geometry import Point
|
||||
from geoalchemy2.functions import ST_DWithin
|
||||
|
||||
|
||||
class LocationRepository:
|
||||
|
||||
@staticmethod
|
||||
async def get_by_osm(session: AsyncSession, osm_type: str, osm_id: int):
|
||||
stmt = select(Location).where(
|
||||
Location.osm_type == osm_type,
|
||||
Location.osm_id == osm_id
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@staticmethod
|
||||
async def create(session, data):
|
||||
# For geography this is still correct
|
||||
geom = from_shape(Point(data["longitude"], data["latitude"]), srid=4326)
|
||||
|
||||
location = Location(
|
||||
**data,
|
||||
geom=geom
|
||||
)
|
||||
|
||||
session.add(location)
|
||||
await session.flush()
|
||||
return location
|
||||
|
||||
|
||||
@staticmethod
|
||||
async def find_within_radius(session, lat, lon, radius_m=2000):
|
||||
# Create a geography point instead of string
|
||||
point = from_shape(Point(lon, lat), srid=4326)
|
||||
|
||||
stmt = select(Location).where(
|
||||
ST_DWithin(
|
||||
Location.geom,
|
||||
point,
|
||||
radius_m
|
||||
)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
8
app/repositories/owner_repo.py
Normal file
8
app/repositories/owner_repo.py
Normal file
@@ -0,0 +1,8 @@
|
||||
class OwnerRepository:
|
||||
|
||||
@staticmethod
|
||||
async def update(session, owner, data: dict):
|
||||
for key, value in data.items():
|
||||
setattr(owner, key, value)
|
||||
await session.flush()
|
||||
return owner
|
||||
@@ -1,110 +1,20 @@
|
||||
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
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.db.models.property import Property
|
||||
|
||||
|
||||
def find_nearest_property_and_owner(driver: Driver, lat: float, lng: float):
|
||||
class PropertyRepository:
|
||||
@staticmethod
|
||||
async def get_by_location_id(session, location_id: int):
|
||||
|
||||
with driver.session(database="neo4j") as session:
|
||||
result = session.run(
|
||||
QUERY_NEAREST,
|
||||
lat=lat,
|
||||
lng=lng
|
||||
stmt = (
|
||||
select(Property)
|
||||
.options(
|
||||
selectinload(Property.owner),
|
||||
selectinload(Property.location),
|
||||
)
|
||||
.where(Property.location_id == location_id)
|
||||
)
|
||||
|
||||
record = result.single()
|
||||
|
||||
if not record:
|
||||
return None
|
||||
|
||||
return {
|
||||
"owner": record["owner"],
|
||||
"property": record["property"],
|
||||
"distanceMeters": record["distanceMeters"]
|
||||
}
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
10
app/schemas/full_entry_schema.py
Normal file
10
app/schemas/full_entry_schema.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
from app.schemas.location_create_schema import LocationCreate
|
||||
from app.schemas.owner_create_schema import OwnerCreate
|
||||
from app.schemas.property_create_schema import PropertyCreate
|
||||
|
||||
|
||||
class FullEntryCreateRequest(BaseModel):
|
||||
location: LocationCreate
|
||||
owner: OwnerCreate
|
||||
property: PropertyCreate
|
||||
26
app/schemas/location_create_schema.py
Normal file
26
app/schemas/location_create_schema.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LocationCreate(BaseModel):
|
||||
place_id: int
|
||||
osm_id: int
|
||||
osm_type: str
|
||||
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
house_number: str | None = None
|
||||
road: str
|
||||
city: str
|
||||
county: str
|
||||
state: str
|
||||
postcode: str
|
||||
country: str
|
||||
country_code: str
|
||||
|
||||
display_name: str
|
||||
|
||||
bbox_lat_min: float
|
||||
bbox_lat_max: float
|
||||
bbox_lon_min: float
|
||||
bbox_lon_max: float
|
||||
@@ -1,6 +0,0 @@
|
||||
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)
|
||||
49
app/schemas/map_schema.py
Normal file
49
app/schemas/map_schema.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
||||
class MapQueryParams(BaseModel):
|
||||
lat: float = Field(..., ge=-90, le=90, description="Latitude")
|
||||
lon: float = Field(..., ge=-180, le=180, description="Longitude")
|
||||
radius: int = Field(2000, gt=0, le=50000, description="Search radius in meters")
|
||||
|
||||
|
||||
|
||||
class LocationResponse(BaseModel):
|
||||
id: int
|
||||
|
||||
# OSM identifiers
|
||||
place_id: int
|
||||
osm_id: int
|
||||
osm_type: str
|
||||
|
||||
# Coordinates
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
# Address info
|
||||
house_number: str | None = None
|
||||
road: str
|
||||
city: str
|
||||
county: str
|
||||
state: str
|
||||
postcode: str
|
||||
country: str
|
||||
country_code: str
|
||||
|
||||
# Display name
|
||||
display_name: str
|
||||
|
||||
# Bounding box
|
||||
bbox_lat_min: float
|
||||
bbox_lat_max: float
|
||||
bbox_lon_min: float
|
||||
bbox_lon_max: float
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MapQueryResponse(BaseModel):
|
||||
total: int
|
||||
locations: list[LocationResponse]
|
||||
15
app/schemas/owner_create_schema.py
Normal file
15
app/schemas/owner_create_schema.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OwnerCreate(BaseModel):
|
||||
full_name: str
|
||||
phone_number: str
|
||||
email: str
|
||||
occupation: str
|
||||
annual_income_range: str
|
||||
|
||||
willing_to_rent: bool = False
|
||||
desired_rent_price: int | None = None
|
||||
|
||||
willing_to_sell: bool = False
|
||||
desired_sell_price: int | None = None
|
||||
17
app/schemas/owner_schema.py
Normal file
17
app/schemas/owner_schema.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OwnerResponse(BaseModel):
|
||||
id: int
|
||||
full_name: str
|
||||
phone_number: str
|
||||
email: str
|
||||
occupation: str
|
||||
annual_income_range: str
|
||||
willing_to_rent: bool
|
||||
desired_rent_price: int | None
|
||||
willing_to_sell: bool
|
||||
desired_sell_price: int | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
15
app/schemas/owner_update_schema.py
Normal file
15
app/schemas/owner_update_schema.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OwnerUpdate(BaseModel):
|
||||
full_name: str | None = None
|
||||
phone_number: str | None = None
|
||||
email: str | None = None
|
||||
occupation: str | None = None
|
||||
annual_income_range: str | None = None
|
||||
|
||||
willing_to_rent: bool | None = None
|
||||
desired_rent_price: int | None = None
|
||||
|
||||
willing_to_sell: bool | None = None
|
||||
desired_sell_price: int | None = None
|
||||
26
app/schemas/property_create_schema.py
Normal file
26
app/schemas/property_create_schema.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import date
|
||||
|
||||
|
||||
class PropertyCreate(BaseModel):
|
||||
property_type: str
|
||||
built_up_area: float
|
||||
|
||||
bedrooms: int
|
||||
bathrooms: int
|
||||
|
||||
purchase_date: date
|
||||
purchase_price: float
|
||||
|
||||
sale_listing_date: date | None = None
|
||||
sale_asking_price: float | None = None
|
||||
|
||||
last_renovation_date: date | None = None
|
||||
renovation_description: str | None = None
|
||||
|
||||
roof_repair_date: date | None = None
|
||||
roof_condition: str
|
||||
|
||||
available_for_rent: bool = False
|
||||
expected_rent: float | None = None
|
||||
rental_available_date: date | None = None
|
||||
@@ -1,172 +0,0 @@
|
||||
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)
|
||||
33
app/schemas/property_schema.py
Normal file
33
app/schemas/property_schema.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import date
|
||||
from app.schemas.owner_schema import OwnerResponse
|
||||
|
||||
|
||||
class PropertyResponse(BaseModel):
|
||||
id: int
|
||||
|
||||
property_type: str
|
||||
built_up_area: float
|
||||
bedrooms: int
|
||||
bathrooms: int
|
||||
|
||||
purchase_date: date
|
||||
purchase_price: float
|
||||
|
||||
sale_listing_date: date | None
|
||||
sale_asking_price: float | None
|
||||
|
||||
last_renovation_date: date | None
|
||||
renovation_description: str | None
|
||||
|
||||
roof_repair_date: date | None
|
||||
roof_condition: str
|
||||
|
||||
available_for_rent: bool
|
||||
expected_rent: float | None
|
||||
rental_available_date: date | None
|
||||
|
||||
owner: OwnerResponse
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
8
app/schemas/property_update_request.py
Normal file
8
app/schemas/property_update_request.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from pydantic import BaseModel
|
||||
from app.schemas.owner_update_schema import OwnerUpdate
|
||||
from app.schemas.property_update_schema import PropertyUpdate
|
||||
|
||||
|
||||
class PropertyOwnerUpdateRequest(BaseModel):
|
||||
owner: OwnerUpdate | None = None
|
||||
property: PropertyUpdate | None = None
|
||||
26
app/schemas/property_update_schema.py
Normal file
26
app/schemas/property_update_schema.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import date
|
||||
|
||||
|
||||
class PropertyUpdate(BaseModel):
|
||||
property_type: str | None = None
|
||||
built_up_area: float | None = None
|
||||
|
||||
bedrooms: int | None = None
|
||||
bathrooms: int | None = None
|
||||
|
||||
purchase_date: date | None = None
|
||||
purchase_price: float | None = None
|
||||
|
||||
sale_listing_date: date | None = None
|
||||
sale_asking_price: float | None = None
|
||||
|
||||
last_renovation_date: date | None = None
|
||||
renovation_description: str | None = None
|
||||
|
||||
roof_repair_date: date | None = None
|
||||
roof_condition: str | None = None
|
||||
|
||||
available_for_rent: bool | None = None
|
||||
expected_rent: float | None = None
|
||||
rental_available_date: date | None = None
|
||||
14
app/services/location_service.py
Normal file
14
app/services/location_service.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.repositories.location_repo import LocationRepository
|
||||
|
||||
|
||||
class LocationService:
|
||||
|
||||
@staticmethod
|
||||
async def get_nearby_locations(session: AsyncSession, lat: float, lon: float, radius: int = 2000):
|
||||
return await LocationRepository.find_within_radius(
|
||||
session=session,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
radius_m=radius
|
||||
)
|
||||
@@ -1,10 +1,102 @@
|
||||
from app.repositories.property_repo import find_nearest_property_and_owner
|
||||
from app.repositories.location_repo import LocationRepository
|
||||
from app.repositories.property_repo import PropertyRepository
|
||||
from app.db.models.owner import Owner
|
||||
from app.db.models.property import Property
|
||||
|
||||
def get_nearest_asset(driver, lat: float, lng: float):
|
||||
|
||||
data = find_nearest_property_and_owner(driver, lat, lng)
|
||||
class PropertyService:
|
||||
|
||||
@staticmethod
|
||||
async def get_property_by_osm(session, osm_type, osm_id):
|
||||
|
||||
location = await LocationRepository.get_by_osm(
|
||||
session, osm_type, osm_id
|
||||
)
|
||||
|
||||
if not location:
|
||||
return None
|
||||
|
||||
property = await PropertyRepository.get_by_location_id(
|
||||
session, location.id
|
||||
)
|
||||
|
||||
return property
|
||||
|
||||
@staticmethod
|
||||
async def create_full_entry(session, location_data, owner_data, property_data):
|
||||
|
||||
async with session.begin():
|
||||
|
||||
# Check existing location
|
||||
location = await LocationRepository.get_by_osm(
|
||||
session,
|
||||
location_data.osm_type,
|
||||
location_data.osm_id
|
||||
)
|
||||
|
||||
if not location:
|
||||
location = await LocationRepository.create(
|
||||
session,
|
||||
location_data.dict()
|
||||
)
|
||||
|
||||
# Check if property already exists for this location
|
||||
existing_property = await PropertyRepository.get_by_location_id(
|
||||
session,
|
||||
location.id
|
||||
)
|
||||
|
||||
if existing_property:
|
||||
# return existing instead of crashing
|
||||
return existing_property
|
||||
|
||||
# Create owner
|
||||
owner = Owner(**owner_data.dict())
|
||||
session.add(owner)
|
||||
await session.flush()
|
||||
|
||||
# Create property
|
||||
prop_data = property_data.dict()
|
||||
prop_data["owner_id"] = owner.id
|
||||
prop_data["location_id"] = location.id
|
||||
|
||||
property_obj = Property(**prop_data)
|
||||
session.add(property_obj)
|
||||
|
||||
return property_obj
|
||||
|
||||
@staticmethod
|
||||
async def update_property_and_owner(session, property_id, owner_data, property_data):
|
||||
|
||||
async with session.begin():
|
||||
|
||||
property_obj = await session.get(Property, property_id)
|
||||
|
||||
if not property_obj:
|
||||
return None
|
||||
|
||||
owner = property_obj.owner
|
||||
|
||||
# Update owner
|
||||
if owner_data:
|
||||
for k, v in owner_data.items():
|
||||
setattr(owner, k, v)
|
||||
|
||||
# Update property
|
||||
if property_data:
|
||||
for k, v in property_data.items():
|
||||
setattr(property_obj, k, v)
|
||||
|
||||
return property_obj
|
||||
|
||||
@staticmethod
|
||||
async def get_property_by_location_id(session, location_id: int):
|
||||
|
||||
property_obj = await PropertyRepository.get_by_location_id(
|
||||
session, location_id
|
||||
)
|
||||
|
||||
return property_obj
|
||||
|
||||
|
||||
if not data:
|
||||
raise ValueError("No nearby property found")
|
||||
|
||||
return data
|
||||
|
||||
Reference in New Issue
Block a user