diff --git a/CHANGELOG.md b/CHANGELOG.md index 06592f6..e14aeff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- **Bulk actions for articles and leads** — checkbox selection + floating action bar on admin articles and leads pages (same pattern as suppliers). Articles: publish, unpublish, toggle noindex, rebuild, delete. Leads: set status, set heat. Re-renders results via HTMX after each action. - **Stripe payment provider** — second payment provider alongside Paddle, switchable via `PAYMENT_PROVIDER=stripe` env var. Existing Paddle subscribers keep working regardless of toggle — both webhook endpoints stay active. - `billing/stripe.py`: full Stripe implementation (Checkout Sessions, Billing Portal, subscription cancel, webhook verification + parsing) - `billing/paddle.py`: extracted Paddle-specific logic from routes.py into its own module diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 5bbdeed..bf649b7 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -532,6 +532,71 @@ async def lead_results(): ) +@bp.route("/leads/bulk", methods=["POST"]) +@role_required("admin") +@csrf_protect +async def leads_bulk(): + """Bulk actions on leads: set_status, set_heat.""" + form = await request.form + ids_raw = form.get("lead_ids", "").strip() + action = form.get("action", "").strip() + + if action not in ("set_status", "set_heat") or not ids_raw: + return "", 400 + + lead_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()] + assert len(lead_ids) <= 500, "too many lead IDs in bulk action" + if not lead_ids: + return "", 400 + + placeholders = ",".join("?" for _ in lead_ids) + + if action == "set_status": + target = form.get("target_status", "").strip() + if target not in LEAD_STATUSES: + return "", 400 + await execute( + f"UPDATE lead_requests SET status = ? WHERE id IN ({placeholders})", + (target, *lead_ids), + ) + + elif action == "set_heat": + target = form.get("target_heat", "").strip() + if target not in HEAT_OPTIONS: + return "", 400 + await execute( + f"UPDATE lead_requests SET heat_score = ? WHERE id IN ({placeholders})", + (target, *lead_ids), + ) + + # Re-render results partial with current filters + search = form.get("search", "").strip() + status_filter = form.get("status", "") + heat_filter = form.get("heat", "") + country_filter = form.get("country", "") + days_str = form.get("days", "") + days = int(days_str) if days_str.isdigit() else None + per_page = 50 + + lead_list, total = await get_leads( + status=status_filter or None, heat=heat_filter or None, + country=country_filter or None, search=search or None, + days=days, page=1, per_page=per_page, + ) + return await render_template( + "admin/partials/lead_results.html", + leads=lead_list, + page=1, + per_page=per_page, + total=total, + current_status=status_filter, + current_heat=heat_filter, + current_country=country_filter, + current_search=search, + current_days=days_str, + ) + + @bp.route("/leads/") @role_required("admin") async def lead_detail(lead_id: int): @@ -2430,6 +2495,101 @@ async def article_results(): ) +@bp.route("/articles/bulk", methods=["POST"]) +@role_required("admin") +@csrf_protect +async def articles_bulk(): + """Bulk actions on articles: publish, unpublish, toggle_noindex, rebuild, delete.""" + form = await request.form + ids_raw = form.get("article_ids", "").strip() + action = form.get("action", "").strip() + + valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete") + if action not in valid_actions or not ids_raw: + return "", 400 + + article_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()] + assert len(article_ids) <= 500, "too many article IDs in bulk action" + if not article_ids: + return "", 400 + + placeholders = ",".join("?" for _ in article_ids) + now = utcnow_iso() + + if action == "publish": + await execute( + f"UPDATE articles SET status = 'published', updated_at = ? WHERE id IN ({placeholders})", + (now, *article_ids), + ) + from ..sitemap import invalidate_sitemap_cache + invalidate_sitemap_cache() + + elif action == "unpublish": + await execute( + f"UPDATE articles SET status = 'draft', updated_at = ? WHERE id IN ({placeholders})", + (now, *article_ids), + ) + from ..sitemap import invalidate_sitemap_cache + invalidate_sitemap_cache() + + elif action == "toggle_noindex": + await execute( + f"UPDATE articles SET noindex = CASE WHEN noindex = 1 THEN 0 ELSE 1 END, updated_at = ? WHERE id IN ({placeholders})", + (now, *article_ids), + ) + + elif action == "rebuild": + for aid in article_ids: + await _rebuild_article(aid) + + elif action == "delete": + from ..content.routes import BUILD_DIR + + articles = await fetch_all( + f"SELECT id, slug FROM articles WHERE id IN ({placeholders})", + tuple(article_ids), + ) + for a in articles: + build_path = BUILD_DIR / f"{a['slug']}.html" + if build_path.exists(): + build_path.unlink() + md_path = Path("data/content/articles") / f"{a['slug']}.md" + if md_path.exists(): + md_path.unlink() + + await execute( + f"DELETE FROM articles WHERE id IN ({placeholders})", + tuple(article_ids), + ) + from ..sitemap import invalidate_sitemap_cache + invalidate_sitemap_cache() + + # Re-render results partial with current filters + search = form.get("search", "").strip() + status_filter = form.get("status", "") + template_filter = form.get("template", "") + language_filter = form.get("language", "") + + grouped = not language_filter + if grouped: + article_list = await _get_article_list_grouped( + status=status_filter or None, template_slug=template_filter or None, + search=search or None, + ) + else: + article_list = await _get_article_list( + status=status_filter or None, template_slug=template_filter or None, + language=language_filter or None, search=search or None, + ) + return await render_template( + "admin/partials/article_results.html", + articles=article_list, + grouped=grouped, + page=1, + is_generating=await _is_generating(), + ) + + @bp.route("/articles/new", methods=["GET", "POST"]) @role_required("admin") @csrf_protect diff --git a/web/src/padelnomics/admin/templates/admin/articles.html b/web/src/padelnomics/admin/templates/admin/articles.html index 80acc2a..5d744ca 100644 --- a/web/src/padelnomics/admin/templates/admin/articles.html +++ b/web/src/padelnomics/admin/templates/admin/articles.html @@ -70,8 +70,91 @@ + {# Bulk action bar #} + + + {# Results #}
{% include "admin/partials/article_results.html" %}
+ + {% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/leads.html b/web/src/padelnomics/admin/templates/admin/leads.html index 988b3a7..bb84e93 100644 --- a/web/src/padelnomics/admin/templates/admin/leads.html +++ b/web/src/padelnomics/admin/templates/admin/leads.html @@ -126,8 +126,103 @@ + + + +
{% include "admin/partials/lead_results.html" %}
+ + {% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/partials/article_group_row.html b/web/src/padelnomics/admin/templates/admin/partials/article_group_row.html index 200265a..c4f3c5f 100644 --- a/web/src/padelnomics/admin/templates/admin/partials/article_group_row.html +++ b/web/src/padelnomics/admin/templates/admin/partials/article_group_row.html @@ -1,4 +1,5 @@ +
{{ g.title }}
{{ g.url_path }}
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 5346c3a..4f8a14a 100644 --- a/web/src/padelnomics/admin/templates/admin/partials/article_results.html +++ b/web/src/padelnomics/admin/templates/admin/partials/article_results.html @@ -54,6 +54,11 @@ + {% if not grouped %} + + {% else %} + + {% endif %} diff --git a/web/src/padelnomics/admin/templates/admin/partials/article_row.html b/web/src/padelnomics/admin/templates/admin/partials/article_row.html index 470cb13..a125ac5 100644 --- a/web/src/padelnomics/admin/templates/admin/partials/article_row.html +++ b/web/src/padelnomics/admin/templates/admin/partials/article_row.html @@ -1,4 +1,8 @@ +
Title {% if grouped %}Variants{% else %}Status{% endif %} Published
+ + {{ a.title }} diff --git a/web/src/padelnomics/admin/templates/admin/partials/lead_results.html b/web/src/padelnomics/admin/templates/admin/partials/lead_results.html index 71dca83..73446e0 100644 --- a/web/src/padelnomics/admin/templates/admin/partials/lead_results.html +++ b/web/src/padelnomics/admin/templates/admin/partials/lead_results.html @@ -29,6 +29,7 @@ + @@ -43,6 +44,10 @@ {% for lead in leads %} +
ID Heat Contact
+ + #{{ lead.id }} {{ heat_badge(lead.heat_score) }}