diff --git a/.gitignore b/.gitignore index 749b65de..19ca899a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,7 @@ node_modules # Sensitive config .env.development.local .env -.env.local \ No newline at end of file +.env.local + +# IDEs +.vscode/ \ No newline at end of file diff --git a/backend/api/models/landslide_zones.py b/backend/api/models/landslide_zones.py index 36afe9cd..0260e90e 100644 --- a/backend/api/models/landslide_zones.py +++ b/backend/api/models/landslide_zones.py @@ -1,7 +1,6 @@ """All data of the Landslide Zones table from SFData""" -from sqlalchemy import String, Integer, DateTime, func, Float -from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import Integer, DateTime, func, Float from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column from geoalchemy2 import Geometry diff --git a/backend/api/models/tsunami.py b/backend/api/models/tsunami.py index 092e78f3..b09a2a59 100644 --- a/backend/api/models/tsunami.py +++ b/backend/api/models/tsunami.py @@ -1,7 +1,6 @@ """Tsunami Risk Zone data""" from sqlalchemy import String, Integer, Float, DateTime, func -from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column from geoalchemy2 import Geometry diff --git a/backend/api/routers/liquefaction_api.py b/backend/api/routers/liquefaction_api.py index 6d30c710..cc03dadb 100644 --- a/backend/api/routers/liquefaction_api.py +++ b/backend/api/routers/liquefaction_api.py @@ -3,6 +3,7 @@ from fastapi import Depends, HTTPException, APIRouter from ..tags import Tags from sqlalchemy.orm import Session +from geoalchemy2 import functions as geo_func from backend.database.session import get_db from ..schemas.liquefaction_schemas import ( LiquefactionFeature, @@ -41,3 +42,26 @@ async def get_liquefaction_zones(db: Session = Depends(get_db)): LiquefactionFeature.from_sqlalchemy_model(zone) for zone in liquefaction_zones ] return LiquefactionFeatureCollection(type="FeatureCollection", features=features) + + +@router.get("/is-in-liquefaction-zone", response_model=bool) +async def is_in_liquefaction_zone( + lon: float, lat: float, db: Session = Depends(get_db) +): + """ + Check if a point is in a liquefaction zone. + + Args: + lon (float): Longitude of the point. + lat (float): Latitude of the point. + db (Session): The database session dependency. + + Returns: + bool: True if the point is in a liquefaction zone, False otherwise. + """ + query = db.query(LiquefactionZone).filter( + LiquefactionZone.geometry.ST_Contains( + geo_func.ST_SetSRID(geo_func.ST_GeomFromText(f"POINT({lon} {lat})"), 4326) + ) + ) + return db.query(query.exists()).scalar() diff --git a/backend/api/routers/seismic_api.py b/backend/api/routers/seismic_api.py index 1d47397f..00d54916 100644 --- a/backend/api/routers/seismic_api.py +++ b/backend/api/routers/seismic_api.py @@ -3,6 +3,7 @@ from fastapi import Depends, HTTPException, APIRouter from ..tags import Tags from sqlalchemy.orm import Session +from geoalchemy2 import functions as geo_func from backend.database.session import get_db from ..schemas.seismic_schemas import ( SeismicFeature, @@ -39,3 +40,24 @@ async def get_seismic_hazard_zones(db: Session = Depends(get_db)): features = [SeismicFeature.from_sqlalchemy_model(zone) for zone in seismic_zones] return SeismicFeatureCollection(type="FeatureCollection", features=features) + + +@router.get("/is-in-seismic-zone", response_model=bool) +async def is_in_seismic_zone(lon: float, lat: float, db: Session = Depends(get_db)): + """ + Check if a point is in a liquefaction zone. + + Args: + lon (float): Longitude of the point. + lat (float): Latitude of the point. + db (Session): The database session dependency. + + Returns: + bool: True if the point is in a liquefaction zone, False otherwise. + """ + query = db.query(SeismicHazardZone).filter( + SeismicHazardZone.geometry.ST_Contains( + geo_func.ST_SetSRID(geo_func.ST_GeomFromText(f"POINT({lon} {lat})"), 4326) + ) + ) + return db.query(query.exists()).scalar() diff --git a/backend/api/routers/soft_story_api.py b/backend/api/routers/soft_story_api.py index deeafa90..00616094 100644 --- a/backend/api/routers/soft_story_api.py +++ b/backend/api/routers/soft_story_api.py @@ -4,6 +4,7 @@ from ..tags import Tags from sqlalchemy.orm import Session from backend.database.session import get_db +from geoalchemy2 import functions as geo_func from backend.api.schemas.soft_story_schemas import ( SoftStoryFeature, SoftStoryFeatureCollection, @@ -39,3 +40,22 @@ async def get_soft_stories(db: Session = Depends(get_db)): features = [SoftStoryFeature.from_sqlalchemy_model(story) for story in soft_stories] return SoftStoryFeatureCollection(type="FeatureCollection", features=features) + + +@router.get("/is-soft-story", response_model=bool) +async def is_soft_story(lon: float, lat: float, db: Session = Depends(get_db)): + """ + Check if a point is a soft story property. + + Args: + lon (float): Longitude of the point. + lat (float): Latitude of the point. + db (Session): The database session dependency. + + Returns: + bool: True if the point is a soft story property, False otherwise. + """ + query = db.query(SoftStoryProperty).filter( + SoftStoryProperty.point == geo_func.ST_GeomFromText(f"POINT({lon} {lat})", 4326) + ) + return db.query(query.exists()).scalar() diff --git a/backend/api/routers/tsunami_api.py b/backend/api/routers/tsunami_api.py index e2275486..40bc2768 100644 --- a/backend/api/routers/tsunami_api.py +++ b/backend/api/routers/tsunami_api.py @@ -3,6 +3,7 @@ from fastapi import Depends, HTTPException, APIRouter from ..tags import Tags from sqlalchemy.orm import Session +from geoalchemy2 import functions as geo_func from backend.database.session import get_db from backend.api.schemas.tsunami_schemas import TsunamiFeature, TsunamiFeatureCollection from backend.api.models.tsunami import TsunamiZone @@ -38,3 +39,24 @@ async def get_tsunami_zones(db: Session = Depends(get_db)): raise HTTPException(status_code=404, detail="No tsunami zones found") features = [TsunamiFeature.from_sqlalchemy_model(zone) for zone in tsunami_zones] return TsunamiFeatureCollection(type="FeatureCollection", features=features) + + +@router.get("/is-in-tsunami-zone", response_model=bool) +async def is_in_tsunami_zone(lon: float, lat: float, db: Session = Depends(get_db)): + """ + Check if a point is in a tsunami zone. + + Args: + lon (float): Longitude of the point. + lat (float): Latitude of the point. + db (Session): The database session dependency. + + Returns: + bool: True if the point is in a tsunami zone, False otherwise. + """ + query = db.query(TsunamiZone).filter( + TsunamiZone.geometry.ST_Contains( + geo_func.ST_SetSRID(geo_func.ST_GeomFromText(f"POINT({lon} {lat})"), 4326) + ) + ) + return db.query(query.exists()).scalar() diff --git a/backend/api/tests/test_addresses.py b/backend/api/tests/test_addresses.py index f8f970a9..55156320 100644 --- a/backend/api/tests/test_addresses.py +++ b/backend/api/tests/test_addresses.py @@ -1,4 +1,3 @@ -import pytest from backend.api.tests.test_session_config import test_engine, test_session, client diff --git a/backend/api/tests/test_landslide.py b/backend/api/tests/test_landslide.py index 22336314..6556ead6 100644 --- a/backend/api/tests/test_landslide.py +++ b/backend/api/tests/test_landslide.py @@ -1,4 +1,3 @@ -import pytest from backend.api.tests.test_session_config import test_engine, test_session, client diff --git a/backend/api/tests/test_liquefaction.py b/backend/api/tests/test_liquefaction.py index fe0b21c3..f1415c64 100644 --- a/backend/api/tests/test_liquefaction.py +++ b/backend/api/tests/test_liquefaction.py @@ -1,4 +1,3 @@ -import pytest from backend.api.tests.test_session_config import test_engine, test_session, client @@ -7,3 +6,20 @@ def test_get_liquefaction_zones(client): response_dict = response.json() assert response.status_code == 200 assert len(response_dict["features"]) == 3 + + +def test_is_in_liquefaction_zone(client): + lon, lat = [-122.35, 37.83] + response = client.get( + f"/liquefaction-zones/is-in-liquefaction-zone?lon={lon}&lat={lat}" + ) + assert response.status_code == 200 + assert response.json() # True + + # These should not be in liquefaction zones + wrong_lon, wrong_lat = [0.0, 0.0] + response = client.get( + f"/liquefaction-zones/is-in-liquefaction-zone?lon={wrong_lon}&lat={wrong_lat}" + ) + assert response.status_code == 200 + assert not response.json() # False diff --git a/backend/api/tests/test_seismic.py b/backend/api/tests/test_seismic.py index 5ae6ba1c..10e87a3e 100644 --- a/backend/api/tests/test_seismic.py +++ b/backend/api/tests/test_seismic.py @@ -1,4 +1,3 @@ -import pytest from backend.api.tests.test_session_config import test_engine, test_session, client @@ -7,3 +6,18 @@ def test_get_seismic_hazard_zones(client): response_dict = response.json() assert response.status_code == 200 assert len(response_dict["features"]) == 2 + + +def test_is_in_seismic_zone(client): + lon, lat = [-122.407436, 37.779759] + response = client.get(f"/seismic-zones/is-in-seismic-zone?lon={lon}&lat={lat}") + assert response.status_code == 200 + assert response.json() # True + + # These should not be in a seismic hazard zone + wrong_lon, wrong_lat = [0.0, 0.0] + response = client.get( + f"/seismic-zones/is-in-seismic-zone?lon={wrong_lon}&lat={wrong_lat}" + ) + assert response.status_code == 200 + assert not response.json() # False diff --git a/backend/api/tests/test_session_config.py b/backend/api/tests/test_session_config.py index 9902015a..36645f4e 100644 --- a/backend/api/tests/test_session_config.py +++ b/backend/api/tests/test_session_config.py @@ -1,7 +1,6 @@ import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from ...api.models.base import Base from backend.api.config import settings from fastapi.testclient import TestClient from ..main import app diff --git a/backend/api/tests/test_soft_story.py b/backend/api/tests/test_soft_story.py index 5b994d09..452d8c38 100644 --- a/backend/api/tests/test_soft_story.py +++ b/backend/api/tests/test_soft_story.py @@ -1,4 +1,3 @@ -import pytest from backend.api.tests.test_session_config import test_engine, test_session, client @@ -7,3 +6,18 @@ def test_get_soft_stories(client): response_dict = response.json() assert response.status_code == 200 assert len(response_dict["features"]) == 6 + + +def test_is_soft_story(client): + lon, lat = [-122.424966202, 37.762929444] + response = client.get(f"/soft-stories/is-soft-story?lon={lon}&lat={lat}") + assert response.status_code == 200 + assert response.json() # True + + # These should not be soft stories + wrong_lon, wrong_lat = [0.0, 0.0] + response = client.get( + f"/soft-stories/is-soft-story?lon={wrong_lon}&lat={wrong_lat}" + ) + assert response.status_code == 200 + assert not response.json() # False diff --git a/backend/api/tests/test_tsunami.py b/backend/api/tests/test_tsunami.py index 57b93882..77fa6a73 100644 --- a/backend/api/tests/test_tsunami.py +++ b/backend/api/tests/test_tsunami.py @@ -1,5 +1,4 @@ -import pytest -from backend.api.tests.test_session_config import test_engine, test_session, client +from backend.api.tests.test_session_config import test_session, test_engine, client def test_get_tsunami_zones(client): @@ -7,3 +6,18 @@ def test_get_tsunami_zones(client): response_dict = response.json() assert response.status_code == 200 assert len(response_dict["features"]) == 1 + + +def test_is_in_tsunami_zone(client): + lon, lat = [-122.4, 37.75] + response = client.get(f"/tsunami-zones/is-in-tsunami-zone?lon={lon}&lat={lat}") + assert response.status_code == 200 + assert response.json() # True + + # These should not be in our tsunami zone + wrong_lon, wrong_lat = [0.0, 0.0] + response = client.get( + f"/tsunami-zones/is-in-tsunami-zone?lon={wrong_lon}&lat={wrong_lat}" + ) + assert response.status_code == 200 + assert not response.json() # False diff --git a/backend/database/init_db.py b/backend/database/init_db.py index 7b408bc4..0a07fd6e 100644 --- a/backend/database/init_db.py +++ b/backend/database/init_db.py @@ -32,15 +32,8 @@ """ from backend.api.models.base import Base -from sqlalchemy.orm import Session from sqlalchemy import inspect from backend.database.session import engine -from backend.api.models.addresses import Address -from backend.api.models.tsunami import TsunamiZone -from backend.api.models.landslide_zones import LandslideZone -from backend.api.models.seismic_hazard_zones import SeismicHazardZone -from backend.api.models.liquefaction_zones import LiquefactionZone -from backend.api.models.soft_story_properties import SoftStoryProperty def init_db(): diff --git a/backend/etl/address_data_handler.py b/backend/etl/address_data_handler.py index 7d51e4c5..8dbf814c 100644 --- a/backend/etl/address_data_handler.py +++ b/backend/etl/address_data_handler.py @@ -1,8 +1,6 @@ from http.client import HTTPException from backend.etl.data_handler import DataHandler from backend.api.models.addresses import Address -from shapely.geometry import Point -from geoalchemy2.shape import from_shape, to_shape ADDRESSES_URL = "https://data.sfgov.org/resource/ramy-di5m.geojson" # This API has a default limit of providing 1,000 rows diff --git a/backend/etl/tsunami_data_handler.py b/backend/etl/tsunami_data_handler.py index e92b93a8..d6ab9463 100644 --- a/backend/etl/tsunami_data_handler.py +++ b/backend/etl/tsunami_data_handler.py @@ -2,7 +2,7 @@ from backend.etl.data_handler import DataHandler from backend.api.models.tsunami import TsunamiZone from shapely.geometry import Polygon, MultiPolygon -from geoalchemy2.shape import from_shape, to_shape +from geoalchemy2.shape import from_shape TSUNAMI_URL = "https://services2.arcgis.com/zr3KAIbsRSUyARHG/ArcGIS/rest/services/CA_Tsunami_Hazard_Area/FeatureServer/0/query" diff --git a/requirements-dev.txt b/requirements-dev.txt index 53bb077b..4cbcd4dc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -34,6 +34,7 @@ pandas==2.2.3 pathspec==0.12.1 platformdirs==4.3.6 pluggy==1.5.0 +pre_commit==4.0.1 psycopg2-binary==2.9.9 pydantic==2.9.0 pydantic-settings==2.5.2