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