Compare commits

..

25 Commits

Author SHA1 Message Date
Deeman
48401bd2af feat(articles): rewrite B2B article CTAs — directory → /quote form
All checks were successful
CI / test (push) Successful in 50s
CI / tag (push) Successful in 3s
All 12 hall-building articles now link to /quote (leads.quote_request).
Previously: 2 had broken directory prose, 4 had unlinked planner mentions,
4 had broken [→ placeholder] links, 2 had scenario cards but no CTA link.

- Group 1 (bauen/build-guide): replace directory section with quote CTA
- Group 2 (kosten/risiken): link planner refs, append quote CTA
- Group 3 (finanzierung): append quote CTA after scenario card
- Group 4 (standort/businessplan): fix broken [→] links to /de|en/planner,
  append quote CTA

CTA copy is contextual per article. Light-blue banner pattern, .btn class.
B2C gear articles unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:55:28 +01:00
Deeman
cd02726d4c chore(changelog): document B2B article CTA rewrite
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:55:20 +01:00
Deeman
fbc259cafa fix(articles): fix broken CTA links + add /quote CTA in location and business plan articles
- padel-standort-analyse-de, padel-hall-location-guide-en:
  fix [→ ...] placeholders to /de/planner and /en/planner
  append quote CTA "Standort gefunden? Angebote einholen"
- padel-business-plan-bank-de, padel-business-plan-bank-requirements-en:
  fix [→ Businessplan erstellen] / [→ Generate your business plan] to planner
  append quote CTA "Bankfähige Zahlen plus passende Baupartner"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:39:59 +01:00
Deeman
992e448c18 fix(articles): add /quote CTA after scenario card in financing articles
Appends contextual quote CTA block to padel-halle-finanzierung-de.md
and padel-hall-financing-germany-en.md after the scenario card embed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:29:01 +01:00
Deeman
777a4af505 fix(articles): add /quote CTA + planner links in cost and risk articles
- padel-halle-kosten-de, padel-hall-cost-guide-en: link planner ref,
  append quote CTA "Zahlen prüfen — Angebote einholen"
- padel-halle-risiken-de, padel-hall-investment-risks-en: link planner
  in sensitivity tab mention, append quote CTA on risk management

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:18:46 +01:00
Deeman
2c8c662e9e fix(articles): replace directory CTA with /quote in build guides
Removes the broken "find suppliers" directory section from
padel-halle-bauen-de.md and padel-hall-build-guide-en.md.
Replaces with a contextual light-blue quote CTA block linking to /quote.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:17:28 +01:00
Deeman
34f8e45204 merge(articles): iframe preview + collapsible meta + word count 2026-03-02 12:09:04 +01:00
Deeman
6b9187f420 fix(articles): iframe preview + collapsible meta + word count
Replace the auto-escaped `{{ body_html }}` div (showed raw HTML tags)
with a sandboxed `<iframe srcdoc>` pattern matching the email preview.
Both the initial page load and the HTMX live-update endpoint now build
a full `preview_doc` document embedding the public CSS and wrapping
content in `<div class="article-body">` — pixel-perfect against the
live article, admin styles fully isolated.

Also:
- Delete ~65 lines of redundant `.preview-body` custom CSS
- Add "Meta ▾" toolbar toggle to collapse/expand metadata strip
- Add word count footer in the editor pane (updates on input)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 12:01:16 +01:00
Deeman
94d92328b8 merge: fix article .md lookup + lighter editor
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 3s
2026-03-02 11:47:13 +01:00
Deeman
100e200c3b fix(articles): find .md by slug scan + lighter editor theme
Two fixes:
- _find_article_md() scans _ARTICLES_DIR for files whose frontmatter
  slug matches, so padel-halle-bauen-de.md is found for slug
  'padel-halle-bauen'. The previous exact-name lookup missed any file
  where the filename ≠ slug (e.g. {slug}-{lang}.md naming convention).
- Editor pane: replace dark navy background with warm off-white (#FEFDFB)
  and dark text so it reads like a document, not a code editor.
2026-03-02 11:43:26 +01:00
Deeman
70628ea881 merge(pipeline-transform-tab): split article editor + frontmatter fix + transform tab features
All checks were successful
CI / test (push) Successful in 50s
CI / tag (push) Successful in 2s
2026-03-02 11:34:13 +01:00
Deeman
d619f5e1ef feat(articles): split editor with live preview + fix frontmatter bug
Bug: article_edit GET was passing raw .md file content (including YAML
frontmatter) to the body textarea. Articles synced from disk via
_sync_static_articles() had their frontmatter bled into the editor,
making it look like content was missing or garbled.

Fix: strip frontmatter (using existing _FRONTMATTER_RE) before setting
body, consistent with how _rebuild_article() already does it.
Also switch to _ARTICLES_DIR (absolute) instead of relative path.

New: split editor layout — compact metadata strip at top, dark
monospace textarea on the left, live rendered preview on the right
(HTMX, 500ms debounce). Initial preview server-rendered on page load.
New POST /admin/articles/preview endpoint returns the preview partial.
2026-03-02 11:10:01 +01:00
Deeman
2a7eed1576 merge: test suite compression pass (-197 lines)
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 3s
2026-03-02 10:46:01 +01:00
Deeman
162e633c62 refactor(tests): compress admin_client + mock_send_email into conftest
Lift admin_client fixture from 7 duplicate definitions into conftest.py.
Add mock_send_email fixture, replacing 60 inline patch() blocks across
test_emails.py, test_waitlist.py, and test_businessplan.py. Net -197 lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:40:52 +01:00
Deeman
31017457a6 merge: semantic-compression — add compression helpers, macros, and coding philosophy
All checks were successful
CI / test (push) Successful in 50s
CI / tag (push) Successful in 3s
Applies Casey Muratori's semantic compression across all three packages:
- count_where() helper: 30+ COUNT(*) call sites compressed
- _forward_lead(): deduplicates lead forward routes
- 5 SQLMesh macros for country code patterns (7 models)
- skip_if_current() + write_jsonl_atomic() extract helpers
Net: -118 lines (272 added, 390 removed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 08:00:15 +01:00
Deeman
f93e4fd0d1 chore(changelog): document semantic compression pass
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:54:44 +01:00
Deeman
567798ebe1 feat(extract): add skip_if_current() and write_jsonl_atomic() helpers
Task 5/6: Compress repeated patterns in extractors:
- skip_if_current(): cursor check + early-return dict (3 extractors)
- write_jsonl_atomic(): working-file → JSONL → compress (2 extractors)
Applied in gisco, geonames, census_usa, playtomic_tenants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:49:18 +01:00
Deeman
b32b7cd748 merge: unify confirm dialog — pure hx-confirm + form[method=dialog]
Eliminates confirmAction() entirely. One code path: all confirmations
go through showConfirm() called by the htmx:confirm interceptor.
14 template files converted to hx-boost + hx-confirm pattern.
Pipeline endpoints updated to exclude HX-Boosted requests from the
HTMX partial path.

# Conflicts:
#	web/src/padelnomics/admin/templates/admin/affiliate_form.html
#	web/src/padelnomics/admin/templates/admin/affiliate_program_form.html
#	web/src/padelnomics/admin/templates/admin/base_admin.html
#	web/src/padelnomics/admin/templates/admin/partials/affiliate_program_results.html
#	web/src/padelnomics/admin/templates/admin/partials/affiliate_row.html
2026-03-02 07:48:49 +01:00
Deeman
6774254cb0 feat(sqlmesh): add country code macros, apply across models
Task 4/6: Add 5 macros to compress repeated country code patterns:
- @country_name / @country_slug: 20-country CASE in dim_cities, dim_locations
- @normalize_eurostat_country / @normalize_eurostat_nuts: EL→GR, UK→GB
- @infer_country_from_coords: bounding box for 8 markets
Net: +91 lines in macros, -135 lines in models = -44 lines total.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:45:52 +01:00
Deeman
e87a7fc9d6 refactor(admin): extract _forward_lead() from duplicate lead forward routes
Task 3/6: lead_forward and lead_forward_htmx shared ~20 lines of
identical DB logic. Extracted into _forward_lead() that returns an
error string or None. Both routes now call the helper and differ
only in response format (redirect vs HTMX partial).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:43:50 +01:00
Deeman
3d7a72ba26 refactor: apply count_where() across remaining web blueprints
Task 2/6 continued: Compress 18 more COUNT(*) call sites across
suppliers, directory, dashboard, public, planner, pseo, and pipeline
routes. -24 lines net.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:40:24 +01:00
Deeman
a55501f2ea feat(core): add count_where() helper, compress admin COUNT queries
Task 2/6: Adds count_where(table_where, params) to core.py that
compresses the fetch_one + null-check COUNT(*) pattern. Applied
across admin/routes.py — dashboard stats shrinks from ~75 to ~25
lines, plus 10 more call sites compressed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:35:33 +01:00
Deeman
d3626193c5 refactor(admin): unify confirm dialog — pure hx-confirm + form[method=dialog]
Eliminate `confirmAction()` and the duplicate `cloneNode` hack entirely.
One code path: everything goes through `showConfirm()` called by the
`htmx:confirm` interceptor.

Dialog HTML:
- `<form method="dialog">` for native close semantics; button `value`
  becomes `dialog.returnValue` — no manual event listener reassignment.

JS:
- `showConfirm(message)` — Promise-based, listens for `close` once.
- `htmx:confirm` handler calls `showConfirm()` and calls `issueRequest`
  if confirmed. Replaces both the old HTMX handler and `confirmAction()`.

Templates (Padelnomics, 14 files):
- All `onclick=confirmAction(...)` and `onclick=confirm()` removed.
- Form-submit buttons: added `hx-boost="true"` to form + `hx-confirm`
  on the submit button.
- Pure HTMX buttons (pipeline_transform, pipeline_overview): `hx-confirm`
  replaces `onclick=if(!confirm(...))return false;`.

Pipeline routes (pipeline_trigger_extract, pipeline_trigger_transform):
- `is_htmx` now excludes `HX-Boosted: true` requests — boosted form
  POSTs get the normal redirect instead of the inline partial.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 07:35:32 +01:00
Deeman
7ea1f234e8 chore(changelog): document htmx:confirm guard fix
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:40:07 +01:00
Deeman
c1cf472caf fix(admin): guard htmx:confirm handler against empty question
The handler called evt.preventDefault() unconditionally, so auto-poll
requests (hx-trigger="every 5s", no hx-confirm) caused an empty dialog
to pop up every 5 seconds. Add an early return when evt.detail.question
is falsy so only actual hx-confirm interactions are intercepted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:39:38 +01:00
60 changed files with 1185 additions and 1010 deletions

View File

@@ -6,6 +6,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### Fixed
- **B2B article CTAs rewritten — all 12 now link to `/quote`** — zero articles previously linked to the quote lead-capture form. Each article's final section has been updated:
- `padel-halle-bauen-de` / `padel-hall-build-guide-en`: replaced broken "directory" section (no link) with a contextual light-blue quote CTA block
- `padel-halle-kosten-de` / `padel-hall-cost-guide-en`: planner mention linked to `/de/planner` / `/en/planner`; quote CTA block appended
- `padel-halle-risiken-de` / `padel-hall-investment-risks-en`: planner sensitivity-tab mention linked; quote CTA block appended
- `padel-halle-finanzierung-de` / `padel-hall-financing-germany-en`: quote CTA block appended after scenario card embed
- `padel-standort-analyse-de` / `padel-hall-location-guide-en`: fixed broken `[→ Standortanalyse starten]` / `[→ Run a location analysis]` placeholders (no href) to `/de/planner` / `/en/planner`; quote CTA block appended
- `padel-business-plan-bank-de` / `padel-business-plan-bank-requirements-en`: fixed broken `[→ Businessplan erstellen]` / `[→ Generate your business plan]` placeholders to `/de/planner` / `/en/planner`; quote CTA block appended
- CTA copy is contextual per article (not identical boilerplate); uses the light-blue banner pattern (`.btn` class, `#EFF6FF` background) consistent with other generated articles
- **Article editor preview now renders HTML correctly** — replaced the raw `{{ body_html }}` div (which Jinja2 auto-escaped to literal `<h1>...</h1>` text) with a sandboxed `<iframe srcdoc="...">` pattern. The route builds a full `preview_doc` HTML document embedding the public site stylesheet (`/static/css/output.css`) and wraps content in `<div class="article-body">`, so the preview is pixel-perfect against the live article. The `article_preview` POST endpoint uses the same pattern for HTMX live updates. Removed ~65 lines of redundant `.preview-body` custom CSS from the editor template.
### Changed
- **Semantic compression pass** — applied Casey Muratori's compression workflow (write concrete → observe patterns → compress genuine repetitions) across all three packages. Net result: ~200 lines removed, codebase simpler.
- **`count_where()` helper** (`web/core.py`): compresses the `fetch_one("SELECT COUNT(*) ...") + null-check` pattern. Applied across 30+ call sites in admin, suppliers, directory, dashboard, public, and planner routes. Dashboard stats function shrinks from 75 to 25 lines.
- **`_forward_lead()` helper** (`web/admin/routes.py`): extracts shared DB logic from `lead_forward` and `lead_forward_htmx` — both routes now call the helper and differ only in response format.
- **SQLMesh macros** (`transform/macros/__init__.py`): 5 new macros compress repeated country code patterns across 7 SQL models: `@country_name`, `@country_slug`, `@normalize_eurostat_country`, `@normalize_eurostat_nuts`, `@infer_country_from_coords`.
- **Extract helpers** (`extract/utils.py`): `skip_if_current()` compresses cursor-check + early-return pattern (3 extractors); `write_jsonl_atomic()` compresses working-file → JSONL → compress pattern (2 extractors).
- **Coding philosophy updated** (`~/.claude/coding_philosophy.md`): added `<compression>` section documenting the workflow, the test ("Did this abstraction make the total codebase smaller?"), and distinction from premature DRY.
- **Test suite compression pass** — applied same compression workflow to `web/tests/` (30 files, 13,949 lines). Net result: -197 lines across 11 files.
- **`admin_client` fixture** lifted from 7 duplicate definitions into `conftest.py`.
- **`mock_send_email` fixture** added to `conftest.py`, replacing 60 inline `with patch("padelnomics.worker.send_email", ...)` blocks across `test_emails.py` (51), `test_waitlist.py` (4), `test_businessplan.py` (2). Each refactored test drops one indentation level.
### Fixed
- **Admin: empty confirm dialog on auto-poll** — `htmx:confirm` handler now guards with `if (!evt.detail.question) return` so auto-poll requests (`hx-trigger="every 5s"`, no `hx-confirm` attribute) no longer trigger an empty dialog every 5 seconds.
### Changed ### Changed
- **Admin: styled confirm dialog for all destructive actions** — replaced all native `window.confirm()` calls with the existing `#confirm-dialog` styled `<dialog>`. A new global `htmx:confirm` handler intercepts HTMX confirmation prompts and shows the dialog; form-submit buttons on affiliate pages were updated to use `confirmAction()`. Affected: pipeline Transform tab (Run Transform, Run Export, Run Full Pipeline), pipeline Overview tab (Run extractor), affiliate product delete, affiliate program delete (both form and list variants). - **Admin: styled confirm dialog for all destructive actions** — replaced all native `window.confirm()` calls with the existing `#confirm-dialog` styled `<dialog>`. A new global `htmx:confirm` handler intercepts HTMX confirmation prompts and shows the dialog; form-submit buttons on affiliate pages were updated to use `confirmAction()`. Affected: pipeline Transform tab (Run Transform, Run Export, Run Full Pipeline), pipeline Overview tab (Run extractor), affiliate product delete, affiliate program delete (both form and list variants).
- **Pipeline tabs: no scrollbar** — added `scrollbar-width: none` and `::-webkit-scrollbar { display: none }` to `.pipeline-tabs` to suppress the spurious horizontal scrollbar on narrow viewports. - **Pipeline tabs: no scrollbar** — added `scrollbar-width: none` and `::-webkit-scrollbar { display: none }` to `.pipeline-tabs` to suppress the spurious horizontal scrollbar on narrow viewports.
@@ -16,6 +43,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **Proxy URL scheme validation in `load_proxy_tiers()`** — URLs in `PROXY_URLS_DATACENTER` / `PROXY_URLS_RESIDENTIAL` that are missing an `http://` or `https://` scheme are now logged as a warning and skipped, rather than being passed through and causing SSL handshake failures or connection errors at request time. Also fixed a missing `http://` prefix in the dev `.env` `PROXY_URLS_DATACENTER` entry. - **Proxy URL scheme validation in `load_proxy_tiers()`** — URLs in `PROXY_URLS_DATACENTER` / `PROXY_URLS_RESIDENTIAL` that are missing an `http://` or `https://` scheme are now logged as a warning and skipped, rather than being passed through and causing SSL handshake failures or connection errors at request time. Also fixed a missing `http://` prefix in the dev `.env` `PROXY_URLS_DATACENTER` entry.
### Changed ### Changed
- **Unified confirm dialog — pure HTMX `hx-confirm` + `<form method="dialog">`** — eliminated the `confirmAction()` JS function and the duplicate `cloneNode` hack. All confirmation prompts now go through a single `showConfirm()` Promise-based function called by the `htmx:confirm` interceptor. The dialog HTML uses `<form method="dialog">` for native close semantics (`returnValue` is `"ok"` or `"cancel"`), removing the need to clone and replace buttons on every invocation. All 12 Padelnomics call sites converted from `onclick=confirmAction(...)` to `hx-boost="true"` + `hx-confirm="..."` on the submit button. Pipeline trigger endpoints updated to treat `HX-Boosted: true` requests as non-HTMX (returning a redirect rather than an inline partial) so boosted form submissions flow through the normal redirect cycle. Same changes applied to BeanFlows and the quart-saas-boilerplate template.
- `web/src/padelnomics/admin/templates/admin/base_admin.html`: replaced dialog `<div>` with `<form method="dialog">`, replaced `confirmAction()` + inline `htmx:confirm` handler with unified `showConfirm()` + single `htmx:confirm` listener
- `web/src/padelnomics/admin/pipeline_routes.py`: `pipeline_trigger_extract` and `pipeline_trigger_transform` now exclude `HX-Boosted: true` from the HTMX partial path
- 12 templates updated: `pipeline.html`, `partials/pipeline_extractions.html`, `affiliate_form.html`, `affiliate_program_form.html`, `partials/affiliate_program_results.html`, `partials/affiliate_row.html`, `generate_form.html`, `articles.html`, `audience_contacts.html`, `template_detail.html`, `partials/scenario_results.html`
- Same changes mirrored to BeanFlows and quart-saas-boilerplate template
- **Per-proxy dead tracking in tiered cycler** — `make_tiered_cycler` now accepts a `proxy_failure_limit` parameter (default 3). Individual proxies that hit the limit are marked dead and permanently skipped by `next_proxy()`. If all proxies in the active tier are dead, `next_proxy()` auto-escalates to the next tier without needing the tier-level threshold. `record_failure(proxy_url)` and `record_success(proxy_url)` accept an optional `proxy_url` argument for per-proxy tracking; callers without `proxy_url` are fully backward-compatible. New `dead_proxy_count()` callable exposed for monitoring. - **Per-proxy dead tracking in tiered cycler** — `make_tiered_cycler` now accepts a `proxy_failure_limit` parameter (default 3). Individual proxies that hit the limit are marked dead and permanently skipped by `next_proxy()`. If all proxies in the active tier are dead, `next_proxy()` auto-escalates to the next tier without needing the tier-level threshold. `record_failure(proxy_url)` and `record_success(proxy_url)` accept an optional `proxy_url` argument for per-proxy tracking; callers without `proxy_url` are fully backward-compatible. New `dead_proxy_count()` callable exposed for monitoring.
- `extract/padelnomics_extract/src/padelnomics_extract/proxy.py`: added per-proxy state (`proxy_failure_counts`, `dead_proxies`), updated `next_proxy`/`record_failure`/`record_success`, added `dead_proxy_count` - `extract/padelnomics_extract/src/padelnomics_extract/proxy.py`: added per-proxy state (`proxy_failure_counts`, `dead_proxies`), updated `next_proxy`/`record_failure`/`record_success`, added `dead_proxy_count`
- `extract/padelnomics_extract/src/padelnomics_extract/playtomic_tenants.py`: `_fetch_page_via_cycler` passes `proxy_url` to `record_success`/`record_failure` - `extract/padelnomics_extract/src/padelnomics_extract/playtomic_tenants.py`: `_fetch_page_via_cycler` passes `proxy_url` to `record_success`/`record_failure`

View File

@@ -160,4 +160,10 @@ Ein bankfähiger Businessplan steht und fällt mit der Qualität der Finanzdaten
Der Businessplan-Export enthält alle 13 Gliederungsabschnitte mit automatisch befüllten Finanztabellen, einer KDDB-Berechnung für alle drei Szenarien und einer Übersicht der relevanten KfW-Programme für Ihr Bundesland. Der Businessplan-Export enthält alle 13 Gliederungsabschnitte mit automatisch befüllten Finanztabellen, einer KDDB-Berechnung für alle drei Szenarien und einer Übersicht der relevanten KfW-Programme für Ihr Bundesland.
[→ Businessplan erstellen] [→ Businessplan erstellen](/de/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Bankfähige Zahlen plus passende Baupartner</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Zum überzeugenden Bankgespräch gehören nicht nur solide Zahlen — sondern auch ein konkretes Angebot von realen Baupartnern. Schildern Sie Ihr Vorhaben in wenigen Minuten — wir stellen den Kontakt zu Architekten, Court-Lieferanten und Haustechnikspezialisten her. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -160,4 +160,10 @@ A bankable business plan depends on the quality of the financial model behind it
The business plan export includes all 13 sections with auto-populated financial tables, a DSCR calculation across all three scenarios, and a summary of applicable KfW and state programs for your *Bundesland*. The business plan export includes all 13 sections with auto-populated financial tables, a DSCR calculation across all three scenarios, and a summary of applicable KfW and state programs for your *Bundesland*.
[→ Generate your business plan] [→ Generate your business plan](/en/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Complete your bank file — get a build cost estimate</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">A credible bank application pairs a financial model with a real contractor quote. Describe your project — we'll connect you with architects, court suppliers, and MEP specialists who can provide the cost documentation your bank needs. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -331,8 +331,10 @@ Building a padel hall is complex, but it is a solved problem. The failures are n
--- ---
## Find Builders and Suppliers Through Padelnomics ## Find the Right Build Partners
Padelnomics maintains a directory of verified build partners for padel hall projects: architects with sports facility experience, court suppliers, HVAC specialists, and operational consultants. <div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Get quotes from verified build partners</p>
If you're currently in Phase 1 or Phase 2 and looking for the right partners, the directory is the fastest place to start. <p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">From feasibility to court installation: describe your project in a few minutes — we'll connect you with vetted architects, court suppliers, and MEP specialists. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -191,4 +191,10 @@ Opening a padel hall in Germany in 2026 is a real capital commitment: €930k on
The investors who succeed here are not the ones who found a cheaper build. They are the ones who understood the numbers precisely enough to make the right location and concept decisions early — and to structure their financing before the costs escalated. The investors who succeed here are not the ones who found a cheaper build. They are the ones who understood the numbers precisely enough to make the right location and concept decisions early — and to structure their financing before the costs escalated.
**Next step:** Use the Padelnomics Financial Planner to model your specific scenario — your city, your financing mix, your pricing assumptions. The figures in this article are your starting point; your hall deserves a projection built around your actual numbers. **Next step:** Use the [Padelnomics Financial Planner](/en/planner) to model your specific scenario — your city, your financing mix, your pricing assumptions. The figures in this article are your starting point; your hall deserves a projection built around your actual numbers.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Test your numbers against real market prices</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Once your model is in shape, the next step is benchmarking against actual quotes. Describe your project — we'll connect you with build partners who can give you concrete figures for your specific facility. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -179,3 +179,9 @@ Your most powerful tool in every bank meeting: a complete financial model demons
[scenario:padel-halle-6-courts:full] [scenario:padel-halle-6-courts:full]
The Padelnomics business plan includes a full financing structure overview and use-of-funds breakdown — the exact format your bank needs to evaluate the application. The Padelnomics business plan includes a full financing structure overview and use-of-funds breakdown — the exact format your bank needs to evaluate the application.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Ready to take financing to the next step?</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">A credible bank application pairs your financial model with a real build cost estimate from a contractor. Describe your project — we'll connect you with build partners who provide the cost documentation lenders expect. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -218,6 +218,12 @@ The investors who succeed long-term in padel aren't the ones who found a risk-fr
## Model the Downside with Padelnomics ## Model the Downside with Padelnomics
The Padelnomics investment planner includes a sensitivity analysis tab designed for exactly this kind of scenario work: how does ROI change at 40% vs 65% utilization? What does a six-month construction delay cost in total? What happens to the model when a competitor opens in year three and takes 20% of demand? The [Padelnomics investment planner](/en/planner) includes a sensitivity analysis tab designed for exactly this kind of scenario work: how does ROI change at 40% vs 65% utilization? What does a six-month construction delay cost in total? What happens to the model when a competitor opens in year three and takes 20% of demand?
Good decisions need an honest model — not just the best-case assumptions. Good decisions need an honest model — not just the best-case assumptions.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Start with the right partners</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Most of the risks in this article are manageable with the right advisors, builders, and specialists on board from day one. Describe your project — we'll connect you with vetted partners who specialize in padel facilities. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -184,4 +184,10 @@ Padelnomics tracks venue density, booking platform utilisation, and demographic
Padelnomics analyzes market data for your target area: player density, competitive supply, demand signals from booking platform data, and demographic indicators at municipality level. For your candidate sites, Padelnomics produces a catchment area profile and a side-by-side comparison — so the decision is grounded in data rather than a map with a finger pointing at it. Padelnomics analyzes market data for your target area: player density, competitive supply, demand signals from booking platform data, and demographic indicators at municipality level. For your candidate sites, Padelnomics produces a catchment area profile and a side-by-side comparison — so the decision is grounded in data rather than a map with a finger pointing at it.
[→ Run a location analysis] [→ Run a location analysis](/en/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Site shortlisted — time to get quotes</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Once a location passes your criteria, the next step is engaging architects and court suppliers. Describe your project — we'll connect you with vetted build partners who can give you concrete figures. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -326,8 +326,10 @@ Eine Padelhalle zu bauen ist komplex — aber kein ungelöstes Problem. Die Fehl
--- ---
## Planer und Lieferanten finden ## Die richtigen Baupartner finden
Padelnomics führt ein Verzeichnis verifizierter Baupartner für Padelhallen im DACH-Raum: Architekten mit Sportanlagenerfahrung, Court-Lieferanten, Haustechnikspezialisten und Betriebsberater. <div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Angebote von verifizierten Baupartnern erhalten</p>
Wenn Sie gerade in Phase 1 oder Phase 2 sind und die richtigen Partner suchen, ist das Verzeichnis der schnellste Einstieg. <p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Von der Machbarkeitsstudie bis zum Court-Einbau: Schildern Sie Ihr Projekt in wenigen Minuten — wir stellen den Kontakt zu geprüften Architekten, Court-Lieferanten und Haustechnikspezialisten her. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -199,3 +199,9 @@ Ihr wichtigstes Werkzeug in jedem Bankgespräch: ein vollständiges Finanzmodell
[scenario:padel-halle-6-courts:full] [scenario:padel-halle-6-courts:full]
Der Padelnomics-Businessplan enthält eine vollständige Finanzierungsstrukturübersicht und eine Mittelverwendungsplanung, die direkt in Ihr Bankgespräch mitgenommen werden kann. Der Padelnomics-Businessplan enthält eine vollständige Finanzierungsstrukturübersicht und eine Mittelverwendungsplanung, die direkt in Ihr Bankgespräch mitgenommen werden kann.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Bankgespräch vorbereiten — Baupartner finden</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Bereit, die Finanzierungsphase anzugehen? Für ein überzeugendes Bankgespräch brauchen Sie auch ein konkretes Angebot von realen Baupartnern. Schildern Sie Ihr Projekt in wenigen Minuten — wir stellen den Kontakt zu Architekten, Court-Lieferanten und Haustechnikspezialisten her, die bankfähige Kalkulationsunterlagen liefern. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -189,4 +189,10 @@ Die Kosten für eine Padelhalle sind real und erheblich — €930.000 bis €1,
Richtig aufgesetzt, stimmt die Wirtschaftlichkeit: Bei konservativen Annahmen und solider Betriebsführung ist die Amortisation in 35 Jahren realistisch. Der deutsche Padel-Markt wächst weiter — aber mit wachsendem Angebot steigen auch die Erwartungen der Spieler und die Anforderungen an Konzept, Lage und Service. Richtig aufgesetzt, stimmt die Wirtschaftlichkeit: Bei konservativen Annahmen und solider Betriebsführung ist die Amortisation in 35 Jahren realistisch. Der deutsche Padel-Markt wächst weiter — aber mit wachsendem Angebot steigen auch die Erwartungen der Spieler und die Anforderungen an Konzept, Lage und Service.
**Nächster Schritt:** Nutzen Sie den Padelnomics Financial Planner, um Ihre spezifische Konstellation durchzurechnen — mit Ihrem Standort, Ihrer Finanzierungsstruktur und Ihren Preisannahmen. Die Zahlen in diesem Artikel sind Ihr Ausgangspunkt — Ihre Halle verdient eine Kalkulation, die auf Ihren tatsächlichen Rahmenbedingungen aufbaut. **Nächster Schritt:** Nutzen Sie den [Padelnomics Financial Planner](/de/planner), um Ihre spezifische Konstellation durchzurechnen — mit Ihrem Standort, Ihrer Finanzierungsstruktur und Ihren Preisannahmen. Die Zahlen in diesem Artikel sind Ihr Ausgangspunkt — Ihre Halle verdient eine Kalkulation, die auf Ihren tatsächlichen Rahmenbedingungen aufbaut.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Zahlen prüfen — Angebote einholen</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Wenn Ihre Kalkulation steht, ist der nächste Schritt die Konfrontation mit realen Marktpreisen. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu Baupartnern her, die konkrete Angebote auf Basis Ihrer Anlage machen können. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -216,6 +216,12 @@ Niemand kann alle Risiken eliminieren. Aber die Investoren, die langfristig erfo
## Die Padelnomics-Investitionsrechnung ## Die Padelnomics-Investitionsrechnung
Der Padelnomics-Planer enthält einen Sensitivitätsanalyse-Tab, der genau diese Szenarien berechenbar macht: Wie verändert sich der ROI bei 40 versus 65 Prozent Auslastung? Was kostet ein sechsmonatiger Bauverzug? Was passiert, wenn ein Wettbewerber in Jahr drei 20 Prozent Ihrer Nachfrage abzieht? Der [Padelnomics-Planer](/de/planner) enthält einen Sensitivitätsanalyse-Tab, der genau diese Szenarien berechenbar macht: Wie verändert sich der ROI bei 40 versus 65 Prozent Auslastung? Was kostet ein sechsmonatiger Bauverzug? Was passiert, wenn ein Wettbewerber in Jahr drei 20 Prozent Ihrer Nachfrage abzieht?
Gute Entscheidungen brauchen ein ehrliches Modell — nicht nur die besten Annahmen. Gute Entscheidungen brauchen ein ehrliches Modell — nicht nur die besten Annahmen.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Ihr Projekt mit den richtigen Partnern absichern</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Das beste Risikomanagement beginnt mit der richtigen Auswahl an Planern und Baupartnern. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu geprüften Architekten, Court-Lieferanten und Haustechnikspezialisten her, die sich auf Padelanlagen spezialisiert haben. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -174,4 +174,10 @@ Padelnomics erfasst Anlagendichte, Buchungsplattform-Auslastung und demografisch
Padelnomics wertet Marktdaten für Ihr Zielgebiet aus: Spielerdichte, Wettbewerbsdichte, Court-Nachfrage-Indikatoren aus Buchungsplattformdaten und demografische Kennzahlen auf Gemeindeebene. Für Ihre potenziellen Standorte erstellt Padelnomics ein Einzugsgebietsprofil und einen Standortvergleich — so dass die Entscheidung auf einer Datenbasis getroffen werden kann, nicht auf einer Karte mit Fingerzeig. Padelnomics wertet Marktdaten für Ihr Zielgebiet aus: Spielerdichte, Wettbewerbsdichte, Court-Nachfrage-Indikatoren aus Buchungsplattformdaten und demografische Kennzahlen auf Gemeindeebene. Für Ihre potenziellen Standorte erstellt Padelnomics ein Einzugsgebietsprofil und einen Standortvergleich — so dass die Entscheidung auf einer Datenbasis getroffen werden kann, nicht auf einer Karte mit Fingerzeig.
[→ Standortanalyse starten] [→ Standortanalyse starten](/de/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Den richtigen Standort gefunden? Angebote einholen.</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Sobald ein Standort die Kriterien erfüllt, folgt der nächste Schritt: die Kontaktaufnahme mit Architekten und Court-Lieferanten. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu geprüften Baupartnern her. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -19,7 +19,7 @@ from pathlib import Path
import niquests import niquests
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging
from .utils import get_last_cursor, landing_path, write_gzip_atomic from .utils import landing_path, skip_if_current, write_gzip_atomic
logger = setup_logging("padelnomics.extract.census_usa") logger = setup_logging("padelnomics.extract.census_usa")
@@ -73,10 +73,10 @@ def extract(
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0} return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
# Skip if we already have data for this month (annual data, monthly cursor) # Skip if we already have data for this month (annual data, monthly cursor)
last_cursor = get_last_cursor(conn, EXTRACTOR_NAME) skip = skip_if_current(conn, EXTRACTOR_NAME, year_month)
if last_cursor == year_month: if skip:
logger.info("already have data for %s — skipping", year_month) logger.info("already have data for %s — skipping", year_month)
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0} return skip
year, month = year_month.split("/") year, month = year_month.split("/")
url = f"{ACS_URL}&key={api_key}" url = f"{ACS_URL}&key={api_key}"

View File

@@ -19,7 +19,6 @@ Output: one JSON object per line, e.g.:
import gzip import gzip
import io import io
import json
import os import os
import sqlite3 import sqlite3
import zipfile import zipfile
@@ -28,7 +27,7 @@ from pathlib import Path
import niquests import niquests
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging
from .utils import compress_jsonl_atomic, get_last_cursor, landing_path from .utils import landing_path, skip_if_current, write_jsonl_atomic
logger = setup_logging("padelnomics.extract.geonames") logger = setup_logging("padelnomics.extract.geonames")
@@ -139,10 +138,10 @@ def extract(
tmp.rename(dest) tmp.rename(dest)
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0} return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
last_cursor = get_last_cursor(conn, EXTRACTOR_NAME) skip = skip_if_current(conn, EXTRACTOR_NAME, year_month)
if last_cursor == year_month: if skip:
logger.info("already have data for %s — skipping", year_month) logger.info("already have data for %s — skipping", year_month)
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0} return skip
year, month = year_month.split("/") year, month = year_month.split("/")
@@ -168,11 +167,7 @@ def extract(
dest_dir = landing_path(landing_dir, "geonames", year, month) dest_dir = landing_path(landing_dir, "geonames", year, month)
dest = dest_dir / "cities_global.jsonl.gz" dest = dest_dir / "cities_global.jsonl.gz"
working_path = dest.with_suffix(".working.jsonl") bytes_written = write_jsonl_atomic(dest, rows)
with open(working_path, "w") as f:
for row in rows:
f.write(json.dumps(row, separators=(",", ":")) + "\n")
bytes_written = compress_jsonl_atomic(working_path, dest)
logger.info("written %s bytes compressed", f"{bytes_written:,}") logger.info("written %s bytes compressed", f"{bytes_written:,}")
return { return {

View File

@@ -17,7 +17,7 @@ from pathlib import Path
import niquests import niquests
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging
from .utils import get_last_cursor from .utils import skip_if_current
logger = setup_logging("padelnomics.extract.gisco") logger = setup_logging("padelnomics.extract.gisco")
@@ -45,10 +45,10 @@ def extract(
session: niquests.Session, session: niquests.Session,
) -> dict: ) -> dict:
"""Download NUTS-2 GeoJSON. Skips if already run this month or file exists.""" """Download NUTS-2 GeoJSON. Skips if already run this month or file exists."""
last_cursor = get_last_cursor(conn, EXTRACTOR_NAME) skip = skip_if_current(conn, EXTRACTOR_NAME, year_month)
if last_cursor == year_month: if skip:
logger.info("already ran for %s — skipping", year_month) logger.info("already ran for %s — skipping", year_month)
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0} return skip
dest = landing_dir / DEST_REL dest = landing_dir / DEST_REL
if dest.exists(): if dest.exists():

View File

@@ -21,7 +21,6 @@ Rate: 1 req / 2 s per IP (see docs/data-sources-inventory.md §1.2).
Landing: {LANDING_DIR}/playtomic/{year}/{month}/tenants.jsonl.gz Landing: {LANDING_DIR}/playtomic/{year}/{month}/tenants.jsonl.gz
""" """
import json
import os import os
import sqlite3 import sqlite3
import time import time
@@ -33,7 +32,7 @@ import niquests
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging, ua_for_proxy from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging, ua_for_proxy
from .proxy import load_proxy_tiers, make_tiered_cycler from .proxy import load_proxy_tiers, make_tiered_cycler
from .utils import compress_jsonl_atomic, landing_path from .utils import landing_path, write_jsonl_atomic
logger = setup_logging("padelnomics.extract.playtomic_tenants") logger = setup_logging("padelnomics.extract.playtomic_tenants")
@@ -215,11 +214,7 @@ def extract(
time.sleep(THROTTLE_SECONDS) time.sleep(THROTTLE_SECONDS)
# Write each tenant as a JSONL line, then compress atomically # Write each tenant as a JSONL line, then compress atomically
working_path = dest.with_suffix(".working.jsonl") bytes_written = write_jsonl_atomic(dest, all_tenants)
with open(working_path, "w") as f:
for tenant in all_tenants:
f.write(json.dumps(tenant, separators=(",", ":")) + "\n")
bytes_written = compress_jsonl_atomic(working_path, dest)
logger.info("%d unique venues -> %s", len(all_tenants), dest) logger.info("%d unique venues -> %s", len(all_tenants), dest)
return { return {

View File

@@ -101,6 +101,19 @@ def get_last_cursor(conn: sqlite3.Connection, extractor: str) -> str | None:
return row["cursor_value"] if row else None return row["cursor_value"] if row else None
_SKIP_RESULT = {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
def skip_if_current(conn: sqlite3.Connection, extractor: str, year_month: str) -> dict | None:
"""Return an early-exit result dict if this extractor already ran for year_month.
Returns None when the extractor should proceed with extraction.
"""
if get_last_cursor(conn, extractor) == year_month:
return _SKIP_RESULT
return None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# File I/O helpers # File I/O helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -176,6 +189,20 @@ def write_gzip_atomic(path: Path, data: bytes) -> int:
return len(compressed) return len(compressed)
def write_jsonl_atomic(dest: Path, items: list[dict]) -> int:
"""Write items as JSONL, then compress atomically to dest (.jsonl.gz).
Compresses the working-file → JSONL → gzip pattern into one call.
Returns compressed bytes written.
"""
assert items, "items must not be empty"
working_path = dest.with_suffix(".working.jsonl")
with open(working_path, "w") as f:
for item in items:
f.write(json.dumps(item, separators=(",", ":")) + "\n")
return compress_jsonl_atomic(working_path, dest)
def compress_jsonl_atomic(jsonl_path: Path, dest_path: Path) -> int: def compress_jsonl_atomic(jsonl_path: Path, dest_path: Path) -> int:
"""Compress a JSONL working file to .jsonl.gz atomically, then delete the source. """Compress a JSONL working file to .jsonl.gz atomically, then delete the source.

View File

@@ -16,5 +16,92 @@ def padelnomics_glob(evaluator) -> str:
return f"'{landing_dir}/padelnomics/**/*.csv.gz'" return f"'{landing_dir}/padelnomics/**/*.csv.gz'"
# Add one macro per landing zone subdirectory you create. # ── Country code helpers ─────────────────────────────────────────────────────
# Pattern: def {source}_glob(evaluator) → f"'{landing_dir}/{source}/**/*.csv.gz'" # Shared lookup used by dim_cities and dim_locations.
_COUNTRY_NAMES = {
"DE": "Germany", "ES": "Spain", "GB": "United Kingdom",
"FR": "France", "IT": "Italy", "PT": "Portugal",
"AT": "Austria", "CH": "Switzerland", "NL": "Netherlands",
"BE": "Belgium", "SE": "Sweden", "NO": "Norway",
"DK": "Denmark", "FI": "Finland", "US": "United States",
"AR": "Argentina", "MX": "Mexico", "AE": "UAE",
"AU": "Australia", "IE": "Ireland",
}
def _country_case(col: str) -> str:
"""Build a CASE expression mapping ISO 3166-1 alpha-2 → English name."""
whens = "\n ".join(
f"WHEN '{code}' THEN '{name}'" for code, name in _COUNTRY_NAMES.items()
)
return f"CASE {col}\n {whens}\n ELSE {col}\n END"
@macro()
def country_name(evaluator, code_col) -> str:
"""CASE expression: country code → English name.
Usage in SQL: @country_name(vc.country_code) AS country_name_en
"""
return _country_case(str(code_col))
@macro()
def country_slug(evaluator, code_col) -> str:
"""CASE expression: country code → URL-safe slug (lowercased, spaces → dashes).
Usage in SQL: @country_slug(vc.country_code) AS country_slug
"""
return f"LOWER(REGEXP_REPLACE({_country_case(str(code_col))}, '[^a-zA-Z0-9]+', '-'))"
@macro()
def normalize_eurostat_country(evaluator, code_col) -> str:
"""Normalize Eurostat country codes to ISO 3166-1 alpha-2: EL→GR, UK→GB.
Usage in SQL: @normalize_eurostat_country(geo_code) AS country_code
"""
col = str(code_col)
return f"CASE {col} WHEN 'EL' THEN 'GR' WHEN 'UK' THEN 'GB' ELSE {col} END"
@macro()
def normalize_eurostat_nuts(evaluator, code_col) -> str:
"""Normalize NUTS code prefix: EL→GR, UK→GB, preserving the suffix.
Usage in SQL: @normalize_eurostat_nuts(geo_code) AS nuts_code
"""
col = str(code_col)
return (
f"CASE"
f" WHEN {col} LIKE 'EL%' THEN 'GR' || SUBSTR({col}, 3)"
f" WHEN {col} LIKE 'UK%' THEN 'GB' || SUBSTR({col}, 3)"
f" ELSE {col}"
f" END"
)
@macro()
def infer_country_from_coords(evaluator, lat_col, lon_col) -> str:
"""Infer ISO country code from lat/lon using bounding boxes for 8 European markets.
Usage in SQL:
COALESCE(NULLIF(TRIM(UPPER(country_code)), ''),
@infer_country_from_coords(lat, lon)) AS country_code
"""
lat = str(lat_col)
lon = str(lon_col)
return (
f"CASE"
f" WHEN {lat} BETWEEN 47.27 AND 55.06 AND {lon} BETWEEN 5.87 AND 15.04 THEN 'DE'"
f" WHEN {lat} BETWEEN 35.95 AND 43.79 AND {lon} BETWEEN -9.39 AND 4.33 THEN 'ES'"
f" WHEN {lat} BETWEEN 49.90 AND 60.85 AND {lon} BETWEEN -8.62 AND 1.77 THEN 'GB'"
f" WHEN {lat} BETWEEN 41.36 AND 51.09 AND {lon} BETWEEN -5.14 AND 9.56 THEN 'FR'"
f" WHEN {lat} BETWEEN 45.46 AND 47.80 AND {lon} BETWEEN 5.96 AND 10.49 THEN 'CH'"
f" WHEN {lat} BETWEEN 46.37 AND 49.02 AND {lon} BETWEEN 9.53 AND 17.16 THEN 'AT'"
f" WHEN {lat} BETWEEN 36.35 AND 47.09 AND {lon} BETWEEN 6.62 AND 18.51 THEN 'IT'"
f" WHEN {lat} BETWEEN 37.00 AND 42.15 AND {lon} BETWEEN -9.50 AND -6.19 THEN 'PT'"
f" ELSE NULL"
f" END"
)

View File

@@ -110,55 +110,9 @@ SELECT
vc.city_slug, vc.city_slug,
vc.city_name, vc.city_name,
-- Human-readable country name for pSEO templates and internal linking -- Human-readable country name for pSEO templates and internal linking
CASE vc.country_code @country_name(vc.country_code) AS country_name_en,
WHEN 'DE' THEN 'Germany'
WHEN 'ES' THEN 'Spain'
WHEN 'GB' THEN 'United Kingdom'
WHEN 'FR' THEN 'France'
WHEN 'IT' THEN 'Italy'
WHEN 'PT' THEN 'Portugal'
WHEN 'AT' THEN 'Austria'
WHEN 'CH' THEN 'Switzerland'
WHEN 'NL' THEN 'Netherlands'
WHEN 'BE' THEN 'Belgium'
WHEN 'SE' THEN 'Sweden'
WHEN 'NO' THEN 'Norway'
WHEN 'DK' THEN 'Denmark'
WHEN 'FI' THEN 'Finland'
WHEN 'US' THEN 'United States'
WHEN 'AR' THEN 'Argentina'
WHEN 'MX' THEN 'Mexico'
WHEN 'AE' THEN 'UAE'
WHEN 'AU' THEN 'Australia'
WHEN 'IE' THEN 'Ireland'
ELSE vc.country_code
END AS country_name_en,
-- URL-safe country slug -- URL-safe country slug
LOWER(REGEXP_REPLACE( @country_slug(vc.country_code) AS country_slug,
CASE vc.country_code
WHEN 'DE' THEN 'Germany'
WHEN 'ES' THEN 'Spain'
WHEN 'GB' THEN 'United Kingdom'
WHEN 'FR' THEN 'France'
WHEN 'IT' THEN 'Italy'
WHEN 'PT' THEN 'Portugal'
WHEN 'AT' THEN 'Austria'
WHEN 'CH' THEN 'Switzerland'
WHEN 'NL' THEN 'Netherlands'
WHEN 'BE' THEN 'Belgium'
WHEN 'SE' THEN 'Sweden'
WHEN 'NO' THEN 'Norway'
WHEN 'DK' THEN 'Denmark'
WHEN 'FI' THEN 'Finland'
WHEN 'US' THEN 'United States'
WHEN 'AR' THEN 'Argentina'
WHEN 'MX' THEN 'Mexico'
WHEN 'AE' THEN 'UAE'
WHEN 'AU' THEN 'Australia'
WHEN 'IE' THEN 'Ireland'
ELSE vc.country_code
END, '[^a-zA-Z0-9]+', '-'
)) AS country_slug,
vc.centroid_lat AS lat, vc.centroid_lat AS lat,
vc.centroid_lon AS lon, vc.centroid_lon AS lon,
-- Population cascade: Eurostat EU > US Census > ONS UK > GeoNames string > GeoNames spatial > 0. -- Population cascade: Eurostat EU > US Census > ONS UK > GeoNames string > GeoNames spatial > 0.

View File

@@ -215,55 +215,9 @@ SELECT
l.geoname_id, l.geoname_id,
l.country_code, l.country_code,
-- Human-readable country name (consistent with dim_cities) -- Human-readable country name (consistent with dim_cities)
CASE l.country_code @country_name(l.country_code) AS country_name_en,
WHEN 'DE' THEN 'Germany'
WHEN 'ES' THEN 'Spain'
WHEN 'GB' THEN 'United Kingdom'
WHEN 'FR' THEN 'France'
WHEN 'IT' THEN 'Italy'
WHEN 'PT' THEN 'Portugal'
WHEN 'AT' THEN 'Austria'
WHEN 'CH' THEN 'Switzerland'
WHEN 'NL' THEN 'Netherlands'
WHEN 'BE' THEN 'Belgium'
WHEN 'SE' THEN 'Sweden'
WHEN 'NO' THEN 'Norway'
WHEN 'DK' THEN 'Denmark'
WHEN 'FI' THEN 'Finland'
WHEN 'US' THEN 'United States'
WHEN 'AR' THEN 'Argentina'
WHEN 'MX' THEN 'Mexico'
WHEN 'AE' THEN 'UAE'
WHEN 'AU' THEN 'Australia'
WHEN 'IE' THEN 'Ireland'
ELSE l.country_code
END AS country_name_en,
-- URL-safe country slug -- URL-safe country slug
LOWER(REGEXP_REPLACE( @country_slug(l.country_code) AS country_slug,
CASE l.country_code
WHEN 'DE' THEN 'Germany'
WHEN 'ES' THEN 'Spain'
WHEN 'GB' THEN 'United Kingdom'
WHEN 'FR' THEN 'France'
WHEN 'IT' THEN 'Italy'
WHEN 'PT' THEN 'Portugal'
WHEN 'AT' THEN 'Austria'
WHEN 'CH' THEN 'Switzerland'
WHEN 'NL' THEN 'Netherlands'
WHEN 'BE' THEN 'Belgium'
WHEN 'SE' THEN 'Sweden'
WHEN 'NO' THEN 'Norway'
WHEN 'DK' THEN 'Denmark'
WHEN 'FI' THEN 'Finland'
WHEN 'US' THEN 'United States'
WHEN 'AR' THEN 'Argentina'
WHEN 'MX' THEN 'Mexico'
WHEN 'AE' THEN 'UAE'
WHEN 'AU' THEN 'Australia'
WHEN 'IE' THEN 'Ireland'
ELSE l.country_code
END, '[^a-zA-Z0-9]+', '-'
)) AS country_slug,
l.location_name, l.location_name,
l.location_slug, l.location_slug,
l.lat, l.lat,

View File

@@ -30,11 +30,7 @@ parsed AS (
) )
SELECT SELECT
-- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB -- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB
CASE geo_code @normalize_eurostat_country(geo_code) AS country_code,
WHEN 'EL' THEN 'GR'
WHEN 'UK' THEN 'GB'
ELSE geo_code
END AS country_code,
ref_year, ref_year,
median_income_pps, median_income_pps,
extracted_date extracted_date

View File

@@ -28,11 +28,7 @@ WITH raw AS (
SELECT SELECT
NUTS_ID AS nuts2_code, NUTS_ID AS nuts2_code,
-- Normalise country prefix to ISO 3166-1 alpha-2: EL→GR, UK→GB -- Normalise country prefix to ISO 3166-1 alpha-2: EL→GR, UK→GB
CASE CNTR_CODE @normalize_eurostat_country(CNTR_CODE) AS country_code,
WHEN 'EL' THEN 'GR'
WHEN 'UK' THEN 'GB'
ELSE CNTR_CODE
END AS country_code,
NAME_LATN AS region_name, NAME_LATN AS region_name,
geom AS geometry, geom AS geometry,
-- Pre-compute bounding box for efficient spatial pre-filter in dim_locations. -- Pre-compute bounding box for efficient spatial pre-filter in dim_locations.

View File

@@ -48,17 +48,8 @@ deduped AS (
with_country AS ( with_country AS (
SELECT SELECT
osm_id, lat, lon, osm_id, lat, lon,
COALESCE(NULLIF(TRIM(UPPER(country_code)), ''), CASE COALESCE(NULLIF(TRIM(UPPER(country_code)), ''),
WHEN lat BETWEEN 47.27 AND 55.06 AND lon BETWEEN 5.87 AND 15.04 THEN 'DE' @infer_country_from_coords(lat, lon)) AS country_code,
WHEN lat BETWEEN 35.95 AND 43.79 AND lon BETWEEN -9.39 AND 4.33 THEN 'ES'
WHEN lat BETWEEN 49.90 AND 60.85 AND lon BETWEEN -8.62 AND 1.77 THEN 'GB'
WHEN lat BETWEEN 41.36 AND 51.09 AND lon BETWEEN -5.14 AND 9.56 THEN 'FR'
WHEN lat BETWEEN 45.46 AND 47.80 AND lon BETWEEN 5.96 AND 10.49 THEN 'CH'
WHEN lat BETWEEN 46.37 AND 49.02 AND lon BETWEEN 9.53 AND 17.16 THEN 'AT'
WHEN lat BETWEEN 36.35 AND 47.09 AND lon BETWEEN 6.62 AND 18.51 THEN 'IT'
WHEN lat BETWEEN 37.00 AND 42.15 AND lon BETWEEN -9.50 AND -6.19 THEN 'PT'
ELSE NULL
END) AS country_code,
NULLIF(TRIM(name), '') AS name, NULLIF(TRIM(name), '') AS name,
NULLIF(TRIM(city_tag), '') AS city, NULLIF(TRIM(city_tag), '') AS city,
postcode, operator_name, opening_hours, fee, extracted_date postcode, operator_name, opening_hours, fee, extracted_date

View File

@@ -30,11 +30,7 @@ parsed AS (
) )
SELECT SELECT
-- Normalise to ISO 3166-1 alpha-2 prefix: EL→GR, UK→GB -- Normalise to ISO 3166-1 alpha-2 prefix: EL→GR, UK→GB
CASE @normalize_eurostat_nuts(geo_code) AS nuts_code,
WHEN geo_code LIKE 'EL%' THEN 'GR' || SUBSTR(geo_code, 3)
WHEN geo_code LIKE 'UK%' THEN 'GB' || SUBSTR(geo_code, 3)
ELSE geo_code
END AS nuts_code,
-- NUTS level: 3-char = NUTS-1, 4-char = NUTS-2 -- NUTS level: 3-char = NUTS-1, 4-char = NUTS-2
LENGTH(geo_code) - 2 AS nuts_level, LENGTH(geo_code) - 2 AS nuts_level,
ref_year, ref_year,

View File

@@ -54,17 +54,8 @@ deduped AS (
with_country AS ( with_country AS (
SELECT SELECT
osm_id, lat, lon, osm_id, lat, lon,
COALESCE(NULLIF(TRIM(UPPER(country_code)), ''), CASE COALESCE(NULLIF(TRIM(UPPER(country_code)), ''),
WHEN lat BETWEEN 47.27 AND 55.06 AND lon BETWEEN 5.87 AND 15.04 THEN 'DE' @infer_country_from_coords(lat, lon)) AS country_code,
WHEN lat BETWEEN 35.95 AND 43.79 AND lon BETWEEN -9.39 AND 4.33 THEN 'ES'
WHEN lat BETWEEN 49.90 AND 60.85 AND lon BETWEEN -8.62 AND 1.77 THEN 'GB'
WHEN lat BETWEEN 41.36 AND 51.09 AND lon BETWEEN -5.14 AND 9.56 THEN 'FR'
WHEN lat BETWEEN 45.46 AND 47.80 AND lon BETWEEN 5.96 AND 10.49 THEN 'CH'
WHEN lat BETWEEN 46.37 AND 49.02 AND lon BETWEEN 9.53 AND 17.16 THEN 'AT'
WHEN lat BETWEEN 36.35 AND 47.09 AND lon BETWEEN 6.62 AND 18.51 THEN 'IT'
WHEN lat BETWEEN 37.00 AND 42.15 AND lon BETWEEN -9.50 AND -6.19 THEN 'PT'
ELSE NULL
END) AS country_code,
NULLIF(TRIM(name), '') AS name, NULLIF(TRIM(name), '') AS name,
NULLIF(TRIM(city_tag), '') AS city, NULLIF(TRIM(city_tag), '') AS city,
extracted_date extracted_date

View File

@@ -35,7 +35,7 @@ from pathlib import Path
from quart import Blueprint, flash, redirect, render_template, request, url_for from quart import Blueprint, flash, redirect, render_template, request, url_for
from ..auth.routes import role_required from ..auth.routes import role_required
from ..core import csrf_protect from ..core import count_where, csrf_protect
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -298,11 +298,8 @@ async def _inject_sidebar_data():
"""Load unread inbox count for the admin sidebar badge.""" """Load unread inbox count for the admin sidebar badge."""
from quart import g from quart import g
from ..core import fetch_one
try: try:
row = await fetch_one("SELECT COUNT(*) as cnt FROM inbound_emails WHERE is_read = 0") g.admin_unread_count = await count_where("inbound_emails WHERE is_read = 0")
g.admin_unread_count = row["cnt"] if row else 0
except Exception: except Exception:
g.admin_unread_count = 0 g.admin_unread_count = 0
@@ -780,7 +777,8 @@ async def pipeline_trigger_extract():
else: else:
await enqueue("run_extraction") await enqueue("run_extraction")
is_htmx = request.headers.get("HX-Request") == "true" is_htmx = (request.headers.get("HX-Request") == "true"
and request.headers.get("HX-Boosted") != "true")
if is_htmx: if is_htmx:
return await _render_overview_partial() return await _render_overview_partial()
@@ -1005,7 +1003,8 @@ async def pipeline_trigger_transform():
(task_name,), (task_name,),
) )
if existing: if existing:
is_htmx = request.headers.get("HX-Request") == "true" is_htmx = (request.headers.get("HX-Request") == "true"
and request.headers.get("HX-Boosted") != "true")
if is_htmx: if is_htmx:
return await _render_transform_partial() return await _render_transform_partial()
await flash(f"A '{step}' task is already queued (task #{existing['id']}).", "warning") await flash(f"A '{step}' task is already queued (task #{existing['id']}).", "warning")
@@ -1013,7 +1012,8 @@ async def pipeline_trigger_transform():
await enqueue(task_name) await enqueue(task_name)
is_htmx = request.headers.get("HX-Request") == "true" is_htmx = (request.headers.get("HX-Request") == "true"
and request.headers.get("HX-Boosted") != "true")
if is_htmx: if is_htmx:
return await _render_transform_partial() return await _render_transform_partial()

View File

@@ -25,7 +25,7 @@ from ..content.health import (
get_template_freshness, get_template_freshness,
get_template_stats, get_template_stats,
) )
from ..core import csrf_protect, fetch_all, fetch_one from ..core import count_where, csrf_protect, fetch_all, fetch_one
bp = Blueprint( bp = Blueprint(
"pseo", "pseo",
@@ -41,8 +41,7 @@ async def _inject_sidebar_data():
from quart import g from quart import g
try: try:
row = await fetch_one("SELECT COUNT(*) as cnt FROM inbound_emails WHERE is_read = 0") g.admin_unread_count = await count_where("inbound_emails WHERE is_read = 0")
g.admin_unread_count = row["cnt"] if row else 0
except Exception: except Exception:
g.admin_unread_count = 0 g.admin_unread_count = 0
@@ -80,8 +79,7 @@ async def pseo_dashboard():
total_published = sum(r["stats"]["published"] for r in template_rows) total_published = sum(r["stats"]["published"] for r in template_rows)
stale_count = sum(1 for f in freshness if f["status"] == "stale") stale_count = sum(1 for f in freshness if f["status"] == "stale")
noindex_row = await fetch_one("SELECT COUNT(*) as cnt FROM articles WHERE noindex = 1") noindex_count = await count_where("articles WHERE noindex = 1")
noindex_count = noindex_row["cnt"] if noindex_row else 0
# Recent generation jobs — enough for the dashboard summary. # Recent generation jobs — enough for the dashboard summary.
jobs = await fetch_all( jobs = await fetch_all(

View File

@@ -28,6 +28,7 @@ from ..auth.routes import role_required
from ..core import ( from ..core import (
EMAIL_ADDRESSES, EMAIL_ADDRESSES,
config, config,
count_where,
csrf_protect, csrf_protect,
execute, execute,
fetch_all, fetch_all,
@@ -91,8 +92,7 @@ async def _inject_admin_sidebar_data():
"""Load unread inbox count for sidebar badge on every admin page.""" """Load unread inbox count for sidebar badge on every admin page."""
from quart import g from quart import g
try: try:
row = await fetch_one("SELECT COUNT(*) as cnt FROM inbound_emails WHERE is_read = 0") g.admin_unread_count = await count_where("inbound_emails WHERE is_read = 0")
g.admin_unread_count = row["cnt"] if row else 0
except Exception: except Exception:
logger.exception("Failed to load admin sidebar unread count") logger.exception("Failed to load admin sidebar unread count")
g.admin_unread_count = 0 g.admin_unread_count = 0
@@ -114,76 +114,32 @@ async def get_dashboard_stats() -> dict:
now = utcnow() now = utcnow()
today = now.date().isoformat() today = now.date().isoformat()
week_ago = (now - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S") week_ago = (now - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S")
users_total = await fetch_one("SELECT COUNT(*) as count FROM users WHERE deleted_at IS NULL")
users_today = await fetch_one(
"SELECT COUNT(*) as count FROM users WHERE created_at >= ? AND deleted_at IS NULL",
(today,)
)
users_week = await fetch_one(
"SELECT COUNT(*) as count FROM users WHERE created_at >= ? AND deleted_at IS NULL",
(week_ago,)
)
subs = await fetch_one( # Two queries that aren't simple COUNT(*) — keep as fetch_one
"SELECT COUNT(*) as count FROM subscriptions WHERE status = 'active'" planner_row = await fetch_one(
"SELECT COUNT(DISTINCT user_id) AS n FROM scenarios WHERE deleted_at IS NULL"
) )
credits_row = await fetch_one(
tasks_pending = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'pending'") "SELECT COALESCE(SUM(ABS(delta)), 0) AS n FROM credit_ledger WHERE delta < 0"
tasks_failed = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'failed'")
# Lead funnel stats
leads_total = await fetch_one(
"SELECT COUNT(*) as count FROM lead_requests WHERE lead_type = 'quote'"
)
leads_new = await fetch_one(
"SELECT COUNT(*) as count FROM lead_requests WHERE status = 'new' AND lead_type = 'quote'"
)
leads_verified = await fetch_one(
"SELECT COUNT(*) as count FROM lead_requests WHERE verified_at IS NOT NULL AND lead_type = 'quote'"
)
leads_unlocked = await fetch_one(
"SELECT COUNT(*) as count FROM lead_requests WHERE unlock_count > 0 AND lead_type = 'quote'"
)
# Planner users
planner_users = await fetch_one(
"SELECT COUNT(DISTINCT user_id) as count FROM scenarios WHERE deleted_at IS NULL"
)
# Supplier stats
suppliers_claimed = await fetch_one(
"SELECT COUNT(*) as count FROM suppliers WHERE claimed_by IS NOT NULL"
)
suppliers_growth = await fetch_one(
"SELECT COUNT(*) as count FROM suppliers WHERE tier = 'growth'"
)
suppliers_pro = await fetch_one(
"SELECT COUNT(*) as count FROM suppliers WHERE tier = 'pro'"
)
total_credits_spent = await fetch_one(
"SELECT COALESCE(SUM(ABS(delta)), 0) as total FROM credit_ledger WHERE delta < 0"
)
leads_unlocked_by_suppliers = await fetch_one(
"SELECT COUNT(*) as count FROM lead_forwards"
) )
return { return {
"users_total": users_total["count"] if users_total else 0, "users_total": await count_where("users WHERE deleted_at IS NULL"),
"users_today": users_today["count"] if users_today else 0, "users_today": await count_where("users WHERE created_at >= ? AND deleted_at IS NULL", (today,)),
"users_week": users_week["count"] if users_week else 0, "users_week": await count_where("users WHERE created_at >= ? AND deleted_at IS NULL", (week_ago,)),
"active_subscriptions": subs["count"] if subs else 0, "active_subscriptions": await count_where("subscriptions WHERE status = 'active'"),
"tasks_pending": tasks_pending["count"] if tasks_pending else 0, "tasks_pending": await count_where("tasks WHERE status = 'pending'"),
"tasks_failed": tasks_failed["count"] if tasks_failed else 0, "tasks_failed": await count_where("tasks WHERE status = 'failed'"),
"leads_total": leads_total["count"] if leads_total else 0, "leads_total": await count_where("lead_requests WHERE lead_type = 'quote'"),
"leads_new": leads_new["count"] if leads_new else 0, "leads_new": await count_where("lead_requests WHERE status = 'new' AND lead_type = 'quote'"),
"leads_verified": leads_verified["count"] if leads_verified else 0, "leads_verified": await count_where("lead_requests WHERE verified_at IS NOT NULL AND lead_type = 'quote'"),
"leads_unlocked": leads_unlocked["count"] if leads_unlocked else 0, "leads_unlocked": await count_where("lead_requests WHERE unlock_count > 0 AND lead_type = 'quote'"),
"planner_users": planner_users["count"] if planner_users else 0, "planner_users": planner_row["n"] if planner_row else 0,
"suppliers_claimed": suppliers_claimed["count"] if suppliers_claimed else 0, "suppliers_claimed": await count_where("suppliers WHERE claimed_by IS NOT NULL"),
"suppliers_growth": suppliers_growth["count"] if suppliers_growth else 0, "suppliers_growth": await count_where("suppliers WHERE tier = 'growth'"),
"suppliers_pro": suppliers_pro["count"] if suppliers_pro else 0, "suppliers_pro": await count_where("suppliers WHERE tier = 'pro'"),
"total_credits_spent": total_credits_spent["total"] if total_credits_spent else 0, "total_credits_spent": credits_row["n"] if credits_row else 0,
"leads_unlocked_by_suppliers": leads_unlocked_by_suppliers["count"] if leads_unlocked_by_suppliers else 0, "leads_unlocked_by_suppliers": await count_where("lead_forwards WHERE 1=1"),
} }
@@ -446,10 +402,7 @@ async def get_leads(
params.append(f"-{days} days") params.append(f"-{days} days")
where = " AND ".join(wheres) where = " AND ".join(wheres)
count_row = await fetch_one( total = await count_where(f"lead_requests WHERE {where}", tuple(params))
f"SELECT COUNT(*) as cnt FROM lead_requests WHERE {where}", tuple(params)
)
total = count_row["cnt"] if count_row else 0
offset = (page - 1) * per_page offset = (page - 1) * per_page
rows = await fetch_all( rows = await fetch_all(
@@ -679,26 +632,14 @@ async def lead_new():
return await render_template("admin/lead_form.html", data={}, statuses=LEAD_STATUSES) return await render_template("admin/lead_form.html", data={}, statuses=LEAD_STATUSES)
@bp.route("/leads/<int:lead_id>/forward", methods=["POST"]) async def _forward_lead(lead_id: int, supplier_id: int) -> str | None:
@role_required("admin") """Forward a lead to a supplier. Returns error message or None on success."""
@csrf_protect
async def lead_forward(lead_id: int):
"""Manually forward a lead to a supplier (no credit cost)."""
form = await request.form
supplier_id = int(form.get("supplier_id", 0))
if not supplier_id:
await flash("Select a supplier.", "error")
return redirect(url_for("admin.lead_detail", lead_id=lead_id))
# Check if already forwarded
existing = await fetch_one( existing = await fetch_one(
"SELECT 1 FROM lead_forwards WHERE lead_id = ? AND supplier_id = ?", "SELECT 1 FROM lead_forwards WHERE lead_id = ? AND supplier_id = ?",
(lead_id, supplier_id), (lead_id, supplier_id),
) )
if existing: if existing:
await flash("Already forwarded to this supplier.", "warning") return "Already forwarded to this supplier."
return redirect(url_for("admin.lead_detail", lead_id=lead_id))
now = utcnow_iso() now = utcnow_iso()
await execute( await execute(
@@ -710,15 +651,27 @@ async def lead_forward(lead_id: int):
"UPDATE lead_requests SET unlock_count = unlock_count + 1, status = 'forwarded' WHERE id = ?", "UPDATE lead_requests SET unlock_count = unlock_count + 1, status = 'forwarded' WHERE id = ?",
(lead_id,), (lead_id,),
) )
# Enqueue forward email
from ..worker import enqueue from ..worker import enqueue
await enqueue("send_lead_forward_email", { await enqueue("send_lead_forward_email", {"lead_id": lead_id, "supplier_id": supplier_id})
"lead_id": lead_id, return None
"supplier_id": supplier_id,
})
await flash("Lead forwarded to supplier.", "success")
@bp.route("/leads/<int:lead_id>/forward", methods=["POST"])
@role_required("admin")
@csrf_protect
async def lead_forward(lead_id: int):
"""Manually forward a lead to a supplier (no credit cost)."""
form = await request.form
supplier_id = int(form.get("supplier_id", 0))
if not supplier_id:
await flash("Select a supplier.", "error")
return redirect(url_for("admin.lead_detail", lead_id=lead_id))
error = await _forward_lead(lead_id, supplier_id)
if error:
await flash(error, "warning")
else:
await flash("Lead forwarded to supplier.", "success")
return redirect(url_for("admin.lead_detail", lead_id=lead_id)) return redirect(url_for("admin.lead_detail", lead_id=lead_id))
@@ -751,25 +704,9 @@ async def lead_forward_htmx(lead_id: int):
return Response("Select a supplier.", status=422) return Response("Select a supplier.", status=422)
supplier_id = int(supplier_id_str) supplier_id = int(supplier_id_str)
existing = await fetch_one( error = await _forward_lead(lead_id, supplier_id)
"SELECT 1 FROM lead_forwards WHERE lead_id = ? AND supplier_id = ?", if error:
(lead_id, supplier_id), return Response(error, status=422)
)
if existing:
return Response("Already forwarded to this supplier.", status=422)
now = utcnow_iso()
await execute(
"""INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, status, created_at)
VALUES (?, ?, 0, 'sent', ?)""",
(lead_id, supplier_id, now),
)
await execute(
"UPDATE lead_requests SET unlock_count = unlock_count + 1, status = 'forwarded' WHERE id = ?",
(lead_id,),
)
from ..worker import enqueue
await enqueue("send_lead_forward_email", {"lead_id": lead_id, "supplier_id": supplier_id})
lead = await get_lead_detail(lead_id) lead = await get_lead_detail(lead_id)
return await render_template( return await render_template(
@@ -929,13 +866,10 @@ async def get_suppliers_list(
async def get_supplier_stats() -> dict: async def get_supplier_stats() -> dict:
"""Get aggregate supplier stats for the admin list header.""" """Get aggregate supplier stats for the admin list header."""
claimed = await fetch_one("SELECT COUNT(*) as cnt FROM suppliers WHERE claimed_by IS NOT NULL")
growth = await fetch_one("SELECT COUNT(*) as cnt FROM suppliers WHERE tier = 'growth'")
pro = await fetch_one("SELECT COUNT(*) as cnt FROM suppliers WHERE tier = 'pro'")
return { return {
"claimed": claimed["cnt"] if claimed else 0, "claimed": await count_where("suppliers WHERE claimed_by IS NOT NULL"),
"growth": growth["cnt"] if growth else 0, "growth": await count_where("suppliers WHERE tier = 'growth'"),
"pro": pro["cnt"] if pro else 0, "pro": await count_where("suppliers WHERE tier = 'pro'"),
} }
@@ -1017,11 +951,7 @@ async def supplier_detail(supplier_id: int):
(supplier_id,), (supplier_id,),
) )
enquiry_row = await fetch_one( enquiry_count = await count_where("supplier_enquiries WHERE supplier_id = ?", (supplier_id,))
"SELECT COUNT(*) as cnt FROM supplier_enquiries WHERE supplier_id = ?",
(supplier_id,),
)
enquiry_count = enquiry_row["cnt"] if enquiry_row else 0
# Email activity timeline — correlate by contact_email (no FK) # Email activity timeline — correlate by contact_email (no FK)
timeline = [] timeline = []
@@ -1239,7 +1169,6 @@ _PRODUCT_CATEGORIES = [
@role_required("admin") @role_required("admin")
async def billing_products(): async def billing_products():
"""Read-only overview of Paddle products, subscriptions, and revenue proxies.""" """Read-only overview of Paddle products, subscriptions, and revenue proxies."""
active_subs_row = await fetch_one("SELECT COUNT(*) as cnt FROM subscriptions WHERE status = 'active'")
mrr_row = await fetch_one( mrr_row = await fetch_one(
"""SELECT COALESCE(SUM( """SELECT COALESCE(SUM(
CASE WHEN pp.key LIKE '%_yearly' THEN pp.price_cents / 12 CASE WHEN pp.key LIKE '%_yearly' THEN pp.price_cents / 12
@@ -1249,14 +1178,12 @@ async def billing_products():
JOIN paddle_products pp ON s.plan = pp.key JOIN paddle_products pp ON s.plan = pp.key
WHERE s.status = 'active' AND pp.billing_type = 'subscription'""" WHERE s.status = 'active' AND pp.billing_type = 'subscription'"""
) )
active_boosts_row = await fetch_one("SELECT COUNT(*) as cnt FROM supplier_boosts WHERE status = 'active'")
bp_exports_row = await fetch_one("SELECT COUNT(*) as cnt FROM business_plan_exports WHERE status = 'completed'")
stats = { stats = {
"active_subs": (active_subs_row or {}).get("cnt", 0), "active_subs": await count_where("subscriptions WHERE status = 'active'"),
"mrr_cents": (mrr_row or {}).get("total_cents", 0), "mrr_cents": (mrr_row or {}).get("total_cents", 0),
"active_boosts": (active_boosts_row or {}).get("cnt", 0), "active_boosts": await count_where("supplier_boosts WHERE status = 'active'"),
"bp_exports": (bp_exports_row or {}).get("cnt", 0), "bp_exports": await count_where("business_plan_exports WHERE status = 'completed'"),
} }
products_rows = await fetch_all("SELECT * FROM paddle_products ORDER BY key") products_rows = await fetch_all("SELECT * FROM paddle_products ORDER BY key")
@@ -1342,23 +1269,18 @@ async def get_email_log(
async def get_email_stats() -> dict: async def get_email_stats() -> dict:
"""Aggregate email stats for the list header.""" """Aggregate email stats for the list header."""
total = await fetch_one("SELECT COUNT(*) as cnt FROM email_log")
delivered = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE last_event = 'delivered'")
bounced = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE last_event = 'bounced'")
today = utcnow().date().isoformat() today = utcnow().date().isoformat()
sent_today = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE created_at >= ?", (today,))
return { return {
"total": total["cnt"] if total else 0, "total": await count_where("email_log WHERE 1=1"),
"delivered": delivered["cnt"] if delivered else 0, "delivered": await count_where("email_log WHERE last_event = 'delivered'"),
"bounced": bounced["cnt"] if bounced else 0, "bounced": await count_where("email_log WHERE last_event = 'bounced'"),
"sent_today": sent_today["cnt"] if sent_today else 0, "sent_today": await count_where("email_log WHERE created_at >= ?", (today,)),
} }
async def get_unread_count() -> int: async def get_unread_count() -> int:
"""Count unread inbound emails.""" """Count unread inbound emails."""
row = await fetch_one("SELECT COUNT(*) as cnt FROM inbound_emails WHERE is_read = 0") return await count_where("inbound_emails WHERE is_read = 0")
return row["cnt"] if row else 0
@bp.route("/emails") @bp.route("/emails")
@@ -1824,11 +1746,7 @@ async def template_detail(slug: str):
columns = await get_table_columns(config["data_table"]) columns = await get_table_columns(config["data_table"])
sample_rows = await fetch_template_data(config["data_table"], limit=10) sample_rows = await fetch_template_data(config["data_table"], limit=10)
# Count generated articles generated_count = await count_where("articles WHERE template_slug = ?", (slug,))
row = await fetch_one(
"SELECT COUNT(*) as cnt FROM articles WHERE template_slug = ?", (slug,),
)
generated_count = row["cnt"] if row else 0
return await render_template( return await render_template(
"admin/template_detail.html", "admin/template_detail.html",
@@ -1959,8 +1877,8 @@ async def _query_scenarios(search: str, country: str, venue_type: str) -> tuple[
f"SELECT * FROM published_scenarios WHERE {where} ORDER BY created_at DESC LIMIT 500", f"SELECT * FROM published_scenarios WHERE {where} ORDER BY created_at DESC LIMIT 500",
tuple(params), tuple(params),
) )
total_row = await fetch_one("SELECT COUNT(*) as cnt FROM published_scenarios") total = await count_where("published_scenarios WHERE 1=1")
return rows, (total_row["cnt"] if total_row else 0) return rows, total
@bp.route("/scenarios") @bp.route("/scenarios")
@@ -2203,6 +2121,27 @@ _ARTICLES_DIR = Path(__file__).parent.parent.parent.parent.parent / "data" / "co
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL) _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
def _find_article_md(slug: str) -> Path | None:
"""Return the Path of the .md file whose frontmatter slug matches, or None.
Tries the exact name first ({slug}.md), then scans _ARTICLES_DIR for any
file whose YAML frontmatter contains 'slug: <slug>'. This handles the
common pattern where files are named {slug}-{lang}.md but the frontmatter
slug omits the language suffix.
"""
if not _ARTICLES_DIR.is_dir():
return None
exact = _ARTICLES_DIR / f"{slug}.md"
if exact.exists():
return exact
for path in _ARTICLES_DIR.glob("*.md"):
raw = path.read_text(encoding="utf-8")
m = _FRONTMATTER_RE.match(raw)
if m and f"slug: {slug}" in m.group(1):
return path
return None
async def _sync_static_articles() -> None: async def _sync_static_articles() -> None:
"""Upsert static .md articles from data/content/articles/ into the DB. """Upsert static .md articles from data/content/articles/ into the DB.
@@ -2519,11 +2458,11 @@ async def article_new():
if not title or not body: if not title or not body:
await flash("Title and body are required.", "error") await flash("Title and body are required.", "error")
return await render_template("admin/article_form.html", data=dict(form), editing=False) return await render_template("admin/article_form.html", data=dict(form), editing=False, preview_doc="")
if is_reserved_path(url_path): if is_reserved_path(url_path):
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error") await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
return await render_template("admin/article_form.html", data=dict(form), editing=False) return await render_template("admin/article_form.html", data=dict(form), editing=False, preview_doc="")
# Render markdown → HTML with scenario + product cards baked in # Render markdown → HTML with scenario + product cards baked in
body_html = mistune.html(body) body_html = mistune.html(body)
@@ -2556,7 +2495,7 @@ async def article_new():
await flash(f"Article '{title}' created.", "success") await flash(f"Article '{title}' created.", "success")
return redirect(url_for("admin.articles")) return redirect(url_for("admin.articles"))
return await render_template("admin/article_form.html", data={}, editing=False) return await render_template("admin/article_form.html", data={}, editing=False, preview_doc="")
@bp.route("/articles/<int:article_id>/edit", methods=["GET", "POST"]) @bp.route("/articles/<int:article_id>/edit", methods=["GET", "POST"])
@@ -2592,7 +2531,7 @@ async def article_edit(article_id: int):
if is_reserved_path(url_path): if is_reserved_path(url_path):
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error") await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
return await render_template( return await render_template(
"admin/article_form.html", data=dict(form), editing=True, article_id=article_id, "admin/article_form.html", data=dict(form), editing=True, article_id=article_id, preview_doc="",
) )
# Re-render if body provided # Re-render if body provided
@@ -2626,18 +2565,55 @@ async def article_edit(article_id: int):
# Load markdown source if available (manual or generated) # Load markdown source if available (manual or generated)
from ..content.routes import BUILD_DIR as CONTENT_BUILD_DIR from ..content.routes import BUILD_DIR as CONTENT_BUILD_DIR
md_path = Path("data/content/articles") / f"{article['slug']}.md" md_path = _find_article_md(article["slug"])
if not md_path.exists(): if md_path is None:
lang = article["language"] or "en" lang = article["language"] or "en"
md_path = CONTENT_BUILD_DIR / lang / "md" / f"{article['slug']}.md" fallback = CONTENT_BUILD_DIR / lang / "md" / f"{article['slug']}.md"
body = md_path.read_text() if md_path.exists() else "" md_path = fallback if fallback.exists() else None
raw = md_path.read_text() if md_path else ""
# Strip YAML frontmatter so only the prose body appears in the editor
m = _FRONTMATTER_RE.match(raw)
body = raw[m.end():].lstrip("\n") if m else raw
body_html = mistune.html(body) if body else ""
css_url = url_for("static", filename="css/output.css")
preview_doc = (
f"<!doctype html><html><head>"
f"<link rel='stylesheet' href='{css_url}'>"
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>"
f"</head><body><div class='article-body'>{body_html}</div></body></html>"
) if body_html else ""
data = {**dict(article), "body": body} data = {**dict(article), "body": body}
return await render_template( return await render_template(
"admin/article_form.html", data=data, editing=True, article_id=article_id, "admin/article_form.html",
data=data,
editing=True,
article_id=article_id,
preview_doc=preview_doc,
) )
@bp.route("/articles/preview", methods=["POST"])
@role_required("admin")
@csrf_protect
async def article_preview():
"""Render markdown body to HTML for the live editor preview panel."""
form = await request.form
body = form.get("body", "")
m = _FRONTMATTER_RE.match(body)
body = body[m.end():].lstrip("\n") if m else body
body_html = mistune.html(body) if body else ""
css_url = url_for("static", filename="css/output.css")
preview_doc = (
f"<!doctype html><html><head>"
f"<link rel='stylesheet' href='{css_url}'>"
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>"
f"</head><body><div class='article-body'>{body_html}</div></body></html>"
) if body_html else ""
return await render_template("admin/partials/article_preview.html", preview_doc=preview_doc)
@bp.route("/articles/<int:article_id>/delete", methods=["POST"]) @bp.route("/articles/<int:article_id>/delete", methods=["POST"])
@role_required("admin") @role_required("admin")
@csrf_protect @csrf_protect
@@ -2927,11 +2903,9 @@ _CSV_IMPORT_LIMIT = 500 # guard against huge uploads
async def get_follow_up_due_count() -> int: async def get_follow_up_due_count() -> int:
"""Count pipeline suppliers with follow_up_at <= today.""" """Count pipeline suppliers with follow_up_at <= today."""
row = await fetch_one( return await count_where(
"""SELECT COUNT(*) as cnt FROM suppliers "suppliers WHERE outreach_status IS NOT NULL AND follow_up_at <= date('now')"
WHERE outreach_status IS NOT NULL AND follow_up_at <= date('now')"""
) )
return row["cnt"] if row else 0
async def get_outreach_pipeline() -> dict: async def get_outreach_pipeline() -> dict:

View File

@@ -226,10 +226,9 @@ document.addEventListener('DOMContentLoaded', function() {
<a href="{{ url_for('admin.affiliate_products') }}" class="btn-outline">Cancel</a> <a href="{{ url_for('admin.affiliate_products') }}" class="btn-outline">Cancel</a>
</div> </div>
{% if editing %} {% if editing %}
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product_id) }}" style="margin:0"> <form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product_id) }}" style="margin:0" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline" <button type="submit" class="btn-outline"
onclick="event.preventDefault(); confirmAction('Delete this product? This cannot be undone.', this.closest('form'))">Delete</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>

View File

@@ -120,10 +120,9 @@ document.addEventListener('DOMContentLoaded', function() {
<a href="{{ url_for('admin.affiliate_programs') }}" class="btn-outline">Cancel</a> <a href="{{ url_for('admin.affiliate_programs') }}" class="btn-outline">Cancel</a>
</div> </div>
{% if editing %} {% if editing %}
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=program_id) }}" style="margin:0"> <form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=program_id) }}" style="margin:0" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline" <button type="submit" class="btn-outline"
onclick="event.preventDefault(); confirmAction('Delete this program? Blocked if products reference it.', this.closest('form'))">Delete</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>

View File

@@ -1,89 +1,413 @@
{% extends "admin/base_admin.html" %} {% extends "admin/base_admin.html" %}
{% set admin_page = "articles" %} {% set admin_page = "articles" %}
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}{% if editing %}Edit{% else %}New{% endif %} Article Admin {{ config.APP_NAME }}{% endblock %}
{% block head %}{{ super() }}
<style>
/* Override admin-main so the split editor fills the column */
.admin-main {
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ── Editor shell ──────────────────────────────────────────── */
.ae-shell {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ── Toolbar ────────────────────────────────────────────────── */
.ae-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1.25rem;
background: #fff;
border-bottom: 1px solid #E2E8F0;
flex-shrink: 0;
}
.ae-toolbar__back {
font-size: 0.8125rem;
color: #64748B;
text-decoration: none;
flex-shrink: 0;
transition: color 0.1s;
}
.ae-toolbar__back:hover { color: #0F172A; }
.ae-toolbar__sep {
width: 1px; height: 1.25rem;
background: #E2E8F0;
flex-shrink: 0;
}
.ae-toolbar__title {
font-size: 0.875rem;
font-weight: 600;
color: #0F172A;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ae-toolbar__status {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.2rem 0.55rem;
border-radius: 9999px;
flex-shrink: 0;
}
.ae-toolbar__status--draft {
background: #F1F5F9;
color: #64748B;
}
.ae-toolbar__status--published {
background: #DCFCE7;
color: #16A34A;
}
/* ── Metadata strip ─────────────────────────────────────────── */
#ae-form {
display: contents; /* form participates in flex layout as transparent wrapper */
}
.ae-meta {
padding: 0.75rem 1.25rem;
background: #F8FAFC;
border-bottom: 1px solid #E2E8F0;
flex-shrink: 0;
}
.ae-meta__row {
display: flex;
gap: 0.625rem;
flex-wrap: wrap;
align-items: end;
}
.ae-meta__row + .ae-meta__row { margin-top: 0.5rem; }
.ae-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.ae-field--flex1 { flex: 1; min-width: 120px; }
.ae-field--flex2 { flex: 2; min-width: 180px; }
.ae-field--flex3 { flex: 3; min-width: 220px; }
.ae-field--fixed80 { flex: 0 0 80px; }
.ae-field--fixed120 { flex: 0 0 120px; }
.ae-field--fixed160 { flex: 0 0 160px; }
.ae-field label {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #94A3B8;
white-space: nowrap;
}
.ae-field input,
.ae-field select {
width: 100%;
padding: 0.3rem 0.5rem;
border: 1px solid #E2E8F0;
border-radius: 4px;
font-size: 0.8125rem;
font-family: var(--font-sans);
color: #0F172A;
background: #fff;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
min-width: 0;
}
.ae-field input:focus,
.ae-field select:focus {
border-color: #1D4ED8;
box-shadow: 0 0 0 2px rgba(29,78,216,0.1);
}
.ae-field input[readonly] {
background: #F1F5F9;
color: #94A3B8;
}
/* ── Split pane ─────────────────────────────────────────────── */
.ae-split {
display: grid;
grid-template-columns: 1fr 1fr;
flex: 1;
min-height: 0;
overflow: hidden;
}
.ae-pane {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.ae-pane--editor { border-right: 1px solid #E2E8F0; }
.ae-pane__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.875rem;
background: #F8FAFC;
border-bottom: 1px solid #E2E8F0;
flex-shrink: 0;
}
.ae-pane--preview .ae-pane__header {
background: #F8FAFC;
border-bottom: 1px solid #E2E8F0;
}
.ae-pane__label {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.09em;
color: #94A3B8;
}
.ae-pane__hint {
font-size: 0.625rem;
font-family: var(--font-mono);
color: #94A3B8;
}
/* The markdown textarea */
.ae-editor {
flex: 1;
resize: none;
border: none;
outline: none;
padding: 1.5rem 2rem;
font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.8;
background: #FEFDFB;
color: #1E293B;
caret-color: #1D4ED8;
tab-size: 2;
}
.ae-editor::placeholder { color: #CBD5E1; }
.ae-editor:focus { outline: none; }
/* Preview pane — iframe fills the content area */
#ae-preview-content {
flex: 1;
display: flex;
min-height: 0;
}
.preview-placeholder {
font-size: 0.875rem;
color: #94A3B8;
font-style: italic;
margin: 1.5rem 2rem;
}
/* Collapsible metadata */
.ae-meta--collapsed { display: none; }
.ae-toolbar__toggle {
font-size: 0.75rem;
font-weight: 600;
color: #64748B;
background: none;
border: 1px solid #E2E8F0;
border-radius: 4px;
padding: 0.25rem 0.6rem;
cursor: pointer;
flex-shrink: 0;
}
.ae-toolbar__toggle:hover { color: #0F172A; border-color: #94A3B8; }
/* Word count footer */
.ae-footer {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0.25rem 0.875rem;
background: #F8FAFC;
border-top: 1px solid #E2E8F0;
flex-shrink: 0;
}
.ae-wordcount {
font-size: 0.625rem;
font-family: var(--font-mono);
color: #94A3B8;
}
/* HTMX loading indicator — htmx toggles .htmx-request on the element */
.ae-loading {
font-size: 0.625rem;
color: #94A3B8;
font-family: var(--font-mono);
opacity: 0;
transition: opacity 0.2s;
}
.ae-loading.htmx-request { opacity: 1; }
/* Responsive: stack on narrow screens */
@media (max-width: 900px) {
.ae-split { grid-template-columns: 1fr; }
.ae-pane--preview { display: none; }
}
</style>
{% endblock %}
{% block admin_content %} {% block admin_content %}
<div style="max-width: 48rem; margin: 0 auto;"> <div class="ae-shell">
<a href="{{ url_for('admin.articles') }}" class="text-sm text-slate">&larr; Back to articles</a>
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Article</h1>
<form method="post" class="card"> <!-- Toolbar -->
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <div class="ae-toolbar">
<a href="{{ url_for('admin.articles') }}" class="ae-toolbar__back">← Articles</a>
<div class="ae-toolbar__sep"></div>
<span class="ae-toolbar__title">
{% if editing %}{{ data.get('title', 'Edit Article') }}{% else %}New Article{% endif %}
</span>
{% if editing %}
<span class="ae-toolbar__status ae-toolbar__status--{{ data.get('status', 'draft') }}">
{{ data.get('status', 'draft') }}
</span>
{% endif %}
<button type="button" class="ae-toolbar__toggle"
onclick="document.querySelector('.ae-meta').classList.toggle('ae-meta--collapsed')">Meta ▾</button>
<button form="ae-form" type="submit" class="btn btn-sm">
{% if editing %}Save Changes{% else %}Create Article{% endif %}
</button>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-4"> <!-- Form wraps everything below the toolbar -->
<div> <form id="ae-form" method="post" style="display:contents;">
<label class="form-label" for="title">Title</label> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="text" id="title" name="title" value="{{ data.get('title', '') }}" class="form-input" required>
<!-- Metadata strip -->
<div class="ae-meta">
<div class="ae-meta__row">
<div class="ae-field ae-field--flex3">
<label for="title">Title</label>
<input type="text" id="title" name="title" value="{{ data.get('title', '') }}"
required placeholder="Article title…">
</div> </div>
<div> <div class="ae-field ae-field--flex2">
<label class="form-label" for="slug">Slug</label> <label for="slug">Slug</label>
<input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}" class="form-input" <input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}"
placeholder="auto-generated from title" {% if editing %}readonly{% endif %}> placeholder="auto-generated" {% if editing %}readonly{% endif %}>
</div> </div>
</div> <div class="ae-field ae-field--flex2">
<label for="url_path">URL Path</label>
<div class="mb-4"> <input type="text" id="url_path" name="url_path" value="{{ data.get('url_path', '') }}"
<label class="form-label" for="url_path">URL Path</label> placeholder="/slug">
<input type="text" id="url_path" name="url_path" value="{{ data.get('url_path', '') }}" class="form-input"
placeholder="e.g. /padel-court-cost-miami">
<p class="form-hint">Defaults to /slug. Must not conflict with existing routes.</p>
</div>
<div class="mb-4">
<label class="form-label" for="meta_description">Meta Description</label>
<input type="text" id="meta_description" name="meta_description" value="{{ data.get('meta_description', '') }}"
class="form-input" maxlength="160">
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;" class="mb-4">
<div>
<label class="form-label" for="country">Country</label>
<input type="text" id="country" name="country" value="{{ data.get('country', '') }}" class="form-input"
placeholder="e.g. US">
</div> </div>
<div> <div class="ae-field ae-field--fixed80">
<label class="form-label" for="region">Region</label> <label for="language">Language</label>
<input type="text" id="region" name="region" value="{{ data.get('region', '') }}" class="form-input" <select id="language" name="language">
placeholder="e.g. North America"> <option value="en" {% if data.get('language', 'en') == 'en' %}selected{% endif %}>EN</option>
</div> <option value="de" {% if data.get('language') == 'de' %}selected{% endif %}>DE</option>
<div>
<label class="form-label" for="og_image_url">OG Image URL</label>
<input type="text" id="og_image_url" name="og_image_url" value="{{ data.get('og_image_url', '') }}" class="form-input">
</div>
</div>
<div class="mb-4">
<label class="form-label" for="body">Body (Markdown)</label>
<textarea id="body" name="body" rows="20" class="form-input"
style="font-family: var(--font-mono); font-size: 0.8125rem;" {% if not editing %}required{% endif %}>{{ data.get('body', '') }}</textarea>
<p class="form-hint">Use [scenario:slug] to embed scenario widgets. Sections: :capex, :operating, :cashflow, :returns, :full</p>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;" class="mb-4">
<div>
<label class="form-label" for="language">Language</label>
<select id="language" name="language" class="form-input">
<option value="en" {% if data.get('language', 'en') == 'en' %}selected{% endif %}>English (en)</option>
<option value="de" {% if data.get('language') == 'de' %}selected{% endif %}>German (de)</option>
</select> </select>
</div> </div>
<div> <div class="ae-field ae-field--fixed120">
<label class="form-label" for="status">Status</label> <label for="status">Status</label>
<select id="status" name="status" class="form-input"> <select id="status" name="status">
<option value="draft" {% if data.get('status') == 'draft' %}selected{% endif %}>Draft</option> <option value="draft" {% if data.get('status', 'draft') == 'draft' %}selected{% endif %}>Draft</option>
<option value="published" {% if data.get('status') == 'published' %}selected{% endif %}>Published</option> <option value="published" {% if data.get('status') == 'published' %}selected{% endif %}>Published</option>
</select> </select>
</div> </div>
<div> </div>
<label class="form-label" for="published_at">Publish Date</label> <div class="ae-meta__row">
<div class="ae-field ae-field--flex3">
<label for="meta_description">Meta Description</label>
<input type="text" id="meta_description" name="meta_description"
value="{{ data.get('meta_description', '') }}" maxlength="160"
placeholder="160 chars max…">
</div>
<div class="ae-field ae-field--flex1">
<label for="country">Country</label>
<input type="text" id="country" name="country" value="{{ data.get('country', '') }}"
placeholder="e.g. US">
</div>
<div class="ae-field ae-field--flex1">
<label for="region">Region</label>
<input type="text" id="region" name="region" value="{{ data.get('region', '') }}"
placeholder="e.g. North America">
</div>
<div class="ae-field ae-field--flex2">
<label for="og_image_url">OG Image URL</label>
<input type="text" id="og_image_url" name="og_image_url"
value="{{ data.get('og_image_url', '') }}">
</div>
<div class="ae-field ae-field--fixed160">
<label for="published_at">Publish Date</label>
<input type="datetime-local" id="published_at" name="published_at" <input type="datetime-local" id="published_at" name="published_at"
value="{{ data.get('published_at', '')[:16] if data.get('published_at') else '' }}" class="form-input"> value="{{ data.get('published_at', '')[:16] if data.get('published_at') else '' }}">
<p class="form-hint">Leave blank for now. Future date = scheduled.</p> </div>
</div>
</div>
<!-- Split: editor | preview -->
<div class="ae-split">
<!-- Left — Markdown editor -->
<div class="ae-pane ae-pane--editor">
<div class="ae-pane__header">
<span class="ae-pane__label">Markdown</span>
<span class="ae-pane__hint">[scenario:slug] · [product:slug]</span>
</div>
<textarea
id="body" name="body"
class="ae-editor"
{% if not editing %}required{% endif %}
placeholder="Start writing in Markdown…"
hx-post="{{ url_for('admin.article_preview') }}"
hx-trigger="input delay:500ms"
hx-target="#ae-preview-content"
hx-include="[name=csrf_token]"
hx-indicator="#ae-loading"
>{{ data.get('body', '') }}</textarea>
<div class="ae-footer">
<span id="ae-wordcount" class="ae-wordcount">0 words</span>
</div> </div>
</div> </div>
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update Article{% else %}Create Article{% endif %}</button> <!-- Right — Rendered preview -->
</form> <div class="ae-pane ae-pane--preview">
</div> <div class="ae-pane__header">
<span class="ae-pane__label">Preview</span>
<span id="ae-loading" class="ae-loading">Rendering…</span>
</div>
<div id="ae-preview-content" style="flex:1;display:flex;min-height:0;">
{% if preview_doc %}
<iframe
srcdoc="{{ preview_doc | e }}"
style="flex:1;width:100%;border:none;display:block;"
sandbox="allow-same-origin"
title="Article preview"
></iframe>
{% else %}
<p class="preview-placeholder">Start writing to see a preview.</p>
{% endif %}
</div>
</div>
</div>
</form>
</div>
<script>
(function () {
var textarea = document.getElementById('body');
var counter = document.getElementById('ae-wordcount');
function updateCount() {
var text = textarea.value.trim();
var count = text ? text.split(/\s+/).length : 0;
counter.textContent = count + (count === 1 ? ' word' : ' words');
}
textarea.addEventListener('input', updateCount);
updateCount();
}());
</script>
{% endblock %} {% endblock %}

View File

@@ -11,9 +11,10 @@
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a href="{{ url_for('admin.article_new') }}" class="btn btn-sm">New Article</a> <a href="{{ url_for('admin.article_new') }}" class="btn btn-sm">New Article</a>
<form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display:inline"> <form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display:inline" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="button" class="btn-outline btn-sm" onclick="confirmAction('Rebuild all articles? This will re-render every article from its template.', this.closest('form'))">Rebuild All</button> <button type="submit" class="btn-outline btn-sm"
hx-confirm="Rebuild all articles? This will re-render every article from its template.">Rebuild All</button>
</form> </form>
</div> </div>
</header> </header>

View File

@@ -27,10 +27,11 @@
<td class="text-sm">{{ c.email if c.email is defined else (c.get('email', '-') if c is mapping else '-') }}</td> <td class="text-sm">{{ c.email if c.email is defined else (c.get('email', '-') if c is mapping else '-') }}</td>
<td class="mono text-sm">{{ (c.created_at if c.created_at is defined else (c.get('created_at', '-') if c is mapping else '-'))[:16] if c else '-' }}</td> <td class="mono text-sm">{{ (c.created_at if c.created_at is defined else (c.get('created_at', '-') if c is mapping else '-'))[:16] if c else '-' }}</td>
<td style="text-align:right"> <td style="text-align:right">
<form method="post" action="{{ url_for('admin.audience_contact_remove', audience_id=audience.audience_id) }}" style="display:inline"> <form method="post" action="{{ url_for('admin.audience_contact_remove', audience_id=audience.audience_id) }}" style="display:inline" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="contact_id" value="{{ c.id if c.id is defined else (c.get('id', '') if c is mapping else '') }}"> <input type="hidden" name="contact_id" value="{{ c.id if c.id is defined else (c.get('id', '') if c is mapping else '') }}">
<button type="button" class="btn-outline btn-sm" style="color:#DC2626" onclick="confirmAction('Remove this contact from the audience?', this.closest('form'))">Remove</button> <button type="submit" class="btn-outline btn-sm" style="color:#DC2626"
hx-confirm="Remove this contact from the audience?">Remove</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -228,35 +228,29 @@
<dialog id="confirm-dialog"> <dialog id="confirm-dialog">
<p id="confirm-msg"></p> <p id="confirm-msg"></p>
<div class="dialog-actions"> <form method="dialog" class="dialog-actions">
<button id="confirm-cancel" class="btn-outline btn-sm">Cancel</button> <button value="cancel" class="btn-outline btn-sm">Cancel</button>
<button id="confirm-ok" class="btn btn-sm">Confirm</button> <button value="ok" class="btn btn-sm">Confirm</button>
</div> </form>
</dialog> </dialog>
<script> <script>
function confirmAction(message, form) { function showConfirm(message) {
var dialog = document.getElementById('confirm-dialog'); var dialog = document.getElementById('confirm-dialog');
document.getElementById('confirm-msg').textContent = message; document.getElementById('confirm-msg').textContent = message;
var ok = document.getElementById('confirm-ok');
var newOk = ok.cloneNode(true);
ok.replaceWith(newOk);
newOk.addEventListener('click', function() { dialog.close(); form.submit(); });
document.getElementById('confirm-cancel').addEventListener('click', function() { dialog.close(); }, { once: true });
dialog.showModal(); dialog.showModal();
return new Promise(function(resolve) {
dialog.addEventListener('close', function() {
resolve(dialog.returnValue === 'ok');
}, { once: true });
});
} }
// Intercept hx-confirm to use the styled dialog instead of window.confirm()
document.body.addEventListener('htmx:confirm', function(evt) { document.body.addEventListener('htmx:confirm', function(evt) {
var dialog = document.getElementById('confirm-dialog'); if (!evt.detail.question) return;
if (!dialog) return; // fallback: let HTMX use native confirm
evt.preventDefault(); evt.preventDefault();
document.getElementById('confirm-msg').textContent = evt.detail.question; showConfirm(evt.detail.question).then(function(ok) {
var ok = document.getElementById('confirm-ok'); if (ok) evt.detail.issueRequest(true);
var newOk = ok.cloneNode(true); });
ok.replaceWith(newOk);
newOk.addEventListener('click', function() { dialog.close(); evt.detail.issueRequest(true); }, { once: true });
document.getElementById('confirm-cancel').addEventListener('click', function() { dialog.close(); }, { once: true });
dialog.showModal();
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -19,7 +19,7 @@
<p class="text-slate text-sm">No data rows found. Run the data pipeline to populate <code>{{ config_data.data_table }}</code>.</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" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4"> <div class="mb-4">
@@ -45,7 +45,8 @@
</p> </p>
</div> </div>
<button type="button" class="btn" style="width: 100%;" onclick="confirmAction('Generate articles? Existing articles will be updated in-place.', this.closest('form'))"> <button type="submit" class="btn" style="width: 100%;"
hx-confirm="Generate articles? Existing articles will be updated in-place.">
Generate Articles Generate Articles
</button> </button>
</form> </form>

View File

@@ -21,10 +21,9 @@
</td> </td>
<td class="text-right" style="white-space:nowrap"> <td class="text-right" style="white-space:nowrap">
<a href="{{ url_for('admin.affiliate_program_edit', program_id=prog.id) }}" class="btn-outline btn-sm">Edit</a> <a href="{{ url_for('admin.affiliate_program_edit', program_id=prog.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=prog.id) }}" style="display:inline"> <form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=prog.id) }}" style="display:inline" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" <button type="submit" class="btn-outline btn-sm"
onclick="event.preventDefault(); confirmAction('Delete {{ prog.name }}? This is blocked if products reference it.', this.closest('form'))">Delete</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -20,10 +20,9 @@
<td class="mono text-right">{{ product.click_count or 0 }}</td> <td class="mono text-right">{{ product.click_count or 0 }}</td>
<td class="text-right" style="white-space:nowrap"> <td class="text-right" style="white-space:nowrap">
<a href="{{ url_for('admin.affiliate_edit', product_id=product.id) }}" class="btn-outline btn-sm">Edit</a> <a href="{{ url_for('admin.affiliate_edit', product_id=product.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product.id) }}" style="display:inline"> <form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product.id) }}" style="display:inline" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" <button type="submit" class="btn-outline btn-sm"
onclick="event.preventDefault(); confirmAction('Delete {{ product.name }}?', this.closest('form'))">Delete</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -0,0 +1,12 @@
{# HTMX partial: sandboxed iframe showing a rendered article preview.
Rendered by POST /admin/articles/preview. #}
{% if preview_doc %}
<iframe
srcdoc="{{ preview_doc | e }}"
style="flex:1;width:100%;border:none;display:block;"
sandbox="allow-same-origin"
title="Article preview"
></iframe>
{% else %}
<p class="preview-placeholder">Start writing to see a preview.</p>
{% endif %}

View File

@@ -29,10 +29,10 @@
</div> </div>
</form> </form>
<form method="post" action="{{ url_for('pipeline.pipeline_trigger_extract') }}" class="m-0"> <form method="post" action="{{ url_for('pipeline.pipeline_trigger_extract') }}" class="m-0" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="button" class="btn-outline btn-sm" <button type="submit" class="btn-outline btn-sm"
onclick="confirmAction('Enqueue a full extraction run? This will run all extractors in the background.', this.closest('form'))"> hx-confirm="Enqueue a full extraction run? This will run all extractors in the background.">
Run All Extractors Run All Extractors
</button> </button>
</form> </form>
@@ -112,11 +112,11 @@
{% if run.status == 'running' %} {% if run.status == 'running' %}
<form method="post" <form method="post"
action="{{ url_for('pipeline.pipeline_mark_stale', run_id=run.run_id) }}" action="{{ url_for('pipeline.pipeline_mark_stale', run_id=run.run_id) }}"
class="m-0"> class="m-0" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="button" class="btn-danger btn-sm" <button type="submit" class="btn-danger btn-sm"
style="padding:2px 8px;font-size:11px" style="padding:2px 8px;font-size:11px"
onclick="confirmAction('Mark run #{{ run.run_id }} as failed? Only do this if the process is definitely dead.', this.closest('form'))"> hx-confirm="Mark run #{{ run.run_id }} as failed? Only do this if the process is definitely dead.">
Mark Failed Mark Failed
</button> </button>
</form> </form>

View File

@@ -36,9 +36,10 @@
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='en') }}" class="btn-outline btn-sm">PDF EN</a> <a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='en') }}" class="btn-outline btn-sm">PDF EN</a>
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='de') }}" class="btn-outline btn-sm">PDF DE</a> <a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='de') }}" class="btn-outline btn-sm">PDF DE</a>
<a href="{{ url_for('admin.scenario_edit', scenario_id=s.id) }}" class="btn-outline btn-sm">Edit</a> <a href="{{ url_for('admin.scenario_edit', scenario_id=s.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.scenario_delete', scenario_id=s.id) }}" class="m-0" style="display: inline;"> <form method="post" action="{{ url_for('admin.scenario_delete', scenario_id=s.id) }}" class="m-0" style="display: inline;" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="button" class="btn-outline btn-sm" onclick="confirmAction('Delete this scenario? This cannot be undone.', this.closest('form'))">Delete</button> <button type="submit" class="btn-outline btn-sm"
hx-confirm="Delete this scenario? This cannot be undone.">Delete</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -57,11 +57,11 @@
<p class="text-sm text-slate mt-1">Extraction status, data catalog, and ad-hoc query editor</p> <p class="text-sm text-slate mt-1">Extraction status, data catalog, and ad-hoc query editor</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<form method="post" action="{{ url_for('pipeline.pipeline_trigger_transform') }}" class="m-0"> <form method="post" action="{{ url_for('pipeline.pipeline_trigger_transform') }}" class="m-0" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="step" value="pipeline"> <input type="hidden" name="step" value="pipeline">
<button type="button" class="btn btn-sm" <button type="submit" class="btn btn-sm"
onclick="confirmAction('Run full ELT pipeline (extract → transform → export)? This runs in the background.', this.closest('form'))"> hx-confirm="Run full ELT pipeline (extract → transform → export)? This runs in the background.">
Run Pipeline Run Pipeline
</button> </button>
</form> </form>

View File

@@ -13,9 +13,10 @@
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a href="{{ url_for('admin.template_generate', slug=config_data.slug) }}" class="btn">Generate Articles</a> <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"> <form method="post" action="{{ url_for('admin.template_regenerate', slug=config_data.slug) }}" style="display:inline" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="button" class="btn-outline" onclick="confirmAction('Regenerate all articles for this template with fresh data? Existing articles will be overwritten.', this.closest('form'))"> <button type="submit" class="btn-outline"
hx-confirm="Regenerate all articles for this template with fresh data? Existing articles will be overwritten.">
Regenerate Regenerate
</button> </button>
</form> </form>

View File

@@ -192,6 +192,15 @@ async def fetch_all(sql: str, params: tuple = ()) -> list[dict]:
return [dict(row) for row in rows] return [dict(row) for row in rows]
async def count_where(table_where: str, params: tuple = ()) -> int:
"""Count rows matching a condition. Compresses the fetch_one + null-check pattern.
Usage: await count_where("users WHERE deleted_at IS NULL")
"""
row = await fetch_one(f"SELECT COUNT(*) AS n FROM {table_where}", params)
return row["n"] if row else 0
async def execute(sql: str, params: tuple = ()) -> int: async def execute(sql: str, params: tuple = ()) -> int:
"""Execute SQL and return lastrowid.""" """Execute SQL and return lastrowid."""
db = await get_db() db = await get_db()

View File

@@ -6,7 +6,7 @@ from pathlib import Path
from quart import Blueprint, flash, g, redirect, render_template, request, url_for from quart import Blueprint, flash, g, redirect, render_template, request, url_for
from ..auth.routes import login_required, update_user from ..auth.routes import login_required, update_user
from ..core import csrf_protect, fetch_one, soft_delete, utcnow_iso from ..core import count_where, csrf_protect, fetch_one, soft_delete, utcnow_iso
from ..i18n import get_translations from ..i18n import get_translations
bp = Blueprint( bp = Blueprint(
@@ -18,17 +18,13 @@ bp = Blueprint(
async def get_user_stats(user_id: int) -> dict: async def get_user_stats(user_id: int) -> dict:
scenarios = await fetch_one(
"SELECT COUNT(*) as count FROM scenarios WHERE user_id = ? AND deleted_at IS NULL",
(user_id,),
)
leads = await fetch_one(
"SELECT COUNT(*) as count FROM lead_requests WHERE user_id = ?",
(user_id,),
)
return { return {
"scenarios": scenarios["count"] if scenarios else 0, "scenarios": await count_where(
"leads": leads["count"] if leads else 0, "scenarios WHERE user_id = ? AND deleted_at IS NULL", (user_id,)
),
"leads": await count_where(
"lead_requests WHERE user_id = ?", (user_id,)
),
} }

View File

@@ -6,7 +6,7 @@ from pathlib import Path
from quart import Blueprint, g, make_response, redirect, render_template, request, url_for from quart import Blueprint, g, make_response, redirect, render_template, request, url_for
from ..core import csrf_protect, execute, fetch_all, fetch_one, utcnow_iso from ..core import count_where, csrf_protect, execute, fetch_all, fetch_one, utcnow_iso
from ..i18n import COUNTRY_LABELS, get_translations from ..i18n import COUNTRY_LABELS, get_translations
bp = Blueprint( bp = Blueprint(
@@ -79,11 +79,7 @@ async def _build_directory_query(q, country, category, region, page, per_page=24
where = " AND ".join(wheres) if wheres else "1=1" where = " AND ".join(wheres) if wheres else "1=1"
count_row = await fetch_one( total = await count_where(f"suppliers s WHERE {where}", tuple(params))
f"SELECT COUNT(*) as cnt FROM suppliers s WHERE {where}",
tuple(params),
)
total = count_row["cnt"] if count_row else 0
offset = (page - 1) * per_page offset = (page - 1) * per_page
# Tier-based ordering: sticky first, then pro > growth > free, then name # Tier-based ordering: sticky first, then pro > growth > free, then name
@@ -159,16 +155,16 @@ async def index():
"SELECT category, COUNT(*) as cnt FROM suppliers GROUP BY category ORDER BY cnt DESC" "SELECT category, COUNT(*) as cnt FROM suppliers GROUP BY category ORDER BY cnt DESC"
) )
total_suppliers = await fetch_one("SELECT COUNT(*) as cnt FROM suppliers") total_suppliers = await count_where("suppliers")
total_countries = await fetch_one("SELECT COUNT(DISTINCT country_code) as cnt FROM suppliers") total_countries = await count_where("(SELECT DISTINCT country_code FROM suppliers)")
return await render_template( return await render_template(
"directory.html", "directory.html",
**ctx, **ctx,
country_counts=country_counts, country_counts=country_counts,
category_counts=category_counts, category_counts=category_counts,
total_suppliers=total_suppliers["cnt"] if total_suppliers else 0, total_suppliers=total_suppliers,
total_countries=total_countries["cnt"] if total_countries else 0, total_countries=total_countries,
) )
@@ -204,11 +200,9 @@ async def supplier_detail(slug: str):
# Enquiry count (Basic+) # Enquiry count (Basic+)
enquiry_count = 0 enquiry_count = 0
if supplier.get("tier") in ("basic", "growth", "pro"): if supplier.get("tier") in ("basic", "growth", "pro"):
row = await fetch_one( enquiry_count = await count_where(
"SELECT COUNT(*) as cnt FROM supplier_enquiries WHERE supplier_id = ?", "supplier_enquiries WHERE supplier_id = ?", (supplier["id"],)
(supplier["id"],),
) )
enquiry_count = row["cnt"] if row else 0
lang = g.get("lang", "en") lang = g.get("lang", "en")
cat_labels, country_labels, region_labels = get_directory_labels(lang) cat_labels, country_labels, region_labels = get_directory_labels(lang)

View File

@@ -12,6 +12,7 @@ from quart import Blueprint, Response, g, jsonify, render_template, request
from ..auth.routes import login_required from ..auth.routes import login_required
from ..core import ( from ..core import (
config, config,
count_where,
csrf_protect, csrf_protect,
execute, execute,
feature_gate, feature_gate,
@@ -50,11 +51,9 @@ COUNTRY_PRESETS = {
async def count_scenarios(user_id: int) -> int: async def count_scenarios(user_id: int) -> int:
row = await fetch_one( return await count_where(
"SELECT COUNT(*) as cnt FROM scenarios WHERE user_id = ? AND deleted_at IS NULL", "scenarios WHERE user_id = ? AND deleted_at IS NULL", (user_id,)
(user_id,),
) )
return row["cnt"] if row else 0
async def get_default_scenario(user_id: int) -> dict | None: async def get_default_scenario(user_id: int) -> dict | None:

View File

@@ -5,7 +5,7 @@ from pathlib import Path
from quart import Blueprint, g, render_template, request, session from quart import Blueprint, g, render_template, request, session
from ..core import check_rate_limit, csrf_protect, execute, fetch_all, fetch_one from ..core import check_rate_limit, count_where, csrf_protect, execute, fetch_all, fetch_one
from ..i18n import get_translations from ..i18n import get_translations
bp = Blueprint( bp = Blueprint(
@@ -17,13 +17,9 @@ bp = Blueprint(
async def _supplier_counts(): async def _supplier_counts():
"""Fetch aggregate supplier stats for landing/marketing pages.""" """Fetch aggregate supplier stats for landing/marketing pages."""
total = await fetch_one("SELECT COUNT(*) as cnt FROM suppliers")
countries = await fetch_one(
"SELECT COUNT(DISTINCT country_code) as cnt FROM suppliers"
)
return ( return (
total["cnt"] if total else 0, await count_where("suppliers"),
countries["cnt"] if countries else 0, await count_where("(SELECT DISTINCT country_code FROM suppliers)"),
) )
@@ -75,15 +71,15 @@ async def suppliers():
total_suppliers, total_countries = await _supplier_counts() total_suppliers, total_countries = await _supplier_counts()
# Live stats # Live stats
calc_requests = await fetch_one("SELECT COUNT(*) as cnt FROM scenarios WHERE deleted_at IS NULL") calc_requests = await count_where("scenarios WHERE deleted_at IS NULL")
avg_budget = await fetch_one( avg_budget = await fetch_one(
"SELECT AVG(budget_estimate) as avg FROM lead_requests WHERE budget_estimate > 0 AND lead_type = 'quote'" "SELECT AVG(budget_estimate) as avg FROM lead_requests WHERE budget_estimate > 0 AND lead_type = 'quote'"
) )
active_suppliers = await fetch_one( active_suppliers = await count_where(
"SELECT COUNT(*) as cnt FROM suppliers WHERE tier IN ('growth', 'pro') AND claimed_by IS NOT NULL" "suppliers WHERE tier IN ('growth', 'pro') AND claimed_by IS NOT NULL"
) )
monthly_leads = await fetch_one( monthly_leads = await count_where(
"SELECT COUNT(*) as cnt FROM lead_requests WHERE lead_type = 'quote' AND created_at >= date('now', '-30 days')" "lead_requests WHERE lead_type = 'quote' AND created_at >= date('now', '-30 days')"
) )
# Lead feed preview — 3 recent verified hot/warm leads, anonymized # Lead feed preview — 3 recent verified hot/warm leads, anonymized
@@ -100,10 +96,10 @@ async def suppliers():
"suppliers.html", "suppliers.html",
total_suppliers=total_suppliers, total_suppliers=total_suppliers,
total_countries=total_countries, total_countries=total_countries,
calc_requests=calc_requests["cnt"] if calc_requests else 0, calc_requests=calc_requests,
avg_budget=int(avg_budget["avg"]) if avg_budget and avg_budget["avg"] else 0, avg_budget=int(avg_budget["avg"]) if avg_budget and avg_budget["avg"] else 0,
active_suppliers=active_suppliers["cnt"] if active_suppliers else 0, active_suppliers=active_suppliers,
monthly_leads=monthly_leads["cnt"] if monthly_leads else 0, monthly_leads=monthly_leads,
preview_leads=preview_leads, preview_leads=preview_leads,
) )

View File

@@ -11,6 +11,7 @@ from werkzeug.utils import secure_filename
from ..core import ( from ..core import (
capture_waitlist_email, capture_waitlist_email,
config, config,
count_where,
csrf_protect, csrf_protect,
execute, execute,
feature_gate, feature_gate,
@@ -776,9 +777,8 @@ async def dashboard_overview():
supplier = g.supplier supplier = g.supplier
# Leads unlocked count # Leads unlocked count
unlocked = await fetch_one( leads_unlocked = await count_where(
"SELECT COUNT(*) as cnt FROM lead_forwards WHERE supplier_id = ?", "lead_forwards WHERE supplier_id = ?", (supplier["id"],)
(supplier["id"],),
) )
# New leads matching supplier's area since last login # New leads matching supplier's area since last login
@@ -787,22 +787,20 @@ async def dashboard_overview():
new_leads_count = 0 new_leads_count = 0
if service_area: if service_area:
placeholders = ",".join("?" * len(service_area)) placeholders = ",".join("?" * len(service_area))
row = await fetch_one( new_leads_count = await count_where(
f"""SELECT COUNT(*) as cnt FROM lead_requests f"""lead_requests
WHERE lead_type = 'quote' AND status = 'new' AND verified_at IS NOT NULL WHERE lead_type = 'quote' AND status = 'new' AND verified_at IS NOT NULL
AND country IN ({placeholders}) AND country IN ({placeholders})
AND NOT EXISTS (SELECT 1 FROM lead_forwards WHERE lead_id = lead_requests.id AND supplier_id = ?)""", AND NOT EXISTS (SELECT 1 FROM lead_forwards WHERE lead_id = lead_requests.id AND supplier_id = ?)""",
(*service_area, supplier["id"]), (*service_area, supplier["id"]),
) )
new_leads_count = row["cnt"] if row else 0
else: else:
row = await fetch_one( new_leads_count = await count_where(
"""SELECT COUNT(*) as cnt FROM lead_requests """lead_requests
WHERE lead_type = 'quote' AND status = 'new' AND verified_at IS NOT NULL WHERE lead_type = 'quote' AND status = 'new' AND verified_at IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM lead_forwards WHERE lead_id = lead_requests.id AND supplier_id = ?)""", AND NOT EXISTS (SELECT 1 FROM lead_forwards WHERE lead_id = lead_requests.id AND supplier_id = ?)""",
(supplier["id"],), (supplier["id"],),
) )
new_leads_count = row["cnt"] if row else 0
# Recent activity (last 10 events from credit_ledger + lead_forwards) # Recent activity (last 10 events from credit_ledger + lead_forwards)
recent_activity = await fetch_all( recent_activity = await fetch_all(
@@ -825,16 +823,14 @@ async def dashboard_overview():
# Enquiry count for Basic tier # Enquiry count for Basic tier
enquiry_count = 0 enquiry_count = 0
if supplier.get("tier") == "basic": if supplier.get("tier") == "basic":
eq_row = await fetch_one( enquiry_count = await count_where(
"SELECT COUNT(*) as cnt FROM supplier_enquiries WHERE supplier_id = ?", "supplier_enquiries WHERE supplier_id = ?", (supplier["id"],)
(supplier["id"],),
) )
enquiry_count = eq_row["cnt"] if eq_row else 0
return await render_template( return await render_template(
"suppliers/partials/dashboard_overview.html", "suppliers/partials/dashboard_overview.html",
supplier=supplier, supplier=supplier,
leads_unlocked=unlocked["cnt"] if unlocked else 0, leads_unlocked=leads_unlocked,
new_leads_count=new_leads_count, new_leads_count=new_leads_count,
recent_activity=recent_activity, recent_activity=recent_activity,
active_boosts=active_boosts, active_boosts=active_boosts,

View File

@@ -125,6 +125,32 @@ async def auth_client(app, test_user):
yield c yield c
@pytest.fixture
async def admin_client(app, db):
"""Test client with an admin user pre-loaded in session."""
now = datetime.now(UTC).isoformat()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("admin@test.com", "Admin", now),
) as cursor:
user_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (user_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = user_id
yield c
@pytest.fixture
def mock_send_email():
"""Patch padelnomics.worker.send_email for the duration of the test."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock:
yield mock
# ── Subscriptions ──────────────────────────────────────────── # ── Subscriptions ────────────────────────────────────────────
@pytest.fixture @pytest.fixture

View File

@@ -9,7 +9,6 @@ Covers:
""" """
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest import pytest
from padelnomics.businessplan import generate_business_plan, get_plan_sections from padelnomics.businessplan import generate_business_plan, get_plan_sections
@@ -184,19 +183,18 @@ async def _insert_export(db, user_id: int, scenario_id: int, status: str = "pend
@requires_weasyprint @requires_weasyprint
class TestWorkerHandler: class TestWorkerHandler:
async def test_happy_path_generates_pdf_and_updates_status(self, db, scenario): async def test_happy_path_generates_pdf_and_updates_status(self, db, scenario, mock_send_email):
from padelnomics.worker import handle_generate_business_plan from padelnomics.worker import handle_generate_business_plan
export = await _insert_export(db, scenario["user_id"], scenario["id"]) export = await _insert_export(db, scenario["user_id"], scenario["id"])
output_file = None output_file = None
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_email: await handle_generate_business_plan({
await handle_generate_business_plan({ "export_id": export["id"],
"export_id": export["id"], "user_id": scenario["user_id"],
"user_id": scenario["user_id"], "scenario_id": scenario["id"],
"scenario_id": scenario["id"], "language": "en",
"language": "en", })
})
# Status should be 'ready' # Status should be 'ready'
from padelnomics.core import fetch_one from padelnomics.core import fetch_one
@@ -214,14 +212,14 @@ class TestWorkerHandler:
assert output_file.read_bytes()[:4] == b"%PDF" assert output_file.read_bytes()[:4] == b"%PDF"
# Email should have been sent # Email should have been sent
mock_email.assert_called_once() mock_send_email.assert_called_once()
assert "to" in mock_email.call_args.kwargs assert "to" in mock_send_email.call_args.kwargs
assert "subject" in mock_email.call_args.kwargs assert "subject" in mock_send_email.call_args.kwargs
finally: finally:
if output_file and output_file.exists(): if output_file and output_file.exists():
output_file.unlink() output_file.unlink()
async def test_marks_failed_on_bad_scenario(self, db, scenario): async def test_marks_failed_on_bad_scenario(self, db, scenario, mock_send_email):
"""Handler marks export failed when user_id doesn't match scenario owner.""" """Handler marks export failed when user_id doesn't match scenario owner."""
from padelnomics.worker import handle_generate_business_plan from padelnomics.worker import handle_generate_business_plan
@@ -229,14 +227,13 @@ class TestWorkerHandler:
wrong_user_id = scenario["user_id"] + 9999 wrong_user_id = scenario["user_id"] + 9999
export = await _insert_export(db, scenario["user_id"], scenario["id"]) export = await _insert_export(db, scenario["user_id"], scenario["id"])
with patch("padelnomics.worker.send_email", new_callable=AsyncMock): with pytest.raises(ValueError):
with pytest.raises(ValueError): await handle_generate_business_plan({
await handle_generate_business_plan({ "export_id": export["id"],
"export_id": export["id"], "user_id": wrong_user_id,
"user_id": wrong_user_id, "scenario_id": scenario["id"],
"scenario_id": scenario["id"], "language": "en",
"language": "en", })
})
from padelnomics.core import fetch_one from padelnomics.core import fetch_one
row = await fetch_one( row = await fetch_one(

View File

@@ -938,26 +938,6 @@ class TestRouteRegistration:
# Admin routes (require admin session) # Admin routes (require admin session)
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@pytest.fixture
async def admin_client(app, db):
"""Test client with admin user (has admin role)."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("admin@test.com", "Admin", now),
) as cursor:
admin_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = admin_id
yield c
class TestAdminTemplates: class TestAdminTemplates:
async def test_template_list_requires_admin(self, client): async def test_template_list_requires_admin(self, client):
resp = await client.get("/admin/templates") resp = await client.get("/admin/templates")

View File

@@ -9,7 +9,6 @@ Admin gallery tests: access control, list page, preview page, error handling.
""" """
import pytest import pytest
from padelnomics.core import utcnow_iso
from padelnomics.email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template from padelnomics.email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template
# ── render_email_template() ────────────────────────────────────────────────── # ── render_email_template() ──────────────────────────────────────────────────
@@ -124,26 +123,6 @@ class TestRenderEmailTemplate:
# ── Admin gallery routes ────────────────────────────────────────────────────── # ── Admin gallery routes ──────────────────────────────────────────────────────
@pytest.fixture
async def admin_client(app, db):
"""Test client with a user that has the admin role."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("gallery_admin@test.com", "Gallery Admin", now),
) as cursor:
admin_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = admin_id
yield c
class TestEmailGalleryRoutes: class TestEmailGalleryRoutes:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_gallery_requires_auth(self, client): async def test_gallery_requires_auth(self, client):

View File

@@ -50,59 +50,51 @@ def _assert_common_design(html: str, lang: str = "en"):
class TestMagicLink: class TestMagicLink:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sends_to_correct_recipient(self): async def test_sends_to_correct_recipient(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"}) kw = _call_kwargs(mock_send_email)
kw = _call_kwargs(mock_send) assert kw["to"] == "user@example.com"
assert kw["to"] == "user@example.com"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_subject_contains_app_name(self): async def test_subject_contains_app_name(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"}) kw = _call_kwargs(mock_send_email)
kw = _call_kwargs(mock_send) assert core.config.APP_NAME.lower() in kw["subject"].lower()
assert core.config.APP_NAME.lower() in kw["subject"].lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_verify_link(self): async def test_html_contains_verify_link(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"}) kw = _call_kwargs(mock_send_email)
kw = _call_kwargs(mock_send) assert "/auth/verify?token=abc123" in kw["html"]
assert "/auth/verify?token=abc123" in kw["html"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_fallback_link_text(self): async def test_html_contains_fallback_link_text(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
await handle_send_magic_link({"email": "user@example.com", "token": "tok"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] assert "word-break:break-all" in html # fallback URL block
assert "word-break:break-all" in html # fallback URL block
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_transactional_from_addr(self): async def test_uses_transactional_from_addr(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
await handle_send_magic_link({"email": "user@example.com", "token": "tok"}) assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_preheader_mentions_expiry(self): async def test_preheader_mentions_expiry(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
await handle_send_magic_link({"email": "user@example.com", "token": "tok"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] # preheader is hidden span; should mention minutes
# preheader is hidden span; should mention minutes assert "display:none" in html # preheader present
assert "display:none" in html # preheader present
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_design_elements_present(self): async def test_design_elements_present(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
await handle_send_magic_link({"email": "user@example.com", "token": "tok"}) _assert_common_design(_call_kwargs(mock_send_email)["html"])
_assert_common_design(_call_kwargs(mock_send)["html"])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_respects_lang_parameter(self): async def test_respects_lang_parameter(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_magic_link({"email": "user@example.com", "token": "tok", "lang": "de"})
await handle_send_magic_link({"email": "user@example.com", "token": "tok", "lang": "de"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] _assert_common_design(html, lang="de")
_assert_common_design(html, lang="de")
# ── Welcome ────────────────────────────────────────────────────── # ── Welcome ──────────────────────────────────────────────────────
@@ -110,59 +102,51 @@ class TestMagicLink:
class TestWelcome: class TestWelcome:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sends_to_correct_recipient(self): async def test_sends_to_correct_recipient(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_welcome({"email": "new@example.com"})
await handle_send_welcome({"email": "new@example.com"}) assert _call_kwargs(mock_send_email)["to"] == "new@example.com"
assert _call_kwargs(mock_send)["to"] == "new@example.com"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_subject_not_empty(self): async def test_subject_not_empty(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_welcome({"email": "new@example.com"})
await handle_send_welcome({"email": "new@example.com"}) assert len(_call_kwargs(mock_send_email)["subject"]) > 5
assert len(_call_kwargs(mock_send)["subject"]) > 5
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_quickstart_links(self): async def test_html_contains_quickstart_links(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_welcome({"email": "new@example.com"})
await handle_send_welcome({"email": "new@example.com"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] assert "/planner" in html
assert "/planner" in html assert "/markets" in html
assert "/markets" in html assert "/leads/quote" in html
assert "/leads/quote" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_first_name_when_provided(self): async def test_uses_first_name_when_provided(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_welcome({"email": "new@example.com", "name": "Alice Smith"})
await handle_send_welcome({"email": "new@example.com", "name": "Alice Smith"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] assert "Alice" in html
assert "Alice" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fallback_greeting_when_no_name(self): async def test_fallback_greeting_when_no_name(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_welcome({"email": "new@example.com"})
await handle_send_welcome({"email": "new@example.com"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] # Should use "there" as fallback first_name
# Should use "there" as fallback first_name assert "there" in html.lower()
assert "there" in html.lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_transactional_from_addr(self): async def test_uses_transactional_from_addr(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_welcome({"email": "new@example.com"})
await handle_send_welcome({"email": "new@example.com"}) assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_design_elements_present(self): async def test_design_elements_present(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_welcome({"email": "new@example.com"})
await handle_send_welcome({"email": "new@example.com"}) _assert_common_design(_call_kwargs(mock_send_email)["html"])
_assert_common_design(_call_kwargs(mock_send)["html"])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_german_welcome(self): async def test_german_welcome(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_welcome({"email": "new@example.com", "lang": "de"})
await handle_send_welcome({"email": "new@example.com", "lang": "de"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] _assert_common_design(html, lang="de")
_assert_common_design(html, lang="de")
# ── Quote Verification ─────────────────────────────────────────── # ── Quote Verification ───────────────────────────────────────────
@@ -180,57 +164,50 @@ class TestQuoteVerification:
} }
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sends_to_contact_email(self): async def test_sends_to_contact_email(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_quote_verification(self._BASE_PAYLOAD)
await handle_send_quote_verification(self._BASE_PAYLOAD) assert _call_kwargs(mock_send_email)["to"] == "lead@example.com"
assert _call_kwargs(mock_send)["to"] == "lead@example.com"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_verify_link(self): async def test_html_contains_verify_link(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_quote_verification(self._BASE_PAYLOAD)
await handle_send_quote_verification(self._BASE_PAYLOAD) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] assert "token=verify_tok" in html
assert "token=verify_tok" in html assert "lead=lead_tok" in html
assert "lead=lead_tok" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_project_recap(self): async def test_html_contains_project_recap(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_quote_verification(self._BASE_PAYLOAD)
await handle_send_quote_verification(self._BASE_PAYLOAD) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] assert "6 courts" in html
assert "6 courts" in html assert "Indoor" in html
assert "Indoor" in html assert "Germany" in html
assert "Germany" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_first_name_from_contact(self): async def test_uses_first_name_from_contact(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_quote_verification(self._BASE_PAYLOAD)
await handle_send_quote_verification(self._BASE_PAYLOAD) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] assert "Bob" in html
assert "Bob" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handles_minimal_payload(self): async def test_handles_minimal_payload(self, mock_send_email):
"""No court_count/facility_type/country — should still send.""" """No court_count/facility_type/country — should still send."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_quote_verification({
await handle_send_quote_verification({ "email": "lead@example.com",
"email": "lead@example.com", "token": "tok",
"token": "tok", "lead_token": "ltok",
"lead_token": "ltok", })
}) mock_send_email.assert_called_once()
mock_send.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_transactional_from_addr(self): async def test_uses_transactional_from_addr(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_quote_verification(self._BASE_PAYLOAD)
await handle_send_quote_verification(self._BASE_PAYLOAD) assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_design_elements_present(self): async def test_design_elements_present(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_quote_verification(self._BASE_PAYLOAD)
await handle_send_quote_verification(self._BASE_PAYLOAD) _assert_common_design(_call_kwargs(mock_send_email)["html"])
_assert_common_design(_call_kwargs(mock_send)["html"])
# ── Lead Forward (the money email) ────────────────────────────── # ── Lead Forward (the money email) ──────────────────────────────
@@ -238,89 +215,71 @@ class TestQuoteVerification:
class TestLeadForward: class TestLeadForward:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sends_to_supplier_email(self, db): async def test_sends_to_supplier_email(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: assert _call_kwargs(mock_send_email)["to"] == "supplier@test.com"
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
assert _call_kwargs(mock_send)["to"] == "supplier@test.com"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_subject_contains_heat_and_country(self, db): async def test_subject_contains_heat_and_country(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: subject = _call_kwargs(mock_send_email)["subject"]
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id}) assert "[HOT]" in subject
subject = _call_kwargs(mock_send)["subject"] assert "Germany" in subject
assert "[HOT]" in subject assert "4 courts" in subject
assert "Germany" in subject
assert "4 courts" in subject
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_heat_badge(self, db): async def test_html_contains_heat_badge(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: html = _call_kwargs(mock_send_email)["html"]
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id}) assert "#DC2626" in html # HOT badge color
html = _call_kwargs(mock_send)["html"] assert "HOT" in html
assert "#DC2626" in html # HOT badge color
assert "HOT" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_project_brief(self, db): async def test_html_contains_project_brief(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: html = _call_kwargs(mock_send_email)["html"]
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id}) assert "Indoor" in html
html = _call_kwargs(mock_send)["html"] assert "Germany" in html
assert "Indoor" in html
assert "Germany" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_contact_info(self, db): async def test_html_contains_contact_info(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: html = _call_kwargs(mock_send_email)["html"]
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id}) assert "lead@buyer.com" in html
html = _call_kwargs(mock_send)["html"] assert "mailto:lead@buyer.com" in html
assert "lead@buyer.com" in html assert "John Doe" in html
assert "mailto:lead@buyer.com" in html
assert "John Doe" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_urgency_callout(self, db): async def test_html_contains_urgency_callout(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: html = _call_kwargs(mock_send_email)["html"]
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id}) # Urgency callout has yellow background
html = _call_kwargs(mock_send)["html"] assert "#FEF3C7" in html
# Urgency callout has yellow background
assert "#FEF3C7" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_direct_reply_cta(self, db): async def test_html_contains_direct_reply_cta(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: html = _call_kwargs(mock_send_email)["html"]
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id}) # Direct reply link text should mention the contact email
html = _call_kwargs(mock_send)["html"] assert "lead@buyer.com" in html
# Direct reply link text should mention the contact email
assert "lead@buyer.com" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_leads_from_addr(self, db): async def test_uses_leads_from_addr(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_updates_email_sent_at(self, db): async def test_updates_email_sent_at(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db, create_forward=True) lead_id, supplier_id = await _seed_lead_and_supplier(db, create_forward=True)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock):
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
async with db.execute( async with db.execute(
"SELECT email_sent_at FROM lead_forwards WHERE lead_id = ? AND supplier_id = ?", "SELECT email_sent_at FROM lead_forwards WHERE lead_id = ? AND supplier_id = ?",
@@ -331,30 +290,24 @@ class TestLeadForward:
assert row["email_sent_at"] is not None assert row["email_sent_at"] is not None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_when_no_supplier_email(self, db): async def test_skips_when_no_supplier_email(self, db, mock_send_email):
"""No email on supplier record — handler exits without sending.""" """No email on supplier record — handler exits without sending."""
lead_id, supplier_id = await _seed_lead_and_supplier(db, supplier_email="") lead_id, supplier_id = await _seed_lead_and_supplier(db, supplier_email="")
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: mock_send_email.assert_not_called()
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
mock_send.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_when_lead_not_found(self, db): async def test_skips_when_lead_not_found(self, db, mock_send_email):
"""Non-existent lead_id — handler exits without sending.""" """Non-existent lead_id — handler exits without sending."""
_, supplier_id = await _seed_lead_and_supplier(db) _, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": 99999, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: mock_send_email.assert_not_called()
await handle_send_lead_forward_email({"lead_id": 99999, "supplier_id": supplier_id})
mock_send.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_design_elements_present(self, db): async def test_design_elements_present(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db) lead_id, supplier_id = await _seed_lead_and_supplier(db)
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: _assert_common_design(_call_kwargs(mock_send_email)["html"])
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
_assert_common_design(_call_kwargs(mock_send)["html"])
# ── Lead Matched Notification ──────────────────────────────────── # ── Lead Matched Notification ────────────────────────────────────
@@ -362,70 +315,55 @@ class TestLeadForward:
class TestLeadMatched: class TestLeadMatched:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sends_to_lead_contact_email(self, db): async def test_sends_to_lead_contact_email(self, db, mock_send_email):
lead_id = await _seed_lead(db) lead_id = await _seed_lead(db)
await handle_send_lead_matched_notification({"lead_id": lead_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: assert _call_kwargs(mock_send_email)["to"] == "lead@buyer.com"
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert _call_kwargs(mock_send)["to"] == "lead@buyer.com"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_subject_contains_first_name(self, db): async def test_subject_contains_first_name(self, db, mock_send_email):
lead_id = await _seed_lead(db) lead_id = await _seed_lead(db)
await handle_send_lead_matched_notification({"lead_id": lead_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: assert "John" in _call_kwargs(mock_send_email)["subject"]
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert "John" in _call_kwargs(mock_send)["subject"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_what_happens_next(self, db): async def test_html_contains_what_happens_next(self, db, mock_send_email):
lead_id = await _seed_lead(db) lead_id = await _seed_lead(db)
await handle_send_lead_matched_notification({"lead_id": lead_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: html = _call_kwargs(mock_send_email)["html"]
await handle_send_lead_matched_notification({"lead_id": lead_id}) # "What happens next" section and tip callout (blue bg)
html = _call_kwargs(mock_send)["html"] assert "#F0F9FF" in html # tip callout background
# "What happens next" section and tip callout (blue bg)
assert "#F0F9FF" in html # tip callout background
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_project_context(self, db): async def test_html_contains_project_context(self, db, mock_send_email):
lead_id = await _seed_lead(db) lead_id = await _seed_lead(db)
await handle_send_lead_matched_notification({"lead_id": lead_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: html = _call_kwargs(mock_send_email)["html"]
await handle_send_lead_matched_notification({"lead_id": lead_id}) assert "Indoor" in html
html = _call_kwargs(mock_send)["html"] assert "Germany" in html
assert "Indoor" in html
assert "Germany" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_leads_from_addr(self, db): async def test_uses_leads_from_addr(self, db, mock_send_email):
lead_id = await _seed_lead(db) lead_id = await _seed_lead(db)
await handle_send_lead_matched_notification({"lead_id": lead_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_when_lead_not_found(self, db): async def test_skips_when_lead_not_found(self, db, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_lead_matched_notification({"lead_id": 99999})
await handle_send_lead_matched_notification({"lead_id": 99999}) mock_send_email.assert_not_called()
mock_send.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_when_no_contact_email(self, db): async def test_skips_when_no_contact_email(self, db, mock_send_email):
lead_id = await _seed_lead(db, contact_email="") lead_id = await _seed_lead(db, contact_email="")
await handle_send_lead_matched_notification({"lead_id": lead_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: mock_send_email.assert_not_called()
await handle_send_lead_matched_notification({"lead_id": lead_id})
mock_send.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_design_elements_present(self, db): async def test_design_elements_present(self, db, mock_send_email):
lead_id = await _seed_lead(db) lead_id = await _seed_lead(db)
await handle_send_lead_matched_notification({"lead_id": lead_id})
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: _assert_common_design(_call_kwargs(mock_send_email)["html"])
await handle_send_lead_matched_notification({"lead_id": lead_id})
_assert_common_design(_call_kwargs(mock_send)["html"])
# ── Supplier Enquiry ───────────────────────────────────────────── # ── Supplier Enquiry ─────────────────────────────────────────────
@@ -441,50 +379,43 @@ class TestSupplierEnquiry:
} }
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sends_to_supplier_email(self): async def test_sends_to_supplier_email(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD) assert _call_kwargs(mock_send_email)["to"] == "supplier@corp.com"
assert _call_kwargs(mock_send)["to"] == "supplier@corp.com"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_subject_contains_contact_name(self): async def test_subject_contains_contact_name(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD) assert "Alice Smith" in _call_kwargs(mock_send_email)["subject"]
assert "Alice Smith" in _call_kwargs(mock_send)["subject"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_message(self): async def test_html_contains_message(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] assert "4 courts" in html
assert "4 courts" in html assert "alice@buyer.com" in html
assert "alice@buyer.com" in html
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_html_contains_respond_fast_nudge(self): async def test_html_contains_respond_fast_nudge(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] # The respond-fast nudge line should be present
# The respond-fast nudge line should be present assert "24" in html # "24 hours" reference
assert "24" in html # "24 hours" reference
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_when_no_supplier_email(self): async def test_skips_when_no_supplier_email(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_supplier_enquiry_email({**self._BASE_PAYLOAD, "supplier_email": ""})
await handle_send_supplier_enquiry_email({**self._BASE_PAYLOAD, "supplier_email": ""}) mock_send_email.assert_not_called()
mock_send.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_transactional_from_addr(self): async def test_uses_transactional_from_addr(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD) assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_design_elements_present(self): async def test_design_elements_present(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD) _assert_common_design(_call_kwargs(mock_send_email)["html"])
_assert_common_design(_call_kwargs(mock_send)["html"])
# ── Waitlist (supplement existing test_waitlist.py) ────────────── # ── Waitlist (supplement existing test_waitlist.py) ──────────────
@@ -494,33 +425,29 @@ class TestWaitlistEmails:
"""Verify design & content for waitlist confirmation emails.""" """Verify design & content for waitlist confirmation emails."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_general_waitlist_has_preheader(self): async def test_general_waitlist_has_preheader(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"})
await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] assert "display:none" in html # preheader span
assert "display:none" in html # preheader span
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_supplier_waitlist_mentions_plan(self): async def test_supplier_waitlist_mentions_plan(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_growth"})
await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_growth"}) kw = _call_kwargs(mock_send_email)
kw = _call_kwargs(mock_send) assert "growth" in kw["subject"].lower()
assert "growth" in kw["subject"].lower() assert "supplier" in kw["html"].lower()
assert "supplier" in kw["html"].lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_general_waitlist_design_elements(self): async def test_general_waitlist_design_elements(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"})
await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"}) _assert_common_design(_call_kwargs(mock_send_email)["html"])
_assert_common_design(_call_kwargs(mock_send)["html"])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_supplier_waitlist_perks_listed(self): async def test_supplier_waitlist_perks_listed(self, mock_send_email):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_pro"})
await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_pro"}) html = _call_kwargs(mock_send_email)["html"]
html = _call_kwargs(mock_send)["html"] # Should have <li> perks
# Should have <li> perks assert html.count("<li>") >= 3
assert html.count("<li>") >= 3
# ── DB seed helpers ────────────────────────────────────────────── # ── DB seed helpers ──────────────────────────────────────────────

View File

@@ -10,7 +10,6 @@ import sqlite3
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from padelnomics.core import utcnow_iso
from padelnomics.migrations.migrate import migrate from padelnomics.migrations.migrate import migrate
from padelnomics import core from padelnomics import core
@@ -25,25 +24,6 @@ def mock_csrf_validation():
yield yield
@pytest.fixture
async def admin_client(app, db):
"""Test client with an admin-role user session (module-level, follows test_content.py)."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("flags_admin@test.com", "Flags Admin", now),
) as cursor:
admin_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = admin_id
yield c
async def _set_flag(db, name: str, enabled: bool, description: str = ""): async def _set_flag(db, name: str, enabled: bool, description: str = ""):
"""Insert or replace a flag in the test DB.""" """Insert or replace a flag in the test DB."""
await db.execute( await db.execute(

View File

@@ -46,26 +46,6 @@ def _bypass_csrf():
yield yield
@pytest.fixture
async def admin_client(app, db):
"""Test client with an admin user pre-loaded in session."""
now = datetime.now(UTC).isoformat()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("admin@example.com", "Admin User", now),
) as cursor:
user_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (user_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = user_id
yield c
async def _insert_supplier( async def _insert_supplier(
db, db,
name: str = "Test Supplier", name: str = "Test Supplier",

View File

@@ -14,31 +14,10 @@ from unittest.mock import AsyncMock, MagicMock, patch
import padelnomics.admin.pipeline_routes as pipeline_mod import padelnomics.admin.pipeline_routes as pipeline_mod
import pytest import pytest
from padelnomics.core import utcnow_iso
# ── Fixtures ────────────────────────────────────────────────────────────────── # ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
async def admin_client(app, db):
"""Authenticated admin test client."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("pipeline-admin@test.com", "Pipeline Admin", now),
) as cursor:
admin_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = admin_id
yield c
@pytest.fixture @pytest.fixture
def state_db_dir(): def state_db_dir():
"""Temp directory with a seeded .state.sqlite for testing.""" """Temp directory with a seeded .state.sqlite for testing."""

View File

@@ -10,7 +10,6 @@ Covers:
import json import json
from unittest.mock import patch from unittest.mock import patch
import pytest
from padelnomics.content.health import ( from padelnomics.content.health import (
check_broken_scenario_refs, check_broken_scenario_refs,
check_hreflang_orphans, check_hreflang_orphans,
@@ -27,26 +26,6 @@ from padelnomics import core
# ── Fixtures ────────────────────────────────────────────────────────────────── # ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
async def admin_client(app, db):
"""Authenticated admin test client."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("pseo-admin@test.com", "pSEO Admin", now),
) as cursor:
admin_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = admin_id
yield c
# ── DB helpers ──────────────────────────────────────────────────────────────── # ── DB helpers ────────────────────────────────────────────────────────────────

View File

@@ -89,26 +89,6 @@ async def articles_data(db, seo_data):
await db.commit() await db.commit()
@pytest.fixture
async def admin_client(app, db):
"""Authenticated admin client."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("admin@test.com", "Admin", now),
) as cursor:
admin_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = admin_id
yield c
# ── Query function tests ───────────────────────────────────── # ── Query function tests ─────────────────────────────────────
class TestSearchPerformance: class TestSearchPerformance:

View File

@@ -188,59 +188,55 @@ class TestWorkerTask:
"""Test send_waitlist_confirmation worker task.""" """Test send_waitlist_confirmation worker task."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sends_entrepreneur_confirmation(self): async def test_sends_entrepreneur_confirmation(self, mock_send_email):
"""Task sends confirmation email for entrepreneur signup.""" """Task sends confirmation email for entrepreneur signup."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_waitlist_confirmation({
await handle_send_waitlist_confirmation({ "email": "entrepreneur@example.com",
"email": "entrepreneur@example.com", "intent": "signup",
"intent": "signup", })
})
mock_send.assert_called_once() mock_send_email.assert_called_once()
call_args = mock_send.call_args call_args = mock_send_email.call_args
assert call_args.kwargs["to"] == "entrepreneur@example.com" assert call_args.kwargs["to"] == "entrepreneur@example.com"
assert "notify you at launch" in call_args.kwargs["subject"].lower() assert "notify you at launch" in call_args.kwargs["subject"].lower()
assert "waitlist" in call_args.kwargs["html"].lower() assert "waitlist" in call_args.kwargs["html"].lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sends_supplier_confirmation(self): async def test_sends_supplier_confirmation(self, mock_send_email):
"""Task sends confirmation email for supplier signup.""" """Task sends confirmation email for supplier signup."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_waitlist_confirmation({
await handle_send_waitlist_confirmation({ "email": "supplier@example.com",
"email": "supplier@example.com", "intent": "supplier_growth",
"intent": "supplier_growth", })
})
mock_send.assert_called_once() mock_send_email.assert_called_once()
call_args = mock_send.call_args call_args = mock_send_email.call_args
assert call_args.kwargs["to"] == "supplier@example.com" assert call_args.kwargs["to"] == "supplier@example.com"
assert "growth" in call_args.kwargs["subject"].lower() assert "growth" in call_args.kwargs["subject"].lower()
assert "supplier" in call_args.kwargs["html"].lower() assert "supplier" in call_args.kwargs["html"].lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_supplier_email_includes_plan_name(self): async def test_supplier_email_includes_plan_name(self, mock_send_email):
"""Supplier confirmation should mention the specific plan.""" """Supplier confirmation should mention the specific plan."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_waitlist_confirmation({
await handle_send_waitlist_confirmation({ "email": "supplier@example.com",
"email": "supplier@example.com", "intent": "supplier_pro",
"intent": "supplier_pro", })
})
call_args = mock_send.call_args call_args = mock_send_email.call_args
html = call_args.kwargs["html"] html = call_args.kwargs["html"]
assert "pro" in html.lower() assert "pro" in html.lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_transactional_email_address(self): async def test_uses_transactional_email_address(self, mock_send_email):
"""Task should use transactional sender address.""" """Task should use transactional sender address."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send: await handle_send_waitlist_confirmation({
await handle_send_waitlist_confirmation({ "email": "test@example.com",
"email": "test@example.com", "intent": "signup",
"intent": "signup", })
})
call_args = mock_send.call_args call_args = mock_send_email.call_args
assert call_args.kwargs["from_addr"] == core.EMAIL_ADDRESSES["transactional"] assert call_args.kwargs["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
# ── TestAuthRoutes ──────────────────────────────────────────────── # ── TestAuthRoutes ────────────────────────────────────────────────