diff --git a/Cargo.toml b/Cargo.toml index 8871e4b..e6d4657 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "flexcal" version = "0.1.0" edition = "2021" +license-file = "LICENSE.txt" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d4f1eea --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Kinesin Data Technologies Incorporated + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 7b39e3d..a041375 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,8 @@ Introduction ============ -Easier job scheduling, supporting: - -- Schedules in a particular timezone -- Calendars (defining which days are in scope) -- Schedules (defining spans of hours in a day) -- Jobs - - Given a calendar - - And a schedule - - Define a pattern of when jobs should run - - e.g. - - minute = "0,15,30,35", hour = "123" - - minute = "0", hour = "*" - - minute = "5", frequency = "15" // Start at 5 past the hour, and run every 15 minutes from then - -A job will - -Authorization -============= - -ezsched relies on PAM authentication. By default, all objects are owned -by the user that submitted them. +A flexible calendar structure to support aribtrary and calculated +off days. Calendars ========= @@ -43,147 +24,5 @@ Calendars consist of: - Optional `description` - Optional `observed` attribute that is `Next`, `Prev`, `Closest`, `NoAdjustment` (default) that is in the day of week mask and not also a holiday -- A public flag, which if true will make the calendar publicly available - A name, which can contain letters, numbers, dashes, and underscores - They cannot contain dots / periods - -By default, calendars are referred to by their name, but are scoped by -the owning user in the format `user.calendar`. - -Users can refer to their own calendars with just the name, other users -must access unowned calendars using the fulle `user.calendar` notation. - -Endpoints ---------- - -``` -/api/v1 - /calendars - GET -- Retrieve list of calendars and descriptions - /calendar/:id - GET -- Get the current definition - POST -- Create a new calendar - PATCH -- Update an existing calendar - DELETE -- Delete the named calendar - /calendar/:id/:date - GET -- Returns true if the date is in the calendar - /calendar/:id/:from/:to - GET -- Return the list of valid dates between :from and :to, inclusive. - :from and :to are in the ISO-8601 date format (YYYY-MM-DD) -``` - -Calendar `:id`s are alphanumeric strings without spaces (e.g. `USHolidays`). - -Encoding --------- - -A JSON definition for a calendar looks as follows: - -```json -{ - "description": "Long description", - "dow_list": ["Mon","Tue","Wed","Thu","Fri", "Sat", "Sun"], - "public": true, - "exclude": [ - { - "date": "2021-01-01", - "description": "New Years Day" - }, - { - "month": "January", - "day": 17, - "observed": "closest", - "description": "Martin Luther King Day" - }, - { - "month": "January", - "dow": ["Mon"], - "offset": "-1", - "observed": "closest", - "description": "Final Monday Margarita Day" - }, - ] - ] -} -``` - -Inheritance ------------ - -Calendars can inherit from other calendars. The result will be a union -of the sets of days. For instance, a calendar of vacations could inherit -from a calendar of holidays. - -```json -{ - "description": "Personal calendar days", - "inherits": [ - "company/USHolidays", - "company/CompanyHolidays" - ], - "exclude": [ - { - "date": "2021-05-01", - "description": "My gerbil's birthday" - } - ] -} -``` - -Job -=== - -Jobs are tasks that must be run on a schedule. Schedules are defined -using a calendar, a start time, and an optional frequency. - -Jobs are identified by a name that can contain letters, numbers, dashes, -and underscores. Just like calendars, jobs can be reffered to using a -`user.job` notation. - -By default, Jobs are run as the user that submitted them. - -```json -{ - "description": "Description of job", - "calendar": "US Holidays", - "timezone": "Atlantic/Reykjavik", - "schedules": [ - { - "start": { - "minute": "5", - "hour": "*", - }, - "frequency": "15m" - } - ], - "command": { - }, - "environment": { - }, - "mailto": [ - "oncall@company.com" - ], - "public_acl": "rwx" -} -``` - -The `public_acl` is a string that defines how the non-owning users can -access the job: `r`ead it, `w`rite its defintiion, or e`x`ecute it. - -Endpoints ---------- - -``` -/api/v1 - /jobs - GET -- Retrieve list of jobs and descriptions - /job/:id - GET -- Get the current definition - POST -- Create a new job - PATCH -- Update an existing job - DELETE -- Delete the job - /job/:id/run - GET -- Force a run of the job immediately - /job/:id/history - GET -- Retrieve the run history and results -``` diff --git a/src/calendar.rs b/src/calendar.rs index 7caa49f..3e194cb 100644 --- a/src/calendar.rs +++ b/src/calendar.rs @@ -1,15 +1,12 @@ +use crate::date_range::DateRange; use chrono::naive::NaiveDate; use chrono::{Datelike, Month, Weekday}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; /* - There are a few gaping holes here, and some functional deficiencies: - - - workdays are only calculated within a year. If a workday in a prior - year is bumped to the next year, it won't be considered. - - workday impact is searched forward. If there is a mix of AdjustmentPolicies - then some weird stuff can happen (workdays occur A, B, but end up getting + - offday impact is searched forward. If there is a mix of AdjustmentPolicies + then some weird stuff can happen (offdays occur A, B, but end up getting observed B, A) */ @@ -27,6 +24,13 @@ impl Default for AdjustmentPolicy { } } +fn default_earliest_date() -> NaiveDate { + chrono::naive::MIN_DATE +} +fn default_latest_date() -> NaiveDate { + chrono::naive::MAX_DATE +} + #[derive(Clone, Serialize, Deserialize, Debug)] #[serde(tag = "type")] pub enum DateSpec { @@ -42,10 +46,10 @@ pub enum DateSpec { observed: AdjustmentPolicy, #[serde(default)] description: String, - #[serde(default)] - valid_since: Option, - #[serde(default)] - valid_until: Option, + #[serde(default = "default_earliest_date")] + valid_since: NaiveDate, + #[serde(default = "default_latest_date")] + valid_until: NaiveDate, }, NthDayOccurance { month: Month, @@ -55,20 +59,30 @@ pub enum DateSpec { observed: AdjustmentPolicy, #[serde(default)] description: String, - #[serde(default)] - valid_since: Option, - #[serde(default)] - valid_until: Option, + #[serde(default = "default_earliest_date")] + valid_since: NaiveDate, + #[serde(default = "default_latest_date")] + valid_until: NaiveDate, }, } impl DateSpec { /// Get exact date of event and its policy, if it occured in that year - fn resolve(&self, year: i32) -> Option<(NaiveDate, AdjustmentPolicy)> { + fn resolve( + &self, + start: NaiveDate, + end: NaiveDate, + ) -> Option<(Vec, AdjustmentPolicy)> { use DateSpec::*; match self { - SpecificDate { date, .. } => Some((*date, AdjustmentPolicy::NoAdjustment)), + SpecificDate { date, .. } => { + if *date < start || end < *date { + None + } else { + Some((vec![*date], AdjustmentPolicy::NoAdjustment)) + } + } DayOfMonth { month, day, @@ -77,13 +91,28 @@ impl DateSpec { observed, .. } => { - if valid_since.is_some() && valid_since.unwrap().year() > year { - None - } else if valid_until.is_some() && valid_until.unwrap().year() < year { + if *valid_since > end || *valid_until < start { None } else { - let date = NaiveDate::from_ymd(year, month.number_from_month(), *day); - Some((date, *observed)) + let s = if *valid_since < start { + start + } else { + *valid_since + } + .year(); + + let e = if *valid_until < end { + *valid_until + } else { + end + } + .year(); + + let mut result = Vec::new(); + for year in s..(e + 1) { + result.push(NaiveDate::from_ymd(year, month.number_from_month(), *day)); + } + Some((result, *observed)) } } NthDayOccurance { @@ -95,39 +124,56 @@ impl DateSpec { observed, .. } => { - if valid_since.is_some() && valid_since.unwrap().year() > year { - None - } else if valid_until.is_some() && valid_until.unwrap().year() < year { + if *valid_since > end || *valid_until < start { None } else { - let month_num = month.number_from_month(); - if *offset < 0i8 { - let mut date = if month_num == 12 { - NaiveDate::from_ymd(year, 12, 31) - } else { - NaiveDate::from_ymd(year, month_num + 1, 1).pred() - }; - while date.weekday() != *dow { - date = date.pred() - } - let mut off = offset + 1; - while off < 0 { - date = date.checked_sub_signed(chrono::Duration::days(7)).unwrap(); - off += 1; - } - Some((date, *observed)) + let s = if *valid_since < start { + start } else { - let mut date = NaiveDate::from_ymd(year, month_num, 1); - while date.weekday() != *dow { - date = date.succ() - } - let mut off = offset - 1; - while off > 0 { - date = date.checked_add_signed(chrono::Duration::days(7)).unwrap(); - off -= 1; - } - Some((date, *observed)) + *valid_since } + .year(); + + let e = if *valid_until < end { + *valid_until + } else { + end + } + .year(); + + let mut result = Vec::new(); + for year in s..(e + 1) { + let month_num = month.number_from_month(); + if *offset < 0i8 { + let mut date = if month_num == 12 { + NaiveDate::from_ymd(year, 12, 31) + } else { + NaiveDate::from_ymd(year, month_num + 1, 1).pred() + }; + while date.weekday() != *dow { + date = date.pred() + } + let mut off = offset + 1; + while off < 0 { + date = date.checked_sub_signed(chrono::Duration::days(7)).unwrap(); + off += 1; + } + result.push(date); + } else { + let mut date = NaiveDate::from_ymd(year, month_num, 1); + while date.weekday() != *dow { + date = date.succ() + } + let mut off = offset - 1; + while off > 0 { + date = date.checked_add_signed(chrono::Duration::days(7)).unwrap(); + off -= 1; + } + result.push(date); + } + } + + Some((result, *observed)) } } } @@ -146,42 +192,45 @@ pub struct Calendar { #[serde(default = "default_dow_set")] pub dow: HashSet, #[serde(default)] - pub public: bool, - #[serde(default)] pub exclude: Vec, #[serde(default)] pub inherits: Vec, } impl Calendar { - fn adjust_workdays(&self, workdays: &Vec<(NaiveDate, AdjustmentPolicy)>) -> HashSet { + fn adjust_special_offdays( + &self, + offdays: &Vec<(Vec, AdjustmentPolicy)>, + ) -> HashSet { let mut observed = HashSet::new(); - for (date, policy) in workdays.iter() { - match self.adjust_workday(*date, policy, &observed) { - Some(workday) => { - observed.insert(workday); + for (dates, policy) in offdays.iter() { + for date in dates.iter() { + match self.adjust_special_offday(*date, policy, &observed) { + Some(offday) => { + observed.insert(offday); + } + None => {} } - None => {} } } observed } - /// Adjust a date given existing workdays and adjustment policy - fn adjust_workday( + /// Adjust a date given existing offday and adjustment policy + fn adjust_special_offday( &self, date: NaiveDate, policy: &AdjustmentPolicy, - workdays: &HashSet, + offdays: &HashSet, ) -> Option { let mut actual = date.clone(); println!("Adjusting {:?}", date); let is_blocked = - |x: NaiveDate| -> bool { (!self.dow.contains(&x.weekday())) || workdays.contains(&x) }; + |x: NaiveDate| -> bool { (!self.dow.contains(&x.weekday())) || offdays.contains(&x) }; use AdjustmentPolicy::*; match policy { @@ -199,10 +248,10 @@ impl Calendar { } Closest => { let prev = self - .adjust_workday(date, &AdjustmentPolicy::Prev, workdays) + .adjust_special_offday(date, &AdjustmentPolicy::Prev, offdays) .unwrap(); let next = self - .adjust_workday(date, &AdjustmentPolicy::Next, workdays) + .adjust_special_offday(date, &AdjustmentPolicy::Next, offdays) .unwrap(); if (date - prev) < (next - date) { Some(prev) @@ -220,41 +269,35 @@ impl Calendar { } } - /// Get the set of all workdays in a given year - pub fn get_workdays(&self, date: NaiveDate) -> HashSet { - let workdays: Vec<(NaiveDate, AdjustmentPolicy)> = self + /// Get the set of all offdays in a given year + pub fn get_special_offdays(&self, start: NaiveDate, end: NaiveDate) -> HashSet { + let offdays: Vec<(Vec, AdjustmentPolicy)> = self .exclude .iter() - .map(|x| x.resolve(date.year())) + .map(|x| x.resolve(start, end)) .filter(|x| x.is_some()) .map(|x| x.unwrap()) .collect(); - self.adjust_workdays(&workdays) + self.adjust_special_offdays(&offdays) } - /// Returns true if the given date is a workday / non-business day - fn is_workday(&self, date: NaiveDate) -> bool { - self.get_workdays(date).contains(&date) + /// Returns true if the given date is a offday / non-business day + fn is_offday(&self, date: NaiveDate) -> bool { + !self.dow.contains(&date.weekday()) || self.get_special_offdays(date, date).contains(&date) } - /// Returns the set of valid calendar dates within the specified range + /// Returns the set of non-offday calendar dates within the specified range pub fn date_range(&self, from: NaiveDate, to: NaiveDate) -> Vec { - let mut result = Vec::new(); - let mut cur = from.pred(); - let year = from.year(); - let mut workdays = self.get_workdays(cur); + let offdays = self.get_special_offdays(from, to); - while cur <= to { - cur = cur.succ(); - if cur.year() != year { - workdays = self.get_workdays(cur); - } - if !self.dow.contains(&cur.weekday()) || workdays.contains(&cur) { - continue; - } - result.push(cur); - } - result + // Expand by 2 weeks on each side to allow for adjustments in + // out-of-scope periods to affect in-scope dates + let dr = DateRange::new(from - chrono::Duration::days(14), to.succ()); + + dr.into_iter() + .filter(|x| self.dow.contains(&x.weekday()) && !offdays.contains(x)) + .filter(|x| *x >= from) + .collect() } } @@ -270,35 +313,34 @@ mod tests { let cal = Calendar { description: "Test description".to_owned(), dow: HashSet::from([Mon, Tue, Wed, Thu, Fri]), - public: false, exclude: vec![ DateSpec::DayOfMonth { month: December, day: 25u32, observed: AdjustmentPolicy::Next, description: "Christmas".to_owned(), - valid_since: None, - valid_until: None, + valid_since: default_earliest_date(), + valid_until: default_latest_date(), }, DateSpec::DayOfMonth { month: December, day: 26u32, observed: AdjustmentPolicy::Next, description: "Boxing Day".to_owned(), - valid_since: None, - valid_until: None, + valid_since: default_earliest_date(), + valid_until: default_latest_date(), }, ], inherits: vec![], }; // Christmas falls on a Saturday, observed was Monday - assert!(cal.is_workday(NaiveDate::from_ymd(2021, 12, 27))); + assert!(cal.is_offday(NaiveDate::from_ymd(2021, 12, 27))); // Boxing Day falls on a Sunday, observed is a Tuesday - assert!(cal.is_workday(NaiveDate::from_ymd(2021, 12, 28))); + assert!(cal.is_offday(NaiveDate::from_ymd(2021, 12, 28))); - assert!(!cal.is_workday(NaiveDate::from_ymd(2021, 12, 24))); + assert!(!cal.is_offday(NaiveDate::from_ymd(2021, 12, 24))); } #[test] @@ -309,36 +351,37 @@ mod tests { let cal = Calendar { description: "Test description".to_owned(), dow: HashSet::from([Mon, Tue, Wed, Thu, Fri]), - public: false, exclude: vec![ DateSpec::DayOfMonth { month: December, day: 25u32, observed: AdjustmentPolicy::Next, description: "Christmas".to_owned(), - valid_since: None, - valid_until: None, + valid_since: default_earliest_date(), + valid_until: default_latest_date(), }, DateSpec::DayOfMonth { month: December, day: 26u32, observed: AdjustmentPolicy::Next, description: "Boxing Day".to_owned(), - valid_since: None, - valid_until: None, + valid_since: default_earliest_date(), + valid_until: default_latest_date(), }, DateSpec::DayOfMonth { month: January, day: 1u32, observed: AdjustmentPolicy::Next, description: "New Years Day".to_owned(), - valid_since: None, - valid_until: None, + valid_since: default_earliest_date(), + valid_until: default_latest_date(), }, ], inherits: vec![], }; + assert!(cal.is_offday(NaiveDate::from_ymd(2021, 12, 25))); + let myrange = cal.date_range( NaiveDate::from_ymd(2021, 12, 15), NaiveDate::from_ymd(2022, 01, 15), @@ -352,7 +395,6 @@ mod tests { { "description": "Long description", "dow": ["Mon","Tue","Wed","Thu","Fri", "Sat", "Sun"], - "public": true, "exclude": [ { "type": "SpecificDate", diff --git a/src/date_range.rs b/src/date_range.rs index 926315d..c2c6c20 100644 --- a/src/date_range.rs +++ b/src/date_range.rs @@ -1,13 +1,5 @@ use chrono::naive::NaiveDate; -/* -pub type DateRange = Range; -fn iter_dates(range: DateRange) -> impl Iterator { - let ndays = (range.end - range.start).num_days(); - (0..ndays).map(move |x| range.start.checked_add_signed(Duration::days(x)).unwrap()) -} -*/ - pub struct DateRange { start: NaiveDate, end: NaiveDate,