feat: add ntfy push notifications for new requests

- Add ntfy server/topic settings in /admin/settings next to Hermes API key
- POST a push notification to the configured topic on every new customer request
- Add a Send Test ntfy button to verify configuration
- Add requests dependency for ntfy HTTP calls
- Bump version to 0.5.6
This commit is contained in:
Troll (Hermes Agent) 2026-08-07 17:14:02 +00:00
parent 410a9cf021
commit a56f6e498b
6 changed files with 105 additions and 8 deletions

View file

@ -1,6 +1,6 @@
# Theme Song Booth
**Version:** `v0.5.5`
**Version:** `v0.5.6`
A Flask web application for running a convention booth where visitors request a custom AI-generated theme song. Operators manage the queue from an admin dashboard, generate Suno prompts, upload MP3 previews, collect payment, and deliver final songs by email.

View file

@ -11,7 +11,7 @@ Flask app that lets convention attendees request custom AI-generated theme songs
- Python 3.12 + Flask
- SQLite (file-based, request-scoped connection via `g`)
- Gunicorn in Docker
- Portainer stack deployed from GitLab repo
- Portainer stack deployed from Gitea repo
- SMTP (SSL port 465) for customer emails
- Square Terminal/Reader for manual payment
- `mutagen` for MP3 metadata tagging
@ -20,7 +20,7 @@ Flask app that lets convention attendees request custom AI-generated theme songs
## Repository
- GitLab: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth` (public)
- Gitea: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth` (public)
- Deployed at: `https://booth.dionysismedia.ca`
## Key files and what they hold
@ -112,7 +112,7 @@ These survive redeploys because `booth_settings.json` lives in the persistent up
## How to redeploy
1. Push changes to GitLab `main`.
1. Push changes to Gitea `main`.
2. In Portainer: Stacks → `theme-song-booth`**Pull and redeploy**.
3. If schema changed, open container console and run `python init_db.py`, or use `/admin/settings`**Fix Database Schema**.
@ -134,17 +134,17 @@ These survive redeploys because `booth_settings.json` lives in the persistent up
## Project state notes
- No `.gitlab-ci.yml` is currently in the repo; old pipeline records from an earlier CI config are still visible in GitLab but are not actionable because no runners are attached. Add a CI skeleton (see below) if you want automated checks back.
- No `.gitlab-ci.yml` is currently in the repo; old pipeline records from an earlier CI config are still visible in Gitea but are not actionable because no runners are attached. Add a CI skeleton (see below) if you want automated checks back.
- No automated tests exist yet.
## CI skeleton (optional)
A **CI skeleton** is the smallest GitLab CI config that gives you useful automated checks on every push without needing a heavy test suite. For this project it would be a `.gitlab-ci.yml` with one or two jobs:
A **CI skeleton** is the smallest Gitea CI config that gives you useful automated checks on every push without needing a heavy test suite. For this project it would be a `.gitlab-ci.yml` with one or two jobs:
1. **Syntax check job** — install Python dependencies and run `python -m py_compile app.py models.py config.py init_db.py` to catch SyntaxErrors before they reach Portainer.
2. **(Optional) Test job** — run a minimal pytest suite once tests are written. Right now this would be a placeholder that skips if no tests exist, so the pipeline stays green while you decide whether to add tests.
It needs a GitLab runner to execute. Your GitLab instance has no runners attached, which is why the old pipelines are stuck/canceled. The skeleton just defines *what* to run; a runner is still required for it to actually execute.
It needs a Gitea runner to execute. Your Gitea instance has no runners attached, which is why the old pipelines are stuck/canceled. The skeleton just defines *what* to run; a runner is still required for it to actually execute.
## Static assets to keep in the repo

View file

@ -1 +1 @@
0.5.5
0.5.6

68
app.py
View file

@ -64,6 +64,9 @@ from models import SCHEMA, get_requests_by_email, log_revision, list_revision_hi
from mutagen.mp3 import MP3
from mutagen.easyid3 import EasyID3
# HTTP client for ntfy push notifications
import requests
# ---------------------------------------------------------------------------
# Genre / decade helpers
@ -411,6 +414,36 @@ def mask_api_key(key):
return '*' * (len(key) - 6) + key[-6:]
def get_ntfy_config():
"""Return the effective ntfy server URL and topic from runtime settings."""
cfg = load_booth_settings()
return {
'server': cfg.get('ntfy_server', ''),
'topic': cfg.get('ntfy_topic', ''),
}
def send_ntfy(message, title='Theme Song Booth', priority='default', tags='bell'):
"""Send a push notification to the configured ntfy topic, if configured."""
ntfy = get_ntfy_config()
server = ntfy.get('server', '').rstrip('/')
topic = ntfy.get('topic', '').strip()
if not server or not topic:
return False
url = f"{server}/{topic}"
headers = {
'Title': title,
'Priority': priority,
'Tags': tags,
}
try:
resp = requests.post(url, data=message.encode('utf-8'), headers=headers, timeout=10)
return resp.status_code in (200, 202)
except Exception:
return False
def sign_prompt_callback(rid, expires_at=None):
"""
Create a signed callback token for a specific request ID.
@ -559,6 +592,18 @@ def request_form():
return render_template('request.html', form=form_data, decades=DECADES, genres=MUSIC_GENRES), 400
rid = create_request(**form_data)
# Notify operator via ntfy when a new request comes in.
try:
send_ntfy(
f"New request #{rid} from {form_data['name']} ({form_data['email']})\nStyle: {form_data['style_genre'] or '-'}",
title='New Theme Song Request',
priority='high',
tags='musical_note'
)
except Exception:
# Push notification failure should never break the customer form.
pass
# Send confirmation email with a summary of what the customer asked for.
req = get_request_by_id(rid)
if req:
@ -1626,6 +1671,28 @@ def admin_settings():
flash('Hermes API key is managed via the HERMES_API_KEY environment variable.', 'info')
return redirect(url_for('admin_settings'))
elif action == 'save_ntfy':
# Update ntfy push notification server/topic from the settings form.
cfg = load_booth_settings()
cfg['ntfy_server'] = request.form.get('ntfy_server', '').strip().rstrip('/')
cfg['ntfy_topic'] = request.form.get('ntfy_topic', '').strip()
save_booth_settings(cfg)
flash('ntfy notification settings saved.', 'success')
return redirect(url_for('admin_settings'))
elif action == 'send_test_ntfy':
# Send a test push notification to the configured ntfy topic.
ntfy = get_ntfy_config()
if not ntfy['server'] or not ntfy['topic']:
flash('Configure ntfy server and topic first.', 'error')
return redirect(url_for('admin_settings'))
ok = send_ntfy('This is a test push notification from the Trollgorithm Theme Song Booth.', title='Test Notification', priority='high', tags='test_tube')
if ok:
flash('Test ntfy notification sent.', 'success')
else:
flash('Failed to send ntfy test notification. Check server and topic.', 'error')
return redirect(url_for('admin_settings'))
return render_template(
'admin/settings.html',
health=health,
@ -1653,6 +1720,7 @@ def admin_settings():
hermes_key_set=hermes_key_set,
hermes_key_just_generated=hermes_key_just_generated,
version=current_app.config['VERSION'],
ntfy=runtime_settings,
)

View file

@ -18,3 +18,4 @@ werkzeug
mutagen
flask-limiter
cryptography
requests

View file

@ -293,6 +293,34 @@
{% endif %}
</div>
<!-- ntfy push notifications -->
<div class="section">
<h2>ntfy Notifications</h2>
<p class="copy-hint">Push notifications for new customer requests. Leave either field blank to disable.</p>
<form method="POST">
<input type="hidden" name="action" value="save_ntfy">
<table class="meta-table">
<tr>
<td>ntfy Server URL</td>
<td><input type="text" id="ntfy_server" name="ntfy_server" value="{{ ntfy.get('ntfy_server', '') }}" placeholder="e.g. https://ntfy.hallsworth.ca"></td>
</tr>
<tr>
<td>ntfy Topic</td>
<td><input type="text" id="ntfy_topic" name="ntfy_topic" value="{{ ntfy.get('ntfy_topic', '') }}" placeholder="e.g. Troll-AI-ntfy"></td>
</tr>
</table>
<button type="submit">Save ntfy Settings</button>
</form>
<hr style="border:none;border-top:1px solid var(--border);margin:1.5rem 0;">
<h3>Send Test Notification</h3>
<form method="POST">
<input type="hidden" name="action" value="send_test_ntfy">
<button type="submit">Send Test ntfy</button>
</form>
</div>
<!-- Email / SMTP configuration -->
<div class="section">
<h2>Email (SMTP) Settings</h2>