prelude_xml_parser/native/
subject_native.rs

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