imip-agent

Annotated imiptools/freebusy/database.py

1385:79ff5b0a199c
2017-11-01 Paul Boddie Update the sequence number when modifying existing separate recurrences. client-editing-simplification
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