Add bulk approval for selected admin reviews
Build and Publish / Build and Publish Docker Image (push) Successful in 6m10s
Build and Publish / Build and Publish Docker Image (push) Successful in 6m10s
This commit is contained in:
1 parent
59345a8d22
commit
bcb5d378c8
3 files changed
+264
-33
No files matched your search
+132
-26
@@ -432,6 +432,8 @@ struct JobRunDetailDto {
|
|||||||
struct BulkReviewsResponse {
|
struct BulkReviewsResponse {
|
||||||
ok: bool,
|
ok: bool,
|
||||||
affected: u64,
|
affected: u64,
|
||||||
|
failed: u64,
|
||||||
|
skipped: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -989,10 +991,14 @@ pub async fn bulk_reviews(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let action = body.action.trim();
|
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"));
|
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 mode = body.mode.as_deref().unwrap_or("ids");
|
||||||
let affected = if mode == "filter" {
|
let affected = if mode == "filter" {
|
||||||
apply_review_filter_action(pool, action, body.filter.unwrap_or_default()).await?
|
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(
|
pub async fn approve_review(
|
||||||
@@ -1042,31 +1140,9 @@ pub async fn approve_review(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.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 (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
let stats = crate::scheduler::ProcessingStats::get_by_review_id(&db, review_id)
|
match finalize_review_approval(&db, pool, &live_config, &mut review, &normalized).await {
|
||||||
.await
|
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||||
.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()
|
|
||||||
}
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::error!(?error, "review approval failed");
|
tracing::error!(?error, "review approval failed");
|
||||||
let _ = review.set_rejected(&db).await;
|
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(
|
pub async fn jobs(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
|
|||||||
+28
-7
@@ -2129,26 +2129,30 @@ tbody tr:hover {
|
|||||||
|
|
||||||
<div class="action-strip">
|
<div class="action-strip">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="btn" @click="selectVisibleReviews()">
|
<button class="btn" @click="selectVisibleReviews()" :disabled="reviewBulkBusy">
|
||||||
<i data-lucide="check-square"></i>
|
<i data-lucide="check-square"></i>
|
||||||
Select visible
|
Select visible
|
||||||
</button>
|
</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>
|
<i data-lucide="list-checks"></i>
|
||||||
<span x-text="`Select all (${fmt(reviews.total)})`"></span>
|
<span x-text="`Select all (${fmt(reviews.total)})`"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn" @click="clearReviewSelection()" :disabled="selectedReviewCount() === 0">
|
<button class="btn" @click="clearReviewSelection()" :disabled="reviewBulkBusy || selectedReviewCount() === 0">
|
||||||
<i data-lucide="x"></i>
|
<i data-lucide="x"></i>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<span class="selection-summary" x-text="reviewSelectionSummary()"></span>
|
<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>
|
<i data-lucide="rotate-ccw"></i>
|
||||||
Requeue selected
|
Requeue selected
|
||||||
</button>
|
</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>
|
<i data-lucide="trash-2"></i>
|
||||||
Delete selected
|
Delete selected
|
||||||
</button>
|
</button>
|
||||||
@@ -2172,7 +2176,7 @@ tbody tr:hover {
|
|||||||
<template x-for="row in reviews.items" :key="row.id">
|
<template x-for="row in reviews.items" :key="row.id">
|
||||||
<tr @click="openReview(row)">
|
<tr @click="openReview(row)">
|
||||||
<td class="col-check" @click.stop>
|
<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>
|
||||||
<td><span class="badge" :class="row.status" x-text="row.status"></span></td>
|
<td><span class="badge" :class="row.status" x-text="row.status"></span></td>
|
||||||
<td>
|
<td>
|
||||||
@@ -3834,6 +3838,8 @@ function adminV2() {
|
|||||||
reviewFilter: { status: null, search: '' },
|
reviewFilter: { status: null, search: '' },
|
||||||
selectedReviewIds: {},
|
selectedReviewIds: {},
|
||||||
reviewSelectionScope: 'ids',
|
reviewSelectionScope: 'ids',
|
||||||
|
reviewBulkBusy: false,
|
||||||
|
reviewBulkAction: null,
|
||||||
activeReview: null,
|
activeReview: null,
|
||||||
reviewDraft: {
|
reviewDraft: {
|
||||||
title: '',
|
title: '',
|
||||||
@@ -4830,6 +4836,11 @@ function adminV2() {
|
|||||||
return Object.keys(this.selectedReviewIds).length;
|
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() {
|
reviewSelectionSummary() {
|
||||||
const total = this.selectedReviewCount();
|
const total = this.selectedReviewCount();
|
||||||
if (!total) return 'Nothing selected';
|
if (!total) return 'Nothing selected';
|
||||||
@@ -4852,8 +4863,10 @@ function adminV2() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async bulkReviews(action) {
|
async bulkReviews(action) {
|
||||||
|
if (this.reviewBulkBusy) return;
|
||||||
const count = this.selectedReviewCount();
|
const count = this.selectedReviewCount();
|
||||||
if (!count) return;
|
if (!count) return;
|
||||||
|
if (action === 'approve' && !this.selectedPendingReviewCount()) return;
|
||||||
if (action === 'delete' && !confirm(`Delete ${count} review row(s)?`)) return;
|
if (action === 'delete' && !confirm(`Delete ${count} review row(s)?`)) return;
|
||||||
const payload = {
|
const payload = {
|
||||||
action,
|
action,
|
||||||
@@ -4864,16 +4877,24 @@ function adminV2() {
|
|||||||
search: this.reviewFilter.search || null
|
search: this.reviewFilter.search || null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
this.reviewBulkBusy = true;
|
||||||
|
this.reviewBulkAction = action;
|
||||||
try {
|
try {
|
||||||
const result = await this.request(`${this.apiBase}/reviews/bulk`, {
|
const result = await this.request(`${this.apiBase}/reviews/bulk`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload)
|
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();
|
this.clearReviewSelection();
|
||||||
await this.loadReviews(false);
|
await this.loadReviews(false);
|
||||||
|
if (action === 'approve') await this.loadLibrary(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showToast(error.message);
|
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