imip-agent

Annotated imip_manager.py

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