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