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