1use derive_builder::Builder;
19use reqwest::Method;
20use std::borrow::Cow;
21
22use crate::api::custom_fields::CustomFieldEssentialsWithValue;
23use crate::api::enumerations::TimeEntryActivityEssentials;
24use crate::api::issues::{
25 IssueEssentials, IssueStatusFilter, MemberOfGroupFilter, RoleFilter, UserFilter,
26};
27use crate::api::projects::{ProjectEssentials, ProjectFilter, ProjectStatusFilter};
28use crate::api::users::UserEssentials;
29use crate::api::{
30 ActivityFilter, CustomFieldFilter, DateFilter, Endpoint, FloatFilter, NoPagination, Pageable,
31 QueryParams, ReturnsJsonResponse, StringFieldFilter, TrackerFilter, VersionFilter,
32};
33use serde::Serialize;
34
35#[derive(Debug, Clone, Serialize, serde::Deserialize)]
39pub struct TimeEntry {
40 pub id: u64,
42 pub user: UserEssentials,
44 pub hours: f64,
46 pub activity: TimeEntryActivityEssentials,
48 #[serde(default)]
50 pub comments: Option<String>,
51 pub issue: Option<IssueEssentials>,
53 pub project: Option<ProjectEssentials>,
55 pub spent_on: Option<time::Date>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub custom_fields: Option<Vec<CustomFieldEssentialsWithValue>>,
60
61 #[serde(
63 serialize_with = "crate::api::serialize_rfc3339",
64 deserialize_with = "crate::api::deserialize_rfc3339"
65 )]
66 pub created_on: time::OffsetDateTime,
67 #[serde(
69 serialize_with = "crate::api::serialize_rfc3339",
70 deserialize_with = "crate::api::deserialize_rfc3339"
71 )]
72 pub updated_on: time::OffsetDateTime,
73}
74
75#[derive(Debug, Clone, Builder)]
77#[builder(setter(strip_option))]
78pub struct ListTimeEntries<'a> {
79 #[builder(default)]
81 author_id: Option<UserFilter>,
82 #[builder(setter(into), default)]
84 project_id_or_name: Option<Cow<'a, str>>,
85 #[builder(default)]
87 activity_id: Option<ActivityFilter>,
88 #[builder(default)]
90 spent_on: Option<DateFilter>,
91 #[builder(setter(into), default)]
93 comments: Option<StringFieldFilter>,
94 #[builder(default)]
96 hours: Option<FloatFilter>,
97 #[builder(default)]
99 tracker_id: Option<TrackerFilter>,
100 #[builder(default)]
102 status_id: Option<IssueStatusFilter>,
103 #[builder(default)]
105 fixed_version_id: Option<VersionFilter>,
106 #[builder(setter(into), default)]
108 subject: Option<StringFieldFilter>,
109 #[builder(default)]
111 group_id: Option<MemberOfGroupFilter>,
112 #[builder(default)]
114 role_id: Option<RoleFilter>,
115 #[builder(default)]
117 project_status: Option<ProjectStatusFilter>,
118 #[builder(default)]
120 subproject_id: Option<ProjectFilter>,
121 #[builder(default)]
123 custom_field_filters: Option<Vec<CustomFieldFilter>>,
124}
125
126impl ReturnsJsonResponse for ListTimeEntries<'_> {}
127impl Pageable for ListTimeEntries<'_> {
128 fn response_wrapper_key(&self) -> String {
129 "time_entries".to_string()
130 }
131}
132
133impl<'a> ListTimeEntries<'a> {
134 #[must_use]
136 pub fn builder() -> ListTimeEntriesBuilder<'a> {
137 ListTimeEntriesBuilder::default()
138 }
139}
140
141impl Endpoint for ListTimeEntries<'_> {
142 fn method(&self) -> Method {
143 Method::GET
144 }
145
146 fn endpoint(&self) -> Cow<'static, str> {
147 "time_entries.json".into()
148 }
149
150 fn parameters(&self) -> QueryParams<'_> {
151 let mut params = QueryParams::default();
152 params.push_opt("user_id", self.author_id.as_ref().map(|f| f.to_string()));
153 params.push_opt("project_id", self.project_id_or_name.as_ref());
154 params.push_opt(
155 "activity_id",
156 self.activity_id.as_ref().map(|f| f.to_string()),
157 );
158 params.push_opt("spent_on", self.spent_on.as_ref().map(|f| f.to_string()));
159 params.push_opt("comments", self.comments.as_ref().map(|f| f.to_string()));
160 params.push_opt("hours", self.hours.as_ref().map(|f| f.to_string()));
161 params.push_opt(
162 "tracker_id",
163 self.tracker_id.as_ref().map(|f| f.to_string()),
164 );
165 params.push_opt("status_id", self.status_id.as_ref().map(|f| f.to_string()));
166 params.push_opt(
167 "fixed_version_id",
168 self.fixed_version_id.as_ref().map(|f| f.to_string()),
169 );
170 params.push_opt("subject", self.subject.as_ref().map(|f| f.to_string()));
171 params.push_opt("group_id", self.group_id.as_ref().map(|f| f.to_string()));
172 params.push_opt("role_id", self.role_id.as_ref().map(|f| f.to_string()));
173 params.push_opt(
174 "project_status",
175 self.project_status.as_ref().map(|f| f.to_string()),
176 );
177 params.push_opt(
178 "subproject_id",
179 self.subproject_id.as_ref().map(|f| f.to_string()),
180 );
181
182 if let Some(custom_field_filters) = &self.custom_field_filters {
183 for cf_filter in custom_field_filters {
184 params.push(format!("cf_{}", cf_filter.id), cf_filter.value.to_string());
185 }
186 }
187 params
188 }
189}
190
191#[derive(Debug, Clone, Builder)]
193#[builder(setter(strip_option))]
194pub struct GetTimeEntry {
195 id: u64,
197}
198
199impl ReturnsJsonResponse for GetTimeEntry {}
200impl NoPagination for GetTimeEntry {}
201
202impl GetTimeEntry {
203 #[must_use]
205 pub fn builder() -> GetTimeEntryBuilder {
206 GetTimeEntryBuilder::default()
207 }
208}
209
210impl Endpoint for GetTimeEntry {
211 fn method(&self) -> Method {
212 Method::GET
213 }
214
215 fn endpoint(&self) -> Cow<'static, str> {
216 format!("time_entries/{}.json", self.id).into()
217 }
218}
219
220#[serde_with::skip_serializing_none]
222#[derive(Debug, Clone, Builder, Serialize)]
223#[builder(setter(strip_option), build_fn(validate = "Self::validate"))]
224pub struct CreateTimeEntry<'a> {
225 #[builder(default)]
227 issue_id: Option<u64>,
228 #[builder(default)]
230 project_id: Option<u64>,
231 #[builder(default)]
233 spent_on: Option<time::Date>,
234 hours: f64,
236 #[builder(default)]
238 activity_id: Option<u64>,
239 #[builder(default)]
241 comments: Option<Cow<'a, str>>,
242 #[builder(default)]
244 user_id: Option<u64>,
245}
246
247impl ReturnsJsonResponse for CreateTimeEntry<'_> {}
248impl NoPagination for CreateTimeEntry<'_> {}
249
250impl CreateTimeEntryBuilder<'_> {
251 fn validate(&self) -> Result<(), String> {
253 if self.issue_id.is_none() && self.project_id.is_none() {
254 Err("Either issue_id or project_id need to be specified".to_string())
255 } else {
256 Ok(())
257 }
258 }
259}
260
261impl<'a> CreateTimeEntry<'a> {
262 #[must_use]
264 pub fn builder() -> CreateTimeEntryBuilder<'a> {
265 CreateTimeEntryBuilder::default()
266 }
267}
268
269impl Endpoint for CreateTimeEntry<'_> {
270 fn method(&self) -> Method {
271 Method::POST
272 }
273
274 fn endpoint(&self) -> Cow<'static, str> {
275 "time_entries.json".into()
276 }
277
278 fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
279 Ok(Some((
280 "application/json",
281 serde_json::to_vec(&TimeEntryWrapper::<CreateTimeEntry> {
282 time_entry: (*self).to_owned(),
283 })?,
284 )))
285 }
286}
287
288#[serde_with::skip_serializing_none]
290#[derive(Debug, Clone, Builder, Serialize)]
291#[builder(setter(strip_option))]
292pub struct UpdateTimeEntry<'a> {
293 #[serde(skip_serializing)]
295 id: u64,
296 #[builder(default)]
298 issue_id: Option<u64>,
299 #[builder(default)]
301 project_id: Option<u64>,
302 #[builder(default)]
304 spent_on: Option<time::Date>,
305 #[builder(default)]
307 hours: Option<f64>,
308 #[builder(default)]
310 activity_id: Option<u64>,
311 #[builder(default)]
313 comments: Option<Cow<'a, str>>,
314 #[builder(default)]
316 user_id: Option<u64>,
317}
318
319impl<'a> UpdateTimeEntry<'a> {
320 #[must_use]
322 pub fn builder() -> UpdateTimeEntryBuilder<'a> {
323 UpdateTimeEntryBuilder::default()
324 }
325}
326
327impl Endpoint for UpdateTimeEntry<'_> {
328 fn method(&self) -> Method {
329 Method::PUT
330 }
331
332 fn endpoint(&self) -> Cow<'static, str> {
333 format!("time_entries/{}.json", self.id).into()
334 }
335
336 fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
337 Ok(Some((
338 "application/json",
339 serde_json::to_vec(&TimeEntryWrapper::<UpdateTimeEntry> {
340 time_entry: (*self).to_owned(),
341 })?,
342 )))
343 }
344}
345
346#[derive(Debug, Clone, Builder)]
348#[builder(setter(strip_option))]
349pub struct DeleteTimeEntry {
350 id: u64,
352}
353
354impl DeleteTimeEntry {
355 #[must_use]
357 pub fn builder() -> DeleteTimeEntryBuilder {
358 DeleteTimeEntryBuilder::default()
359 }
360}
361
362impl Endpoint for DeleteTimeEntry {
363 fn method(&self) -> Method {
364 Method::DELETE
365 }
366
367 fn endpoint(&self) -> Cow<'static, str> {
368 format!("time_entries/{}.json", &self.id).into()
369 }
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
374pub struct TimeEntriesWrapper<T> {
375 pub time_entries: Vec<T>,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
382pub struct TimeEntryWrapper<T> {
383 pub time_entry: T,
385}
386
387#[cfg(test)]
388mod test {
389 use crate::api::ResponsePage;
390
391 use super::*;
392 use pretty_assertions::assert_eq;
393 use std::error::Error;
394 use tokio::sync::RwLock;
395 use tracing_test::traced_test;
396
397 static TIME_ENTRY_LOCK: RwLock<()> = RwLock::const_new(());
400
401 #[traced_test]
402 #[test]
403 fn test_list_time_entries_first_page() -> Result<(), Box<dyn Error>> {
404 let _r_time_entries = TIME_ENTRY_LOCK.blocking_read();
405 dotenvy::dotenv()?;
406 let redmine = crate::api::Redmine::from_env(
407 reqwest::blocking::Client::builder()
408 .use_rustls_tls()
409 .build()?,
410 )?;
411 let endpoint = ListTimeEntries::builder().build()?;
412 redmine.json_response_body_page::<_, TimeEntry>(&endpoint, 0, 25)?;
413 Ok(())
414 }
415
416 #[traced_test]
430 #[test]
431 fn test_get_time_entry() -> Result<(), Box<dyn Error>> {
432 let _r_time_entries = TIME_ENTRY_LOCK.blocking_read();
433 dotenvy::dotenv()?;
434 let redmine = crate::api::Redmine::from_env(
435 reqwest::blocking::Client::builder()
436 .use_rustls_tls()
437 .build()?,
438 )?;
439 let endpoint = GetTimeEntry::builder().id(832).build()?;
440 redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&endpoint)?;
441 Ok(())
442 }
443
444 #[traced_test]
445 #[test]
446 fn test_create_time_entry() -> Result<(), Box<dyn Error>> {
447 let _w_time_entries = TIME_ENTRY_LOCK.blocking_write();
448 dotenvy::dotenv()?;
449 let redmine = crate::api::Redmine::from_env(
450 reqwest::blocking::Client::builder()
451 .use_rustls_tls()
452 .build()?,
453 )?;
454 let create_endpoint = super::CreateTimeEntry::builder()
455 .issue_id(25095)
456 .hours(1.0)
457 .activity_id(8)
458 .build()?;
459 redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&create_endpoint)?;
460 Ok(())
461 }
462
463 #[traced_test]
464 #[test]
465 fn test_update_time_entry() -> Result<(), Box<dyn Error>> {
466 let _w_time_entries = TIME_ENTRY_LOCK.blocking_write();
467 dotenvy::dotenv()?;
468 let redmine = crate::api::Redmine::from_env(
469 reqwest::blocking::Client::builder()
470 .use_rustls_tls()
471 .build()?,
472 )?;
473 let create_endpoint = super::CreateTimeEntry::builder()
474 .issue_id(25095)
475 .hours(1.0)
476 .activity_id(8)
477 .build()?;
478 let TimeEntryWrapper { time_entry } =
479 redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&create_endpoint)?;
480 let update_endpoint = super::UpdateTimeEntry::builder()
481 .id(time_entry.id)
482 .hours(2.0)
483 .build()?;
484 redmine.ignore_response_body::<_>(&update_endpoint)?;
485 Ok(())
486 }
487
488 #[traced_test]
493 #[test]
494 fn test_completeness_time_entry_type_first_page() -> Result<(), Box<dyn Error>> {
495 let _r_time_entries = TIME_ENTRY_LOCK.blocking_read();
496 dotenvy::dotenv()?;
497 let redmine = crate::api::Redmine::from_env(
498 reqwest::blocking::Client::builder()
499 .use_rustls_tls()
500 .build()?,
501 )?;
502 let endpoint = ListTimeEntries::builder().build()?;
503 let ResponsePage {
504 values,
505 total_count: _,
506 offset: _,
507 limit: _,
508 } = redmine.json_response_body_page::<_, serde_json::Value>(&endpoint, 0, 100)?;
509 for value in values {
510 let o: TimeEntry = serde_json::from_value(value.clone())?;
511 let reserialized = serde_json::to_value(o)?;
512 assert_eq!(value, reserialized);
513 }
514 Ok(())
515 }
516}