Fix metric column name casing: DuckDB returns lowercase, align everywhere

SQLMesh normalizes unquoted identifiers to lowercase in physical tables,
so commodity_metrics columns are e.g. 'production' not 'Production'.
Update ALLOWED_METRICS, all analytics.py SQL queries, dashboard routes,
and both dashboard templates (Jinja + JS chart references) to use
lowercase column names consistently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-20 17:14:52 +01:00
parent 423fb8c619
commit d569ba0162
4 changed files with 37 additions and 37 deletions

View File

@@ -14,19 +14,19 @@ COFFEE_COMMODITY_CODE = 711100
# Metrics safe for user-facing queries (prevents SQL injection in dynamic column refs) # Metrics safe for user-facing queries (prevents SQL injection in dynamic column refs)
ALLOWED_METRICS = frozenset({ ALLOWED_METRICS = frozenset({
"Production", "production",
"Imports", "imports",
"Exports", "exports",
"Total_Distribution", "total_distribution",
"Ending_Stocks", "ending_stocks",
"Beginning_Stocks", "beginning_stocks",
"Total_Supply", "total_supply",
"Domestic_Consumption", "domestic_consumption",
"Net_Supply", "net_supply",
"Trade_Balance", "trade_balance",
"Supply_Demand_Balance", "supply_demand_balance",
"Stock_to_Use_Ratio_pct", "stock_to_use_ratio_pct",
"Production_YoY_pct", "production_yoy_pct",
}) })
_conn: duckdb.DuckDBPyConnection | None = None _conn: duckdb.DuckDBPyConnection | None = None
@@ -152,7 +152,7 @@ async def get_stock_to_use_trend(commodity_code: int) -> list[dict]:
"""Global stock-to-use ratio over time.""" """Global stock-to-use ratio over time."""
return await fetch_analytics( return await fetch_analytics(
""" """
SELECT market_year, Stock_to_Use_Ratio_pct SELECT market_year, stock_to_use_ratio_pct
FROM serving.commodity_metrics FROM serving.commodity_metrics
WHERE commodity_code = ? WHERE commodity_code = ?
AND country_name = 'Global' AND country_name = 'Global'
@@ -166,7 +166,7 @@ async def get_supply_demand_balance(commodity_code: int) -> list[dict]:
"""Global supply-demand balance trend.""" """Global supply-demand balance trend."""
return await fetch_analytics( return await fetch_analytics(
""" """
SELECT market_year, Production, Total_Distribution, Supply_Demand_Balance SELECT market_year, production, total_distribution, supply_demand_balance
FROM serving.commodity_metrics FROM serving.commodity_metrics
WHERE commodity_code = ? WHERE commodity_code = ?
AND country_name = 'Global' AND country_name = 'Global'
@@ -189,13 +189,13 @@ async def get_production_yoy_by_country(
WHERE commodity_code = ? AND country_code IS NOT NULL WHERE commodity_code = ? AND country_code IS NOT NULL
) )
SELECT country_name, country_code, market_year, SELECT country_name, country_code, market_year,
Production, Production_YoY_pct production, production_yoy_pct
FROM serving.commodity_metrics, latest FROM serving.commodity_metrics, latest
WHERE commodity_code = ? WHERE commodity_code = ?
AND country_code IS NOT NULL AND country_code IS NOT NULL
AND market_year = latest.max_year AND market_year = latest.max_year
AND Production > 0 AND production > 0
ORDER BY ABS(Production_YoY_pct) DESC ORDER BY ABS(production_yoy_pct) DESC
LIMIT ? LIMIT ?
""", """,
[commodity_code, commodity_code, limit], [commodity_code, commodity_code, limit],

View File

@@ -104,9 +104,9 @@ async def index():
time_series, top_producers, stu_trend, balance, yoy = await asyncio.gather( time_series, top_producers, stu_trend, balance, yoy = await asyncio.gather(
analytics.get_global_time_series( analytics.get_global_time_series(
analytics.COFFEE_COMMODITY_CODE, analytics.COFFEE_COMMODITY_CODE,
["Production", "Exports", "Imports", "Ending_Stocks", "Total_Distribution"], ["production", "exports", "imports", "ending_stocks", "total_distribution"],
), ),
analytics.get_top_countries(analytics.COFFEE_COMMODITY_CODE, "Production", limit=10), analytics.get_top_countries(analytics.COFFEE_COMMODITY_CODE, "production", limit=10),
analytics.get_stock_to_use_trend(analytics.COFFEE_COMMODITY_CODE), analytics.get_stock_to_use_trend(analytics.COFFEE_COMMODITY_CODE),
analytics.get_supply_demand_balance(analytics.COFFEE_COMMODITY_CODE), analytics.get_supply_demand_balance(analytics.COFFEE_COMMODITY_CODE),
analytics.get_production_yoy_by_country(analytics.COFFEE_COMMODITY_CODE, limit=15), analytics.get_production_yoy_by_country(analytics.COFFEE_COMMODITY_CODE, limit=15),
@@ -147,11 +147,11 @@ async def countries():
plan = (g.get("subscription") or {}).get("plan", "free") plan = (g.get("subscription") or {}).get("plan", "free")
# Get available countries for coffee # Get available countries for coffee
all_countries = await analytics.get_top_countries(analytics.COFFEE_COMMODITY_CODE, "Production", limit=50) all_countries = await analytics.get_top_countries(analytics.COFFEE_COMMODITY_CODE, "production", limit=50)
# Parse query params # Parse query params
selected_codes = request.args.getlist("country") selected_codes = request.args.getlist("country")
metric = request.args.get("metric", "Production") metric = request.args.get("metric", "production")
comparison_data = [] comparison_data = []
if selected_codes: if selected_codes:

View File

@@ -21,7 +21,7 @@
<div> <div>
<label for="metric" class="form-label">Metric</label> <label for="metric" class="form-label">Metric</label>
<select name="metric" id="metric" class="form-input" onchange="this.form.submit()"> <select name="metric" id="metric" class="form-input" onchange="this.form.submit()">
{% for m in ["Production", "Exports", "Imports", "Ending_Stocks"] %} {% for m in ["production", "exports", "imports", "ending_stocks"] %}
<option value="{{ m }}" {{ "selected" if metric == m }}>{{ m | replace("_", " ") }}</option> <option value="{{ m }}" {{ "selected" if metric == m }}>{{ m | replace("_", " ") }}</option>
{% endfor %} {% endfor %}
</select> </select>

View File

@@ -18,7 +18,7 @@
<div class="grid-4 mb-8"> <div class="grid-4 mb-8">
<div class="metric-card"> <div class="metric-card">
<div class="metric-label">Global Production (latest year)</div> <div class="metric-label">Global Production (latest year)</div>
<div class="metric-value">{{ "{:,.0f}".format(latest.get("Production", 0)) }}</div> <div class="metric-value">{{ "{:,.0f}".format(latest.get("production", 0)) }}</div>
<div class="metric-sub">1,000 60-kg bags</div> <div class="metric-sub">1,000 60-kg bags</div>
</div> </div>
@@ -26,7 +26,7 @@
<div class="metric-label">Stock-to-Use Ratio</div> <div class="metric-label">Stock-to-Use Ratio</div>
<div class="metric-value"> <div class="metric-value">
{% if stu_trend %} {% if stu_trend %}
{{ "{:.1f}".format(stu_trend[-1].get("Stock_to_Use_Ratio_pct", 0)) }}% {{ "{:.1f}".format(stu_trend[-1].get("stock_to_use_ratio_pct", 0)) }}%
{% else %} {% else %}
-- --
{% endif %} {% endif %}
@@ -36,7 +36,7 @@
<div class="metric-card"> <div class="metric-card">
<div class="metric-label">Trade Balance</div> <div class="metric-label">Trade Balance</div>
<div class="metric-value">{{ "{:,.0f}".format(latest.get("Exports", 0) - latest.get("Imports", 0)) }}</div> <div class="metric-value">{{ "{:,.0f}".format(latest.get("exports", 0) - latest.get("imports", 0)) }}</div>
<div class="metric-sub">Exports minus imports</div> <div class="metric-sub">Exports minus imports</div>
</div> </div>
@@ -90,10 +90,10 @@
{% for row in yoy %} {% for row in yoy %}
<tr> <tr>
<td>{{ row.country_name }}</td> <td>{{ row.country_name }}</td>
<td class="text-right">{{ "{:,.0f}".format(row.Production) }}</td> <td class="text-right">{{ "{:,.0f}".format(row.production) }}</td>
<td class="text-right {% if row.Production_YoY_pct and row.Production_YoY_pct > 0 %}text-bean-green{% elif row.Production_YoY_pct and row.Production_YoY_pct < 0 %}text-danger{% endif %}"> <td class="text-right {% if row.production_yoy_pct and row.production_yoy_pct > 0 %}text-bean-green{% elif row.production_yoy_pct and row.production_yoy_pct < 0 %}text-danger{% endif %}">
{% if row.Production_YoY_pct is not none %} {% if row.production_yoy_pct is not none %}
{{ "{:+.1f}%".format(row.Production_YoY_pct) }} {{ "{:+.1f}%".format(row.production_yoy_pct) }}
{% else %} {% else %}
-- --
{% endif %} {% endif %}
@@ -163,11 +163,11 @@ if (tsData.length > 0) {
data: { data: {
labels: tsData.map(r => r.market_year), labels: tsData.map(r => r.market_year),
datasets: [ datasets: [
{label: 'Production', data: tsData.map(r => r.Production), borderColor: CHART_PALETTE[0], tension: 0.3}, {label: 'Production', data: tsData.map(r => r.production), borderColor: CHART_PALETTE[0], tension: 0.3},
{label: 'Exports', data: tsData.map(r => r.Exports), borderColor: CHART_PALETTE[1], tension: 0.3}, {label: 'Exports', data: tsData.map(r => r.exports), borderColor: CHART_PALETTE[1], tension: 0.3},
{label: 'Imports', data: tsData.map(r => r.Imports), borderColor: CHART_PALETTE[2], tension: 0.3}, {label: 'Imports', data: tsData.map(r => r.imports), borderColor: CHART_PALETTE[2], tension: 0.3},
{label: 'Ending Stocks', data: tsData.map(r => r.Ending_Stocks), borderColor: CHART_PALETTE[3], tension: 0.3}, {label: 'Ending Stocks', data: tsData.map(r => r.ending_stocks), borderColor: CHART_PALETTE[3], tension: 0.3},
{label: 'Total Distribution', data: tsData.map(r => r.Total_Distribution), borderColor: CHART_PALETTE[4], tension: 0.3}, {label: 'Total Distribution', data: tsData.map(r => r.total_distribution), borderColor: CHART_PALETTE[4], tension: 0.3},
] ]
}, },
options: { options: {
@@ -187,7 +187,7 @@ if (stuData.length > 0) {
labels: stuData.map(r => r.market_year), labels: stuData.map(r => r.market_year),
datasets: [{ datasets: [{
label: 'Stock-to-Use Ratio (%)', label: 'Stock-to-Use Ratio (%)',
data: stuData.map(r => r.Stock_to_Use_Ratio_pct), data: stuData.map(r => r.stock_to_use_ratio_pct),
borderColor: CHART_COLORS.copper, borderColor: CHART_COLORS.copper,
backgroundColor: 'rgba(180, 83, 9, 0.08)', backgroundColor: 'rgba(180, 83, 9, 0.08)',
fill: true, fill: true,
@@ -211,7 +211,7 @@ if (topData.length > 0) {
labels: topData.map(r => r.country_name), labels: topData.map(r => r.country_name),
datasets: [{ datasets: [{
label: 'Production', label: 'Production',
data: topData.map(r => r.Production), data: topData.map(r => r.production),
backgroundColor: CHART_COLORS.copper backgroundColor: CHART_COLORS.copper
}] }]
}, },