Compare commits

..

7 Commits

Author SHA1 Message Date
Deeman
8d86669360 merge: article bulk select-all matching + cornerstone filter
All checks were successful
CI / test (push) Successful in 59s
CI / tag (push) Successful in 3s
2026-03-06 23:48:26 +01:00
Deeman
7d523250f7 feat(admin): article bulk select-all matching + cornerstone filter
- Extract _build_article_where() helper, eliminating duplicated WHERE
  logic from _get_article_list() and _get_article_list_grouped()
- Add template_slug='__manual__' sentinel → filters template_slug IS NULL
  (cornerstone / hand-written articles without a pSEO template)
- Add GET /articles/matching-count endpoint returning count of articles
  matching current filter params (for the Gmail-style select-all banner)
- Extend POST /articles/bulk with apply_to_all=true mode: builds WHERE
  from filter params instead of explicit IDs; rebuild capped at 2,000,
  delete at 5,000
- Add "Manual" option to Template filter dropdown
- Add Gmail-style "select all matching" banner: appears when select-all
  checkbox is checked, fetches total count, lets user switch to
  apply_to_all mode with confirmation dialog
- Sync filter hidden inputs into bulk form on filter change; changing
  filters resets apply-to-all state and clears selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:47:10 +01:00
Deeman
fee0d6913b fix(pipeline): use sqlmesh plan --auto-apply instead of run
All checks were successful
CI / test (push) Successful in 56s
CI / tag (push) Successful in 3s
2026-03-06 22:34:58 +01:00
Deeman
71e08a5fa6 fix(pipeline): also update supervisor.py to use plan --auto-apply
Missed the Python supervisor module — same fix as supervisor.sh and
worker.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:33:59 +01:00
Deeman
27e86db6a1 fix(pipeline): use sqlmesh plan --auto-apply instead of sqlmesh run
`sqlmesh run` only re-evaluates intervals for already-planned models —
it does not detect new, modified, or deleted models. Switching to
`plan prod --auto-apply` ensures schema changes (like the new
location_profiles model) are picked up automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:33:17 +01:00
Deeman
90754b8d9f chore: move ci.py to ~/.claude/scripts (uv inline script, no project dep)
All checks were successful
CI / test (push) Successful in 53s
CI / tag (push) Successful in 2s
Script now lives globally as a uv inline-dependency script.
Removes per-project scripts/ci.py and the msgspec dev dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:51:36 +01:00
Deeman
277c92e507 chore: add scripts/ci.py for Gitea CI pipeline status
Copies ci.py from beanflows (same script, shared across projects).
Adds msgspec dev dependency required by the script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:38:42 +01:00
6 changed files with 322 additions and 104 deletions

View File

@@ -33,10 +33,10 @@ do
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
uv run --package padelnomics_extract extract
# Transform — run evaluates missing daily intervals for incremental models.
# Transform — plan detects new/modified/deleted models and applies changes.
LANDING_DIR="${LANDING_DIR:-/data/padelnomics/landing}" \
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
uv run sqlmesh -p transform/sqlmesh_padelnomics run prod
uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply
# Export serving tables to analytics.duckdb (atomic swap).
# The web app detects the inode change on next query — no restart needed.

View File

@@ -247,10 +247,10 @@ def run_shell(cmd: str, timeout_seconds: int = SUBPROCESS_TIMEOUT_SECONDS) -> tu
def run_transform() -> None:
"""Run SQLMesh — evaluates missing daily intervals."""
"""Run SQLMesh — detects new/modified/deleted models and applies changes."""
logger.info("Running SQLMesh transform")
ok, err = run_shell(
"uv run sqlmesh -p transform/sqlmesh_padelnomics run prod",
"uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply",
)
if not ok:
send_alert(f"[transform] {err}")

5
uv.lock generated
View File

@@ -150,6 +150,11 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/87/ba6298c3d7f8d66ce80d7a487f2a487ebae74a79c6049c7c2990178ce529/brotlicffi-1.2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b13fb476a96f02e477a506423cb5e7bc21e0e3ac4c060c20ba31c44056e38c68", size = 433038, upload-time = "2026-03-05T17:57:37.96Z" },
{ url = "https://files.pythonhosted.org/packages/00/49/16c7a77d1cae0519953ef0389a11a9c2e2e62e87d04f8e7afbae40124255/brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17db36fb581f7b951635cd6849553a95c6f2f53c1a707817d06eae5aeff5f6af", size = 1541124, upload-time = "2026-03-05T17:57:39.488Z" },
{ url = "https://files.pythonhosted.org/packages/e8/17/fab2c36ea820e2288f8c1bf562de1b6cd9f30e28d66f1ce2929a4baff6de/brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40190192790489a7b054312163d0ce82b07d1b6e706251036898ce1684ef12e9", size = 1541983, upload-time = "2026-03-05T17:57:41.061Z" },
{ url = "https://files.pythonhosted.org/packages/78/c9/849a669b3b3bb8ac96005cdef04df4db658c33443a7fc704a6d4a2f07a56/brotlicffi-1.2.0.0-cp314-cp314t-win32.whl", hash = "sha256:a8079e8ecc32ecef728036a1d9b7105991ce6a5385cf51ee8c02297c90fb08c2", size = 349046, upload-time = "2026-03-05T17:57:42.76Z" },
{ url = "https://files.pythonhosted.org/packages/a4/25/09c0fd21cfc451fa38ad538f4d18d8be566746531f7f27143f63f8c45a9f/brotlicffi-1.2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ca90c4266704ca0a94de8f101b4ec029624273380574e4cf19301acfa46c61a0", size = 385653, upload-time = "2026-03-05T17:57:44.224Z" },
{ url = "https://files.pythonhosted.org/packages/e4/df/a72b284d8c7bef0ed5756b41c2eb7d0219a1dd6ac6762f1c7bdbc31ef3af/brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4", size = 432340, upload-time = "2025-11-21T18:17:42.277Z" },
{ url = "https://files.pythonhosted.org/packages/74/2b/cc55a2d1d6fb4f5d458fba44a3d3f91fb4320aa14145799fd3a996af0686/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7", size = 1534002, upload-time = "2025-11-21T18:17:43.746Z" },
{ url = "https://files.pythonhosted.org/packages/e4/9c/d51486bf366fc7d6735f0e46b5b96ca58dc005b250263525a1eea3cd5d21/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990", size = 1536547, upload-time = "2025-11-21T18:17:45.729Z" },

View File

@@ -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
@@ -2507,26 +2511,131 @@ 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"
# 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 or not ids_raw:
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"<p class='text-red-600 p-4'>Too many articles ({count:,}) for bulk rebuild"
f" — max 2,000. Narrow your filters first.</p>",
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)
now = utcnow_iso()
if action == "publish":
await execute(
@@ -2557,18 +2666,17 @@ async def articles_bulk():
elif action == "delete":
from ..content.routes import BUILD_DIR
articles = await fetch_all(
articles_rows = await fetch_all(
f"SELECT id, slug FROM articles WHERE id IN ({placeholders})",
tuple(article_ids),
)
for a in articles:
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),
@@ -2577,11 +2685,6 @@ async def articles_bulk():
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(

View File

@@ -48,6 +48,7 @@
<label class="text-xs font-semibold text-slate block mb-1">Template</label>
<select name="template" class="form-input" style="min-width:140px">
<option value="">All</option>
<option value="__manual__" {% if current_template == '__manual__' %}selected{% endif %}>Manual</option>
{% for t in template_slugs %}
<option value="{{ t }}" {% if t == current_template %}selected{% endif %}>{{ t }}</option>
{% endfor %}
@@ -75,12 +76,13 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="article_ids" id="article-bulk-ids" value="">
<input type="hidden" name="action" id="article-bulk-action" value="">
<input type="hidden" name="search" value="{{ current_search }}">
<input type="hidden" name="status" value="{{ current_status }}">
<input type="hidden" name="template" value="{{ current_template }}">
<input type="hidden" name="language" value="{{ current_language }}">
<input type="hidden" name="apply_to_all" id="article-bulk-apply-to-all" value="false">
<input type="hidden" name="search" id="article-bulk-search" value="{{ current_search }}">
<input type="hidden" name="status" id="article-bulk-status" value="{{ current_status }}">
<input type="hidden" name="template" id="article-bulk-template" value="{{ current_template }}">
<input type="hidden" name="language" id="article-bulk-language" value="{{ current_language }}">
</form>
<div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;background:#EFF6FF;border:1px solid #BFDBFE;">
<div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;flex-wrap:wrap;background:#EFF6FF;border:1px solid #BFDBFE;">
<span id="article-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
<select id="article-bulk-action-select" class="form-input" style="min-width:140px;padding:0.25rem 0.5rem;font-size:0.8125rem">
<option value="">Action…</option>
@@ -92,6 +94,20 @@
</select>
<button type="button" class="btn btn-sm" onclick="submitArticleBulk()">Apply</button>
<button type="button" class="btn-outline btn-sm" onclick="clearArticleSelection()">Clear</button>
<span id="article-select-all-banner" style="display:none;font-size:0.8125rem;color:#1E40AF;margin-left:0.5rem">
All <strong id="article-page-count"></strong> on this page selected.
<button type="button" onclick="enableApplyToAll()"
style="background:none;border:none;color:#1D4ED8;font-weight:600;cursor:pointer;text-decoration:underline;padding:0;font-size:inherit">
Select all <span id="article-matching-count"></span> matching instead?
</button>
</span>
<span id="article-apply-to-all-banner" style="display:none;font-size:0.8125rem;color:#991B1B;font-weight:600;margin-left:0.5rem">
All matching articles selected (<span id="article-matching-count-confirm"></span> total).
<button type="button" onclick="disableApplyToAll()"
style="background:none;border:none;color:#1D4ED8;font-weight:400;cursor:pointer;text-decoration:underline;padding:0;font-size:inherit">
Undo
</button>
</span>
</div>
{# Results #}
@@ -101,10 +117,13 @@
<script>
const articleSelectedIds = new Set();
let articleApplyToAll = false;
let articleMatchingCount = 0;
function toggleArticleSelect(id, checked) {
if (checked) articleSelectedIds.add(id);
else articleSelectedIds.delete(id);
disableApplyToAll();
updateArticleBulkBar();
}
@@ -114,30 +133,91 @@ function toggleArticleGroupSelect(checkbox) {
if (checkbox.checked) articleSelectedIds.add(id);
else articleSelectedIds.delete(id);
});
disableApplyToAll();
updateArticleBulkBar();
}
function clearArticleSelection() {
articleSelectedIds.clear();
articleApplyToAll = false;
document.querySelectorAll('.article-checkbox').forEach(function(cb) { cb.checked = false; });
var selectAll = document.getElementById('article-select-all');
if (selectAll) selectAll.checked = false;
updateArticleBulkBar();
}
function enableApplyToAll() {
articleApplyToAll = true;
document.getElementById('article-bulk-apply-to-all').value = 'true';
document.getElementById('article-select-all-banner').style.display = 'none';
document.getElementById('article-apply-to-all-banner').style.display = 'inline';
var confirmEl = document.getElementById('article-matching-count-confirm');
if (confirmEl) confirmEl.textContent = articleMatchingCount.toLocaleString();
document.getElementById('article-bulk-count').textContent = 'All matching selected';
}
function disableApplyToAll() {
articleApplyToAll = false;
document.getElementById('article-bulk-apply-to-all').value = 'false';
document.getElementById('article-select-all-banner').style.display = 'none';
document.getElementById('article-apply-to-all-banner').style.display = 'none';
}
function updateArticleBulkBar() {
var bar = document.getElementById('article-bulk-bar');
var count = document.getElementById('article-bulk-count');
var countEl = document.getElementById('article-bulk-count');
var ids = document.getElementById('article-bulk-ids');
bar.style.display = articleSelectedIds.size > 0 ? 'flex' : 'none';
count.textContent = articleSelectedIds.size + ' selected';
if (articleSelectedIds.size === 0 && !articleApplyToAll) {
bar.style.display = 'none';
return;
}
bar.style.display = 'flex';
if (!articleApplyToAll) {
countEl.textContent = articleSelectedIds.size + ' selected';
ids.value = Array.from(articleSelectedIds).join(',');
}
// Check if select-all is checked → show "select all matching" banner
var selectAll = document.getElementById('article-select-all');
var allOnPage = document.querySelectorAll('.article-checkbox');
var pageCount = 0;
allOnPage.forEach(function(cb) {
if (cb.dataset.ids) {
pageCount += (cb.dataset.ids || '').split(',').filter(Boolean).length;
} else {
pageCount += 1;
}
});
var selectAllBanner = document.getElementById('article-select-all-banner');
if (!articleApplyToAll && selectAll && selectAll.checked && pageCount > 0) {
document.getElementById('article-page-count').textContent = pageCount;
selectAllBanner.style.display = 'inline';
// Fetch count of matching articles
var params = new URLSearchParams({
search: document.getElementById('article-bulk-search').value,
status: document.getElementById('article-bulk-status').value,
template: document.getElementById('article-bulk-template').value,
language: document.getElementById('article-bulk-language').value,
});
fetch('{{ url_for("admin.articles_matching_count") }}?' + params.toString())
.then(function(r) { return r.text(); })
.then(function(text) {
articleMatchingCount = parseInt(text.replace(/,/g, ''), 10) || 0;
var el = document.getElementById('article-matching-count');
if (el) el.textContent = text;
});
} else if (!articleApplyToAll) {
selectAllBanner.style.display = 'none';
}
}
function submitArticleBulk() {
var action = document.getElementById('article-bulk-action-select').value;
if (!action) return;
if (articleSelectedIds.size === 0) return;
if (!articleApplyToAll && articleSelectedIds.size === 0) return;
function doSubmit() {
document.getElementById('article-bulk-action').value = action;
@@ -150,7 +230,13 @@ function submitArticleBulk() {
}
if (action === 'delete') {
showConfirm('Delete ' + articleSelectedIds.size + ' articles? This cannot be undone.').then(function(ok) {
var subject = articleApplyToAll
? 'Delete all ' + articleMatchingCount.toLocaleString() + ' matching articles? This cannot be undone.'
: 'Delete ' + articleSelectedIds.size + ' articles? This cannot be undone.';
showConfirm(subject).then(function(ok) { if (ok) doSubmit(); });
} else if (articleApplyToAll) {
var verb = action.charAt(0).toUpperCase() + action.slice(1);
showConfirm(verb + ' all ' + articleMatchingCount.toLocaleString() + ' matching articles?').then(function(ok) {
if (ok) doSubmit();
});
} else {
@@ -158,6 +244,30 @@ function submitArticleBulk() {
}
}
// Sync filter values into bulk form hidden inputs when filters change
document.addEventListener('DOMContentLoaded', function() {
var filterForm = document.querySelector('form[hx-get*="article_results"]');
if (!filterForm) return;
filterForm.addEventListener('change', syncBulkFilters);
filterForm.addEventListener('input', syncBulkFilters);
});
function syncBulkFilters() {
var filterForm = document.querySelector('form[hx-get*="article_results"]');
if (!filterForm) return;
var fd = new FormData(filterForm);
var searchEl = document.getElementById('article-bulk-search');
var statusEl = document.getElementById('article-bulk-status');
var templateEl = document.getElementById('article-bulk-template');
var languageEl = document.getElementById('article-bulk-language');
if (searchEl) searchEl.value = fd.get('search') || '';
if (statusEl) statusEl.value = fd.get('status') || '';
if (templateEl) templateEl.value = fd.get('template') || '';
if (languageEl) languageEl.value = fd.get('language') || '';
// Changing filters clears apply-to-all and resets selection
clearArticleSelection();
}
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'article-results') {
document.querySelectorAll('.article-checkbox').forEach(function(cb) {

View File

@@ -737,9 +737,9 @@ async def handle_run_extraction(payload: dict) -> None:
@task("run_transform")
async def handle_run_transform(payload: dict) -> None:
"""Run SQLMesh transform (prod run) in the background.
"""Run SQLMesh transform (prod plan + apply) in the background.
Shells out to `uv run sqlmesh -p transform/sqlmesh_padelnomics run prod`.
Shells out to `uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply`.
2-hour absolute timeout — same as extraction.
"""
import subprocess
@@ -748,7 +748,7 @@ async def handle_run_transform(payload: dict) -> None:
repo_root = Path(__file__).resolve().parents[4]
result = await asyncio.to_thread(
subprocess.run,
["uv", "run", "sqlmesh", "-p", "transform/sqlmesh_padelnomics", "run", "prod"],
["uv", "run", "sqlmesh", "-p", "transform/sqlmesh_padelnomics", "plan", "prod", "--auto-apply"],
capture_output=True,
text=True,
timeout=7200,
@@ -803,7 +803,7 @@ async def handle_run_pipeline(payload: dict) -> None:
),
(
"transform",
["uv", "run", "sqlmesh", "-p", "transform/sqlmesh_padelnomics", "run", "prod"],
["uv", "run", "sqlmesh", "-p", "transform/sqlmesh_padelnomics", "plan", "prod", "--auto-apply"],
7200,
),
(