feat: pSEO CMS — SSG architecture with git templates + DuckDB
# Conflicts: # web/pyproject.toml
This commit is contained in:
18
CHANGELOG.md
18
CHANGELOG.md
@@ -53,6 +53,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
(`rate_peak`, `rate_off_peak`, `avg_utilisation_pct`, `courts_typical`); adds
|
(`rate_peak`, `rate_off_peak`, `avg_utilisation_pct`, `courts_typical`); adds
|
||||||
`_dataSource` and `_currency` metadata keys
|
`_dataSource` and `_currency` metadata keys
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **pSEO CMS: SSG architecture** — templates now live in git as `.md.jinja` files with YAML
|
||||||
|
frontmatter (slug, data_table, url_pattern, etc.) instead of SQLite `article_templates` table;
|
||||||
|
data comes directly from DuckDB serving tables instead of intermediary `template_data` table;
|
||||||
|
admin template views are read-only (edit in git, preview/generate in admin)
|
||||||
|
- **pSEO CMS: SEO pipeline** — article generation bakes canonical URLs, hreflang links (EN + DE),
|
||||||
|
JSON-LD structured data (Article, FAQPage, BreadcrumbList), and Open Graph tags into each
|
||||||
|
article's `seo_head` column at generation time; articles stored with `template_slug`, `language`,
|
||||||
|
and `date_modified` columns for regeneration and freshness tracking
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- `article_templates` and `template_data` SQLite tables (migration 0018) — replaced by git
|
||||||
|
template files + direct DuckDB reads; `template_data_id` FK removed from `articles` and
|
||||||
|
`published_scenarios` tables
|
||||||
|
- Admin template CRUD routes (create/edit/delete) and CSV upload — replaced by read-only views
|
||||||
|
with generate/regenerate/preview actions
|
||||||
|
- `template_form.html` and `template_data.html` admin templates
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Extraction: one file per source** — replaced monolithic `execute.py` with per-source
|
- **Extraction: one file per source** — replaced monolithic `execute.py` with per-source
|
||||||
modules (`overpass.py`, `eurostat.py`, `playtomic_tenants.py`, `playtomic_availability.py`);
|
modules (`overpass.py`, `eurostat.py`, `playtomic_tenants.py`, `playtomic_availability.py`);
|
||||||
|
|||||||
57
uv.lock
generated
57
uv.lock
generated
@@ -1159,6 +1159,7 @@ dependencies = [
|
|||||||
{ name = "paddle-python-sdk" },
|
{ name = "paddle-python-sdk" },
|
||||||
{ name = "pyarrow" },
|
{ name = "pyarrow" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
{ name = "quart" },
|
{ name = "quart" },
|
||||||
{ name = "resend" },
|
{ name = "resend" },
|
||||||
{ name = "weasyprint" },
|
{ name = "weasyprint" },
|
||||||
@@ -1175,6 +1176,7 @@ requires-dist = [
|
|||||||
{ name = "paddle-python-sdk", specifier = ">=1.13.0" },
|
{ name = "paddle-python-sdk", specifier = ">=1.13.0" },
|
||||||
{ name = "pyarrow", specifier = ">=23.0.1" },
|
{ name = "pyarrow", specifier = ">=23.0.1" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0" },
|
||||||
{ name = "quart", specifier = ">=0.19.0" },
|
{ name = "quart", specifier = ">=0.19.0" },
|
||||||
{ name = "resend", specifier = ">=2.22.0" },
|
{ name = "resend", specifier = ">=2.22.0" },
|
||||||
{ name = "weasyprint", specifier = ">=68.1" },
|
{ name = "weasyprint", specifier = ">=68.1" },
|
||||||
@@ -1733,6 +1735,61 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qh3"
|
name = "qh3"
|
||||||
version = "1.5.6"
|
version = "1.5.6"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ dependencies = [
|
|||||||
"weasyprint>=68.1",
|
"weasyprint>=68.1",
|
||||||
"duckdb>=1.0.0",
|
"duckdb>=1.0.0",
|
||||||
"pyarrow>=23.0.1",
|
"pyarrow>=23.0.1",
|
||||||
|
"pyyaml>=6.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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">← Back to {{ template.name }}</a>
|
<a href="{{ url_for('admin.template_detail', slug=config_data.slug) }}" class="text-sm text-slate">← 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>
|
||||||
|
× {{ 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 × {{ 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 %}
|
||||||
|
|||||||
@@ -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">← 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 %}
|
|
||||||
111
web/src/padelnomics/admin/templates/admin/template_detail.html
Normal file
111
web/src/padelnomics/admin/templates/admin/template_detail.html
Normal 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">← 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 %}
|
||||||
@@ -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">← 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 %}
|
|
||||||
@@ -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">← 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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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("&", "&").replace('"', """).replace("<", "<")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
64
web/src/padelnomics/content/templates/city-cost-de.md.jinja
Normal file
64
web/src/padelnomics/content/templates/city-cost-de.md.jinja
Normal 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.
|
||||||
@@ -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
|
||||||
|
""")
|
||||||
@@ -81,43 +81,67 @@ async def _create_article(slug="test-article", url_path="/test-article",
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _create_template():
|
TEST_TEMPLATE = """\
|
||||||
"""Insert a template + 3 data rows, return (template_id, data_row_count)."""
|
---
|
||||||
template_id = await execute(
|
name: "Test City Analysis"
|
||||||
"""INSERT INTO article_templates
|
slug: test-city
|
||||||
(name, slug, content_type, input_schema, url_pattern,
|
content_type: calculator
|
||||||
title_pattern, meta_description_pattern, body_template)
|
data_table: serving.test_cities
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
natural_key: city_slug
|
||||||
(
|
languages: [en]
|
||||||
"City Cost Analysis", "city-cost", "calculator",
|
url_pattern: "/markets/{{ country | lower }}/{{ city_slug }}"
|
||||||
json.dumps([
|
title_pattern: "Padel in {{ city }}"
|
||||||
{"name": "city", "label": "City", "field_type": "text", "required": True},
|
meta_description_pattern: "Padel costs in {{ city }}"
|
||||||
{"name": "city_slug", "label": "Slug", "field_type": "text", "required": True},
|
schema_type: Article
|
||||||
{"name": "country", "label": "Country", "field_type": "text", "required": True},
|
---
|
||||||
{"name": "region", "label": "Region", "field_type": "text", "required": False},
|
# Padel in {{ city }}
|
||||||
{"name": "electricity", "label": "Electricity", "field_type": "number", "required": False},
|
|
||||||
]),
|
|
||||||
"/padel-court-cost-{{ city_slug }}",
|
|
||||||
"Padel Center Cost in {{ city }}",
|
|
||||||
"How much does a padel center cost in {{ city }}?",
|
|
||||||
"# Padel in {{ city }}\n\n[scenario:{{ scenario_slug }}]\n\n## CAPEX\n\n[scenario:{{ scenario_slug }}:capex]",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
cities = [
|
Welcome to {{ city }}.
|
||||||
("Miami", "miami", "US", "North America", 700),
|
|
||||||
("Madrid", "madrid", "ES", "Europe", 500),
|
[scenario:{{ scenario_slug }}:capex]
|
||||||
("Berlin", "berlin", "DE", "Europe", 550),
|
"""
|
||||||
]
|
|
||||||
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,
|
|
||||||
})),
|
TEST_COLUMNS = [
|
||||||
)
|
{"column_name": "city", "data_type": "VARCHAR"},
|
||||||
return template_id, len(cities)
|
{"column_name": "city_slug", "data_type": "VARCHAR"},
|
||||||
|
{"column_name": "country", "data_type": "VARCHAR"},
|
||||||
|
{"column_name": "region", "data_type": "VARCHAR"},
|
||||||
|
{"column_name": "electricity", "data_type": "INTEGER"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pseo_env(tmp_path, monkeypatch):
|
||||||
|
"""Set up pSEO environment: temp template dir, build dir, mock DuckDB."""
|
||||||
|
import padelnomics.content as content_mod
|
||||||
|
|
||||||
|
tpl_dir = tmp_path / "templates"
|
||||||
|
tpl_dir.mkdir()
|
||||||
|
monkeypatch.setattr(content_mod, "TEMPLATES_DIR", tpl_dir)
|
||||||
|
|
||||||
|
build_dir = tmp_path / "build"
|
||||||
|
build_dir.mkdir()
|
||||||
|
monkeypatch.setattr(content_mod, "BUILD_DIR", build_dir)
|
||||||
|
|
||||||
|
(tpl_dir / "test-city.md.jinja").write_text(TEST_TEMPLATE)
|
||||||
|
|
||||||
|
async def mock_fetch_analytics(query, params=None):
|
||||||
|
if "information_schema" in query:
|
||||||
|
return TEST_COLUMNS
|
||||||
|
if "WHERE" in query and params:
|
||||||
|
# preview_article: filter by natural key value
|
||||||
|
return [r for r in TEST_ROWS if params[0] in r.values()]
|
||||||
|
return TEST_ROWS
|
||||||
|
|
||||||
|
monkeypatch.setattr(content_mod, "fetch_analytics", mock_fetch_analytics)
|
||||||
|
|
||||||
|
return {"tpl_dir": tpl_dir, "build_dir": build_dir}
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
@@ -401,22 +425,14 @@ class TestBakeScenarioCards:
|
|||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class TestGenerationPipeline:
|
class TestGenerationPipeline:
|
||||||
async def test_generates_correct_count(self, db):
|
async def test_generates_correct_count(self, db, pseo_env):
|
||||||
from padelnomics.admin.routes import _generate_from_template
|
from padelnomics.content import generate_articles
|
||||||
template_id, count = await _create_template()
|
generated = await generate_articles("test-city", date(2026, 3, 1), 10)
|
||||||
template = await fetch_one(
|
assert generated == 3 # 3 rows × 1 language
|
||||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
||||||
)
|
|
||||||
generated = await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
|
||||||
assert generated == count
|
|
||||||
|
|
||||||
async def test_staggered_dates_two_per_day(self, db):
|
async def test_staggered_dates_two_per_day(self, db, pseo_env):
|
||||||
from padelnomics.admin.routes import _generate_from_template
|
from padelnomics.content import generate_articles
|
||||||
template_id, _ = await _create_template()
|
await generate_articles("test-city", date(2026, 3, 1), 2)
|
||||||
template = await fetch_one(
|
|
||||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
||||||
)
|
|
||||||
await _generate_from_template(dict(template), date(2026, 3, 1), 2)
|
|
||||||
|
|
||||||
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
|
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
|
||||||
assert len(articles) == 3
|
assert len(articles) == 3
|
||||||
@@ -426,55 +442,39 @@ class TestGenerationPipeline:
|
|||||||
assert dates[1] == "2026-03-01"
|
assert dates[1] == "2026-03-01"
|
||||||
assert dates[2] == "2026-03-02"
|
assert dates[2] == "2026-03-02"
|
||||||
|
|
||||||
async def test_staggered_dates_one_per_day(self, db):
|
async def test_staggered_dates_one_per_day(self, db, pseo_env):
|
||||||
from padelnomics.admin.routes import _generate_from_template
|
from padelnomics.content import generate_articles
|
||||||
template_id, _ = await _create_template()
|
await generate_articles("test-city", date(2026, 3, 1), 1)
|
||||||
template = await fetch_one(
|
|
||||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
||||||
)
|
|
||||||
await _generate_from_template(dict(template), date(2026, 3, 1), 1)
|
|
||||||
|
|
||||||
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
|
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
|
||||||
dates = sorted({a["published_at"][:10] for a in articles})
|
dates = sorted({a["published_at"][:10] for a in articles})
|
||||||
assert dates == ["2026-03-01", "2026-03-02", "2026-03-03"]
|
assert dates == ["2026-03-01", "2026-03-02", "2026-03-03"]
|
||||||
|
|
||||||
async def test_article_url_and_title(self, db):
|
async def test_article_url_and_title(self, db, pseo_env):
|
||||||
from padelnomics.admin.routes import _generate_from_template
|
from padelnomics.content import generate_articles
|
||||||
template_id, _ = await _create_template()
|
await generate_articles("test-city", date(2026, 3, 1), 10)
|
||||||
template = await fetch_one(
|
|
||||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
||||||
)
|
|
||||||
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
|
||||||
|
|
||||||
miami = await fetch_one("SELECT * FROM articles WHERE slug = 'city-cost-miami'")
|
miami = await fetch_one("SELECT * FROM articles WHERE slug = 'test-city-en-miami'")
|
||||||
assert miami is not None
|
assert miami is not None
|
||||||
assert miami["url_path"] == "/padel-court-cost-miami"
|
assert miami["url_path"] == "/en/markets/us/miami"
|
||||||
assert miami["title"] == "Padel Center Cost in Miami"
|
assert miami["title"] == "Padel in Miami"
|
||||||
assert miami["country"] == "US"
|
assert miami["template_slug"] == "test-city"
|
||||||
assert miami["region"] == "North America"
|
assert miami["language"] == "en"
|
||||||
assert miami["status"] == "published"
|
assert miami["status"] == "published"
|
||||||
|
|
||||||
async def test_scenario_created_per_row(self, db):
|
async def test_scenario_created_per_row(self, db, pseo_env):
|
||||||
from padelnomics.admin.routes import _generate_from_template
|
from padelnomics.content import generate_articles
|
||||||
template_id, count = await _create_template()
|
await generate_articles("test-city", date(2026, 3, 1), 10)
|
||||||
template = await fetch_one(
|
|
||||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
||||||
)
|
|
||||||
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
|
||||||
|
|
||||||
scenarios = await fetch_all("SELECT * FROM published_scenarios")
|
scenarios = await fetch_all("SELECT * FROM published_scenarios")
|
||||||
assert len(scenarios) == count
|
assert len(scenarios) == 3
|
||||||
|
|
||||||
async def test_scenario_has_valid_calc_json(self, db):
|
async def test_scenario_has_valid_calc_json(self, db, pseo_env):
|
||||||
from padelnomics.admin.routes import _generate_from_template
|
from padelnomics.content import generate_articles
|
||||||
template_id, _ = await _create_template()
|
await generate_articles("test-city", date(2026, 3, 1), 10)
|
||||||
template = await fetch_one(
|
|
||||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
||||||
)
|
|
||||||
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
|
||||||
|
|
||||||
scenario = await fetch_one(
|
scenario = await fetch_one(
|
||||||
"SELECT * FROM published_scenarios WHERE slug = 'city-cost-miami'"
|
"SELECT * FROM published_scenarios WHERE slug = 'test-city-miami'"
|
||||||
)
|
)
|
||||||
assert scenario is not None
|
assert scenario is not None
|
||||||
d = json.loads(scenario["calc_json"])
|
d = json.loads(scenario["calc_json"])
|
||||||
@@ -483,112 +483,302 @@ class TestGenerationPipeline:
|
|||||||
assert "irr" in d
|
assert "irr" in d
|
||||||
assert d["capex"] > 0
|
assert d["capex"] > 0
|
||||||
|
|
||||||
async def test_template_data_linked(self, db):
|
async def test_build_files_written(self, db, pseo_env):
|
||||||
from padelnomics.admin.routes import _generate_from_template
|
from padelnomics.content import generate_articles
|
||||||
template_id, _ = await _create_template()
|
await generate_articles("test-city", date(2026, 3, 1), 10)
|
||||||
template = await fetch_one(
|
|
||||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
||||||
)
|
|
||||||
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
|
||||||
|
|
||||||
rows = await fetch_all(
|
|
||||||
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
|
|
||||||
)
|
|
||||||
for row in rows:
|
|
||||||
assert row["article_id"] is not None, f"Row {row['id']} not linked to article"
|
|
||||||
assert row["scenario_id"] is not None, f"Row {row['id']} not linked to scenario"
|
|
||||||
|
|
||||||
async def test_build_files_written(self, db):
|
|
||||||
from padelnomics.admin.routes import _generate_from_template
|
|
||||||
from padelnomics.content.routes import BUILD_DIR
|
|
||||||
template_id, _ = await _create_template()
|
|
||||||
template = await fetch_one(
|
|
||||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
|
||||||
)
|
|
||||||
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
|
||||||
|
|
||||||
|
build_dir = pseo_env["build_dir"]
|
||||||
articles = await fetch_all("SELECT slug FROM articles")
|
articles = await fetch_all("SELECT slug FROM articles")
|
||||||
try:
|
for a in articles:
|
||||||
for a in articles:
|
build_path = build_dir / "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"
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# Template discovery & loading
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestDiscoverTemplates:
|
||||||
|
def test_discovers_templates(self, pseo_env):
|
||||||
|
from padelnomics.content import discover_templates
|
||||||
|
templates = discover_templates()
|
||||||
|
assert len(templates) == 1
|
||||||
|
assert templates[0]["slug"] == "test-city"
|
||||||
|
assert templates[0]["name"] == "Test City Analysis"
|
||||||
|
|
||||||
|
def test_empty_dir(self, tmp_path, monkeypatch):
|
||||||
|
import padelnomics.content as content_mod
|
||||||
|
monkeypatch.setattr(content_mod, "TEMPLATES_DIR", tmp_path / "nonexistent")
|
||||||
|
from padelnomics.content import discover_templates
|
||||||
|
assert discover_templates() == []
|
||||||
|
|
||||||
|
def test_skips_invalid_frontmatter(self, pseo_env):
|
||||||
|
from padelnomics.content import discover_templates
|
||||||
|
(pseo_env["tpl_dir"] / "bad.md.jinja").write_text("no frontmatter here")
|
||||||
|
templates = discover_templates()
|
||||||
|
assert len(templates) == 1 # Only the valid test-city template
|
||||||
|
|
||||||
|
def test_includes_path(self, pseo_env):
|
||||||
|
from padelnomics.content import discover_templates
|
||||||
|
templates = discover_templates()
|
||||||
|
assert "_path" in templates[0]
|
||||||
|
assert templates[0]["_path"].endswith("test-city.md.jinja")
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadTemplate:
|
||||||
|
def test_loads_config_and_body(self, pseo_env):
|
||||||
|
from padelnomics.content import load_template
|
||||||
|
config = load_template("test-city")
|
||||||
|
assert config["slug"] == "test-city"
|
||||||
|
assert config["content_type"] == "calculator"
|
||||||
|
assert config["data_table"] == "serving.test_cities"
|
||||||
|
assert "body_template" in config
|
||||||
|
assert "Padel in {{ city }}" in config["body_template"]
|
||||||
|
|
||||||
|
def test_missing_template_raises(self, pseo_env):
|
||||||
|
from padelnomics.content import load_template
|
||||||
|
with pytest.raises(AssertionError, match="Template not found"):
|
||||||
|
load_template("nonexistent")
|
||||||
|
|
||||||
|
def test_schema_type_normalized_to_list(self, pseo_env):
|
||||||
|
from padelnomics.content import load_template
|
||||||
|
config = load_template("test-city")
|
||||||
|
assert isinstance(config["schema_type"], list)
|
||||||
|
assert "Article" in config["schema_type"]
|
||||||
|
|
||||||
|
def test_languages_parsed(self, pseo_env):
|
||||||
|
from padelnomics.content import load_template
|
||||||
|
config = load_template("test-city")
|
||||||
|
assert config["languages"] == ["en"]
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# FAQ extraction
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestExtractFaqPairs:
|
||||||
|
def test_extracts_pairs(self):
|
||||||
|
from padelnomics.content import _extract_faq_pairs
|
||||||
|
md = (
|
||||||
|
"# Title\n\n"
|
||||||
|
"## FAQ\n\n"
|
||||||
|
"**How much does it cost?**\n"
|
||||||
|
"It costs about 500k.\n\n"
|
||||||
|
"**How long does it take?**\n"
|
||||||
|
"About 12 months.\n\n"
|
||||||
|
"## Other Section\n"
|
||||||
|
)
|
||||||
|
pairs = _extract_faq_pairs(md)
|
||||||
|
assert len(pairs) == 2
|
||||||
|
assert pairs[0]["question"] == "How much does it cost?"
|
||||||
|
assert "500k" in pairs[0]["answer"]
|
||||||
|
assert pairs[1]["question"] == "How long does it take?"
|
||||||
|
|
||||||
|
def test_no_faq_section(self):
|
||||||
|
from padelnomics.content import _extract_faq_pairs
|
||||||
|
assert _extract_faq_pairs("# Title\n\nSome content") == []
|
||||||
|
|
||||||
|
def test_faq_at_end_of_document(self):
|
||||||
|
from padelnomics.content import _extract_faq_pairs
|
||||||
|
md = "## FAQ\n\n**Question one?**\nAnswer one.\n"
|
||||||
|
pairs = _extract_faq_pairs(md)
|
||||||
|
assert len(pairs) == 1
|
||||||
|
assert pairs[0]["question"] == "Question one?"
|
||||||
|
assert pairs[0]["answer"] == "Answer one."
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# Breadcrumbs
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestBuildBreadcrumbs:
|
||||||
|
def test_basic_path(self):
|
||||||
|
from padelnomics.content import _build_breadcrumbs
|
||||||
|
crumbs = _build_breadcrumbs("/en/markets/germany/berlin", "https://padelnomics.io")
|
||||||
|
assert len(crumbs) == 5
|
||||||
|
assert crumbs[0] == {"name": "Home", "url": "https://padelnomics.io/"}
|
||||||
|
assert crumbs[1] == {"name": "En", "url": "https://padelnomics.io/en"}
|
||||||
|
assert crumbs[2] == {"name": "Markets", "url": "https://padelnomics.io/en/markets"}
|
||||||
|
assert crumbs[3] == {"name": "Germany", "url": "https://padelnomics.io/en/markets/germany"}
|
||||||
|
assert crumbs[4] == {"name": "Berlin", "url": "https://padelnomics.io/en/markets/germany/berlin"}
|
||||||
|
|
||||||
|
def test_root_path(self):
|
||||||
|
from padelnomics.content import _build_breadcrumbs
|
||||||
|
crumbs = _build_breadcrumbs("/", "https://padelnomics.io")
|
||||||
|
assert len(crumbs) == 1
|
||||||
|
assert crumbs[0]["name"] == "Home"
|
||||||
|
|
||||||
|
def test_hyphenated_segments_titlecased(self):
|
||||||
|
from padelnomics.content import _build_breadcrumbs
|
||||||
|
crumbs = _build_breadcrumbs("/en/my-section", "https://padelnomics.io")
|
||||||
|
assert crumbs[2]["name"] == "My Section"
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# JSON-LD structured data
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestBuildJsonld:
|
||||||
|
_COMMON = dict(
|
||||||
|
title="Test Title",
|
||||||
|
description="Test description",
|
||||||
|
url="https://padelnomics.io/en/markets/us/miami",
|
||||||
|
published_at="2026-01-01T08:00:00",
|
||||||
|
date_modified="2026-01-02T10:00:00",
|
||||||
|
language="en",
|
||||||
|
breadcrumbs=[
|
||||||
|
{"name": "Home", "url": "https://padelnomics.io/"},
|
||||||
|
{"name": "Markets", "url": "https://padelnomics.io/en/markets"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_always_includes_breadcrumbs(self):
|
||||||
|
from padelnomics.content import build_jsonld
|
||||||
|
objects = build_jsonld(["Article"], **self._COMMON)
|
||||||
|
types = [o["@type"] for o in objects]
|
||||||
|
assert "BreadcrumbList" in types
|
||||||
|
|
||||||
|
def test_breadcrumb_positions(self):
|
||||||
|
from padelnomics.content import build_jsonld
|
||||||
|
objects = build_jsonld(["Article"], **self._COMMON)
|
||||||
|
bc = [o for o in objects if o["@type"] == "BreadcrumbList"][0]
|
||||||
|
items = bc["itemListElement"]
|
||||||
|
assert items[0]["position"] == 1
|
||||||
|
assert items[0]["name"] == "Home"
|
||||||
|
assert items[1]["position"] == 2
|
||||||
|
|
||||||
|
def test_article_schema(self):
|
||||||
|
from padelnomics.content import build_jsonld
|
||||||
|
objects = build_jsonld(["Article"], **self._COMMON)
|
||||||
|
article = [o for o in objects if o["@type"] == "Article"][0]
|
||||||
|
assert article["headline"] == "Test Title"
|
||||||
|
assert article["inLanguage"] == "en"
|
||||||
|
assert article["datePublished"] == "2026-01-01T08:00:00"
|
||||||
|
assert article["dateModified"] == "2026-01-02T10:00:00"
|
||||||
|
assert article["publisher"]["name"] == "Padelnomics"
|
||||||
|
|
||||||
|
def test_headline_truncated_at_110(self):
|
||||||
|
from padelnomics.content import build_jsonld
|
||||||
|
long_title = "A" * 200
|
||||||
|
objects = build_jsonld(["Article"], **{**self._COMMON, "title": long_title})
|
||||||
|
article = [o for o in objects if o["@type"] == "Article"][0]
|
||||||
|
assert len(article["headline"]) == 110
|
||||||
|
|
||||||
|
def test_faqpage_schema(self):
|
||||||
|
from padelnomics.content import build_jsonld
|
||||||
|
faq_pairs = [
|
||||||
|
{"question": "How much?", "answer": "About 500k."},
|
||||||
|
{"question": "How long?", "answer": "12 months."},
|
||||||
|
]
|
||||||
|
objects = build_jsonld(["Article", "FAQPage"], **self._COMMON, faq_pairs=faq_pairs)
|
||||||
|
faq = [o for o in objects if o["@type"] == "FAQPage"][0]
|
||||||
|
assert len(faq["mainEntity"]) == 2
|
||||||
|
assert faq["mainEntity"][0]["name"] == "How much?"
|
||||||
|
assert faq["mainEntity"][0]["acceptedAnswer"]["text"] == "About 500k."
|
||||||
|
|
||||||
|
def test_faqpage_omitted_without_pairs(self):
|
||||||
|
from padelnomics.content import build_jsonld
|
||||||
|
objects = build_jsonld(["FAQPage"], **self._COMMON, faq_pairs=[])
|
||||||
|
types = [o["@type"] for o in objects]
|
||||||
|
assert "FAQPage" not in types
|
||||||
|
|
||||||
|
def test_no_article_when_not_in_types(self):
|
||||||
|
from padelnomics.content import build_jsonld
|
||||||
|
faq_pairs = [{"question": "Q?", "answer": "A."}]
|
||||||
|
objects = build_jsonld(["FAQPage"], **self._COMMON, faq_pairs=faq_pairs)
|
||||||
|
types = [o["@type"] for o in objects]
|
||||||
|
assert "Article" not in types
|
||||||
|
assert "FAQPage" in types
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# Preview article
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestPreviewArticle:
|
||||||
|
async def test_preview_returns_rendered_data(self, db, pseo_env):
|
||||||
|
from padelnomics.content import preview_article
|
||||||
|
result = await preview_article("test-city", "miami")
|
||||||
|
assert result["title"] == "Padel in Miami"
|
||||||
|
assert result["url_path"] == "/en/markets/us/miami"
|
||||||
|
assert result["meta_description"] == "Padel costs in Miami"
|
||||||
|
assert "<h1>" in result["html"]
|
||||||
|
|
||||||
|
async def test_preview_unknown_row_raises(self, db, pseo_env):
|
||||||
|
from padelnomics.content import preview_article
|
||||||
|
with pytest.raises(AssertionError, match="No row found"):
|
||||||
|
await preview_article("test-city", "nonexistent")
|
||||||
|
|
||||||
|
async def test_preview_with_language(self, db, pseo_env):
|
||||||
|
from padelnomics.content import preview_article
|
||||||
|
result = await preview_article("test-city", "miami", lang="de")
|
||||||
|
assert result["url_path"] == "/de/markets/us/miami"
|
||||||
|
|
||||||
|
async def test_preview_unknown_template_raises(self, db, pseo_env):
|
||||||
|
from padelnomics.content import preview_article
|
||||||
|
with pytest.raises(AssertionError, match="Template not found"):
|
||||||
|
await preview_article("nonexistent", "miami")
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
@@ -772,76 +962,114 @@ class TestAdminTemplates:
|
|||||||
resp = await admin_client.get("/admin/templates")
|
resp = await admin_client.get("/admin/templates")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
async def test_template_new_form(self, admin_client):
|
async def test_template_list_shows_discovered(self, admin_client, pseo_env):
|
||||||
resp = await admin_client.get("/admin/templates/new")
|
resp = await admin_client.get("/admin/templates")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
html = (await resp.data).decode()
|
||||||
|
assert "Test City Analysis" in html
|
||||||
|
assert "test-city" in html
|
||||||
|
|
||||||
async def test_template_create(self, admin_client, db):
|
|
||||||
|
class TestAdminTemplateDetail:
|
||||||
|
async def test_detail_shows_config(self, admin_client, db, pseo_env):
|
||||||
|
resp = await admin_client.get("/admin/templates/test-city")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = (await resp.data).decode()
|
||||||
|
assert "Test City Analysis" in html
|
||||||
|
assert "serving.test_cities" in html
|
||||||
|
|
||||||
|
async def test_detail_shows_columns(self, admin_client, db, pseo_env):
|
||||||
|
resp = await admin_client.get("/admin/templates/test-city")
|
||||||
|
html = (await resp.data).decode()
|
||||||
|
assert "city_slug" in html
|
||||||
|
assert "VARCHAR" in html
|
||||||
|
|
||||||
|
async def test_detail_shows_sample_data(self, admin_client, db, pseo_env):
|
||||||
|
resp = await admin_client.get("/admin/templates/test-city")
|
||||||
|
html = (await resp.data).decode()
|
||||||
|
assert "Miami" in html
|
||||||
|
assert "Berlin" in html
|
||||||
|
|
||||||
|
async def test_detail_unknown_slug_redirects(self, admin_client, db, pseo_env):
|
||||||
|
resp = await admin_client.get("/admin/templates/nonexistent")
|
||||||
|
assert resp.status_code == 302
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminTemplatePreview:
|
||||||
|
async def test_preview_renders_article(self, admin_client, db, pseo_env):
|
||||||
|
resp = await admin_client.get("/admin/templates/test-city/preview/miami")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = (await resp.data).decode()
|
||||||
|
assert "Padel in Miami" in html
|
||||||
|
|
||||||
|
async def test_preview_bad_key_redirects(self, admin_client, db, pseo_env):
|
||||||
|
resp = await admin_client.get("/admin/templates/test-city/preview/nonexistent")
|
||||||
|
assert resp.status_code == 302
|
||||||
|
|
||||||
|
async def test_preview_bad_template_redirects(self, admin_client, db, pseo_env):
|
||||||
|
resp = await admin_client.get("/admin/templates/bad-slug/preview/miami")
|
||||||
|
assert resp.status_code == 302
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminTemplateGenerate:
|
||||||
|
async def test_generate_form(self, admin_client, db, pseo_env):
|
||||||
|
resp = await admin_client.get("/admin/templates/test-city/generate")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = (await resp.data).decode()
|
||||||
|
assert "3" in html # 3 rows available
|
||||||
|
assert "Generate" in html
|
||||||
|
|
||||||
|
async def test_generate_creates_articles(self, admin_client, db, pseo_env):
|
||||||
async with admin_client.session_transaction() as sess:
|
async with admin_client.session_transaction() as sess:
|
||||||
sess["csrf_token"] = "test"
|
sess["csrf_token"] = "test"
|
||||||
|
|
||||||
resp = await admin_client.post("/admin/templates/new", form={
|
resp = await admin_client.post("/admin/templates/test-city/generate", form={
|
||||||
"csrf_token": "test",
|
"csrf_token": "test",
|
||||||
"name": "Test Template",
|
"start_date": "2026-04-01",
|
||||||
"slug": "test-tmpl",
|
"articles_per_day": "2",
|
||||||
"content_type": "calculator",
|
|
||||||
"input_schema": '[{"name":"city","label":"City","field_type":"text","required":true}]',
|
|
||||||
"url_pattern": "/test-{{ city }}",
|
|
||||||
"title_pattern": "Test {{ city }}",
|
|
||||||
"meta_description_pattern": "",
|
|
||||||
"body_template": "# Hello {{ city }}",
|
|
||||||
})
|
})
|
||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
|
|
||||||
row = await fetch_one("SELECT * FROM article_templates WHERE slug = 'test-tmpl'")
|
articles = await fetch_all("SELECT * FROM articles")
|
||||||
assert row is not None
|
assert len(articles) == 3
|
||||||
assert row["name"] == "Test Template"
|
|
||||||
|
|
||||||
async def test_template_edit(self, admin_client, db):
|
scenarios = await fetch_all("SELECT * FROM published_scenarios")
|
||||||
template_id = await execute(
|
assert len(scenarios) == 3
|
||||||
"""INSERT INTO article_templates
|
|
||||||
(name, slug, content_type, input_schema, url_pattern,
|
async def test_generate_unknown_template_redirects(self, admin_client, db, pseo_env):
|
||||||
title_pattern, body_template)
|
resp = await admin_client.get("/admin/templates/nonexistent/generate")
|
||||||
VALUES ('Edit Me', 'edit-me', 'calculator', '[]',
|
assert resp.status_code == 302
|
||||||
'/edit', 'Edit', '# body')"""
|
|
||||||
)
|
|
||||||
|
class TestAdminTemplateRegenerate:
|
||||||
|
async def test_regenerate_updates_articles(self, admin_client, db, pseo_env):
|
||||||
|
from padelnomics.content import generate_articles
|
||||||
|
# First generate
|
||||||
|
await generate_articles("test-city", date(2026, 3, 1), 10)
|
||||||
|
initial = await fetch_all("SELECT * FROM articles")
|
||||||
|
assert len(initial) == 3
|
||||||
|
|
||||||
async with admin_client.session_transaction() as sess:
|
async with admin_client.session_transaction() as sess:
|
||||||
sess["csrf_token"] = "test"
|
sess["csrf_token"] = "test"
|
||||||
|
|
||||||
resp = await admin_client.post(f"/admin/templates/{template_id}/edit", form={
|
resp = await admin_client.post("/admin/templates/test-city/regenerate", form={
|
||||||
"csrf_token": "test",
|
"csrf_token": "test",
|
||||||
"name": "Edited",
|
|
||||||
"input_schema": "[]",
|
|
||||||
"url_pattern": "/edit",
|
|
||||||
"title_pattern": "Edited",
|
|
||||||
"body_template": "# edited",
|
|
||||||
})
|
})
|
||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
|
|
||||||
row = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
|
# Same count — upserted, not duplicated
|
||||||
assert row["name"] == "Edited"
|
articles = await fetch_all("SELECT * FROM articles")
|
||||||
|
assert len(articles) == 3
|
||||||
async def test_template_delete(self, admin_client, db):
|
|
||||||
template_id = await execute(
|
|
||||||
"""INSERT INTO article_templates
|
|
||||||
(name, slug, content_type, input_schema, url_pattern,
|
|
||||||
title_pattern, body_template)
|
|
||||||
VALUES ('Del Me', 'del-me', 'calculator', '[]',
|
|
||||||
'/del', 'Del', '# body')"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
async def test_regenerate_unknown_template_redirects(self, admin_client, db, pseo_env):
|
||||||
async with admin_client.session_transaction() as sess:
|
async with admin_client.session_transaction() as sess:
|
||||||
sess["csrf_token"] = "test"
|
sess["csrf_token"] = "test"
|
||||||
|
|
||||||
resp = await admin_client.post(f"/admin/templates/{template_id}/delete", form={
|
resp = await admin_client.post("/admin/templates/nonexistent/regenerate", form={
|
||||||
"csrf_token": "test",
|
"csrf_token": "test",
|
||||||
})
|
})
|
||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
|
|
||||||
row = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
|
|
||||||
assert row is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdminScenarios:
|
class TestAdminScenarios:
|
||||||
async def test_scenario_list(self, admin_client):
|
async def test_scenario_list(self, admin_client):
|
||||||
@@ -1012,81 +1240,6 @@ class TestAdminArticles:
|
|||||||
assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (article_id,)) is None
|
assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (article_id,)) is None
|
||||||
|
|
||||||
|
|
||||||
class TestAdminTemplateData:
|
|
||||||
async def test_data_add(self, admin_client, db):
|
|
||||||
template_id, _ = await _create_template()
|
|
||||||
|
|
||||||
async with admin_client.session_transaction() as sess:
|
|
||||||
sess["csrf_token"] = "test"
|
|
||||||
|
|
||||||
resp = await admin_client.post(f"/admin/templates/{template_id}/data/add", form={
|
|
||||||
"csrf_token": "test",
|
|
||||||
"city": "London",
|
|
||||||
"city_slug": "london",
|
|
||||||
"country": "UK",
|
|
||||||
"region": "Europe",
|
|
||||||
"electricity": "650",
|
|
||||||
})
|
|
||||||
assert resp.status_code == 302
|
|
||||||
|
|
||||||
rows = await fetch_all(
|
|
||||||
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
|
|
||||||
)
|
|
||||||
# 3 from _create_template + 1 just added
|
|
||||||
assert len(rows) == 4
|
|
||||||
|
|
||||||
async def test_data_delete(self, admin_client, db):
|
|
||||||
template_id, _ = await _create_template()
|
|
||||||
rows = await fetch_all(
|
|
||||||
"SELECT id FROM template_data WHERE template_id = ?", (template_id,)
|
|
||||||
)
|
|
||||||
data_id = rows[0]["id"]
|
|
||||||
|
|
||||||
async with admin_client.session_transaction() as sess:
|
|
||||||
sess["csrf_token"] = "test"
|
|
||||||
|
|
||||||
resp = await admin_client.post(
|
|
||||||
f"/admin/templates/{template_id}/data/{data_id}/delete",
|
|
||||||
form={"csrf_token": "test"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 302
|
|
||||||
|
|
||||||
remaining = await fetch_all(
|
|
||||||
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
|
|
||||||
)
|
|
||||||
assert len(remaining) == 2
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdminGenerate:
|
|
||||||
async def test_generate_form(self, admin_client, db):
|
|
||||||
template_id, _ = await _create_template()
|
|
||||||
resp = await admin_client.get(f"/admin/templates/{template_id}/generate")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
html = (await resp.data).decode()
|
|
||||||
assert "3" in html # pending count
|
|
||||||
|
|
||||||
async def test_generate_creates_articles(self, admin_client, db):
|
|
||||||
from padelnomics.content.routes import BUILD_DIR
|
|
||||||
template_id, _ = await _create_template()
|
|
||||||
|
|
||||||
async with admin_client.session_transaction() as sess:
|
|
||||||
sess["csrf_token"] = "test"
|
|
||||||
|
|
||||||
resp = await admin_client.post(f"/admin/templates/{template_id}/generate", form={
|
|
||||||
"csrf_token": "test",
|
|
||||||
"start_date": "2026-04-01",
|
|
||||||
"articles_per_day": "2",
|
|
||||||
})
|
|
||||||
assert resp.status_code == 302
|
|
||||||
|
|
||||||
articles = await fetch_all("SELECT * FROM articles")
|
|
||||||
assert len(articles) == 3
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
for a in articles:
|
|
||||||
p = BUILD_DIR / f"{a['slug']}.html"
|
|
||||||
if p.exists():
|
|
||||||
p.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user