diff --git a/.vscode/settings.json b/.vscode/settings.json index 00bdbdc0c4..dc8ad8381f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -54,14 +54,20 @@ ], "typescript.preferences.importModuleSpecifier": "non-relative", "cSpell.words": [ + "actuals", "aiobotocore", + "aiohttp", "Albers", "allclose", "anyio", "APCP", + "Behaviour", "botocore", "cffdrs", + "determinates", "excinfo", + "fastapi", + "fireweather", "firezone", "GDPS", "GEOGCS", @@ -69,14 +75,19 @@ "geotiff", "grib", "gribs", + "HAINES", "hourlies", "HRDPS", + "idir", + "Indeterminates", "luxon", "maxx", "maxy", "miny", + "morecast", "ndarray", "numba", + "ORJSON", "osgeo", "PMTILES", "polygonize", @@ -85,6 +96,7 @@ "PRECIP", "PRIMEM", "PROJCS", + "pydantic", "RDPS", "rocketchat", "sfms", @@ -96,6 +108,7 @@ "vsimem", "vsis", "WDIR", + "wfwx", "Whitebox" ], "autoDocstring.docstringFormat": "sphinx-notypes", diff --git a/api/app/morecast_v2/forecasts.py b/api/app/morecast_v2/forecasts.py index 02e0360c0f..a4648185a3 100644 --- a/api/app/morecast_v2/forecasts.py +++ b/api/app/morecast_v2/forecasts.py @@ -1,4 +1,3 @@ - from datetime import datetime, time, timedelta, timezone from urllib.parse import urljoin from app import config @@ -22,38 +21,45 @@ def get_forecasts(db_session: Session, start_time: Optional[datetime], end_time: result = get_forecasts_in_range(db_session, start_time, end_time, station_codes) - forecasts: List[MoreCastForecastOutput] = [MoreCastForecastOutput(station_code=forecast.station_code, - for_date=forecast.for_date.timestamp() * 1000, - temp=forecast.temp, - rh=forecast.rh, - precip=forecast.precip, - wind_speed=forecast.wind_speed, - wind_direction=forecast.wind_direction, - grass_curing=forecast.grass_curing, - update_timestamp=int(forecast.update_timestamp.timestamp())) for forecast in result] + forecasts: List[MoreCastForecastOutput] = [ + MoreCastForecastOutput( + station_code=forecast.station_code, + for_date=forecast.for_date.timestamp() * 1000, + temp=forecast.temp, + rh=forecast.rh, + precip=forecast.precip, + wind_speed=forecast.wind_speed, + wind_direction=forecast.wind_direction, + grass_curing=forecast.grass_curing, + update_timestamp=int(forecast.update_timestamp.timestamp()), + ) + for forecast in result + ] return forecasts def construct_wf1_forecast(forecast: MoreCastForecastInput, stations: List[WFWXWeatherStation], forecast_id: Optional[str], created_by: Optional[str]) -> WF1PostForecast: station = next(filter(lambda obj: obj.code == forecast.station_code, stations)) station_id = station.wfwx_id - station_url = urljoin(config.get('WFWX_BASE_URL'), f'wfwx-fireweather-api/v1/stations/{station_id}') - wf1_post_forecast = WF1PostForecast(createdBy=created_by, - id=forecast_id, - stationId=station_id, - station=station_url, - temperature=forecast.temp, - relativeHumidity=forecast.rh, - precipitation=forecast.precip, - windSpeed=forecast.wind_speed, - windDirection=forecast.wind_direction, - weatherTimestamp=forecast.for_date, - recordType=WF1ForecastRecordType(), - grasslandCuring=forecast.grass_curing) + station_url = urljoin(config.get("WFWX_BASE_URL"), f"wfwx-fireweather-api/v1/stations/{station_id}") + wf1_post_forecast = WF1PostForecast( + createdBy=created_by, + id=forecast_id, + stationId=station_id, + station=station_url, + temperature=forecast.temp, + relativeHumidity=forecast.rh, + precipitation=forecast.precip, + windSpeed=forecast.wind_speed, + windDirection=forecast.wind_direction, + weatherTimestamp=forecast.for_date, + recordType=WF1ForecastRecordType(), + grasslandCuring=forecast.grass_curing, + ) return wf1_post_forecast -async def construct_wf1_forecasts(session: ClientSession, forecast_records: List[MoreCastForecastInput], stations: List[WFWXWeatherStation]) -> List[WF1PostForecast]: +async def construct_wf1_forecasts(session: ClientSession, forecast_records: List[MoreCastForecastInput], stations: List[WFWXWeatherStation], username: str) -> List[WF1PostForecast]: # Fetch existing forecasts from WF1 for the stations and date range in the forecast records header = await get_auth_header(session) forecast_dates = [datetime.fromtimestamp(f.for_date / 1000, timezone.utc) for f in forecast_records] @@ -62,8 +68,7 @@ async def construct_wf1_forecasts(session: ClientSession, forecast_records: List start_time = vancouver_tz.localize(datetime.combine(min_forecast_date, time.min)) end_time = vancouver_tz.localize(datetime.combine(max_forecast_date, time.max)) unique_station_codes = list(set([f.station_code for f in forecast_records])) - dailies = await get_forecasts_for_stations_by_date_range(session, header, start_time, - end_time, unique_station_codes) + dailies = await get_forecasts_for_stations_by_date_range(session, header, start_time, end_time, unique_station_codes) # Shape the WF1 dailies into a dictionary keyed by station codes for easier consumption grouped_dailies = defaultdict(list[StationDailyFromWF1]) @@ -75,36 +80,32 @@ async def construct_wf1_forecasts(session: ClientSession, forecast_records: List for forecast in forecast_records: forecast_timestamp = datetime.fromtimestamp(forecast.for_date / 1000, timezone.utc) # Check if an existing daily was retrieved from WF1 and use id and createdBy attributes if present - observed_daily = next( - (daily for daily in grouped_dailies[forecast.station_code] if daily.utcTimestamp == forecast_timestamp), None) + observed_daily = next((daily for daily in grouped_dailies[forecast.station_code] if daily.utcTimestamp == forecast_timestamp), None) forecast_id = observed_daily.forecast_id if observed_daily is not None else None - created_by = observed_daily.created_by if observed_daily is not None else None + created_by = observed_daily.created_by if observed_daily is not None else username wf1_forecasts.append(construct_wf1_forecast(forecast, stations, forecast_id, created_by)) return wf1_forecasts -async def format_as_wf1_post_forecasts(session: ClientSession, forecast_records: List[MoreCastForecastInput]) -> List[WF1PostForecast]: - """ Returns list of forecast records re-formatted in the data structure WF1 API expects """ - header = await get_auth_header(session) +async def format_as_wf1_post_forecasts(session: ClientSession, forecast_records: List[MoreCastForecastInput], username: str, headers: dict) -> List[WF1PostForecast]: + """Returns list of forecast records re-formatted in the data structure WF1 API expects""" station_codes = [record.station_code for record in forecast_records] - stations = await get_wfwx_stations_from_station_codes(session, header, station_codes) + stations = await get_wfwx_stations_from_station_codes(session, headers, station_codes) unique_stations = list(set(stations)) - wf1_post_forecasts = await construct_wf1_forecasts(session, forecast_records, unique_stations) + wf1_post_forecasts = await construct_wf1_forecasts(session, forecast_records, unique_stations, username) return wf1_post_forecasts def actual_exists(forecast: WeatherIndeterminate, actuals: List[WeatherIndeterminate]): - """ Returns True if the actuals contain a WeatherIndeterminate with station_code and utc_timestamp that + """Returns True if the actuals contain a WeatherIndeterminate with station_code and utc_timestamp that matches those of the forecast; otherwise, returns False.""" - station_code_matches = [actual for actual in actuals if actual.station_code == - forecast.station_code] - utc_timestamp_matches = [station_code_match for station_code_match in station_code_matches - if station_code_match.utc_timestamp == forecast.utc_timestamp] + station_code_matches = [actual for actual in actuals if actual.station_code == forecast.station_code] + utc_timestamp_matches = [station_code_match for station_code_match in station_code_matches if station_code_match.utc_timestamp == forecast.utc_timestamp] return len(utc_timestamp_matches) > 0 def filter_for_api_forecasts(forecasts: List[WeatherIndeterminate], actuals: List[WeatherIndeterminate]): - """ Returns a list of forecasts where each forecast has a corresponding WeatherIndeterminate in the + """Returns a list of forecasts where each forecast has a corresponding WeatherIndeterminate in the actuals with a matching station_code and utc_timestamp.""" filtered_forecasts = [] for forecast in forecasts: @@ -132,23 +133,20 @@ def get_fwi_values(actuals: List[WeatherIndeterminate], forecasts: List[WeatherI indeterminates_dict[indeterminate.station_code][indeterminate.utc_timestamp.date()] = indeterminate for idx, indeterminate in enumerate(all_indeterminates): - last_indeterminate = indeterminates_dict[indeterminate.station_code].get( - indeterminate.utc_timestamp.date() - timedelta(days=1), None) + last_indeterminate = indeterminates_dict[indeterminate.station_code].get(indeterminate.utc_timestamp.date() - timedelta(days=1), None) if last_indeterminate is not None: updated_forecast = calculate_fwi_values(last_indeterminate, indeterminate) all_indeterminates[idx] = updated_forecast - updated_forecasts = [indeterminate for indeterminate in all_indeterminates if indeterminate.determinate == - WeatherDeterminate.FORECAST] - updated_actuals = [ - indeterminate for indeterminate in all_indeterminates if indeterminate.determinate == WeatherDeterminate.ACTUAL] + updated_forecasts = [indeterminate for indeterminate in all_indeterminates if indeterminate.determinate == WeatherDeterminate.FORECAST] + updated_actuals = [indeterminate for indeterminate in all_indeterminates if indeterminate.determinate == WeatherDeterminate.ACTUAL] return updated_actuals, updated_forecasts def calculate_fwi_values(yesterday: WeatherIndeterminate, today: WeatherIndeterminate) -> WeatherIndeterminate: """ - Uses CFFDRS library to calculate Fire Weather Index System values + Uses CFFDRS library to calculate Fire Weather Index System values :param yesterday: The WeatherIndeterminate from the day before the date to calculate :type yesterday: WeatherIndeterminate @@ -159,7 +157,7 @@ def calculate_fwi_values(yesterday: WeatherIndeterminate, today: WeatherIndeterm """ # weather params for calculation date - month_to_calculate_for = int(today.utc_timestamp.strftime('%m')) + month_to_calculate_for = int(today.utc_timestamp.strftime("%m")) latitude = today.latitude temp = today.temperature rh = today.relative_humidity @@ -167,32 +165,19 @@ def calculate_fwi_values(yesterday: WeatherIndeterminate, today: WeatherIndeterm wind_spd = today.wind_speed if yesterday.fine_fuel_moisture_code is not None: - today.fine_fuel_moisture_code = cffdrs.fine_fuel_moisture_code(ffmc=yesterday.fine_fuel_moisture_code, - temperature=temp, - relative_humidity=rh, - precipitation=precip, - wind_speed=wind_spd) + today.fine_fuel_moisture_code = cffdrs.fine_fuel_moisture_code( + ffmc=yesterday.fine_fuel_moisture_code, temperature=temp, relative_humidity=rh, precipitation=precip, wind_speed=wind_spd + ) if yesterday.duff_moisture_code is not None: - today.duff_moisture_code = cffdrs.duff_moisture_code(dmc=yesterday.duff_moisture_code, - temperature=temp, - relative_humidity=rh, - precipitation=precip, - latitude=latitude, - month=month_to_calculate_for, - latitude_adjust=True - ) + today.duff_moisture_code = cffdrs.duff_moisture_code( + dmc=yesterday.duff_moisture_code, temperature=temp, relative_humidity=rh, precipitation=precip, latitude=latitude, month=month_to_calculate_for, latitude_adjust=True + ) if yesterday.drought_code is not None: - today.drought_code = cffdrs.drought_code(dc=yesterday.drought_code, - temperature=temp, - relative_humidity=rh, - precipitation=precip, - latitude=latitude, - month=month_to_calculate_for, - latitude_adjust=True - ) + today.drought_code = cffdrs.drought_code( + dc=yesterday.drought_code, temperature=temp, relative_humidity=rh, precipitation=precip, latitude=latitude, month=month_to_calculate_for, latitude_adjust=True + ) if today.fine_fuel_moisture_code is not None: - today.initial_spread_index = cffdrs.initial_spread_index(ffmc=today.fine_fuel_moisture_code, - wind_speed=today.wind_speed) + today.initial_spread_index = cffdrs.initial_spread_index(ffmc=today.fine_fuel_moisture_code, wind_speed=today.wind_speed) if today.duff_moisture_code is not None and today.drought_code is not None: today.build_up_index = cffdrs.bui_calc(dmc=today.duff_moisture_code, dc=today.drought_code) if today.initial_spread_index is not None and today.build_up_index is not None: diff --git a/api/app/routers/morecast_v2.py b/api/app/routers/morecast_v2.py index 11296fbe56..cfca4c49d2 100644 --- a/api/app/routers/morecast_v2.py +++ b/api/app/routers/morecast_v2.py @@ -1,4 +1,5 @@ -""" Routes for Morecast v2 """ +"""Routes for Morecast v2""" + import logging from aiohttp.client import ClientSession from app.morecast_v2.forecasts import format_as_wf1_post_forecasts @@ -7,9 +8,7 @@ from datetime import date, datetime, time, timedelta, timezone from fastapi import APIRouter, Response, Depends, status, HTTPException from fastapi.responses import ORJSONResponse -from app.auth import (auth_with_forecaster_role_required, - audit, - authentication_required) +from app.auth import auth_with_forecaster_role_required, audit, authentication_required from app.db.crud.grass_curing import get_percent_grass_curing_by_station_for_date_range from app.db.crud.morecast_v2 import get_forecasts_in_range, get_user_forecasts_for_date, save_all_forecasts from app.db.database import get_read_session_scope, get_write_session_scope @@ -29,9 +28,7 @@ from app.wildfire_one.schema_parsers import transform_morecastforecastoutput_to_weatherindeterminate from app.utils.time import get_hour_20_from_date, get_utc_now from app.weather_models.fetch.predictions import fetch_latest_model_run_predictions_by_station_code_and_date_range -from app.wildfire_one.wfwx_api import (get_auth_header, - get_dailies_for_stations_and_date, - get_daily_determinates_for_stations_and_date, get_wfwx_stations_from_station_codes) +from app.wildfire_one.wfwx_api import get_auth_header, get_dailies_for_stations_and_date, get_daily_determinates_for_stations_and_date, get_wfwx_stations_from_station_codes from app.wildfire_one.wfwx_post_api import post_forecasts from app.utils.redis import clear_cache_matching @@ -40,21 +37,16 @@ no_cache = "max-age=0" # don't let the browser cache this -router = APIRouter( - prefix="/morecast-v2", - dependencies=[Depends(authentication_required), Depends(audit)] -) +router = APIRouter(prefix="/morecast-v2", dependencies=[Depends(authentication_required), Depends(audit)]) @router.get("/forecasts/{for_date}") -async def get_forecasts_for_date_and_user(for_date: date, - response: Response, - token=Depends(authentication_required)) -> List[MorecastForecastResponse]: - """ Return forecasts """ - logger.info('/forecasts/') +async def get_forecasts_for_date_and_user(for_date: date, response: Response, token=Depends(authentication_required)) -> List[MorecastForecastResponse]: + """Return forecasts""" + logger.info("/forecasts/") response.headers["Cache-Control"] = no_cache - username = token.get('idir_username', None) + username = token.get("idir_username", None) with get_read_session_scope() as db_session: return get_user_forecasts_for_date(db_session, username, for_date) @@ -62,7 +54,7 @@ async def get_forecasts_for_date_and_user(for_date: date, @router.post("/forecasts/{start_date}/{end_date}") async def get_forecasts_by_date_range(start_date: date, end_date: date, request: StationsRequest, response: Response): - """ Return forecasts for the specified date range and stations """ + """Return forecasts for the specified date range and stations""" logger.info(f"/forecasts/{start_date}/{end_date}") response.headers["Cache-Control"] = no_cache @@ -71,76 +63,88 @@ async def get_forecasts_by_date_range(start_date: date, end_date: date, request: with get_read_session_scope() as db_session: result = get_forecasts_in_range(db_session, start_time, end_time, request.stations) - morecast_forecast_outputs = [MoreCastForecastOutput(station_code=forecast.station_code, - for_date=forecast.for_date.timestamp() * 1000, - temp=forecast.temp, - rh=forecast.rh, - precip=forecast.precip, - wind_speed=forecast.wind_speed, - wind_direction=forecast.wind_direction, - grass_curing=forecast.grass_curing, - update_timestamp=forecast.update_timestamp.timestamp()) for forecast in result] + morecast_forecast_outputs = [ + MoreCastForecastOutput( + station_code=forecast.station_code, + for_date=forecast.for_date.timestamp() * 1000, + temp=forecast.temp, + rh=forecast.rh, + precip=forecast.precip, + wind_speed=forecast.wind_speed, + wind_direction=forecast.wind_direction, + grass_curing=forecast.grass_curing, + update_timestamp=forecast.update_timestamp.timestamp(), + ) + for forecast in result + ] return MorecastForecastResponse(forecasts=morecast_forecast_outputs) @router.post("/forecast", status_code=status.HTTP_201_CREATED) -async def save_forecasts(forecasts: MoreCastForecastRequest, - response: Response, - token=Depends(auth_with_forecaster_role_required)) -> MorecastForecastResponse: - """ Persist a forecast """ - logger.info('/forecast') +async def save_forecasts(forecasts: MoreCastForecastRequest, response: Response, token=Depends(auth_with_forecaster_role_required)) -> MorecastForecastResponse: + """Persist a forecast""" + logger.info("/forecast") response.headers["Cache-Control"] = no_cache - logger.info('Saving %s forecasts', len(forecasts.forecasts)) + logger.info("Saving %s forecasts", len(forecasts.forecasts)) - username = token.get('idir_username', None) + username = token.get("idir_username", None) now = get_utc_now() forecasts_list = forecasts.forecasts - forecasts_to_save = [MorecastForecastRecord(station_code=forecast.station_code, - for_date=datetime.fromtimestamp(forecast.for_date / 1000, timezone.utc), - temp=forecast.temp, - rh=forecast.rh, - precip=forecast.precip, - wind_speed=forecast.wind_speed, - wind_direction=forecast.wind_direction, - grass_curing=forecast.grass_curing, - create_user=username, - create_timestamp=now, - update_user=username, - update_timestamp=now) for forecast in forecasts_list] + forecasts_to_save = [ + MorecastForecastRecord( + station_code=forecast.station_code, + for_date=datetime.fromtimestamp(forecast.for_date / 1000, timezone.utc), + temp=forecast.temp, + rh=forecast.rh, + precip=forecast.precip, + wind_speed=forecast.wind_speed, + wind_direction=forecast.wind_direction, + grass_curing=forecast.grass_curing, + create_user=username, + create_timestamp=now, + update_user=username, + update_timestamp=now, + ) + for forecast in forecasts_list + ] async with ClientSession() as client_session: try: - wf1_forecast_records = await format_as_wf1_post_forecasts(client_session, forecasts_list) - await post_forecasts(client_session, token=forecasts.token, forecasts=wf1_forecast_records) - + headers = await get_auth_header(client_session) + wf1_forecast_records = await format_as_wf1_post_forecasts(client_session, forecasts_list, username, headers) + await post_forecasts(client_session, forecasts=wf1_forecast_records) + station_ids = [wfwx_station.stationId for wfwx_station in wf1_forecast_records] for station_id in station_ids: clear_cache_matching(station_id) except Exception as exc: - logger.error('Encountered error posting forecast data to WF1 API', exc_info=exc) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Error submitting forecast(s) to WF1') + logger.error("Encountered error posting forecast data to WF1 API", exc_info=exc) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Error submitting forecast(s) to WF1") with get_write_session_scope() as db_session: save_all_forecasts(db_session, forecasts_to_save) - morecast_forecast_outputs = [MoreCastForecastOutput(station_code=forecast.station_code, - for_date=forecast.for_date, - temp=forecast.temp, - rh=forecast.rh, - precip=forecast.precip, - wind_speed=forecast.wind_speed, - wind_direction=forecast.wind_direction, - grass_curing=forecast.grass_curing, - update_timestamp=int(now.timestamp() * 1000)) for forecast in forecasts_list] + morecast_forecast_outputs = [ + MoreCastForecastOutput( + station_code=forecast.station_code, + for_date=forecast.for_date, + temp=forecast.temp, + rh=forecast.rh, + precip=forecast.precip, + wind_speed=forecast.wind_speed, + wind_direction=forecast.wind_direction, + grass_curing=forecast.grass_curing, + update_timestamp=int(now.timestamp() * 1000), + ) + for forecast in forecasts_list + ] return MorecastForecastResponse(forecasts=morecast_forecast_outputs) -@router.post('/yesterday-dailies/{today}', - response_model=StationDailiesResponse) +@router.post("/yesterday-dailies/{today}", response_model=StationDailiesResponse) async def get_yesterdays_actual_dailies(today: date, request: ObservedDailiesForStations): - """ Returns the daily actuals for the day before the requested day. - """ - logger.info('/yesterday-dailies/%s/', today) + """Returns the daily actuals for the day before the requested day.""" + logger.info("/yesterday-dailies/%s/", today) unique_station_codes = list(set(request.station_codes)) @@ -149,19 +153,17 @@ async def get_yesterdays_actual_dailies(today: date, request: ObservedDailiesFor async with ClientSession() as session: header = await get_auth_header(session) - yeserday_dailies = await get_dailies_for_stations_and_date(session, header, time_of_interest, - time_of_interest, unique_station_codes) + yesterday_dailies = await get_dailies_for_stations_and_date(session, header, time_of_interest, time_of_interest, unique_station_codes) - return StationDailiesResponse(dailies=yeserday_dailies) + return StationDailiesResponse(dailies=yesterday_dailies) -@router.post('/observed-dailies/{start_date}/{end_date}', - response_model=StationDailiesResponse) +@router.post("/observed-dailies/{start_date}/{end_date}", response_model=StationDailiesResponse) async def get_observed_dailies(start_date: date, end_date: date, request: ObservedDailiesForStations): - """ Returns the daily observations for the requested station codes, from the given start_date to the + """Returns the daily observations for the requested station codes, from the given start_date to the most recent date where daily observation data is available. """ - logger.info('/observed-dailies/%s/', start_date) + logger.info("/observed-dailies/%s/", start_date) unique_station_codes = list(set(request.station_codes)) @@ -170,23 +172,17 @@ async def get_observed_dailies(start_date: date, end_date: date, request: Observ async with ClientSession() as session: header = await get_auth_header(session) - observed_dailies = await get_dailies_for_stations_and_date(session, header, - start_date_of_interest, end_date_of_interest, - unique_station_codes) + observed_dailies = await get_dailies_for_stations_and_date(session, header, start_date_of_interest, end_date_of_interest, unique_station_codes) return StationDailiesResponse(dailies=observed_dailies) -@router.post('/determinates/{start_date}/{end_date}', - response_model=IndeterminateDailiesResponse, - response_class=ORJSONResponse) -async def get_determinates_for_date_range(start_date: date, - end_date: date, - request: StationsRequest): - """ Returns the weather values for any actuals, predictions and forecasts for the +@router.post("/determinates/{start_date}/{end_date}", response_model=IndeterminateDailiesResponse, response_class=ORJSONResponse) +async def get_determinates_for_date_range(start_date: date, end_date: date, request: StationsRequest): + """Returns the weather values for any actuals, predictions and forecasts for the requested stations within the requested date range. """ - logger.info('/morecast-v2/determinates/%s/%s', start_date, end_date) + logger.info("/morecast-v2/determinates/%s/%s", start_date, end_date) unique_station_codes = list(set(request.stations)) @@ -200,10 +196,7 @@ async def get_determinates_for_date_range(start_date: date, header = await get_auth_header(session) # get station information from the wfwx api wfwx_stations = await get_wfwx_stations_from_station_codes(session, header, unique_station_codes) - wf1_actuals, wf1_forecasts = await get_daily_determinates_for_stations_and_date(session, header, - start_date_for_fwi_calc, - end_date_of_interest, - unique_station_codes) + wf1_actuals, wf1_forecasts = await get_daily_determinates_for_stations_and_date(session, header, start_date_for_fwi_calc, end_date_of_interest, unique_station_codes) wf1_actuals, wf1_forecasts = get_fwi_values(wf1_actuals, wf1_forecasts) @@ -219,11 +212,8 @@ async def get_determinates_for_date_range(start_date: date, max_wf1_actuals_date = max(wf1_actuals_dates, default=None) with get_read_session_scope() as db_session: - forecasts_from_db: List[MoreCastForecastOutput] = get_forecasts( - db_session, min_wf1_actuals_date, max_wf1_actuals_date, request.stations) - predictions: List[WeatherIndeterminate] = await fetch_latest_model_run_predictions_by_station_code_and_date_range(db_session, - unique_station_codes, - start_time, end_time) + forecasts_from_db: List[MoreCastForecastOutput] = get_forecasts(db_session, min_wf1_actuals_date, max_wf1_actuals_date, request.stations) + predictions: List[WeatherIndeterminate] = await fetch_latest_model_run_predictions_by_station_code_and_date_range(db_session, unique_station_codes, start_time, end_time) station_codes = [station.code for station in wfwx_stations] grass_curing_rows = get_percent_grass_curing_by_station_for_date_range(db_session, start_time.date(), end_time.date(), station_codes) grass_curing = [] @@ -238,24 +228,18 @@ async def get_determinates_for_date_range(start_date: date, latitude=current_station.lat, longitude=current_station.long, utc_timestamp=get_hour_20_from_date(gc_row.for_date), - grass_curing=gc_row.percent_grass_curing + grass_curing=gc_row.percent_grass_curing, ) grass_curing.append(gc_indeterminate) - transformed_forecasts = transform_morecastforecastoutput_to_weatherindeterminate( - forecasts_from_db, wfwx_stations) + transformed_forecasts = transform_morecastforecastoutput_to_weatherindeterminate(forecasts_from_db, wfwx_stations) # Not all weather stations report actuals at the same time, so we can end up in a situation where # for a given date, we need to show the forecast from the wf1 API for one station, and the forecast # from our API database for another station. We can check this by testing for the presence of an # actual for the given date and station; if an actual exists we use the forecast from our API database. - transformed_forceasts_to_add = filter_for_api_forecasts(transformed_forecasts, wf1_actuals) - - wf1_forecasts.extend(transformed_forceasts_to_add) + transformed_forecasts_to_add = filter_for_api_forecasts(transformed_forecasts, wf1_actuals) - return IndeterminateDailiesResponse( - actuals=wf1_actuals, - forecasts=wf1_forecasts, - grass_curing=grass_curing, - predictions=predictions) + wf1_forecasts.extend(transformed_forecasts_to_add) + return IndeterminateDailiesResponse(actuals=wf1_actuals, forecasts=wf1_forecasts, grass_curing=grass_curing, predictions=predictions) diff --git a/api/app/schemas/morecast_v2.py b/api/app/schemas/morecast_v2.py index 7c7d899138..9e72ceff13 100644 --- a/api/app/schemas/morecast_v2.py +++ b/api/app/schemas/morecast_v2.py @@ -1,4 +1,4 @@ -""" This module contains pydantic models for Morecast v2""" +"""This module contains pydantic models for Morecast v2""" from enum import Enum from typing import List, Optional @@ -7,66 +7,74 @@ class ModelChoice(str, Enum): - """ Enumerator for all valid forecasted value types """ - GDPS = 'GDPS' - GFS = 'GFS' - HRDPS = 'HRDPS' - NAM = 'NAM' - MANUAL = 'MANUAL' - RDPS = 'RDPS' + """Enumerator for all valid forecasted value types""" + + GDPS = "GDPS" + GFS = "GFS" + HRDPS = "HRDPS" + NAM = "NAM" + MANUAL = "MANUAL" + RDPS = "RDPS" class WeatherDeterminate(str, Enum): - """ Enumerator for all valid determinate weather sources""" - GDPS = 'GDPS' - GDPS_BIAS = 'GDPS_BIAS' - GFS = 'GFS' - GFS_BIAS = 'GFS_BIAS' - HRDPS = 'HRDPS' - HRDPS_BIAS = 'HRDPS_BIAS' - NAM = 'NAM' - NAM_BIAS = 'NAM_BIAS' - RDPS = 'RDPS' - RDPS_BIAS = 'RDPS_BIAS' - GRASS_CURING_CWFIS = 'Grass_Curing_CWFIS' + """Enumerator for all valid determinate weather sources""" + + GDPS = "GDPS" + GDPS_BIAS = "GDPS_BIAS" + GFS = "GFS" + GFS_BIAS = "GFS_BIAS" + HRDPS = "HRDPS" + HRDPS_BIAS = "HRDPS_BIAS" + NAM = "NAM" + NAM_BIAS = "NAM_BIAS" + RDPS = "RDPS" + RDPS_BIAS = "RDPS_BIAS" + GRASS_CURING_CWFIS = "Grass_Curing_CWFIS" # non prediction models - FORECAST = 'Forecast' - ACTUAL = 'Actual' + FORECAST = "Forecast" + ACTUAL = "Actual" class ForecastedTemperature(BaseModel): - """ Forecaster chosen temperature """ + """Forecaster chosen temperature""" + temp: float choice: ModelChoice class ForecastedRH(BaseModel): - """ Forecaster chosen rh """ + """Forecaster chosen rh""" + rh: float choice: ModelChoice class ForecastedPrecip(BaseModel): - """ Forecaster chosen 24-hour precipitation mm """ + """Forecaster chosen 24-hour precipitation mm""" + precip: float choice: ModelChoice class ForecastedWindSpeed(BaseModel): - """ Forecaster chosen wind speed """ + """Forecaster chosen wind speed""" + wind_speed: float choice: ModelChoice class ForecastedWindDirection(BaseModel): - """ Forecaster chosen wind direction """ + """Forecaster chosen wind direction""" + wind_direction: float choice: ModelChoice class MoreCastForecastInput(BaseModel): - """ Forecasted daily request """ + """Forecasted daily request""" + station_code: int for_date: int temp: float @@ -78,28 +86,32 @@ class MoreCastForecastInput(BaseModel): class MoreCastForecastRequest(BaseModel): - """ Incoming daily forecasts to be saved """ - token: str # WF1 token + """Incoming daily forecasts to be saved""" + forecasts: List[MoreCastForecastInput] class MoreCastForecastOutput(MoreCastForecastInput): - """ Outgoing forecast daily response item """ + """Outgoing forecast daily response item""" + update_timestamp: int class MorecastForecastResponse(BaseModel): - """ Outgoing forecast daily response """ + """Outgoing forecast daily response""" + forecasts: List[MoreCastForecastOutput] class ObservedDailiesForStations(BaseModel): - """ Request for observed dailies for stations """ + """Request for observed dailies for stations""" + station_codes: List[int] class StationDailyFromWF1(BaseModel): - """ Daily weather data (forecast or observed) for a specific station and date retrieved from WF1 API """ + """Daily weather data (forecast or observed) for a specific station and date retrieved from WF1 API""" + created_by: str forecast_id: str station_code: int @@ -113,12 +125,14 @@ class StationDailyFromWF1(BaseModel): class StationDailiesResponse(BaseModel): - """ List of StationDailyFromWF1 records as response """ + """List of StationDailyFromWF1 records as response""" + dailies: List[StationDailyFromWF1] class WeatherIndeterminate(BaseModel): - """ Used to represent a predicted or actual value """ + """Used to represent a predicted or actual value""" + station_code: int station_name: str determinate: WeatherDeterminate @@ -153,8 +167,9 @@ class WF1ForecastRecordType(BaseModel): class WF1PostForecast(BaseModel): - """ Used to represent a forecast to be POSTed to WF1 """ - archive: str = 'false' + """Used to represent a forecast to be POSTed to WF1""" + + archive: str = "false" createdBy: Optional[str] = None id: Optional[str] = None station: str # station URL diff --git a/api/app/tests/morecast_v2/test_forecasts.py b/api/app/tests/morecast_v2/test_forecasts.py index ee567e5709..1ce8bebecd 100644 --- a/api/app/tests/morecast_v2/test_forecasts.py +++ b/api/app/tests/morecast_v2/test_forecasts.py @@ -4,172 +4,150 @@ import pytest from math import isclose from app.db.models.morecast_v2 import MorecastForecastRecord -from app.morecast_v2.forecasts import (actual_exists, construct_wf1_forecast, - construct_wf1_forecasts, filter_for_api_forecasts, get_forecasts, get_fwi_values) -from app.schemas.morecast_v2 import (StationDailyFromWF1, WeatherDeterminate, WeatherIndeterminate, - WF1ForecastRecordType, WF1PostForecast, MoreCastForecastInput) +from app.morecast_v2.forecasts import actual_exists, construct_wf1_forecast, construct_wf1_forecasts, filter_for_api_forecasts, get_forecasts, get_fwi_values +from app.schemas.morecast_v2 import StationDailyFromWF1, WeatherDeterminate, WeatherIndeterminate, WF1ForecastRecordType, WF1PostForecast, MoreCastForecastInput from app.wildfire_one.schema_parsers import WFWXWeatherStation start_time = datetime(2022, 1, 1, tzinfo=timezone.utc) end_time = datetime(2022, 1, 2, tzinfo=timezone.utc) -station_1_url = 'https://wf1/wfwx-fireweather-api/v1/stations/1' -station_2_url = 'https://wf1/wfwx-fireweather-api/v1/stations/2' - -morecast_record_1 = MorecastForecastRecord(id=1, - station_code=1, - for_date=start_time, - temp=1, - rh=1, - precip=1, - wind_speed=1, - wind_direction=1, - create_timestamp=start_time, - create_user='test1', - update_timestamp=start_time, - update_user='test1') - -morecast_record_2 = MorecastForecastRecord(id=2, - station_code=2, - for_date=start_time, - temp=2, - rh=2, - precip=2, - wind_speed=2, - wind_direction=2, - create_timestamp=end_time, - create_user='test2', - update_timestamp=end_time, - update_user='test2') - -morecast_input_1 = MoreCastForecastInput(station_code=1, - for_date=datetime.timestamp(start_time) * 1000, - temp=1, - rh=1, - precip=1, - wind_speed=1, - wind_direction=1, - grass_curing=1) - -morecast_input_2 = MoreCastForecastInput(station_code=2, - for_date=datetime.timestamp(start_time) * 1000, - temp=2, - rh=2, - precip=2, - wind_speed=2, - wind_direction=2, - grass_curing=2) - -actual_indeterminate_1 = WeatherIndeterminate(station_code=123, - station_name="TEST_STATION", - determinate=WeatherDeterminate.ACTUAL, - utc_timestamp=start_time, - latitude=51.507, - longitude=-121.162, - temperature=4.1, - relative_humidity=34.0, - precipitation=0.0, - wind_direction=184.0, - wind_speed=8.9, - fine_fuel_moisture_code=62, - duff_moisture_code=27, - drought_code=487, - initial_spread_index=4, - build_up_index=52, - fire_weather_index=14, - danger_rating=2) - -forecast_indeterminate_1 = WeatherIndeterminate(station_code=123, - station_name="TEST_STATION", - determinate=WeatherDeterminate.FORECAST, - utc_timestamp=end_time, - latitude=51.507, - longitude=-121.162, - temperature=6.3, - relative_humidity=35.0, - precipitation=0.0, - wind_direction=176.0, - wind_speed=8.9, - fine_fuel_moisture_code=None, - duff_moisture_code=None, - drought_code=None, - initial_spread_index=None, - build_up_index=None, - fire_weather_index=None, - danger_rating=None) - -actual_indeterminate_2 = WeatherIndeterminate(station_code=321, - station_name="TEST_STATION2", - determinate=WeatherDeterminate.ACTUAL, - utc_timestamp=start_time, - latitude=49.4358, - longitude=-116.7464, - temperature=28.3, - relative_humidity=34.0, - precipitation=0.0, - wind_direction=180.0, - wind_speed=5.6, - fine_fuel_moisture_code=91, - duff_moisture_code=91, - drought_code=560, - initial_spread_index=7, - build_up_index=130, - fire_weather_index=28, - danger_rating=3) - -forecast_indeterminate_2 = WeatherIndeterminate(station_code=321, - station_name="TEST_STATION2", - determinate=WeatherDeterminate.FORECAST, - utc_timestamp=end_time, - latitude=49.4358, - longitude=-116.7464, - temperature=27.0, - relative_humidity=50.0, - precipitation=1.0, - wind_direction=176.0, - wind_speed=12, - fine_fuel_moisture_code=None, - duff_moisture_code=None, - drought_code=None, - initial_spread_index=None, - build_up_index=None, - fire_weather_index=None, - danger_rating=None) +station_1_url = "https://wf1/wfwx-fireweather-api/v1/stations/1" +station_2_url = "https://wf1/wfwx-fireweather-api/v1/stations/2" + +morecast_record_1 = MorecastForecastRecord( + id=1, + station_code=1, + for_date=start_time, + temp=1, + rh=1, + precip=1, + wind_speed=1, + wind_direction=1, + create_timestamp=start_time, + create_user="test1", + update_timestamp=start_time, + update_user="test1", +) + +morecast_record_2 = MorecastForecastRecord( + id=2, + station_code=2, + for_date=start_time, + temp=2, + rh=2, + precip=2, + wind_speed=2, + wind_direction=2, + create_timestamp=end_time, + create_user="test2", + update_timestamp=end_time, + update_user="test2", +) + +morecast_input_1 = MoreCastForecastInput(station_code=1, for_date=datetime.timestamp(start_time) * 1000, temp=1, rh=1, precip=1, wind_speed=1, wind_direction=1, grass_curing=1) + +morecast_input_2 = MoreCastForecastInput(station_code=2, for_date=datetime.timestamp(start_time) * 1000, temp=2, rh=2, precip=2, wind_speed=2, wind_direction=2, grass_curing=2) + +actual_indeterminate_1 = WeatherIndeterminate( + station_code=123, + station_name="TEST_STATION", + determinate=WeatherDeterminate.ACTUAL, + utc_timestamp=start_time, + latitude=51.507, + longitude=-121.162, + temperature=4.1, + relative_humidity=34.0, + precipitation=0.0, + wind_direction=184.0, + wind_speed=8.9, + fine_fuel_moisture_code=62, + duff_moisture_code=27, + drought_code=487, + initial_spread_index=4, + build_up_index=52, + fire_weather_index=14, + danger_rating=2, +) + +forecast_indeterminate_1 = WeatherIndeterminate( + station_code=123, + station_name="TEST_STATION", + determinate=WeatherDeterminate.FORECAST, + utc_timestamp=end_time, + latitude=51.507, + longitude=-121.162, + temperature=6.3, + relative_humidity=35.0, + precipitation=0.0, + wind_direction=176.0, + wind_speed=8.9, + fine_fuel_moisture_code=None, + duff_moisture_code=None, + drought_code=None, + initial_spread_index=None, + build_up_index=None, + fire_weather_index=None, + danger_rating=None, +) + +actual_indeterminate_2 = WeatherIndeterminate( + station_code=321, + station_name="TEST_STATION2", + determinate=WeatherDeterminate.ACTUAL, + utc_timestamp=start_time, + latitude=49.4358, + longitude=-116.7464, + temperature=28.3, + relative_humidity=34.0, + precipitation=0.0, + wind_direction=180.0, + wind_speed=5.6, + fine_fuel_moisture_code=91, + duff_moisture_code=91, + drought_code=560, + initial_spread_index=7, + build_up_index=130, + fire_weather_index=28, + danger_rating=3, +) + +forecast_indeterminate_2 = WeatherIndeterminate( + station_code=321, + station_name="TEST_STATION2", + determinate=WeatherDeterminate.FORECAST, + utc_timestamp=end_time, + latitude=49.4358, + longitude=-116.7464, + temperature=27.0, + relative_humidity=50.0, + precipitation=1.0, + wind_direction=176.0, + wind_speed=12, + fine_fuel_moisture_code=None, + duff_moisture_code=None, + drought_code=None, + initial_spread_index=None, + build_up_index=None, + fire_weather_index=None, + danger_rating=None, +) wfwx_weather_stations = [ - WFWXWeatherStation( - wfwx_id='1', - code=1, - name='station1', - latitude=12.1, - longitude=12.1, - elevation=123, - zone_code=1 - ), - WFWXWeatherStation( - wfwx_id='2', - code=2, - name='station2', - latitude=12.2, - longitude=12.2, - elevation=123.2, - zone_code=2 - ) + WFWXWeatherStation(wfwx_id="1", code=1, name="station1", latitude=12.1, longitude=12.1, elevation=123, zone_code=1), + WFWXWeatherStation(wfwx_id="2", code=2, name="station2", latitude=12.2, longitude=12.2, elevation=123.2, zone_code=2), ] -station_1_daily_from_wf1 = StationDailyFromWF1(created_by='test', - forecast_id='f1', - station_code=1, - station_name='station1', - utcTimestamp=start_time) +station_1_daily_from_wf1 = StationDailyFromWF1(created_by="test", forecast_id="f1", station_code=1, station_name="station1", utcTimestamp=start_time) -def assert_wf1_forecast(result: WF1PostForecast, - morecast_record_1: MoreCastForecastInput, - expected_id: Optional[str], - expected_created_by: Optional[str], - expected_station_url: str, - expected_station_id: str): +def assert_wf1_forecast( + result: WF1PostForecast, + morecast_record_1: MoreCastForecastInput, + expected_id: Optional[str], + expected_created_by: Optional[str], + expected_station_url: str, + expected_station_id: str, +): assert result.id == expected_id assert result.createdBy == expected_created_by assert result.station == expected_station_url @@ -185,8 +163,7 @@ def assert_wf1_forecast(result: WF1PostForecast, def test_get_fwi_values(): - actuals, forecasts = get_fwi_values([actual_indeterminate_1, actual_indeterminate_2], [ - forecast_indeterminate_1, forecast_indeterminate_2]) + actuals, forecasts = get_fwi_values([actual_indeterminate_1, actual_indeterminate_2], [forecast_indeterminate_1, forecast_indeterminate_2]) assert len(forecasts) == 2 assert len(actuals) == 2 # The below values were calculated using the CFFDRS library and the values from the test indeterminates as input @@ -205,7 +182,7 @@ def test_get_fwi_values(): assert isclose(forecasts[1].fire_weather_index, 22.169614889600865) -@patch('app.morecast_v2.forecasts.get_forecasts_in_range', return_value=[]) +@patch("app.morecast_v2.forecasts.get_forecasts_in_range", return_value=[]) def test_get_forecasts_empty(_): result = get_forecasts(Mock(), start_time, end_time, []) assert len(result) == 0 @@ -215,7 +192,7 @@ def test_get_forecasts_empty(_): assert len(result) == 0 -@patch('app.morecast_v2.forecasts.get_forecasts_in_range', return_value=[morecast_record_1, morecast_record_2]) +@patch("app.morecast_v2.forecasts.get_forecasts_in_range", return_value=[morecast_record_1, morecast_record_2]) def test_get_forecasts_non_empty(_): result = get_forecasts(Mock(), start_time, end_time, [1, 2]) assert len(result) == 2 @@ -224,52 +201,29 @@ def test_get_forecasts_non_empty(_): def test_construct_wf1_forecast_new(): - result = construct_wf1_forecast(morecast_input_1, - wfwx_weather_stations, - None, - 'test') - assert_wf1_forecast(result, morecast_input_1, None, 'test', station_1_url, '1') + result = construct_wf1_forecast(morecast_input_1, wfwx_weather_stations, None, "test") + assert_wf1_forecast(result, morecast_input_1, None, "test", station_1_url, "1") def test_construct_wf1_forecast_update(): - result = construct_wf1_forecast(morecast_input_1, - wfwx_weather_stations, - 'f1', - 'test') - assert_wf1_forecast(result, morecast_input_1, 'f1', 'test', station_1_url, '1') + result = construct_wf1_forecast(morecast_input_1, wfwx_weather_stations, "f1", "test") + assert_wf1_forecast(result, morecast_input_1, "f1", "test", station_1_url, "1") @pytest.mark.anyio -@patch('aiohttp.ClientSession.get') -@patch('app.morecast_v2.forecasts.get_forecasts_for_stations_by_date_range', return_value=[station_1_daily_from_wf1]) +@patch("aiohttp.ClientSession.get") +@patch("app.morecast_v2.forecasts.get_forecasts_for_stations_by_date_range", return_value=[station_1_daily_from_wf1]) async def test_construct_wf1_forecasts_new(_, mock_get): - result = await construct_wf1_forecasts(mock_get, - [morecast_input_1, morecast_input_2], - wfwx_weather_stations) + result = await construct_wf1_forecasts(mock_get, [morecast_input_1, morecast_input_2], wfwx_weather_stations, "user") assert len(result) == 2 # existing forecast - assert_wf1_forecast(result[0], morecast_input_1, - station_1_daily_from_wf1.forecast_id, - station_1_daily_from_wf1.created_by, - station_1_url, '1') + assert_wf1_forecast(result[0], morecast_input_1, station_1_daily_from_wf1.forecast_id, station_1_daily_from_wf1.created_by, station_1_url, "1") # no existing forecast - assert_wf1_forecast(result[1], - morecast_input_2, - None, - None, - station_2_url, '2') - - -def build_weather_indeterminate(station_code: int, - station_name: str, - determinate: WeatherDeterminate, - utc_timestamp: datetime): - return WeatherIndeterminate( - station_code=station_code, - station_name=station_name, - determinate=determinate, - utc_timestamp=utc_timestamp - ) + assert_wf1_forecast(result[1], morecast_input_2, None, "user", station_2_url, "2") + + +def build_weather_indeterminate(station_code: int, station_name: str, determinate: WeatherDeterminate, utc_timestamp: datetime): + return WeatherIndeterminate(station_code=station_code, station_name=station_name, determinate=determinate, utc_timestamp=utc_timestamp) def create_list_of_actuals(): diff --git a/api/app/tests/morecast_v2/test_morecast_v2_endpoint.py b/api/app/tests/morecast_v2/test_morecast_v2_endpoint.py index 410c58521d..5ee9336efe 100644 --- a/api/app/tests/morecast_v2/test_morecast_v2_endpoint.py +++ b/api/app/tests/morecast_v2/test_morecast_v2_endpoint.py @@ -5,24 +5,22 @@ from aiohttp import ClientSession from app.schemas.shared import StationsRequest from app.tests.common import default_mock_client_get -from app.schemas.morecast_v2 import (MoreCastForecastInput, - MoreCastForecastRequest, StationDailyFromWF1) +from app.schemas.morecast_v2 import MoreCastForecastInput, MoreCastForecastRequest, StationDailyFromWF1 import app.routers.morecast_v2 from app.tests.utils.mock_jwt_decode_role import MockJWTDecodeWithRole -morecast_v2_post_url = '/api/morecast-v2/forecast' -morecast_v2_get_url = '/api/morecast-v2/forecasts/2023-03-15' +morecast_v2_post_url = "/api/morecast-v2/forecast" +morecast_v2_get_url = "/api/morecast-v2/forecasts/2023-03-15" morecast_v2_post_by_date_range_url = "/api/morecast-v2/forecasts/2023-03-15/2023-03-19" -today = '2022-10-07' -morecast_v2_post_yesterday_dailies_url = f'/api/morecast-v2/yesterday-dailies/{today}' +today = "2022-10-07" +morecast_v2_post_yesterday_dailies_url = f"/api/morecast-v2/yesterday-dailies/{today}" morecast_v2_post_determinates_url = "/api/morecast-v2/determinates/2023-03-15/2023-03-19" decode_fn = "jwt.decode" -forecast = MoreCastForecastRequest(token="testToken", forecasts=[MoreCastForecastInput( - station_code=1, for_date=1, temp=10.0, rh=40.1, precip=70.2, wind_speed=20.3, wind_direction=40)]) +forecast = MoreCastForecastRequest(token="testToken", forecasts=[MoreCastForecastInput(station_code=1, for_date=1, temp=10.0, rh=40.1, precip=70.2, wind_speed=20.3, wind_direction=40)]) stations = StationsRequest(stations=[1, 2]) @@ -44,66 +42,69 @@ async def async_client(): def test_get_forecast_unauthorized(client: TestClient): - """ forecast role required for retrieving forecasts """ + """forecast role required for retrieving forecasts""" response = client.get(morecast_v2_get_url) assert response.status_code == 401 def test_get_forecast_authorized(client: TestClient, monkeypatch: pytest.MonkeyPatch): - """ forecast role required for persisting a forecast """ + """forecast role required for persisting a forecast""" def mock_admin_role_function(*_, **__): - return MockJWTDecodeWithRole('morecast2_write_forecast') + return MockJWTDecodeWithRole("morecast2_write_forecast") monkeypatch.setattr(decode_fn, mock_admin_role_function) - monkeypatch.setattr(app.routers.morecast_v2, 'get_user_forecasts_for_date', lambda *_: []) + monkeypatch.setattr(app.routers.morecast_v2, "get_user_forecasts_for_date", lambda *_: []) response = client.get(morecast_v2_get_url) assert response.status_code == 200 def test_post_forecast_unauthorized(client: TestClient): - """ forecast role required for persisting a forecast """ + """forecast role required for persisting a forecast""" response = client.post(morecast_v2_post_url, json=[]) assert response.status_code == 401 @pytest.mark.anyio -def test_post_forecast_authorized(client: TestClient, - monkeypatch: pytest.MonkeyPatch): - """ Allowed to post station changes with correct role""" +def test_post_forecast_authorized(client: TestClient, monkeypatch: pytest.MonkeyPatch): + """Allowed to post station changes with correct role""" def mock_admin_role_function(*_, **__): - return MockJWTDecodeWithRole('morecast2_write_forecast') + return MockJWTDecodeWithRole("morecast2_write_forecast") monkeypatch.setattr(decode_fn, mock_admin_role_function) - async def mock_format_as_wf1_post_forecasts(client_session, forecasts_to_save): + async def mock_format_as_wf1_post_forecasts(client_session, forecasts_to_save, username, headers): return [] - monkeypatch.setattr(app.routers.morecast_v2, 'format_as_wf1_post_forecasts', mock_format_as_wf1_post_forecasts) + monkeypatch.setattr(app.routers.morecast_v2, "format_as_wf1_post_forecasts", mock_format_as_wf1_post_forecasts) - async def mock_post_forecasts(client_session, token, forecasts): + async def mock_post_forecasts(client_session, forecasts): return None - monkeypatch.setattr(app.routers.morecast_v2, 'post_forecasts', mock_post_forecasts) + monkeypatch.setattr(app.routers.morecast_v2, "post_forecasts", mock_post_forecasts) - response = client.post(morecast_v2_post_url, json=forecast.dict()) + async def mock_get_auth_header(_): + return dict() + + monkeypatch.setattr(app.routers.morecast_v2, "get_auth_header", mock_get_auth_header) + + response = client.post(morecast_v2_post_url, json=forecast.model_dump()) assert response.status_code == 201 def test_post_forecasts_by_date_range_unauthorized(client: TestClient): - """ forecast role required for persisting a forecast """ + """forecast role required for persisting a forecast""" response = client.post(morecast_v2_post_by_date_range_url, json=[]) assert response.status_code == 401 -def test_post_forecast_by_date_range_authorized(client: TestClient, - monkeypatch: pytest.MonkeyPatch): - """ Allowed to post station changes with correct role""" +def test_post_forecast_by_date_range_authorized(client: TestClient, monkeypatch: pytest.MonkeyPatch): + """Allowed to post station changes with correct role""" def mock_admin_role_function(*_, **__): - return MockJWTDecodeWithRole('morecast2_write_forecast') + return MockJWTDecodeWithRole("morecast2_write_forecast") monkeypatch.setattr(decode_fn, mock_admin_role_function) @@ -112,31 +113,32 @@ def mock_admin_role_function(*_, **__): def test_get_yesterday_dailies_unauthorized(client: TestClient): - """ user must be authenticated to retrieve yesterday dailies """ + """user must be authenticated to retrieve yesterday dailies""" response = client.post(morecast_v2_post_yesterday_dailies_url, json={"station_codes": [209, 211, 302]}) assert response.status_code == 401 def test_get_yesterday_dailies_authorized(client: TestClient, monkeypatch: pytest.MonkeyPatch): - """ user must be authenticated to retrieve yesterday dailies """ + """user must be authenticated to retrieve yesterday dailies""" + def mock_admin_role_function(*_, **__): - return MockJWTDecodeWithRole('morecast2_write_forecast') + return MockJWTDecodeWithRole("morecast2_write_forecast") monkeypatch.setattr(decode_fn, mock_admin_role_function) - monkeypatch.setattr(ClientSession, 'get', default_mock_client_get) + monkeypatch.setattr(ClientSession, "get", default_mock_client_get) requested_station_codes = [209, 211, 302] response = client.post(morecast_v2_post_yesterday_dailies_url, json={"station_codes": requested_station_codes}) assert response.status_code == 200 - parsed_dailies = [StationDailyFromWF1.model_validate(raw_daily) for raw_daily in response.json().get('dailies')] + parsed_dailies = [StationDailyFromWF1.model_validate(raw_daily) for raw_daily in response.json().get("dailies")] assert len(parsed_dailies) == 3 - today_date = datetime.strptime(today, '%Y-%m-%d').date() + today_date = datetime.strptime(today, "%Y-%m-%d").date() for requested_station_code, response in zip(requested_station_codes, parsed_dailies): assert requested_station_code == response.station_code - assert response.utcTimestamp.tzname() == 'UTC' + assert response.utcTimestamp.tzname() == "UTC" assert response.utcTimestamp.year == today_date.year assert response.utcTimestamp.month == today_date.month assert response.utcTimestamp.day == today_date.day - 1 @@ -151,10 +153,10 @@ def test_get_determinates_unauthorized(client: TestClient): @pytest.mark.anyio async def test_get_determinates_authorized(anyio_backend, async_client: AsyncClient, monkeypatch: pytest.MonkeyPatch): def mock_admin_role_function(*_, **__): - return MockJWTDecodeWithRole('morecast2_write_forecast') + return MockJWTDecodeWithRole("morecast2_write_forecast") monkeypatch.setattr(decode_fn, mock_admin_role_function) - monkeypatch.setattr(ClientSession, 'get', default_mock_client_get) + monkeypatch.setattr(ClientSession, "get", default_mock_client_get) response = await async_client.post(morecast_v2_post_determinates_url, json={"stations": [209, 211, 302]}) assert response.status_code == 200 diff --git a/api/app/tests/wildfire_one/test_wildfire_one.py b/api/app/tests/wildfire_one/test_wildfire_one.py index 744f6e78a5..16845ad525 100644 --- a/api/app/tests/wildfire_one/test_wildfire_one.py +++ b/api/app/tests/wildfire_one/test_wildfire_one.py @@ -1,100 +1,76 @@ -""" Unit testing for WFWX API code """ +"""Unit testing for WFWX API code""" + import asyncio from unittest.mock import patch, AsyncMock import pytest from fastapi import HTTPException from pytest_mock import MockFixture -from app.wildfire_one.query_builders import (BuildQueryAllForecastsByAfterStart, - BuildQueryAllHourliesByRange, - BuildQueryDailiesByStationCode, - BuildQueryStationGroups) -from app.wildfire_one.wfwx_api import (WFWXWeatherStation, - get_wfwx_stations_from_station_codes) +from app.wildfire_one.query_builders import BuildQueryAllForecastsByAfterStart, BuildQueryAllHourliesByRange, BuildQueryDailiesByStationCode, BuildQueryStationGroups +from app.wildfire_one.wfwx_api import WFWXWeatherStation, get_wfwx_stations_from_station_codes from app.wildfire_one.wfwx_post_api import post_forecasts def test_build_all_hourlies_query(): - """ Verifies the query builder returns the correct url and parameters """ + """Verifies the query builder returns the correct url and parameters""" query_builder = BuildQueryAllHourliesByRange(0, 1) result = query_builder.query(0) - assert result == ("https://wf1/wfwx/v1/hourlies/rsql", - { - 'size': '1000', - 'page': 0, - 'query': 'weatherTimestamp >=0;weatherTimestamp <1' - }) + assert result == ("https://wf1/wfwx/v1/hourlies/rsql", {"size": "1000", "page": 0, "query": "weatherTimestamp >=0;weatherTimestamp <1"}) def test_build_forecasts_query(): - """ Verifies the query builder returns the correct url and parameters """ + """Verifies the query builder returns the correct url and parameters""" query_builder = BuildQueryAllForecastsByAfterStart(0) result = query_builder.query(0) - assert result == ("https://wf1/wfwx/v1/dailies/rsql", - { - 'size': '1000', - 'page': 0, - 'query': "weatherTimestamp >=0;recordType.id == 'FORECAST'" - }) + assert result == ("https://wf1/wfwx/v1/dailies/rsql", {"size": "1000", "page": 0, "query": "weatherTimestamp >=0;recordType.id == 'FORECAST'"}) def test_build_dailies_by_station_code(): - """ Verifies the query builder returns the correct url and parameters for dailies by station code """ - query_builder = BuildQueryDailiesByStationCode(0, 1, ['1', '2']) + """Verifies the query builder returns the correct url and parameters for dailies by station code""" + query_builder = BuildQueryDailiesByStationCode(0, 1, ["1", "2"]) result = query_builder.query(0) - assert result == ('https://wf1/wfwx/v1/dailies/search/' + - 'findDailiesByStationIdIsInAndWeather' + - 'TimestampBetweenOrderByStationIdAscWeatherTimestampAsc', - { - 'size': '1000', - 'page': 0, - 'startingTimestamp': 0, - 'endingTimestamp': 1, - 'stationIds': ['1', '2'] - }) + assert result == ( + "https://wf1/wfwx/v1/dailies/search/" + "findDailiesByStationIdIsInAndWeather" + "TimestampBetweenOrderByStationIdAscWeatherTimestampAsc", + {"size": "1000", "page": 0, "startingTimestamp": 0, "endingTimestamp": 1, "stationIds": ["1", "2"]}, + ) def test_build_station_groups_query(): - """ Verifies the query builder returns the correct url and parameters for a station groups query""" + """Verifies the query builder returns the correct url and parameters for a station groups query""" query_builder = BuildQueryStationGroups() result = query_builder.query(0) - assert result == ('https://wf1/wfwx/v1/stationGroups', - { - 'size': '1000', - 'page': 0, - 'sort': 'groupOwnerUserId,asc' - }) + assert result == ("https://wf1/wfwx/v1/stationGroups", {"size": "1000", "page": 0, "sort": "groupOwnerUserId,asc"}) code1 = 322 code2 = 239 -all_station_codes = [{'station_code': code1}, {'station_code': code2}] -station_1 = WFWXWeatherStation(code=code1, name="name", wfwx_id="one", - latitude=0, longitude=0, elevation=0, zone_code='T1') -station_2 = WFWXWeatherStation(code=code2, name="name", wfwx_id="two", - latitude=0, longitude=0, elevation=0, zone_code='T1') +all_station_codes = [{"station_code": code1}, {"station_code": code2}] +station_1 = WFWXWeatherStation(code=code1, name="name", wfwx_id="one", latitude=0, longitude=0, elevation=0, zone_code="T1") +station_2 = WFWXWeatherStation(code=code2, name="name", wfwx_id="two", latitude=0, longitude=0, elevation=0, zone_code="T1") all_stations = [station_1, station_2] @pytest.fixture() def mock_responses(mocker: MockFixture): - """ Mocks out hourly actuals as async result """ + """Mocks out hourly actuals as async result""" + async def mock_get_stations(_, __, **___): - """ Returns mocked WFWXWeatherStations. """ + """Returns mocked WFWXWeatherStations.""" return all_stations def mock_get_fire_centre_station_codes(__): - """ Returns mocked WFWXWeatherStations codes. """ + """Returns mocked WFWXWeatherStations codes.""" return all_station_codes - mocker.patch('app.db.crud.hfi_calc.get_all_stations', mock_get_fire_centre_station_codes) - mocker.patch('app.wildfire_one.wfwx_api.get_station_data', mock_get_stations) + mocker.patch("app.db.crud.hfi_calc.get_all_stations", mock_get_fire_centre_station_codes) + mocker.patch("app.wildfire_one.wfwx_api.get_station_data", mock_get_stations) def test_get_ids_from_station_codes_no_stations(mock_responses): - """ Verifies the query builder returns the correct url and parameters for dailies by station code """ + """Verifies the query builder returns the correct url and parameters for dailies by station code""" + async def run_test(): - """ Async function to run test and assert result """ + """Async function to run test and assert result""" result = await get_wfwx_stations_from_station_codes(None, {}, None) assert len(result) == 2 @@ -104,10 +80,10 @@ async def run_test(): def test_get_ids_from_station_codes(mock_responses): - """ Verifies the query builder returns the correct url and parameters for dailies by station code """ + """Verifies the query builder returns the correct url and parameters for dailies by station code""" async def run_test(): - """ Async function to run test and assert result """ + """Async function to run test and assert result""" result = await get_wfwx_stations_from_station_codes(None, {}, [code1]) assert result == [station_1] @@ -117,9 +93,9 @@ async def run_test(): @pytest.mark.anyio -@patch('app.wildfire_one.wfwx_post_api.ClientSession') +@patch("app.wildfire_one.wfwx_post_api.ClientSession") async def test_wf1_post_failure(mock_client): - """ Verifies that posting to WF1 raises an exception upon failure """ + """Verifies that posting to WF1 raises an exception upon failure""" mock_client.post.return_value.__aenter__.return_value = AsyncMock(status=400) with pytest.raises(HTTPException): - await post_forecasts(mock_client, 'token', []) + await post_forecasts(mock_client, []) diff --git a/api/app/wildfire_one/wfwx_post_api.py b/api/app/wildfire_one/wfwx_post_api.py index 8a6973e092..70506bd41d 100644 --- a/api/app/wildfire_one/wfwx_post_api.py +++ b/api/app/wildfire_one/wfwx_post_api.py @@ -1,30 +1,28 @@ -""" This module contains methods for submitting information to the WFWX Fireweather API. -""" +"""This module contains methods for submitting information to the WFWX Fireweather API.""" + import logging from typing import List from aiohttp import ClientSession from fastapi import status, HTTPException from app import config from app.schemas.morecast_v2 import WF1PostForecast +from app.wildfire_one.wfwx_api import get_auth_header logger = logging.getLogger(__name__) WF1_FORECAST_POST_URL = f"{config.get('WFWX_BASE_URL')}/v1/dailies/daily-bulk" -async def post_forecasts(session: ClientSession, - token: str, - forecasts: List[WF1PostForecast]): - - logger.info('Using WFWX to post/put forecasts') - headers = {'Authorization': f"Bearer {token}"} +async def post_forecasts(session: ClientSession, forecasts: List[WF1PostForecast]): + logger.info("Using WFWX to post/put forecasts") + headers = await get_auth_header(session) - forecasts_json = [forecast.dict() for forecast in forecasts] + forecasts_json = [forecast.model_dump() for forecast in forecasts] async with session.post(WF1_FORECAST_POST_URL, json=forecasts_json, headers=headers) as response: response_json = await response.json() if response.status == status.HTTP_201_CREATED or response.status == status.HTTP_200_OK: - logger.info('submitted forecasts to wf1 %s.', response_json) + logger.info("submitted forecasts to wf1 %s.", response_json) else: - logger.error(f'error submitting forecasts to wf1 {response_json}') - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Error submitting forecast(s) to WF1') + logger.error(f"error submitting forecasts to wf1 {response_json}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Error submitting forecast(s) to WF1") diff --git a/web/src/api/moreCast2API.test.ts b/web/src/api/moreCast2API.test.ts index 50b152725d..5576aa3e9d 100644 --- a/web/src/api/moreCast2API.test.ts +++ b/web/src/api/moreCast2API.test.ts @@ -52,7 +52,7 @@ describe('moreCast2API', () => { }) it('should call submit endpoint for forecast submission', async () => { axios.post = jest.fn().mockResolvedValue({ status: 201 }) - const res = await submitMoreCastForecastRecords('testToken', [ + const res = await submitMoreCastForecastRecords([ buildMorecast2Forecast('1', 1, 'one', DateTime.fromObject({ year: 2021, month: 1, day: 1 })), buildMorecast2Forecast('2', 2, 'two', DateTime.fromObject({ year: 2021, month: 1, day: 1 })) ]) diff --git a/web/src/api/moreCast2API.ts b/web/src/api/moreCast2API.ts index 8a69680a19..c402ccaace 100644 --- a/web/src/api/moreCast2API.ts +++ b/web/src/api/moreCast2API.ts @@ -176,7 +176,6 @@ export interface MoreCast2ForecastRecord { } export interface MoreCastForecastRequest { - wf1Token: string forecasts: MoreCast2ForecastRecord[] } @@ -203,14 +202,12 @@ export const marshalMoreCast2ForecastRecords = (forecasts: MoreCast2ForecastRow[ * @returns True if the response is a 201, otherwise false. */ export async function submitMoreCastForecastRecords( - token: string, forecasts: MoreCast2ForecastRow[] ): Promise<{ success: boolean; errorMessage?: string }> { const forecastRecords = marshalMoreCast2ForecastRecords(forecasts) const url = `/morecast-v2/forecast` try { const { status } = await axios.post(url, { - token, forecasts: forecastRecords }) return { success: status === 201 } diff --git a/web/src/app/Routes.tsx b/web/src/app/Routes.tsx index 19b8f61ea1..b2ea10cdaa 100644 --- a/web/src/app/Routes.tsx +++ b/web/src/app/Routes.tsx @@ -23,7 +23,6 @@ const FireBehaviourCalculator = lazy(() => import('features/fbaCalculator/pages/ const FireBehaviourAdvisoryPage = lazy(() => import('features/fba/pages/FireBehaviourAdvisoryPage')) const LandingPage = lazy(() => import('features/landingPage/pages/LandingPage')) const MoreCast2Page = lazy(() => import('features/moreCast2/pages/MoreCast2Page')) -import MoreCast2AuthWrapper from 'features/auth/components/MoreCast2AuthWrapper' import LoadingBackdrop from 'features/hfiCalculator/components/LoadingBackdrop' const shouldShowDisclaimer = HIDE_DISCLAIMER === 'false' || HIDE_DISCLAIMER === undefined @@ -86,9 +85,7 @@ const WPSRoutes: React.FunctionComponent = () => { path={MORECAST_ROUTE} element={ - - - + } /> diff --git a/web/src/app/rootReducer.ts b/web/src/app/rootReducer.ts index 8b836d33b6..52f49e00fb 100644 --- a/web/src/app/rootReducer.ts +++ b/web/src/app/rootReducer.ts @@ -5,7 +5,6 @@ import percentilesReducer from 'features/percentileCalculator/slices/percentiles import cHainesModelRunReducer from 'features/cHaines/slices/cHainesModelRunsSlice' import cHainesPredictionReducer from 'features/cHaines/slices/cHainesPredictionsSlice' import authReducer from 'features/auth/slices/authenticationSlice' -import wf1AuthReducer from 'features/auth/slices/wf1AuthenticationSlice' import hfiCalculatorDailiesReducer, { HFICalculatorState } from 'features/hfiCalculator/slices/hfiCalculatorSlice' import hfiStationsReducer from 'features/hfiCalculator/slices/stationsSlice' import hfiReadyReducer, { HFIReadyState } from 'features/hfiCalculator/slices/hfiReadySlice' @@ -29,7 +28,6 @@ const rootReducer = combineReducers({ cHainesModelRuns: cHainesModelRunReducer, cHainesPredictions: cHainesPredictionReducer, authentication: authReducer, - wf1Authentication: wf1AuthReducer, hfiCalculatorDailies: hfiCalculatorDailiesReducer, hfiStations: hfiStationsReducer, hfiReady: hfiReadyReducer, @@ -60,7 +58,6 @@ export const selectPercentiles = (state: RootState) => state.percentiles export const selectCHainesModelRuns = (state: RootState) => state.cHainesModelRuns export const selectChainesPredictions = (state: RootState) => state.cHainesPredictions export const selectAuthentication = (state: RootState) => state.authentication -export const selectWf1Authentication = (state: RootState) => state.wf1Authentication export const selectToken = (state: RootState) => state.authentication.token export const selectFireBehaviourCalcResult = (state: RootState) => state.fbaCalculatorResults export const selectHFIStations = (state: RootState) => state.hfiStations diff --git a/web/src/features/auth/components/MoreCast2AuthWrapper.tsx b/web/src/features/auth/components/MoreCast2AuthWrapper.tsx deleted file mode 100644 index 0319f7674d..0000000000 --- a/web/src/features/auth/components/MoreCast2AuthWrapper.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import { AppDispatch } from 'app/store' -import { TEST_AUTH, WF1_AUTH_URL } from 'utils/env' -import { wf1Authenticate, wf1AuthenticateError } from 'features/auth/slices/wf1AuthenticationSlice' -import { selectAuthentication, selectWf1Authentication } from 'app/rootReducer' -import { ROLES } from 'features/auth/roles' -import { DateTime } from 'luxon' - -interface Props { - children: React.ReactElement -} - -const MoreCast2AuthWrapper = ({ children }: Props) => { - const dispatch: AppDispatch = useDispatch() - - const [renderChildren, setRenderChildren] = useState(false) - const { roles } = useSelector(selectAuthentication) - const { error } = useSelector(selectWf1Authentication) - - const isAuthenticatedForecaster = roles.includes(ROLES.MORECAST_2.WRITE_FORECAST) - - useEffect(() => { - async function fetchData() { - if (TEST_AUTH || window.Cypress) { - dispatch(wf1Authenticate('test token')) - setRenderChildren(true) - } else { - if (!isAuthenticatedForecaster) { - setRenderChildren(true) - } - - const lastLoginString = localStorage.getItem('last_morecast_login') ?? '0' - const lastLogin = parseInt(lastLoginString) - const now = DateTime.now().toUnixInteger() - - // Force a redirect to WF1 authentication if there is no access_token or the last time Morecast - // was logged into was more than 60 minutes ago in order to handle a user reloading a page with an - // old access token - const redirectUri = `${location.origin}${location.pathname}` - - if (isAuthenticatedForecaster && (!window.location.href?.includes('access_token') || now - lastLogin > 3600)) { - window.location.href = `${WF1_AUTH_URL}&redirect_uri=${redirectUri}` - } - - if (window.location.href?.includes('access_token')) { - const wf1Token = window.location.href.split('#access_token=')[1].split('&')[0] - localStorage.setItem('last_morecast_login', DateTime.now().toUnixInteger().toString()) - try { - dispatch(wf1Authenticate(wf1Token)) - setRenderChildren(true) - } catch (e) { - dispatch(wf1AuthenticateError('Failed to authenticate with WF1')) - } - } - } - } - - fetchData() - }, []) // eslint-disable-line react-hooks/exhaustive-deps - - if (error) { - return
{error}
- } - - return renderChildren ? children : null -} - -export default React.memo(MoreCast2AuthWrapper) diff --git a/web/src/features/auth/components/morecast2AuthWrapper.test.tsx b/web/src/features/auth/components/morecast2AuthWrapper.test.tsx deleted file mode 100644 index bb93153d12..0000000000 --- a/web/src/features/auth/components/morecast2AuthWrapper.test.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { render } from '@testing-library/react' -import MoreCast2AuthWrapper from 'features/auth/components/MoreCast2AuthWrapper' -import { Provider } from 'react-redux' -import authReducer, { initialState, AuthState } from 'features/auth/slices/authenticationSlice' -import wf1AuthReducer from 'features/auth/slices/wf1AuthenticationSlice' -import React from 'react' -import { WF1_AUTH_URL } from 'utils/env' -import { ROLES } from 'features/auth/roles' -import { combineReducers, configureStore } from '@reduxjs/toolkit' -import { DateTime } from 'luxon' - -const mockLocalStorage = (() => { - let store = {} as Storage - - return { - getItem(key: string) { - return store[key] - }, - - setItem(key: string, value: string) { - store[key] = value - }, - - removeItem(key: string) { - delete store[key] - }, - - clear() { - store = {} as Storage - } - } -})() - -Object.defineProperty(window, 'localStorage', { - value: mockLocalStorage -}) - -describe('MoreCast2AuthWrapper', () => { - const { location } = window - - beforeEach((): void => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - delete window.location - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - window.location = { - href: '' - } - }) - - afterEach((): void => { - window.location = location - }) - - const buildTestStore = (initialState: AuthState) => { - const rootReducer = combineReducers({ authentication: authReducer, wf1Authentication: wf1AuthReducer }) - const testStore = configureStore({ - reducer: rootReducer, - preloadedState: { - authentication: initialState - } - }) - return testStore - } - - it('should make auth request to wf1 if forecaster when not authd and no timestamp in local storage', () => { - const testStore = buildTestStore({ - ...initialState, - roles: [ROLES.MORECAST_2.WRITE_FORECAST] - }) - - expect(window.location.href).toBe('') - - render( - - -
-
- -
- ) - expect(window.location.href.indexOf(`${WF1_AUTH_URL}&redirect_uri=`)).toBe(0) - }) - - it('should make auth request to wf1 if forecaster already authd and timestamp in localstorage is more than 3600 seconds old', () => { - const testStore = buildTestStore({ - ...initialState, - roles: [ROLES.MORECAST_2.WRITE_FORECAST] - }) - window.localStorage.setItem('last_morecast_login', '0') - const authedUrl = 'test.com/#access_token=t&' - expect(window.location.href).toBe('') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - delete window.location - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - window.location = { - href: authedUrl - } - - render( - - -
-
-
- ) - expect(window.location.href.indexOf(`${WF1_AUTH_URL}&redirect_uri=`)).toBe(0) - }) - - it('should not make auth request to wf1 if forecaster when already authd and timestamp in localstorage is less than 3600 seconds old', () => { - const testStore = buildTestStore({ - ...initialState, - roles: [ROLES.MORECAST_2.WRITE_FORECAST] - }) - window.localStorage.setItem('last_morecast_login', DateTime.now().toUnixInteger().toString()) - const authedUrl = 'test.com/#access_token=t&' - expect(window.location.href).toBe('') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - delete window.location - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - window.location = { - href: authedUrl - } - - render( - - -
-
-
- ) - expect(window.location.href).toBe(authedUrl) - }) - - it('should not make auth request to wf1 if not a forecaster', () => { - const testStore = buildTestStore({ - ...initialState, - roles: [] - }) - expect(window.location.href).toBe('') - - render( - - -
-
-
- ) - expect(window.location.href).toBe('') - }) -}) diff --git a/web/src/features/auth/slices/wf1AuthenticationSlice.test.ts b/web/src/features/auth/slices/wf1AuthenticationSlice.test.ts deleted file mode 100644 index d6e9da5c5d..0000000000 --- a/web/src/features/auth/slices/wf1AuthenticationSlice.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import wf1AuthReducer, { - initialState, - authenticated, - unAuthenticated, - authenticateError -} from 'features/auth/slices/wf1AuthenticationSlice' - -describe('wf1AuthenticationSlice', () => { - const testToken = 'testToken' - - describe('reducer', () => { - it('should be initialized with correct state', () => { - expect( - wf1AuthReducer(undefined, { - type: undefined - }) - ).toEqual(initialState) - }) - it('should set authenticate start when authenticateStart is called', () => { - expect(wf1AuthReducer(initialState, authenticated(testToken))).toEqual({ - ...initialState, - wf1Token: testToken - }) - }) - it('should unset token when unAuthenticated is dispatched ', () => { - const signedInState = { - ...initialState, - wf1Token: testToken - } - - expect(wf1AuthReducer(signedInState, unAuthenticated())).toEqual({ - ...initialState, - wf1Token: undefined - }) - }) - it('should unset token and set error when authenticateError is dispatched ', () => { - const signedInState = { - ...initialState, - wf1Token: testToken - } - - expect(wf1AuthReducer(signedInState, authenticateError('error'))).toEqual({ - ...initialState, - wf1Token: undefined, - error: 'error' - }) - }) - }) -}) diff --git a/web/src/features/auth/slices/wf1AuthenticationSlice.ts b/web/src/features/auth/slices/wf1AuthenticationSlice.ts deleted file mode 100644 index f07bdcc8a4..0000000000 --- a/web/src/features/auth/slices/wf1AuthenticationSlice.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit' -import { AppThunk } from 'app/store' - -interface State { - wf1Token: string | undefined - error: string | null -} - -export const initialState: State = { - wf1Token: undefined, - error: null -} - -const wf1AuthSlice = createSlice({ - name: 'wf1Authentication', - initialState, - reducers: { - authenticated(state: State, action: PayloadAction) { - state.wf1Token = action.payload - }, - unAuthenticated(state: State) { - state.wf1Token = undefined - }, - authenticateError(state: State, action: PayloadAction) { - state.wf1Token = undefined - state.error = action.payload - } - } -}) - -export const { authenticated, unAuthenticated, authenticateError } = wf1AuthSlice.actions - -export default wf1AuthSlice.reducer - -export const wf1Authenticate = - (wf1Token: string): AppThunk => - dispatch => { - dispatch(authenticated(wf1Token)) - } - -export const wf1Signout = (): AppThunk => async dispatch => { - dispatch(unAuthenticated()) -} - -export const wf1AuthenticateError = - (error: string): AppThunk => - dispatch => { - dispatch(authenticateError(error)) - } diff --git a/web/src/features/moreCast2/components/TabbedDataGrid.tsx b/web/src/features/moreCast2/components/TabbedDataGrid.tsx index 4f6319501e..d7f30242b1 100644 --- a/web/src/features/moreCast2/components/TabbedDataGrid.tsx +++ b/web/src/features/moreCast2/components/TabbedDataGrid.tsx @@ -26,7 +26,7 @@ import { selectSelectedStations } from 'features/moreCast2/slices/selectedStatio import { cloneDeep, groupBy, isEqual, isNull, isUndefined } from 'lodash' import SaveForecastButton from 'features/moreCast2/components/SaveForecastButton' import { ROLES } from 'features/auth/roles' -import { selectAuthentication, selectWf1Authentication } from 'app/rootReducer' +import { selectAuthentication } from 'app/rootReducer' import { DateRange } from 'components/dateRangePicker/types' import MoreCast2Snackbar from 'features/moreCast2/components/MoreCast2Snackbar' import { isForecastRowPredicate, getRowsToSave, isForecastValid } from 'features/moreCast2/saveForecasts' @@ -74,7 +74,6 @@ const TabbedDataGrid = ({ fromTo, setFromTo, fetchWeatherIndeterminates }: Tabbe const selectedStations = useSelector(selectSelectedStations) const loading = useSelector(selectWeatherIndeterminatesLoading) const { roles, isAuthenticated } = useSelector(selectAuthentication) - const { wf1Token } = useSelector(selectWf1Authentication) // All MoreCast2Rows derived from WeatherIndeterminates in dataSlice.ts. Updates in response to // a change of station group or date range. @@ -449,9 +448,9 @@ const TabbedDataGrid = ({ fromTo, setFromTo, fetchWeatherIndeterminates }: Tabbe } const handleSaveClick = async () => { - if (isForecastValid(visibleRows) && !isUndefined(wf1Token)) { + if (isForecastValid(visibleRows)) { const rowsToSave: MoreCast2ForecastRow[] = getRowsToSave(visibleRows) - const result = await submitMoreCastForecastRecords(wf1Token, rowsToSave) + const result = await submitMoreCastForecastRecords(rowsToSave) if (result.success) { setSnackbarMessage(FORECAST_SAVED_MESSAGE) setSnackbarSeverity('success')