diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index bf649b7..b0729fc 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -2275,15 +2275,17 @@ async def _sync_static_articles() -> None: ) -async def _get_article_list( +def _build_article_where( status: str = None, template_slug: str = None, language: str = None, search: str = None, - page: int = 1, - per_page: int = 50, -) -> list[dict]: - """Get articles with optional filters and pagination.""" +) -> tuple[list[str], list]: + """Build WHERE clauses and params for article queries. + + template_slug='__manual__' filters for articles with template_slug IS NULL + (cornerstone / manually written articles, no pSEO template). + """ wheres = ["1=1"] params: list = [] @@ -2293,7 +2295,9 @@ async def _get_article_list( wheres.append("status = 'published' AND published_at > datetime('now')") elif status == "draft": wheres.append("status = 'draft'") - if template_slug: + if template_slug == "__manual__": + wheres.append("template_slug IS NULL") + elif template_slug: wheres.append("template_slug = ?") params.append(template_slug) if language: @@ -2303,6 +2307,20 @@ async def _get_article_list( wheres.append("title LIKE ?") params.append(f"%{search}%") + return wheres, params + + +async def _get_article_list( + status: str = None, + template_slug: str = None, + language: str = None, + search: str = None, + page: int = 1, + per_page: int = 50, +) -> list[dict]: + """Get articles with optional filters and pagination.""" + wheres, params = _build_article_where(status=status, template_slug=template_slug, + language=language, search=search) where = " AND ".join(wheres) offset = (page - 1) * per_page params.extend([per_page, offset]) @@ -2332,22 +2350,8 @@ async def _get_article_list_grouped( Static cornerstones (group_key e.g. 'C2') group by cornerstone key regardless of url_path. Each returned item has a 'variants' list (one dict per language variant). """ - wheres = ["1=1"] - params: list = [] - - if status == "live": - wheres.append("status = 'published' AND published_at <= datetime('now')") - elif status == "scheduled": - wheres.append("status = 'published' AND published_at > datetime('now')") - elif status == "draft": - wheres.append("status = 'draft'") - if template_slug: - wheres.append("template_slug = ?") - params.append(template_slug) - if search: - wheres.append("title LIKE ?") - params.append(f"%{search}%") - + wheres, params = _build_article_where(status=status, template_slug=template_slug, + search=search) where = " AND ".join(wheres) offset = (page - 1) * per_page @@ -2495,81 +2499,180 @@ async def article_results(): ) +@bp.route("/articles/matching-count") +@role_required("admin") +async def articles_matching_count(): + """Return count of articles matching current filters (for bulk select-all banner).""" + status_filter = request.args.get("status", "") + template_filter = request.args.get("template", "") + language_filter = request.args.get("language", "") + search = request.args.get("search", "").strip() + + wheres, params = _build_article_where( + status=status_filter or None, + template_slug=template_filter or None, + language=language_filter or None, + search=search or None, + ) + where = " AND ".join(wheres) + row = await fetch_one(f"SELECT COUNT(*) AS cnt FROM articles WHERE {where}", tuple(params)) + count = row["cnt"] if row else 0 + return f"{count:,}" + + @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.""" + """Bulk actions on articles: publish, unpublish, toggle_noindex, rebuild, delete. + + Supports two modes: + - Explicit IDs: article_ids=1,2,3 (max 500) + - Apply to all matching: apply_to_all=true + filter params (rebuild capped at 2000, delete at 5000) + """ form = await request.form - ids_raw = form.get("article_ids", "").strip() action = form.get("action", "").strip() + apply_to_all = form.get("apply_to_all", "").strip() == "true" - 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 + # Common filter params (used for action scope and re-render) search = form.get("search", "").strip() status_filter = form.get("status", "") template_filter = form.get("template", "") language_filter = form.get("language", "") + valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete") + if action not in valid_actions: + return "", 400 + + now = utcnow_iso() + + if apply_to_all: + wheres, where_params = _build_article_where( + status=status_filter or None, + template_slug=template_filter or None, + language=language_filter or None, + search=search or None, + ) + where = " AND ".join(wheres) + + if action == "rebuild": + count_row = await fetch_one( + f"SELECT COUNT(*) AS cnt FROM articles WHERE {where}", tuple(where_params) + ) + count = count_row["cnt"] if count_row else 0 + if count > 2000: + return ( + f"
Too many articles ({count:,}) for bulk rebuild" + f" — max 2,000. Narrow your filters first.
", + 400, + ) + + if action == "publish": + await execute( + f"UPDATE articles SET status = 'published', updated_at = ? WHERE {where}", + (now, *where_params), + ) + from ..sitemap import invalidate_sitemap_cache + invalidate_sitemap_cache() + + elif action == "unpublish": + await execute( + f"UPDATE articles SET status = 'draft', updated_at = ? WHERE {where}", + (now, *where_params), + ) + 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," + f" updated_at = ? WHERE {where}", + (now, *where_params), + ) + + elif action == "rebuild": + rows = await fetch_all( + f"SELECT id FROM articles WHERE {where} LIMIT 2000", tuple(where_params) + ) + for r in rows: + await _rebuild_article(r["id"]) + + elif action == "delete": + from ..content.routes import BUILD_DIR + + rows = await fetch_all( + f"SELECT id, slug FROM articles WHERE {where} LIMIT 5000", tuple(where_params) + ) + for a in rows: + 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 {where}", tuple(where_params)) + from ..sitemap import invalidate_sitemap_cache + invalidate_sitemap_cache() + + else: + ids_raw = form.get("article_ids", "").strip() + if 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) + + 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_rows = await fetch_all( + f"SELECT id, slug FROM articles WHERE id IN ({placeholders})", + tuple(article_ids), + ) + for a in articles_rows: + 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 grouped = not language_filter if grouped: article_list = await _get_article_list_grouped( diff --git a/web/src/padelnomics/admin/templates/admin/articles.html b/web/src/padelnomics/admin/templates/admin/articles.html index bf9990d..ad08fc8 100644 --- a/web/src/padelnomics/admin/templates/admin/articles.html +++ b/web/src/padelnomics/admin/templates/admin/articles.html @@ -48,6 +48,7 @@