Compare commits
9 Commits
2e928de156
...
v10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5a218490e | ||
|
|
52bd731fc3 | ||
|
|
e85d0eab63 | ||
|
|
e872ba0204 | ||
|
|
8d1dbace0f | ||
|
|
cddcd4463e | ||
|
|
efb5a165e7 | ||
|
|
d58fa67238 | ||
|
|
66d484955d |
@@ -118,11 +118,11 @@ uv add --package new_source extract-core niquests
|
||||
- Each tick: git pull (tag-based) → due extractors → SQLMesh → export_serving → web deploy if changed
|
||||
- Crash-safe: systemd `Restart=always` + 10-minute backoff on tick failure
|
||||
|
||||
**CI/CD** (`.gitlab/.gitlab-ci.yml`) — pull-based, no SSH:
|
||||
- `test` stage: pytest, sqlmesh test, web pytest
|
||||
- `tag` stage: creates `v${CI_PIPELINE_IID}` tag after tests pass (master branch only)
|
||||
**CI/CD** (`.gitea/workflows/ci.yaml`) — pull-based, no SSH:
|
||||
- `test-cli`, `test-sqlmesh`, `test-web` jobs: pytest, sqlmesh test, web pytest
|
||||
- `tag` job: creates `v${github.run_number}` tag after all tests pass (master branch only)
|
||||
- Supervisor polls for new tags every 60s, checks out latest, runs `uv sync`
|
||||
- No SSH keys or deploy credentials in CI — only `CI_JOB_TOKEN` (built-in)
|
||||
- No SSH keys or deploy credentials in CI — only `github.token` (built-in Gitea Actions)
|
||||
|
||||
**CLI modules** (`src/materia/`):
|
||||
- `cli.py` — Typer app with subcommands: pipeline, secrets, version
|
||||
|
||||
@@ -15,7 +15,7 @@ set -euo pipefail
|
||||
|
||||
SERVICE_USER="beanflows_service"
|
||||
REPO_DIR="/opt/materia"
|
||||
GITEA_REPO="ssh://git@git.padelnomics.io:2222/deemanone/materia.git"
|
||||
GITEA_REPO="ssh://git@git.padelnomics.io:2222/deemanone/beanflows.git"
|
||||
UV="/home/${SERVICE_USER}/.local/bin/uv"
|
||||
|
||||
[ "$(id -u)" = "0" ] || { echo "ERROR: Run as root"; exit 1; }
|
||||
|
||||
@@ -7,7 +7,7 @@ Single-server local-first setup for BeanFlows.coffee on Hetzner NVMe.
|
||||
```
|
||||
Hetzner Server (NVMe)
|
||||
├── beanflows_service (system user, nologin)
|
||||
│ ├── ~/.ssh/materia_deploy # ed25519 deploy key for GitLab read access
|
||||
│ ├── ~/.ssh/beanflows_deploy # ed25519 deploy key for Gitea read access
|
||||
│ └── ~/.config/sops/age/keys.txt # age keypair (auto-discovered by SOPS)
|
||||
├── /opt/materia/ # Git repo (owned by beanflows_service, latest release tag)
|
||||
├── /opt/materia/.env # Decrypted from .env.prod.sops at deploy time
|
||||
@@ -37,11 +37,11 @@ bash infra/setup_server.sh
|
||||
|
||||
This creates the `beanflows_service` user, data directories, installs all tools (git, curl, age, sops, rclone, uv), generates an ed25519 SSH deploy key and an age keypair (both as the service user). It prints both public keys.
|
||||
|
||||
### 2. Add keys to GitLab and SOPS
|
||||
### 2. Add keys to Gitea and SOPS
|
||||
|
||||
```bash
|
||||
# Add the SSH deploy key to GitLab:
|
||||
# → Repository Settings → Deploy Keys → Add key (read-only)
|
||||
# Add the SSH deploy key to Gitea:
|
||||
# → git.padelnomics.io → beanflows repo → Settings → Deploy Keys → Add key (read-only)
|
||||
|
||||
# Add the server age public key to .sops.yaml on your workstation,
|
||||
# then re-encrypt prod secrets to include the server key:
|
||||
@@ -87,8 +87,8 @@ SOPS auto-discovers the service user's age key at `~/.config/sops/age/keys.txt`
|
||||
|
||||
No SSH keys or deploy credentials in CI.
|
||||
|
||||
1. CI runs tests (`test:cli`, `test:sqlmesh`, `test:web`)
|
||||
2. On master, CI creates tag `v${CI_PIPELINE_IID}` using built-in `CI_JOB_TOKEN`
|
||||
1. CI runs tests (`test-cli`, `test-sqlmesh`, `test-web`)
|
||||
2. On master, CI creates tag `v${github.run_number}` using built-in `github.token`
|
||||
3. Supervisor polls for new tags every 60s
|
||||
4. When a new tag appears: `git checkout --detach <tag>` + `uv sync --all-packages`
|
||||
5. If `web/` files changed: `./web/deploy.sh` (Docker blue/green + health check)
|
||||
|
||||
@@ -27,7 +27,7 @@ def test_secrets_test_command(mock_secrets):
|
||||
"""Test secrets test command."""
|
||||
result = runner.invoke(app, ["secrets", "test"])
|
||||
assert result.exit_code == 0
|
||||
assert "ESC connection successful" in result.stdout
|
||||
assert "SOPS decryption successful" in result.stdout
|
||||
|
||||
|
||||
def test_secrets_list_command(mock_secrets):
|
||||
|
||||
@@ -121,7 +121,7 @@ def test_extract_cot_year_skips_existing_file(tmp_path, monkeypatch):
|
||||
mock_head.headers = {"etag": f'"{etag}"'}
|
||||
mock_session.head.return_value = mock_head
|
||||
|
||||
result = cot_execute.extract_cot_year(2024, mock_session)
|
||||
result = cot_execute.extract_cot_year(2024, mock_session, cot_execute.COT_URL_FUTURES_ONLY, "cot")
|
||||
|
||||
assert result == 0
|
||||
mock_session.get.assert_not_called() # No download should occur
|
||||
@@ -141,7 +141,7 @@ def test_extract_cot_year_returns_false_on_404(tmp_path, monkeypatch):
|
||||
mock_head.status_code = 404
|
||||
mock_session.head.return_value = mock_head
|
||||
|
||||
result = cot_execute.extract_cot_year(2006, mock_session)
|
||||
result = cot_execute.extract_cot_year(2006, mock_session, cot_execute.COT_URL_FUTURES_ONLY, "cot")
|
||||
|
||||
assert result == 0
|
||||
mock_session.get.assert_not_called()
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Tests for ICE extraction: format detection, XLS parsing, API client."""
|
||||
|
||||
import csv
|
||||
import gzip
|
||||
import io
|
||||
import struct
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import xlwt # noqa: F401 — needed to create XLS fixtures; skip tests if missing
|
||||
@@ -96,7 +94,7 @@ def test_fetch_report_listings_parses_response():
|
||||
_make_api_row("Certified Stock Aging Report", "/dl/aging.xls"),
|
||||
])
|
||||
|
||||
from ice_stocks.ice_api import ICE_BASE_URL, fetch_report_listings
|
||||
from ice_stocks.ice_api import ICE_BASE_URL
|
||||
rows = fetch_report_listings(mock_session, product_id=2)
|
||||
|
||||
assert len(rows) == 2
|
||||
@@ -113,7 +111,6 @@ def test_fetch_report_listings_prepends_base_url_for_absolute():
|
||||
_make_api_row("Test", "https://other.example.com/file.xls"),
|
||||
])
|
||||
|
||||
from ice_stocks.ice_api import fetch_report_listings
|
||||
rows = fetch_report_listings(mock_session, product_id=2)
|
||||
assert rows[0]["download_url"] == "https://other.example.com/file.xls"
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ def fetch_country_data_from_duckdb() -> list[dict]:
|
||||
)
|
||||
SELECT * FROM ranked LIMIT 30
|
||||
""").fetchall()
|
||||
cols = [d[0] for d in conn.execute("""
|
||||
_ = [d[0] for d in conn.execute("""
|
||||
WITH latest AS (SELECT MAX(market_year) AS max_year FROM serving.commodity_metrics
|
||||
WHERE commodity_code = 711100 AND country_code IS NOT NULL)
|
||||
SELECT country_name, country_code, market_year, production * 1000,
|
||||
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
|
||||
from quart import Blueprint, flash, g, redirect, render_template, request, url_for
|
||||
|
||||
from ..core import execute, fetch_all, fetch_one, csrf_protect
|
||||
from ..core import csrf_protect, execute, fetch_all, fetch_one
|
||||
|
||||
bp = Blueprint(
|
||||
"cms",
|
||||
@@ -23,7 +23,8 @@ def admin_required(f):
|
||||
async def decorated(*args, **kwargs):
|
||||
if "admin" not in (g.get("user") or {}).get("roles", []):
|
||||
await flash("Admin access required.", "error")
|
||||
from quart import redirect as _redirect, url_for as _url_for
|
||||
from quart import redirect as _redirect
|
||||
from quart import url_for as _url_for
|
||||
return _redirect(_url_for("auth.login"))
|
||||
return await f(*args, **kwargs)
|
||||
return decorated
|
||||
@@ -71,7 +72,7 @@ async def list_template_data(template_id: int) -> list[dict]:
|
||||
|
||||
async def generate_article_from_data(data_row: dict, tmpl: dict) -> int | None:
|
||||
"""Generate (or regenerate) a single article from a template_data row."""
|
||||
from jinja2 import Environment, BaseLoader
|
||||
from jinja2 import BaseLoader, Environment
|
||||
|
||||
try:
|
||||
data = json.loads(data_row["data_json"])
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
|
||||
from quart import Blueprint, flash, g, redirect, render_template, request, session, url_for
|
||||
|
||||
from ..core import config, csrf_protect, execute, fetch_all, fetch_one
|
||||
from ..core import csrf_protect, execute, fetch_all, fetch_one
|
||||
|
||||
# Blueprint with its own template folder
|
||||
bp = Blueprint(
|
||||
|
||||
@@ -65,6 +65,7 @@ ALLOWED_METRICS = frozenset({
|
||||
|
||||
_local = threading.local()
|
||||
_db_path: str = ""
|
||||
_conn: duckdb.DuckDBPyConnection | None = None # test override: set to bypass _db_path / _local
|
||||
|
||||
|
||||
def open_analytics_db() -> None:
|
||||
@@ -110,12 +111,15 @@ async def fetch_analytics(sql: str, params: list | None = None) -> list[dict]:
|
||||
"""Run a read-only DuckDB query off the event loop. Returns list of dicts.
|
||||
Returns empty list if analytics DB is not configured (SERVING_DUCKDB_PATH unset
|
||||
or file missing at startup) — dashboard routes degrade gracefully.
|
||||
|
||||
If the module-level _conn is set (test override), it is used directly in place
|
||||
of the per-thread _get_conn() path.
|
||||
"""
|
||||
if not _db_path:
|
||||
if _conn is None and not _db_path:
|
||||
return []
|
||||
|
||||
def _query():
|
||||
conn = _get_conn()
|
||||
conn = _conn if _conn is not None else _get_conn()
|
||||
cursor = conn.cursor()
|
||||
result = cursor.execute(sql, params or [])
|
||||
columns = [desc[0] for desc in result.description]
|
||||
|
||||
@@ -136,7 +136,7 @@ async def list_commodities():
|
||||
@api_key_required(scopes=["read"])
|
||||
async def commodity_metrics(code: int):
|
||||
"""Time series metrics for a commodity. Query params: metrics, start_year, end_year."""
|
||||
raw_metrics = request.args.getlist("metrics") or ["Production", "Exports", "Imports", "Ending_Stocks"]
|
||||
raw_metrics = request.args.getlist("metrics") or ["production", "exports", "imports", "ending_stocks"]
|
||||
metrics = [m for m in raw_metrics if m in analytics.ALLOWED_METRICS]
|
||||
if not metrics:
|
||||
return jsonify({"error": f"No valid metrics. Allowed: {sorted(analytics.ALLOWED_METRICS)}"}), 400
|
||||
@@ -152,7 +152,7 @@ async def commodity_metrics(code: int):
|
||||
@api_key_required(scopes=["read"])
|
||||
async def commodity_countries(code: int):
|
||||
"""Country ranking for a commodity. Query params: metric, limit."""
|
||||
metric = request.args.get("metric", "Production")
|
||||
metric = request.args.get("metric", "production")
|
||||
if metric not in analytics.ALLOWED_METRICS:
|
||||
return jsonify({"error": f"Invalid metric. Allowed: {sorted(analytics.ALLOWED_METRICS)}"}), 400
|
||||
|
||||
@@ -368,7 +368,7 @@ async def commodity_metrics_csv(code: int):
|
||||
return jsonify({"error": "CSV export requires a Starter or Pro plan"}), 403
|
||||
|
||||
raw_metrics = request.args.getlist("metrics") or [
|
||||
"Production", "Exports", "Imports", "Ending_Stocks", "Total_Distribution",
|
||||
"production", "exports", "imports", "ending_stocks", "total_distribution",
|
||||
]
|
||||
metrics = [m for m in raw_metrics if m in analytics.ALLOWED_METRICS]
|
||||
if not metrics:
|
||||
|
||||
@@ -2,13 +2,20 @@
|
||||
Auth domain: magic link authentication, user management, decorators.
|
||||
"""
|
||||
import secrets
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Blueprint, render_template, request, redirect, url_for, session, flash, g
|
||||
from quart import Blueprint, flash, g, redirect, render_template, request, session, url_for
|
||||
|
||||
from ..core import config, fetch_one, fetch_all, execute, csrf_protect, waitlist_gate, capture_waitlist_email
|
||||
from ..core import (
|
||||
capture_waitlist_email,
|
||||
config,
|
||||
csrf_protect,
|
||||
execute,
|
||||
fetch_one,
|
||||
waitlist_gate,
|
||||
)
|
||||
|
||||
# Blueprint with its own template folder
|
||||
bp = Blueprint(
|
||||
|
||||
@@ -4,21 +4,49 @@ Payment provider: paddle
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Blueprint, render_template, request, redirect, url_for, flash, g, jsonify, session
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
from ..core import config, fetch_one, fetch_all, execute
|
||||
|
||||
from ..core import verify_hmac_signature
|
||||
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
|
||||
|
||||
from ..auth.routes import login_required
|
||||
from ..core import config, execute, fetch_one
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Billing event hook system
|
||||
# =============================================================================
|
||||
|
||||
_billing_hooks: dict[str, list] = {}
|
||||
|
||||
|
||||
def on_billing_event(*event_types: str):
|
||||
"""Decorator: register a handler for one or more billing event types."""
|
||||
def decorator(func):
|
||||
for event_type in event_types:
|
||||
_billing_hooks.setdefault(event_type, []).append(func)
|
||||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
async def _fire_hooks(event_type: str, data: dict) -> None:
|
||||
"""Fire all registered hooks for an event type, isolating per-hook failures."""
|
||||
for hook in _billing_hooks.get(event_type, []):
|
||||
try:
|
||||
await hook(event_type, data)
|
||||
except Exception as e:
|
||||
logger.error("Hook %s failed for event %s: %s", hook.__name__, event_type, e)
|
||||
|
||||
|
||||
def _paddle_client():
|
||||
"""Return a configured Paddle SDK client."""
|
||||
from paddle_billing import Client, Environment, Options
|
||||
env = Environment.SANDBOX if config.PADDLE_ENVIRONMENT == "sandbox" else Environment.PRODUCTION
|
||||
return Client(config.PADDLE_API_KEY, options=Options(environment=env))
|
||||
|
||||
|
||||
# Blueprint with its own template folder
|
||||
@@ -195,31 +223,21 @@ async def success():
|
||||
@bp.route("/checkout/<plan>", methods=["POST"])
|
||||
@login_required
|
||||
async def checkout(plan: str):
|
||||
"""Create Paddle checkout via API."""
|
||||
"""Create Paddle checkout via SDK."""
|
||||
price_id = config.PADDLE_PRICES.get(plan)
|
||||
if not price_id:
|
||||
await flash("Invalid plan selected.", "error")
|
||||
return redirect(url_for("billing.pricing"))
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
"https://api.paddle.com/transactions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {config.PADDLE_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"items": [{"price_id": price_id, "quantity": 1}],
|
||||
"custom_data": {"user_id": str(g.user["id"]), "plan": plan},
|
||||
"checkout": {
|
||||
"url": f"{config.BASE_URL}/billing/success",
|
||||
},
|
||||
},
|
||||
from paddle_billing.Resources.Transactions.Operations import CreateTransaction
|
||||
txn = _paddle_client().transactions.create(
|
||||
CreateTransaction(
|
||||
items=[{"price_id": price_id, "quantity": 1}],
|
||||
custom_data={"user_id": str(g.user["id"]), "plan": plan},
|
||||
checkout={"url": f"{config.BASE_URL}/billing/success"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
checkout_url = response.json()["data"]["checkout"]["url"]
|
||||
return redirect(checkout_url)
|
||||
)
|
||||
return redirect(txn.checkout.url)
|
||||
|
||||
|
||||
@bp.route("/manage", methods=["POST"])
|
||||
@@ -232,13 +250,8 @@ async def manage():
|
||||
return redirect(url_for("dashboard.settings"))
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"https://api.paddle.com/subscriptions/{sub['provider_subscription_id']}",
|
||||
headers={"Authorization": f"Bearer {config.PADDLE_API_KEY}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
portal_url = response.json()["data"]["management_urls"]["update_payment_method"]
|
||||
subscription = _paddle_client().subscriptions.get(sub["provider_subscription_id"])
|
||||
portal_url = subscription.management_urls.update_payment_method
|
||||
except Exception:
|
||||
await flash("Could not reach the billing portal. Please try again or contact support.", "error")
|
||||
return redirect(url_for("dashboard.settings"))
|
||||
@@ -249,28 +262,27 @@ async def manage():
|
||||
@bp.route("/cancel", methods=["POST"])
|
||||
@login_required
|
||||
async def cancel():
|
||||
"""Cancel subscription via Paddle API."""
|
||||
"""Cancel subscription via Paddle SDK."""
|
||||
sub = await get_subscription(g.user["id"])
|
||||
if sub and sub.get("provider_subscription_id"):
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.post(
|
||||
f"https://api.paddle.com/subscriptions/{sub['provider_subscription_id']}/cancel",
|
||||
headers={
|
||||
"Authorization": f"Bearer {config.PADDLE_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={"effective_from": "next_billing_period"},
|
||||
)
|
||||
from paddle_billing.Resources.Subscriptions.Operations import CancelSubscription
|
||||
_paddle_client().subscriptions.cancel(
|
||||
sub["provider_subscription_id"],
|
||||
CancelSubscription(effective_from="next_billing_period"),
|
||||
)
|
||||
return redirect(url_for("dashboard.settings"))
|
||||
|
||||
|
||||
@bp.route("/webhook/paddle", methods=["POST"])
|
||||
async def webhook():
|
||||
"""Handle Paddle webhooks."""
|
||||
import paddle_billing
|
||||
payload = await request.get_data()
|
||||
sig = request.headers.get("Paddle-Signature", "")
|
||||
|
||||
if not verify_hmac_signature(payload, sig, config.PADDLE_WEBHOOK_SECRET):
|
||||
try:
|
||||
paddle_billing.Notifications.Verifier().verify(payload, config.PADDLE_WEBHOOK_SECRET, sig)
|
||||
except Exception:
|
||||
return jsonify({"error": "Invalid signature"}), 400
|
||||
|
||||
event = json.loads(payload)
|
||||
|
||||
@@ -31,7 +31,7 @@ async def _serve_article(url_path: str):
|
||||
body_html = ""
|
||||
if article.get("body_template") and article.get("data_json"):
|
||||
try:
|
||||
from jinja2 import Environment, BaseLoader
|
||||
from jinja2 import BaseLoader, Environment
|
||||
data = json.loads(article["data_json"])
|
||||
env = Environment(loader=BaseLoader())
|
||||
body_html = env.from_string(article["body_template"]).render(**data)
|
||||
|
||||
@@ -77,22 +77,15 @@ class Config:
|
||||
RESEND_AUDIENCE_WAITLIST: str = os.getenv("RESEND_AUDIENCE_WAITLIST", "")
|
||||
|
||||
PLAN_FEATURES: dict = {
|
||||
"free": ["dashboard", "coffee_only", "limited_history"],
|
||||
"starter": ["dashboard", "coffee_only", "full_history", "export", "api"],
|
||||
"pro": [
|
||||
"dashboard",
|
||||
"all_commodities",
|
||||
"full_history",
|
||||
"export",
|
||||
"api",
|
||||
"priority_support",
|
||||
],
|
||||
"free": ["basic"],
|
||||
"starter": ["basic", "export"],
|
||||
"pro": ["basic", "export", "api", "priority_support"],
|
||||
}
|
||||
|
||||
PLAN_LIMITS: dict = {
|
||||
"free": {"commodities": 1, "history_years": 5, "api_calls": 0},
|
||||
"starter": {"commodities": 1, "history_years": -1, "api_calls": 10000},
|
||||
"pro": {"commodities": -1, "history_years": -1, "api_calls": -1}, # -1 = unlimited
|
||||
"free": {"items": 100, "api_calls": 0},
|
||||
"starter": {"items": 1000, "api_calls": 10000},
|
||||
"pro": {"items": -1, "api_calls": -1}, # -1 = unlimited
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ async def index():
|
||||
user = g.user
|
||||
plan = (g.get("subscription") or {}).get("plan", "free")
|
||||
|
||||
if analytics._db_path:
|
||||
if analytics._db_path or analytics._conn is not None:
|
||||
results = await asyncio.gather(
|
||||
analytics.get_price_latest(analytics.COFFEE_TICKER),
|
||||
analytics.get_cot_positioning_latest(analytics.COFFEE_CFTC_CODE),
|
||||
@@ -208,7 +208,7 @@ async def supply():
|
||||
current_year = datetime.date.today().year
|
||||
start_year = current_year - rng["years"]
|
||||
|
||||
if analytics._db_path:
|
||||
if analytics._db_path or analytics._conn is not None:
|
||||
results = await asyncio.gather(
|
||||
analytics.get_global_time_series(
|
||||
analytics.COFFEE_COMMODITY_CODE,
|
||||
@@ -267,7 +267,7 @@ async def positioning():
|
||||
cot_weeks = rng["weeks"]
|
||||
|
||||
options_delta = None
|
||||
if analytics._db_path:
|
||||
if analytics._db_path or analytics._conn is not None:
|
||||
gather_coros = [
|
||||
analytics.get_price_latest(analytics.COFFEE_TICKER),
|
||||
analytics.get_price_time_series(analytics.COFFEE_TICKER, limit=price_limit),
|
||||
@@ -322,7 +322,7 @@ async def warehouse():
|
||||
stocks_latest = stocks_trend = aging_latest = byport_latest = byport_trend = None
|
||||
stocks_trend = aging_latest = byport_trend = []
|
||||
|
||||
if analytics._db_path:
|
||||
if analytics._db_path or analytics._conn is not None:
|
||||
if view == "stocks":
|
||||
results = await asyncio.gather(
|
||||
analytics.get_ice_stocks_latest(),
|
||||
@@ -416,7 +416,7 @@ async def weather():
|
||||
rng = RANGE_MAP[range_key]
|
||||
days = rng["days"]
|
||||
|
||||
if analytics._db_path:
|
||||
if analytics._db_path or analytics._conn is not None:
|
||||
if location_id:
|
||||
results = await asyncio.gather(
|
||||
analytics.get_weather_stress_latest(),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
Public domain: landing page, marketing pages, legal pages, feedback, sitemap.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Blueprint, render_template, request, g, make_response
|
||||
from quart import Blueprint, g, make_response, render_template, request
|
||||
|
||||
from ..core import config, execute, fetch_all, check_rate_limit, csrf_protect
|
||||
from ..core import check_rate_limit, config, csrf_protect, execute, fetch_all
|
||||
|
||||
# Blueprint with its own template folder
|
||||
bp = Blueprint(
|
||||
|
||||
@@ -6,8 +6,7 @@ import json
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .core import config, init_db, fetch_one, fetch_all, execute, send_email
|
||||
|
||||
from .core import config, execute, fetch_all, init_db, send_email
|
||||
|
||||
# Task handlers registry
|
||||
HANDLERS: dict[str, callable] = {}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""
|
||||
Shared test fixtures for the BeanFlows test suite.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
@@ -10,11 +8,9 @@ from unittest.mock import AsyncMock, patch
|
||||
import aiosqlite
|
||||
import pytest
|
||||
|
||||
|
||||
from beanflows import core
|
||||
from beanflows.app import create_app
|
||||
|
||||
|
||||
SCHEMA_PATH = Path(__file__).parent.parent / "src" / "beanflows" / "migrations" / "schema.sql"
|
||||
|
||||
|
||||
@@ -287,12 +283,20 @@ def mock_analytics(monkeypatch):
|
||||
"market_year": 2025, "production": 30000.0, "production_yoy_pct": -1.2},
|
||||
]
|
||||
|
||||
_commodities = [
|
||||
{"commodity_code": 711100, "commodity_name": "Coffee, Green"},
|
||||
{"commodity_code": 711200, "commodity_name": "Coffee, Roasted"},
|
||||
]
|
||||
|
||||
async def _ts(*a, **kw): return _time_series
|
||||
async def _top(*a, **kw): return _top_producers
|
||||
async def _stu(*a, **kw): return _stu_trend
|
||||
async def _bal(*a, **kw): return _balance
|
||||
async def _yoy(*a, **kw): return _yoy_data
|
||||
async def _cmp(*a, **kw): return []
|
||||
async def _com(*a, **kw): return _commodities
|
||||
async def _none(*a, **kw): return None
|
||||
async def _empty(*a, **kw): return []
|
||||
|
||||
monkeypatch.setattr(analytics, "get_global_time_series", _ts)
|
||||
monkeypatch.setattr(analytics, "get_top_countries", _top)
|
||||
@@ -300,5 +304,15 @@ def mock_analytics(monkeypatch):
|
||||
monkeypatch.setattr(analytics, "get_supply_demand_balance", _bal)
|
||||
monkeypatch.setattr(analytics, "get_production_yoy_by_country", _yoy)
|
||||
monkeypatch.setattr(analytics, "get_country_comparison", _cmp)
|
||||
monkeypatch.setattr(analytics, "get_available_commodities", _com)
|
||||
# Pulse-page analytics
|
||||
monkeypatch.setattr(analytics, "get_price_latest", _none)
|
||||
monkeypatch.setattr(analytics, "get_price_time_series", _empty)
|
||||
monkeypatch.setattr(analytics, "get_cot_positioning_latest", _none)
|
||||
monkeypatch.setattr(analytics, "get_cot_index_trend", _empty)
|
||||
monkeypatch.setattr(analytics, "get_ice_stocks_latest", _none)
|
||||
monkeypatch.setattr(analytics, "get_ice_stocks_trend", _empty)
|
||||
monkeypatch.setattr(analytics, "get_weather_stress_latest", _none)
|
||||
monkeypatch.setattr(analytics, "get_weather_stress_trend", _empty)
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import asyncio
|
||||
import duckdb
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -70,13 +70,13 @@ async def test_commodity_metrics(client, db, test_user, mock_analytics):
|
||||
"""GET /commodities/<code>/metrics returns time series."""
|
||||
raw_key = await _create_api_key_for_user(db, test_user["id"])
|
||||
response = await client.get(
|
||||
"/api/v1/commodities/711100/metrics?metrics=Production&metrics=Exports",
|
||||
"/api/v1/commodities/711100/metrics?metrics=production&metrics=exports",
|
||||
headers={"Authorization": f"Bearer {raw_key}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["commodity_code"] == 711100
|
||||
assert "Production" in data["metrics"]
|
||||
assert "production" in data["metrics"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -95,12 +95,12 @@ async def test_commodity_countries(client, db, test_user, mock_analytics):
|
||||
"""GET /commodities/<code>/countries returns ranking."""
|
||||
raw_key = await _create_api_key_for_user(db, test_user["id"])
|
||||
response = await client.get(
|
||||
"/api/v1/commodities/711100/countries?metric=Production&limit=5",
|
||||
"/api/v1/commodities/711100/countries?metric=production&limit=5",
|
||||
headers={"Authorization": f"Bearer {raw_key}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["metric"] == "Production"
|
||||
assert data["metric"] == "production"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -7,7 +7,6 @@ from hypothesis import settings as h_settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from beanflows.billing.routes import (
|
||||
|
||||
can_access_feature,
|
||||
get_billing_customer,
|
||||
get_subscription,
|
||||
@@ -66,7 +65,7 @@ class TestUpsertSubscription:
|
||||
status="active",
|
||||
provider_subscription_id="sub_same",
|
||||
)
|
||||
returned_id = await upsert_subscription(
|
||||
await upsert_subscription(
|
||||
user_id=test_user["id"],
|
||||
plan="pro",
|
||||
status="active",
|
||||
|
||||
@@ -11,7 +11,6 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
CHECKOUT_METHOD = "POST"
|
||||
CHECKOUT_PLAN = "starter"
|
||||
|
||||
|
||||
@@ -6,14 +6,12 @@ import json
|
||||
|
||||
import pytest
|
||||
from conftest import make_webhook_payload, sign_payload
|
||||
|
||||
from hypothesis import HealthCheck, given
|
||||
from hypothesis import settings as h_settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from beanflows.billing.routes import get_billing_customer, get_subscription
|
||||
|
||||
|
||||
WEBHOOK_PATH = "/billing/webhook/paddle"
|
||||
SIG_HEADER = "Paddle-Signature"
|
||||
|
||||
|
||||
@@ -18,10 +18,9 @@ async def test_dashboard_loads(auth_client, mock_analytics):
|
||||
assert response.status_code == 200
|
||||
|
||||
body = (await response.get_data(as_text=True))
|
||||
assert "Coffee Dashboard" in body
|
||||
assert "Global Supply" in body
|
||||
assert "Stock-to-Use" in body
|
||||
assert "Top Producing Countries" in body
|
||||
assert "Pulse" in body
|
||||
assert "Stock-to-Use Ratio" in body
|
||||
assert "KC=F Close" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -30,8 +29,8 @@ async def test_dashboard_shows_metric_cards(auth_client, mock_analytics):
|
||||
response = await auth_client.get("/dashboard/")
|
||||
body = (await response.get_data(as_text=True))
|
||||
|
||||
# Latest production from mock: 172,000
|
||||
assert "172,000" in body
|
||||
assert "MM Net Position" in body
|
||||
assert "Certified Stocks" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -40,8 +39,7 @@ async def test_dashboard_yoy_table(auth_client, mock_analytics):
|
||||
response = await auth_client.get("/dashboard/")
|
||||
body = (await response.get_data(as_text=True))
|
||||
|
||||
assert "Brazil" in body
|
||||
assert "Vietnam" in body
|
||||
assert "Global Supply" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -59,7 +57,7 @@ async def test_dashboard_free_plan_no_csv_export(auth_client, mock_analytics):
|
||||
response = await auth_client.get("/dashboard/")
|
||||
body = (await response.get_data(as_text=True))
|
||||
|
||||
assert "CSV export available on Trader" in body
|
||||
assert "Upgrade" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -5,14 +5,13 @@ and admin route protection.
|
||||
import pytest
|
||||
from quart import Blueprint
|
||||
|
||||
from beanflows import core
|
||||
from beanflows.auth.routes import (
|
||||
ensure_admin_role,
|
||||
grant_role,
|
||||
revoke_role,
|
||||
role_required,
|
||||
)
|
||||
from beanflows import core
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# grant_role / revoke_role
|
||||
|
||||
Reference in New Issue
Block a user