imip-agent

Annotated imip_manager.py

153:731f81633ac5
2015-01-13 Paul Boddie Introduced day headings that work with multiple day 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@79 93
class ManagerHandler(Handler):
paul@79 94
paul@121 95
    """
paul@121 96
    A content handler for use by the manager, as opposed to operating within the
paul@121 97
    mail processing pipeline.
paul@121 98
    """
paul@79 99
paul@121 100
    def __init__(self, obj, user, messenger):
paul@121 101
        details, details_attr = obj.values()[0]
paul@79 102
        Handler.__init__(self, details)
paul@121 103
        self.obj = obj
paul@79 104
        self.user = user
paul@82 105
        self.messenger = messenger
paul@82 106
paul@79 107
        self.organisers = map(get_address, self.get_values("ORGANIZER"))
paul@79 108
paul@79 109
    # Communication methods.
paul@79 110
paul@79 111
    def send_message(self, sender):
paul@79 112
paul@79 113
        """
paul@128 114
        Create a full calendar object and send it to the organisers, sending a
paul@128 115
        copy to the 'sender'.
paul@79 116
        """
paul@79 117
paul@121 118
        node = to_node(self.obj)
paul@79 119
        part = to_part("REPLY", [node])
paul@128 120
        message = self.messenger.make_message([part], self.organisers, outgoing_bcc=sender)
paul@128 121
        self.messenger.sendmail(self.organisers, message.as_string(), outgoing_bcc=sender)
paul@79 122
paul@79 123
    # Action methods.
paul@79 124
paul@79 125
    def process_request(self, accept):
paul@79 126
paul@79 127
        """
paul@79 128
        Process the current request for the given 'user', accepting any request
paul@79 129
        when 'accept' is true, declining requests otherwise. Return whether any
paul@79 130
        action was taken.
paul@79 131
        """
paul@79 132
paul@79 133
        # When accepting or declining, do so only on behalf of this user,
paul@79 134
        # preserving any other attributes set as an attendee.
paul@79 135
paul@79 136
        for attendee, attendee_attr in self.get_items("ATTENDEE"):
paul@79 137
paul@79 138
            if attendee == self.user:
paul@79 139
                freebusy = self.store.get_freebusy(attendee)
paul@79 140
paul@79 141
                attendee_attr["PARTSTAT"] = accept and "ACCEPTED" or "DECLINED"
paul@128 142
                if self.messenger and self.messenger.sender != get_address(attendee):
paul@128 143
                    attendee_attr["SENT-BY"] = get_uri(self.messenger.sender)
paul@79 144
                self.details["ATTENDEE"] = [(attendee, attendee_attr)]
paul@79 145
                self.send_message(get_address(attendee))
paul@79 146
paul@79 147
                return True
paul@79 148
paul@79 149
        return False
paul@79 150
paul@69 151
class Manager:
paul@69 152
paul@69 153
    "A simple manager application."
paul@69 154
paul@82 155
    def __init__(self, messenger=None):
paul@82 156
        self.messenger = messenger or Messenger()
paul@82 157
paul@69 158
        self.env = CGIEnvironment()
paul@69 159
        user = self.env.get_user()
paul@77 160
        self.user = user and get_uri(user) or None
paul@147 161
        self.preferences = None
paul@149 162
        self.locale = None
paul@121 163
        self.requests = None
paul@121 164
paul@69 165
        self.out = self.env.get_output()
paul@69 166
        self.page = markup.page()
paul@69 167
        self.encoding = "utf-8"
paul@69 168
paul@77 169
        self.store = imip_store.FileStore()
paul@77 170
paul@77 171
        try:
paul@77 172
            self.publisher = imip_store.FilePublisher()
paul@77 173
        except OSError:
paul@77 174
            self.publisher = None
paul@77 175
paul@121 176
    def _get_uid(self, path_info):
paul@121 177
        return path_info.lstrip("/").split("/", 1)[0]
paul@121 178
paul@117 179
    def _get_object(self, uid):
paul@117 180
        f = uid and self.store.get_event(self.user, uid) or None
paul@117 181
paul@117 182
        if not f:
paul@117 183
            return None
paul@117 184
paul@117 185
        obj = parse_object(f, "utf-8")
paul@117 186
paul@117 187
        if not obj:
paul@117 188
            return None
paul@117 189
paul@121 190
        return obj
paul@121 191
paul@121 192
    def _get_details(self, obj):
paul@121 193
        details, details_attr = obj.values()[0]
paul@121 194
        return details
paul@121 195
paul@121 196
    def _get_requests(self):
paul@121 197
        if self.requests is None:
paul@121 198
            self.requests = self.store.get_requests(self.user)
paul@121 199
        return self.requests
paul@117 200
paul@147 201
    # Preference methods.
paul@147 202
paul@149 203
    def get_user_locale(self):
paul@149 204
        if not self.locale:
paul@149 205
            self.locale = self.get_preferences().get("LANG", "C")
paul@149 206
        return self.locale
paul@147 207
paul@147 208
    def get_preferences(self):
paul@147 209
        if not self.preferences:
paul@147 210
            self.preferences = Preferences(self.user)
paul@147 211
        return self.preferences
paul@147 212
paul@149 213
    def format_date(self, dt, format):
paul@149 214
        return self._format_datetime(babel.dates.format_date, dt, format)
paul@149 215
paul@149 216
    def format_time(self, dt, format):
paul@149 217
        return self._format_datetime(babel.dates.format_time, dt, format)
paul@149 218
paul@149 219
    def format_datetime(self, dt, format):
paul@149 220
        return self._format_datetime(babel.dates.format_datetime, dt, format)
paul@149 221
paul@149 222
    def _format_datetime(self, fn, dt, format):
paul@149 223
        return fn(dt, format=format, locale=self.get_user_locale())
paul@149 224
paul@78 225
    # Data management methods.
paul@78 226
paul@78 227
    def remove_request(self, uid):
paul@105 228
        return self.store.dequeue_request(self.user, uid)
paul@78 229
paul@78 230
    # Presentation methods.
paul@78 231
paul@69 232
    def new_page(self, title):
paul@69 233
        self.page.init(title=title, charset=self.encoding)
paul@69 234
paul@69 235
    def status(self, code, message):
paul@123 236
        self.header("Status", "%s %s" % (code, message))
paul@123 237
paul@123 238
    def header(self, header, value):
paul@123 239
        print >>self.out, "%s: %s" % (header, value)
paul@69 240
paul@69 241
    def no_user(self):
paul@69 242
        self.status(403, "Forbidden")
paul@69 243
        self.new_page(title="Forbidden")
paul@69 244
        self.page.p("You are not logged in and thus cannot access scheduling requests.")
paul@69 245
paul@70 246
    def no_page(self):
paul@70 247
        self.status(404, "Not Found")
paul@70 248
        self.new_page(title="Not Found")
paul@70 249
        self.page.p("No page is provided at the given address.")
paul@70 250
paul@123 251
    def redirect(self, url):
paul@123 252
        self.status(302, "Redirect")
paul@123 253
        self.header("Location", url)
paul@123 254
        self.new_page(title="Redirect")
paul@123 255
        self.page.p("Redirecting to: %s" % url)
paul@123 256
paul@121 257
    # Request logic and page fragment methods.
paul@121 258
paul@121 259
    def handle_request(self, uid, request):
paul@121 260
paul@121 261
        "Handle actions involving the given 'uid' and 'request' object."
paul@121 262
paul@121 263
        # Handle a submitted form.
paul@121 264
paul@121 265
        args = self.env.get_args()
paul@123 266
        handled = True
paul@121 267
paul@121 268
        accept = args.has_key("accept")
paul@121 269
        decline = args.has_key("decline")
paul@121 270
paul@121 271
        if accept or decline:
paul@121 272
paul@121 273
            handler = ManagerHandler(request, self.user, self.messenger)
paul@121 274
paul@121 275
            if handler.process_request(accept):
paul@121 276
paul@121 277
                # Remove the request from the list.
paul@121 278
paul@121 279
                self.remove_request(uid)
paul@121 280
paul@121 281
        elif args.has_key("ignore"):
paul@121 282
paul@121 283
            # Remove the request from the list.
paul@121 284
paul@121 285
            self.remove_request(uid)
paul@121 286
paul@121 287
        else:
paul@123 288
            handled = False
paul@121 289
paul@123 290
        if handled:
paul@123 291
            self.redirect(self.env.get_path())
paul@123 292
paul@123 293
        return handled
paul@121 294
paul@121 295
    def show_request_form(self):
paul@121 296
paul@121 297
        "Show a form for a request."
paul@121 298
paul@121 299
        self.page.p("Action to take for this request:")
paul@121 300
        self.page.form(method="POST")
paul@121 301
        self.page.p()
paul@121 302
        self.page.input(name="accept", type="submit", value="Accept")
paul@121 303
        self.page.add(" ")
paul@121 304
        self.page.input(name="decline", type="submit", value="Decline")
paul@121 305
        self.page.add(" ")
paul@121 306
        self.page.input(name="ignore", type="submit", value="Ignore")
paul@121 307
        self.page.p.close()
paul@121 308
        self.page.form.close()
paul@121 309
paul@121 310
    def show_object_on_page(self, uid, obj):
paul@121 311
paul@121 312
        """
paul@121 313
        Show the calendar object with the given 'uid' and representation 'obj'
paul@121 314
        on the current page.
paul@121 315
        """
paul@121 316
paul@121 317
        details = self._get_details(obj)
paul@121 318
paul@121 319
        # Provide a summary of the object.
paul@121 320
paul@121 321
        self.page.dl()
paul@121 322
paul@121 323
        for name in ["SUMMARY", "DTSTART", "DTEND", "ORGANIZER", "ATTENDEE"]:
paul@147 324
            if name in ["DTSTART", "DTEND"]:
paul@147 325
                value, attr = get_item(details, name)
paul@147 326
                tzid = attr.get("TZID")
paul@149 327
                value = self.format_datetime(to_timezone(get_datetime(value), tzid), "full")
paul@121 328
                self.page.dt(name)
paul@121 329
                self.page.dd(value)
paul@147 330
            else:
paul@147 331
                for value in get_values(details, name):
paul@147 332
                    self.page.dt(name)
paul@147 333
                    self.page.dd(value)
paul@121 334
paul@121 335
        self.page.dl.close()
paul@121 336
paul@121 337
        dtstart = format_datetime(get_utc_datetime(details, "DTSTART"))
paul@121 338
        dtend = format_datetime(get_utc_datetime(details, "DTEND"))
paul@121 339
paul@121 340
        # Indicate whether there are conflicting events.
paul@121 341
paul@121 342
        freebusy = self.store.get_freebusy(self.user)
paul@121 343
paul@121 344
        if freebusy:
paul@121 345
paul@121 346
            # Obtain any time zone details from the suggested event.
paul@121 347
paul@121 348
            _dtstart, attr = get_item(details, "DTSTART")
paul@121 349
            tzid = attr.get("TZID")
paul@121 350
paul@121 351
            # Show any conflicts.
paul@121 352
paul@121 353
            for t in have_conflict(freebusy, [(dtstart, dtend)], True):
paul@121 354
                start, end, found_uid = t[:3]
paul@121 355
                if uid != found_uid:
paul@149 356
                    start = self.format_datetime(to_timezone(get_datetime(start), tzid), "full")
paul@149 357
                    end = self.format_datetime(to_timezone(get_datetime(end), tzid), "full")
paul@121 358
                    self.page.p("Event conflicts with another from %s to %s." % (start, end))
paul@121 359
paul@121 360
    def show_requests_on_page(self):
paul@69 361
paul@69 362
        "Show requests for the current user."
paul@69 363
paul@69 364
        # NOTE: This list could be more informative, but it is envisaged that
paul@69 365
        # NOTE: the requests would be visited directly anyway.
paul@69 366
paul@121 367
        requests = self._get_requests()
paul@70 368
paul@80 369
        if requests:
paul@114 370
            self.page.p("Pending requests:")
paul@114 371
paul@80 372
            self.page.ul()
paul@69 373
paul@80 374
            for request in requests:
paul@80 375
                self.page.li()
paul@80 376
                self.page.a(request, href="%s/%s" % (self.env.get_url().rstrip("/"), request))
paul@80 377
                self.page.li.close()
paul@80 378
paul@80 379
            self.page.ul.close()
paul@80 380
paul@80 381
        else:
paul@80 382
            self.page.p("There are no pending requests.")
paul@69 383
paul@121 384
    # Full page output methods.
paul@70 385
paul@121 386
    def show_object(self, path_info):
paul@70 387
paul@121 388
        "Show an object request using the given 'path_info' for the current user."
paul@70 389
paul@121 390
        uid = self._get_uid(path_info)
paul@121 391
        obj = self._get_object(uid)
paul@121 392
paul@121 393
        if not obj:
paul@70 394
            return False
paul@70 395
paul@123 396
        is_request = uid in self._get_requests()
paul@123 397
        handled = is_request and self.handle_request(uid, obj)
paul@77 398
paul@123 399
        if handled:
paul@123 400
            return True
paul@73 401
paul@123 402
        self.new_page(title="Event")
paul@79 403
paul@121 404
        self.show_object_on_page(uid, obj)
paul@73 405
paul@123 406
        if is_request and not handled:
paul@121 407
            self.show_request_form()
paul@73 408
paul@70 409
        return True
paul@70 410
paul@114 411
    def show_calendar(self):
paul@114 412
paul@114 413
        "Show the calendar for the current user."
paul@114 414
paul@114 415
        self.new_page(title="Calendar")
paul@121 416
        self.show_requests_on_page()
paul@114 417
paul@114 418
        freebusy = self.store.get_freebusy(self.user)
paul@114 419
        page = self.page
paul@114 420
paul@114 421
        if not freebusy:
paul@114 422
            page.p("No events scheduled.")
paul@114 423
            return
paul@114 424
paul@147 425
        # Set the locale and obtain the user's timezone.
paul@147 426
paul@147 427
        prefs = self.get_preferences()
paul@153 428
        tzid = prefs.get("TZID", "UTC")
paul@147 429
paul@114 430
        # Day view: start at the earliest known day and produce days until the
paul@114 431
        # latest known day, perhaps with expandable sections of empty days.
paul@114 432
paul@114 433
        # Month view: start at the earliest known month and produce months until
paul@114 434
        # the latest known month, perhaps with expandable sections of empty
paul@114 435
        # months.
paul@114 436
paul@114 437
        # Details of users to invite to new events could be superimposed on the
paul@114 438
        # calendar.
paul@114 439
paul@114 440
        # Requests could be listed and linked to their tentative positions in
paul@114 441
        # the calendar.
paul@114 442
paul@114 443
        slots = get_slots(freebusy)
paul@153 444
        partitioned = partition_slots(slots, tzid)
paul@153 445
        columns = max(map(lambda i: len(i[1]), slots)) + 1
paul@114 446
paul@114 447
        page.table(border=1, cellspacing=0, cellpadding=5)
paul@114 448
paul@153 449
        for day, slots in partitioned:
paul@153 450
            spans = get_spans(slots)
paul@147 451
paul@114 452
            page.tr()
paul@153 453
            page.th(class_="dayheading", colspan=columns)
paul@153 454
            page.add(self.format_date(day, "full"))
paul@114 455
            page.th.close()
paul@153 456
            page.tr.close()
paul@114 457
paul@153 458
            for point, active in slots:
paul@153 459
                dt = to_timezone(get_datetime(point), tzid)
paul@153 460
                continuation = dt == get_start_of_day(dt)
paul@153 461
paul@153 462
                page.tr()
paul@153 463
                page.th(class_="timeslot")
paul@153 464
                page.add(self.format_time(dt, "long"))
paul@153 465
                page.th.close()
paul@117 466
paul@153 467
                for t in active:
paul@153 468
                    if t:
paul@153 469
                        start, end, uid = t[:3]
paul@153 470
                        span = spans[uid]
paul@153 471
                        if point == start or continuation:
paul@153 472
paul@153 473
                            page.td(class_="event", rowspan=span)
paul@153 474
                            obj = self._get_object(uid)
paul@153 475
                            if obj:
paul@153 476
                                details = self._get_details(obj)
paul@153 477
                                page.a(get_value(details, "SUMMARY"), href="%s/%s" % (self.env.get_url().rstrip("/"), uid))
paul@153 478
                            page.td.close()
paul@153 479
                    else:
paul@153 480
                        page.td(class_="empty")
paul@114 481
                        page.td.close()
paul@114 482
paul@153 483
                page.tr.close()
paul@114 484
paul@114 485
        page.table.close()
paul@114 486
paul@69 487
    def select_action(self):
paul@69 488
paul@69 489
        "Select the desired action and show the result."
paul@69 490
paul@121 491
        path_info = self.env.get_path_info().strip("/")
paul@121 492
paul@69 493
        if not path_info:
paul@114 494
            self.show_calendar()
paul@121 495
        elif self.show_object(path_info):
paul@70 496
            pass
paul@70 497
        else:
paul@70 498
            self.no_page()
paul@69 499
paul@82 500
    def __call__(self):
paul@69 501
paul@69 502
        "Interpret a request and show an appropriate response."
paul@69 503
paul@69 504
        if not self.user:
paul@69 505
            self.no_user()
paul@69 506
        else:
paul@69 507
            self.select_action()
paul@69 508
paul@70 509
        # Write the headers and actual content.
paul@70 510
paul@69 511
        print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding
paul@69 512
        print >>self.out
paul@69 513
        self.out.write(unicode(self.page).encode(self.encoding))
paul@69 514
paul@69 515
if __name__ == "__main__":
paul@128 516
    Manager()()
paul@69 517
paul@69 518
# vim: tabstop=4 expandtab shiftwidth=4