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