1 #!/usr/bin/env python 2 3 """ 4 A Web interface to a user's calendar. 5 6 Copyright (C) 2014, 2015 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 # Edit this path to refer to the location of the imiptools libraries, if 23 # necessary. 24 25 LIBRARY_PATH = "/var/lib/imip-agent" 26 27 from datetime import date, datetime, timedelta 28 import babel.dates 29 import cgi, os, sys 30 31 sys.path.append(LIBRARY_PATH) 32 33 from imiptools.content import Handler 34 from imiptools.data import get_address, get_uri, make_freebusy, Object, to_part 35 from imiptools.dates import format_datetime, format_time, get_date, get_datetime, \ 36 get_datetime_item, get_default_timezone, \ 37 get_end_of_day, get_start_of_day, get_start_of_next_day, \ 38 get_timestamp, ends_on_same_day, to_timezone 39 from imiptools.mail import Messenger 40 from imiptools.period import add_day_start_points, add_empty_days, add_slots, \ 41 convert_periods, get_freebusy_details, \ 42 get_scale, have_conflict, get_slots, get_spans, \ 43 partition_by_day, remove_from_freebusy, update_freebusy, \ 44 _update_freebusy 45 from imiptools.profile import Preferences 46 import imip_store 47 import markup 48 49 getenv = os.environ.get 50 setenv = os.environ.__setitem__ 51 52 class CGIEnvironment: 53 54 "A CGI-compatible environment." 55 56 def __init__(self, charset=None): 57 self.charset = charset 58 self.args = None 59 self.method = None 60 self.path = None 61 self.path_info = None 62 self.user = None 63 64 def get_args(self): 65 if self.args is None: 66 if self.get_method() != "POST": 67 setenv("QUERY_STRING", "") 68 args = cgi.parse(keep_blank_values=True) 69 70 if not self.charset: 71 self.args = args 72 else: 73 self.args = {} 74 for key, values in args.items(): 75 self.args[key] = [unicode(value, self.charset) for value in values] 76 77 return self.args 78 79 def get_method(self): 80 if self.method is None: 81 self.method = getenv("REQUEST_METHOD") or "GET" 82 return self.method 83 84 def get_path(self): 85 if self.path is None: 86 self.path = getenv("SCRIPT_NAME") or "" 87 return self.path 88 89 def get_path_info(self): 90 if self.path_info is None: 91 self.path_info = getenv("PATH_INFO") or "" 92 return self.path_info 93 94 def get_user(self): 95 if self.user is None: 96 self.user = getenv("REMOTE_USER") or "" 97 return self.user 98 99 def get_output(self): 100 return sys.stdout 101 102 def get_url(self): 103 path = self.get_path() 104 path_info = self.get_path_info() 105 return "%s%s" % (path.rstrip("/"), path_info) 106 107 def new_url(self, path_info): 108 path = self.get_path() 109 return "%s/%s" % (path.rstrip("/"), path_info.lstrip("/")) 110 111 class Common: 112 113 "Common handler and manager methods." 114 115 def __init__(self, user): 116 self.user = user 117 self.preferences = None 118 119 def get_preferences(self): 120 if not self.preferences: 121 self.preferences = Preferences(self.user) 122 return self.preferences 123 124 def get_tzid(self): 125 prefs = self.get_preferences() 126 return prefs.get("TZID") or get_default_timezone() 127 128 class ManagerHandler(Handler, Common): 129 130 """ 131 A content handler for use by the manager, as opposed to operating within the 132 mail processing pipeline. 133 """ 134 135 def __init__(self, obj, user, messenger): 136 Handler.__init__(self, messenger=messenger) 137 Common.__init__(self, user) 138 139 self.set_object(obj) 140 141 self.organiser = self.obj.get_value("ORGANIZER") 142 self.attendees = self.obj.get_values("ATTENDEE") 143 144 # Communication methods. 145 146 def send_message(self, method, sender, for_organiser): 147 148 """ 149 Create a full calendar object employing the given 'method', and send it 150 to the appropriate recipients, also sending a copy to the 'sender'. The 151 'for_organiser' value indicates whether the organiser is sending this 152 message. 153 """ 154 155 parts = [self.obj.to_part(method)] 156 157 # As organiser, send an invitation to attendees, excluding oneself if 158 # also attending. The updated event will be saved by the outgoing 159 # handler. 160 161 if for_organiser: 162 recipients = [get_address(attendee) for attendee in self.attendees if attendee != self.user] 163 else: 164 recipients = [get_address(self.organiser)] 165 166 # Bundle free/busy information if appropriate. 167 168 preferences = Preferences(self.user) 169 170 if preferences.get("freebusy_sharing") == "share" and \ 171 preferences.get("freebusy_bundling") == "always": 172 173 # Invent a unique identifier. 174 175 utcnow = get_timestamp() 176 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 177 178 freebusy = self.store.get_freebusy(self.user) 179 180 # Replace the non-updated free/busy details for this event with 181 # newer details (since the outgoing handler updates this user's 182 # free/busy details). 183 184 tzid = self.get_tzid() 185 186 _update_freebusy(freebusy, self.obj.get_periods_for_freebusy(tzid), 187 self.obj.get_value("TRANSP") or "OPAQUE", self.obj.get_value("UID")) 188 189 user_attr = self.messenger and self.messenger.sender != get_address(self.user) and \ 190 {"SENT-BY" : get_uri(self.messenger.sender)} or {} 191 192 parts.append(to_part("PUBLISH", [ 193 make_freebusy(freebusy, uid, self.user, user_attr) 194 ])) 195 196 message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender) 197 self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender) 198 199 # Action methods. 200 201 def process_received_request(self, update=False): 202 203 """ 204 Process the current request for the given 'user'. Return whether any 205 action was taken. 206 207 If 'update' is given, the sequence number will be incremented in order 208 to override any previous response. 209 """ 210 211 # Reply only on behalf of this user. 212 213 for attendee, attendee_attr in self.obj.get_items("ATTENDEE"): 214 215 if attendee == self.user: 216 if attendee_attr.has_key("RSVP"): 217 del attendee_attr["RSVP"] 218 if self.messenger and self.messenger.sender != get_address(attendee): 219 attendee_attr["SENT-BY"] = get_uri(self.messenger.sender) 220 self.obj["ATTENDEE"] = [(attendee, attendee_attr)] 221 222 self.update_dtstamp() 223 self.set_sequence(update) 224 225 self.send_message("REPLY", get_address(attendee), for_organiser=False) 226 227 return True 228 229 return False 230 231 def process_created_request(self, method, update=False): 232 233 """ 234 Process the current request for the given 'user', sending a created 235 request of the given 'method' to attendees. Return whether any action 236 was taken. 237 238 If 'update' is given, the sequence number will be incremented in order 239 to override any previous message. 240 """ 241 242 organiser, organiser_attr = self.obj.get_item("ORGANIZER") 243 244 if self.messenger and self.messenger.sender != get_address(organiser): 245 organiser_attr["SENT-BY"] = get_uri(self.messenger.sender) 246 247 self.update_dtstamp() 248 self.set_sequence(update) 249 250 self.send_message(method, get_address(self.organiser), for_organiser=True) 251 return True 252 253 class Manager(Common): 254 255 "A simple manager application." 256 257 def __init__(self, messenger=None): 258 self.messenger = messenger or Messenger() 259 self.encoding = "utf-8" 260 self.env = CGIEnvironment(self.encoding) 261 262 user = self.env.get_user() 263 Common.__init__(self, user and get_uri(user) or None) 264 265 self.locale = None 266 self.requests = None 267 268 self.out = self.env.get_output() 269 self.page = markup.page() 270 271 self.store = imip_store.FileStore() 272 self.objects = {} 273 274 try: 275 self.publisher = imip_store.FilePublisher() 276 except OSError: 277 self.publisher = None 278 279 def _get_uid(self, path_info): 280 return path_info.lstrip("/").split("/", 1)[0] 281 282 def _get_object(self, uid): 283 if self.objects.has_key(uid): 284 return self.objects[uid] 285 286 fragment = uid and self.store.get_event(self.user, uid) or None 287 obj = self.objects[uid] = fragment and Object(fragment) 288 return obj 289 290 def _get_requests(self): 291 if self.requests is None: 292 self.requests = self.store.get_requests(self.user) 293 return self.requests 294 295 def _get_request_summary(self): 296 summary = [] 297 for uid in self._get_requests(): 298 obj = self._get_object(uid) 299 if obj: 300 summary.append(( 301 obj.get_value("DTSTART"), 302 obj.get_value("DTEND"), 303 uid 304 )) 305 return summary 306 307 # Preference methods. 308 309 def get_user_locale(self): 310 if not self.locale: 311 self.locale = self.get_preferences().get("LANG", "C") 312 return self.locale 313 314 # Prettyprinting of dates and times. 315 316 def format_date(self, dt, format): 317 return self._format_datetime(babel.dates.format_date, dt, format) 318 319 def format_time(self, dt, format): 320 return self._format_datetime(babel.dates.format_time, dt, format) 321 322 def format_datetime(self, dt, format): 323 return self._format_datetime( 324 isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date, 325 dt, format) 326 327 def _format_datetime(self, fn, dt, format): 328 return fn(dt, format=format, locale=self.get_user_locale()) 329 330 # Data management methods. 331 332 def remove_request(self, uid): 333 return self.store.dequeue_request(self.user, uid) 334 335 def remove_event(self, uid): 336 return self.store.remove_event(self.user, uid) 337 338 def update_freebusy(self, uid, obj): 339 tzid = self.get_tzid() 340 freebusy = self.store.get_freebusy(self.user) 341 update_freebusy(freebusy, self.user, obj.get_periods_for_freebusy(tzid), 342 obj.get_value("TRANSP"), uid, self.store) 343 344 def remove_from_freebusy(self, uid): 345 freebusy = self.store.get_freebusy(self.user) 346 remove_from_freebusy(freebusy, self.user, uid, self.store) 347 348 # Presentation methods. 349 350 def new_page(self, title): 351 self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css")) 352 353 def status(self, code, message): 354 self.header("Status", "%s %s" % (code, message)) 355 356 def header(self, header, value): 357 print >>self.out, "%s: %s" % (header, value) 358 359 def no_user(self): 360 self.status(403, "Forbidden") 361 self.new_page(title="Forbidden") 362 self.page.p("You are not logged in and thus cannot access scheduling requests.") 363 364 def no_page(self): 365 self.status(404, "Not Found") 366 self.new_page(title="Not Found") 367 self.page.p("No page is provided at the given address.") 368 369 def redirect(self, url): 370 self.status(302, "Redirect") 371 self.header("Location", url) 372 self.new_page(title="Redirect") 373 self.page.p("Redirecting to: %s" % url) 374 375 # Request logic methods. 376 377 def handle_newevent(self): 378 379 """ 380 Handle any new event operation, creating a new event and redirecting to 381 the event page for further activity. 382 """ 383 384 # Handle a submitted form. 385 386 args = self.env.get_args() 387 388 if not args.has_key("newevent"): 389 return 390 391 # Create a new event using the available information. 392 393 slots = args.get("slot", []) 394 participants = args.get("participants", []) 395 396 if not slots: 397 return 398 399 # Obtain the user's timezone. 400 401 tzid = self.get_tzid() 402 403 # Coalesce the selected slots. 404 405 slots.sort() 406 coalesced = [] 407 last = None 408 409 for slot in slots: 410 start, end = slot.split("-") 411 start = get_datetime(start, {"TZID" : tzid}) 412 end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid) 413 414 if last: 415 last_start, last_end = last 416 417 # Merge adjacent dates and datetimes. 418 419 if start == last_end or get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid): 420 last = last_start, end 421 continue 422 423 # Handle datetimes within dates. 424 # Datetime periods are within single days and are therefore 425 # discarded. 426 427 elif get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid): 428 continue 429 430 # Add separate dates and datetimes. 431 432 else: 433 coalesced.append(last) 434 435 last = start, end 436 437 if last: 438 coalesced.append(last) 439 440 # Invent a unique identifier. 441 442 utcnow = get_timestamp() 443 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 444 445 # Define a single occurrence if only one coalesced slot exists. 446 # Otherwise, many occurrences are defined. 447 448 for i, (start, end) in enumerate(coalesced): 449 this_uid = "%s-%s" % (uid, i) 450 451 start_value, start_attr = get_datetime_item(start, tzid) 452 end_value, end_attr = get_datetime_item(end, tzid) 453 454 # Create a calendar object and store it as a request. 455 456 record = [] 457 rwrite = record.append 458 459 rwrite(("UID", {}, this_uid)) 460 rwrite(("SUMMARY", {}, "New event at %s" % utcnow)) 461 rwrite(("DTSTAMP", {}, utcnow)) 462 rwrite(("DTSTART", start_attr, start_value)) 463 rwrite(("DTEND", end_attr, end_value)) 464 rwrite(("ORGANIZER", {}, self.user)) 465 466 for participant in participants: 467 if not participant: 468 continue 469 participant = get_uri(participant) 470 rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant)) 471 472 obj = ("VEVENT", {}, record) 473 474 self.store.set_event(self.user, this_uid, obj) 475 self.store.queue_request(self.user, this_uid) 476 477 # Redirect to the object (or the first of the objects), where instead of 478 # attendee controls, there will be organiser controls. 479 480 self.redirect(self.env.new_url("%s-0" % uid)) 481 482 def handle_request(self, uid, obj): 483 484 """ 485 Handle actions involving the given 'uid' and 'obj' object, returning an 486 error if one occurred, or None if the request was successfully handled. 487 """ 488 489 # Handle a submitted form. 490 491 args = self.env.get_args() 492 493 # Get the possible actions. 494 495 reply = args.has_key("reply") 496 discard = args.has_key("discard") 497 invite = args.has_key("invite") 498 cancel = args.has_key("cancel") 499 save = args.has_key("save") 500 501 have_action = reply or discard or invite or cancel or save 502 503 if not have_action: 504 return ["action"] 505 506 # Update the object. 507 508 if args.has_key("summary"): 509 obj["SUMMARY"] = [(args["summary"][0], {})] 510 511 if args.has_key("partstat"): 512 organisers = obj.get_value_map("ORGANIZER") 513 attendees = obj.get_value_map("ATTENDEE") 514 for d in attendees, organisers: 515 if d.has_key(self.user): 516 d[self.user]["PARTSTAT"] = args["partstat"][0] 517 if d[self.user].has_key("RSVP"): 518 del d[self.user]["RSVP"] 519 520 is_organiser = obj.get_value("ORGANIZER") == self.user 521 522 # Obtain the user's timezone and process datetime values. 523 524 update = False 525 526 if is_organiser: 527 dtend_enabled = args.get("dtend-control", [None])[0] == "enable" 528 dttimes_enabled = args.get("dttimes-control", [None])[0] == "enable" 529 530 t = self.handle_date_controls("dtstart", dttimes_enabled) 531 if t: 532 dtstart, attr = t 533 update = self.set_datetime_in_object(dtstart, attr.get("TZID"), "DTSTART", obj) or update 534 else: 535 return ["dtstart"] 536 537 # Handle specified end datetimes. 538 539 if dtend_enabled: 540 t = self.handle_date_controls("dtend", dttimes_enabled) 541 if t: 542 dtend, attr = t 543 544 # Convert end dates to iCalendar "next day" dates. 545 546 if not isinstance(dtend, datetime): 547 dtend += timedelta(1) 548 update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update 549 else: 550 return ["dtend"] 551 552 # Otherwise, treat the end date as the start date. Datetimes are 553 # handled by making the event occupy the rest of the day. 554 555 else: 556 dtend = dtstart + timedelta(1) 557 if isinstance(dtstart, datetime): 558 dtend = get_start_of_day(dtend, attr["TZID"]) 559 update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update 560 561 if dtstart >= dtend: 562 return ["dtstart", "dtend"] 563 564 # Process any action. 565 566 handled = True 567 568 if reply or invite or cancel: 569 570 handler = ManagerHandler(obj, self.user, self.messenger) 571 572 # Process the object and remove it from the list of requests. 573 574 if reply and handler.process_received_request(update) or \ 575 (invite or cancel) and handler.process_created_request(invite and "REQUEST" or "CANCEL", update): 576 577 self.remove_request(uid) 578 579 # Save single user events. 580 581 elif save: 582 self.store.set_event(self.user, uid, obj.to_node()) 583 self.update_freebusy(uid, obj) 584 self.remove_request(uid) 585 586 # Remove the request and the object. 587 588 elif discard: 589 self.remove_from_freebusy(uid) 590 self.remove_event(uid) 591 self.remove_request(uid) 592 593 else: 594 handled = False 595 596 # Upon handling an action, redirect to the main page. 597 598 if handled: 599 self.redirect(self.env.get_path()) 600 601 return None 602 603 def handle_date_controls(self, name, with_time=True): 604 605 """ 606 Handle date control information for fields starting with 'name', 607 returning a (datetime, attr) tuple or None if the fields cannot be used 608 to construct a datetime object. 609 """ 610 611 args = self.env.get_args() 612 613 if args.has_key("%s-date" % name): 614 date = args["%s-date" % name][0] 615 616 if with_time: 617 hour = args.get("%s-hour" % name, [None])[0] 618 minute = args.get("%s-minute" % name, [None])[0] 619 second = args.get("%s-second" % name, [None])[0] 620 tzid = args.get("%s-tzid" % name, [self.get_tzid()])[0] 621 622 time = (hour or minute or second) and "T%s%s%s" % (hour, minute, second) or "" 623 value = "%s%s" % (date, time) 624 attr = {"TZID" : tzid, "VALUE" : "DATE-TIME"} 625 dt = get_datetime(value, attr) 626 else: 627 attr = {"VALUE" : "DATE"} 628 dt = get_datetime(date) 629 630 if dt: 631 return dt, attr 632 633 return None 634 635 def set_datetime_in_object(self, dt, tzid, property, obj): 636 637 """ 638 Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether 639 an update has occurred. 640 """ 641 642 if dt: 643 old_value = obj.get_value(property) 644 obj[property] = [get_datetime_item(dt, tzid)] 645 return format_datetime(dt) != old_value 646 647 return False 648 649 # Page fragment methods. 650 651 def show_request_controls(self, obj): 652 653 "Show form controls for a request concerning 'obj'." 654 655 page = self.page 656 657 is_organiser = obj.get_value("ORGANIZER") == self.user 658 659 attendees = obj.get_value_map("ATTENDEE") 660 is_attendee = attendees.has_key(self.user) 661 attendee_attr = attendees.get(self.user) 662 663 is_request = obj.get_value("UID") in self._get_requests() 664 665 have_other_attendees = len(attendees) > (is_attendee and 1 or 0) 666 667 # Show appropriate options depending on the role of the user. 668 669 if is_attendee and not is_organiser: 670 page.p("An action is required for this request:") 671 672 page.p() 673 page.input(name="reply", type="submit", value="Reply") 674 page.add(" ") 675 page.input(name="discard", type="submit", value="Discard") 676 page.p.close() 677 678 if is_organiser: 679 if have_other_attendees: 680 page.p("As organiser, you can perform the following:") 681 682 page.p() 683 page.input(name="invite", type="submit", value="Invite") 684 page.add(" ") 685 if is_request: 686 page.input(name="discard", type="submit", value="Discard") 687 else: 688 page.input(name="cancel", type="submit", value="Cancel") 689 page.p.close() 690 else: 691 page.p() 692 page.input(name="save", type="submit", value="Save") 693 page.add(" ") 694 page.input(name="discard", type="submit", value="Discard") 695 page.p.close() 696 697 property_items = [ 698 ("SUMMARY", "Summary"), 699 ("DTSTART", "Start"), 700 ("DTEND", "End"), 701 ("ORGANIZER", "Organiser"), 702 ("ATTENDEE", "Attendee"), 703 ] 704 705 partstat_items = [ 706 ("NEEDS-ACTION", "Not confirmed"), 707 ("ACCEPTED", "Attending"), 708 ("TENTATIVE", "Tentatively attending"), 709 ("DECLINED", "Not attending"), 710 ("DELEGATED", "Delegated"), 711 ] 712 713 def show_object_on_page(self, uid, obj, error=None): 714 715 """ 716 Show the calendar object with the given 'uid' and representation 'obj' 717 on the current page. If 'error' is given, show a suitable message. 718 """ 719 720 page = self.page 721 page.form(method="POST") 722 723 # Obtain the user's timezone. 724 725 tzid = self.get_tzid() 726 727 # Provide controls to change the displayed object. 728 729 args = self.env.get_args() 730 dtend_control = args.get("dtend-control", [None])[0] 731 dttimes_control = args.get("dttimes-control", [None])[0] 732 with_time = dttimes_control == "enable" 733 734 t = self.handle_date_controls("dtstart", with_time) 735 if t: 736 dtstart, dtstart_attr = t 737 else: 738 dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") 739 740 if dtend_control == "enable": 741 t = self.handle_date_controls("dtend", with_time) 742 if t: 743 dtend, dtend_attr = t 744 else: 745 dtend, dtend_attr = None, {} 746 elif dtend_control == "disable": 747 dtend, dtend_attr = None, {} 748 else: 749 dtend, dtend_attr = obj.get_datetime_item("DTEND") 750 751 # Change end dates to refer to the actual dates, not the iCalendar 752 # "next day" dates. 753 754 if dtend and not isinstance(dtend, datetime): 755 dtend -= timedelta(1) 756 757 # Show the end datetime controls if already active or if an object needs 758 # them. 759 760 dtend_enabled = dtend_control == "enable" or isinstance(dtend, datetime) or dtstart != dtend 761 dttimes_enabled = dttimes_control == "enable" or isinstance(dtstart, datetime) or isinstance(dtend, datetime) 762 763 if dtend_enabled: 764 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked") 765 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable") 766 else: 767 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable") 768 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked") 769 770 if dttimes_enabled: 771 page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable", checked="checked") 772 page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable") 773 else: 774 page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable") 775 page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable", checked="checked") 776 777 # Provide a summary of the object. 778 779 page.table(class_="object", cellspacing=5, cellpadding=5) 780 page.thead() 781 page.tr() 782 page.th("Event", class_="mainheading", colspan=2) 783 page.tr.close() 784 page.thead.close() 785 page.tbody() 786 787 is_organiser = obj.get_value("ORGANIZER") == self.user 788 789 for name, label in self.property_items: 790 page.tr() 791 792 # Handle datetimes specially. 793 794 if name in ["DTSTART", "DTEND"]: 795 field = name.lower() 796 797 page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or "")) 798 799 # Obtain the datetime. 800 801 if name == "DTSTART": 802 dt, attr, event_tzid = dtstart, dtstart_attr, dtstart_attr.get("TZID", tzid) 803 804 # Where no end datetime exists, use the start datetime as the 805 # basis of any potential datetime specified if dt-control is 806 # set. 807 808 else: 809 dt, attr, event_tzid = dtend or dtstart, dtend_attr or dtstart_attr, (dtend_attr or dtstart_attr).get("TZID", tzid) 810 811 # Show controls for editing as organiser. 812 813 if is_organiser: 814 value = format_datetime(dt) 815 816 page.td(class_="objectvalue %s" % field) 817 if name == "DTEND": 818 page.div(class_="dt disabled") 819 page.label("Specify end date", for_="dtend-enable", class_="enable") 820 page.div.close() 821 822 page.div(class_="dt enabled") 823 self._show_date_controls(field, value, attr, tzid) 824 if name == "DTSTART": 825 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 826 page.label("Specify dates only", for_="dttimes-disable", class_="time enabled disable") 827 elif name == "DTEND": 828 page.label("End on same day", for_="dtend-disable", class_="disable") 829 page.div.close() 830 831 page.td.close() 832 833 # Show a label as attendee. 834 835 else: 836 page.td(self.format_datetime(dt, "full")) 837 838 page.tr.close() 839 840 # Handle the summary specially. 841 842 elif name == "SUMMARY": 843 value = args.get("summary", [obj.get_value(name)])[0] 844 845 page.th(label, class_="objectheading") 846 page.td() 847 if is_organiser: 848 page.input(name="summary", type="text", value=value, size=80) 849 else: 850 page.add(value) 851 page.td.close() 852 page.tr.close() 853 854 # Handle potentially many values. 855 856 else: 857 items = obj.get_items(name) 858 if not items: 859 continue 860 861 page.th(label, class_="objectheading", rowspan=len(items)) 862 863 first = True 864 865 for value, attr in items: 866 if not first: 867 page.tr() 868 else: 869 first = False 870 871 if name in ("ATTENDEE", "ORGANIZER"): 872 page.td(class_="objectattribute") 873 page.add(value) 874 page.add(" ") 875 876 partstat = attr.get("PARTSTAT") 877 if value == self.user and (not is_organiser or name == "ORGANIZER"): 878 self._show_menu("partstat", partstat, self.partstat_items) 879 else: 880 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") 881 else: 882 page.td(class_="objectattribute") 883 page.add(value) 884 885 page.td.close() 886 page.tr.close() 887 888 page.tbody.close() 889 page.table.close() 890 891 self.show_conflicting_events(uid, obj) 892 self.show_request_controls(obj) 893 894 page.form.close() 895 896 def show_conflicting_events(self, uid, obj): 897 898 """ 899 Show conflicting events for the object having the given 'uid' and 900 representation 'obj'. 901 """ 902 903 page = self.page 904 905 # Obtain the user's timezone. 906 907 tzid = self.get_tzid() 908 909 dtstart = format_datetime(obj.get_utc_datetime("DTSTART")) 910 dtend = format_datetime(obj.get_utc_datetime("DTEND")) 911 912 # Indicate whether there are conflicting events. 913 914 freebusy = self.store.get_freebusy(self.user) 915 916 if freebusy: 917 918 # Obtain any time zone details from the suggested event. 919 920 _dtstart, attr = obj.get_item("DTSTART") 921 tzid = attr.get("TZID", tzid) 922 923 # Show any conflicts. 924 925 conflicts = [t for t in have_conflict(freebusy, [(dtstart, dtend)], True) if t[2] != uid] 926 927 if conflicts: 928 page.p("This event conflicts with others:") 929 930 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 931 page.thead() 932 page.tr() 933 page.th("Event") 934 page.th("Start") 935 page.th("End") 936 page.tr.close() 937 page.thead.close() 938 page.tbody() 939 940 for t in conflicts: 941 start, end, found_uid = t[:3] 942 943 # Provide details of any conflicting event. 944 945 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long") 946 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long") 947 948 page.tr() 949 950 # Show the event summary for the conflicting event. 951 952 page.td() 953 954 found_obj = self._get_object(found_uid) 955 if found_obj: 956 page.a(found_obj.get_value("SUMMARY"), href=self.env.new_url(found_uid)) 957 else: 958 page.add("No details available") 959 960 page.td.close() 961 962 page.td(start) 963 page.td(end) 964 965 page.tr.close() 966 967 page.tbody.close() 968 page.table.close() 969 970 def show_requests_on_page(self): 971 972 "Show requests for the current user." 973 974 # NOTE: This list could be more informative, but it is envisaged that 975 # NOTE: the requests would be visited directly anyway. 976 977 requests = self._get_requests() 978 979 self.page.div(id="pending-requests") 980 981 if requests: 982 self.page.p("Pending requests:") 983 984 self.page.ul() 985 986 for request in requests: 987 obj = self._get_object(request) 988 if obj: 989 self.page.li() 990 self.page.a(obj.get_value("SUMMARY"), href="#request-%s" % request) 991 self.page.li.close() 992 993 self.page.ul.close() 994 995 else: 996 self.page.p("There are no pending requests.") 997 998 self.page.div.close() 999 1000 def show_participants_on_page(self): 1001 1002 "Show participants for scheduling purposes." 1003 1004 args = self.env.get_args() 1005 participants = args.get("participants", []) 1006 1007 try: 1008 for name, value in args.items(): 1009 if name.startswith("remove-participant-"): 1010 i = int(name[len("remove-participant-"):]) 1011 del participants[i] 1012 break 1013 except ValueError: 1014 pass 1015 1016 # Trim empty participants. 1017 1018 while participants and not participants[-1].strip(): 1019 participants.pop() 1020 1021 # Show any specified participants together with controls to remove and 1022 # add participants. 1023 1024 self.page.div(id="participants") 1025 1026 self.page.p("Participants for scheduling:") 1027 1028 for i, participant in enumerate(participants): 1029 self.page.p() 1030 self.page.input(name="participants", type="text", value=participant) 1031 self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 1032 self.page.p.close() 1033 1034 self.page.p() 1035 self.page.input(name="participants", type="text") 1036 self.page.input(name="add-participant", type="submit", value="Add") 1037 self.page.p.close() 1038 1039 self.page.div.close() 1040 1041 return participants 1042 1043 # Full page output methods. 1044 1045 def show_object(self, path_info): 1046 1047 "Show an object request using the given 'path_info' for the current user." 1048 1049 uid = self._get_uid(path_info) 1050 obj = self._get_object(uid) 1051 1052 if not obj: 1053 return False 1054 1055 error = self.handle_request(uid, obj) 1056 1057 if not error: 1058 return True 1059 1060 self.new_page(title="Event") 1061 self.show_object_on_page(uid, obj, error) 1062 1063 return True 1064 1065 def show_calendar(self): 1066 1067 "Show the calendar for the current user." 1068 1069 handled = self.handle_newevent() 1070 1071 self.new_page(title="Calendar") 1072 page = self.page 1073 1074 # Form controls are used in various places on the calendar page. 1075 1076 page.form(method="POST") 1077 1078 self.show_requests_on_page() 1079 participants = self.show_participants_on_page() 1080 1081 # Show a button for scheduling a new event. 1082 1083 page.p(class_="controls") 1084 page.input(name="newevent", type="submit", value="New event", id="newevent") 1085 page.input(name="reset", type="submit", value="Clear selections", id="reset") 1086 page.p.close() 1087 1088 # Show controls for hiding empty days and busy slots. 1089 # The positioning of the control, paragraph and table are important here. 1090 1091 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 1092 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 1093 1094 page.p(class_="controls") 1095 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 1096 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 1097 page.label("Show empty days", for_="showdays", class_="showdays disable") 1098 page.label("Hide empty days", for_="showdays", class_="showdays enable") 1099 page.p.close() 1100 1101 freebusy = self.store.get_freebusy(self.user) 1102 1103 if not freebusy: 1104 page.p("No events scheduled.") 1105 return 1106 1107 # Obtain the user's timezone. 1108 1109 tzid = self.get_tzid() 1110 1111 # Day view: start at the earliest known day and produce days until the 1112 # latest known day, perhaps with expandable sections of empty days. 1113 1114 # Month view: start at the earliest known month and produce months until 1115 # the latest known month, perhaps with expandable sections of empty 1116 # months. 1117 1118 # Details of users to invite to new events could be superimposed on the 1119 # calendar. 1120 1121 # Requests are listed and linked to their tentative positions in the 1122 # calendar. Other participants are also shown. 1123 1124 request_summary = self._get_request_summary() 1125 1126 period_groups = [request_summary, freebusy] 1127 period_group_types = ["request", "freebusy"] 1128 period_group_sources = ["Pending requests", "Your schedule"] 1129 1130 for i, participant in enumerate(participants): 1131 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 1132 period_group_types.append("freebusy-part%d" % i) 1133 period_group_sources.append(participant) 1134 1135 groups = [] 1136 group_columns = [] 1137 group_types = period_group_types 1138 group_sources = period_group_sources 1139 all_points = set() 1140 1141 # Obtain time point information for each group of periods. 1142 1143 for periods in period_groups: 1144 periods = convert_periods(periods, tzid) 1145 1146 # Get the time scale with start and end points. 1147 1148 scale = get_scale(periods) 1149 1150 # Get the time slots for the periods. 1151 1152 slots = get_slots(scale) 1153 1154 # Add start of day time points for multi-day periods. 1155 1156 add_day_start_points(slots, tzid) 1157 1158 # Record the slots and all time points employed. 1159 1160 groups.append(slots) 1161 all_points.update([point for point, active in slots]) 1162 1163 # Partition the groups into days. 1164 1165 days = {} 1166 partitioned_groups = [] 1167 partitioned_group_types = [] 1168 partitioned_group_sources = [] 1169 1170 for slots, group_type, group_source in zip(groups, group_types, group_sources): 1171 1172 # Propagate time points to all groups of time slots. 1173 1174 add_slots(slots, all_points) 1175 1176 # Count the number of columns employed by the group. 1177 1178 columns = 0 1179 1180 # Partition the time slots by day. 1181 1182 partitioned = {} 1183 1184 for day, day_slots in partition_by_day(slots).items(): 1185 intervals = [] 1186 last = None 1187 1188 for point, active in day_slots: 1189 columns = max(columns, len(active)) 1190 if last: 1191 intervals.append((last, point)) 1192 last = point 1193 1194 if last: 1195 intervals.append((last, None)) 1196 1197 if not days.has_key(day): 1198 days[day] = set() 1199 1200 # Convert each partition to a mapping from points to active 1201 # periods. 1202 1203 partitioned[day] = dict(day_slots) 1204 1205 # Record the divisions or intervals within each day. 1206 1207 days[day].update(intervals) 1208 1209 if group_type != "request" or columns: 1210 group_columns.append(columns) 1211 partitioned_groups.append(partitioned) 1212 partitioned_group_types.append(group_type) 1213 partitioned_group_sources.append(group_source) 1214 1215 # Add empty days. 1216 1217 add_empty_days(days, tzid) 1218 1219 # Show the controls permitting day selection. 1220 1221 self.show_calendar_day_controls(days) 1222 1223 # Show the calendar itself. 1224 1225 page.table(cellspacing=5, cellpadding=5, class_="calendar") 1226 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 1227 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 1228 page.table.close() 1229 1230 # End the form region. 1231 1232 page.form.close() 1233 1234 # More page fragment methods. 1235 1236 def show_calendar_day_controls(self, days): 1237 1238 "Show controls for the given 'days' in the calendar." 1239 1240 page = self.page 1241 slots = self.env.get_args().get("slot", []) 1242 1243 for day in days: 1244 value, identifier = self._day_value_and_identifier(day) 1245 self._slot_selector(value, identifier, slots) 1246 1247 # Generate a dynamic stylesheet to allow day selections to colour 1248 # specific days. 1249 # NOTE: The style details need to be coordinated with the static 1250 # NOTE: stylesheet. 1251 1252 page.style(type="text/css") 1253 1254 for day in days: 1255 daystr = format_datetime(day) 1256 page.add("""\ 1257 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, 1258 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { 1259 background-color: #5f4; 1260 text-decoration: underline; 1261 } 1262 """ % (daystr, daystr, daystr, daystr)) 1263 1264 page.style.close() 1265 1266 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 1267 1268 """ 1269 Show headings for the participants and other scheduling contributors, 1270 defined by 'group_types', 'group_sources' and 'group_columns'. 1271 """ 1272 1273 page = self.page 1274 1275 page.colgroup(span=1, id="columns-timeslot") 1276 1277 for group_type, columns in zip(group_types, group_columns): 1278 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 1279 1280 page.thead() 1281 page.tr() 1282 page.th("", class_="emptyheading") 1283 1284 for group_type, source, columns in zip(group_types, group_sources, group_columns): 1285 page.th(source, 1286 class_=(group_type == "request" and "requestheading" or "participantheading"), 1287 colspan=max(columns, 1)) 1288 1289 page.tr.close() 1290 page.thead.close() 1291 1292 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 1293 1294 """ 1295 Show calendar days, defined by a collection of 'days', the contributing 1296 period information as 'partitioned_groups' (partitioned by day), the 1297 'partitioned_group_types' indicating the kind of contribution involved, 1298 and the 'group_columns' defining the number of columns in each group. 1299 """ 1300 1301 page = self.page 1302 1303 # Determine the number of columns required. Where participants provide 1304 # no columns for events, one still needs to be provided for the 1305 # participant itself. 1306 1307 all_columns = sum([max(columns, 1) for columns in group_columns]) 1308 1309 # Determine the days providing time slots. 1310 1311 all_days = days.items() 1312 all_days.sort() 1313 1314 # Produce a heading and time points for each day. 1315 1316 for day, intervals in all_days: 1317 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 1318 is_empty = True 1319 1320 for slots in groups_for_day: 1321 if not slots: 1322 continue 1323 1324 for active in slots.values(): 1325 if active: 1326 is_empty = False 1327 break 1328 1329 page.thead(class_="separator%s" % (is_empty and " empty" or "")) 1330 page.tr() 1331 page.th(class_="dayheading container", colspan=all_columns+1) 1332 self._day_heading(day) 1333 page.th.close() 1334 page.tr.close() 1335 page.thead.close() 1336 1337 page.tbody(class_="points%s" % (is_empty and " empty" or "")) 1338 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 1339 page.tbody.close() 1340 1341 def show_calendar_points(self, intervals, groups, group_types, group_columns): 1342 1343 """ 1344 Show the time 'intervals' along with period information from the given 1345 'groups', having the indicated 'group_types', each with the number of 1346 columns given by 'group_columns'. 1347 """ 1348 1349 page = self.page 1350 1351 # Obtain the user's timezone. 1352 1353 tzid = self.get_tzid() 1354 1355 # Produce a row for each interval. 1356 1357 intervals = list(intervals) 1358 intervals.sort() 1359 1360 for point, endpoint in intervals: 1361 continuation = point == get_start_of_day(point, tzid) 1362 1363 # Some rows contain no period details and are marked as such. 1364 1365 have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None) 1366 1367 css = " ".join( 1368 ["slot"] + 1369 (have_active and ["busy"] or ["empty"]) + 1370 (continuation and ["daystart"] or []) 1371 ) 1372 1373 page.tr(class_=css) 1374 page.th(class_="timeslot") 1375 self._time_point(point, endpoint) 1376 page.th.close() 1377 1378 # Obtain slots for the time point from each group. 1379 1380 for columns, slots, group_type in zip(group_columns, groups, group_types): 1381 active = slots and slots.get(point) 1382 1383 # Where no periods exist for the given time interval, generate 1384 # an empty cell. Where a participant provides no periods at all, 1385 # the colspan is adjusted to be 1, not 0. 1386 1387 if not active: 1388 page.td(class_="empty container", colspan=max(columns, 1)) 1389 self._empty_slot(point, endpoint) 1390 page.td.close() 1391 continue 1392 1393 slots = slots.items() 1394 slots.sort() 1395 spans = get_spans(slots) 1396 1397 empty = 0 1398 1399 # Show a column for each active period. 1400 1401 for t in active: 1402 if t and len(t) >= 2: 1403 1404 # Flush empty slots preceding this one. 1405 1406 if empty: 1407 page.td(class_="empty container", colspan=empty) 1408 self._empty_slot(point, endpoint) 1409 page.td.close() 1410 empty = 0 1411 1412 start, end, uid, key = get_freebusy_details(t) 1413 span = spans[key] 1414 1415 # Produce a table cell only at the start of the period 1416 # or when continued at the start of a day. 1417 1418 if point == start or continuation: 1419 1420 obj = self._get_object(uid) 1421 1422 has_continued = continuation and point != start 1423 will_continue = not ends_on_same_day(point, end, tzid) 1424 is_organiser = obj and obj.get_value("ORGANIZER") == self.user 1425 1426 css = " ".join( 1427 ["event"] + 1428 (has_continued and ["continued"] or []) + 1429 (will_continue and ["continues"] or []) + 1430 (is_organiser and ["organising"] or ["attending"]) 1431 ) 1432 1433 # Only anchor the first cell of events. 1434 1435 if point == start: 1436 page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid)) 1437 else: 1438 page.td(class_=css, rowspan=span) 1439 1440 if not obj: 1441 page.span("(Participant is busy)") 1442 else: 1443 summary = obj.get_value("SUMMARY") 1444 1445 # Only link to events if they are not being 1446 # updated by requests. 1447 1448 if uid in self._get_requests() and group_type != "request": 1449 page.span(summary) 1450 else: 1451 href = "%s/%s" % (self.env.get_url().rstrip("/"), uid) 1452 page.a(summary, href=href) 1453 1454 page.td.close() 1455 else: 1456 empty += 1 1457 1458 # Pad with empty columns. 1459 1460 empty = columns - len(active) 1461 1462 if empty: 1463 page.td(class_="empty container", colspan=empty) 1464 self._empty_slot(point, endpoint) 1465 page.td.close() 1466 1467 page.tr.close() 1468 1469 def _day_heading(self, day): 1470 1471 """ 1472 Generate a heading for 'day' of the following form: 1473 1474 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 1475 """ 1476 1477 page = self.page 1478 daystr = format_datetime(day) 1479 value, identifier = self._day_value_and_identifier(day) 1480 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 1481 1482 def _time_point(self, point, endpoint): 1483 1484 """ 1485 Generate headings for the 'point' to 'endpoint' period of the following 1486 form: 1487 1488 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1489 <span class="endpoint">10:00:00 CET</span> 1490 """ 1491 1492 page = self.page 1493 tzid = self.get_tzid() 1494 daystr = format_datetime(point.date()) 1495 value, identifier = self._slot_value_and_identifier(point, endpoint) 1496 slots = self.env.get_args().get("slot", []) 1497 self._slot_selector(value, identifier, slots) 1498 page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 1499 page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") 1500 1501 def _slot_selector(self, value, identifier, slots): 1502 reset = self.env.get_args().has_key("reset") 1503 page = self.page 1504 if not reset and value in slots: 1505 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 1506 else: 1507 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 1508 1509 def _empty_slot(self, point, endpoint): 1510 page = self.page 1511 value, identifier = self._slot_value_and_identifier(point, endpoint) 1512 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 1513 1514 def _day_value_and_identifier(self, day): 1515 value = "%s-" % format_datetime(day) 1516 identifier = "day-%s" % value 1517 return value, identifier 1518 1519 def _slot_value_and_identifier(self, point, endpoint): 1520 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 1521 identifier = "slot-%s" % value 1522 return value, identifier 1523 1524 def _show_menu(self, name, default, items): 1525 page = self.page 1526 values = self.env.get_args().get(name, [default]) 1527 page.select(name=name) 1528 for v, label in items: 1529 if v in values: 1530 page.option(label, value=v, selected="selected") 1531 else: 1532 page.option(label, value=v) 1533 page.select.close() 1534 1535 def _show_date_controls(self, name, default, attr, tzid): 1536 1537 """ 1538 Show date controls for a field with the given 'name' and 'default' value 1539 and 'attr', with the given 'tzid' being used if no other time regime 1540 information is provided. 1541 """ 1542 1543 page = self.page 1544 args = self.env.get_args() 1545 1546 event_tzid = attr.get("TZID", tzid) 1547 dt = get_datetime(default, attr) 1548 1549 # Show dates for up to one week around the current date. 1550 1551 base = get_date(dt) 1552 items = [] 1553 for i in range(-7, 8): 1554 d = base + timedelta(i) 1555 items.append((format_datetime(d), self.format_date(d, "full"))) 1556 1557 self._show_menu("%s-date" % name, format_datetime(base), items) 1558 1559 # Show time details. 1560 1561 dt_time = isinstance(dt, datetime) and dt or None 1562 hour = args.get("%s-hour" % name, "%02d" % (dt_time and dt_time.hour or 0)) 1563 minute = args.get("%s-minute" % name, "%02d" % (dt_time and dt_time.minute or 0)) 1564 second = args.get("%s-second" % name, "%02d" % (dt_time and dt_time.second or 0)) 1565 1566 page.span(class_="time enabled") 1567 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 1568 page.add(":") 1569 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 1570 page.add(":") 1571 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 1572 page.add(" ") 1573 self._show_menu("%s-tzid" % name, event_tzid, 1574 [(event_tzid, event_tzid)] + ( 1575 event_tzid != tzid and [(tzid, tzid)] or [] 1576 )) 1577 page.span.close() 1578 1579 # Incoming HTTP request direction. 1580 1581 def select_action(self): 1582 1583 "Select the desired action and show the result." 1584 1585 path_info = self.env.get_path_info().strip("/") 1586 1587 if not path_info: 1588 self.show_calendar() 1589 elif self.show_object(path_info): 1590 pass 1591 else: 1592 self.no_page() 1593 1594 def __call__(self): 1595 1596 "Interpret a request and show an appropriate response." 1597 1598 if not self.user: 1599 self.no_user() 1600 else: 1601 self.select_action() 1602 1603 # Write the headers and actual content. 1604 1605 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 1606 print >>self.out 1607 self.out.write(unicode(self.page).encode(self.encoding)) 1608 1609 if __name__ == "__main__": 1610 Manager()() 1611 1612 # vim: tabstop=4 expandtab shiftwidth=4