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 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 as_event_period(self, index=None): 123 124 """ 125 Return a converted version of this object as an event period suitable 126 for iCalendar usage. If 'index' is indicated, include it in any error 127 raised in the conversion process. 128 """ 129 130 dtstart, dtstart_attr = self.get_start_item() 131 if not dtstart: 132 if index is not None: 133 raise PeriodError(("dtstart", index)) 134 else: 135 raise PeriodError("dtstart") 136 137 dtend, dtend_attr = self.get_end_item() 138 if not dtend: 139 if index is not None: 140 raise PeriodError(("dtend", index)) 141 else: 142 raise PeriodError("dtend") 143 144 if dtstart > dtend: 145 if index is not None: 146 raise PeriodError(("dtstart", index), ("dtend", index)) 147 else: 148 raise PeriodError("dtstart", "dtend") 149 150 return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid, 151 self.origin, dtstart_attr, dtend_attr, 152 self.start, self.end, self.replaced) 153 154 # Period data methods. 155 156 def get_start(self): 157 return self.start and self.start.as_datetime(self.times_enabled) or None 158 159 def get_end(self): 160 161 # Handle specified end datetimes. 162 163 if self.end_enabled: 164 dtend = self.end.as_datetime(self.times_enabled) 165 if not dtend: 166 return None 167 168 # Handle same day times. 169 170 elif self.times_enabled: 171 formdate = FormDate(self.start.date, self.end.hour, self.end.minute, self.end.second, self.end.tzid) 172 dtend = formdate.as_datetime(self.times_enabled) 173 if not dtend: 174 return None 175 176 # Otherwise, treat the end date as the start date. Datetimes are 177 # handled by making the event occupy the rest of the day. 178 179 else: 180 dtstart, dtstart_attr = self.get_start_item() 181 if dtstart: 182 if isinstance(dtstart, datetime): 183 dtend = get_end_of_day(dtstart, dtstart_attr["TZID"]) 184 else: 185 dtend = dtstart 186 else: 187 return None 188 189 return dtend 190 191 def get_start_attr(self): 192 return self.start and self.start.get_attributes(self.times_enabled) or {} 193 194 def get_end_attr(self): 195 return self.end and self.end.get_attributes(self.times_enabled) or {} 196 197 # Form data methods. 198 199 def get_form_start(self): 200 return self.start 201 202 def get_form_end(self): 203 return self.end 204 205 def as_form_period(self): 206 return self 207 208 class FormDate: 209 210 "Date information originating from form information." 211 212 def __init__(self, date=None, hour=None, minute=None, second=None, tzid=None, dt=None, attr=None): 213 self.date = date 214 self.hour = hour 215 self.minute = minute 216 self.second = second 217 self.tzid = tzid 218 self.dt = dt 219 self.attr = attr 220 221 def as_tuple(self): 222 return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr 223 224 def __repr__(self): 225 return "FormDate%r" % (self.as_tuple(),) 226 227 def get_component(self, value): 228 return (value or "").rjust(2, "0")[:2] 229 230 def get_hour(self): 231 return self.get_component(self.hour) 232 233 def get_minute(self): 234 return self.get_component(self.minute) 235 236 def get_second(self): 237 return self.get_component(self.second) 238 239 def get_date_string(self): 240 return self.date or "" 241 242 def get_datetime_string(self): 243 if not self.date: 244 return "" 245 246 hour = self.hour; minute = self.minute; second = self.second 247 248 if hour or minute or second: 249 time = "T%s%s%s" % tuple(map(self.get_component, (hour, minute, second))) 250 else: 251 time = "" 252 253 return "%s%s" % (self.date, time) 254 255 def get_tzid(self): 256 return self.tzid 257 258 def as_datetime(self, with_time=True): 259 260 "Return a datetime for this object." 261 262 # Return any original datetime details. 263 264 if self.dt: 265 return self.dt 266 267 # Otherwise, construct a datetime. 268 269 s, attr = self.as_datetime_item(with_time) 270 if s: 271 return get_datetime(s, attr) 272 else: 273 return None 274 275 def as_datetime_item(self, with_time=True): 276 277 """ 278 Return a (datetime string, attr) tuple for the datetime information 279 provided by this object, where both tuple elements will be None if no 280 suitable date or datetime information exists. 281 """ 282 283 s = None 284 if with_time: 285 s = self.get_datetime_string() 286 attr = self.get_attributes(True) 287 if not s: 288 s = self.get_date_string() 289 attr = self.get_attributes(False) 290 if not s: 291 return None, None 292 return s, attr 293 294 def get_attributes(self, with_time=True): 295 296 "Return attributes for the date or datetime represented by this object." 297 298 if with_time: 299 return {"TZID" : self.get_tzid(), "VALUE" : "DATE-TIME"} 300 else: 301 return {"VALUE" : "DATE"} 302 303 def event_period_from_period(period): 304 305 """ 306 Convert a 'period' to one suitable for use in an iCalendar representation. 307 In an "event period" representation, the end day of any date-level event is 308 encoded as the "day after" the last day actually involved in the event. 309 """ 310 311 if isinstance(period, EventPeriod): 312 return period 313 elif isinstance(period, FormPeriod): 314 return period.as_event_period() 315 else: 316 dtstart, dtstart_attr = period.get_start_item() 317 dtend, dtend_attr = period.get_end_item() 318 if not isinstance(period, RecurringPeriod): 319 dtend = end_date_to_calendar(dtend) 320 return EventPeriod(dtstart, dtend, period.tzid, period.origin, dtstart_attr, dtend_attr) 321 322 def form_period_from_period(period): 323 324 """ 325 Convert a 'period' into a representation usable in a user-editable form. 326 In a "form period" representation, the end day of any date-level event is 327 presented in a "natural" form, not the iCalendar "day after" form. 328 """ 329 330 if isinstance(period, EventPeriod): 331 return period.as_form_period() 332 elif isinstance(period, FormPeriod): 333 return period 334 else: 335 return event_period_from_period(period).as_form_period() 336 337 338 339 # Form field extraction and serialisation. 340 341 def get_date_control_inputs(args, name, tzid_name=None): 342 343 """ 344 Return a tuple of date control inputs taken from 'args' for field names 345 starting with 'name'. 346 347 If 'tzid_name' is specified, the time zone information will be acquired 348 from fields starting with 'tzid_name' instead of 'name'. 349 """ 350 351 return args.get("%s-date" % name, []), \ 352 args.get("%s-hour" % name, []), \ 353 args.get("%s-minute" % name, []), \ 354 args.get("%s-second" % name, []), \ 355 args.get("%s-tzid" % (tzid_name or name), []) 356 357 def get_date_control_values(args, name, multiple=False, tzid_name=None, tzid=None): 358 359 """ 360 Return a form date object representing fields taken from 'args' starting 361 with 'name'. 362 363 If 'multiple' is set to a true value, many date objects will be returned 364 corresponding to a collection of datetimes. 365 366 If 'tzid_name' is specified, the time zone information will be acquired 367 from fields starting with 'tzid_name' instead of 'name'. 368 369 If 'tzid' is specified, it will provide the time zone where no explicit 370 time zone information is indicated in the field data. 371 """ 372 373 dates, hours, minutes, seconds, tzids = get_date_control_inputs(args, name, tzid_name) 374 375 # Handle absent values by employing None values. 376 377 field_values = map(None, dates, hours, minutes, seconds, tzids) 378 379 if not field_values and not multiple: 380 all_values = FormDate() 381 else: 382 all_values = [] 383 for date, hour, minute, second, tzid_field in field_values: 384 value = FormDate(date, hour, minute, second, tzid_field or tzid) 385 386 # Return a single value or append to a collection of all values. 387 388 if not multiple: 389 return value 390 else: 391 all_values.append(value) 392 393 return all_values 394 395 def set_date_control_values(formdates, args, name, tzid_name=None): 396 397 """ 398 Using the values of the given 'formdates', replace form fields in 'args' 399 starting with 'name'. 400 401 If 'tzid_name' is specified, the time zone information will be stored in 402 fields starting with 'tzid_name' instead of 'name'. 403 """ 404 405 args["%s-date" % name] = [] 406 args["%s-hour" % name] = [] 407 args["%s-minute" % name] = [] 408 args["%s-second" % name] = [] 409 args["%s-tzid" % (tzid_name or name)] = [] 410 411 for d in formdates: 412 args["%s-date" % name].append(d and d.date or "") 413 args["%s-hour" % name].append(d and d.hour or "") 414 args["%s-minute" % name].append(d and d.minute or "") 415 args["%s-second" % name].append(d and d.second or "") 416 args["%s-tzid" % (tzid_name or name)].append(d and d.tzid or "") 417 418 def get_period_control_values(args, start_name, end_name, 419 end_enabled_name, times_enabled_name, 420 origin=None, origin_name=None, 421 replaced_name=None, tzid=None): 422 423 """ 424 Return period values from fields found in 'args' prefixed with the given 425 'start_name' (for start dates), 'end_name' (for end dates), 426 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 427 (to enable times for periods). 428 429 If 'origin' is specified, a single period with the given origin is 430 returned. If 'origin_name' is specified, fields containing the name will 431 provide origin information, and fields containing 'replaced_name' will 432 indicate periods that are replaced. 433 434 If 'tzid' is specified, it will provide the time zone where no explicit 435 time zone information is indicated in the field data. 436 """ 437 438 # Get the end datetime and time presence settings. 439 440 all_end_enabled = args.get(end_enabled_name, []) 441 all_times_enabled = args.get(times_enabled_name, []) 442 443 # Get the origins of period data and whether the periods are replaced. 444 445 if origin: 446 all_origins = [origin] 447 else: 448 all_origins = origin_name and args.get(origin_name, []) or [] 449 450 all_replaced = replaced_name and args.get(replaced_name, []) or [] 451 452 # Get the start and end datetimes. 453 454 all_starts = get_date_control_values(args, start_name, True, tzid=tzid) 455 all_ends = get_date_control_values(args, end_name, True, start_name, tzid=tzid) 456 457 # Construct period objects for each start, end, origin combination. 458 459 periods = [] 460 461 for index, (start, end, found_origin) in \ 462 enumerate(map(None, all_starts, all_ends, all_origins)): 463 464 # Obtain period settings from separate controls. 465 466 end_enabled = str(index) in all_end_enabled 467 times_enabled = str(index) in all_times_enabled 468 replaced = str(index) in all_replaced 469 470 period = FormPeriod(start, end, end_enabled, times_enabled, tzid, 471 found_origin or origin, replaced) 472 periods.append(period) 473 474 # Return a single period if a single origin was specified. 475 476 if origin: 477 return periods[0] 478 else: 479 return periods 480 481 def set_period_control_values(periods, args, start_name, end_name, 482 end_enabled_name, times_enabled_name, 483 origin_name=None, replaced_name=None): 484 485 """ 486 Using the given 'periods', replace form fields in 'args' prefixed with the 487 given 'start_name' (for start dates), 'end_name' (for end dates), 488 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 489 (to enable times for periods). 490 491 If 'origin_name' is specified, fields containing the name will provide 492 origin information, and fields containing 'replaced_name' will indicate 493 periods that are replaced. 494 """ 495 496 # Record period settings separately. 497 498 args[end_enabled_name] = [] 499 args[times_enabled_name] = [] 500 501 # Record origin and replacement information if naming is defined. 502 503 if origin_name: 504 args[origin_name] = [] 505 506 if replaced_name: 507 args[replaced_name] = [] 508 509 all_starts = [] 510 all_ends = [] 511 512 for index, period in enumerate(periods): 513 514 # Encode period settings in controls. 515 516 if period.end_enabled: 517 args[end_enabled_name].append(str(index)) 518 if period.times_enabled: 519 args[times_enabled_name].append(str(index)) 520 521 # Add origin information where controls are present to record it. 522 523 if origin_name: 524 args[origin_name].append(period.origin or "") 525 526 # Add replacement information where controls are present to record it. 527 528 if replaced_name and period.replaced: 529 args[replaced_name].append(str(index)) 530 531 # Collect form date information for addition below. 532 533 all_starts.append(period.get_form_start()) 534 all_ends.append(period.get_form_end()) 535 536 # Set the controls for the dates. 537 538 set_date_control_values(all_starts, args, start_name) 539 set_date_control_values(all_ends, args, end_name, tzid_name=start_name) 540 541 # vim: tabstop=4 expandtab shiftwidth=4