From 65da460c0cc5936c703be07476225ada3e972d1d Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 27 May 2026 15:03:06 +0300 Subject: [PATCH] ADMIN: added pending review --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/admin/mod.rs | 38 +++++++- src/admin/v2.rs | 139 +++++++++++++++++++++++++++++ src/admin/views.rs | 120 +++++++++++++++++++++++-- src/scheduler/mod.rs | 10 +++ templates/admin/review_detail.html | 60 +++++++++++-- templates/admin/v2.html | 95 +++++++++++++++++++- 8 files changed, 444 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ef71e2..b956316 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.1.17" +version = "0.1.18" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 5f60c00..43a315e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.1.18" +version = "0.1.19" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 632b7f8..e71d3a4 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -20,8 +20,8 @@ use crate::i18n::I18n; use crate::scheduler::{JobRegistry, SchedulerHandle}; use crate::user::User; use views::{ - ArtistForm, CronForm, MetadataBackfillForm, OidcSettingsForm, ReleaseForm, ReviewsBulkForm, - SetImageBody, SetupForm, UploadImageBody, UserForm, + ArtistForm, CronForm, MetadataBackfillForm, OidcSettingsForm, ReleaseForm, ReviewApproveForm, + ReviewsBulkForm, SetImageBody, SetupForm, UploadImageBody, UserForm, }; #[derive(Debug, Deserialize)] @@ -227,6 +227,35 @@ impl App for AdminApp { }, "admin_v2_reviews_bulk", ), + Route::with_handler_and_name( + "/v2/api/reviews/{id}/approve", + { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + cot::router::method::post( + move |session: Session, + db: Database, + path: Path, + json: Json| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("admin pool") + }) + .await; + v2::approve_review(session, db, pg_pool, path.0.id, json).await + } + }, + ) + }, + "admin_v2_review_approve", + ), Route::with_handler_and_name( "/v2/api/jobs", { @@ -1048,7 +1077,8 @@ impl App for AdminApp { let config = Arc::clone(&self.config); let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, path: Path| { + move |session: Session, db: Database, path: Path, + form: RequestForm| { let config = Arc::clone(&config); let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); @@ -1064,7 +1094,7 @@ impl App for AdminApp { .await .expect("admin pool") }).await; - views::review_approve(admin, &config, &db, pg_pool, path.0.id).await + views::review_approve(admin, &config, &db, pg_pool, path.0.id, form).await } } }), diff --git a/src/admin/v2.rs b/src/admin/v2.rs index ae53ecf..1ae7af4 100644 --- a/src/admin/v2.rs +++ b/src/admin/v2.rs @@ -156,10 +156,24 @@ struct ReviewDto { token_count: Option, tags: Vec, error_message: Option, + normalized: ReviewEditDto, created_at: String, updated_at: String, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +pub(super) struct ReviewEditDto { + title: String, + artist: String, + album: String, + year: String, + track_number: String, + genre: String, + featured_artists: String, + release_type: String, + notes: String, +} + #[derive(Debug, Serialize, JsonSchema)] struct JobDto { name: String, @@ -533,6 +547,65 @@ pub async fn bulk_reviews( Json(BulkReviewsResponse { ok: true, affected }).into_response() } +pub async fn approve_review( + session: Session, + db: Database, + pool: &PgPool, + review_id: i64, + Json(body): Json, +) -> cot::Result { + if let Err(response) = require_admin_json(&session, &db).await { + return Ok(response); + } + + let mut review = crate::scheduler::PendingReview::get_by_id(&db, review_id) + .await + .map_err(|e| cot::Error::internal(e.to_string()))? + .ok_or_else(|| cot::Error::internal("review not found"))?; + let normalized = normalized_from_review_edit(&body); + let result_json = serde_json::to_string(&normalized) + .map_err(|e| cot::Error::internal(format!("failed to serialize review fields: {e}")))?; + review + .set_result_json(&db, result_json) + .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() + } + Err(error) => { + tracing::error!(?error, "review approval failed"); + let _ = review.set_rejected(&db).await; + Ok(json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "review approval failed", + )) + } + } +} + pub async fn jobs( session: Session, db: Database, @@ -1209,6 +1282,11 @@ fn review_dto( .as_deref() .and_then(|json| serde_json::from_str::(json).ok()) .and_then(|value| value.get("confidence").and_then(|v| v.as_f64())); + let normalized = row + .result_json + .as_deref() + .map(review_edit_dto_from_json) + .unwrap_or_default(); ReviewDto { id: row.id, @@ -1224,11 +1302,72 @@ fn review_dto( token_count: stat.map(|s| s.prompt_tokens + s.completion_tokens), tags, error_message: row.error_message, + normalized, created_at: row.created_at, updated_at: row.updated_at, } } +fn review_edit_dto_from_json(result_json: &str) -> ReviewEditDto { + let Ok(normalized) = serde_json::from_str::(result_json) + else { + return ReviewEditDto::default(); + }; + ReviewEditDto { + title: normalized.title.unwrap_or_default(), + artist: normalized.artist.unwrap_or_default(), + album: normalized.album.unwrap_or_default(), + year: normalized.year.map(|v| v.to_string()).unwrap_or_default(), + track_number: normalized + .track_number + .map(|v| v.to_string()) + .unwrap_or_default(), + genre: normalized.genre.unwrap_or_default(), + featured_artists: normalized.featured_artists.join(", "), + release_type: normalized + .release_type + .unwrap_or_else(|| "album".to_owned()), + notes: normalized.notes.unwrap_or_default(), + } +} + +fn optional_trimmed(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + +fn parse_optional_i32(value: &str) -> Option { + value.trim().parse::().ok() +} + +fn parse_featured_artists(value: &str) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(str::to_owned) + .collect() +} + +fn normalized_from_review_edit(edit: &ReviewEditDto) -> crate::agent::dto::NormalizedFields { + crate::agent::dto::NormalizedFields { + title: optional_trimmed(&edit.title), + artist: optional_trimmed(&edit.artist), + album: optional_trimmed(&edit.album), + year: parse_optional_i32(&edit.year), + track_number: parse_optional_i32(&edit.track_number), + genre: optional_trimmed(&edit.genre), + featured_artists: parse_featured_artists(&edit.featured_artists), + release_type: optional_trimmed(&edit.release_type).or_else(|| Some("album".to_owned())), + confidence: Some(1.0), + notes: optional_trimmed(&edit.notes), + } +} + fn media_tags(row: &ReviewMediaRow) -> Vec { let mut tags = Vec::new(); if let Some(format) = row.audio_format.as_deref().filter(|s| !s.trim().is_empty()) { diff --git a/src/admin/views.rs b/src/admin/views.rs index d9a5430..b85602a 100644 --- a/src/admin/views.rs +++ b/src/admin/views.rs @@ -1794,12 +1794,104 @@ struct ReviewDetailTemplate { user_name: String, user_role: String, review: PendingReview, + edit: ReviewEditFields, + release_types: &'static [(&'static str, &'static str, &'static str)], + lang_code: &'static str, context_pretty: String, result_pretty: String, error_message: String, stats: Option, } +#[derive(Debug, Default)] +struct ReviewEditFields { + title: String, + artist: String, + album: String, + year: String, + track_number: String, + genre: String, + featured_artists: String, + release_type: String, + notes: String, +} + +#[derive(Debug, Form)] +pub struct ReviewApproveForm { + title: String, + artist: String, + album: String, + year: String, + track_number: String, + genre: String, + featured_artists: String, + release_type: String, + notes: String, +} + +fn optional_trimmed(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + +fn parse_optional_i32(value: &str) -> Option { + value.trim().parse::().ok() +} + +fn parse_featured_artists(value: &str) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(str::to_owned) + .collect() +} + +fn edit_fields_from_normalized( + normalized: &crate::agent::dto::NormalizedFields, +) -> ReviewEditFields { + ReviewEditFields { + title: normalized.title.clone().unwrap_or_default(), + artist: normalized.artist.clone().unwrap_or_default(), + album: normalized.album.clone().unwrap_or_default(), + year: normalized.year.map(|v| v.to_string()).unwrap_or_default(), + track_number: normalized + .track_number + .map(|v| v.to_string()) + .unwrap_or_default(), + genre: normalized.genre.clone().unwrap_or_default(), + featured_artists: normalized.featured_artists.join(", "), + release_type: normalized + .release_type + .clone() + .unwrap_or_else(|| "album".to_owned()), + notes: normalized.notes.clone().unwrap_or_default(), + } +} + +fn normalized_from_result_json(result_json: &str) -> crate::agent::dto::NormalizedFields { + serde_json::from_str(result_json).unwrap_or_default() +} + +fn normalized_from_review_form(form: &ReviewApproveForm) -> crate::agent::dto::NormalizedFields { + crate::agent::dto::NormalizedFields { + title: optional_trimmed(&form.title), + artist: optional_trimmed(&form.artist), + album: optional_trimmed(&form.album), + year: parse_optional_i32(&form.year), + track_number: parse_optional_i32(&form.track_number), + genre: optional_trimmed(&form.genre), + featured_artists: parse_featured_artists(&form.featured_artists), + release_type: optional_trimmed(&form.release_type).or_else(|| Some("album".to_owned())), + confidence: Some(1.0), + notes: optional_trimmed(&form.notes), + } +} + pub async fn review_detail( admin: AuthenticatedUser, i18n: I18n, @@ -1830,12 +1922,17 @@ pub async fn review_detail( let stats = scheduler::ProcessingStats::get_by_review_id(db, review_id) .await .unwrap_or(None); + let normalized = normalized_from_result_json(review.result_json_str()); + let edit = edit_fields_from_normalized(&normalized); let template = ReviewDetailTemplate { t: i18n.t, user_name: admin.name, user_role: admin.role.code().to_owned(), review, + edit, + release_types: RELEASE_TYPES, + lang_code: i18n.t.lang.code(), context_pretty, result_pretty, error_message, @@ -1850,24 +1947,29 @@ pub async fn review_approve( db: &Database, pool: &sqlx::PgPool, review_id: i64, + form: RequestForm, ) -> cot::Result> { let mut review = PendingReview::get_by_id(db, review_id) .await .map_err(|e| cot::Error::internal(format!("db error: {e}")))? .ok_or_else(|| cot::Error::internal("review not found"))?; - let result_str = review.result_json_str().to_owned(); + let RequestForm(form_result) = form; + let normalized = match form_result { + FormResult::Ok(data) => normalized_from_review_form(&data), + FormResult::ValidationError(_) => { + return Ok(auth::redirect(&format!("/admin/reviews/{review_id}"))); + } + }; + let result_str = serde_json::to_string(&normalized) + .map_err(|e| cot::Error::internal(format!("failed to serialize review fields: {e}")))?; + review + .set_result_json(db, result_str) + .await + .map_err(|e| cot::Error::internal(format!("db error: {e}")))?; let context_str = review.context_json_str().to_owned(); let input_path = review.input_path_str().to_owned(); - if result_str.is_empty() { - let _ = review.set_rejected(db).await; - return Ok(auth::redirect(&format!("/admin/reviews/{review_id}"))); - } - - let normalized: crate::agent::dto::NormalizedFields = serde_json::from_str(&result_str) - .map_err(|e| cot::Error::internal(format!("invalid result_json: {e}")))?; - let context: serde_json::Value = serde_json::from_str(&context_str).unwrap_or_default(); // Load live config from DB so admin-set values are used diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index a96f6dc..29feea8 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -460,6 +460,16 @@ impl PendingReview { self.save(db).await } + pub async fn set_result_json( + &mut self, + db: &Database, + result_json: String, + ) -> cot::db::Result<()> { + self.result_json = Some(result_json); + self.updated_at = now_iso(); + self.save(db).await + } + pub async fn set_failed(&mut self, db: &Database, error: &str) -> cot::db::Result<()> { self.status = LimitedString::new("failed").unwrap(); self.error_message = Some(error.to_owned()); diff --git a/templates/admin/review_detail.html b/templates/admin/review_detail.html index 743c4ad..7601a67 100644 --- a/templates/admin/review_detail.html +++ b/templates/admin/review_detail.html @@ -25,28 +25,76 @@ {% endif %} +{% if review.status_str() == "pending" %} +

{{ t.reviews_result }}

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
- {% if review.status_str() == "pending" %} -
- -
- {% endif %} +
+{% else %} +
{% if review.status_str() == "failed" || review.status_str() == "processing" %}
{% endif %}
+{% endif %} {% if !context_pretty.is_empty() %}

{{ t.reviews_context }}

{{ context_pretty }}
{% endif %} -{% if !result_pretty.is_empty() %} +{% if !result_pretty.is_empty() && review.status_str() != "pending" %}

{{ t.reviews_result }}

{{ result_pretty }}
{% endif %} diff --git a/templates/admin/v2.html b/templates/admin/v2.html index 17bf526..04dbd93 100644 --- a/templates/admin/v2.html +++ b/templates/admin/v2.html @@ -851,7 +851,8 @@ tbody tr:hover { } .field input, -.field textarea { +.field textarea, +.field select { width: 100%; min-height: 34px; padding: 8px 10px; @@ -1673,7 +1674,60 @@ tbody tr:hover { +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+