diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 96162c1..fc589db 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -22,6 +22,18 @@ jobs: uses: Swatinem/rust-cache@v2.7.3 - name: Run cargo clippy run: cargo clippy --all-targets -- --deny warnings + clippy-python: + name: Clippy-Python + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + - name: Cache dependencies + uses: Swatinem/rust-cache@v2.7.3 + - name: Run cargo clippy + run: cargo clippy --all-targets -F python -- --deny warnings fmt: name: Rustfmt runs-on: ubuntu-latest @@ -34,6 +46,18 @@ jobs: uses: Swatinem/rust-cache@v2.7.3 - name: Run cargo fmt run: cargo fmt --all -- --check + check-python: + name: Check-Python + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + - name: Cache dependencies + uses: Swatinem/rust-cache@v2.7.3 + - name: Run cargo fmt + run: cargo check --all-targets -F python test: name: Tests strategy: diff --git a/src/lib.rs b/src/lib.rs index df2a0db..6fb5564 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,7 @@ pub mod native; use std::{fs::read_to_string, path::Path}; use crate::errors::Error; -use crate::native::subject_native::SubjectNative; +use crate::native::{site_native::SiteNative, subject_native::SubjectNative}; /// Parses a Prelude native XML file into a `Native` stuct. /// @@ -15,6 +15,387 @@ use crate::native::subject_native::SubjectNative; /// ``` /// use std::path::Path; /// +/// use prelude_xml_parser::parse_site_native_file; +/// +/// let file_path = Path::new("tests/assets/site_native.xml"); +/// let native = parse_site_native_file(&file_path).unwrap(); +/// +/// assert!(native.sites.len() >= 1, "Vector length is less than 1"); +/// ``` +pub fn parse_site_native_file(xml_path: &Path) -> Result { + if !xml_path.exists() { + return Err(Error::FileNotFound(xml_path.to_path_buf())); + } + + let xml_file = read_to_string(xml_path)?; + let native = parse_site_native_string(&xml_file)?; + + Ok(native) +} + +/// Parse a string of Preliude native site XML into a `SiteNative` struct. +/// +/// # Example +/// +/// ``` +/// use chrono::{DateTime, Utc}; +/// use prelude_xml_parser::parse_site_native_string; +/// use prelude_xml_parser::native::site_native::*; +/// +/// let xml = r#" +/// +/// +/// +///
+/// +/// +/// +/// +/// +/// Some Company +/// +/// +/// +/// +/// ABC-Some Site +/// calculated value +/// +/// +/// Some Site +/// calculated value +/// +/// +/// +/// +/// +/// +/// +/// Yes +/// +/// +/// +/// +/// +///
+/// +/// +///
+/// +/// +/// +/// +/// 1111 Moon Drive +/// +/// +/// +/// +///
+/// +///
+/// "#; +/// +/// let expected = SiteNative { +/// sites: vec![ +/// Site { +/// name: "Some Site".to_string(), +/// unique_id: "1681574834910".to_string(), +/// number_of_patients: 4, +/// count_of_randomized_patients: 0, +/// when_created: DateTime::parse_from_rfc3339("2023-04-15T16:08:19Z") +/// .unwrap() +/// .with_timezone(&Utc), +/// creator: "Paul Sanders".to_string(), +/// number_of_forms: 1, +/// forms: Some(vec![Form { +/// name: "demographic.form.name.site.demographics".to_string(), +/// last_modified: Some( +/// DateTime::parse_from_rfc3339("2023-04-15T16:08:19Z") +/// .unwrap() +/// .with_timezone(&Utc), +/// ), +/// who_last_modified_name: Some("Paul Sanders".to_string()), +/// who_last_modified_role: Some("Project Manager".to_string()), +/// when_created: 1681574834930, +/// has_errors: false, +/// has_warnings: false, +/// locked: false, +/// user: None, +/// date_time_changed: None, +/// form_title: "Site Demographics".to_string(), +/// form_index: 1, +/// form_group: "Demographic".to_string(), +/// form_state: "In-Work".to_string(), +/// states: Some(vec![State { +/// value: "form.state.in.work".to_string(), +/// signer: "Paul Sanders - Project Manager".to_string(), +/// signer_unique_id: "1681162687395".to_string(), +/// date_signed: Some( +/// DateTime::parse_from_rfc3339("2023-04-15T16:08:19Z") +/// .unwrap() +/// .with_timezone(&Utc), +/// ), +/// }]), +/// categories: Some(vec![ +/// Category { +/// name: "Demographics".to_string(), +/// category_type: "normal".to_string(), +/// highest_index: 0, +/// fields: vec![ +/// Field { +/// name: "address".to_string(), +/// field_type: "text".to_string(), +/// data_type: "string".to_string(), +/// error_code: "valid".to_string(), +/// when_created: DateTime::parse_from_rfc3339( +/// "2023-04-15T16:07:14Z", +/// ) +/// .unwrap() +/// .with_timezone(&Utc), +/// keep_history: true, +/// entries: None, +/// }, +/// Field { +/// name: "company".to_string(), +/// field_type: "text".to_string(), +/// data_type: "string".to_string(), +/// error_code: "valid".to_string(), +/// when_created: DateTime::parse_from_rfc3339( +/// "2023-04-15T16:07:14Z", +/// ) +/// .unwrap() +/// .with_timezone(&Utc), +/// keep_history: true, +/// entries: Some(vec![Entry { +/// entry_id: "1".to_string(), +/// value: Some(Value { +/// by: "Paul Sanders".to_string(), +/// by_unique_id: Some("1681162687395".to_string()), +/// role: "Project Manager".to_string(), +/// when: DateTime::parse_from_rfc3339( +/// "2023-04-15T16:08:19Z", +/// ) +/// .unwrap() +/// .with_timezone(&Utc), +/// value: "Some Company".to_string(), +/// }), +/// reason: None, +/// }]), +/// }, +/// Field { +/// name: "site_code_name".to_string(), +/// field_type: "hidden".to_string(), +/// data_type: "string".to_string(), +/// error_code: "valid".to_string(), +/// when_created: DateTime::parse_from_rfc3339( +/// "2023-04-15T16:07:14Z", +/// ) +/// .unwrap() +/// .with_timezone(&Utc), +/// keep_history: true, +/// entries: Some(vec![ +/// Entry { +/// entry_id: "1".to_string(), +/// value: Some(Value { +/// by: "set from calculation".to_string(), +/// by_unique_id: None, +/// role: "System".to_string(), +/// when: DateTime::parse_from_rfc3339( +/// "2023-04-15T16:08:19Z", +/// ) +/// .unwrap() +/// .with_timezone(&Utc), +/// value: "ABC-Some Site".to_string(), +/// }), +/// reason: Some(Reason { +/// by: "set from calculation".to_string(), +/// by_unique_id: None, +/// role: "System".to_string(), +/// when: DateTime::parse_from_rfc3339( +/// "2023-04-15T16:08:19Z", +/// ) +/// .unwrap() +/// .with_timezone(&Utc), +/// value: "calculated value".to_string(), +/// }), +/// }, +/// Entry { +/// entry_id: "2".to_string(), +/// value: Some(Value { +/// by: "set from calculation".to_string(), +/// by_unique_id: None, +/// role: "System".to_string(), +/// when: DateTime::parse_from_rfc3339( +/// "2023-04-15T16:07:24Z", +/// ) +/// .unwrap() +/// .with_timezone(&Utc), +/// value: "Some Site".to_string(), +/// }), +/// reason: Some(Reason { +/// by: "set from calculation".to_string(), +/// by_unique_id: None, +/// role: "System".to_string(), +/// when: DateTime::parse_from_rfc3339( +/// "2023-04-15T16:07:24Z", +/// ) +/// .unwrap() +/// .with_timezone(&Utc), +/// value: "calculated value".to_string(), +/// }), +/// }, +/// ]), +/// }, +/// ], +/// }, +/// Category { +/// name: "Enrollment".to_string(), +/// category_type: "normal".to_string(), +/// highest_index: 0, +/// fields: vec![ +/// Field { +/// name: "enrollment_closed_date".to_string(), +/// field_type: "popUpCalendar".to_string(), +/// data_type: "date".to_string(), +/// error_code: "valid".to_string(), +/// when_created: DateTime::parse_from_rfc3339( +/// "2023-04-15T16:07:14Z", +/// ) +/// .unwrap() +/// .with_timezone(&Utc), +/// keep_history: true, +/// entries: None, +/// }, +/// Field { +/// name: "enrollment_open".to_string(), +/// field_type: "radio".to_string(), +/// data_type: "string".to_string(), +/// error_code: "valid".to_string(), +/// when_created: DateTime::parse_from_rfc3339( +/// "2023-04-15T16:07:14Z", +/// ) +/// .unwrap() +/// .with_timezone(&Utc), +/// keep_history: true, +/// entries: Some(vec![Entry { +/// entry_id: "1".to_string(), +/// value: Some(Value { +/// by: "Paul Sanders".to_string(), +/// by_unique_id: Some("1681162687395".to_string()), +/// role: "Project Manager".to_string(), +/// when: DateTime::parse_from_rfc3339( +/// "2023-04-15T16:08:19Z", +/// ) +/// .unwrap() +/// .with_timezone(&Utc), +/// value: "Yes".to_string(), +/// }), +/// reason: None, +/// }]), +/// }, +/// Field { +/// name: "enrollment_open_date".to_string(), +/// field_type: "popUpCalendar".to_string(), +/// data_type: "date".to_string(), +/// error_code: "valid".to_string(), +/// when_created: DateTime::parse_from_rfc3339( +/// "2023-04-15T16:07:14Z", +/// ) +/// .unwrap() +/// .with_timezone(&Utc), +/// keep_history: true, +/// entries: None, +/// }, +/// ], +/// }, +/// ]), +/// }]), +/// }, +/// Site { +/// name: "Artemis".to_string(), +/// unique_id: "1691420994591".to_string(), +/// number_of_patients: 0, +/// count_of_randomized_patients: 0, +/// when_created: DateTime::parse_from_rfc3339("2023-08-07T15:14:23Z") +/// .unwrap() +/// .with_timezone(&Utc), +/// creator: "Paul Sanders".to_string(), +/// number_of_forms: 1, +/// forms: Some(vec![Form { +/// name: "demographic.form.name.site.demographics".to_string(), +/// last_modified: Some( +/// DateTime::parse_from_rfc3339("2023-08-07T15:14:23Z") +/// .unwrap() +/// .with_timezone(&Utc), +/// ), +/// who_last_modified_name: Some("Paul Sanders".to_string()), +/// who_last_modified_role: Some("Project Manager".to_string()), +/// when_created: 1691420994611, +/// has_errors: false, +/// has_warnings: false, +/// locked: false, +/// user: None, +/// date_time_changed: None, +/// form_title: "Site Demographics".to_string(), +/// form_index: 1, +/// form_group: "Demographic".to_string(), +/// form_state: "In-Work".to_string(), +/// states: Some(vec![State { +/// value: "form.state.in.work".to_string(), +/// signer: "Paul Sanders - Project Manager".to_string(), +/// signer_unique_id: "1681162687395".to_string(), +/// date_signed: Some( +/// DateTime::parse_from_rfc3339("2023-08-07T15:14:23Z") +/// .unwrap() +/// .with_timezone(&Utc), +/// ), +/// }]), +/// categories: Some(vec![Category { +/// name: "Demographics".to_string(), +/// category_type: "normal".to_string(), +/// highest_index: 0, +/// fields: vec![Field { +/// name: "address".to_string(), +/// field_type: "text".to_string(), +/// data_type: "string".to_string(), +/// error_code: "valid".to_string(), +/// when_created: DateTime::parse_from_rfc3339("2023-08-07T15:09:54Z") +/// .unwrap() +/// .with_timezone(&Utc), +/// keep_history: true, +/// entries: Some(vec![Entry { +/// entry_id: "1".to_string(), +/// value: Some(Value { +/// by: "Paul Sanders".to_string(), +/// by_unique_id: Some("1681162687395".to_string()), +/// role: "Project Manager".to_string(), +/// when: DateTime::parse_from_rfc3339("2023-08-07T15:14:21Z") +/// .unwrap() +/// .with_timezone(&Utc), +/// value: "1111 Moon Drive".to_string(), +/// }), +/// reason: None, +/// }]), +/// }], +/// }]), +/// }]), +/// }, +/// ], +/// }; +/// let result = parse_site_native_string(xml).unwrap(); +/// assert_eq!(result, expected); +pub fn parse_site_native_string(xml_str: &str) -> Result { + let native: SiteNative = serde_xml_rs::from_str(xml_str)?; + + Ok(native) +} + +/// Parses a Prelude native subject XML file into a `SubjectNative` stuct. +/// +/// # Example +/// +/// ``` +/// use std::path::Path; +/// /// use prelude_xml_parser::parse_subject_native_file; /// /// let file_path = Path::new("tests/assets/subject_native.xml"); @@ -33,7 +414,7 @@ pub fn parse_subject_native_file(xml_path: &Path) -> Result Result Result Result Result, + pub role: String, + pub when: DateTime, + + #[serde(rename = "$value")] + pub value: String, +} + +#[cfg(feature = "python")] +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[pyclass] +pub struct Value { + pub by: String, + + #[serde( + default = "default_string_none", + deserialize_with = "deserialize_empty_string_as_none" + )] + pub by_unique_id: Option, + pub role: String, + pub when: DateTime, + + #[serde(rename = "$value")] + pub value: String, +} + +#[cfg(feature = "python")] +#[pymethods] +impl Value { + #[getter] + fn by(&self) -> PyResult { + Ok(self.by.clone()) + } + + #[getter] + fn by_unique_id(&self) -> PyResult> { + Ok(self.by_unique_id.clone()) + } + + #[getter] + fn role(&self) -> PyResult { + Ok(self.role.clone()) + } + + #[getter] + fn when<'py>(&self, py: Python<'py>) -> PyResult> { + to_py_datetime(py, &self.when) + } + + #[getter] + fn value(&self) -> PyResult { + Ok(self.value.clone()) + } +} + +#[cfg(not(feature = "python"))] +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Reason { + pub by: String, + + #[serde( + default = "default_string_none", + deserialize_with = "deserialize_empty_string_as_none" + )] + pub by_unique_id: Option, + + pub role: String, + pub when: DateTime, + + #[serde(rename = "$value")] + pub value: String, +} + +#[cfg(feature = "python")] +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[pyclass] +pub struct Reason { + pub by: String, + + #[serde( + default = "default_string_none", + deserialize_with = "deserialize_empty_string_as_none" + )] + pub by_unique_id: Option, + + pub role: String, + pub when: DateTime, + + #[serde(rename = "$value")] + pub value: String, +} + +#[cfg(feature = "python")] +#[pymethods] +impl Reason { + #[getter] + fn by(&self) -> PyResult { + Ok(self.by.clone()) + } + + #[getter] + fn by_unique_id(&self) -> PyResult> { + Ok(self.by_unique_id.clone()) + } + + #[getter] + fn role(&self) -> PyResult { + Ok(self.role.clone()) + } + + #[getter] + fn when<'py>(&self, py: Python<'py>) -> PyResult> { + to_py_datetime(py, &self.when) + } + + #[getter] + fn value(&self) -> PyResult { + Ok(self.value.clone()) + } +} + +#[cfg(not(feature = "python"))] +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Entry { + #[serde(alias = "id")] + pub entry_id: String, + pub value: Option, + pub reason: Option, +} + +#[cfg(feature = "python")] +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[pyclass(get_all)] +pub struct Entry { + #[serde(alias = "id")] + pub entry_id: String, + pub value: Option, + pub reason: Option, +} + +#[cfg(not(feature = "python"))] +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Field { + pub name: String, + + #[serde(alias = "type")] + pub field_type: String, + + pub data_type: String, + pub error_code: String, + pub when_created: DateTime, + pub keep_history: bool, + + #[serde(rename = "entry")] + pub entries: Option>, +} + +#[cfg(feature = "python")] +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[pyclass] +pub struct Field { + pub name: String, + + #[serde(alias = "type")] + pub field_type: String, + + pub data_type: String, + pub error_code: String, + pub when_created: DateTime, + pub keep_history: bool, + + #[serde(rename = "entry")] + pub entries: Option>, +} + +#[cfg(feature = "python")] +#[pymethods] +impl Field { + #[getter] + fn name(&self) -> PyResult { + Ok(self.name.clone()) + } + + #[getter] + fn field_type(&self) -> PyResult { + Ok(self.field_type.clone()) + } + + #[getter] + fn data_type(&self) -> PyResult { + Ok(self.data_type.clone()) + } + + #[getter] + fn error_code(&self) -> PyResult { + Ok(self.error_code.clone()) + } + + #[getter] + fn when_created<'py>(&self, py: Python<'py>) -> PyResult> { + to_py_datetime(py, &self.when_created) + } + + #[getter] + fn keep_history(&self) -> PyResult { + Ok(self.keep_history) + } + + #[getter] + fn entries(&self) -> PyResult>> { + Ok(self.entries.clone()) + } +} + +#[cfg(not(feature = "python"))] +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Category { + pub name: String, + + #[serde(alias = "type")] + pub category_type: String, + + pub highest_index: usize, + + #[serde(rename = "field", default)] + pub fields: Vec, +} + +#[cfg(feature = "python")] +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[pyclass(get_all)] +pub struct Category { + pub name: String, + + #[serde(alias = "type")] + pub category_type: String, + + pub highest_index: usize, + + #[serde(rename = "field", default)] + pub fields: Vec, +} + +#[cfg(not(feature = "python"))] +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct State { + pub value: String, + pub signer: String, + pub signer_unique_id: String, + + #[serde( + default = "default_datetime_none", + deserialize_with = "deserialize_empty_string_as_none_datetime" + )] + pub date_signed: Option>, +} + +#[cfg(feature = "python")] +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[pyclass] +pub struct State { + pub value: String, + pub signer: String, + pub signer_unique_id: String, + + #[serde( + default = "default_datetime_none", + deserialize_with = "deserialize_empty_string_as_none_datetime" + )] + pub date_signed: Option>, +} + +#[cfg(feature = "python")] +#[pymethods] +impl State { + #[getter] + fn value(&self) -> PyResult { + Ok(self.value.clone()) + } + + #[getter] + fn signer(&self) -> PyResult { + Ok(self.signer.clone()) + } + + #[getter] + fn signer_unique_id(&self) -> PyResult { + Ok(self.signer_unique_id.clone()) + } + + #[getter] + fn date_signed<'py>(&self, py: Python<'py>) -> PyResult>> { + to_py_datetime_option(py, &self.date_signed) + } +} diff --git a/src/native/deserializers.rs b/src/native/deserializers.rs new file mode 100644 index 0000000..bd17dd7 --- /dev/null +++ b/src/native/deserializers.rs @@ -0,0 +1,88 @@ +#[cfg(feature = "python")] +use chrono::{Datelike, Timelike}; + +use chrono::{DateTime, Utc}; + +use serde::{Deserialize, Deserializer}; + +#[cfg(feature = "python")] +use pyo3::{prelude::*, types::PyDateTime}; + +pub fn deserialize_empty_string_as_none_datetime<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let s: String = Deserialize::deserialize(deserializer)?; + if s.is_empty() { + Ok(None) + } else { + // Parse the datetime with a fixed offset, then convert it to UTC + let dt_with_offset = DateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S %z") + .map_err(serde::de::Error::custom)?; + Ok(Some(dt_with_offset.with_timezone(&Utc))) + } +} + +pub fn deserialize_empty_string_as_none<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s = Option::::deserialize(deserializer)?; + match s { + Some(v) if v.is_empty() => Ok(None), + Some(v) => Ok(Some(v)), + None => Ok(None), + } +} + +pub fn default_datetime_none() -> Option> { + None +} + +pub fn default_string_none() -> Option { + None +} + +#[cfg(feature = "python")] +pub fn to_py_datetime<'py>( + py: Python<'py>, + date_time: &DateTime, +) -> PyResult> { + let py_datetime = PyDateTime::new_bound( + py, + date_time.year(), + date_time.month() as u8, + date_time.day() as u8, + date_time.hour() as u8, + date_time.minute() as u8, + date_time.second() as u8, + date_time.timestamp_subsec_micros(), + None, + )?; + Ok(py_datetime) +} + +#[cfg(feature = "python")] +pub fn to_py_datetime_option<'py>( + py: Python<'py>, + date_time: &Option>, +) -> PyResult>> { + if let Some(d) = date_time { + let py_datetime = Some(PyDateTime::new_bound( + py, + d.year(), + d.month() as u8, + d.day() as u8, + d.hour() as u8, + d.minute() as u8, + d.second() as u8, + d.timestamp_subsec_micros(), + None, + )?); + Ok(py_datetime) + } else { + Ok(None) + } +} diff --git a/src/native/mod.rs b/src/native/mod.rs index b2552d1..6155e85 100644 --- a/src/native/mod.rs +++ b/src/native/mod.rs @@ -1 +1,4 @@ +mod common; +mod deserializers; +pub mod site_native; pub mod subject_native; diff --git a/src/native/site_native.rs b/src/native/site_native.rs new file mode 100644 index 0000000..f24c473 --- /dev/null +++ b/src/native/site_native.rs @@ -0,0 +1,302 @@ +use chrono::{DateTime, Utc}; + +#[cfg(feature = "python")] +use pyo3::{prelude::*, types::PyDateTime}; + +use serde::Deserialize; + +pub use crate::native::common::{Category, Entry, Field, Reason, State, Value}; +use crate::native::deserializers::{ + default_datetime_none, default_string_none, deserialize_empty_string_as_none, + deserialize_empty_string_as_none_datetime, +}; + +#[cfg(feature = "python")] +use crate::native::deserializers::{to_py_datetime, to_py_datetime_option}; + +#[cfg(not(feature = "python"))] +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Form { + pub name: String, + + #[serde( + default = "default_datetime_none", + deserialize_with = "deserialize_empty_string_as_none_datetime" + )] + pub last_modified: Option>, + + #[serde( + default = "default_string_none", + deserialize_with = "deserialize_empty_string_as_none" + )] + pub who_last_modified_name: Option, + + #[serde( + default = "default_string_none", + deserialize_with = "deserialize_empty_string_as_none" + )] + pub who_last_modified_role: Option, + + pub when_created: usize, + pub has_errors: bool, + pub has_warnings: bool, + pub locked: bool, + + #[serde( + default = "default_string_none", + deserialize_with = "deserialize_empty_string_as_none" + )] + pub user: Option, + + #[serde( + default = "default_datetime_none", + deserialize_with = "deserialize_empty_string_as_none_datetime" + )] + pub date_time_changed: Option>, + + pub form_title: String, + pub form_index: usize, + pub form_group: String, + pub form_state: String, + + #[serde(rename = "state", default)] + pub states: Option>, + + #[serde(rename = "category", default)] + pub categories: Option>, +} + +#[cfg(feature = "python")] +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[pyclass] +pub struct Form { + pub name: String, + + #[serde( + default = "default_datetime_none", + deserialize_with = "deserialize_empty_string_as_none_datetime" + )] + pub last_modified: Option>, + + #[serde( + default = "default_string_none", + deserialize_with = "deserialize_empty_string_as_none" + )] + pub who_last_modified_name: Option, + + #[serde( + default = "default_string_none", + deserialize_with = "deserialize_empty_string_as_none" + )] + pub who_last_modified_role: Option, + + pub when_created: usize, + pub has_errors: bool, + pub has_warnings: bool, + pub locked: bool, + + #[serde( + default = "default_string_none", + deserialize_with = "deserialize_empty_string_as_none" + )] + pub user: Option, + + #[serde( + default = "default_datetime_none", + deserialize_with = "deserialize_empty_string_as_none_datetime" + )] + pub date_time_changed: Option>, + + pub form_title: String, + pub form_index: usize, + pub form_group: String, + pub form_state: String, + + #[serde(rename = "state", default)] + pub states: Option>, + + #[serde(rename = "category", default)] + pub categories: Option>, +} + +#[cfg(feature = "python")] +#[pymethods] +impl Form { + #[getter] + fn name(&self) -> PyResult { + Ok(self.name.clone()) + } + + #[getter] + fn last_modified<'py>(&self, py: Python<'py>) -> PyResult>> { + to_py_datetime_option(py, &self.last_modified) + } + + #[getter] + fn who_last_modified_name(&self) -> PyResult> { + Ok(self.who_last_modified_name.clone()) + } + + #[getter] + fn who_last_modified_role(&self) -> PyResult> { + Ok(self.who_last_modified_role.clone()) + } + + #[getter] + fn when_created(&self) -> PyResult { + Ok(self.when_created) + } + + #[getter] + fn has_errors(&self) -> PyResult { + Ok(self.has_errors) + } + + #[getter] + fn has_warnings(&self) -> PyResult { + Ok(self.has_warnings) + } + + #[getter] + fn locked(&self) -> PyResult { + Ok(self.locked) + } + + #[getter] + fn user(&self) -> PyResult> { + Ok(self.user.clone()) + } + + #[getter] + fn date_time_changed<'py>(&self, py: Python<'py>) -> PyResult>> { + to_py_datetime_option(py, &self.date_time_changed) + } + + #[getter] + fn form_title(&self) -> PyResult { + Ok(self.form_title.clone()) + } + + #[getter] + fn form_index(&self) -> PyResult { + Ok(self.form_index) + } + + #[getter] + fn form_group(&self) -> PyResult { + Ok(self.form_group.clone()) + } + + #[getter] + fn form_state(&self) -> PyResult { + Ok(self.form_state.clone()) + } + + #[getter] + fn states(&self) -> PyResult>> { + Ok(self.states.clone()) + } + + #[getter] + fn categories(&self) -> PyResult>> { + Ok(self.categories.clone()) + } +} + +#[cfg(not(feature = "python"))] +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Site { + pub name: String, + pub unique_id: String, + pub number_of_patients: usize, + pub count_of_randomized_patients: usize, + pub when_created: DateTime, + pub creator: String, + pub number_of_forms: usize, + + #[serde(rename = "form")] + pub forms: Option>, +} + +#[cfg(feature = "python")] +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[pyclass] +pub struct Site { + pub name: String, + pub unique_id: String, + pub number_of_patients: usize, + pub count_of_randomized_patients: usize, + pub when_created: DateTime, + pub creator: String, + pub number_of_forms: usize, + + #[serde(rename = "form")] + pub forms: Option>, +} + +#[cfg(feature = "python")] +#[pymethods] +impl Site { + #[getter] + fn name(&self) -> PyResult { + Ok(self.name.clone()) + } + + #[getter] + fn unique_id(&self) -> PyResult { + Ok(self.unique_id.clone()) + } + + #[getter] + fn number_of_patients(&self) -> PyResult { + Ok(self.number_of_patients) + } + + #[getter] + fn count_of_randomized_patients(&self) -> PyResult { + Ok(self.count_of_randomized_patients) + } + + #[getter] + fn when_created<'py>(&self, py: Python<'py>) -> PyResult> { + to_py_datetime(py, &self.when_created) + } + + #[getter] + fn creator(&self) -> PyResult { + Ok(self.creator.clone()) + } + + #[getter] + fn number_of_forms(&self) -> PyResult { + Ok(self.number_of_forms) + } + + #[getter] + fn forms(&self) -> PyResult>> { + Ok(self.forms.clone()) + } +} + +#[cfg(not(feature = "python"))] +/// Contains the information from the Prelude native site XML. +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SiteNative { + #[serde(rename = "site", default)] + pub sites: Vec, +} + +#[cfg(feature = "python")] +/// Contains the information from the Prelude native site XML. +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[pyclass(get_all)] +pub struct SiteNative { + #[serde(rename = "site", default)] + pub sites: Vec, +} diff --git a/src/native/subject_native.rs b/src/native/subject_native.rs index 6d5c31f..d8f0f68 100644 --- a/src/native/subject_native.rs +++ b/src/native/subject_native.rs @@ -1,286 +1,18 @@ use chrono::{DateTime, Utc}; -#[cfg(feature = "python")] -use chrono::{Datelike, Timelike}; - -use serde::{Deserialize, Deserializer}; - #[cfg(feature = "python")] use pyo3::{prelude::*, types::PyDateTime}; -fn deserialize_empty_string_as_none_datetime<'de, D>( - deserializer: D, -) -> Result>, D::Error> -where - D: Deserializer<'de>, -{ - let s: String = Deserialize::deserialize(deserializer)?; - if s.is_empty() { - Ok(None) - } else { - // Parse the datetime with a fixed offset, then convert it to UTC - let dt_with_offset = DateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S %z") - .map_err(serde::de::Error::custom)?; - Ok(Some(dt_with_offset.with_timezone(&Utc))) - } -} - -fn deserialize_empty_string_as_none<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let s = Option::::deserialize(deserializer)?; - match s { - Some(v) if v.is_empty() => Ok(None), - Some(v) => Ok(Some(v)), - None => Ok(None), - } -} - -#[cfg(not(feature = "python"))] -#[derive(Debug, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Value { - pub by: String, - - #[serde( - default = "default_string_none", - deserialize_with = "deserialize_empty_string_as_none" - )] - pub by_unique_id: Option, - pub role: String, - pub when: DateTime, - - #[serde(rename = "$value")] - pub value: String, -} - -#[cfg(feature = "python")] -#[derive(Clone, Debug, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[pyclass] -pub struct Value { - pub by: String, - - #[serde( - default = "default_string_none", - deserialize_with = "deserialize_empty_string_as_none" - )] - pub by_unique_id: Option, - pub role: String, - pub when: DateTime, - - #[serde(rename = "$value")] - pub value: String, -} - -#[cfg(feature = "python")] -#[pymethods] -impl Value { - #[getter] - fn by(&self) -> PyResult { - Ok(self.by.clone()) - } - - #[getter] - fn by_unique_id(&self) -> PyResult> { - Ok(self.by_unique_id.clone()) - } - - #[getter] - fn role(&self) -> PyResult { - Ok(self.role.clone()) - } - - #[getter] - fn when<'py>(&self, py: Python<'py>) -> PyResult> { - to_py_datetime(py, &self.when) - } - - #[getter] - fn value(&self) -> PyResult { - Ok(self.value.clone()) - } -} - -#[cfg(not(feature = "python"))] -#[derive(Debug, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Entry { - pub id: String, - pub value: Option, -} - -#[cfg(feature = "python")] -#[derive(Clone, Debug, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[pyclass(get_all)] -pub struct Entry { - pub id: String, - pub value: Option, -} - -#[cfg(not(feature = "python"))] -#[derive(Debug, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Field { - pub name: String, - - #[serde(alias = "type")] - pub field_type: String, - - pub data_type: String, - pub error_code: String, - pub when_created: DateTime, - pub keep_history: bool, - - #[serde(rename = "entry")] - pub entries: Option>, -} - -#[cfg(feature = "python")] -#[derive(Clone, Debug, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[pyclass] -pub struct Field { - pub name: String, - - #[serde(alias = "type")] - pub field_type: String, - - pub data_type: String, - pub error_code: String, - pub when_created: DateTime, - pub keep_history: bool, - - #[serde(rename = "entry")] - pub entries: Option>, -} - -#[cfg(feature = "python")] -#[pymethods] -impl Field { - #[getter] - fn name(&self) -> PyResult { - Ok(self.name.clone()) - } - - #[getter] - fn field_type(&self) -> PyResult { - Ok(self.field_type.clone()) - } - - #[getter] - fn data_type(&self) -> PyResult { - Ok(self.data_type.clone()) - } - - #[getter] - fn error_code(&self) -> PyResult { - Ok(self.error_code.clone()) - } - - #[getter] - fn when_created<'py>(&self, py: Python<'py>) -> PyResult> { - to_py_datetime(py, &self.when_created) - } - - #[getter] - fn keep_history(&self) -> PyResult { - Ok(self.keep_history) - } - - #[getter] - fn entries(&self) -> PyResult>> { - Ok(self.entries.clone()) - } -} - -#[cfg(not(feature = "python"))] -#[derive(Debug, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Category { - pub name: String, - - #[serde(alias = "type")] - pub category_type: String, - - pub highest_index: usize, - - #[serde(rename = "field", default)] - pub fields: Vec, -} - -#[cfg(feature = "python")] -#[derive(Clone, Debug, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[pyclass(get_all)] -pub struct Category { - pub name: String, - - #[serde(alias = "type")] - pub category_type: String, - - pub highest_index: usize, - - #[serde(rename = "field", default)] - pub fields: Vec, -} - -#[cfg(not(feature = "python"))] -#[derive(Debug, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct State { - pub value: String, - pub signer: String, - pub signer_unique_id: String, - - #[serde( - default = "default_datetime_none", - deserialize_with = "deserialize_empty_string_as_none_datetime" - )] - pub date_signed: Option>, -} - #[cfg(feature = "python")] -#[derive(Clone, Debug, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[pyclass] -pub struct State { - pub value: String, - pub signer: String, - pub signer_unique_id: String, +use crate::native::deserializers::{to_py_datetime, to_py_datetime_option}; - #[serde( - default = "default_datetime_none", - deserialize_with = "deserialize_empty_string_as_none_datetime" - )] - pub date_signed: Option>, -} +use serde::Deserialize; -#[cfg(feature = "python")] -#[pymethods] -impl State { - #[getter] - fn value(&self) -> PyResult { - Ok(self.value.clone()) - } - - #[getter] - fn signer(&self) -> PyResult { - Ok(self.signer.clone()) - } - - #[getter] - fn signer_unique_id(&self) -> PyResult { - Ok(self.signer_unique_id.clone()) - } - - #[getter] - fn date_signed<'py>(&self, py: Python<'py>) -> PyResult>> { - to_py_datetime_option(py, &self.date_signed) - } -} +pub use crate::native::common::{Category, Entry, Field, Reason, State, Value}; +use crate::native::deserializers::{ + default_datetime_none, default_string_none, deserialize_empty_string_as_none, + deserialize_empty_string_as_none_datetime, +}; #[cfg(not(feature = "python"))] #[derive(Debug, Deserialize, PartialEq)] @@ -587,53 +319,3 @@ pub struct SubjectNative { #[serde(rename = "patient", default)] pub patients: Vec, } - -#[cfg(feature = "python")] -fn to_py_datetime<'py>( - py: Python<'py>, - date_time: &DateTime, -) -> PyResult> { - let py_datetime = PyDateTime::new_bound( - py, - date_time.year(), - date_time.month() as u8, - date_time.day() as u8, - date_time.hour() as u8, - date_time.minute() as u8, - date_time.second() as u8, - date_time.timestamp_subsec_micros(), - None, - )?; - Ok(py_datetime) -} - -#[cfg(feature = "python")] -fn to_py_datetime_option<'py>( - py: Python<'py>, - date_time: &Option>, -) -> PyResult>> { - if let Some(d) = date_time { - let py_datetime = Some(PyDateTime::new_bound( - py, - d.year(), - d.month() as u8, - d.day() as u8, - d.hour() as u8, - d.minute() as u8, - d.second() as u8, - d.timestamp_subsec_micros(), - None, - )?); - Ok(py_datetime) - } else { - Ok(None) - } -} - -fn default_datetime_none() -> Option> { - None -} - -fn default_string_none() -> Option { - None -} diff --git a/tests/assets/site_native.xml b/tests/assets/site_native.xml new file mode 100644 index 0000000..bba945f --- /dev/null +++ b/tests/assets/site_native.xml @@ -0,0 +1,50 @@ + + + + +
+ + + + + + Some Company + + + + + ABC-Some Site + calculated value + + + Some Site + calculated value + + + + + + + + Yes + + + + + +
+ + +
+ + + + + 1111 Moon Drive + + + + +
+ +