Add BeanFlows MVP: coffee analytics dashboard, API, and web app

- Fix pipeline granularity: add market_year to cleaned/serving SQL models
- Add DuckDB data access layer with async query functions (analytics.py)
- Build Chart.js dashboard: supply/demand, STU ratio, top producers, YoY table
- Add country comparison page with multi-select picker
- Replace items CRUD with read-only commodity API (list, metrics, countries, CSV)
- Configure BeanFlows plan tiers (Free/Starter/Pro) with feature gating
- Rewrite public pages for coffee market intelligence positioning
- Remove boilerplate items schema, update health check for DuckDB
- Add test suite: 139 tests passing (dashboard, API, billing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-18 16:11:50 +01:00
parent b222c01828
commit 2748c606e9
59 changed files with 6272 additions and 2 deletions

32
CHANGELOG.md Normal file
View File

@@ -0,0 +1,32 @@
# Changelog
All notable changes to BeanFlows are documented here.
## [Unreleased]
### Added
- **Coffee analytics dashboard** — Chart.js visualizations for global supply/demand time series, stock-to-use ratio trend, top producing countries bar chart, and YoY production change table
- **Country comparison page** — Multi-select country picker with metric selector and overlay line chart at `/dashboard/countries`
- **DuckDB data access layer** (`web/src/beanflows/analytics.py`) — Async bridge to read-only DuckDB via `asyncio.to_thread()` with 7 domain query functions and metric allowlist for injection prevention
- **Commodity REST API** — `GET /api/v1/commodities`, `/commodities/<code>/metrics`, `/commodities/<code>/countries`, `/commodities/<code>/metrics.csv` (CSV export)
- **BeanFlows plan tiers** — Free (coffee, 5yr history), Starter (full history, CSV, API), Pro (all 65 commodities, unlimited API)
- **Landing, features, and pricing pages** rewritten for coffee market intelligence positioning
- **Health check** now verifies both SQLite and DuckDB connectivity
- **Admin panel** shows commodity count and data year range from DuckDB
- **Docker** config updated with DuckDB volume mount
- **Tests** — `test_dashboard.py` (8 tests), `test_api_commodities.py` (8 tests), analytics mock fixture in conftest
### Fixed
- **Pipeline time granularity** — Added `market_year` to GROUP BY in `cleaned.psdalldata__commodity_pivoted` and carried through to `serving.commodity_metrics`. Previously summed across all market years, making per-year metrics meaningless.
### Removed
- `items` table, FTS virtual table, and triggers from schema (boilerplate domain entity)
- Items CRUD from API routes
- Items count from dashboard stats
### Changed
- `PLAN_FEATURES` updated from generic (`basic`, `export`) to domain-specific (`dashboard`, `coffee_only`, `all_commodities`, `full_history`, `export`, `api`)
- `PLAN_LIMITS` changed from `items`/`api_calls` to `commodities`/`history_years`/`api_calls`
- API now requires Starter or Pro plan (free plan gets 403)
- Dashboard routes and API routes import analytics module (not individual functions) for testability
- Billing tests updated to match new feature and limit names

View File

@@ -13,6 +13,7 @@ SELECT
max(commodity_name) as commodity_name,
country_code,
max(country_name) as country_name,
market_year,
ingest_date,
COALESCE(SUM(CASE WHEN attribute_name = 'Production' THEN value END), 0) AS Production,
COALESCE(SUM(CASE WHEN attribute_name = 'Imports' THEN value END), 0) AS Imports,
@@ -48,8 +49,10 @@ WHERE attribute_name IN (
GROUP BY
commodity_code,
country_code,
market_year,
ingest_date
ORDER BY
commodity_code,
country_code,
market_year,
ingest_date

View File

@@ -14,6 +14,7 @@ WITH country_metrics AS (
commodity_name,
country_code,
country_name,
market_year,
ingest_date,
Production,
Imports,
@@ -27,7 +28,7 @@ WITH country_metrics AS (
-- Handle division by zero for Stock-to-Use Ratio
(Ending_Stocks / NULLIF(Total_Distribution, 0)) * 100 AS Stock_to_Use_Ratio_pct,
-- Calculate Production YoY percentage change using a window function
(Production - LAG(Production, 1, 0) OVER (PARTITION BY commodity_code, country_code ORDER BY ingest_date)) / NULLIF(LAG(Production, 1, 0) OVER (PARTITION BY commodity_code, country_code ORDER BY ingest_date), 0) * 100 AS Production_YoY_pct
(Production - LAG(Production, 1, 0) OVER (PARTITION BY commodity_code, country_code ORDER BY market_year, ingest_date)) / NULLIF(LAG(Production, 1, 0) OVER (PARTITION BY commodity_code, country_code ORDER BY market_year, ingest_date), 0) * 100 AS Production_YoY_pct
FROM cleaned.psdalldata__commodity_pivoted
),
global_aggregates AS (
@@ -36,6 +37,7 @@ global_aggregates AS (
commodity_name,
NULL::TEXT AS country_code, -- Use NULL for global aggregates
'Global' AS country_name,
market_year,
ingest_date,
SUM(Production) AS Production,
SUM(Imports) AS Imports,
@@ -46,6 +48,7 @@ global_aggregates AS (
GROUP BY
commodity_code,
commodity_name,
market_year,
ingest_date
),
-- CTE to calculate derived metrics for global aggregates
@@ -55,6 +58,7 @@ global_metrics AS (
commodity_name,
country_code,
country_name,
market_year,
ingest_date,
Production,
Imports,
@@ -65,7 +69,7 @@ global_metrics AS (
(Exports - Imports) AS Trade_Balance,
(Production + Imports - Exports) - Total_Distribution AS Supply_Demand_Balance,
(Ending_Stocks / NULLIF(Total_Distribution, 0)) * 100 AS Stock_to_Use_Ratio_pct,
(Production - LAG(Production, 1, 0) OVER (PARTITION BY commodity_code ORDER BY ingest_date)) / NULLIF(LAG(Production, 1, 0) OVER (PARTITION BY commodity_code ORDER BY ingest_date), 0) * 100 AS Production_YoY_pct
(Production - LAG(Production, 1, 0) OVER (PARTITION BY commodity_code ORDER BY market_year, ingest_date)) / NULLIF(LAG(Production, 1, 0) OVER (PARTITION BY commodity_code ORDER BY market_year, ingest_date), 0) * 100 AS Production_YoY_pct
FROM global_aggregates
)
-- Combine country-level and global-level data into a single output
@@ -74,6 +78,7 @@ SELECT
commodity_name,
country_code,
country_name,
market_year,
ingest_date,
Production,
Imports,
@@ -97,4 +102,5 @@ FROM (
ORDER BY
commodity_name,
country_name,
market_year,
ingest_date;

194
uv.lock generated
View File

@@ -9,11 +9,21 @@ resolution-markers = [
[manifest]
members = [
"beanflows",
"materia",
"psdonline",
"sqlmesh-materia",
]
[[package]]
name = "aiofiles"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
]
[[package]]
name = "aiosqlite"
version = "0.22.1"
@@ -232,6 +242,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
]
[[package]]
name = "beanflows"
version = "0.1.0"
source = { editable = "web" }
dependencies = [
{ name = "aiosqlite" },
{ name = "duckdb" },
{ name = "httpx" },
{ name = "hypercorn" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "python-dotenv" },
{ name = "quart" },
]
[package.dev-dependencies]
dev = [
{ name = "hypothesis" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "respx" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "aiosqlite", specifier = ">=0.19.0" },
{ name = "duckdb", specifier = ">=1.0.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "hypercorn", specifier = ">=0.17.0" },
{ name = "itsdangerous", specifier = ">=2.1.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "quart", specifier = ">=0.19.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "hypothesis", specifier = ">=6.100.0" },
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
{ name = "respx", specifier = ">=0.22.0" },
{ name = "ruff", specifier = ">=0.3.0" },
]
[[package]]
name = "beartype"
version = "0.22.9"
@@ -241,6 +296,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "boto3"
version = "1.40.55"
@@ -706,6 +770,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" },
]
[[package]]
name = "flask"
version = "3.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
]
[[package]]
name = "fsspec"
version = "2026.1.0"
@@ -902,6 +983,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" },
]
[[package]]
name = "hypercorn"
version = "0.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
{ name = "h2" },
{ name = "priority" },
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
@@ -920,6 +1016,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/2d/05a668e7dd7eaf0d7ee9bc162ebf358e9be9af43e4720cdecf622ccc6c9b/hyperscript-0.3.0-py3-none-any.whl", hash = "sha256:5b1689c5cd4ab7c076a0bc61bff11200aa2e0e788bb50358be496e77924df4f0", size = 4970, upload-time = "2024-10-22T17:17:22.807Z" },
]
[[package]]
name = "hypothesis"
version = "6.151.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/e1/ef365ff480903b929d28e057f57b76cae51a30375943e33374ec9a165d9c/hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca", size = 463534, upload-time = "2026-02-16T22:59:23.09Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" },
]
[[package]]
name = "identify"
version = "2.6.13"
@@ -1041,6 +1149,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/6a/9166369a2f092bd286d24e6307de555d63616e8ddb373ebad2b5635ca4cd/ipywidgets-8.1.7-py3-none-any.whl", hash = "sha256:764f2602d25471c213919b8a1997df04bef869251db4ca8efba1b76b1bd9f7bb", size = 139806, upload-time = "2025-05-05T12:41:56.833Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jedi"
version = "0.19.2"
@@ -1801,6 +1918,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/95/2145b372daecb8d449530afb12e3e2cb3cd9aceeeecae8ba001dc1bb5ed2/prefect-3.6.15-py3-none-any.whl", hash = "sha256:eff2b5a1385677b9d94629b94428ddddbb9df63eadd8a870f05ef1ab124f9b84", size = 11799648, upload-time = "2026-01-30T00:49:57.145Z" },
]
[[package]]
name = "priority"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" },
]
[[package]]
name = "prometheus-client"
version = "0.24.1"
@@ -2163,6 +2289,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
@@ -2338,6 +2476,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/e4/9159114a1d96c0442e1465ace2ec1f197e5027db6f794887cf2ca386cc40/qh3-1.5.4-cp37-abi3-win_amd64.whl", hash = "sha256:90ce786909cd7d39db158d86d4c9569d2aebfb18782d04c81b98a1b912489b5a", size = 1991452, upload-time = "2025-08-11T06:47:58.663Z" },
]
[[package]]
name = "quart"
version = "0.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
{ name = "blinker" },
{ name = "click" },
{ name = "flask" },
{ name = "hypercorn" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/9d/12e1143a5bd2ccc05c293a6f5ae1df8fd94a8fc1440ecc6c344b2b30ce13/quart-0.20.0.tar.gz", hash = "sha256:08793c206ff832483586f5ae47018c7e40bdd75d886fee3fabbdaa70c2cf505d", size = 63874, upload-time = "2024-12-23T13:53:05.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/e9/cc28f21f52913adf333f653b9e0a3bf9cb223f5083a26422968ba73edd8d/quart-0.20.0-py3-none-any.whl", hash = "sha256:003c08f551746710acb757de49d9b768986fd431517d0eb127380b656b98b8f1", size = 77960, upload-time = "2024-12-23T13:53:02.842Z" },
]
[[package]]
name = "readchar"
version = "4.2.1"
@@ -2433,6 +2591,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
]
[[package]]
name = "respx"
version = "0.22.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" },
]
[[package]]
name = "rfc3339-validator"
version = "0.1.4"
@@ -3137,6 +3307,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" },
]
[[package]]
name = "whenever"
version = "0.9.5"
@@ -3215,6 +3397,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/51/5447876806d1088a0f8f71e16542bf350918128d0a69437df26047c8e46f/widgetsnbextension-4.0.14-py3-none-any.whl", hash = "sha256:4875a9eaf72fbf5079dc372a51a9f268fc38d46f767cbf85c43a36da5cb9b575", size = 2196503, upload-time = "2025-04-10T13:01:23.086Z" },
]
[[package]]
name = "wsproto"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
]
[[package]]
name = "zipp"
version = "3.23.0"

10
web/.copier-answers.yml Normal file
View File

@@ -0,0 +1,10 @@
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
_commit: v0.3.0
_src_path: /home/Deeman/Projects/quart_saas_boilerplate
author_email: hendrik@beanflows.coffee
author_name: Hendrik Deeman
base_url: https://beanflows.coffee
description: Commodity analytics for coffee traders
payment_provider: paddle
project_name: BeanFlows
project_slug: beanflows

17
web/.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
.venv/
__pycache__/
*.pyc
*.pyo
.git/
.gitignore
.env
.env.*
*.db
*.db-shm
*.db-wal
data/
backups/
.ruff_cache/
.pytest_cache/
.vscode/
.idea/

30
web/.env.example Normal file
View File

@@ -0,0 +1,30 @@
# App
APP_NAME=BeanFlows
SECRET_KEY=change-me-generate-a-real-secret
BASE_URL=http://localhost:5000
DEBUG=true
ADMIN_PASSWORD=admin
# Database
DATABASE_PATH=data/app.db
DUCKDB_PATH=../local.duckdb
# Auth
MAGIC_LINK_EXPIRY_MINUTES=15
SESSION_LIFETIME_DAYS=30
# Email (Resend)
RESEND_API_KEY=
EMAIL_FROM=hello@example.com
# Paddle
PADDLE_API_KEY=
PADDLE_WEBHOOK_SECRET=
PADDLE_PRICE_STARTER=
PADDLE_PRICE_PRO=
# Rate limiting
RATE_LIMIT_REQUESTS=100
RATE_LIMIT_WINDOW=60

37
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# Python
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
.uv/
# Environment
.env
.env.local
# Database
*.db
*.db-shm
*.db-wal
data/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Testing
.pytest_cache/
.coverage
htmlcov/
# Build
dist/
build/
*.egg-info/

1
web/.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

78
web/CLAUDE.md Normal file
View File

@@ -0,0 +1,78 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What This Is
A **Copier template** for generating SaaS applications. The template lives in `{{project_slug}}/` with Jinja2-templated files (`.jinja` extension). Files without `.jinja` are copied as-is. The `copier.yml` defines template variables: `project_slug`, `project_name`, `description`, `author_name`, `author_email`, `base_url`, `payment_provider` (stripe/paddle/lemonsqueezy).
This is NOT a runnable application itself — it generates one via `copier copy`.
## Generated Project Commands
After generation, the project uses **uv** as package manager:
```bash
uv sync # Install dependencies
uv run python -m <slug>.migrations.migrate # Initialize/migrate DB
uv run python -m <slug>.app # Run dev server (port 5000)
uv run python -m <slug>.worker # Run background worker
uv run python -m <slug>.worker scheduler # Run periodic scheduler
uv run pytest # Run tests
uv run ruff check . # Lint
docker compose up -d # Production deploy
```
## Stack
- **Quart** (async Flask) with Jinja2 templates and Pico CSS (no build step)
- **SQLite** with WAL mode + aiosqlite (no ORM — plain SQL everywhere)
- **Stripe**, **Paddle**, or **LemonSqueezy** for billing (chosen via `payment_provider` template variable)
- **Resend** for transactional email
- **Litestream** for SQLite replication/backups
- **Docker + Caddy** for deployment
- **Hypercorn** as ASGI server in production
## Architecture
### Domain-based flat structure
Each domain is a directory with `routes.py` + `templates/`. Routes, SQL queries, and decorators all live together in `routes.py` — no separate models/services/repositories layers.
```
src/<slug>/
app.py → Application factory, blueprint registration, middleware
core.py → Config class, DB helpers (fetch_one/fetch_all/execute), email, CSRF, rate limiting
worker.py → SQLite-based background task queue (no Redis)
auth/ → Magic link auth, login_required/subscription_required decorators
billing/ → Checkout/webhooks/portal (Stripe, Paddle, or LemonSqueezy), plan feature/limit checks
dashboard/ → User settings, API key management
public/ → Marketing pages (landing, terms, privacy)
api/ → REST API with Bearer token auth (api_key_required decorator)
admin/ → Password-protected admin panel (ADMIN_PASSWORD env var)
migrations/ → schema.sql + migrate.py (runs full schema idempotently with CREATE IF NOT EXISTS)
```
### Key patterns
- **Database access**: Use `fetch_one()`, `fetch_all()`, `execute()` from `core.py`. No ORM. Queries live directly in route files next to the routes that use them.
- **Auth decorators**: `@login_required` for user pages, `@subscription_required(plans=["pro"])` for plan-gated features, `@api_key_required(scopes=["read"])` for API endpoints.
- **CSRF**: `@csrf_protect` decorator on POST routes. Templates include `<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">`.
- **Background tasks**: `from ..worker import enqueue` then `await enqueue("task_name", {"key": "value"})`. Register handlers with `@task("task_name")` decorator in `worker.py`.
- **Blueprints**: Each domain registers as a Quart Blueprint with its own `template_folder`. Templates in domain dirs override shared ones.
- **Soft deletes**: `deleted_at` column pattern. Use `soft_delete()`, `restore()`, `hard_delete()` from `core.py`.
- **Dates**: All stored as ISO 8601 TEXT in SQLite, using `datetime.utcnow().isoformat()`.
- **Rate limiting**: SQLite-based, no Redis. `@rate_limit()` decorator or `check_rate_limit()` function.
- **Migrations**: Single `schema.sql` file with all `CREATE TABLE IF NOT EXISTS`. Run via `python -m <slug>.migrations.migrate`.
### Conditional template blocks
`billing/routes.py.jinja` conditionally generates the full billing implementation based on the `payment_provider` variable (stripe/paddle/lemonsqueezy). The `.env.example.jinja`, `core.py.jinja`, and `schema.sql.jinja` similarly adapt per provider.
## Design Philosophy
Data-oriented, minimal abstraction. Plain SQL over ORM. Write code first, extract patterns only when repeated 3+ times. SQLite as the default database. Server-rendered HTML with Pico CSS.
## Ruff Config
Line length 100, target Python 3.11+, rules: E, F, I, UP (ignoring E501).

22
web/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# Build stage
FROM python:3.12-slim AS build
COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /uvx /bin/
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
COPY uv.lock pyproject.toml README.md ./
COPY src/ ./src/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --no-dev --frozen
# Runtime stage
FROM python:3.12-slim AS runtime
ENV PATH="/app/.venv/bin:$PATH"
RUN useradd -m -u 1000 appuser
WORKDIR /app
RUN mkdir -p /app/data && chown -R appuser:appuser /app
COPY --from=build --chown=appuser:appuser /app .
USER appuser
ENV PYTHONUNBUFFERED=1
ENV DATABASE_PATH=/app/data/app.db
EXPOSE 5000
CMD ["hypercorn", "beanflows.app:app", "--bind", "0.0.0.0:5000", "--workers", "1"]

0
web/README.md Normal file
View File

76
web/deploy.sh Normal file
View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
COMPOSE="docker compose -f docker-compose.prod.yml"
LIVE_FILE=".live-slot"
ROUTER_CONF="router/default.conf"
# ── Determine slots ─────────────────────────────────────────
CURRENT=$(cat "$LIVE_FILE" 2>/dev/null || echo "none")
if [ "$CURRENT" = "blue" ]; then
TARGET="green"
else
TARGET="blue"
fi
echo "==> Current: $CURRENT → Deploying: $TARGET"
# ── Build ───────────────────────────────────────────────────
echo "==> Building $TARGET..."
$COMPOSE --profile "$TARGET" build
# ── Migrate ─────────────────────────────────────────────────
echo "==> Running migrations..."
$COMPOSE --profile "$TARGET" run --rm "${TARGET}-app" \
python -m beanflows.migrations.migrate
# ── Start & health check ───────────────────────────────────
echo "==> Starting $TARGET (waiting for health check)..."
if ! $COMPOSE --profile "$TARGET" up -d --wait; then
echo "!!! Health check failed — rolling back"
$COMPOSE stop "${TARGET}-app" "${TARGET}-worker" "${TARGET}-scheduler"
exit 1
fi
# ── Switch router ───────────────────────────────────────────
echo "==> Switching router to $TARGET..."
mkdir -p "$(dirname "$ROUTER_CONF")"
cat > "$ROUTER_CONF" <<NGINX
upstream app {
server ${TARGET}-app:5000;
}
server {
listen 80;
location / {
proxy_pass http://app;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
NGINX
# Ensure router is running, then reload
$COMPOSE up -d router
$COMPOSE exec router nginx -s reload
# ── Stop old slot ───────────────────────────────────────────
if [ "$CURRENT" != "none" ]; then
echo "==> Stopping $CURRENT..."
$COMPOSE stop "${CURRENT}-app" "${CURRENT}-worker" "${CURRENT}-scheduler"
fi
# ── Record live slot ────────────────────────────────────────
echo "$TARGET" > "$LIVE_FILE"
echo "==> Deployed $TARGET successfully!"

128
web/docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,128 @@
services:
# ── Always-on infrastructure ──────────────────────────────
router:
image: nginx:alpine
restart: unless-stopped
ports:
- "5000:80"
volumes:
- ./router/default.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- net
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 30s
timeout: 5s
litestream:
image: litestream/litestream:latest
restart: unless-stopped
command: replicate -config /etc/litestream.yml
volumes:
- app-data:/app/data
- ./beanflows/litestream.yml:/etc/litestream.yml:ro
# ── Blue slot ─────────────────────────────────────────────
blue-app:
profiles: ["blue"]
build:
context: ./beanflows
restart: unless-stopped
env_file: ./beanflows/.env
environment:
- DATABASE_PATH=/app/data/app.db
volumes:
- app-data:/app/data
networks:
- net
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
blue-worker:
profiles: ["blue"]
build:
context: ./beanflows
restart: unless-stopped
command: python -m beanflows.worker
env_file: ./beanflows/.env
environment:
- DATABASE_PATH=/app/data/app.db
volumes:
- app-data:/app/data
networks:
- net
blue-scheduler:
profiles: ["blue"]
build:
context: ./beanflows
restart: unless-stopped
command: python -m beanflows.worker scheduler
env_file: ./beanflows/.env
environment:
- DATABASE_PATH=/app/data/app.db
volumes:
- app-data:/app/data
networks:
- net
# ── Green slot ────────────────────────────────────────────
green-app:
profiles: ["green"]
build:
context: ./beanflows
restart: unless-stopped
env_file: ./beanflows/.env
environment:
- DATABASE_PATH=/app/data/app.db
volumes:
- app-data:/app/data
networks:
- net
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
green-worker:
profiles: ["green"]
build:
context: ./beanflows
restart: unless-stopped
command: python -m beanflows.worker
env_file: ./beanflows/.env
environment:
- DATABASE_PATH=/app/data/app.db
volumes:
- app-data:/app/data
networks:
- net
green-scheduler:
profiles: ["green"]
build:
context: ./beanflows
restart: unless-stopped
command: python -m beanflows.worker scheduler
env_file: ./beanflows/.env
environment:
- DATABASE_PATH=/app/data/app.db
volumes:
- app-data:/app/data
networks:
- net
volumes:
app-data:
networks:
net:

56
web/docker-compose.yml Normal file
View File

@@ -0,0 +1,56 @@
services:
app:
build: .
restart: unless-stopped
ports:
- "5000:5000"
volumes:
- ./data:/app/data
- ./duckdb:/app/duckdb:ro
env_file: .env
environment:
- DATABASE_PATH=/app/data/app.db
- DUCKDB_PATH=/app/duckdb/local.duckdb
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
worker:
build: .
restart: unless-stopped
command: python -m beanflows.worker
volumes:
- ./data:/app/data
env_file: .env
environment:
- DATABASE_PATH=/app/data/app.db
depends_on:
- app
scheduler:
build: .
restart: unless-stopped
command: python -m beanflows.worker scheduler
volumes:
- ./data:/app/data
env_file: .env
environment:
- DATABASE_PATH=/app/data/app.db
depends_on:
- app
# Optional: Litestream for backups
litestream:
image: litestream/litestream:latest
restart: unless-stopped
command: replicate -config /etc/litestream.yml
volumes:
- ./data:/app/data
- ./litestream.yml:/etc/litestream.yml:ro
depends_on:
- app
volumes:

22
web/litestream.yml Normal file
View File

@@ -0,0 +1,22 @@
# Litestream configuration for SQLite replication
# Supports S3, Cloudflare R2, MinIO, etc.
dbs:
- path: /app/data/app.db
replicas:
# Option 1: AWS S3
# - url: s3://your-bucket/beanflows/app.db
# access-key-id: ${AWS_ACCESS_KEY_ID}
# secret-access-key: ${AWS_SECRET_ACCESS_KEY}
# region: us-east-1
# Option 2: Cloudflare R2
# - url: s3://your-bucket/beanflows/app.db
# access-key-id: ${R2_ACCESS_KEY_ID}
# secret-access-key: ${R2_SECRET_ACCESS_KEY}
# endpoint: https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com
# Option 3: Local file backup (for development)
- path: /app/data/backups
retention: 24h
snapshot-interval: 1h

48
web/pyproject.toml Normal file
View File

@@ -0,0 +1,48 @@
[project]
name = "beanflows"
version = "0.1.0"
description = "Commodity analytics for coffee traders"
readme = "README.md"
requires-python = ">=3.11"
authors = [
{ name = "Hendrik Deeman", email = "hendrik@beanflows.coffee" }
]
dependencies = [
"quart>=0.19.0",
"aiosqlite>=0.19.0",
"duckdb>=1.0.0",
"httpx>=0.27.0",
"python-dotenv>=1.0.0",
"itsdangerous>=2.1.0",
"jinja2>=3.1.0",
"hypercorn>=0.17.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/beanflows"]
[tool.uv]
dev-dependencies = [
"hypothesis>=6.100.0",
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"respx>=0.22.0",
"ruff>=0.3.0",
]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
ignore = ["E501"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

15
web/router/default.conf Normal file
View File

@@ -0,0 +1,15 @@
upstream app {
server blue-app:5000;
}
server {
listen 80;
location / {
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

23
web/scripts/backup.sh Normal file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
set -e
# BeanFlows Manual Backup Script
BACKUP_DIR="./backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DB_PATH="./data/app.db"
mkdir -p "$BACKUP_DIR"
# Create backup using SQLite's backup command
sqlite3 "$DB_PATH" ".backup '$BACKUP_DIR/app_$TIMESTAMP.db'"
# Compress
gzip "$BACKUP_DIR/app_$TIMESTAMP.db"
echo "✅ Backup created: $BACKUP_DIR/app_$TIMESTAMP.db.gz"
# Clean old backups (keep last 7 days)
find "$BACKUP_DIR" -name "*.db.gz" -mtime +7 -delete
echo "🧹 Old backups cleaned"

22
web/scripts/deploy.sh Normal file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
set -e
# BeanFlows Deployment Script
echo "🚀 Deploying BeanFlows..."
# Pull latest code
git pull origin main
# Build and restart containers
docker compose build
docker compose up -d
# Run migrations
docker compose exec app uv run python -m beanflows.migrations.migrate
# Health check
sleep 5
curl -f http://localhost:5000/health || exit 1
echo "✅ Deployment complete!"

View File

@@ -0,0 +1,3 @@
"""BeanFlows - Commodity analytics for coffee traders"""
__version__ = "0.1.0"

View File

@@ -0,0 +1,340 @@
"""
Admin domain: password-protected admin panel for managing users, tasks, etc.
"""
import secrets
from datetime import datetime, timedelta
from functools import wraps
from pathlib import Path
from quart import Blueprint, flash, redirect, render_template, request, session, url_for
from ..core import config, csrf_protect, execute, fetch_all, fetch_one
# Blueprint with its own template folder
bp = Blueprint(
"admin",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
url_prefix="/admin",
)
# =============================================================================
# Config
# =============================================================================
def get_admin_password() -> str:
"""Get admin password from env. Generate one if not set (dev only)."""
import os
password = os.getenv("ADMIN_PASSWORD", "")
if not password and config.DEBUG:
# In dev, use a default password
return "admin"
return password
# =============================================================================
# SQL Queries
# =============================================================================
async def get_dashboard_stats() -> dict:
"""Get admin dashboard statistics."""
now = datetime.utcnow()
today = now.date().isoformat()
week_ago = (now - timedelta(days=7)).isoformat()
users_total = await fetch_one("SELECT COUNT(*) as count FROM users WHERE deleted_at IS NULL")
users_today = await fetch_one(
"SELECT COUNT(*) as count FROM users WHERE created_at >= ? AND deleted_at IS NULL",
(today,),
)
users_week = await fetch_one(
"SELECT COUNT(*) as count FROM users WHERE created_at >= ? AND deleted_at IS NULL",
(week_ago,),
)
subs = await fetch_one(
"SELECT COUNT(*) as count FROM subscriptions WHERE status = 'active'"
)
tasks_pending = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'pending'")
tasks_failed = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'failed'")
# Analytics stats (DuckDB)
analytics = {"commodity_count": 0, "min_year": None, "max_year": None}
try:
from ..analytics import _conn as duckdb_conn
from ..analytics import fetch_analytics
if duckdb_conn is not None:
rows = await fetch_analytics(
"""
SELECT COUNT(DISTINCT commodity_code) as commodity_count,
MIN(market_year) as min_year,
MAX(market_year) as max_year
FROM serving.commodity_metrics
WHERE country_code IS NOT NULL
"""
)
if rows:
analytics = rows[0]
except Exception:
pass
return {
"users_total": users_total["count"] if users_total else 0,
"users_today": users_today["count"] if users_today else 0,
"users_week": users_week["count"] if users_week else 0,
"active_subscriptions": subs["count"] if subs else 0,
"tasks_pending": tasks_pending["count"] if tasks_pending else 0,
"tasks_failed": tasks_failed["count"] if tasks_failed else 0,
"commodity_count": analytics.get("commodity_count", 0),
"data_year_range": f"{analytics.get('min_year', '?')}{analytics.get('max_year', '?')}",
}
async def get_users(limit: int = 50, offset: int = 0, search: str = None) -> list[dict]:
"""Get users with optional search."""
if search:
return await fetch_all(
"""
SELECT u.*, s.plan, s.status as sub_status
FROM users u
LEFT JOIN subscriptions s ON s.user_id = u.id AND s.status = 'active'
WHERE u.deleted_at IS NULL AND u.email LIKE ?
ORDER BY u.created_at DESC
LIMIT ? OFFSET ?
""",
(f"%{search}%", limit, offset)
)
return await fetch_all(
"""
SELECT u.*, s.plan, s.status as sub_status
FROM users u
LEFT JOIN subscriptions s ON s.user_id = u.id AND s.status = 'active'
WHERE u.deleted_at IS NULL
ORDER BY u.created_at DESC
LIMIT ? OFFSET ?
""",
(limit, offset)
)
async def get_user_by_id(user_id: int) -> dict | None:
"""Get user by ID with subscription info."""
return await fetch_one(
"""
SELECT u.*, s.plan, s.status as sub_status, s.stripe_customer_id
FROM users u
LEFT JOIN subscriptions s ON s.user_id = u.id
WHERE u.id = ?
""",
(user_id,)
)
async def get_recent_tasks(limit: int = 50) -> list[dict]:
"""Get recent tasks."""
return await fetch_all(
"""
SELECT * FROM tasks
ORDER BY created_at DESC
LIMIT ?
""",
(limit,)
)
async def get_failed_tasks() -> list[dict]:
"""Get failed tasks."""
return await fetch_all(
"SELECT * FROM tasks WHERE status = 'failed' ORDER BY created_at DESC"
)
async def retry_task(task_id: int) -> bool:
"""Retry a failed task."""
result = await execute(
"""
UPDATE tasks
SET status = 'pending', run_at = ?, error = NULL
WHERE id = ? AND status = 'failed'
""",
(datetime.utcnow().isoformat(), task_id)
)
return result > 0
async def delete_task(task_id: int) -> bool:
"""Delete a task."""
result = await execute("DELETE FROM tasks WHERE id = ?", (task_id,))
return result > 0
# =============================================================================
# Decorators
# =============================================================================
def admin_required(f):
"""Require admin authentication."""
@wraps(f)
async def decorated(*args, **kwargs):
if not session.get("is_admin"):
return redirect(url_for("admin.login"))
return await f(*args, **kwargs)
return decorated
# =============================================================================
# Routes
# =============================================================================
@bp.route("/login", methods=["GET", "POST"])
@csrf_protect
async def login():
"""Admin login page."""
admin_password = get_admin_password()
if not admin_password:
await flash("Admin access not configured. Set ADMIN_PASSWORD env var.", "error")
return redirect(url_for("public.landing"))
if session.get("is_admin"):
return redirect(url_for("admin.index"))
if request.method == "POST":
form = await request.form
password = form.get("password", "")
if secrets.compare_digest(password, admin_password):
session["is_admin"] = True
await flash("Welcome, admin!", "success")
return redirect(url_for("admin.index"))
else:
await flash("Invalid password.", "error")
return await render_template("login.html")
@bp.route("/logout", methods=["POST"])
@csrf_protect
async def logout():
"""Admin logout."""
session.pop("is_admin", None)
await flash("Logged out of admin.", "info")
return redirect(url_for("admin.login"))
@bp.route("/")
@admin_required
async def index():
"""Admin dashboard."""
stats = await get_dashboard_stats()
recent_users = await get_users(limit=10)
failed_tasks = await get_failed_tasks()
return await render_template(
"index.html",
stats=stats,
recent_users=recent_users,
failed_tasks=failed_tasks,
)
@bp.route("/users")
@admin_required
async def users():
"""User list."""
search = request.args.get("search", "").strip()
page = int(request.args.get("page", 1))
per_page = 50
offset = (page - 1) * per_page
user_list = await get_users(limit=per_page, offset=offset, search=search or None)
return await render_template(
"users.html",
users=user_list,
search=search,
page=page,
)
@bp.route("/users/<int:user_id>")
@admin_required
async def user_detail(user_id: int):
"""User detail page."""
user = await get_user_by_id(user_id)
if not user:
await flash("User not found.", "error")
return redirect(url_for("admin.users"))
return await render_template("user_detail.html", user=user)
@bp.route("/users/<int:user_id>/impersonate", methods=["POST"])
@admin_required
@csrf_protect
async def impersonate(user_id: int):
"""Impersonate a user (login as them)."""
user = await get_user_by_id(user_id)
if not user:
await flash("User not found.", "error")
return redirect(url_for("admin.users"))
# Store admin session so we can return
session["admin_impersonating"] = True
session["user_id"] = user_id
await flash(f"Now impersonating {user['email']}. Return to admin to stop.", "warning")
return redirect(url_for("dashboard.index"))
@bp.route("/stop-impersonating", methods=["POST"])
@csrf_protect
async def stop_impersonating():
"""Stop impersonating and return to admin."""
session.pop("user_id", None)
session.pop("admin_impersonating", None)
await flash("Stopped impersonating.", "info")
return redirect(url_for("admin.index"))
@bp.route("/tasks")
@admin_required
async def tasks():
"""Task queue management."""
task_list = await get_recent_tasks(limit=100)
failed = await get_failed_tasks()
return await render_template(
"tasks.html",
tasks=task_list,
failed_tasks=failed,
)
@bp.route("/tasks/<int:task_id>/retry", methods=["POST"])
@admin_required
@csrf_protect
async def task_retry(task_id: int):
"""Retry a failed task."""
success = await retry_task(task_id)
if success:
await flash("Task queued for retry.", "success")
else:
await flash("Could not retry task.", "error")
return redirect(url_for("admin.tasks"))
@bp.route("/tasks/<int:task_id>/delete", methods=["POST"])
@admin_required
@csrf_protect
async def task_delete(task_id: int):
"""Delete a task."""
success = await delete_task(task_id)
if success:
await flash("Task deleted.", "success")
else:
await flash("Could not delete task.", "error")
return redirect(url_for("admin.tasks"))

View File

@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}Admin Dashboard - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h1>Admin Dashboard</h1>
{% if session.get('admin_impersonating') %}
<mark>Currently impersonating a user</mark>
<form method="post" action="{{ url_for('admin.stop_impersonating') }}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary outline" style="padding: 0.25rem 0.5rem;">Stop</button>
</form>
{% endif %}
</div>
<form method="post" action="{{ url_for('admin.logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary outline">Logout</button>
</form>
</header>
<!-- Stats Grid -->
<div class="grid">
<article>
<header><small>Total Users</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.users_total }}</strong></p>
<small>+{{ stats.users_today }} today, +{{ stats.users_week }} this week</small>
</article>
<article>
<header><small>Active Subscriptions</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.active_subscriptions }}</strong></p>
</article>
<article>
<header><small>Task Queue</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.tasks_pending }}</strong> pending</p>
{% if stats.tasks_failed > 0 %}
<small style="color: var(--del-color);">{{ stats.tasks_failed }} failed</small>
{% else %}
<small style="color: var(--ins-color);">0 failed</small>
{% endif %}
</article>
</div>
<!-- Quick Links -->
<div class="grid" style="margin-bottom: 2rem;">
<a href="{{ url_for('admin.users') }}" role="button" class="secondary outline">All Users</a>
<a href="{{ url_for('admin.tasks') }}" role="button" class="secondary outline">Task Queue</a>
<a href="{{ url_for('dashboard.index') }}" role="button" class="secondary outline">View as User</a>
</div>
<div class="grid">
<!-- Recent Users -->
<section>
<h2>Recent Users</h2>
<article>
{% if recent_users %}
<table>
<thead>
<tr>
<th>Email</th>
<th>Plan</th>
<th>Joined</th>
</tr>
</thead>
<tbody>
{% for u in recent_users %}
<tr>
<td>
<a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a>
</td>
<td>{{ u.plan or 'free' }}</td>
<td>{{ u.created_at[:10] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{{ url_for('admin.users') }}">View all →</a>
{% else %}
<p>No users yet.</p>
{% endif %}
</article>
</section>
<!-- Failed Tasks -->
<section>
<h2>Failed Tasks</h2>
<article>
{% if failed_tasks %}
<table>
<thead>
<tr>
<th>Task</th>
<th>Error</th>
<th></th>
</tr>
</thead>
<tbody>
{% for task in failed_tasks[:5] %}
<tr>
<td>{{ task.task_name }}</td>
<td><small>{{ task.error[:50] }}...</small></td>
<td>
<form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline" style="padding: 0.25rem 0.5rem; margin: 0;">Retry</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{{ url_for('admin.tasks') }}">View all →</a>
{% else %}
<p style="color: var(--ins-color);">✓ No failed tasks</p>
{% endif %}
</article>
</section>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}Admin Login - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 400px; margin: 4rem auto;">
<header>
<h1>Admin Login</h1>
</header>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="password">
Password
<input
type="password"
id="password"
name="password"
required
autofocus
>
</label>
<button type="submit">Login</button>
</form>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block title %}Tasks - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header style="display: flex; justify-content: space-between; align-items: center;">
<h1>Task Queue</h1>
<a href="{{ url_for('admin.index') }}" role="button" class="secondary outline">← Dashboard</a>
</header>
<!-- Failed Tasks -->
{% if failed_tasks %}
<section>
<h2 style="color: var(--del-color);">Failed Tasks ({{ failed_tasks | length }})</h2>
<article style="border-color: var(--del-color);">
<table>
<thead>
<tr>
<th>ID</th>
<th>Task</th>
<th>Error</th>
<th>Retries</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for task in failed_tasks %}
<tr>
<td>{{ task.id }}</td>
<td><code>{{ task.task_name }}</code></td>
<td>
<details>
<summary>{{ task.error[:40] if task.error else 'No error' }}...</summary>
<pre style="font-size: 0.75rem; white-space: pre-wrap;">{{ task.error }}</pre>
</details>
</td>
<td>{{ task.retries }}</td>
<td>{{ task.created_at[:16] }}</td>
<td>
<div style="display: flex; gap: 0.5rem;">
<form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline" style="padding: 0.25rem 0.5rem; margin: 0;">Retry</button>
</form>
<form method="post" action="{{ url_for('admin.task_delete', task_id=task.id) }}" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline secondary" style="padding: 0.25rem 0.5rem; margin: 0;">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</article>
</section>
{% endif %}
<!-- All Tasks -->
<section>
<h2>Recent Tasks</h2>
<article>
{% if tasks %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Task</th>
<th>Status</th>
<th>Run At</th>
<th>Created</th>
<th>Completed</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td>{{ task.id }}</td>
<td><code>{{ task.task_name }}</code></td>
<td>
{% if task.status == 'complete' %}
<span style="color: var(--ins-color);">✓ complete</span>
{% elif task.status == 'failed' %}
<span style="color: var(--del-color);">✗ failed</span>
{% elif task.status == 'pending' %}
<span style="color: var(--mark-background-color);">○ pending</span>
{% else %}
{{ task.status }}
{% endif %}
</td>
<td>{{ task.run_at[:16] if task.run_at else '-' }}</td>
<td>{{ task.created_at[:16] }}</td>
<td>{{ task.completed_at[:16] if task.completed_at else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No tasks in queue.</p>
{% endif %}
</article>
</section>
</main>
{% endblock %}

View File

@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}User: {{ user.email }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header style="display: flex; justify-content: space-between; align-items: center;">
<h1>{{ user.email }}</h1>
<a href="{{ url_for('admin.users') }}" role="button" class="secondary outline">← Users</a>
</header>
<div class="grid">
<!-- User Info -->
<article>
<header><h3>User Info</h3></header>
<dl>
<dt>ID</dt>
<dd>{{ user.id }}</dd>
<dt>Email</dt>
<dd>{{ user.email }}</dd>
<dt>Name</dt>
<dd>{{ user.name or '-' }}</dd>
<dt>Created</dt>
<dd>{{ user.created_at }}</dd>
<dt>Last Login</dt>
<dd>{{ user.last_login_at or 'Never' }}</dd>
</dl>
</article>
<!-- Subscription -->
<article>
<header><h3>Subscription</h3></header>
<dl>
<dt>Plan</dt>
<dd>
{% if user.plan %}
<mark>{{ user.plan }}</mark>
{% else %}
free
{% endif %}
</dd>
<dt>Status</dt>
<dd>{{ user.sub_status or 'N/A' }}</dd>
{% if user.stripe_customer_id %}
<dt>Stripe Customer</dt>
<dd>
<a href="https://dashboard.stripe.com/customers/{{ user.stripe_customer_id }}" target="_blank">
{{ user.stripe_customer_id }}
</a>
</dd>
{% endif %}
</dl>
</article>
</div>
<!-- Actions -->
<article>
<header><h3>Actions</h3></header>
<div style="display: flex; gap: 1rem;">
<form method="post" action="{{ url_for('admin.impersonate', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary">Impersonate User</button>
</form>
</div>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Users - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header style="display: flex; justify-content: space-between; align-items: center;">
<h1>Users</h1>
<a href="{{ url_for('admin.index') }}" role="button" class="secondary outline">← Dashboard</a>
</header>
<!-- Search -->
<form method="get" style="margin-bottom: 2rem;">
<div class="grid">
<input
type="search"
name="search"
placeholder="Search by email..."
value="{{ search }}"
>
<button type="submit">Search</button>
</div>
</form>
<!-- User Table -->
<article>
{% if users %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Name</th>
<th>Plan</th>
<th>Joined</th>
<th>Last Login</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.id }}</td>
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
<td>{{ u.name or '-' }}</td>
<td>
{% if u.plan %}
<mark>{{ u.plan }}</mark>
{% else %}
free
{% endif %}
</td>
<td>{{ u.created_at[:10] }}</td>
<td>{{ u.last_login_at[:10] if u.last_login_at else 'Never' }}</td>
<td>
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline secondary" style="padding: 0.25rem 0.5rem; margin: 0;">
Impersonate
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Pagination -->
<div style="display: flex; gap: 1rem; justify-content: center; margin-top: 1rem;">
{% if page > 1 %}
<a href="?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}">← Previous</a>
{% endif %}
<span>Page {{ page }}</span>
{% if users | length == 50 %}
<a href="?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}">Next →</a>
{% endif %}
</div>
{% else %}
<p>No users found.</p>
{% endif %}
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,220 @@
"""
DuckDB analytics data access layer.
Bridge between the async Quart app and sync DuckDB reads.
All queries run via asyncio.to_thread() against a read-only connection.
"""
import asyncio
import os
import duckdb
# Coffee (Green) commodity code in USDA PSD
COFFEE_COMMODITY_CODE = 711100
# Metrics safe for user-facing queries (prevents SQL injection in dynamic column refs)
ALLOWED_METRICS = frozenset({
"Production",
"Imports",
"Exports",
"Total_Distribution",
"Ending_Stocks",
"Beginning_Stocks",
"Total_Supply",
"Domestic_Consumption",
"Net_Supply",
"Trade_Balance",
"Supply_Demand_Balance",
"Stock_to_Use_Ratio_pct",
"Production_YoY_pct",
})
_conn: duckdb.DuckDBPyConnection | None = None
def open_analytics_db() -> None:
"""Open read-only DuckDB connection."""
global _conn
db_path = os.getenv("DUCKDB_PATH", "")
assert db_path, "DUCKDB_PATH environment variable must be set"
_conn = duckdb.connect(db_path, read_only=True)
def close_analytics_db() -> None:
"""Close DuckDB connection."""
global _conn
if _conn:
_conn.close()
_conn = None
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."""
assert _conn is not None, "Analytics DB not initialized — call open_analytics_db() first"
def _query():
result = _conn.execute(sql, params or [])
columns = [desc[0] for desc in result.description]
return [dict(zip(columns, row)) for row in result.fetchall()]
return await asyncio.to_thread(_query)
def _validate_metrics(metrics: list[str]) -> list[str]:
"""Filter metrics to allowed set. Returns validated list."""
valid = [m for m in metrics if m in ALLOWED_METRICS]
assert valid, f"No valid metrics in {metrics}. Allowed: {sorted(ALLOWED_METRICS)}"
return valid
# =============================================================================
# Query Functions
# =============================================================================
async def get_available_commodities() -> list[dict]:
"""Distinct commodity list from serving layer."""
return await fetch_analytics(
"""
SELECT DISTINCT commodity_code, commodity_name
FROM serving.commodity_metrics
WHERE country_code IS NOT NULL
ORDER BY commodity_name
"""
)
async def get_global_time_series(
commodity_code: int,
metrics: list[str],
start_year: int | None = None,
end_year: int | None = None,
) -> list[dict]:
"""Global supply/demand time series by market_year for a commodity."""
metrics = _validate_metrics(metrics)
cols = ", ".join(metrics)
where_parts = ["country_name = 'Global'", "commodity_code = ?"]
params: list = [commodity_code]
if start_year is not None:
where_parts.append("market_year >= ?")
params.append(start_year)
if end_year is not None:
where_parts.append("market_year <= ?")
params.append(end_year)
where_clause = " AND ".join(where_parts)
return await fetch_analytics(
f"""
SELECT market_year, {cols}
FROM serving.commodity_metrics
WHERE {where_clause}
ORDER BY market_year
""",
params,
)
async def get_top_countries(
commodity_code: int,
metric: str,
limit: int = 10,
) -> list[dict]:
"""Country ranking for latest market year by a single metric."""
metric = _validate_metrics([metric])[0]
return await fetch_analytics(
f"""
WITH latest AS (
SELECT MAX(market_year) AS max_year
FROM serving.commodity_metrics
WHERE commodity_code = ? AND country_code IS NOT NULL
)
SELECT country_name, country_code, market_year, {metric}
FROM serving.commodity_metrics, latest
WHERE commodity_code = ?
AND country_code IS NOT NULL
AND market_year = latest.max_year
ORDER BY {metric} DESC
LIMIT ?
""",
[commodity_code, commodity_code, limit],
)
async def get_stock_to_use_trend(commodity_code: int) -> list[dict]:
"""Global stock-to-use ratio over time."""
return await fetch_analytics(
"""
SELECT market_year, Stock_to_Use_Ratio_pct
FROM serving.commodity_metrics
WHERE commodity_code = ?
AND country_name = 'Global'
ORDER BY market_year
""",
[commodity_code],
)
async def get_supply_demand_balance(commodity_code: int) -> list[dict]:
"""Global supply-demand balance trend."""
return await fetch_analytics(
"""
SELECT market_year, Production, Total_Distribution, Supply_Demand_Balance
FROM serving.commodity_metrics
WHERE commodity_code = ?
AND country_name = 'Global'
ORDER BY market_year
""",
[commodity_code],
)
async def get_production_yoy_by_country(
commodity_code: int,
limit: int = 15,
) -> list[dict]:
"""Latest YoY production changes by country."""
return await fetch_analytics(
"""
WITH latest AS (
SELECT MAX(market_year) AS max_year
FROM serving.commodity_metrics
WHERE commodity_code = ? AND country_code IS NOT NULL
)
SELECT country_name, country_code, market_year,
Production, Production_YoY_pct
FROM serving.commodity_metrics, latest
WHERE commodity_code = ?
AND country_code IS NOT NULL
AND market_year = latest.max_year
AND Production > 0
ORDER BY ABS(Production_YoY_pct) DESC
LIMIT ?
""",
[commodity_code, commodity_code, limit],
)
async def get_country_comparison(
commodity_code: int,
country_codes: list[str],
metric: str,
) -> list[dict]:
"""Multi-country time series for a single metric."""
assert len(country_codes) <= 10, "Maximum 10 countries for comparison"
metric = _validate_metrics([metric])[0]
placeholders = ", ".join(["?"] * len(country_codes))
return await fetch_analytics(
f"""
SELECT country_name, country_code, market_year, {metric}
FROM serving.commodity_metrics
WHERE commodity_code = ?
AND country_code IN ({placeholders})
ORDER BY country_name, market_year
""",
[commodity_code, *country_codes],
)

View File

@@ -0,0 +1,191 @@
"""
API domain: REST API for commodity analytics with key authentication and rate limiting.
"""
import csv
import hashlib
import io
from datetime import datetime
from functools import wraps
from quart import Blueprint, Response, g, jsonify, request
from .. import analytics
from ..core import check_rate_limit, execute, fetch_one
bp = Blueprint("api", __name__)
# =============================================================================
# SQL Queries
# =============================================================================
async def verify_api_key(raw_key: str) -> dict | None:
"""Verify API key and return key data with user info."""
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
result = await fetch_one(
"""
SELECT ak.*, u.email, u.id as user_id,
COALESCE(s.plan, 'free') as plan
FROM api_keys ak
JOIN users u ON u.id = ak.user_id
LEFT JOIN subscriptions s ON s.user_id = u.id AND s.status = 'active'
WHERE ak.key_hash = ? AND ak.deleted_at IS NULL AND u.deleted_at IS NULL
""",
(key_hash,),
)
if result:
await execute(
"UPDATE api_keys SET last_used_at = ? WHERE id = ?",
(datetime.utcnow().isoformat(), result["id"]),
)
return result
async def log_api_request(user_id: int, endpoint: str, method: str) -> None:
"""Log API request for analytics and rate limiting."""
await execute(
"""
INSERT INTO api_requests (user_id, endpoint, method, created_at)
VALUES (?, ?, ?, ?)
""",
(user_id, endpoint, method, datetime.utcnow().isoformat()),
)
# =============================================================================
# Decorators
# =============================================================================
def api_key_required(scopes: list[str] = None):
"""Require valid API key with optional scope check."""
def decorator(f):
@wraps(f)
async def decorated(*args, **kwargs):
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return jsonify({"error": "Missing API key"}), 401
raw_key = auth[7:]
key_data = await verify_api_key(raw_key)
if not key_data:
return jsonify({"error": "Invalid API key"}), 401
# Check scopes
if scopes:
key_scopes = (key_data.get("scopes") or "").split(",")
if not any(s in key_scopes for s in scopes):
return jsonify({"error": "Insufficient permissions"}), 403
# Check plan allows API access
plan = key_data.get("plan", "free")
if plan == "free":
return jsonify({"error": "API access requires a Starter or Pro plan"}), 403
# Rate limiting
rate_key = f"api:{key_data['id']}"
allowed, info = await check_rate_limit(rate_key, limit=1000, window=3600)
if not allowed:
response = jsonify({"error": "Rate limit exceeded", **info})
response.headers["X-RateLimit-Limit"] = str(info["limit"])
response.headers["X-RateLimit-Remaining"] = str(info["remaining"])
response.headers["X-RateLimit-Reset"] = str(info["reset"])
return response, 429
await log_api_request(key_data["user_id"], request.path, request.method)
g.api_key = key_data
g.user_id = key_data["user_id"]
g.plan = plan
return await f(*args, **kwargs)
return decorated
return decorator
# =============================================================================
# Routes
# =============================================================================
@bp.route("/me")
@api_key_required()
async def me():
"""Get current user info."""
return jsonify({
"user_id": g.user_id,
"email": g.api_key["email"],
"plan": g.plan,
"key_name": g.api_key["name"],
"scopes": g.api_key["scopes"].split(","),
})
@bp.route("/commodities")
@api_key_required(scopes=["read"])
async def list_commodities():
"""List available commodities."""
commodities = await analytics.get_available_commodities()
return jsonify({"commodities": commodities})
@bp.route("/commodities/<int:code>/metrics")
@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"]
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
start_year = request.args.get("start_year", type=int)
end_year = request.args.get("end_year", type=int)
data = await analytics.get_global_time_series(code, metrics, start_year, end_year)
return jsonify({"commodity_code": code, "metrics": metrics, "data": data})
@bp.route("/commodities/<int:code>/countries")
@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")
if metric not in analytics.ALLOWED_METRICS:
return jsonify({"error": f"Invalid metric. Allowed: {sorted(analytics.ALLOWED_METRICS)}"}), 400
limit = min(int(request.args.get("limit", 20)), 100)
data = await analytics.get_top_countries(code, metric, limit)
return jsonify({"commodity_code": code, "metric": metric, "data": data})
@bp.route("/commodities/<int:code>/metrics.csv")
@api_key_required(scopes=["read"])
async def commodity_metrics_csv(code: int):
"""CSV export of time series metrics."""
if g.plan == "free":
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",
]
metrics = [m for m in raw_metrics if m in analytics.ALLOWED_METRICS]
if not metrics:
return jsonify({"error": "No valid metrics"}), 400
data = await analytics.get_global_time_series(code, metrics)
output = io.StringIO()
if data:
writer = csv.DictWriter(output, fieldnames=data[0].keys())
writer.writeheader()
writer.writerows(data)
return Response(
output.getvalue(),
mimetype="text/csv",
headers={"Content-Disposition": f"attachment; filename=commodity_{code}_metrics.csv"},
)

123
web/src/beanflows/app.py Normal file
View File

@@ -0,0 +1,123 @@
"""
BeanFlows - Application factory and entry point.
"""
import os
from pathlib import Path
from quart import Quart, g, session
from .analytics import close_analytics_db, open_analytics_db
from .core import close_db, config, get_csrf_token, init_db, setup_request_id
def create_app() -> Quart:
"""Create and configure the Quart application."""
# Get package directory for templates
pkg_dir = Path(__file__).parent
app = Quart(
__name__,
template_folder=str(pkg_dir / "templates"),
static_folder=str(pkg_dir / "static"),
)
app.secret_key = config.SECRET_KEY
# Session config
app.config["SESSION_COOKIE_SECURE"] = not config.DEBUG
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * config.SESSION_LIFETIME_DAYS
# Database lifecycle
@app.before_serving
async def startup():
await init_db()
if os.getenv("DUCKDB_PATH"):
open_analytics_db()
@app.after_serving
async def shutdown():
close_analytics_db()
await close_db()
# Security headers
@app.after_request
async def add_security_headers(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
if not config.DEBUG:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
# Load current user before each request
@app.before_request
async def load_user():
g.user = None
user_id = session.get("user_id")
if user_id:
from .auth.routes import get_user_by_id
g.user = await get_user_by_id(user_id)
# Template context globals
@app.context_processor
def inject_globals():
from datetime import datetime
return {
"config": config,
"user": g.get("user"),
"now": datetime.utcnow(),
"csrf_token": get_csrf_token,
}
# Health check
@app.route("/health")
async def health():
from .analytics import _conn as duckdb_conn
from .analytics import fetch_analytics
from .core import fetch_one
result = {"status": "healthy", "sqlite": "ok", "duckdb": "ok"}
try:
await fetch_one("SELECT 1")
except Exception as e:
result["sqlite"] = str(e)
result["status"] = "unhealthy"
if duckdb_conn is not None:
try:
await fetch_analytics("SELECT 1")
except Exception as e:
result["duckdb"] = str(e)
result["status"] = "unhealthy"
else:
result["duckdb"] = "not configured"
status_code = 200 if result["status"] == "healthy" else 500
return result, status_code
# Register blueprints
from .admin.routes import bp as admin_bp
from .api.routes import bp as api_bp
from .auth.routes import bp as auth_bp
from .billing.routes import bp as billing_bp
from .dashboard.routes import bp as dashboard_bp
from .public.routes import bp as public_bp
app.register_blueprint(public_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(billing_bp)
app.register_blueprint(api_bp, url_prefix="/api/v1")
app.register_blueprint(admin_bp)
# Request ID tracking
setup_request_id(app)
return app
app = create_app()
if __name__ == "__main__":
app.run(debug=config.DEBUG, port=5000)

View File

@@ -0,0 +1,314 @@
"""
Auth domain: magic link authentication, user management, decorators.
"""
import secrets
from functools import wraps
from datetime import datetime, timedelta
from pathlib import Path
from quart import Blueprint, render_template, request, redirect, url_for, session, flash, g
from ..core import config, fetch_one, fetch_all, execute, csrf_protect
# Blueprint with its own template folder
bp = Blueprint(
"auth",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
url_prefix="/auth",
)
# =============================================================================
# SQL Queries
# =============================================================================
async def get_user_by_id(user_id: int) -> dict | None:
"""Get user by ID."""
return await fetch_one(
"SELECT * FROM users WHERE id = ? AND deleted_at IS NULL",
(user_id,)
)
async def get_user_by_email(email: str) -> dict | None:
"""Get user by email."""
return await fetch_one(
"SELECT * FROM users WHERE email = ? AND deleted_at IS NULL",
(email.lower(),)
)
async def create_user(email: str) -> int:
"""Create new user, return ID."""
now = datetime.utcnow().isoformat()
return await execute(
"INSERT INTO users (email, created_at) VALUES (?, ?)",
(email.lower(), now)
)
async def update_user(user_id: int, **fields) -> None:
"""Update user fields."""
if not fields:
return
sets = ", ".join(f"{k} = ?" for k in fields.keys())
values = list(fields.values()) + [user_id]
await execute(f"UPDATE users SET {sets} WHERE id = ?", tuple(values))
async def create_auth_token(user_id: int, token: str, minutes: int = None) -> int:
"""Create auth token for user."""
minutes = minutes or config.MAGIC_LINK_EXPIRY_MINUTES
expires = datetime.utcnow() + timedelta(minutes=minutes)
return await execute(
"INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
(user_id, token, expires.isoformat())
)
async def get_valid_token(token: str) -> dict | None:
"""Get token if valid and not expired."""
return await fetch_one(
"""
SELECT at.*, u.email
FROM auth_tokens at
JOIN users u ON u.id = at.user_id
WHERE at.token = ? AND at.expires_at > ? AND at.used_at IS NULL
""",
(token, datetime.utcnow().isoformat())
)
async def mark_token_used(token_id: int) -> None:
"""Mark token as used."""
await execute(
"UPDATE auth_tokens SET used_at = ? WHERE id = ?",
(datetime.utcnow().isoformat(), token_id)
)
async def get_user_with_subscription(user_id: int) -> dict | None:
"""Get user with their active subscription info."""
return await fetch_one(
"""
SELECT u.*, s.plan, s.status as sub_status, s.current_period_end
FROM users u
LEFT JOIN subscriptions s ON s.user_id = u.id AND s.status = 'active'
WHERE u.id = ? AND u.deleted_at IS NULL
""",
(user_id,)
)
# =============================================================================
# Decorators
# =============================================================================
def login_required(f):
"""Require authenticated user."""
@wraps(f)
async def decorated(*args, **kwargs):
if not g.get("user"):
await flash("Please sign in to continue.", "warning")
return redirect(url_for("auth.login", next=request.path))
return await f(*args, **kwargs)
return decorated
def subscription_required(plans: list[str] = None):
"""Require active subscription, optionally of specific plan(s)."""
def decorator(f):
@wraps(f)
async def decorated(*args, **kwargs):
if not g.get("user"):
await flash("Please sign in to continue.", "warning")
return redirect(url_for("auth.login"))
user = await get_user_with_subscription(g.user["id"])
if not user or not user.get("plan"):
await flash("Please subscribe to access this feature.", "warning")
return redirect(url_for("billing.pricing"))
if plans and user["plan"] not in plans:
await flash(f"This feature requires a {' or '.join(plans)} plan.", "warning")
return redirect(url_for("billing.pricing"))
return await f(*args, **kwargs)
return decorated
return decorator
# =============================================================================
# Routes
# =============================================================================
@bp.route("/login", methods=["GET", "POST"])
@csrf_protect
async def login():
"""Login page - request magic link."""
if g.get("user"):
return redirect(url_for("dashboard.index"))
if request.method == "POST":
form = await request.form
email = form.get("email", "").strip().lower()
if not email or "@" not in email:
await flash("Please enter a valid email address.", "error")
return redirect(url_for("auth.login"))
# Get or create user
user = await get_user_by_email(email)
if not user:
user_id = await create_user(email)
else:
user_id = user["id"]
# Create magic link token
token = secrets.token_urlsafe(32)
await create_auth_token(user_id, token)
# Queue email
from ..worker import enqueue
await enqueue("send_magic_link", {"email": email, "token": token})
await flash("Check your email for the sign-in link!", "success")
return redirect(url_for("auth.magic_link_sent", email=email))
return await render_template("login.html")
@bp.route("/signup", methods=["GET", "POST"])
@csrf_protect
async def signup():
"""Signup page - same as login but with different messaging."""
if g.get("user"):
return redirect(url_for("dashboard.index"))
plan = request.args.get("plan", "free")
if request.method == "POST":
form = await request.form
email = form.get("email", "").strip().lower()
selected_plan = form.get("plan", "free")
if not email or "@" not in email:
await flash("Please enter a valid email address.", "error")
return redirect(url_for("auth.signup", plan=selected_plan))
# Check if user exists
user = await get_user_by_email(email)
if user:
await flash("Account already exists. Please sign in.", "info")
return redirect(url_for("auth.login"))
# Create user
user_id = await create_user(email)
# Create magic link token
token = secrets.token_urlsafe(32)
await create_auth_token(user_id, token)
# Queue emails
from ..worker import enqueue
await enqueue("send_magic_link", {"email": email, "token": token})
await enqueue("send_welcome", {"email": email})
await flash("Check your email to complete signup!", "success")
return redirect(url_for("auth.magic_link_sent", email=email))
return await render_template("signup.html", plan=plan)
@bp.route("/verify")
async def verify():
"""Verify magic link token."""
token = request.args.get("token")
if not token:
await flash("Invalid or expired link.", "error")
return redirect(url_for("auth.login"))
token_data = await get_valid_token(token)
if not token_data:
await flash("Invalid or expired link. Please request a new one.", "error")
return redirect(url_for("auth.login"))
# Mark token as used
await mark_token_used(token_data["id"])
# Update last login
await update_user(token_data["user_id"], last_login_at=datetime.utcnow().isoformat())
# Set session
session.permanent = True
session["user_id"] = token_data["user_id"]
await flash("Successfully signed in!", "success")
# Redirect to intended page or dashboard
next_url = request.args.get("next", url_for("dashboard.index"))
return redirect(next_url)
@bp.route("/logout", methods=["POST"])
@csrf_protect
async def logout():
"""Log out user."""
session.clear()
await flash("You have been signed out.", "info")
return redirect(url_for("public.landing"))
@bp.route("/magic-link-sent")
async def magic_link_sent():
"""Confirmation page after magic link sent."""
email = request.args.get("email", "")
return await render_template("magic_link_sent.html", email=email)
@bp.route("/dev-login")
async def dev_login():
"""Instant login for development. Only works in DEBUG mode."""
if not config.DEBUG:
return "Not available", 404
email = request.args.get("email", "dev@localhost")
user = await get_user_by_email(email)
if not user:
user_id = await create_user(email)
else:
user_id = user["id"]
session.permanent = True
session["user_id"] = user_id
await flash(f"Dev login as {email}", "success")
return redirect(url_for("dashboard.index"))
@bp.route("/resend", methods=["POST"])
@csrf_protect
async def resend():
"""Resend magic link."""
form = await request.form
email = form.get("email", "").strip().lower()
if not email:
await flash("Email address required.", "error")
return redirect(url_for("auth.login"))
user = await get_user_by_email(email)
if user:
token = secrets.token_urlsafe(32)
await create_auth_token(user["id"], token)
from ..worker import enqueue
await enqueue("send_magic_link", {"email": email, "token": token})
# Always show success (don't reveal if email exists)
await flash("If that email is registered, we've sent a new link.", "success")
return redirect(url_for("auth.magic_link_sent", email=email))

View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Sign In - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 400px; margin: 4rem auto;">
<header>
<h1>Sign In</h1>
<p>Enter your email to receive a sign-in link.</p>
</header>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="email">
Email
<input
type="email"
id="email"
name="email"
placeholder="you@example.com"
required
autofocus
>
</label>
<button type="submit">Send Sign-In Link</button>
</form>
<footer style="text-align: center; margin-top: 1rem;">
<small>
Don't have an account?
<a href="{{ url_for('auth.signup') }}">Sign up</a>
</small>
</footer>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Check Your Email - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 400px; margin: 4rem auto; text-align: center;">
<header>
<h1>Check Your Email</h1>
</header>
<p>We've sent a sign-in link to:</p>
<p><strong>{{ email }}</strong></p>
<p>Click the link in the email to sign in. The link expires in {{ config.MAGIC_LINK_EXPIRY_MINUTES }} minutes.</p>
<hr>
<details>
<summary>Didn't receive the email?</summary>
<ul style="text-align: left;">
<li>Check your spam folder</li>
<li>Make sure the email address is correct</li>
<li>Wait a minute and try again</li>
</ul>
<form method="post" action="{{ url_for('auth.resend') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="email" value="{{ email }}">
<button type="submit" class="secondary outline">Resend Link</button>
</form>
</details>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}Sign Up - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 400px; margin: 4rem auto;">
<header>
<h1>Create Account</h1>
<p>Enter your email to get started.</p>
</header>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="plan" value="{{ plan }}">
<label for="email">
Email
<input
type="email"
id="email"
name="email"
placeholder="you@example.com"
required
autofocus
>
</label>
{% if plan and plan != 'free' %}
<small>You'll be able to subscribe to the <strong>{{ plan | title }}</strong> plan after signing up.</small>
{% endif %}
<button type="submit">Create Account</button>
</form>
<footer style="text-align: center; margin-top: 1rem;">
<small>
Already have an account?
<a href="{{ url_for('auth.login') }}">Sign in</a>
</small>
</footer>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,275 @@
"""
Billing domain: checkout, webhooks, subscription management.
Payment provider: paddle
"""
import json
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 ..auth.routes import login_required
# Blueprint with its own template folder
bp = Blueprint(
"billing",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
url_prefix="/billing",
)
# =============================================================================
# SQL Queries
# =============================================================================
async def get_subscription(user_id: int) -> dict | None:
"""Get user's subscription."""
return await fetch_one(
"SELECT * FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
(user_id,)
)
async def upsert_subscription(
user_id: int,
plan: str,
status: str,
provider_customer_id: str,
provider_subscription_id: str,
current_period_end: str = None,
) -> int:
"""Create or update subscription."""
now = datetime.utcnow().isoformat()
customer_col = "paddle_customer_id"
subscription_col = "paddle_subscription_id"
existing = await fetch_one("SELECT id FROM subscriptions WHERE user_id = ?", (user_id,))
if existing:
await execute(
f"""UPDATE subscriptions
SET plan = ?, status = ?, {customer_col} = ?, {subscription_col} = ?,
current_period_end = ?, updated_at = ?
WHERE user_id = ?""",
(plan, status, provider_customer_id, provider_subscription_id,
current_period_end, now, user_id),
)
return existing["id"]
else:
return await execute(
f"""INSERT INTO subscriptions
(user_id, plan, status, {customer_col}, {subscription_col},
current_period_end, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user_id, plan, status, provider_customer_id, provider_subscription_id,
current_period_end, now, now),
)
async def get_subscription_by_provider_id(subscription_id: str) -> dict | None:
return await fetch_one(
"SELECT * FROM subscriptions WHERE paddle_subscription_id = ?",
(subscription_id,)
)
async def update_subscription_status(provider_subscription_id: str, status: str, **extra) -> None:
"""Update subscription status by provider subscription ID."""
extra["updated_at"] = datetime.utcnow().isoformat()
extra["status"] = status
sets = ", ".join(f"{k} = ?" for k in extra)
values = list(extra.values())
values.append(provider_subscription_id)
await execute(f"UPDATE subscriptions SET {sets} WHERE paddle_subscription_id = ?", tuple(values))
async def can_access_feature(user_id: int, feature: str) -> bool:
"""Check if user can access a feature based on their plan."""
sub = await get_subscription(user_id)
plan = sub["plan"] if sub and sub["status"] in ("active", "on_trial", "cancelled") else "free"
return feature in config.PLAN_FEATURES.get(plan, [])
async def is_within_limits(user_id: int, resource: str, current_count: int) -> bool:
"""Check if user is within their plan limits."""
sub = await get_subscription(user_id)
plan = sub["plan"] if sub and sub["status"] in ("active", "on_trial", "cancelled") else "free"
limit = config.PLAN_LIMITS.get(plan, {}).get(resource, 0)
if limit == -1:
return True
return current_count < limit
# =============================================================================
# Access Gating
# =============================================================================
def subscription_required(allowed=("active", "on_trial", "cancelled")):
"""Decorator to gate routes behind active subscription."""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
if "user_id" not in session:
return redirect(url_for("auth.login"))
sub = await get_subscription(session["user_id"])
if not sub or sub["status"] not in allowed:
return redirect(url_for("billing.pricing"))
return await func(*args, **kwargs)
return wrapper
return decorator
# =============================================================================
# Routes
# =============================================================================
@bp.route("/pricing")
async def pricing():
"""Pricing page."""
user_sub = None
if "user_id" in session:
user_sub = await get_subscription(session["user_id"])
return await render_template("pricing.html", subscription=user_sub)
@bp.route("/success")
@login_required
async def success():
"""Checkout success page."""
return await render_template("success.html")
# =============================================================================
# Paddle Implementation
# =============================================================================
@bp.route("/checkout/<plan>", methods=["POST"])
@login_required
async def checkout(plan: str):
"""Create Paddle checkout via API."""
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",
},
},
)
response.raise_for_status()
checkout_url = response.json()["data"]["checkout"]["url"]
return redirect(checkout_url)
@bp.route("/manage", methods=["POST"])
@login_required
async def manage():
"""Redirect to Paddle customer portal."""
sub = await get_subscription(g.user["id"])
if not sub or not sub.get("paddle_subscription_id"):
await flash("No active subscription found.", "error")
return redirect(url_for("dashboard.settings"))
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.paddle.com/subscriptions/{sub['paddle_subscription_id']}",
headers={"Authorization": f"Bearer {config.PADDLE_API_KEY}"},
)
response.raise_for_status()
portal_url = response.json()["data"]["management_urls"]["update_payment_method"]
return redirect(portal_url)
@bp.route("/cancel", methods=["POST"])
@login_required
async def cancel():
"""Cancel subscription via Paddle API."""
sub = await get_subscription(g.user["id"])
if sub and sub.get("paddle_subscription_id"):
async with httpx.AsyncClient() as client:
await client.post(
f"https://api.paddle.com/subscriptions/{sub['paddle_subscription_id']}/cancel",
headers={
"Authorization": f"Bearer {config.PADDLE_API_KEY}",
"Content-Type": "application/json",
},
json={"effective_from": "next_billing_period"},
)
return redirect(url_for("dashboard.settings"))
@bp.route("/webhook/paddle", methods=["POST"])
async def webhook():
"""Handle Paddle webhooks."""
payload = await request.get_data()
sig = request.headers.get("Paddle-Signature", "")
if not verify_hmac_signature(payload, sig, config.PADDLE_WEBHOOK_SECRET):
return jsonify({"error": "Invalid signature"}), 400
event = json.loads(payload)
event_type = event.get("event_type")
data = event.get("data", {})
custom_data = data.get("custom_data", {})
user_id = custom_data.get("user_id")
if event_type == "subscription.activated":
plan = custom_data.get("plan", "starter")
await upsert_subscription(
user_id=int(user_id) if user_id else 0,
plan=plan,
status="active",
provider_customer_id=str(data.get("customer_id", "")),
provider_subscription_id=data.get("id", ""),
current_period_end=data.get("current_billing_period", {}).get("ends_at"),
)
elif event_type == "subscription.updated":
await update_subscription_status(
data.get("id", ""),
status=data.get("status", "active"),
current_period_end=data.get("current_billing_period", {}).get("ends_at"),
)
elif event_type == "subscription.canceled":
await update_subscription_status(data.get("id", ""), status="cancelled")
elif event_type == "subscription.past_due":
await update_subscription_status(data.get("id", ""), status="past_due")
return jsonify({"received": True})

View File

@@ -0,0 +1,120 @@
{% extends "base.html" %}
{% block title %}Pricing - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header style="text-align: center; margin-bottom: 3rem;">
<h1>Simple, Transparent Pricing</h1>
<p>Start free with coffee data. Upgrade when you need more.</p>
</header>
<div class="grid">
<!-- Free Plan -->
<article>
<header>
<h3>Free</h3>
<p><strong style="font-size: 2rem;">$0</strong> <small>/month</small></p>
</header>
<ul>
<li>Coffee dashboard</li>
<li>Last 5 years of data</li>
<li>Global &amp; country charts</li>
<li>Community support</li>
</ul>
<footer>
{% if user %}
{% if (user.plan or 'free') == 'free' %}
<button class="secondary" disabled>Current Plan</button>
{% else %}
<button class="secondary" disabled>Free</button>
{% endif %}
{% else %}
<a href="{{ url_for('auth.signup', plan='free') }}" role="button" class="secondary">Get Started</a>
{% endif %}
</footer>
</article>
<!-- Starter Plan -->
<article>
<header>
<h3>Starter</h3>
<p><strong style="font-size: 2rem;">TBD</strong> <small>/month</small></p>
</header>
<ul>
<li>Full coffee history (18+ years)</li>
<li>CSV data export</li>
<li>REST API access (10k calls/mo)</li>
<li>Email support</li>
</ul>
<footer>
{% if user %}
{% if (user.plan or 'free') == 'starter' %}
<button class="secondary" disabled>Current Plan</button>
{% else %}
<form method="post" action="{{ url_for('billing.checkout', plan='starter') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit">Upgrade</button>
</form>
{% endif %}
{% else %}
<a href="{{ url_for('auth.signup', plan='starter') }}" role="button">Get Started</a>
{% endif %}
</footer>
</article>
<!-- Pro Plan -->
<article>
<header>
<h3>Pro</h3>
<p><strong style="font-size: 2rem;">TBD</strong> <small>/month</small></p>
</header>
<ul>
<li>All 65 USDA commodities</li>
<li>Unlimited API calls</li>
<li>CSV &amp; API export</li>
<li>Priority support</li>
</ul>
<footer>
{% if user %}
{% if (user.plan or 'free') == 'pro' %}
<button class="secondary" disabled>Current Plan</button>
{% else %}
<form method="post" action="{{ url_for('billing.checkout', plan='pro') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit">Upgrade</button>
</form>
{% endif %}
{% else %}
<a href="{{ url_for('auth.signup', plan='pro') }}" role="button">Get Started</a>
{% endif %}
</footer>
</article>
</div>
<!-- FAQ -->
<section style="margin-top: 4rem; max-width: 600px; margin-left: auto; margin-right: auto;">
<h2>Frequently Asked Questions</h2>
<details>
<summary>Where does the data come from?</summary>
<p>All data comes from the USDA Production, Supply &amp; Distribution (PSD) Online database, which is freely available. We process and transform it daily into analytics-ready metrics.</p>
</details>
<details>
<summary>Can I change plans later?</summary>
<p>Yes. Upgrade or downgrade at any time. Changes take effect immediately with prorated billing.</p>
</details>
<details>
<summary>What commodities are available on Pro?</summary>
<p>All 65 commodities tracked by USDA PSD, including coffee, cocoa, sugar, cotton, grains, oilseeds, and more.</p>
</details>
<details>
<summary>How do I cancel?</summary>
<p>Cancel anytime from your dashboard settings. You keep access until the end of your billing period.</p>
</details>
</section>
</main>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Success! - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 500px; margin: 4rem auto; text-align: center;">
<header>
<h1>🎉 Welcome Aboard!</h1>
</header>
<p>Your subscription is now active. You have full access to all features included in your plan.</p>
<p>
<a href="{{ url_for('dashboard.index') }}" role="button">Go to Dashboard</a>
</p>
<hr>
<p><small>
Need to manage your subscription? Visit
<a href="{{ url_for('dashboard.settings') }}">account settings</a>.
</small></p>
</article>
</main>
{% endblock %}

334
web/src/beanflows/core.py Normal file
View File

@@ -0,0 +1,334 @@
"""
Core infrastructure: database, config, email, and shared utilities.
"""
import os
import secrets
import hashlib
import hmac
import aiosqlite
import httpx
from pathlib import Path
from functools import wraps
from datetime import datetime, timedelta
from contextvars import ContextVar
from quart import request, session, g
# =============================================================================
# Configuration
# =============================================================================
class Config:
APP_NAME: str = os.getenv("APP_NAME", "BeanFlows")
SECRET_KEY: str = os.getenv("SECRET_KEY", "change-me-in-production")
BASE_URL: str = os.getenv("BASE_URL", "http://localhost:5000")
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
DATABASE_PATH: str = os.getenv("DATABASE_PATH", "data/app.db")
MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15"))
SESSION_LIFETIME_DAYS: int = int(os.getenv("SESSION_LIFETIME_DAYS", "30"))
PAYMENT_PROVIDER: str = "paddle"
PADDLE_API_KEY: str = os.getenv("PADDLE_API_KEY", "")
PADDLE_WEBHOOK_SECRET: str = os.getenv("PADDLE_WEBHOOK_SECRET", "")
PADDLE_PRICES: dict = {
"starter": os.getenv("PADDLE_PRICE_STARTER", ""),
"pro": os.getenv("PADDLE_PRICE_PRO", ""),
}
RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
EMAIL_FROM: str = os.getenv("EMAIL_FROM", "hello@example.com")
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
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"],
}
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
}
config = Config()
# =============================================================================
# Database
# =============================================================================
_db: aiosqlite.Connection | None = None
async def init_db(path: str = None) -> None:
"""Initialize database connection with WAL mode."""
global _db
db_path = path or config.DATABASE_PATH
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
_db = await aiosqlite.connect(db_path)
_db.row_factory = aiosqlite.Row
await _db.execute("PRAGMA journal_mode=WAL")
await _db.execute("PRAGMA foreign_keys=ON")
await _db.execute("PRAGMA busy_timeout=5000")
await _db.execute("PRAGMA synchronous=NORMAL")
await _db.execute("PRAGMA cache_size=-64000")
await _db.execute("PRAGMA temp_store=MEMORY")
await _db.execute("PRAGMA mmap_size=268435456")
await _db.commit()
async def close_db() -> None:
"""Close database connection."""
global _db
if _db:
await _db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
await _db.close()
_db = None
async def get_db() -> aiosqlite.Connection:
"""Get database connection."""
if _db is None:
await init_db()
return _db
async def fetch_one(sql: str, params: tuple = ()) -> dict | None:
"""Fetch a single row as dict."""
db = await get_db()
async with db.execute(sql, params) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def fetch_all(sql: str, params: tuple = ()) -> list[dict]:
"""Fetch all rows as list of dicts."""
db = await get_db()
async with db.execute(sql, params) as cursor:
rows = await cursor.fetchall()
return [dict(row) for row in rows]
async def execute(sql: str, params: tuple = ()) -> int:
"""Execute SQL and return lastrowid."""
db = await get_db()
async with db.execute(sql, params) as cursor:
await db.commit()
return cursor.lastrowid
async def execute_many(sql: str, params_list: list[tuple]) -> None:
"""Execute SQL for multiple parameter sets."""
db = await get_db()
await db.executemany(sql, params_list)
await db.commit()
class transaction:
"""Async context manager for transactions."""
async def __aenter__(self):
self.db = await get_db()
return self.db
async def __aexit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
await self.db.commit()
else:
await self.db.rollback()
return False
# =============================================================================
# Email
# =============================================================================
async def send_email(to: str, subject: str, html: str, text: str = None) -> bool:
"""Send email via Resend API."""
if not config.RESEND_API_KEY:
print(f"[EMAIL] Would send to {to}: {subject}")
return True
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.resend.com/emails",
headers={"Authorization": f"Bearer {config.RESEND_API_KEY}"},
json={
"from": config.EMAIL_FROM,
"to": to,
"subject": subject,
"html": html,
"text": text or html,
},
)
return response.status_code == 200
# =============================================================================
# CSRF Protection
# =============================================================================
def get_csrf_token() -> str:
"""Get or create CSRF token for current session."""
if "csrf_token" not in session:
session["csrf_token"] = secrets.token_urlsafe(32)
return session["csrf_token"]
def validate_csrf_token(token: str) -> bool:
"""Validate CSRF token."""
return token and secrets.compare_digest(token, session.get("csrf_token", ""))
def csrf_protect(f):
"""Decorator to require valid CSRF token for POST requests."""
@wraps(f)
async def decorated(*args, **kwargs):
if request.method == "POST":
form = await request.form
token = form.get("csrf_token") or request.headers.get("X-CSRF-Token")
if not validate_csrf_token(token):
return {"error": "Invalid CSRF token"}, 403
return await f(*args, **kwargs)
return decorated
# =============================================================================
# Rate Limiting (SQLite-based)
# =============================================================================
async def check_rate_limit(key: str, limit: int = None, window: int = None) -> tuple[bool, dict]:
"""
Check if rate limit exceeded. Returns (is_allowed, info).
Uses SQLite for storage - no Redis needed.
"""
limit = limit or config.RATE_LIMIT_REQUESTS
window = window or config.RATE_LIMIT_WINDOW
now = datetime.utcnow()
window_start = now - timedelta(seconds=window)
# Clean old entries and count recent
await execute(
"DELETE FROM rate_limits WHERE key = ? AND timestamp < ?",
(key, window_start.isoformat())
)
result = await fetch_one(
"SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?",
(key, window_start.isoformat())
)
count = result["count"] if result else 0
info = {
"limit": limit,
"remaining": max(0, limit - count - 1),
"reset": int((window_start + timedelta(seconds=window)).timestamp()),
}
if count >= limit:
return False, info
# Record this request
await execute(
"INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)",
(key, now.isoformat())
)
return True, info
def rate_limit(limit: int = None, window: int = None, key_func=None):
"""Decorator for rate limiting routes."""
def decorator(f):
@wraps(f)
async def decorated(*args, **kwargs):
if key_func:
key = key_func()
else:
key = f"ip:{request.remote_addr}"
allowed, info = await check_rate_limit(key, limit, window)
if not allowed:
response = {"error": "Rate limit exceeded", **info}
return response, 429
return await f(*args, **kwargs)
return decorated
return decorator
# =============================================================================
# Request ID Tracking
# =============================================================================
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
def get_request_id() -> str:
"""Get current request ID."""
return request_id_var.get()
def setup_request_id(app):
"""Setup request ID middleware."""
@app.before_request
async def set_request_id():
rid = request.headers.get("X-Request-ID") or secrets.token_hex(8)
request_id_var.set(rid)
g.request_id = rid
@app.after_request
async def add_request_id_header(response):
response.headers["X-Request-ID"] = get_request_id()
return response
# =============================================================================
# Webhook Signature Verification
# =============================================================================
def verify_hmac_signature(payload: bytes, signature: str, secret: str) -> bool:
"""Verify HMAC-SHA256 webhook signature."""
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
# =============================================================================
# Soft Delete Helpers
# =============================================================================
async def soft_delete(table: str, id: int) -> bool:
"""Mark record as deleted."""
result = await execute(
f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL",
(datetime.utcnow().isoformat(), id)
)
return result > 0
async def restore(table: str, id: int) -> bool:
"""Restore soft-deleted record."""
result = await execute(
f"UPDATE {table} SET deleted_at = NULL WHERE id = ?",
(id,)
)
return result > 0
async def hard_delete(table: str, id: int) -> bool:
"""Permanently delete record."""
result = await execute(f"DELETE FROM {table} WHERE id = ?", (id,))
return result > 0
async def purge_deleted(table: str, days: int = 30) -> int:
"""Purge records deleted more than X days ago."""
cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
return await execute(
f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?",
(cutoff,)
)

View File

@@ -0,0 +1,246 @@
"""
Dashboard domain: coffee analytics dashboard, settings, API keys.
"""
import asyncio
import hashlib
import secrets
from datetime import datetime
from pathlib import Path
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, url_for
from .. import analytics
from ..auth.routes import get_user_with_subscription, login_required, update_user
from ..billing.routes import get_subscription
from ..core import csrf_protect, execute, fetch_all, fetch_one, soft_delete
# Blueprint with its own template folder
bp = Blueprint(
"dashboard",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
url_prefix="/dashboard",
)
# =============================================================================
# SQL Queries
# =============================================================================
async def get_user_stats(user_id: int) -> dict:
"""Get dashboard stats for user."""
api_calls = await fetch_one(
"""
SELECT COUNT(*) as count FROM api_requests
WHERE user_id = ? AND created_at > date('now', '-30 days')
""",
(user_id,),
)
return {
"api_calls": api_calls["count"] if api_calls else 0,
}
# API Key queries
async def create_api_key(user_id: int, name: str, scopes: list[str] = None) -> tuple[str, int]:
"""Create API key. Returns (raw_key, key_id) - raw_key shown only once."""
raw_key = f"sk_{secrets.token_urlsafe(32)}"
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
prefix = raw_key[:12]
now = datetime.utcnow().isoformat()
key_id = await execute(
"""
INSERT INTO api_keys (user_id, name, key_hash, key_prefix, scopes, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(user_id, name, key_hash, prefix, ",".join(scopes or ["read"]), now),
)
return raw_key, key_id
async def get_user_api_keys(user_id: int) -> list[dict]:
"""Get all API keys for user (without hashes)."""
return await fetch_all(
"""
SELECT id, name, key_prefix, scopes, created_at, last_used_at
FROM api_keys
WHERE user_id = ? AND deleted_at IS NULL
ORDER BY created_at DESC
""",
(user_id,),
)
async def delete_api_key(key_id: int, user_id: int) -> bool:
"""Delete API key (soft delete)."""
result = await execute(
"""
UPDATE api_keys
SET deleted_at = ?
WHERE id = ? AND user_id = ? AND deleted_at IS NULL
""",
(datetime.utcnow().isoformat(), key_id, user_id),
)
return result > 0
# =============================================================================
# Routes
# =============================================================================
@bp.route("/")
@login_required
async def index():
"""Coffee analytics dashboard."""
user = await get_user_with_subscription(g.user["id"])
stats = await get_user_stats(g.user["id"])
plan = user.get("plan") or "free"
# Fetch all analytics data in parallel
time_series, top_producers, stu_trend, balance, yoy = await asyncio.gather(
analytics.get_global_time_series(
analytics.COFFEE_COMMODITY_CODE,
["Production", "Exports", "Imports", "Ending_Stocks", "Total_Distribution"],
),
analytics.get_top_countries(analytics.COFFEE_COMMODITY_CODE, "Production", limit=10),
analytics.get_stock_to_use_trend(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),
)
# Latest global snapshot for key metric cards
latest = time_series[-1] if time_series else {}
# Apply free plan history limit (last 5 years)
if plan == "free" and time_series:
max_year = time_series[-1]["market_year"]
cutoff_year = max_year - 5
time_series = [r for r in time_series if r["market_year"] >= cutoff_year]
stu_trend = [r for r in stu_trend if r["market_year"] >= cutoff_year]
balance = [r for r in balance if r["market_year"] >= cutoff_year]
return await render_template(
"index.html",
user=user,
stats=stats,
plan=plan,
latest=latest,
time_series=time_series,
top_producers=top_producers,
stu_trend=stu_trend,
balance=balance,
yoy=yoy,
)
@bp.route("/countries")
@login_required
async def countries():
"""Country comparison page."""
user = await get_user_with_subscription(g.user["id"])
plan = user.get("plan") or "free"
# Get available countries for coffee
all_countries = await analytics.get_top_countries(analytics.COFFEE_COMMODITY_CODE, "Production", limit=50)
# Parse query params
selected_codes = request.args.getlist("country")
metric = request.args.get("metric", "Production")
comparison_data = []
if selected_codes:
selected_codes = selected_codes[:10] # cap at 10
comparison_data = await analytics.get_country_comparison(
analytics.COFFEE_COMMODITY_CODE, selected_codes, metric
)
# HTMX partial: return just the chart data as JSON
if request.headers.get("HX-Request"):
return jsonify({"data": comparison_data, "metric": metric})
return await render_template(
"countries.html",
user=user,
plan=plan,
all_countries=all_countries,
selected_codes=selected_codes,
metric=metric,
comparison_data=comparison_data,
)
@bp.route("/settings", methods=["GET", "POST"])
@login_required
@csrf_protect
async def settings():
"""User settings page."""
if request.method == "POST":
form = await request.form
# Update user settings
await update_user(
g.user["id"],
name=form.get("name", "").strip() or None,
updated_at=datetime.utcnow().isoformat(),
)
await flash("Settings saved!", "success")
return redirect(url_for("dashboard.settings"))
user = await get_user_with_subscription(g.user["id"])
subscription = await get_subscription(g.user["id"])
api_keys = await get_user_api_keys(g.user["id"])
return await render_template(
"settings.html",
user=user,
subscription=subscription,
api_keys=api_keys,
)
@bp.route("/api-keys", methods=["POST"])
@login_required
@csrf_protect
async def create_key():
"""Create new API key."""
form = await request.form
name = form.get("name", "").strip() or "Untitled Key"
scopes = form.getlist("scopes") or ["read"]
raw_key, key_id = await create_api_key(g.user["id"], name, scopes)
await flash(f"API key created! Copy it now, it won't be shown again: {raw_key}", "success")
return redirect(url_for("dashboard.settings") + "#api-keys")
@bp.route("/api-keys/<int:key_id>/delete", methods=["POST"])
@login_required
@csrf_protect
async def delete_key(key_id: int):
"""Delete API key."""
success = await delete_api_key(key_id, g.user["id"])
if success:
await flash("API key deleted.", "success")
else:
await flash("Could not delete API key.", "error")
return redirect(url_for("dashboard.settings") + "#api-keys")
@bp.route("/delete-account", methods=["POST"])
@login_required
@csrf_protect
async def delete_account():
"""Delete user account (soft delete)."""
from quart import session
await soft_delete("users", g.user["id"])
session.clear()
await flash("Your account has been deleted.", "info")
return redirect(url_for("public.landing"))

View File

@@ -0,0 +1,101 @@
{% extends "base.html" %}
{% block title %}Country Comparison - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
{% endblock %}
{% block content %}
<main class="container">
<header>
<h1>Country Comparison</h1>
<p>Compare coffee metrics across producing and consuming countries.</p>
</header>
<!-- Filters -->
<form id="country-form" method="get" action="{{ url_for('dashboard.countries') }}">
<div class="grid">
<label>
Metric
<select name="metric" onchange="this.form.submit()">
{% for m in ["Production", "Exports", "Imports", "Ending_Stocks"] %}
<option value="{{ m }}" {{ "selected" if metric == m }}>{{ m | replace("_", " ") }}</option>
{% endfor %}
</select>
</label>
<label>
Countries (select up to 10)
<select name="country" multiple size="8" onchange="this.form.submit()">
{% for c in all_countries %}
<option value="{{ c.country_code }}" {{ "selected" if c.country_code in selected_codes }}>
{{ c.country_name }}
</option>
{% endfor %}
</select>
</label>
</div>
</form>
<!-- Chart -->
{% if comparison_data %}
<section>
<canvas id="comparisonChart" style="max-height: 500px;"></canvas>
</section>
{% else %}
<article style="text-align: center; color: var(--muted-color);">
<p>Select countries above to see the comparison chart.</p>
</article>
{% endif %}
<a href="{{ url_for('dashboard.index') }}" role="button" class="secondary outline">Back to Dashboard</a>
</main>
{% endblock %}
{% block scripts %}
<script>
const COLORS = [
'#2563eb', '#dc2626', '#16a34a', '#ca8a04', '#9333ea',
'#0891b2', '#e11d48', '#65a30d', '#d97706', '#7c3aed'
];
const rawData = {{ comparison_data | tojson }};
const metric = {{ metric | tojson }};
if (rawData.length > 0) {
// Group by country
const byCountry = {};
for (const row of rawData) {
if (!byCountry[row.country_name]) byCountry[row.country_name] = [];
byCountry[row.country_name].push(row);
}
// Collect all years
const allYears = [...new Set(rawData.map(r => r.market_year))].sort();
const datasets = Object.entries(byCountry).map(([name, rows], i) => {
const yearMap = Object.fromEntries(rows.map(r => [r.market_year, r[metric]]));
return {
label: name,
data: allYears.map(y => yearMap[y] ?? null),
borderColor: COLORS[i % COLORS.length],
tension: 0.3,
spanGaps: true
};
});
new Chart(document.getElementById('comparisonChart'), {
type: 'line',
data: { labels: allYears, datasets },
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' },
title: { display: true, text: metric.replace(/_/g, ' ') + ' by Country' }
},
scales: { y: { beginAtZero: false } }
}
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,211 @@
{% extends "base.html" %}
{% block title %}Dashboard - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
{% endblock %}
{% block content %}
<main class="container">
<header>
<h1>Coffee Dashboard</h1>
<p>Welcome back{% if user.name %}, {{ user.name }}{% endif %}! Global coffee market data from USDA PSD.</p>
</header>
<!-- Key Metric Cards -->
<div class="grid">
<article>
<header><small>Global Production (latest year)</small></header>
<p style="font-size: 2rem; margin: 0;">
<strong>{{ "{:,.0f}".format(latest.get("Production", 0)) }}</strong>
</p>
<small>1,000 60-kg bags</small>
</article>
<article>
<header><small>Stock-to-Use Ratio</small></header>
<p style="font-size: 2rem; margin: 0;">
{% if stu_trend %}
<strong>{{ "{:.1f}".format(stu_trend[-1].get("Stock_to_Use_Ratio_pct", 0)) }}%</strong>
{% else %}
<strong>--</strong>
{% endif %}
</p>
<small>Ending stocks / consumption</small>
</article>
<article>
<header><small>Trade Balance</small></header>
<p style="font-size: 2rem; margin: 0;">
<strong>{{ "{:,.0f}".format(latest.get("Exports", 0) - latest.get("Imports", 0)) }}</strong>
</p>
<small>Exports minus imports</small>
</article>
<article>
<header><small>Your Plan</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ plan | title }}</strong></p>
<small>
{% if plan == "free" %}
<a href="{{ url_for('billing.pricing') }}">Upgrade for full history</a>
{% else %}
{{ stats.api_calls }} API calls (30d)
{% endif %}
</small>
</article>
</div>
<!-- Global Supply/Demand Time Series -->
<section>
<h2>Global Supply &amp; Demand</h2>
{% if plan == "free" %}
<p><small>Showing last 5 years. <a href="{{ url_for('billing.pricing') }}">Upgrade</a> for full 18+ year history.</small></p>
{% endif %}
<canvas id="supplyDemandChart" style="max-height: 400px;"></canvas>
</section>
<!-- Stock-to-Use Ratio -->
<section>
<h2>Stock-to-Use Ratio Trend</h2>
<canvas id="stuChart" style="max-height: 300px;"></canvas>
</section>
<!-- Two-column: Top Producers + YoY Table -->
<div class="grid">
<section>
<h2>Top Producing Countries</h2>
<canvas id="topProducersChart" style="max-height: 400px;"></canvas>
</section>
<section>
<h2>Year-over-Year Production Change</h2>
<div style="overflow-x: auto;">
<table>
<thead>
<tr>
<th>Country</th>
<th style="text-align: right;">Production</th>
<th style="text-align: right;">YoY %</th>
</tr>
</thead>
<tbody>
{% for row in yoy %}
<tr>
<td>{{ row.country_name }}</td>
<td style="text-align: right;">{{ "{:,.0f}".format(row.Production) }}</td>
<td style="text-align: right; color: {{ 'var(--ins-color)' if row.Production_YoY_pct and row.Production_YoY_pct > 0 else 'var(--del-color)' if row.Production_YoY_pct and row.Production_YoY_pct < 0 else 'inherit' }};">
{% if row.Production_YoY_pct is not none %}
{{ "{:+.1f}%".format(row.Production_YoY_pct) }}
{% else %}
--
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
<!-- CSV Export (plan-gated) -->
{% if plan != "free" %}
<section>
<a href="{{ url_for('api.commodity_metrics_csv', code=711100) }}" role="button" class="secondary outline">Export CSV</a>
</section>
{% else %}
<section>
<p><small>CSV export available on Starter and Pro plans. <a href="{{ url_for('billing.pricing') }}">Upgrade</a></small></p>
</section>
{% endif %}
<!-- Quick Actions -->
<section>
<div class="grid">
<a href="{{ url_for('dashboard.countries') }}" role="button" class="secondary outline">Country Comparison</a>
<a href="{{ url_for('dashboard.settings') }}" role="button" class="secondary outline">Settings</a>
<a href="{{ url_for('dashboard.settings') }}#api-keys" role="button" class="secondary outline">API Keys</a>
</div>
</section>
</main>
{% endblock %}
{% block scripts %}
<script>
// Chart colors
const COLORS = [
'#2563eb', '#dc2626', '#16a34a', '#ca8a04', '#9333ea',
'#0891b2', '#e11d48', '#65a30d', '#d97706', '#7c3aed'
];
// -- Supply/Demand Chart --
const tsData = {{ time_series | tojson }};
if (tsData.length > 0) {
new Chart(document.getElementById('supplyDemandChart'), {
type: 'line',
data: {
labels: tsData.map(r => r.market_year),
datasets: [
{label: 'Production', data: tsData.map(r => r.Production), borderColor: COLORS[0], tension: 0.3},
{label: 'Exports', data: tsData.map(r => r.Exports), borderColor: COLORS[1], tension: 0.3},
{label: 'Imports', data: tsData.map(r => r.Imports), borderColor: COLORS[2], tension: 0.3},
{label: 'Ending Stocks', data: tsData.map(r => r.Ending_Stocks), borderColor: COLORS[3], tension: 0.3},
{label: 'Total Distribution', data: tsData.map(r => r.Total_Distribution), borderColor: COLORS[4], tension: 0.3},
]
},
options: {
responsive: true,
plugins: {legend: {position: 'bottom'}},
scales: {y: {beginAtZero: false}}
}
});
}
// -- Stock-to-Use Chart --
const stuData = {{ stu_trend | tojson }};
if (stuData.length > 0) {
new Chart(document.getElementById('stuChart'), {
type: 'line',
data: {
labels: stuData.map(r => r.market_year),
datasets: [{
label: 'Stock-to-Use Ratio (%)',
data: stuData.map(r => r.Stock_to_Use_Ratio_pct),
borderColor: COLORS[0],
backgroundColor: 'rgba(37,99,235,0.1)',
fill: true,
tension: 0.3
}]
},
options: {
responsive: true,
plugins: {legend: {display: false}},
scales: {y: {beginAtZero: false}}
}
});
}
// -- Top Producers Horizontal Bar --
const topData = {{ top_producers | tojson }};
if (topData.length > 0) {
new Chart(document.getElementById('topProducersChart'), {
type: 'bar',
data: {
labels: topData.map(r => r.country_name),
datasets: [{
label: 'Production',
data: topData.map(r => r.Production),
backgroundColor: COLORS[0]
}]
},
options: {
indexAxis: 'y',
responsive: true,
plugins: {legend: {display: false}},
scales: {x: {beginAtZero: true}}
}
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,155 @@
{% extends "base.html" %}
{% block title %}Settings - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header>
<h1>Settings</h1>
</header>
<!-- Profile Section -->
<section>
<h2>Profile</h2>
<article>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="email">
Email
<input type="email" id="email" value="{{ user.email }}" disabled>
<small>Email cannot be changed</small>
</label>
<label for="name">
Name
<input type="text" id="name" name="name" value="{{ user.name or '' }}" placeholder="Your name">
</label>
<button type="submit">Save Changes</button>
</form>
</article>
</section>
<!-- Subscription Section -->
<section>
<h2>Subscription</h2>
<article>
<div class="grid">
<div>
<strong>Current Plan:</strong> {{ (user.plan or 'free') | title }}
</div>
<div>
<strong>Status:</strong> {{ (user.sub_status or 'active') | title }}
</div>
{% if user.current_period_end %}
<div>
<strong>Renews:</strong> {{ user.current_period_end[:10] }}
</div>
{% endif %}
</div>
<div style="margin-top: 1rem;">
{% if subscription %}
<form method="post" action="{{ url_for('billing.portal') }}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary">Manage Subscription</button>
</form>
{% else %}
<a href="{{ url_for('billing.pricing') }}" role="button">Upgrade Plan</a>
{% endif %}
</div>
</article>
</section>
<!-- API Keys Section -->
<section id="api-keys">
<h2>API Keys</h2>
<article>
<p>API keys allow you to access the API programmatically.</p>
{% if api_keys %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Key</th>
<th>Scopes</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for key in api_keys %}
<tr>
<td>{{ key.name }}</td>
<td><code>{{ key.key_prefix }}...</code></td>
<td>{{ key.scopes }}</td>
<td>{{ key.created_at[:10] }}</td>
<td>
<form method="post" action="{{ url_for('dashboard.delete_key', key_id=key.id) }}" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary outline" style="padding: 0.25rem 0.5rem; margin: 0;">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p><em>No API keys yet.</em></p>
{% endif %}
<details>
<summary>Create New API Key</summary>
<form method="post" action="{{ url_for('dashboard.create_key') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="key-name">
Key Name
<input type="text" id="key-name" name="name" placeholder="My API Key" required>
</label>
<fieldset>
<legend>Scopes</legend>
<label>
<input type="checkbox" name="scopes" value="read" checked>
Read
</label>
<label>
<input type="checkbox" name="scopes" value="write">
Write
</label>
</fieldset>
<button type="submit">Create Key</button>
</form>
</details>
</article>
</section>
<!-- Danger Zone -->
<section>
<h2>Danger Zone</h2>
<article style="border-color: var(--del-color);">
<p>Once you delete your account, there is no going back. Please be certain.</p>
<details>
<summary role="button" class="secondary outline" style="--pico-color: var(--del-color);">Delete Account</summary>
<p>Are you sure? This will:</p>
<ul>
<li>Delete all your data</li>
<li>Cancel your subscription</li>
<li>Remove your API keys</li>
</ul>
<form method="post" action="{{ url_for('dashboard.delete_account') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary" style="--pico-background-color: var(--del-color);">
Yes, Delete My Account
</button>
</form>
</details>
</article>
</section>
</main>
{% endblock %}

View File

@@ -0,0 +1,53 @@
"""
Simple migration runner. Runs schema.sql against the database.
"""
import sqlite3
from pathlib import Path
import os
import sys
# Add parent to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from dotenv import load_dotenv
load_dotenv()
def migrate():
"""Run migrations."""
# Get database path from env or default
db_path = os.getenv("DATABASE_PATH", "data/app.db")
# Ensure directory exists
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
# Read schema
schema_path = Path(__file__).parent / "schema.sql"
schema = schema_path.read_text()
# Connect and execute
conn = sqlite3.connect(db_path)
# Enable WAL mode
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
# Run schema
conn.executescript(schema)
conn.commit()
print(f"✓ Migrations complete: {db_path}")
# Show tables
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
)
tables = [row[0] for row in cursor.fetchall()]
print(f" Tables: {', '.join(tables)}")
conn.close()
if __name__ == "__main__":
migrate()

View File

@@ -0,0 +1,101 @@
-- BeanFlows Database Schema
-- Run with: python -m beanflows.migrations.migrate
-- Users
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT,
created_at TEXT NOT NULL,
updated_at TEXT,
last_login_at TEXT,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_deleted ON users(deleted_at);
-- Auth Tokens (magic links)
CREATE TABLE IF NOT EXISTS auth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
token TEXT UNIQUE NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_token ON auth_tokens(token);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
-- Subscriptions
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
plan TEXT NOT NULL DEFAULT 'free',
status TEXT NOT NULL DEFAULT 'free',
paddle_customer_id TEXT,
paddle_subscription_id TEXT,
current_period_end TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_provider ON subscriptions(paddle_subscription_id);
-- API Keys
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
key_hash TEXT UNIQUE NOT NULL,
key_prefix TEXT NOT NULL,
scopes TEXT DEFAULT 'read',
created_at TEXT NOT NULL,
last_used_at TEXT,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id);
-- API Request Log
CREATE TABLE IF NOT EXISTS api_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
endpoint TEXT NOT NULL,
method TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_api_requests_user ON api_requests(user_id);
CREATE INDEX IF NOT EXISTS idx_api_requests_date ON api_requests(created_at);
-- Rate Limits
CREATE TABLE IF NOT EXISTS rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
timestamp TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rate_limits_key ON rate_limits(key, timestamp);
-- Background Tasks
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_name TEXT NOT NULL,
payload TEXT,
status TEXT NOT NULL DEFAULT 'pending',
run_at TEXT NOT NULL,
retries INTEGER DEFAULT 0,
error TEXT,
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status, run_at);

View File

@@ -0,0 +1,45 @@
"""
Public domain: landing page, marketing pages, legal pages.
"""
from pathlib import Path
from quart import Blueprint, render_template
from ..core import config
# 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")

View File

@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}About - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 800px; margin: 0 auto;">
<header style="text-align: center;">
<h1>About {{ config.APP_NAME }}</h1>
</header>
<section>
<p>{{ config.APP_NAME }} was built with a simple philosophy: ship fast, stay simple.</p>
<p>Too many SaaS boilerplates are over-engineered. They use PostgreSQL when SQLite would do. They add Redis for a job queue that runs 10 tasks a day. They have 50 npm dependencies for a landing page.</p>
<p>We took a different approach:</p>
<ul>
<li><strong>SQLite for everything</strong> It handles more than you think.</li>
<li><strong>Server-rendered HTML</strong> No build step, no hydration, no complexity.</li>
<li><strong>Minimal dependencies</strong> Fewer things to break.</li>
<li><strong>Flat structure</strong> Find things where you expect them.</li>
</ul>
<p>The result is a codebase you can understand in an afternoon and deploy for $5/month.</p>
</section>
<section style="text-align: center; margin-top: 3rem;">
<a href="{{ url_for('auth.signup') }}" role="button">Get Started</a>
</section>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}Features - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header style="text-align: center; margin-bottom: 3rem;">
<h1>Features</h1>
<p>Coffee market intelligence built on USDA Production, Supply &amp; Distribution data.</p>
</header>
<section>
<article>
<h2>Supply &amp; Demand Dashboard</h2>
<p>Interactive charts showing global coffee production, exports, imports, ending stocks, and total distribution by market year. Spot surplus and deficit years at a glance.</p>
<ul>
<li>18+ years of historical data (2006&ndash;present)</li>
<li>Line charts for production, trade, and consumption trends</li>
<li>Key metric cards for quick orientation</li>
<li>Auto-refreshed daily from USDA PSD Online</li>
</ul>
</article>
<article>
<h2>Country Analysis &amp; Comparison</h2>
<p>Rank the world's coffee producers and consumers. Compare up to 10 countries side-by-side on any metric.</p>
<ul>
<li>Top-N country rankings (production, exports, imports, stocks)</li>
<li>Year-over-year production change table with directional coloring</li>
<li>Multi-country overlay charts</li>
<li>65 commodity-country combinations from USDA data</li>
</ul>
</article>
<article>
<h2>Stock-to-Use Ratio</h2>
<p>The ratio traders watch most closely. Track the global coffee stock-to-use ratio over time to gauge market tightness and anticipate price moves.</p>
<ul>
<li>Global ratio trend chart</li>
<li>Ending stocks vs. total distribution breakdown</li>
<li>Historical context spanning two decades</li>
</ul>
</article>
<article>
<h2>Data Export &amp; API</h2>
<p>Download CSV files or integrate directly with your trading systems via REST API.</p>
<ul>
<li>CSV export of any metric series</li>
<li>RESTful JSON API with Bearer token auth</li>
<li>Rate-limited and logged for security</li>
<li>Commodity listing, time series, and country endpoints</li>
</ul>
</article>
<article>
<h2>Daily Data Pipeline</h2>
<p>Our pipeline extracts data from the USDA PSD Online database, transforms it through a 4-layer SQL pipeline (raw &rarr; staging &rarr; cleaned &rarr; serving), and delivers analytics-ready metrics every day.</p>
<ul>
<li>Automated daily extraction from USDA</li>
<li>SQLMesh + DuckDB transformation pipeline</li>
<li>Incremental processing (only new data each day)</li>
<li>Auditable data lineage</li>
</ul>
</article>
</section>
<section style="text-align: center; margin-top: 3rem;">
<a href="{{ url_for('auth.signup') }}" role="button">Start Free</a>
<a href="{{ url_for('billing.pricing') }}" role="button" class="secondary outline" style="margin-left: 1rem;">View Pricing</a>
</section>
</main>
{% endblock %}

View File

@@ -0,0 +1,91 @@
{% extends "base.html" %}
{% block title %}{{ config.APP_NAME }} - Coffee Market Intelligence for Independent Traders{% endblock %}
{% block content %}
<main class="container">
<!-- Hero -->
<header style="text-align: center; padding: 4rem 0;">
<h1>Coffee Market Intelligence<br>for Independent Traders</h1>
<p style="font-size: 1.25rem; max-width: 640px; margin: 0 auto;">
Track global supply and demand, compare producing countries, and spot trends
with 18+ years of USDA data. No expensive terminal required.
</p>
<div style="margin-top: 2rem;">
<a href="{{ url_for('auth.signup') }}" role="button" style="margin-right: 1rem;">Start Free</a>
<a href="{{ url_for('public.features') }}" role="button" class="secondary outline">See Features</a>
</div>
</header>
<!-- Value Props -->
<section style="padding: 4rem 0;">
<h2 style="text-align: center;">What You Get</h2>
<div class="grid">
<article>
<h3>Supply &amp; Demand Charts</h3>
<p>Global production, exports, imports, ending stocks, and consumption visualized by market year.</p>
</article>
<article>
<h3>Country Analysis</h3>
<p>Compare up to 10 producing countries side-by-side. See who's growing, who's shrinking.</p>
</article>
<article>
<h3>Stock-to-Use Ratio</h3>
<p>The key indicator traders watch. Track the global ratio over time to gauge tightness.</p>
</article>
</div>
<div class="grid">
<article>
<h3>CSV &amp; API Export</h3>
<p>Download data for your own models. Integrate with your trading tools via REST API.</p>
</article>
<article>
<h3>Daily Refresh</h3>
<p>Data pipeline runs daily against USDA PSD Online. Always current, always reliable.</p>
</article>
<article>
<h3>No Lock-in</h3>
<p>Public USDA data, open methodology. You own your exports. Cancel anytime.</p>
</article>
</div>
</section>
<!-- How It Works -->
<section style="background: var(--card-background-color); border-radius: var(--border-radius); padding: 2rem;">
<h2 style="text-align: center;">How It Works</h2>
<div class="grid">
<div style="text-align: center;">
<p style="font-size: 2rem;">1</p>
<h4>Sign Up</h4>
<p>Enter your email, click the magic link. No password needed.</p>
</div>
<div style="text-align: center;">
<p style="font-size: 2rem;">2</p>
<h4>Explore the Dashboard</h4>
<p>Instant access to coffee supply/demand charts and country rankings.</p>
</div>
<div style="text-align: center;">
<p style="font-size: 2rem;">3</p>
<h4>Go Deeper</h4>
<p>Upgrade for full history, CSV exports, and API access for your own models.</p>
</div>
</div>
</section>
<!-- CTA -->
<section style="text-align: center; padding: 4rem 0;">
<h2>Ready to See the Data?</h2>
<p>Free plan includes the last 5 years of coffee market data. No credit card required.</p>
<a href="{{ url_for('auth.signup') }}" role="button">Start Free</a>
</section>
</main>
{% endblock %}

View File

@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}Privacy Policy - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 800px; margin: 0 auto;">
<header>
<h1>Privacy Policy</h1>
<p><small>Last updated: January 2024</small></p>
</header>
<section>
<h2>1. Information We Collect</h2>
<p>We collect information you provide directly:</p>
<ul>
<li>Email address (required for account creation)</li>
<li>Name (optional)</li>
<li>Payment information (processed by Stripe)</li>
</ul>
<p>We automatically collect:</p>
<ul>
<li>IP address</li>
<li>Browser type</li>
<li>Usage data</li>
</ul>
</section>
<section>
<h2>2. How We Use Information</h2>
<p>We use your information to:</p>
<ul>
<li>Provide and maintain the service</li>
<li>Process payments</li>
<li>Send transactional emails</li>
<li>Improve the service</li>
<li>Respond to support requests</li>
</ul>
</section>
<section>
<h2>3. Information Sharing</h2>
<p>We do not sell your personal information. We may share information with:</p>
<ul>
<li>Service providers (Stripe for payments, Resend for email)</li>
<li>Law enforcement when required by law</li>
</ul>
</section>
<section>
<h2>4. Data Retention</h2>
<p>We retain your data as long as your account is active. Upon deletion, we remove your data within 30 days.</p>
</section>
<section>
<h2>5. Security</h2>
<p>We implement industry-standard security measures including encryption, secure sessions, and regular backups.</p>
</section>
<section>
<h2>6. Cookies</h2>
<p>We use essential cookies for session management. We do not use tracking or advertising cookies.</p>
</section>
<section>
<h2>7. Your Rights</h2>
<p>You have the right to:</p>
<ul>
<li>Access your data</li>
<li>Correct inaccurate data</li>
<li>Delete your account and data</li>
<li>Export your data</li>
</ul>
</section>
<section>
<h2>8. GDPR Compliance</h2>
<p>For EU users: We process data based on consent and legitimate interest. You may contact us to exercise your GDPR rights.</p>
</section>
<section>
<h2>9. Changes</h2>
<p>We may update this policy. We will notify you of significant changes via email.</p>
</section>
<section>
<h2>10. Contact</h2>
<p>For privacy inquiries: {{ config.EMAIL_FROM }}</p>
</section>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Terms of Service - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 800px; margin: 0 auto;">
<header>
<h1>Terms of Service</h1>
<p><small>Last updated: January 2024</small></p>
</header>
<section>
<h2>1. Acceptance of Terms</h2>
<p>By accessing or using {{ config.APP_NAME }}, you agree to be bound by these Terms of Service. If you do not agree, do not use the service.</p>
</section>
<section>
<h2>2. Description of Service</h2>
<p>{{ config.APP_NAME }} provides a software-as-a-service platform. Features and functionality may change over time.</p>
</section>
<section>
<h2>3. User Accounts</h2>
<p>You are responsible for maintaining the security of your account. You must provide accurate information and keep it updated.</p>
</section>
<section>
<h2>4. Acceptable Use</h2>
<p>You agree not to:</p>
<ul>
<li>Violate any laws or regulations</li>
<li>Infringe on intellectual property rights</li>
<li>Transmit harmful code or malware</li>
<li>Attempt to gain unauthorized access</li>
<li>Interfere with service operation</li>
</ul>
</section>
<section>
<h2>5. Payment Terms</h2>
<p>Paid plans are billed in advance. Refunds are handled on a case-by-case basis. We may change pricing with 30 days notice.</p>
</section>
<section>
<h2>6. Termination</h2>
<p>We may terminate or suspend your account for violations of these terms. You may cancel your account at any time.</p>
</section>
<section>
<h2>7. Disclaimer of Warranties</h2>
<p>The service is provided "as is" without warranties of any kind. We do not guarantee uninterrupted or error-free operation.</p>
</section>
<section>
<h2>8. Limitation of Liability</h2>
<p>We shall not be liable for any indirect, incidental, special, or consequential damages arising from use of the service.</p>
</section>
<section>
<h2>9. Changes to Terms</h2>
<p>We may modify these terms at any time. Continued use after changes constitutes acceptance of the new terms.</p>
</section>
<section>
<h2>10. Contact</h2>
<p>For questions about these terms, please contact us at {{ config.EMAIL_FROM }}.</p>
</section>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,40 @@
/* BeanFlows Custom Styles */
article {
margin-bottom: 1.5rem;
}
code {
background: var(--code-background-color);
padding: 0.125rem 0.25rem;
border-radius: var(--border-radius);
}
table {
width: 100%;
}
/* HTMX loading indicators */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}
/* Dashboard chart sections */
section canvas {
width: 100% !important;
}
/* Key metric cards */
article header small {
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted-color);
}

View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ config.APP_NAME }}{% endblock %}</title>
<!-- Pico CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<!-- Custom styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<!-- Navigation -->
<nav class="container">
<ul>
<li><a href="{{ url_for('public.landing') }}"><strong>{{ config.APP_NAME }}</strong></a></li>
</ul>
<ul>
<li><a href="{{ url_for('public.features') }}">Features</a></li>
<li><a href="{{ url_for('billing.pricing') }}">Pricing</a></li>
{% if user %}
<li><a href="{{ url_for('dashboard.index') }}">Dashboard</a></li>
{% if session.get('is_admin') %}
<li><a href="{{ url_for('admin.index') }}"><mark>Admin</mark></a></li>
{% endif %}
<li>
<form method="post" action="{{ url_for('auth.logout') }}" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline secondary" style="padding: 0.5rem 1rem; margin: 0;">Sign Out</button>
</form>
</li>
{% else %}
<li><a href="{{ url_for('auth.login') }}">Sign In</a></li>
<li><a href="{{ url_for('auth.signup') }}" role="button">Get Started</a></li>
{% endif %}
</ul>
</nav>
<!-- Flash messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="container">
{% for category, message in messages %}
<article
style="padding: 1rem; margin-bottom: 1rem;
{% if category == 'error' %}border-left: 4px solid var(--del-color);
{% elif category == 'success' %}border-left: 4px solid var(--ins-color);
{% elif category == 'warning' %}border-left: 4px solid var(--mark-background-color);
{% else %}border-left: 4px solid var(--primary);{% endif %}"
>
{{ message }}
</article>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
<!-- Footer -->
<footer class="container" style="margin-top: 4rem; padding: 2rem 0; border-top: 1px solid var(--muted-border-color);">
<div class="grid">
<div>
<strong>{{ config.APP_NAME }}</strong>
<p><small>Coffee market intelligence for independent traders.</small></p>
</div>
<div>
<strong>Product</strong>
<ul style="list-style: none; padding: 0;">
<li><a href="{{ url_for('public.features') }}">Features</a></li>
<li><a href="{{ url_for('billing.pricing') }}">Pricing</a></li>
<li><a href="{{ url_for('public.about') }}">About</a></li>
</ul>
</div>
<div>
<strong>Legal</strong>
<ul style="list-style: none; padding: 0;">
<li><a href="{{ url_for('public.terms') }}">Terms</a></li>
<li><a href="{{ url_for('public.privacy') }}">Privacy</a></li>
</ul>
</div>
</div>
<p style="text-align: center; margin-top: 2rem;">
<small>&copy; {{ now.year }} {{ config.APP_NAME }}. All rights reserved.</small>
</p>
</footer>
<!-- HTMX (optional) -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
{% block scripts %}{% endblock %}
</body>
</html>

238
web/src/beanflows/worker.py Normal file
View File

@@ -0,0 +1,238 @@
"""
Background task worker - SQLite-based queue (no Redis needed).
"""
import asyncio
import json
import traceback
from datetime import datetime, timedelta
from .core import config, init_db, fetch_one, fetch_all, execute, send_email
# Task handlers registry
HANDLERS: dict[str, callable] = {}
def task(name: str):
"""Decorator to register a task handler."""
def decorator(f):
HANDLERS[name] = f
return f
return decorator
# =============================================================================
# Task Queue Operations
# =============================================================================
async def enqueue(task_name: str, payload: dict = None, run_at: datetime = None) -> int:
"""Add a task to the queue."""
return await execute(
"""
INSERT INTO tasks (task_name, payload, status, run_at, created_at)
VALUES (?, ?, 'pending', ?, ?)
""",
(
task_name,
json.dumps(payload or {}),
(run_at or datetime.utcnow()).isoformat(),
datetime.utcnow().isoformat(),
)
)
async def get_pending_tasks(limit: int = 10) -> list[dict]:
"""Get pending tasks ready to run."""
now = datetime.utcnow().isoformat()
return await fetch_all(
"""
SELECT * FROM tasks
WHERE status = 'pending' AND run_at <= ?
ORDER BY run_at ASC
LIMIT ?
""",
(now, limit)
)
async def mark_complete(task_id: int) -> None:
"""Mark task as completed."""
await execute(
"UPDATE tasks SET status = 'complete', completed_at = ? WHERE id = ?",
(datetime.utcnow().isoformat(), task_id)
)
async def mark_failed(task_id: int, error: str, retries: int) -> None:
"""Mark task as failed, schedule retry if attempts remain."""
max_retries = 3
if retries < max_retries:
# Exponential backoff: 1min, 5min, 25min
delay = timedelta(minutes=5 ** retries)
run_at = datetime.utcnow() + delay
await execute(
"""
UPDATE tasks
SET status = 'pending', error = ?, retries = ?, run_at = ?
WHERE id = ?
""",
(error, retries + 1, run_at.isoformat(), task_id)
)
else:
await execute(
"UPDATE tasks SET status = 'failed', error = ? WHERE id = ?",
(error, task_id)
)
# =============================================================================
# Built-in Task Handlers
# =============================================================================
@task("send_email")
async def handle_send_email(payload: dict) -> None:
"""Send an email."""
await send_email(
to=payload["to"],
subject=payload["subject"],
html=payload["html"],
text=payload.get("text"),
)
@task("send_magic_link")
async def handle_send_magic_link(payload: dict) -> None:
"""Send magic link email."""
link = f"{config.BASE_URL}/auth/verify?token={payload['token']}"
html = f"""
<h2>Sign in to {config.APP_NAME}</h2>
<p>Click the link below to sign in:</p>
<p><a href="{link}">{link}</a></p>
<p>This link expires in {config.MAGIC_LINK_EXPIRY_MINUTES} minutes.</p>
<p>If you didn't request this, you can safely ignore this email.</p>
"""
await send_email(
to=payload["email"],
subject=f"Sign in to {config.APP_NAME}",
html=html,
)
@task("send_welcome")
async def handle_send_welcome(payload: dict) -> None:
"""Send welcome email to new user."""
html = f"""
<h2>Welcome to {config.APP_NAME}!</h2>
<p>Thanks for signing up. We're excited to have you.</p>
<p><a href="{config.BASE_URL}/dashboard">Go to your dashboard</a></p>
"""
await send_email(
to=payload["email"],
subject=f"Welcome to {config.APP_NAME}",
html=html,
)
@task("cleanup_expired_tokens")
async def handle_cleanup_tokens(payload: dict) -> None:
"""Clean up expired auth tokens."""
await execute(
"DELETE FROM auth_tokens WHERE expires_at < ?",
(datetime.utcnow().isoformat(),)
)
@task("cleanup_rate_limits")
async def handle_cleanup_rate_limits(payload: dict) -> None:
"""Clean up old rate limit entries."""
cutoff = (datetime.utcnow() - timedelta(hours=1)).isoformat()
await execute("DELETE FROM rate_limits WHERE timestamp < ?", (cutoff,))
@task("cleanup_old_tasks")
async def handle_cleanup_tasks(payload: dict) -> None:
"""Clean up completed/failed tasks older than 7 days."""
cutoff = (datetime.utcnow() - timedelta(days=7)).isoformat()
await execute(
"DELETE FROM tasks WHERE status IN ('complete', 'failed') AND created_at < ?",
(cutoff,)
)
# =============================================================================
# Worker Loop
# =============================================================================
async def process_task(task: dict) -> None:
"""Process a single task."""
task_name = task["task_name"]
task_id = task["id"]
retries = task.get("retries", 0)
handler = HANDLERS.get(task_name)
if not handler:
await mark_failed(task_id, f"Unknown task: {task_name}", retries)
return
try:
payload = json.loads(task["payload"]) if task["payload"] else {}
await handler(payload)
await mark_complete(task_id)
print(f"[WORKER] Completed: {task_name} (id={task_id})")
except Exception as e:
error = f"{e}\n{traceback.format_exc()}"
await mark_failed(task_id, error, retries)
print(f"[WORKER] Failed: {task_name} (id={task_id}): {e}")
async def run_worker(poll_interval: float = 1.0) -> None:
"""Main worker loop."""
print("[WORKER] Starting...")
await init_db()
while True:
try:
tasks = await get_pending_tasks(limit=10)
for task in tasks:
await process_task(task)
if not tasks:
await asyncio.sleep(poll_interval)
except Exception as e:
print(f"[WORKER] Error: {e}")
await asyncio.sleep(poll_interval * 5)
async def run_scheduler() -> None:
"""Schedule periodic cleanup tasks."""
print("[SCHEDULER] Starting...")
await init_db()
while True:
try:
# Schedule cleanup tasks every hour
await enqueue("cleanup_expired_tokens")
await enqueue("cleanup_rate_limits")
await enqueue("cleanup_old_tasks")
await asyncio.sleep(3600) # 1 hour
except Exception as e:
print(f"[SCHEDULER] Error: {e}")
await asyncio.sleep(60)
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "scheduler":
asyncio.run(run_scheduler())
else:
asyncio.run(run_worker())

247
web/tests/conftest.py Normal file
View File

@@ -0,0 +1,247 @@
"""
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
import aiosqlite
import pytest
from beanflows import analytics, core
from beanflows.app import create_app
SCHEMA_PATH = Path(__file__).parent.parent / "src" / "beanflows" / "migrations" / "schema.sql"
# ── Database ─────────────────────────────────────────────────
@pytest.fixture
async def db():
"""In-memory SQLite with full schema, patches core._db."""
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
await conn.execute("PRAGMA foreign_keys=ON")
schema = SCHEMA_PATH.read_text()
await conn.executescript(schema)
await conn.commit()
original_db = core._db
core._db = conn
yield conn
core._db = original_db
await conn.close()
# ── App & client ─────────────────────────────────────────────
@pytest.fixture
async def app(db):
"""Quart app with DB already initialized (init_db/close_db patched to no-op)."""
with patch.object(core, "init_db", new_callable=AsyncMock), \
patch.object(core, "close_db", new_callable=AsyncMock), \
patch.object(analytics, "open_analytics_db"), \
patch.object(analytics, "close_analytics_db"):
application = create_app()
application.config["TESTING"] = True
yield application
@pytest.fixture
async def client(app):
"""Unauthenticated test client."""
async with app.test_client() as c:
yield c
# ── Users ────────────────────────────────────────────────────
@pytest.fixture
async def test_user(db):
"""Create a test user, return dict with id/email/name."""
now = datetime.utcnow().isoformat()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("test@example.com", "Test User", now),
) as cursor:
user_id = cursor.lastrowid
await db.commit()
return {"id": user_id, "email": "test@example.com", "name": "Test User"}
@pytest.fixture
async def auth_client(app, test_user):
"""Test client with session['user_id'] pre-set."""
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = test_user["id"]
yield c
# ── Subscriptions ────────────────────────────────────────────
@pytest.fixture
def create_subscription(db):
"""Factory: create a subscription row for a user."""
async def _create(
user_id: int,
plan: str = "pro",
status: str = "active",
paddle_customer_id: str = "ctm_test123",
paddle_subscription_id: str = "sub_test456",
current_period_end: str = "2025-03-01T00:00:00Z",
) -> int:
now = datetime.utcnow().isoformat()
async with db.execute(
"""INSERT INTO subscriptions
(user_id, plan, status, paddle_customer_id,
paddle_subscription_id, current_period_end, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user_id, plan, status, paddle_customer_id, paddle_subscription_id,
current_period_end, now, now),
) as cursor:
sub_id = cursor.lastrowid
await db.commit()
return sub_id
return _create
# ── Config ───────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def patch_config():
"""Set test Paddle config values."""
original_values = {}
test_values = {
"PADDLE_API_KEY": "test_api_key_123",
"PADDLE_WEBHOOK_SECRET": "whsec_test_secret",
"PADDLE_PRICES": {"starter": "pri_starter_123", "pro": "pri_pro_456"},
"BASE_URL": "http://localhost:5000",
"DEBUG": True,
}
for key, val in test_values.items():
original_values[key] = getattr(core.config, key, None)
setattr(core.config, key, val)
yield
for key, val in original_values.items():
setattr(core.config, key, val)
# ── Webhook helpers ──────────────────────────────────────────
def make_webhook_payload(
event_type: str,
subscription_id: str = "sub_test456",
customer_id: str = "ctm_test123",
user_id: str = "1",
plan: str = "starter",
status: str = "active",
ends_at: str = "2025-03-01T00:00:00.000000Z",
) -> dict:
"""Build a Paddle webhook payload dict."""
return {
"event_type": event_type,
"data": {
"id": subscription_id,
"status": status,
"customer_id": customer_id,
"custom_data": {"user_id": user_id, "plan": plan},
"current_billing_period": {
"starts_at": "2025-02-01T00:00:00.000000Z",
"ends_at": ends_at,
},
},
}
def sign_payload(payload_bytes: bytes, secret: str = "whsec_test_secret") -> str:
"""Compute HMAC-SHA256 signature for a webhook payload."""
return hmac.new(secret.encode(), payload_bytes, hashlib.sha256).hexdigest()
# ── Analytics mock data ──────────────────────────────────────
MOCK_TIME_SERIES = [
{"market_year": 2018, "Production": 165000, "Exports": 115000, "Imports": 105000,
"Ending_Stocks": 33000, "Total_Distribution": 160000},
{"market_year": 2019, "Production": 168000, "Exports": 118000, "Imports": 108000,
"Ending_Stocks": 34000, "Total_Distribution": 163000},
{"market_year": 2020, "Production": 170000, "Exports": 120000, "Imports": 110000,
"Ending_Stocks": 35000, "Total_Distribution": 165000},
{"market_year": 2021, "Production": 175000, "Exports": 125000, "Imports": 115000,
"Ending_Stocks": 36000, "Total_Distribution": 170000},
{"market_year": 2022, "Production": 172000, "Exports": 122000, "Imports": 112000,
"Ending_Stocks": 34000, "Total_Distribution": 168000},
]
MOCK_TOP_COUNTRIES = [
{"country_name": "Brazil", "country_code": "BR", "market_year": 2022, "Production": 65000},
{"country_name": "Vietnam", "country_code": "VN", "market_year": 2022, "Production": 30000},
{"country_name": "Colombia", "country_code": "CO", "market_year": 2022, "Production": 14000},
]
MOCK_STU_TREND = [
{"market_year": 2020, "Stock_to_Use_Ratio_pct": 21.2},
{"market_year": 2021, "Stock_to_Use_Ratio_pct": 21.1},
{"market_year": 2022, "Stock_to_Use_Ratio_pct": 20.2},
]
MOCK_BALANCE = [
{"market_year": 2020, "Production": 170000, "Total_Distribution": 165000, "Supply_Demand_Balance": 5000},
{"market_year": 2021, "Production": 175000, "Total_Distribution": 170000, "Supply_Demand_Balance": 5000},
{"market_year": 2022, "Production": 172000, "Total_Distribution": 168000, "Supply_Demand_Balance": 4000},
]
MOCK_YOY = [
{"country_name": "Brazil", "country_code": "BR", "market_year": 2022,
"Production": 65000, "Production_YoY_pct": -3.5},
{"country_name": "Vietnam", "country_code": "VN", "market_year": 2022,
"Production": 30000, "Production_YoY_pct": 2.1},
]
MOCK_COMMODITIES = [
{"commodity_code": 711100, "commodity_name": "Coffee, Green"},
{"commodity_code": 222000, "commodity_name": "Soybeans"},
]
@pytest.fixture
def mock_analytics():
"""Patch all analytics query functions with mock data."""
with patch.object(analytics, "get_global_time_series", new_callable=AsyncMock,
return_value=MOCK_TIME_SERIES), \
patch.object(analytics, "get_top_countries", new_callable=AsyncMock,
return_value=MOCK_TOP_COUNTRIES), \
patch.object(analytics, "get_stock_to_use_trend", new_callable=AsyncMock,
return_value=MOCK_STU_TREND), \
patch.object(analytics, "get_supply_demand_balance", new_callable=AsyncMock,
return_value=MOCK_BALANCE), \
patch.object(analytics, "get_production_yoy_by_country", new_callable=AsyncMock,
return_value=MOCK_YOY), \
patch.object(analytics, "get_country_comparison", new_callable=AsyncMock,
return_value=[]), \
patch.object(analytics, "get_available_commodities", new_callable=AsyncMock,
return_value=MOCK_COMMODITIES), \
patch.object(analytics, "fetch_analytics", new_callable=AsyncMock,
return_value=[{"result": 1}]):
yield

View File

@@ -0,0 +1,129 @@
"""
Tests for the commodity analytics API endpoints.
"""
import hashlib
import secrets
from datetime import datetime
import pytest
async def _create_api_key_for_user(db, user_id, plan="starter"):
"""Helper: create an API key and subscription, return the raw key."""
raw_key = f"sk_{secrets.token_urlsafe(32)}"
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
now = datetime.utcnow().isoformat()
await db.execute(
"""INSERT INTO api_keys (user_id, name, key_hash, key_prefix, scopes, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(user_id, "test-key", key_hash, raw_key[:12], "read,write", now),
)
# Create subscription for plan
if plan != "free":
await db.execute(
"""INSERT OR REPLACE INTO subscriptions
(user_id, plan, status, created_at, updated_at)
VALUES (?, ?, 'active', ?, ?)""",
(user_id, plan, now, now),
)
await db.commit()
return raw_key
@pytest.mark.asyncio
async def test_api_requires_auth(client):
"""API returns 401 without auth header."""
response = await client.get("/api/v1/commodities")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_api_rejects_free_plan(client, db, test_user, mock_analytics):
"""API returns 403 for free plan users."""
raw_key = await _create_api_key_for_user(db, test_user["id"], plan="free")
response = await client.get(
"/api/v1/commodities",
headers={"Authorization": f"Bearer {raw_key}"},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_list_commodities(client, db, test_user, mock_analytics):
"""GET /commodities returns commodity list."""
raw_key = await _create_api_key_for_user(db, test_user["id"])
response = await client.get(
"/api/v1/commodities",
headers={"Authorization": f"Bearer {raw_key}"},
)
assert response.status_code == 200
data = await response.get_json()
assert "commodities" in data
assert len(data["commodities"]) == 2
@pytest.mark.asyncio
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",
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"]
@pytest.mark.asyncio
async def test_commodity_metrics_invalid_metric(client, db, test_user, mock_analytics):
"""GET /commodities/<code>/metrics rejects invalid metrics."""
raw_key = await _create_api_key_for_user(db, test_user["id"])
response = await client.get(
"/api/v1/commodities/711100/metrics?metrics=DROP_TABLE",
headers={"Authorization": f"Bearer {raw_key}"},
)
assert response.status_code == 400
@pytest.mark.asyncio
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",
headers={"Authorization": f"Bearer {raw_key}"},
)
assert response.status_code == 200
data = await response.get_json()
assert data["metric"] == "Production"
@pytest.mark.asyncio
async def test_commodity_csv_export(client, db, test_user, mock_analytics):
"""GET /commodities/<code>/metrics.csv returns CSV."""
raw_key = await _create_api_key_for_user(db, test_user["id"])
response = await client.get(
"/api/v1/commodities/711100/metrics.csv",
headers={"Authorization": f"Bearer {raw_key}"},
)
assert response.status_code == 200
assert "text/csv" in response.content_type
@pytest.mark.asyncio
async def test_me_endpoint(client, db, test_user, mock_analytics):
"""GET /me returns user info."""
raw_key = await _create_api_key_for_user(db, test_user["id"])
response = await client.get(
"/api/v1/me",
headers={"Authorization": f"Bearer {raw_key}"},
)
assert response.status_code == 200
data = await response.get_json()
assert data["email"] == "test@example.com"
assert data["plan"] == "starter"

View File

@@ -0,0 +1,325 @@
"""
Unit tests for billing SQL helpers, feature/limit access, and plan determination.
"""
import pytest
from hypothesis import HealthCheck, given
from hypothesis import settings as h_settings
from hypothesis import strategies as st
from beanflows.billing.routes import (
can_access_feature,
get_subscription,
get_subscription_by_provider_id,
is_within_limits,
update_subscription_status,
upsert_subscription,
)
from beanflows.core import config
# ════════════════════════════════════════════════════════════
# get_subscription
# ════════════════════════════════════════════════════════════
class TestGetSubscription:
async def test_returns_none_for_user_without_subscription(self, db, test_user):
result = await get_subscription(test_user["id"])
assert result is None
async def test_returns_subscription_for_user(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
result = await get_subscription(test_user["id"])
assert result is not None
assert result["plan"] == "pro"
assert result["status"] == "active"
assert result["user_id"] == test_user["id"]
# ════════════════════════════════════════════════════════════
# upsert_subscription
# ════════════════════════════════════════════════════════════
class TestUpsertSubscription:
async def test_insert_new_subscription(self, db, test_user):
sub_id = await upsert_subscription(
user_id=test_user["id"],
plan="pro",
status="active",
provider_customer_id="cust_abc",
provider_subscription_id="sub_xyz",
current_period_end="2025-06-01T00:00:00Z",
)
assert sub_id > 0
row = await get_subscription(test_user["id"])
assert row["plan"] == "pro"
assert row["status"] == "active"
assert row["paddle_customer_id"] == "cust_abc"
assert row["paddle_subscription_id"] == "sub_xyz"
assert row["current_period_end"] == "2025-06-01T00:00:00Z"
async def test_update_existing_subscription(self, db, test_user, create_subscription):
original_id = await create_subscription(
test_user["id"], plan="starter", status="active",
paddle_subscription_id="sub_old",
)
returned_id = await upsert_subscription(
user_id=test_user["id"],
plan="pro",
status="active",
provider_customer_id="cust_new",
provider_subscription_id="sub_new",
)
assert returned_id == original_id
row = await get_subscription(test_user["id"])
assert row["plan"] == "pro"
assert row["paddle_subscription_id"] == "sub_new"
async def test_upsert_with_none_period_end(self, db, test_user):
await upsert_subscription(
user_id=test_user["id"],
plan="pro",
status="active",
provider_customer_id="cust_1",
provider_subscription_id="sub_1",
current_period_end=None,
)
row = await get_subscription(test_user["id"])
assert row["current_period_end"] is None
# ════════════════════════════════════════════════════════════
# get_subscription_by_provider_id
# ════════════════════════════════════════════════════════════
class TestGetSubscriptionByProviderId:
async def test_returns_none_for_unknown_id(self, db):
result = await get_subscription_by_provider_id("nonexistent")
assert result is None
async def test_finds_by_paddle_subscription_id(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_findme")
result = await get_subscription_by_provider_id("sub_findme")
assert result is not None
assert result["user_id"] == test_user["id"]
# ════════════════════════════════════════════════════════════
# update_subscription_status
# ════════════════════════════════════════════════════════════
class TestUpdateSubscriptionStatus:
async def test_updates_status(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_upd")
await update_subscription_status("sub_upd", status="cancelled")
row = await get_subscription(test_user["id"])
assert row["status"] == "cancelled"
assert row["updated_at"] is not None
async def test_updates_extra_fields(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_extra")
await update_subscription_status(
"sub_extra",
status="active",
plan="starter",
current_period_end="2026-01-01T00:00:00Z",
)
row = await get_subscription(test_user["id"])
assert row["status"] == "active"
assert row["plan"] == "starter"
assert row["current_period_end"] == "2026-01-01T00:00:00Z"
async def test_noop_for_unknown_provider_id(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_known", status="active")
await update_subscription_status("sub_unknown", status="expired")
row = await get_subscription(test_user["id"])
assert row["status"] == "active" # unchanged
# ════════════════════════════════════════════════════════════
# can_access_feature
# ════════════════════════════════════════════════════════════
class TestCanAccessFeature:
async def test_no_subscription_gets_free_features(self, db, test_user):
assert await can_access_feature(test_user["id"], "dashboard") is True
assert await can_access_feature(test_user["id"], "export") is False
assert await can_access_feature(test_user["id"], "api") is False
async def test_active_pro_gets_all_features(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
assert await can_access_feature(test_user["id"], "dashboard") is True
assert await can_access_feature(test_user["id"], "export") is True
assert await can_access_feature(test_user["id"], "api") is True
assert await can_access_feature(test_user["id"], "priority_support") is True
async def test_active_starter_gets_starter_features(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="starter", status="active")
assert await can_access_feature(test_user["id"], "dashboard") is True
assert await can_access_feature(test_user["id"], "export") is True
assert await can_access_feature(test_user["id"], "all_commodities") is False
async def test_cancelled_still_has_features(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="cancelled")
assert await can_access_feature(test_user["id"], "api") is True
async def test_on_trial_has_features(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="on_trial")
assert await can_access_feature(test_user["id"], "api") is True
async def test_expired_falls_back_to_free(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="expired")
assert await can_access_feature(test_user["id"], "api") is False
assert await can_access_feature(test_user["id"], "dashboard") is True
async def test_past_due_falls_back_to_free(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="past_due")
assert await can_access_feature(test_user["id"], "export") is False
async def test_paused_falls_back_to_free(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="paused")
assert await can_access_feature(test_user["id"], "api") is False
async def test_nonexistent_feature_returns_false(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
assert await can_access_feature(test_user["id"], "teleportation") is False
# ════════════════════════════════════════════════════════════
# is_within_limits
# ════════════════════════════════════════════════════════════
class TestIsWithinLimits:
async def test_free_user_no_api_calls(self, db, test_user):
assert await is_within_limits(test_user["id"], "api_calls", 0) is False
async def test_free_user_commodity_limit(self, db, test_user):
assert await is_within_limits(test_user["id"], "commodities", 0) is True
assert await is_within_limits(test_user["id"], "commodities", 1) is False
async def test_free_user_history_limit(self, db, test_user):
assert await is_within_limits(test_user["id"], "history_years", 4) is True
assert await is_within_limits(test_user["id"], "history_years", 5) is False
async def test_pro_unlimited(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
assert await is_within_limits(test_user["id"], "commodities", 999999) is True
assert await is_within_limits(test_user["id"], "api_calls", 999999) is True
async def test_starter_limits(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="starter", status="active")
assert await is_within_limits(test_user["id"], "api_calls", 9999) is True
assert await is_within_limits(test_user["id"], "api_calls", 10000) is False
async def test_expired_pro_gets_free_limits(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="expired")
assert await is_within_limits(test_user["id"], "api_calls", 0) is False
async def test_unknown_resource_returns_false(self, db, test_user):
assert await is_within_limits(test_user["id"], "unicorns", 0) is False
# ════════════════════════════════════════════════════════════
# Parameterized: status × feature access matrix
# ════════════════════════════════════════════════════════════
STATUSES = ["free", "active", "on_trial", "cancelled", "past_due", "paused", "expired"]
FEATURES = ["dashboard", "export", "api", "priority_support"]
ACTIVE_STATUSES = {"active", "on_trial", "cancelled"}
@pytest.mark.parametrize("status", STATUSES)
@pytest.mark.parametrize("feature", FEATURES)
async def test_feature_access_matrix(db, test_user, create_subscription, status, feature):
if status != "free":
await create_subscription(test_user["id"], plan="pro", status=status)
result = await can_access_feature(test_user["id"], feature)
if status in ACTIVE_STATUSES:
expected = feature in config.PLAN_FEATURES["pro"]
else:
expected = feature in config.PLAN_FEATURES["free"]
assert result == expected, f"status={status}, feature={feature}"
# ════════════════════════════════════════════════════════════
# Parameterized: plan × feature matrix (active status)
# ════════════════════════════════════════════════════════════
PLANS = ["free", "starter", "pro"]
@pytest.mark.parametrize("plan", PLANS)
@pytest.mark.parametrize("feature", FEATURES)
async def test_plan_feature_matrix(db, test_user, create_subscription, plan, feature):
if plan != "free":
await create_subscription(test_user["id"], plan=plan, status="active")
result = await can_access_feature(test_user["id"], feature)
expected = feature in config.PLAN_FEATURES.get(plan, [])
assert result == expected, f"plan={plan}, feature={feature}"
# ════════════════════════════════════════════════════════════
# Parameterized: plan × resource limit boundaries
# ════════════════════════════════════════════════════════════
@pytest.mark.parametrize("plan", PLANS)
@pytest.mark.parametrize("resource,at_limit", [
("commodities", 1),
("commodities", 65),
("api_calls", 0),
("api_calls", 10000),
])
async def test_plan_limit_matrix(db, test_user, create_subscription, plan, resource, at_limit):
if plan != "free":
await create_subscription(test_user["id"], plan=plan, status="active")
plan_limit = config.PLAN_LIMITS.get(plan, {}).get(resource, 0)
result = await is_within_limits(test_user["id"], resource, at_limit)
if plan_limit == -1:
assert result is True
elif at_limit < plan_limit:
assert result is True
else:
assert result is False
# ════════════════════════════════════════════════════════════
# Hypothesis: limit boundaries
# ════════════════════════════════════════════════════════════
class TestLimitsHypothesis:
@given(count=st.integers(min_value=0, max_value=100))
@h_settings(max_examples=100, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture])
async def test_free_limit_boundary_commodities(self, db, test_user, count):
result = await is_within_limits(test_user["id"], "commodities", count)
assert result == (count < 1)
@given(count=st.integers(min_value=0, max_value=100000))
@h_settings(max_examples=100, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture])
async def test_pro_always_within_limits(self, db, test_user, create_subscription, count):
# Use upsert to avoid duplicate inserts across Hypothesis examples
await upsert_subscription(
user_id=test_user["id"], plan="pro", status="active",
provider_customer_id="cust_hyp", provider_subscription_id="sub_hyp",
)
result = await is_within_limits(test_user["id"], "commodities", count)
assert result is True

View File

@@ -0,0 +1,268 @@
"""
Route integration tests for Paddle billing endpoints.
External Paddle API calls mocked with respx.
"""
import json
import httpx
import pytest
import respx
CHECKOUT_METHOD = "POST"
CHECKOUT_PLAN = "starter"
# ════════════════════════════════════════════════════════════
# Public routes (pricing, success)
# ════════════════════════════════════════════════════════════
class TestPricingPage:
async def test_accessible_without_auth(self, client, db):
response = await client.get("/billing/pricing")
assert response.status_code == 200
async def test_accessible_with_auth(self, auth_client, db, test_user):
response = await auth_client.get("/billing/pricing")
assert response.status_code == 200
async def test_with_subscription(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
response = await auth_client.get("/billing/pricing")
assert response.status_code == 200
class TestSuccessPage:
async def test_requires_auth(self, client, db):
response = await client.get("/billing/success", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_accessible_with_auth(self, auth_client, db, test_user):
response = await auth_client.get("/billing/success")
assert response.status_code == 200
# ════════════════════════════════════════════════════════════
# Checkout
# ════════════════════════════════════════════════════════════
class TestCheckoutRoute:
async def test_requires_auth(self, client, db):
response = await client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@respx.mock
async def test_creates_checkout_session(self, auth_client, db, test_user):
respx.post("https://api.paddle.com/transactions").mock(
return_value=httpx.Response(200, json={
"data": {
"checkout": {
"url": "https://checkout.paddle.com/test_123"
}
}
})
)
response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_invalid_plan_rejected(self, auth_client, db, test_user):
response = await auth_client.post("/billing/checkout/invalid", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@respx.mock
async def test_api_error_propagates(self, auth_client, db, test_user):
respx.post("https://api.paddle.com/transactions").mock(
return_value=httpx.Response(500, json={"error": "server error"})
)
with pytest.raises(httpx.HTTPStatusError):
await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}")
# ════════════════════════════════════════════════════════════
# Manage subscription / Portal
# ════════════════════════════════════════════════════════════
class TestManageRoute:
async def test_requires_auth(self, client, db):
response = await client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_requires_subscription(self, auth_client, db, test_user):
response = await auth_client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@respx.mock
async def test_redirects_to_portal(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_test")
respx.get("https://api.paddle.com/subscriptions/sub_test").mock(
return_value=httpx.Response(200, json={
"data": {
"management_urls": {
"update_payment_method": "https://paddle.com/manage/test_123"
}
}
})
)
response = await auth_client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307)
# ════════════════════════════════════════════════════════════
# Cancel subscription
# ════════════════════════════════════════════════════════════
class TestCancelRoute:
async def test_requires_auth(self, client, db):
response = await client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_no_error_without_subscription(self, auth_client, db, test_user):
response = await auth_client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@respx.mock
async def test_cancels_subscription(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_test")
respx.post("https://api.paddle.com/subscriptions/sub_test/cancel").mock(
return_value=httpx.Response(200, json={"data": {}})
)
response = await auth_client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307)
# ════════════════════════════════════════════════════════════
# subscription_required decorator
# ════════════════════════════════════════════════════════════
from beanflows.billing.routes import subscription_required
from quart import Blueprint
test_bp = Blueprint("test", __name__)
@test_bp.route("/protected")
@subscription_required()
async def protected_route():
return "success", 200
@test_bp.route("/custom_allowed")
@subscription_required(allowed=("active", "past_due"))
async def custom_allowed_route():
return "success", 200
class TestSubscriptionRequiredDecorator:
@pytest.fixture
async def test_app(self, app):
app.register_blueprint(test_bp)
return app
@pytest.fixture
async def test_client(self, test_app):
async with test_app.test_client() as c:
yield c
async def test_redirects_unauthenticated(self, test_client, db):
response = await test_client.get("/protected", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_redirects_without_subscription(self, test_client, db, test_user):
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_allows_active_subscription(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected")
assert response.status_code == 200
async def test_allows_on_trial(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="on_trial")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected")
assert response.status_code == 200
async def test_allows_cancelled(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="cancelled")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected")
assert response.status_code == 200
async def test_rejects_expired(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="expired")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@pytest.mark.parametrize("status", ["free", "active", "on_trial", "cancelled", "past_due", "paused", "expired"])
async def test_default_allowed_tuple(self, test_client, db, test_user, create_subscription, status):
if status != "free":
await create_subscription(test_user["id"], plan="pro", status=status)
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected", follow_redirects=False)
if status in ("active", "on_trial", "cancelled"):
assert response.status_code == 200
else:
assert response.status_code in (302, 303, 307)
async def test_custom_allowed_tuple(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="past_due")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/custom_allowed")
assert response.status_code == 200

View File

@@ -0,0 +1,274 @@
"""
Integration tests for Paddle webhook handling.
Covers signature verification, event parsing, subscription lifecycle transitions, and Hypothesis fuzzing.
"""
import json
import pytest
from hypothesis import HealthCheck, given
from hypothesis import settings as h_settings
from hypothesis import strategies as st
from beanflows.billing.routes import get_subscription
from conftest import make_webhook_payload, sign_payload
WEBHOOK_PATH = "/billing/webhook/paddle"
SIG_HEADER = "Paddle-Signature"
# ════════════════════════════════════════════════════════════
# Signature Verification
# ════════════════════════════════════════════════════════════
class TestWebhookSignature:
async def test_missing_signature_rejected(self, client, db):
payload = make_webhook_payload("subscription.activated")
payload_bytes = json.dumps(payload).encode()
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={"Content-Type": "application/json"},
)
assert response.status_code in (400, 401)
async def test_invalid_signature_rejected(self, client, db):
payload = make_webhook_payload("subscription.activated")
payload_bytes = json.dumps(payload).encode()
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: "invalid_signature", "Content-Type": "application/json"},
)
assert response.status_code in (400, 401)
async def test_valid_signature_accepted(self, client, db, test_user):
payload = make_webhook_payload("subscription.activated", user_id=str(test_user["id"]))
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code in (200, 204)
async def test_modified_payload_rejected(self, client, db, test_user):
payload = make_webhook_payload("subscription.activated", user_id=str(test_user["id"]))
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
tampered = payload_bytes + b"extra"
# Paddle/LemonSqueezy: HMAC signature verification fails before JSON parsing
response = await client.post(
WEBHOOK_PATH,
data=tampered,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code in (400, 401)
async def test_empty_payload_rejected(self, client, db):
sig = sign_payload(b"")
with pytest.raises(Exception): # JSONDecodeError in TESTING mode
await client.post(
WEBHOOK_PATH,
data=b"",
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
# ════════════════════════════════════════════════════════════
# Subscription Lifecycle Events
# ════════════════════════════════════════════════════════════
class TestWebhookSubscriptionActivated:
async def test_creates_subscription(self, client, db, test_user):
payload = make_webhook_payload(
"subscription.activated",
user_id=str(test_user["id"]),
plan="starter",
)
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code in (200, 204)
sub = await get_subscription(test_user["id"])
assert sub is not None
assert sub["plan"] == "starter"
assert sub["status"] == "active"
class TestWebhookSubscriptionUpdated:
async def test_updates_subscription_status(self, client, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_test456")
payload = make_webhook_payload(
"subscription.updated",
subscription_id="sub_test456",
status="paused",
)
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code in (200, 204)
sub = await get_subscription(test_user["id"])
assert sub["status"] == "paused"
class TestWebhookSubscriptionCanceled:
async def test_marks_subscription_cancelled(self, client, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_test456")
payload = make_webhook_payload(
"subscription.canceled",
subscription_id="sub_test456",
)
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code in (200, 204)
sub = await get_subscription(test_user["id"])
assert sub["status"] == "cancelled"
class TestWebhookSubscriptionPastDue:
async def test_marks_subscription_past_due(self, client, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_test456")
payload = make_webhook_payload(
"subscription.past_due",
subscription_id="sub_test456",
)
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code in (200, 204)
sub = await get_subscription(test_user["id"])
assert sub["status"] == "past_due"
# ════════════════════════════════════════════════════════════
# Parameterized: event → status transitions
# ════════════════════════════════════════════════════════════
@pytest.mark.parametrize("event_type,expected_status", [
("subscription.activated", "active"),
("subscription.updated", "active"),
("subscription.canceled", "cancelled"),
("subscription.past_due", "past_due"),
])
async def test_event_status_transitions(client, db, test_user, create_subscription, event_type, expected_status):
if event_type != "subscription.activated":
await create_subscription(test_user["id"], paddle_subscription_id="sub_test456")
payload = make_webhook_payload(event_type, user_id=str(test_user["id"]))
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code in (200, 204)
sub = await get_subscription(test_user["id"])
assert sub["status"] == expected_status
# ════════════════════════════════════════════════════════════
# Hypothesis: fuzz webhook payloads
# ════════════════════════════════════════════════════════════
fuzz_event_type = st.sampled_from([
"subscription.activated",
"subscription.updated",
"subscription.canceled",
"subscription.past_due",
])
fuzz_status = st.sampled_from(["active", "paused", "past_due", "canceled"])
@st.composite
def fuzz_payload(draw):
event_type = draw(fuzz_event_type)
return make_webhook_payload(
event_type=event_type,
subscription_id=f"sub_{draw(st.integers(min_value=100, max_value=999999))}",
user_id=str(draw(st.integers(min_value=1, max_value=999999))),
status=draw(fuzz_status),
)
class TestWebhookHypothesis:
@given(payload_dict=fuzz_payload())
@h_settings(max_examples=50, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
async def test_webhook_never_500s(self, client, db, test_user, payload_dict):
# Pin user_id to the test user so subscription_created/activated events don't hit FK violations
payload_dict["data"]["custom_data"]["user_id"] = str(test_user["id"])
payload_bytes = json.dumps(payload_dict).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code < 500

View File

@@ -0,0 +1,79 @@
"""
Tests for the coffee analytics dashboard.
"""
import pytest
@pytest.mark.asyncio
async def test_dashboard_requires_login(client):
"""Dashboard redirects unauthenticated users."""
response = await client.get("/dashboard/")
assert response.status_code == 302
@pytest.mark.asyncio
async def test_dashboard_loads(auth_client, mock_analytics):
"""Dashboard renders with chart data for authenticated user."""
response = await auth_client.get("/dashboard/")
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
@pytest.mark.asyncio
async def test_dashboard_shows_metric_cards(auth_client, mock_analytics):
"""Dashboard shows key metric values from latest data."""
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
@pytest.mark.asyncio
async def test_dashboard_yoy_table(auth_client, mock_analytics):
"""Dashboard renders YoY production change table."""
response = await auth_client.get("/dashboard/")
body = (await response.get_data(as_text=True))
assert "Brazil" in body
assert "Vietnam" in body
@pytest.mark.asyncio
async def test_dashboard_free_plan_limits_history(auth_client, mock_analytics):
"""Free plan should show limited history notice."""
response = await auth_client.get("/dashboard/")
body = (await response.get_data(as_text=True))
assert "Upgrade" in body
@pytest.mark.asyncio
async def test_dashboard_free_plan_no_csv_export(auth_client, mock_analytics):
"""Free plan should not show CSV export button."""
response = await auth_client.get("/dashboard/")
body = (await response.get_data(as_text=True))
assert "CSV export available on Starter" in body
@pytest.mark.asyncio
async def test_countries_page_loads(auth_client, mock_analytics):
"""Country comparison page loads."""
response = await auth_client.get("/dashboard/countries")
assert response.status_code == 200
body = (await response.get_data(as_text=True))
assert "Country Comparison" in body
@pytest.mark.asyncio
async def test_countries_page_with_selection(auth_client, mock_analytics):
"""Country comparison with country params."""
response = await auth_client.get("/dashboard/countries?country=BR&country=VN&metric=Production")
assert response.status_code == 200