Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcb5d378c8 |
No files matched your search
+132
-26
@@ -432,6 +432,8 @@ struct JobRunDetailDto {
|
||||
struct BulkReviewsResponse {
|
||||
ok: bool,
|
||||
affected: u64,
|
||||
failed: u64,
|
||||
skipped: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
@@ -989,10 +991,14 @@ pub async fn bulk_reviews(
|
||||
}
|
||||
|
||||
let action = body.action.trim();
|
||||
if action != "delete" && action != "requeue" {
|
||||
if action != "delete" && action != "requeue" && action != "approve" {
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, "unknown bulk action"));
|
||||
}
|
||||
|
||||
if action == "approve" {
|
||||
return Json(approve_selected_reviews(&db, pool, &body).await?).into_response();
|
||||
}
|
||||
|
||||
let mode = body.mode.as_deref().unwrap_or("ids");
|
||||
let affected = if mode == "filter" {
|
||||
apply_review_filter_action(pool, action, body.filter.unwrap_or_default()).await?
|
||||
@@ -1016,7 +1022,99 @@ pub async fn bulk_reviews(
|
||||
}
|
||||
};
|
||||
|
||||
Json(BulkReviewsResponse { ok: true, affected }).into_response()
|
||||
Json(BulkReviewsResponse {
|
||||
ok: true,
|
||||
affected,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn selected_reviews_query(body: &BulkReviewsRequest) -> QueryBuilder<'static, Postgres> {
|
||||
let mut qb = QueryBuilder::new("SELECT id FROM furumusic__pending_review WHERE 1=1");
|
||||
if body.mode.as_deref() == Some("filter") {
|
||||
let filter = body.filter.clone().unwrap_or_default();
|
||||
push_review_filters(
|
||||
&mut qb,
|
||||
normalize_status(filter.status.as_deref()),
|
||||
clean_search(filter.search.as_deref()).map(|s| format!("%{s}%")),
|
||||
);
|
||||
} else {
|
||||
qb.push(" AND id = ANY(");
|
||||
qb.push_bind(body.ids.clone().unwrap_or_default());
|
||||
qb.push(")");
|
||||
}
|
||||
qb.push(" ORDER BY id");
|
||||
qb
|
||||
}
|
||||
|
||||
async fn approve_selected_reviews(
|
||||
db: &Database,
|
||||
pool: &PgPool,
|
||||
body: &BulkReviewsRequest,
|
||||
) -> cot::Result<BulkReviewsResponse> {
|
||||
let ids = selected_reviews_query(body)
|
||||
.build_query_as::<IdRow>()
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let (config, _) = AppConfig::load_with_db(db).await;
|
||||
let mut result = BulkReviewsResponse {
|
||||
ok: true,
|
||||
affected: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
for row in ids {
|
||||
let Some(mut review) = crate::scheduler::PendingReview::get_by_id(db, row.id)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?
|
||||
else {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
if review.status_str() != "pending" {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Claim one row at a time so overlapping bulk requests cannot import it twice.
|
||||
let claimed = sqlx::query(
|
||||
"UPDATE furumusic__pending_review SET status = 'processing', updated_at = $2 \
|
||||
WHERE id = $1 AND status = 'pending'",
|
||||
)
|
||||
.bind(row.id)
|
||||
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
if claimed.rows_affected() == 0 {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let approval = async {
|
||||
let normalized = serde_json::from_str::<crate::agent::dto::NormalizedFields>(
|
||||
review.result_json_str(),
|
||||
)?;
|
||||
finalize_review_approval(db, pool, &config, &mut review, &normalized).await
|
||||
}
|
||||
.await;
|
||||
match approval {
|
||||
Ok(()) => result.affected += 1,
|
||||
Err(error) => {
|
||||
tracing::error!(review_id = row.id, ?error, "bulk review approval failed");
|
||||
review
|
||||
.set_failed(db, &format!("Review approval failed: {error:#}"))
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
result.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.ok = result.failed == 0;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn approve_review(
|
||||
@@ -1042,31 +1140,9 @@ pub async fn approve_review(
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let context: serde_json::Value =
|
||||
serde_json::from_str(review.context_json_str()).unwrap_or_default();
|
||||
let input_path = review.input_path_str().to_owned();
|
||||
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||
let stats = crate::scheduler::ProcessingStats::get_by_review_id(&db, review_id)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let model_name = stats.as_ref().map(|s| s.model_name.to_string());
|
||||
|
||||
match crate::jobs::inbox_process::finalize_approved(
|
||||
&db,
|
||||
pool,
|
||||
&live_config,
|
||||
&input_path,
|
||||
&normalized,
|
||||
&context,
|
||||
&live_config.agent_storage_dir,
|
||||
model_name.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let _ = review.set_approved(&db).await;
|
||||
Json(serde_json::json!({ "ok": true })).into_response()
|
||||
}
|
||||
match finalize_review_approval(&db, pool, &live_config, &mut review, &normalized).await {
|
||||
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||
Err(error) => {
|
||||
tracing::error!(?error, "review approval failed");
|
||||
let _ = review.set_rejected(&db).await;
|
||||
@@ -1078,6 +1154,36 @@ pub async fn approve_review(
|
||||
}
|
||||
}
|
||||
|
||||
async fn finalize_review_approval(
|
||||
db: &Database,
|
||||
pool: &PgPool,
|
||||
config: &AppConfig,
|
||||
review: &mut crate::scheduler::PendingReview,
|
||||
normalized: &crate::agent::dto::NormalizedFields,
|
||||
) -> anyhow::Result<()> {
|
||||
let context: serde_json::Value =
|
||||
serde_json::from_str(review.context_json_str()).unwrap_or_default();
|
||||
let input_path = review.input_path_str().to_owned();
|
||||
let stats = crate::scheduler::ProcessingStats::get_by_review_id(db, review.id_val())
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let model_name = stats.as_ref().map(|s| s.model_name.to_string());
|
||||
|
||||
crate::jobs::inbox_process::finalize_approved(
|
||||
db,
|
||||
pool,
|
||||
config,
|
||||
&input_path,
|
||||
normalized,
|
||||
&context,
|
||||
&config.agent_storage_dir,
|
||||
model_name.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
review.set_approved(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn jobs(
|
||||
session: Session,
|
||||
db: Database,
|
||||
|
||||
+28
-7
@@ -2129,26 +2129,30 @@ tbody tr:hover {
|
||||
|
||||
<div class="action-strip">
|
||||
<div class="toolbar">
|
||||
<button class="btn" @click="selectVisibleReviews()">
|
||||
<button class="btn" @click="selectVisibleReviews()" :disabled="reviewBulkBusy">
|
||||
<i data-lucide="check-square"></i>
|
||||
Select visible
|
||||
</button>
|
||||
<button class="btn" @click="selectReviewFilter()" :disabled="reviews.total === 0">
|
||||
<button class="btn" @click="selectReviewFilter()" :disabled="reviewBulkBusy || reviews.total === 0">
|
||||
<i data-lucide="list-checks"></i>
|
||||
<span x-text="`Select all (${fmt(reviews.total)})`"></span>
|
||||
</button>
|
||||
<button class="btn" @click="clearReviewSelection()" :disabled="selectedReviewCount() === 0">
|
||||
<button class="btn" @click="clearReviewSelection()" :disabled="reviewBulkBusy || selectedReviewCount() === 0">
|
||||
<i data-lucide="x"></i>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<span class="selection-summary" x-text="reviewSelectionSummary()"></span>
|
||||
<button class="btn warn" @click="bulkReviews('requeue')" :disabled="selectedReviewCount() === 0">
|
||||
<button class="btn primary" @click="bulkReviews('approve')" :disabled="reviewBulkBusy || selectedPendingReviewCount() === 0">
|
||||
<i data-lucide="check"></i>
|
||||
<span x-text="reviewBulkBusy && reviewBulkAction === 'approve' ? 'Approving…' : 'Approve selected'"></span>
|
||||
</button>
|
||||
<button class="btn warn" @click="bulkReviews('requeue')" :disabled="reviewBulkBusy || selectedReviewCount() === 0">
|
||||
<i data-lucide="rotate-ccw"></i>
|
||||
Requeue selected
|
||||
</button>
|
||||
<button class="btn danger" @click="bulkReviews('delete')" :disabled="selectedReviewCount() === 0">
|
||||
<button class="btn danger" @click="bulkReviews('delete')" :disabled="reviewBulkBusy || selectedReviewCount() === 0">
|
||||
<i data-lucide="trash-2"></i>
|
||||
Delete selected
|
||||
</button>
|
||||
@@ -2172,7 +2176,7 @@ tbody tr:hover {
|
||||
<template x-for="row in reviews.items" :key="row.id">
|
||||
<tr @click="openReview(row)">
|
||||
<td class="col-check" @click.stop>
|
||||
<input class="check" type="checkbox" :checked="isReviewSelected(row.id)" @change="toggleReview(row)" />
|
||||
<input class="check" type="checkbox" :checked="isReviewSelected(row.id)" @change="toggleReview(row)" :disabled="reviewBulkBusy" />
|
||||
</td>
|
||||
<td><span class="badge" :class="row.status" x-text="row.status"></span></td>
|
||||
<td>
|
||||
@@ -3834,6 +3838,8 @@ function adminV2() {
|
||||
reviewFilter: { status: null, search: '' },
|
||||
selectedReviewIds: {},
|
||||
reviewSelectionScope: 'ids',
|
||||
reviewBulkBusy: false,
|
||||
reviewBulkAction: null,
|
||||
activeReview: null,
|
||||
reviewDraft: {
|
||||
title: '',
|
||||
@@ -4830,6 +4836,11 @@ function adminV2() {
|
||||
return Object.keys(this.selectedReviewIds).length;
|
||||
},
|
||||
|
||||
selectedPendingReviewCount() {
|
||||
if (this.reviewSelectionScope === 'filter') return this.filterStatusCounts().pending || 0;
|
||||
return Object.values(this.selectedReviewIds).filter(status => status === 'pending').length;
|
||||
},
|
||||
|
||||
reviewSelectionSummary() {
|
||||
const total = this.selectedReviewCount();
|
||||
if (!total) return 'Nothing selected';
|
||||
@@ -4852,8 +4863,10 @@ function adminV2() {
|
||||
},
|
||||
|
||||
async bulkReviews(action) {
|
||||
if (this.reviewBulkBusy) return;
|
||||
const count = this.selectedReviewCount();
|
||||
if (!count) return;
|
||||
if (action === 'approve' && !this.selectedPendingReviewCount()) return;
|
||||
if (action === 'delete' && !confirm(`Delete ${count} review row(s)?`)) return;
|
||||
const payload = {
|
||||
action,
|
||||
@@ -4864,16 +4877,24 @@ function adminV2() {
|
||||
search: this.reviewFilter.search || null
|
||||
}
|
||||
};
|
||||
this.reviewBulkBusy = true;
|
||||
this.reviewBulkAction = action;
|
||||
try {
|
||||
const result = await this.request(`${this.apiBase}/reviews/bulk`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
this.showToast(`${result.affected} review row(s) updated`);
|
||||
this.showToast(action === 'approve'
|
||||
? `${result.affected} review(s) approved · ${result.failed || 0} failed · ${result.skipped || 0} skipped`
|
||||
: `${result.affected} review row(s) updated`);
|
||||
this.clearReviewSelection();
|
||||
await this.loadReviews(false);
|
||||
if (action === 'approve') await this.loadLibrary(false);
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
} finally {
|
||||
this.reviewBulkBusy = false;
|
||||
this.reviewBulkAction = null;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { test } = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const template = readFileSync(`${__dirname}/../templates/admin/v2.html`, 'utf8');
|
||||
const script = template.match(/<script>\s*(function adminV2\(\)[\s\S]*?)<\/script>/)[1];
|
||||
|
||||
function createApp() {
|
||||
const context = vm.createContext({});
|
||||
vm.runInContext(script, context);
|
||||
const app = context.adminV2();
|
||||
app.showToast = message => { app.toastMessage = message; };
|
||||
app.loadReviews = async () => { app.reviewsReloaded = true; };
|
||||
app.loadLibrary = async () => { app.libraryReloaded = true; };
|
||||
return app;
|
||||
}
|
||||
|
||||
test('approves individually selected reviews and reports partial failures', async () => {
|
||||
const app = createApp();
|
||||
app.toggleReview({ id: 12, status: 'pending' });
|
||||
app.toggleReview({ id: 34, status: 'pending' });
|
||||
app.toggleReview({ id: 56, status: 'failed' });
|
||||
assert.equal(app.selectedPendingReviewCount(), 2);
|
||||
app.request = async (url, options) => {
|
||||
assert.equal(url, '/admin/v2/api/reviews/bulk');
|
||||
assert.equal(options.method, 'POST');
|
||||
assert.deepEqual(JSON.parse(options.body), {
|
||||
action: 'approve', mode: 'ids', ids: [12, 34, 56],
|
||||
filter: { status: null, search: null }
|
||||
});
|
||||
assert.equal(app.reviewBulkBusy, true);
|
||||
return { ok: false, affected: 1, failed: 1, skipped: 1 };
|
||||
};
|
||||
await app.bulkReviews('approve');
|
||||
assert.equal(app.toastMessage, '1 review(s) approved · 1 failed · 1 skipped');
|
||||
assert.equal(app.selectedReviewCount(), 0);
|
||||
assert.equal(app.reviewsReloaded, true);
|
||||
assert.equal(app.libraryReloaded, true);
|
||||
assert.equal(app.reviewBulkBusy, false);
|
||||
});
|
||||
|
||||
test('select all approves the whole searched filter across pages', async () => {
|
||||
const app = createApp();
|
||||
app.reviewFilter = { status: 'pending', search: 'Album' };
|
||||
app.reviews = { total: 320, items: [{ id: 1, status: 'pending' }] };
|
||||
app.selectReviewFilter();
|
||||
assert.equal(app.selectedPendingReviewCount(), 320);
|
||||
app.request = async (_url, options) => {
|
||||
assert.deepEqual(JSON.parse(options.body), {
|
||||
action: 'approve', mode: 'filter', ids: [],
|
||||
filter: { status: 'pending', search: 'Album' }
|
||||
});
|
||||
return { affected: 320, failed: 0, skipped: 0 };
|
||||
};
|
||||
await app.bulkReviews('approve');
|
||||
assert.equal(app.toastMessage, '320 review(s) approved · 0 failed · 0 skipped');
|
||||
});
|
||||
|
||||
test('mixed status filter enables approval only when pending reviews exist', () => {
|
||||
const app = createApp();
|
||||
app.reviews = { total: 10, status_counts: [{ status: 'pending', count: 3 }, { status: 'queued', count: 7 }] };
|
||||
app.selectReviewFilter();
|
||||
assert.equal(app.selectedPendingReviewCount(), 3);
|
||||
app.reviewFilter.status = 'queued';
|
||||
assert.equal(app.selectedPendingReviewCount(), 0);
|
||||
});
|
||||
|
||||
test('empty and non-pending selections do not submit approval', async () => {
|
||||
const app = createApp();
|
||||
app.request = async () => { assert.fail('unexpected request'); };
|
||||
await app.bulkReviews('approve');
|
||||
app.toggleReview({ id: 1, status: 'queued' });
|
||||
await app.bulkReviews('approve');
|
||||
});
|
||||
|
||||
test('blocks duplicate bulk submissions while approval is running', async () => {
|
||||
const app = createApp();
|
||||
app.toggleReview({ id: 1, status: 'pending' });
|
||||
let finish;
|
||||
let calls = 0;
|
||||
app.request = async () => {
|
||||
calls += 1;
|
||||
return new Promise(resolve => { finish = resolve; });
|
||||
};
|
||||
const approval = app.bulkReviews('approve');
|
||||
await app.bulkReviews('approve');
|
||||
await app.bulkReviews('requeue');
|
||||
assert.equal(calls, 1);
|
||||
finish({ affected: 1 });
|
||||
await approval;
|
||||
assert.equal(app.reviewBulkBusy, false);
|
||||
});
|
||||
|
||||
test('request failure retains selection and restores bulk controls', async () => {
|
||||
const app = createApp();
|
||||
app.toggleReview({ id: 1, status: 'pending' });
|
||||
app.request = async () => { throw new Error('Connection lost'); };
|
||||
await app.bulkReviews('approve');
|
||||
assert.equal(app.selectedReviewCount(), 1);
|
||||
assert.equal(app.toastMessage, 'Connection lost');
|
||||
assert.equal(app.reviewBulkBusy, false);
|
||||
assert.equal(app.reviewBulkAction, null);
|
||||
});
|
||||
Reference in new issue
Block a user