1 #!/usr/bin/env python 2 3 """ 4 Web interface data abstractions. 5 6 Copyright (C) 2014, 2015, 2017 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 # Form field extraction and serialisation. 327 328 def get_date_control_values(args, name, multiple=False, tzid_name=None, tzid=None): 329 330 """ 331 Return a form date object representing fields taken from 'args' starting 332 with 'name'. 333 334 If 'multiple' is set to a true value, many date objects will be returned 335 corresponding to a collection of datetimes. 336 337 If 'tzid_name' is specified, the time zone information will be acquired 338 from fields starting with 'tzid_name' instead of 'name'. 339 340 If 'tzid' is specified, it will provide the time zone where no explicit 341 time zone information is indicated in the field data. 342 """ 343 344 dates = args.get("%s-date" % name, []) 345 hours = args.get("%s-hour" % name, []) 346 minutes = args.get("%s-minute" % name, []) 347 seconds = args.get("%s-second" % name, []) 348 tzids = args.get("%s-tzid" % (tzid_name or name), []) 349 350 # Handle absent values by employing None values. 351 352 field_values = map(None, dates, hours, minutes, seconds, tzids) 353 354 if not field_values and not multiple: 355 all_values = FormDate() 356 else: 357 all_values = [] 358 for date, hour, minute, second, tzid_field in field_values: 359 value = FormDate(date, hour, minute, second, tzid_field or tzid) 360 361 # Return a single value or append to a collection of all values. 362 363 if not multiple: 364 return value 365 else: 366 all_values.append(value) 367 368 return all_values 369 370 def set_date_control_values(formdates, args, name, tzid_name=None): 371 372 """ 373 Using the values of the given 'formdates', replace form fields in 'args' 374 starting with 'name'. 375 376 If 'tzid_name' is specified, the time zone information will be stored in 377 fields starting with 'tzid_name' instead of 'name'. 378 """ 379 380 args["%s-date" % name] = [] 381 args["%s-hour" % name] = [] 382 args["%s-minute" % name] = [] 383 args["%s-second" % name] = [] 384 args["%s-tzid" % (tzid_name or name)] = [] 385 386 for d in formdates: 387 args["%s-date" % name].append(d.date) 388 args["%s-hour" % name].append(d.hour) 389 args["%s-minute" % name].append(d.minute) 390 args["%s-second" % name].append(d.second) 391 args["%s-tzid" % (tzid_name or name)].append(d.tzid) 392 393 def get_period_control_values(args, start_name, end_name, 394 end_enabled_name, times_enabled_name, 395 origin=None, origin_name=None, 396 replaced_name=None, tzid=None): 397 398 """ 399 Return period values from fields found in 'args' prefixed with the given 400 'start_name' (for start dates), 'end_name' (for end dates), 401 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 402 (to enable times for periods). 403 404 If 'origin' is specified, a single period with the given origin is 405 returned. If 'origin_name' is specified, fields containing the name will 406 provide origin information, and fields containing 'replaced_name' will 407 indicate periods that are replaced. 408 409 If 'tzid' is specified, it will provide the time zone where no explicit 410 time zone information is indicated in the field data. 411 """ 412 413 # Get the end datetime and time presence settings. 414 415 all_end_enabled = args.get(end_enabled_name, []) 416 all_times_enabled = args.get(times_enabled_name, []) 417 418 # Get the origins of period data and whether the periods are replaced. 419 420 if origin: 421 all_origins = [origin] 422 else: 423 all_origins = origin_name and args.get(origin_name, []) or [] 424 425 all_replaced = replaced_name and args.get(replaced_name, []) or [] 426 427 # Get the start and end datetimes. 428 429 all_starts = get_date_control_values(args, start_name, True, tzid=tzid) 430 all_ends = get_date_control_values(args, end_name, True, start_name, tzid=tzid) 431 432 # Construct period objects for each start, end, origin combination. 433 434 periods = [] 435 436 for index, (start, end, found_origin) in \ 437 enumerate(map(None, all_starts, all_ends, all_origins)): 438 439 # Obtain period settings from separate controls. 440 441 end_enabled = str(index) in all_end_enabled 442 times_enabled = str(index) in all_times_enabled 443 replaced = str(index) in all_replaced 444 445 period = FormPeriod(start, end, end_enabled, times_enabled, tzid, 446 found_origin or origin, replaced) 447 periods.append(period) 448 449 # Return a single period if a single origin was specified. 450 451 if origin: 452 return periods[0] 453 else: 454 return periods 455 456 def set_period_control_values(periods, args, start_name, end_name, 457 end_enabled_name, times_enabled_name, 458 origin_name=None, replaced_name=None): 459 460 """ 461 Using the given 'periods', replace form fields in 'args' prefixed with the 462 given 'start_name' (for start dates), 'end_name' (for end dates), 463 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 464 (to enable times for periods). 465 466 If 'origin_name' is specified, fields containing the name will provide 467 origin information, and fields containing 'replaced_name' will indicate 468 periods that are replaced. 469 """ 470 471 # Record period settings separately. 472 473 args[end_enabled_name] = [] 474 args[times_enabled_name] = [] 475 476 # Record origin and replacement information if naming is defined. 477 478 if origin_name: 479 args[origin_name] = [] 480 481 if replaced_name: 482 args[replaced_name] = [] 483 484 all_starts = [] 485 all_ends = [] 486 487 for index, period in enumerate(periods): 488 489 # Encode period settings in controls. 490 491 if period.end_enabled: 492 args[end_enabled_name].append(str(index)) 493 if period.times_enabled: 494 args[times_enabled_name].append(str(index)) 495 496 # Add origin information where controls are present to record it. 497 498 if origin_name: 499 args[origin_name].append(period.origin or "") 500 501 # Add replacement information where controls are present to record it. 502 503 if replaced_name and period.replaced: 504 args[replaced_name].append(str(index)) 505 506 # Collect form date information for addition below. 507 508 all_starts.append(period.get_form_start()) 509 all_ends.append(period.get_form_end()) 510 511 # Set the controls for the dates. 512 513 set_date_control_values(all_starts, args, start_name) 514 set_date_control_values(all_ends, args, end_name, tzid_name=start_name) 515 516 # vim: tabstop=4 expandtab shiftwidth=4