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:
32
CHANGELOG.md
Normal file
32
CHANGELOG.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
194
uv.lock
generated
@@ -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
10
web/.copier-answers.yml
Normal 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
17
web/.dockerignore
Normal 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
30
web/.env.example
Normal 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
37
web/.gitignore
vendored
Normal 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
1
web/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
78
web/CLAUDE.md
Normal file
78
web/CLAUDE.md
Normal 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
22
web/Dockerfile
Normal 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
0
web/README.md
Normal file
76
web/deploy.sh
Normal file
76
web/deploy.sh
Normal 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
128
web/docker-compose.prod.yml
Normal 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
56
web/docker-compose.yml
Normal 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
22
web/litestream.yml
Normal 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
48
web/pyproject.toml
Normal 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
15
web/router/default.conf
Normal 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
23
web/scripts/backup.sh
Normal 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
22
web/scripts/deploy.sh
Normal 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!"
|
||||
3
web/src/beanflows/__init__.py
Normal file
3
web/src/beanflows/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""BeanFlows - Commodity analytics for coffee traders"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
340
web/src/beanflows/admin/routes.py
Normal file
340
web/src/beanflows/admin/routes.py
Normal 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"))
|
||||
124
web/src/beanflows/admin/templates/index.html
Normal file
124
web/src/beanflows/admin/templates/index.html
Normal 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 %}
|
||||
30
web/src/beanflows/admin/templates/login.html
Normal file
30
web/src/beanflows/admin/templates/login.html
Normal 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 %}
|
||||
106
web/src/beanflows/admin/templates/tasks.html
Normal file
106
web/src/beanflows/admin/templates/tasks.html
Normal 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 %}
|
||||
73
web/src/beanflows/admin/templates/user_detail.html
Normal file
73
web/src/beanflows/admin/templates/user_detail.html
Normal 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 %}
|
||||
83
web/src/beanflows/admin/templates/users.html
Normal file
83
web/src/beanflows/admin/templates/users.html
Normal 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 %}
|
||||
220
web/src/beanflows/analytics.py
Normal file
220
web/src/beanflows/analytics.py
Normal 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],
|
||||
)
|
||||
191
web/src/beanflows/api/routes.py
Normal file
191
web/src/beanflows/api/routes.py
Normal 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
123
web/src/beanflows/app.py
Normal 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)
|
||||
314
web/src/beanflows/auth/routes.py
Normal file
314
web/src/beanflows/auth/routes.py
Normal 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))
|
||||
39
web/src/beanflows/auth/templates/login.html
Normal file
39
web/src/beanflows/auth/templates/login.html
Normal 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 %}
|
||||
35
web/src/beanflows/auth/templates/magic_link_sent.html
Normal file
35
web/src/beanflows/auth/templates/magic_link_sent.html
Normal 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 %}
|
||||
44
web/src/beanflows/auth/templates/signup.html
Normal file
44
web/src/beanflows/auth/templates/signup.html
Normal 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 %}
|
||||
275
web/src/beanflows/billing/routes.py
Normal file
275
web/src/beanflows/billing/routes.py
Normal 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})
|
||||
|
||||
|
||||
|
||||
120
web/src/beanflows/billing/templates/pricing.html
Normal file
120
web/src/beanflows/billing/templates/pricing.html
Normal 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 & 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 & 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 & 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 %}
|
||||
26
web/src/beanflows/billing/templates/success.html
Normal file
26
web/src/beanflows/billing/templates/success.html
Normal 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
334
web/src/beanflows/core.py
Normal 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,)
|
||||
)
|
||||
246
web/src/beanflows/dashboard/routes.py
Normal file
246
web/src/beanflows/dashboard/routes.py
Normal 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"))
|
||||
101
web/src/beanflows/dashboard/templates/countries.html
Normal file
101
web/src/beanflows/dashboard/templates/countries.html
Normal 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 %}
|
||||
211
web/src/beanflows/dashboard/templates/index.html
Normal file
211
web/src/beanflows/dashboard/templates/index.html
Normal 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 & 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 %}
|
||||
155
web/src/beanflows/dashboard/templates/settings.html
Normal file
155
web/src/beanflows/dashboard/templates/settings.html
Normal 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 %}
|
||||
53
web/src/beanflows/migrations/migrate.py
Normal file
53
web/src/beanflows/migrations/migrate.py
Normal 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()
|
||||
101
web/src/beanflows/migrations/schema.sql
Normal file
101
web/src/beanflows/migrations/schema.sql
Normal 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);
|
||||
45
web/src/beanflows/public/routes.py
Normal file
45
web/src/beanflows/public/routes.py
Normal 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")
|
||||
34
web/src/beanflows/public/templates/about.html
Normal file
34
web/src/beanflows/public/templates/about.html
Normal 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 %}
|
||||
73
web/src/beanflows/public/templates/features.html
Normal file
73
web/src/beanflows/public/templates/features.html
Normal 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 & Distribution data.</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<article>
|
||||
<h2>Supply & 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–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 & 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 & 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 → staging → cleaned → 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 %}
|
||||
91
web/src/beanflows/public/templates/landing.html
Normal file
91
web/src/beanflows/public/templates/landing.html
Normal 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 & 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 & 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 %}
|
||||
92
web/src/beanflows/public/templates/privacy.html
Normal file
92
web/src/beanflows/public/templates/privacy.html
Normal 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 %}
|
||||
71
web/src/beanflows/public/templates/terms.html
Normal file
71
web/src/beanflows/public/templates/terms.html
Normal 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 %}
|
||||
40
web/src/beanflows/static/css/custom.css
Normal file
40
web/src/beanflows/static/css/custom.css
Normal 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);
|
||||
}
|
||||
97
web/src/beanflows/templates/base.html
Normal file
97
web/src/beanflows/templates/base.html
Normal 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>© {{ 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
238
web/src/beanflows/worker.py
Normal 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
247
web/tests/conftest.py
Normal 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
|
||||
|
||||
|
||||
129
web/tests/test_api_commodities.py
Normal file
129
web/tests/test_api_commodities.py
Normal 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"
|
||||
325
web/tests/test_billing_helpers.py
Normal file
325
web/tests/test_billing_helpers.py
Normal 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
|
||||
268
web/tests/test_billing_routes.py
Normal file
268
web/tests/test_billing_routes.py
Normal 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
|
||||
274
web/tests/test_billing_webhooks.py
Normal file
274
web/tests/test_billing_webhooks.py
Normal 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
|
||||
79
web/tests/test_dashboard.py
Normal file
79
web/tests/test_dashboard.py
Normal 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
|
||||
Reference in New Issue
Block a user