use derive_builder::Builder;
use reqwest::Method;
use std::borrow::Cow;
use crate::api::custom_fields::CustomFieldEssentialsWithValue;
use crate::api::enumerations::TimeEntryActivityEssentials;
use crate::api::issues::{
IssueEssentials, IssueStatusFilter, MemberOfGroupFilter, RoleFilter, UserFilter,
};
use crate::api::projects::{ProjectEssentials, ProjectFilter, ProjectStatusFilter};
use crate::api::users::UserEssentials;
use crate::api::{
ActivityFilter, CustomFieldFilter, DateFilter, Endpoint, FloatFilter, NoPagination, Pageable,
QueryParams, ReturnsJsonResponse, StringFieldFilter, TrackerFilter, VersionFilter,
};
use serde::Serialize;
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct TimeEntry {
pub id: u64,
pub user: UserEssentials,
pub hours: f64,
pub activity: TimeEntryActivityEssentials,
#[serde(default)]
pub comments: Option<String>,
pub issue: Option<IssueEssentials>,
pub project: Option<ProjectEssentials>,
pub spent_on: Option<time::Date>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_fields: Option<Vec<CustomFieldEssentialsWithValue>>,
#[serde(
serialize_with = "crate::api::serialize_rfc3339",
deserialize_with = "crate::api::deserialize_rfc3339"
)]
pub created_on: time::OffsetDateTime,
#[serde(
serialize_with = "crate::api::serialize_rfc3339",
deserialize_with = "crate::api::deserialize_rfc3339"
)]
pub updated_on: time::OffsetDateTime,
}
#[derive(Debug, Clone, Builder)]
#[builder(setter(strip_option))]
pub struct ListTimeEntries<'a> {
#[builder(default)]
author_id: Option<UserFilter>,
#[builder(setter(into), default)]
project_id_or_name: Option<Cow<'a, str>>,
#[builder(default)]
activity_id: Option<ActivityFilter>,
#[builder(default)]
spent_on: Option<DateFilter>,
#[builder(setter(into), default)]
comments: Option<StringFieldFilter>,
#[builder(default)]
hours: Option<FloatFilter>,
#[builder(default)]
tracker_id: Option<TrackerFilter>,
#[builder(default)]
status_id: Option<IssueStatusFilter>,
#[builder(default)]
fixed_version_id: Option<VersionFilter>,
#[builder(setter(into), default)]
subject: Option<StringFieldFilter>,
#[builder(default)]
group_id: Option<MemberOfGroupFilter>,
#[builder(default)]
role_id: Option<RoleFilter>,
#[builder(default)]
project_status: Option<ProjectStatusFilter>,
#[builder(default)]
subproject_id: Option<ProjectFilter>,
#[builder(default)]
custom_field_filters: Option<Vec<CustomFieldFilter>>,
}
impl ReturnsJsonResponse for ListTimeEntries<'_> {}
impl Pageable for ListTimeEntries<'_> {
fn response_wrapper_key(&self) -> String {
"time_entries".to_string()
}
}
impl<'a> ListTimeEntries<'a> {
#[must_use]
pub fn builder() -> ListTimeEntriesBuilder<'a> {
ListTimeEntriesBuilder::default()
}
}
impl Endpoint for ListTimeEntries<'_> {
fn method(&self) -> Method {
Method::GET
}
fn endpoint(&self) -> Cow<'static, str> {
"time_entries.json".into()
}
fn parameters(&self) -> QueryParams<'_> {
let mut params = QueryParams::default();
params.push_opt("user_id", self.author_id.as_ref().map(|f| f.to_string()));
params.push_opt("project_id", self.project_id_or_name.as_ref());
params.push_opt(
"activity_id",
self.activity_id.as_ref().map(|f| f.to_string()),
);
params.push_opt("spent_on", self.spent_on.as_ref().map(|f| f.to_string()));
params.push_opt("comments", self.comments.as_ref().map(|f| f.to_string()));
params.push_opt("hours", self.hours.as_ref().map(|f| f.to_string()));
params.push_opt(
"tracker_id",
self.tracker_id.as_ref().map(|f| f.to_string()),
);
params.push_opt("status_id", self.status_id.as_ref().map(|f| f.to_string()));
params.push_opt(
"fixed_version_id",
self.fixed_version_id.as_ref().map(|f| f.to_string()),
);
params.push_opt("subject", self.subject.as_ref().map(|f| f.to_string()));
params.push_opt("group_id", self.group_id.as_ref().map(|f| f.to_string()));
params.push_opt("role_id", self.role_id.as_ref().map(|f| f.to_string()));
params.push_opt(
"project_status",
self.project_status.as_ref().map(|f| f.to_string()),
);
params.push_opt(
"subproject_id",
self.subproject_id.as_ref().map(|f| f.to_string()),
);
if let Some(custom_field_filters) = &self.custom_field_filters {
for cf_filter in custom_field_filters {
params.push(format!("cf_{}", cf_filter.id), cf_filter.value.to_string());
}
}
params
}
}
#[derive(Debug, Clone, Builder)]
#[builder(setter(strip_option))]
pub struct GetTimeEntry {
id: u64,
}
impl ReturnsJsonResponse for GetTimeEntry {}
impl NoPagination for GetTimeEntry {}
impl GetTimeEntry {
#[must_use]
pub fn builder() -> GetTimeEntryBuilder {
GetTimeEntryBuilder::default()
}
}
impl Endpoint for GetTimeEntry {
fn method(&self) -> Method {
Method::GET
}
fn endpoint(&self) -> Cow<'static, str> {
format!("time_entries/{}.json", self.id).into()
}
}
#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, Builder, Serialize)]
#[builder(setter(strip_option), build_fn(validate = "Self::validate"))]
pub struct CreateTimeEntry<'a> {
#[builder(default)]
issue_id: Option<u64>,
#[builder(default)]
project_id: Option<u64>,
#[builder(default)]
spent_on: Option<time::Date>,
hours: f64,
#[builder(default)]
activity_id: Option<u64>,
#[builder(default)]
comments: Option<Cow<'a, str>>,
#[builder(default)]
user_id: Option<u64>,
}
impl ReturnsJsonResponse for CreateTimeEntry<'_> {}
impl NoPagination for CreateTimeEntry<'_> {}
impl CreateTimeEntryBuilder<'_> {
fn validate(&self) -> Result<(), String> {
if self.issue_id.is_none() && self.project_id.is_none() {
Err("Either issue_id or project_id need to be specified".to_string())
} else {
Ok(())
}
}
}
impl<'a> CreateTimeEntry<'a> {
#[must_use]
pub fn builder() -> CreateTimeEntryBuilder<'a> {
CreateTimeEntryBuilder::default()
}
}
impl Endpoint for CreateTimeEntry<'_> {
fn method(&self) -> Method {
Method::POST
}
fn endpoint(&self) -> Cow<'static, str> {
"time_entries.json".into()
}
fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
Ok(Some((
"application/json",
serde_json::to_vec(&TimeEntryWrapper::<CreateTimeEntry> {
time_entry: (*self).to_owned(),
})?,
)))
}
}
#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, Builder, Serialize)]
#[builder(setter(strip_option))]
pub struct UpdateTimeEntry<'a> {
#[serde(skip_serializing)]
id: u64,
#[builder(default)]
issue_id: Option<u64>,
#[builder(default)]
project_id: Option<u64>,
#[builder(default)]
spent_on: Option<time::Date>,
#[builder(default)]
hours: Option<f64>,
#[builder(default)]
activity_id: Option<u64>,
#[builder(default)]
comments: Option<Cow<'a, str>>,
#[builder(default)]
user_id: Option<u64>,
}
impl<'a> UpdateTimeEntry<'a> {
#[must_use]
pub fn builder() -> UpdateTimeEntryBuilder<'a> {
UpdateTimeEntryBuilder::default()
}
}
impl Endpoint for UpdateTimeEntry<'_> {
fn method(&self) -> Method {
Method::PUT
}
fn endpoint(&self) -> Cow<'static, str> {
format!("time_entries/{}.json", self.id).into()
}
fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
Ok(Some((
"application/json",
serde_json::to_vec(&TimeEntryWrapper::<UpdateTimeEntry> {
time_entry: (*self).to_owned(),
})?,
)))
}
}
#[derive(Debug, Clone, Builder)]
#[builder(setter(strip_option))]
pub struct DeleteTimeEntry {
id: u64,
}
impl DeleteTimeEntry {
#[must_use]
pub fn builder() -> DeleteTimeEntryBuilder {
DeleteTimeEntryBuilder::default()
}
}
impl Endpoint for DeleteTimeEntry {
fn method(&self) -> Method {
Method::DELETE
}
fn endpoint(&self) -> Cow<'static, str> {
format!("time_entries/{}.json", &self.id).into()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
pub struct TimeEntriesWrapper<T> {
pub time_entries: Vec<T>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
pub struct TimeEntryWrapper<T> {
pub time_entry: T,
}
#[cfg(test)]
mod test {
use crate::api::ResponsePage;
use super::*;
use pretty_assertions::assert_eq;
use std::error::Error;
use tokio::sync::RwLock;
use tracing_test::traced_test;
static TIME_ENTRY_LOCK: RwLock<()> = RwLock::const_new(());
#[traced_test]
#[test]
fn test_list_time_entries_first_page() -> Result<(), Box<dyn Error>> {
let _r_time_entries = TIME_ENTRY_LOCK.blocking_read();
dotenvy::dotenv()?;
let redmine = crate::api::Redmine::from_env(
reqwest::blocking::Client::builder()
.tls_backend_rustls()
.build()?,
)?;
let endpoint = ListTimeEntries::builder().build()?;
redmine.json_response_body_page::<_, TimeEntry>(&endpoint, 0, 25)?;
Ok(())
}
#[traced_test]
#[test]
fn test_get_time_entry() -> Result<(), Box<dyn Error>> {
let _r_time_entries = TIME_ENTRY_LOCK.blocking_read();
dotenvy::dotenv()?;
let redmine = crate::api::Redmine::from_env(
reqwest::blocking::Client::builder()
.tls_backend_rustls()
.build()?,
)?;
let endpoint = GetTimeEntry::builder().id(832).build()?;
redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&endpoint)?;
Ok(())
}
#[traced_test]
#[test]
fn test_create_time_entry() -> Result<(), Box<dyn Error>> {
let _w_time_entries = TIME_ENTRY_LOCK.blocking_write();
dotenvy::dotenv()?;
let redmine = crate::api::Redmine::from_env(
reqwest::blocking::Client::builder()
.tls_backend_rustls()
.build()?,
)?;
let create_endpoint = super::CreateTimeEntry::builder()
.issue_id(25095)
.hours(1.0)
.activity_id(8)
.build()?;
redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&create_endpoint)?;
Ok(())
}
#[traced_test]
#[test]
fn test_update_time_entry() -> Result<(), Box<dyn Error>> {
let _w_time_entries = TIME_ENTRY_LOCK.blocking_write();
dotenvy::dotenv()?;
let redmine = crate::api::Redmine::from_env(
reqwest::blocking::Client::builder()
.tls_backend_rustls()
.build()?,
)?;
let create_endpoint = super::CreateTimeEntry::builder()
.issue_id(25095)
.hours(1.0)
.activity_id(8)
.build()?;
let TimeEntryWrapper { time_entry } =
redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&create_endpoint)?;
let update_endpoint = super::UpdateTimeEntry::builder()
.id(time_entry.id)
.hours(2.0)
.build()?;
redmine.ignore_response_body::<_>(&update_endpoint)?;
Ok(())
}
#[traced_test]
#[test]
fn test_completeness_time_entry_type_first_page() -> Result<(), Box<dyn Error>> {
let _r_time_entries = TIME_ENTRY_LOCK.blocking_read();
dotenvy::dotenv()?;
let redmine = crate::api::Redmine::from_env(
reqwest::blocking::Client::builder()
.tls_backend_rustls()
.build()?,
)?;
let endpoint = ListTimeEntries::builder().build()?;
let ResponsePage {
values,
total_count: _,
offset: _,
limit: _,
} = redmine.json_response_body_page::<_, serde_json::Value>(&endpoint, 0, 100)?;
for value in values {
let o: TimeEntry = serde_json::from_value(value.clone())?;
let reserialized = serde_json::to_value(o)?;
assert_eq!(value, reserialized);
}
Ok(())
}
}