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
`_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
- **Extraction: one file per source** — replaced monolithic `execute.py` with per-source
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 = "pyarrow" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "quart" },
{ name = "resend" },
{ name = "weasyprint" },
@@ -1175,6 +1176,7 @@ requires-dist = [
{ name = "paddle-python-sdk", specifier = ">=1.13.0" },
{ name = "pyarrow", specifier = ">=23.0.1" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "quart", specifier = ">=0.19.0" },
{ name = "resend", specifier = ">=2.22.0" },
{ 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" },
]
[[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]]
name = "qh3"
version = "1.5.6"

View File

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

View File

@@ -1,8 +1,6 @@
"""
Admin domain: role-based admin panel for managing users, tasks, etc.
"""
import csv
import io
import json
from datetime import date, datetime, timedelta
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")
@role_required("admin")
async def templates():
"""List article templates."""
template_list = await fetch_all(
"SELECT * FROM article_templates ORDER BY created_at DESC"
)
# Attach data row counts
"""List content templates scanned from disk."""
from ..content import discover_templates, fetch_template_data
template_list = discover_templates()
# Attach DuckDB row counts
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(
"SELECT COUNT(*) as cnt FROM template_data WHERE template_id = ?", (t["id"],)
)
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"],),
"SELECT COUNT(*) as cnt FROM articles WHERE template_slug = ?",
(t["slug"],),
)
t["generated_count"] = row["cnt"] if row else 0
return await render_template("admin/templates.html", templates=template_list)
@bp.route("/templates/new", methods=["GET", "POST"])
@bp.route("/templates/<slug>")
@role_required("admin")
@csrf_protect
async def template_new():
"""Create a new article 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,
)
async def template_detail(slug: str):
"""Template detail: config (read-only), columns, sample data, actions."""
from ..content import fetch_template_data, get_table_columns, load_template
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=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:
config = load_template(slug)
except (AssertionError, FileNotFoundError):
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
data_rows = await fetch_all(
"""SELECT td.*, a.title as article_title, a.url_path as article_url,
ps.slug as scenario_slug
FROM template_data td
LEFT JOIN articles a ON a.id = td.article_id
LEFT JOIN published_scenarios ps ON ps.id = td.scenario_id
WHERE td.template_id = ?
ORDER BY td.created_at DESC""",
(template_id,),
columns = await get_table_columns(config["data_table"])
sample_rows = await fetch_template_data(config["data_table"], limit=10)
# Count generated articles
row = await fetch_one(
"SELECT COUNT(*) as cnt FROM articles WHERE template_slug = ?", (slug,),
)
generated_count = row["cnt"] if row else 0
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:
row["parsed_data"] = json.loads(row["data_json"])
except (json.JSONDecodeError, TypeError):
row["parsed_data"] = {}
result = await preview_article(slug, row_key, lang=lang)
except (AssertionError, Exception) as exc:
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(
"admin/template_data.html",
template=template,
data_rows=data_rows,
schema=schema,
"admin/template_preview.html",
config={"slug": slug},
preview=result,
lang=lang,
)
@bp.route("/templates/<int:template_id>/data/add", methods=["POST"])
@bp.route("/templates/<slug>/generate", methods=["GET", "POST"])
@role_required("admin")
@csrf_protect
async def template_data_add(template_id: int):
"""Add a single data row."""
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"))
async def template_generate(slug: str):
"""Generate articles from template + DuckDB data."""
from ..content import fetch_template_data, generate_articles, load_template
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:
data[field["name"]] = float(val) if val else 0
except ValueError:
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:
config = load_template(slug)
except (AssertionError, FileNotFoundError):
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
files = await request.files
csv_file = files.get("csv_file")
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
data_rows = await fetch_template_data(config["data_table"], limit=501)
row_count = len(data_rows)
if request.method == "POST":
form = await request.form
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:
start_date = date.today()
else:
start_date = date.fromisoformat(start_date_str)
assert articles_per_day > 0, "articles_per_day must be positive"
generated = await _generate_from_template(template, start_date, articles_per_day)
generated = await generate_articles(
slug, start_date, articles_per_day, limit=500,
)
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(
"admin/generate_form.html",
template=template,
pending_count=pending,
config_data=config,
row_count=row_count,
today=date.today().isoformat(),
)
async def _generate_from_template(template: dict, start_date: date, articles_per_day: int) -> int:
"""Generate scenarios + articles for all un-generated data rows."""
from ..content.routes import BUILD_DIR, bake_scenario_cards, is_reserved_path
from ..planner.calculator import DEFAULTS, calc, validate_state
@bp.route("/templates/<slug>/regenerate", methods=["POST"])
@role_required("admin")
@csrf_protect
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(
"SELECT * FROM template_data WHERE template_id = ? AND article_id IS NULL",
(template["id"],),
)
try:
load_template(slug)
except (AssertionError, FileNotFoundError):
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
publish_date = start_date
published_today = 0
generated = 0
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
# Use today as start date, keep existing publish dates via upsert
generated = await generate_articles(slug, date.today(), articles_per_day=500)
await flash(f"Regenerated {generated} articles from fresh data.", "success")
return redirect(url_for("admin.template_detail", slug=slug))
# =============================================================================
@@ -1659,46 +1373,31 @@ async def rebuild_all():
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
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
if not article:
return
if article["template_data_id"]:
# Generated article: re-render from template + data
td = await fetch_one(
"""SELECT td.*, at.body_template, at.title_pattern, at.meta_description_pattern
FROM template_data td
JOIN article_templates at ON at.id = td.template_id
WHERE td.id = ?""",
(article["template_data_id"],),
)
if not td:
if article["template_slug"]:
# SSG-generated article: regenerate via the content module
from ..content import generate_articles, load_template
try:
load_template(article["template_slug"])
except (AssertionError, FileNotFoundError):
return
data = json.loads(td["data_json"])
# 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"],)
# Regenerate all articles for this template (upserts, so safe)
await generate_articles(
article["template_slug"], date.today(), articles_per_day=500,
)
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:
# Manual article: re-render from markdown file
md_path = Path("data/content/articles") / f"{article['slug']}.md"
if not md_path.exists():
return
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)
BUILD_DIR.mkdir(parents=True, exist_ok=True)
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)

View File

@@ -1,17 +1,22 @@
{% extends "admin/base_admin.html" %}
{% 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 %}
<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>
<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">
<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>
{% else %}
<form method="post" class="card">
@@ -25,20 +30,23 @@
<div class="mb-4">
<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>
</div>
<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">
This will generate <strong class="text-navy">{{ pending_count }}</strong> articles
over <strong class="text-navy" id="days-estimate">{{ ((pending_count + 1) // 2) }}</strong> days,
each with its own financial scenario computed from the data row's input values.
This will generate up to <strong class="text-navy">{{ row_count * config_data.languages | length }}</strong> articles
({{ row_count }} rows &times; {{ config_data.languages | length }} languages).
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>
</div>
<button type="submit" class="btn" style="width: 100%;" onclick="return confirm('Generate {{ pending_count }} articles? This cannot be undone.')">
Generate {{ pending_count }} Articles
<button type="submit" class="btn" style="width: 100%;" onclick="return confirm('Generate articles? Existing articles will be updated.')">
Generate Articles
</button>
</form>
{% 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" %}
{% 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 %}
<header class="flex justify-between items-center mb-8">
<div>
<h1 class="text-2xl">Article Templates</h1>
<p class="text-slate text-sm">{{ templates | length }} template{{ 's' if templates | length != 1 }}</p>
<h1 class="text-2xl">Content Templates</h1>
<p class="text-slate text-sm">{{ templates | length }} template{{ 's' if templates | length != 1 }} (scanned from git)</p>
</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>
</div>
</header>
<div class="card">
@@ -23,29 +20,32 @@
<th>Name</th>
<th>Slug</th>
<th>Type</th>
<th>Data Table</th>
<th>Data Rows</th>
<th>Generated</th>
<th>Languages</th>
<th></th>
</tr>
</thead>
<tbody>
{% for t in templates %}
<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><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.generated_count }}</td>
<td class="text-sm">{{ t.languages | join(', ') }}</td>
<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', template_id=t.id) }}" class="btn btn-sm">Generate</a>
<a href="{{ url_for('admin.template_generate', slug=t.slug) }}" class="btn btn-sm">Generate</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% 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 %}
</div>
{% 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:
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"
if not build_path.exists():
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():
"""Insert a template + 3 data rows, return (template_id, data_row_count)."""
template_id = await execute(
"""INSERT INTO article_templates
(name, slug, content_type, input_schema, url_pattern,
title_pattern, meta_description_pattern, body_template)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
"City Cost Analysis", "city-cost", "calculator",
json.dumps([
{"name": "city", "label": "City", "field_type": "text", "required": True},
{"name": "city_slug", "label": "Slug", "field_type": "text", "required": True},
{"name": "country", "label": "Country", "field_type": "text", "required": True},
{"name": "region", "label": "Region", "field_type": "text", "required": False},
{"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]",
),
)
TEST_TEMPLATE = """\
---
name: "Test City Analysis"
slug: test-city
content_type: calculator
data_table: serving.test_cities
natural_key: city_slug
languages: [en]
url_pattern: "/markets/{{ country | lower }}/{{ city_slug }}"
title_pattern: "Padel in {{ city }}"
meta_description_pattern: "Padel costs in {{ city }}"
schema_type: Article
---
# Padel in {{ city }}
cities = [
("Miami", "miami", "US", "North America", 700),
("Madrid", "madrid", "ES", "Europe", 500),
("Berlin", "berlin", "DE", "Europe", 550),
Welcome to {{ city }}.
[scenario:{{ scenario_slug }}:capex]
"""
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(
"INSERT INTO template_data (template_id, data_json) VALUES (?, ?)",
(template_id, json.dumps({
"city": city, "city_slug": slug, "country": country,
"region": region, "electricity": elec,
})),
)
return template_id, len(cities)
TEST_COLUMNS = [
{"column_name": "city", "data_type": "VARCHAR"},
{"column_name": "city_slug", "data_type": "VARCHAR"},
{"column_name": "country", "data_type": "VARCHAR"},
{"column_name": "region", "data_type": "VARCHAR"},
{"column_name": "electricity", "data_type": "INTEGER"},
]
@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:
async def test_generates_correct_count(self, db):
from padelnomics.admin.routes import _generate_from_template
template_id, count = await _create_template()
template = await fetch_one(
"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_generates_correct_count(self, db, pseo_env):
from padelnomics.content import generate_articles
generated = await generate_articles("test-city", date(2026, 3, 1), 10)
assert generated == 3 # 3 rows × 1 language
async def test_staggered_dates_two_per_day(self, db):
from padelnomics.admin.routes import _generate_from_template
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), 2)
async def test_staggered_dates_two_per_day(self, db, pseo_env):
from padelnomics.content import generate_articles
await generate_articles("test-city", date(2026, 3, 1), 2)
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
assert len(articles) == 3
@@ -426,55 +442,39 @@ class TestGenerationPipeline:
assert dates[1] == "2026-03-01"
assert dates[2] == "2026-03-02"
async def test_staggered_dates_one_per_day(self, db):
from padelnomics.admin.routes import _generate_from_template
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), 1)
async def test_staggered_dates_one_per_day(self, db, pseo_env):
from padelnomics.content import generate_articles
await generate_articles("test-city", date(2026, 3, 1), 1)
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
dates = sorted({a["published_at"][:10] for a in articles})
assert dates == ["2026-03-01", "2026-03-02", "2026-03-03"]
async def test_article_url_and_title(self, db):
from padelnomics.admin.routes import _generate_from_template
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)
async def test_article_url_and_title(self, db, pseo_env):
from padelnomics.content import generate_articles
await generate_articles("test-city", 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["url_path"] == "/padel-court-cost-miami"
assert miami["title"] == "Padel Center Cost in Miami"
assert miami["country"] == "US"
assert miami["region"] == "North America"
assert miami["url_path"] == "/en/markets/us/miami"
assert miami["title"] == "Padel in Miami"
assert miami["template_slug"] == "test-city"
assert miami["language"] == "en"
assert miami["status"] == "published"
async def test_scenario_created_per_row(self, db):
from padelnomics.admin.routes import _generate_from_template
template_id, count = 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)
async def test_scenario_created_per_row(self, db, pseo_env):
from padelnomics.content import generate_articles
await generate_articles("test-city", date(2026, 3, 1), 10)
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):
from padelnomics.admin.routes import _generate_from_template
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)
async def test_scenario_has_valid_calc_json(self, db, pseo_env):
from padelnomics.content import generate_articles
await generate_articles("test-city", date(2026, 3, 1), 10)
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
d = json.loads(scenario["calc_json"])
@@ -483,112 +483,302 @@ class TestGenerationPipeline:
assert "irr" in d
assert d["capex"] > 0
async def test_template_data_linked(self, db):
from padelnomics.admin.routes import _generate_from_template
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)
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)
async def test_build_files_written(self, db, pseo_env):
from padelnomics.content import generate_articles
await generate_articles("test-city", date(2026, 3, 1), 10)
build_dir = pseo_env["build_dir"]
articles = await fetch_all("SELECT slug FROM articles")
try:
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}"
content = build_path.read_text()
assert len(content) > 100, f"Build file too small: {build_path}"
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()
assert len(content) > 50
async def test_skips_already_generated(self, db):
"""Running generate twice does not duplicate articles."""
from padelnomics.admin.routes import _generate_from_template
template_id, count = await _create_template()
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
async def test_updates_existing_on_regeneration(self, db, pseo_env):
"""Running generate twice updates articles, doesn't duplicate."""
from padelnomics.content import generate_articles
first = await _generate_from_template(dict(template), date(2026, 3, 1), 10)
assert first == count
first = await generate_articles("test-city", date(2026, 3, 1), 10)
assert first == 3
# Second run: all rows already linked → 0 generated
second = await _generate_from_template(dict(template), date(2026, 3, 10), 10)
assert second == 0
second = await generate_articles("test-city", date(2026, 3, 10), 10)
assert second == 3 # Updates existing
articles = await fetch_all("SELECT * FROM articles")
assert len(articles) == count
assert len(articles) == 3 # No duplicates
# Cleanup
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):
async def test_calc_overrides_applied(self, db, pseo_env):
"""Data row values that match DEFAULTS keys are used as calc overrides."""
from padelnomics.admin.routes import _generate_from_template
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)
from padelnomics.content import generate_articles
await generate_articles("test-city", date(2026, 3, 1), 10)
# Miami had electricity=700, default is 600
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"])
assert state["electricity"] == 700
# Cleanup
from padelnomics.content.routes import BUILD_DIR
for slug in ("miami", "madrid", "berlin"):
p = BUILD_DIR / f"{slug}.html"
if p.exists():
p.unlink()
async def test_seo_head_populated(self, db, pseo_env):
from padelnomics.content import generate_articles
await generate_articles("test-city", date(2026, 3, 1), 10)
article = await fetch_one("SELECT * FROM articles WHERE slug = 'test-city-en-miami'")
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
# ════════════════════════════════════════════════════════════
class TestRenderJinjaString:
class TestRenderPattern:
def test_simple(self):
from padelnomics.admin.routes import _render_jinja_string
assert _render_jinja_string("Hello {{ name }}!", {"name": "World"}) == "Hello World!"
from padelnomics.content import _render_pattern
assert _render_pattern("Hello {{ name }}!", {"name": "World"}) == "Hello World!"
def test_missing_var_empty(self):
from padelnomics.admin.routes import _render_jinja_string
result = _render_jinja_string("Hello {{ missing }}!", {})
from padelnomics.content import _render_pattern
result = _render_pattern("Hello {{ missing }}!", {})
assert result == "Hello !"
def test_url_pattern(self):
from padelnomics.admin.routes import _render_jinja_string
result = _render_jinja_string("/padel-court-cost-{{ slug }}", {"slug": "miami"})
assert result == "/padel-court-cost-miami"
from padelnomics.content import _render_pattern
result = _render_pattern("/markets/{{ country | lower }}/{{ slug }}", {"country": "US", "slug": "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")
assert resp.status_code == 200
async def test_template_new_form(self, admin_client):
resp = await admin_client.get("/admin/templates/new")
async def test_template_list_shows_discovered(self, admin_client, pseo_env):
resp = await admin_client.get("/admin/templates")
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:
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",
"name": "Test Template",
"slug": "test-tmpl",
"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 }}",
"start_date": "2026-04-01",
"articles_per_day": "2",
})
assert resp.status_code == 302
row = await fetch_one("SELECT * FROM article_templates WHERE slug = 'test-tmpl'")
assert row is not None
assert row["name"] == "Test Template"
articles = await fetch_all("SELECT * FROM articles")
assert len(articles) == 3
async def test_template_edit(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 ('Edit Me', 'edit-me', 'calculator', '[]',
'/edit', 'Edit', '# body')"""
)
scenarios = await fetch_all("SELECT * FROM published_scenarios")
assert len(scenarios) == 3
async def test_generate_unknown_template_redirects(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/nonexistent/generate")
assert resp.status_code == 302
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:
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",
"name": "Edited",
"input_schema": "[]",
"url_pattern": "/edit",
"title_pattern": "Edited",
"body_template": "# edited",
})
assert resp.status_code == 302
row = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
assert row["name"] == "Edited"
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')"""
)
# Same count — upserted, not duplicated
articles = await fetch_all("SELECT * FROM articles")
assert len(articles) == 3
async def test_regenerate_unknown_template_redirects(self, admin_client, db, pseo_env):
async with admin_client.session_transaction() as sess:
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",
})
assert resp.status_code == 302
row = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
assert row is None
class TestAdminScenarios:
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
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()
# ════════════════════════════════════════════════════════════