Skip to main content

headless_lms_models/
course_designer_analysis_workspace.rs

1//! Serialized JSON shape for the Analysis stage workspace form (`workspace_data` on `course_designer_plan_stages`).
2
3use serde::{Deserialize, Serialize};
4use utoipa::ToSchema;
5
6use crate::prelude::*;
7
8/// Discriminant for forward-compatible workspace payloads stored in `workspace_data`.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
10#[serde(tag = "schema", content = "payload", rename_all = "snake_case")]
11pub enum CourseDesignerStageWorkspace {
12    AnalysisV1(AnalysisWorkspaceV1),
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
16#[serde(rename_all = "snake_case")]
17pub enum AnalysisCourseType {
18    Compulsory,
19    Elective,
20}
21
22/// Analysis stage form: course metadata, needs, wishes, market, resources, contributors.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
24#[serde(rename_all = "snake_case")]
25#[derive(Default)]
26pub struct AnalysisWorkspaceV1 {
27    pub course_title: Option<String>,
28    pub credits: Option<f64>,
29    pub language: Option<String>,
30    pub target_group: Option<String>,
31    pub mode_synchronous: bool,
32    pub mode_asynchronous: bool,
33    pub open_period_i: bool,
34    pub open_period_ii: bool,
35    pub open_period_iii: bool,
36    pub open_period_iv: bool,
37    pub open_period_all: bool,
38    pub responsible_teachers: Option<String>,
39    pub degree_programme: Option<String>,
40    pub course_type: Option<AnalysisCourseType>,
41    pub students_demographic_data: Option<String>,
42    pub wishes_topics: Option<String>,
43    pub wishes_content_format_text: bool,
44    pub wishes_content_format_video: bool,
45    pub wishes_content_format_podcast: bool,
46    pub wishes_content_format_xr: bool,
47    pub wishes_content_format_notes: Option<String>,
48    pub wishes_assessment_text: Option<String>,
49    pub wishes_other_suggestions: Option<String>,
50    pub market_results: Option<String>,
51    pub resources_university: Option<String>,
52    pub resources_purchase_budget: Option<String>,
53    pub contributors_instructional_designer: Option<String>,
54    pub contributors_subject_matter_experts: Option<String>,
55    pub contributors_editors: Option<String>,
56    pub contributors_support_staff: Option<String>,
57}
58
59impl AnalysisWorkspaceV1 {
60    /// Returns an error message if credits or other fields are invalid.
61    pub fn validate(&self) -> ModelResult<()> {
62        if let Some(c) = self.credits
63            && (!c.is_finite() || c < 0.0)
64        {
65            return Err(ModelError::new(
66                ModelErrorType::InvalidRequest,
67                "Credits must be a non-negative finite number.".to_string(),
68                None,
69            ));
70        }
71        Ok(())
72    }
73}
74
75/// Parses `workspace_data` JSON from the DB into a typed envelope.
76pub fn parse_workspace_data(
77    value: Option<serde_json::Value>,
78) -> ModelResult<Option<CourseDesignerStageWorkspace>> {
79    match value {
80        None => Ok(None),
81        Some(v) if v.is_null() => Ok(None),
82        Some(v) => {
83            let parsed: CourseDesignerStageWorkspace = serde_json::from_value(v).map_err(|e| {
84                ModelError::new(
85                    ModelErrorType::InvalidRequest,
86                    format!("Invalid workspace_data: {e}"),
87                    None,
88                )
89            })?;
90            match &parsed {
91                CourseDesignerStageWorkspace::AnalysisV1(a) => a.validate()?,
92            }
93            Ok(Some(parsed))
94        }
95    }
96}
97
98/// Serializes workspace for storage; `None` clears the column.
99pub fn workspace_to_json(
100    workspace: Option<CourseDesignerStageWorkspace>,
101) -> ModelResult<Option<serde_json::Value>> {
102    match workspace {
103        None => Ok(None),
104        Some(w) => {
105            match &w {
106                CourseDesignerStageWorkspace::AnalysisV1(a) => a.validate()?,
107            }
108            serde_json::to_value(w).map(Some).map_err(|e| {
109                ModelError::new(
110                    ModelErrorType::Json,
111                    format!("Failed to serialize workspace: {e}"),
112                    None,
113                )
114            })
115        }
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn analysis_workspace_round_trip() {
125        let w = AnalysisWorkspaceV1 {
126            course_title: Some("Test".to_string()),
127            credits: Some(5.5),
128            language: Some("en".to_string()),
129            target_group: None,
130            mode_synchronous: true,
131            mode_asynchronous: false,
132            open_period_i: true,
133            open_period_ii: false,
134            open_period_iii: false,
135            open_period_iv: false,
136            open_period_all: false,
137            responsible_teachers: Some("A, B".to_string()),
138            degree_programme: None,
139            course_type: Some(AnalysisCourseType::Elective),
140            students_demographic_data: None,
141            wishes_topics: None,
142            wishes_content_format_text: true,
143            wishes_content_format_video: false,
144            wishes_content_format_podcast: false,
145            wishes_content_format_xr: false,
146            wishes_content_format_notes: None,
147            wishes_assessment_text: None,
148            wishes_other_suggestions: None,
149            market_results: None,
150            resources_university: None,
151            resources_purchase_budget: None,
152            contributors_instructional_designer: None,
153            contributors_subject_matter_experts: None,
154            contributors_editors: None,
155            contributors_support_staff: None,
156        };
157        let env = CourseDesignerStageWorkspace::AnalysisV1(w.clone());
158        let v = serde_json::to_value(&env).unwrap();
159        assert!(v.get("payload").is_some());
160        let back: CourseDesignerStageWorkspace = serde_json::from_value(v).unwrap();
161        assert!(matches!(
162            back,
163            CourseDesignerStageWorkspace::AnalysisV1(ref a) if a.course_title.as_deref() == Some("Test")
164        ));
165    }
166}