redmine_api/api/
time_entries.rs

1//! Time Entries Rest API Endpoint definitions
2//!
3//! [Redmine Documentation](https://www.redmine.org/projects/redmine/wiki/Rest_TimeEntries)
4//!
5//! - [x] all time entries endpoint
6//!   - [x] user_id filter
7//!   - [x] project_id filter
8//!   - [x] issue_id filter
9//!   - [x] activity_id filter
10//!   - [x] spent_on filter (date)
11//!   - [x] from filter
12//!   - [x] to filter
13//! - [x] specific time entry endpoint
14//! - [x] create time entry endpoint
15//! - [x] update time entry endpoint
16//! - [x] delete time entry endpoint
17
18use 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::IssueEssentials;
25use crate::api::projects::ProjectEssentials;
26use crate::api::users::UserEssentials;
27use crate::api::{Endpoint, Pageable, QueryParams, ReturnsJsonResponse};
28use serde::Serialize;
29
30/// a type for time entries to use as an API return type
31///
32/// alternatively you can use your own type limited to the fields you need
33#[derive(Debug, Clone, Serialize, serde::Deserialize)]
34pub struct TimeEntry {
35    /// numeric id
36    pub id: u64,
37    /// The user spending the time
38    pub user: UserEssentials,
39    /// the hours spent
40    pub hours: f64,
41    /// the activity
42    pub activity: TimeEntryActivityEssentials,
43    /// the comment
44    #[serde(default)]
45    pub comments: Option<String>,
46    /// issue the time was spent on
47    pub issue: Option<IssueEssentials>,
48    /// project
49    pub project: Option<ProjectEssentials>,
50    /// day the time was spent on
51    pub spent_on: Option<time::Date>,
52    /// custom fields with values
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub custom_fields: Option<Vec<CustomFieldEssentialsWithValue>>,
55
56    /// The time when this time entry was created
57    #[serde(
58        serialize_with = "crate::api::serialize_rfc3339",
59        deserialize_with = "crate::api::deserialize_rfc3339"
60    )]
61    pub created_on: time::OffsetDateTime,
62    /// The time when this time entry was last updated
63    #[serde(
64        serialize_with = "crate::api::serialize_rfc3339",
65        deserialize_with = "crate::api::deserialize_rfc3339"
66    )]
67    pub updated_on: time::OffsetDateTime,
68}
69
70/// The endpoint for all time entries
71#[derive(Debug, Clone, Builder)]
72#[builder(setter(strip_option))]
73pub struct ListTimeEntries<'a> {
74    /// user who spent the time
75    #[builder(default)]
76    user_id: Option<u64>,
77    /// project id or name as it appears in the URL on which the time was spent
78    #[builder(setter(into), default)]
79    project_id_or_name: Option<Cow<'a, str>>,
80    /// issue on which the time was spent
81    #[builder(default)]
82    issue_id: Option<u64>,
83    /// activity for the spent time
84    #[builder(default)]
85    activity_id: Option<u64>,
86    /// day the time was spent on
87    #[builder(default)]
88    spent_on: Option<time::Date>,
89    /// from day filter for spent on
90    #[builder(default)]
91    from: Option<time::Date>,
92    /// to day filter for spent on
93    #[builder(default)]
94    to: Option<time::Date>,
95}
96
97impl ReturnsJsonResponse for ListTimeEntries<'_> {}
98impl Pageable for ListTimeEntries<'_> {
99    fn response_wrapper_key(&self) -> String {
100        "time_entries".to_string()
101    }
102}
103
104impl<'a> ListTimeEntries<'a> {
105    /// Create a builder for the endpoint.
106    #[must_use]
107    pub fn builder() -> ListTimeEntriesBuilder<'a> {
108        ListTimeEntriesBuilder::default()
109    }
110}
111
112impl Endpoint for ListTimeEntries<'_> {
113    fn method(&self) -> Method {
114        Method::GET
115    }
116
117    fn endpoint(&self) -> Cow<'static, str> {
118        "time_entries.json".into()
119    }
120
121    fn parameters(&self) -> QueryParams {
122        let mut params = QueryParams::default();
123        params.push_opt("user_id", self.user_id);
124        params.push_opt("project_id", self.project_id_or_name.as_ref());
125        params.push_opt("issue_id", self.issue_id);
126        params.push_opt("activity_id", self.activity_id);
127        params.push_opt("spent_on", self.spent_on);
128        params.push_opt("from", self.from);
129        params.push_opt("to", self.to);
130        params
131    }
132}
133
134/// The endpoint for a specific time entry
135#[derive(Debug, Clone, Builder)]
136#[builder(setter(strip_option))]
137pub struct GetTimeEntry {
138    /// the id of the time entry to retrieve
139    id: u64,
140}
141
142impl ReturnsJsonResponse for GetTimeEntry {}
143
144impl GetTimeEntry {
145    /// Create a builder for the endpoint.
146    #[must_use]
147    pub fn builder() -> GetTimeEntryBuilder {
148        GetTimeEntryBuilder::default()
149    }
150}
151
152impl Endpoint for GetTimeEntry {
153    fn method(&self) -> Method {
154        Method::GET
155    }
156
157    fn endpoint(&self) -> Cow<'static, str> {
158        format!("time_entries/{}.json", self.id).into()
159    }
160}
161
162/// The endpoint to create a Redmine time entry
163#[serde_with::skip_serializing_none]
164#[derive(Debug, Clone, Builder, Serialize)]
165#[builder(setter(strip_option), build_fn(validate = "Self::validate"))]
166pub struct CreateTimeEntry<'a> {
167    /// Issue id is required if project_id is not specified
168    #[builder(default)]
169    issue_id: Option<u64>,
170    /// Project id is required if issue_id is not specified
171    #[builder(default)]
172    project_id: Option<u64>,
173    /// The date the time was spent, default is today
174    #[builder(default)]
175    spent_on: Option<time::Date>,
176    /// the hours spent
177    hours: f64,
178    /// This is required unless there is a default activity defined in Redmine
179    #[builder(default)]
180    activity_id: Option<u64>,
181    /// Short description for the entry (255 characters max)
182    #[builder(default)]
183    comments: Option<Cow<'a, str>>,
184    /// User Id is only required when posting time on behalf of another user, defaults to current user
185    #[builder(default)]
186    user_id: Option<u64>,
187}
188
189impl ReturnsJsonResponse for CreateTimeEntry<'_> {}
190
191impl CreateTimeEntryBuilder<'_> {
192    /// ensures that either issue_id or project_id is non-None when [Self::build()] is called
193    fn validate(&self) -> Result<(), String> {
194        if self.issue_id.is_none() && self.project_id.is_none() {
195            Err("Either issue_id or project_id need to be specified".to_string())
196        } else {
197            Ok(())
198        }
199    }
200}
201
202impl<'a> CreateTimeEntry<'a> {
203    /// Create a builder for the endpoint.
204    #[must_use]
205    pub fn builder() -> CreateTimeEntryBuilder<'a> {
206        CreateTimeEntryBuilder::default()
207    }
208}
209
210impl Endpoint for CreateTimeEntry<'_> {
211    fn method(&self) -> Method {
212        Method::POST
213    }
214
215    fn endpoint(&self) -> Cow<'static, str> {
216        "time_entries.json".into()
217    }
218
219    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
220        Ok(Some((
221            "application/json",
222            serde_json::to_vec(&TimeEntryWrapper::<CreateTimeEntry> {
223                time_entry: (*self).to_owned(),
224            })?,
225        )))
226    }
227}
228
229/// The endpoint to update an existing Redmine time entry
230#[serde_with::skip_serializing_none]
231#[derive(Debug, Clone, Builder, Serialize)]
232#[builder(setter(strip_option))]
233pub struct UpdateTimeEntry<'a> {
234    /// the id of the time entry to update
235    #[serde(skip_serializing)]
236    id: u64,
237    /// Issue id is required if project_id is not specified
238    #[builder(default)]
239    issue_id: Option<u64>,
240    /// Project id is required if issue_id is not specified
241    #[builder(default)]
242    project_id: Option<u64>,
243    /// The date the time was spent, default is today
244    #[builder(default)]
245    spent_on: Option<time::Date>,
246    /// the hours spent
247    #[builder(default)]
248    hours: Option<f64>,
249    /// This is required unless there is a default activity defined in Redmine
250    #[builder(default)]
251    activity_id: Option<u64>,
252    /// Short description for the entry (255 characters max)
253    #[builder(default)]
254    comments: Option<Cow<'a, str>>,
255    /// User Id is only required when posting time on behalf of another user, defaults to current user
256    #[builder(default)]
257    user_id: Option<u64>,
258}
259
260impl<'a> UpdateTimeEntry<'a> {
261    /// Create a builder for the endpoint.
262    #[must_use]
263    pub fn builder() -> UpdateTimeEntryBuilder<'a> {
264        UpdateTimeEntryBuilder::default()
265    }
266}
267
268impl Endpoint for UpdateTimeEntry<'_> {
269    fn method(&self) -> Method {
270        Method::PUT
271    }
272
273    fn endpoint(&self) -> Cow<'static, str> {
274        format!("time_entries/{}.json", self.id).into()
275    }
276
277    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
278        Ok(Some((
279            "application/json",
280            serde_json::to_vec(&TimeEntryWrapper::<UpdateTimeEntry> {
281                time_entry: (*self).to_owned(),
282            })?,
283        )))
284    }
285}
286
287/// The endpoint to delete a Redmine time entry
288#[derive(Debug, Clone, Builder)]
289#[builder(setter(strip_option))]
290pub struct DeleteTimeEntry {
291    /// the id of the time entry to delete
292    id: u64,
293}
294
295impl DeleteTimeEntry {
296    /// Create a builder for the endpoint.
297    #[must_use]
298    pub fn builder() -> DeleteTimeEntryBuilder {
299        DeleteTimeEntryBuilder::default()
300    }
301}
302
303impl Endpoint for DeleteTimeEntry {
304    fn method(&self) -> Method {
305        Method::DELETE
306    }
307
308    fn endpoint(&self) -> Cow<'static, str> {
309        format!("time_entries/{}.json", &self.id).into()
310    }
311}
312
313/// helper struct for outer layers with a time_entries field holding the inner data
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
315pub struct TimeEntriesWrapper<T> {
316    /// to parse JSON with time_entries key
317    pub time_entries: Vec<T>,
318}
319
320/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
321/// helper struct for outer layers with a time_entry field holding the inner data
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
323pub struct TimeEntryWrapper<T> {
324    /// to parse JSON with time_entry key
325    pub time_entry: T,
326}
327
328#[cfg(test)]
329mod test {
330    use super::*;
331    use pretty_assertions::assert_eq;
332    use std::error::Error;
333    use tokio::sync::RwLock;
334    use tracing_test::traced_test;
335
336    /// needed so we do not get 404s when listing while
337    /// creating/deleting or creating/updating/deleting
338    static TIME_ENTRY_LOCK: RwLock<()> = RwLock::const_new(());
339
340    #[traced_test]
341    #[test]
342    fn test_list_time_entries_no_pagination() -> Result<(), Box<dyn Error>> {
343        let _r_time_entries = TIME_ENTRY_LOCK.read();
344        dotenvy::dotenv()?;
345        let redmine = crate::api::Redmine::from_env()?;
346        let endpoint = ListTimeEntries::builder().build()?;
347        redmine.json_response_body::<_, TimeEntriesWrapper<TimeEntry>>(&endpoint)?;
348        Ok(())
349    }
350
351    #[traced_test]
352    #[test]
353    fn test_list_time_entries_first_page() -> Result<(), Box<dyn Error>> {
354        let _r_time_entries = TIME_ENTRY_LOCK.read();
355        dotenvy::dotenv()?;
356        let redmine = crate::api::Redmine::from_env()?;
357        let endpoint = ListTimeEntries::builder().build()?;
358        redmine.json_response_body_page::<_, TimeEntry>(&endpoint, 0, 25)?;
359        Ok(())
360    }
361
362    /// this takes a long time and is not very useful given the relative uniformity of time entries
363    // #[traced_test]
364    // #[test]
365    // #[ignore]
366    // fn test_list_time_entries_all_pages() -> Result<(), Box<dyn Error>> {
367    //     let _r_time_entries = TIME_ENTRY_LOCK.read();
368    //     dotenvy::dotenv()?;
369    //     let redmine = crate::api::Redmine::from_env()?;
370    //     let endpoint = ListTimeEntries::builder().build()?;
371    //     redmine.json_response_body_all_pages::<_, TimeEntry>(&endpoint)?;
372    //     Ok(())
373    // }
374
375    #[traced_test]
376    #[test]
377    fn test_get_time_entry() -> Result<(), Box<dyn Error>> {
378        let _r_time_entries = TIME_ENTRY_LOCK.read();
379        dotenvy::dotenv()?;
380        let redmine = crate::api::Redmine::from_env()?;
381        let endpoint = GetTimeEntry::builder().id(832).build()?;
382        redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&endpoint)?;
383        Ok(())
384    }
385
386    #[traced_test]
387    #[test]
388    fn test_create_time_entry() -> Result<(), Box<dyn Error>> {
389        let _w_time_entries = TIME_ENTRY_LOCK.write();
390        dotenvy::dotenv()?;
391        let redmine = crate::api::Redmine::from_env()?;
392        let create_endpoint = super::CreateTimeEntry::builder()
393            .issue_id(25095)
394            .hours(1.0)
395            .activity_id(8)
396            .build()?;
397        redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&create_endpoint)?;
398        Ok(())
399    }
400
401    #[traced_test]
402    #[test]
403    fn test_update_time_entry() -> Result<(), Box<dyn Error>> {
404        let _w_time_entries = TIME_ENTRY_LOCK.write();
405        dotenvy::dotenv()?;
406        let redmine = crate::api::Redmine::from_env()?;
407        let create_endpoint = super::CreateTimeEntry::builder()
408            .issue_id(25095)
409            .hours(1.0)
410            .activity_id(8)
411            .build()?;
412        let TimeEntryWrapper { time_entry } =
413            redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&create_endpoint)?;
414        let update_endpoint = super::UpdateTimeEntry::builder()
415            .id(time_entry.id)
416            .hours(2.0)
417            .build()?;
418        redmine.ignore_response_body::<_>(&update_endpoint)?;
419        Ok(())
420    }
421
422    /// this tests if any of the results contain a field we are not deserializing
423    ///
424    /// this will only catch fields we missed if they are part of the response but
425    /// it is better than nothing
426    #[traced_test]
427    #[test]
428    fn test_completeness_time_entry_type() -> Result<(), Box<dyn Error>> {
429        let _r_time_entries = TIME_ENTRY_LOCK.read();
430        dotenvy::dotenv()?;
431        let redmine = crate::api::Redmine::from_env()?;
432        let endpoint = ListTimeEntries::builder().build()?;
433        let TimeEntriesWrapper {
434            time_entries: values,
435        } = redmine.json_response_body::<_, TimeEntriesWrapper<serde_json::Value>>(&endpoint)?;
436        for value in values {
437            let o: TimeEntry = serde_json::from_value(value.clone())?;
438            let reserialized = serde_json::to_value(o)?;
439            assert_eq!(value, reserialized);
440        }
441        Ok(())
442    }
443}