feat: Add dashboard support and enhance Discord webhook functionality
Build and Deploy Docker Container / build-and-deploy (push) Failing after 3m8s
Test Application / test (push) Failing after 28s

- Updated docker-compose.yml to expose dashboard on port 8080.
- Enhanced main.py with timezone database caching and improved state management.
- Introduced a minimal dashboard using Flask to display webhook status and notifications.
- Added templates.json for customizable embed messages in Discord notifications.
- Created templates.py for loading and saving notification templates.
- Implemented tests for dashboard rendering and main functionality.
- Added requirements for Flask and tzdata to support new features.
- Included test cases for timezone handling and template management.
This commit is contained in:
2026-05-09 21:59:52 +02:00
parent 983c7cde9d
commit e30920067f
13 changed files with 1143 additions and 44 deletions
+65
View File
@@ -0,0 +1,65 @@
from datetime import datetime
from dashboard import create_app
def test_dashboard_renders_locations():
state = {
"running": True,
"started_at": datetime(2025, 1, 1, 0, 0, 0),
"last_type": "420",
"last_attempt_at": datetime(2025, 1, 1, 0, 1, 2),
"last_success_at": datetime(2025, 1, 1, 0, 1, 3),
"last_status_code": 204,
"last_error": None,
"last_locations": ["Nowhere", "Somewhere"],
}
app = create_app(
get_state=lambda: state,
get_next_event=lambda: {"type": "reminder", "at": datetime(2025, 1, 1, 0, 15, 0)},
)
client = app.test_client()
resp = client.get("/")
assert resp.status_code == 200
body = resp.data.decode("utf-8")
assert "thc-webhook" in body
assert "Locations" in body
assert "<li>Nowhere</li>" in body
assert "<li>Somewhere</li>" in body
def test_admin_roundtrip(tmp_path, monkeypatch):
templates_path = tmp_path / "templates.json"
monkeypatch.setenv("TEMPLATES_PATH", str(templates_path))
app = create_app(
get_state=lambda: {},
get_next_event=lambda: {"type": "reminder", "at": datetime(2025, 1, 1, 0, 15, 0)},
)
app.config["TEMPLATES_PATH"] = str(templates_path)
client = app.test_client()
# GET admin should render
resp = client.get("/admin")
assert resp.status_code == 200
# POST should save
form = {
"reminder__text": "R",
"reminder__color": "#e67e22",
"reminder__image_url": "",
"420__text": "B",
"420__color": "0x2ecc71",
"420__image_url": "http://example.com/img.png",
"reminder_halftime__text": "H",
"reminder_halftime__color": "15158306",
"reminder_halftime__image_url": "",
"halftime__text": "HT",
"halftime__color": "3066993",
"halftime__image_url": "",
}
resp = client.post("/admin", data=form)
assert resp.status_code == 200
assert templates_path.exists()
+136
View File
@@ -0,0 +1,136 @@
import io
import time
from unittest import mock
import pytest
import main
SAMPLE_TIMEZONE_CSV = """Etc/UTC,ZZ,UTC,0,0,0
America/New_York,US,EST,0,-18000,0
Europe/London,GB,BST,0,0,1
"""
SAMPLE_COUNTRY_CSV = """ZZ,Unknown
US,United States
GB,United Kingdom
"""
def test_load_timezones_and_countries(monkeypatch):
tzs = main.load_timezones()
countries = main.load_countries()
assert any(t['zone_name'] == 'America/New_York' for t in tzs)
assert any(c['country_code'] == 'US' for c in countries)
def test_get_tz_and_country_info():
timezones = [{'zone_name': 'A/B', 'country_code': 'US'}]
countries = [{'country_code': 'US', 'country_name': 'United States'}]
assert main.get_tz_info('A/B', timezones)['zone_name'] == 'A/B'
assert main.get_country_info('US', countries)[
'country_name'] == 'United States'
assert main.get_tz_info('X/Y', timezones) is None
assert main.get_country_info('XX', countries) is None
def test_create_embed_all_types(monkeypatch):
# Prevent create_embed from trying to read actual CSV files by patching loaders
monkeypatch.setattr(main, 'load_timezones', lambda: [
{'zone_name': 'Etc/UTC', 'country_code': 'ZZ'}])
monkeypatch.setattr(main, 'load_countries', lambda: [
{'country_code': 'ZZ', 'country_name': 'Nowhere'}])
# reminder
emb = main.create_embed('reminder')
assert emb['title'] == 'Reminder'
assert '5 minute' in emb['description']
assert emb['color'] == 0xe67e22
# reminder_halftime
emb = main.create_embed('reminder_halftime')
assert emb['title'] == 'Reminder halftime'
assert 'Half-time in 5 minutes' in emb['description']
# halftime (should include image)
monkeypatch.setattr(main, 'where_is_it_420', lambda tzs, cs, **kwargs: [])
emb = main.create_embed('halftime')
assert emb['title'] == 'Halftime'
assert emb['image'] is not None
# 420 (should include image and appended tz info string when list empty)
monkeypatch.setattr(main, 'where_is_it_420', lambda tzs, cs, **kwargs: [])
emb = main.create_embed('420')
assert emb['title'] == '420'
assert emb['image'] is not None
assert "not 4:20" in emb['description'] or "It's 4:20" in emb['description']
# unknown
emb = main.create_embed('nope')
assert emb['description'] == 'Unknown notification type'
def test_where_is_it_420(monkeypatch):
# Limit timezones to a predictable set
monkeypatch.setattr(main.pytz, 'all_timezones', ['Etc/UTC'])
tzs = [{'zone_name': 'Etc/UTC', 'country_code': 'ZZ'}]
countries = [{'country_code': 'ZZ', 'country_name': 'Nowhere'}]
class FakeDatetime:
@staticmethod
def now(tz):
class R:
hour = 4
return R()
monkeypatch.setattr(main, 'datetime', FakeDatetime)
monkeypatch.setattr(main, 'get_tz_info', lambda name,
t: tzs[0] if name == 'Etc/UTC' else None)
monkeypatch.setattr(main, 'get_country_info', lambda code,
c: countries[0] if code == 'ZZ' else None)
res = main.where_is_it_420(tzs, countries)
assert res == ['Nowhere']
def test_main_exits_quickly(monkeypatch):
# Patch send_notification so it doesn't perform network
monkeypatch.setattr(main, 'send_notification', lambda x: None)
# Don't start dashboard during this test
monkeypatch.setattr(main, 'start_dashboard', lambda: None)
# Make schedule.run_pending raise KeyboardInterrupt to exit loop
monkeypatch.setattr(main.schedule, 'run_pending', lambda: (
_ for _ in ()).throw(KeyboardInterrupt()))
# Patch time.sleep to no-op
monkeypatch.setattr(main.time, 'sleep', lambda s: None)
# Ensure WEBHOOK_URL present to avoid early return
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'http://example.com/webhook')
main.WEBHOOK_URL = 'http://example.com/webhook'
# Should exit quickly due to KeyboardInterrupt from run_pending
main.main()
def test_get_next_scheduled_event():
# 10:14 -> next is 10:15 reminder
now = main.datetime(2025, 1, 1, 10, 14, 30)
nxt = main.get_next_scheduled_event(now)
assert nxt["type"] == "reminder"
assert nxt["at"].hour == 10 and nxt["at"].minute == 15
# 10:50:01 -> next is 11:15 reminder
now = main.datetime(2025, 1, 1, 10, 50, 1)
nxt = main.get_next_scheduled_event(now)
assert nxt["type"] == "reminder"
assert nxt["at"].hour == 11 and nxt["at"].minute == 15
def test_split_tz_name():
assert main.split_tz_name("America/New_York") == ("America", "New_York")
assert main.split_tz_name("America/Argentina/Buenos_Aires") == (
"America",
"Argentina/Buenos_Aires",
)
assert main.split_tz_name("UTC") == ("UTC", "")
+27
View File
@@ -0,0 +1,27 @@
from templates import DEFAULT_TEMPLATES, load_templates, parse_color, save_templates
def test_parse_color():
assert parse_color("#e67e22") == 0xE67E22
assert parse_color("e67e22") == 0xE67E22
assert parse_color("0xe67e22") == 0xE67E22
assert parse_color("15158306") == 15158306
def test_load_templates_missing_file(tmp_path):
templates = load_templates(tmp_path / "missing.json")
assert templates["420"]["text"] == DEFAULT_TEMPLATES["420"]["text"]
def test_save_and_load_templates_roundtrip(tmp_path):
path = tmp_path / "templates.json"
data = {
"420": {"text": "Custom", "color": 123},
}
save_templates(path, data)
loaded = load_templates(path)
assert loaded["420"]["text"] == "Custom"
assert loaded["420"]["color"] == 123
# defaults should still exist for other keys
assert "reminder" in loaded