imip-agent

Annotated imip_manager.py

154:dc9a25250a46
2015-01-13 Paul Boddie Display times in the preferred time zone if no explicit event TZID is given. Added links to conflicting events.
paul@69 1
#!/usr/bin/env python
paul@69 2
paul@146 3
"""
paul@146 4
A Web interface to a user's calendar.
paul@146 5
paul@146 6
Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>
paul@146 7
paul@146 8
This program is free software; you can redistribute it and/or modify it under
paul@146 9
the terms of the GNU General Public License as published by the Free Software
paul@146 10
Foundation; either version 3 of the License, or (at your option) any later
paul@146 11
version.
paul@146 12
paul@146 13
This program is distributed in the hope that it will be useful, but WITHOUT
paul@146 14
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
paul@146 15
FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
paul@146 16
details.
paul@146 17
paul@146 18
You should have received a copy of the GNU General Public License along with
paul@146 19
this program.  If not, see <http://www.gnu.org/licenses/>.
paul@146 20
"""
paul@146 21
paul@146 22
# Edit this path to refer to the location of the imiptools libraries, if
paul@146 23
# necessary.
paul@146 24
paul@146 25
LIBRARY_PATH = "/var/lib/imip-agent"
paul@146 26
paul@149 27
import babel.dates
paul@149 28
import cgi, os, sys
paul@69 29
paul@146 30
sys.path.append(LIBRARY_PATH)
paul@69 31
paul@153 32
from imiptools.content import Handler, get_address, \
paul@117 33
                              get_item, get_uri, get_utc_datetime, get_value, \
paul@153 34
                              get_values, parse_object, to_part
paul@153 35
from imiptools.dates import format_datetime, get_datetime, get_start_of_day, \
paul@153 36
                            to_timezone
paul@83 37
from imiptools.mail import Messenger
paul@153 38
from imiptools.period import have_conflict, get_slots, get_spans, partition_slots
paul@147 39
from imiptools.profile import Preferences
paul@77 40
from vCalendar import to_node
paul@69 41
import markup
paul@69 42
import imip_store
paul@69 43
paul@69 44
getenv = os.environ.get
paul@69 45
setenv = os.environ.__setitem__
paul@69 46
paul@69 47
class CGIEnvironment:
paul@69 48
paul@69 49
    "A CGI-compatible environment."
paul@69 50
paul@69 51
    def __init__(self):
paul@69 52
        self.args = None
paul@69 53
        self.method = None
paul@69 54
        self.path = None
paul@69 55
        self.path_info = None
paul@69 56
        self.user = None
paul@69 57
paul@69 58
    def get_args(self):
paul@69 59
        if self.args is None:
paul@69 60
            if self.get_method() != "POST":
paul@69 61
                setenv("QUERY_STRING", "")
paul@69 62
            self.args = cgi.parse(keep_blank_values=True)
paul@69 63
        return self.args
paul@69 64
paul@69 65
    def get_method(self):
paul@69 66
        if self.method is None:
paul@69 67
            self.method = getenv("REQUEST_METHOD") or "GET"
paul@69 68
        return self.method
paul@69 69
paul@69 70
    def get_path(self):
paul@69 71
        if self.path is None:
paul@69 72
            self.path = getenv("SCRIPT_NAME") or ""
paul@69 73
        return self.path
paul@69 74
paul@69 75
    def get_path_info(self):
paul@69 76
        if self.path_info is None:
paul@69 77
            self.path_info = getenv("PATH_INFO") or ""
paul@69 78
        return self.path_info
paul@69 79
paul@69 80
    def get_user(self):
paul@69 81
        if self.user is None:
paul@69 82
            self.user = getenv("REMOTE_USER") or ""
paul@69 83
        return self.user
paul@69 84
paul@69 85
    def get_output(self):
paul@69 86
        return sys.stdout
paul@69 87
paul@69 88
    def get_url(self):
paul@69 89
        path = self.get_path()
paul@69 90
        path_info = self.get_path_info()
paul@69 91
        return "%s%s" % (path.rstrip("/"), path_info)
paul@69 92
paul@154 93
    def new_url(self, path_info):
paul@154 94
        path = self.get_path()
paul@154 95
        return "%s/%s" % (path.rstrip("/"), path_info.lstrip("/"))
paul@154 96
paul@79 97
class ManagerHandler(Handler):
paul@79 98
paul@121 99
    """
paul@121 100
    A content handler for use by the manager, as opposed to operating within the
paul@121 101
    mail processing pipeline.
paul@121 102
    """
paul@79 103
paul@121 104
    def __init__(self, obj, user, messenger):
paul@121 105
        details, details_attr = obj.values()[0]
paul@79 106
        Handler.__init__(self, details)
paul@121 107
        self.obj = obj
paul@79 108
        self.user = user
paul@82 109
        self.messenger = messenger
paul@82 110
paul@79 111
        self.organisers = map(get_address, self.get_values("ORGANIZER"))
paul@79 112
paul@79 113
    # Communication methods.
paul@79 114
paul@79 115
    def send_message(self, sender):
paul@79 116
paul@79 117
        """
paul@128 118
        Create a full calendar object and send it to the organisers, sending a
paul@128 119
        copy to the 'sender'.
paul@79 120
        """
paul@79 121
paul@121 122
        node = to_node(self.obj)
paul@79 123
        part = to_part("REPLY", [node])
paul@128 124
        message = self.messenger.make_message([part], self.organisers, outgoing_bcc=sender)
paul@128 125
        self.messenger.sendmail(self.organisers, message.as_string(), outgoing_bcc=sender)
paul@79 126
paul@79 127
    # Action methods.
paul@79 128
paul@79 129
    def process_request(self, accept):
paul@79 130
paul@79 131
        """
paul@79 132
        Process the current request for the given 'user', accepting any request
paul@79 133
        when 'accept' is true, declining requests otherwise. Return whether any
paul@79 134
        action was taken.
paul@79 135
        """
paul@79 136
paul@79 137
        # When accepting or declining, do so only on behalf of this user,
paul@79 138
        # preserving any other attributes set as an attendee.
paul@79 139
paul@79 140
        for attendee, attendee_attr in self.get_items("ATTENDEE"):
paul@79 141
paul@79 142
            if attendee == self.user:
paul@79 143
                freebusy = self.store.get_freebusy(attendee)
paul@79 144
paul@79 145
                attendee_attr["PARTSTAT"] = accept and "ACCEPTED" or "DECLINED"
paul@128 146
                if self.messenger and self.messenger.sender != get_address(attendee):
paul@128 147
                    attendee_attr["SENT-BY"] = get_uri(self.messenger.sender)
paul@79 148
                self.details["ATTENDEE"] = [(attendee, attendee_attr)]
paul@79 149
                self.send_message(get_address(attendee))
paul@79 150
paul@79 151
                return True
paul@79 152
paul@79 153
        return False
paul@79 154
paul@69 155
class Manager:
paul@69 156
paul@69 157
    "A simple manager application."
paul@69 158
paul@82 159
    def __init__(self, messenger=None):
paul@82 160
        self.messenger = messenger or Messenger()
paul@82 161
paul@69 162
        self.env = CGIEnvironment()
paul@69 163
        user = self.env.get_user()
paul@77 164
        self.user = user and get_uri(user) or None
paul@147 165
        self.preferences = None
paul@149 166
        self.locale = None
paul@121 167
        self.requests = None
paul@121 168
paul@69 169
        self.out = self.env.get_output()
paul@69 170
        self.page = markup.page()
paul@69 171
        self.encoding = "utf-8"
paul@69 172
paul@77 173
        self.store = imip_store.FileStore()
paul@77 174
paul@77 175
        try:
paul@77 176
            self.publisher = imip_store.FilePublisher()
paul@77 177
        except OSError:
paul@77 178
            self.publisher = None
paul@77 179
paul@121 180
    def _get_uid(self, path_info):
paul@121 181
        return path_info.lstrip("/").split("/", 1)[0]
paul@121 182
paul@117 183
    def _get_object(self, uid):
paul@117 184
        f = uid and self.store.get_event(self.user, uid) or None
paul@117 185
paul@117 186
        if not f:
paul@117 187
            return None
paul@117 188
paul@117 189
        obj = parse_object(f, "utf-8")
paul@117 190
paul@117 191
        if not obj:
paul@117 192
            return None
paul@117 193
paul@121 194
        return obj
paul@121 195
paul@121 196
    def _get_details(self, obj):
paul@121 197
        details, details_attr = obj.values()[0]
paul@121 198
        return details
paul@121 199
paul@121 200
    def _get_requests(self):
paul@121 201
        if self.requests is None:
paul@121 202
            self.requests = self.store.get_requests(self.user)
paul@121 203
        return self.requests
paul@117 204
paul@147 205
    # Preference methods.
paul@147 206
paul@149 207
    def get_user_locale(self):
paul@149 208
        if not self.locale:
paul@149 209
            self.locale = self.get_preferences().get("LANG", "C")
paul@149 210
        return self.locale
paul@147 211
paul@147 212
    def get_preferences(self):
paul@147 213
        if not self.preferences:
paul@147 214
            self.preferences = Preferences(self.user)
paul@147 215
        return self.preferences
paul@147 216
paul@149 217
    def format_date(self, dt, format):
paul@149 218
        return self._format_datetime(babel.dates.format_date, dt, format)
paul@149 219
paul@149 220
    def format_time(self, dt, format):
paul@149 221
        return self._format_datetime(babel.dates.format_time, dt, format)
paul@149 222
paul@149 223
    def format_datetime(self, dt, format):
paul@149 224
        return self._format_datetime(babel.dates.format_datetime, dt, format)
paul@149 225
paul@149 226
    def _format_datetime(self, fn, dt, format):
paul@149 227
        return fn(dt, format=format, locale=self.get_user_locale())
paul@149 228
paul@78 229
    # Data management methods.
paul@78 230
paul@78 231
    def remove_request(self, uid):
paul@105 232
        return self.store.dequeue_request(self.user, uid)
paul@78 233
paul@78 234
    # Presentation methods.
paul@78 235
paul@69 236
    def new_page(self, title):
paul@69 237
        self.page.init(title=title, charset=self.encoding)
paul@69 238
paul@69 239
    def status(self, code, message):
paul@123 240
        self.header("Status", "%s %s" % (code, message))
paul@123 241
paul@123 242
    def header(self, header, value):
paul@123 243
        print >>self.out, "%s: %s" % (header, value)
paul@69 244
paul@69 245
    def no_user(self):
paul@69 246
        self.status(403, "Forbidden")
paul@69 247
        self.new_page(title="Forbidden")
paul@69 248
        self.page.p("You are not logged in and thus cannot access scheduling requests.")
paul@69 249
paul@70 250
    def no_page(self):
paul@70 251
        self.status(404, "Not Found")
paul@70 252
        self.new_page(title="Not Found")
paul@70 253
        self.page.p("No page is provided at the given address.")
paul@70 254
paul@123 255
    def redirect(self, url):
paul@123 256
        self.status(302, "Redirect")
paul@123 257
        self.header("Location", url)
paul@123 258
        self.new_page(title="Redirect")
paul@123 259
        self.page.p("Redirecting to: %s" % url)
paul@123 260
paul@121 261
    # Request logic and page fragment methods.
paul@121 262
paul@121 263
    def handle_request(self, uid, request):
paul@121 264
paul@121 265
        "Handle actions involving the given 'uid' and 'request' object."
paul@121 266
paul@121 267
        # Handle a submitted form.
paul@121 268
paul@121 269
        args = self.env.get_args()
paul@123 270
        handled = True
paul@121 271
paul@121 272
        accept = args.has_key("accept")
paul@121 273
        decline = args.has_key("decline")
paul@121 274
paul@121 275
        if accept or decline:
paul@121 276
paul@121 277
            handler = ManagerHandler(request, self.user, self.messenger)
paul@121 278
paul@121 279
            if handler.process_request(accept):
paul@121 280
paul@121 281
                # Remove the request from the list.
paul@121 282
paul@121 283
                self.remove_request(uid)
paul@121 284
paul@121 285
        elif args.has_key("ignore"):
paul@121 286
paul@121 287
            # Remove the request from the list.
paul@121 288
paul@121 289
            self.remove_request(uid)
paul@121 290
paul@121 291
        else:
paul@123 292
            handled = False
paul@121 293
paul@123 294
        if handled:
paul@123 295
            self.redirect(self.env.get_path())
paul@123 296
paul@123 297
        return handled
paul@121 298
paul@121 299
    def show_request_form(self):
paul@121 300
paul@121 301
        "Show a form for a request."
paul@121 302
paul@121 303
        self.page.p("Action to take for this request:")
paul@121 304
        self.page.form(method="POST")
paul@121 305
        self.page.p()
paul@121 306
        self.page.input(name="accept", type="submit", value="Accept")
paul@121 307
        self.page.add(" ")
paul@121 308
        self.page.input(name="decline", type="submit", value="Decline")
paul@121 309
        self.page.add(" ")
paul@121 310
        self.page.input(name="ignore", type="submit", value="Ignore")
paul@121 311
        self.page.p.close()
paul@121 312
        self.page.form.close()
paul@121 313
paul@121 314
    def show_object_on_page(self, uid, obj):
paul@121 315
paul@121 316
        """
paul@121 317
        Show the calendar object with the given 'uid' and representation 'obj'
paul@121 318
        on the current page.
paul@121 319
        """
paul@121 320
paul@154 321
        # Obtain the user's timezone.
paul@154 322
paul@154 323
        prefs = self.get_preferences()
paul@154 324
        tzid = prefs.get("TZID", "UTC")
paul@121 325
paul@121 326
        # Provide a summary of the object.
paul@121 327
paul@154 328
        details = self._get_details(obj)
paul@154 329
paul@121 330
        self.page.dl()
paul@121 331
paul@121 332
        for name in ["SUMMARY", "DTSTART", "DTEND", "ORGANIZER", "ATTENDEE"]:
paul@147 333
            if name in ["DTSTART", "DTEND"]:
paul@147 334
                value, attr = get_item(details, name)
paul@154 335
                tzid = attr.get("TZID", tzid)
paul@149 336
                value = self.format_datetime(to_timezone(get_datetime(value), tzid), "full")
paul@121 337
                self.page.dt(name)
paul@121 338
                self.page.dd(value)
paul@147 339
            else:
paul@147 340
                for value in get_values(details, name):
paul@147 341
                    self.page.dt(name)
paul@147 342
                    self.page.dd(value)
paul@121 343
paul@121 344
        self.page.dl.close()
paul@121 345
paul@121 346
        dtstart = format_datetime(get_utc_datetime(details, "DTSTART"))
paul@121 347
        dtend = format_datetime(get_utc_datetime(details, "DTEND"))
paul@121 348
paul@121 349
        # Indicate whether there are conflicting events.
paul@121 350
paul@121 351
        freebusy = self.store.get_freebusy(self.user)
paul@121 352
paul@121 353
        if freebusy:
paul@121 354
paul@121 355
            # Obtain any time zone details from the suggested event.
paul@121 356
paul@121 357
            _dtstart, attr = get_item(details, "DTSTART")
paul@154 358
            tzid = attr.get("TZID", tzid)
paul@121 359
paul@121 360
            # Show any conflicts.
paul@121 361
paul@121 362
            for t in have_conflict(freebusy, [(dtstart, dtend)], True):
paul@121 363
                start, end, found_uid = t[:3]
paul@154 364
paul@154 365
                # Provide details of any conflicting event.
paul@154 366
paul@121 367
                if uid != found_uid:
paul@149 368
                    start = self.format_datetime(to_timezone(get_datetime(start), tzid), "full")
paul@149 369
                    end = self.format_datetime(to_timezone(get_datetime(end), tzid), "full")
paul@154 370
                    self.page.p("Event conflicts with another from %s to %s: " % (start, end))
paul@154 371
paul@154 372
                    # Show the event summary for the conflicting event.
paul@154 373
paul@154 374
                    found_obj = self._get_object(found_uid)
paul@154 375
                    if found_obj:
paul@154 376
                        found_details = self._get_details(found_obj)
paul@154 377
                        self.page.a(get_value(found_details, "SUMMARY"), href=self.env.new_url(found_uid))
paul@121 378
paul@121 379
    def show_requests_on_page(self):
paul@69 380
paul@69 381
        "Show requests for the current user."
paul@69 382
paul@69 383
        # NOTE: This list could be more informative, but it is envisaged that
paul@69 384
        # NOTE: the requests would be visited directly anyway.
paul@69 385
paul@121 386
        requests = self._get_requests()
paul@70 387
paul@80 388
        if requests:
paul@114 389
            self.page.p("Pending requests:")
paul@114 390
paul@80 391
            self.page.ul()
paul@69 392
paul@80 393
            for request in requests:
paul@80 394
                self.page.li()
paul@80 395
                self.page.a(request, href="%s/%s" % (self.env.get_url().rstrip("/"), request))
paul@80 396
                self.page.li.close()
paul@80 397
paul@80 398
            self.page.ul.close()
paul@80 399
paul@80 400
        else:
paul@80 401
            self.page.p("There are no pending requests.")
paul@69 402
paul@121 403
    # Full page output methods.
paul@70 404
paul@121 405
    def show_object(self, path_info):
paul@70 406
paul@121 407
        "Show an object request using the given 'path_info' for the current user."
paul@70 408
paul@121 409
        uid = self._get_uid(path_info)
paul@121 410
        obj = self._get_object(uid)
paul@121 411
paul@121 412
        if not obj:
paul@70 413
            return False
paul@70 414
paul@123 415
        is_request = uid in self._get_requests()
paul@123 416
        handled = is_request and self.handle_request(uid, obj)
paul@77 417
paul@123 418
        if handled:
paul@123 419
            return True
paul@73 420
paul@123 421
        self.new_page(title="Event")
paul@79 422
paul@121 423
        self.show_object_on_page(uid, obj)
paul@73 424
paul@123 425
        if is_request and not handled:
paul@121 426
            self.show_request_form()
paul@73 427
paul@70 428
        return True
paul@70 429
paul@114 430
    def show_calendar(self):
paul@114 431
paul@114 432
        "Show the calendar for the current user."
paul@114 433
paul@114 434
        self.new_page(title="Calendar")
paul@121 435
        self.show_requests_on_page()
paul@114 436
paul@114 437
        freebusy = self.store.get_freebusy(self.user)
paul@114 438
        page = self.page
paul@114 439
paul@114 440
        if not freebusy:
paul@114 441
            page.p("No events scheduled.")
paul@114 442
            return
paul@114 443
paul@154 444
        # Obtain the user's timezone.
paul@147 445
paul@147 446
        prefs = self.get_preferences()
paul@153 447
        tzid = prefs.get("TZID", "UTC")
paul@147 448
paul@114 449
        # Day view: start at the earliest known day and produce days until the
paul@114 450
        # latest known day, perhaps with expandable sections of empty days.
paul@114 451
paul@114 452
        # Month view: start at the earliest known month and produce months until
paul@114 453
        # the latest known month, perhaps with expandable sections of empty
paul@114 454
        # months.
paul@114 455
paul@114 456
        # Details of users to invite to new events could be superimposed on the
paul@114 457
        # calendar.
paul@114 458
paul@114 459
        # Requests could be listed and linked to their tentative positions in
paul@114 460
        # the calendar.
paul@114 461
paul@114 462
        slots = get_slots(freebusy)
paul@153 463
        partitioned = partition_slots(slots, tzid)
paul@153 464
        columns = max(map(lambda i: len(i[1]), slots)) + 1
paul@114 465
paul@114 466
        page.table(border=1, cellspacing=0, cellpadding=5)
paul@114 467
paul@153 468
        for day, slots in partitioned:
paul@153 469
            spans = get_spans(slots)
paul@147 470
paul@114 471
            page.tr()
paul@153 472
            page.th(class_="dayheading", colspan=columns)
paul@153 473
            page.add(self.format_date(day, "full"))
paul@114 474
            page.th.close()
paul@153 475
            page.tr.close()
paul@114 476
paul@153 477
            for point, active in slots:
paul@153 478
                dt = to_timezone(get_datetime(point), tzid)
paul@153 479
                continuation = dt == get_start_of_day(dt)
paul@153 480
paul@153 481
                page.tr()
paul@153 482
                page.th(class_="timeslot")
paul@153 483
                page.add(self.format_time(dt, "long"))
paul@153 484
                page.th.close()
paul@117 485
paul@153 486
                for t in active:
paul@153 487
                    if t:
paul@153 488
                        start, end, uid = t[:3]
paul@153 489
                        span = spans[uid]
paul@153 490
                        if point == start or continuation:
paul@153 491
paul@153 492
                            page.td(class_="event", rowspan=span)
paul@153 493
                            obj = self._get_object(uid)
paul@153 494
                            if obj:
paul@153 495
                                details = self._get_details(obj)
paul@153 496
                                page.a(get_value(details, "SUMMARY"), href="%s/%s" % (self.env.get_url().rstrip("/"), uid))
paul@153 497
                            page.td.close()
paul@153 498
                    else:
paul@153 499
                        page.td(class_="empty")
paul@114 500
                        page.td.close()
paul@114 501
paul@153 502
                page.tr.close()
paul@114 503
paul@114 504
        page.table.close()
paul@114 505
paul@69 506
    def select_action(self):
paul@69 507
paul@69 508
        "Select the desired action and show the result."
paul@69 509
paul@121 510
        path_info = self.env.get_path_info().strip("/")
paul@121 511
paul@69 512
        if not path_info:
paul@114 513
            self.show_calendar()
paul@121 514
        elif self.show_object(path_info):
paul@70 515
            pass
paul@70 516
        else:
paul@70 517
            self.no_page()
paul@69 518
paul@82 519
    def __call__(self):
paul@69 520
paul@69 521
        "Interpret a request and show an appropriate response."
paul@69 522
paul@69 523
        if not self.user:
paul@69 524
            self.no_user()
paul@69 525
        else:
paul@69 526
            self.select_action()
paul@69 527
paul@70 528
        # Write the headers and actual content.
paul@70 529
paul@69 530
        print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding
paul@69 531
        print >>self.out
paul@69 532
        self.out.write(unicode(self.page).encode(self.encoding))
paul@69 533
paul@69 534
if __name__ == "__main__":
paul@128 535
    Manager()()
paul@69 536
paul@69 537
# vim: tabstop=4 expandtab shiftwidth=4