imip-agent

Annotated imip_store.py

1055:9d7567e5675c
2016-02-08 Paul Boddie Added a test of simultaneous resource booking with recurring events and differing organiser quota limits.
paul@2 1
#!/usr/bin/env python
paul@2 2
paul@146 3
"""
paul@146 4
A simple filesystem-based store of calendar data.
paul@146 5
paul@1039 6
Copyright (C) 2014, 2015, 2016 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@30 22
from datetime import datetime
paul@1039 23
from imiptools.config import STORE_DIR, PUBLISH_DIR, JOURNAL_DIR
paul@301 24
from imiptools.data import make_calendar, parse_object, to_stream
paul@740 25
from imiptools.dates import format_datetime, get_datetime, to_timezone
paul@147 26
from imiptools.filesys import fix_permissions, FileBase
paul@458 27
from imiptools.period import FreeBusyPeriod
paul@1046 28
from imiptools.text import parse_line
paul@808 29
from os.path import isdir, isfile, join
paul@343 30
from os import listdir, remove, rmdir
paul@303 31
from time import sleep
paul@395 32
import codecs
paul@15 33
paul@1039 34
class FileStoreBase(FileBase):
paul@50 35
paul@1039 36
    "A file store supporting user-specific locking and tabular data."
paul@147 37
paul@303 38
    def acquire_lock(self, user, timeout=None):
paul@303 39
        FileBase.acquire_lock(self, timeout, user)
paul@303 40
paul@303 41
    def release_lock(self, user):
paul@303 42
        FileBase.release_lock(self, user)
paul@303 43
paul@648 44
    # Utility methods.
paul@648 45
paul@343 46
    def _set_defaults(self, t, empty_defaults):
paul@343 47
        for i, default in empty_defaults:
paul@343 48
            if i >= len(t):
paul@343 49
                t += [None] * (i - len(t) + 1)
paul@343 50
            if not t[i]:
paul@343 51
                t[i] = default
paul@343 52
        return t
paul@343 53
paul@1046 54
    def _get_table(self, user, filename, empty_defaults=None, tab_separated=True):
paul@343 55
paul@343 56
        """
paul@343 57
        From the file for the given 'user' having the given 'filename', return
paul@343 58
        a list of tuples representing the file's contents.
paul@343 59
paul@343 60
        The 'empty_defaults' is a list of (index, value) tuples indicating the
paul@343 61
        default value where a column either does not exist or provides an empty
paul@343 62
        value.
paul@1046 63
paul@1046 64
        If 'tab_separated' is specified and is a false value, line parsing using
paul@1046 65
        the imiptools.text.parse_line function will be performed instead of
paul@1046 66
        splitting each line of the file using tab characters as separators.
paul@343 67
        """
paul@343 68
paul@702 69
        f = codecs.open(filename, "rb", encoding="utf-8")
paul@702 70
        try:
paul@702 71
            l = []
paul@702 72
            for line in f.readlines():
paul@1046 73
                line = line.strip(" \r\n")
paul@1046 74
                if tab_separated:
paul@1046 75
                    t = line.split("\t")
paul@1046 76
                else:
paul@1046 77
                    t = parse_line(line)
paul@702 78
                if empty_defaults:
paul@702 79
                    t = self._set_defaults(t, empty_defaults)
paul@702 80
                l.append(tuple(t))
paul@702 81
            return l
paul@702 82
        finally:
paul@702 83
            f.close()
paul@702 84
paul@1046 85
    def _get_table_atomic(self, user, filename, empty_defaults=None, tab_separated=True):
paul@702 86
paul@702 87
        """
paul@702 88
        From the file for the given 'user' having the given 'filename', return
paul@702 89
        a list of tuples representing the file's contents.
paul@702 90
paul@702 91
        The 'empty_defaults' is a list of (index, value) tuples indicating the
paul@702 92
        default value where a column either does not exist or provides an empty
paul@702 93
        value.
paul@1046 94
paul@1046 95
        If 'tab_separated' is specified and is a false value, line parsing using
paul@1046 96
        the imiptools.text.parse_line function will be performed instead of
paul@1046 97
        splitting each line of the file using tab characters as separators.
paul@702 98
        """
paul@702 99
paul@343 100
        self.acquire_lock(user)
paul@343 101
        try:
paul@1046 102
            return self._get_table(user, filename, empty_defaults, tab_separated)
paul@343 103
        finally:
paul@343 104
            self.release_lock(user)
paul@343 105
paul@343 106
    def _set_table(self, user, filename, items, empty_defaults=None):
paul@343 107
paul@343 108
        """
paul@343 109
        For the given 'user', write to the file having the given 'filename' the
paul@343 110
        'items'.
paul@343 111
paul@343 112
        The 'empty_defaults' is a list of (index, value) tuples indicating the
paul@343 113
        default value where a column either does not exist or provides an empty
paul@343 114
        value.
paul@343 115
        """
paul@343 116
paul@702 117
        f = codecs.open(filename, "wb", encoding="utf-8")
paul@702 118
        try:
paul@702 119
            for item in items:
paul@747 120
                self._set_table_item(f, item, empty_defaults)
paul@702 121
        finally:
paul@702 122
            f.close()
paul@702 123
            fix_permissions(filename)
paul@702 124
paul@747 125
    def _set_table_item(self, f, item, empty_defaults=None):
paul@747 126
paul@747 127
        "Set in table 'f' the given 'item', using any 'empty_defaults'."
paul@747 128
paul@747 129
        if empty_defaults:
paul@747 130
            item = self._set_defaults(list(item), empty_defaults)
paul@747 131
        f.write("\t".join(item) + "\n")
paul@747 132
paul@702 133
    def _set_table_atomic(self, user, filename, items, empty_defaults=None):
paul@702 134
paul@702 135
        """
paul@702 136
        For the given 'user', write to the file having the given 'filename' the
paul@702 137
        'items'.
paul@702 138
paul@702 139
        The 'empty_defaults' is a list of (index, value) tuples indicating the
paul@702 140
        default value where a column either does not exist or provides an empty
paul@702 141
        value.
paul@702 142
        """
paul@702 143
paul@343 144
        self.acquire_lock(user)
paul@343 145
        try:
paul@702 146
            self._set_table(user, filename, items, empty_defaults)
paul@343 147
        finally:
paul@343 148
            self.release_lock(user)
paul@343 149
paul@1039 150
class FileStore(FileStoreBase):
paul@1039 151
paul@1039 152
    "A file store of tabular free/busy data and objects."
paul@1039 153
paul@1039 154
    def __init__(self, store_dir=None):
paul@1039 155
        FileBase.__init__(self, store_dir or STORE_DIR)
paul@1039 156
paul@648 157
    # Store object access.
paul@648 158
paul@329 159
    def _get_object(self, user, filename):
paul@329 160
paul@329 161
        """
paul@329 162
        Return the parsed object for the given 'user' having the given
paul@329 163
        'filename'.
paul@329 164
        """
paul@329 165
paul@329 166
        self.acquire_lock(user)
paul@329 167
        try:
paul@329 168
            f = open(filename, "rb")
paul@329 169
            try:
paul@329 170
                return parse_object(f, "utf-8")
paul@329 171
            finally:
paul@329 172
                f.close()
paul@329 173
        finally:
paul@329 174
            self.release_lock(user)
paul@329 175
paul@329 176
    def _set_object(self, user, filename, node):
paul@329 177
paul@329 178
        """
paul@329 179
        Set an object for the given 'user' having the given 'filename', using
paul@329 180
        'node' to define the object.
paul@329 181
        """
paul@329 182
paul@329 183
        self.acquire_lock(user)
paul@329 184
        try:
paul@329 185
            f = open(filename, "wb")
paul@329 186
            try:
paul@329 187
                to_stream(f, node)
paul@329 188
            finally:
paul@329 189
                f.close()
paul@329 190
                fix_permissions(filename)
paul@329 191
        finally:
paul@329 192
            self.release_lock(user)
paul@329 193
paul@329 194
        return True
paul@329 195
paul@329 196
    def _remove_object(self, filename):
paul@329 197
paul@329 198
        "Remove the object with the given 'filename'."
paul@329 199
paul@329 200
        try:
paul@329 201
            remove(filename)
paul@329 202
        except OSError:
paul@329 203
            return False
paul@329 204
paul@329 205
        return True
paul@329 206
paul@343 207
    def _remove_collection(self, filename):
paul@343 208
paul@343 209
        "Remove the collection with the given 'filename'."
paul@343 210
paul@343 211
        try:
paul@343 212
            rmdir(filename)
paul@343 213
        except OSError:
paul@343 214
            return False
paul@343 215
paul@343 216
        return True
paul@343 217
paul@670 218
    # User discovery.
paul@670 219
paul@670 220
    def get_users(self):
paul@670 221
paul@670 222
        "Return a list of users."
paul@670 223
paul@670 224
        return listdir(self.store_dir)
paul@670 225
paul@648 226
    # Event and event metadata access.
paul@648 227
paul@119 228
    def get_events(self, user):
paul@119 229
paul@119 230
        "Return a list of event identifiers."
paul@119 231
paul@138 232
        filename = self.get_object_in_store(user, "objects")
paul@808 233
        if not filename or not isdir(filename):
paul@119 234
            return None
paul@119 235
paul@119 236
        return [name for name in listdir(filename) if isfile(join(filename, name))]
paul@119 237
paul@648 238
    def get_all_events(self, user):
paul@648 239
paul@648 240
        "Return a set of (uid, recurrenceid) tuples for all events."
paul@648 241
paul@648 242
        uids = self.get_events(user)
paul@674 243
        if not uids:
paul@674 244
            return set()
paul@648 245
paul@648 246
        all_events = set()
paul@648 247
        for uid in uids:
paul@648 248
            all_events.add((uid, None))
paul@648 249
            all_events.update([(uid, recurrenceid) for recurrenceid in self.get_recurrences(user, uid)])
paul@648 250
paul@648 251
        return all_events
paul@648 252
paul@760 253
    def get_event_filename(self, user, uid, recurrenceid=None, dirname=None, username=None):
paul@648 254
paul@694 255
        """
paul@694 256
        Get the filename providing the event for the given 'user' with the given
paul@694 257
        'uid'. If the optional 'recurrenceid' is specified, a specific instance
paul@694 258
        or occurrence of an event is returned.
paul@648 259
paul@694 260
        Where 'dirname' is specified, the given directory name is used as the
paul@694 261
        base of the location within which any filename will reside.
paul@694 262
        """
paul@648 263
paul@694 264
        if recurrenceid:
paul@760 265
            return self.get_recurrence_filename(user, uid, recurrenceid, dirname, username)
paul@694 266
        else:
paul@760 267
            return self.get_complete_event_filename(user, uid, dirname, username)
paul@648 268
paul@858 269
    def get_event(self, user, uid, recurrenceid=None, dirname=None):
paul@343 270
paul@343 271
        """
paul@343 272
        Get the event for the given 'user' with the given 'uid'. If
paul@343 273
        the optional 'recurrenceid' is specified, a specific instance or
paul@343 274
        occurrence of an event is returned.
paul@343 275
        """
paul@343 276
paul@858 277
        filename = self.get_event_filename(user, uid, recurrenceid, dirname)
paul@808 278
        if not filename or not isfile(filename):
paul@694 279
            return None
paul@694 280
paul@694 281
        return filename and self._get_object(user, filename)
paul@694 282
paul@760 283
    def get_complete_event_filename(self, user, uid, dirname=None, username=None):
paul@694 284
paul@694 285
        """
paul@694 286
        Get the filename providing the event for the given 'user' with the given
paul@694 287
        'uid'. 
paul@694 288
paul@694 289
        Where 'dirname' is specified, the given directory name is used as the
paul@694 290
        base of the location within which any filename will reside.
paul@760 291
paul@760 292
        Where 'username' is specified, the event details will reside in a file
paul@760 293
        bearing that name within a directory having 'uid' as its name.
paul@694 294
        """
paul@694 295
paul@760 296
        return self.get_object_in_store(user, dirname, "objects", uid, username)
paul@343 297
paul@343 298
    def get_complete_event(self, user, uid):
paul@50 299
paul@50 300
        "Get the event for the given 'user' with the given 'uid'."
paul@50 301
paul@694 302
        filename = self.get_complete_event_filename(user, uid)
paul@808 303
        if not filename or not isfile(filename):
paul@50 304
            return None
paul@50 305
paul@694 306
        return filename and self._get_object(user, filename)
paul@50 307
paul@343 308
    def set_event(self, user, uid, recurrenceid, node):
paul@343 309
paul@343 310
        """
paul@343 311
        Set an event for 'user' having the given 'uid' and 'recurrenceid' (which
paul@343 312
        if the latter is specified, a specific instance or occurrence of an
paul@343 313
        event is referenced), using the given 'node' description.
paul@343 314
        """
paul@343 315
paul@343 316
        if recurrenceid:
paul@343 317
            return self.set_recurrence(user, uid, recurrenceid, node)
paul@343 318
        else:
paul@343 319
            return self.set_complete_event(user, uid, node)
paul@343 320
paul@343 321
    def set_complete_event(self, user, uid, node):
paul@50 322
paul@50 323
        "Set an event for 'user' having the given 'uid' and 'node'."
paul@50 324
paul@138 325
        filename = self.get_object_in_store(user, "objects", uid)
paul@50 326
        if not filename:
paul@50 327
            return False
paul@50 328
paul@329 329
        return self._set_object(user, filename, node)
paul@15 330
paul@365 331
    def remove_event(self, user, uid, recurrenceid=None):
paul@234 332
paul@343 333
        """
paul@343 334
        Remove an event for 'user' having the given 'uid'. If the optional
paul@343 335
        'recurrenceid' is specified, a specific instance or occurrence of an
paul@343 336
        event is removed.
paul@343 337
        """
paul@343 338
paul@343 339
        if recurrenceid:
paul@343 340
            return self.remove_recurrence(user, uid, recurrenceid)
paul@343 341
        else:
paul@343 342
            for recurrenceid in self.get_recurrences(user, uid) or []:
paul@343 343
                self.remove_recurrence(user, uid, recurrenceid)
paul@343 344
            return self.remove_complete_event(user, uid)
paul@343 345
paul@343 346
    def remove_complete_event(self, user, uid):
paul@343 347
paul@234 348
        "Remove an event for 'user' having the given 'uid'."
paul@234 349
paul@378 350
        self.remove_recurrences(user, uid)
paul@369 351
paul@234 352
        filename = self.get_object_in_store(user, "objects", uid)
paul@234 353
        if not filename:
paul@234 354
            return False
paul@234 355
paul@329 356
        return self._remove_object(filename)
paul@234 357
paul@334 358
    def get_recurrences(self, user, uid):
paul@334 359
paul@334 360
        """
paul@334 361
        Get additional event instances for an event of the given 'user' with the
paul@694 362
        indicated 'uid'. Both active and cancelled recurrences are returned.
paul@694 363
        """
paul@694 364
paul@694 365
        return self.get_active_recurrences(user, uid) + self.get_cancelled_recurrences(user, uid)
paul@694 366
paul@694 367
    def get_active_recurrences(self, user, uid):
paul@694 368
paul@694 369
        """
paul@694 370
        Get additional event instances for an event of the given 'user' with the
paul@694 371
        indicated 'uid'. Cancelled recurrences are not returned.
paul@334 372
        """
paul@334 373
paul@334 374
        filename = self.get_object_in_store(user, "recurrences", uid)
paul@808 375
        if not filename or not isdir(filename):
paul@347 376
            return []
paul@334 377
paul@334 378
        return [name for name in listdir(filename) if isfile(join(filename, name))]
paul@334 379
paul@694 380
    def get_cancelled_recurrences(self, user, uid):
paul@694 381
paul@694 382
        """
paul@694 383
        Get additional event instances for an event of the given 'user' with the
paul@694 384
        indicated 'uid'. Only cancelled recurrences are returned.
paul@694 385
        """
paul@694 386
paul@782 387
        filename = self.get_object_in_store(user, "cancellations", "recurrences", uid)
paul@808 388
        if not filename or not isdir(filename):
paul@694 389
            return []
paul@694 390
paul@694 391
        return [name for name in listdir(filename) if isfile(join(filename, name))]
paul@694 392
paul@760 393
    def get_recurrence_filename(self, user, uid, recurrenceid, dirname=None, username=None):
paul@694 394
paul@694 395
        """
paul@694 396
        For the event of the given 'user' with the given 'uid', return the
paul@694 397
        filename providing the recurrence with the given 'recurrenceid'.
paul@694 398
paul@694 399
        Where 'dirname' is specified, the given directory name is used as the
paul@694 400
        base of the location within which any filename will reside.
paul@760 401
paul@760 402
        Where 'username' is specified, the event details will reside in a file
paul@760 403
        bearing that name within a directory having 'uid' as its name.
paul@694 404
        """
paul@694 405
paul@760 406
        return self.get_object_in_store(user, dirname, "recurrences", uid, recurrenceid, username)
paul@694 407
paul@334 408
    def get_recurrence(self, user, uid, recurrenceid):
paul@334 409
paul@334 410
        """
paul@334 411
        For the event of the given 'user' with the given 'uid', return the
paul@334 412
        specific recurrence indicated by the 'recurrenceid'.
paul@334 413
        """
paul@334 414
paul@694 415
        filename = self.get_recurrence_filename(user, uid, recurrenceid)
paul@808 416
        if not filename or not isfile(filename):
paul@334 417
            return None
paul@334 418
paul@694 419
        return filename and self._get_object(user, filename)
paul@334 420
paul@334 421
    def set_recurrence(self, user, uid, recurrenceid, node):
paul@334 422
paul@334 423
        "Set an event for 'user' having the given 'uid' and 'node'."
paul@334 424
paul@334 425
        filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid)
paul@334 426
        if not filename:
paul@334 427
            return False
paul@334 428
paul@334 429
        return self._set_object(user, filename, node)
paul@334 430
paul@334 431
    def remove_recurrence(self, user, uid, recurrenceid):
paul@334 432
paul@378 433
        """
paul@378 434
        Remove a special recurrence from an event stored by 'user' having the
paul@378 435
        given 'uid' and 'recurrenceid'.
paul@378 436
        """
paul@334 437
paul@378 438
        filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid)
paul@334 439
        if not filename:
paul@334 440
            return False
paul@334 441
paul@334 442
        return self._remove_object(filename)
paul@334 443
paul@378 444
    def remove_recurrences(self, user, uid):
paul@378 445
paul@378 446
        """
paul@378 447
        Remove all recurrences for an event stored by 'user' having the given
paul@378 448
        'uid'.
paul@378 449
        """
paul@378 450
paul@378 451
        for recurrenceid in self.get_recurrences(user, uid):
paul@378 452
            self.remove_recurrence(user, uid, recurrenceid)
paul@378 453
paul@378 454
        recurrences = self.get_object_in_store(user, "recurrences", uid)
paul@378 455
        if recurrences:
paul@378 456
            return self._remove_collection(recurrences)
paul@378 457
paul@378 458
        return True
paul@378 459
paul@652 460
    # Free/busy period providers, upon extension of the free/busy records.
paul@652 461
paul@672 462
    def _get_freebusy_providers(self, user):
paul@672 463
paul@672 464
        """
paul@672 465
        Return the free/busy providers for the given 'user'.
paul@672 466
paul@672 467
        This function returns any stored datetime and a list of providers as a
paul@672 468
        2-tuple. Each provider is itself a (uid, recurrenceid) tuple.
paul@672 469
        """
paul@672 470
paul@672 471
        filename = self.get_object_in_store(user, "freebusy-providers")
paul@808 472
        if not filename or not isfile(filename):
paul@672 473
            return None
paul@672 474
paul@672 475
        # Attempt to read providers, with a declaration of the datetime
paul@672 476
        # from which such providers are considered as still being active.
paul@672 477
paul@702 478
        t = self._get_table_atomic(user, filename, [(1, None)])
paul@672 479
        try:
paul@672 480
            dt_string = t[0][0]
paul@672 481
        except IndexError:
paul@672 482
            return None
paul@672 483
paul@672 484
        return dt_string, t[1:]
paul@672 485
paul@652 486
    def get_freebusy_providers(self, user, dt=None):
paul@652 487
paul@652 488
        """
paul@652 489
        Return a set of uncancelled events of the form (uid, recurrenceid)
paul@652 490
        providing free/busy details beyond the given datetime 'dt'.
paul@654 491
paul@654 492
        If 'dt' is not specified, all events previously found to provide
paul@654 493
        details will be returned. Otherwise, if 'dt' is earlier than the
paul@654 494
        datetime recorded for the known providers, None is returned, indicating
paul@654 495
        that the list of providers must be recomputed.
paul@672 496
paul@672 497
        This function returns a list of (uid, recurrenceid) tuples upon success.
paul@652 498
        """
paul@652 499
paul@672 500
        t = self._get_freebusy_providers(user)
paul@672 501
        if not t:
paul@672 502
            return None
paul@672 503
paul@672 504
        dt_string, t = t
paul@672 505
paul@672 506
        # If the requested datetime is earlier than the stated datetime, the
paul@672 507
        # providers will need to be recomputed.
paul@672 508
paul@672 509
        if dt:
paul@672 510
            providers_dt = get_datetime(dt_string)
paul@672 511
            if not providers_dt or providers_dt > dt:
paul@672 512
                return None
paul@672 513
paul@672 514
        # Otherwise, return the providers.
paul@672 515
paul@672 516
        return t[1:]
paul@672 517
paul@672 518
    def _set_freebusy_providers(self, user, dt_string, t):
paul@672 519
paul@672 520
        "Set the given provider timestamp 'dt_string' and table 't'."
paul@672 521
paul@652 522
        filename = self.get_object_in_store(user, "freebusy-providers")
paul@672 523
        if not filename:
paul@672 524
            return False
paul@652 525
paul@672 526
        t.insert(0, (dt_string,))
paul@702 527
        self._set_table_atomic(user, filename, t, [(1, "")])
paul@672 528
        return True
paul@652 529
paul@654 530
    def set_freebusy_providers(self, user, dt, providers):
paul@654 531
paul@654 532
        """
paul@654 533
        Define the uncancelled events providing free/busy details beyond the
paul@654 534
        given datetime 'dt'.
paul@654 535
        """
paul@654 536
paul@672 537
        t = []
paul@654 538
paul@654 539
        for obj in providers:
paul@672 540
            t.append((obj.get_uid(), obj.get_recurrenceid()))
paul@672 541
paul@672 542
        return self._set_freebusy_providers(user, format_datetime(dt), t)
paul@654 543
paul@672 544
    def append_freebusy_provider(self, user, provider):
paul@672 545
paul@672 546
        "For the given 'user', append the free/busy 'provider'."
paul@672 547
paul@672 548
        t = self._get_freebusy_providers(user)
paul@672 549
        if not t:
paul@654 550
            return False
paul@654 551
paul@672 552
        dt_string, t = t
paul@672 553
        t.append((provider.get_uid(), provider.get_recurrenceid()))
paul@672 554
paul@672 555
        return self._set_freebusy_providers(user, dt_string, t)
paul@672 556
paul@672 557
    def remove_freebusy_provider(self, user, provider):
paul@672 558
paul@672 559
        "For the given 'user', remove the free/busy 'provider'."
paul@672 560
paul@672 561
        t = self._get_freebusy_providers(user)
paul@672 562
        if not t:
paul@672 563
            return False
paul@672 564
paul@672 565
        dt_string, t = t
paul@672 566
        try:
paul@672 567
            t.remove((provider.get_uid(), provider.get_recurrenceid()))
paul@672 568
        except ValueError:
paul@672 569
            return False
paul@672 570
paul@672 571
        return self._set_freebusy_providers(user, dt_string, t)
paul@654 572
paul@648 573
    # Free/busy period access.
paul@648 574
paul@702 575
    def get_freebusy(self, user, name=None, get_table=None):
paul@15 576
paul@15 577
        "Get free/busy details for the given 'user'."
paul@15 578
paul@702 579
        filename = self.get_object_in_store(user, name or "freebusy")
paul@808 580
        if not filename or not isfile(filename):
paul@167 581
            return []
paul@112 582
        else:
paul@702 583
            return map(lambda t: FreeBusyPeriod(*t),
paul@702 584
                (get_table or self._get_table_atomic)(user, filename, [(4, None)]))
paul@702 585
paul@702 586
    def get_freebusy_for_other(self, user, other, get_table=None):
paul@112 587
paul@112 588
        "For the given 'user', get free/busy details for the 'other' user."
paul@112 589
paul@112 590
        filename = self.get_object_in_store(user, "freebusy-other", other)
paul@808 591
        if not filename or not isfile(filename):
paul@167 592
            return []
paul@112 593
        else:
paul@702 594
            return map(lambda t: FreeBusyPeriod(*t),
paul@702 595
                (get_table or self._get_table_atomic)(user, filename, [(4, None)]))
paul@702 596
paul@702 597
    def set_freebusy(self, user, freebusy, name=None, set_table=None):
paul@15 598
paul@15 599
        "For the given 'user', set 'freebusy' details."
paul@15 600
paul@702 601
        filename = self.get_object_in_store(user, name or "freebusy")
paul@15 602
        if not filename:
paul@15 603
            return False
paul@15 604
paul@702 605
        (set_table or self._set_table_atomic)(user, filename,
paul@702 606
            map(lambda fb: fb.as_tuple(strings_only=True), freebusy))
paul@15 607
        return True
paul@15 608
paul@702 609
    def set_freebusy_for_other(self, user, freebusy, other, set_table=None):
paul@110 610
paul@110 611
        "For the given 'user', set 'freebusy' details for the 'other' user."
paul@110 612
paul@110 613
        filename = self.get_object_in_store(user, "freebusy-other", other)
paul@110 614
        if not filename:
paul@110 615
            return False
paul@110 616
paul@702 617
        (set_table or self._set_table_atomic)(user, filename,
paul@702 618
            map(lambda fb: fb.as_tuple(strings_only=True), freebusy))
paul@112 619
        return True
paul@112 620
paul@710 621
    # Tentative free/busy periods related to countering.
paul@710 622
paul@710 623
    def get_freebusy_offers(self, user):
paul@710 624
paul@710 625
        "Get free/busy offers for the given 'user'."
paul@710 626
paul@710 627
        offers = []
paul@710 628
        expired = []
paul@741 629
        now = to_timezone(datetime.utcnow(), "UTC")
paul@710 630
paul@710 631
        # Expire old offers and save the collection if modified.
paul@710 632
paul@730 633
        self.acquire_lock(user)
paul@710 634
        try:
paul@730 635
            l = self.get_freebusy(user, "freebusy-offers")
paul@710 636
            for fb in l:
paul@710 637
                if fb.expires and get_datetime(fb.expires) <= now:
paul@710 638
                    expired.append(fb)
paul@710 639
                else:
paul@710 640
                    offers.append(fb)
paul@710 641
paul@710 642
            if expired:
paul@730 643
                self.set_freebusy_offers(user, offers)
paul@710 644
        finally:
paul@730 645
            self.release_lock(user)
paul@710 646
paul@710 647
        return offers
paul@710 648
paul@710 649
    def set_freebusy_offers(self, user, freebusy):
paul@710 650
paul@710 651
        "For the given 'user', set 'freebusy' offers."
paul@710 652
paul@710 653
        return self.set_freebusy(user, freebusy, "freebusy-offers")
paul@710 654
paul@747 655
    # Requests and counter-proposals.
paul@648 656
paul@142 657
    def _get_requests(self, user, queue):
paul@66 658
paul@142 659
        "Get requests for the given 'user' from the given 'queue'."
paul@66 660
paul@142 661
        filename = self.get_object_in_store(user, queue)
paul@808 662
        if not filename or not isfile(filename):
paul@66 663
            return None
paul@66 664
paul@747 665
        return self._get_table_atomic(user, filename, [(1, None), (2, None)])
paul@66 666
paul@142 667
    def get_requests(self, user):
paul@142 668
paul@142 669
        "Get requests for the given 'user'."
paul@142 670
paul@142 671
        return self._get_requests(user, "requests")
paul@142 672
paul@142 673
    def _set_requests(self, user, requests, queue):
paul@66 674
paul@142 675
        """
paul@142 676
        For the given 'user', set the list of queued 'requests' in the given
paul@142 677
        'queue'.
paul@142 678
        """
paul@142 679
paul@142 680
        filename = self.get_object_in_store(user, queue)
paul@66 681
        if not filename:
paul@66 682
            return False
paul@66 683
paul@747 684
        self._set_table_atomic(user, filename, requests, [(1, ""), (2, "")])
paul@66 685
        return True
paul@66 686
paul@142 687
    def set_requests(self, user, requests):
paul@142 688
paul@142 689
        "For the given 'user', set the list of queued 'requests'."
paul@142 690
paul@142 691
        return self._set_requests(user, requests, "requests")
paul@142 692
paul@747 693
    def _set_request(self, user, request, queue):
paul@142 694
paul@343 695
        """
paul@747 696
        For the given 'user', set the given 'request' in the given 'queue'.
paul@343 697
        """
paul@142 698
paul@142 699
        filename = self.get_object_in_store(user, queue)
paul@55 700
        if not filename:
paul@55 701
            return False
paul@55 702
paul@303 703
        self.acquire_lock(user)
paul@55 704
        try:
paul@747 705
            f = codecs.open(filename, "ab", encoding="utf-8")
paul@303 706
            try:
paul@747 707
                self._set_table_item(f, request, [(1, ""), (2, "")])
paul@303 708
            finally:
paul@303 709
                f.close()
paul@303 710
                fix_permissions(filename)
paul@55 711
        finally:
paul@303 712
            self.release_lock(user)
paul@55 713
paul@55 714
        return True
paul@55 715
paul@747 716
    def set_request(self, user, uid, recurrenceid=None, type=None):
paul@142 717
paul@747 718
        """
paul@747 719
        For the given 'user', set the queued 'uid' and 'recurrenceid',
paul@747 720
        indicating a request, along with any given 'type'.
paul@747 721
        """
paul@142 722
paul@747 723
        return self._set_request(user, (uid, recurrenceid, type), "requests")
paul@747 724
paul@747 725
    def queue_request(self, user, uid, recurrenceid=None, type=None):
paul@142 726
paul@343 727
        """
paul@343 728
        Queue a request for 'user' having the given 'uid'. If the optional
paul@747 729
        'recurrenceid' is specified, the entry refers to a specific instance
paul@747 730
        or occurrence of an event. The 'type' parameter can be used to indicate
paul@747 731
        a specific type of request.
paul@747 732
        """
paul@747 733
paul@747 734
        requests = self.get_requests(user) or []
paul@747 735
paul@747 736
        if not self.have_request(requests, uid, recurrenceid):
paul@747 737
            return self.set_request(user, uid, recurrenceid, type)
paul@747 738
paul@747 739
        return False
paul@747 740
paul@840 741
    def dequeue_request(self, user, uid, recurrenceid=None):
paul@747 742
paul@747 743
        """
paul@747 744
        Dequeue all requests for 'user' having the given 'uid'. If the optional
paul@747 745
        'recurrenceid' is specified, all requests for that specific instance or
paul@747 746
        occurrence of an event are dequeued.
paul@343 747
        """
paul@66 748
paul@81 749
        requests = self.get_requests(user) or []
paul@750 750
        result = []
paul@747 751
paul@750 752
        for request in requests:
paul@808 753
            if request[:2] != (uid, recurrenceid):
paul@750 754
                result.append(request)
paul@747 755
paul@750 756
        self.set_requests(user, result)
paul@747 757
        return True
paul@747 758
paul@817 759
    def has_request(self, user, uid, recurrenceid=None, type=None, strict=False):
paul@817 760
        return self.have_request(self.get_requests(user) or [], uid, recurrenceid, type, strict)
paul@817 761
paul@754 762
    def have_request(self, requests, uid, recurrenceid=None, type=None, strict=False):
paul@754 763
paul@754 764
        """
paul@754 765
        Return whether 'requests' contains a request with the given 'uid' and
paul@754 766
        any specified 'recurrenceid' and 'type'. If 'strict' is set to a true
paul@754 767
        value, the precise type of the request must match; otherwise, any type
paul@754 768
        of request for the identified object may be matched.
paul@754 769
        """
paul@754 770
paul@750 771
        for request in requests:
paul@762 772
            if request[:2] == (uid, recurrenceid) and (
paul@762 773
                not strict or
paul@762 774
                not request[2:] and not type or
paul@762 775
                request[2:] and request[2] == type):
paul@762 776
paul@750 777
                return True
paul@762 778
paul@750 779
        return False
paul@747 780
paul@760 781
    def get_counters(self, user, uid, recurrenceid=None):
paul@754 782
paul@754 783
        """
paul@766 784
        For the given 'user', return a list of users from whom counter-proposals
paul@766 785
        have been received for the given 'uid' and optional 'recurrenceid'.
paul@754 786
        """
paul@754 787
paul@754 788
        filename = self.get_event_filename(user, uid, recurrenceid, "counters")
paul@808 789
        if not filename or not isdir(filename):
paul@754 790
            return False
paul@754 791
paul@766 792
        return [name for name in listdir(filename) if isfile(join(filename, name))]
paul@760 793
paul@760 794
    def get_counter(self, user, other, uid, recurrenceid=None):
paul@105 795
paul@343 796
        """
paul@760 797
        For the given 'user', return the counter-proposal from 'other' for the
paul@760 798
        given 'uid' and optional 'recurrenceid'.
paul@760 799
        """
paul@760 800
paul@760 801
        filename = self.get_event_filename(user, uid, recurrenceid, "counters", other)
paul@760 802
        if not filename:
paul@760 803
            return False
paul@760 804
paul@760 805
        return self._get_object(user, filename)
paul@760 806
paul@760 807
    def set_counter(self, user, other, node, uid, recurrenceid=None):
paul@760 808
paul@760 809
        """
paul@760 810
        For the given 'user', store a counter-proposal received from 'other' the
paul@760 811
        given 'node' representing that proposal for the given 'uid' and
paul@760 812
        'recurrenceid'.
paul@760 813
        """
paul@760 814
paul@760 815
        filename = self.get_event_filename(user, uid, recurrenceid, "counters", other)
paul@760 816
        if not filename:
paul@760 817
            return False
paul@760 818
paul@760 819
        return self._set_object(user, filename, node)
paul@760 820
paul@760 821
    def remove_counters(self, user, uid, recurrenceid=None):
paul@760 822
paul@760 823
        """
paul@760 824
        For the given 'user', remove all counter-proposals associated with the
paul@760 825
        given 'uid' and 'recurrenceid'.
paul@343 826
        """
paul@105 827
paul@747 828
        filename = self.get_event_filename(user, uid, recurrenceid, "counters")
paul@808 829
        if not filename or not isdir(filename):
paul@747 830
            return False
paul@747 831
paul@760 832
        removed = False
paul@747 833
paul@760 834
        for other in listdir(filename):
paul@760 835
            counter_filename = self.get_event_filename(user, uid, recurrenceid, "counters", other)
paul@760 836
            removed = removed or self._remove_object(counter_filename)
paul@760 837
paul@760 838
        return removed
paul@760 839
paul@760 840
    def remove_counter(self, user, other, uid, recurrenceid=None):
paul@105 841
paul@747 842
        """
paul@760 843
        For the given 'user', remove any counter-proposal from 'other'
paul@760 844
        associated with the given 'uid' and 'recurrenceid'.
paul@747 845
        """
paul@747 846
paul@760 847
        filename = self.get_event_filename(user, uid, recurrenceid, "counters", other)
paul@808 848
        if not filename or not isfile(filename):
paul@105 849
            return False
paul@747 850
paul@747 851
        return self._remove_object(filename)
paul@747 852
paul@747 853
    # Event cancellation.
paul@105 854
paul@343 855
    def cancel_event(self, user, uid, recurrenceid=None):
paul@142 856
paul@343 857
        """
paul@694 858
        Cancel an event for 'user' having the given 'uid'. If the optional
paul@694 859
        'recurrenceid' is specified, a specific instance or occurrence of an
paul@694 860
        event is cancelled.
paul@343 861
        """
paul@142 862
paul@694 863
        filename = self.get_event_filename(user, uid, recurrenceid)
paul@694 864
        cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations")
paul@142 865
paul@808 866
        if filename and cancelled_filename and isfile(filename):
paul@694 867
            return self.move_object(filename, cancelled_filename)
paul@142 868
paul@142 869
        return False
paul@142 870
paul@863 871
    def uncancel_event(self, user, uid, recurrenceid=None):
paul@863 872
paul@863 873
        """
paul@863 874
        Uncancel an event for 'user' having the given 'uid'. If the optional
paul@863 875
        'recurrenceid' is specified, a specific instance or occurrence of an
paul@863 876
        event is uncancelled.
paul@863 877
        """
paul@863 878
paul@863 879
        filename = self.get_event_filename(user, uid, recurrenceid)
paul@863 880
        cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations")
paul@863 881
paul@863 882
        if filename and cancelled_filename and isfile(cancelled_filename):
paul@863 883
            return self.move_object(cancelled_filename, filename)
paul@863 884
paul@863 885
        return False
paul@863 886
paul@796 887
    def remove_cancellations(self, user, uid, recurrenceid=None):
paul@796 888
paul@796 889
        """
paul@796 890
        Remove cancellations for 'user' for any event having the given 'uid'. If
paul@796 891
        the optional 'recurrenceid' is specified, a specific instance or
paul@796 892
        occurrence of an event is affected.
paul@796 893
        """
paul@796 894
paul@796 895
        # Remove all recurrence cancellations if a general event is indicated.
paul@796 896
paul@796 897
        if not recurrenceid:
paul@796 898
            for _recurrenceid in self.get_cancelled_recurrences(user, uid):
paul@796 899
                self.remove_cancellation(user, uid, _recurrenceid)
paul@796 900
paul@796 901
        return self.remove_cancellation(user, uid, recurrenceid)
paul@796 902
paul@796 903
    def remove_cancellation(self, user, uid, recurrenceid=None):
paul@796 904
paul@796 905
        """
paul@796 906
        Remove a cancellation for 'user' for the event having the given 'uid'.
paul@796 907
        If the optional 'recurrenceid' is specified, a specific instance or
paul@796 908
        occurrence of an event is affected.
paul@796 909
        """
paul@796 910
paul@796 911
        # Remove any parent event cancellation or a specific recurrence
paul@796 912
        # cancellation if indicated.
paul@796 913
paul@796 914
        filename = self.get_event_filename(user, uid, recurrenceid, "cancellations")
paul@796 915
paul@808 916
        if filename and isfile(filename):
paul@796 917
            return self._remove_object(filename)
paul@796 918
paul@796 919
        return False
paul@796 920
paul@30 921
class FilePublisher(FileBase):
paul@30 922
paul@30 923
    "A publisher of objects."
paul@30 924
paul@597 925
    def __init__(self, store_dir=None):
paul@597 926
        FileBase.__init__(self, store_dir or PUBLISH_DIR)
paul@30 927
paul@30 928
    def set_freebusy(self, user, freebusy):
paul@30 929
paul@30 930
        "For the given 'user', set 'freebusy' details."
paul@30 931
paul@52 932
        filename = self.get_object_in_store(user, "freebusy")
paul@30 933
        if not filename:
paul@30 934
            return False
paul@30 935
paul@30 936
        record = []
paul@30 937
        rwrite = record.append
paul@30 938
paul@30 939
        rwrite(("ORGANIZER", {}, user))
paul@30 940
        rwrite(("UID", {}, user))
paul@30 941
        rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")))
paul@30 942
paul@458 943
        for fb in freebusy:
paul@458 944
            if not fb.transp or fb.transp == "OPAQUE":
paul@529 945
                rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join(
paul@563 946
                    map(format_datetime, [fb.get_start_point(), fb.get_end_point()]))))
paul@30 947
paul@395 948
        f = open(filename, "wb")
paul@30 949
        try:
paul@30 950
            to_stream(f, make_calendar([("VFREEBUSY", {}, record)], "PUBLISH"))
paul@30 951
        finally:
paul@30 952
            f.close()
paul@103 953
            fix_permissions(filename)
paul@30 954
paul@30 955
        return True
paul@30 956
paul@1039 957
class FileJournal(FileStoreBase):
paul@1039 958
paul@1039 959
    "A journal system to support quotas."
paul@1039 960
paul@1039 961
    def __init__(self, store_dir=None):
paul@1039 962
        FileBase.__init__(self, store_dir or JOURNAL_DIR)
paul@1039 963
paul@1049 964
    # Quota and user identity/group discovery.
paul@1049 965
paul@1049 966
    def get_quotas(self):
paul@1049 967
paul@1049 968
        "Return a list of quotas."
paul@1049 969
paul@1049 970
        return listdir(self.store_dir)
paul@1049 971
paul@1049 972
    def get_quota_users(self, quota):
paul@1049 973
paul@1049 974
        "Return a list of quota users."
paul@1049 975
paul@1049 976
        filename = self.get_object_in_store(quota, "journal")
paul@1049 977
        if not filename or not isdir(filename):
paul@1049 978
            return []
paul@1049 979
paul@1049 980
        return listdir(filename)
paul@1049 981
paul@1039 982
    # Groups of users sharing quotas.
paul@1039 983
paul@1039 984
    def get_groups(self, quota):
paul@1039 985
paul@1039 986
        "Return the identity mappings for the given 'quota' as a dictionary."
paul@1039 987
paul@1039 988
        filename = self.get_object_in_store(quota, "groups")
paul@1039 989
        if not filename or not isfile(filename):
paul@1039 990
            return {}
paul@1039 991
paul@1046 992
        return dict(self._get_table_atomic(quota, filename, tab_separated=False))
paul@1039 993
paul@1039 994
    def get_limits(self, quota):
paul@1039 995
paul@1039 996
        """
paul@1039 997
        Return the limits for the 'quota' as a dictionary mapping identities or
paul@1039 998
        groups to durations.
paul@1039 999
        """
paul@1039 1000
paul@1039 1001
        filename = self.get_object_in_store(quota, "limits")
paul@1039 1002
        if not filename or not isfile(filename):
paul@1039 1003
            return None
paul@1039 1004
paul@1046 1005
        return dict(self._get_table_atomic(quota, filename, tab_separated=False))
paul@1039 1006
paul@1048 1007
    # Free/busy period access for users within quota groups.
paul@1039 1008
paul@1039 1009
    def get_freebusy(self, quota, user, get_table=None):
paul@1039 1010
paul@1039 1011
        "Get free/busy details for the given 'quota' and 'user'."
paul@1039 1012
paul@1039 1013
        filename = self.get_object_in_store(quota, "freebusy", user)
paul@1039 1014
        if not filename or not isfile(filename):
paul@1039 1015
            return []
paul@1039 1016
        else:
paul@1039 1017
            return map(lambda t: FreeBusyPeriod(*t),
paul@1039 1018
                (get_table or self._get_table_atomic)(quota, filename, [(4, None)]))
paul@1039 1019
paul@1039 1020
    def set_freebusy(self, quota, user, freebusy, set_table=None):
paul@1039 1021
paul@1039 1022
        "For the given 'quota' and 'user', set 'freebusy' details."
paul@1039 1023
paul@1039 1024
        filename = self.get_object_in_store(quota, "freebusy", user)
paul@1039 1025
        if not filename:
paul@1039 1026
            return False
paul@1039 1027
paul@1039 1028
        (set_table or self._set_table_atomic)(quota, filename,
paul@1039 1029
            map(lambda fb: fb.as_tuple(strings_only=True), freebusy))
paul@1039 1030
        return True
paul@1039 1031
paul@1039 1032
    # Journal entry methods.
paul@1039 1033
paul@1039 1034
    def get_entries(self, quota, group):
paul@1039 1035
paul@1039 1036
        """
paul@1039 1037
        Return a list of journal entries for the given 'quota' for the indicated
paul@1039 1038
        'group'.
paul@1039 1039
        """
paul@1039 1040
paul@1039 1041
        filename = self.get_object_in_store(quota, "journal", group)
paul@1039 1042
        if not filename or not isfile(filename):
paul@1039 1043
            return []
paul@1039 1044
paul@1048 1045
        return self._get_table_atomic(quota, filename, [(1, None), (3, None)])
paul@1039 1046
paul@1039 1047
    def set_entries(self, quota, group, entries):
paul@1039 1048
paul@1039 1049
        """
paul@1039 1050
        For the given 'quota' and indicated 'group', set the list of journal
paul@1039 1051
        'entries'.
paul@1039 1052
        """
paul@1039 1053
paul@1039 1054
        filename = self.get_object_in_store(quota, "journal", group)
paul@1039 1055
        if not filename:
paul@1039 1056
            return False
paul@1039 1057
paul@1048 1058
        self._set_table_atomic(quota, filename, entries, [(1, ""), (3, "")])
paul@1039 1059
        return True
paul@1039 1060
paul@2 1061
# vim: tabstop=4 expandtab shiftwidth=4