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 period processing. 347 348 def get_active_periods(periods): 349 350 "Return a mapping of non-replaced periods to counts, given 'periods'." 351 352 active_periods = {} 353 for p in periods: 354 if not p.replaced: 355 if not active_periods.has_key(p): 356 active_periods[p] = 1 357 else: 358 active_periods[p] += 1 359 return active_periods 360 361 def get_removed_periods(periods, still_to_remove): 362 363 """ 364 From the recurrence 'periods', given details of those 'still_to_remove', 365 return the remaining active periods and the periods to unschedule or 366 exclude, using a tuple of the form (active, unscheduled, excluded). 367 """ 368 369 to_remove = set() 370 371 # Get all periods that are not replaced. 372 373 active_periods = get_active_periods(periods) 374 375 for i in still_to_remove: 376 try: 377 period = periods[int(i)] 378 except (IndexError, ValueError): 379 continue 380 381 active_periods[period] -= 1 382 to_remove.add(period) 383 384 # Determine whether some periods are both removed and added. 385 386 remaining = [] 387 for period, n in active_periods.items(): 388 if n > 0: 389 remaining.append(period) 390 391 to_remove.difference_update(remaining) 392 return remaining, to_remove 393 394 395 396 # Form field extraction and serialisation. 397 398 def get_date_control_inputs(args, name, tzid_name=None): 399 400 """ 401 Return a tuple of date control inputs taken from 'args' for field names 402 starting with 'name'. 403 404 If 'tzid_name' is specified, the time zone information will be acquired 405 from fields starting with 'tzid_name' instead of 'name'. 406 """ 407 408 return args.get("%s-date" % name, []), \ 409 args.get("%s-hour" % name, []), \ 410 args.get("%s-minute" % name, []), \ 411 args.get("%s-second" % name, []), \ 412 args.get("%s-tzid" % (tzid_name or name), []) 413 414 def get_date_control_values(args, name, multiple=False, tzid_name=None, tzid=None): 415 416 """ 417 Return a form date object representing fields taken from 'args' starting 418 with 'name'. 419 420 If 'multiple' is set to a true value, many date objects will be returned 421 corresponding to a collection of datetimes. 422 423 If 'tzid_name' is specified, the time zone information will be acquired 424 from fields starting with 'tzid_name' instead of 'name'. 425 426 If 'tzid' is specified, it will provide the time zone where no explicit 427 time zone information is indicated in the field data. 428 """ 429 430 dates, hours, minutes, seconds, tzids = get_date_control_inputs(args, name, tzid_name) 431 432 # Handle absent values by employing None values. 433 434 field_values = map(None, dates, hours, minutes, seconds, tzids) 435 436 if not field_values and not multiple: 437 all_values = FormDate() 438 else: 439 all_values = [] 440 for date, hour, minute, second, tzid_field in field_values: 441 value = FormDate(date, hour, minute, second, tzid_field or tzid) 442 443 # Return a single value or append to a collection of all values. 444 445 if not multiple: 446 return value 447 else: 448 all_values.append(value) 449 450 return all_values 451 452 def set_date_control_values(formdates, args, name, tzid_name=None): 453 454 """ 455 Using the values of the given 'formdates', replace form fields in 'args' 456 starting with 'name'. 457 458 If 'tzid_name' is specified, the time zone information will be stored in 459 fields starting with 'tzid_name' instead of 'name'. 460 """ 461 462 args["%s-date" % name] = [] 463 args["%s-hour" % name] = [] 464 args["%s-minute" % name] = [] 465 args["%s-second" % name] = [] 466 args["%s-tzid" % (tzid_name or name)] = [] 467 468 for d in formdates: 469 args["%s-date" % name].append(d and d.date or "") 470 args["%s-hour" % name].append(d and d.hour or "") 471 args["%s-minute" % name].append(d and d.minute or "") 472 args["%s-second" % name].append(d and d.second or "") 473 args["%s-tzid" % (tzid_name or name)].append(d and d.tzid or "") 474 475 def get_period_control_values(args, start_name, end_name, 476 end_enabled_name, times_enabled_name, 477 origin=None, origin_name=None, 478 replaced_name=None, tzid=None): 479 480 """ 481 Return period values from fields found in 'args' prefixed with the given 482 'start_name' (for start dates), 'end_name' (for end dates), 483 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 484 (to enable times for periods). 485 486 If 'origin' is specified, a single period with the given origin is 487 returned. If 'origin_name' is specified, fields containing the name will 488 provide origin information, and fields containing 'replaced_name' will 489 indicate periods that are replaced. 490 491 If 'tzid' is specified, it will provide the time zone where no explicit 492 time zone information is indicated in the field data. 493 """ 494 495 # Get the end datetime and time presence settings. 496 497 all_end_enabled = args.get(end_enabled_name, []) 498 all_times_enabled = args.get(times_enabled_name, []) 499 500 # Get the origins of period data and whether the periods are replaced. 501 502 if origin: 503 all_origins = [origin] 504 else: 505 all_origins = origin_name and args.get(origin_name, []) or [] 506 507 all_replaced = replaced_name and args.get(replaced_name, []) or [] 508 509 # Get the start and end datetimes. 510 511 all_starts = get_date_control_values(args, start_name, True, tzid=tzid) 512 all_ends = get_date_control_values(args, end_name, True, start_name, tzid=tzid) 513 514 # Construct period objects for each start, end, origin combination. 515 516 periods = [] 517 518 for index, (start, end, found_origin) in \ 519 enumerate(map(None, all_starts, all_ends, all_origins)): 520 521 # Obtain period settings from separate controls. 522 523 end_enabled = str(index) in all_end_enabled 524 times_enabled = str(index) in all_times_enabled 525 replaced = str(index) in all_replaced 526 527 period = FormPeriod(start, end, end_enabled, times_enabled, tzid, 528 found_origin or origin, replaced) 529 periods.append(period) 530 531 # Return a single period if a single origin was specified. 532 533 if origin: 534 return periods[0] 535 else: 536 return periods 537 538 def set_period_control_values(periods, args, start_name, end_name, 539 end_enabled_name, times_enabled_name, 540 origin_name=None, replaced_name=None): 541 542 """ 543 Using the given 'periods', replace form fields in 'args' prefixed with the 544 given 'start_name' (for start dates), 'end_name' (for end dates), 545 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 546 (to enable times for periods). 547 548 If 'origin_name' is specified, fields containing the name will provide 549 origin information, and fields containing 'replaced_name' will indicate 550 periods that are replaced. 551 """ 552 553 # Record period settings separately. 554 555 args[end_enabled_name] = [] 556 args[times_enabled_name] = [] 557 558 # Record origin and replacement information if naming is defined. 559 560 if origin_name: 561 args[origin_name] = [] 562 563 if replaced_name: 564 args[replaced_name] = [] 565 566 all_starts = [] 567 all_ends = [] 568 569 for index, period in enumerate(periods): 570 571 # Encode period settings in controls. 572 573 if period.end_enabled: 574 args[end_enabled_name].append(str(index)) 575 if period.times_enabled: 576 args[times_enabled_name].append(str(index)) 577 578 # Add origin information where controls are present to record it. 579 580 if origin_name: 581 args[origin_name].append(period.origin or "") 582 583 # Add replacement information where controls are present to record it. 584 585 if replaced_name and period.replaced: 586 args[replaced_name].append(str(index)) 587 588 # Collect form date information for addition below. 589 590 all_starts.append(period.get_form_start()) 591 all_ends.append(period.get_form_end()) 592 593 # Set the controls for the dates. 594 595 set_date_control_values(all_starts, args, start_name) 596 set_date_control_values(all_ends, args, end_name, tzid_name=start_name) 597 598 599 600 # Utilities. 601 602 def filter_duplicates(l): 603 604 """ 605 Return collection 'l' filtered for duplicate values, retaining the given 606 element ordering. 607 """ 608 609 s = set() 610 f = [] 611 612 for value in l: 613 if value not in s: 614 s.add(value) 615 f.append(value) 616 617 return f 618 619 def remove_from_collection(l, indexes, fn): 620 621 """ 622 Remove from collection 'l' all values present at the given 'indexes' where 623 'fn' applied to each referenced value returns a true value. Values where 624 'fn' returns a false value are added to a list of deferred removals which is 625 returned. 626 """ 627 628 still_to_remove = [] 629 correction = 0 630 631 for i in indexes: 632 try: 633 i = int(i) - correction 634 value = l[i] 635 except (IndexError, ValueError): 636 continue 637 638 if fn(value): 639 del l[i] 640 correction += 1 641 else: 642 still_to_remove.append(str(i)) 643 644 return still_to_remove 645 646 # vim: tabstop=4 expandtab shiftwidth=4