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 # Otherwise, treat the end date as the start date. Datetimes are 158 # handled by making the event occupy the rest of the day. 159 160 else: 161 dtstart, dtstart_attr = self.get_start_item() 162 if dtstart: 163 if isinstance(dtstart, datetime): 164 dtend = get_end_of_day(dtstart, dtstart_attr["TZID"]) 165 else: 166 dtend = dtstart 167 else: 168 return None 169 170 return dtend 171 172 def get_start_attr(self): 173 return self.start.get_attributes(self.times_enabled) 174 175 def get_end_attr(self): 176 return self.end.get_attributes(self.times_enabled) 177 178 # Form data methods. 179 180 def get_form_start(self): 181 return self.start 182 183 def get_form_end(self): 184 return self.end 185 186 def as_form_period(self): 187 return self 188 189 class FormDate: 190 191 "Date information originating from form information." 192 193 def __init__(self, date=None, hour=None, minute=None, second=None, tzid=None, dt=None, attr=None): 194 self.date = date 195 self.hour = hour 196 self.minute = minute 197 self.second = second 198 self.tzid = tzid 199 self.dt = dt 200 self.attr = attr 201 202 def as_tuple(self): 203 return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr 204 205 def __repr__(self): 206 return "FormDate%r" % (self.as_tuple(),) 207 208 def get_component(self, value): 209 return (value or "").rjust(2, "0")[:2] 210 211 def get_hour(self): 212 return self.get_component(self.hour) 213 214 def get_minute(self): 215 return self.get_component(self.minute) 216 217 def get_second(self): 218 return self.get_component(self.second) 219 220 def get_date_string(self): 221 return self.date or "" 222 223 def get_datetime_string(self): 224 if not self.date: 225 return "" 226 227 hour = self.hour; minute = self.minute; second = self.second 228 229 if hour or minute or second: 230 time = "T%s%s%s" % tuple(map(self.get_component, (hour, minute, second))) 231 else: 232 time = "" 233 234 return "%s%s" % (self.date, time) 235 236 def get_tzid(self): 237 return self.tzid 238 239 def as_datetime(self, with_time=True): 240 241 "Return a datetime for this object." 242 243 # Return any original datetime details. 244 245 if self.dt: 246 return self.dt 247 248 # Otherwise, construct a datetime. 249 250 s, attr = self.as_datetime_item(with_time) 251 if s: 252 return get_datetime(s, attr) 253 else: 254 return None 255 256 def as_datetime_item(self, with_time=True): 257 258 """ 259 Return a (datetime string, attr) tuple for the datetime information 260 provided by this object, where both tuple elements will be None if no 261 suitable date or datetime information exists. 262 """ 263 264 s = None 265 if with_time: 266 s = self.get_datetime_string() 267 attr = self.get_attributes(True) 268 if not s: 269 s = self.get_date_string() 270 attr = self.get_attributes(False) 271 if not s: 272 return None, None 273 return s, attr 274 275 def get_attributes(self, with_time=True): 276 277 "Return attributes for the date or datetime represented by this object." 278 279 if with_time: 280 return {"TZID" : self.get_tzid(), "VALUE" : "DATE-TIME"} 281 else: 282 return {"VALUE" : "DATE"} 283 284 def event_period_from_period(period): 285 286 """ 287 Convert a 'period' to one suitable for use in an iCalendar representation. 288 In an "event period" representation, the end day of any date-level event is 289 encoded as the "day after" the last day actually involved in the event. 290 """ 291 292 if isinstance(period, EventPeriod): 293 return period 294 elif isinstance(period, FormPeriod): 295 return period.as_event_period() 296 else: 297 dtstart, dtstart_attr = period.get_start_item() 298 dtend, dtend_attr = period.get_end_item() 299 if not isinstance(period, RecurringPeriod): 300 dtend = end_date_to_calendar(dtend) 301 return EventPeriod(dtstart, dtend, period.tzid, period.origin, dtstart_attr, dtend_attr) 302 303 def form_period_from_period(period): 304 305 """ 306 Convert a 'period' into a representation usable in a user-editable form. 307 In a "form period" representation, the end day of any date-level event is 308 presented in a "natural" form, not the iCalendar "day after" form. 309 """ 310 311 if isinstance(period, EventPeriod): 312 return period.as_form_period() 313 elif isinstance(period, FormPeriod): 314 return period 315 else: 316 return event_period_from_period(period).as_form_period() 317 318 # vim: tabstop=4 expandtab shiftwidth=4