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, 39 end_attr=None, form_start=None, form_end=None, replaced=False): 40 41 """ 42 Initialise a period with the given 'start' and 'end' datetimes, together 43 with optional 'start_attr' and 'end_attr' metadata, 'form_start' and 44 'form_end' values provided as textual input, and with an optional 45 'origin' indicating the kind of period this object describes. 46 """ 47 48 RecurringPeriod.__init__(self, start, end, tzid, origin, start_attr, end_attr) 49 self.form_start = form_start 50 self.form_end = form_end 51 self.replaced = replaced 52 53 def as_tuple(self): 54 return self.start, self.end, self.tzid, self.origin, self.start_attr, \ 55 self.end_attr, self.form_start, self.form_end, self.replaced 56 57 def __repr__(self): 58 return "EventPeriod%r" % (self.as_tuple(),) 59 60 def as_event_period(self): 61 return self 62 63 def get_start_item(self): 64 return self.get_start(), self.get_start_attr() 65 66 def get_end_item(self): 67 return self.get_end(), self.get_end_attr() 68 69 # Form data compatibility methods. 70 71 def get_form_start(self): 72 if not self.form_start: 73 self.form_start = self.get_form_date(self.get_start(), self.start_attr) 74 return self.form_start 75 76 def get_form_end(self): 77 if not self.form_end: 78 self.form_end = self.get_form_date(end_date_from_calendar(self.get_end()), self.end_attr) 79 return self.form_end 80 81 def as_form_period(self): 82 return FormPeriod( 83 self.get_form_start(), 84 self.get_form_end(), 85 isinstance(self.end, datetime) or self.get_start() != self.get_end() - timedelta(1), 86 isinstance(self.start, datetime) or isinstance(self.end, datetime), 87 self.tzid, 88 self.origin, 89 self.replaced and True or False 90 ) 91 92 def get_form_date(self, dt, attr=None): 93 return FormDate( 94 format_datetime(to_date(dt)), 95 isinstance(dt, datetime) and str(dt.hour) or None, 96 isinstance(dt, datetime) and str(dt.minute) or None, 97 isinstance(dt, datetime) and str(dt.second) or None, 98 attr and attr.get("TZID") or None, 99 dt, attr 100 ) 101 102 class FormPeriod(RecurringPeriod): 103 104 "A period whose information originates from a form." 105 106 def __init__(self, start, end, end_enabled=True, times_enabled=True, 107 tzid=None, origin=None, replaced=False): 108 self.start = start 109 self.end = end 110 self.end_enabled = end_enabled 111 self.times_enabled = times_enabled 112 self.tzid = tzid 113 self.origin = origin 114 self.replaced = replaced 115 116 def as_tuple(self): 117 return self.start, self.end, self.end_enabled, self.times_enabled, self.tzid, self.origin, self.replaced 118 119 def __repr__(self): 120 return "FormPeriod%r" % (self.as_tuple(),) 121 122 def __cmp__(self, other): 123 result = RecurringPeriod.__cmp__(self, other) 124 if result: 125 return result 126 other = form_period_from_period(other) 127 return cmp(self.replaced, other.replaced) 128 129 def as_event_period(self, index=None): 130 131 """ 132 Return a converted version of this object as an event period suitable 133 for iCalendar usage. If 'index' is indicated, include it in any error 134 raised in the conversion process. 135 """ 136 137 dtstart, dtstart_attr = self.get_start_item() 138 if not dtstart: 139 if index is not None: 140 raise PeriodError(("dtstart", index)) 141 else: 142 raise PeriodError("dtstart") 143 144 dtend, dtend_attr = self.get_end_item() 145 if not dtend: 146 if index is not None: 147 raise PeriodError(("dtend", index)) 148 else: 149 raise PeriodError("dtend") 150 151 if dtstart > dtend: 152 if index is not None: 153 raise PeriodError(("dtstart", index), ("dtend", index)) 154 else: 155 raise PeriodError("dtstart", "dtend") 156 157 return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid, 158 self.origin, dtstart_attr, dtend_attr, 159 self.start, self.end, self.replaced) 160 161 # Period data methods. 162 163 def get_start(self): 164 return self.start and self.start.as_datetime(self.times_enabled) or None 165 166 def get_end(self): 167 168 # Handle specified end datetimes. 169 170 if self.end_enabled: 171 dtend = self.end.as_datetime(self.times_enabled) 172 if not dtend: 173 return None 174 175 # Handle same day times. 176 177 elif self.times_enabled: 178 formdate = FormDate(self.start.date, self.end.hour, self.end.minute, self.end.second, self.end.tzid) 179 dtend = formdate.as_datetime(self.times_enabled) 180 if not dtend: 181 return None 182 183 # Otherwise, treat the end date as the start date. Datetimes are 184 # handled by making the event occupy the rest of the day. 185 186 else: 187 dtstart, dtstart_attr = self.get_start_item() 188 if dtstart: 189 if isinstance(dtstart, datetime): 190 dtend = get_end_of_day(dtstart, dtstart_attr["TZID"]) 191 else: 192 dtend = dtstart 193 else: 194 return None 195 196 return dtend 197 198 def get_start_attr(self): 199 return self.start and self.start.get_attributes(self.times_enabled) or {} 200 201 def get_end_attr(self): 202 return self.end and self.end.get_attributes(self.times_enabled) or {} 203 204 # Form data methods. 205 206 def get_form_start(self): 207 return self.start 208 209 def get_form_end(self): 210 return self.end 211 212 def as_form_period(self): 213 return self 214 215 class FormDate: 216 217 "Date information originating from form information." 218 219 def __init__(self, date=None, hour=None, minute=None, second=None, tzid=None, dt=None, attr=None): 220 self.date = date 221 self.hour = hour 222 self.minute = minute 223 self.second = second 224 self.tzid = tzid 225 self.dt = dt 226 self.attr = attr 227 228 def as_tuple(self): 229 return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr 230 231 def __repr__(self): 232 return "FormDate%r" % (self.as_tuple(),) 233 234 def get_component(self, value): 235 return (value or "").rjust(2, "0")[:2] 236 237 def get_hour(self): 238 return self.get_component(self.hour) 239 240 def get_minute(self): 241 return self.get_component(self.minute) 242 243 def get_second(self): 244 return self.get_component(self.second) 245 246 def get_date_string(self): 247 return self.date or "" 248 249 def get_datetime_string(self): 250 if not self.date: 251 return "" 252 253 hour = self.hour; minute = self.minute; second = self.second 254 255 if hour or minute or second: 256 time = "T%s%s%s" % tuple(map(self.get_component, (hour, minute, second))) 257 else: 258 time = "" 259 260 return "%s%s" % (self.date, time) 261 262 def get_tzid(self): 263 return self.tzid 264 265 def as_datetime(self, with_time=True): 266 267 "Return a datetime for this object." 268 269 # Return any original datetime details. 270 271 if self.dt: 272 return self.dt 273 274 # Otherwise, construct a datetime. 275 276 s, attr = self.as_datetime_item(with_time) 277 if s: 278 return get_datetime(s, attr) 279 else: 280 return None 281 282 def as_datetime_item(self, with_time=True): 283 284 """ 285 Return a (datetime string, attr) tuple for the datetime information 286 provided by this object, where both tuple elements will be None if no 287 suitable date or datetime information exists. 288 """ 289 290 s = None 291 if with_time: 292 s = self.get_datetime_string() 293 attr = self.get_attributes(True) 294 if not s: 295 s = self.get_date_string() 296 attr = self.get_attributes(False) 297 if not s: 298 return None, None 299 return s, attr 300 301 def get_attributes(self, with_time=True): 302 303 "Return attributes for the date or datetime represented by this object." 304 305 if with_time: 306 return {"TZID" : self.get_tzid(), "VALUE" : "DATE-TIME"} 307 else: 308 return {"VALUE" : "DATE"} 309 310 def event_period_from_period(period): 311 312 """ 313 Convert a 'period' to one suitable for use in an iCalendar representation. 314 In an "event period" representation, the end day of any date-level event is 315 encoded as the "day after" the last day actually involved in the event. 316 """ 317 318 if isinstance(period, EventPeriod): 319 return period 320 elif isinstance(period, FormPeriod): 321 return period.as_event_period() 322 else: 323 dtstart, dtstart_attr = period.get_start_item() 324 dtend, dtend_attr = period.get_end_item() 325 if not isinstance(period, RecurringPeriod): 326 dtend = end_date_to_calendar(dtend) 327 return EventPeriod(dtstart, dtend, period.tzid, period.origin, dtstart_attr, dtend_attr) 328 329 def form_period_from_period(period): 330 331 """ 332 Convert a 'period' into a representation usable in a user-editable form. 333 In a "form period" representation, the end day of any date-level event is 334 presented in a "natural" form, not the iCalendar "day after" form. 335 """ 336 337 if isinstance(period, EventPeriod): 338 return period.as_form_period() 339 elif isinstance(period, FormPeriod): 340 return period 341 else: 342 return event_period_from_period(period).as_form_period() 343 344 345 346 # Form field extraction and serialisation. 347 348 def get_date_control_inputs(args, name, tzid_name=None): 349 350 """ 351 Return a tuple of date control inputs taken from 'args' for field names 352 starting with 'name'. 353 354 If 'tzid_name' is specified, the time zone information will be acquired 355 from fields starting with 'tzid_name' instead of 'name'. 356 """ 357 358 return args.get("%s-date" % name, []), \ 359 args.get("%s-hour" % name, []), \ 360 args.get("%s-minute" % name, []), \ 361 args.get("%s-second" % name, []), \ 362 args.get("%s-tzid" % (tzid_name or name), []) 363 364 def get_date_control_values(args, name, multiple=False, tzid_name=None, tzid=None): 365 366 """ 367 Return a form date object representing fields taken from 'args' starting 368 with 'name'. 369 370 If 'multiple' is set to a true value, many date objects will be returned 371 corresponding to a collection of datetimes. 372 373 If 'tzid_name' is specified, the time zone information will be acquired 374 from fields starting with 'tzid_name' instead of 'name'. 375 376 If 'tzid' is specified, it will provide the time zone where no explicit 377 time zone information is indicated in the field data. 378 """ 379 380 dates, hours, minutes, seconds, tzids = get_date_control_inputs(args, name, tzid_name) 381 382 # Handle absent values by employing None values. 383 384 field_values = map(None, dates, hours, minutes, seconds, tzids) 385 386 if not field_values and not multiple: 387 all_values = FormDate() 388 else: 389 all_values = [] 390 for date, hour, minute, second, tzid_field in field_values: 391 value = FormDate(date, hour, minute, second, tzid_field or tzid) 392 393 # Return a single value or append to a collection of all values. 394 395 if not multiple: 396 return value 397 else: 398 all_values.append(value) 399 400 return all_values 401 402 def set_date_control_values(formdates, args, name, tzid_name=None): 403 404 """ 405 Using the values of the given 'formdates', replace form fields in 'args' 406 starting with 'name'. 407 408 If 'tzid_name' is specified, the time zone information will be stored in 409 fields starting with 'tzid_name' instead of 'name'. 410 """ 411 412 args["%s-date" % name] = [] 413 args["%s-hour" % name] = [] 414 args["%s-minute" % name] = [] 415 args["%s-second" % name] = [] 416 args["%s-tzid" % (tzid_name or name)] = [] 417 418 for d in formdates: 419 args["%s-date" % name].append(d and d.date or "") 420 args["%s-hour" % name].append(d and d.hour or "") 421 args["%s-minute" % name].append(d and d.minute or "") 422 args["%s-second" % name].append(d and d.second or "") 423 args["%s-tzid" % (tzid_name or name)].append(d and d.tzid or "") 424 425 def get_period_control_values(args, start_name, end_name, 426 end_enabled_name, times_enabled_name, 427 origin=None, origin_name=None, 428 replaced_name=None, tzid=None): 429 430 """ 431 Return period values from fields found in 'args' prefixed with the given 432 'start_name' (for start dates), 'end_name' (for end dates), 433 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 434 (to enable times for periods). 435 436 If 'origin' is specified, a single period with the given origin is 437 returned. If 'origin_name' is specified, fields containing the name will 438 provide origin information, and fields containing 'replaced_name' will 439 indicate periods that are replaced. 440 441 If 'tzid' is specified, it will provide the time zone where no explicit 442 time zone information is indicated in the field data. 443 """ 444 445 # Get the end datetime and time presence settings. 446 447 all_end_enabled = args.get(end_enabled_name, []) 448 all_times_enabled = args.get(times_enabled_name, []) 449 450 # Get the origins of period data and whether the periods are replaced. 451 452 if origin: 453 all_origins = [origin] 454 else: 455 all_origins = origin_name and args.get(origin_name, []) or [] 456 457 all_replaced = replaced_name and args.get(replaced_name, []) or [] 458 459 # Get the start and end datetimes. 460 461 all_starts = get_date_control_values(args, start_name, True, tzid=tzid) 462 all_ends = get_date_control_values(args, end_name, True, start_name, tzid=tzid) 463 464 # Construct period objects for each start, end, origin combination. 465 466 periods = [] 467 468 for index, (start, end, found_origin) in \ 469 enumerate(map(None, all_starts, all_ends, all_origins)): 470 471 # Obtain period settings from separate controls. 472 473 end_enabled = str(index) in all_end_enabled 474 times_enabled = str(index) in all_times_enabled 475 replaced = str(index) in all_replaced 476 477 period = FormPeriod(start, end, end_enabled, times_enabled, tzid, 478 found_origin or origin, replaced) 479 periods.append(period) 480 481 # Return a single period if a single origin was specified. 482 483 if origin: 484 return periods[0] 485 else: 486 return periods 487 488 def set_period_control_values(periods, args, start_name, end_name, 489 end_enabled_name, times_enabled_name, 490 origin_name=None, replaced_name=None): 491 492 """ 493 Using the given 'periods', replace form fields in 'args' prefixed with the 494 given 'start_name' (for start dates), 'end_name' (for end dates), 495 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 496 (to enable times for periods). 497 498 If 'origin_name' is specified, fields containing the name will provide 499 origin information, and fields containing 'replaced_name' will indicate 500 periods that are replaced. 501 """ 502 503 # Record period settings separately. 504 505 args[end_enabled_name] = [] 506 args[times_enabled_name] = [] 507 508 # Record origin and replacement information if naming is defined. 509 510 if origin_name: 511 args[origin_name] = [] 512 513 if replaced_name: 514 args[replaced_name] = [] 515 516 all_starts = [] 517 all_ends = [] 518 519 for index, period in enumerate(periods): 520 521 # Encode period settings in controls. 522 523 if period.end_enabled: 524 args[end_enabled_name].append(str(index)) 525 if period.times_enabled: 526 args[times_enabled_name].append(str(index)) 527 528 # Add origin information where controls are present to record it. 529 530 if origin_name: 531 args[origin_name].append(period.origin or "") 532 533 # Add replacement information where controls are present to record it. 534 535 if replaced_name and period.replaced: 536 args[replaced_name].append(str(index)) 537 538 # Collect form date information for addition below. 539 540 all_starts.append(period.get_form_start()) 541 all_ends.append(period.get_form_end()) 542 543 # Set the controls for the dates. 544 545 set_date_control_values(all_starts, args, start_name) 546 set_date_control_values(all_ends, args, end_name, tzid_name=start_name) 547 548 # vim: tabstop=4 expandtab shiftwidth=4