Files
beanflows/web/src/beanflows/public/routes.py
Deeman 67c048485b Add Phase 1A-C + ICE warehouse stocks: prices, methodology, pipeline automation
Phase 1A — KC=F Coffee Futures Prices:
- New extract/coffee_prices/ package (yfinance): downloads KC=F daily OHLCV,
  stores as gzip CSV with SHA256-based idempotency
- SQLMesh models: raw/coffee_prices → foundation/fct_coffee_prices →
  serving/coffee_prices (with 20d/50d SMA, 52-week high/low, daily return %)
- Dashboard: 4 metric cards + dual-line chart (close, 20d MA, 50d MA)
- API: GET /commodities/<ticker>/prices

Phase 1B — Data Methodology Page:
- New /methodology route with full-page template (base.html)
- 6 anchored sections: USDA PSD, CFTC COT, KC=F price, ICE warehouse stocks,
  data quality model, update schedule table
- "Methodology" link added to marketing footer

Phase 1C — Automated Pipeline:
- supervisor.sh updated: runs extract_cot, extract_prices, extract_ice in
  sequence before transform
- Webhook failure alerting via ALERT_WEBHOOK_URL env var (ntfy/Slack/Telegram)

ICE Warehouse Stocks:
- New extract/ice_stocks/ package (niquests): normalizes ICE Report Center CSV
  to canonical schema, hash-based idempotency, soft-fail on 404 with guidance
- SQLMesh models: raw/ice_warehouse_stocks → foundation/fct_ice_warehouse_stocks
  → serving/ice_warehouse_stocks (30d avg, WoW change, 52w drawdown)
- Dashboard: 4 metric cards + line chart (certified bags + 30d avg)
- API: GET /commodities/<code>/stocks

Foundation:
- dim_commodity: added ticker (KC=F) and ice_stock_report_code (COFFEE-C) columns
- macros/__init__.py: added prices_glob() and ice_stocks_glob()
- pipelines.py: added extract_prices and extract_ice entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 11:41:43 +01:00

128 lines
3.9 KiB
Python

"""
Public domain: landing page, marketing pages, legal pages, feedback, sitemap.
"""
from pathlib import Path
from datetime import datetime
from quart import Blueprint, render_template, request, g, make_response
from ..core import config, execute, check_rate_limit, csrf_protect
# Blueprint with its own template folder
bp = Blueprint(
"public",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
)
@bp.route("/")
async def landing():
"""Landing page."""
return await render_template("landing.html")
@bp.route("/features")
async def features():
"""Features page."""
return await render_template("features.html")
@bp.route("/terms")
async def terms():
"""Terms of service."""
return await render_template("terms.html")
@bp.route("/privacy")
async def privacy():
"""Privacy policy."""
return await render_template("privacy.html")
@bp.route("/about")
async def about():
"""About page."""
return await render_template("about.html")
@bp.route("/methodology")
async def methodology():
"""Data methodology page — explains all data sources."""
return await render_template("methodology.html")
@bp.route("/feedback", methods=["POST"])
@csrf_protect
async def feedback():
"""Submit feedback. Rate-limited to 5/hour per IP. Returns HTMX snippet."""
ip = request.remote_addr or "unknown"
allowed, _ = await check_rate_limit(f"feedback:{ip}", limit=5, window=3600)
if not allowed:
return '<p style="color:#EF4444;font-size:13px;">Too many submissions. Try again later.</p>', 429
form = await request.form
message = (form.get("message") or "").strip()
page_url = (form.get("page_url") or "").strip()[:500]
if not message:
return '<p style="color:#EF4444;font-size:13px;">Message cannot be empty.</p>', 400
if len(message) > 2000:
return '<p style="color:#EF4444;font-size:13px;">Message too long (max 2000 characters).</p>', 400
user_id = g.user["id"] if g.get("user") else None
await execute(
"""
INSERT INTO feedback (user_id, page_url, message, created_at)
VALUES (?, ?, ?, ?)
""",
(user_id, page_url, message, datetime.utcnow().isoformat()),
)
return '<p style="color:#15803D;font-size:13px;font-weight:500;">Thanks for your feedback!</p>'
@bp.route("/robots.txt")
async def robots_txt():
"""robots.txt for search engines."""
body = "User-agent: *\n"
body += "Disallow: /admin/\n"
body += "Disallow: /auth/\n"
body += "Disallow: /dashboard/\n"
body += "Disallow: /billing/\n"
body += f"Sitemap: {config.BASE_URL.rstrip('/')}/sitemap.xml\n"
response = await make_response(body)
response.headers["Content-Type"] = "text/plain"
return response
@bp.route("/sitemap.xml")
async def sitemap_xml():
"""XML sitemap for search engines."""
base = config.BASE_URL.rstrip("/")
def url_entry(loc: str, priority: str = "0.5", changefreq: str = "weekly") -> str:
return (
f" <url>\n"
f" <loc>{loc}</loc>\n"
f" <changefreq>{changefreq}</changefreq>\n"
f" <priority>{priority}</priority>\n"
f" </url>\n"
)
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
xml += url_entry(f"{base}/", priority="1.0", changefreq="daily")
xml += url_entry(f"{base}/features", priority="0.8")
xml += url_entry(f"{base}/about", priority="0.6")
xml += url_entry(f"{base}/pricing", priority="0.8")
xml += url_entry(f"{base}/terms", priority="0.3", changefreq="yearly")
xml += url_entry(f"{base}/privacy", priority="0.3", changefreq="yearly")
# Add dynamic BeanFlows entries here (e.g. public commodity pages)
xml += "</urlset>"
response = await make_response(xml)
response.headers["Content-Type"] = "application/xml"
return response