Squashed commit of the following:

commit 3fd1474140
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Dec 8 14:01:28 2021 -0400

    Adding license and adjusting readme

commit 638659a467
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Dec 8 13:55:40 2021 -0400

    Adding support for generating date ranges on date ranges
This commit is contained in:
Ian Roddis
2021-12-08 14:04:28 -04:00
parent fde7a01e99
commit 6166c6d664
5 changed files with 170 additions and 275 deletions

View File

@@ -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

21
LICENSE.txt Normal file
View File

@@ -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.

165
README.md
View File

@@ -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
```

View File

@@ -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<NaiveDate>,
#[serde(default)]
valid_until: Option<NaiveDate>,
#[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<NaiveDate>,
#[serde(default)]
valid_until: Option<NaiveDate>,
#[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<NaiveDate>, 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<Weekday>,
#[serde(default)]
pub public: bool,
#[serde(default)]
pub exclude: Vec<DateSpec>,
#[serde(default)]
pub inherits: Vec<String>,
}
impl Calendar {
fn adjust_workdays(&self, workdays: &Vec<(NaiveDate, AdjustmentPolicy)>) -> HashSet<NaiveDate> {
fn adjust_special_offdays(
&self,
offdays: &Vec<(Vec<NaiveDate>, AdjustmentPolicy)>,
) -> HashSet<NaiveDate> {
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<NaiveDate>,
offdays: &HashSet<NaiveDate>,
) -> Option<NaiveDate> {
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<NaiveDate> {
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<NaiveDate> {
let offdays: Vec<(Vec<NaiveDate>, 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<NaiveDate> {
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",

View File

@@ -1,13 +1,5 @@
use chrono::naive::NaiveDate;
/*
pub type DateRange = Range<NaiveDate>;
fn iter_dates(range: DateRange) -> impl Iterator<Item = NaiveDate> {
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,