feat: SSG-inspired pSEO CMS — git templates + DuckDB direct reads

Replace the old CSV-upload-based CMS with an SSG architecture where
templates live in git as .md.jinja files with YAML frontmatter and
data comes directly from DuckDB serving tables. Only articles and
published_scenarios remain in SQLite for routing/state.

- Content module: discover, load, generate, preview functions
- Migration 0018: drop article_templates + template_data, recreate
  articles + published_scenarios without FK references, add
  template_slug/language/date_modified/seo_head columns
- Admin routes: read-only template views with generate/regenerate/preview
- SEO pipeline: canonical URLs, hreflang (EN+DE), JSON-LD (Article,
  FAQPage, BreadcrumbList), Open Graph tags baked at generation time
- Example template: city-cost-de.md.jinja for German city market data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-23 12:25:44 +01:00
parent 5b6c4182f7
commit f1181342ad
15 changed files with 1153 additions and 899 deletions

View File

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

57
uv.lock generated
View File

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

View File

@@ -16,6 +16,7 @@ dependencies = [
"resend>=2.22.0", "resend>=2.22.0",
"weasyprint>=68.1", "weasyprint>=68.1",
"duckdb>=1.0.0", "duckdb>=1.0.0",
"pyyaml>=6.0",
] ]
[build-system] [build-system]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -215,7 +215,12 @@ async def article_page(url_path: str):
if not article: if not article:
abort(404) abort(404)
build_path = BUILD_DIR / f"{article['slug']}.html" # 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(): if not build_path.exists():
abort(404) abort(404)

View File

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

View File

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

View File

@@ -81,43 +81,54 @@ async def _create_article(slug="test-article", url_path="/test-article",
) )
async def _create_template(): TEST_TEMPLATE = """\
"""Insert a template + 3 data rows, return (template_id, data_row_count).""" ---
template_id = await execute( name: "Test City Analysis"
"""INSERT INTO article_templates slug: test-city
(name, slug, content_type, input_schema, url_pattern, content_type: calculator
title_pattern, meta_description_pattern, body_template) data_table: serving.test_cities
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", natural_key: city_slug
( languages: [en]
"City Cost Analysis", "city-cost", "calculator", url_pattern: "/markets/{{ country | lower }}/{{ city_slug }}"
json.dumps([ title_pattern: "Padel in {{ city }}"
{"name": "city", "label": "City", "field_type": "text", "required": True}, meta_description_pattern: "Padel costs in {{ city }}"
{"name": "city_slug", "label": "Slug", "field_type": "text", "required": True}, schema_type: Article
{"name": "country", "label": "Country", "field_type": "text", "required": True}, ---
{"name": "region", "label": "Region", "field_type": "text", "required": False}, # Padel in {{ city }}
{"name": "electricity", "label": "Electricity", "field_type": "number", "required": False},
]),
"/padel-court-cost-{{ city_slug }}",
"Padel Center Cost in {{ city }}",
"How much does a padel center cost in {{ city }}?",
"# Padel in {{ city }}\n\n[scenario:{{ scenario_slug }}]\n\n## CAPEX\n\n[scenario:{{ scenario_slug }}:capex]",
),
)
cities = [ Welcome to {{ city }}.
("Miami", "miami", "US", "North America", 700),
("Madrid", "madrid", "ES", "Europe", 500), [scenario:{{ scenario_slug }}:capex]
("Berlin", "berlin", "DE", "Europe", 550), """
]
for city, slug, country, region, elec in cities: TEST_ROWS = [
await execute( {"city": "Miami", "city_slug": "miami", "country": "US", "region": "North America", "electricity": 700},
"INSERT INTO template_data (template_id, data_json) VALUES (?, ?)", {"city": "Madrid", "city_slug": "madrid", "country": "ES", "region": "Europe", "electricity": 500},
(template_id, json.dumps({ {"city": "Berlin", "city_slug": "berlin", "country": "DE", "region": "Europe", "electricity": 550},
"city": city, "city_slug": slug, "country": country, ]
"region": region, "electricity": elec,
})),
) @pytest.fixture
return template_id, len(cities) 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):
return TEST_ROWS
monkeypatch.setattr(content_mod, "fetch_analytics", mock_fetch_analytics)
return {"tpl_dir": tpl_dir, "build_dir": build_dir}
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@@ -401,22 +412,14 @@ class TestBakeScenarioCards:
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
class TestGenerationPipeline: class TestGenerationPipeline:
async def test_generates_correct_count(self, db): async def test_generates_correct_count(self, db, pseo_env):
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, count = await _create_template() generated = await generate_articles("test-city", date(2026, 3, 1), 10)
template = await fetch_one( assert generated == 3 # 3 rows × 1 language
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
generated = await _generate_from_template(dict(template), date(2026, 3, 1), 10)
assert generated == count
async def test_staggered_dates_two_per_day(self, db): async def test_staggered_dates_two_per_day(self, db, pseo_env):
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, _ = await _create_template() await generate_articles("test-city", date(2026, 3, 1), 2)
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 2)
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at") articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
assert len(articles) == 3 assert len(articles) == 3
@@ -426,55 +429,39 @@ class TestGenerationPipeline:
assert dates[1] == "2026-03-01" assert dates[1] == "2026-03-01"
assert dates[2] == "2026-03-02" assert dates[2] == "2026-03-02"
async def test_staggered_dates_one_per_day(self, db): async def test_staggered_dates_one_per_day(self, db, pseo_env):
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, _ = await _create_template() await generate_articles("test-city", date(2026, 3, 1), 1)
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 1)
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at") articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
dates = sorted({a["published_at"][:10] for a in articles}) dates = sorted({a["published_at"][:10] for a in articles})
assert dates == ["2026-03-01", "2026-03-02", "2026-03-03"] assert dates == ["2026-03-01", "2026-03-02", "2026-03-03"]
async def test_article_url_and_title(self, db): async def test_article_url_and_title(self, db, pseo_env):
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, _ = await _create_template() await generate_articles("test-city", date(2026, 3, 1), 10)
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
miami = await fetch_one("SELECT * FROM articles WHERE slug = 'city-cost-miami'") miami = await fetch_one("SELECT * FROM articles WHERE slug = 'test-city-en-miami'")
assert miami is not None assert miami is not None
assert miami["url_path"] == "/padel-court-cost-miami" assert miami["url_path"] == "/en/markets/us/miami"
assert miami["title"] == "Padel Center Cost in Miami" assert miami["title"] == "Padel in Miami"
assert miami["country"] == "US" assert miami["template_slug"] == "test-city"
assert miami["region"] == "North America" assert miami["language"] == "en"
assert miami["status"] == "published" assert miami["status"] == "published"
async def test_scenario_created_per_row(self, db): async def test_scenario_created_per_row(self, db, pseo_env):
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, count = await _create_template() await generate_articles("test-city", date(2026, 3, 1), 10)
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
scenarios = await fetch_all("SELECT * FROM published_scenarios") scenarios = await fetch_all("SELECT * FROM published_scenarios")
assert len(scenarios) == count assert len(scenarios) == 3
async def test_scenario_has_valid_calc_json(self, db): async def test_scenario_has_valid_calc_json(self, db, pseo_env):
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, _ = await _create_template() await generate_articles("test-city", date(2026, 3, 1), 10)
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
scenario = await fetch_one( scenario = await fetch_one(
"SELECT * FROM published_scenarios WHERE slug = 'city-cost-miami'" "SELECT * FROM published_scenarios WHERE slug = 'test-city-miami'"
) )
assert scenario is not None assert scenario is not None
d = json.loads(scenario["calc_json"]) d = json.loads(scenario["calc_json"])
@@ -483,112 +470,76 @@ class TestGenerationPipeline:
assert "irr" in d assert "irr" in d
assert d["capex"] > 0 assert d["capex"] > 0
async def test_template_data_linked(self, db): async def test_build_files_written(self, db, pseo_env):
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, _ = await _create_template() await generate_articles("test-city", date(2026, 3, 1), 10)
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
rows = await fetch_all(
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
)
for row in rows:
assert row["article_id"] is not None, f"Row {row['id']} not linked to article"
assert row["scenario_id"] is not None, f"Row {row['id']} not linked to scenario"
async def test_build_files_written(self, db):
from padelnomics.admin.routes import _generate_from_template
from padelnomics.content.routes import BUILD_DIR
template_id, _ = await _create_template()
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
build_dir = pseo_env["build_dir"]
articles = await fetch_all("SELECT slug FROM articles") articles = await fetch_all("SELECT slug FROM articles")
try: for a in articles:
for a in articles: build_path = build_dir / "en" / f"{a['slug']}.html"
build_path = BUILD_DIR / f"{a['slug']}.html" assert build_path.exists(), f"Missing build file: {build_path}"
assert build_path.exists(), f"Missing build file: {build_path}" content = build_path.read_text()
content = build_path.read_text() assert len(content) > 50
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()
async def test_skips_already_generated(self, db): async def test_updates_existing_on_regeneration(self, db, pseo_env):
"""Running generate twice does not duplicate articles.""" """Running generate twice updates articles, doesn't duplicate."""
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, count = await _create_template()
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
first = await _generate_from_template(dict(template), date(2026, 3, 1), 10) first = await generate_articles("test-city", date(2026, 3, 1), 10)
assert first == count assert first == 3
# Second run: all rows already linked → 0 generated second = await generate_articles("test-city", date(2026, 3, 10), 10)
second = await _generate_from_template(dict(template), date(2026, 3, 10), 10) assert second == 3 # Updates existing
assert second == 0
articles = await fetch_all("SELECT * FROM articles") articles = await fetch_all("SELECT * FROM articles")
assert len(articles) == count assert len(articles) == 3 # No duplicates
# Cleanup async def test_calc_overrides_applied(self, db, pseo_env):
from padelnomics.content.routes import BUILD_DIR
for a in articles:
p = BUILD_DIR / f"{a['slug']}.html"
if p.exists():
p.unlink()
async def test_calc_overrides_applied(self, db):
"""Data row values that match DEFAULTS keys are used as calc overrides.""" """Data row values that match DEFAULTS keys are used as calc overrides."""
from padelnomics.admin.routes import _generate_from_template from padelnomics.content import generate_articles
template_id, _ = await _create_template() await generate_articles("test-city", date(2026, 3, 1), 10)
template = await fetch_one(
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
)
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
# Miami had electricity=700, default is 600 # Miami had electricity=700, default is 600
scenario = await fetch_one( scenario = await fetch_one(
"SELECT * FROM published_scenarios WHERE slug = 'city-cost-miami'" "SELECT * FROM published_scenarios WHERE slug = 'test-city-miami'"
) )
state = json.loads(scenario["state_json"]) state = json.loads(scenario["state_json"])
assert state["electricity"] == 700 assert state["electricity"] == 700
# Cleanup async def test_seo_head_populated(self, db, pseo_env):
from padelnomics.content.routes import BUILD_DIR from padelnomics.content import generate_articles
for slug in ("miami", "madrid", "berlin"): await generate_articles("test-city", date(2026, 3, 1), 10)
p = BUILD_DIR / f"{slug}.html"
if p.exists(): article = await fetch_one("SELECT * FROM articles WHERE slug = 'test-city-en-miami'")
p.unlink() assert article["seo_head"] is not None
assert 'rel="canonical"' in article["seo_head"]
assert 'application/ld+json' in article["seo_head"]
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# Jinja string rendering # Jinja string rendering
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
class TestRenderJinjaString: class TestRenderPattern:
def test_simple(self): def test_simple(self):
from padelnomics.admin.routes import _render_jinja_string from padelnomics.content import _render_pattern
assert _render_jinja_string("Hello {{ name }}!", {"name": "World"}) == "Hello World!" assert _render_pattern("Hello {{ name }}!", {"name": "World"}) == "Hello World!"
def test_missing_var_empty(self): def test_missing_var_empty(self):
from padelnomics.admin.routes import _render_jinja_string from padelnomics.content import _render_pattern
result = _render_jinja_string("Hello {{ missing }}!", {}) result = _render_pattern("Hello {{ missing }}!", {})
assert result == "Hello !" assert result == "Hello !"
def test_url_pattern(self): def test_url_pattern(self):
from padelnomics.admin.routes import _render_jinja_string from padelnomics.content import _render_pattern
result = _render_jinja_string("/padel-court-cost-{{ slug }}", {"slug": "miami"}) result = _render_pattern("/markets/{{ country | lower }}/{{ slug }}", {"country": "US", "slug": "miami"})
assert result == "/padel-court-cost-miami" assert result == "/markets/us/miami"
def test_slugify_filter(self):
from padelnomics.content import _render_pattern
result = _render_pattern("{{ name | slugify }}", {"name": "Hello World"})
assert result == "hello-world"
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@@ -772,75 +723,6 @@ class TestAdminTemplates:
resp = await admin_client.get("/admin/templates") resp = await admin_client.get("/admin/templates")
assert resp.status_code == 200 assert resp.status_code == 200
async def test_template_new_form(self, admin_client):
resp = await admin_client.get("/admin/templates/new")
assert resp.status_code == 200
async def test_template_create(self, admin_client, db):
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post("/admin/templates/new", 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 }}",
})
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"
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')"""
)
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/templates/{template_id}/edit", 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')"""
)
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/templates/{template_id}/delete", 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: class TestAdminScenarios:
@@ -1012,81 +894,6 @@ class TestAdminArticles:
assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (article_id,)) is None assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (article_id,)) is None
class TestAdminTemplateData:
async def test_data_add(self, admin_client, db):
template_id, _ = await _create_template()
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/templates/{template_id}/data/add", form={
"csrf_token": "test",
"city": "London",
"city_slug": "london",
"country": "UK",
"region": "Europe",
"electricity": "650",
})
assert resp.status_code == 302
rows = await fetch_all(
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
)
# 3 from _create_template + 1 just added
assert len(rows) == 4
async def test_data_delete(self, admin_client, db):
template_id, _ = await _create_template()
rows = await fetch_all(
"SELECT id FROM template_data WHERE template_id = ?", (template_id,)
)
data_id = rows[0]["id"]
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(
f"/admin/templates/{template_id}/data/{data_id}/delete",
form={"csrf_token": "test"},
)
assert resp.status_code == 302
remaining = await fetch_all(
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
)
assert len(remaining) == 2
class TestAdminGenerate:
async def test_generate_form(self, admin_client, db):
template_id, _ = await _create_template()
resp = await admin_client.get(f"/admin/templates/{template_id}/generate")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "3" in html # pending count
async def test_generate_creates_articles(self, admin_client, db):
from padelnomics.content.routes import BUILD_DIR
template_id, _ = await _create_template()
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/templates/{template_id}/generate", form={
"csrf_token": "test",
"start_date": "2026-04-01",
"articles_per_day": "2",
})
assert resp.status_code == 302
articles = await fetch_all("SELECT * FROM articles")
assert len(articles) == 3
# Cleanup
for a in articles:
p = BUILD_DIR / f"{a['slug']}.html"
if p.exists():
p.unlink()
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════