1 #!/usr/bin/env python 2 3 """ 4 Managing and presenting periods of time. 5 6 Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 from bisect import bisect_left, insort_left 23 from imiptools.dates import get_datetime, get_start_of_day, to_timezone 24 25 # Time management. 26 27 def have_conflict(freebusy, periods, get_conflicts=False): 28 29 """ 30 Return whether any period in 'freebusy' overlaps with the given 'periods', 31 returning a collection of such overlapping periods if 'get_conflicts' is 32 set to a true value. 33 """ 34 35 conflicts = [] 36 for start, end in periods: 37 overlapping = period_overlaps(freebusy, (start, end), get_conflicts) 38 if overlapping: 39 if get_conflicts: 40 conflicts += overlapping 41 else: 42 return True 43 44 if get_conflicts: 45 return conflicts 46 else: 47 return False 48 49 def insert_period(freebusy, period): 50 insort_left(freebusy, period) 51 52 def remove_period(freebusy, uid): 53 i = 0 54 while i < len(freebusy): 55 t = freebusy[i] 56 if len(t) >= 3 and t[2] == uid: 57 del freebusy[i] 58 else: 59 i += 1 60 61 def period_overlaps(freebusy, period, get_periods=False): 62 63 """ 64 Return whether any period in 'freebusy' overlaps with the given 'period', 65 returning a collection of overlapping periods if 'get_periods' is set to a 66 true value. 67 """ 68 69 dtstart, dtend = period[:2] 70 found = bisect_left(freebusy, (dtstart, dtend, None, None)) 71 72 overlapping = [] 73 74 # Find earlier overlapping periods. 75 76 i = found 77 78 while i > 0 and freebusy[i - 1][1] > dtstart: 79 if get_periods: 80 overlapping.insert(0, freebusy[i - 1]) 81 else: 82 return True 83 i -= 1 84 85 # Find later overlapping periods. 86 87 i = found 88 89 while i < len(freebusy) and (dtend is None or freebusy[i][0] < dtend): 90 if get_periods: 91 overlapping.append(freebusy[i]) 92 else: 93 return True 94 i += 1 95 96 if get_periods: 97 return overlapping 98 else: 99 return False 100 101 # Period layout. 102 103 def convert_periods(periods, tzid): 104 105 "Convert 'periods' to use datetime objects employing the given 'tzid'." 106 107 l = [] 108 109 for t in periods: 110 start, end = t[:2] 111 start = to_timezone(get_datetime(start), tzid) 112 end = to_timezone(get_datetime(end), tzid) 113 l.append((start, end) + tuple(t[2:])) 114 115 return l 116 117 def get_scale(periods): 118 119 """ 120 Return an ordered time scale from the given list 'periods', with the first 121 two elements of each tuple being start and end times. 122 123 The given 'tzid' is used to make sure that the times are defined according 124 to the chosen time zone. 125 126 The returned scale is a mapping from time to (starting, ending) tuples, 127 where starting and ending are collections of tuples from 'periods'. 128 """ 129 130 scale = {} 131 132 for t in periods: 133 start, end = t[:2] 134 135 # Add a point and this event to the starting list. 136 137 if not scale.has_key(start): 138 scale[start] = [], [] 139 scale[start][0].append(t) 140 141 # Add a point and this event to the ending list. 142 143 if not scale.has_key(end): 144 scale[end] = [], [] 145 scale[end][1].append(t) 146 147 return scale 148 149 def get_slots(scale): 150 151 """ 152 Return an ordered list of time slots from the given 'scale'. 153 154 Each slot is a tuple containing a point in time for the start of the slot, 155 together with a list of parallel event tuples, each tuple containing the 156 original details of an event. 157 """ 158 159 slots = [] 160 active = [] 161 162 points = scale.items() 163 points.sort() 164 165 for point, (starting, ending) in points: 166 167 # Discard all active events ending at or before this start time. 168 # Free up the position in the active list. 169 170 for t in ending: 171 i = active.index(t) 172 active[i] = None 173 174 # For each event starting at the current point, fill any newly-vacated 175 # position or add to the end of the active list. 176 177 for t in starting: 178 try: 179 i = active.index(None) 180 active[i] = t 181 except ValueError: 182 active.append(t) 183 184 # Discard vacant positions from the end of the active list. 185 186 while active and active[-1] is None: 187 active.pop() 188 189 slots.append((point, active[:])) 190 191 return slots 192 193 def add_day_start_points(slots): 194 195 """ 196 Introduce into the 'slots' any day start points required by multi-day 197 periods. 198 """ 199 200 new_slots = [] 201 current_date = None 202 previously_active = None 203 204 for point, active in slots: 205 start_of_day = get_start_of_day(point) 206 this_date = point.date() 207 208 # For each new day, add a slot for the start of the day where periods 209 # are active and where no such slot already exists. 210 211 if this_date != current_date: 212 current_date = this_date 213 214 # Add any continuing periods. 215 216 if point != start_of_day and previously_active: 217 new_slots.append((start_of_day, previously_active)) 218 219 # Add the currently active periods at this point in time. 220 221 previously_active = active 222 223 for t in new_slots: 224 insort_left(slots, t) 225 226 def add_slots(slots, points): 227 228 """ 229 Introduce into the 'slots' entries for those in 'points' that are not 230 already present, propagating active periods from time points preceding 231 those added. 232 """ 233 234 new_slots = [] 235 236 for point in points: 237 i = bisect_left(slots, (point, None)) 238 if i < len(slots) and slots[i][0] == point: 239 continue 240 241 new_slots.append((point, i > 0 and slots[i-1][1] or [])) 242 243 for t in new_slots: 244 insort_left(slots, t) 245 246 def partition_by_day(slots): 247 248 """ 249 Return a mapping from dates to time points provided by 'slots'. 250 """ 251 252 d = {} 253 254 for point, value in slots: 255 day = point.date() 256 if not d.has_key(day): 257 d[day] = [] 258 d[day].append((point, value)) 259 260 return d 261 262 def get_spans(slots): 263 264 "Inspect the given 'slots', returning a mapping of event uids to spans." 265 266 points = [point for point, active in slots] 267 spans = {} 268 269 for point, active in slots: 270 for t in active: 271 if t and len(t) >= 2: 272 start, end, uid, key = get_freebusy_details(t) 273 274 try: 275 start_slot = points.index(start) 276 except ValueError: 277 start_slot = 0 278 try: 279 end_slot = points.index(end) 280 except ValueError: 281 end_slot = len(slots) 282 spans[key] = end_slot - start_slot 283 284 return spans 285 286 def get_freebusy_details(t): 287 288 "Return a tuple of the form (start, end, uid, key) from 't'." 289 290 # Handle both complete free/busy details... 291 292 if len(t) > 2: 293 start, end, uid = t[:3] 294 key = uid 295 296 # ...and published details without specific event details. 297 298 else: 299 start, end = t[:2] 300 uid = None 301 key = (start, end) 302 303 return start, end, uid, key 304 305 # vim: tabstop=4 expandtab shiftwidth=4