1 #!/usr/bin/env python 2 3 """ 4 Web interface data abstractions. 5 6 Copyright (C) 2014, 2015, 2017 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 from datetime import datetime, timedelta 23 from imiptools.dates import end_date_from_calendar, end_date_to_calendar, \ 24 format_datetime, get_datetime, get_end_of_day, \ 25 to_date 26 from imiptools.period import RecurringPeriod 27 28 class PeriodError(Exception): 29 pass 30 31 class EventPeriod(RecurringPeriod): 32 33 """ 34 A simple period plus attribute details, compatible with RecurringPeriod, and 35 intended to represent information obtained from an iCalendar resource. 36 """ 37 38 def __init__(self, start, end, tzid=None, origin=None, start_attr=None, 39 end_attr=None, form_start=None, form_end=None, replaced=False): 40 41 """ 42 Initialise a period with the given 'start' and 'end' datetimes, together 43 with optional 'start_attr' and 'end_attr' metadata, 'form_start' and 44 'form_end' values provided as textual input, and with an optional 45 'origin' indicating the kind of period this object describes. 46 """ 47 48 RecurringPeriod.__init__(self, start, end, tzid, origin, start_attr, end_attr) 49 self.form_start = form_start 50 self.form_end = form_end 51 self.replaced = replaced 52 53 def as_tuple(self): 54 return self.start, self.end, self.tzid, self.origin, self.start_attr, \ 55 self.end_attr, self.form_start, self.form_end, self.replaced 56 57 def __repr__(self): 58 return "EventPeriod%r" % (self.as_tuple(),) 59 60 def as_event_period(self): 61 return self 62 63 def get_start_item(self): 64 return self.get_start(), self.get_start_attr() 65 66 def get_end_item(self): 67 return self.get_end(), self.get_end_attr() 68 69 # Form data compatibility methods. 70 71 def get_form_start(self): 72 if not self.form_start: 73 self.form_start = self.get_form_date(self.get_start(), self.start_attr) 74 return self.form_start 75 76 def get_form_end(self): 77 if not self.form_end: 78 self.form_end = self.get_form_date(end_date_from_calendar(self.get_end()), self.end_attr) 79 return self.form_end 80 81 def as_form_period(self): 82 return FormPeriod( 83 self.get_form_start(), 84 self.get_form_end(), 85 isinstance(self.end, datetime) or self.get_start() != self.get_end() - timedelta(1), 86 isinstance(self.start, datetime) or isinstance(self.end, datetime), 87 self.tzid, 88 self.origin, 89 self.replaced and True or False 90 ) 91 92 def get_form_date(self, dt, attr=None): 93 return FormDate( 94 format_datetime(to_date(dt)), 95 isinstance(dt, datetime) and str(dt.hour) or None, 96 isinstance(dt, datetime) and str(dt.minute) or None, 97 isinstance(dt, datetime) and str(dt.second) or None, 98 attr and attr.get("TZID") or None, 99 dt, attr 100 ) 101 102 class FormPeriod(RecurringPeriod): 103 104 "A period whose information originates from a form." 105 106 def __init__(self, start, end, end_enabled=True, times_enabled=True, 107 tzid=None, origin=None, replaced=False): 108 self.start = start 109 self.end = end 110 self.end_enabled = end_enabled 111 self.times_enabled = times_enabled 112 self.tzid = tzid 113 self.origin = origin 114 self.replaced = replaced 115 116 def as_tuple(self): 117 return self.start, self.end, self.end_enabled, self.times_enabled, self.tzid, self.origin, self.replaced 118 119 def __repr__(self): 120 return "FormPeriod%r" % (self.as_tuple(),) 121 122 def __cmp__(self, other): 123 result = RecurringPeriod.__cmp__(self, other) 124 if result: 125 return result 126 other = form_period_from_period(other) 127 return cmp(self.replaced, other.replaced) 128 129 def as_event_period(self, index=None): 130 131 """ 132 Return a converted version of this object as an event period suitable 133 for iCalendar usage. If 'index' is indicated, include it in any error 134 raised in the conversion process. 135 """ 136 137 dtstart, dtstart_attr = self.get_start_item() 138 if not dtstart: 139 if index is not None: 140 raise PeriodError(("dtstart", index)) 141 else: 142 raise PeriodError("dtstart") 143 144 dtend, dtend_attr = self.get_end_item() 145 if not dtend: 146 if index is not None: 147 raise PeriodError(("dtend", index)) 148 else: 149 raise PeriodError("dtend") 150 151 if dtstart > dtend: 152 if index is not None: 153 raise PeriodError(("dtstart", index), ("dtend", index)) 154 else: 155 raise PeriodError("dtstart", "dtend") 156 157 return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid, 158 self.origin, dtstart_attr, dtend_attr, 159 self.start, self.end, self.replaced) 160 161 # Period data methods. 162 163 def get_start(self): 164 return self.start and self.start.as_datetime(self.times_enabled) or None 165 166 def get_end(self): 167 168 # Handle specified end datetimes. 169 170 if self.end_enabled: 171 dtend = self.end.as_datetime(self.times_enabled) 172 if not dtend: 173 return None 174 175 # Handle same day times. 176 177 elif self.times_enabled: 178 formdate = FormDate(self.start.date, self.end.hour, self.end.minute, self.end.second, self.end.tzid) 179 dtend = formdate.as_datetime(self.times_enabled) 180 if not dtend: 181 return None 182 183 # Otherwise, treat the end date as the start date. Datetimes are 184 # handled by making the event occupy the rest of the day. 185 186 else: 187 dtstart, dtstart_attr = self.get_start_item() 188 if dtstart: 189 if isinstance(dtstart, datetime): 190 dtend = get_end_of_day(dtstart, dtstart_attr["TZID"]) 191 else: 192 dtend = dtstart 193 else: 194 return None 195 196 return dtend 197 198 def get_start_attr(self): 199 return self.start and self.start.get_attributes(self.times_enabled) or {} 200 201 def get_end_attr(self): 202 return self.end and self.end.get_attributes(self.times_enabled) or {} 203 204 # Form data methods. 205 206 def get_form_start(self): 207 return self.start 208 209 def get_form_end(self): 210 return self.end 211 212 def as_form_period(self): 213 return self 214 215 class FormDate: 216 217 "Date information originating from form information." 218 219 def __init__(self, date=None, hour=None, minute=None, second=None, tzid=None, dt=None, attr=None): 220 self.date = date 221 self.hour = hour 222 self.minute = minute 223 self.second = second 224 self.tzid = tzid 225 self.dt = dt 226 self.attr = attr 227 228 def as_tuple(self): 229 return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr 230 231 def __repr__(self): 232 return "FormDate%r" % (self.as_tuple(),) 233 234 def get_component(self, value): 235 return (value or "").rjust(2, "0")[:2] 236 237 def get_hour(self): 238 return self.get_component(self.hour) 239 240 def get_minute(self): 241 return self.get_component(self.minute) 242 243 def get_second(self): 244 return self.get_component(self.second) 245 246 def get_date_string(self): 247 return self.date or "" 248 249 def get_datetime_string(self): 250 if not self.date: 251 return "" 252 253 hour = self.hour; minute = self.minute; second = self.second 254 255 if hour or minute or second: 256 time = "T%s%s%s" % tuple(map(self.get_component, (hour, minute, second))) 257 else: 258 time = "" 259 260 return "%s%s" % (self.date, time) 261 262 def get_tzid(self): 263 return self.tzid 264 265 def as_datetime(self, with_time=True): 266 267 "Return a datetime for this object." 268 269 # Return any original datetime details. 270 271 if self.dt: 272 return self.dt 273 274 # Otherwise, construct a datetime. 275 276 s, attr = self.as_datetime_item(with_time) 277 if s: 278 return get_datetime(s, attr) 279 else: 280 return None 281 282 def as_datetime_item(self, with_time=True): 283 284 """ 285 Return a (datetime string, attr) tuple for the datetime information 286 provided by this object, where both tuple elements will be None if no 287 suitable date or datetime information exists. 288 """ 289 290 s = None 291 if with_time: 292 s = self.get_datetime_string() 293 attr = self.get_attributes(True) 294 if not s: 295 s = self.get_date_string() 296 attr = self.get_attributes(False) 297 if not s: 298 return None, None 299 return s, attr 300 301 def get_attributes(self, with_time=True): 302 303 "Return attributes for the date or datetime represented by this object." 304 305 if with_time: 306 return {"TZID" : self.get_tzid(), "VALUE" : "DATE-TIME"} 307 else: 308 return {"VALUE" : "DATE"} 309 310 def event_period_from_period(period): 311 312 """ 313 Convert a 'period' to one suitable for use in an iCalendar representation. 314 In an "event period" representation, the end day of any date-level event is 315 encoded as the "day after" the last day actually involved in the event. 316 """ 317 318 if isinstance(period, EventPeriod): 319 return period 320 elif isinstance(period, FormPeriod): 321 return period.as_event_period() 322 else: 323 dtstart, dtstart_attr = period.get_start_item() 324 dtend, dtend_attr = period.get_end_item() 325 if not isinstance(period, RecurringPeriod): 326 dtend = end_date_to_calendar(dtend) 327 return EventPeriod(dtstart, dtend, period.tzid, period.origin, dtstart_attr, dtend_attr) 328 329 def form_period_from_period(period): 330 331 """ 332 Convert a 'period' into a representation usable in a user-editable form. 333 In a "form period" representation, the end day of any date-level event is 334 presented in a "natural" form, not the iCalendar "day after" form. 335 """ 336 337 if isinstance(period, EventPeriod): 338 return period.as_form_period() 339 elif isinstance(period, FormPeriod): 340 return period 341 else: 342 return event_period_from_period(period).as_form_period() 343 344 345 346 # Form period processing. 347 348 def get_active_periods(periods): 349 350 "Return a mapping of non-replaced periods to counts, given 'periods'." 351 352 active_periods = {} 353 for p in periods: 354 if not p.replaced: 355 if not active_periods.has_key(p): 356 active_periods[p] = 1 357 else: 358 active_periods[p] += 1 359 return active_periods 360 361 def get_removed_periods(periods, still_to_remove): 362 363 """ 364 From the recurrence 'periods', given details of those 'still_to_remove', 365 return the remaining active periods and the periods to unschedule or 366 exclude, using a tuple of the form (active, unscheduled, excluded). 367 """ 368 369 to_remove = set() 370 371 # Get all periods that are not replaced. 372 373 active_periods = get_active_periods(periods) 374 375 for period in still_to_remove: 376 active_periods[period] -= 1 377 to_remove.add(period) 378 379 # Determine whether some periods are both removed and added. 380 381 remaining = [] 382 for period, n in active_periods.items(): 383 if n > 0: 384 remaining.append(period) 385 386 to_remove.difference_update(remaining) 387 return remaining, to_remove 388 389 390 391 # Form field extraction and serialisation. 392 393 def get_date_control_inputs(args, name, tzid_name=None): 394 395 """ 396 Return a tuple of date control inputs taken from 'args' for field names 397 starting with 'name'. 398 399 If 'tzid_name' is specified, the time zone information will be acquired 400 from fields starting with 'tzid_name' instead of 'name'. 401 """ 402 403 return args.get("%s-date" % name, []), \ 404 args.get("%s-hour" % name, []), \ 405 args.get("%s-minute" % name, []), \ 406 args.get("%s-second" % name, []), \ 407 args.get("%s-tzid" % (tzid_name or name), []) 408 409 def get_date_control_values(args, name, multiple=False, tzid_name=None, tzid=None): 410 411 """ 412 Return a form date object representing fields taken from 'args' starting 413 with 'name'. 414 415 If 'multiple' is set to a true value, many date objects will be returned 416 corresponding to a collection of datetimes. 417 418 If 'tzid_name' is specified, the time zone information will be acquired 419 from fields starting with 'tzid_name' instead of 'name'. 420 421 If 'tzid' is specified, it will provide the time zone where no explicit 422 time zone information is indicated in the field data. 423 """ 424 425 dates, hours, minutes, seconds, tzids = get_date_control_inputs(args, name, tzid_name) 426 427 # Handle absent values by employing None values. 428 429 field_values = map(None, dates, hours, minutes, seconds, tzids) 430 431 if not field_values and not multiple: 432 all_values = FormDate() 433 else: 434 all_values = [] 435 for date, hour, minute, second, tzid_field in field_values: 436 value = FormDate(date, hour, minute, second, tzid_field or tzid) 437 438 # Return a single value or append to a collection of all values. 439 440 if not multiple: 441 return value 442 else: 443 all_values.append(value) 444 445 return all_values 446 447 def set_date_control_values(formdates, args, name, tzid_name=None): 448 449 """ 450 Using the values of the given 'formdates', replace form fields in 'args' 451 starting with 'name'. 452 453 If 'tzid_name' is specified, the time zone information will be stored in 454 fields starting with 'tzid_name' instead of 'name'. 455 """ 456 457 args["%s-date" % name] = [] 458 args["%s-hour" % name] = [] 459 args["%s-minute" % name] = [] 460 args["%s-second" % name] = [] 461 args["%s-tzid" % (tzid_name or name)] = [] 462 463 for d in formdates: 464 args["%s-date" % name].append(d and d.date or "") 465 args["%s-hour" % name].append(d and d.hour or "") 466 args["%s-minute" % name].append(d and d.minute or "") 467 args["%s-second" % name].append(d and d.second or "") 468 args["%s-tzid" % (tzid_name or name)].append(d and d.tzid or "") 469 470 def get_period_control_values(args, start_name, end_name, 471 end_enabled_name, times_enabled_name, 472 origin=None, origin_name=None, 473 replaced_name=None, tzid=None): 474 475 """ 476 Return period values from fields found in 'args' prefixed with the given 477 'start_name' (for start dates), 'end_name' (for end dates), 478 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 479 (to enable times for periods). 480 481 If 'origin' is specified, a single period with the given origin is 482 returned. If 'origin_name' is specified, fields containing the name will 483 provide origin information, and fields containing 'replaced_name' will 484 indicate periods that are replaced. 485 486 If 'tzid' is specified, it will provide the time zone where no explicit 487 time zone information is indicated in the field data. 488 """ 489 490 # Get the end datetime and time presence settings. 491 492 all_end_enabled = args.get(end_enabled_name, []) 493 all_times_enabled = args.get(times_enabled_name, []) 494 495 # Get the origins of period data and whether the periods are replaced. 496 497 if origin: 498 all_origins = [origin] 499 else: 500 all_origins = origin_name and args.get(origin_name, []) or [] 501 502 all_replaced = replaced_name and args.get(replaced_name, []) or [] 503 504 # Get the start and end datetimes. 505 506 all_starts = get_date_control_values(args, start_name, True, tzid=tzid) 507 all_ends = get_date_control_values(args, end_name, True, start_name, tzid=tzid) 508 509 # Construct period objects for each start, end, origin combination. 510 511 periods = [] 512 513 for index, (start, end, found_origin) in \ 514 enumerate(map(None, all_starts, all_ends, all_origins)): 515 516 # Obtain period settings from separate controls. 517 518 end_enabled = str(index) in all_end_enabled 519 times_enabled = str(index) in all_times_enabled 520 replaced = str(index) in all_replaced 521 522 period = FormPeriod(start, end, end_enabled, times_enabled, tzid, 523 found_origin or origin, replaced) 524 periods.append(period) 525 526 # Return a single period if a single origin was specified. 527 528 if origin: 529 return periods[0] 530 else: 531 return periods 532 533 def set_period_control_values(periods, args, start_name, end_name, 534 end_enabled_name, times_enabled_name, 535 origin_name=None, replaced_name=None): 536 537 """ 538 Using the given 'periods', replace form fields in 'args' prefixed with the 539 given 'start_name' (for start dates), 'end_name' (for end dates), 540 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 541 (to enable times for periods). 542 543 If 'origin_name' is specified, fields containing the name will provide 544 origin information, and fields containing 'replaced_name' will indicate 545 periods that are replaced. 546 """ 547 548 # Record period settings separately. 549 550 args[end_enabled_name] = [] 551 args[times_enabled_name] = [] 552 553 # Record origin and replacement information if naming is defined. 554 555 if origin_name: 556 args[origin_name] = [] 557 558 if replaced_name: 559 args[replaced_name] = [] 560 561 all_starts = [] 562 all_ends = [] 563 564 for index, period in enumerate(periods): 565 566 # Encode period settings in controls. 567 568 if period.end_enabled: 569 args[end_enabled_name].append(str(index)) 570 if period.times_enabled: 571 args[times_enabled_name].append(str(index)) 572 573 # Add origin information where controls are present to record it. 574 575 if origin_name: 576 args[origin_name].append(period.origin or "") 577 578 # Add replacement information where controls are present to record it. 579 580 if replaced_name and period.replaced: 581 args[replaced_name].append(str(index)) 582 583 # Collect form date information for addition below. 584 585 all_starts.append(period.get_form_start()) 586 all_ends.append(period.get_form_end()) 587 588 # Set the controls for the dates. 589 590 set_date_control_values(all_starts, args, start_name) 591 set_date_control_values(all_ends, args, end_name, tzid_name=start_name) 592 593 594 595 # Utilities. 596 597 def filter_duplicates(l): 598 599 """ 600 Return collection 'l' filtered for duplicate values, retaining the given 601 element ordering. 602 """ 603 604 s = set() 605 f = [] 606 607 for value in l: 608 if value not in s: 609 s.add(value) 610 f.append(value) 611 612 return f 613 614 def remove_from_collection(l, indexes, fn): 615 616 """ 617 Remove from collection 'l' all values present at the given 'indexes' where 618 'fn' applied to each referenced value returns a true value. Values where 619 'fn' returns a false value are added to a list of deferred removals which is 620 returned. 621 """ 622 623 still_to_remove = [] 624 correction = 0 625 626 for i in indexes: 627 try: 628 i = int(i) - correction 629 value = l[i] 630 except (IndexError, ValueError): 631 continue 632 633 if fn(value): 634 del l[i] 635 correction += 1 636 else: 637 still_to_remove.append(value) 638 639 return still_to_remove 640 641 # vim: tabstop=4 expandtab shiftwidth=4