From b972dfac00509abed050d4937898337674f0717b Mon Sep 17 00:00:00 2001 From: Bob Torgerson Date: Fri, 5 Dec 2025 11:29:59 -0900 Subject: [PATCH 1/3] =?UTF-8?q?Adds=20an=20API=20endpoint=20to=20retrieve?= =?UTF-8?q?=20the=20landslide=20risk=20data=20for=20the=20=E1=B8=B5ut?= =?UTF-8?q?=C3=AD=20project=20(#667)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application.py | 5 +- environment.yml | 1 + fetch_data.py | 119 ++++++++ luts.py | 2 + routes/__init__.py | 1 + routes/landslide.py | 118 ++++++++ routes/vectordata.py | 160 +++++------ static/502.jpg | Bin 0 -> 28694 bytes templates/502/upstream_unreachable.html | 8 + templates/documentation/landslide.html | 144 ++++++++++ tests/test_landslide.py | 343 ++++++++++++++++++++++++ validate_data.py | 54 +--- validate_request.py | 16 +- 13 files changed, 830 insertions(+), 141 deletions(-) create mode 100644 routes/landslide.py create mode 100644 static/502.jpg create mode 100644 templates/502/upstream_unreachable.html create mode 100644 templates/documentation/landslide.html create mode 100644 tests/test_landslide.py 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..a8d51970 --- /dev/null +++ b/routes/landslide.py @@ -0,0 +1,118 @@ +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"), + } + + expires_at = 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() + ) + + if expires_datetime < current_datetime: + di["error_code"] = 409 + di["error_msg"] = "Data is stale" + except (ValueError, TypeError) as exc: + raise exc + + # 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) + + 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 0000000000000000000000000000000000000000..edb634793b326f0c7462ce11d93a69ed995f45bf GIT binary patch literal 28694 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�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 H{a*NA@&^RI literal 0 HcmV?d00001 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: +

+ +
    +
  • Processing precipitation data from weather monitoring stations
  • +
  • Calculating landslide risk probabilities using established thresholds
  • +
  • Inserting processed data into the PostgreSQL database
  • +
  • Managing data expiration and cleanup processes
  • +
+ +

+ 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..10348ee1 --- /dev/null +++ b/tests/test_landslide.py @@ -0,0 +1,343 @@ +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 == 200 + + actual_data = response.get_json() + assert "error_code" in actual_data + assert actual_data["error_code"] == 409 + assert "error_msg" in actual_data + assert actual_data["error_msg"] == "Data is stale" + + +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): From 1c057419a30e9bbddf831e66ff4865202abc42d5 Mon Sep 17 00:00:00 2001 From: Bruce Crevensten Date: Tue, 9 Dec 2025 16:01:19 -0800 Subject: [PATCH 2/3] Changes response to be HTTP 409 if data are stale --- routes/landslide.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/routes/landslide.py b/routes/landslide.py index a8d51970..ec1763a0 100644 --- a/routes/landslide.py +++ b/routes/landslide.py @@ -52,22 +52,6 @@ def package_landslide_data(landslide_resp, community_data=None): "risk_is_elevated_from_previous": data.get("risk_is_elevated_from_previous"), } - expires_at = 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() - ) - - if expires_datetime < current_datetime: - di["error_code"] = 409 - di["error_msg"] = "Data is stale" - except (ValueError, TypeError) as exc: - raise exc - # Add community data if provided if community_data: di["community"] = community_data @@ -111,6 +95,23 @@ def run_fetch_landslide_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: From 719d1fe477fc488d2cfb9f0209c4597592db6c56 Mon Sep 17 00:00:00 2001 From: Bob Torgerson Date: Tue, 9 Dec 2025 15:23:45 -0900 Subject: [PATCH 3/3] Updates landslide test to check for HTTP response code of 409. --- tests/test_landslide.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/test_landslide.py b/tests/test_landslide.py index 10348ee1..2a97c465 100644 --- a/tests/test_landslide.py +++ b/tests/test_landslide.py @@ -78,13 +78,7 @@ def mock_get_place_data(community_id): monkeypatch.setattr("routes.landslide.get_place_data", mock_get_place_data) response = client.get("/landslide/AK91") - assert response.status_code == 200 - - actual_data = response.get_json() - assert "error_code" in actual_data - assert actual_data["error_code"] == 409 - assert "error_msg" in actual_data - assert actual_data["error_msg"] == "Data is stale" + assert response.status_code == 409 def test_landslide_general_exception_in_processing(client, monkeypatch):