1 #!/usr/bin/env python 2 3 """ 4 User 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 collections import OrderedDict 23 from copy import copy 24 from datetime import datetime, timedelta 25 from imiptools.client import ClientForObject 26 from imiptools.data import get_main_period 27 from imiptools.dates import end_date_from_calendar, end_date_to_calendar, \ 28 format_datetime, get_datetime, \ 29 get_datetime_attributes, get_end_of_day, \ 30 to_date, to_utc_datetime, to_timezone 31 from imiptools.period import get_overlapping_members, RecurringPeriod 32 from itertools import chain 33 34 # General editing abstractions. 35 36 class State: 37 38 "Manage editing state." 39 40 def __init__(self, callables): 41 42 """ 43 Define state variable initialisation using the given 'callables', which 44 is a mapping that defines a callable for each variable name that is 45 invoked when the variable is first requested. 46 """ 47 48 self.state = {} 49 self.original = {} 50 self.callables = callables 51 52 def get_callable(self, key): 53 return self.callables.get(key, lambda: None) 54 55 def ensure_original(self, key): 56 57 "Ensure the original state for the given 'key'." 58 59 if not self.original.has_key(key): 60 self.original[key] = self.get_callable(key)() 61 62 def get_original(self, key): 63 64 "Return the original state for the given 'key'." 65 66 self.ensure_original(key) 67 return copy(self.original[key]) 68 69 def get(self, key, reset=False): 70 71 """ 72 Return state for the given 'key', using the configured callable to 73 compute and set the state if no state is already defined. 74 75 If 'reset' is set to a true value, compute and return the state using 76 the configured callable regardless of any existing state. 77 """ 78 79 if reset or not self.state.has_key(key): 80 self.state[key] = self.get_original(key) 81 82 return self.state[key] 83 84 def set(self, key, value): 85 86 "Set the state of 'key' to 'value'." 87 88 self.ensure_original(key) 89 self.state[key] = value 90 91 def has_changed(self, key): 92 93 "Return whether 'key' has changed during editing." 94 95 return self.get_original(key) != self.get(key) 96 97 # Dictionary emulation methods. 98 99 def __getitem__(self, key): 100 return self.get(key) 101 102 def __setitem__(self, key, value): 103 self.set(key, value) 104 105 106 107 # Object editing abstractions. 108 109 class EditingClient(ClientForObject): 110 111 "A simple calendar client." 112 113 def __init__(self, user, messenger, store, preferences_dir): 114 ClientForObject.__init__(self, None, user, messenger, store, 115 preferences_dir=preferences_dir) 116 self.reset() 117 118 # Editing state. 119 120 def reset(self): 121 122 "Reset the editing state." 123 124 self.state = State({ 125 "attendees" : lambda: OrderedDict(self.obj.get_items("ATTENDEE") or []), 126 "organiser" : lambda: self.obj.get_value("ORGANIZER"), 127 "periods" : lambda: form_periods_from_periods(self.get_unedited_periods()), 128 "suggested_attendees" : self.get_suggested_attendees, 129 "suggested_periods" : self.get_suggested_periods, 130 "summary" : lambda: self.obj.get_value("SUMMARY"), 131 }) 132 133 # Access to stored and current information. 134 135 def get_stored_periods(self): 136 137 """ 138 Return the stored, unrevised, integral periods for the event, excluding 139 revisions from separate recurrence instances. 140 """ 141 142 return event_periods_from_periods(self.get_periods()) 143 144 def get_unedited_periods(self): 145 146 """ 147 Return the original, unedited periods including revisions from separate 148 recurrence instances. 149 """ 150 151 return event_periods_from_updated_periods(self.get_updated_periods()) 152 153 def get_counters(self): 154 155 "Return a counter-proposal mapping from attendees to objects." 156 157 # Get counter-proposals for the specific object. 158 159 attendees = self.store.get_counters(self.user, self.uid, self.recurrenceid) 160 d = {} 161 162 for attendee in attendees: 163 if not d.has_key(attendee): 164 d[attendee] = [] 165 d[attendee].append(self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee)) 166 167 return d 168 169 def get_suggested_attendees(self): 170 171 "For all counter-proposals, return suggested attendee items." 172 173 existing = self.state.get("attendees") 174 l = [] 175 for attendee, objects in self.get_counters().items(): 176 for obj in objects: 177 for suggested, attr in obj.get_items("ATTENDEE"): 178 if suggested not in existing: 179 l.append((attendee, (suggested, attr))) 180 return l 181 182 def get_suggested_periods(self): 183 184 "For all counter-proposals, return suggested event periods." 185 186 existing = self.state.get("periods") 187 188 # Get active periods for filtering of suggested periods. 189 190 active = [] 191 for p in existing: 192 if not p.cancelled: 193 active.append(p) 194 195 suggested = [] 196 197 for attendee, objects in self.get_counters().items(): 198 199 # For each object, obtain suggested periods. 200 201 for obj in objects: 202 203 # Obtain the current periods for the object providing the 204 # suggested periods. 205 206 updated = self.get_updated_periods(obj) 207 suggestions = event_periods_from_updated_periods(updated) 208 209 # Compare current periods with suggested periods. 210 211 new = set(suggestions).difference(active) 212 213 # Treat each specific recurrence as affecting only the original 214 # period. 215 216 if obj.get_recurrenceid(): 217 removed = [] 218 else: 219 removed = set(active).difference(suggestions) 220 221 # Associate new and removed periods with the attendee. 222 223 for period in new: 224 suggested.append((attendee, period, "add")) 225 226 for period in removed: 227 suggested.append((attendee, period, "remove")) 228 229 return suggested 230 231 # Validation methods. 232 233 def get_checked_periods(self): 234 235 """ 236 Check the edited periods and return objects representing them, setting 237 the "periods" state. If errors occur, raise an exception and set the 238 "errors" state. 239 """ 240 241 self.state["period_errors"] = errors = {} 242 243 # Basic validation. 244 245 try: 246 periods = event_periods_from_periods(self.state.get("periods")) 247 248 except PeriodError, exc: 249 250 # Obtain error and period index details from the exception, 251 # collecting errors for each index position. 252 253 for err, index in exc.args: 254 l = errors.get(index) 255 if not l: 256 l = errors[index] = [] 257 l.append(err) 258 raise 259 260 # Check for overlapping periods. 261 262 overlapping = get_overlapping_members(periods) 263 264 for period in overlapping: 265 for index, p in enumerate(periods): 266 if period is p: 267 errors[index] = ["overlap"] 268 269 if overlapping: 270 raise PeriodError 271 272 self.state["periods"] = form_periods_from_periods(periods) 273 return periods 274 275 # Update result computation. 276 277 def classify_attendee_changes(self): 278 279 "Classify the attendees in the event." 280 281 original = self.state.get_original("attendees") 282 current = self.state.get("attendees") 283 return classify_attendee_changes(original, current) 284 285 def classify_attendee_operations(self): 286 287 "Classify attendee update operations." 288 289 new, modified, unmodified, removed = self.classify_attendee_changes() 290 291 if self.is_organiser(): 292 to_invite = new 293 to_cancel = removed 294 to_modify = modified 295 else: 296 to_invite = new 297 to_cancel = {} 298 to_modify = modified 299 300 return to_invite, to_cancel, to_modify 301 302 def classify_period_changes(self): 303 304 "Classify changes in the updated periods for the edited event." 305 306 updated = self.combine_periods_for_comparison() 307 return classify_period_changes(updated) 308 309 def classify_periods(self): 310 311 "Classify the updated periods for the edited event." 312 313 updated = self.combine_periods() 314 return classify_periods(updated) 315 316 def combine_periods(self): 317 318 "Combine stored and checked edited periods to make updated periods." 319 320 stored = self.get_stored_periods() 321 current = self.get_checked_periods() 322 return combine_periods(stored, current) 323 324 def combine_periods_for_comparison(self): 325 326 "Combine unedited and checked edited periods to make updated periods." 327 328 original = self.get_unedited_periods() 329 current = self.get_checked_periods() 330 return combine_periods(original, current) 331 332 def classify_period_operations(self, is_changed=False): 333 334 "Classify period update operations." 335 336 new, replaced, retained, cancelled = self.classify_periods() 337 338 modified, unmodified, removed = self.classify_period_changes() 339 340 is_organiser = self.is_organiser() 341 is_shared = self.obj.is_shared() 342 343 return classify_period_operations(new, replaced, retained, cancelled, 344 modified, removed, 345 is_organiser, is_shared, is_changed) 346 347 def properties_changed(self): 348 349 "Test for changes in event details." 350 351 is_changed = [] 352 353 for name in ["summary"]: 354 if self.state.has_changed(name): 355 is_changed.append(name) 356 357 return is_changed 358 359 def finish(self): 360 361 "Finish editing, writing edited details to the object." 362 363 if self.state.get("finished"): 364 return 365 366 is_changed = self.properties_changed() 367 368 # Determine attendee modifications. 369 370 self.state["attendee_operations"] = \ 371 to_invite, to_cancel, to_modify = \ 372 self.classify_attendee_operations() 373 374 self.state["attendees_to_cancel"] = to_cancel 375 376 # Determine period modification operations. 377 # Use property changes and attendee suggestions to affect the result for 378 # attendee responses. 379 380 is_changed = is_changed or to_invite 381 382 self.state["period_operations"] = \ 383 to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ 384 all_unscheduled, all_rescheduled = \ 385 self.classify_period_operations(is_changed) 386 387 # Determine whole event update status. 388 389 is_changed = is_changed or to_set 390 391 # Update event details. 392 393 if self.can_edit_properties(): 394 self.obj.set_value("SUMMARY", self.state.get("summary")) 395 396 self.update_attendees(to_invite, to_cancel, to_modify) 397 self.update_event_from_periods(to_set, to_exclude) 398 399 # Classify the nature of any update. 400 401 if is_changed: 402 self.state["changed"] = "complete" 403 elif to_reschedule or to_unschedule or to_add: 404 self.state["changed"] = "incremental" 405 406 self.state["finished"] = self.update_event_version(is_changed) 407 408 # Update preparation. 409 410 def have_update(self): 411 412 "Return whether an update can be prepared and sent." 413 414 return not self.is_organiser() or \ 415 not self.obj.is_shared() or \ 416 self.obj.is_shared() and self.state.get("changed") and \ 417 self.have_other_attendees() 418 419 def have_other_attendees(self): 420 421 "Return whether any attendees other than the user are present." 422 423 attendees = self.state.get("attendees") 424 return attendees and (not attendees.has_key(self.user) or len(attendees.keys()) > 1) 425 426 def prepare_cancel_message(self): 427 428 "Prepare the cancel message for uninvited attendees." 429 430 to_cancel = self.state.get("attendees_to_cancel") 431 return self.make_cancel_message(to_cancel) 432 433 def prepare_publish_message(self): 434 435 "Prepare the publishing message for the updated event." 436 437 to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ 438 all_unscheduled, all_rescheduled = self.state.get("period_operations") 439 440 return self.make_self_update_message(all_unscheduled, all_rescheduled, to_add) 441 442 def prepare_update_message(self): 443 444 "Prepare the update message for the updated event." 445 446 if not self.have_update(): 447 return None 448 449 # Obtain operation details. 450 451 to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ 452 all_unscheduled, all_rescheduled = self.state.get("period_operations") 453 454 # Prepare the message. 455 456 recipients = self.get_recipients() 457 update_parent = self.state["changed"] == "complete" 458 459 if self.is_organiser(): 460 return self.make_update_message(recipients, update_parent, 461 to_unschedule, to_reschedule, 462 all_unscheduled, all_rescheduled, 463 to_add) 464 else: 465 return self.make_response_message(recipients, update_parent, 466 all_rescheduled, to_reschedule) 467 468 # Modification methods. 469 470 def add_attendee(self, uri=None): 471 472 "Add a blank attendee." 473 474 attendees = self.state.get("attendees") 475 attendees[uri or ""] = {"PARTSTAT" : "NEEDS-ACTION"} 476 477 def add_suggested_attendee(self, index): 478 479 "Add the suggested attendee at 'index' to the event." 480 481 attendees = self.state.get("attendees") 482 suggested_attendees = self.state.get("suggested_attendees") 483 try: 484 attendee, (suggested, attr) = suggested_attendees[index] 485 self.add_attendee(suggested) 486 except IndexError: 487 pass 488 489 def add_period(self): 490 491 "Add a copy of the main period as a new recurrence." 492 493 current = self.state.get("periods") 494 new = get_main_period(current).copy() 495 new.origin = "RDATE" 496 new.replacement = False 497 new.recurrenceid = False 498 new.cancelled = False 499 current.append(new) 500 501 def apply_suggested_period(self, index): 502 503 "Apply the suggested period at 'index' to the event." 504 505 current = self.state.get("periods") 506 suggested = self.state.get("suggested_periods") 507 508 try: 509 attendee, period, operation = suggested[index] 510 period = form_period_from_period(period) 511 512 # Cancel any removed periods. 513 514 if operation == "remove": 515 for index, p in enumerate(current): 516 if p == period: 517 self.cancel_periods([index]) 518 break 519 520 # Add or replace any other suggestions. 521 522 elif operation == "add": 523 524 # Make the status of the period compatible. 525 526 period.cancelled = False 527 period.origin = "DTSTART-RECUR" 528 529 # Either replace or add the period. 530 531 recurrenceid = period.get_recurrenceid() 532 533 for i, p in enumerate(current): 534 if p.get_recurrenceid() == recurrenceid: 535 current[i] = period 536 break 537 538 # Add as a new period. 539 540 else: 541 period.recurrenceid = None 542 current.append(period) 543 544 except IndexError: 545 pass 546 547 def cancel_periods(self, indexes, cancelled=True): 548 549 """ 550 Set cancellation state for periods with the given 'indexes', indicating 551 'cancelled' as a true or false value. New periods will be removed if 552 cancelled. 553 """ 554 555 periods = self.state.get("periods") 556 to_remove = [] 557 removed = 0 558 559 for index in indexes: 560 p = periods[index] 561 562 # Make replacements from existing periods and cancel them. 563 564 if p.recurrenceid: 565 p.replacement = True 566 p.cancelled = cancelled 567 568 # Remove new periods completely. 569 570 elif cancelled: 571 to_remove.append(index - removed) 572 removed += 1 573 574 for index in to_remove: 575 del periods[index] 576 577 def can_edit_attendance(self): 578 579 "Return whether the organiser's attendance can be edited." 580 581 return self.state.get("attendees").has_key(self.user) 582 583 def edit_attendance(self, partstat): 584 585 "Set the 'partstat' of the current user, if attending." 586 587 attendees = self.state.get("attendees") 588 attr = attendees.get(self.user) 589 590 # Set the attendance for the user, if attending. 591 592 if attr is not None: 593 new_attr = {} 594 new_attr.update(attr) 595 new_attr["PARTSTAT"] = partstat 596 attendees[self.user] = new_attr 597 598 def can_edit_attendee(self, index): 599 600 """ 601 Return whether the attendee at 'index' can be edited, requiring either 602 the organiser and an unshared event, or a new attendee. 603 """ 604 605 attendees = self.state.get("attendees") 606 attendee = attendees.keys()[index] 607 608 try: 609 attr = attendees[attendee] 610 if self.is_organiser() and not self.obj.is_shared() or not attr: 611 return (attendee, attr) 612 except IndexError: 613 pass 614 615 return None 616 617 def can_remove_attendee(self, index): 618 619 """ 620 Return whether the attendee at 'index' can be removed, requiring either 621 the organiser or a new attendee. 622 """ 623 624 attendees = self.state.get("attendees") 625 attendee = attendees.keys()[index] 626 627 try: 628 attr = attendees[attendee] 629 if self.is_organiser() or not attr: 630 return (attendee, attr) 631 except IndexError: 632 pass 633 634 return None 635 636 def remove_attendees(self, indexes): 637 638 "Remove attendee at 'index'." 639 640 attendees = self.state.get("attendees") 641 to_remove = [] 642 643 for index in indexes: 644 attendee_item = self.can_remove_attendee(index) 645 if attendee_item: 646 attendee, attr = attendee_item 647 to_remove.append(attendee) 648 649 for key in to_remove: 650 del attendees[key] 651 652 def can_edit_period(self, index): 653 654 """ 655 Return the period at 'index' for editing or None if it cannot be edited. 656 """ 657 658 try: 659 return self.state.get("periods")[index] 660 except IndexError: 661 return None 662 663 def can_edit_properties(self): 664 665 "Return whether general event properties can be edited." 666 667 return True 668 669 670 671 # Period-related abstractions. 672 673 class PeriodError(Exception): 674 pass 675 676 class EditablePeriod(RecurringPeriod): 677 678 "An editable period tracking the identity of any original period." 679 680 def _get_recurrenceid_item(self): 681 682 # Convert any stored identifier to the current time zone. 683 # NOTE: This should not be necessary, but is done for consistency with 684 # NOTE: the datetime properties. 685 686 dt = get_datetime(self.recurrenceid) 687 dt = to_timezone(dt, self.tzid) 688 return dt, get_datetime_attributes(dt) 689 690 def get_recurrenceid(self): 691 692 """ 693 Return a recurrence identity to be used to associate stored periods with 694 edited periods. 695 """ 696 697 if not self.recurrenceid: 698 return RecurringPeriod.get_recurrenceid(self) 699 return self.recurrenceid 700 701 def get_recurrenceid_item(self): 702 703 """ 704 Return a recurrence identifier value and datetime properties for use in 705 specifying the RECURRENCE-ID property. 706 """ 707 708 if not self.recurrenceid: 709 return RecurringPeriod.get_recurrenceid_item(self) 710 return self._get_recurrenceid_item() 711 712 class EventPeriod(EditablePeriod): 713 714 """ 715 A simple period plus attribute details, compatible with RecurringPeriod, and 716 intended to represent information obtained from an iCalendar resource. 717 """ 718 719 def __init__(self, start, end, tzid=None, origin=None, start_attr=None, 720 end_attr=None, form_start=None, form_end=None, 721 replacement=False, cancelled=False, recurrenceid=None): 722 723 """ 724 Initialise a period with the given 'start' and 'end' datetimes. 725 726 The optional 'tzid' provides time zone information, and the optional 727 'origin' indicates the kind of period this object describes. 728 729 The optional 'start_attr' and 'end_attr' provide metadata for the start 730 and end datetimes respectively, and 'form_start' and 'form_end' are 731 values provided as textual input. 732 733 The 'replacement' flag indicates whether the period is provided by a 734 separate recurrence instance. 735 736 The 'cancelled' flag indicates whether a separate recurrence is 737 cancelled. 738 739 The 'recurrenceid' describes the original identity of the period, 740 regardless of whether it is separate or not. 741 """ 742 743 EditablePeriod.__init__(self, start, end, tzid, origin, start_attr, end_attr) 744 self.form_start = form_start 745 self.form_end = form_end 746 747 # Information about whether a separate recurrence provides this period 748 # and the original period identity. 749 750 self.replacement = replacement 751 self.cancelled = cancelled 752 self.recurrenceid = recurrenceid 753 754 # Additional editing state. 755 756 self.new_replacement = False 757 758 def as_tuple(self): 759 return self.start, self.end, self.tzid, self.origin, self.start_attr, \ 760 self.end_attr, self.form_start, self.form_end, self.replacement, \ 761 self.cancelled, self.recurrenceid 762 763 def __repr__(self): 764 return "EventPeriod%r" % (self.as_tuple(),) 765 766 def copy(self): 767 return EventPeriod(*self.as_tuple()) 768 769 def as_event_period(self, index=None): 770 return self 771 772 def get_start_item(self): 773 return self.get_start(), self.get_start_attr() 774 775 def get_end_item(self): 776 return self.get_end(), self.get_end_attr() 777 778 # Form data compatibility methods. 779 780 def get_form_start(self): 781 if not self.form_start: 782 self.form_start = self.get_form_date(self.get_start(), self.start_attr) 783 return self.form_start 784 785 def get_form_end(self): 786 if not self.form_end: 787 self.form_end = self.get_form_date(end_date_from_calendar(self.get_end()), self.end_attr) 788 return self.form_end 789 790 def as_form_period(self): 791 return FormPeriod( 792 self.get_form_start(), 793 self.get_form_end(), 794 isinstance(self.end, datetime) or self.get_start() != self.get_end() - timedelta(1), 795 isinstance(self.start, datetime) or isinstance(self.end, datetime), 796 self.tzid, 797 self.origin, 798 self.replacement, 799 self.cancelled, 800 self.recurrenceid 801 ) 802 803 def get_form_date(self, dt, attr=None): 804 return FormDate( 805 format_datetime(to_date(dt)), 806 isinstance(dt, datetime) and str(dt.hour) or None, 807 isinstance(dt, datetime) and str(dt.minute) or None, 808 isinstance(dt, datetime) and str(dt.second) or None, 809 attr and attr.get("TZID") or None, 810 dt, attr 811 ) 812 813 class FormPeriod(EditablePeriod): 814 815 "A period whose information originates from a form." 816 817 def __init__(self, start, end, end_enabled=True, times_enabled=True, 818 tzid=None, origin=None, replacement=False, cancelled=False, 819 recurrenceid=None): 820 self.start = start 821 self.end = end 822 self.end_enabled = end_enabled 823 self.times_enabled = times_enabled 824 self.tzid = tzid 825 self.origin = origin 826 self.replacement = replacement 827 self.cancelled = cancelled 828 self.recurrenceid = recurrenceid 829 self.new_replacement = False 830 831 def as_tuple(self): 832 return self.start, self.end, self.end_enabled, self.times_enabled, \ 833 self.tzid, self.origin, self.replacement, self.cancelled, \ 834 self.recurrenceid 835 836 def __repr__(self): 837 return "FormPeriod%r" % (self.as_tuple(),) 838 839 def copy(self): 840 args = (self.start.copy(), self.end.copy()) + self.as_tuple()[2:] 841 return FormPeriod(*args) 842 843 def as_event_period(self, index=None): 844 845 """ 846 Return a converted version of this object as an event period suitable 847 for iCalendar usage. If 'index' is indicated, include it in any error 848 raised in the conversion process. 849 """ 850 851 dtstart, dtstart_attr = self.get_start_item() 852 if not dtstart: 853 if index is not None: 854 raise PeriodError(("dtstart", index)) 855 else: 856 raise PeriodError("dtstart") 857 858 dtend, dtend_attr = self.get_end_item() 859 if not dtend: 860 if index is not None: 861 raise PeriodError(("dtend", index)) 862 else: 863 raise PeriodError("dtend") 864 865 if dtstart > dtend: 866 if index is not None: 867 raise PeriodError(("dtstart", index), ("dtend", index)) 868 else: 869 raise PeriodError("dtstart", "dtend") 870 871 return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid, 872 self.origin, dtstart_attr, dtend_attr, 873 self.start, self.end, self.replacement, 874 self.cancelled, self.recurrenceid) 875 876 # Period data methods. 877 878 def get_start(self): 879 return self.start and self.start.as_datetime(self.times_enabled) or None 880 881 def get_end(self): 882 883 # Handle specified end datetimes. 884 885 if self.end_enabled: 886 dtend = self.end.as_datetime(self.times_enabled) 887 if not dtend: 888 return None 889 890 # Handle same day times. 891 892 elif self.times_enabled: 893 formdate = FormDate(self.start.date, self.end.hour, self.end.minute, self.end.second, self.end.tzid) 894 dtend = formdate.as_datetime(self.times_enabled) 895 if not dtend: 896 return None 897 898 # Otherwise, treat the end date as the start date. Datetimes are 899 # handled by making the event occupy the rest of the day. 900 901 else: 902 dtstart, dtstart_attr = self.get_start_item() 903 if dtstart: 904 if isinstance(dtstart, datetime): 905 dtend = get_end_of_day(dtstart, dtstart_attr["TZID"]) 906 else: 907 dtend = dtstart 908 else: 909 return None 910 911 return dtend 912 913 def get_start_attr(self): 914 return self.start and self.start.get_attributes(self.times_enabled) or {} 915 916 def get_end_attr(self): 917 return self.end and self.end.get_attributes(self.times_enabled) or {} 918 919 # Form data methods. 920 921 def get_form_start(self): 922 return self.start 923 924 def get_form_end(self): 925 return self.end 926 927 def as_form_period(self): 928 return self 929 930 class FormDate: 931 932 "Date information originating from form information." 933 934 def __init__(self, date=None, hour=None, minute=None, second=None, tzid=None, dt=None, attr=None): 935 self.date = date 936 self.hour = hour 937 self.minute = minute 938 self.second = second 939 self.tzid = tzid 940 self.dt = dt 941 self.attr = attr 942 943 def as_tuple(self): 944 return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr 945 946 def copy(self): 947 return FormDate(*self.as_tuple()) 948 949 def reset(self): 950 self.dt = None 951 952 def __repr__(self): 953 return "FormDate%r" % (self.as_tuple(),) 954 955 def get_component(self, value): 956 return (value or "").rjust(2, "0")[:2] 957 958 def get_hour(self): 959 return self.get_component(self.hour) 960 961 def get_minute(self): 962 return self.get_component(self.minute) 963 964 def get_second(self): 965 return self.get_component(self.second) 966 967 def get_date_string(self): 968 return self.date or "" 969 970 def get_datetime_string(self): 971 if not self.date: 972 return "" 973 974 hour = self.hour; minute = self.minute; second = self.second 975 976 if hour or minute or second: 977 time = "T%s%s%s" % tuple(map(self.get_component, (hour, minute, second))) 978 else: 979 time = "" 980 981 return "%s%s" % (self.date, time) 982 983 def get_tzid(self): 984 return self.tzid 985 986 def as_datetime(self, with_time=True): 987 988 """ 989 Return a datetime for this object if one is provided or can be produced. 990 """ 991 992 # Return any original datetime details. 993 994 if self.dt: 995 return self.dt 996 997 # Otherwise, construct a datetime. 998 999 s, attr = self.as_datetime_item(with_time) 1000 if not s: 1001 return None 1002 1003 # An erroneous datetime will yield None as result. 1004 1005 try: 1006 return get_datetime(s, attr) 1007 except ValueError: 1008 return None 1009 1010 def as_datetime_item(self, with_time=True): 1011 1012 """ 1013 Return a (datetime string, attr) tuple for the datetime information 1014 provided by this object, where both tuple elements will be None if no 1015 suitable date or datetime information exists. 1016 """ 1017 1018 s = None 1019 if with_time: 1020 s = self.get_datetime_string() 1021 attr = self.get_attributes(True) 1022 if not s: 1023 s = self.get_date_string() 1024 attr = self.get_attributes(False) 1025 if not s: 1026 return None, None 1027 return s, attr 1028 1029 def get_attributes(self, with_time=True): 1030 1031 "Return attributes for the date or datetime represented by this object." 1032 1033 if with_time: 1034 return {"TZID" : self.get_tzid(), "VALUE" : "DATE-TIME"} 1035 else: 1036 return {"VALUE" : "DATE"} 1037 1038 def event_period_from_period(period, index=None): 1039 1040 """ 1041 Convert a 'period' to one suitable for use in an iCalendar representation. 1042 In an "event period" representation, the end day of any date-level event is 1043 encoded as the "day after" the last day actually involved in the event. 1044 """ 1045 1046 if isinstance(period, EventPeriod): 1047 return period 1048 elif isinstance(period, FormPeriod): 1049 return period.as_event_period(index) 1050 else: 1051 dtstart, dtstart_attr = period.get_start_item() 1052 dtend, dtend_attr = period.get_end_item() 1053 1054 if not isinstance(period, RecurringPeriod): 1055 dtend = end_date_to_calendar(dtend) 1056 1057 return EventPeriod(dtstart, dtend, period.tzid, period.origin, 1058 dtstart_attr, dtend_attr, 1059 recurrenceid=format_datetime(to_utc_datetime(dtstart))) 1060 1061 def event_periods_from_periods(periods): 1062 return map(event_period_from_period, periods, range(0, len(periods))) 1063 1064 def form_period_from_period(period): 1065 1066 """ 1067 Convert a 'period' into a representation usable in a user-editable form. 1068 In a "form period" representation, the end day of any date-level event is 1069 presented in a "natural" form, not the iCalendar "day after" form. 1070 """ 1071 1072 if isinstance(period, EventPeriod): 1073 return period.as_form_period() 1074 elif isinstance(period, FormPeriod): 1075 return period 1076 else: 1077 return event_period_from_period(period).as_form_period() 1078 1079 def form_periods_from_periods(periods): 1080 return map(form_period_from_period, periods) 1081 1082 1083 1084 # Event period processing. 1085 1086 def periods_from_updated_periods(updated_periods, fn): 1087 1088 """ 1089 Return periods from the given 'updated_periods' created using 'fn', setting 1090 replacement, cancelled and recurrence identifier details. 1091 1092 This function should be used to produce editing-related periods from the 1093 general updated periods provided by the client abstractions. 1094 """ 1095 1096 periods = [] 1097 1098 for sp, p in updated_periods: 1099 1100 # Stored periods with corresponding current periods. 1101 1102 if p: 1103 period = fn(p) 1104 1105 # Replacements are identified by comparing object identities, since 1106 # a replacement will not be provided by the same object. 1107 1108 if sp is not p: 1109 period.replacement = True 1110 1111 # Stored periods without corresponding current periods. 1112 1113 else: 1114 period = fn(sp) 1115 period.replacement = True 1116 period.cancelled = True 1117 1118 # Replace the recurrence identifier with that of the original period. 1119 1120 period.recurrenceid = sp.get_recurrenceid() 1121 periods.append(period) 1122 1123 return periods 1124 1125 def event_periods_from_updated_periods(updated_periods): 1126 return periods_from_updated_periods(updated_periods, event_period_from_period) 1127 1128 def form_periods_from_updated_periods(updated_periods): 1129 return periods_from_updated_periods(updated_periods, form_period_from_period) 1130 1131 def periods_by_recurrence(periods): 1132 1133 """ 1134 Return a mapping from recurrence identifier to period for 'periods' along 1135 with a collection of unmapped periods. 1136 """ 1137 1138 d = {} 1139 new = [] 1140 1141 for p in periods: 1142 if not p.recurrenceid: 1143 new.append(p) 1144 else: 1145 d[p.recurrenceid] = p 1146 1147 return d, new 1148 1149 def combine_periods(old, new): 1150 1151 """ 1152 Combine 'old' and 'new' periods for comparison, making a list of (old, new) 1153 updated period tuples. 1154 """ 1155 1156 old_by_recurrenceid, _new_periods = periods_by_recurrence(old) 1157 new_by_recurrenceid, new_periods = periods_by_recurrence(new) 1158 1159 combined = [] 1160 1161 for recurrenceid, op in old_by_recurrenceid.items(): 1162 np = new_by_recurrenceid.get(recurrenceid) 1163 1164 # Old period has corresponding new period that is not cancelled. 1165 1166 if np and not (np.cancelled and not op.cancelled): 1167 combined.append((op, np)) 1168 1169 # No corresponding new, uncancelled period. 1170 1171 else: 1172 combined.append((op, None)) 1173 1174 # New periods without corresponding old periods are genuinely new. 1175 1176 for np in new_periods: 1177 combined.append((None, np)) 1178 1179 # Note that new periods should not have recurrence identifiers, and if 1180 # imported from other events, they should have such identifiers removed. 1181 1182 return combined 1183 1184 def classify_periods(updated_periods): 1185 1186 """ 1187 Using the 'updated_periods', being a list of (stored, current) periods, 1188 return a tuple containing collections of new, replaced, retained and 1189 cancelled periods. 1190 1191 Note that replaced and retained indicate the presence or absence of 1192 differences between the original event periods and the current periods that 1193 would need to be represented using separate recurrence instances, not 1194 whether any editing operations have changed the periods. 1195 """ 1196 1197 new = [] 1198 replaced = [] 1199 retained = [] 1200 cancelled = [] 1201 1202 for sp, p in updated_periods: 1203 1204 # Stored periods... 1205 1206 if sp: 1207 1208 # With cancelled or absent current periods. 1209 1210 if not p or p.cancelled: 1211 cancelled.append(sp) 1212 1213 # With differing or replacement current periods. 1214 1215 elif p != sp or p.replacement: 1216 replaced.append(p) 1217 if not p.replacement: 1218 p.new_replacement = True 1219 1220 # With retained, not differing current periods. 1221 1222 else: 1223 retained.append(p) 1224 if p.new_replacement: 1225 p.new_replacement = False 1226 1227 # New periods without corresponding stored periods. 1228 1229 elif p: 1230 new.append(p) 1231 1232 return new, replaced, retained, cancelled 1233 1234 def classify_period_changes(updated_periods): 1235 1236 """ 1237 Using the 'updated_periods', being a list of (original, current) periods, 1238 return a tuple containing collections of modified, unmodified and removed 1239 periods. 1240 """ 1241 1242 modified = [] 1243 unmodified = [] 1244 removed = [] 1245 1246 for op, p in updated_periods: 1247 1248 # Test for periods cancelled, reinstated or changed, or left unmodified 1249 # during editing. 1250 1251 if op: 1252 if not op.cancelled and (not p or p.cancelled): 1253 removed.append(op) 1254 elif op.cancelled and not p.cancelled or p != op: 1255 modified.append(p) 1256 else: 1257 unmodified.append(p) 1258 1259 # New periods are always modifications. 1260 1261 elif p: 1262 modified.append(p) 1263 1264 return modified, unmodified, removed 1265 1266 def classify_period_operations(new, replaced, retained, cancelled, 1267 modified, removed, 1268 is_organiser, is_shared, is_changed): 1269 1270 """ 1271 Classify the operations for the update of an event. For updates modifying 1272 shared events, return periods for descheduling and rescheduling (where these 1273 operations can modify the event), and periods for exclusion and application 1274 (where these operations redefine the event). 1275 1276 To define the new state of the event, details of the complete set of 1277 unscheduled and rescheduled periods are also provided. 1278 """ 1279 1280 active_periods = new + replaced + retained 1281 1282 # Modified replaced and retained recurrences are used for incremental 1283 # updates. 1284 1285 replaced_modified = select_recurrences(replaced, modified).values() 1286 retained_modified = select_recurrences(retained, modified).values() 1287 1288 # Unmodified replaced and retained recurrences are used in the complete 1289 # event summary. 1290 1291 replaced_unmodified = subtract_recurrences(replaced, modified).values() 1292 retained_unmodified = subtract_recurrences(retained, modified).values() 1293 1294 # Obtain the removed periods in terms of existing periods. These are used in 1295 # incremental updates. 1296 1297 cancelled_removed = select_recurrences(cancelled, removed).values() 1298 1299 # Cancelled periods originating from rules must be excluded since there are 1300 # no explicit instances to be deleted. 1301 1302 cancelled_rule = [] 1303 for p in cancelled_removed: 1304 if p.origin == "RRULE": 1305 cancelled_rule.append(p) 1306 1307 # Reinstated periods are previously-cancelled periods that are now modified 1308 # periods, and they appear in updates. 1309 1310 reinstated = select_recurrences(modified, cancelled).values() 1311 1312 # Get cancelled periods without reinstated periods. These appear in complete 1313 # event summaries. 1314 1315 cancelled_unmodified = subtract_recurrences(cancelled, modified).values() 1316 1317 # As organiser... 1318 1319 if is_organiser: 1320 1321 # For unshared events... 1322 # All modifications redefine the event. 1323 1324 # For shared events... 1325 # New periods should cause the event to be redefined. 1326 # Other changes should also cause event redefinition. 1327 # Event redefinition should only occur if no replacement periods exist. 1328 # Cancelled rule-originating periods must be excluded. 1329 1330 if not is_shared or new and not replaced: 1331 to_set = active_periods 1332 to_exclude = cancelled_rule 1333 to_unschedule = [] 1334 to_reschedule = [] 1335 to_add = [] 1336 all_unscheduled = [] 1337 all_rescheduled = [] 1338 1339 # Changed periods should be rescheduled separately. 1340 # Removed periods should be cancelled separately. 1341 1342 else: 1343 to_set = [] 1344 to_exclude = [] 1345 to_unschedule = cancelled_removed 1346 to_reschedule = list(chain(replaced_modified, retained_modified, reinstated)) 1347 to_add = new 1348 all_unscheduled = cancelled_unmodified 1349 all_rescheduled = list(chain(replaced_unmodified, to_reschedule)) 1350 1351 # As attendee... 1352 1353 else: 1354 to_unschedule = [] 1355 to_add = [] 1356 1357 # Changed periods without new or removed periods are proposed as 1358 # separate changes. Parent event changes cause redefinition of the 1359 # entire event. 1360 1361 if not new and not removed and not is_changed: 1362 to_set = [] 1363 to_exclude = [] 1364 to_reschedule = list(chain(replaced_modified, retained_modified, reinstated)) 1365 all_unscheduled = list(cancelled_unmodified) 1366 all_rescheduled = list(chain(replaced_unmodified, to_reschedule)) 1367 1368 # Otherwise, the event is defined in terms of new periods and 1369 # exceptions for removed periods. 1370 1371 else: 1372 to_set = active_periods 1373 to_exclude = cancelled 1374 to_reschedule = [] 1375 all_unscheduled = [] 1376 all_rescheduled = [] 1377 1378 return to_unschedule, to_reschedule, to_add, to_exclude, to_set, all_unscheduled, all_rescheduled 1379 1380 def get_period_mapping(periods): 1381 1382 "Return a mapping of recurrence identifiers to the given 'periods." 1383 1384 d, new = periods_by_recurrence(periods) 1385 return d 1386 1387 def select_recurrences(source, selected): 1388 1389 "Restrict 'source' to the recurrences referenced by 'selected'." 1390 1391 mapping = get_period_mapping(source) 1392 1393 recurrenceids = get_recurrenceids(selected) 1394 for recurrenceid in mapping.keys(): 1395 if not recurrenceid in recurrenceids: 1396 del mapping[recurrenceid] 1397 return mapping 1398 1399 def subtract_recurrences(source, selected): 1400 1401 "Remove from 'source' the recurrences referenced by 'selected'." 1402 1403 mapping = get_period_mapping(source) 1404 1405 for recurrenceid in get_recurrenceids(selected): 1406 if mapping.has_key(recurrenceid): 1407 del mapping[recurrenceid] 1408 return mapping 1409 1410 def get_recurrenceids(periods): 1411 1412 "Return the recurrence identifiers employed by 'periods'." 1413 1414 return map(lambda p: p.get_recurrenceid(), periods) 1415 1416 1417 1418 # Form field extraction and serialisation. 1419 1420 def get_date_control_inputs(args, name, tzid_name=None): 1421 1422 """ 1423 Return a tuple of date control inputs taken from 'args' for field names 1424 starting with 'name'. 1425 1426 If 'tzid_name' is specified, the time zone information will be acquired 1427 from fields starting with 'tzid_name' instead of 'name'. 1428 """ 1429 1430 return args.get("%s-date" % name, []), \ 1431 args.get("%s-hour" % name, []), \ 1432 args.get("%s-minute" % name, []), \ 1433 args.get("%s-second" % name, []), \ 1434 args.get("%s-tzid" % (tzid_name or name), []) 1435 1436 def get_date_control_values(args, name, multiple=False, tzid_name=None, tzid=None): 1437 1438 """ 1439 Return a form date object representing fields taken from 'args' starting 1440 with 'name'. 1441 1442 If 'multiple' is set to a true value, many date objects will be returned 1443 corresponding to a collection of datetimes. 1444 1445 If 'tzid_name' is specified, the time zone information will be acquired 1446 from fields starting with 'tzid_name' instead of 'name'. 1447 1448 If 'tzid' is specified, it will provide the time zone where no explicit 1449 time zone information is indicated in the field data. 1450 """ 1451 1452 dates, hours, minutes, seconds, tzids = get_date_control_inputs(args, name, tzid_name) 1453 1454 # Handle absent values by employing None values. 1455 1456 field_values = map(None, dates, hours, minutes, seconds, tzids) 1457 1458 if not field_values and not multiple: 1459 all_values = FormDate() 1460 else: 1461 all_values = [] 1462 for date, hour, minute, second, tzid_field in field_values: 1463 value = FormDate(date, hour, minute, second, tzid_field or tzid) 1464 1465 # Return a single value or append to a collection of all values. 1466 1467 if not multiple: 1468 return value 1469 else: 1470 all_values.append(value) 1471 1472 return all_values 1473 1474 def set_date_control_values(formdates, args, name, tzid_name=None): 1475 1476 """ 1477 Using the values of the given 'formdates', replace form fields in 'args' 1478 starting with 'name'. 1479 1480 If 'tzid_name' is specified, the time zone information will be stored in 1481 fields starting with 'tzid_name' instead of 'name'. 1482 """ 1483 1484 args["%s-date" % name] = [] 1485 args["%s-hour" % name] = [] 1486 args["%s-minute" % name] = [] 1487 args["%s-second" % name] = [] 1488 args["%s-tzid" % (tzid_name or name)] = [] 1489 1490 for d in formdates: 1491 args["%s-date" % name].append(d and d.date or "") 1492 args["%s-hour" % name].append(d and d.hour or "") 1493 args["%s-minute" % name].append(d and d.minute or "") 1494 args["%s-second" % name].append(d and d.second or "") 1495 args["%s-tzid" % (tzid_name or name)].append(d and d.tzid or "") 1496 1497 def get_period_control_values(args, start_name, end_name, 1498 end_enabled_name, times_enabled_name, 1499 origin=None, origin_name=None, 1500 replacement_name=None, cancelled_name=None, 1501 recurrenceid_name=None, tzid=None): 1502 1503 """ 1504 Return period values from fields found in 'args' prefixed with the given 1505 'start_name' (for start dates), 'end_name' (for end dates), 1506 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 1507 (to enable times for periods). 1508 1509 If 'origin' is specified, a single period with the given origin is 1510 returned. If 'origin_name' is specified, fields containing the name will 1511 provide origin information. 1512 1513 If specified, fields containing 'replacement_name' will indicate periods 1514 provided by separate recurrences, fields containing 'cancelled_name' 1515 will indicate periods that are replacements and cancelled, and fields 1516 containing 'recurrenceid_name' will indicate periods that have existing 1517 recurrence details from an event. 1518 1519 If 'tzid' is specified, it will provide the time zone where no explicit 1520 time zone information is indicated in the field data. 1521 """ 1522 1523 # Get the end datetime and time presence settings. 1524 1525 all_end_enabled = args.get(end_enabled_name, []) 1526 all_times_enabled = args.get(times_enabled_name, []) 1527 1528 # Get the origins of period data and whether the periods are replacements. 1529 1530 if origin: 1531 all_origins = [origin] 1532 else: 1533 all_origins = origin_name and args.get(origin_name, []) or [] 1534 1535 all_replacements = replacement_name and args.get(replacement_name, []) or [] 1536 all_cancelled = cancelled_name and args.get(cancelled_name, []) or [] 1537 all_recurrenceids = recurrenceid_name and args.get(recurrenceid_name, []) or [] 1538 1539 # Get the start and end datetimes. 1540 1541 all_starts = get_date_control_values(args, start_name, True, tzid=tzid) 1542 all_ends = get_date_control_values(args, end_name, True, start_name, tzid=tzid) 1543 1544 # Construct period objects for each start, end, origin combination. 1545 1546 periods = [] 1547 1548 for index, (start, end, found_origin, recurrenceid) in \ 1549 enumerate(map(None, all_starts, all_ends, all_origins, all_recurrenceids)): 1550 1551 # Obtain period settings from separate controls. 1552 1553 end_enabled = str(index) in all_end_enabled 1554 times_enabled = str(index) in all_times_enabled 1555 replacement = str(index) in all_replacements 1556 cancelled = str(index) in all_cancelled 1557 1558 period = FormPeriod(start, end, end_enabled, times_enabled, tzid, 1559 found_origin or origin, replacement, cancelled, 1560 recurrenceid) 1561 periods.append(period) 1562 1563 # Return a single period if a single origin was specified. 1564 1565 if origin: 1566 return periods[0] 1567 else: 1568 return periods 1569 1570 def set_period_control_values(periods, args, start_name, end_name, 1571 end_enabled_name, times_enabled_name, 1572 origin_name=None, replacement_name=None, 1573 cancelled_name=None, recurrenceid_name=None): 1574 1575 """ 1576 Using the given 'periods', replace form fields in 'args' prefixed with the 1577 given 'start_name' (for start dates), 'end_name' (for end dates), 1578 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 1579 (to enable times for periods). 1580 1581 If 'origin_name' is specified, fields containing the name will provide 1582 origin information, fields containing 'replacement_name' will indicate 1583 periods provided by separate recurrences, fields containing 'cancelled_name' 1584 will indicate periods that are replacements and cancelled, and fields 1585 containing 'recurrenceid_name' will indicate periods that have existing 1586 recurrence details from an event. 1587 """ 1588 1589 # Record period settings separately. 1590 1591 args[end_enabled_name] = [] 1592 args[times_enabled_name] = [] 1593 1594 # Record origin and replacement information if naming is defined. 1595 1596 if origin_name: 1597 args[origin_name] = [] 1598 1599 if replacement_name: 1600 args[replacement_name] = [] 1601 1602 if cancelled_name: 1603 args[cancelled_name] = [] 1604 1605 if recurrenceid_name: 1606 args[recurrenceid_name] = [] 1607 1608 all_starts = [] 1609 all_ends = [] 1610 1611 for index, period in enumerate(periods): 1612 1613 # Encode period settings in controls. 1614 1615 if period.end_enabled: 1616 args[end_enabled_name].append(str(index)) 1617 if period.times_enabled: 1618 args[times_enabled_name].append(str(index)) 1619 1620 # Add origin information where controls are present to record it. 1621 1622 if origin_name: 1623 args[origin_name].append(period.origin or "") 1624 1625 # Add replacement information where controls are present to record it. 1626 1627 if replacement_name and period.replacement: 1628 args[replacement_name].append(str(index)) 1629 1630 # Add cancelled recurrence information where controls are present to 1631 # record it. 1632 1633 if cancelled_name and period.cancelled: 1634 args[cancelled_name].append(str(index)) 1635 1636 # Add recurrence identifiers where controls are present to record it. 1637 1638 if recurrenceid_name: 1639 args[recurrenceid_name].append(period.recurrenceid or "") 1640 1641 # Collect form date information for addition below. 1642 1643 all_starts.append(period.get_form_start()) 1644 all_ends.append(period.get_form_end()) 1645 1646 # Set the controls for the dates. 1647 1648 set_date_control_values(all_starts, args, start_name) 1649 set_date_control_values(all_ends, args, end_name, tzid_name=start_name) 1650 1651 1652 1653 # Attendee processing. 1654 1655 def classify_attendee_changes(original, current): 1656 1657 """ 1658 Return categories of attendees given the 'original' and 'current' 1659 collections of attendees. 1660 """ 1661 1662 new = {} 1663 modified = {} 1664 unmodified = {} 1665 1666 # Check current attendees against the original ones. 1667 1668 for attendee, attendee_attr in current.items(): 1669 original_attr = original.get(attendee) 1670 1671 # New attendee if missing original details. 1672 1673 if not original_attr: 1674 new[attendee] = attendee_attr 1675 1676 # Details unchanged for existing attendee. 1677 1678 elif attendee_attr == original_attr: 1679 unmodified[attendee] = attendee_attr 1680 1681 # Details changed for existing attendee. 1682 1683 else: 1684 modified[attendee] = attendee_attr 1685 1686 removed = {} 1687 1688 # Check for removed attendees. 1689 1690 for attendee, attendee_attr in original.items(): 1691 if not current.has_key(attendee): 1692 removed[attendee] = attendee_attr 1693 1694 return new, modified, unmodified, removed 1695 1696 1697 1698 # Utilities. 1699 1700 def filter_duplicates(l): 1701 1702 """ 1703 Return collection 'l' filtered for duplicate values, retaining the given 1704 element ordering. 1705 """ 1706 1707 s = set() 1708 f = [] 1709 1710 for value in l: 1711 if value not in s: 1712 s.add(value) 1713 f.append(value) 1714 1715 return f 1716 1717 def remove_from_collection(l, indexes, fn): 1718 1719 """ 1720 Remove from collection 'l' all values present at the given 'indexes' where 1721 'fn' applied to each referenced value returns a true value. Values where 1722 'fn' returns a false value are added to a list of deferred removals which is 1723 returned. 1724 """ 1725 1726 still_to_remove = [] 1727 correction = 0 1728 1729 for i in indexes: 1730 try: 1731 i = int(i) - correction 1732 value = l[i] 1733 except (IndexError, ValueError): 1734 continue 1735 1736 if fn(value): 1737 del l[i] 1738 correction += 1 1739 else: 1740 still_to_remove.append(value) 1741 1742 return still_to_remove 1743 1744 # vim: tabstop=4 expandtab shiftwidth=4