From a77788fc47fa7fc0f2c8ec27c8824b006d7ae6aa Mon Sep 17 00:00:00 2001 From: exolonConfidental Date: Sun, 8 Feb 2026 11:18:47 +0530 Subject: [PATCH] endpoint setup --- alembic/env.py | 36 +++- ...71bdc3c5b51_add_insurance_details_table.py | 60 ++++++ .../versions/b7538fce8343_add_geom_column.py | 73 ++++++++ app/api/v1/endpoints/map.py | 40 ++++ app/api/v1/endpoints/property.py | 93 ++++++++++ app/api/v1/property_routes.py | 35 ---- app/api/v1/router.py | 4 +- app/core/config.py | 12 -- app/core/neo4j.py | 10 - app/db/models/insurance_details.py | 83 +++++++++ app/db/models/location.py | 10 +- app/db/models/property.py | 6 + app/db/session.py | 2 +- app/repositories/location_repo.py | 50 +++++ app/repositories/owner_repo.py | 8 + app/repositories/property_repo.py | 120 ++---------- app/schemas/full_entry_schema.py | 10 + app/schemas/location_create_schema.py | 26 +++ app/schemas/location_request.py | 6 - app/schemas/map_schema.py | 49 +++++ app/schemas/owner_create_schema.py | 15 ++ app/schemas/owner_schema.py | 17 ++ app/schemas/owner_update_schema.py | 15 ++ app/schemas/property_create_schema.py | 26 +++ app/schemas/property_owner_response.py | 172 ------------------ app/schemas/property_schema.py | 33 ++++ app/schemas/property_update_request.py | 8 + app/schemas/property_update_schema.py | 26 +++ app/services/location_service.py | 14 ++ app/services/property_service.py | 104 ++++++++++- pyproject.toml | 2 + test.py | 0 uv.lock | 119 ++++++++++++ 33 files changed, 932 insertions(+), 352 deletions(-) create mode 100644 alembic/versions/471bdc3c5b51_add_insurance_details_table.py create mode 100644 alembic/versions/b7538fce8343_add_geom_column.py create mode 100644 app/api/v1/endpoints/map.py create mode 100644 app/api/v1/endpoints/property.py delete mode 100644 app/api/v1/property_routes.py delete mode 100644 app/core/config.py delete mode 100644 app/core/neo4j.py create mode 100644 app/db/models/insurance_details.py create mode 100644 app/repositories/location_repo.py create mode 100644 app/repositories/owner_repo.py create mode 100644 app/schemas/full_entry_schema.py create mode 100644 app/schemas/location_create_schema.py delete mode 100644 app/schemas/location_request.py create mode 100644 app/schemas/map_schema.py create mode 100644 app/schemas/owner_create_schema.py create mode 100644 app/schemas/owner_schema.py create mode 100644 app/schemas/owner_update_schema.py create mode 100644 app/schemas/property_create_schema.py delete mode 100644 app/schemas/property_owner_response.py create mode 100644 app/schemas/property_schema.py create mode 100644 app/schemas/property_update_request.py create mode 100644 app/schemas/property_update_schema.py create mode 100644 app/services/location_service.py create mode 100644 test.py diff --git a/alembic/env.py b/alembic/env.py index 664fe2f..aa1eb85 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -21,12 +21,35 @@ from app.db.models.base import Base from app.db.models.location import Location from app.db.models.owner import Owner from app.db.models.property import Property -from app.db.models.location import Location - +from app.db.models.insurance_details import InsuranceDetails target_metadata = Base.metadata +# ---- IMPORTANT: IGNORE POSTGIS TABLES ---- +def include_object(object, name, type_, reflected, compare_to): + POSTGIS_TABLES = { + "spatial_ref_sys", + "layer", + "topology", + "geography_columns", + "geometry_columns", + "raster_columns", + "raster_overviews", + } + + # ignore PostGIS internal tables + if type_ == "table" and name in POSTGIS_TABLES: + return False + + # ignore entire topology schema + if getattr(object, "schema", None) in {"topology", "tiger", "tiger_data"}: + return False + + return True + + +# ---- OFFLINE MIGRATIONS ---- def run_migrations_offline(): url = config.get_main_option("sqlalchemy.url") @@ -36,12 +59,15 @@ def run_migrations_offline(): target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, + include_object=include_object, # βœ… applied here + compare_type=True, ) with context.begin_transaction(): context.run_migrations() +# ---- ONLINE MIGRATIONS (ASYNC) ---- async def run_migrations_online(): connectable = async_engine_from_config( @@ -56,6 +82,8 @@ async def run_migrations_online(): context.configure( connection=connection, target_metadata=target_metadata, + include_object=include_object, # βœ… THIS WAS MISSING + compare_type=True, ) with context.begin_transaction(): @@ -65,6 +93,8 @@ async def run_migrations_online(): await connectable.dispose() + +# ---- ENTRYPOINT ---- def run(): if context.is_offline_mode(): @@ -74,4 +104,4 @@ def run(): asyncio.run(run_migrations_online()) -run() +run() \ No newline at end of file diff --git a/alembic/versions/471bdc3c5b51_add_insurance_details_table.py b/alembic/versions/471bdc3c5b51_add_insurance_details_table.py new file mode 100644 index 0000000..81fc020 --- /dev/null +++ b/alembic/versions/471bdc3c5b51_add_insurance_details_table.py @@ -0,0 +1,60 @@ +"""add insurance details table + +Revision ID: 471bdc3c5b51 +Revises: b7538fce8343 +Create Date: 2026-02-07 23:47:44.253489 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '471bdc3c5b51' +down_revision: Union[str, Sequence[str], None] = 'b7538fce8343' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('insurance_details', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('property_id', sa.Integer(), nullable=False), + sa.Column('insurance_company', sa.String(length=150), nullable=False), + sa.Column('claim_number', sa.String(length=100), nullable=False), + sa.Column('date_of_loss', sa.Date(), nullable=False), + sa.Column('adjuster_name', sa.String(length=150), nullable=True), + sa.Column('adjuster_phone', sa.String(length=30), nullable=True), + sa.Column('adjuster_email', sa.String(length=150), nullable=True), + sa.Column('claim_filed', sa.Boolean(), nullable=False), + sa.Column('claim_approved', sa.Boolean(), nullable=False), + sa.Column('policy_number', sa.String(length=100), nullable=True), + sa.Column('coverage_type', sa.String(length=50), nullable=True), + sa.Column('claim_type', sa.String(length=50), nullable=True), + sa.Column('deductible_amount', sa.Integer(), nullable=True), + sa.Column('claim_amount', sa.Integer(), nullable=True), + sa.Column('approved_amount', sa.Integer(), nullable=True), + sa.Column('payment_status', sa.String(length=50), nullable=True), + sa.Column('date_claim_filed', sa.Date(), nullable=True), + sa.Column('date_claim_closed', sa.Date(), nullable=True), + sa.Column('insurance_agent_name', sa.String(length=150), nullable=True), + sa.Column('insurance_agent_phone', sa.String(length=30), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['property_id'], ['properties.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('claim_number') + ) + op.create_index(op.f('ix_insurance_details_property_id'), 'insurance_details', ['property_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_insurance_details_property_id'), table_name='insurance_details') + op.drop_table('insurance_details') + # ### end Alembic commands ### diff --git a/alembic/versions/b7538fce8343_add_geom_column.py b/alembic/versions/b7538fce8343_add_geom_column.py new file mode 100644 index 0000000..3045c17 --- /dev/null +++ b/alembic/versions/b7538fce8343_add_geom_column.py @@ -0,0 +1,73 @@ +"""add geom column + +Revision ID: b7538fce8343 +Revises: 6cd12cae8c96 +Create Date: 2026-02-07 14:33:51.832269 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from geoalchemy2 import Geography + +# revision identifiers, used by Alembic. +revision: str = 'b7538fce8343' +down_revision: Union[str, Sequence[str], None] = '6cd12cae8c96' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + # op.drop_table('layer') + # op.drop_table('spatial_ref_sys') + # op.drop_table('topology') + op.add_column('locations', sa.Column('geom',Geography(geometry_type='POINT', srid=4326, dimension=2, from_text='ST_GeogFromText', name='geography', nullable=False), nullable=False)) + op.execute( + "CREATE INDEX IF NOT EXISTS idx_locations_geom ON locations USING gist (geom);" + ) + op.create_unique_constraint('uq_osm_location', 'locations', ['osm_type', 'osm_id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('uq_osm_location', 'locations', type_='unique') + op.drop_index('idx_locations_geom', table_name='locations', postgresql_using='gist') + op.drop_column('locations', 'geom') + # op.create_table('topology', + # sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + # sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=False), + # sa.Column('srid', sa.INTEGER(), autoincrement=False, nullable=False), + # sa.Column('precision', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=False), + # sa.Column('hasz', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False), + # sa.Column('useslargeids', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False), + # sa.PrimaryKeyConstraint('id', name=op.f('topology_pkey')), + # sa.UniqueConstraint('name', name=op.f('topology_name_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) + # ) + # op.create_table('spatial_ref_sys', + # sa.Column('srid', sa.INTEGER(), autoincrement=False, nullable=False), + # sa.Column('auth_name', sa.VARCHAR(length=256), autoincrement=False, nullable=True), + # sa.Column('auth_srid', sa.INTEGER(), autoincrement=False, nullable=True), + # sa.Column('srtext', sa.VARCHAR(length=2048), autoincrement=False, nullable=True), + # sa.Column('proj4text', sa.VARCHAR(length=2048), autoincrement=False, nullable=True), + # sa.CheckConstraint('srid > 0 AND srid <= 998999', name=op.f('spatial_ref_sys_srid_check')), + # sa.PrimaryKeyConstraint('srid', name=op.f('spatial_ref_sys_pkey')) + # ) + # op.create_table('layer', + # sa.Column('topology_id', sa.INTEGER(), autoincrement=False, nullable=False), + # sa.Column('layer_id', sa.INTEGER(), autoincrement=False, nullable=False), + # sa.Column('schema_name', sa.VARCHAR(), autoincrement=False, nullable=False), + # sa.Column('table_name', sa.VARCHAR(), autoincrement=False, nullable=False), + # sa.Column('feature_column', sa.VARCHAR(), autoincrement=False, nullable=False), + # sa.Column('feature_type', sa.INTEGER(), autoincrement=False, nullable=False), + # sa.Column('level', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=False), + # sa.Column('child_id', sa.INTEGER(), autoincrement=False, nullable=True), + # sa.ForeignKeyConstraint(['topology_id'], ['topology.id'], name=op.f('layer_topology_id_fkey')), + # sa.PrimaryKeyConstraint('topology_id', 'layer_id', name=op.f('layer_pkey')), + # sa.UniqueConstraint('schema_name', 'table_name', 'feature_column', name=op.f('layer_schema_name_table_name_feature_column_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) + # ) + # ### end Alembic commands ### diff --git a/app/api/v1/endpoints/map.py b/app/api/v1/endpoints/map.py new file mode 100644 index 0000000..2843a74 --- /dev/null +++ b/app/api/v1/endpoints/map.py @@ -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" + ) \ No newline at end of file diff --git a/app/api/v1/endpoints/property.py b/app/api/v1/endpoints/property.py new file mode 100644 index 0000000..050189e --- /dev/null +++ b/app/api/v1/endpoints/property.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/property_routes.py b/app/api/v1/property_routes.py deleted file mode 100644 index c2a4846..0000000 --- a/app/api/v1/property_routes.py +++ /dev/null @@ -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" - ) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 3547803..1d5602b 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -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) diff --git a/app/core/config.py b/app/core/config.py deleted file mode 100644 index 0d114cb..0000000 --- a/app/core/config.py +++ /dev/null @@ -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() diff --git a/app/core/neo4j.py b/app/core/neo4j.py deleted file mode 100644 index b190775..0000000 --- a/app/core/neo4j.py +++ /dev/null @@ -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 diff --git a/app/db/models/insurance_details.py b/app/db/models/insurance_details.py new file mode 100644 index 0000000..b691ecc --- /dev/null +++ b/app/db/models/insurance_details.py @@ -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") \ No newline at end of file diff --git a/app/db/models/location.py b/app/db/models/location.py index 2551cc2..1d6d258 100644 --- a/app/db/models/location.py +++ b/app/db/models/location.py @@ -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", diff --git a/app/db/models/property.py b/app/db/models/property.py index b0c1712..2bae0d0 100644 --- a/app/db/models/property.py +++ b/app/db/models/property.py @@ -63,3 +63,9 @@ class Property(Base): back_populates="property", uselist=False, ) + + insurance_records = relationship( + "InsuranceDetails", + back_populates="property", + cascade="all, delete-orphan" + ) \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py index 9bcfc6b..9c5785c 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -8,6 +8,6 @@ AsyncSessionLocal = async_sessionmaker( ) -async def get_db(): +async def get_async_session(): async with AsyncSessionLocal() as session: yield session diff --git a/app/repositories/location_repo.py b/app/repositories/location_repo.py new file mode 100644 index 0000000..2252468 --- /dev/null +++ b/app/repositories/location_repo.py @@ -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() \ No newline at end of file diff --git a/app/repositories/owner_repo.py b/app/repositories/owner_repo.py new file mode 100644 index 0000000..98877ac --- /dev/null +++ b/app/repositories/owner_repo.py @@ -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 \ No newline at end of file diff --git a/app/repositories/property_repo.py b/app/repositories/property_repo.py index ae0ceb2..564b13e 100644 --- a/app/repositories/property_repo.py +++ b/app/repositories/property_repo.py @@ -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() \ No newline at end of file diff --git a/app/schemas/full_entry_schema.py b/app/schemas/full_entry_schema.py new file mode 100644 index 0000000..f9b19bc --- /dev/null +++ b/app/schemas/full_entry_schema.py @@ -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 \ No newline at end of file diff --git a/app/schemas/location_create_schema.py b/app/schemas/location_create_schema.py new file mode 100644 index 0000000..a328a2d --- /dev/null +++ b/app/schemas/location_create_schema.py @@ -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 \ No newline at end of file diff --git a/app/schemas/location_request.py b/app/schemas/location_request.py deleted file mode 100644 index 611126c..0000000 --- a/app/schemas/location_request.py +++ /dev/null @@ -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) diff --git a/app/schemas/map_schema.py b/app/schemas/map_schema.py new file mode 100644 index 0000000..26a75a8 --- /dev/null +++ b/app/schemas/map_schema.py @@ -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] \ No newline at end of file diff --git a/app/schemas/owner_create_schema.py b/app/schemas/owner_create_schema.py new file mode 100644 index 0000000..f7e3476 --- /dev/null +++ b/app/schemas/owner_create_schema.py @@ -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 \ No newline at end of file diff --git a/app/schemas/owner_schema.py b/app/schemas/owner_schema.py new file mode 100644 index 0000000..764313e --- /dev/null +++ b/app/schemas/owner_schema.py @@ -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 \ No newline at end of file diff --git a/app/schemas/owner_update_schema.py b/app/schemas/owner_update_schema.py new file mode 100644 index 0000000..3cf6163 --- /dev/null +++ b/app/schemas/owner_update_schema.py @@ -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 \ No newline at end of file diff --git a/app/schemas/property_create_schema.py b/app/schemas/property_create_schema.py new file mode 100644 index 0000000..17d7781 --- /dev/null +++ b/app/schemas/property_create_schema.py @@ -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 \ No newline at end of file diff --git a/app/schemas/property_owner_response.py b/app/schemas/property_owner_response.py deleted file mode 100644 index aa11960..0000000 --- a/app/schemas/property_owner_response.py +++ /dev/null @@ -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) diff --git a/app/schemas/property_schema.py b/app/schemas/property_schema.py new file mode 100644 index 0000000..36a497b --- /dev/null +++ b/app/schemas/property_schema.py @@ -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 \ No newline at end of file diff --git a/app/schemas/property_update_request.py b/app/schemas/property_update_request.py new file mode 100644 index 0000000..7a5b56b --- /dev/null +++ b/app/schemas/property_update_request.py @@ -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 \ No newline at end of file diff --git a/app/schemas/property_update_schema.py b/app/schemas/property_update_schema.py new file mode 100644 index 0000000..8c334e3 --- /dev/null +++ b/app/schemas/property_update_schema.py @@ -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 \ No newline at end of file diff --git a/app/services/location_service.py b/app/services/location_service.py new file mode 100644 index 0000000..b5faa9a --- /dev/null +++ b/app/services/location_service.py @@ -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 + ) \ No newline at end of file diff --git a/app/services/property_service.py b/app/services/property_service.py index c184d71..004c2b5 100644 --- a/app/services/property_service.py +++ b/app/services/property_service.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 0b6d02e..5e93780 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,10 +8,12 @@ dependencies = [ "alembic>=1.18.3", "asyncpg>=0.31.0", "fastapi>=0.128.0", + "geoalchemy2>=0.18.1", "neo4j>=6.1.0", "pydantic>=2.12.5", "pydantic-settings>=2.12.0", "python-dotenv>=1.2.1", + "shapely>=2.1.2", "sqlalchemy>=2.0.46", "uvicorn>=0.40.0", ] diff --git a/test.py b/test.py new file mode 100644 index 0000000..e69de29 diff --git a/uv.lock b/uv.lock index 3c0950c..7670e7e 100644 --- a/uv.lock +++ b/uv.lock @@ -107,10 +107,12 @@ dependencies = [ { name = "alembic" }, { name = "asyncpg" }, { name = "fastapi" }, + { name = "geoalchemy2" }, { name = "neo4j" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-dotenv" }, + { name = "shapely" }, { name = "sqlalchemy" }, { name = "uvicorn" }, ] @@ -120,10 +122,12 @@ requires-dist = [ { name = "alembic", specifier = ">=1.18.3" }, { name = "asyncpg", specifier = ">=0.31.0" }, { name = "fastapi", specifier = ">=0.128.0" }, + { name = "geoalchemy2", specifier = ">=0.18.1" }, { name = "neo4j", specifier = ">=6.1.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "shapely", specifier = ">=2.1.2" }, { name = "sqlalchemy", specifier = ">=2.0.46" }, { name = "uvicorn", specifier = ">=0.40.0" }, ] @@ -143,6 +147,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, ] +[[package]] +name = "geoalchemy2" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/df/f6d689120a15a2287794e16696c3bdb4cf2e53038255d288b61a4d59e1fa/geoalchemy2-0.18.1.tar.gz", hash = "sha256:4bdc7daf659e36f6456e2f2c3bcce222b879584921a4f50a803ab05fa2bb3124", size = 239302, upload-time = "2025-11-18T15:12:05.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/25/b3d6fc757d8d909e0e666ec6fbf1b7914e9ad18d6e1b08994cd9d2e63330/geoalchemy2-0.18.1-py3-none-any.whl", hash = "sha256:a49d9559bf7acbb69129a01c6e1861657c15db420886ad0a09b1871fb0ff4bdb", size = 81261, upload-time = "2025-11-18T15:12:03.985Z" }, +] + [[package]] name = "greenlet" version = "3.3.1" @@ -271,6 +288,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/5c/ee71e2dd955045425ef44283f40ba1da67673cf06404916ca2950ac0cd39/neo4j-6.1.0-py3-none-any.whl", hash = "sha256:3bd93941f3a3559af197031157220af9fd71f4f93a311db687bd69ffa417b67d", size = 325326, upload-time = "2026-01-12T11:27:33.196Z" }, ] +[[package]] +name = "numpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, + { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, + { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, + { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, + { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, + { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -371,6 +447,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, + { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, + { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, + { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, + { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, + { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, + { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, + { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.46"