imip-agent

Annotated imiptools/stores/file.py

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