imip-agent

Annotated imiptools/freebusy/database.py

1309:644b7e259059
2017-10-14 Paul Boddie Support BCC sending suppression so that routines requesting it can still be used with senders that will not support it, usually because there are no outgoing routing destinations for those senders.
paul@1234 1
#!/usr/bin/env python
paul@1234 2
paul@1234 3
"""
paul@1234 4
Managing free/busy period collections using database representations.
paul@1234 5
paul@1234 6
Copyright (C) 2014, 2015, 2016, 2017 Paul Boddie <paul@boddie.org.uk>
paul@1234 7
paul@1234 8
This program is free software; you can redistribute it and/or modify it under
paul@1234 9
the terms of the GNU General Public License as published by the Free Software
paul@1234 10
Foundation; either version 3 of the License, or (at your option) any later
paul@1234 11
version.
paul@1234 12
paul@1234 13
This program is distributed in the hope that it will be useful, but WITHOUT
paul@1234 14
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
paul@1234 15
FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
paul@1234 16
details.
paul@1234 17
paul@1234 18
You should have received a copy of the GNU General Public License along with
paul@1234 19
this program.  If not, see <http://www.gnu.org/licenses/>.
paul@1234 20
"""
paul@1234 21
paul@1234 22
from imiptools.dates import format_datetime
paul@1234 23
from imiptools.freebusy.common import FreeBusyCollectionBase, SupportAttendee, \
paul@1234 24
                                      SupportExpires, from_string, to_string
paul@1234 25
from imiptools.sql import DatabaseOperations
paul@1234 26
paul@1234 27
try:
paul@1234 28
    from cStringIO import StringIO
paul@1234 29
except ImportError:
paul@1234 30
    from StringIO import StringIO
paul@1234 31
paul@1234 32
# Conversion functions.
paul@1234 33
paul@1234 34
def to_copy_string(s, encoding):
paul@1234 35
paul@1234 36
    """
paul@1234 37
    Encode 's' using 'encoding' as a string suitable for use in tabular data
paul@1234 38
    acceptable to the PostgreSQL COPY command with \N as null.
paul@1234 39
    """
paul@1234 40
paul@1234 41
    s = to_string(s, encoding)
paul@1234 42
    return s is None and "\\N" or s
paul@1234 43
paul@1234 44
def to_copy_file(records):
paul@1234 45
paul@1234 46
    """
paul@1234 47
    Encode the given 'records' and store them in a file-like object for use with
paul@1234 48
    a tabular import mechanism. Return the file-like object.
paul@1234 49
    """
paul@1234 50
paul@1234 51
    io = StringIO()
paul@1234 52
    for values in records:
paul@1234 53
        l = []
paul@1234 54
        for v in values:
paul@1234 55
            l.append(to_copy_string(v, "utf-8"))
paul@1234 56
        io.write("\t".join(l))
paul@1234 57
        io.write("\n")
paul@1234 58
    io.seek(0)
paul@1234 59
    return io
paul@1234 60
paul@1234 61
def quote_column(column):
paul@1234 62
paul@1234 63
    "Quote 'column' using the SQL keyword quoting notation."
paul@1234 64
paul@1234 65
    return '"%s"' % column
paul@1234 66
paul@1234 67
paul@1234 68
paul@1234 69
# Collection abstractions.
paul@1234 70
paul@1234 71
class FreeBusyDatabaseCollection(FreeBusyCollectionBase, DatabaseOperations):
paul@1234 72
paul@1234 73
    """
paul@1234 74
    An abstraction for a collection of free/busy periods stored in a database
paul@1234 75
    system.
paul@1234 76
    """
paul@1234 77
paul@1234 78
    def __init__(self, cursor, table_name, column_names=None, filter_values=None,
paul@1234 79
        mutable=True, paramstyle=None):
paul@1234 80
paul@1234 81
        """
paul@1234 82
        Initialise the collection with the given 'cursor' and with the
paul@1234 83
        'table_name', 'column_names' and 'filter_values' configuring the
paul@1234 84
        selection of data. If 'mutable' is indicated, the collection may be
paul@1234 85
        changed; otherwise, an exception will be raised.
paul@1234 86
        """
paul@1234 87
paul@1234 88
        FreeBusyCollectionBase.__init__(self, mutable)
paul@1234 89
        DatabaseOperations.__init__(self, column_names, filter_values, paramstyle)
paul@1234 90
        self.cursor = cursor
paul@1234 91
        self.table_name = table_name
paul@1234 92
paul@1236 93
        self.period_columns = self.period_class.period_columns
paul@1236 94
paul@1234 95
    # List emulation methods.
paul@1234 96
paul@1234 97
    def __nonzero__(self):
paul@1234 98
        return len(self) and True or False
paul@1234 99
paul@1234 100
    def __iter__(self):
paul@1234 101
        query, values = self.get_query(
paul@1234 102
            "select %(columns)s from %(table)s :condition" % {
paul@1234 103
                "columns" : self.columnlist(self.period_columns),
paul@1234 104
                "table" : self.table_name
paul@1234 105
                })
paul@1234 106
        self.cursor.execute(query, values)
paul@1234 107
        return iter(map(lambda t: self.make_period(t), self.cursor.fetchall()))
paul@1234 108
paul@1234 109
    def __len__(self):
paul@1234 110
        query, values = self.get_query(
paul@1234 111
            "select count(*) from %(table)s :condition" % {
paul@1234 112
                "table" : self.table_name
paul@1234 113
                })
paul@1234 114
        self.cursor.execute(query, values)
paul@1234 115
        result = self.cursor.fetchone()
paul@1234 116
        return result and int(result[0]) or 0
paul@1234 117
paul@1234 118
    def __getitem__(self, i):
paul@1234 119
        return list(iter(self))[i]
paul@1234 120
paul@1236 121
    # Dictionary emulation methods (even though this is not a mapping).
paul@1236 122
paul@1236 123
    def clear(self):
paul@1236 124
        query, values = self.get_query(
paul@1236 125
            "delete from %(table)s :condition" % {
paul@1236 126
                "table" : self.table_name
paul@1236 127
                })
paul@1236 128
        self.cursor.execute(query, values)
paul@1236 129
paul@1234 130
    # Operations.
paul@1234 131
paul@1234 132
    def insert_period(self, period):
paul@1234 133
paul@1234 134
        "Insert the given 'period' into the collection."
paul@1234 135
paul@1234 136
        self._check_mutable()
paul@1234 137
paul@1234 138
        columns, values = self.period_columns, period.as_tuple(string_datetimes=True)
paul@1234 139
paul@1234 140
        query, values = self.get_query(
paul@1234 141
            "insert into %(table)s (:columns) values (:values)" % {
paul@1234 142
                "table" : self.table_name
paul@1234 143
                },
paul@1234 144
            columns, [to_string(v, "utf-8") for v in values])
paul@1234 145
paul@1234 146
        self.cursor.execute(query, values)
paul@1234 147
paul@1234 148
    def insert_periods(self, periods):
paul@1234 149
paul@1234 150
        "Insert the given 'periods' into the collection."
paul@1234 151
paul@1234 152
        if not hasattr(self.cursor, "copy_from"):
paul@1234 153
            return FreeBusyCollectionBase.insert_periods(self, periods)
paul@1234 154
paul@1234 155
        self._check_mutable()
paul@1234 156
paul@1234 157
        columns = self.merge_default_columns(self.period_columns)
paul@1234 158
paul@1234 159
        all_values = []
paul@1234 160
        for period in periods:
paul@1234 161
            all_values.append(self.merge_default_values(period.as_tuple(string_datetimes=True)))
paul@1234 162
paul@1234 163
        f = to_copy_file(all_values)
paul@1234 164
paul@1234 165
        # Copy from the file-like object to the table.
paul@1234 166
paul@1234 167
        self.cursor.copy_from(f, self.table_name, columns=map(quote_column, columns))
paul@1234 168
paul@1234 169
    def remove_periods(self, periods):
paul@1234 170
paul@1234 171
        "Remove the given 'periods' from the collection."
paul@1234 172
paul@1234 173
        self._check_mutable()
paul@1234 174
paul@1234 175
        for period in periods:
paul@1234 176
            values = period.as_tuple(string_datetimes=True)
paul@1234 177
paul@1234 178
            query, values = self.get_query(
paul@1234 179
                "delete from %(table)s :condition" % {
paul@1234 180
                    "table" : self.table_name
paul@1234 181
                    },
paul@1234 182
                self.period_columns, [to_string(v, "utf-8") for v in values])
paul@1234 183
paul@1234 184
            self.cursor.execute(query, values)
paul@1234 185
paul@1243 186
    def remove_periods_before(self, period):
paul@1243 187
paul@1243 188
        "Remove the entries in the collection before 'period'."
paul@1243 189
paul@1243 190
        end = format_datetime(period.get_start_point())
paul@1243 191
paul@1243 192
        columns, values = [], []
paul@1243 193
paul@1243 194
        if end:
paul@1243 195
            columns.append("end <= ?")
paul@1243 196
            values.append(end)
paul@1243 197
paul@1243 198
        query, values = self.get_query(
paul@1243 199
            "delete from %(table)s :condition" % {
paul@1243 200
                "columns" : self.columnlist(self.period_columns),
paul@1243 201
                "table" : self.table_name
paul@1243 202
                },
paul@1243 203
            columns, values)
paul@1243 204
paul@1243 205
        self.cursor.execute(query, values)
paul@1243 206
paul@1234 207
    def remove_event_periods(self, uid, recurrenceid=None, participant=None):
paul@1234 208
paul@1234 209
        """
paul@1234 210
        Remove from the collection all periods associated with 'uid' and
paul@1234 211
        'recurrenceid' (which if omitted causes the "parent" object's periods to
paul@1234 212
        be referenced).
paul@1234 213
paul@1234 214
        If 'participant' is specified, only remove periods for which the
paul@1234 215
        participant is given as attending.
paul@1234 216
paul@1234 217
        Return the removed periods.
paul@1234 218
        """
paul@1234 219
paul@1234 220
        self._check_mutable()
paul@1234 221
paul@1234 222
        columns, values = ["object_uid"], [uid]
paul@1234 223
paul@1234 224
        if recurrenceid:
paul@1234 225
            columns.append("object_recurrenceid")
paul@1234 226
            values.append(recurrenceid)
paul@1234 227
        else:
paul@1234 228
            columns.append("object_recurrenceid is null")
paul@1234 229
paul@1234 230
        if participant:
paul@1234 231
            columns.append("attendee")
paul@1234 232
            values.append(participant)
paul@1234 233
paul@1234 234
        query, _values = self.get_query(
paul@1234 235
            "select %(columns)s from %(table)s :condition" % {
paul@1234 236
                "columns" : self.columnlist(self.period_columns),
paul@1234 237
                "table" : self.table_name
paul@1234 238
                },
paul@1234 239
            columns, values)
paul@1234 240
paul@1234 241
        self.cursor.execute(query, _values)
paul@1234 242
        removed = self.cursor.fetchall()
paul@1234 243
paul@1234 244
        query, values = self.get_query(
paul@1234 245
            "delete from %(table)s :condition" % {
paul@1234 246
                "table" : self.table_name
paul@1234 247
                },
paul@1234 248
            columns, values)
paul@1234 249
paul@1234 250
        self.cursor.execute(query, values)
paul@1234 251
paul@1234 252
        return map(lambda t: self.make_period(t), removed)
paul@1234 253
paul@1234 254
    # Specific period removal when updating event details.
paul@1234 255
paul@1234 256
    remove_specific_event_periods = remove_event_periods
paul@1234 257
paul@1234 258
    def remove_additional_periods(self, uid, recurrenceids=None):
paul@1234 259
paul@1234 260
        """
paul@1234 261
        Remove from the collection all periods associated with 'uid' having a
paul@1234 262
        recurrence identifier indicating an additional or modified period.
paul@1234 263
paul@1234 264
        If 'recurrenceids' is specified, remove all periods associated with
paul@1234 265
        'uid' that do not have a recurrence identifier in the given list.
paul@1234 266
paul@1234 267
        Return the removed periods.
paul@1234 268
        """
paul@1234 269
paul@1234 270
        self._check_mutable()
paul@1234 271
paul@1234 272
        if not recurrenceids:
paul@1234 273
            columns, values = ["object_uid", "object_recurrenceid is not null"], [uid]
paul@1234 274
        else:
paul@1234 275
            columns, values = ["object_uid", "object_recurrenceid not in ?", "object_recurrenceid is not null"], [uid, tuple(recurrenceids)]
paul@1234 276
paul@1234 277
        query, _values = self.get_query(
paul@1234 278
            "select %(columns)s from %(table)s :condition" % {
paul@1234 279
                "columns" : self.columnlist(self.period_columns),
paul@1234 280
                "table" : self.table_name
paul@1234 281
                },
paul@1234 282
            columns, values)
paul@1234 283
paul@1234 284
        self.cursor.execute(query, _values)
paul@1234 285
        removed = self.cursor.fetchall()
paul@1234 286
paul@1234 287
        query, values = self.get_query(
paul@1234 288
            "delete from %(table)s :condition" % {
paul@1234 289
                "table" : self.table_name
paul@1234 290
                },
paul@1234 291
            columns, values)
paul@1234 292
paul@1234 293
        self.cursor.execute(query, values)
paul@1234 294
paul@1234 295
        return map(lambda t: self.make_period(t), removed)
paul@1234 296
paul@1234 297
    def remove_affected_period(self, uid, start, participant=None):
paul@1234 298
paul@1234 299
        """
paul@1234 300
        Remove from the collection the period associated with 'uid' that
paul@1234 301
        provides an occurrence starting at the given 'start' (provided by a
paul@1234 302
        recurrence identifier, converted to a datetime). A recurrence identifier
paul@1234 303
        is used to provide an alternative time period whilst also acting as a
paul@1234 304
        reference to the originally-defined occurrence.
paul@1234 305
paul@1234 306
        If 'participant' is specified, only remove periods for which the
paul@1234 307
        participant is given as attending.
paul@1234 308
paul@1234 309
        Return any removed period in a list.
paul@1234 310
        """
paul@1234 311
paul@1234 312
        self._check_mutable()
paul@1234 313
paul@1234 314
        start = format_datetime(start)
paul@1234 315
paul@1234 316
        columns, values = ["object_uid", "start", "object_recurrenceid is null"], [uid, start]
paul@1234 317
paul@1234 318
        if participant:
paul@1234 319
            columns.append("attendee")
paul@1234 320
            values.append(participant)
paul@1234 321
paul@1234 322
        query, _values = self.get_query(
paul@1234 323
            "select %(columns)s from %(table)s :condition" % {
paul@1234 324
                "columns" : self.columnlist(self.period_columns),
paul@1234 325
                "table" : self.table_name
paul@1234 326
                },
paul@1234 327
            columns, values)
paul@1234 328
paul@1234 329
        self.cursor.execute(query, _values)
paul@1234 330
        removed = self.cursor.fetchall()
paul@1234 331
paul@1234 332
        query, values = self.get_query(
paul@1234 333
            "delete from %(table)s :condition" % {
paul@1234 334
                "table" : self.table_name
paul@1234 335
                },
paul@1234 336
            columns, values)
paul@1234 337
paul@1234 338
        self.cursor.execute(query, values)
paul@1234 339
paul@1234 340
        return map(lambda t: self.make_period(t), removed)
paul@1234 341
paul@1234 342
    def periods_from(self, period):
paul@1234 343
paul@1234 344
        "Return the entries in the collection at or after 'period'."
paul@1234 345
paul@1234 346
        start = format_datetime(period.get_start_point())
paul@1234 347
paul@1234 348
        columns, values = [], []
paul@1234 349
paul@1234 350
        if start:
paul@1234 351
            columns.append("start >= ?")
paul@1234 352
            values.append(start)
paul@1234 353
paul@1234 354
        query, values = self.get_query(
paul@1234 355
            "select %(columns)s from %(table)s :condition" % {
paul@1234 356
                "columns" : self.columnlist(self.period_columns),
paul@1234 357
                "table" : self.table_name
paul@1234 358
                },
paul@1234 359
            columns, values)
paul@1234 360
paul@1234 361
        self.cursor.execute(query, values)
paul@1234 362
paul@1234 363
        return map(lambda t: self.make_period(t), self.cursor.fetchall())
paul@1234 364
paul@1234 365
    def periods_until(self, period):
paul@1234 366
paul@1234 367
        "Return the entries in the collection before 'period'."
paul@1234 368
paul@1234 369
        end = format_datetime(period.get_end_point())
paul@1234 370
paul@1234 371
        columns, values = [], []
paul@1234 372
paul@1234 373
        if end:
paul@1234 374
            columns.append("start < ?")
paul@1234 375
            values.append(end)
paul@1234 376
paul@1234 377
        query, values = self.get_query(
paul@1234 378
            "select %(columns)s from %(table)s :condition" % {
paul@1234 379
                "columns" : self.columnlist(self.period_columns),
paul@1234 380
                "table" : self.table_name
paul@1234 381
                },
paul@1234 382
            columns, values)
paul@1234 383
paul@1234 384
        self.cursor.execute(query, values)
paul@1234 385
paul@1234 386
        return map(lambda t: self.make_period(t), self.cursor.fetchall())
paul@1234 387
paul@1234 388
    def get_overlapping(self, periods):
paul@1234 389
paul@1234 390
        """
paul@1234 391
        Return the entries in the collection providing periods overlapping with
paul@1234 392
        the given sorted collection of 'periods'.
paul@1234 393
        """
paul@1234 394
paul@1234 395
        overlapping = set()
paul@1234 396
paul@1234 397
        for period in periods:
paul@1234 398
            columns, values = self._get_period_values(period)
paul@1234 399
paul@1234 400
            query, values = self.get_query(
paul@1234 401
                "select %(columns)s from %(table)s :condition" % {
paul@1234 402
                    "columns" : self.columnlist(self.period_columns),
paul@1234 403
                    "table" : self.table_name
paul@1234 404
                    },
paul@1234 405
                columns, values)
paul@1234 406
paul@1234 407
            self.cursor.execute(query, values)
paul@1234 408
paul@1234 409
            overlapping.update(map(lambda t: self.make_period(t), self.cursor.fetchall()))
paul@1234 410
paul@1234 411
        overlapping = list(overlapping)
paul@1234 412
        overlapping.sort()
paul@1234 413
        return overlapping
paul@1234 414
paul@1234 415
    def remove_overlapping(self, period):
paul@1234 416
paul@1234 417
        "Remove all periods overlapping with 'period' from the collection."
paul@1234 418
paul@1234 419
        self._check_mutable()
paul@1234 420
paul@1234 421
        columns, values = self._get_period_values(period)
paul@1234 422
paul@1234 423
        query, values = self.get_query(
paul@1234 424
            "delete from %(table)s :condition" % {
paul@1234 425
                "table" : self.table_name
paul@1234 426
                },
paul@1234 427
            columns, values)
paul@1234 428
paul@1234 429
        self.cursor.execute(query, values)
paul@1234 430
paul@1234 431
    def _get_period_values(self, period):
paul@1234 432
paul@1234 433
        start = format_datetime(period.get_start_point())
paul@1234 434
        end = format_datetime(period.get_end_point())
paul@1234 435
paul@1234 436
        columns, values = [], []
paul@1234 437
paul@1234 438
        if end:
paul@1234 439
            columns.append("start < ?")
paul@1234 440
            values.append(end)
paul@1234 441
        if start:
paul@1234 442
            columns.append("end > ?")
paul@1234 443
            values.append(start)
paul@1234 444
paul@1234 445
        return columns, values
paul@1234 446
paul@1234 447
class FreeBusyGroupDatabaseCollection(SupportAttendee, FreeBusyDatabaseCollection):
paul@1234 448
paul@1234 449
    "A collection of quota group free/busy objects."
paul@1234 450
paul@1234 451
    def remove_specific_event_periods(self, uid, recurrenceid=None, attendee=None):
paul@1234 452
paul@1234 453
        """
paul@1234 454
        Remove from the collection all periods associated with 'uid' and
paul@1234 455
        'recurrenceid' (which if omitted causes the "parent" object's periods to
paul@1234 456
        be referenced) and any 'attendee'.
paul@1234 457
paul@1234 458
        Return the removed periods.
paul@1234 459
        """
paul@1234 460
paul@1234 461
        self._check_mutable()
paul@1234 462
paul@1234 463
        columns, values = ["object_uid"], [uid]
paul@1234 464
paul@1234 465
        if recurrenceid:
paul@1234 466
            columns.append("object_recurrenceid")
paul@1234 467
            values.append(recurrenceid)
paul@1234 468
        else:
paul@1234 469
            columns.append("object_recurrenceid is null")
paul@1234 470
paul@1234 471
        if attendee:
paul@1234 472
            columns.append("attendee")
paul@1234 473
            values.append(attendee)
paul@1234 474
        else:
paul@1234 475
            columns.append("attendee is null")
paul@1234 476
paul@1234 477
        query, _values = self.get_query(
paul@1234 478
            "select %(columns)s from %(table)s :condition" % {
paul@1234 479
                "columns" : self.columnlist(self.period_columns),
paul@1234 480
                "table" : self.table_name
paul@1234 481
                },
paul@1234 482
            columns, values)
paul@1234 483
paul@1234 484
        self.cursor.execute(query, _values)
paul@1234 485
        removed = self.cursor.fetchall()
paul@1234 486
paul@1234 487
        query, values = self.get_query(
paul@1234 488
            "delete from %(table)s :condition" % {
paul@1234 489
                "table" : self.table_name
paul@1234 490
                },
paul@1234 491
            columns, values)
paul@1234 492
paul@1234 493
        self.cursor.execute(query, values)
paul@1234 494
paul@1234 495
        return map(lambda t: self.make_period(t), removed)
paul@1234 496
paul@1234 497
class FreeBusyOffersDatabaseCollection(SupportExpires, FreeBusyDatabaseCollection):
paul@1234 498
paul@1234 499
    "A collection of offered free/busy objects."
paul@1234 500
paul@1234 501
    pass
paul@1234 502
paul@1234 503
# vim: tabstop=4 expandtab shiftwidth=4