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, NoPagination, 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 {}
143impl NoPagination for GetTimeEntry {}
144
145impl GetTimeEntry {
146    /// Create a builder for the endpoint.
147    #[must_use]
148    pub fn builder() -> GetTimeEntryBuilder {
149        GetTimeEntryBuilder::default()
150    }
151}
152
153impl Endpoint for GetTimeEntry {
154    fn method(&self) -> Method {
155        Method::GET
156    }
157
158    fn endpoint(&self) -> Cow<'static, str> {
159        format!("time_entries/{}.json", self.id).into()
160    }
161}
162
163/// The endpoint to create a Redmine time entry
164#[serde_with::skip_serializing_none]
165#[derive(Debug, Clone, Builder, Serialize)]
166#[builder(setter(strip_option), build_fn(validate = "Self::validate"))]
167pub struct CreateTimeEntry<'a> {
168    /// Issue id is required if project_id is not specified
169    #[builder(default)]
170    issue_id: Option<u64>,
171    /// Project id is required if issue_id is not specified
172    #[builder(default)]
173    project_id: Option<u64>,
174    /// The date the time was spent, default is today
175    #[builder(default)]
176    spent_on: Option<time::Date>,
177    /// the hours spent
178    hours: f64,
179    /// This is required unless there is a default activity defined in Redmine
180    #[builder(default)]
181    activity_id: Option<u64>,
182    /// Short description for the entry (255 characters max)
183    #[builder(default)]
184    comments: Option<Cow<'a, str>>,
185    /// User Id is only required when posting time on behalf of another user, defaults to current user
186    #[builder(default)]
187    user_id: Option<u64>,
188}
189
190impl ReturnsJsonResponse for CreateTimeEntry<'_> {}
191impl NoPagination for CreateTimeEntry<'_> {}
192
193impl CreateTimeEntryBuilder<'_> {
194    /// ensures that either issue_id or project_id is non-None when [Self::build()] is called
195    fn validate(&self) -> Result<(), String> {
196        if self.issue_id.is_none() && self.project_id.is_none() {
197            Err("Either issue_id or project_id need to be specified".to_string())
198        } else {
199            Ok(())
200        }
201    }
202}
203
204impl<'a> CreateTimeEntry<'a> {
205    /// Create a builder for the endpoint.
206    #[must_use]
207    pub fn builder() -> CreateTimeEntryBuilder<'a> {
208        CreateTimeEntryBuilder::default()
209    }
210}
211
212impl Endpoint for CreateTimeEntry<'_> {
213    fn method(&self) -> Method {
214        Method::POST
215    }
216
217    fn endpoint(&self) -> Cow<'static, str> {
218        "time_entries.json".into()
219    }
220
221    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
222        Ok(Some((
223            "application/json",
224            serde_json::to_vec(&TimeEntryWrapper::<CreateTimeEntry> {
225                time_entry: (*self).to_owned(),
226            })?,
227        )))
228    }
229}
230
231/// The endpoint to update an existing Redmine time entry
232#[serde_with::skip_serializing_none]
233#[derive(Debug, Clone, Builder, Serialize)]
234#[builder(setter(strip_option))]
235pub struct UpdateTimeEntry<'a> {
236    /// the id of the time entry to update
237    #[serde(skip_serializing)]
238    id: u64,
239    /// Issue id is required if project_id is not specified
240    #[builder(default)]
241    issue_id: Option<u64>,
242    /// Project id is required if issue_id is not specified
243    #[builder(default)]
244    project_id: Option<u64>,
245    /// The date the time was spent, default is today
246    #[builder(default)]
247    spent_on: Option<time::Date>,
248    /// the hours spent
249    #[builder(default)]
250    hours: Option<f64>,
251    /// This is required unless there is a default activity defined in Redmine
252    #[builder(default)]
253    activity_id: Option<u64>,
254    /// Short description for the entry (255 characters max)
255    #[builder(default)]
256    comments: Option<Cow<'a, str>>,
257    /// User Id is only required when posting time on behalf of another user, defaults to current user
258    #[builder(default)]
259    user_id: Option<u64>,
260}
261
262impl<'a> UpdateTimeEntry<'a> {
263    /// Create a builder for the endpoint.
264    #[must_use]
265    pub fn builder() -> UpdateTimeEntryBuilder<'a> {
266        UpdateTimeEntryBuilder::default()
267    }
268}
269
270impl Endpoint for UpdateTimeEntry<'_> {
271    fn method(&self) -> Method {
272        Method::PUT
273    }
274
275    fn endpoint(&self) -> Cow<'static, str> {
276        format!("time_entries/{}.json", self.id).into()
277    }
278
279    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
280        Ok(Some((
281            "application/json",
282            serde_json::to_vec(&TimeEntryWrapper::<UpdateTimeEntry> {
283                time_entry: (*self).to_owned(),
284            })?,
285        )))
286    }
287}
288
289/// The endpoint to delete a Redmine time entry
290#[derive(Debug, Clone, Builder)]
291#[builder(setter(strip_option))]
292pub struct DeleteTimeEntry {
293    /// the id of the time entry to delete
294    id: u64,
295}
296
297impl DeleteTimeEntry {
298    /// Create a builder for the endpoint.
299    #[must_use]
300    pub fn builder() -> DeleteTimeEntryBuilder {
301        DeleteTimeEntryBuilder::default()
302    }
303}
304
305impl Endpoint for DeleteTimeEntry {
306    fn method(&self) -> Method {
307        Method::DELETE
308    }
309
310    fn endpoint(&self) -> Cow<'static, str> {
311        format!("time_entries/{}.json", &self.id).into()
312    }
313}
314
315/// helper struct for outer layers with a time_entries field holding the inner data
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
317pub struct TimeEntriesWrapper<T> {
318    /// to parse JSON with time_entries key
319    pub time_entries: Vec<T>,
320}
321
322/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
323/// helper struct for outer layers with a time_entry field holding the inner data
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
325pub struct TimeEntryWrapper<T> {
326    /// to parse JSON with time_entry key
327    pub time_entry: T,
328}
329
330#[cfg(test)]
331mod test {
332    use crate::api::ResponsePage;
333
334    use super::*;
335    use pretty_assertions::assert_eq;
336    use std::error::Error;
337    use tokio::sync::RwLock;
338    use tracing_test::traced_test;
339
340    /// needed so we do not get 404s when listing while
341    /// creating/deleting or creating/updating/deleting
342    static TIME_ENTRY_LOCK: RwLock<()> = RwLock::const_new(());
343
344    #[traced_test]
345    #[test]
346    fn test_list_time_entries_first_page() -> Result<(), Box<dyn Error>> {
347        let _r_time_entries = TIME_ENTRY_LOCK.read();
348        dotenvy::dotenv()?;
349        let redmine = crate::api::Redmine::from_env()?;
350        let endpoint = ListTimeEntries::builder().build()?;
351        redmine.json_response_body_page::<_, TimeEntry>(&endpoint, 0, 25)?;
352        Ok(())
353    }
354
355    /// this takes a long time and is not very useful given the relative uniformity of time entries
356    // #[traced_test]
357    // #[test]
358    // #[ignore]
359    // fn test_list_time_entries_all_pages() -> Result<(), Box<dyn Error>> {
360    //     let _r_time_entries = TIME_ENTRY_LOCK.read();
361    //     dotenvy::dotenv()?;
362    //     let redmine = crate::api::Redmine::from_env()?;
363    //     let endpoint = ListTimeEntries::builder().build()?;
364    //     redmine.json_response_body_all_pages::<_, TimeEntry>(&endpoint)?;
365    //     Ok(())
366    // }
367
368    #[traced_test]
369    #[test]
370    fn test_get_time_entry() -> Result<(), Box<dyn Error>> {
371        let _r_time_entries = TIME_ENTRY_LOCK.read();
372        dotenvy::dotenv()?;
373        let redmine = crate::api::Redmine::from_env()?;
374        let endpoint = GetTimeEntry::builder().id(832).build()?;
375        redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&endpoint)?;
376        Ok(())
377    }
378
379    #[traced_test]
380    #[test]
381    fn test_create_time_entry() -> Result<(), Box<dyn Error>> {
382        let _w_time_entries = TIME_ENTRY_LOCK.write();
383        dotenvy::dotenv()?;
384        let redmine = crate::api::Redmine::from_env()?;
385        let create_endpoint = super::CreateTimeEntry::builder()
386            .issue_id(25095)
387            .hours(1.0)
388            .activity_id(8)
389            .build()?;
390        redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&create_endpoint)?;
391        Ok(())
392    }
393
394    #[traced_test]
395    #[test]
396    fn test_update_time_entry() -> Result<(), Box<dyn Error>> {
397        let _w_time_entries = TIME_ENTRY_LOCK.write();
398        dotenvy::dotenv()?;
399        let redmine = crate::api::Redmine::from_env()?;
400        let create_endpoint = super::CreateTimeEntry::builder()
401            .issue_id(25095)
402            .hours(1.0)
403            .activity_id(8)
404            .build()?;
405        let TimeEntryWrapper { time_entry } =
406            redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&create_endpoint)?;
407        let update_endpoint = super::UpdateTimeEntry::builder()
408            .id(time_entry.id)
409            .hours(2.0)
410            .build()?;
411        redmine.ignore_response_body::<_>(&update_endpoint)?;
412        Ok(())
413    }
414
415    /// this tests if any of the results contain a field we are not deserializing
416    ///
417    /// this will only catch fields we missed if they are part of the response but
418    /// it is better than nothing
419    #[traced_test]
420    #[test]
421    fn test_completeness_time_entry_type_first_page() -> Result<(), Box<dyn Error>> {
422        let _r_time_entries = TIME_ENTRY_LOCK.read();
423        dotenvy::dotenv()?;
424        let redmine = crate::api::Redmine::from_env()?;
425        let endpoint = ListTimeEntries::builder().build()?;
426        let ResponsePage {
427            values,
428            total_count: _,
429            offset: _,
430            limit: _,
431        } = redmine.json_response_body_page::<_, serde_json::Value>(&endpoint, 0, 100)?;
432        for value in values {
433            let o: TimeEntry = serde_json::from_value(value.clone())?;
434            let reserialized = serde_json::to_value(o)?;
435            assert_eq!(value, reserialized);
436        }
437        Ok(())
438    }
439}