imip-agent

Annotated imip_manager.py

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