Skip to main content

prelude_xml_parser/native/
subject_native.rs

1use std::collections::HashMap;
2
3use chrono::{DateTime, Utc};
4
5#[cfg(feature = "python")]
6use pyo3::{
7    exceptions::PyValueError,
8    prelude::*,
9    types::{PyDateTime, PyDict},
10};
11
12#[cfg(feature = "python")]
13use crate::native::deserializers::{
14    default_string_none, deserialize_empty_string_as_none, to_py_datetime,
15};
16
17use serde::{Deserialize, Serialize};
18
19pub use crate::native::common::{Category, Comment, Entry, Field, Form, Reason, State, Value};
20
21#[cfg(not(feature = "python"))]
22#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
23pub struct Patient {
24    #[serde(rename = "patientId")]
25    pub patient_id: String,
26    #[serde(rename = "uniqueId")]
27    pub unique_id: String,
28    #[serde(rename = "whenCreated")]
29    pub when_created: Option<DateTime<Utc>>,
30    pub creator: String,
31    #[serde(rename = "siteName")]
32    pub site_name: String,
33    #[serde(rename = "siteUniqueId")]
34    pub site_unique_id: String,
35    #[serde(rename = "lastLanguage")]
36    pub last_language: Option<String>,
37    #[serde(rename = "numberOfForms")]
38    pub number_of_forms: usize,
39    pub forms: Option<Vec<Form>>,
40}
41
42impl Patient {
43    pub fn from_attributes(attrs: HashMap<String, String>) -> Result<Self, crate::errors::Error> {
44        let patient_id = attrs.get("patientId").cloned().ok_or_else(|| {
45            crate::errors::Error::ParsingError(quick_xml::de::DeError::Custom(
46                "Missing patientId".to_string(),
47            ))
48        })?;
49
50        let unique_id = attrs.get("uniqueId").cloned().ok_or_else(|| {
51            crate::errors::Error::ParsingError(quick_xml::de::DeError::Custom(
52                "Missing uniqueId".to_string(),
53            ))
54        })?;
55
56        let when_created = if let Some(wc_str) = attrs.get("whenCreated") {
57            if wc_str.is_empty() {
58                None
59            } else {
60                Some(parse_datetime(wc_str)?)
61            }
62        } else {
63            None
64        };
65
66        let creator = attrs.get("creator").cloned().ok_or_else(|| {
67            crate::errors::Error::ParsingError(quick_xml::de::DeError::Custom(
68                "Missing creator".to_string(),
69            ))
70        })?;
71
72        let site_name = attrs.get("siteName").cloned().ok_or_else(|| {
73            crate::errors::Error::ParsingError(quick_xml::de::DeError::Custom(
74                "Missing siteName".to_string(),
75            ))
76        })?;
77
78        let site_unique_id = attrs.get("siteUniqueId").cloned().ok_or_else(|| {
79            crate::errors::Error::ParsingError(quick_xml::de::DeError::Custom(
80                "Missing siteUniqueId".to_string(),
81            ))
82        })?;
83
84        let last_language = attrs.get("lastLanguage").filter(|s| !s.is_empty()).cloned();
85
86        let number_of_forms = attrs
87            .get("numberOfForms")
88            .and_then(|s| s.parse().ok())
89            .unwrap_or(0);
90
91        Ok(Patient {
92            patient_id,
93            unique_id,
94            when_created,
95            creator,
96            site_name,
97            site_unique_id,
98            last_language,
99            number_of_forms,
100            forms: None,
101        })
102    }
103
104    pub fn set_forms(&mut self, forms: Vec<Form>) {
105        self.forms = if forms.is_empty() { None } else { Some(forms) };
106    }
107}
108
109fn parse_datetime(s: &str) -> Result<DateTime<Utc>, crate::errors::Error> {
110    if let Ok(dt) = chrono::DateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S %z") {
111        Ok(dt.with_timezone(&Utc))
112    } else if let Ok(dt) = chrono::DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%z") {
113        Ok(dt.with_timezone(&Utc))
114    } else if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
115        Ok(dt.with_timezone(&Utc))
116    } else {
117        Err(crate::errors::Error::ParsingError(
118            quick_xml::de::DeError::Custom(format!("Invalid datetime format: {}", s)),
119        ))
120    }
121}
122
123#[cfg(feature = "python")]
124#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
125#[serde(rename_all = "camelCase")]
126#[pyclass(skip_from_py_object)]
127pub struct Patient {
128    #[serde(rename = "patientId")]
129    #[serde(alias = "@patientId")]
130    #[serde(alias = "patientId")]
131    pub patient_id: String,
132    #[serde(rename = "uniqueId")]
133    #[serde(alias = "@uniqueId")]
134    #[serde(alias = "uniqueId")]
135    pub unique_id: String,
136    #[serde(rename = "whenCreated")]
137    #[serde(alias = "@whenCreated")]
138    #[serde(alias = "whenCreated")]
139    pub when_created: Option<DateTime<Utc>>,
140    #[serde(rename = "creator")]
141    #[serde(alias = "@creator")]
142    #[serde(alias = "creator")]
143    pub creator: String,
144    #[serde(rename = "siteName")]
145    #[serde(alias = "@siteName")]
146    #[serde(alias = "siteName")]
147    pub site_name: String,
148    #[serde(rename = "siteUniqueId")]
149    #[serde(alias = "@siteUniqueId")]
150    #[serde(alias = "siteUniqueId")]
151    pub site_unique_id: String,
152
153    #[serde(rename = "lastLanguage")]
154    #[serde(alias = "@lastLanguage")]
155    #[serde(alias = "lastLanguage")]
156    #[serde(
157        default = "default_string_none",
158        deserialize_with = "deserialize_empty_string_as_none"
159    )]
160    pub last_language: Option<String>,
161
162    #[serde(rename = "numberOfForms")]
163    #[serde(alias = "@numberOfForms")]
164    #[serde(alias = "numberOfForms")]
165    pub number_of_forms: usize,
166
167    #[serde(alias = "form")]
168    pub forms: Option<Vec<Form>>,
169}
170
171#[cfg(feature = "python")]
172#[pymethods]
173impl Patient {
174    #[getter]
175    fn patient_id(&self) -> PyResult<String> {
176        Ok(self.patient_id.clone())
177    }
178
179    #[getter]
180    fn unique_id(&self) -> PyResult<String> {
181        Ok(self.unique_id.clone())
182    }
183
184    #[getter]
185    fn when_created<'py>(&self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyDateTime>>> {
186        self.when_created
187            .as_ref()
188            .map(|dt| to_py_datetime(py, dt))
189            .transpose()
190    }
191
192    #[getter]
193    fn creator(&self) -> PyResult<String> {
194        Ok(self.creator.clone())
195    }
196
197    #[getter]
198    fn site_name(&self) -> PyResult<String> {
199        Ok(self.site_name.clone())
200    }
201
202    #[getter]
203    fn site_unique_id(&self) -> PyResult<String> {
204        Ok(self.site_unique_id.clone())
205    }
206
207    #[getter]
208    fn last_language(&self) -> PyResult<Option<String>> {
209        Ok(self.last_language.clone())
210    }
211
212    #[getter]
213    fn number_of_forms(&self) -> PyResult<usize> {
214        Ok(self.number_of_forms)
215    }
216
217    #[getter]
218    fn forms(&self) -> PyResult<Option<Vec<Form>>> {
219        Ok(self.forms.clone())
220    }
221
222    pub fn to_dict<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
223        let dict = PyDict::new(py);
224        dict.set_item("patient_id", &self.patient_id)?;
225        dict.set_item("unique_id", &self.unique_id)?;
226        dict.set_item(
227            "when_created",
228            self.when_created
229                .as_ref()
230                .map(|dt| to_py_datetime(py, dt))
231                .transpose()?,
232        )?;
233        dict.set_item("creator", &self.creator)?;
234        dict.set_item("site_name", &self.site_name)?;
235        dict.set_item("site_unique_id", &self.site_unique_id)?;
236        dict.set_item("last_language", &self.last_language)?;
237        dict.set_item("number_of_forms", self.number_of_forms)?;
238
239        let mut form_dicts = Vec::new();
240        if let Some(forms) = &self.forms {
241            for form in forms {
242                let form_dict = form.to_dict(py)?;
243                form_dicts.push(form_dict);
244            }
245            dict.set_item("forms", form_dicts)?;
246        } else {
247            dict.set_item("forms", py.None())?;
248        }
249
250        Ok(dict)
251    }
252}
253
254#[cfg(not(feature = "python"))]
255/// Contains the information from the Prelude native subject XML.
256#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
257pub struct SubjectNative {
258    pub patients: Vec<Patient>,
259}
260
261#[cfg(not(feature = "python"))]
262impl SubjectNative {
263    /// Convert to a JSON string
264    ///
265    /// # Example
266    ///
267    /// ```
268    /// use std::path::Path;
269    ///
270    /// use prelude_xml_parser::parse_subject_native_file;
271    ///
272    /// let file_path = Path::new("tests/assets/subject_native_small.xml");
273    /// let native = parse_subject_native_file(&file_path).unwrap();
274    /// let json = native.to_json().unwrap();
275    /// // Verify the JSON contains expected patient data
276    /// assert!(json.contains("ABC-001"));
277    /// assert!(json.contains("Paul Sanders"));
278    /// assert!(json.contains("Labrador"));
279    /// ```
280    pub fn to_json(&self) -> serde_json::Result<String> {
281        let json = serde_json::to_string(&self)?;
282
283        Ok(json)
284    }
285}
286
287#[cfg(feature = "python")]
288/// Contains the information from the Prelude native subject XML.
289#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
290#[serde(rename_all = "camelCase")]
291#[pyclass(get_all, skip_from_py_object)]
292pub struct SubjectNative {
293    #[serde(alias = "patient")]
294    pub patients: Vec<Patient>,
295}
296
297#[cfg(feature = "python")]
298#[pymethods]
299impl SubjectNative {
300    #[getter]
301    fn sites(&self) -> PyResult<Vec<Patient>> {
302        Ok(self.patients.clone())
303    }
304
305    /// Convert the class instance to a dictionary
306    fn to_dict<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
307        let dict = PyDict::new(py);
308        let mut patient_dicts = Vec::new();
309        for patient in &self.patients {
310            let patient_dict = patient.to_dict(py)?;
311            patient_dicts.push(patient_dict);
312        }
313        dict.set_item("patients", patient_dicts)?;
314        Ok(dict)
315    }
316
317    /// Convert the class instance to a JSON string
318    fn to_json(&self) -> PyResult<String> {
319        serde_json::to_string(&self)
320            .map_err(|_| PyErr::new::<PyValueError, _>("Error converting to JSON"))
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use insta::assert_yaml_snapshot;
327
328    use super::*;
329
330    #[test]
331    fn deserialize_subject_native_json() {
332        let json_str = r#"{
333    "patients": [
334        {
335            "patientId": "ABC-001",
336            "uniqueId": "1681574905819",
337            "whenCreated": "2023-04-15T16:09:02Z",
338            "creator": "Paul Sanders",
339            "siteName": "Some Site",
340            "siteUniqueId": "1681574834910",
341            "lastLanguage": "English",
342            "numberOfForms": 6,
343            "forms": [
344                {
345                    "name": "day.0.form.name.demographics",
346                    "lastModified": "2023-04-15T16:09:15Z",
347                    "whoLastModifiedName": "Paul Sanders",
348                    "whoLastModifiedRole": "Project Manager",
349                    "whenCreated": 1681574905839,
350                    "hasErrors": false,
351                    "hasWarnings": false,
352                    "locked": false,
353                    "user": null,
354                    "dateTimeChanged": null,
355                    "formTitle": "Demographics",
356                    "formIndex": 1,
357                    "formGroup": "Day 0",
358                    "formState": "In-Work",
359                    "states": [
360                        {
361                            "value": "form.state.in.work",
362                            "signer": "Paul Sanders - Project Manager",
363                            "signerUniqueId": "1681162687395",
364                            "dateSigned": "2023-04-15T16:09:02Z"
365                        }
366                    ],
367                    "categories": [
368                        {
369                            "name": "Demographics",
370                            "categoryType": "normal",
371                            "highestIndex": 0,
372                            "fields": [
373                                {
374                                    "name": "breed",
375                                    "fieldType": "combo-box",
376                                    "dataType": "string",
377                                    "errorCode": "valid",
378                                    "whenCreated": "2023-04-15T16:08:26Z",
379                                    "keepHistory": true,
380                                    "entries": [
381                                        {
382                                            "entryId": "1",
383                                            "value": {
384                                                "by": "Paul Sanders",
385                                                "byUniqueId": "1681162687395",
386                                                "role": "Project Manager",
387                                                "when": "2023-04-15T16:09:02Z",
388                                                "value": "Labrador"
389                                            },
390                                            "reason": null
391                                        }
392                                    ]
393                                }
394                            ]
395                        }
396                    ]
397                }
398            ]
399        },
400        {
401            "patientId": "DEF-002",
402            "uniqueId": "1681574905820",
403            "whenCreated": "2023-04-16T16:10:02Z",
404            "creator": "Wade Watts",
405            "siteName": "Another Site",
406            "siteUniqueId": "1681574834911",
407            "lastLanguage": null,
408            "numberOfForms": 8,
409            "forms": [
410                {
411                    "name": "day.0.form.name.demographics",
412                    "lastModified": "2023-04-16T16:10:15Z",
413                    "whoLastModifiedName": "Barney Rubble",
414                    "whoLastModifiedRole": "Technician",
415                    "whenCreated": 1681574905838,
416                    "hasErrors": false,
417                    "hasWarnings": false,
418                    "locked": false,
419                    "user": null,
420                    "dateTimeChanged": null,
421                    "formTitle": "Demographics",
422                    "formIndex": 1,
423                    "formGroup": "Day 0",
424                    "formState": "In-Work",
425                    "states": [
426                        {
427                            "value": "form.state.in.work",
428                            "signer": "Paul Sanders - Project Manager",
429                            "signerUniqueId": "1681162687395",
430                            "dateSigned": "2023-04-16T16:10:02Z"
431                        }
432                    ],
433                    "categories": [
434                        {
435                            "name": "Demographics",
436                            "categoryType": "normal",
437                            "highestIndex": 0,
438                            "fields": [
439                                {
440                                    "name": "breed",
441                                    "fieldType": "combo-box",
442                                    "dataType": "string",
443                                    "errorCode": "valid",
444                                    "whenCreated": "2023-04-15T16:08:26Z",
445                                    "keepHistory": true,
446                                    "entries": [
447                                        {
448                                            "entryId": "1",
449                                            "value": {
450                                                "by": "Paul Sanders",
451                                                "byUniqueId": "1681162687395",
452                                                "role": "Project Manager",
453                                                "when": "2023-04-15T16:09:02Z",
454                                                "value": "Labrador"
455                                            },
456                                            "reason": null
457                                        }
458                                    ]
459                                }
460                            ]
461                        }
462                    ]
463                }
464            ]
465        }
466    ]
467}
468        "#;
469
470        let result: SubjectNative = serde_json::from_str(json_str).unwrap();
471
472        assert_yaml_snapshot!(result);
473    }
474}