Add standalone weather widget (current/hourly/daily/multi-city, pluggable providers)
New widget type with four display modes -- current conditions, an hourly forecast strip, a multi-day forecast, and several cities' current day side by side -- backed by a pluggable provider registry (app/weather/, mirroring the app/widgets/ dispatch pattern): Open-Meteo (worldwide) and NWS (US-only) both wired up now, Environment Canada documented as the next one to add given its more involved station/grid-lookup API. The calendar widget's existing embedded weather strip is untouched and still Open-Meteo-only; this lifts the same underlying icon-drawing primitives (now shared via app/weather_render.py, calendar_render.py still imports draw_weather_row unchanged) into a widget that can be placed and sized on its own. Icons are redrawn in the panel's actual ink colors (yellow sun/bolt, blue rain/snow) instead of flat black, and build_multi_city's icon/font sizing now scales with how many cities need to fit rather than the box's height alone -- both fixed after catching them via live browser verification, along with a mode-switch cache-shape crash and a mobile-width dialog overflow. New WeatherWidgetConfig table (migration 24), grid footprint, widget module, common.py fetch/cache helper, router endpoints (location set/ clear, city add/remove, preview), dialog template + JS, and full test coverage (providers, widget render, HTTP endpoints, migration replay). docs/widgets.md and CLAUDE.md's TODO updated accordingly.
This commit is contained in:
@@ -20,6 +20,7 @@ from app.models import (
|
||||
StaticWidgetConfig,
|
||||
TaskWidgetConfig,
|
||||
TextWidgetConfig,
|
||||
WeatherWidgetConfig,
|
||||
Widget,
|
||||
)
|
||||
|
||||
@@ -80,6 +81,18 @@ def _add_text_widget(db_session) -> Widget:
|
||||
return widget
|
||||
|
||||
|
||||
def _add_weather_widget(db_session, **cfg_kwargs) -> Widget:
|
||||
import time
|
||||
|
||||
widget = Widget(frame_id=1, widget_type="weather", x=0, y=0, w=2, h=2,
|
||||
sort_order=1, created_at=time.time())
|
||||
db_session.add(widget)
|
||||
db_session.flush()
|
||||
db_session.add(WeatherWidgetConfig(widget_id=widget.id, **cfg_kwargs))
|
||||
db_session.commit()
|
||||
return widget
|
||||
|
||||
|
||||
def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", size, color).save(buf, format="PNG")
|
||||
@@ -150,6 +163,73 @@ def test_config_save_updates_a_tasks_widget(client, db_session):
|
||||
assert cfg.show_completed is True
|
||||
|
||||
|
||||
def test_config_save_updates_a_weather_widget(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_weather_widget(db_session)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/config",
|
||||
data={"weather_mode": "hourly", "weather_provider": "nws", "weather_units": "celsius",
|
||||
"weather_hourly_interval_hours": "6", "weather_daily_days": "3"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
cfg = db_session.get(WeatherWidgetConfig, widget.id)
|
||||
assert cfg.mode == "hourly"
|
||||
assert cfg.provider == "nws"
|
||||
assert cfg.units == "celsius"
|
||||
assert cfg.hourly_interval_hours == 6
|
||||
assert cfg.daily_days == 3
|
||||
|
||||
|
||||
def test_config_save_ignores_an_invalid_weather_mode(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_weather_widget(db_session)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/config",
|
||||
data={"weather_mode": "not_a_real_mode"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
cfg = db_session.get(WeatherWidgetConfig, widget.id)
|
||||
assert cfg.mode == "current" # unchanged -- invalid value silently ignored, same as calendar_view's own validation
|
||||
|
||||
|
||||
def test_config_save_switching_mode_clears_the_now_incompatible_cache(client, db_session, monkeypatch):
|
||||
"""Regression test: a widget's `cached` shape depends on its mode (a
|
||||
single-temp dict for current, a list for hourly/multi_city, a dict
|
||||
for daily). Switching modes without clearing the old cache used to
|
||||
crash the very next preview -- get_or_refresh_weather_widget_data's
|
||||
multi_city branch tried `c["label"]` against a leftover "current"-
|
||||
mode dict, TypeError: string indices must be integers -- rather than
|
||||
just triggering a fresh fetch shaped for the new mode."""
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_weather_widget(db_session, mode="current", cached={"temp": 70.0, "category": "clear"},
|
||||
checked_at=1e15) # far in the future -- would still be "fresh" if not cleared
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/config",
|
||||
data={"weather_mode": "multi_city"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
cfg = db_session.get(WeatherWidgetConfig, widget.id)
|
||||
assert cfg.cached is None
|
||||
|
||||
cfg.cities = [{"label": "Portland, Oregon, United States", "latitude": 45.5, "longitude": -122.6}]
|
||||
db_session.commit()
|
||||
monkeypatch.setattr(
|
||||
"app.routers.api_widgets.get_or_refresh_weather_widget_data",
|
||||
lambda db, frame, widget, force=False: [
|
||||
{"label": "Portland", "high": 75, "low": 55, "category": "clear"},
|
||||
],
|
||||
)
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def test_config_save_truncates_an_overlong_tasks_name(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_tasks_widget(db_session)
|
||||
@@ -420,3 +500,130 @@ def test_preview_text_400s_for_a_widget_that_is_not_text(client, db_session):
|
||||
widget = _add_static_widget(db_session)
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# --- weather: location/cities/preview ---------------------------------
|
||||
|
||||
def _mock_geocode(monkeypatch, label="Portland, Oregon, United States", latitude=45.5, longitude=-122.6):
|
||||
monkeypatch.setattr(
|
||||
"app.routers.api_widgets.weather.geocode_city",
|
||||
lambda name: {"label": label, "latitude": latitude, "longitude": longitude},
|
||||
)
|
||||
|
||||
|
||||
def test_weather_location_set_and_clear(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_weather_widget(db_session, mode="current")
|
||||
_mock_geocode(monkeypatch)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/weather-location",
|
||||
json={"name": "Portland, OR"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["city"]["label"] == "Portland, Oregon, United States"
|
||||
|
||||
cfg = db_session.get(WeatherWidgetConfig, widget.id)
|
||||
assert cfg.city_label == "Portland, Oregon, United States"
|
||||
assert cfg.city_latitude == 45.5
|
||||
assert cfg.city_longitude == -122.6
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/weather-location",
|
||||
json={"name": None},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
cfg = db_session.get(WeatherWidgetConfig, widget.id)
|
||||
assert cfg.city_label is None
|
||||
assert cfg.city_latitude is None
|
||||
|
||||
|
||||
def test_weather_location_400s_for_a_widget_that_is_not_weather(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_calendar_widget(db_session)
|
||||
_mock_geocode(monkeypatch)
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/weather-location",
|
||||
json={"name": "Portland, OR"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_weather_widget_cities_add_and_remove(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_weather_widget(db_session, mode="multi_city")
|
||||
_mock_geocode(monkeypatch)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/weather-widget-cities/add",
|
||||
json={"name": "Portland, OR"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
cfg = db_session.get(WeatherWidgetConfig, widget.id)
|
||||
assert cfg.cities == [{"label": "Portland, Oregon, United States", "latitude": 45.5, "longitude": -122.6}]
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/weather-widget-cities/add",
|
||||
json={"name": "Portland, OR"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 400 # already on the list
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/weather-widget-cities/remove",
|
||||
json={"label": "Portland, Oregon, United States"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
cfg = db_session.get(WeatherWidgetConfig, widget.id)
|
||||
assert cfg.cities == []
|
||||
|
||||
|
||||
def test_weather_widget_cities_400s_for_a_widget_that_is_not_weather(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_calendar_widget(db_session)
|
||||
_mock_geocode(monkeypatch)
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/weather-widget-cities/add",
|
||||
json={"name": "Portland, OR"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_preview_weather_400s_before_anything_is_configured(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_weather_widget(db_session, mode="current")
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_preview_weather_400s_before_any_city_for_multi_city_mode(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_weather_widget(db_session, mode="multi_city")
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_preview_weather_renders_after_location_is_set(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_weather_widget(db_session, mode="current")
|
||||
monkeypatch.setattr(
|
||||
"app.routers.api_widgets.get_or_refresh_weather_widget_data",
|
||||
lambda db, frame, widget, force=False: {"temp": 72.0, "category": "clear"},
|
||||
)
|
||||
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
|
||||
|
||||
def test_preview_weather_400s_for_a_widget_that_is_not_weather(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_static_widget(db_session)
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
|
||||
assert resp.status_code == 400
|
||||
|
||||
Reference in New Issue
Block a user