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