From eef6b703f7a62544051a813fdcd9371652f6ffe5 Mon Sep 17 00:00:00 2001 From: NanGua-QWQ Date: Fri, 10 Apr 2026 21:09:04 +0800 Subject: [PATCH] feat: Enhance AutoScore functionality with interval triggers and JSON tree support - Added support for trigger trees in AutoScoreRule interface. - Implemented functions to convert query trees to JSON and vice versa. - Updated IntervalValue to use days, hours, and minutes instead of a single amount and unit. - Refactored IntervalValueWidget to allow separate input for days, hours, and minutes. - Added validation to ensure at least one interval trigger is present. - Enhanced user interface with informative alerts and tooltips for better user experience. - Updated localization files to include new strings for interval triggers and UI elements. --- src-tauri/data.sql | Bin 110592 -> 126976 bytes src-tauri/src/commands/auto_score.rs | 6 + src-tauri/src/commands/event.rs | 4 + src-tauri/src/services/auto_score.rs | 752 ++++++++++++++---- src/components/AutoScore/AutoScoreUtils.ts | 65 ++ .../AutoScore/IntervalValueCodec.ts | 96 ++- .../AutoScore/IntervalValueWidget.tsx | 159 ++-- .../AutoScore/TriggerRuleBuilder.tsx | 8 +- src/components/AutoScoreManager.tsx | 69 +- src/components/ScoreManager.tsx | 22 +- src/i18n/locales/en-US.json | 5 + src/i18n/locales/zh-CN.json | 7 +- src/preload/types.ts | 32 +- 13 files changed, 931 insertions(+), 294 deletions(-) diff --git a/src-tauri/data.sql b/src-tauri/data.sql index 81f6249753701495d17cff6b976514cf00591071..0612fe9a9cecd4ce724715372ff05c745201c9fd 100644 GIT binary patch literal 126976 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 diff --git a/src-tauri/src/commands/auto_score.rs b/src-tauri/src/commands/auto_score.rs index 8ac3e8b..2d6ad22 100644 --- a/src-tauri/src/commands/auto_score.rs +++ b/src-tauri/src/commands/auto_score.rs @@ -28,6 +28,8 @@ pub struct CreateAutoScoreRule { #[serde(rename = "studentNames")] pub student_names: Vec, pub triggers: Vec, + #[serde(rename = "triggerTree", default)] + pub trigger_tree: Option, pub actions: Vec, #[serde(default)] pub execution: AutoScoreExecutionConfig, @@ -43,6 +45,8 @@ pub struct UpdateAutoScoreRule { #[serde(rename = "studentNames")] pub student_names: Vec, pub triggers: Vec, + #[serde(rename = "triggerTree", default)] + pub trigger_tree: Option, pub actions: Vec, #[serde(default)] pub execution: AutoScoreExecutionConfig, @@ -75,6 +79,7 @@ fn build_rule_from_create(rule: CreateAutoScoreRule) -> AutoScoreRule { enabled: rule.enabled, student_names: rule.student_names, triggers: rule.triggers, + trigger_tree: rule.trigger_tree, actions: rule.actions, execution: rule.execution, filters: rule.filters, @@ -89,6 +94,7 @@ fn build_rule_from_update(rule: UpdateAutoScoreRule) -> AutoScoreRule { enabled: rule.enabled, student_names: rule.student_names, triggers: rule.triggers, + trigger_tree: rule.trigger_tree, actions: rule.actions, execution: rule.execution, filters: rule.filters, diff --git a/src-tauri/src/commands/event.rs b/src-tauri/src/commands/event.rs index ff2a8d6..368aa32 100644 --- a/src-tauri/src/commands/event.rs +++ b/src-tauri/src/commands/event.rs @@ -32,7 +32,9 @@ pub struct ScoreEvent { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateScoreEvent { + #[serde(alias = "studentName")] pub student_name: String, + #[serde(alias = "reasonContent")] pub reason_content: String, pub delta: i32, } @@ -44,8 +46,10 @@ pub struct QueryEventParams { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QueryByStudentParams { + #[serde(alias = "studentName")] pub student_name: String, pub limit: Option, + #[serde(alias = "startTime")] pub start_time: Option, } diff --git a/src-tauri/src/services/auto_score.rs b/src-tauri/src/services/auto_score.rs index 8b7ff20..ad4bc4a 100644 --- a/src-tauri/src/services/auto_score.rs +++ b/src-tauri/src/services/auto_score.rs @@ -1,11 +1,11 @@ use chrono::{DateTime, Months, Utc}; use sea_orm::{ - ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DatabaseConnection, EntityTrait, + ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter, Set, Statement, TransactionTrait, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value as JsonValue}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use tauri::{AppHandle, Emitter, Manager}; use tokio::time::{interval, Duration}; use uuid::Uuid; @@ -75,10 +75,10 @@ enum IntervalUnit { Month, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -struct IntervalTriggerValue { - amount: i64, - unit: IntervalUnit, +#[derive(Debug, Clone, PartialEq, Eq)] +enum IntervalTriggerValue { + Legacy { amount: i64, unit: IntervalUnit }, + Composite { days: i64, hours: i64, minutes: i64 }, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -89,6 +89,8 @@ pub struct AutoScoreRule { #[serde(rename = "studentNames")] pub student_names: Vec, pub triggers: Vec, + #[serde(rename = "triggerTree", default)] + pub trigger_tree: Option, pub actions: Vec, #[serde(default)] pub execution: AutoScoreExecutionConfig, @@ -123,18 +125,6 @@ struct RuleExecutionStats { settled: bool, } -impl StudentRefs { - fn is_empty(&self) -> bool { - self.ids.is_empty() && self.names.is_empty() - } - - fn intersect(mut self, other: StudentRefs) -> StudentRefs { - self.ids.retain(|id| other.ids.contains(id)); - self.names.retain(|name| other.names.contains(name)); - self - } -} - impl Default for AutoScoreRule { fn default() -> Self { Self { @@ -143,6 +133,7 @@ impl Default for AutoScoreRule { enabled: true, student_names: Vec::new(), triggers: Vec::new(), + trigger_tree: None, actions: Vec::new(), execution: AutoScoreExecutionConfig::default(), filters: AutoScoreFilterConfig::default(), @@ -461,18 +452,30 @@ fn normalize_rule(mut rule: AutoScoreRule) -> Result { rule.name = normalize_required_string(rule.name, "automation name")?; rule.student_names = dedupe_trimmed_strings(rule.student_names); - if rule.triggers.is_empty() { + if rule.triggers.is_empty() && rule.trigger_tree.is_none() { return Err("At least one trigger is required".to_string()); } if rule.actions.is_empty() { return Err("At least one action is required".to_string()); } - rule.triggers = rule + let normalized_triggers = rule .triggers .into_iter() .map(normalize_trigger) .collect::, _>>()?; + let normalized_tree = if let Some(tree) = rule.trigger_tree.take() { + normalize_trigger_tree(tree)? + } else { + build_trigger_tree_from_triggers(&normalized_triggers) + }; + let tree_triggers = collect_triggers_from_tree(&normalized_tree)?; + if tree_triggers.is_empty() { + return Err("At least one trigger is required".to_string()); + } + + rule.triggers = tree_triggers; + rule.trigger_tree = Some(normalized_tree); rule.actions = rule .actions .into_iter() @@ -507,6 +510,285 @@ fn normalize_filter_config(mut config: AutoScoreFilterConfig) -> Result JsonValue { + JsonValue::Object( + [ + ("id".to_string(), JsonValue::String(Uuid::new_v4().to_string())), + ("type".to_string(), JsonValue::String("group".to_string())), + ( + "properties".to_string(), + json!({ + "conjunction": "AND" + }), + ), + ( + "children1".to_string(), + JsonValue::Array( + triggers + .iter() + .map(build_trigger_rule_node) + .collect::>(), + ), + ), + ] + .into_iter() + .collect(), + ) +} + +fn build_trigger_rule_node(trigger: &AutoScoreTrigger) -> JsonValue { + let (field, operator, value) = match trigger.event.as_str() { + "interval_time_passed" => ( + "interval_minutes", + "equal", + JsonValue::Array(vec![JsonValue::String( + trigger.value.clone().unwrap_or_default(), + )]), + ), + "student_has_tag" => { + let tag_values = parse_tag_values(trigger.value.as_deref()) + .into_iter() + .map(JsonValue::String) + .collect::>(); + ( + "student_tag", + "multiselect_contains", + JsonValue::Array(vec![JsonValue::Array(tag_values)]), + ) + } + "query_sql" | "student_query_sql" | "student_sql" => ( + "student_sql", + "equal", + JsonValue::Array(vec![JsonValue::String( + trigger.value.clone().unwrap_or_default(), + )]), + ), + _ => ( + "student_sql", + "equal", + JsonValue::Array(vec![JsonValue::String( + trigger.value.clone().unwrap_or_default(), + )]), + ), + }; + + JsonValue::Object( + [ + ("id".to_string(), JsonValue::String(Uuid::new_v4().to_string())), + ("type".to_string(), JsonValue::String("rule".to_string())), + ( + "properties".to_string(), + json!({ + "field": field, + "operator": operator, + "value": value, + }), + ), + ] + .into_iter() + .collect(), + ) +} + +fn normalize_trigger_tree(tree: JsonValue) -> Result { + let normalized = normalize_trigger_tree_node(tree)?; + if !matches!( + normalized + .get("type") + .and_then(JsonValue::as_str) + .unwrap_or_default(), + "group" + ) { + return Err("Trigger tree root must be a group".to_string()); + } + Ok(normalized) +} + +fn normalize_trigger_tree_node(node: JsonValue) -> Result { + let Some(node_type) = node.get("type").and_then(JsonValue::as_str) else { + return Err("Trigger tree node type is required".to_string()); + }; + + match node_type { + "group" => normalize_trigger_tree_group(node), + "rule" => normalize_trigger_tree_rule(node), + _ => Err(format!("Unsupported trigger tree node type: {}", node_type)), + } +} + +fn normalize_trigger_tree_group(node: JsonValue) -> Result { + let conjunction = node + .get("properties") + .and_then(|value| value.get("conjunction")) + .and_then(JsonValue::as_str) + .unwrap_or("AND") + .to_uppercase(); + if conjunction != "AND" && conjunction != "OR" { + return Err("Trigger tree group conjunction must be AND or OR".to_string()); + } + + let not = node + .get("properties") + .and_then(|value| value.get("not")) + .and_then(JsonValue::as_bool) + .unwrap_or(false); + + let children = node + .get("children1") + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_default(); + if children.is_empty() { + return Err("Trigger tree group must contain at least one child".to_string()); + } + + let normalized_children = children + .into_iter() + .map(normalize_trigger_tree_node) + .collect::, _>>()?; + + Ok(JsonValue::Object( + [ + ( + "id".to_string(), + JsonValue::String( + node.get("id") + .and_then(JsonValue::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| Uuid::new_v4().to_string()), + ), + ), + ("type".to_string(), JsonValue::String("group".to_string())), + ( + "properties".to_string(), + json!({ + "conjunction": conjunction, + "not": not, + }), + ), + ("children1".to_string(), JsonValue::Array(normalized_children)), + ] + .into_iter() + .collect(), + )) +} + +fn normalize_trigger_tree_rule(node: JsonValue) -> Result { + let trigger = trigger_from_rule_node(&node)?; + + Ok(JsonValue::Object( + [ + ( + "id".to_string(), + JsonValue::String( + node.get("id") + .and_then(JsonValue::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| Uuid::new_v4().to_string()), + ), + ), + ("type".to_string(), JsonValue::String("rule".to_string())), + ( + "properties".to_string(), + build_trigger_rule_node(&trigger) + .get("properties") + .cloned() + .unwrap_or_else(|| json!({})), + ), + ] + .into_iter() + .collect(), + )) +} + +fn collect_triggers_from_tree(tree: &JsonValue) -> Result, String> { + let mut collected = Vec::new(); + collect_triggers_from_tree_node(tree, &mut collected)?; + Ok(collected) +} + +fn collect_triggers_from_tree_node( + node: &JsonValue, + collected: &mut Vec, +) -> Result<(), String> { + let Some(node_type) = node.get("type").and_then(JsonValue::as_str) else { + return Err("Trigger tree node type is required".to_string()); + }; + + match node_type { + "group" => { + let children = node + .get("children1") + .and_then(JsonValue::as_array) + .ok_or_else(|| "Trigger tree group children1 must be an array".to_string())?; + for child in children { + collect_triggers_from_tree_node(child, collected)?; + } + Ok(()) + } + "rule" => { + collected.push(trigger_from_rule_node(node)?); + Ok(()) + } + _ => Err(format!("Unsupported trigger tree node type: {}", node_type)), + } +} + +fn trigger_from_rule_node(node: &JsonValue) -> Result { + let field = node + .get("properties") + .and_then(|value| value.get("field")) + .and_then(JsonValue::as_str) + .unwrap_or_default(); + let first_value = node + .get("properties") + .and_then(|value| value.get("value")) + .and_then(JsonValue::as_array) + .and_then(|values| values.first()) + .cloned(); + + match field { + "interval_minutes" => { + let trigger = AutoScoreTrigger { + event: "interval_time_passed".to_string(), + value: first_value + .as_ref() + .and_then(JsonValue::as_str) + .map(str::to_string), + }; + normalize_trigger(trigger) + } + "student_tag" => { + let tags = match first_value { + Some(JsonValue::Array(items)) => items + .into_iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect::>(), + Some(JsonValue::String(value)) => vec![value], + _ => Vec::new(), + }; + let trigger = AutoScoreTrigger { + event: "student_has_tag".to_string(), + value: stringify_tag_values(&tags), + }; + normalize_trigger(trigger) + } + "student_sql" => { + let trigger = AutoScoreTrigger { + event: "query_sql".to_string(), + value: first_value + .as_ref() + .and_then(JsonValue::as_str) + .map(str::to_string), + }; + normalize_trigger(trigger) + } + _ => Err(format!("Unsupported trigger tree field: {}", field)), + } +} + fn normalize_trigger(trigger: AutoScoreTrigger) -> Result { let event = normalize_required_string(trigger.event, "trigger event")?; @@ -657,6 +939,22 @@ fn normalize_last_executed(value: Option) -> Option { .map(|parsed| parsed.with_timezone(&Utc).to_rfc3339()) } +fn parse_positive_i64(value: &JsonValue) -> Option { + match value { + JsonValue::Number(number) => number.as_i64().filter(|parsed| *parsed > 0), + JsonValue::String(text) => text.trim().parse::().ok().filter(|parsed| *parsed > 0), + _ => None, + } +} + +fn parse_non_negative_i64(value: &JsonValue) -> Option { + match value { + JsonValue::Number(number) => number.as_i64().filter(|parsed| *parsed >= 0), + JsonValue::String(text) => text.trim().parse::().ok().filter(|parsed| *parsed >= 0), + _ => None, + } +} + fn parse_interval_trigger_value(value: Option<&str>) -> Option { let raw_value = value.map(str::trim)?; if raw_value.is_empty() { @@ -665,29 +963,75 @@ fn parse_interval_trigger_value(value: Option<&str>) -> Option() { if minutes > 0 { - return Some(IntervalTriggerValue { + return Some(IntervalTriggerValue::Legacy { amount: minutes, unit: IntervalUnit::Minute, }); } } - let parsed_value = serde_json::from_str::(raw_value).ok()?; - if parsed_value.amount <= 0 { - return None; + let parsed_value = serde_json::from_str::(raw_value).ok()?; + let object = parsed_value.as_object()?; + + if let (Some(days), Some(hours), Some(minutes)) = ( + object.get("days").and_then(parse_non_negative_i64), + object.get("hours").and_then(parse_non_negative_i64), + object.get("minutes").and_then(parse_non_negative_i64), + ) { + if days > 0 || hours > 0 || minutes > 0 { + return Some(IntervalTriggerValue::Composite { + days, + hours, + minutes, + }); + } } - Some(parsed_value) + let amount = object.get("amount").and_then(parse_positive_i64)?; + let unit = match object.get("unit").and_then(JsonValue::as_str) { + Some("minute") => IntervalUnit::Minute, + Some("day") => IntervalUnit::Day, + Some("month") => IntervalUnit::Month, + _ => return None, + }; + + Some(IntervalTriggerValue::Legacy { amount, unit }) } fn stringify_interval_trigger_value(value: &IntervalTriggerValue) -> Option { - if value.amount <= 0 { - return None; - } + match value { + IntervalTriggerValue::Legacy { amount, unit } => { + if *amount <= 0 { + return None; + } - match value.unit { - IntervalUnit::Minute => Some(value.amount.to_string()), - IntervalUnit::Day | IntervalUnit::Month => serde_json::to_string(value).ok(), + match unit { + IntervalUnit::Minute => Some(amount.to_string()), + IntervalUnit::Day | IntervalUnit::Month => serde_json::to_string( + &json!({ + "amount": amount, + "unit": unit, + }), + ) + .ok(), + } + } + IntervalTriggerValue::Composite { + days, + hours, + minutes, + } => { + if *days <= 0 && *hours <= 0 && *minutes <= 0 { + return None; + } + + serde_json::to_string(&json!({ + "days": days, + "hours": hours, + "minutes": minutes, + })) + .ok() + } } } @@ -695,48 +1039,73 @@ fn add_interval_to_time( base_time: DateTime, interval: &IntervalTriggerValue, ) -> Option> { - match interval.unit { - IntervalUnit::Minute => { - base_time.checked_add_signed(chrono::Duration::minutes(interval.amount)) - } - IntervalUnit::Day => base_time.checked_add_signed(chrono::Duration::days(interval.amount)), - IntervalUnit::Month => { - let months = u32::try_from(interval.amount).ok()?; - base_time.checked_add_months(Months::new(months)) + match interval { + IntervalTriggerValue::Legacy { amount, unit } => match unit { + IntervalUnit::Minute => { + base_time.checked_add_signed(chrono::Duration::minutes(*amount)) + } + IntervalUnit::Day => { + base_time.checked_add_signed(chrono::Duration::days(*amount)) + } + IntervalUnit::Month => { + let months = u32::try_from(*amount).ok()?; + base_time.checked_add_months(Months::new(months)) + } + }, + IntervalTriggerValue::Composite { + days, + hours, + minutes, + } => { + let total_minutes = days + .saturating_mul(24 * 60) + .saturating_add(hours.saturating_mul(60)) + .saturating_add(*minutes); + if total_minutes <= 0 { + return None; + } + base_time.checked_add_signed(chrono::Duration::minutes(total_minutes)) } } } fn check_interval_trigger(rule: &AutoScoreRule) -> Option { - let now = Utc::now(); + let delays = rule + .triggers + .iter() + .filter(|trigger| trigger.event == "interval_time_passed") + .map(|trigger| get_interval_delay_ms(rule, trigger.value.as_deref())) + .collect::>>(); - for trigger in &rule.triggers { - if trigger.event != "interval_time_passed" { - continue; - } - - let interval = parse_interval_trigger_value(trigger.value.as_deref()).unwrap_or( - IntervalTriggerValue { - amount: DEFAULT_INTERVAL_MINUTES, - unit: IntervalUnit::Minute, - }, - ); - - let Some(base_time) = rule - .last_executed - .as_ref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|value| value.with_timezone(&Utc)) - else { - return Some(0); - }; - - let next_execute_time = add_interval_to_time(base_time, &interval)?; - let delay_ms = (next_execute_time - now).num_milliseconds(); - return Some(delay_ms.max(0)); + if delays.is_empty() { + return None; } - None + delays.into_iter().flatten().min() +} + +fn get_interval_delay_ms(rule: &AutoScoreRule, value: Option<&str>) -> Option { + let now = Utc::now(); + let interval = parse_interval_trigger_value(value).unwrap_or(IntervalTriggerValue::Legacy { + amount: DEFAULT_INTERVAL_MINUTES, + unit: IntervalUnit::Minute, + }); + + let Some(base_time) = rule + .last_executed + .as_ref() + .and_then(|raw| DateTime::parse_from_rfc3339(raw).ok()) + .map(|parsed| parsed.with_timezone(&Utc)) + else { + return Some(0); + }; + + let next_execute_time = add_interval_to_time(base_time, &interval)?; + Some((next_execute_time - now).num_milliseconds().max(0)) +} + +fn is_interval_due(rule: &AutoScoreRule, value: Option<&str>) -> bool { + get_interval_delay_ms(rule, value).unwrap_or(0) <= 0 } fn contains_forbidden_keyword(sql: &str) -> bool { @@ -1301,82 +1670,198 @@ async fn execute_settlement(conn: &DatabaseConnection) -> Result<(), String> { Ok(()) } -async fn resolve_target_students( - conn: &DatabaseConnection, - rule: &AutoScoreRule, -) -> Result, String> { - let explicit_sql_values: Vec = rule - .triggers - .iter() - .filter(|trigger| { - matches!( +#[derive(Debug, Default)] +struct TriggerEvalContext { + student_tags_by_id: HashMap>, + sql_refs_by_query: HashMap, +} + +fn collect_sql_queries_from_tree(node: &JsonValue, queries: &mut Vec) -> Result<(), String> { + let Some(node_type) = node.get("type").and_then(JsonValue::as_str) else { + return Err("Trigger tree node type is required".to_string()); + }; + + match node_type { + "group" => { + let children = node + .get("children1") + .and_then(JsonValue::as_array) + .ok_or_else(|| "Trigger tree group children1 must be an array".to_string())?; + for child in children { + collect_sql_queries_from_tree(child, queries)?; + } + Ok(()) + } + "rule" => { + let trigger = trigger_from_rule_node(node)?; + if matches!( trigger.event.as_str(), "query_sql" | "student_query_sql" | "student_sql" - ) - }) - .filter_map(|trigger| trigger.value.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .collect(); - - if !explicit_sql_values.is_empty() { - let mut refs: Option = None; - for sql_value in explicit_sql_values { - let next_refs = query_student_refs_by_sql(conn, &sql_value).await?; - refs = Some(match refs { - None => next_refs, - Some(existing) => existing.intersect(next_refs), - }); + ) { + if let Some(sql) = trigger.value.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + queries.push(sql.to_string()); + } + } + Ok(()) } - - let refs = refs.unwrap_or_default(); - if refs.is_empty() { - return Ok(vec![]); - } - return load_students_from_refs(conn, refs).await; - } - - let tag_filters: Vec = rule - .triggers - .iter() - .filter(|trigger| trigger.event == "student_has_tag") - .flat_map(|trigger| parse_tag_values(trigger.value.as_deref())) - .collect(); - - let tag_filters = dedupe_trimmed_strings(tag_filters); - if tag_filters.is_empty() { - return students::Entity::find() - .all(conn) - .await - .map_err(|e| e.to_string()); + _ => Err(format!("Unsupported trigger tree node type: {}", node_type)), } +} +async fn load_student_tags_by_student_id( + conn: &DatabaseConnection, +) -> Result>, String> { let tag_models = tags::Entity::find() - .filter(tags::Column::Name.is_in(tag_filters)) .all(conn) .await .map_err(|e| e.to_string())?; if tag_models.is_empty() { - return Ok(vec![]); + return Ok(HashMap::new()); } - let tag_ids: Vec = tag_models.iter().map(|tag| tag.id).collect(); + let tag_name_by_id: HashMap = tag_models + .into_iter() + .map(|tag| (tag.id, tag.name)) + .collect(); let links = student_tags::Entity::find() - .filter(student_tags::Column::TagId.is_in(tag_ids)) .all(conn) .await .map_err(|e| e.to_string())?; - if links.is_empty() { + + let mut student_tags_by_id: HashMap> = HashMap::new(); + for link in links { + let Some(tag_name) = tag_name_by_id.get(&link.tag_id) else { + continue; + }; + student_tags_by_id + .entry(link.student_id) + .or_default() + .insert(tag_name.clone()); + } + + Ok(student_tags_by_id) +} + +fn evaluate_trigger_tree_for_student( + node: &JsonValue, + student: &students::Model, + rule: &AutoScoreRule, + ctx: &TriggerEvalContext, +) -> Result { + let Some(node_type) = node.get("type").and_then(JsonValue::as_str) else { + return Err("Trigger tree node type is required".to_string()); + }; + + match node_type { + "group" => { + let conjunction = node + .get("properties") + .and_then(|value| value.get("conjunction")) + .and_then(JsonValue::as_str) + .unwrap_or("AND") + .to_uppercase(); + let not = node + .get("properties") + .and_then(|value| value.get("not")) + .and_then(JsonValue::as_bool) + .unwrap_or(false); + let children = node + .get("children1") + .and_then(JsonValue::as_array) + .ok_or_else(|| "Trigger tree group children1 must be an array".to_string())?; + if children.is_empty() { + return Ok(false); + } + + let base = if conjunction == "OR" { + children.iter().try_fold(false, |matched, child| { + if matched { + return Ok(true); + } + evaluate_trigger_tree_for_student(child, student, rule, ctx) + })? + } else { + children.iter().try_fold(true, |matched, child| { + if !matched { + return Ok(false); + } + evaluate_trigger_tree_for_student(child, student, rule, ctx) + })? + }; + + Ok(if not { !base } else { base }) + } + "rule" => { + let trigger = trigger_from_rule_node(node)?; + let matched = match trigger.event.as_str() { + "interval_time_passed" => is_interval_due(rule, trigger.value.as_deref()), + "student_has_tag" => { + let required_tags = parse_tag_values(trigger.value.as_deref()); + let student_tags = ctx.student_tags_by_id.get(&student.id); + required_tags.iter().any(|tag| { + student_tags + .map(|values| values.contains(tag)) + .unwrap_or(false) + }) + } + "query_sql" | "student_query_sql" | "student_sql" => { + let sql = trigger + .value + .as_deref() + .map(str::trim) + .unwrap_or_default() + .to_string(); + ctx.sql_refs_by_query + .get(&sql) + .map(|refs| refs.ids.contains(&student.id) || refs.names.contains(&student.name)) + .unwrap_or(false) + } + _ => false, + }; + Ok(matched) + } + _ => Err(format!("Unsupported trigger tree node type: {}", node_type)), + } +} + +async fn resolve_target_students( + conn: &DatabaseConnection, + rule: &AutoScoreRule, +) -> Result, String> { + let all_students = students::Entity::find() + .all(conn) + .await + .map_err(|e| e.to_string())?; + if all_students.is_empty() { return Ok(vec![]); } - let student_ids: HashSet = links.iter().map(|link| link.student_id).collect(); - students::Entity::find() - .filter(students::Column::Id.is_in(student_ids)) - .all(conn) - .await - .map_err(|e| e.to_string()) + let fallback_tree = build_trigger_tree_from_triggers(&rule.triggers); + let trigger_tree = rule.trigger_tree.as_ref().unwrap_or(&fallback_tree); + + let mut sql_queries = Vec::new(); + collect_sql_queries_from_tree(trigger_tree, &mut sql_queries)?; + let sql_queries = dedupe_trimmed_strings(sql_queries); + + let mut sql_refs_by_query = HashMap::new(); + for sql in sql_queries { + let refs = query_student_refs_by_sql(conn, &sql).await?; + sql_refs_by_query.insert(sql, refs); + } + + let ctx = TriggerEvalContext { + student_tags_by_id: load_student_tags_by_student_id(conn).await?, + sql_refs_by_query, + }; + + let mut matched = Vec::new(); + for student in all_students { + if evaluate_trigger_tree_for_student(trigger_tree, &student, rule, &ctx)? { + matched.push(student); + } + } + + Ok(matched) } @@ -1458,29 +1943,6 @@ async fn query_student_refs_by_sql( Ok(refs) } -async fn load_students_from_refs( - conn: &DatabaseConnection, - refs: StudentRefs, -) -> Result, String> { - if refs.is_empty() { - return Ok(vec![]); - } - - let mut condition = Condition::any(); - if !refs.ids.is_empty() { - condition = condition.add(students::Column::Id.is_in(refs.ids)); - } - if !refs.names.is_empty() { - condition = condition.add(students::Column::Name.is_in(refs.names)); - } - - students::Entity::find() - .filter(condition) - .all(conn) - .await - .map_err(|e| e.to_string()) -} - fn try_get_i32(row: &sea_orm::QueryResult, column: &str) -> Option { row.try_get::("", column) .ok() diff --git a/src/components/AutoScore/AutoScoreUtils.ts b/src/components/AutoScore/AutoScoreUtils.ts index 458a851..d1188f1 100644 --- a/src/components/AutoScore/AutoScoreUtils.ts +++ b/src/components/AutoScore/AutoScoreUtils.ts @@ -51,6 +51,7 @@ export interface AutoScoreRule { enabled: boolean studentNames: string[] triggers: AutoScoreTrigger[] + triggerTree?: JsonGroup | null actions: AutoScoreAction[] execution?: AutoScoreExecutionConfig lastExecuted?: string | null @@ -368,6 +369,70 @@ export const queryTreeToTriggers = (tree: ImmutableTree, config: Config): AutoSc return collectTriggersFromItems(jsonTree.children1) } +export const queryTreeToJson = (tree: ImmutableTree, config: Config): JsonGroup | null => { + const checkedTree = QbUtils.checkTree(tree, config) + const jsonTree = QbUtils.getTree(checkedTree, false, true) + if (!jsonTree || jsonTree.type !== "group") return null + return jsonTree as JsonGroup +} + +const hydrateTriggerTreeNode = ( + node: JsonItem, + fallbackTriggers: AutoScoreTrigger[], + fallbackIndex: { current: number } +): JsonItem | null => { + if (node.type === "group") { + const children = Array.isArray(node.children1) + ? node.children1 + .map((child) => hydrateTriggerTreeNode(child, fallbackTriggers, fallbackIndex)) + .filter((child): child is JsonItem => Boolean(child)) + : [] + return { + ...node, + children1: children, + } + } + + if (node.type !== "rule") { + return node + } + + const fallbackTrigger = fallbackTriggers[fallbackIndex.current] + fallbackIndex.current += 1 + const parsed = triggerFromRule(node) + if (parsed) { + return node + } + + if (!fallbackTrigger) { + return node + } + + const fallbackRule = ruleFromTrigger(fallbackTrigger) + if (!fallbackRule) { + return node + } + + return { + ...node, + properties: fallbackRule.properties, + } +} + +export const triggerTreeJsonToQueryTree = ( + config: Config, + triggerTree?: JsonGroup | null, + fallbackTriggers: AutoScoreTrigger[] = [] +): ImmutableTree => { + if (triggerTree && triggerTree.type === "group") { + const hydratedTree = hydrateTriggerTreeNode(triggerTree, fallbackTriggers, { current: 0 }) + if (hydratedTree && hydratedTree.type === "group") { + return QbUtils.checkTree(QbUtils.loadTree(hydratedTree), config) + } + } + return triggersToQueryTree(config, fallbackTriggers) +} + const hasUnsupportedLogicInGroup = (group: JsonGroup): boolean => { const conjunction = typeof group.properties?.conjunction === "string" ? group.properties.conjunction : "AND" diff --git a/src/components/AutoScore/IntervalValueCodec.ts b/src/components/AutoScore/IntervalValueCodec.ts index 66b0a18..66ed942 100644 --- a/src/components/AutoScore/IntervalValueCodec.ts +++ b/src/components/AutoScore/IntervalValueCodec.ts @@ -1,44 +1,87 @@ export type IntervalUnit = "minute" | "day" | "month" export interface IntervalValue { + days: number + hours: number + minutes: number +} + +interface LegacyIntervalValue { amount: number unit: IntervalUnit } -export const DEFAULT_INTERVAL_UNIT: IntervalUnit = "day" - -const normalizePositiveInteger = (value: unknown): number | null => { +const normalizeNonNegativeInteger = (value: unknown): number | null => { if (typeof value === "number" && Number.isFinite(value)) { const normalized = Math.floor(value) - return normalized > 0 ? normalized : null + return normalized >= 0 ? normalized : null } if (typeof value === "string" && value.trim()) { const parsed = Number(value) if (Number.isFinite(parsed)) { const normalized = Math.floor(parsed) - return normalized > 0 ? normalized : null + return normalized >= 0 ? normalized : null } } return null } +const normalizePositiveInteger = (value: unknown): number | null => { + const normalized = normalizeNonNegativeInteger(value) + if (normalized === null || normalized <= 0) return null + return normalized +} + const isIntervalUnit = (value: unknown): value is IntervalUnit => value === "minute" || value === "day" || value === "month" export const getDefaultIntervalValue = (): IntervalValue => ({ - amount: 1, - unit: DEFAULT_INTERVAL_UNIT, + days: 1, + hours: 0, + minutes: 0, }) +const fromTotalMinutes = (totalMinutes: number): IntervalValue => { + const normalized = Math.max(0, Math.floor(totalMinutes)) + const days = Math.floor(normalized / (24 * 60)) + const hours = Math.floor((normalized % (24 * 60)) / 60) + const minutes = normalized % 60 + return { days, hours, minutes } +} + +const toTotalMinutes = (value: IntervalValue): number => + value.days * 24 * 60 + value.hours * 60 + value.minutes + +const normalizeCompositeValue = (value: Partial): IntervalValue | null => { + const days = normalizeNonNegativeInteger(value.days) ?? 0 + const hours = normalizeNonNegativeInteger(value.hours) ?? 0 + const minutes = normalizeNonNegativeInteger(value.minutes) ?? 0 + + const totalMinutes = days * 24 * 60 + hours * 60 + minutes + if (totalMinutes <= 0) return null + return fromTotalMinutes(totalMinutes) +} + +const legacyToComposite = (value: Partial): IntervalValue | null => { + const amount = normalizePositiveInteger(value.amount) + if (!amount || !isIntervalUnit(value.unit)) return null + + if (value.unit === "minute") return fromTotalMinutes(amount) + if (value.unit === "day") return fromTotalMinutes(amount * 24 * 60) + + // Legacy month values are approximated as 30 days in the editor. + return fromTotalMinutes(amount * 30 * 24 * 60) +} + export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null => { if (typeof value === "object" && value !== null) { - const amount = normalizePositiveInteger((value as IntervalValue).amount) - const unit = (value as IntervalValue).unit - if (amount && isIntervalUnit(unit)) { - return { amount, unit } - } + const composite = normalizeCompositeValue(value as Partial) + if (composite) return composite + + const legacy = legacyToComposite(value as Partial) + if (legacy) return legacy } const text = value === null || value === undefined ? "" : String(value).trim() @@ -46,15 +89,16 @@ export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null const legacyMinutes = normalizePositiveInteger(text) if (legacyMinutes !== null && !text.startsWith("{")) { - return { amount: legacyMinutes, unit: "minute" } + return fromTotalMinutes(legacyMinutes) } try { - const parsed = JSON.parse(text) as Partial - const amount = normalizePositiveInteger(parsed.amount) - if (amount && isIntervalUnit(parsed.unit)) { - return { amount, unit: parsed.unit } - } + const parsed = JSON.parse(text) as Partial + const composite = normalizeCompositeValue(parsed) + if (composite) return composite + + const legacy = legacyToComposite(parsed) + if (legacy) return legacy } catch { void 0 } @@ -63,17 +107,15 @@ export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null } export const stringifyIntervalTriggerValue = (value: IntervalValue): string | null => { - const amount = normalizePositiveInteger(value.amount) - if (!amount || !isIntervalUnit(value.unit)) { - return null - } + const normalized = normalizeCompositeValue(value) + if (!normalized) return null - if (value.unit === "minute") { - return String(amount) - } + const totalMinutes = toTotalMinutes(normalized) + if (totalMinutes <= 0) return null return JSON.stringify({ - amount, - unit: value.unit, + days: normalized.days, + hours: normalized.hours, + minutes: normalized.minutes, }) } diff --git a/src/components/AutoScore/IntervalValueWidget.tsx b/src/components/AutoScore/IntervalValueWidget.tsx index bdc1562..76d82f2 100644 --- a/src/components/AutoScore/IntervalValueWidget.tsx +++ b/src/components/AutoScore/IntervalValueWidget.tsx @@ -1,116 +1,87 @@ -import React, { useMemo } from "react" -import { InputNumber, Select } from "antd" +import React from "react" +import { InputNumber } from "antd" import type { WidgetProps } from "@react-awesome-query-builder/antd" import { useTranslation } from "react-i18next" +import { + getDefaultIntervalValue, + parseIntervalTriggerValue, + stringifyIntervalTriggerValue, + type IntervalValue, +} from "./IntervalValueCodec" -export type IntervalUnit = "month" | "day" | "minute" +export { parseIntervalTriggerValue, stringifyIntervalTriggerValue } from "./IntervalValueCodec" +export type { IntervalValue, IntervalUnit } from "./IntervalValueCodec" -export interface IntervalValue { - amount: number - unit: IntervalUnit -} - -export const DEFAULT_INTERVAL_UNIT: IntervalUnit = "day" - -export const getDefaultIntervalValue = (): IntervalValue => ({ - amount: 1, - unit: DEFAULT_INTERVAL_UNIT, -}) - -export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null => { - if (value === null || value === undefined) return null - - const str = String(value).trim() - if (!str) return null - - try { - const parsed = JSON.parse(str) - if ( - typeof parsed === "object" && - parsed !== null && - typeof parsed.amount === "number" && - typeof parsed.unit === "string" - ) { - const validUnits: IntervalUnit[] = ["month", "day", "minute"] - if (validUnits.includes(parsed.unit as IntervalUnit)) { - return { - amount: Math.max(1, Math.floor(parsed.amount)), - unit: parsed.unit as IntervalUnit, - } - } - } - } catch { - void 0 +const buildNextIntervalValue = ( + currentValue: IntervalValue | null, + patch: Partial +): IntervalValue => { + const base = currentValue ?? getDefaultIntervalValue() + return { + days: patch.days ?? base.days, + hours: patch.hours ?? base.hours, + minutes: patch.minutes ?? base.minutes, } - - return null } -export const stringifyIntervalTriggerValue = (value: IntervalValue): string => { - return JSON.stringify({ - amount: value.amount, - unit: value.unit, - }) -} +const getDisplayValue = (value: number | undefined, fallback = 0) => + typeof value === "number" && Number.isFinite(value) ? value : fallback export const IntervalValueWidget: React.FC = ({ value, setValue, readonly, - placeholder, }) => { const { t } = useTranslation() const parsedValue = parseIntervalTriggerValue(value) - const unitOptions = useMemo( - () => [ - { label: t("autoScore.intervalUnitMonth"), value: "month" }, - { label: t("autoScore.intervalUnitDay"), value: "day" }, - { label: t("autoScore.intervalUnitMinute"), value: "minute" }, - ], - [t] - ) - - const handleAmountChange = (nextAmount: number | null) => { - if (nextAmount === null) { - setValue(null) - return - } - - const serializedValue = stringifyIntervalTriggerValue({ - amount: nextAmount, - unit: parsedValue?.unit ?? DEFAULT_INTERVAL_UNIT, - }) - setValue(serializedValue) - } - - const handleUnitChange = (nextUnit: IntervalUnit) => { - const baseValue = parsedValue ?? getDefaultIntervalValue() - const serializedValue = stringifyIntervalTriggerValue({ - amount: baseValue.amount, - unit: nextUnit, - }) - setValue(serializedValue) + const updateValue = (patch: Partial) => { + const nextValue = buildNextIntervalValue(parsedValue, patch) + const serialized = stringifyIntervalTriggerValue(nextValue) + setValue(serialized) } return ( -
- - matchStudentName(getOptionLabel(option), input)} - options={students.map((s) => ({ label: s.name, value: s.name }))} - /> + + + +