diff --git a/application.py b/application.py index 4be14c7e..784fc399 100644 --- a/application.py +++ b/application.py @@ -45,6 +45,7 @@ def get_service_categories(): ("Flammability and Vegetation Type (ALFRESCO)", "/alfresco"), ("Hydrology", "/hydrology"), ("Landfast Sea Ice", "/landfastice"), + ("Landslide Risk", "/landslide"), ("Permafrost", "/permafrost"), # ("Physical and Administrative Boundary Polygons", "/boundary"), # ("Ecoregions", "/ecoregions"), @@ -131,9 +132,7 @@ def validate_vars(value): Raises: ValidationError: when `value` not a valid vars string """ # 200 is arbitrary, but endpoints (e.g., era5wrf) have many vars - climate_var_regex = re.compile( - r"^(?=.{1,200}$)[A-Za-z0-9,_]+$" - ) + climate_var_regex = re.compile(r"^(?=.{1,200}$)[A-Za-z0-9,_]+$") if not climate_var_regex.match(value): raise ValidationError("Invalid var(s) provided.") return True diff --git a/environment.yml b/environment.yml index f53026ac..288fb7f7 100644 --- a/environment.yml +++ b/environment.yml @@ -24,3 +24,4 @@ dependencies: - pytest - pytest-html - cftime + - psycopg2 diff --git a/fetch_data.py b/fetch_data.py index 1c507e36..1a10bc44 100644 --- a/fetch_data.py +++ b/fetch_data.py @@ -14,6 +14,9 @@ import re import ast import datetime +import os +import psycopg2 +from psycopg2.extras import RealDictCursor from collections import defaultdict from functools import reduce from aiohttp import ClientSession @@ -35,6 +38,66 @@ logger = logging.getLogger(__name__) +required_vars = ["DB_HOST", "DB_NAME", "DB_USER", "DB_PASSWORD"] +db_env_var_missing = [var for var in required_vars if not os.getenv(var)] + +if db_env_var_missing: + error_msg = ( + f"Missing required environment variables: {', '.join(db_env_var_missing)}" + ) + logger.error(error_msg) + raise ValueError(error_msg) + + +def get_landslide_db_connection(): + """ + Create a database connection using environment variables. + Returns psycopg2 connection object. + """ + + try: + connection = psycopg2.connect( + host=os.getenv("DB_HOST"), + database=os.getenv("DB_NAME"), + user=os.getenv("DB_USER"), + password=os.getenv("DB_PASSWORD"), + port=5432, + ) + return connection + except Exception as e: + logger.error(f"Database connection failed: {e}") + raise + + +def get_landslide_db_row(place_name): + """ + Fetch landslide data row for a specific place from the database. + + Args: + place_name (str): The name of the place + + Returns: + list: Query results from the database + """ + connection = get_landslide_db_connection() + try: + with connection.cursor(cursor_factory=RealDictCursor) as cursor: + query = """ + SELECT * FROM precip_risk + WHERE place_name = %s + ORDER BY ts DESC + LIMIT 1 + """ + + cursor.execute(query, (place_name.capitalize(),)) + results = cursor.fetchall() + return results + except Exception as exc: + logger.error(f"Database query failed: {exc}") + raise exc + finally: + connection.close() + async def fetch_wcs_point_data(x, y, cov_id, var_coord=None): """Create the async request for data at the specified point. @@ -501,3 +564,59 @@ def cftime_value_to_ymd(time_value, base_date): """Convert a time value in days since the base date to a year, month, day tuple.""" date = base_date + datetime.timedelta(days=time_value) return date.year, date.month, date.day + + +def get_place_data(place_id): + """ + Get comprehensive place data for a given place ID. + + Args: + place_id (str): place identifier (e.g., AK124, AK182) + + Returns: + dict or None: Complete place data if found, None if not found + """ + if place_id is None: + return None + + if place_id in all_areas_full: + return all_areas_full[place_id] + + if place_id in all_communities_full: + return all_communities_full[place_id] + + return None + + +communities_features = asyncio.run( + fetch_data( + [ + generate_wfs_places_url( + "all_boundaries:all_communities", + "name,alt_name,id,region,country,type,latitude,longitude,tags,is_coastal,ocean_lat1,ocean_lon1", + ) + ] + ) +)["features"] + +areas_features = asyncio.run( + fetch_data( + [ + generate_wfs_places_url( + "all_boundaries:all_areas", + "id,name,type,area_type,alt_name,zone,subzone", + ) + ] + ) +)["features"] + +# Creates dictionaries mapping place IDs to their property dictionaries +# for fast lookup by community or area ID ("AK124") +all_communities_full = { + feature["properties"]["id"]: feature["properties"] + for feature in communities_features +} + +all_areas_full = { + feature["properties"]["id"]: feature["properties"] for feature in areas_features +} diff --git a/luts.py b/luts.py index b20c3760..80c69aa9 100644 --- a/luts.py +++ b/luts.py @@ -671,3 +671,5 @@ for model in var.values() for scenario in model ) + +valid_kuti_communityIDs = {"AK182": "Kasaan", "AK91": "Craig"} diff --git a/routes/__init__.py b/routes/__init__.py index 4956249a..b5749507 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -40,3 +40,4 @@ def enforce_site_offline(): from .places import * from .era5wrf import * from .fire_weather import * +from .landslide import * diff --git a/routes/landslide.py b/routes/landslide.py new file mode 100644 index 00000000..ec1763a0 --- /dev/null +++ b/routes/landslide.py @@ -0,0 +1,119 @@ +from flask import render_template, jsonify, abort +import logging +from datetime import datetime + +from . import routes +from fetch_data import get_landslide_db_row, get_place_data +from validate_data import place_name_and_type +from luts import valid_kuti_communityIDs + +logger = logging.getLogger(__name__) + + +def validate_community_id(community_id): + """ + Validate that the community ID is both a valid place ID and one of the supported landslide locations. + Uses the existing place validation system, then restricts to AK182 and AK91. + + Args: + community_id (str): The community ID to validate (AK91 for Craig, AK182 for Kasaan) + + Returns: + str or None: Place name if valid community ID, None if invalid + """ + + if community_id in valid_kuti_communityIDs: + return valid_kuti_communityIDs[community_id] + + return None + + +def package_landslide_data(landslide_resp, community_data=None): + """Package landslide data in dict, optionally including community data""" + if not landslide_resp or landslide_resp == []: + return None + + data = landslide_resp[0] if isinstance(landslide_resp, list) else landslide_resp + + di = { + "timestamp": str(data.get("ts", "")), + "expires_at": str(data.get("expires_at", "")), + "hour": data.get("hour"), + "precipitation_mm": data.get("precip"), + "precipitation_inches": data.get("precip_inches"), + "precipitation_24hr": data.get("precip24hr"), + "precipitation_2days": data.get("precip2days"), + "precipitation_3days": data.get("precip3days"), + "risk_level": data.get("risk_level"), + "risk_probability": data.get("risk_prob"), + "risk_24hr": data.get("risk24hr"), + "risk_2days": data.get("risk2days"), + "risk_3days": data.get("risk3days"), + "risk_is_elevated_from_previous": data.get("risk_is_elevated_from_previous"), + } + + # Add community data if provided + if community_data: + di["community"] = community_data + + return di + + +@routes.route("/landslide/") +def landslide_about(): + return render_template("documentation/landslide.html") + + +@routes.route("/landslide/") +def run_fetch_landslide_data(community_id): + """ + Run the landslide data fetch for a specific community. + + Args: + community_id (str): Community ID (AK182 for Kasaan, AK91 for Craig) + + Returns: + Rendered template or JSON response with landslide data including community info + + Example request: http://localhost:5000/landslide/AK182 + """ + place_name = validate_community_id(community_id) + if not place_name: + return render_template("400/bad_request.html"), 400 + + # Check for errors when fetching landslide data + try: + results = get_landslide_db_row(place_name) + except Exception as exc: + logger.error(f"Error fetching landslide data for {community_id}: {exc}") + return render_template("502/upstream_unreachable.html"), 502 + + # Check for errors when fetching community data + # and processing landslide data + try: + community_data = get_place_data(community_id) + + landslide_data = package_landslide_data(results, community_data) + + expires_at = landslide_data.get("expires_at") + if expires_at: + try: + expires_datetime = datetime.fromisoformat(str(expires_at)) + current_datetime = ( + datetime.now(expires_datetime.tzinfo) + if expires_datetime.tzinfo + else datetime.now() + ) + + # data are stale, return the data + HTTP code 409 + if expires_datetime < current_datetime: + return jsonify(landslide_data), 409 + + except (ValueError, TypeError) as exc: + raise exc + + return jsonify(landslide_data) + + except Exception as exc: + logger.error(f"Error in landslide endpoint for {community_id}: {exc}") + return render_template("500/server_error.html"), 500 diff --git a/routes/vectordata.py b/routes/vectordata.py index 7237de30..034560b9 100644 --- a/routes/vectordata.py +++ b/routes/vectordata.py @@ -15,25 +15,14 @@ ) from config import EAST_BBOX, WEST_BBOX, geojson_names from validate_request import validate_latlon -from generate_urls import generate_wfs_search_url, generate_wfs_places_url -from fetch_data import fetch_data +from generate_urls import generate_wfs_search_url +from fetch_data import fetch_data, all_areas_full, all_communities_full from csv_functions import create_csv data_api = Blueprint("data_api", __name__) extent_filtered_communities = {} -all_communities_full = asyncio.run( - fetch_data( - [ - generate_wfs_places_url( - "all_boundaries:all_communities", - "name,alt_name,id,region,country,type,latitude,longitude,tags,is_coastal,ocean_lat1,ocean_lon1", - ) - ] - ) -)["features"] - for extent in geojson_names: geojson_path = os.path.join( os.path.dirname(__file__), "..", "data", "geojsons", f"{extent}.geojson" @@ -42,12 +31,12 @@ gdf_extent = gdf_extent.set_crs(epsg=4326, allow_override=True) region_geom = gdf_extent.unary_union filtered = [] - for community in all_communities_full: - lat = float(community["properties"].get("latitude", 0)) - lon = float(community["properties"].get("longitude", 0)) + for community_id, community in all_communities_full.items(): + lat = float(community.get("latitude", 0)) + lon = float(community.get("longitude", 0)) pt = Point(lon, lat) if region_geom.contains(pt): - filtered.append(community) + filtered.append({"properties": community}) extent_filtered_communities[extent] = filtered @@ -100,7 +89,10 @@ def find_via_gs(lat, lon): # alternate name, id, lat, lon, and type. They are all # found within the properties of the returned JSON. for i in range(len(filtered_communities)): - proximal_di["communities"][i] = filtered_communities[i]["properties"] + if "geometry" in filtered_communities[i]: + proximal_di["communities"][i] = filtered_communities[i]["properties"] + else: + proximal_di["communities"][i] = filtered_communities[i] # WFS request to Geoserver for all polygon areas. nearby_areas = asyncio.run( @@ -207,34 +199,55 @@ def gather_nearby_area(nearby_area): return curr_di -def filter_by_tag(communities): +def filter_by_tag(communities_data): """ Filters communities by tags if tags are provided in the request. Args: - communities: All communities returned from the WFS request. + communities_data: Either a dictionary of communities with ID as key and properties as value, + or a list of GeoJSON features with properties. Returns: - Communities with the tags provided in the request, with the tags removed - from the output after filtering. + - For dictionary input: List of community properties with tags removed + - For GeoJSON list input: List of GeoJSON features with tags removed from properties """ - if request.args.get("tags"): - tags = request.args.get("tags").split(",") - filtered_communities = [] - for community in communities: - community_added = False - for tag in tags: - if not community_added: - community_tags = community["properties"]["tags"].split(",") - if tag in community_tags: - # Remove tags property from output - del community["properties"]["tags"] - - filtered_communities.append(community) - community_added = True - return filtered_communities - else: - return communities + tags = request.args.get("tags") + + if not tags: + return ( + list(communities_data.values()) + if isinstance(communities_data, dict) + else communities_data + ) + + target_tags = set(tags.split(",")) + filtered_communities = [] + + communities = ( + communities_data.items() + if isinstance(communities_data, dict) + else enumerate(communities_data) + ) + + for _, community in communities: + community_properties = ( + community if isinstance(communities_data, dict) else community["properties"] + ) + + if community_properties.get("tags") and any( + tag in community_properties["tags"].split(",") for tag in target_tags + ): + tag_filtered_props = community_properties.copy() + tag_filtered_props.pop("tags", None) + + if isinstance(communities_data, dict): + filtered_communities.append(tag_filtered_props) + else: + community_copy = community.copy() + community_copy["properties"] = tag_filtered_props + filtered_communities.append(community_copy) + + return filtered_communities @routes.route("/places/") @@ -274,56 +287,29 @@ def get_json_for_type(type, recurse=False): else: js_list = list() if type == "communities": - # Requests the Geoserver WFS URL for gathering all the communities - all_communities = asyncio.run( - fetch_data( - [ - generate_wfs_places_url( - "all_boundaries:all_communities", - "name,alt_name,id,region,country,type,latitude,longitude,tags,is_coastal,ocean_lat1,ocean_lon1", - ) - ] - ) - )["features"] - - filtered_communities = filter_by_tag(all_communities) - - # For each feature, put the properties (name, id, etc.) into the - # list for creation of a JSON object to be returned. - for i in range(len(filtered_communities)): - js_list.append(filtered_communities[i]["properties"]) + filtered_communities = filter_by_tag(all_communities_full) + + js_list.extend(filtered_communities) else: # Remove the 's' at the end of the type type = type[:-1] - # Requests the Geoserver WFS URL for gathering all the polygon areas - all_areas = asyncio.run( - fetch_data( - [ - generate_wfs_places_url( - "all_boundaries:all_areas", - "id,name,type,area_type", - type, - ) - ] - ) - )["features"] - - # For each feature, put the properties (name, id, type) into the - # list for creation of a JSON object to be returned. - for ai in range(len(all_areas)): - # HUC12s do not play well with Northern Climate Reports. - # Remove them from /places endpoints for now. - if all_areas[ai]["properties"]["area_type"] == "HUC12": - continue - - # If this area is a protected_area, keep area_type in - # returned output. - if all_areas[ai]["properties"]["area_type"] != "": - js_list.append(all_areas[ai]["properties"]) - else: - del all_areas[ai]["properties"]["area_type"] - js_list.append(all_areas[ai]["properties"]) + # Filter areas by type and process them directly + for area_id, area_props in all_areas_full.items(): + if area_props["type"] == type: + # HUC12s do not play well with Northern Climate Reports. + # Remove them from /places endpoints for now. + area_type = area_props.get("area_type", "") + if area_type == "HUC12": + continue + + if area_type != "": + js_list.append(area_props) + else: + area_copy = area_props.copy() + if "area_type" in area_copy: + del area_copy["area_type"] + js_list.append(area_copy) # Creates JSON object from created list js = json.dumps(js_list) @@ -355,7 +341,9 @@ def get_communities(): if extent in geojson_names: all_communities = extent_filtered_communities[extent] else: - all_communities = all_communities_full + all_communities = [ + {"properties": props} for props in all_communities_full.values() + ] # Filter by substring if provided substring = request.args.get("substring") diff --git a/static/502.jpg b/static/502.jpg new file mode 100644 index 00000000..edb63479 Binary files /dev/null and b/static/502.jpg differ diff --git a/templates/502/upstream_unreachable.html b/templates/502/upstream_unreachable.html new file mode 100644 index 00000000..3b182474 --- /dev/null +++ b/templates/502/upstream_unreachable.html @@ -0,0 +1,8 @@ +{% extends 'base.html' %} {% block content %} +

Upstream Data Unreachable

+

+ Unable to access upstream data. Contact uaf-snap-sys-team@alaska.edu for help. +

+ + +{% endblock %} diff --git a/templates/documentation/landslide.html b/templates/documentation/landslide.html new file mode 100644 index 00000000..6d43ac43 --- /dev/null +++ b/templates/documentation/landslide.html @@ -0,0 +1,144 @@ +{% extends 'base.html' %} {% block content %} +

Landslide Risk Data

+ +

+ This service endpoint provides access to landslide risk data based on + precipitation measurements. The system monitors precipitation levels and + calculates landslide risk probabilities for specific locations in Southeast + Alaska (Craig and Kasaan). Data includes current precipitation measurements, + accumulated precipitation over multiple time periods (24 hours, 2 days, 3 + days), and corresponding risk levels. +

+ +

Service endpoints

+ +

Location query

+ +

+ Query landslide risk data for a specific location by community ID. Currently + supported communities are Craig (AK91) and Kasaan (AK182). +

+ + + + + + + + + + + + + + + + + + +
EndpointExample URL
Landslide risk data for Craig + /landslide/AK91 +
Landslide risk data for Kasaan + /landslide/AK182 +
+ +

Output

+ +

Results from landslide risk queries will look like this:

+ +

+ Note: The response includes both landslide risk data and + comprehensive community information from the places service, providing + geographic and administrative details about the queried location. +

+ +
+{
+  "place_name": "Craig",
+  "timestamp": "2024-12-02 15:30:00",
+  "expires_at": "2024-12-02 18:30:00",
+  "hour": "15:30",
+  "precipitation_mm": 12.5,
+  "precipitation_inches": 0.49,
+  "precipitation_24hr": 45.2,
+  "precipitation_2days": 78.1,
+  "precipitation_3days": 95.6,
+  "risk_level": 2,
+  "risk_probability": 0.25,
+  "risk_24hr": 3,
+  "risk_2days": 4,
+  "risk_3days": 4,
+  "community": {
+    "name": "Craig",
+    "alt_name": "",
+    "id": "AK91",
+    "region": "Southeast Alaska",
+    "country": "US",
+    "type": "community",
+    "latitude": 55.4769,
+    "longitude": -133.1478,
+    "is_coastal": true,
+    "ocean_lat1": 55.4769,
+    "ocean_lon1": -133.1478
+  }
+}
+
+ +

The above output is structured like this:

+ +
+{
+  "place_name": <location name where the measurement was taken>,
+  "timestamp": <timestamp of the measurement>,
+  "expires_at": <timestamp when this data expires>,
+  "hour": <time of measurement (HH:MM format)>,
+  "precipitation_mm": <current precipitation measurement in millimeters>,
+  "precipitation_inches": <current precipitation measurement in inches>,
+  "precipitation_24hr": <accumulated precipitation over 24 hours (mm)>,
+  "precipitation_2days": <accumulated precipitation over 2 days (mm)>,
+  "precipitation_3days": <accumulated precipitation over 3 days (mm)>,
+  "risk_level": <integer risk level (higher values indicate greater risk)>,
+  "risk_probability": <landslide risk probability (0.0 to 1.0)>,
+  "risk_24hr": <risk level based on 24-hour precipitation>,
+  "risk_2days": <risk level based on 2-day precipitation>,
+  "risk_3days": <risk level based on 3-day precipitation>,
+  "risk_is_elevated_from_previous": <boolean indicating if risk increased>,
+  "community": {
+    "name": <community name>,
+    "alt_name": <alternative community name, if any>,
+    "id": <community ID (e.g., AK91, AK182)>,
+    "region": <geographic region>,
+    "country": <country code>,
+    "type": <location type (typically "community")>,
+    "latitude": <community latitude coordinate>,
+    "longitude": <community longitude coordinate>,
+    "is_coastal": <boolean indicating if community is coastal>,
+    "ocean_lat1": <nearest ocean point latitude>,
+    "ocean_lon1": <nearest ocean point longitude>
+  }
+}
+
+ +

Data Structure and Processing

+ +

+ The landslide risk data processing and data structure definitions are + maintained in the + kuti-lambda repository. This repository contains the AWS Lambda function responsible for: +

+ + + +

+ The system is designed to update data every 3 hours, with automatic expiration + of older records to maintain current and relevant risk assessments. +

+ +{% endblock %} diff --git a/tests/test_landslide.py b/tests/test_landslide.py new file mode 100644 index 00000000..2a97c465 --- /dev/null +++ b/tests/test_landslide.py @@ -0,0 +1,337 @@ +import json +import pytest +from unittest.mock import Mock + +###################################### +# 1. Database Unreachable Test Case # +###################################### + + +def test_landslide_get_landslide_db_connection_failed(client, monkeypatch): + """ + Tests the /landslide/ endpoint when get_landslide_db_row raises an exception, + expecting a 502 error (upstream unreachable). + """ + + # Mock get_landslide_db_row to raise an exception + def mock_get_landslide_db_row(place_name): + raise Exception("Database connection failed") + + monkeypatch.setattr( + "routes.landslide.get_landslide_db_row", mock_get_landslide_db_row + ) + + response = client.get("/landslide/AK91") + assert response.status_code == 502 + + +################################################# +# 2. Stale Data and Processing Error Test Cases # +################################################# + + +def test_landslide_package_data_stale_datetime(client, monkeypatch): + """ + Tests the /landslide/ endpoint when the data has a stale expires_at datetime, + expecting a 200 status code with error_code 409 in the JSON response. + """ + + def mock_get_landslide_db_row(place_name): + return [ + { + "ts": "2023-12-04T10:00:00Z", + "expires_at": "2023-12-03T18:00:00Z", + "hour": 10, + "precip": 25.4, + "precip_inches": 1.0, + "precip24hr": 50.8, + "precip2days": 76.2, + "precip3days": 101.6, + "risk_level": "moderate", + "risk_prob": 0.6, + "risk24hr": 0.5, + "risk2days": 0.7, + "risk3days": 0.8, + "risk_is_elevated_from_previous": True, + } + ] + + def mock_get_place_data(community_id): + return { + "id": "AK91", + "name": "Craig", + "alt_name": "", + "country": "United States", + "is_coastal": True, + "latitude": 55.4756, + "longitude": -133.1481, + "ocean_lat1": 55.5, + "ocean_lon1": -133.2, + "region": "Southeast", + "tags": ["community"], + "type": "community", + } + + monkeypatch.setattr( + "routes.landslide.get_landslide_db_row", mock_get_landslide_db_row + ) + monkeypatch.setattr("routes.landslide.get_place_data", mock_get_place_data) + + response = client.get("/landslide/AK91") + assert response.status_code == 409 + + +def test_landslide_general_exception_in_processing(client, monkeypatch): + """ + Tests the /landslide/ endpoint when a general exception occurs during processing, + expecting a 500 error. + """ + + def mock_get_landslide_db_row(place_name): + return [ + { + "ts": "2023-12-04T10:00:00Z", + "expires_at": "2023-12-04T18:00:00Z", + "hour": 10, + "precip": 25.4, + "precip_inches": 1.0, + "precip24hr": 50.8, + "precip2days": 76.2, + "precip3days": 101.6, + "risk_level": "moderate", + "risk_prob": 0.6, + "risk24hr": 0.5, + "risk2days": 0.7, + "risk3days": 0.8, + "risk_is_elevated_from_previous": True, + } + ] + + def mock_get_place_data(community_id): + return { + "id": "AK91", + "name": "Craig", + "alt_name": "", + "country": "United States", + "is_coastal": True, + "latitude": 55.4756, + "longitude": -133.1481, + "ocean_lat1": 55.5, + "ocean_lon1": -133.2, + "region": "Southeast", + "tags": ["community"], + "type": "community", + } + + def mock_package_landslide_data(place_id): + exc = Exception("Unexpected error during place data processing") + raise exc + + monkeypatch.setattr( + "routes.landslide.get_landslide_db_row", mock_get_landslide_db_row + ) + monkeypatch.setattr("routes.landslide.get_place_data", mock_get_place_data) + monkeypatch.setattr( + "routes.landslide.package_landslide_data", mock_package_landslide_data + ) + + response = client.get("/landslide/AK91") + assert response.status_code == 500 + + +def test_landslide_bad_datetime_parsing_error(client, monkeypatch): + """ + Tests the /landslide/ endpoint when the expires_at datetime is malformed + and causes a parsing error, expecting a 500 error. + """ + + def mock_get_landslide_db_row(place_name): + return [ + { + "ts": "2023-12-04T10:00:00Z", + "expires_at": "12-04-2023T18:00:00Z", + "hour": 10, + "precip": 25.4, + "precip_inches": 1.0, + "precip24hr": 50.8, + "precip2days": 76.2, + "precip3days": 101.6, + "risk_level": "moderate", + "risk_prob": 0.6, + "risk24hr": 0.5, + "risk2days": 0.7, + "risk3days": 0.8, + "risk_is_elevated_from_previous": True, + } + ] + + def mock_get_place_data(community_id): + return { + "id": "AK91", + "name": "Craig", + "alt_name": "", + "country": "United States", + "is_coastal": True, + "latitude": 55.4756, + "longitude": -133.1481, + "ocean_lat1": 55.5, + "ocean_lon1": -133.2, + "region": "Southeast", + "tags": ["community"], + "type": "community", + } + + monkeypatch.setattr( + "routes.landslide.get_landslide_db_row", mock_get_landslide_db_row + ) + + monkeypatch.setattr("routes.landslide.get_place_data", mock_get_place_data) + + response = client.get("/landslide/AK91") + assert response.status_code == 500 + + +################################### +# 3. Valid Community Test Cases # +################################### + + +def test_landslide_ak91(client): + """ + Tests the /landslide/AK91 endpoint to ensure the output + contains all required keys for Craig, AK. + """ + response = client.get("/landslide/AK91") + assert response.status_code == 200 + actual_data = response.get_json() + + # Required top-level keys + required_keys = [ + "community", + "expires_at", + "hour", + "precipitation_24hr", + "precipitation_2days", + "precipitation_3days", + "precipitation_inches", + "precipitation_mm", + "risk_24hr", + "risk_2days", + "risk_3days", + "risk_level", + "risk_probability", + "timestamp", + ] + + # Check that all required keys exist + for key in required_keys: + assert key in actual_data, f"Missing required key: {key}" + + # Required community keys + required_community_keys = [ + "alt_name", + "country", + "id", + "is_coastal", + "latitude", + "longitude", + "name", + "ocean_lat1", + "ocean_lon1", + "region", + "tags", + "type", + ] + + # Check that community data exists and has required keys + assert "community" in actual_data + community_data = actual_data["community"] + for key in required_community_keys: + assert key in community_data, f"Missing required community key: {key}" + + # Verify community ID is correct for this endpoint + assert community_data["id"] == "AK91" + assert community_data["name"] == "Craig" + + +def test_landslide_ak182(client): + """ + Tests the /landslide/AK182 endpoint to ensure the output + contains all required keys for Kasaan, AK. + """ + response = client.get("/landslide/AK182") + assert response.status_code == 200 + actual_data = response.get_json() + + # Required top-level keys + required_keys = [ + "community", + "expires_at", + "hour", + "precipitation_24hr", + "precipitation_2days", + "precipitation_3days", + "precipitation_inches", + "precipitation_mm", + "risk_24hr", + "risk_2days", + "risk_3days", + "risk_level", + "risk_probability", + "timestamp", + ] + + # Check that all required keys exist + for key in required_keys: + assert key in actual_data, f"Missing required key: {key}" + + # Required community keys + required_community_keys = [ + "alt_name", + "country", + "id", + "is_coastal", + "latitude", + "longitude", + "name", + "ocean_lat1", + "ocean_lon1", + "region", + "tags", + "type", + ] + + # Check that community data exists and has required keys + assert "community" in actual_data + community_data = actual_data["community"] + for key in required_community_keys: + assert key in community_data, f"Missing required community key: {key}" + + # Verify community ID is correct for this endpoint + assert community_data["id"] == "AK182" + assert community_data["name"] == "Kasaan" + + +################################################# +# 4. Invalid / Unsupported Community Test Cases # +################################################# + + +def test_landslide_invalid_community(client): + """ + Tests the /landslide/ endpoint with an invalid community ID + to ensure proper error handling. + """ + response = client.get("/landslide/INVALID") + assert response.status_code == 400 + + +def test_landslide_valid_but_unsupported_community(client): + """ + Tests the /landslide/ endpoint with a valid community ID + that is not supported for landslide data. + """ + response = client.get( + "/landslide/AK124" + ) # Fairbanks - valid place but not supported for landslides + assert response.status_code == 400 diff --git a/validate_data.py b/validate_data.py index 48217d42..3c33db83 100644 --- a/validate_data.py +++ b/validate_data.py @@ -1,9 +1,7 @@ """A module to validate fetched data values.""" -import asyncio from datetime import datetime -from generate_urls import generate_wfs_places_url -from fetch_data import fetch_data +from fetch_data import all_areas_full, all_communities_full def place_name_and_type(place_id): @@ -20,43 +18,19 @@ def place_name_and_type(place_id): if place_id is None: return None, None - place = asyncio.run( - fetch_data( - [ - generate_wfs_places_url( - "all_boundaries:all_areas", - "name,alt_name,type", - place_id, - "id", - ) - ] - ) - ) - if place["numberMatched"] > 0: - place = place["features"][0]["properties"] - full_place = place["name"] - if place["alt_name"] != "": - full_place += " (" + place["alt_name"] + ")" - return full_place, place["type"] - else: - place = asyncio.run( - fetch_data( - [ - generate_wfs_places_url( - "all_boundaries:all_communities", - "name,alt_name,type", - place_id, - "id", - ) - ] - ) - ) - if place["numberMatched"] > 0: - place = place["features"][0]["properties"] - full_place = place["name"] - if place["alt_name"] != "": - full_place += " (" + place["alt_name"] + ")" - return full_place, place["type"] + if place_id in all_areas_full: + place = all_areas_full[place_id] + full_name = place["name"] + if place.get("alt_name", "") != "": + full_name += " (" + place["alt_name"] + ")" + return full_name, place["type"] + + if place_id in all_communities_full: + place = all_communities_full[place_id] + full_name = place["name"] + if place.get("alt_name", "") != "": + full_name += " (" + place["alt_name"] + ")" + return full_name, place["type"] return None, None diff --git a/validate_request.py b/validate_request.py index 44ac7659..f8e8170e 100644 --- a/validate_request.py +++ b/validate_request.py @@ -17,8 +17,7 @@ from rasterio.crs import CRS from config import WEST_BBOX, EAST_BBOX, SEAICE_BBOX -from generate_urls import generate_wfs_places_url -from fetch_data import fetch_data +from fetch_data import all_areas_full from luts import geotiff_projections @@ -254,17 +253,10 @@ def validate_var_id(var_id): if not var_id.isalnum(): return render_template("400/bad_request.html"), 400 - var_id_check = asyncio.run( - fetch_data( - [generate_wfs_places_url("all_boundaries:all_areas", "type", var_id, "id")] - ) - ) - - if var_id_check["numberMatched"] > 0: - return var_id_check["features"][0]["properties"]["type"] + if var_id in all_areas_full: + return all_areas_full[var_id]["type"] - else: - return render_template("422/invalid_area.html"), 400 + return render_template("422/invalid_area.html"), 400 def project_latlon(lat1, lon1, dst_crs, lat2=None, lon2=None):