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 # General editing abstractions. 29 30 class State: 31 32 "Manage computed state." 33 34 def __init__(self, callables): 35 36 """ 37 Define state variable initialisation using the given 'callables', which 38 is a mapping that defines a callable for each variable name that is 39 invoked when the variable is first requested. 40 """ 41 42 self.state = {} 43 self.callables = callables 44 45 def get_callable(self, key): 46 return self.callables.get(key, lambda: None) 47 48 def get(self, key, reset=False): 49 50 """ 51 Return state for the given 'key', using the configured callable to 52 compute and set the state if no state is already defined. 53 54 If 'reset' is set to a true value, compute and return the state using 55 the configured callable regardless of any existing state. 56 """ 57 58 if reset or not self.state.has_key(key): 59 self.state[key] = self.get_callable(key)() 60 61 return self.state[key] 62 63 def set(self, key, value): 64 self.state[key] = value 65 66 def __getitem__(self, key): 67 return self.get(key) 68 69 def __setitem__(self, key, value): 70 self.set(key, value) 71 72 def has_changed(self, key): 73 return self.get_callable(key)() != self.get(key) 74 75 76 77 # Period-related abstractions. 78 79 class PeriodError(Exception): 80 pass 81 82 class EventPeriod(RecurringPeriod): 83 84 """ 85 A simple period plus attribute details, compatible with RecurringPeriod, and 86 intended to represent information obtained from an iCalendar resource. 87 """ 88 89 def __init__(self, start, end, tzid=None, origin=None, start_attr=None, 90 end_attr=None, form_start=None, form_end=None, replaced=False): 91 92 """ 93 Initialise a period with the given 'start' and 'end' datetimes, together 94 with optional 'start_attr' and 'end_attr' metadata, 'form_start' and 95 'form_end' values provided as textual input, and with an optional 96 'origin' indicating the kind of period this object describes. 97 """ 98 99 RecurringPeriod.__init__(self, start, end, tzid, origin, start_attr, end_attr) 100 self.form_start = form_start 101 self.form_end = form_end 102 self.replaced = replaced 103 104 def as_tuple(self): 105 return self.start, self.end, self.tzid, self.origin, self.start_attr, \ 106 self.end_attr, self.form_start, self.form_end, self.replaced 107 108 def __repr__(self): 109 return "EventPeriod%r" % (self.as_tuple(),) 110 111 def as_event_period(self): 112 return self 113 114 def get_start_item(self): 115 return self.get_start(), self.get_start_attr() 116 117 def get_end_item(self): 118 return self.get_end(), self.get_end_attr() 119 120 # Form data compatibility methods. 121 122 def get_form_start(self): 123 if not self.form_start: 124 self.form_start = self.get_form_date(self.get_start(), self.start_attr) 125 return self.form_start 126 127 def get_form_end(self): 128 if not self.form_end: 129 self.form_end = self.get_form_date(end_date_from_calendar(self.get_end()), self.end_attr) 130 return self.form_end 131 132 def as_form_period(self): 133 return FormPeriod( 134 self.get_form_start(), 135 self.get_form_end(), 136 isinstance(self.end, datetime) or self.get_start() != self.get_end() - timedelta(1), 137 isinstance(self.start, datetime) or isinstance(self.end, datetime), 138 self.tzid, 139 self.origin, 140 self.replaced and True or False, 141 format_datetime(self.get_start_point()) 142 ) 143 144 def get_form_date(self, dt, attr=None): 145 return FormDate( 146 format_datetime(to_date(dt)), 147 isinstance(dt, datetime) and str(dt.hour) or None, 148 isinstance(dt, datetime) and str(dt.minute) or None, 149 isinstance(dt, datetime) and str(dt.second) or None, 150 attr and attr.get("TZID") or None, 151 dt, attr 152 ) 153 154 class FormPeriod(RecurringPeriod): 155 156 "A period whose information originates from a form." 157 158 def __init__(self, start, end, end_enabled=True, times_enabled=True, 159 tzid=None, origin=None, replaced=False, recurrenceid=None): 160 self.start = start 161 self.end = end 162 self.end_enabled = end_enabled 163 self.times_enabled = times_enabled 164 self.tzid = tzid 165 self.origin = origin 166 self.replaced = replaced 167 self.recurrenceid = recurrenceid 168 169 def as_tuple(self): 170 return self.start, self.end, self.end_enabled, self.times_enabled, self.tzid, self.origin, self.replaced, self.recurrenceid 171 172 def __repr__(self): 173 return "FormPeriod%r" % (self.as_tuple(),) 174 175 def is_changed(self): 176 return not self.recurrenceid or format_datetime(self.get_start_point()) != self.recurrenceid 177 178 def as_event_period(self, index=None): 179 180 """ 181 Return a converted version of this object as an event period suitable 182 for iCalendar usage. If 'index' is indicated, include it in any error 183 raised in the conversion process. 184 """ 185 186 dtstart, dtstart_attr = self.get_start_item() 187 if not dtstart: 188 if index is not None: 189 raise PeriodError(("dtstart", index)) 190 else: 191 raise PeriodError("dtstart") 192 193 dtend, dtend_attr = self.get_end_item() 194 if not dtend: 195 if index is not None: 196 raise PeriodError(("dtend", index)) 197 else: 198 raise PeriodError("dtend") 199 200 if dtstart > dtend: 201 if index is not None: 202 raise PeriodError(("dtstart", index), ("dtend", index)) 203 else: 204 raise PeriodError("dtstart", "dtend") 205 206 return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid, 207 self.origin, dtstart_attr, dtend_attr, 208 self.start, self.end, self.replaced) 209 210 # Period data methods. 211 212 def get_start(self): 213 return self.start and self.start.as_datetime(self.times_enabled) or None 214 215 def get_end(self): 216 217 # Handle specified end datetimes. 218 219 if self.end_enabled: 220 dtend = self.end.as_datetime(self.times_enabled) 221 if not dtend: 222 return None 223 224 # Handle same day times. 225 226 elif self.times_enabled: 227 formdate = FormDate(self.start.date, self.end.hour, self.end.minute, self.end.second, self.end.tzid) 228 dtend = formdate.as_datetime(self.times_enabled) 229 if not dtend: 230 return None 231 232 # Otherwise, treat the end date as the start date. Datetimes are 233 # handled by making the event occupy the rest of the day. 234 235 else: 236 dtstart, dtstart_attr = self.get_start_item() 237 if dtstart: 238 if isinstance(dtstart, datetime): 239 dtend = get_end_of_day(dtstart, dtstart_attr["TZID"]) 240 else: 241 dtend = dtstart 242 else: 243 return None 244 245 return dtend 246 247 def get_start_attr(self): 248 return self.start and self.start.get_attributes(self.times_enabled) or {} 249 250 def get_end_attr(self): 251 return self.end and self.end.get_attributes(self.times_enabled) or {} 252 253 # Form data methods. 254 255 def get_form_start(self): 256 return self.start 257 258 def get_form_end(self): 259 return self.end 260 261 def as_form_period(self): 262 return self 263 264 class FormDate: 265 266 "Date information originating from form information." 267 268 def __init__(self, date=None, hour=None, minute=None, second=None, tzid=None, dt=None, attr=None): 269 self.date = date 270 self.hour = hour 271 self.minute = minute 272 self.second = second 273 self.tzid = tzid 274 self.dt = dt 275 self.attr = attr 276 277 def as_tuple(self): 278 return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr 279 280 def __repr__(self): 281 return "FormDate%r" % (self.as_tuple(),) 282 283 def get_component(self, value): 284 return (value or "").rjust(2, "0")[:2] 285 286 def get_hour(self): 287 return self.get_component(self.hour) 288 289 def get_minute(self): 290 return self.get_component(self.minute) 291 292 def get_second(self): 293 return self.get_component(self.second) 294 295 def get_date_string(self): 296 return self.date or "" 297 298 def get_datetime_string(self): 299 if not self.date: 300 return "" 301 302 hour = self.hour; minute = self.minute; second = self.second 303 304 if hour or minute or second: 305 time = "T%s%s%s" % tuple(map(self.get_component, (hour, minute, second))) 306 else: 307 time = "" 308 309 return "%s%s" % (self.date, time) 310 311 def get_tzid(self): 312 return self.tzid 313 314 def as_datetime(self, with_time=True): 315 316 """ 317 Return a datetime for this object if one is provided or can be produced. 318 """ 319 320 # Return any original datetime details. 321 322 if self.dt: 323 return self.dt 324 325 # Otherwise, construct a datetime. 326 327 s, attr = self.as_datetime_item(with_time) 328 if not s: 329 return None 330 331 # An erroneous datetime will yield None as result. 332 333 try: 334 return get_datetime(s, attr) 335 except ValueError: 336 return None 337 338 def as_datetime_item(self, with_time=True): 339 340 """ 341 Return a (datetime string, attr) tuple for the datetime information 342 provided by this object, where both tuple elements will be None if no 343 suitable date or datetime information exists. 344 """ 345 346 s = None 347 if with_time: 348 s = self.get_datetime_string() 349 attr = self.get_attributes(True) 350 if not s: 351 s = self.get_date_string() 352 attr = self.get_attributes(False) 353 if not s: 354 return None, None 355 return s, attr 356 357 def get_attributes(self, with_time=True): 358 359 "Return attributes for the date or datetime represented by this object." 360 361 if with_time: 362 return {"TZID" : self.get_tzid(), "VALUE" : "DATE-TIME"} 363 else: 364 return {"VALUE" : "DATE"} 365 366 def event_period_from_period(period): 367 368 """ 369 Convert a 'period' to one suitable for use in an iCalendar representation. 370 In an "event period" representation, the end day of any date-level event is 371 encoded as the "day after" the last day actually involved in the event. 372 """ 373 374 if isinstance(period, EventPeriod): 375 return period 376 elif isinstance(period, FormPeriod): 377 return period.as_event_period() 378 else: 379 dtstart, dtstart_attr = period.get_start_item() 380 dtend, dtend_attr = period.get_end_item() 381 if not isinstance(period, RecurringPeriod): 382 dtend = end_date_to_calendar(dtend) 383 return EventPeriod(dtstart, dtend, period.tzid, period.origin, dtstart_attr, dtend_attr) 384 385 def form_period_from_period(period): 386 387 """ 388 Convert a 'period' into a representation usable in a user-editable form. 389 In a "form period" representation, the end day of any date-level event is 390 presented in a "natural" form, not the iCalendar "day after" form. 391 """ 392 393 if isinstance(period, EventPeriod): 394 return period.as_form_period() 395 elif isinstance(period, FormPeriod): 396 return period 397 else: 398 return event_period_from_period(period).as_form_period() 399 400 401 402 # Form period processing. 403 404 def get_existing_periods(periods, still_to_remove): 405 406 """ 407 Find all periods that existed before editing, given 'periods', applying 408 the periods in 'still_to_remove' and producing retained, replaced and 409 to-remove collections containing these existing periods. 410 """ 411 412 retained = [] 413 replaced = [] 414 to_remove = [] 415 416 for p in periods: 417 p = form_period_from_period(p) 418 if p.recurrenceid: 419 if p.replaced: 420 replaced.append(p) 421 elif p in still_to_remove: 422 to_remove.append(p) 423 else: 424 retained.append(p) 425 426 return retained, replaced, to_remove 427 428 def get_new_periods(periods): 429 430 "Return all periods introduced during editing, given 'periods'." 431 432 new = [] 433 for p in periods: 434 fp = form_period_from_period(p) 435 if not fp.recurrenceid: 436 new.append(p) 437 return new 438 439 def get_changed_periods(periods): 440 441 "Return changed and unchanged periods, given 'periods'." 442 443 changed = [] 444 unchanged = [] 445 446 for p in periods: 447 fp = form_period_from_period(p) 448 if fp.is_changed(): 449 changed.append(p) 450 else: 451 unchanged.append(p) 452 453 return changed, unchanged 454 455 def classify_periods(periods, still_to_remove): 456 457 """ 458 From the recurrence 'periods', given details of those 'still_to_remove', 459 return a tuple containing collections of new, changed, unchanged, replaced 460 and to-be-removed periods. 461 """ 462 463 retained, replaced, to_remove = get_existing_periods(periods, still_to_remove) 464 465 # Filter new periods with the existing period information. 466 467 new = set(get_new_periods(periods)) 468 469 new.difference_update(retained) 470 new.difference_update(replaced) 471 new.difference_update(to_remove) 472 473 # Divide retained periods into changed and unchanged collections. 474 475 changed, unchanged = get_changed_periods(retained) 476 477 return list(new), changed, unchanged, replaced, to_remove 478 479 480 481 # Form field extraction and serialisation. 482 483 def get_date_control_inputs(args, name, tzid_name=None): 484 485 """ 486 Return a tuple of date control inputs taken from 'args' for field names 487 starting with 'name'. 488 489 If 'tzid_name' is specified, the time zone information will be acquired 490 from fields starting with 'tzid_name' instead of 'name'. 491 """ 492 493 return args.get("%s-date" % name, []), \ 494 args.get("%s-hour" % name, []), \ 495 args.get("%s-minute" % name, []), \ 496 args.get("%s-second" % name, []), \ 497 args.get("%s-tzid" % (tzid_name or name), []) 498 499 def get_date_control_values(args, name, multiple=False, tzid_name=None, tzid=None): 500 501 """ 502 Return a form date object representing fields taken from 'args' starting 503 with 'name'. 504 505 If 'multiple' is set to a true value, many date objects will be returned 506 corresponding to a collection of datetimes. 507 508 If 'tzid_name' is specified, the time zone information will be acquired 509 from fields starting with 'tzid_name' instead of 'name'. 510 511 If 'tzid' is specified, it will provide the time zone where no explicit 512 time zone information is indicated in the field data. 513 """ 514 515 dates, hours, minutes, seconds, tzids = get_date_control_inputs(args, name, tzid_name) 516 517 # Handle absent values by employing None values. 518 519 field_values = map(None, dates, hours, minutes, seconds, tzids) 520 521 if not field_values and not multiple: 522 all_values = FormDate() 523 else: 524 all_values = [] 525 for date, hour, minute, second, tzid_field in field_values: 526 value = FormDate(date, hour, minute, second, tzid_field or tzid) 527 528 # Return a single value or append to a collection of all values. 529 530 if not multiple: 531 return value 532 else: 533 all_values.append(value) 534 535 return all_values 536 537 def set_date_control_values(formdates, args, name, tzid_name=None): 538 539 """ 540 Using the values of the given 'formdates', replace form fields in 'args' 541 starting with 'name'. 542 543 If 'tzid_name' is specified, the time zone information will be stored in 544 fields starting with 'tzid_name' instead of 'name'. 545 """ 546 547 args["%s-date" % name] = [] 548 args["%s-hour" % name] = [] 549 args["%s-minute" % name] = [] 550 args["%s-second" % name] = [] 551 args["%s-tzid" % (tzid_name or name)] = [] 552 553 for d in formdates: 554 args["%s-date" % name].append(d and d.date or "") 555 args["%s-hour" % name].append(d and d.hour or "") 556 args["%s-minute" % name].append(d and d.minute or "") 557 args["%s-second" % name].append(d and d.second or "") 558 args["%s-tzid" % (tzid_name or name)].append(d and d.tzid or "") 559 560 def get_period_control_values(args, start_name, end_name, 561 end_enabled_name, times_enabled_name, 562 origin=None, origin_name=None, 563 replaced_name=None, recurrenceid_name=None, 564 tzid=None): 565 566 """ 567 Return period values from fields found in 'args' prefixed with the given 568 'start_name' (for start dates), 'end_name' (for end dates), 569 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 570 (to enable times for periods). 571 572 If 'origin' is specified, a single period with the given origin is 573 returned. If 'origin_name' is specified, fields containing the name will 574 provide origin information, fields containing 'replaced_name' will indicate 575 periods that are replaced, and fields containing 'recurrenceid_name' will 576 indicate periods that have existing recurrence details from an event. 577 578 If 'tzid' is specified, it will provide the time zone where no explicit 579 time zone information is indicated in the field data. 580 """ 581 582 # Get the end datetime and time presence settings. 583 584 all_end_enabled = args.get(end_enabled_name, []) 585 all_times_enabled = args.get(times_enabled_name, []) 586 587 # Get the origins of period data and whether the periods are replaced. 588 589 if origin: 590 all_origins = [origin] 591 else: 592 all_origins = origin_name and args.get(origin_name, []) or [] 593 594 all_replaced = replaced_name and args.get(replaced_name, []) or [] 595 all_recurrenceids = recurrenceid_name and args.get(recurrenceid_name, []) or [] 596 597 # Get the start and end datetimes. 598 599 all_starts = get_date_control_values(args, start_name, True, tzid=tzid) 600 all_ends = get_date_control_values(args, end_name, True, start_name, tzid=tzid) 601 602 # Construct period objects for each start, end, origin combination. 603 604 periods = [] 605 606 for index, (start, end, found_origin, recurrenceid) in \ 607 enumerate(map(None, all_starts, all_ends, all_origins, all_recurrenceids)): 608 609 # Obtain period settings from separate controls. 610 611 end_enabled = str(index) in all_end_enabled 612 times_enabled = str(index) in all_times_enabled 613 replaced = str(index) in all_replaced 614 615 period = FormPeriod(start, end, end_enabled, times_enabled, tzid, 616 found_origin or origin, replaced, recurrenceid) 617 periods.append(period) 618 619 # Return a single period if a single origin was specified. 620 621 if origin: 622 return periods[0] 623 else: 624 return periods 625 626 def set_period_control_values(periods, args, start_name, end_name, 627 end_enabled_name, times_enabled_name, 628 origin_name=None, replaced_name=None, 629 recurrenceid_name=None): 630 631 """ 632 Using the given 'periods', replace form fields in 'args' prefixed with the 633 given 'start_name' (for start dates), 'end_name' (for end dates), 634 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 635 (to enable times for periods). 636 637 If 'origin_name' is specified, fields containing the name will provide 638 origin information, fields containing 'replaced_name' will indicate periods 639 that are replaced, and fields containing 'recurrenceid_name' will indicate 640 periods that have existing recurrence details from an event. 641 """ 642 643 # Record period settings separately. 644 645 args[end_enabled_name] = [] 646 args[times_enabled_name] = [] 647 648 # Record origin and replacement information if naming is defined. 649 650 if origin_name: 651 args[origin_name] = [] 652 653 if replaced_name: 654 args[replaced_name] = [] 655 656 if recurrenceid_name: 657 args[recurrenceid_name] = [] 658 659 all_starts = [] 660 all_ends = [] 661 662 for index, period in enumerate(periods): 663 664 # Encode period settings in controls. 665 666 if period.end_enabled: 667 args[end_enabled_name].append(str(index)) 668 if period.times_enabled: 669 args[times_enabled_name].append(str(index)) 670 671 # Add origin information where controls are present to record it. 672 673 if origin_name: 674 args[origin_name].append(period.origin or "") 675 676 # Add replacement information where controls are present to record it. 677 678 if replaced_name and period.replaced: 679 args[replaced_name].append(str(index)) 680 681 # Add recurrence identifiers where controls are present to record it. 682 683 if recurrenceid_name: 684 args[recurrenceid_name].append(period.recurrenceid or "") 685 686 # Collect form date information for addition below. 687 688 all_starts.append(period.get_form_start()) 689 all_ends.append(period.get_form_end()) 690 691 # Set the controls for the dates. 692 693 set_date_control_values(all_starts, args, start_name) 694 set_date_control_values(all_ends, args, end_name, tzid_name=start_name) 695 696 697 698 # Utilities. 699 700 def filter_duplicates(l): 701 702 """ 703 Return collection 'l' filtered for duplicate values, retaining the given 704 element ordering. 705 """ 706 707 s = set() 708 f = [] 709 710 for value in l: 711 if value not in s: 712 s.add(value) 713 f.append(value) 714 715 return f 716 717 def remove_from_collection(l, indexes, fn): 718 719 """ 720 Remove from collection 'l' all values present at the given 'indexes' where 721 'fn' applied to each referenced value returns a true value. Values where 722 'fn' returns a false value are added to a list of deferred removals which is 723 returned. 724 """ 725 726 still_to_remove = [] 727 correction = 0 728 729 for i in indexes: 730 try: 731 i = int(i) - correction 732 value = l[i] 733 except (IndexError, ValueError): 734 continue 735 736 if fn(value): 737 del l[i] 738 correction += 1 739 else: 740 still_to_remove.append(value) 741 742 return still_to_remove 743 744 # vim: tabstop=4 expandtab shiftwidth=4