diff --git a/CHANGELOG.md b/CHANGELOG.md index bceecd3..5335d96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [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 - **Extraction: one file per source** — replaced monolithic `execute.py` with per-source modules (`overpass.py`, `eurostat.py`, `playtomic_tenants.py`, `playtomic_availability.py`); diff --git a/uv.lock b/uv.lock index ee0ef11..f8029e2 100644 --- a/uv.lock +++ b/uv.lock @@ -1158,6 +1158,7 @@ dependencies = [ { name = "mistune" }, { name = "paddle-python-sdk" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "quart" }, { name = "resend" }, { name = "weasyprint" }, @@ -1173,6 +1174,7 @@ requires-dist = [ { name = "mistune", specifier = ">=3.0.0" }, { name = "paddle-python-sdk", specifier = ">=1.13.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "quart", specifier = ">=0.19.0" }, { name = "resend", specifier = ">=2.22.0" }, { name = "weasyprint", specifier = ">=68.1" }, @@ -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" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "qh3" version = "1.5.6" diff --git a/web/pyproject.toml b/web/pyproject.toml index c6a1a1c..59b0690 100644 --- a/web/pyproject.toml +++ b/web/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "resend>=2.22.0", "weasyprint>=68.1", "duckdb>=1.0.0", + "pyyaml>=6.0", ] [build-system] diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index fed6144..8bcf7c8 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -1,8 +1,6 @@ """ Admin domain: role-based admin panel for managing users, tasks, etc. """ -import csv -import io import json from datetime import date, datetime, timedelta from pathlib import Path @@ -828,424 +826,140 @@ async def feedback(): # ============================================================================= -# Article Template Management +# Content Templates (read-only — templates live in git as .md.jinja files) # ============================================================================= @bp.route("/templates") @role_required("admin") async def templates(): - """List article templates.""" - template_list = await fetch_all( - "SELECT * FROM article_templates ORDER BY created_at DESC" - ) - # Attach data row counts + """List content templates scanned from disk.""" + from ..content import discover_templates, fetch_template_data + + template_list = discover_templates() + + # Attach DuckDB row counts for t in template_list: + count_rows = await fetch_template_data(t["data_table"], limit=501) + t["data_count"] = len(count_rows) + + # Count generated articles for this template row = await fetch_one( - "SELECT COUNT(*) as cnt FROM template_data WHERE template_id = ?", (t["id"],) - ) - t["data_count"] = row["cnt"] if row else 0 - row = await fetch_one( - "SELECT COUNT(*) as cnt FROM template_data WHERE template_id = ? AND article_id IS NOT NULL", - (t["id"],), + "SELECT COUNT(*) as cnt FROM articles WHERE template_slug = ?", + (t["slug"],), ) t["generated_count"] = row["cnt"] if row else 0 + return await render_template("admin/templates.html", templates=template_list) -@bp.route("/templates/new", methods=["GET", "POST"]) +@bp.route("/templates/") @role_required("admin") -@csrf_protect -async def template_new(): - """Create a new article template.""" - if request.method == "POST": - form = await request.form - name = form.get("name", "").strip() - template_slug = form.get("slug", "").strip() or slugify(name) - content_type = form.get("content_type", "calculator") - input_schema = form.get("input_schema", "[]").strip() - url_pattern = form.get("url_pattern", "").strip() - title_pattern = form.get("title_pattern", "").strip() - meta_description_pattern = form.get("meta_description_pattern", "").strip() - body_template = form.get("body_template", "").strip() +async def template_detail(slug: str): + """Template detail: config (read-only), columns, sample data, actions.""" + from ..content import fetch_template_data, get_table_columns, load_template - if not name or not url_pattern or not title_pattern or not body_template: - await flash("Name, URL pattern, title pattern, and body template are required.", "error") - return await render_template("admin/template_form.html", data=dict(form), editing=False) - - # Validate input_schema is valid JSON - try: - json.loads(input_schema) - except json.JSONDecodeError: - await flash("Input schema must be valid JSON.", "error") - return await render_template("admin/template_form.html", data=dict(form), editing=False) - - existing = await fetch_one( - "SELECT 1 FROM article_templates WHERE slug = ?", (template_slug,) - ) - if existing: - await flash(f"Slug '{template_slug}' already exists.", "error") - return await render_template("admin/template_form.html", data=dict(form), editing=False) - - template_id = await execute( - """INSERT INTO article_templates - (name, slug, content_type, input_schema, url_pattern, - title_pattern, meta_description_pattern, body_template) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", - (name, template_slug, content_type, input_schema, url_pattern, - title_pattern, meta_description_pattern, body_template), - ) - await flash(f"Template '{name}' created.", "success") - return redirect(url_for("admin.template_data", template_id=template_id)) - - return await render_template("admin/template_form.html", data={}, editing=False) - - -@bp.route("/templates//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: + try: + config = load_template(slug) + except (AssertionError, FileNotFoundError): await flash("Template not found.", "error") return redirect(url_for("admin.templates")) - if request.method == "POST": - form = await request.form - name = form.get("name", "").strip() - input_schema = form.get("input_schema", "[]").strip() - url_pattern = form.get("url_pattern", "").strip() - title_pattern = form.get("title_pattern", "").strip() - meta_description_pattern = form.get("meta_description_pattern", "").strip() - body_template = form.get("body_template", "").strip() + columns = await get_table_columns(config["data_table"]) + sample_rows = await fetch_template_data(config["data_table"], limit=10) - if not name or not url_pattern or not title_pattern or not body_template: - await flash("Name, URL pattern, title pattern, and body template are required.", "error") - return await render_template( - "admin/template_form.html", data=dict(form), editing=True, template_id=template_id, - ) - - try: - 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")) + # Count generated articles + row = await fetch_one( + "SELECT COUNT(*) as cnt FROM articles WHERE template_slug = ?", (slug,), + ) + generated_count = row["cnt"] if row else 0 return await render_template( - "admin/template_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//delete", methods=["POST"]) +@bp.route("/templates//preview/") @role_required("admin") -@csrf_protect -async def template_delete(template_id: int): - """Delete an article template.""" - await execute("DELETE FROM article_templates WHERE id = ?", (template_id,)) - await flash("Template deleted.", "success") - return redirect(url_for("admin.templates")) +async def template_preview(slug: str, row_key: str): + """Preview a single article rendered from template + DuckDB row.""" + from ..content import preview_article + lang = request.args.get("lang", "en") + try: + 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//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( - "admin/template_data.html", - template=template, - data_rows=data_rows, - schema=schema, + "admin/template_preview.html", + config={"slug": slug}, + preview=result, + lang=lang, ) -@bp.route("/templates//data/add", methods=["POST"]) +@bp.route("/templates//generate", methods=["GET", "POST"]) @role_required("admin") @csrf_protect -async def template_data_add(template_id: int): - """Add a single data row.""" - template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,)) - if not template: +async def template_generate(slug: str): + """Generate articles from template + DuckDB data.""" + from ..content import fetch_template_data, generate_articles, load_template + + try: + config = load_template(slug) + except (AssertionError, FileNotFoundError): await flash("Template not found.", "error") return redirect(url_for("admin.templates")) - form = await request.form - schema = json.loads(template["input_schema"]) - - data = {} - for field in schema: - val = form.get(field["name"], "").strip() - if field.get("field_type") in ("number", "float"): - try: - 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//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//data//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//generate", methods=["GET", "POST"]) -@role_required("admin") -@csrf_protect -async def template_generate(template_id: int): - """Bulk-generate scenarios + articles from template data.""" - template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,)) - if not template: - await flash("Template not found.", "error") - return redirect(url_for("admin.templates")) - - pending_count = await fetch_one( - "SELECT COUNT(*) as cnt FROM template_data WHERE template_id = ? AND article_id IS NULL", - (template_id,), - ) - pending = pending_count["cnt"] if pending_count else 0 + data_rows = await fetch_template_data(config["data_table"], limit=501) + row_count = len(data_rows) if request.method == "POST": form = await request.form start_date_str = form.get("start_date", "") - articles_per_day = int(form.get("articles_per_day", 2) or 2) + articles_per_day = int(form.get("articles_per_day", 3) or 3) if not start_date_str: start_date = date.today() else: start_date = date.fromisoformat(start_date_str) - assert articles_per_day > 0, "articles_per_day must be positive" - - generated = await _generate_from_template(template, start_date, articles_per_day) + generated = await generate_articles( + slug, start_date, articles_per_day, limit=500, + ) await flash(f"Generated {generated} articles with staggered publish dates.", "success") - return redirect(url_for("admin.template_data", template_id=template_id)) + return redirect(url_for("admin.articles")) return await render_template( "admin/generate_form.html", - template=template, - pending_count=pending, + config_data=config, + row_count=row_count, today=date.today().isoformat(), ) -async def _generate_from_template(template: dict, start_date: date, articles_per_day: int) -> int: - """Generate scenarios + articles for all un-generated data rows.""" - from ..content.routes import BUILD_DIR, bake_scenario_cards, is_reserved_path - from ..planner.calculator import DEFAULTS, calc, validate_state +@bp.route("/templates//regenerate", methods=["POST"]) +@role_required("admin") +@csrf_protect +async def template_regenerate(slug: str): + """Re-generate all articles for a template with fresh DuckDB data.""" + from ..content import generate_articles, load_template - data_rows = await fetch_all( - "SELECT * FROM template_data WHERE template_id = ? AND article_id IS NULL", - (template["id"],), - ) + try: + load_template(slug) + except (AssertionError, FileNotFoundError): + await flash("Template not found.", "error") + return redirect(url_for("admin.templates")) - publish_date = start_date - published_today = 0 - generated = 0 - - for row in data_rows: - data = json.loads(row["data_json"]) - - # Separate calc fields from display fields - lang = data.get("language", "en") - calc_overrides = {k: v for k, v in data.items() if k in DEFAULTS} - state = validate_state(calc_overrides) - d = calc(state, lang=lang) - - # Build scenario slug - city_slug = data.get("city_slug", str(row["id"])) - scenario_slug = template["slug"] + "-" + city_slug - - # Court config label - dbl = state.get("dblCourts", 0) - sgl = state.get("sglCourts", 0) - court_config = f"{dbl} double + {sgl} single" - - # Create published scenario - scenario_id = await execute( - """INSERT OR IGNORE INTO published_scenarios - (slug, title, subtitle, location, country, venue_type, ownership, - court_config, state_json, calc_json, template_data_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - scenario_slug, - data.get("city", scenario_slug), - data.get("subtitle", ""), - data.get("city", ""), - data.get("country", state.get("country", "")), - state.get("venue", "indoor"), - state.get("own", "rent"), - court_config, - json.dumps(state), - json.dumps(d), - row["id"], - ), - ) - - if not scenario_id: - # Slug already exists, fetch existing - existing = await fetch_one( - "SELECT id FROM published_scenarios WHERE slug = ?", (scenario_slug,) - ) - scenario_id = existing["id"] if existing else None - if not scenario_id: - continue - - # Fill template patterns - data["scenario_slug"] = scenario_slug - title = _render_jinja_string(template["title_pattern"], data) - url_path = _render_jinja_string(template["url_pattern"], data) - article_slug = template["slug"] + "-" + city_slug - - meta_desc = "" - if template["meta_description_pattern"]: - meta_desc = _render_jinja_string(template["meta_description_pattern"], data) - - # Validate url_path - if is_reserved_path(url_path): - continue - - # Render body - body_md = _render_jinja_string(template["body_template"], data) - body_html = mistune.html(body_md) - body_html = await bake_scenario_cards(body_html, lang=lang) - - # Write to disk - BUILD_DIR.mkdir(parents=True, exist_ok=True) - build_path = BUILD_DIR / f"{article_slug}.html" - build_path.write_text(body_html) - - # Stagger publish date - publish_dt = datetime(publish_date.year, publish_date.month, publish_date.day, 8, 0, 0) - - # Create article - article_id = await execute( - """INSERT OR IGNORE INTO articles - (url_path, slug, title, meta_description, country, region, - status, published_at, template_data_id) - VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?)""", - ( - url_path, article_slug, title, meta_desc, - data.get("country", ""), data.get("region", ""), - publish_dt.isoformat(), row["id"], - ), - ) - - if article_id: - # Link data row - now = datetime.utcnow().isoformat() - await execute( - "UPDATE template_data SET scenario_id = ?, article_id = ?, updated_at = ? WHERE id = ?", - (scenario_id, article_id, now, row["id"]), - ) - generated += 1 - - # Stagger dates - published_today += 1 - if published_today >= articles_per_day: - published_today = 0 - publish_date += timedelta(days=1) - - return generated + # Use today as start date, keep existing publish dates via upsert + generated = await generate_articles(slug, date.today(), articles_per_day=500) + await flash(f"Regenerated {generated} articles from fresh data.", "success") + return redirect(url_for("admin.template_detail", slug=slug)) # ============================================================================= @@ -1659,46 +1373,31 @@ async def rebuild_all(): async def _rebuild_article(article_id: int): - """Re-render a single article from its source (template+data or markdown).""" + """Re-render a single article from its source.""" from ..content.routes import BUILD_DIR, bake_scenario_cards article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,)) if not article: return - if article["template_data_id"]: - # Generated article: re-render from template + data - td = await fetch_one( - """SELECT td.*, at.body_template, at.title_pattern, at.meta_description_pattern - FROM template_data td - JOIN article_templates at ON at.id = td.template_id - WHERE td.id = ?""", - (article["template_data_id"],), - ) - if not td: + if article["template_slug"]: + # SSG-generated article: regenerate via the content module + from ..content import generate_articles, load_template + try: + load_template(article["template_slug"]) + except (AssertionError, FileNotFoundError): return - - data = json.loads(td["data_json"]) - - # Re-fetch scenario for fresh calc_json - if td["scenario_id"]: - scenario = await fetch_one( - "SELECT slug FROM published_scenarios WHERE id = ?", (td["scenario_id"],) - ) - 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") + # Regenerate all articles for this template (upserts, so safe) + await generate_articles( + article["template_slug"], date.today(), articles_per_day=500, + ) else: # Manual article: re-render from markdown file md_path = Path("data/content/articles") / f"{article['slug']}.md" if not md_path.exists(): return body_html = mistune.html(md_path.read_text()) - lang = "en" - - body_html = await bake_scenario_cards(body_html, lang=lang) - BUILD_DIR.mkdir(parents=True, exist_ok=True) - (BUILD_DIR / f"{article['slug']}.html").write_text(body_html) + lang = article.get("language", "en") if hasattr(article, "get") else "en" + body_html = await bake_scenario_cards(body_html, lang=lang) + BUILD_DIR.mkdir(parents=True, exist_ok=True) + (BUILD_DIR / f"{article['slug']}.html").write_text(body_html) diff --git a/web/src/padelnomics/admin/templates/admin/generate_form.html b/web/src/padelnomics/admin/templates/admin/generate_form.html index 4c85806..d59f7ed 100644 --- a/web/src/padelnomics/admin/templates/admin/generate_form.html +++ b/web/src/padelnomics/admin/templates/admin/generate_form.html @@ -1,17 +1,22 @@ {% extends "admin/base_admin.html" %} {% set admin_page = "templates" %} -{% block title %}Generate Articles - {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %} +{% block title %}Generate Articles - {{ config_data.name }} - Admin{% endblock %} {% block admin_content %}
- ← Back to {{ template.name }} + ← Back to {{ config_data.name }}

Generate Articles

-

{{ pending_count }} pending data row{{ 's' if pending_count != 1 }} ready to generate.

+

+ {{ row_count }} data row{{ 's' if row_count != 1 }} available in + {{ config_data.data_table }} + × {{ config_data.languages | length }} language{{ 's' if config_data.languages | length != 1 }} + = {{ row_count * config_data.languages | length }} articles. +

- {% if pending_count == 0 %} + {% if row_count == 0 %}
-

All data rows have already been generated. Add more data rows first.

+

No data rows found. Run the data pipeline to populate {{ config_data.data_table }}.

{% else %}
@@ -25,20 +30,23 @@
- +

How many articles to publish per day. Remaining articles get staggered to following days.

- This will generate {{ pending_count }} articles - over {{ ((pending_count + 1) // 2) }} days, - each with its own financial scenario computed from the data row's input values. + This will generate up to {{ row_count * config_data.languages | length }} articles + ({{ row_count }} rows × {{ config_data.languages | length }} languages). + Existing articles with the same URL will be updated in-place. + {% if config_data.priority_column %} + Articles are ordered by {{ config_data.priority_column }} (highest first). + {% endif %}

-
{% endif %} diff --git a/web/src/padelnomics/admin/templates/admin/template_data.html b/web/src/padelnomics/admin/templates/admin/template_data.html deleted file mode 100644 index a7bbbbb..0000000 --- a/web/src/padelnomics/admin/templates/admin/template_data.html +++ /dev/null @@ -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 %} -
-
- ← Back to templates -

{{ template.name }}

-

{{ data_rows | length }} data row{{ 's' if data_rows | length != 1 }} · {{ template.slug }}

-
- -
- - -
-

Add Data Row

-
- -
- {% for field in schema %} -
- - -
- {% endfor %} -
- -
-
- - -
-

Bulk Upload (CSV)

-
- -
-
- - -
- -
-

CSV headers should match field names: {{ schema | map(attribute='name') | join(', ') }}

-
-
- - -
-

Data Rows

- {% if data_rows %} -
- - - - - {% for field in schema[:5] %} - - {% endfor %} - - - - - - {% for row in data_rows %} - - - {% for field in schema[:5] %} - - {% endfor %} - - - - {% endfor %} - -
#{{ field.label }}Status
{{ row.id }}{{ row.parsed_data.get(field.name, '') }} - {% if row.article_id %} - Generated - {% if row.article_url %} - View - {% endif %} - {% else %} - Pending - {% endif %} - -
- - -
-
-
- {% else %} -

No data rows yet. Add some above or upload a CSV.

- {% endif %} -
-{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/template_detail.html b/web/src/padelnomics/admin/templates/admin/template_detail.html new file mode 100644 index 0000000..1d46268 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/template_detail.html @@ -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 %} + ← Templates + +
+
+

{{ config_data.name }}

+

{{ config_data.slug }}

+
+
+ Generate Articles +
+ + +
+
+
+ + {# Config section #} +
+

Configuration (read-only)

+ + + + + + + + + + + {% if config_data.priority_column %} + + {% endif %} + +
Content Type{{ config_data.content_type }}
Data Table{{ config_data.data_table }}
Natural Key{{ config_data.natural_key }}
Languages{{ config_data.languages | join(', ') }}
URL Pattern{{ config_data.url_pattern }}
Title Pattern{{ config_data.title_pattern }}
Meta Description{{ config_data.meta_description_pattern }}
Schema Types{{ config_data.schema_type | join(', ') }}
Priority Column{{ config_data.priority_column }}
+

Edit this template in the repo: content/templates/{{ config_data.slug }}.md.jinja

+
+ + {# Stats #} +
+
+
{{ columns | length }}
+
Columns
+
+
+
{{ sample_rows | length }}{% if sample_rows | length >= 10 %}+{% endif %}
+
Data Rows
+
+
+
{{ generated_count }}
+
Generated
+
+
+ + {# Columns #} +
+

Available Columns

+ {% if columns %} +
+ {% for col in columns %} + {{ col.name }} ({{ col.type }}) + {% endfor %} +
+ {% else %} +

No columns found. Is the DuckDB table {{ config_data.data_table }} available?

+ {% endif %} +
+ + {# Sample data #} +
+

Sample Data (first 10 rows)

+ {% if sample_rows %} +
+ + + + {% for col in columns %} + + {% endfor %} + + + + + {% for row in sample_rows %} + + {% for col in columns %} + + {% endfor %} + + + {% endfor %} + +
{{ col.name }}Preview
+ {{ row[col.name] }} + + Preview +
+
+ {% else %} +

No data available. Run the data pipeline first.

+ {% endif %} +
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/template_form.html b/web/src/padelnomics/admin/templates/admin/template_form.html deleted file mode 100644 index fbc174a..0000000 --- a/web/src/padelnomics/admin/templates/admin/template_form.html +++ /dev/null @@ -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 %} -
- ← Back to templates -

{% if editing %}Edit{% else %}New{% endif %} Article Template

- -
- - -
-
- - -
-
- - -
-
- -
- - -
- -
- - -

JSON array of field definitions: [{name, label, field_type, required}]

-
- -
- - -

Jinja2 template string. Use {{ '{{' }} variable {{ '}}' }} placeholders from data rows.

-
- -
- - -
- -
- - -
- -
- - -

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].

-
- - -
-
-{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/template_preview.html b/web/src/padelnomics/admin/templates/admin/template_preview.html new file mode 100644 index 0000000..aaf01c9 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/template_preview.html @@ -0,0 +1,31 @@ +{% extends "admin/base_admin.html" %} +{% set admin_page = "templates" %} + +{% block title %}Preview - {{ preview.title }} - Admin{% endblock %} + +{% block admin_content %} + ← Back to template + +
+

Article Preview

+

Language: {{ lang }} | URL: {{ preview.url_path }}

+
+ + {# Meta preview #} +
+

SEO Preview

+
+
{{ preview.title }}
+
{{ preview.url_path }}
+
{{ preview.meta_description }}
+
+
+ + {# Rendered article #} +
+

Rendered HTML

+
+ {{ preview.html | safe }} +
+
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/templates.html b/web/src/padelnomics/admin/templates/admin/templates.html index 75561f1..7590868 100644 --- a/web/src/padelnomics/admin/templates/admin/templates.html +++ b/web/src/padelnomics/admin/templates/admin/templates.html @@ -1,18 +1,15 @@ {% extends "admin/base_admin.html" %} {% set admin_page = "templates" %} -{% block title %}Article Templates - Admin - {{ config.APP_NAME }}{% endblock %} +{% block title %}Content Templates - Admin - {{ config.APP_NAME }}{% endblock %} {% block admin_content %}
-

Article Templates

-

{{ templates | length }} template{{ 's' if templates | length != 1 }}

-
-
- New Template - Back +

Content Templates

+

{{ templates | length }} template{{ 's' if templates | length != 1 }} (scanned from git)

+ Back
@@ -23,29 +20,32 @@ Name Slug Type + Data Table Data Rows Generated + Languages {% for t in templates %} - {{ t.name }} + {{ t.name }} {{ t.slug }} {{ t.content_type }} + {{ t.data_table }} {{ t.data_count }} {{ t.generated_count }} + {{ t.languages | join(', ') }} - Edit - Generate + Generate {% endfor %} {% else %} -

No templates yet. Create one to get started.

+

No templates found. Add .md.jinja files to content/templates/ in the repo.

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