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>
128 lines
3.9 KiB
Python
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
|