Skip to main content

prelude_xml_parser/native/
site_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
12use serde::{Deserialize, Serialize};
13
14pub use crate::native::common::{Category, Comment, Entry, Field, Form, Reason, State, Value};
15
16#[cfg(feature = "python")]
17use crate::native::deserializers::to_py_datetime;
18
19#[cfg(not(feature = "python"))]
20#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
21#[serde(rename_all = "camelCase")]
22pub struct Site {
23    #[serde(alias = "@name")]
24    #[serde(alias = "name")]
25    pub name: String,
26    #[serde(rename = "uniqueId")]
27    #[serde(alias = "@uniqueId")]
28    #[serde(alias = "uniqueId")]
29    pub unique_id: String,
30    #[serde(rename = "numberOfPatients")]
31    #[serde(alias = "@numberOfPatients")]
32    #[serde(alias = "numberOfPatients")]
33    pub number_of_patients: usize,
34    #[serde(rename = "countOfRandomizedPatients")]
35    #[serde(alias = "@countOfRandomizedPatients")]
36    #[serde(alias = "countOfRandomizedPatients")]
37    pub count_of_randomized_patients: usize,
38    #[serde(rename = "whenCreated")]
39    #[serde(alias = "@whenCreated")]
40    #[serde(alias = "whenCreated")]
41    pub when_created: Option<DateTime<Utc>>,
42    #[serde(alias = "@creator")]
43    #[serde(alias = "creator")]
44    pub creator: String,
45    #[serde(rename = "numberOfForms")]
46    #[serde(alias = "@numberOfForms")]
47    #[serde(alias = "numberOfForms")]
48    pub number_of_forms: usize,
49
50    #[serde(rename = "form")]
51    #[serde(alias = "form")]
52    pub forms: Option<Vec<Form>>,
53}
54
55#[cfg(not(feature = "python"))]
56impl Site {
57    pub fn from_attributes(attrs: HashMap<String, String>) -> Result<Self, crate::errors::Error> {
58        let name = attrs.get("name").cloned().ok_or_else(|| {
59            crate::errors::Error::ParsingError(quick_xml::de::DeError::Custom(
60                "Missing name".to_string(),
61            ))
62        })?;
63
64        let unique_id = attrs.get("uniqueId").cloned().ok_or_else(|| {
65            crate::errors::Error::ParsingError(quick_xml::de::DeError::Custom(
66                "Missing uniqueId".to_string(),
67            ))
68        })?;
69
70        let number_of_patients = attrs
71            .get("numberOfPatients")
72            .and_then(|s| s.parse().ok())
73            .unwrap_or(0);
74
75        let count_of_randomized_patients = attrs
76            .get("countOfRandomizedPatients")
77            .and_then(|s| s.parse().ok())
78            .unwrap_or(0);
79
80        let when_created = if let Some(wc_str) = attrs.get("whenCreated") {
81            if wc_str.is_empty() {
82                None
83            } else {
84                Some(parse_datetime(wc_str)?)
85            }
86        } else {
87            None
88        };
89
90        let creator = attrs.get("creator").cloned().ok_or_else(|| {
91            crate::errors::Error::ParsingError(quick_xml::de::DeError::Custom(
92                "Missing creator".to_string(),
93            ))
94        })?;
95
96        let number_of_forms = attrs
97            .get("numberOfForms")
98            .and_then(|s| s.parse().ok())
99            .unwrap_or(0);
100
101        Ok(Site {
102            name,
103            unique_id,
104            number_of_patients,
105            count_of_randomized_patients,
106            when_created,
107            creator,
108            number_of_forms,
109            forms: None,
110        })
111    }
112
113    pub fn set_forms(&mut self, forms: Vec<Form>) {
114        self.forms = if forms.is_empty() { None } else { Some(forms) };
115    }
116}
117
118fn parse_datetime(s: &str) -> Result<DateTime<Utc>, crate::errors::Error> {
119    if let Ok(dt) = chrono::DateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S %z") {
120        Ok(dt.with_timezone(&Utc))
121    } else if let Ok(dt) = chrono::DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%z") {
122        Ok(dt.with_timezone(&Utc))
123    } else if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
124        Ok(dt.with_timezone(&Utc))
125    } else {
126        Err(crate::errors::Error::ParsingError(
127            quick_xml::de::DeError::Custom(format!("Invalid datetime format: {}", s)),
128        ))
129    }
130}
131
132#[cfg(feature = "python")]
133#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
134#[serde(rename_all = "camelCase")]
135#[pyclass(skip_from_py_object)]
136pub struct Site {
137    #[serde(alias = "@name")]
138    #[serde(alias = "name")]
139    pub name: String,
140    #[serde(rename = "uniqueId")]
141    #[serde(alias = "@uniqueId")]
142    #[serde(alias = "uniqueId")]
143    pub unique_id: String,
144    #[serde(rename = "numberOfPatients")]
145    #[serde(alias = "@numberOfPatients")]
146    #[serde(alias = "numberOfPatients")]
147    pub number_of_patients: usize,
148    #[serde(rename = "countOfRandomizedPatients")]
149    #[serde(alias = "@countOfRandomizedPatients")]
150    #[serde(alias = "countOfRandomizedPatients")]
151    pub count_of_randomized_patients: usize,
152    #[serde(rename = "whenCreated")]
153    #[serde(alias = "@whenCreated")]
154    #[serde(alias = "whenCreated")]
155    pub when_created: Option<DateTime<Utc>>,
156    #[serde(alias = "@creator")]
157    #[serde(alias = "creator")]
158    pub creator: String,
159    #[serde(rename = "numberOfForms")]
160    #[serde(alias = "@numberOfForms")]
161    #[serde(alias = "numberOfForms")]
162    pub number_of_forms: usize,
163
164    #[serde(rename = "form")]
165    #[serde(alias = "form")]
166    pub forms: Option<Vec<Form>>,
167}
168
169#[cfg(feature = "python")]
170impl Site {
171    pub fn from_attributes(attrs: HashMap<String, String>) -> Result<Self, crate::errors::Error> {
172        let name = attrs.get("name").cloned().ok_or_else(|| {
173            crate::errors::Error::ParsingError(quick_xml::de::DeError::Custom(
174                "Missing name".to_string(),
175            ))
176        })?;
177
178        let unique_id = attrs.get("uniqueId").cloned().ok_or_else(|| {
179            crate::errors::Error::ParsingError(quick_xml::de::DeError::Custom(
180                "Missing uniqueId".to_string(),
181            ))
182        })?;
183
184        let number_of_patients = attrs
185            .get("numberOfPatients")
186            .and_then(|s| s.parse().ok())
187            .unwrap_or(0);
188
189        let count_of_randomized_patients = attrs
190            .get("countOfRandomizedPatients")
191            .and_then(|s| s.parse().ok())
192            .unwrap_or(0);
193
194        let when_created = if let Some(wc_str) = attrs.get("whenCreated") {
195            if wc_str.is_empty() {
196                None
197            } else {
198                Some(parse_datetime(wc_str)?)
199            }
200        } else {
201            None
202        };
203
204        let creator = attrs.get("creator").cloned().ok_or_else(|| {
205            crate::errors::Error::ParsingError(quick_xml::de::DeError::Custom(
206                "Missing creator".to_string(),
207            ))
208        })?;
209
210        let number_of_forms = attrs
211            .get("numberOfForms")
212            .and_then(|s| s.parse().ok())
213            .unwrap_or(0);
214
215        Ok(Site {
216            name,
217            unique_id,
218            number_of_patients,
219            count_of_randomized_patients,
220            when_created,
221            creator,
222            number_of_forms,
223            forms: None,
224        })
225    }
226
227    pub fn set_forms(&mut self, forms: Vec<Form>) {
228        self.forms = if forms.is_empty() { None } else { Some(forms) };
229    }
230}
231
232#[cfg(feature = "python")]
233#[pymethods]
234impl Site {
235    #[getter]
236    fn name(&self) -> PyResult<String> {
237        Ok(self.name.clone())
238    }
239
240    #[getter]
241    fn unique_id(&self) -> PyResult<String> {
242        Ok(self.unique_id.clone())
243    }
244
245    #[getter]
246    fn number_of_patients(&self) -> PyResult<usize> {
247        Ok(self.number_of_patients)
248    }
249
250    #[getter]
251    fn count_of_randomized_patients(&self) -> PyResult<usize> {
252        Ok(self.count_of_randomized_patients)
253    }
254
255    #[getter]
256    fn when_created<'py>(&self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyDateTime>>> {
257        self.when_created
258            .as_ref()
259            .map(|dt| to_py_datetime(py, dt))
260            .transpose()
261    }
262
263    #[getter]
264    fn creator(&self) -> PyResult<String> {
265        Ok(self.creator.clone())
266    }
267
268    #[getter]
269    fn number_of_forms(&self) -> PyResult<usize> {
270        Ok(self.number_of_forms)
271    }
272
273    #[getter]
274    fn forms(&self) -> PyResult<Option<Vec<Form>>> {
275        Ok(self.forms.clone())
276    }
277
278    pub fn to_dict<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
279        let dict = PyDict::new(py);
280        dict.set_item("name", &self.name)?;
281        dict.set_item("unique_id", &self.unique_id)?;
282        dict.set_item("number_of_patients", self.number_of_patients)?;
283        dict.set_item(
284            "count_of_randomized_patients",
285            self.count_of_randomized_patients,
286        )?;
287        dict.set_item(
288            "when_created",
289            self.when_created
290                .as_ref()
291                .map(|dt| to_py_datetime(py, dt))
292                .transpose()?,
293        )?;
294        dict.set_item("creator", &self.creator)?;
295        dict.set_item("number_of_forms", self.number_of_forms)?;
296
297        let mut form_dicts = Vec::new();
298        if let Some(forms) = &self.forms {
299            for form in forms {
300                let form_dict = form.to_dict(py)?;
301                form_dicts.push(form_dict);
302            }
303            dict.set_item("forms", form_dicts)?;
304        } else {
305            dict.set_item("forms", py.None())?;
306        }
307
308        Ok(dict)
309    }
310}
311
312#[cfg(not(feature = "python"))]
313/// Contains the information from the Prelude native site XML.
314#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
315#[serde(rename_all = "camelCase")]
316pub struct SiteNative {
317    #[serde(alias = "site")]
318    pub sites: Vec<Site>,
319}
320
321#[cfg(not(feature = "python"))]
322impl SiteNative {
323    /// Convert to a JSON string
324    ///
325    /// # Example
326    ///
327    /// ```
328    /// use std::path::Path;
329    ///
330    /// use prelude_xml_parser::parse_site_native_file;
331    ///
332    /// let file_path = Path::new("tests/assets/site_native_small.xml");
333    /// let native = parse_site_native_file(&file_path).unwrap();
334    /// let json = native.to_json().unwrap();
335    /// println!("{json}");
336    /// // Verify the JSON contains expected site data
337    /// assert!(json.contains("Some Site"));
338    /// assert!(json.contains("Paul Sanders"));
339    /// assert!(json.contains("Some Company"));
340    /// ```
341    pub fn to_json(&self) -> serde_json::Result<String> {
342        let json = serde_json::to_string(&self)?;
343
344        Ok(json)
345    }
346}
347
348#[cfg(feature = "python")]
349/// Contains the information from the Prelude native site XML.
350#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
351#[serde(rename_all = "camelCase")]
352#[pyclass(get_all, skip_from_py_object)]
353pub struct SiteNative {
354    #[serde(alias = "site")]
355    pub sites: Vec<Site>,
356}
357
358#[cfg(feature = "python")]
359#[pymethods]
360impl SiteNative {
361    #[getter]
362    fn sites(&self) -> PyResult<Vec<Site>> {
363        Ok(self.sites.clone())
364    }
365
366    /// Convert the class instance to a dictionary
367    fn to_dict<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
368        let dict = PyDict::new(py);
369        let mut site_dicts = Vec::new();
370        for site in &self.sites {
371            let site_dict = site.to_dict(py)?;
372            site_dicts.push(site_dict);
373        }
374        dict.set_item("sites", site_dicts)?;
375        Ok(dict)
376    }
377
378    /// Convert the class instance to a JSON string
379    fn to_json(&self) -> PyResult<String> {
380        serde_json::to_string(&self)
381            .map_err(|_| PyErr::new::<PyValueError, _>("Error converting to JSON"))
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use insta::assert_yaml_snapshot;
389
390    #[test]
391    fn deserialize_site_native_json() {
392        let json_str = r#"{
393  "sites": [
394    {
395      "name": "Some Site",
396      "uniqueId": "1681574834910",
397      "numberOfPatients": 4,
398      "countOfRandomizedPatients": 0,
399      "whenCreated": "2023-04-15T16:08:19Z",
400      "creator": "Paul Sanders",
401      "numberOfForms": 1,
402      "forms": [
403        {
404          "name": "demographic.form.name.site.demographics",
405          "lastModified": "2023-04-15T16:08:19Z",
406          "whoLastModifiedName": "Paul Sanders",
407          "whoLastModifiedRole": "Project Manager",
408          "whenCreated": 1681574834930,
409          "hasErrors": false,
410          "hasWarnings": false,
411          "locked": false,
412          "user": null,
413          "dateTimeChanged": null,
414          "formTitle": "Site Demographics",
415          "formIndex": 1,
416          "formGroup": "Demographic",
417          "formState": "In-Work",
418          "states": [
419            {
420              "value": "form.state.in.work",
421              "signer": "Paul Sanders - Project Manager",
422              "signerUniqueId": "1681162687395",
423              "dateSigned": "2023-04-15T16:08:19Z"
424            }
425          ],
426          "categories": [
427            {
428              "name": "Demographics",
429              "categoryType": "normal",
430              "highestIndex": 0,
431              "fields": [
432                {
433                  "name": "address",
434                  "fieldType": "text",
435                  "dataType": "string",
436                  "errorCode": "valid",
437                  "whenCreated": "2023-04-15T16:07:14Z",
438                  "keepHistory": true,
439                  "entry": null
440                },
441                {
442                  "name": "company",
443                  "fieldType": "text",
444                  "dataType": "string",
445                  "errorCode": "valid",
446                  "whenCreated": "2023-04-15T16:07:14Z",
447                  "keepHistory": true,
448                  "entries": [
449                    {
450                      "entryId": "1",
451                      "value": {
452                        "by": "Paul Sanders",
453                        "byUniqueId": "1681162687395",
454                        "role": "Project Manager",
455                        "when": "2023-04-15T16:08:19Z",
456                        "value": "Some Company"
457                      },
458                      "reason": null
459                    }
460                  ]
461                },
462                {
463                  "name": "site_code_name",
464                  "fieldType": "hidden",
465                  "dataType": "string",
466                  "errorCode": "valid",
467                  "whenCreated": "2023-04-15T16:07:14Z",
468                  "keepHistory": true,
469                  "entry": [
470                    {
471                      "entryId": "1",
472                      "value": {
473                        "by": "set from calculation",
474                        "byUniqueId": null,
475                        "role": "System",
476                        "when": "2023-04-15T16:08:19Z",
477                        "value": "ABC-Some Site"
478                      },
479                      "reason": {
480                        "by": "set from calculation",
481                        "byUniqueId": null,
482                        "role": "System",
483                        "when": "2023-04-15T16:08:19Z",
484                        "value": "calculated value"
485                      }
486                    },
487                    {
488                      "entryId": "2",
489                      "value": {
490                        "by": "set from calculation",
491                        "byUniqueId": null,
492                        "role": "System",
493                        "when": "2023-04-15T16:07:24Z",
494                        "value": "Some Site"
495                      },
496                      "reason": {
497                        "by": "set from calculation",
498                        "byUniqueId": null,
499                        "role": "System",
500                        "when": "2023-04-15T16:07:24Z",
501                        "value": "calculated value"
502                      }
503                    }
504                  ]
505                }
506              ]
507            },
508            {
509              "name": "Enrollment",
510              "categoryType": "normal",
511              "highestIndex": 0,
512              "field": [
513                {
514                  "name": "enrollment_closed_date",
515                  "fieldType": "popUpCalendar",
516                  "dataType": "date",
517                  "errorCode": "valid",
518                  "whenCreated": "2023-04-15T16:07:14Z",
519                  "keepHistory": true,
520                  "entry": null
521                },
522                {
523                  "name": "enrollment_open",
524                  "fieldType": "radio",
525                  "dataType": "string",
526                  "errorCode": "valid",
527                  "whenCreated": "2023-04-15T16:07:14Z",
528                  "keepHistory": true,
529                  "entry": [
530                    {
531                      "entryId": "1",
532                      "value": {
533                        "by": "Paul Sanders",
534                        "byUniqueId": "1681162687395",
535                        "role": "Project Manager",
536                        "when": "2023-04-15T16:08:19Z",
537                        "value": "Yes"
538                      },
539                      "reason": null
540                    }
541                  ]
542                },
543                {
544                  "name": "enrollment_open_date",
545                  "fieldType": "popUpCalendar",
546                  "dataType": "date",
547                  "errorCode": "valid",
548                  "whenCreated": "2023-04-15T16:07:14Z",
549                  "keepHistory": true,
550                  "entry": null
551                }
552              ]
553            }
554          ]
555        }
556      ]
557    },
558    {
559      "name": "Artemis",
560      "uniqueId": "1691420994591",
561      "numberOfPatients": 0,
562      "countOfRandomizedPatients": 0,
563      "whenCreated": "2023-08-07T15:14:23Z",
564      "creator": "Paul Sanders",
565      "numberOfForms": 1,
566      "forms": [
567        {
568          "name": "demographic.form.name.site.demographics",
569          "lastModified": "2023-08-07T15:14:23Z",
570          "whoLastModifiedName": "Paul Sanders",
571          "whoLastModifiedRole": "Project Manager",
572          "whenCreated": 1691420994611,
573          "hasErrors": false,
574          "hasWarnings": false,
575          "locked": false,
576          "user": null,
577          "dateTimeChanged": null,
578          "formTitle": "Site Demographics",
579          "formIndex": 1,
580          "formGroup": "Demographic",
581          "formState": "In-Work",
582          "states": [
583            {
584              "value": "form.state.in.work",
585              "signer": "Paul Sanders - Project Manager",
586              "signerUniqueId": "1681162687395",
587              "dateSigned": "2023-08-07T15:14:23Z"
588            }
589          ],
590          "categories": [
591            {
592              "name": "Demographics",
593              "categoryType": "normal",
594              "highestIndex": 0,
595              "fields": [
596                {
597                  "name": "address",
598                  "fieldType": "text",
599                  "dataType": "string",
600                  "errorCode": "valid",
601                  "whenCreated": "2023-08-07T15:09:54Z",
602                  "keepHistory": true,
603                  "entries": [
604                    {
605                      "entryId": "1",
606                      "value": {
607                        "by": "Paul Sanders",
608                        "byUniqueId": "1681162687395",
609                        "role": "Project Manager",
610                        "when": "2023-08-07T15:14:21Z",
611                        "value": "1111 Moon Drive"
612                      },
613                      "reason": null
614                    }
615                  ]
616                }
617              ]
618            }
619          ]
620        }
621      ]
622    }
623  ]
624}
625        "#;
626
627        let result: SiteNative = serde_json::from_str(json_str).unwrap();
628
629        assert_yaml_snapshot!(result);
630    }
631}