ADMIN: added pending review
Build and Publish / Build and Publish Docker Image (push) Successful in 5m5s

This commit is contained in:
Ultradesu
2026-05-27 15:03:06 +03:00
parent 538a6f6abf
commit 65da460c0c
8 changed files with 444 additions and 22 deletions
+34 -4
View File
@@ -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<PathId>,
json: Json<v2::ReviewEditDto>| {
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<PathId>| {
move |session: Session, db: Database, path: Path<PathId>,
form: RequestForm<ReviewApproveForm>| {
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
}
}
}),
+139
View File
@@ -156,10 +156,24 @@ struct ReviewDto {
token_count: Option<i64>,
tags: Vec<TagDto>,
error_message: Option<String>,
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<ReviewEditDto>,
) -> cot::Result<cot::response::Response> {
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::<serde_json::Value>(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::<crate::agent::dto::NormalizedFields>(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<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
}
fn parse_optional_i32(value: &str) -> Option<i32> {
value.trim().parse::<i32>().ok()
}
fn parse_featured_artists(value: &str) -> Vec<String> {
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<TagDto> {
let mut tags = Vec::new();
if let Some(format) = row.audio_format.as_deref().filter(|s| !s.trim().is_empty()) {
+111 -9
View File
@@ -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<scheduler::ProcessingStats>,
}
#[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<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
}
fn parse_optional_i32(value: &str) -> Option<i32> {
value.trim().parse::<i32>().ok()
}
fn parse_featured_artists(value: &str) -> Vec<String> {
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<ReviewApproveForm>,
) -> cot::Result<cot::http::Response<Body>> {
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