1 #!/usr/bin/env python 2 3 """ 4 Web interface data abstractions. 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 datetime import datetime, timedelta 23 from imiptools.dates import end_date_from_calendar, end_date_to_calendar, \ 24 format_datetime, get_datetime, get_end_of_day, \ 25 to_date 26 from imiptools.period import RecurringPeriod 27 28 class PeriodError(Exception): 29 pass 30 31 class EventPeriod(RecurringPeriod): 32 33 """ 34 A simple period plus attribute details, compatible with RecurringPeriod, and 35 intended to represent information obtained from an iCalendar resource. 36 """ 37 38 def __init__(self, start, end, tzid=None, origin=None, start_attr=None, end_attr=None, form_start=None, form_end=None, replaced=False): 39 40 """ 41 Initialise a period with the given 'start' and 'end' datetimes, together 42 with optional 'start_attr' and 'end_attr' metadata, 'form_start' and 43 'form_end' values provided as textual input, and with an optional 44 'origin' indicating the kind of period this object describes. 45 """ 46 47 RecurringPeriod.__init__(self, start, end, tzid, origin, start_attr, end_attr) 48 self.form_start = form_start 49 self.form_end = form_end 50 self.replaced = replaced 51 52 def as_tuple(self): 53 return self.start, self.end, self.tzid, self.origin, self.start_attr, self.end_attr, self.form_start, self.form_end, self.replaced 54 55 def __repr__(self): 56 return "EventPeriod%r" % (self.as_tuple(),) 57 58 def as_event_period(self): 59 return self 60 61 def get_start_item(self): 62 return self.get_start(), self.get_start_attr() 63 64 def get_end_item(self): 65 return self.get_end(), self.get_end_attr() 66 67 # Form data compatibility methods. 68 69 def get_form_start(self): 70 if not self.form_start: 71 self.form_start = self.get_form_date(self.get_start(), self.start_attr) 72 return self.form_start 73 74 def get_form_end(self): 75 if not self.form_end: 76 self.form_end = self.get_form_date(end_date_from_calendar(self.get_end()), self.end_attr) 77 return self.form_end 78 79 def as_form_period(self): 80 return FormPeriod( 81 self.get_form_start(), 82 self.get_form_end(), 83 isinstance(self.end, datetime) or self.get_start() != self.get_end() - timedelta(1), 84 isinstance(self.start, datetime) or isinstance(self.end, datetime), 85 self.tzid, 86 self.origin, 87 self.replaced 88 ) 89 90 def get_form_date(self, dt, attr=None): 91 return FormDate( 92 format_datetime(to_date(dt)), 93 isinstance(dt, datetime) and str(dt.hour) or None, 94 isinstance(dt, datetime) and str(dt.minute) or None, 95 isinstance(dt, datetime) and str(dt.second) or None, 96 attr and attr.get("TZID") or None, 97 dt, attr 98 ) 99 100 class FormPeriod(RecurringPeriod): 101 102 "A period whose information originates from a form." 103 104 def __init__(self, start, end, end_enabled=True, times_enabled=True, tzid=None, origin=None, replaced=False): 105 self.start = start 106 self.end = end 107 self.end_enabled = end_enabled 108 self.times_enabled = times_enabled 109 self.tzid = tzid 110 self.origin = origin 111 self.replaced = replaced 112 113 def as_tuple(self): 114 return self.start, self.end, self.end_enabled, self.times_enabled, self.tzid, self.origin, self.replaced 115 116 def __repr__(self): 117 return "FormPeriod%r" % (self.as_tuple(),) 118 119 def as_event_period(self, index=None): 120 121 """ 122 Return a converted version of this object as an event period suitable 123 for iCalendar usage. If 'index' is indicated, include it in any error 124 raised in the conversion process. 125 """ 126 127 dtstart, dtstart_attr = self.get_start_item() 128 if not dtstart: 129 raise PeriodError(*[index is not None and ("dtstart", index) or "dtstart"]) 130 131 dtend, dtend_attr = self.get_end_item() 132 if not dtend: 133 raise PeriodError(*[index is not None and ("dtend", index) or "dtend"]) 134 135 if dtstart > dtend: 136 raise PeriodError(*[ 137 index is not None and ("dtstart", index) or "dtstart", 138 index is not None and ("dtend", index) or "dtend" 139 ]) 140 141 return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid, self.origin, dtstart_attr, dtend_attr, self.start, self.end, self.replaced) 142 143 # Period data methods. 144 145 def get_start(self): 146 return self.start.as_datetime(self.times_enabled) 147 148 def get_end(self): 149 150 # Handle specified end datetimes. 151 152 if self.end_enabled: 153 dtend = self.end.as_datetime(self.times_enabled) 154 if not dtend: 155 return None 156 157 # Handle same day times. 158 159 elif self.times_enabled: 160 formdate = FormDate(self.start.date, self.end.hour, self.end.minute, self.end.second, self.end.tzid) 161 dtend = formdate.as_datetime(self.times_enabled) 162 if not dtend: 163 return None 164 165 # Otherwise, treat the end date as the start date. Datetimes are 166 # handled by making the event occupy the rest of the day. 167 168 else: 169 dtstart, dtstart_attr = self.get_start_item() 170 if dtstart: 171 if isinstance(dtstart, datetime): 172 dtend = get_end_of_day(dtstart, dtstart_attr["TZID"]) 173 else: 174 dtend = dtstart 175 else: 176 return None 177 178 return dtend 179 180 def get_start_attr(self): 181 return self.start.get_attributes(self.times_enabled) 182 183 def get_end_attr(self): 184 return self.end.get_attributes(self.times_enabled) 185 186 # Form data methods. 187 188 def get_form_start(self): 189 return self.start 190 191 def get_form_end(self): 192 return self.end 193 194 def as_form_period(self): 195 return self 196 197 class FormDate: 198 199 "Date information originating from form information." 200 201 def __init__(self, date=None, hour=None, minute=None, second=None, tzid=None, dt=None, attr=None): 202 self.date = date 203 self.hour = hour 204 self.minute = minute 205 self.second = second 206 self.tzid = tzid 207 self.dt = dt 208 self.attr = attr 209 210 def as_tuple(self): 211 return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr 212 213 def __repr__(self): 214 return "FormDate%r" % (self.as_tuple(),) 215 216 def get_component(self, value): 217 return (value or "").rjust(2, "0")[:2] 218 219 def get_hour(self): 220 return self.get_component(self.hour) 221 222 def get_minute(self): 223 return self.get_component(self.minute) 224 225 def get_second(self): 226 return self.get_component(self.second) 227 228 def get_date_string(self): 229 return self.date or "" 230 231 def get_datetime_string(self): 232 if not self.date: 233 return "" 234 235 hour = self.hour; minute = self.minute; second = self.second 236 237 if hour or minute or second: 238 time = "T%s%s%s" % tuple(map(self.get_component, (hour, minute, second))) 239 else: 240 time = "" 241 242 return "%s%s" % (self.date, time) 243 244 def get_tzid(self): 245 return self.tzid 246 247 def as_datetime(self, with_time=True): 248 249 "Return a datetime for this object." 250 251 # Return any original datetime details. 252 253 if self.dt: 254 return self.dt 255 256 # Otherwise, construct a datetime. 257 258 s, attr = self.as_datetime_item(with_time) 259 if s: 260 return get_datetime(s, attr) 261 else: 262 return None 263 264 def as_datetime_item(self, with_time=True): 265 266 """ 267 Return a (datetime string, attr) tuple for the datetime information 268 provided by this object, where both tuple elements will be None if no 269 suitable date or datetime information exists. 270 """ 271 272 s = None 273 if with_time: 274 s = self.get_datetime_string() 275 attr = self.get_attributes(True) 276 if not s: 277 s = self.get_date_string() 278 attr = self.get_attributes(False) 279 if not s: 280 return None, None 281 return s, attr 282 283 def get_attributes(self, with_time=True): 284 285 "Return attributes for the date or datetime represented by this object." 286 287 if with_time: 288 return {"TZID" : self.get_tzid(), "VALUE" : "DATE-TIME"} 289 else: 290 return {"VALUE" : "DATE"} 291 292 def event_period_from_period(period): 293 294 """ 295 Convert a 'period' to one suitable for use in an iCalendar representation. 296 In an "event period" representation, the end day of any date-level event is 297 encoded as the "day after" the last day actually involved in the event. 298 """ 299 300 if isinstance(period, EventPeriod): 301 return period 302 elif isinstance(period, FormPeriod): 303 return period.as_event_period() 304 else: 305 dtstart, dtstart_attr = period.get_start_item() 306 dtend, dtend_attr = period.get_end_item() 307 if not isinstance(period, RecurringPeriod): 308 dtend = end_date_to_calendar(dtend) 309 return EventPeriod(dtstart, dtend, period.tzid, period.origin, dtstart_attr, dtend_attr) 310 311 def form_period_from_period(period): 312 313 """ 314 Convert a 'period' into a representation usable in a user-editable form. 315 In a "form period" representation, the end day of any date-level event is 316 presented in a "natural" form, not the iCalendar "day after" form. 317 """ 318 319 if isinstance(period, EventPeriod): 320 return period.as_form_period() 321 elif isinstance(period, FormPeriod): 322 return period 323 else: 324 return event_period_from_period(period).as_form_period() 325 326 # vim: tabstop=4 expandtab shiftwidth=4