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::{
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/// a type for time entries to use as an API return type
36///
37/// alternatively you can use your own type limited to the fields you need
38#[derive(Debug, Clone, Serialize, serde::Deserialize)]
39pub struct TimeEntry {
40    /// numeric id
41    pub id: u64,
42    /// The user spending the time
43    pub user: UserEssentials,
44    /// the hours spent
45    pub hours: f64,
46    /// the activity
47    pub activity: TimeEntryActivityEssentials,
48    /// the comment
49    #[serde(default)]
50    pub comments: Option<String>,
51    /// issue the time was spent on
52    pub issue: Option<IssueEssentials>,
53    /// project
54    pub project: Option<ProjectEssentials>,
55    /// day the time was spent on
56    pub spent_on: Option<time::Date>,
57    /// custom fields with values
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub custom_fields: Option<Vec<CustomFieldEssentialsWithValue>>,
60
61    /// The time when this time entry was created
62    #[serde(
63        serialize_with = "crate::api::serialize_rfc3339",
64        deserialize_with = "crate::api::deserialize_rfc3339"
65    )]
66    pub created_on: time::OffsetDateTime,
67    /// The time when this time entry was last updated
68    #[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/// The endpoint for all time entries
76#[derive(Debug, Clone, Builder)]
77#[builder(setter(strip_option))]
78pub struct ListTimeEntries<'a> {
79    /// user who spent the time
80    #[builder(default)]
81    author_id: Option<UserFilter>,
82    /// project id or name as it appears in the URL on which the time was spent
83    #[builder(setter(into), default)]
84    project_id_or_name: Option<Cow<'a, str>>,
85    /// Filter by time entry activity.
86    #[builder(default)]
87    activity_id: Option<ActivityFilter>,
88    /// day the time was spent on
89    #[builder(default)]
90    spent_on: Option<DateFilter>,
91    /// comments text search
92    #[builder(setter(into), default)]
93    comments: Option<StringFieldFilter>,
94    /// hours spent
95    #[builder(default)]
96    hours: Option<FloatFilter>,
97    /// Filter by issue tracker.
98    #[builder(default)]
99    tracker_id: Option<TrackerFilter>,
100    /// issue status id
101    #[builder(default)]
102    status_id: Option<IssueStatusFilter>,
103    /// Filter by the ID of the target version (milestone) to which the issue is assigned.
104    #[builder(default)]
105    fixed_version_id: Option<VersionFilter>,
106    /// issue subject text search
107    #[builder(setter(into), default)]
108    subject: Option<StringFieldFilter>,
109    /// user group id
110    #[builder(default)]
111    group_id: Option<MemberOfGroupFilter>,
112    /// user role id
113    #[builder(default)]
114    role_id: Option<RoleFilter>,
115    /// Filter by project status (e.g., active, closed). Uses the ProjectStatusFilter enum.
116    #[builder(default)]
117    project_status: Option<ProjectStatusFilter>,
118    /// Filter by subproject.
119    #[builder(default)]
120    subproject_id: Option<ProjectFilter>,
121    /// custom field filters
122    #[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    /// Create a builder for the endpoint.
135    #[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/// The endpoint for a specific time entry
192#[derive(Debug, Clone, Builder)]
193#[builder(setter(strip_option))]
194pub struct GetTimeEntry {
195    /// the id of the time entry to retrieve
196    id: u64,
197}
198
199impl ReturnsJsonResponse for GetTimeEntry {}
200impl NoPagination for GetTimeEntry {}
201
202impl GetTimeEntry {
203    /// Create a builder for the endpoint.
204    #[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/// The endpoint to create a Redmine time entry
221#[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    /// Issue id is required if project_id is not specified
226    #[builder(default)]
227    issue_id: Option<u64>,
228    /// Project id is required if issue_id is not specified
229    #[builder(default)]
230    project_id: Option<u64>,
231    /// The date the time was spent, default is today
232    #[builder(default)]
233    spent_on: Option<time::Date>,
234    /// the hours spent
235    hours: f64,
236    /// This is required unless there is a default activity defined in Redmine
237    #[builder(default)]
238    activity_id: Option<u64>,
239    /// Short description for the entry (255 characters max)
240    #[builder(default)]
241    comments: Option<Cow<'a, str>>,
242    /// User Id is only required when posting time on behalf of another user, defaults to current user
243    #[builder(default)]
244    user_id: Option<u64>,
245}
246
247impl ReturnsJsonResponse for CreateTimeEntry<'_> {}
248impl NoPagination for CreateTimeEntry<'_> {}
249
250impl CreateTimeEntryBuilder<'_> {
251    /// ensures that either issue_id or project_id is non-None when [Self::build()] is called
252    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    /// Create a builder for the endpoint.
263    #[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/// The endpoint to update an existing Redmine time entry
289#[serde_with::skip_serializing_none]
290#[derive(Debug, Clone, Builder, Serialize)]
291#[builder(setter(strip_option))]
292pub struct UpdateTimeEntry<'a> {
293    /// the id of the time entry to update
294    #[serde(skip_serializing)]
295    id: u64,
296    /// Issue id is required if project_id is not specified
297    #[builder(default)]
298    issue_id: Option<u64>,
299    /// Project id is required if issue_id is not specified
300    #[builder(default)]
301    project_id: Option<u64>,
302    /// The date the time was spent, default is today
303    #[builder(default)]
304    spent_on: Option<time::Date>,
305    /// the hours spent
306    #[builder(default)]
307    hours: Option<f64>,
308    /// This is required unless there is a default activity defined in Redmine
309    #[builder(default)]
310    activity_id: Option<u64>,
311    /// Short description for the entry (255 characters max)
312    #[builder(default)]
313    comments: Option<Cow<'a, str>>,
314    /// User Id is only required when posting time on behalf of another user, defaults to current user
315    #[builder(default)]
316    user_id: Option<u64>,
317}
318
319impl<'a> UpdateTimeEntry<'a> {
320    /// Create a builder for the endpoint.
321    #[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/// The endpoint to delete a Redmine time entry
347#[derive(Debug, Clone, Builder)]
348#[builder(setter(strip_option))]
349pub struct DeleteTimeEntry {
350    /// the id of the time entry to delete
351    id: u64,
352}
353
354impl DeleteTimeEntry {
355    /// Create a builder for the endpoint.
356    #[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/// helper struct for outer layers with a time_entries field holding the inner data
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
374pub struct TimeEntriesWrapper<T> {
375    /// to parse JSON with time_entries key
376    pub time_entries: Vec<T>,
377}
378
379/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
380/// helper struct for outer layers with a time_entry field holding the inner data
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
382pub struct TimeEntryWrapper<T> {
383    /// to parse JSON with time_entry key
384    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    /// needed so we do not get 404s when listing while
398    /// creating/deleting or creating/updating/deleting
399    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    /// this takes a long time and is not very useful given the relative uniformity of time entries
417    // #[traced_test]
418    // #[test]
419    // #[ignore]
420    // fn test_list_time_entries_all_pages() -> Result<(), Box<dyn Error>> {
421    //     let _r_time_entries = TIME_ENTRY_LOCK.blocking_read();
422    //     dotenvy::dotenv()?;
423    //     let redmine = crate::api::Redmine::from_env()?;
424    //     let endpoint = ListTimeEntries::builder().build()?;
425    //     redmine.json_response_body_all_pages::<_, TimeEntry>(&endpoint)?;
426    //     Ok(())
427    // }
428
429    #[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    /// this tests if any of the results contain a field we are not deserializing
489    ///
490    /// this will only catch fields we missed if they are part of the response but
491    /// it is better than nothing
492    #[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}