Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ dependencies:
- pytest
- pytest-html
- cftime
- psycopg2
119 changes: 119 additions & 0 deletions fetch_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions luts.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,3 +671,5 @@
for model in var.values()
for scenario in model
)

valid_kuti_communityIDs = {"AK182": "Kasaan", "AK91": "Craig"}
1 change: 1 addition & 0 deletions routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ def enforce_site_offline():
from .places import *
from .era5wrf import *
from .fire_weather import *
from .landslide import *
119 changes: 119 additions & 0 deletions routes/landslide.py
Original file line number Diff line number Diff line change
@@ -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/<community_id>")
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
Loading
Loading