feat: pSEO CMS — SSG architecture with git templates + DuckDB

# Conflicts:
#	web/pyproject.toml
This commit is contained in:
Deeman
2026-02-23 12:51:30 +01:00
15 changed files with 1474 additions and 874 deletions

View File

@@ -53,6 +53,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
(`rate_peak`, `rate_off_peak`, `avg_utilisation_pct`, `courts_typical`); adds (`rate_peak`, `rate_off_peak`, `avg_utilisation_pct`, `courts_typical`); adds
`_dataSource` and `_currency` metadata keys `_dataSource` and `_currency` metadata keys
### Changed
- **pSEO CMS: SSG architecture** — templates now live in git as `.md.jinja` files with YAML
frontmatter (slug, data_table, url_pattern, etc.) instead of SQLite `article_templates` table;
data comes directly from DuckDB serving tables instead of intermediary `template_data` table;
admin template views are read-only (edit in git, preview/generate in admin)
- **pSEO CMS: SEO pipeline** — article generation bakes canonical URLs, hreflang links (EN + DE),
JSON-LD structured data (Article, FAQPage, BreadcrumbList), and Open Graph tags into each
article's `seo_head` column at generation time; articles stored with `template_slug`, `language`,
and `date_modified` columns for regeneration and freshness tracking
### Removed
- `article_templates` and `template_data` SQLite tables (migration 0018) — replaced by git
template files + direct DuckDB reads; `template_data_id` FK removed from `articles` and
`published_scenarios` tables
- Admin template CRUD routes (create/edit/delete) and CSV upload — replaced by read-only views
with generate/regenerate/preview actions
- `template_form.html` and `template_data.html` admin templates
### Changed ### Changed
- **Extraction: one file per source** — replaced monolithic `execute.py` with per-source - **Extraction: one file per source** — replaced monolithic `execute.py` with per-source
modules (`overpass.py`, `eurostat.py`, `playtomic_tenants.py`, `playtomic_availability.py`); modules (`overpass.py`, `eurostat.py`, `playtomic_tenants.py`, `playtomic_availability.py`);

57
uv.lock generated
View File

@@ -1159,6 +1159,7 @@ dependencies = [
{ name = "paddle-python-sdk" }, { name = "paddle-python-sdk" },
{ name = "pyarrow" }, { name = "pyarrow" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "quart" }, { name = "quart" },
{ name = "resend" }, { name = "resend" },
{ name = "weasyprint" }, { name = "weasyprint" },
@@ -1175,6 +1176,7 @@ requires-dist = [
{ name = "paddle-python-sdk", specifier = ">=1.13.0" }, { name = "paddle-python-sdk", specifier = ">=1.13.0" },
{ name = "pyarrow", specifier = ">=23.0.1" }, { name = "pyarrow", specifier = ">=23.0.1" },
{ name = "python-dotenv", specifier = ">=1.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "quart", specifier = ">=0.19.0" }, { name = "quart", specifier = ">=0.19.0" },
{ name = "resend", specifier = ">=2.22.0" }, { name = "resend", specifier = ">=2.22.0" },
{ name = "weasyprint", specifier = ">=68.1" }, { name = "weasyprint", specifier = ">=68.1" },
@@ -1733,6 +1735,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
] ]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]] [[package]]
name = "qh3" name = "qh3"
version = "1.5.6" version = "1.5.6"

View File

@@ -17,6 +17,7 @@ dependencies = [
"weasyprint>=68.1", "weasyprint>=68.1",
"duckdb>=1.0.0", "duckdb>=1.0.0",
"pyarrow>=23.0.1", "pyarrow>=23.0.1",
"pyyaml>=6.0",
] ]
[build-system] [build-system]

View File

@@ -1,8 +1,6 @@
""" """
Admin domain: role-based admin panel for managing users, tasks, etc. Admin domain: role-based admin panel for managing users, tasks, etc.
""" """
import csv
import io
import json import json
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
@@ -828,424 +826,140 @@ async def feedback():
# ============================================================================= # =============================================================================
# Article Template Management # Content Templates (read-only — templates live in git as .md.jinja files)
# ============================================================================= # =============================================================================
@bp.route("/templates") @bp.route("/templates")
@role_required("admin") @role_required("admin")
async def templates(): async def templates():
"""List article templates.""" """List content templates scanned from disk."""
template_list = await fetch_all( from ..content import discover_templates, fetch_template_data
"SELECT * FROM article_templates ORDER BY created_at DESC"
) template_list = discover_templates()
# Attach data row counts
# Attach DuckDB row counts
for t in template_list: for t in template_list:
count_rows = await fetch_template_data(t["data_table"], limit=501)
t["data_count"] = len(count_rows)
# Count generated articles for this template
row = await fetch_one( row = await fetch_one(
"SELECT COUNT(*) as cnt FROM template_data WHERE template_id = ?", (t["id"],) "SELECT COUNT(*) as cnt FROM articles WHERE template_slug = ?",
) (t["slug"],),
t["data_count"] = row["cnt"] if row else 0
row = await fetch_one(
"SELECT COUNT(*) as cnt FROM template_data WHERE template_id = ? AND article_id IS NOT NULL",
(t["id"],),
) )
t["generated_count"] = row["cnt"] if row else 0 t["generated_count"] = row["cnt"] if row else 0
return await render_template("admin/templates.html", templates=template_list) return await render_template("admin/templates.html", templates=template_list)
@bp.route("/templates/new", methods=["GET", "POST"]) @bp.route("/templates/<slug>")
@role_required("admin") @role_required("admin")
@csrf_protect async def template_detail(slug: str):
async def template_new(): """Template detail: config (read-only), columns, sample data, actions."""
"""Create a new article template.""" from ..content import fetch_template_data, get_table_columns, load_template
if request.method == "POST":
form = await request.form
name = form.get("name", "").strip()
template_slug = form.get("slug", "").strip() or slugify(name)
content_type = form.get("content_type", "calculator")
input_schema = form.get("input_schema", "[]").strip()
url_pattern = form.get("url_pattern", "").strip()
title_pattern = form.get("title_pattern", "").strip()
meta_description_pattern = form.get("meta_description_pattern", "").strip()
body_template = form.get("body_template", "").strip()
if not name or not url_pattern or not title_pattern or not body_template:
await flash("Name, URL pattern, title pattern, and body template are required.", "error")
return await render_template("admin/template_form.html", data=dict(form), editing=False)
# Validate input_schema is valid JSON
try:
json.loads(input_schema)
except json.JSONDecodeError:
await flash("Input schema must be valid JSON.", "error")
return await render_template("admin/template_form.html", data=dict(form), editing=False)
existing = await fetch_one(
"SELECT 1 FROM article_templates WHERE slug = ?", (template_slug,)
)
if existing:
await flash(f"Slug '{template_slug}' already exists.", "error")
return await render_template("admin/template_form.html", data=dict(form), editing=False)
template_id = await execute(
"""INSERT INTO article_templates
(name, slug, content_type, input_schema, url_pattern,
title_pattern, meta_description_pattern, body_template)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(name, template_slug, content_type, input_schema, url_pattern,
title_pattern, meta_description_pattern, body_template),
)
await flash(f"Template '{name}' created.", "success")
return redirect(url_for("admin.template_data", template_id=template_id))
return await render_template("admin/template_form.html", data={}, editing=False)
@bp.route("/templates/<int:template_id>/edit", methods=["GET", "POST"])
@role_required("admin")
@csrf_protect
async def template_edit(template_id: int):
"""Edit an article template."""
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
if not template:
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
if request.method == "POST":
form = await request.form
name = form.get("name", "").strip()
input_schema = form.get("input_schema", "[]").strip()
url_pattern = form.get("url_pattern", "").strip()
title_pattern = form.get("title_pattern", "").strip()
meta_description_pattern = form.get("meta_description_pattern", "").strip()
body_template = form.get("body_template", "").strip()
if not name or not url_pattern or not title_pattern or not body_template:
await flash("Name, URL pattern, title pattern, and body template are required.", "error")
return await render_template(
"admin/template_form.html", data=dict(form), editing=True, template_id=template_id,
)
try: try:
json.loads(input_schema) config = load_template(slug)
except json.JSONDecodeError: except (AssertionError, FileNotFoundError):
await flash("Input schema must be valid JSON.", "error")
return await render_template(
"admin/template_form.html", data=dict(form), editing=True, template_id=template_id,
)
now = datetime.utcnow().isoformat()
await execute(
"""UPDATE article_templates
SET name = ?, input_schema = ?, url_pattern = ?,
title_pattern = ?, meta_description_pattern = ?,
body_template = ?, updated_at = ?
WHERE id = ?""",
(name, input_schema, url_pattern, title_pattern,
meta_description_pattern, body_template, now, template_id),
)
await flash("Template updated.", "success")
return redirect(url_for("admin.templates"))
return await render_template(
"admin/template_form.html", data=dict(template), editing=True, template_id=template_id,
)
@bp.route("/templates/<int:template_id>/delete", methods=["POST"])
@role_required("admin")
@csrf_protect
async def template_delete(template_id: int):
"""Delete an article template."""
await execute("DELETE FROM article_templates WHERE id = ?", (template_id,))
await flash("Template deleted.", "success")
return redirect(url_for("admin.templates"))
# =============================================================================
# Template Data Management
# =============================================================================
@bp.route("/templates/<int:template_id>/data")
@role_required("admin")
async def template_data(template_id: int):
"""View data rows for a template."""
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
if not template:
await flash("Template not found.", "error") await flash("Template not found.", "error")
return redirect(url_for("admin.templates")) return redirect(url_for("admin.templates"))
data_rows = await fetch_all( columns = await get_table_columns(config["data_table"])
"""SELECT td.*, a.title as article_title, a.url_path as article_url, sample_rows = await fetch_template_data(config["data_table"], limit=10)
ps.slug as scenario_slug
FROM template_data td # Count generated articles
LEFT JOIN articles a ON a.id = td.article_id row = await fetch_one(
LEFT JOIN published_scenarios ps ON ps.id = td.scenario_id "SELECT COUNT(*) as cnt FROM articles WHERE template_slug = ?", (slug,),
WHERE td.template_id = ? )
ORDER BY td.created_at DESC""", generated_count = row["cnt"] if row else 0
(template_id,),
return await render_template(
"admin/template_detail.html",
config_data=config,
columns=columns,
sample_rows=sample_rows,
generated_count=generated_count,
) )
# Pre-parse data_json for display in template
for row in data_rows: @bp.route("/templates/<slug>/preview/<row_key>")
@role_required("admin")
async def template_preview(slug: str, row_key: str):
"""Preview a single article rendered from template + DuckDB row."""
from ..content import preview_article
lang = request.args.get("lang", "en")
try: try:
row["parsed_data"] = json.loads(row["data_json"]) result = await preview_article(slug, row_key, lang=lang)
except (json.JSONDecodeError, TypeError): except (AssertionError, Exception) as exc:
row["parsed_data"] = {} await flash(f"Preview error: {exc}", "error")
return redirect(url_for("admin.template_detail", slug=slug))
schema = json.loads(template["input_schema"])
return await render_template( return await render_template(
"admin/template_data.html", "admin/template_preview.html",
template=template, config={"slug": slug},
data_rows=data_rows, preview=result,
schema=schema, lang=lang,
) )
@bp.route("/templates/<int:template_id>/data/add", methods=["POST"]) @bp.route("/templates/<slug>/generate", methods=["GET", "POST"])
@role_required("admin") @role_required("admin")
@csrf_protect @csrf_protect
async def template_data_add(template_id: int): async def template_generate(slug: str):
"""Add a single data row.""" """Generate articles from template + DuckDB data."""
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,)) from ..content import fetch_template_data, generate_articles, load_template
if not template:
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
form = await request.form
schema = json.loads(template["input_schema"])
data = {}
for field in schema:
val = form.get(field["name"], "").strip()
if field.get("field_type") in ("number", "float"):
try: try:
data[field["name"]] = float(val) if val else 0 config = load_template(slug)
except ValueError: except (AssertionError, FileNotFoundError):
data[field["name"]] = 0
else:
data[field["name"]] = val
await execute(
"INSERT INTO template_data (template_id, data_json) VALUES (?, ?)",
(template_id, json.dumps(data)),
)
await flash("Data row added.", "success")
return redirect(url_for("admin.template_data", template_id=template_id))
@bp.route("/templates/<int:template_id>/data/upload", methods=["POST"])
@role_required("admin")
@csrf_protect
async def template_data_upload(template_id: int):
"""Bulk upload data rows from CSV."""
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
if not template:
await flash("Template not found.", "error") await flash("Template not found.", "error")
return redirect(url_for("admin.templates")) return redirect(url_for("admin.templates"))
files = await request.files data_rows = await fetch_template_data(config["data_table"], limit=501)
csv_file = files.get("csv_file") row_count = len(data_rows)
if not csv_file:
await flash("No CSV file uploaded.", "error")
return redirect(url_for("admin.template_data", template_id=template_id))
content = (await csv_file.read()).decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(content))
rows_added = 0
for row in reader:
data = {k.strip(): v.strip() for k, v in row.items() if k and v}
if data:
await execute(
"INSERT INTO template_data (template_id, data_json) VALUES (?, ?)",
(template_id, json.dumps(data)),
)
rows_added += 1
await flash(f"{rows_added} data rows imported from CSV.", "success")
return redirect(url_for("admin.template_data", template_id=template_id))
@bp.route("/templates/<int:template_id>/data/<int:data_id>/delete", methods=["POST"])
@role_required("admin")
@csrf_protect
async def template_data_delete(template_id: int, data_id: int):
"""Delete a single data row."""
await execute("DELETE FROM template_data WHERE id = ? AND template_id = ?", (data_id, template_id))
await flash("Data row deleted.", "success")
return redirect(url_for("admin.template_data", template_id=template_id))
# =============================================================================
# Bulk Generation
# =============================================================================
def _render_jinja_string(template_str: str, context: dict) -> str:
"""Render a Jinja2 template string with the given context."""
from jinja2 import Environment
env = Environment()
tmpl = env.from_string(template_str)
return tmpl.render(**context)
@bp.route("/templates/<int:template_id>/generate", methods=["GET", "POST"])
@role_required("admin")
@csrf_protect
async def template_generate(template_id: int):
"""Bulk-generate scenarios + articles from template data."""
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
if not template:
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
pending_count = await fetch_one(
"SELECT COUNT(*) as cnt FROM template_data WHERE template_id = ? AND article_id IS NULL",
(template_id,),
)
pending = pending_count["cnt"] if pending_count else 0
if request.method == "POST": if request.method == "POST":
form = await request.form form = await request.form
start_date_str = form.get("start_date", "") start_date_str = form.get("start_date", "")
articles_per_day = int(form.get("articles_per_day", 2) or 2) articles_per_day = int(form.get("articles_per_day", 3) or 3)
if not start_date_str: if not start_date_str:
start_date = date.today() start_date = date.today()
else: else:
start_date = date.fromisoformat(start_date_str) start_date = date.fromisoformat(start_date_str)
assert articles_per_day > 0, "articles_per_day must be positive" generated = await generate_articles(
slug, start_date, articles_per_day, limit=500,
generated = await _generate_from_template(template, start_date, articles_per_day) )
await flash(f"Generated {generated} articles with staggered publish dates.", "success") await flash(f"Generated {generated} articles with staggered publish dates.", "success")
return redirect(url_for("admin.template_data", template_id=template_id)) return redirect(url_for("admin.articles"))
return await render_template( return await render_template(
"admin/generate_form.html", "admin/generate_form.html",
template=template, config_data=config,
pending_count=pending, row_count=row_count,
today=date.today().isoformat(), today=date.today().isoformat(),
) )
async def _generate_from_template(template: dict, start_date: date, articles_per_day: int) -> int: @bp.route("/templates/<slug>/regenerate", methods=["POST"])
"""Generate scenarios + articles for all un-generated data rows.""" @role_required("admin")
from ..content.routes import BUILD_DIR, bake_scenario_cards, is_reserved_path @csrf_protect
from ..planner.calculator import DEFAULTS, calc, validate_state async def template_regenerate(slug: str):
"""Re-generate all articles for a template with fresh DuckDB data."""
from ..content import generate_articles, load_template
data_rows = await fetch_all( try:
"SELECT * FROM template_data WHERE template_id = ? AND article_id IS NULL", load_template(slug)
(template["id"],), except (AssertionError, FileNotFoundError):
) await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
publish_date = start_date # Use today as start date, keep existing publish dates via upsert
published_today = 0 generated = await generate_articles(slug, date.today(), articles_per_day=500)
generated = 0 await flash(f"Regenerated {generated} articles from fresh data.", "success")
return redirect(url_for("admin.template_detail", slug=slug))
for row in data_rows:
data = json.loads(row["data_json"])
# Separate calc fields from display fields
lang = data.get("language", "en")
calc_overrides = {k: v for k, v in data.items() if k in DEFAULTS}
state = validate_state(calc_overrides)
d = calc(state, lang=lang)
# Build scenario slug
city_slug = data.get("city_slug", str(row["id"]))
scenario_slug = template["slug"] + "-" + city_slug
# Court config label
dbl = state.get("dblCourts", 0)
sgl = state.get("sglCourts", 0)
court_config = f"{dbl} double + {sgl} single"
# Create published scenario
scenario_id = await execute(
"""INSERT OR IGNORE INTO published_scenarios
(slug, title, subtitle, location, country, venue_type, ownership,
court_config, state_json, calc_json, template_data_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
scenario_slug,
data.get("city", scenario_slug),
data.get("subtitle", ""),
data.get("city", ""),
data.get("country", state.get("country", "")),
state.get("venue", "indoor"),
state.get("own", "rent"),
court_config,
json.dumps(state),
json.dumps(d),
row["id"],
),
)
if not scenario_id:
# Slug already exists, fetch existing
existing = await fetch_one(
"SELECT id FROM published_scenarios WHERE slug = ?", (scenario_slug,)
)
scenario_id = existing["id"] if existing else None
if not scenario_id:
continue
# Fill template patterns
data["scenario_slug"] = scenario_slug
title = _render_jinja_string(template["title_pattern"], data)
url_path = _render_jinja_string(template["url_pattern"], data)
article_slug = template["slug"] + "-" + city_slug
meta_desc = ""
if template["meta_description_pattern"]:
meta_desc = _render_jinja_string(template["meta_description_pattern"], data)
# Validate url_path
if is_reserved_path(url_path):
continue
# Render body
body_md = _render_jinja_string(template["body_template"], data)
body_html = mistune.html(body_md)
body_html = await bake_scenario_cards(body_html, lang=lang)
# Write to disk
BUILD_DIR.mkdir(parents=True, exist_ok=True)
build_path = BUILD_DIR / f"{article_slug}.html"
build_path.write_text(body_html)
# Stagger publish date
publish_dt = datetime(publish_date.year, publish_date.month, publish_date.day, 8, 0, 0)
# Create article
article_id = await execute(
"""INSERT OR IGNORE INTO articles
(url_path, slug, title, meta_description, country, region,
status, published_at, template_data_id)
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?)""",
(
url_path, article_slug, title, meta_desc,
data.get("country", ""), data.get("region", ""),
publish_dt.isoformat(), row["id"],
),
)
if article_id:
# Link data row
now = datetime.utcnow().isoformat()
await execute(
"UPDATE template_data SET scenario_id = ?, article_id = ?, updated_at = ? WHERE id = ?",
(scenario_id, article_id, now, row["id"]),
)
generated += 1
# Stagger dates
published_today += 1
if published_today >= articles_per_day:
published_today = 0
publish_date += timedelta(days=1)
return generated
# ============================================================================= # =============================================================================
@@ -1659,46 +1373,31 @@ async def rebuild_all():
async def _rebuild_article(article_id: int): async def _rebuild_article(article_id: int):
"""Re-render a single article from its source (template+data or markdown).""" """Re-render a single article from its source."""
from ..content.routes import BUILD_DIR, bake_scenario_cards from ..content.routes import BUILD_DIR, bake_scenario_cards
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,)) article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
if not article: if not article:
return return
if article["template_data_id"]: if article["template_slug"]:
# Generated article: re-render from template + data # SSG-generated article: regenerate via the content module
td = await fetch_one( from ..content import generate_articles, load_template
"""SELECT td.*, at.body_template, at.title_pattern, at.meta_description_pattern try:
FROM template_data td load_template(article["template_slug"])
JOIN article_templates at ON at.id = td.template_id except (AssertionError, FileNotFoundError):
WHERE td.id = ?""",
(article["template_data_id"],),
)
if not td:
return return
# Regenerate all articles for this template (upserts, so safe)
data = json.loads(td["data_json"]) await generate_articles(
article["template_slug"], date.today(), articles_per_day=500,
# Re-fetch scenario for fresh calc_json
if td["scenario_id"]:
scenario = await fetch_one(
"SELECT slug FROM published_scenarios WHERE id = ?", (td["scenario_id"],)
) )
if scenario:
data["scenario_slug"] = scenario["slug"]
body_md = _render_jinja_string(td["body_template"], data)
body_html = mistune.html(body_md)
lang = data.get("language", "en")
else: else:
# Manual article: re-render from markdown file # Manual article: re-render from markdown file
md_path = Path("data/content/articles") / f"{article['slug']}.md" md_path = Path("data/content/articles") / f"{article['slug']}.md"
if not md_path.exists(): if not md_path.exists():
return return
body_html = mistune.html(md_path.read_text()) body_html = mistune.html(md_path.read_text())
lang = "en" lang = article.get("language", "en") if hasattr(article, "get") else "en"
body_html = await bake_scenario_cards(body_html, lang=lang) body_html = await bake_scenario_cards(body_html, lang=lang)
BUILD_DIR.mkdir(parents=True, exist_ok=True) BUILD_DIR.mkdir(parents=True, exist_ok=True)
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html) (BUILD_DIR / f"{article['slug']}.html").write_text(body_html)

View File

@@ -1,17 +1,22 @@
{% extends "admin/base_admin.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "templates" %} {% set admin_page = "templates" %}
{% block title %}Generate Articles - {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Generate Articles - {{ config_data.name }} - Admin{% endblock %}
{% block admin_content %} {% block admin_content %}
<div style="max-width: 32rem; margin: 0 auto;"> <div style="max-width: 32rem; margin: 0 auto;">
<a href="{{ url_for('admin.template_data', template_id=template.id) }}" class="text-sm text-slate">&larr; Back to {{ template.name }}</a> <a href="{{ url_for('admin.template_detail', slug=config_data.slug) }}" class="text-sm text-slate">&larr; Back to {{ config_data.name }}</a>
<h1 class="text-2xl mt-4 mb-2">Generate Articles</h1> <h1 class="text-2xl mt-4 mb-2">Generate Articles</h1>
<p class="text-slate text-sm mb-6">{{ pending_count }} pending data row{{ 's' if pending_count != 1 }} ready to generate.</p> <p class="text-slate text-sm mb-6">
{{ row_count }} data row{{ 's' if row_count != 1 }} available in
<code>{{ config_data.data_table }}</code>
&times; {{ config_data.languages | length }} language{{ 's' if config_data.languages | length != 1 }}
= <strong>{{ row_count * config_data.languages | length }}</strong> articles.
</p>
{% if pending_count == 0 %} {% if row_count == 0 %}
<div class="card"> <div class="card">
<p class="text-slate text-sm">All data rows have already been generated. Add more data rows first.</p> <p class="text-slate text-sm">No data rows found. Run the data pipeline to populate <code>{{ config_data.data_table }}</code>.</p>
</div> </div>
{% else %} {% else %}
<form method="post" class="card"> <form method="post" class="card">
@@ -25,20 +30,23 @@
<div class="mb-4"> <div class="mb-4">
<label class="form-label" for="articles_per_day">Articles Per Day</label> <label class="form-label" for="articles_per_day">Articles Per Day</label>
<input type="number" id="articles_per_day" name="articles_per_day" value="2" min="1" max="50" class="form-input" required> <input type="number" id="articles_per_day" name="articles_per_day" value="3" min="1" max="50" class="form-input" required>
<p class="form-hint">How many articles to publish per day. Remaining articles get staggered to following days.</p> <p class="form-hint">How many articles to publish per day. Remaining articles get staggered to following days.</p>
</div> </div>
<div class="card" style="background: var(--color-soft-white); border: 1px dashed var(--color-mid-gray); margin-bottom: 1rem;"> <div class="card" style="background: var(--color-soft-white); border: 1px dashed var(--color-mid-gray); margin-bottom: 1rem;">
<p class="text-sm text-slate"> <p class="text-sm text-slate">
This will generate <strong class="text-navy">{{ pending_count }}</strong> articles This will generate up to <strong class="text-navy">{{ row_count * config_data.languages | length }}</strong> articles
over <strong class="text-navy" id="days-estimate">{{ ((pending_count + 1) // 2) }}</strong> days, ({{ row_count }} rows &times; {{ config_data.languages | length }} languages).
each with its own financial scenario computed from the data row's input values. Existing articles with the same URL will be updated in-place.
{% if config_data.priority_column %}
Articles are ordered by <code>{{ config_data.priority_column }}</code> (highest first).
{% endif %}
</p> </p>
</div> </div>
<button type="submit" class="btn" style="width: 100%;" onclick="return confirm('Generate {{ pending_count }} articles? This cannot be undone.')"> <button type="submit" class="btn" style="width: 100%;" onclick="return confirm('Generate articles? Existing articles will be updated.')">
Generate {{ pending_count }} Articles Generate Articles
</button> </button>
</form> </form>
{% endif %} {% endif %}

View File

@@ -1,103 +0,0 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "templates" %}
{% block title %}Data: {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-8">
<div>
<a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">&larr; Back to templates</a>
<h1 class="text-2xl mt-2">{{ template.name }}</h1>
<p class="text-slate text-sm">{{ data_rows | length }} data row{{ 's' if data_rows | length != 1 }} · <span class="mono">{{ template.slug }}</span></p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('admin.template_generate', template_id=template.id) }}" class="btn">Generate Articles</a>
<a href="{{ url_for('admin.template_edit', template_id=template.id) }}" class="btn-outline">Edit Template</a>
</div>
</header>
<!-- Add Single Row -->
<div class="card mb-6">
<h3 class="text-base font-semibold mb-4">Add Data Row</h3>
<form method="post" action="{{ url_for('admin.template_data_add', template_id=template.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.75rem;" class="mb-4">
{% for field in schema %}
<div>
<label class="form-label">{{ field.label }}</label>
<input type="{{ 'number' if field.get('field_type') in ('number', 'float') else 'text' }}"
name="{{ field.name }}" class="form-input"
{% if field.get('field_type') == 'float' %}step="any"{% endif %}
{% if field.get('required') %}required{% endif %}>
</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-sm">Add Row</button>
</form>
</div>
<!-- CSV Upload -->
<div class="card mb-6">
<h3 class="text-base font-semibold mb-4">Bulk Upload (CSV)</h3>
<form method="post" action="{{ url_for('admin.template_data_upload', template_id=template.id) }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="flex items-end gap-3">
<div style="flex: 1;">
<label class="form-label" for="csv_file">CSV File</label>
<input type="file" id="csv_file" name="csv_file" accept=".csv" class="form-input" required>
</div>
<button type="submit" class="btn btn-sm">Upload</button>
</div>
<p class="form-hint mt-1">CSV headers should match field names: {{ schema | map(attribute='name') | join(', ') }}</p>
</form>
</div>
<!-- Data Rows -->
<div class="card">
<h3 class="text-base font-semibold mb-4">Data Rows</h3>
{% if data_rows %}
<div class="scenario-widget__table-wrap">
<table class="table">
<thead>
<tr>
<th>#</th>
{% for field in schema[:5] %}
<th>{{ field.label }}</th>
{% endfor %}
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for row in data_rows %}
<tr>
<td class="mono text-sm">{{ row.id }}</td>
{% for field in schema[:5] %}
<td class="text-sm">{{ row.parsed_data.get(field.name, '') }}</td>
{% endfor %}
<td>
{% if row.article_id %}
<span class="badge-success">Generated</span>
{% if row.article_url %}
<a href="{{ row.article_url }}" class="text-xs ml-1">View</a>
{% endif %}
{% else %}
<span class="badge-warning">Pending</span>
{% endif %}
</td>
<td class="text-right">
<form method="post" action="{{ url_for('admin.template_data_delete', template_id=template.id, data_id=row.id) }}" class="m-0" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Delete this data row?')">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-slate text-sm">No data rows yet. Add some above or upload a CSV.</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,111 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "templates" %}
{% block title %}{{ config_data.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">&larr; Templates</a>
<header class="flex justify-between items-center mt-4 mb-6">
<div>
<h1 class="text-2xl">{{ config_data.name }}</h1>
<p class="text-slate text-sm mono">{{ config_data.slug }}</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('admin.template_generate', slug=config_data.slug) }}" class="btn">Generate Articles</a>
<form method="post" action="{{ url_for('admin.template_regenerate', slug=config_data.slug) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline" onclick="return confirm('Regenerate all articles for this template with fresh data?')">
Regenerate
</button>
</form>
</div>
</header>
{# Config section #}
<div class="card mb-6">
<h2 class="text-lg mb-4">Configuration (read-only)</h2>
<table class="table">
<tbody>
<tr><td class="font-semibold" style="width:200px">Content Type</td><td><span class="badge">{{ config_data.content_type }}</span></td></tr>
<tr><td class="font-semibold">Data Table</td><td class="mono">{{ config_data.data_table }}</td></tr>
<tr><td class="font-semibold">Natural Key</td><td class="mono">{{ config_data.natural_key }}</td></tr>
<tr><td class="font-semibold">Languages</td><td>{{ config_data.languages | join(', ') }}</td></tr>
<tr><td class="font-semibold">URL Pattern</td><td class="mono text-sm">{{ config_data.url_pattern }}</td></tr>
<tr><td class="font-semibold">Title Pattern</td><td class="text-sm">{{ config_data.title_pattern }}</td></tr>
<tr><td class="font-semibold">Meta Description</td><td class="text-sm">{{ config_data.meta_description_pattern }}</td></tr>
<tr><td class="font-semibold">Schema Types</td><td>{{ config_data.schema_type | join(', ') }}</td></tr>
{% if config_data.priority_column %}
<tr><td class="font-semibold">Priority Column</td><td class="mono">{{ config_data.priority_column }}</td></tr>
{% endif %}
</tbody>
</table>
<p class="text-slate text-sm mt-4">Edit this template in the repo: <code>content/templates/{{ config_data.slug }}.md.jinja</code></p>
</div>
{# Stats #}
<div class="flex gap-4 mb-6">
<div class="card" style="flex:1; text-align:center">
<div class="text-2xl font-bold">{{ columns | length }}</div>
<div class="text-slate text-sm">Columns</div>
</div>
<div class="card" style="flex:1; text-align:center">
<div class="text-2xl font-bold">{{ sample_rows | length }}{% if sample_rows | length >= 10 %}+{% endif %}</div>
<div class="text-slate text-sm">Data Rows</div>
</div>
<div class="card" style="flex:1; text-align:center">
<div class="text-2xl font-bold">{{ generated_count }}</div>
<div class="text-slate text-sm">Generated</div>
</div>
</div>
{# Columns #}
<div class="card mb-6">
<h2 class="text-lg mb-4">Available Columns</h2>
{% if columns %}
<div style="display:flex; flex-wrap:wrap; gap:8px">
{% for col in columns %}
<span class="badge" title="{{ col.type }}">{{ col.name }} <span class="text-slate">({{ col.type }})</span></span>
{% endfor %}
</div>
{% else %}
<p class="text-slate text-sm">No columns found. Is the DuckDB table <code>{{ config_data.data_table }}</code> available?</p>
{% endif %}
</div>
{# Sample data #}
<div class="card mb-6">
<h2 class="text-lg mb-4">Sample Data (first 10 rows)</h2>
{% if sample_rows %}
<div style="overflow-x:auto">
<table class="table text-sm">
<thead>
<tr>
{% for col in columns %}
<th>{{ col.name }}</th>
{% endfor %}
<th>Preview</th>
</tr>
</thead>
<tbody>
{% for row in sample_rows %}
<tr>
{% for col in columns %}
<td class="mono" style="max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">
{{ row[col.name] }}
</td>
{% endfor %}
<td>
<a href="{{ url_for('admin.template_preview', slug=config_data.slug, row_key=row[config_data.natural_key]) }}"
class="btn-outline btn-sm">Preview</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-slate text-sm">No data available. Run the data pipeline first.</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,70 +0,0 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "templates" %}
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article Template - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<div style="max-width: 48rem; margin: 0 auto;">
<a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">&larr; Back to templates</a>
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Article Template</h1>
<form method="post" class="card">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-4">
<div>
<label class="form-label" for="name">Name</label>
<input type="text" id="name" name="name" value="{{ data.get('name', '') }}" class="form-input" required>
</div>
<div>
<label class="form-label" for="slug">Slug</label>
<input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}" class="form-input"
placeholder="auto-generated from name" {% if editing %}readonly{% endif %}>
</div>
</div>
<div class="mb-4">
<label class="form-label" for="content_type">Content Type</label>
<select id="content_type" name="content_type" class="form-input" {% if editing %}disabled{% endif %}>
<option value="calculator" {% if data.get('content_type') == 'calculator' %}selected{% endif %}>Calculator</option>
<option value="map" {% if data.get('content_type') == 'map' %}selected{% endif %}>Map (future)</option>
</select>
</div>
<div class="mb-4">
<label class="form-label" for="input_schema">Input Schema (JSON)</label>
<textarea id="input_schema" name="input_schema" rows="6" class="form-input" style="font-family: var(--font-mono); font-size: 0.8125rem;">{{ data.get('input_schema', '[{"name": "city", "label": "City", "field_type": "text", "required": true}, {"name": "city_slug", "label": "City Slug", "field_type": "text", "required": true}, {"name": "country", "label": "Country", "field_type": "text", "required": true}, {"name": "region", "label": "Region", "field_type": "text", "required": false}]') }}</textarea>
<p class="form-hint">JSON array of field definitions: [{name, label, field_type, required}]</p>
</div>
<div class="mb-4">
<label class="form-label" for="url_pattern">URL Pattern</label>
<input type="text" id="url_pattern" name="url_pattern" value="{{ data.get('url_pattern', '') }}"
class="form-input" placeholder="/padel-court-cost-{{ '{{' }} city_slug {{ '}}' }}" required>
<p class="form-hint">Jinja2 template string. Use {{ '{{' }} variable {{ '}}' }} placeholders from data rows.</p>
</div>
<div class="mb-4">
<label class="form-label" for="title_pattern">Title Pattern</label>
<input type="text" id="title_pattern" name="title_pattern" value="{{ data.get('title_pattern', '') }}"
class="form-input" placeholder="Padel Center Cost in {{ '{{' }} city {{ '}}' }}" required>
</div>
<div class="mb-4">
<label class="form-label" for="meta_description_pattern">Meta Description Pattern</label>
<input type="text" id="meta_description_pattern" name="meta_description_pattern"
value="{{ data.get('meta_description_pattern', '') }}" class="form-input"
placeholder="How much does it cost to build a padel center in {{ '{{' }} city {{ '}}' }}?">
</div>
<div class="mb-4">
<label class="form-label" for="body_template">Body Template (Markdown + Jinja2)</label>
<textarea id="body_template" name="body_template" rows="20" class="form-input"
style="font-family: var(--font-mono); font-size: 0.8125rem;" required>{{ data.get('body_template', '') }}</textarea>
<p class="form-hint">Markdown with {{ '{{' }} variable {{ '}}' }} placeholders. Use [scenario:{{ '{{' }} scenario_slug {{ '}}' }}] to embed financial widgets. Sections: [scenario:slug:capex], [scenario:slug:operating], [scenario:slug:cashflow], [scenario:slug:returns], [scenario:slug:full].</p>
</div>
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update{% else %}Create{% endif %} Template</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "templates" %}
{% block title %}Preview - {{ preview.title }} - Admin{% endblock %}
{% block admin_content %}
<a href="{{ url_for('admin.template_detail', slug=config.slug) }}" class="text-sm text-slate">&larr; Back to template</a>
<header class="mt-4 mb-6">
<h1 class="text-2xl">Article Preview</h1>
<p class="text-slate text-sm">Language: {{ lang }} | URL: <code>{{ preview.url_path }}</code></p>
</header>
{# Meta preview #}
<div class="card mb-6">
<h2 class="text-lg mb-2">SEO Preview</h2>
<div style="font-family: Arial, sans-serif; max-width: 600px;">
<div style="color: #1a0dab; font-size: 20px; line-height: 1.3;">{{ preview.title }}</div>
<div style="color: #006621; font-size: 14px; margin: 2px 0;">{{ preview.url_path }}</div>
<div style="color: #545454; font-size: 14px; line-height: 1.58;">{{ preview.meta_description }}</div>
</div>
</div>
{# Rendered article #}
<div class="card">
<h2 class="text-lg mb-4">Rendered HTML</h2>
<div class="prose" style="max-width: none;">
{{ preview.html | safe }}
</div>
</div>
{% endblock %}

View File

@@ -1,18 +1,15 @@
{% extends "admin/base_admin.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "templates" %} {% set admin_page = "templates" %}
{% block title %}Article Templates - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Content Templates - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %} {% block admin_content %}
<header class="flex justify-between items-center mb-8"> <header class="flex justify-between items-center mb-8">
<div> <div>
<h1 class="text-2xl">Article Templates</h1> <h1 class="text-2xl">Content Templates</h1>
<p class="text-slate text-sm">{{ templates | length }} template{{ 's' if templates | length != 1 }}</p> <p class="text-slate text-sm">{{ templates | length }} template{{ 's' if templates | length != 1 }} (scanned from git)</p>
</div> </div>
<div class="flex gap-2">
<a href="{{ url_for('admin.template_new') }}" class="btn">New Template</a>
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a> <a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
</div>
</header> </header>
<div class="card"> <div class="card">
@@ -23,29 +20,32 @@
<th>Name</th> <th>Name</th>
<th>Slug</th> <th>Slug</th>
<th>Type</th> <th>Type</th>
<th>Data Table</th>
<th>Data Rows</th> <th>Data Rows</th>
<th>Generated</th> <th>Generated</th>
<th>Languages</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for t in templates %} {% for t in templates %}
<tr> <tr>
<td><a href="{{ url_for('admin.template_data', template_id=t.id) }}">{{ t.name }}</a></td> <td><a href="{{ url_for('admin.template_detail', slug=t.slug) }}">{{ t.name }}</a></td>
<td class="mono text-sm">{{ t.slug }}</td> <td class="mono text-sm">{{ t.slug }}</td>
<td><span class="badge">{{ t.content_type }}</span></td> <td><span class="badge">{{ t.content_type }}</span></td>
<td class="mono text-sm">{{ t.data_table }}</td>
<td class="mono">{{ t.data_count }}</td> <td class="mono">{{ t.data_count }}</td>
<td class="mono">{{ t.generated_count }}</td> <td class="mono">{{ t.generated_count }}</td>
<td class="text-sm">{{ t.languages | join(', ') }}</td>
<td class="text-right"> <td class="text-right">
<a href="{{ url_for('admin.template_edit', template_id=t.id) }}" class="btn-outline btn-sm">Edit</a> <a href="{{ url_for('admin.template_generate', slug=t.slug) }}" class="btn btn-sm">Generate</a>
<a href="{{ url_for('admin.template_generate', template_id=t.id) }}" class="btn btn-sm">Generate</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
<p class="text-slate text-sm">No templates yet. Create one to get started.</p> <p class="text-slate text-sm">No templates found. Add <code>.md.jinja</code> files to <code>content/templates/</code> in the repo.</p>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,501 @@
"""
SSG-inspired pSEO content engine.
Templates live in git as .md.jinja files with YAML frontmatter.
Data comes from DuckDB serving tables. Only articles + published_scenarios
are stored in SQLite (routing / application state).
"""
import json
import re
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
import mistune
import yaml
from jinja2 import Environment
from ..analytics import fetch_analytics
from ..core import execute, fetch_one, slugify
# ── Constants ────────────────────────────────────────────────────────────────
TEMPLATES_DIR = Path(__file__).parent / "templates"
BUILD_DIR = Path("data/content/_build")
_REQUIRED_FRONTMATTER = {
"name", "slug", "content_type", "data_table",
"natural_key", "languages", "url_pattern", "title_pattern",
"meta_description_pattern",
}
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
# FAQ extraction: **bold question** followed by answer paragraph(s)
_FAQ_RE = re.compile(
r"\*\*(.+?)\*\*\s*\n((?:(?!\*\*).+\n?)+)",
re.MULTILINE,
)
# ── Template discovery & loading ─────────────────────────────────────────────
def discover_templates() -> list[dict]:
"""Scan TEMPLATES_DIR for .md.jinja files, return parsed frontmatter list."""
templates = []
if not TEMPLATES_DIR.exists():
return templates
for path in sorted(TEMPLATES_DIR.glob("*.md.jinja")):
try:
config = _parse_frontmatter(path.read_text())
config["_path"] = str(path)
templates.append(config)
except (ValueError, yaml.YAMLError):
continue
return templates
def load_template(slug: str) -> dict:
"""Load a single template by slug. Returns frontmatter + body_template."""
path = TEMPLATES_DIR / f"{slug}.md.jinja"
assert path.exists(), f"Template not found: {slug}"
text = path.read_text()
config = _parse_frontmatter(text)
# Everything after the closing --- is the body template
match = _FRONTMATTER_RE.match(text)
assert match, f"No frontmatter in {slug}"
config["body_template"] = text[match.end():]
return config
def _parse_frontmatter(text: str) -> dict:
"""Extract YAML frontmatter from a template file."""
match = _FRONTMATTER_RE.match(text)
if not match:
raise ValueError("No YAML frontmatter found")
config = yaml.safe_load(match.group(1))
assert isinstance(config, dict), "Frontmatter must be a YAML mapping"
missing = _REQUIRED_FRONTMATTER - set(config.keys())
assert not missing, f"Missing frontmatter keys: {missing}"
# Normalize schema_type to list
schema_type = config.get("schema_type", "Article")
if isinstance(schema_type, str):
schema_type = [schema_type]
config["schema_type"] = schema_type
return config
# ── DuckDB data access ───────────────────────────────────────────────────────
async def get_table_columns(data_table: str) -> list[dict]:
"""Query DuckDB information_schema for a serving table's columns."""
assert "." in data_table, "data_table must be schema-qualified (e.g. serving.xxx)"
schema, table = data_table.split(".", 1)
rows = await fetch_analytics(
"""SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = ? AND table_name = ?
ORDER BY ordinal_position""",
[schema, table],
)
return [{"name": r["column_name"], "type": r["data_type"]} for r in rows]
async def fetch_template_data(
data_table: str,
order_by: str | None = None,
limit: int = 500,
) -> list[dict]:
"""Fetch all rows from a DuckDB serving table."""
assert "." in data_table, "data_table must be schema-qualified"
_validate_table_name(data_table)
order_clause = f"ORDER BY {order_by} DESC" if order_by else ""
return await fetch_analytics(
f"SELECT * FROM {data_table} {order_clause} LIMIT ?",
[limit],
)
def _validate_table_name(data_table: str) -> None:
"""Guard against SQL injection in table names."""
assert re.match(r"^[a-z_][a-z0-9_.]*$", data_table), (
f"Invalid table name: {data_table}"
)
# ── Rendering helpers ────────────────────────────────────────────────────────
def _render_pattern(pattern: str, context: dict) -> str:
"""Render a Jinja2 pattern string with context variables."""
env = Environment()
env.filters["slugify"] = slugify
return env.from_string(pattern).render(**context)
def _extract_faq_pairs(markdown: str) -> list[dict]:
"""Extract FAQ Q&A pairs from a ## FAQ section in markdown."""
# Find the FAQ section
faq_start = markdown.find("## FAQ")
if faq_start == -1:
return []
# Take content until next ## heading or end
rest = markdown[faq_start:]
next_h2 = rest.find("\n## ", 1)
faq_block = rest[:next_h2] if next_h2 > 0 else rest
pairs = []
for match in _FAQ_RE.finditer(faq_block):
question = match.group(1).strip()
answer = match.group(2).strip()
if question and answer:
pairs.append({"question": question, "answer": answer})
return pairs
# ── JSON-LD structured data ──────────────────────────────────────────────────
def build_jsonld(
schema_types: list[str],
*,
title: str,
description: str,
url: str,
published_at: str,
date_modified: str,
language: str,
breadcrumbs: list[dict],
faq_pairs: list[dict] | None = None,
) -> list[dict]:
"""Build JSON-LD structured data objects for an article."""
objects = []
# BreadcrumbList — always present
objects.append({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": i + 1,
"name": bc["name"],
"item": bc["url"],
}
for i, bc in enumerate(breadcrumbs)
],
})
# Article
if "Article" in schema_types:
objects.append({
"@context": "https://schema.org",
"@type": "Article",
"headline": title[:110],
"description": description[:200],
"url": url,
"inLanguage": language,
"datePublished": published_at,
"dateModified": date_modified,
"author": {
"@type": "Organization",
"name": "Padelnomics",
"url": "https://padelnomics.io",
},
"publisher": {
"@type": "Organization",
"name": "Padelnomics",
"url": "https://padelnomics.io",
},
})
# FAQPage
if "FAQPage" in schema_types and faq_pairs:
objects.append({
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": faq["question"],
"acceptedAnswer": {
"@type": "Answer",
"text": faq["answer"],
},
}
for faq in faq_pairs
],
})
return objects
def _build_breadcrumbs(url_path: str, base_url: str) -> list[dict]:
"""Build breadcrumb list from URL path segments."""
parts = [p for p in url_path.strip("/").split("/") if p]
crumbs = [{"name": "Home", "url": base_url + "/"}]
for i, part in enumerate(parts):
label = part.replace("-", " ").title()
path = "/" + "/".join(parts[: i + 1])
crumbs.append({"name": label, "url": base_url + path})
return crumbs
# ── Article generation pipeline ──────────────────────────────────────────────
async def generate_articles(
slug: str,
start_date: date,
articles_per_day: int,
*,
limit: int = 500,
base_url: str = "https://padelnomics.io",
) -> int:
"""
Generate articles from a git template + DuckDB data.
For each row in the DuckDB table x each language:
- render patterns (url, title, meta)
- create/update published_scenario if calculator type
- render body markdown -> HTML
- bake scenario cards
- inject SEO head (canonical, hreflang, JSON-LD, OG)
- write HTML to disk
- upsert article row in SQLite
Returns count of articles generated.
"""
from ..planner.calculator import DEFAULTS, calc, validate_state
from .routes import bake_scenario_cards, is_reserved_path
assert articles_per_day > 0, "articles_per_day must be positive"
config = load_template(slug)
order_by = config.get("priority_column")
rows = await fetch_template_data(config["data_table"], order_by=order_by, limit=limit)
if not rows:
return 0
publish_date = start_date
published_today = 0
generated = 0
now_iso = datetime.now(UTC).isoformat()
for row in rows:
for lang in config["languages"]:
# Build render context: row data + language
ctx = {**row, "language": lang}
# Render URL pattern
url_path = f"/{lang}" + _render_pattern(config["url_pattern"], ctx)
if is_reserved_path(url_path):
continue
title = _render_pattern(config["title_pattern"], ctx)
meta_desc = _render_pattern(config["meta_description_pattern"], ctx)
article_slug = slug + "-" + lang + "-" + str(row[config["natural_key"]])
# Calculator content type: create scenario
scenario_slug = None
if config["content_type"] == "calculator":
calc_overrides = {k: v for k, v in row.items() if k in DEFAULTS}
state = validate_state(calc_overrides)
d = calc(state, lang=lang)
scenario_slug = slug + "-" + str(row[config["natural_key"]])
dbl = state.get("dblCourts", 0)
sgl = state.get("sglCourts", 0)
court_config = f"{dbl} double + {sgl} single"
city = row.get("city_name", row.get("city", ""))
country = row.get("country", state.get("country", ""))
# Upsert published scenario
existing = await fetch_one(
"SELECT id FROM published_scenarios WHERE slug = ?",
(scenario_slug,),
)
if existing:
await execute(
"""UPDATE published_scenarios
SET state_json = ?, calc_json = ?, updated_at = ?
WHERE slug = ?""",
(json.dumps(state), json.dumps(d), now_iso, scenario_slug),
)
else:
await execute(
"""INSERT INTO published_scenarios
(slug, title, location, country, venue_type, ownership,
court_config, state_json, calc_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
scenario_slug, city, city, country,
state.get("venue", "indoor"),
state.get("own", "rent"),
court_config,
json.dumps(state), json.dumps(d), now_iso,
),
)
ctx["scenario_slug"] = scenario_slug
# Render body template
body_md = _render_pattern(config["body_template"], ctx)
body_html = mistune.html(body_md)
body_html = await bake_scenario_cards(body_html, lang=lang)
# Extract FAQ pairs for structured data
faq_pairs = _extract_faq_pairs(body_md)
# Build SEO metadata
full_url = base_url + url_path
publish_dt = datetime(
publish_date.year, publish_date.month, publish_date.day,
8, 0, 0,
).isoformat()
# Hreflang links
hreflang_links = []
for alt_lang in config["languages"]:
alt_url = f"/{alt_lang}" + _render_pattern(config["url_pattern"], {**row, "language": alt_lang})
hreflang_links.append(
f'<link rel="alternate" hreflang="{alt_lang}" href="{base_url}{alt_url}" />'
)
# x-default points to English (or first language)
default_lang = "en" if "en" in config["languages"] else config["languages"][0]
default_url = f"/{default_lang}" + _render_pattern(config["url_pattern"], {**row, "language": default_lang})
hreflang_links.append(
f'<link rel="alternate" hreflang="x-default" href="{base_url}{default_url}" />'
)
# JSON-LD
breadcrumbs = _build_breadcrumbs(url_path, base_url)
jsonld_objects = build_jsonld(
config["schema_type"],
title=title,
description=meta_desc,
url=full_url,
published_at=publish_dt,
date_modified=now_iso,
language=lang,
breadcrumbs=breadcrumbs,
faq_pairs=faq_pairs,
)
# Build SEO head block
seo_head = "\n".join([
f'<link rel="canonical" href="{full_url}" />',
*hreflang_links,
f'<meta property="og:title" content="{_escape_attr(title)}" />',
f'<meta property="og:description" content="{_escape_attr(meta_desc)}" />',
f'<meta property="og:url" content="{full_url}" />',
'<meta property="og:type" content="article" />',
*[
f'<script type="application/ld+json">{json.dumps(obj, ensure_ascii=False)}</script>'
for obj in jsonld_objects
],
])
# Write HTML to disk
build_dir = BUILD_DIR / lang
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / f"{article_slug}.html").write_text(body_html)
# Upsert article in SQLite
existing_article = await fetch_one(
"SELECT id FROM articles WHERE url_path = ?", (url_path,),
)
if existing_article:
await execute(
"""UPDATE articles
SET title = ?, meta_description = ?, template_slug = ?,
language = ?, date_modified = ?, updated_at = ?,
seo_head = ?
WHERE url_path = ?""",
(title, meta_desc, slug, lang, now_iso, now_iso, seo_head, url_path),
)
else:
await execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, country, region,
status, published_at, template_slug, language, date_modified,
seo_head, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?)""",
(
url_path, article_slug, title, meta_desc,
row.get("country", ""), row.get("region", ""),
publish_dt, slug, lang, now_iso, seo_head, now_iso,
),
)
generated += 1
# Stagger dates
published_today += 1
if published_today >= articles_per_day:
published_today = 0
publish_date += timedelta(days=1)
return generated
async def preview_article(
slug: str,
row_key: str,
lang: str = "en",
base_url: str = "https://padelnomics.io",
) -> dict:
"""
Render one article in-memory for admin preview.
No disk write, no DB insert. Returns {title, url_path, html, meta_description}.
"""
from ..planner.calculator import DEFAULTS, calc, validate_state
from .routes import bake_scenario_cards
config = load_template(slug)
# Fetch one row by natural key
_validate_table_name(config["data_table"])
natural_key = config["natural_key"]
rows = await fetch_analytics(
f"SELECT * FROM {config['data_table']} WHERE {natural_key} = ? LIMIT 1",
[row_key],
)
assert rows, f"No row found for {natural_key}={row_key}"
row = rows[0]
ctx = {**row, "language": lang}
url_path = f"/{lang}" + _render_pattern(config["url_pattern"], ctx)
title = _render_pattern(config["title_pattern"], ctx)
meta_desc = _render_pattern(config["meta_description_pattern"], ctx)
# Calculator: compute scenario in-memory
if config["content_type"] == "calculator":
calc_overrides = {k: v for k, v in row.items() if k in DEFAULTS}
state = validate_state(calc_overrides)
calc(state, lang=lang) # validate state produces valid output
ctx["scenario_slug"] = slug + "-" + str(row[natural_key])
body_md = _render_pattern(config["body_template"], ctx)
body_html = mistune.html(body_md)
body_html = await bake_scenario_cards(body_html, lang=lang)
return {
"title": title,
"url_path": url_path,
"meta_description": meta_desc,
"html": body_html,
}
def _escape_attr(text: str) -> str:
"""Escape text for use in HTML attribute values."""
return text.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;")

View File

@@ -215,6 +215,11 @@ async def article_page(url_path: str):
if not article: if not article:
abort(404) abort(404)
# SSG articles: language-prefixed build path
lang = article["language"] if article.get("language") else "en"
build_path = BUILD_DIR / lang / f"{article['slug']}.html"
if not build_path.exists():
# Fallback: flat build path (legacy manual articles)
build_path = BUILD_DIR / f"{article['slug']}.html" build_path = BUILD_DIR / f"{article['slug']}.html"
if not build_path.exists(): if not build_path.exists():
abort(404) abort(404)

View File

@@ -0,0 +1,64 @@
---
name: "DE City Padel Costs"
slug: city-cost-de
content_type: calculator
data_table: serving.pseo_city_costs_de
natural_key: city_slug
languages: [de, en]
url_pattern: "/markets/{{ country_name_en | lower | slugify }}/{{ city_slug }}"
title_pattern: "Padel in {{ city_name }} — Market Analysis & Costs"
meta_description_pattern: "How much does it cost to build a padel center in {{ city_name }}? {{ padel_venue_count }} venues, pricing data & financial model."
schema_type: [Article, FAQPage]
priority_column: population
---
# Padel in {{ city_name }}
{{ city_name }} ({{ country_name_en }}) is home to **{{ padel_venue_count }}** padel venues, serving a population of {{ population | int | default(0) }} residents. That gives the city a venue density of {{ venues_per_100k | round(1) }} venues per 100,000 people.
## Market Overview
The padel market in {{ city_name }} shows a market score of **{{ market_score | round(1) }}** based on our analysis of venue density, pricing, and occupancy data.
| Metric | Value |
|--------|-------|
| Venues | {{ padel_venue_count }} |
| Venues per 100k | {{ venues_per_100k | round(1) }} |
| Market Score | {{ market_score | round(1) }} |
| Data Confidence | {{ data_confidence }} |
## Pricing
Court rental rates in {{ city_name }}:
- **Peak hours**: {{ median_peak_rate | round(0) | int }} per hour
- **Off-peak hours**: {{ median_offpeak_rate | round(0) | int }} per hour
- **Average hourly rate**: {{ median_hourly_rate | round(0) | int }} per hour
## What Would It Cost to Build?
Based on current market data for {{ city_name }}, here is what a padel center investment looks like:
[scenario:{{ scenario_slug }}:capex]
## Revenue Potential
[scenario:{{ scenario_slug }}:operating]
## Financial Returns
[scenario:{{ scenario_slug }}:returns]
## FAQ
**How much does it cost to build a padel center in {{ city_name }}?**
Based on our financial model, building a padel center in {{ city_name }} with typical court configurations requires a total investment that depends on venue type (indoor vs outdoor), land costs, and construction standards in {{ country_name_en }}.
**How many padel courts are there in {{ city_name }}?**
{{ city_name }} currently has {{ padel_venue_count }} padel venues. With a population of {{ population | int | default(0) }}, this translates to {{ venues_per_100k | round(1) }} venues per 100,000 residents.
**Is {{ city_name }} a good location for a padel center?**
{{ city_name }} has a market score of {{ market_score | round(1) }} based on our analysis. Factors include current venue density, pricing levels, and estimated occupancy rates.
**What are typical padel court rental prices in {{ city_name }}?**
Peak hour rates average around {{ median_peak_rate | round(0) | int }} per hour, while off-peak rates are approximately {{ median_offpeak_rate | round(0) | int }} per hour.

View File

@@ -0,0 +1,125 @@
"""Drop old CMS intermediary tables, recreate articles + published_scenarios.
article_templates and template_data are replaced by git-based .md.jinja
template files + direct DuckDB reads. Nothing was published yet so
this is a clean-slate migration.
published_scenarios and articles had FK references to template_data(id).
SQLite requires full table recreation to remove FK columns, so we do
the standard create-copy-drop-rename dance for both tables.
"""
def up(conn):
# ── 1. Drop articles FTS triggers + virtual table ──────────────────
conn.execute("DROP TRIGGER IF EXISTS articles_ai")
conn.execute("DROP TRIGGER IF EXISTS articles_ad")
conn.execute("DROP TRIGGER IF EXISTS articles_au")
conn.execute("DROP TABLE IF EXISTS articles_fts")
# ── 2. Drop old intermediary tables ────────────────────────────────
conn.execute("DROP TABLE IF EXISTS template_data")
conn.execute("DROP TABLE IF EXISTS article_templates")
# ── 3. Recreate published_scenarios without template_data_id FK ────
conn.execute("""
CREATE TABLE published_scenarios_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
subtitle TEXT,
location TEXT NOT NULL,
country TEXT NOT NULL,
venue_type TEXT NOT NULL DEFAULT 'indoor',
ownership TEXT NOT NULL DEFAULT 'rent',
court_config TEXT NOT NULL,
state_json TEXT NOT NULL,
calc_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
)
""")
conn.execute("""
INSERT INTO published_scenarios_new
(id, slug, title, subtitle, location, country,
venue_type, ownership, court_config, state_json, calc_json,
created_at, updated_at)
SELECT id, slug, title, subtitle, location, country,
venue_type, ownership, court_config, state_json, calc_json,
created_at, updated_at
FROM published_scenarios
""")
conn.execute("DROP TABLE published_scenarios")
conn.execute("ALTER TABLE published_scenarios_new RENAME TO published_scenarios")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_pub_scenarios_slug"
" ON published_scenarios(slug)"
)
# ── 4. Recreate articles without template_data_id, add SSG columns ─
conn.execute("""
CREATE TABLE articles_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_path TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
meta_description TEXT,
country TEXT,
region TEXT,
og_image_url TEXT,
status TEXT NOT NULL DEFAULT 'draft',
published_at TEXT,
template_slug TEXT,
language TEXT NOT NULL DEFAULT 'en',
date_modified TEXT,
seo_head TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
)
""")
conn.execute("""
INSERT INTO articles_new
(id, url_path, slug, title, meta_description, country, region,
og_image_url, status, published_at, created_at, updated_at)
SELECT id, url_path, slug, title, meta_description, country, region,
og_image_url, status, published_at, created_at, updated_at
FROM articles
""")
conn.execute("DROP TABLE articles")
conn.execute("ALTER TABLE articles_new RENAME TO articles")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path)"
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_articles_status"
" ON articles(status, published_at)"
)
# ── 5. Recreate articles FTS + triggers ────────────────────────────
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
title, meta_description, country, region,
content='articles', content_rowid='id'
)
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
END
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
END
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
END
""")

View File

@@ -81,43 +81,67 @@ async def _create_article(slug="test-article", url_path="/test-article",
) )
async def _create_template(): TEST_TEMPLATE = """\
"""Insert a template + 3 data rows, return (template_id, data_row_count).""" ---
template_id = await execute( name: "Test City Analysis"
"""INSERT INTO article_templates slug: test-city
(name, slug, content_type, input_schema, url_pattern, content_type: calculator
title_pattern, meta_description_pattern, body_template) data_table: serving.test_cities
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", natural_key: city_slug
( languages: [en]
"City Cost Analysis", "city-cost", "calculator", url_pattern: "/markets/{{ country | lower }}/{{ city_slug }}"
json.dumps([ title_pattern: "Padel in {{ city }}"
{"name": "city", "label": "City", "field_type": "text", "required": True}, meta_description_pattern: "Padel costs in {{ city }}"
{"name": "city_slug", "label": "Slug", "field_type": "text", "required": True}, schema_type: Article
{"name": "country", "label": "Country", "field_type": "text", "required": True}, ---
{"name": "region", "label": "Region", "field_type": "text", "required": False}, # Padel in {{ city }}
{"name": "electricity", "label": "Electricity", "field_type": "number", "required": False},
]),
"/padel-court-cost-{{ city_slug }}",
"Padel Center Cost in {{ city }}",
"How much does a padel center cost in {{ city }}?",
"# Padel in {{ city }}\n\n[scenario:{{ scenario_slug }}]\n\n## CAPEX\n\n[scenario:{{ scenario_slug }}:capex]",
),
)
cities = [ Welcome to {{ city }}.
("Miami", "miami", "US", "North America", 700),
("Madrid", "madrid", "ES", "Europe", 500), [scenario:{{ scenario_slug }}:capex]
("Berlin", "berlin", "DE", "Europe", 550), """
TEST_ROWS = [
{"city": "Miami", "city_slug": "miami", "country": "US", "region": "North America", "electricity": 700},
{"city": "Madrid", "city_slug": "madrid", "country": "ES", "region": "Europe", "electricity": 500},
{"city": "Berlin", "city_slug": "berlin", "country": "DE", "region": "Europe", "electricity": 550},
] ]
for city, slug, country, region, elec in cities:
await execute( TEST_COLUMNS = [
"INSERT INTO template_data (template_id, data_json) VALUES (?, ?)", {"column_name": "city", "data_type": "VARCHAR"},
(template_id, json.dumps({ {"column_name": "city_slug", "data_type": "VARCHAR"},
"city": city, "city_slug": slug, "country": country, {"column_name": "country", "data_type": "VARCHAR"},
"region": region, "electricity": elec, {"column_name": "region", "data_type": "VARCHAR"},
})), {"column_name": "electricity", "data_type": "INTEGER"},
) ]
return template_id, len(cities)
@pytest.fixture
def pseo_env(tmp_path, monkeypatch):
"""Set up pSEO environment: temp template dir, build dir, mock DuckDB."""
import padelnomics.content as content_mod
tpl_dir = tmp_path / "templates"
tpl_dir.mkdir()
monkeypatch.setattr(content_mod, "TEMPLATES_DIR", tpl_dir)
build_dir = tmp_path / "build"
build_dir.mkdir()
monkeypatch.setattr(content_mod, "BUILD_DIR", build_dir)
(tpl_dir / "test-city.md.jinja").write_text(TEST_TEMPLATE)
async def mock_fetch_analytics(query, params=None):
if "information_schema" in query:
return TEST_COLUMNS
if "WHERE" in query and params:
# preview_article: filter by natural key value
return [r for r in TEST_ROWS if params[0] in r.values()]
return TEST_ROWS
monkeypatch.setattr(content_mod, "fetch_analytics", mock_fetch_analytics)
return {"tpl_dir": tpl_dir, "build_dir": build_dir}
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@@ -401,22 +425,14 @@ class TestBakeScenarioCards:
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
class TestGenerationPipeline: class TestGenerationPipeline:
async def test_generates_correct_count(self, db): async def test_generates_correct_count(self, db, pseo_env):
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, count = await _create_template() generated = await generate_articles("test-city", date(2026, 3, 1), 10)
template = await fetch_one( assert generated == 3 # 3 rows × 1 language
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
generated = await _generate_from_template(dict(template), date(2026, 3, 1), 10)
assert generated == count
async def test_staggered_dates_two_per_day(self, db): async def test_staggered_dates_two_per_day(self, db, pseo_env):
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, _ = await _create_template() await generate_articles("test-city", date(2026, 3, 1), 2)
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 2)
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at") articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
assert len(articles) == 3 assert len(articles) == 3
@@ -426,55 +442,39 @@ class TestGenerationPipeline:
assert dates[1] == "2026-03-01" assert dates[1] == "2026-03-01"
assert dates[2] == "2026-03-02" assert dates[2] == "2026-03-02"
async def test_staggered_dates_one_per_day(self, db): async def test_staggered_dates_one_per_day(self, db, pseo_env):
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, _ = await _create_template() await generate_articles("test-city", date(2026, 3, 1), 1)
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 1)
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at") articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
dates = sorted({a["published_at"][:10] for a in articles}) dates = sorted({a["published_at"][:10] for a in articles})
assert dates == ["2026-03-01", "2026-03-02", "2026-03-03"] assert dates == ["2026-03-01", "2026-03-02", "2026-03-03"]
async def test_article_url_and_title(self, db): async def test_article_url_and_title(self, db, pseo_env):
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, _ = await _create_template() await generate_articles("test-city", date(2026, 3, 1), 10)
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
miami = await fetch_one("SELECT * FROM articles WHERE slug = 'city-cost-miami'") miami = await fetch_one("SELECT * FROM articles WHERE slug = 'test-city-en-miami'")
assert miami is not None assert miami is not None
assert miami["url_path"] == "/padel-court-cost-miami" assert miami["url_path"] == "/en/markets/us/miami"
assert miami["title"] == "Padel Center Cost in Miami" assert miami["title"] == "Padel in Miami"
assert miami["country"] == "US" assert miami["template_slug"] == "test-city"
assert miami["region"] == "North America" assert miami["language"] == "en"
assert miami["status"] == "published" assert miami["status"] == "published"
async def test_scenario_created_per_row(self, db): async def test_scenario_created_per_row(self, db, pseo_env):
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, count = await _create_template() await generate_articles("test-city", date(2026, 3, 1), 10)
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
scenarios = await fetch_all("SELECT * FROM published_scenarios") scenarios = await fetch_all("SELECT * FROM published_scenarios")
assert len(scenarios) == count assert len(scenarios) == 3
async def test_scenario_has_valid_calc_json(self, db): async def test_scenario_has_valid_calc_json(self, db, pseo_env):
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, _ = await _create_template() await generate_articles("test-city", date(2026, 3, 1), 10)
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
scenario = await fetch_one( scenario = await fetch_one(
"SELECT * FROM published_scenarios WHERE slug = 'city-cost-miami'" "SELECT * FROM published_scenarios WHERE slug = 'test-city-miami'"
) )
assert scenario is not None assert scenario is not None
d = json.loads(scenario["calc_json"]) d = json.loads(scenario["calc_json"])
@@ -483,112 +483,302 @@ class TestGenerationPipeline:
assert "irr" in d assert "irr" in d
assert d["capex"] > 0 assert d["capex"] > 0
async def test_template_data_linked(self, db): async def test_build_files_written(self, db, pseo_env):
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, _ = await _create_template() await generate_articles("test-city", date(2026, 3, 1), 10)
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
rows = await fetch_all(
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
)
for row in rows:
assert row["article_id"] is not None, f"Row {row['id']} not linked to article"
assert row["scenario_id"] is not None, f"Row {row['id']} not linked to scenario"
async def test_build_files_written(self, db):
from padelnomics.admin.routes import _generate_from_template
from padelnomics.content.routes import BUILD_DIR
template_id, _ = await _create_template()
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
build_dir = pseo_env["build_dir"]
articles = await fetch_all("SELECT slug FROM articles") articles = await fetch_all("SELECT slug FROM articles")
try:
for a in articles: for a in articles:
build_path = BUILD_DIR / f"{a['slug']}.html" build_path = build_dir / "en" / f"{a['slug']}.html"
assert build_path.exists(), f"Missing build file: {build_path}" assert build_path.exists(), f"Missing build file: {build_path}"
content = build_path.read_text() content = build_path.read_text()
assert len(content) > 100, f"Build file too small: {build_path}" assert len(content) > 50
assert "scenario-widget" in content
finally:
# Cleanup build files
for a in articles:
p = BUILD_DIR / f"{a['slug']}.html"
if p.exists():
p.unlink()
async def test_skips_already_generated(self, db): async def test_updates_existing_on_regeneration(self, db, pseo_env):
"""Running generate twice does not duplicate articles.""" """Running generate twice updates articles, doesn't duplicate."""
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, count = await _create_template()
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
first = await _generate_from_template(dict(template), date(2026, 3, 1), 10) first = await generate_articles("test-city", date(2026, 3, 1), 10)
assert first == count assert first == 3
# Second run: all rows already linked → 0 generated second = await generate_articles("test-city", date(2026, 3, 10), 10)
second = await _generate_from_template(dict(template), date(2026, 3, 10), 10) assert second == 3 # Updates existing
assert second == 0
articles = await fetch_all("SELECT * FROM articles") articles = await fetch_all("SELECT * FROM articles")
assert len(articles) == count assert len(articles) == 3 # No duplicates
# Cleanup async def test_calc_overrides_applied(self, db, pseo_env):
from padelnomics.content.routes import BUILD_DIR
for a in articles:
p = BUILD_DIR / f"{a['slug']}.html"
if p.exists():
p.unlink()
async def test_calc_overrides_applied(self, db):
"""Data row values that match DEFAULTS keys are used as calc overrides.""" """Data row values that match DEFAULTS keys are used as calc overrides."""
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, _ = await _create_template() await generate_articles("test-city", date(2026, 3, 1), 10)
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
# Miami had electricity=700, default is 600 # Miami had electricity=700, default is 600
scenario = await fetch_one( scenario = await fetch_one(
"SELECT * FROM published_scenarios WHERE slug = 'city-cost-miami'" "SELECT * FROM published_scenarios WHERE slug = 'test-city-miami'"
) )
state = json.loads(scenario["state_json"]) state = json.loads(scenario["state_json"])
assert state["electricity"] == 700 assert state["electricity"] == 700
# Cleanup async def test_seo_head_populated(self, db, pseo_env):
from padelnomics.content.routes import BUILD_DIR from padelnomics.content import generate_articles
for slug in ("miami", "madrid", "berlin"): await generate_articles("test-city", date(2026, 3, 1), 10)
p = BUILD_DIR / f"{slug}.html"
if p.exists(): article = await fetch_one("SELECT * FROM articles WHERE slug = 'test-city-en-miami'")
p.unlink() assert article["seo_head"] is not None
assert 'rel="canonical"' in article["seo_head"]
assert 'application/ld+json' in article["seo_head"]
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# Jinja string rendering # Jinja string rendering
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
class TestRenderJinjaString: class TestRenderPattern:
def test_simple(self): def test_simple(self):
from padelnomics.admin.routes import _render_jinja_string from padelnomics.content import _render_pattern
assert _render_jinja_string("Hello {{ name }}!", {"name": "World"}) == "Hello World!" assert _render_pattern("Hello {{ name }}!", {"name": "World"}) == "Hello World!"
def test_missing_var_empty(self): def test_missing_var_empty(self):
from padelnomics.admin.routes import _render_jinja_string from padelnomics.content import _render_pattern
result = _render_jinja_string("Hello {{ missing }}!", {}) result = _render_pattern("Hello {{ missing }}!", {})
assert result == "Hello !" assert result == "Hello !"
def test_url_pattern(self): def test_url_pattern(self):
from padelnomics.admin.routes import _render_jinja_string from padelnomics.content import _render_pattern
result = _render_jinja_string("/padel-court-cost-{{ slug }}", {"slug": "miami"}) result = _render_pattern("/markets/{{ country | lower }}/{{ slug }}", {"country": "US", "slug": "miami"})
assert result == "/padel-court-cost-miami" assert result == "/markets/us/miami"
def test_slugify_filter(self):
from padelnomics.content import _render_pattern
result = _render_pattern("{{ name | slugify }}", {"name": "Hello World"})
assert result == "hello-world"
# ════════════════════════════════════════════════════════════
# Template discovery & loading
# ════════════════════════════════════════════════════════════
class TestDiscoverTemplates:
def test_discovers_templates(self, pseo_env):
from padelnomics.content import discover_templates
templates = discover_templates()
assert len(templates) == 1
assert templates[0]["slug"] == "test-city"
assert templates[0]["name"] == "Test City Analysis"
def test_empty_dir(self, tmp_path, monkeypatch):
import padelnomics.content as content_mod
monkeypatch.setattr(content_mod, "TEMPLATES_DIR", tmp_path / "nonexistent")
from padelnomics.content import discover_templates
assert discover_templates() == []
def test_skips_invalid_frontmatter(self, pseo_env):
from padelnomics.content import discover_templates
(pseo_env["tpl_dir"] / "bad.md.jinja").write_text("no frontmatter here")
templates = discover_templates()
assert len(templates) == 1 # Only the valid test-city template
def test_includes_path(self, pseo_env):
from padelnomics.content import discover_templates
templates = discover_templates()
assert "_path" in templates[0]
assert templates[0]["_path"].endswith("test-city.md.jinja")
class TestLoadTemplate:
def test_loads_config_and_body(self, pseo_env):
from padelnomics.content import load_template
config = load_template("test-city")
assert config["slug"] == "test-city"
assert config["content_type"] == "calculator"
assert config["data_table"] == "serving.test_cities"
assert "body_template" in config
assert "Padel in {{ city }}" in config["body_template"]
def test_missing_template_raises(self, pseo_env):
from padelnomics.content import load_template
with pytest.raises(AssertionError, match="Template not found"):
load_template("nonexistent")
def test_schema_type_normalized_to_list(self, pseo_env):
from padelnomics.content import load_template
config = load_template("test-city")
assert isinstance(config["schema_type"], list)
assert "Article" in config["schema_type"]
def test_languages_parsed(self, pseo_env):
from padelnomics.content import load_template
config = load_template("test-city")
assert config["languages"] == ["en"]
# ════════════════════════════════════════════════════════════
# FAQ extraction
# ════════════════════════════════════════════════════════════
class TestExtractFaqPairs:
def test_extracts_pairs(self):
from padelnomics.content import _extract_faq_pairs
md = (
"# Title\n\n"
"## FAQ\n\n"
"**How much does it cost?**\n"
"It costs about 500k.\n\n"
"**How long does it take?**\n"
"About 12 months.\n\n"
"## Other Section\n"
)
pairs = _extract_faq_pairs(md)
assert len(pairs) == 2
assert pairs[0]["question"] == "How much does it cost?"
assert "500k" in pairs[0]["answer"]
assert pairs[1]["question"] == "How long does it take?"
def test_no_faq_section(self):
from padelnomics.content import _extract_faq_pairs
assert _extract_faq_pairs("# Title\n\nSome content") == []
def test_faq_at_end_of_document(self):
from padelnomics.content import _extract_faq_pairs
md = "## FAQ\n\n**Question one?**\nAnswer one.\n"
pairs = _extract_faq_pairs(md)
assert len(pairs) == 1
assert pairs[0]["question"] == "Question one?"
assert pairs[0]["answer"] == "Answer one."
# ════════════════════════════════════════════════════════════
# Breadcrumbs
# ════════════════════════════════════════════════════════════
class TestBuildBreadcrumbs:
def test_basic_path(self):
from padelnomics.content import _build_breadcrumbs
crumbs = _build_breadcrumbs("/en/markets/germany/berlin", "https://padelnomics.io")
assert len(crumbs) == 5
assert crumbs[0] == {"name": "Home", "url": "https://padelnomics.io/"}
assert crumbs[1] == {"name": "En", "url": "https://padelnomics.io/en"}
assert crumbs[2] == {"name": "Markets", "url": "https://padelnomics.io/en/markets"}
assert crumbs[3] == {"name": "Germany", "url": "https://padelnomics.io/en/markets/germany"}
assert crumbs[4] == {"name": "Berlin", "url": "https://padelnomics.io/en/markets/germany/berlin"}
def test_root_path(self):
from padelnomics.content import _build_breadcrumbs
crumbs = _build_breadcrumbs("/", "https://padelnomics.io")
assert len(crumbs) == 1
assert crumbs[0]["name"] == "Home"
def test_hyphenated_segments_titlecased(self):
from padelnomics.content import _build_breadcrumbs
crumbs = _build_breadcrumbs("/en/my-section", "https://padelnomics.io")
assert crumbs[2]["name"] == "My Section"
# ════════════════════════════════════════════════════════════
# JSON-LD structured data
# ════════════════════════════════════════════════════════════
class TestBuildJsonld:
_COMMON = dict(
title="Test Title",
description="Test description",
url="https://padelnomics.io/en/markets/us/miami",
published_at="2026-01-01T08:00:00",
date_modified="2026-01-02T10:00:00",
language="en",
breadcrumbs=[
{"name": "Home", "url": "https://padelnomics.io/"},
{"name": "Markets", "url": "https://padelnomics.io/en/markets"},
],
)
def test_always_includes_breadcrumbs(self):
from padelnomics.content import build_jsonld
objects = build_jsonld(["Article"], **self._COMMON)
types = [o["@type"] for o in objects]
assert "BreadcrumbList" in types
def test_breadcrumb_positions(self):
from padelnomics.content import build_jsonld
objects = build_jsonld(["Article"], **self._COMMON)
bc = [o for o in objects if o["@type"] == "BreadcrumbList"][0]
items = bc["itemListElement"]
assert items[0]["position"] == 1
assert items[0]["name"] == "Home"
assert items[1]["position"] == 2
def test_article_schema(self):
from padelnomics.content import build_jsonld
objects = build_jsonld(["Article"], **self._COMMON)
article = [o for o in objects if o["@type"] == "Article"][0]
assert article["headline"] == "Test Title"
assert article["inLanguage"] == "en"
assert article["datePublished"] == "2026-01-01T08:00:00"
assert article["dateModified"] == "2026-01-02T10:00:00"
assert article["publisher"]["name"] == "Padelnomics"
def test_headline_truncated_at_110(self):
from padelnomics.content import build_jsonld
long_title = "A" * 200
objects = build_jsonld(["Article"], **{**self._COMMON, "title": long_title})
article = [o for o in objects if o["@type"] == "Article"][0]
assert len(article["headline"]) == 110
def test_faqpage_schema(self):
from padelnomics.content import build_jsonld
faq_pairs = [
{"question": "How much?", "answer": "About 500k."},
{"question": "How long?", "answer": "12 months."},
]
objects = build_jsonld(["Article", "FAQPage"], **self._COMMON, faq_pairs=faq_pairs)
faq = [o for o in objects if o["@type"] == "FAQPage"][0]
assert len(faq["mainEntity"]) == 2
assert faq["mainEntity"][0]["name"] == "How much?"
assert faq["mainEntity"][0]["acceptedAnswer"]["text"] == "About 500k."
def test_faqpage_omitted_without_pairs(self):
from padelnomics.content import build_jsonld
objects = build_jsonld(["FAQPage"], **self._COMMON, faq_pairs=[])
types = [o["@type"] for o in objects]
assert "FAQPage" not in types
def test_no_article_when_not_in_types(self):
from padelnomics.content import build_jsonld
faq_pairs = [{"question": "Q?", "answer": "A."}]
objects = build_jsonld(["FAQPage"], **self._COMMON, faq_pairs=faq_pairs)
types = [o["@type"] for o in objects]
assert "Article" not in types
assert "FAQPage" in types
# ════════════════════════════════════════════════════════════
# Preview article
# ════════════════════════════════════════════════════════════
class TestPreviewArticle:
async def test_preview_returns_rendered_data(self, db, pseo_env):
from padelnomics.content import preview_article
result = await preview_article("test-city", "miami")
assert result["title"] == "Padel in Miami"
assert result["url_path"] == "/en/markets/us/miami"
assert result["meta_description"] == "Padel costs in Miami"
assert "<h1>" in result["html"]
async def test_preview_unknown_row_raises(self, db, pseo_env):
from padelnomics.content import preview_article
with pytest.raises(AssertionError, match="No row found"):
await preview_article("test-city", "nonexistent")
async def test_preview_with_language(self, db, pseo_env):
from padelnomics.content import preview_article
result = await preview_article("test-city", "miami", lang="de")
assert result["url_path"] == "/de/markets/us/miami"
async def test_preview_unknown_template_raises(self, db, pseo_env):
from padelnomics.content import preview_article
with pytest.raises(AssertionError, match="Template not found"):
await preview_article("nonexistent", "miami")
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@@ -772,76 +962,114 @@ class TestAdminTemplates:
resp = await admin_client.get("/admin/templates") resp = await admin_client.get("/admin/templates")
assert resp.status_code == 200 assert resp.status_code == 200
async def test_template_new_form(self, admin_client): async def test_template_list_shows_discovered(self, admin_client, pseo_env):
resp = await admin_client.get("/admin/templates/new") resp = await admin_client.get("/admin/templates")
assert resp.status_code == 200 assert resp.status_code == 200
html = (await resp.data).decode()
assert "Test City Analysis" in html
assert "test-city" in html
async def test_template_create(self, admin_client, db):
class TestAdminTemplateDetail:
async def test_detail_shows_config(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/test-city")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "Test City Analysis" in html
assert "serving.test_cities" in html
async def test_detail_shows_columns(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/test-city")
html = (await resp.data).decode()
assert "city_slug" in html
assert "VARCHAR" in html
async def test_detail_shows_sample_data(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/test-city")
html = (await resp.data).decode()
assert "Miami" in html
assert "Berlin" in html
async def test_detail_unknown_slug_redirects(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/nonexistent")
assert resp.status_code == 302
class TestAdminTemplatePreview:
async def test_preview_renders_article(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/test-city/preview/miami")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "Padel in Miami" in html
async def test_preview_bad_key_redirects(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/test-city/preview/nonexistent")
assert resp.status_code == 302
async def test_preview_bad_template_redirects(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/bad-slug/preview/miami")
assert resp.status_code == 302
class TestAdminTemplateGenerate:
async def test_generate_form(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/test-city/generate")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "3" in html # 3 rows available
assert "Generate" in html
async def test_generate_creates_articles(self, admin_client, db, pseo_env):
async with admin_client.session_transaction() as sess: async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test" sess["csrf_token"] = "test"
resp = await admin_client.post("/admin/templates/new", form={ resp = await admin_client.post("/admin/templates/test-city/generate", form={
"csrf_token": "test", "csrf_token": "test",
"name": "Test Template", "start_date": "2026-04-01",
"slug": "test-tmpl", "articles_per_day": "2",
"content_type": "calculator",
"input_schema": '[{"name":"city","label":"City","field_type":"text","required":true}]',
"url_pattern": "/test-{{ city }}",
"title_pattern": "Test {{ city }}",
"meta_description_pattern": "",
"body_template": "# Hello {{ city }}",
}) })
assert resp.status_code == 302 assert resp.status_code == 302
row = await fetch_one("SELECT * FROM article_templates WHERE slug = 'test-tmpl'") articles = await fetch_all("SELECT * FROM articles")
assert row is not None assert len(articles) == 3
assert row["name"] == "Test Template"
async def test_template_edit(self, admin_client, db): scenarios = await fetch_all("SELECT * FROM published_scenarios")
template_id = await execute( assert len(scenarios) == 3
"""INSERT INTO article_templates
(name, slug, content_type, input_schema, url_pattern, async def test_generate_unknown_template_redirects(self, admin_client, db, pseo_env):
title_pattern, body_template) resp = await admin_client.get("/admin/templates/nonexistent/generate")
VALUES ('Edit Me', 'edit-me', 'calculator', '[]', assert resp.status_code == 302
'/edit', 'Edit', '# body')"""
)
class TestAdminTemplateRegenerate:
async def test_regenerate_updates_articles(self, admin_client, db, pseo_env):
from padelnomics.content import generate_articles
# First generate
await generate_articles("test-city", date(2026, 3, 1), 10)
initial = await fetch_all("SELECT * FROM articles")
assert len(initial) == 3
async with admin_client.session_transaction() as sess: async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test" sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/templates/{template_id}/edit", form={ resp = await admin_client.post("/admin/templates/test-city/regenerate", form={
"csrf_token": "test", "csrf_token": "test",
"name": "Edited",
"input_schema": "[]",
"url_pattern": "/edit",
"title_pattern": "Edited",
"body_template": "# edited",
}) })
assert resp.status_code == 302 assert resp.status_code == 302
row = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,)) # Same count — upserted, not duplicated
assert row["name"] == "Edited" articles = await fetch_all("SELECT * FROM articles")
assert len(articles) == 3
async def test_template_delete(self, admin_client, db):
template_id = await execute(
"""INSERT INTO article_templates
(name, slug, content_type, input_schema, url_pattern,
title_pattern, body_template)
VALUES ('Del Me', 'del-me', 'calculator', '[]',
'/del', 'Del', '# body')"""
)
async def test_regenerate_unknown_template_redirects(self, admin_client, db, pseo_env):
async with admin_client.session_transaction() as sess: async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test" sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/templates/{template_id}/delete", form={ resp = await admin_client.post("/admin/templates/nonexistent/regenerate", form={
"csrf_token": "test", "csrf_token": "test",
}) })
assert resp.status_code == 302 assert resp.status_code == 302
row = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
assert row is None
class TestAdminScenarios: class TestAdminScenarios:
async def test_scenario_list(self, admin_client): async def test_scenario_list(self, admin_client):
@@ -1012,81 +1240,6 @@ class TestAdminArticles:
assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (article_id,)) is None assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (article_id,)) is None
class TestAdminTemplateData:
async def test_data_add(self, admin_client, db):
template_id, _ = await _create_template()
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/templates/{template_id}/data/add", form={
"csrf_token": "test",
"city": "London",
"city_slug": "london",
"country": "UK",
"region": "Europe",
"electricity": "650",
})
assert resp.status_code == 302
rows = await fetch_all(
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
)
# 3 from _create_template + 1 just added
assert len(rows) == 4
async def test_data_delete(self, admin_client, db):
template_id, _ = await _create_template()
rows = await fetch_all(
"SELECT id FROM template_data WHERE template_id = ?", (template_id,)
)
data_id = rows[0]["id"]
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(
f"/admin/templates/{template_id}/data/{data_id}/delete",
form={"csrf_token": "test"},
)
assert resp.status_code == 302
remaining = await fetch_all(
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
)
assert len(remaining) == 2
class TestAdminGenerate:
async def test_generate_form(self, admin_client, db):
template_id, _ = await _create_template()
resp = await admin_client.get(f"/admin/templates/{template_id}/generate")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "3" in html # pending count
async def test_generate_creates_articles(self, admin_client, db):
from padelnomics.content.routes import BUILD_DIR
template_id, _ = await _create_template()
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/templates/{template_id}/generate", form={
"csrf_token": "test",
"start_date": "2026-04-01",
"articles_per_day": "2",
})
assert resp.status_code == 302
articles = await fetch_all("SELECT * FROM articles")
assert len(articles) == 3
# Cleanup
for a in articles:
p = BUILD_DIR / f"{a['slug']}.html"
if p.exists():
p.unlink()
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════