From 3d99b8c3752a3806122ba097bf9a04946698282f Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 09:53:58 +0100 Subject: [PATCH 01/31] fix(cms): change articles unique constraint to (url_path, language) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit url_path UNIQUE prevented multilingual generation — the second language (e.g. EN after DE) always failed with UNIQUE constraint, leaving tasks in a retry loop and only the first 1-2 articles visible. Migration 0020 recreates the articles table with UNIQUE(url_path, language) and adds a composite index. Adds idx_articles_url_lang for the new lookup pattern used by article_page and generate_articles upsert. Also adds search/country/venue_type filters to the admin Scenarios tab and clarifies what "Published Scenarios" means in the subtitle. Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/admin/routes.py | 41 +++++++++- .../admin/templates/admin/scenarios.html | 47 +++++++++-- .../0020_articles_unique_url_language.py | 81 +++++++++++++++++++ 3 files changed, 160 insertions(+), 9 deletions(-) create mode 100644 web/src/padelnomics/migrations/versions/0020_articles_unique_url_language.py diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 89336cc..1657fe7 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -1395,11 +1395,46 @@ SCENARIO_FORM_FIELDS = [ @bp.route("/scenarios") @role_required("admin") async def scenarios(): - """List published scenarios.""" + """List published scenarios with optional filters.""" + search = request.args.get("search", "").strip() + country_filter = request.args.get("country", "") + venue_filter = request.args.get("venue_type", "") + + wheres = ["1=1"] + params: list = [] + if search: + wheres.append("(title LIKE ? OR location LIKE ? OR slug LIKE ?)") + params.extend([f"%{search}%", f"%{search}%", f"%{search}%"]) + if country_filter: + wheres.append("country = ?") + params.append(country_filter) + if venue_filter: + wheres.append("venue_type = ?") + params.append(venue_filter) + + where = " AND ".join(wheres) scenario_list = await fetch_all( - "SELECT * FROM published_scenarios ORDER BY created_at DESC" + f"SELECT * FROM published_scenarios WHERE {where} ORDER BY created_at DESC", + tuple(params), + ) + countries = await fetch_all( + "SELECT DISTINCT country FROM published_scenarios WHERE country != '' ORDER BY country" + ) + venue_types = await fetch_all( + "SELECT DISTINCT venue_type FROM published_scenarios WHERE venue_type != '' ORDER BY venue_type" + ) + total = await fetch_one("SELECT COUNT(*) as cnt FROM published_scenarios") + + return await render_template( + "admin/scenarios.html", + scenarios=scenario_list, + countries=[r["country"] for r in countries], + venue_types=[r["venue_type"] for r in venue_types], + total=total["cnt"] if total else 0, + current_search=search, + current_country=country_filter, + current_venue_type=venue_filter, ) - return await render_template("admin/scenarios.html", scenarios=scenario_list) @bp.route("/scenarios/new", methods=["GET", "POST"]) diff --git a/web/src/padelnomics/admin/templates/admin/scenarios.html b/web/src/padelnomics/admin/templates/admin/scenarios.html index 2ccb481..c32270e 100644 --- a/web/src/padelnomics/admin/templates/admin/scenarios.html +++ b/web/src/padelnomics/admin/templates/admin/scenarios.html @@ -1,20 +1,55 @@ {% extends "admin/base_admin.html" %} {% set admin_page = "scenarios" %} -{% block title %}Published Scenarios - Admin - {{ config.APP_NAME }}{% endblock %} +{% block title %}Scenarios - Admin - {{ config.APP_NAME }}{% endblock %} {% block admin_content %} -
+
-

Published Scenarios

-

{{ scenarios | length }} scenario{{ 's' if scenarios | length != 1 }}

+

Scenarios

+

+ Pre-computed calculator outputs — embedded as cards in articles and PDFs. + Showing {{ scenarios | length }} of {{ total }}. +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + {% if current_search or current_country or current_venue_type %} + Clear + {% endif %} +
+
+
{% if scenarios %} @@ -51,7 +86,7 @@
{% else %} -

No published scenarios yet.

+

No scenarios match the current filters.

{% endif %}
{% endblock %} diff --git a/web/src/padelnomics/migrations/versions/0020_articles_unique_url_language.py b/web/src/padelnomics/migrations/versions/0020_articles_unique_url_language.py new file mode 100644 index 0000000..0cda8d6 --- /dev/null +++ b/web/src/padelnomics/migrations/versions/0020_articles_unique_url_language.py @@ -0,0 +1,81 @@ +"""Change articles unique constraint from url_path alone to (url_path, language). + +Previously url_path was declared UNIQUE, which prevented multiple languages +from sharing the same url_path (e.g. /markets/germany/berlin for both de and en). +""" + + +def up(conn) -> None: + # ── 1. Drop FTS triggers + virtual table ────────────────────────────────── + conn.execute("DROP TRIGGER IF EXISTS articles_ai") + conn.execute("DROP TRIGGER IF EXISTS articles_ad") + conn.execute("DROP TRIGGER IF EXISTS articles_au") + conn.execute("DROP TABLE IF EXISTS articles_fts") + + # ── 2. Recreate articles with UNIQUE(url_path, language) ────────────────── + conn.execute(""" + CREATE TABLE articles_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url_path TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + meta_description TEXT, + country TEXT, + region TEXT, + og_image_url TEXT, + status TEXT NOT NULL DEFAULT 'draft', + published_at TEXT, + template_slug TEXT, + language TEXT NOT NULL DEFAULT 'en', + date_modified TEXT, + seo_head TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT, + UNIQUE(url_path, language) + ) + """) + conn.execute(""" + INSERT INTO articles_new + (id, url_path, slug, title, meta_description, country, region, + og_image_url, status, published_at, template_slug, language, + date_modified, seo_head, created_at, updated_at) + SELECT id, url_path, slug, title, meta_description, country, region, + og_image_url, status, published_at, template_slug, language, + date_modified, seo_head, created_at, updated_at + FROM articles + """) + conn.execute("DROP TABLE articles") + conn.execute("ALTER TABLE articles_new RENAME TO articles") + + conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_url_lang ON articles(url_path, language)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status, published_at)") + + # ── 3. Recreate FTS + triggers ───────────────────────────────────────────── + conn.execute(""" + CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5( + title, meta_description, country, region, + content='articles', content_rowid='id' + ) + """) + conn.execute(""" + CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN + INSERT INTO articles_fts(rowid, title, meta_description, country, region) + VALUES (new.id, new.title, new.meta_description, new.country, new.region); + END + """) + conn.execute(""" + CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN + INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region) + VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region); + END + """) + conn.execute(""" + CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN + INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region) + VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region); + INSERT INTO articles_fts(rowid, title, meta_description, country, region) + VALUES (new.id, new.title, new.meta_description, new.country, new.region); + END + """) From c2bf82917ae28d2d0fddf1b82bf4519594af324a Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:10:39 +0100 Subject: [PATCH 02/31] feat(i18n): add Market Score methodology page keys (EN + DE) Subtask 1/6: ~40 mscore_* keys per locale covering page title, meta, section headings, category descriptions, score band interpretations, data sources, limitations, CTAs, and 5 FAQ Q&A pairs. DE content written as native German (Du-form), not translated from EN. Co-Authored-By: Claude Opus 4.6 --- web/src/padelnomics/locales/de.json | 46 ++++++++++++++++++++++++++++- web/src/padelnomics/locales/en.json | 46 ++++++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/web/src/padelnomics/locales/de.json b/web/src/padelnomics/locales/de.json index ee73447..f932973 100644 --- a/web/src/padelnomics/locales/de.json +++ b/web/src/padelnomics/locales/de.json @@ -1641,5 +1641,49 @@ "email_business_plan_preheader": "Professioneller Padel-Finanzplan \u2014 jetzt herunterladen", "email_footer_tagline": "Die Planungsplattform f\u00fcr Padel-Unternehmer", - "email_footer_copyright": "\u00a9 {year} {app_name}. Du erh\u00e4ltst diese E-Mail, weil du ein Konto hast oder eine Anfrage gestellt hast." + "email_footer_copyright": "\u00a9 {year} {app_name}. Du erh\u00e4ltst diese E-Mail, weil du ein Konto hast oder eine Anfrage gestellt hast.", + + "footer_market_score": "Market Score", + "mscore_page_title": "Der padelnomics Market Score \u2014 So messen wir Marktpotenzial", + "mscore_meta_desc": "Der padelnomics Market Score bewertet St\u00e4dte von 0 bis 100 nach ihrem Potenzial f\u00fcr Padel-Investitionen. Erfahre, wie Demografie, Wirtschaftskraft, Nachfragesignale und Datenabdeckung einflie\u00dfen.", + "mscore_og_desc": "Ein datengest\u00fctzter Komposit-Score (0\u2013100), der die Attraktivit\u00e4t einer Stadt f\u00fcr Padelanlagen-Investitionen misst. Was steckt dahinter \u2014 und was bedeutet er f\u00fcr Deine Planung?", + "mscore_h1": "Der padelnomics Market Score", + "mscore_subtitle": "Ein datengest\u00fctztes Ma\u00df f\u00fcr die Attraktivit\u00e4t einer Stadt als Padel-Investitionsstandort.", + "mscore_what_h2": "Was der Score misst", + "mscore_what_intro": "Der Market Score ist ein Komposit-Index von 0 bis 100, der das Potenzial einer Stadt als Standort f\u00fcr Padelanlagen bewertet. Vier Datenkategorien flie\u00dfen in eine einzige Kennzahl ein \u2014 damit Du schnell einsch\u00e4tzen kannst, welche M\u00e4rkte sich n\u00e4her anzuschauen lohnt.", + "mscore_cat_demo_h3": "Demografie", + "mscore_cat_demo_p": "Bev\u00f6lkerungsgr\u00f6\u00dfe als Indikator f\u00fcr den adressierbaren Markt. Gr\u00f6\u00dfere St\u00e4dte tragen in der Regel mehr Anlagen und h\u00f6here Auslastung.", + "mscore_cat_econ_h3": "Wirtschaftskraft", + "mscore_cat_econ_p": "Regionale Kaufkraft und Einkommensindikatoren. In M\u00e4rkten mit h\u00f6herem verf\u00fcgbarem Einkommen ist die Nachfrage nach Freizeitsportarten wie Padel tendenziell st\u00e4rker.", + "mscore_cat_demand_h3": "Nachfrageindikatoren", + "mscore_cat_demand_p": "Signale aus dem laufenden Betrieb bestehender Anlagen \u2014 Auslastungsraten, Buchungsdaten, Anzahl aktiver Standorte. Wo sich reale Nachfrage bereits messen l\u00e4sst, ist das der st\u00e4rkste Indikator.", + "mscore_cat_data_h3": "Datenqualit\u00e4t", + "mscore_cat_data_p": "Wie umfassend die Datenlage f\u00fcr eine Stadt ist. Ein Score auf Basis unvollst\u00e4ndiger Daten ist weniger belastbar \u2014 wir machen das transparent, damit Du wei\u00dft, wo eigene Recherche sinnvoll ist.", + "mscore_read_h2": "Wie Du den Score liest", + "mscore_band_high_label": "70\u2013100: Starker Markt", + "mscore_band_high_p": "Gro\u00dfe Bev\u00f6lkerung, hohe Wirtschaftskraft und nachgewiesene Nachfrage durch bestehende Anlagen. Diese St\u00e4dte haben validierte Padel-M\u00e4rkte mit belastbaren Benchmarks f\u00fcr die Finanzplanung.", + "mscore_band_mid_label": "45\u201369: Solides Mittelfeld", + "mscore_band_mid_p": "Gute Grundlagen mit Wachstumspotenzial. Genug Daten f\u00fcr fundierte Planung, aber weniger Wettbewerb als in den Top-St\u00e4dten. H\u00e4ufig der Sweet Spot f\u00fcr Neueinsteiger.", + "mscore_band_low_label": "Unter 45: Fr\u00fcher Markt", + "mscore_band_low_p": "Weniger validierte Daten oder kleinere Bev\u00f6lkerung. Das hei\u00dft nicht, dass die Stadt unattraktiv ist \u2014 es kann weniger Wettbewerb und bessere Konditionen f\u00fcr Fr\u00fcheinsteiger bedeuten. Rechne mit mehr eigener Recherche vor Ort.", + "mscore_read_note": "Ein niedriger Score bedeutet nicht automatisch eine schlechte Investition. Er kann auf begrenzte Datenlage oder einen noch jungen Markt hinweisen \u2014 weniger Wettbewerb und g\u00fcnstigere Einstiegsbedingungen sind m\u00f6glich.", + "mscore_sources_h2": "Datenquellen", + "mscore_sources_p": "Der Market Score basiert auf Daten europ\u00e4ischer Statistik\u00e4mter (Bev\u00f6lkerung und Wirtschaftsindikatoren), Buchungsplattformen f\u00fcr Padelanlagen (Standortanzahl, Preise, Auslastung) und geografischen Datenbanken (Standortdaten). Die Daten werden monatlich aktualisiert.", + "mscore_limits_h2": "Einschr\u00e4nkungen", + "mscore_limits_p1": "Der Score bildet die verf\u00fcgbare Datenlage ab, nicht die absolute Marktwahrheit. St\u00e4dte, in denen weniger Anlagen auf Buchungsplattformen erfasst sind, k\u00f6nnen bei den Nachfrageindikatoren niedrigere Werte zeigen \u2014 selbst wenn die lokale Nachfrage hoch ist.", + "mscore_limits_p2": "Der Score ber\u00fccksichtigt keine lokalen Faktoren wie Immobilienkosten, Genehmigungszeitr\u00e4ume, Wettbewerbsdynamik oder regulatorische Rahmenbedingungen. Diese Aspekte sind entscheidend und erfordern Recherche vor Ort.", + "mscore_limits_p3": "Nutze den Market Score als Ausgangspunkt f\u00fcr die Priorisierung, nicht als finale Investitionsentscheidung. Im Finanzplaner kannst Du Dein konkretes Szenario durchrechnen.", + "mscore_cta_markets": "Stadtbewertungen ansehen", + "mscore_cta_planner": "Dein Investment modellieren", + "mscore_faq_h2": "H\u00e4ufig gestellte Fragen", + "mscore_faq_q1": "Was ist der padelnomics Market Score?", + "mscore_faq_a1": "Ein Komposit-Index von 0 bis 100, der die Attraktivit\u00e4t einer Stadt f\u00fcr Padelanlagen-Investitionen misst. Er kombiniert Demografie, Wirtschaftskraft, Nachfrageindikatoren und Datenqualit\u00e4t in einer vergleichbaren Kennzahl.", + "mscore_faq_q2": "Wie oft wird der Score aktualisiert?", + "mscore_faq_a2": "Monatlich. Neue Daten aus Statistik\u00e4mtern, Buchungsplattformen und Standortdatenbanken werden regelm\u00e4\u00dfig extrahiert und verarbeitet. Der Score spiegelt immer die aktuellsten verf\u00fcgbaren Daten wider.", + "mscore_faq_q3": "Warum hat meine Stadt einen niedrigen Score?", + "mscore_faq_a3": "Meist wegen begrenzter Datenabdeckung oder geringerer Bev\u00f6lkerung. Ein niedriger Score bedeutet nicht, dass die Stadt unattraktiv ist \u2014 sondern dass uns weniger Daten zur Quantifizierung der Chance vorliegen. Eigene Recherche kann die L\u00fccken schlie\u00dfen.", + "mscore_faq_q4": "Kann ich Scores l\u00e4nder\u00fcbergreifend vergleichen?", + "mscore_faq_a4": "Ja. Die Methodik ist f\u00fcr alle M\u00e4rkte einheitlich, sodass ein Score von 72 in Deutschland direkt vergleichbar ist mit einem 72 in Spanien oder Gro\u00dfbritannien.", + "mscore_faq_q5": "Garantiert ein hoher Score eine gute Investition?", + "mscore_faq_a5": "Nein. Der Score misst die Marktattraktivit\u00e4t auf Makroebene. Deine konkrete Investition h\u00e4ngt von Anlagentyp, Baukosten, Mietkonditionen und Dutzenden weiterer Faktoren ab. Im Finanzplaner kannst Du Dein Szenario mit echten Zahlen durchrechnen." } diff --git a/web/src/padelnomics/locales/en.json b/web/src/padelnomics/locales/en.json index 70927f4..9b5a0ab 100644 --- a/web/src/padelnomics/locales/en.json +++ b/web/src/padelnomics/locales/en.json @@ -1641,5 +1641,49 @@ "email_business_plan_preheader": "Professional padel facility financial plan \u2014 download now", "email_footer_tagline": "The padel business planning platform", - "email_footer_copyright": "\u00a9 {year} {app_name}. You received this email because you have an account or submitted a request." + "email_footer_copyright": "\u00a9 {year} {app_name}. You received this email because you have an account or submitted a request.", + + "footer_market_score": "Market Score", + "mscore_page_title": "The padelnomics Market Score \u2014 How We Measure Market Potential", + "mscore_meta_desc": "The padelnomics Market Score rates cities from 0 to 100 on their potential for padel investment. Learn how demographics, economic strength, demand signals, and data coverage feed into the score.", + "mscore_og_desc": "A data-driven composite score (0\u2013100) that measures how attractive a city is for padel court investment. See what goes into it and what it means for your planning.", + "mscore_h1": "The padelnomics Market Score", + "mscore_subtitle": "A data-driven measure of how attractive a city is for padel investment.", + "mscore_what_h2": "What It Measures", + "mscore_what_intro": "The Market Score is a composite index from 0 to 100 that evaluates a city\u2019s potential as a location for padel court investment. It combines four categories of data into a single number designed to help you prioritize markets worth investigating further.", + "mscore_cat_demo_h3": "Demographics", + "mscore_cat_demo_p": "Population size as a proxy for the addressable market. Larger cities generally support more venues and higher utilization.", + "mscore_cat_econ_h3": "Economic Strength", + "mscore_cat_econ_p": "Regional purchasing power and income indicators. Markets where people have higher disposable income tend to sustain stronger demand for leisure sports like padel.", + "mscore_cat_demand_h3": "Demand Evidence", + "mscore_cat_demand_p": "Signals from existing venue activity \u2014 occupancy rates, booking data, and the number of operating venues. Where real demand is already measurable, it\u2019s the strongest indicator.", + "mscore_cat_data_h3": "Data Completeness", + "mscore_cat_data_p": "How much data we have for that city. A score influenced by incomplete data is less reliable \u2014 we surface this explicitly so you know when to dig deeper on your own.", + "mscore_read_h2": "How To Read the Score", + "mscore_band_high_label": "70\u2013100: Strong market", + "mscore_band_high_p": "Large population, economic power, and proven demand from existing venues. These cities have validated padel markets with reliable benchmarks for financial planning.", + "mscore_band_mid_label": "45\u201369: Solid mid-tier", + "mscore_band_mid_p": "Good fundamentals with room for growth. Enough data to plan with confidence, but less competition than top-tier cities. Often the sweet spot for new entrants.", + "mscore_band_low_label": "Below 45: Early-stage market", + "mscore_band_low_p": "Less validated data or smaller populations. This does not mean a city is a bad investment \u2014 it may mean less competition and first-mover advantage. Expect to do more local research.", + "mscore_read_note": "A lower score does not mean a city is a bad investment. It may indicate less available data or a market still developing \u2014 which can mean less competition and better terms for early entrants.", + "mscore_sources_h2": "Data Sources", + "mscore_sources_p": "The Market Score draws on data from European statistical offices (population and economic indicators), court booking platforms (venue counts, pricing, occupancy), and geographic databases (venue locations). Data is refreshed monthly as new extractions run.", + "mscore_limits_h2": "Limitations", + "mscore_limits_p1": "The score reflects available data, not absolute market truth. Cities where fewer venues are tracked on booking platforms may score lower on demand evidence \u2014 even if local demand is strong.", + "mscore_limits_p2": "The score does not account for local factors like real estate costs, permitting timelines, competitive dynamics, or regulatory environment. These matter enormously and require on-the-ground research.", + "mscore_limits_p3": "Use the Market Score as a starting point for prioritization, not a final investment decision. The financial planner is where you model your specific scenario.", + "mscore_cta_markets": "Browse city scores", + "mscore_cta_planner": "Model your investment", + "mscore_faq_h2": "Frequently Asked Questions", + "mscore_faq_q1": "What is the padelnomics Market Score?", + "mscore_faq_a1": "A composite index from 0 to 100 that measures how attractive a city is for padel court investment. It combines demographics, economic strength, demand evidence, and data completeness into a single comparable number.", + "mscore_faq_q2": "How often is the score updated?", + "mscore_faq_a2": "Monthly. New data from statistical offices, booking platforms, and venue databases is extracted and processed on a regular cycle. Scores reflect the most recent available data.", + "mscore_faq_q3": "Why is my city\u2019s score low?", + "mscore_faq_a3": "Usually because of limited data coverage or smaller population. A low score doesn\u2019t mean the city is unattractive \u2014 it means we have less data to quantify the opportunity. Local research can fill the gaps.", + "mscore_faq_q4": "Can I compare scores across countries?", + "mscore_faq_a4": "Yes. The methodology is consistent across all markets we track, so a score of 72 in Germany is directly comparable to a 72 in Spain or the UK.", + "mscore_faq_q5": "Does a high score guarantee a good investment?", + "mscore_faq_a5": "No. The score measures market attractiveness at a macro level. Your specific investment depends on venue type, build costs, lease terms, and dozens of other factors. Use the financial planner to model your scenario with real numbers." } From 33aa705ef956a6c3f0cd1f228eecdabfbf767963 Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:11:07 +0100 Subject: [PATCH 03/31] feat(routes): add /market-score route + legacy redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subtask 2/6: Route handler in public blueprint, 301 redirect from /market-score → /en/market-score for bookmarks without lang prefix. Co-Authored-By: Claude Opus 4.6 --- web/src/padelnomics/app.py | 4 ++++ web/src/padelnomics/public/routes.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/web/src/padelnomics/app.py b/web/src/padelnomics/app.py index 75c9f78..cbf1c1a 100644 --- a/web/src/padelnomics/app.py +++ b/web/src/padelnomics/app.py @@ -292,6 +292,10 @@ def create_app() -> Quart: async def legacy_suppliers(): return redirect("/en/suppliers", 301) + @app.route("/market-score") + async def legacy_market_score(): + return redirect("/en/market-score", 301) + # ------------------------------------------------------------------------- # Blueprint registration # ------------------------------------------------------------------------- diff --git a/web/src/padelnomics/public/routes.py b/web/src/padelnomics/public/routes.py index f84bb36..94b9eb4 100644 --- a/web/src/padelnomics/public/routes.py +++ b/web/src/padelnomics/public/routes.py @@ -59,6 +59,11 @@ async def about(): return await render_template("about.html") +@bp.route("/market-score") +async def market_score(): + return await render_template("market_score.html") + + @bp.route("/imprint") async def imprint(): lang = g.get("lang", "en") From 39fe025e5b3e8623cc793b3aca3233598e025b72 Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:12:03 +0100 Subject: [PATCH 04/31] feat(template): create Market Score methodology page Subtask 3/6: Standalone informational page extending base.html. Sections: hero, what it measures (4-card grid), score bands, data sources, limitations, CTAs, FAQ with details/summary. JSON-LD: WebPage + BreadcrumbList + FAQPage. Co-Authored-By: Claude Opus 4.6 --- .../public/templates/market_score.html | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 web/src/padelnomics/public/templates/market_score.html diff --git a/web/src/padelnomics/public/templates/market_score.html b/web/src/padelnomics/public/templates/market_score.html new file mode 100644 index 0000000..df98930 --- /dev/null +++ b/web/src/padelnomics/public/templates/market_score.html @@ -0,0 +1,175 @@ +{% extends "base.html" %} + +{% block title %}{{ t.mscore_page_title }}{% endblock %} + +{% block head %} + + + + +{% endblock %} + +{% block content %} +
+
+ + +
+

+ padelnomics + Market Score +

+

{{ t.mscore_subtitle }}

+
+ + +
+

{{ t.mscore_what_h2 }}

+

{{ t.mscore_what_intro }}

+ +
+
+
👥
+

{{ t.mscore_cat_demo_h3 }}

+

{{ t.mscore_cat_demo_p }}

+
+
+
💶
+

{{ t.mscore_cat_econ_h3 }}

+

{{ t.mscore_cat_econ_p }}

+
+
+
📈
+

{{ t.mscore_cat_demand_h3 }}

+

{{ t.mscore_cat_demand_p }}

+
+
+
🔍
+

{{ t.mscore_cat_data_h3 }}

+

{{ t.mscore_cat_data_p }}

+
+
+
+ + +
+

{{ t.mscore_read_h2 }}

+
+
+
+ + {{ t.mscore_band_high_label }} +
+

{{ t.mscore_band_high_p }}

+
+
+
+ + {{ t.mscore_band_mid_label }} +
+

{{ t.mscore_band_mid_p }}

+
+
+
+ + {{ t.mscore_band_low_label }} +
+

{{ t.mscore_band_low_p }}

+
+
+

{{ t.mscore_read_note }}

+
+ + +
+

{{ t.mscore_sources_h2 }}

+

{{ t.mscore_sources_p }}

+
+ + +
+

{{ t.mscore_limits_h2 }}

+
+

{{ t.mscore_limits_p1 }}

+

{{ t.mscore_limits_p2 }}

+

{{ t.mscore_limits_p3 }}

+
+
+ + + + + +
+

{{ t.mscore_faq_h2 }}

+
+ {% for i in range(1, 6) %} +
+ {{ t['mscore_faq_q' ~ i] }} +

{{ t['mscore_faq_a' ~ i] }}

+
+ {% endfor %} +
+
+ +
+
+{% endblock %} From 2a038e48be8360161002fec628e058766f8f7f5c Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:12:29 +0100 Subject: [PATCH 05/31] feat(seo): add Market Score to sitemap and footer Subtask 4/6: Add /market-score to STATIC_PATHS for sitemap generation (both lang variants + hreflang). Add footer link in Product column between Markets and For Suppliers. Co-Authored-By: Claude Opus 4.6 --- web/src/padelnomics/sitemap.py | 1 + web/src/padelnomics/templates/base.html | 1 + 2 files changed, 2 insertions(+) diff --git a/web/src/padelnomics/sitemap.py b/web/src/padelnomics/sitemap.py index 8a279cd..02e5010 100644 --- a/web/src/padelnomics/sitemap.py +++ b/web/src/padelnomics/sitemap.py @@ -23,6 +23,7 @@ STATIC_PATHS = [ "/imprint", "/suppliers", "/markets", + "/market-score", "/planner/", "/directory/", ] diff --git a/web/src/padelnomics/templates/base.html b/web/src/padelnomics/templates/base.html index a17ce5a..f2a066e 100644 --- a/web/src/padelnomics/templates/base.html +++ b/web/src/padelnomics/templates/base.html @@ -171,6 +171,7 @@
  • {{ t.nav_planner }}
  • {{ t.nav_directory }}
  • {{ t.nav_markets }}
  • +
  • {{ t.footer_market_score }}
  • {{ t.nav_suppliers }}
  • From f76d2889e508be817437a2d458c495408f87a80d Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:12:50 +0100 Subject: [PATCH 06/31] fix(core): add utcnow()/utcnow_iso() helpers, migrate core.py usages Replace deprecated datetime.utcnow() with datetime.now(UTC). - utcnow() -> datetime: for in-memory datetime math - utcnow_iso() -> str: strftime format preserving existing SQLite TEXT format Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/core.py | 38 ++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/web/src/padelnomics/core.py b/web/src/padelnomics/core.py index 5260ec0..c762d5f 100644 --- a/web/src/padelnomics/core.py +++ b/web/src/padelnomics/core.py @@ -10,7 +10,7 @@ import re import secrets import unicodedata from contextvars import ContextVar -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from functools import wraps from pathlib import Path @@ -88,6 +88,26 @@ class Config: config = Config() + +# ============================================================================= +# Datetime helpers +# ============================================================================= + + +def utcnow() -> datetime: + """Timezone-aware UTC now (replaces deprecated datetime.utcnow()).""" + return datetime.now(UTC) + + +def utcnow_iso() -> str: + """UTC now as naive ISO string for SQLite TEXT columns. + + Produces YYYY-MM-DDTHH:MM:SS (no +00:00 suffix) to match the existing + format stored in the DB so lexicographic SQL comparisons keep working. + """ + return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + + # ============================================================================= # Database # ============================================================================= @@ -528,17 +548,18 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t """ limit = limit or config.RATE_LIMIT_REQUESTS window = window or config.RATE_LIMIT_WINDOW - now = datetime.utcnow() + now = utcnow() window_start = now - timedelta(seconds=window) # Clean old entries and count recent await execute( - "DELETE FROM rate_limits WHERE key = ? AND timestamp < ?", (key, window_start.isoformat()) + "DELETE FROM rate_limits WHERE key = ? AND timestamp < ?", + (key, window_start.strftime("%Y-%m-%dT%H:%M:%S")), ) result = await fetch_one( "SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?", - (key, window_start.isoformat()), + (key, window_start.strftime("%Y-%m-%dT%H:%M:%S")), ) count = result["count"] if result else 0 @@ -552,7 +573,10 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t return False, info # Record this request - await execute("INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)", (key, now.isoformat())) + await execute( + "INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)", + (key, now.strftime("%Y-%m-%dT%H:%M:%S")), + ) return True, info @@ -628,7 +652,7 @@ async def soft_delete(table: str, id: int) -> bool: """Mark record as deleted.""" result = await execute( f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL", - (datetime.utcnow().isoformat(), id), + (utcnow_iso(), id), ) return result > 0 @@ -647,7 +671,7 @@ async def hard_delete(table: str, id: int) -> bool: async def purge_deleted(table: str, days: int = 30) -> int: """Purge records deleted more than X days ago.""" - cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat() + cutoff = (utcnow() - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%S") return await execute( f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,) ) From 815edf3cefaaea0f2fd152b30470d63721380c69 Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:13:40 +0100 Subject: [PATCH 07/31] feat(seo): link first Market Score mention to methodology page Subtask 5/6: Wrap first "padelnomics Market Score" per language section in anchor to /{language}/market-score. Updated templates: - city-cost-de.md.jinja (DE intro + EN intro) - city-pricing.md.jinja (DE comparison + EN comparison) - country-overview.md.jinja (DE intro + EN intro) Creates hub-and-spoke internal linking from hundreds of city articles to the methodology page. Co-Authored-By: Claude Opus 4.6 --- web/src/padelnomics/content/templates/city-cost-de.md.jinja | 4 ++-- web/src/padelnomics/content/templates/city-pricing.md.jinja | 4 ++-- .../padelnomics/content/templates/country-overview.md.jinja | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/src/padelnomics/content/templates/city-cost-de.md.jinja b/web/src/padelnomics/content/templates/city-cost-de.md.jinja index 4efb49b..09ca287 100644 --- a/web/src/padelnomics/content/templates/city-cost-de.md.jinja +++ b/web/src/padelnomics/content/templates/city-cost-de.md.jinja @@ -33,7 +33,7 @@ priority_column: population -{{ city_name }} erreicht einen **padelnomics Market Score von {{ market_score | round(1) }}/100** — damit liegt die Stadt{% if market_score >= 70 %} unter den stärksten Padel-Märkten in {{ country_name_en }}{% elif market_score >= 45 %} im soliden Mittelfeld der Padel-Märkte in {{ country_name_en }}{% else %} in einem frühen Padel-Markt mit Wachstumspotenzial{% endif %}. Aktuell gibt es **{{ padel_venue_count }} Padelanlagen** für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner — das entspricht {{ venues_per_100k | round(1) }} Anlagen pro 100.000 Einwohner. +{{ city_name }} erreicht einen **padelnomics Market Score von {{ market_score | round(1) }}/100** — damit liegt die Stadt{% if market_score >= 70 %} unter den stärksten Padel-Märkten in {{ country_name_en }}{% elif market_score >= 45 %} im soliden Mittelfeld der Padel-Märkte in {{ country_name_en }}{% else %} in einem frühen Padel-Markt mit Wachstumspotenzial{% endif %}. Aktuell gibt es **{{ padel_venue_count }} Padelanlagen** für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner — das entspricht {{ venues_per_100k | round(1) }} Anlagen pro 100.000 Einwohner. Die entscheidende Frage für Investoren: Was bringt ein Padel-Investment bei den aktuellen Preisen, Auslastungsraten und Baukosten tatsächlich? Das Finanzmodell unten rechnet mit echten Marktdaten aus {{ city_name }}. @@ -140,7 +140,7 @@ Der padelnomics Market Score of {{ market_score | round(1) }}/100** — placing it{% if market_score >= 70 %} among the strongest padel markets in {{ country_name_en }}{% elif market_score >= 45 %} in the mid-tier of {{ country_name_en }}'s padel markets{% else %} in an early-stage padel market with room for growth{% endif %}. The city currently has **{{ padel_venue_count }} padel venues** serving a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} residents — a density of {{ venues_per_100k | round(1) }} venues per 100,000 people. +{{ city_name }} has a **padelnomics Market Score of {{ market_score | round(1) }}/100** — placing it{% if market_score >= 70 %} among the strongest padel markets in {{ country_name_en }}{% elif market_score >= 45 %} in the mid-tier of {{ country_name_en }}'s padel markets{% else %} in an early-stage padel market with room for growth{% endif %}. The city currently has **{{ padel_venue_count }} padel venues** serving a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} residents — a density of {{ venues_per_100k | round(1) }} venues per 100,000 people. The question investors actually need answered is: given current pricing, occupancy, and build costs, what does the return look like? The financial model below uses real {{ city_name }} market data to give you that answer. diff --git a/web/src/padelnomics/content/templates/city-pricing.md.jinja b/web/src/padelnomics/content/templates/city-pricing.md.jinja index 51711cf..13b8b03 100644 --- a/web/src/padelnomics/content/templates/city-pricing.md.jinja +++ b/web/src/padelnomics/content/templates/city-pricing.md.jinja @@ -55,7 +55,7 @@ Die Preisspanne von {{ hourly_rate_p25 | round(0) | int }} bis {{ hourly_rate_p7 ## Wie steht {{ city_name }} im Vergleich da? -{{ city_name }} hat {{ padel_venue_count }} Padelanlagen für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner ({{ venues_per_100k | round(1) }} Anlagen pro 100K Einwohner). {% if market_score >= 65 %}Mit einem Market Score von {{ market_score | round(1) }}/100 gehört {{ city_name }} zu den stärksten Padel-Märkten in {{ country_name_en }} — höhere Auslastung und Preise sind typisch für dichte, etablierte Märkte. {% elif market_score >= 40 %}Ein Market Score von {{ market_score | round(1) }}/100 steht für einen Markt im Aufbau: genug Angebot für marktgerechte Preise, aber Raum für neue Anlagen. {% else %}Ein Market Score von {{ market_score | round(1) }}/100 deutet auf einen Markt in der Frühphase hin, in dem sich Preise und Auslastung mit dem Wachstum des Sports noch deutlich entwickeln können. {% endif %} +{{ city_name }} hat {{ padel_venue_count }} Padelanlagen für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner ({{ venues_per_100k | round(1) }} Anlagen pro 100K Einwohner). {% if market_score >= 65 %}Mit einem padelnomics Market Score von {{ market_score | round(1) }}/100 gehört {{ city_name }} zu den stärksten Padel-Märkten in {{ country_name_en }} — höhere Auslastung und Preise sind typisch für dichte, etablierte Märkte. {% elif market_score >= 40 %}Ein Market Score von {{ market_score | round(1) }}/100 steht für einen Markt im Aufbau: genug Angebot für marktgerechte Preise, aber Raum für neue Anlagen. {% else %}Ein Market Score von {{ market_score | round(1) }}/100 deutet auf einen Markt in der Frühphase hin, in dem sich Preise und Auslastung mit dem Wachstum des Sports noch deutlich entwickeln können. {% endif %} Die Anlagendichte von {{ venues_per_100k | round(1) }} pro 100K Einwohner beeinflusst die Preisgestaltung direkt: {% if venues_per_100k >= 3.0 %}Höhere Dichte bedeutet mehr Wettbewerb, was die Preise eher stabilisiert oder senkt.{% elif venues_per_100k >= 1.0 %}Moderate Dichte ermöglicht marktgerechte Preise bei gleichzeitigem Wachstumsspielraum.{% else %}Niedrige Dichte gibt Betreibern mehr Preissetzungsmacht — vorausgesetzt, die Nachfrage ist da.{% endif %} @@ -153,7 +153,7 @@ The P25–P75 price range of {{ hourly_rate_p25 | round(0) | int }} to {{ hourly ## How Does {{ city_name }} Compare? -{{ city_name }} has {{ padel_venue_count }} padel venues for a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} ({{ venues_per_100k | round(1) }} venues per 100K residents). {% if market_score >= 65 %}With a market score of {{ market_score | round(1) }}/100, {{ city_name }} is one of the stronger padel markets in {{ country_name_en }} — higher occupancy and pricing typically follow dense, competitive markets. {% elif market_score >= 40 %}A market score of {{ market_score | round(1) }}/100 reflects a mid-tier market: enough supply to have competitive pricing, but room for new venues to grow. {% else %}A market score of {{ market_score | round(1) }}/100 indicates an early-stage market where pricing and occupancy benchmarks may shift as the sport grows. {% endif %} +{{ city_name }} has {{ padel_venue_count }} padel venues for a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} ({{ venues_per_100k | round(1) }} venues per 100K residents). {% if market_score >= 65 %}With a padelnomics Market Score of {{ market_score | round(1) }}/100, {{ city_name }} is one of the stronger padel markets in {{ country_name_en }} — higher occupancy and pricing typically follow dense, competitive markets. {% elif market_score >= 40 %}A market score of {{ market_score | round(1) }}/100 reflects a mid-tier market: enough supply to have competitive pricing, but room for new venues to grow. {% else %}A market score of {{ market_score | round(1) }}/100 indicates an early-stage market where pricing and occupancy benchmarks may shift as the sport grows. {% endif %} Venue density of {{ venues_per_100k | round(1) }} per 100K residents directly influences pricing: {% if venues_per_100k >= 3.0 %}higher density means more competition, which tends to stabilize or compress prices.{% elif venues_per_100k >= 1.0 %}moderate density supports market-rate pricing with room for growth.{% else %}low density gives operators more pricing power — provided demand exists.{% endif %} diff --git a/web/src/padelnomics/content/templates/country-overview.md.jinja b/web/src/padelnomics/content/templates/country-overview.md.jinja index 86b6bcf..d907a39 100644 --- a/web/src/padelnomics/content/templates/country-overview.md.jinja +++ b/web/src/padelnomics/content/templates/country-overview.md.jinja @@ -34,7 +34,7 @@ priority_column: total_venues -In {{ country_name_en }} erfassen wir aktuell **{{ total_venues }} Padelanlagen** in **{{ city_count }} Städten**. Der durchschnittliche padelnomics Market Score liegt bei **{{ avg_market_score }}/100**{% if avg_market_score >= 65 %} — ein starker Markt mit breiter Infrastruktur und belastbaren Preisdaten{% elif avg_market_score >= 40 %} — ein wachsender Markt mit guter Abdeckung{% else %} — ein aufstrebender Markt, in dem Früheinsteiger noch Premiumstandorte sichern können{% endif %}. +In {{ country_name_en }} erfassen wir aktuell **{{ total_venues }} Padelanlagen** in **{{ city_count }} Städten**. Der durchschnittliche padelnomics Market Score liegt bei **{{ avg_market_score }}/100**{% if avg_market_score >= 65 %} — ein starker Markt mit breiter Infrastruktur und belastbaren Preisdaten{% elif avg_market_score >= 40 %} — ein wachsender Markt mit guter Abdeckung{% else %} — ein aufstrebender Markt, in dem Früheinsteiger noch Premiumstandorte sichern können{% endif %}. ## Marktlandschaft @@ -124,7 +124,7 @@ Städte mit höherem padelnomics Market Score across tracked cities is **{{ avg_market_score }}/100**{% if avg_market_score >= 65 %} — a strong market with widespread venue penetration and solid pricing data{% elif avg_market_score >= 40 %} — a growing market with healthy city coverage{% else %} — an emerging market where early entrants can still capture prime locations{% endif %}. +{{ country_name_en }} has **{{ total_venues }} padel venues** tracked across **{{ city_count }} cities**. The average padelnomics Market Score across tracked cities is **{{ avg_market_score }}/100**{% if avg_market_score >= 65 %} — a strong market with widespread venue penetration and solid pricing data{% elif avg_market_score >= 40 %} — a growing market with healthy city coverage{% else %} — an emerging market where early entrants can still capture prime locations{% endif %}. ## Market Landscape From 4033e13e0595009f1b4858a74d8067ad73e90db7 Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:14:21 +0100 Subject: [PATCH 08/31] feat(admin): live-poll Articles and Scenarios tabs during generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _is_generating() helper — queries tasks table for pending generate_articles tasks - Pass is_generating to article_results partial (both full page and HTMX route) - article_results.html: render invisible hx-trigger="every 3s" div when generating; polling stops naturally once generation completes and div is absent - Add /admin/scenarios/results HTMX partial route with same is_generating logic - Extract scenario table into admin/partials/scenario_results.html partial - scenarios.html: wrap table in #scenario-results div, include partial Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/admin/routes.py | 50 ++++++++++++++++++- .../admin/partials/article_results.html | 7 +++ .../admin/partials/scenario_results.html | 44 ++++++++++++++++ .../admin/templates/admin/scenarios.html | 40 ++------------- 4 files changed, 103 insertions(+), 38 deletions(-) create mode 100644 web/src/padelnomics/admin/templates/admin/partials/scenario_results.html diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 1657fe7..c20a49f 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -1434,6 +1434,42 @@ async def scenarios(): current_search=search, current_country=country_filter, current_venue_type=venue_filter, + is_generating=await _is_generating(), + ) + + +@bp.route("/scenarios/results") +@role_required("admin") +async def scenario_results(): + """HTMX partial for scenario results (used by live polling).""" + search = request.args.get("search", "").strip() + country_filter = request.args.get("country", "") + venue_filter = request.args.get("venue_type", "") + + wheres = ["1=1"] + params: list = [] + if search: + wheres.append("(title LIKE ? OR location LIKE ? OR slug LIKE ?)") + params.extend([f"%{search}%", f"%{search}%", f"%{search}%"]) + if country_filter: + wheres.append("country = ?") + params.append(country_filter) + if venue_filter: + wheres.append("venue_type = ?") + params.append(venue_filter) + + where = " AND ".join(wheres) + scenario_list = await fetch_all( + f"SELECT * FROM published_scenarios WHERE {where} ORDER BY created_at DESC LIMIT 500", + tuple(params), + ) + total = await fetch_one("SELECT COUNT(*) as cnt FROM published_scenarios") + + return await render_template( + "admin/partials/scenario_results.html", + scenarios=scenario_list, + total=total["cnt"] if total else 0, + is_generating=await _is_generating(), ) @@ -1683,6 +1719,14 @@ async def _get_article_stats() -> dict: return dict(row) if row else {"total": 0, "live": 0, "scheduled": 0, "draft": 0} +async def _is_generating() -> bool: + """Return True if a generate_articles task is currently pending.""" + row = await fetch_one( + "SELECT COUNT(*) AS cnt FROM tasks WHERE task_type = 'generate_articles' AND status = 'pending'" + ) + return bool(row and row["cnt"] > 0) + + @bp.route("/articles") @role_required("admin") async def articles(): @@ -1712,6 +1756,7 @@ async def articles(): current_template=template_filter, current_language=language_filter, page=page, + is_generating=await _is_generating(), ) @@ -1730,7 +1775,10 @@ async def article_results(): language=language_filter or None, search=search or None, page=page, ) return await render_template( - "admin/partials/article_results.html", articles=article_list, page=page, + "admin/partials/article_results.html", + articles=article_list, + page=page, + is_generating=await _is_generating(), ) diff --git a/web/src/padelnomics/admin/templates/admin/partials/article_results.html b/web/src/padelnomics/admin/templates/admin/partials/article_results.html index 2d95b07..d0ef5d8 100644 --- a/web/src/padelnomics/admin/templates/admin/partials/article_results.html +++ b/web/src/padelnomics/admin/templates/admin/partials/article_results.html @@ -1,3 +1,10 @@ +{% if is_generating %} + +{% endif %} {% if articles %}
    diff --git a/web/src/padelnomics/admin/templates/admin/partials/scenario_results.html b/web/src/padelnomics/admin/templates/admin/partials/scenario_results.html new file mode 100644 index 0000000..93dd343 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/partials/scenario_results.html @@ -0,0 +1,44 @@ +{% if is_generating %} + +{% endif %} +{% if scenarios %} +
    + + + + + + + + + + + + {% for s in scenarios %} + + + + + + + + + {% endfor %} + +
    TitleSlugLocationConfigCreated
    {{ s.title }}{{ s.slug }}{{ s.location }}, {{ s.country }}{{ s.venue_type | capitalize }} · {{ s.court_config }}{{ s.created_at[:10] }} + Preview + PDF EN + PDF DE + Edit +
    + + +
    +
    +{% else %} +

    No scenarios match the current filters.

    +{% endif %} diff --git a/web/src/padelnomics/admin/templates/admin/scenarios.html b/web/src/padelnomics/admin/templates/admin/scenarios.html index c32270e..6d993fb 100644 --- a/web/src/padelnomics/admin/templates/admin/scenarios.html +++ b/web/src/padelnomics/admin/templates/admin/scenarios.html @@ -51,42 +51,8 @@
    - {% if scenarios %} - - - - - - - - - - - - - {% for s in scenarios %} - - - - - - - - - {% endfor %} - -
    TitleSlugLocationConfigCreated
    {{ s.title }}{{ s.slug }}{{ s.location }}, {{ s.country }}{{ s.venue_type | capitalize }} · {{ s.court_config }}{{ s.created_at[:10] }} - Preview - PDF EN - PDF DE - Edit -
    - - -
    -
    - {% else %} -

    No scenarios match the current filters.

    - {% endif %} +
    + {% include "admin/partials/scenario_results.html" %} +
    {% endblock %} From b7485902e65f1e6d80164ddc708884b047a390b0 Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:15:36 +0100 Subject: [PATCH 09/31] test: add Market Score methodology page tests Subtask 6/6: 8 tests covering EN/DE 200 status, legacy 301 redirect, JSON-LD schema types, FAQ sections, OG tags, footer link. Add footer_market_score to i18n parity allowlist (branded term). Co-Authored-By: Claude Opus 4.6 --- web/tests/test_i18n_parity.py | 2 ++ web/tests/test_market_score.py | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 web/tests/test_market_score.py diff --git a/web/tests/test_i18n_parity.py b/web/tests/test_i18n_parity.py index 2c6b466..62a28ab 100644 --- a/web/tests/test_i18n_parity.py +++ b/web/tests/test_i18n_parity.py @@ -57,6 +57,8 @@ _IDENTICAL_VALUE_ALLOWLIST = { # Business plan — Indoor/Outdoor same in DE, financial abbreviations "bp_indoor", "bp_outdoor", "bp_lbl_ebitda", "bp_lbl_irr", "bp_lbl_moic", "bp_lbl_opex", + # Market Score — branded term kept in English in DE + "footer_market_score", } diff --git a/web/tests/test_market_score.py b/web/tests/test_market_score.py new file mode 100644 index 0000000..2adf335 --- /dev/null +++ b/web/tests/test_market_score.py @@ -0,0 +1,59 @@ +"""Tests for the Market Score methodology page.""" + + +async def test_en_returns_200(client): + resp = await client.get("/en/market-score") + assert resp.status_code == 200 + text = await resp.get_data(as_text=True) + assert "Market Score" in text + assert "padelnomics" in text + + +async def test_de_returns_200(client): + resp = await client.get("/de/market-score") + assert resp.status_code == 200 + text = await resp.get_data(as_text=True) + assert "Market Score" in text + assert "padelnomics" in text + + +async def test_legacy_redirect(client): + resp = await client.get("/market-score") + assert resp.status_code == 301 + assert resp.headers["Location"].endswith("/en/market-score") + + +async def test_contains_jsonld(client): + resp = await client.get("/en/market-score") + text = await resp.get_data(as_text=True) + assert '"@type": "WebPage"' in text + assert '"@type": "FAQPage"' in text + assert '"@type": "BreadcrumbList"' in text + + +async def test_contains_faq_section(client): + resp = await client.get("/en/market-score") + text = await resp.get_data(as_text=True) + assert "Frequently Asked Questions" in text + assert " Date: Tue, 24 Feb 2026 10:16:15 +0100 Subject: [PATCH 10/31] docs: update CHANGELOG and PROJECT.md with Market Score page Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 10 ++++++++++ PROJECT.md | 1 + 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0031cb..6c69918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- **Market Score methodology page** — standalone page at `/{lang}/market-score` + explaining the padelnomics Market Score (Zillow Zestimate-style). Reveals four + input categories (demographics, economic strength, demand evidence, data + completeness) and score band interpretations without exposing weights or + formulas. Full JSON-LD (WebPage + FAQPage + BreadcrumbList), OG tags, and + bilingual content (EN professional, DE Du-form). Added to sitemap and footer. + First "padelnomics Market Score" mention in each article template now links + to the methodology page (hub-and-spoke internal linking). + ### Fixed - **Double language prefix in article URLs** — articles were served at `/en/en/markets/italy` (double prefix) because `generate_articles()` stored diff --git a/PROJECT.md b/PROJECT.md index 40e6064..957e102 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -118,6 +118,7 @@ - [x] Cookie consent banner (functional/A/B categories, 1-year cookie) - [x] Virtual office address on imprint - [x] SEO/GEO admin hub — GSC + Bing + Umami sync, search/funnel/scorecard views, daily background sync +- [x] Market Score methodology page (`/{lang}/market-score`) — Zillow-style explanation of the padelnomics Market Score; EN + DE; JSON-LD (WebPage + FAQPage + BreadcrumbList); hub-and-spoke internal linking from all article templates ### Testing - [x] Playwright visual/E2E test suite — 77 tests across 3 files (visual, e2e flows, quote wizard); single session-scoped server + browser; mocked emails + waitlist mode; ~59s runtime From 5644a1ebf8a20f0f8c333d5deb88adb26af5ec7f Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:22:42 +0100 Subject: [PATCH 11/31] fix: replace datetime.utcnow() with utcnow()/utcnow_iso() across all source files Migrates 15 source files from the deprecated datetime.utcnow() API. Uses utcnow() for in-memory math and utcnow_iso() (strftime format) for SQLite TEXT column writes to preserve lexicographic sort order. Also fixes datetime.utcfromtimestamp() in seo/_bing.py. Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/admin/routes.py | 26 +++++++------- web/src/padelnomics/app.py | 4 +-- web/src/padelnomics/auth/routes.py | 14 ++++---- web/src/padelnomics/billing/routes.py | 20 +++++------ web/src/padelnomics/content/__init__.py | 4 +-- web/src/padelnomics/credits.py | 12 +++---- web/src/padelnomics/dashboard/routes.py | 5 ++- web/src/padelnomics/directory/routes.py | 5 ++- web/src/padelnomics/leads/routes.py | 9 ++--- web/src/padelnomics/planner/routes.py | 6 ++-- web/src/padelnomics/scripts/seed_content.py | 4 +-- web/src/padelnomics/scripts/seed_dev_data.py | 18 +++++----- web/src/padelnomics/seo/_bing.py | 20 +++++------ web/src/padelnomics/seo/_gsc.py | 16 ++++----- web/src/padelnomics/seo/_queries.py | 6 ++-- web/src/padelnomics/seo/_umami.py | 14 ++++---- web/src/padelnomics/webhooks.py | 8 ++--- web/src/padelnomics/worker.py | 38 ++++++++++++-------- 18 files changed, 116 insertions(+), 113 deletions(-) diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 89336cc..47913d6 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -29,6 +29,8 @@ from ..core import ( fetch_one, send_email, slugify, + utcnow, + utcnow_iso, ) # Blueprint with its own template folder @@ -64,9 +66,9 @@ def _admin_context(): async def get_dashboard_stats() -> dict: """Get admin dashboard statistics.""" - now = datetime.utcnow() + now = utcnow() today = now.date().isoformat() - week_ago = (now - timedelta(days=7)).isoformat() + week_ago = (now - timedelta(days=7)).strftime("%Y-%m-%dT%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", @@ -211,7 +213,7 @@ async def retry_task(task_id: int) -> bool: SET status = 'pending', run_at = ?, error = NULL WHERE id = ? AND status = 'failed' """, - (datetime.utcnow().isoformat(), task_id) + (utcnow_iso(), task_id) ) return result > 0 @@ -522,7 +524,7 @@ async def lead_new(): from ..credits import HEAT_CREDIT_COSTS credit_cost = HEAT_CREDIT_COSTS.get(heat_score, 8) - now = datetime.utcnow().isoformat() + now = utcnow_iso() verified_at = now if status != "pending_verification" else None lead_id = await execute( @@ -567,7 +569,7 @@ async def lead_forward(lead_id: int): await flash("Already forwarded to this supplier.", "warning") return redirect(url_for("admin.lead_detail", lead_id=lead_id)) - now = datetime.utcnow().isoformat() + now = utcnow_iso() await execute( """INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, status, created_at) VALUES (?, ?, 0, 'sent', ?)""", @@ -771,7 +773,7 @@ async def supplier_new(): instagram_url = form.get("instagram_url", "").strip() youtube_url = form.get("youtube_url", "").strip() - now = datetime.utcnow().isoformat() + now = utcnow_iso() supplier_id = await execute( """INSERT INTO suppliers (name, slug, country_code, city, region, website, description, category, @@ -865,7 +867,7 @@ async def flag_toggle(): return redirect(url_for("admin.flags")) new_enabled = 0 if row["enabled"] else 1 - now = datetime.utcnow().isoformat() + now = utcnow_iso() await execute( "UPDATE feature_flags SET enabled = ?, updated_at = ? WHERE name = ?", (new_enabled, now, flag_name), @@ -940,7 +942,7 @@ async def get_email_stats() -> dict: 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 = datetime.utcnow().date().isoformat() + today = utcnow().date().isoformat() sent_today = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE created_at >= ?", (today,)) return { "total": total["cnt"] if total else 0, @@ -1487,7 +1489,7 @@ async def scenario_edit(scenario_id: int): dbl = state.get("dblCourts", 0) sgl = state.get("sglCourts", 0) court_config = f"{dbl} double + {sgl} single" - now = datetime.utcnow().isoformat() + now = utcnow_iso() await execute( """UPDATE published_scenarios @@ -1740,7 +1742,7 @@ async def article_new(): md_dir.mkdir(parents=True, exist_ok=True) (md_dir / f"{article_slug}.md").write_text(body) - pub_dt = published_at or datetime.utcnow().isoformat() + pub_dt = published_at or utcnow_iso() await execute( """INSERT INTO articles @@ -1800,7 +1802,7 @@ async def article_edit(article_id: int): md_dir.mkdir(parents=True, exist_ok=True) (md_dir / f"{article['slug']}.md").write_text(body) - now = datetime.utcnow().isoformat() + now = utcnow_iso() pub_dt = published_at or article["published_at"] await execute( @@ -1867,7 +1869,7 @@ async def article_publish(article_id: int): return redirect(url_for("admin.articles")) new_status = "published" if article["status"] == "draft" else "draft" - now = datetime.utcnow().isoformat() + now = utcnow_iso() await execute( "UPDATE articles SET status = ?, updated_at = ? WHERE id = ?", (new_status, now, article_id), diff --git a/web/src/padelnomics/app.py b/web/src/padelnomics/app.py index 75c9f78..93f57ce 100644 --- a/web/src/padelnomics/app.py +++ b/web/src/padelnomics/app.py @@ -208,7 +208,7 @@ def create_app() -> Quart: @app.context_processor def inject_globals(): - from datetime import datetime + from .core import utcnow as _utcnow lang = g.get("lang") or _detect_lang() g.lang = lang # ensure g.lang is always set (e.g. for dashboard/billing routes) effective_lang = lang if lang in SUPPORTED_LANGS else "en" @@ -217,7 +217,7 @@ def create_app() -> Quart: "user": g.get("user"), "subscription": g.get("subscription"), "is_admin": "admin" in (g.get("user") or {}).get("roles", []), - "now": datetime.utcnow(), + "now": _utcnow(), "csrf_token": get_csrf_token, "ab_variant": getattr(g, "ab_variant", None), "ab_tag": getattr(g, "ab_tag", None), diff --git a/web/src/padelnomics/auth/routes.py b/web/src/padelnomics/auth/routes.py index 12a30d0..908ca9d 100644 --- a/web/src/padelnomics/auth/routes.py +++ b/web/src/padelnomics/auth/routes.py @@ -18,6 +18,8 @@ from ..core import ( fetch_one, is_disposable_email, is_flag_enabled, + utcnow, + utcnow_iso, ) from ..i18n import SUPPORTED_LANGS, get_translations @@ -64,7 +66,7 @@ async def get_user_by_email(email: str) -> dict | None: async def create_user(email: str) -> int: """Create new user, return ID.""" - now = datetime.utcnow().isoformat() + now = utcnow_iso() return await execute( "INSERT INTO users (email, created_at) VALUES (?, ?)", (email.lower(), now) ) @@ -82,10 +84,10 @@ async def update_user(user_id: int, **fields) -> None: async def create_auth_token(user_id: int, token: str, minutes: int = None) -> int: """Create auth token for user.""" minutes = minutes or config.MAGIC_LINK_EXPIRY_MINUTES - expires = datetime.utcnow() + timedelta(minutes=minutes) + expires = utcnow() + timedelta(minutes=minutes) return await execute( "INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)", - (user_id, token, expires.isoformat()), + (user_id, token, expires.strftime("%Y-%m-%dT%H:%M:%S")), ) @@ -98,14 +100,14 @@ async def get_valid_token(token: str) -> dict | None: JOIN users u ON u.id = at.user_id WHERE at.token = ? AND at.expires_at > ? AND at.used_at IS NULL """, - (token, datetime.utcnow().isoformat()), + (token, utcnow_iso()), ) async def mark_token_used(token_id: int) -> None: """Mark token as used.""" await execute( - "UPDATE auth_tokens SET used_at = ? WHERE id = ?", (datetime.utcnow().isoformat(), token_id) + "UPDATE auth_tokens SET used_at = ? WHERE id = ?", (utcnow_iso(), token_id) ) @@ -331,7 +333,7 @@ async def verify(): await mark_token_used(token_data["id"]) # Update last login - await update_user(token_data["user_id"], last_login_at=datetime.utcnow().isoformat()) + await update_user(token_data["user_id"], last_login_at=utcnow_iso()) # Set session session.permanent = True diff --git a/web/src/padelnomics/billing/routes.py b/web/src/padelnomics/billing/routes.py index b22bdd2..c1c521d 100644 --- a/web/src/padelnomics/billing/routes.py +++ b/web/src/padelnomics/billing/routes.py @@ -5,7 +5,7 @@ Payment provider: paddle import json import secrets -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from paddle_billing import Client as PaddleClient @@ -14,7 +14,7 @@ from paddle_billing.Notifications import Secret, Verifier from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for from ..auth.routes import login_required -from ..core import config, execute, fetch_one, get_paddle_price +from ..core import config, execute, fetch_one, get_paddle_price, utcnow, utcnow_iso from ..i18n import get_translations @@ -69,7 +69,7 @@ async def upsert_subscription( current_period_end: str = None, ) -> int: """Create or update subscription. Finds existing by provider_subscription_id.""" - now = datetime.utcnow().isoformat() + now = utcnow_iso() existing = await fetch_one( "SELECT id FROM subscriptions WHERE provider_subscription_id = ?", @@ -104,7 +104,7 @@ async def get_subscription_by_provider_id(subscription_id: str) -> dict | None: async def update_subscription_status(provider_subscription_id: str, status: str, **extra) -> None: """Update subscription status by provider subscription ID.""" - extra["updated_at"] = datetime.utcnow().isoformat() + extra["updated_at"] = utcnow_iso() extra["status"] = status sets = ", ".join(f"{k} = ?" for k in extra) values = list(extra.values()) @@ -343,7 +343,7 @@ async def _handle_supplier_subscription_activated(data: dict, custom_data: dict) base_plan, tier = _derive_tier_from_plan(plan) monthly_credits = PLAN_MONTHLY_CREDITS.get(base_plan, 0) - now = datetime.utcnow().isoformat() + now = utcnow_iso() async with db_transaction() as db: # Update supplier record — Basic tier also gets is_verified = 1 @@ -392,7 +392,7 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None: """Handle one-time transaction completion (credit packs, sticky boosts, business plan).""" supplier_id = custom_data.get("supplier_id") user_id = custom_data.get("user_id") - now = datetime.utcnow().isoformat() + now = utcnow_iso() items = data.get("items", []) for item in items: @@ -412,10 +412,8 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None: # Sticky boost purchases elif key == "boost_sticky_week" and supplier_id: - from datetime import timedelta - from ..core import transaction as db_transaction - expires = (datetime.utcnow() + timedelta(weeks=1)).isoformat() + expires = (utcnow() + timedelta(weeks=1)).strftime("%Y-%m-%dT%H:%M:%S") country = custom_data.get("sticky_country", "") async with db_transaction() as db: await db.execute( @@ -430,10 +428,8 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None: ) elif key == "boost_sticky_month" and supplier_id: - from datetime import timedelta - from ..core import transaction as db_transaction - expires = (datetime.utcnow() + timedelta(days=30)).isoformat() + expires = (utcnow() + timedelta(days=30)).strftime("%Y-%m-%dT%H:%M:%S") country = custom_data.get("sticky_country", "") async with db_transaction() as db: await db.execute( diff --git a/web/src/padelnomics/content/__init__.py b/web/src/padelnomics/content/__init__.py index 70395e4..618679b 100644 --- a/web/src/padelnomics/content/__init__.py +++ b/web/src/padelnomics/content/__init__.py @@ -15,7 +15,7 @@ import yaml from jinja2 import ChainableUndefined, Environment from ..analytics import fetch_analytics -from ..core import execute, fetch_one, slugify +from ..core import execute, fetch_one, slugify, utcnow_iso # ── Constants ──────────────────────────────────────────────────────────────── @@ -301,7 +301,7 @@ async def generate_articles( publish_date = start_date published_today = 0 generated = 0 - now_iso = datetime.now(UTC).isoformat() + now_iso = utcnow_iso() for row in rows: for lang in config["languages"]: diff --git a/web/src/padelnomics/credits.py b/web/src/padelnomics/credits.py index ac58f37..77943f1 100644 --- a/web/src/padelnomics/credits.py +++ b/web/src/padelnomics/credits.py @@ -5,9 +5,7 @@ All balance mutations go through this module to keep credit_ledger (source of tr and suppliers.credit_balance (denormalized cache) in sync within a single transaction. """ -from datetime import datetime - -from .core import execute, fetch_all, fetch_one, transaction +from .core import execute, fetch_all, fetch_one, transaction, utcnow_iso # Credit cost per heat tier HEAT_CREDIT_COSTS = {"hot": 35, "warm": 20, "cool": 8} @@ -44,7 +42,7 @@ async def add_credits( note: str = None, ) -> int: """Add credits to a supplier. Returns new balance.""" - now = datetime.utcnow().isoformat() + now = utcnow_iso() async with transaction() as db: row = await db.execute_fetchall( "SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,) @@ -73,7 +71,7 @@ async def spend_credits( note: str = None, ) -> int: """Spend credits from a supplier. Returns new balance. Raises InsufficientCredits.""" - now = datetime.utcnow().isoformat() + now = utcnow_iso() async with transaction() as db: row = await db.execute_fetchall( "SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,) @@ -116,7 +114,7 @@ async def unlock_lead(supplier_id: int, lead_id: int) -> dict: raise ValueError("Lead not found") cost = lead["credit_cost"] or compute_credit_cost(lead) - now = datetime.utcnow().isoformat() + now = utcnow_iso() async with transaction() as db: # Check balance @@ -180,7 +178,7 @@ async def monthly_credit_refill(supplier_id: int) -> int: if not row or not row["monthly_credits"]: return 0 - now = datetime.utcnow().isoformat() + now = utcnow_iso() new_balance = await add_credits( supplier_id, row["monthly_credits"], diff --git a/web/src/padelnomics/dashboard/routes.py b/web/src/padelnomics/dashboard/routes.py index 89fd001..6afb375 100644 --- a/web/src/padelnomics/dashboard/routes.py +++ b/web/src/padelnomics/dashboard/routes.py @@ -1,13 +1,12 @@ """ Dashboard domain: user dashboard and settings. """ -from datetime import datetime from pathlib import Path from quart import Blueprint, flash, g, redirect, render_template, request, url_for from ..auth.routes import login_required, update_user -from ..core import csrf_protect, fetch_one, soft_delete +from ..core import csrf_protect, fetch_one, soft_delete, utcnow_iso from ..i18n import get_translations bp = Blueprint( @@ -57,7 +56,7 @@ async def settings(): await update_user( g.user["id"], name=form.get("name", "").strip() or None, - updated_at=datetime.utcnow().isoformat(), + updated_at=utcnow_iso(), ) t = get_translations(g.get("lang") or "en") await flash(t["dash_settings_saved"], "success") diff --git a/web/src/padelnomics/directory/routes.py b/web/src/padelnomics/directory/routes.py index f0d3f59..00caa44 100644 --- a/web/src/padelnomics/directory/routes.py +++ b/web/src/padelnomics/directory/routes.py @@ -2,12 +2,11 @@ Supplier directory: public, searchable listing of padel court suppliers. """ -from datetime import UTC, datetime from pathlib import Path from quart import Blueprint, g, make_response, redirect, render_template, request, url_for -from ..core import csrf_protect, execute, fetch_all, fetch_one +from ..core import csrf_protect, execute, fetch_all, fetch_one, utcnow_iso from ..i18n import get_translations bp = Blueprint( @@ -89,7 +88,7 @@ async def _build_directory_query(q, country, category, region, page, per_page=24 lang = g.get("lang", "en") cat_labels, country_labels, region_labels = get_directory_labels(lang) - now = datetime.now(UTC).isoformat() + now = utcnow_iso() params: list = [] wheres: list[str] = [] diff --git a/web/src/padelnomics/leads/routes.py b/web/src/padelnomics/leads/routes.py index ba73d8c..a814d8e 100644 --- a/web/src/padelnomics/leads/routes.py +++ b/web/src/padelnomics/leads/routes.py @@ -27,6 +27,7 @@ from ..core import ( is_disposable_email, is_plausible_phone, send_email, + utcnow_iso, ) from ..i18n import get_translations @@ -102,7 +103,7 @@ async def suppliers(): form.get("court_count", 0), form.get("budget", 0), form.get("message", ""), - datetime.utcnow().isoformat(), + utcnow_iso(), ), ) # Notify admin @@ -147,7 +148,7 @@ async def financing(): form.get("court_count", 0), form.get("budget", 0), form.get("message", ""), - datetime.utcnow().isoformat(), + utcnow_iso(), ), ) await send_email( @@ -375,7 +376,7 @@ async def quote_request(): status, credit_cost, secrets.token_urlsafe(16), - datetime.utcnow().isoformat(), + utcnow_iso(), ), ) @@ -520,7 +521,7 @@ async def verify_quote(): from ..credits import compute_credit_cost credit_cost = compute_credit_cost(dict(lead)) - now = datetime.utcnow().isoformat() + now = utcnow_iso() await execute( "UPDATE lead_requests SET status = 'new', verified_at = ?, credit_cost = ? WHERE id = ?", (now, credit_cost, lead["id"]), diff --git a/web/src/padelnomics/planner/routes.py b/web/src/padelnomics/planner/routes.py index b5f6247..4a076e5 100644 --- a/web/src/padelnomics/planner/routes.py +++ b/web/src/padelnomics/planner/routes.py @@ -4,7 +4,6 @@ Planner domain: padel court financial planner + scenario management. import json import math -from datetime import datetime from pathlib import Path from quart import Blueprint, Response, g, jsonify, render_template, request @@ -18,6 +17,7 @@ from ..core import ( fetch_all, fetch_one, get_paddle_price, + utcnow_iso, ) from ..i18n import get_translations from .calculator import COUNTRY_CURRENCY, CURRENCY_DEFAULT, calc, validate_state @@ -502,7 +502,7 @@ async def save_scenario(): location = form.get("location", "") scenario_id = form.get("scenario_id") - now = datetime.utcnow().isoformat() + now = utcnow_iso() is_first_save = not scenario_id and (await count_scenarios(g.user["id"])) == 0 @@ -563,7 +563,7 @@ async def get_scenario(scenario_id: int): @login_required @csrf_protect async def delete_scenario(scenario_id: int): - now = datetime.utcnow().isoformat() + now = utcnow_iso() await execute( "UPDATE scenarios SET deleted_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL", (now, scenario_id, g.user["id"]), diff --git a/web/src/padelnomics/scripts/seed_content.py b/web/src/padelnomics/scripts/seed_content.py index 23ee2f7..2b3090d 100644 --- a/web/src/padelnomics/scripts/seed_content.py +++ b/web/src/padelnomics/scripts/seed_content.py @@ -18,7 +18,7 @@ import json import os import sqlite3 import sys -from datetime import date, timedelta +from datetime import UTC, date, datetime, timedelta from pathlib import Path from dotenv import load_dotenv @@ -1390,7 +1390,7 @@ def seed_templates(conn: sqlite3.Connection) -> dict[str, int]: def seed_data_rows(conn: sqlite3.Connection, template_ids: dict[str, int]) -> int: """Insert template_data rows for all cities × languages. Returns count inserted.""" - now = __import__("datetime").datetime.utcnow().isoformat() + now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") inserted = 0 en_id = template_ids.get("city-padel-cost-en") diff --git a/web/src/padelnomics/scripts/seed_dev_data.py b/web/src/padelnomics/scripts/seed_dev_data.py index 96dfe37..69b2830 100644 --- a/web/src/padelnomics/scripts/seed_dev_data.py +++ b/web/src/padelnomics/scripts/seed_dev_data.py @@ -10,7 +10,7 @@ Usage: import os import sqlite3 import sys -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from pathlib import Path from dotenv import load_dotenv @@ -292,7 +292,7 @@ def main(): conn.execute("PRAGMA foreign_keys=ON") conn.row_factory = sqlite3.Row - now = datetime.utcnow() + now = datetime.now(UTC) # 1. Create dev user print("Creating dev user (dev@localhost)...") @@ -303,7 +303,7 @@ def main(): else: cursor = conn.execute( "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", - ("dev@localhost", "Dev User", now.isoformat()), + ("dev@localhost", "Dev User", now.strftime("%Y-%m-%dT%H:%M:%S")), ) dev_user_id = cursor.lastrowid print(f" Created (id={dev_user_id})") @@ -336,7 +336,7 @@ def main(): s["website"], s["description"], s["category"], s["tier"], s["credit_balance"], s["monthly_credits"], s["contact_name"], s["contact_email"], s["years_in_business"], s["project_count"], - s["service_area"], now.isoformat(), + s["service_area"], now.strftime("%Y-%m-%dT%H:%M:%S"), ), ) supplier_ids[s["slug"]] = cursor.lastrowid @@ -349,7 +349,7 @@ def main(): ("courtbuild-spain", "supplier_growth", "maria@courtbuild.example.com", "Maria Garcia"), ("desert-padel-fze", "supplier_pro", "ahmed@desertpadel.example.com", "Ahmed Al-Rashid"), ] - period_end = (now + timedelta(days=30)).isoformat() + period_end = (now + timedelta(days=30)).strftime("%Y-%m-%dT%H:%M:%S") for slug, plan, email, name in claimed_suppliers: sid = supplier_ids.get(slug) if not sid: @@ -364,14 +364,14 @@ def main(): else: cursor = conn.execute( "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", - (email, name, now.isoformat()), + (email, name, now.strftime("%Y-%m-%dT%H:%M:%S")), ) owner_id = cursor.lastrowid # Claim the supplier conn.execute( "UPDATE suppliers SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL", - (owner_id, now.isoformat(), sid), + (owner_id, now.strftime("%Y-%m-%dT%H:%M:%S"), sid), ) # Create billing customer record @@ -382,7 +382,7 @@ def main(): conn.execute( """INSERT INTO billing_customers (user_id, provider_customer_id, created_at) VALUES (?, ?, ?)""", - (owner_id, f"ctm_dev_{slug}", now.isoformat()), + (owner_id, f"ctm_dev_{slug}", now.strftime("%Y-%m-%dT%H:%M:%S")), ) # Create active subscription @@ -396,7 +396,7 @@ def main(): current_period_end, created_at) VALUES (?, ?, 'active', ?, ?, ?)""", (owner_id, plan, f"sub_dev_{slug}", - period_end, now.isoformat()), + period_end, now.strftime("%Y-%m-%dT%H:%M:%S")), ) print(f" {slug} -> owner {email} ({plan})") diff --git a/web/src/padelnomics/seo/_bing.py b/web/src/padelnomics/seo/_bing.py index 5a76446..9a5cb74 100644 --- a/web/src/padelnomics/seo/_bing.py +++ b/web/src/padelnomics/seo/_bing.py @@ -3,12 +3,12 @@ Uses an API key for auth. Fetches query stats and page stats. """ -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from urllib.parse import urlparse import httpx -from ..core import config, execute +from ..core import config, execute, utcnow, utcnow_iso _TIMEOUT_SECONDS = 30 @@ -27,7 +27,7 @@ async def sync_bing(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS) if not config.BING_WEBMASTER_API_KEY or not config.BING_SITE_URL: return 0 # Bing not configured — skip silently - started_at = datetime.utcnow() + started_at = utcnow() try: rows_synced = 0 @@ -48,14 +48,14 @@ async def sync_bing(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS) if not isinstance(entries, list): entries = [] - cutoff = datetime.utcnow() - timedelta(days=days_back) + cutoff = utcnow() - timedelta(days=days_back) for entry in entries: # Bing date format: "/Date(1708905600000)/" (ms since epoch) date_str = entry.get("Date", "") if "/Date(" in date_str: ms = int(date_str.split("(")[1].split(")")[0]) - entry_date = datetime.utcfromtimestamp(ms / 1000) + entry_date = datetime.fromtimestamp(ms / 1000, tz=UTC) else: continue @@ -99,7 +99,7 @@ async def sync_bing(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS) date_str = entry.get("Date", "") if "/Date(" in date_str: ms = int(date_str.split("(")[1].split(")")[0]) - entry_date = datetime.utcfromtimestamp(ms / 1000) + entry_date = datetime.fromtimestamp(ms / 1000, tz=UTC) else: continue @@ -122,21 +122,21 @@ async def sync_bing(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS) ) rows_synced += 1 - duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000) + duration_ms = int((utcnow() - started_at).total_seconds() * 1000) await execute( """INSERT INTO seo_sync_log (source, status, rows_synced, started_at, completed_at, duration_ms) VALUES ('bing', 'success', ?, ?, ?, ?)""", - (rows_synced, started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms), + (rows_synced, started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), ) return rows_synced except Exception as exc: - duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000) + duration_ms = int((utcnow() - started_at).total_seconds() * 1000) await execute( """INSERT INTO seo_sync_log (source, status, rows_synced, error, started_at, completed_at, duration_ms) VALUES ('bing', 'failed', 0, ?, ?, ?, ?)""", - (str(exc), started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms), + (str(exc), started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), ) raise diff --git a/web/src/padelnomics/seo/_gsc.py b/web/src/padelnomics/seo/_gsc.py index 83fa70e..7c5ee86 100644 --- a/web/src/padelnomics/seo/_gsc.py +++ b/web/src/padelnomics/seo/_gsc.py @@ -9,7 +9,7 @@ from datetime import datetime, timedelta from pathlib import Path from urllib.parse import urlparse -from ..core import config, execute +from ..core import config, execute, utcnow, utcnow_iso # GSC returns max 25K rows per request _ROWS_PER_PAGE = 25_000 @@ -95,11 +95,11 @@ async def sync_gsc(days_back: int = 3, max_pages: int = 10) -> int: if not config.GSC_SERVICE_ACCOUNT_PATH or not config.GSC_SITE_URL: return 0 # GSC not configured — skip silently - started_at = datetime.utcnow() + started_at = utcnow() # GSC has ~2 day delay; fetch from days_back ago to 2 days ago - end_date = (datetime.utcnow() - timedelta(days=2)).strftime("%Y-%m-%d") - start_date = (datetime.utcnow() - timedelta(days=days_back + 2)).strftime("%Y-%m-%d") + end_date = (utcnow() - timedelta(days=2)).strftime("%Y-%m-%d") + start_date = (utcnow() - timedelta(days=days_back + 2)).strftime("%Y-%m-%d") try: rows = await asyncio.to_thread( @@ -122,21 +122,21 @@ async def sync_gsc(days_back: int = 3, max_pages: int = 10) -> int: ) rows_synced += 1 - duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000) + duration_ms = int((utcnow() - started_at).total_seconds() * 1000) await execute( """INSERT INTO seo_sync_log (source, status, rows_synced, started_at, completed_at, duration_ms) VALUES ('gsc', 'success', ?, ?, ?, ?)""", - (rows_synced, started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms), + (rows_synced, started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), ) return rows_synced except Exception as exc: - duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000) + duration_ms = int((utcnow() - started_at).total_seconds() * 1000) await execute( """INSERT INTO seo_sync_log (source, status, rows_synced, error, started_at, completed_at, duration_ms) VALUES ('gsc', 'failed', 0, ?, ?, ?, ?)""", - (str(exc), started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms), + (str(exc), started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), ) raise diff --git a/web/src/padelnomics/seo/_queries.py b/web/src/padelnomics/seo/_queries.py index 94434c0..c12820b 100644 --- a/web/src/padelnomics/seo/_queries.py +++ b/web/src/padelnomics/seo/_queries.py @@ -4,14 +4,14 @@ All heavy lifting happens in SQL. Functions accept filter parameters and return plain dicts/lists. """ -from datetime import datetime, timedelta +from datetime import timedelta -from ..core import execute, fetch_all, fetch_one +from ..core import execute, fetch_all, fetch_one, utcnow def _date_cutoff(date_range_days: int) -> str: """Return ISO date string for N days ago.""" - return (datetime.utcnow() - timedelta(days=date_range_days)).strftime("%Y-%m-%d") + return (utcnow() - timedelta(days=date_range_days)).strftime("%Y-%m-%d") async def get_search_performance( diff --git a/web/src/padelnomics/seo/_umami.py b/web/src/padelnomics/seo/_umami.py index c35f357..cccbb8a 100644 --- a/web/src/padelnomics/seo/_umami.py +++ b/web/src/padelnomics/seo/_umami.py @@ -8,7 +8,7 @@ from datetime import datetime, timedelta import httpx -from ..core import config, execute +from ..core import config, execute, utcnow, utcnow_iso _TIMEOUT_SECONDS = 15 @@ -21,7 +21,7 @@ async def sync_umami(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS if not config.UMAMI_API_TOKEN or not config.UMAMI_API_URL: return 0 # Umami not configured — skip silently - started_at = datetime.utcnow() + started_at = utcnow() try: rows_synced = 0 @@ -34,7 +34,7 @@ async def sync_umami(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS # (Umami's metrics endpoint returns totals for the period, # so we query one day at a time for daily granularity) for day_offset in range(days_back): - day = datetime.utcnow() - timedelta(days=day_offset + 1) + day = utcnow() - timedelta(days=day_offset + 1) metric_date = day.strftime("%Y-%m-%d") start_ms = int(day.replace(hour=0, minute=0, second=0).timestamp() * 1000) end_ms = int(day.replace(hour=23, minute=59, second=59).timestamp() * 1000) @@ -96,21 +96,21 @@ async def sync_umami(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS (metric_date, page_count, visitors, br, avg_time), ) - duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000) + duration_ms = int((utcnow() - started_at).total_seconds() * 1000) await execute( """INSERT INTO seo_sync_log (source, status, rows_synced, started_at, completed_at, duration_ms) VALUES ('umami', 'success', ?, ?, ?, ?)""", - (rows_synced, started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms), + (rows_synced, started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), ) return rows_synced except Exception as exc: - duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000) + duration_ms = int((utcnow() - started_at).total_seconds() * 1000) await execute( """INSERT INTO seo_sync_log (source, status, rows_synced, error, started_at, completed_at, duration_ms) VALUES ('umami', 'failed', 0, ?, ?, ?, ?)""", - (str(exc), started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms), + (str(exc), started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), ) raise diff --git a/web/src/padelnomics/webhooks.py b/web/src/padelnomics/webhooks.py index 6d3821c..3196adb 100644 --- a/web/src/padelnomics/webhooks.py +++ b/web/src/padelnomics/webhooks.py @@ -5,12 +5,10 @@ NOT behind @role_required: Resend posts here unauthenticated. Verification uses RESEND_WEBHOOK_SECRET via the Resend SDK. """ -from datetime import datetime - import resend from quart import Blueprint, jsonify, request -from .core import config, execute +from .core import config, execute, utcnow_iso bp = Blueprint("webhooks", __name__, url_prefix="/webhooks") @@ -67,7 +65,7 @@ async def _handle_delivery_event(event_type: str, data: dict) -> None: return last_event, ts_col = _EVENT_UPDATES[event_type] - now = datetime.utcnow().isoformat() + now = utcnow_iso() if ts_col: await execute( @@ -87,7 +85,7 @@ async def _handle_inbound(data: dict) -> None: if not resend_id: return - now = datetime.utcnow().isoformat() + now = utcnow_iso() await execute( """INSERT OR IGNORE INTO inbound_emails (resend_id, message_id, in_reply_to, from_addr, to_addr, diff --git a/web/src/padelnomics/worker.py b/web/src/padelnomics/worker.py index 0681b25..e8f8b8d 100644 --- a/web/src/padelnomics/worker.py +++ b/web/src/padelnomics/worker.py @@ -7,7 +7,17 @@ import json import traceback from datetime import datetime, timedelta -from .core import EMAIL_ADDRESSES, config, execute, fetch_all, fetch_one, init_db, send_email +from .core import ( + EMAIL_ADDRESSES, + config, + execute, + fetch_all, + fetch_one, + init_db, + send_email, + utcnow, + utcnow_iso, +) from .i18n import get_translations # Task handlers registry @@ -29,7 +39,7 @@ def _email_wrap(body: str, lang: str = "en", preheader: str = "") -> str: preheader: hidden preview text shown in email client list views. """ - year = datetime.utcnow().year + year = utcnow().year tagline = _t("email_footer_tagline", lang) copyright_text = _t("email_footer_copyright", lang, year=year, app_name=config.APP_NAME) # Hidden preheader trick: visible text + invisible padding to prevent @@ -132,15 +142,15 @@ async def enqueue(task_name: str, payload: dict = None, run_at: datetime = None) ( task_name, json.dumps(payload or {}), - (run_at or datetime.utcnow()).isoformat(), - datetime.utcnow().isoformat(), + (run_at or utcnow()).strftime("%Y-%m-%dT%H:%M:%S"), + utcnow_iso(), ), ) async def get_pending_tasks(limit: int = 10) -> list[dict]: """Get pending tasks ready to run.""" - now = datetime.utcnow().isoformat() + now = utcnow_iso() return await fetch_all( """ SELECT * FROM tasks @@ -156,7 +166,7 @@ async def mark_complete(task_id: int) -> None: """Mark task as completed.""" await execute( "UPDATE tasks SET status = 'complete', completed_at = ? WHERE id = ?", - (datetime.utcnow().isoformat(), task_id), + (utcnow_iso(), task_id), ) @@ -167,7 +177,7 @@ async def mark_failed(task_id: int, error: str, retries: int) -> None: if retries < max_retries: # Exponential backoff: 1min, 5min, 25min delay = timedelta(minutes=5**retries) - run_at = datetime.utcnow() + delay + run_at = utcnow() + delay await execute( """ @@ -385,13 +395,13 @@ async def handle_send_waitlist_confirmation(payload: dict) -> None: @task("cleanup_expired_tokens") async def handle_cleanup_tokens(payload: dict) -> None: """Clean up expired auth tokens.""" - await execute("DELETE FROM auth_tokens WHERE expires_at < ?", (datetime.utcnow().isoformat(),)) + await execute("DELETE FROM auth_tokens WHERE expires_at < ?", (utcnow_iso(),)) @task("cleanup_rate_limits") async def handle_cleanup_rate_limits(payload: dict) -> None: """Clean up old rate limit entries.""" - cutoff = (datetime.utcnow() - timedelta(hours=1)).isoformat() + cutoff = (utcnow() - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%S") await execute("DELETE FROM rate_limits WHERE timestamp < ?", (cutoff,)) @@ -497,7 +507,7 @@ async def handle_send_lead_forward_email(payload: dict) -> None: ) # Update email_sent_at on lead_forward - now = datetime.utcnow().isoformat() + now = utcnow_iso() await execute( "UPDATE lead_forwards SET email_sent_at = ? WHERE lead_id = ? AND supplier_id = ?", (now, lead_id, supplier_id), @@ -621,7 +631,7 @@ async def handle_generate_business_plan(payload: dict) -> None: file_path.write_bytes(pdf_bytes) # Update record - now = datetime.utcnow().isoformat() + now = utcnow_iso() await execute( "UPDATE business_plan_exports SET status = 'ready', file_path = ?, completed_at = ? WHERE id = ?", (str(file_path), now, export_id), @@ -664,7 +674,7 @@ async def handle_generate_business_plan(payload: dict) -> None: @task("cleanup_old_tasks") async def handle_cleanup_tasks(payload: dict) -> None: """Clean up completed/failed tasks older than 7 days.""" - cutoff = (datetime.utcnow() - timedelta(days=7)).isoformat() + cutoff = (utcnow() - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%S") await execute( "DELETE FROM tasks WHERE status IN ('complete', 'failed') AND created_at < ?", (cutoff,) ) @@ -791,9 +801,7 @@ async def run_scheduler() -> None: await enqueue("cleanup_old_tasks") # Monthly credit refill — run on the 1st of each month - from datetime import datetime - - today = datetime.utcnow() + today = utcnow() this_month = f"{today.year}-{today.month:02d}" if today.day == 1 and last_credit_refill != this_month: await enqueue("refill_monthly_credits") From a05c230ce39b67af6313d75363d6832b1ed54a9a Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:24:16 +0100 Subject: [PATCH 12/31] fix(tests): replace datetime.utcnow() with utcnow_iso() in test files Also fixes test_supplier_webhooks.py fromisoformat() comparisons: expires (naive, from DB) now compared against datetime.now(UTC).replace(tzinfo=None) to avoid mixing naive/aware datetimes. Co-Authored-By: Claude Sonnet 4.6 --- web/tests/test_content.py | 7 ++++--- web/tests/test_credits.py | 12 ++++++------ web/tests/test_feature_flags.py | 5 +++-- web/tests/test_seo.py | 16 +++++++++------- web/tests/test_supplier_webhooks.py | 14 ++++++++------ 5 files changed, 30 insertions(+), 24 deletions(-) diff --git a/web/tests/test_content.py b/web/tests/test_content.py index 015b3ff..4e5cbcf 100644 --- a/web/tests/test_content.py +++ b/web/tests/test_content.py @@ -9,6 +9,8 @@ import importlib import json import sqlite3 from datetime import date, datetime + +from padelnomics.core import utcnow_iso from pathlib import Path import pytest @@ -70,7 +72,7 @@ async def _create_published_scenario(slug="test-scenario", city="TestCity", coun async def _create_article(slug="test-article", url_path="/test-article", status="published", published_at=None): """Insert an article row, return its id.""" - pub = published_at or datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + pub = published_at or utcnow_iso() return await execute( """INSERT INTO articles (url_path, slug, title, meta_description, country, region, @@ -936,8 +938,7 @@ class TestRouteRegistration: @pytest.fixture async def admin_client(app, db): """Test client with admin user (has admin role).""" - from datetime import datetime - now = datetime.utcnow().isoformat() + now = utcnow_iso() async with db.execute( "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", ("admin@test.com", "Admin", now), diff --git a/web/tests/test_credits.py b/web/tests/test_credits.py index 054dfbe..5599a7c 100644 --- a/web/tests/test_credits.py +++ b/web/tests/test_credits.py @@ -3,7 +3,7 @@ Tests for the credit system (credits.py). Pure SQL operations against real in-memory SQLite — no mocking needed. """ -from datetime import datetime +from padelnomics.core import utcnow_iso import pytest from padelnomics.credits import ( @@ -24,7 +24,7 @@ from padelnomics.credits import ( @pytest.fixture async def supplier(db): """Supplier with credit_balance=100, monthly_credits=30, tier=growth.""" - now = datetime.utcnow().isoformat() + now = utcnow_iso() async with db.execute( """INSERT INTO suppliers (name, slug, country_code, region, category, tier, @@ -41,7 +41,7 @@ async def supplier(db): @pytest.fixture async def lead(db): """Lead request with heat_score=warm, credit_cost=20.""" - now = datetime.utcnow().isoformat() + now = utcnow_iso() async with db.execute( """INSERT INTO lead_requests (lead_type, heat_score, credit_cost, status, created_at) @@ -154,7 +154,7 @@ class TestAlreadyUnlocked: assert await already_unlocked(supplier["id"], lead["id"]) is False async def test_returns_true_after_unlock(self, db, supplier, lead): - now = datetime.utcnow().isoformat() + now = utcnow_iso() await db.execute( """INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, created_at) VALUES (?, ?, 20, ?)""", @@ -210,7 +210,7 @@ class TestUnlockLead: async def test_raises_insufficient_credits(self, db, lead): """Supplier with only 5 credits tries to unlock a 20-credit lead.""" - now = datetime.utcnow().isoformat() + now = utcnow_iso() async with db.execute( """INSERT INTO suppliers (name, slug, country_code, region, category, tier, @@ -247,7 +247,7 @@ class TestMonthlyRefill: async def test_noop_when_no_monthly_credits(self, db): """Supplier with monthly_credits=0 gets no refill.""" - now = datetime.utcnow().isoformat() + now = utcnow_iso() async with db.execute( """INSERT INTO suppliers (name, slug, country_code, region, category, tier, diff --git a/web/tests/test_feature_flags.py b/web/tests/test_feature_flags.py index 4ab8768..b467c6b 100644 --- a/web/tests/test_feature_flags.py +++ b/web/tests/test_feature_flags.py @@ -7,7 +7,8 @@ Integration tests exercise full request/response flows via Quart test client. """ import sqlite3 -from datetime import datetime + +from padelnomics.core import utcnow_iso from pathlib import Path from unittest.mock import AsyncMock, patch @@ -30,7 +31,7 @@ def mock_csrf_validation(): @pytest.fixture async def admin_client(app, db): """Test client with an admin-role user session (module-level, follows test_content.py).""" - now = datetime.utcnow().isoformat() + now = utcnow_iso() async with db.execute( "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", ("flags_admin@test.com", "Flags Admin", now), diff --git a/web/tests/test_seo.py b/web/tests/test_seo.py index 708ee51..64dc5b6 100644 --- a/web/tests/test_seo.py +++ b/web/tests/test_seo.py @@ -1,6 +1,8 @@ """Tests for the SEO metrics module: queries, sync functions, admin routes.""" -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta + +from padelnomics.core import utcnow_iso from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -21,11 +23,11 @@ from padelnomics import core # ── Fixtures ────────────────────────────────────────────────── def _today(): - return datetime.utcnow().strftime("%Y-%m-%d") + return datetime.now(UTC).strftime("%Y-%m-%d") def _days_ago(n: int) -> str: - return (datetime.utcnow() - timedelta(days=n)).strftime("%Y-%m-%d") + return (datetime.now(UTC) - timedelta(days=n)).strftime("%Y-%m-%d") @pytest.fixture @@ -72,7 +74,7 @@ async def seo_data(db): @pytest.fixture async def articles_data(db, seo_data): """Create articles that match the SEO data URLs.""" - now = datetime.utcnow().isoformat() + now = utcnow_iso() pub = _days_ago(10) for title, url, tpl, lang in [ @@ -91,7 +93,7 @@ async def articles_data(db, seo_data): @pytest.fixture async def admin_client(app, db): """Authenticated admin client.""" - now = datetime.utcnow().isoformat() + now = utcnow_iso() async with db.execute( "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", ("admin@test.com", "Admin", now), @@ -258,7 +260,7 @@ class TestSyncStatus: """Tests for get_sync_status().""" async def test_returns_last_sync_per_source(self, db): - now = datetime.utcnow().isoformat() + now = utcnow_iso() await db.execute( """INSERT INTO seo_sync_log (source, status, rows_synced, started_at, completed_at, duration_ms) VALUES ('gsc', 'success', 100, ?, ?, 500)""", @@ -286,7 +288,7 @@ class TestCleanupOldMetrics: """Tests for cleanup_old_metrics().""" async def test_deletes_old_data(self, db): - old_date = (datetime.utcnow() - timedelta(days=400)).strftime("%Y-%m-%d") + old_date = (datetime.now(UTC) - timedelta(days=400)).strftime("%Y-%m-%d") recent_date = _today() await db.execute( diff --git a/web/tests/test_supplier_webhooks.py b/web/tests/test_supplier_webhooks.py index 2eba57c..2f1d1da 100644 --- a/web/tests/test_supplier_webhooks.py +++ b/web/tests/test_supplier_webhooks.py @@ -5,7 +5,9 @@ POST real webhook payloads to /billing/webhook/paddle and verify DB state. Uses the existing client, db, sign_payload from conftest. """ import json -from datetime import datetime +from datetime import UTC, datetime + +from padelnomics.core import utcnow_iso from unittest.mock import AsyncMock, patch import pytest @@ -21,7 +23,7 @@ SIG_HEADER = "Paddle-Signature" @pytest.fixture async def supplier(db): """Supplier with tier=free, credit_balance=0.""" - now = datetime.utcnow().isoformat() + now = utcnow_iso() async with db.execute( """INSERT INTO suppliers (name, slug, country_code, region, category, tier, @@ -38,7 +40,7 @@ async def supplier(db): @pytest.fixture async def paddle_products(db): """Insert paddle_products rows for all keys the handlers need.""" - now = datetime.utcnow().isoformat() + now = utcnow_iso() products = [ ("credits_25", "pri_credits25", "Credit Pack 25", 999, "one_time"), ("credits_100", "pri_credits100", "Credit Pack 100", 3290, "one_time"), @@ -175,7 +177,7 @@ class TestStickyBoostPurchase: assert boosts[0][1] == "active" # expires_at should be ~7 days from now expires = datetime.fromisoformat(boosts[0][2]) - assert abs((expires - datetime.utcnow()).days - 7) <= 1 + assert abs((expires - datetime.now(UTC).replace(tzinfo=None)).days - 7) <= 1 # Verify sticky_until set on supplier sup = await db.execute_fetchall( @@ -202,7 +204,7 @@ class TestStickyBoostPurchase: assert len(boosts) == 1 assert boosts[0][0] == "sticky_month" expires = datetime.fromisoformat(boosts[0][1]) - assert abs((expires - datetime.utcnow()).days - 30) <= 1 + assert abs((expires - datetime.now(UTC).replace(tzinfo=None)).days - 30) <= 1 async def test_sticky_boost_sets_country(self, client, db, supplier, paddle_products): payload = make_transaction_payload( @@ -387,7 +389,7 @@ class TestBusinessPlanPurchase: self, client, db, supplier, paddle_products, test_user, ): # Need a scenario for the export - now = datetime.utcnow().isoformat() + now = utcnow_iso() async with db.execute( """INSERT INTO scenarios (user_id, name, state_json, created_at) VALUES (?, 'Test Scenario', '{}', ?)""", From 6bd92c69ced018b5868b35562d65a8b5a2c0a38f Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:24:53 +0100 Subject: [PATCH 13/31] fix(admin): use task_name column (not task_type) in _is_generating query Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/admin/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index c20a49f..03ef84f 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -1722,7 +1722,7 @@ async def _get_article_stats() -> dict: async def _is_generating() -> bool: """Return True if a generate_articles task is currently pending.""" row = await fetch_one( - "SELECT COUNT(*) AS cnt FROM tasks WHERE task_type = 'generate_articles' AND status = 'pending'" + "SELECT COUNT(*) AS cnt FROM tasks WHERE task_name = 'generate_articles' AND status = 'pending'" ) return bool(row and row["cnt"] > 0) From e33b28025e18c56764fb741a702d55f02b1692f4 Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:30:18 +0100 Subject: [PATCH 14/31] fix: use SQLite-compatible space format in utcnow_iso(), fix credits ordering utcnow_iso() now produces 'YYYY-MM-DD HH:MM:SS' (space separator) matching SQLite's datetime('now') so lexicographic comparisons like 'published_at <= datetime(now)' work correctly. Also add `id DESC` tiebreaker to get_ledger() ORDER BY to preserve insertion order when multiple credits are added within the same second. Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/admin/routes.py | 4 ++-- web/src/padelnomics/auth/routes.py | 4 ++-- web/src/padelnomics/billing/routes.py | 6 +++--- web/src/padelnomics/content/__init__.py | 2 +- web/src/padelnomics/core.py | 14 +++++++------- web/src/padelnomics/credits.py | 2 +- web/src/padelnomics/leads/routes.py | 1 - web/src/padelnomics/scripts/seed_content.py | 2 +- web/src/padelnomics/scripts/seed_dev_data.py | 14 +++++++------- web/src/padelnomics/seo/_bing.py | 4 ++-- web/src/padelnomics/seo/_gsc.py | 6 +++--- web/src/padelnomics/seo/_umami.py | 6 +++--- web/src/padelnomics/suppliers/routes.py | 2 +- web/src/padelnomics/worker.py | 6 +++--- web/tests/test_content.py | 6 ++---- web/tests/test_credits.py | 3 +-- web/tests/test_feature_flags.py | 10 ++++------ web/tests/test_seo.py | 3 +-- web/tests/test_supervisor.py | 10 +++------- web/tests/test_supplier_webhooks.py | 3 +-- 20 files changed, 48 insertions(+), 60 deletions(-) diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 47913d6..c8ec9d0 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -2,7 +2,7 @@ Admin domain: role-based admin panel for managing users, tasks, etc. """ import json -from datetime import date, datetime, timedelta +from datetime import date, timedelta from pathlib import Path import mistune @@ -68,7 +68,7 @@ async def get_dashboard_stats() -> dict: """Get admin dashboard statistics.""" now = utcnow() today = now.date().isoformat() - week_ago = (now - timedelta(days=7)).strftime("%Y-%m-%dT%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", diff --git a/web/src/padelnomics/auth/routes.py b/web/src/padelnomics/auth/routes.py index 908ca9d..5c39f73 100644 --- a/web/src/padelnomics/auth/routes.py +++ b/web/src/padelnomics/auth/routes.py @@ -3,7 +3,7 @@ Auth domain: magic link authentication, user management, decorators. """ import secrets -from datetime import datetime, timedelta +from datetime import timedelta from functools import wraps from pathlib import Path @@ -87,7 +87,7 @@ async def create_auth_token(user_id: int, token: str, minutes: int = None) -> in expires = utcnow() + timedelta(minutes=minutes) return await execute( "INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)", - (user_id, token, expires.strftime("%Y-%m-%dT%H:%M:%S")), + (user_id, token, expires.strftime("%Y-%m-%d %H:%M:%S")), ) diff --git a/web/src/padelnomics/billing/routes.py b/web/src/padelnomics/billing/routes.py index c1c521d..5cb2f15 100644 --- a/web/src/padelnomics/billing/routes.py +++ b/web/src/padelnomics/billing/routes.py @@ -5,7 +5,7 @@ Payment provider: paddle import json import secrets -from datetime import datetime, timedelta +from datetime import timedelta from pathlib import Path from paddle_billing import Client as PaddleClient @@ -413,7 +413,7 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None: # Sticky boost purchases elif key == "boost_sticky_week" and supplier_id: from ..core import transaction as db_transaction - expires = (utcnow() + timedelta(weeks=1)).strftime("%Y-%m-%dT%H:%M:%S") + expires = (utcnow() + timedelta(weeks=1)).strftime("%Y-%m-%d %H:%M:%S") country = custom_data.get("sticky_country", "") async with db_transaction() as db: await db.execute( @@ -429,7 +429,7 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None: elif key == "boost_sticky_month" and supplier_id: from ..core import transaction as db_transaction - expires = (utcnow() + timedelta(days=30)).strftime("%Y-%m-%dT%H:%M:%S") + expires = (utcnow() + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S") country = custom_data.get("sticky_country", "") async with db_transaction() as db: await db.execute( diff --git a/web/src/padelnomics/content/__init__.py b/web/src/padelnomics/content/__init__.py index 618679b..33cbeb9 100644 --- a/web/src/padelnomics/content/__init__.py +++ b/web/src/padelnomics/content/__init__.py @@ -135,7 +135,7 @@ def _validate_table_name(data_table: str) -> None: def _datetimeformat(value: str, fmt: str = "%Y-%m-%d") -> str: """Jinja2 filter: format a date string (or 'now') with strftime.""" - from datetime import UTC, datetime + from datetime import datetime if value == "now": dt = datetime.now(UTC) diff --git a/web/src/padelnomics/core.py b/web/src/padelnomics/core.py index c762d5f..9f0895d 100644 --- a/web/src/padelnomics/core.py +++ b/web/src/padelnomics/core.py @@ -102,10 +102,10 @@ def utcnow() -> datetime: def utcnow_iso() -> str: """UTC now as naive ISO string for SQLite TEXT columns. - Produces YYYY-MM-DDTHH:MM:SS (no +00:00 suffix) to match the existing - format stored in the DB so lexicographic SQL comparisons keep working. + Produces YYYY-MM-DD HH:MM:SS (space separator, no +00:00 suffix) to match + SQLite's native datetime('now') format so lexicographic SQL comparisons work. """ - return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S") # ============================================================================= @@ -554,12 +554,12 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t # Clean old entries and count recent await execute( "DELETE FROM rate_limits WHERE key = ? AND timestamp < ?", - (key, window_start.strftime("%Y-%m-%dT%H:%M:%S")), + (key, window_start.strftime("%Y-%m-%d %H:%M:%S")), ) result = await fetch_one( "SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?", - (key, window_start.strftime("%Y-%m-%dT%H:%M:%S")), + (key, window_start.strftime("%Y-%m-%d %H:%M:%S")), ) count = result["count"] if result else 0 @@ -575,7 +575,7 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t # Record this request await execute( "INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)", - (key, now.strftime("%Y-%m-%dT%H:%M:%S")), + (key, now.strftime("%Y-%m-%d %H:%M:%S")), ) return True, info @@ -671,7 +671,7 @@ async def hard_delete(table: str, id: int) -> bool: async def purge_deleted(table: str, days: int = 30) -> int: """Purge records deleted more than X days ago.""" - cutoff = (utcnow() - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%S") + cutoff = (utcnow() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S") return await execute( f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,) ) diff --git a/web/src/padelnomics/credits.py b/web/src/padelnomics/credits.py index 77943f1..fa8c855 100644 --- a/web/src/padelnomics/credits.py +++ b/web/src/padelnomics/credits.py @@ -199,6 +199,6 @@ async def get_ledger(supplier_id: int, limit: int = 50) -> list[dict]: FROM credit_ledger cl LEFT JOIN lead_forwards lf ON cl.reference_id = lf.id AND cl.event_type = 'lead_unlock' WHERE cl.supplier_id = ? - ORDER BY cl.created_at DESC LIMIT ?""", + ORDER BY cl.created_at DESC, cl.id DESC LIMIT ?""", (supplier_id, limit), ) diff --git a/web/src/padelnomics/leads/routes.py b/web/src/padelnomics/leads/routes.py index a814d8e..265fc74 100644 --- a/web/src/padelnomics/leads/routes.py +++ b/web/src/padelnomics/leads/routes.py @@ -4,7 +4,6 @@ Leads domain: capture interest in court suppliers and financing. import json import secrets -from datetime import datetime from pathlib import Path from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for diff --git a/web/src/padelnomics/scripts/seed_content.py b/web/src/padelnomics/scripts/seed_content.py index 2b3090d..1b53b0a 100644 --- a/web/src/padelnomics/scripts/seed_content.py +++ b/web/src/padelnomics/scripts/seed_content.py @@ -1390,7 +1390,7 @@ def seed_templates(conn: sqlite3.Connection) -> dict[str, int]: def seed_data_rows(conn: sqlite3.Connection, template_ids: dict[str, int]) -> int: """Insert template_data rows for all cities × languages. Returns count inserted.""" - now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + now = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S") inserted = 0 en_id = template_ids.get("city-padel-cost-en") diff --git a/web/src/padelnomics/scripts/seed_dev_data.py b/web/src/padelnomics/scripts/seed_dev_data.py index 69b2830..9d9a9c2 100644 --- a/web/src/padelnomics/scripts/seed_dev_data.py +++ b/web/src/padelnomics/scripts/seed_dev_data.py @@ -303,7 +303,7 @@ def main(): else: cursor = conn.execute( "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", - ("dev@localhost", "Dev User", now.strftime("%Y-%m-%dT%H:%M:%S")), + ("dev@localhost", "Dev User", now.strftime("%Y-%m-%d %H:%M:%S")), ) dev_user_id = cursor.lastrowid print(f" Created (id={dev_user_id})") @@ -336,7 +336,7 @@ def main(): s["website"], s["description"], s["category"], s["tier"], s["credit_balance"], s["monthly_credits"], s["contact_name"], s["contact_email"], s["years_in_business"], s["project_count"], - s["service_area"], now.strftime("%Y-%m-%dT%H:%M:%S"), + s["service_area"], now.strftime("%Y-%m-%d %H:%M:%S"), ), ) supplier_ids[s["slug"]] = cursor.lastrowid @@ -349,7 +349,7 @@ def main(): ("courtbuild-spain", "supplier_growth", "maria@courtbuild.example.com", "Maria Garcia"), ("desert-padel-fze", "supplier_pro", "ahmed@desertpadel.example.com", "Ahmed Al-Rashid"), ] - period_end = (now + timedelta(days=30)).strftime("%Y-%m-%dT%H:%M:%S") + period_end = (now + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S") for slug, plan, email, name in claimed_suppliers: sid = supplier_ids.get(slug) if not sid: @@ -364,14 +364,14 @@ def main(): else: cursor = conn.execute( "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", - (email, name, now.strftime("%Y-%m-%dT%H:%M:%S")), + (email, name, now.strftime("%Y-%m-%d %H:%M:%S")), ) owner_id = cursor.lastrowid # Claim the supplier conn.execute( "UPDATE suppliers SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL", - (owner_id, now.strftime("%Y-%m-%dT%H:%M:%S"), sid), + (owner_id, now.strftime("%Y-%m-%d %H:%M:%S"), sid), ) # Create billing customer record @@ -382,7 +382,7 @@ def main(): conn.execute( """INSERT INTO billing_customers (user_id, provider_customer_id, created_at) VALUES (?, ?, ?)""", - (owner_id, f"ctm_dev_{slug}", now.strftime("%Y-%m-%dT%H:%M:%S")), + (owner_id, f"ctm_dev_{slug}", now.strftime("%Y-%m-%d %H:%M:%S")), ) # Create active subscription @@ -396,7 +396,7 @@ def main(): current_period_end, created_at) VALUES (?, ?, 'active', ?, ?, ?)""", (owner_id, plan, f"sub_dev_{slug}", - period_end, now.strftime("%Y-%m-%dT%H:%M:%S")), + period_end, now.strftime("%Y-%m-%d %H:%M:%S")), ) print(f" {slug} -> owner {email} ({plan})") diff --git a/web/src/padelnomics/seo/_bing.py b/web/src/padelnomics/seo/_bing.py index 9a5cb74..df6192a 100644 --- a/web/src/padelnomics/seo/_bing.py +++ b/web/src/padelnomics/seo/_bing.py @@ -127,7 +127,7 @@ async def sync_bing(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS) """INSERT INTO seo_sync_log (source, status, rows_synced, started_at, completed_at, duration_ms) VALUES ('bing', 'success', ?, ?, ?, ?)""", - (rows_synced, started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), + (rows_synced, started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms), ) return rows_synced @@ -137,6 +137,6 @@ async def sync_bing(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS) """INSERT INTO seo_sync_log (source, status, rows_synced, error, started_at, completed_at, duration_ms) VALUES ('bing', 'failed', 0, ?, ?, ?, ?)""", - (str(exc), started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), + (str(exc), started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms), ) raise diff --git a/web/src/padelnomics/seo/_gsc.py b/web/src/padelnomics/seo/_gsc.py index 7c5ee86..dbdce33 100644 --- a/web/src/padelnomics/seo/_gsc.py +++ b/web/src/padelnomics/seo/_gsc.py @@ -5,7 +5,7 @@ is synchronous, so sync runs in asyncio.to_thread(). """ import asyncio -from datetime import datetime, timedelta +from datetime import timedelta from pathlib import Path from urllib.parse import urlparse @@ -127,7 +127,7 @@ async def sync_gsc(days_back: int = 3, max_pages: int = 10) -> int: """INSERT INTO seo_sync_log (source, status, rows_synced, started_at, completed_at, duration_ms) VALUES ('gsc', 'success', ?, ?, ?, ?)""", - (rows_synced, started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), + (rows_synced, started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms), ) return rows_synced @@ -137,6 +137,6 @@ async def sync_gsc(days_back: int = 3, max_pages: int = 10) -> int: """INSERT INTO seo_sync_log (source, status, rows_synced, error, started_at, completed_at, duration_ms) VALUES ('gsc', 'failed', 0, ?, ?, ?, ?)""", - (str(exc), started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), + (str(exc), started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms), ) raise diff --git a/web/src/padelnomics/seo/_umami.py b/web/src/padelnomics/seo/_umami.py index cccbb8a..9a3172a 100644 --- a/web/src/padelnomics/seo/_umami.py +++ b/web/src/padelnomics/seo/_umami.py @@ -4,7 +4,7 @@ Uses bearer token auth. Self-hosted instance, no rate limits. Config already exists: UMAMI_API_URL, UMAMI_API_TOKEN, UMAMI_WEBSITE_ID. """ -from datetime import datetime, timedelta +from datetime import timedelta import httpx @@ -101,7 +101,7 @@ async def sync_umami(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS """INSERT INTO seo_sync_log (source, status, rows_synced, started_at, completed_at, duration_ms) VALUES ('umami', 'success', ?, ?, ?, ?)""", - (rows_synced, started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), + (rows_synced, started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms), ) return rows_synced @@ -111,6 +111,6 @@ async def sync_umami(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS """INSERT INTO seo_sync_log (source, status, rows_synced, error, started_at, completed_at, duration_ms) VALUES ('umami', 'failed', 0, ?, ?, ?, ?)""", - (str(exc), started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), + (str(exc), started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms), ) raise diff --git a/web/src/padelnomics/suppliers/routes.py b/web/src/padelnomics/suppliers/routes.py index f7d2977..7846887 100644 --- a/web/src/padelnomics/suppliers/routes.py +++ b/web/src/padelnomics/suppliers/routes.py @@ -13,9 +13,9 @@ from ..core import ( config, csrf_protect, execute, + feature_gate, fetch_all, fetch_one, - feature_gate, get_paddle_price, is_flag_enabled, ) diff --git a/web/src/padelnomics/worker.py b/web/src/padelnomics/worker.py index e8f8b8d..f7da534 100644 --- a/web/src/padelnomics/worker.py +++ b/web/src/padelnomics/worker.py @@ -142,7 +142,7 @@ async def enqueue(task_name: str, payload: dict = None, run_at: datetime = None) ( task_name, json.dumps(payload or {}), - (run_at or utcnow()).strftime("%Y-%m-%dT%H:%M:%S"), + (run_at or utcnow()).strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), ), ) @@ -401,7 +401,7 @@ async def handle_cleanup_tokens(payload: dict) -> None: @task("cleanup_rate_limits") async def handle_cleanup_rate_limits(payload: dict) -> None: """Clean up old rate limit entries.""" - cutoff = (utcnow() - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%S") + cutoff = (utcnow() - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S") await execute("DELETE FROM rate_limits WHERE timestamp < ?", (cutoff,)) @@ -674,7 +674,7 @@ async def handle_generate_business_plan(payload: dict) -> None: @task("cleanup_old_tasks") async def handle_cleanup_tasks(payload: dict) -> None: """Clean up completed/failed tasks older than 7 days.""" - cutoff = (utcnow() - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%S") + cutoff = (utcnow() - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S") await execute( "DELETE FROM tasks WHERE status IN ('complete', 'failed') AND created_at < ?", (cutoff,) ) diff --git a/web/tests/test_content.py b/web/tests/test_content.py index 4e5cbcf..02cc103 100644 --- a/web/tests/test_content.py +++ b/web/tests/test_content.py @@ -8,9 +8,7 @@ sitemap integration, admin CRUD routes, and path collision prevention. import importlib import json import sqlite3 -from datetime import date, datetime - -from padelnomics.core import utcnow_iso +from datetime import date from pathlib import Path import pytest @@ -21,7 +19,7 @@ from padelnomics.content.routes import ( bake_scenario_cards, is_reserved_path, ) -from padelnomics.core import execute, fetch_all, fetch_one, slugify +from padelnomics.core import execute, fetch_all, fetch_one, slugify, utcnow_iso from padelnomics.planner.calculator import calc, validate_state SCHEMA_PATH = Path(__file__).parent.parent / "src" / "padelnomics" / "migrations" / "schema.sql" diff --git a/web/tests/test_credits.py b/web/tests/test_credits.py index 5599a7c..12b9826 100644 --- a/web/tests/test_credits.py +++ b/web/tests/test_credits.py @@ -3,9 +3,8 @@ Tests for the credit system (credits.py). Pure SQL operations against real in-memory SQLite — no mocking needed. """ -from padelnomics.core import utcnow_iso - import pytest +from padelnomics.core import utcnow_iso from padelnomics.credits import ( InsufficientCredits, add_credits, diff --git a/web/tests/test_feature_flags.py b/web/tests/test_feature_flags.py index b467c6b..df822bb 100644 --- a/web/tests/test_feature_flags.py +++ b/web/tests/test_feature_flags.py @@ -7,16 +7,13 @@ Integration tests exercise full request/response flows via Quart test client. """ import sqlite3 - -from padelnomics.core import utcnow_iso -from pathlib import Path from unittest.mock import AsyncMock, patch import pytest - -from padelnomics import core +from padelnomics.core import utcnow_iso from padelnomics.migrations.migrate import migrate +from padelnomics import core # ── Fixtures & helpers ──────────────────────────────────────────── @@ -294,8 +291,9 @@ class TestLeadUnlockGate: @pytest.mark.asyncio async def test_route_imports_is_flag_enabled(self): """suppliers/routes.py imports is_flag_enabled (gate is wired up).""" - from padelnomics.suppliers.routes import unlock_lead import inspect + + from padelnomics.suppliers.routes import unlock_lead src = inspect.getsource(unlock_lead) assert "is_flag_enabled" in src assert "lead_unlock" in src diff --git a/web/tests/test_seo.py b/web/tests/test_seo.py index 64dc5b6..06fb56a 100644 --- a/web/tests/test_seo.py +++ b/web/tests/test_seo.py @@ -1,11 +1,10 @@ """Tests for the SEO metrics module: queries, sync functions, admin routes.""" from datetime import UTC, datetime, timedelta - -from padelnomics.core import utcnow_iso from unittest.mock import AsyncMock, MagicMock, patch import pytest +from padelnomics.core import utcnow_iso from padelnomics.seo._queries import ( cleanup_old_metrics, get_article_scorecard, diff --git a/web/tests/test_supervisor.py b/web/tests/test_supervisor.py index 8f6eb3f..8a291db 100644 --- a/web/tests/test_supervisor.py +++ b/web/tests/test_supervisor.py @@ -8,19 +8,16 @@ supervisor.py lives in src/padelnomics/ (not a uv workspace package), so we add src/ to sys.path before importing. """ -import sys +# Load supervisor.py directly by path — avoids clashing with the web app's +# 'padelnomics' namespace (which is the installed web package). +import importlib.util as _ilu import textwrap -import tomllib from datetime import UTC, datetime, timedelta from pathlib import Path from unittest.mock import MagicMock, patch import pytest -# Load supervisor.py directly by path — avoids clashing with the web app's -# 'padelnomics' namespace (which is the installed web package). -import importlib.util as _ilu - _SUP_PATH = Path(__file__).parent.parent.parent / "src" / "padelnomics" / "supervisor.py" _spec = _ilu.spec_from_file_location("padelnomics_supervisor", _SUP_PATH) sup = _ilu.module_from_spec(_spec) @@ -32,7 +29,6 @@ from padelnomics_extract.proxy import ( make_sticky_selector, ) - # ── load_workflows ──────────────────────────────────────────────── diff --git a/web/tests/test_supplier_webhooks.py b/web/tests/test_supplier_webhooks.py index 2f1d1da..8322c1c 100644 --- a/web/tests/test_supplier_webhooks.py +++ b/web/tests/test_supplier_webhooks.py @@ -6,12 +6,11 @@ Uses the existing client, db, sign_payload from conftest. """ import json from datetime import UTC, datetime - -from padelnomics.core import utcnow_iso from unittest.mock import AsyncMock, patch import pytest from conftest import sign_payload +from padelnomics.core import utcnow_iso WEBHOOK_PATH = "/billing/webhook/paddle" SIG_HEADER = "Paddle-Signature" From d42c4790b49a5e8f0ad95c3da8be9bd798e4e07d Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:30:31 +0100 Subject: [PATCH 15/31] chore: update CHANGELOG for datetime deprecation fix Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0031cb..9463256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Fixed +- **`datetime.utcnow()` deprecation warnings** — replaced all 94 occurrences + across 22 files (source + tests) with `utcnow()` / `utcnow_iso()` helpers + from `core.py`. `utcnow_iso()` produces `YYYY-MM-DD HH:MM:SS` (space + separator) matching SQLite's `datetime('now')` format so lexicographic SQL + comparisons stay correct. `datetime.utcfromtimestamp()` in `seo/_bing.py` + also replaced with `datetime.fromtimestamp(ts, tz=UTC)`. Zero deprecation + warnings remain. +- **Credit ledger ordering** — `get_ledger()` now uses `ORDER BY created_at + DESC, id DESC` to preserve insertion order when multiple credits are added + within the same second. + + - **Double language prefix in article URLs** — articles were served at `/en/en/markets/italy` (double prefix) because `generate_articles()` stored `url_path` with the lang prefix baked in, but the blueprint is already mounted From bd796178517c08c5325cc60efa8ddbe9465993d3 Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:33:38 +0100 Subject: [PATCH 16/31] feat(admin): replace browser confirm() dialogs with native modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add styled to base_admin.html — frosted backdrop, rounded card, Cancel / Confirm buttons - Add confirmAction(message, form) JS helper — clones OK button to avoid listener accumulation, calls form.submit() on confirm - Replace all 5 onclick="return confirm()" across templates with type="button" + confirmAction(..., this.closest('form')) · articles.html — Rebuild All · template_detail.html — Regenerate · generate_form.html — Generate Articles · scenario_results.html — Delete scenario · audience_contacts.html — Remove contact Co-Authored-By: Claude Sonnet 4.6 --- .../admin/templates/admin/articles.html | 2 +- .../templates/admin/audience_contacts.html | 2 +- .../admin/templates/admin/base_admin.html | 28 +++++++++++++++++++ .../admin/templates/admin/generate_form.html | 2 +- .../admin/partials/scenario_results.html | 2 +- .../templates/admin/template_detail.html | 2 +- 6 files changed, 33 insertions(+), 5 deletions(-) diff --git a/web/src/padelnomics/admin/templates/admin/articles.html b/web/src/padelnomics/admin/templates/admin/articles.html index f5a4af9..19d9d3f 100644 --- a/web/src/padelnomics/admin/templates/admin/articles.html +++ b/web/src/padelnomics/admin/templates/admin/articles.html @@ -18,7 +18,7 @@ New Article
    - +
    diff --git a/web/src/padelnomics/admin/templates/admin/audience_contacts.html b/web/src/padelnomics/admin/templates/admin/audience_contacts.html index 10ecebe..b48de55 100644 --- a/web/src/padelnomics/admin/templates/admin/audience_contacts.html +++ b/web/src/padelnomics/admin/templates/admin/audience_contacts.html @@ -30,7 +30,7 @@
    - +
    diff --git a/web/src/padelnomics/admin/templates/admin/base_admin.html b/web/src/padelnomics/admin/templates/admin/base_admin.html index a153220..ef3f0d0 100644 --- a/web/src/padelnomics/admin/templates/admin/base_admin.html +++ b/web/src/padelnomics/admin/templates/admin/base_admin.html @@ -27,6 +27,14 @@ .admin-main { flex: 1; padding: 2rem; overflow-y: auto; } + #confirm-dialog { + border: none; border-radius: 12px; padding: 1.5rem; max-width: 380px; width: 90%; + box-shadow: 0 20px 60px rgba(0,0,0,0.15), 0 4px 16px rgba(0,0,0,0.08); + } + #confirm-dialog::backdrop { background: rgba(15,23,42,0.45); backdrop-filter: blur(3px); } + #confirm-dialog p { margin: 0 0 1.25rem; font-size: 0.9375rem; color: #0F172A; line-height: 1.55; } + #confirm-dialog .dialog-actions { display: flex; gap: 0.5rem; justify-content: flex-end; } + @media (max-width: 768px) { .admin-layout { flex-direction: column; } .admin-sidebar { @@ -130,4 +138,24 @@ {% block admin_content %}{% endblock %} + + +

    +
    + + +
    +
    + {% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/generate_form.html b/web/src/padelnomics/admin/templates/admin/generate_form.html index d59f7ed..1b7c137 100644 --- a/web/src/padelnomics/admin/templates/admin/generate_form.html +++ b/web/src/padelnomics/admin/templates/admin/generate_form.html @@ -45,7 +45,7 @@

    - diff --git a/web/src/padelnomics/admin/templates/admin/partials/scenario_results.html b/web/src/padelnomics/admin/templates/admin/partials/scenario_results.html index 93dd343..ac9860d 100644 --- a/web/src/padelnomics/admin/templates/admin/partials/scenario_results.html +++ b/web/src/padelnomics/admin/templates/admin/partials/scenario_results.html @@ -32,7 +32,7 @@ Edit
    - +
    diff --git a/web/src/padelnomics/admin/templates/admin/template_detail.html b/web/src/padelnomics/admin/templates/admin/template_detail.html index e053c34..d0ca524 100644 --- a/web/src/padelnomics/admin/templates/admin/template_detail.html +++ b/web/src/padelnomics/admin/templates/admin/template_detail.html @@ -15,7 +15,7 @@ Generate Articles
    -
    From c5176d7d17b74598b46ab41ed6cff35fa3982f6a Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:34:38 +0100 Subject: [PATCH 17/31] fix(admin): center confirm dialog with fixed position + transform Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/admin/templates/admin/base_admin.html | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/padelnomics/admin/templates/admin/base_admin.html b/web/src/padelnomics/admin/templates/admin/base_admin.html index ef3f0d0..3ca1820 100644 --- a/web/src/padelnomics/admin/templates/admin/base_admin.html +++ b/web/src/padelnomics/admin/templates/admin/base_admin.html @@ -30,6 +30,7 @@ #confirm-dialog { border: none; border-radius: 12px; padding: 1.5rem; max-width: 380px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,0.15), 0 4px 16px rgba(0,0,0,0.08); + position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); margin: 0; } #confirm-dialog::backdrop { background: rgba(15,23,42,0.45); backdrop-filter: blur(3px); } #confirm-dialog p { margin: 0 0 1.25rem; font-size: 0.9375rem; color: #0F172A; line-height: 1.55; } From d9de9e4cdae079b3daa4f044b2221b136474f70a Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:36:01 +0100 Subject: [PATCH 18/31] fix(planner): replace alert() error popups with inline error banner Show API errors and network failures in a red inline div below the export form instead of browser alert() dialogs. Error div is hidden on each new submit attempt so stale messages don't linger. Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/planner/templates/export.html | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/src/padelnomics/planner/templates/export.html b/web/src/padelnomics/planner/templates/export.html index a2446dd..9d9d9af 100644 --- a/web/src/padelnomics/planner/templates/export.html +++ b/web/src/padelnomics/planner/templates/export.html @@ -71,6 +71,7 @@ + @@ -106,9 +107,16 @@ {% block scripts %} ' - for obj in jsonld_objects - ], - ]) - - # Write HTML to disk - build_dir = BUILD_DIR / lang - build_dir.mkdir(parents=True, exist_ok=True) - (build_dir / f"{article_slug}.html").write_text(body_html) - - # Write markdown source to disk (for admin editing) - md_dir = BUILD_DIR / lang / "md" - md_dir.mkdir(parents=True, exist_ok=True) - (md_dir / f"{article_slug}.md").write_text(body_md) - - # Upsert article in SQLite — keyed by (url_path, language) since - # multiple languages share the same url_path - existing_article = await fetch_one( - "SELECT id FROM articles WHERE url_path = ? AND language = ?", - (url_path, lang), - ) - if existing_article: - await execute( - """UPDATE articles - SET title = ?, meta_description = ?, template_slug = ?, - language = ?, date_modified = ?, updated_at = ?, - seo_head = ? - WHERE url_path = ? AND language = ?""", - (title, meta_desc, slug, lang, now_iso, now_iso, seo_head, url_path, lang), + # JSON-LD + breadcrumbs = _build_breadcrumbs(f"/{lang}{url_path}", base_url) + jsonld_objects = build_jsonld( + config["schema_type"], + title=title, + description=meta_desc, + url=full_url, + published_at=publish_dt, + date_modified=now_iso, + language=lang, + breadcrumbs=breadcrumbs, + faq_pairs=faq_pairs, ) - else: - await execute( + + # Build SEO head block + seo_head = "\n".join([ + f'', + *hreflang_links, + f'', + f'', + f'', + '', + *[ + f'' + for obj in jsonld_objects + ], + ]) + + # Write HTML to disk + build_dir = BUILD_DIR / lang + build_dir.mkdir(parents=True, exist_ok=True) + (build_dir / f"{article_slug}.html").write_text(body_html) + + # Write markdown source to disk (for admin editing) + md_dir = BUILD_DIR / lang / "md" + md_dir.mkdir(parents=True, exist_ok=True) + (md_dir / f"{article_slug}.md").write_text(body_md) + + # Upsert article in SQLite — keyed by (url_path, language) + await db.execute( """INSERT INTO articles - (url_path, slug, title, meta_description, country, region, - status, published_at, template_slug, language, date_modified, - seo_head, created_at) - VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?)""", + (url_path, slug, title, meta_description, country, region, + status, published_at, template_slug, language, date_modified, + seo_head, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?) + ON CONFLICT(url_path, language) DO UPDATE SET + title = excluded.title, + meta_description = excluded.meta_description, + template_slug = excluded.template_slug, + date_modified = excluded.date_modified, + seo_head = excluded.seo_head, + updated_at = excluded.date_modified""", ( url_path, article_slug, title, meta_desc, row.get("country", ""), row.get("region", ""), @@ -463,14 +450,17 @@ async def generate_articles( ), ) - generated += 1 + generated += 1 + if generated % 25 == 0: + logger.info("%s: %d articles written…", slug, generated) - # Stagger dates - published_today += 1 - if published_today >= articles_per_day: - published_today = 0 - publish_date += timedelta(days=1) + # Stagger dates + published_today += 1 + if published_today >= articles_per_day: + published_today = 0 + publish_date += timedelta(days=1) + logger.info("%s: done — %d total", slug, generated) return generated