imip-agent

Annotated imip_store.py

1063:67369fd525db
2016-03-03 Paul Boddie Introduced a common free/busy collection abstraction and a specific database collection class. 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@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@1062 27
from imiptools.period import FreeBusyPeriod, FreeBusyCollection
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@1062 580
paul@808 581
        if not filename or not isfile(filename):
paul@1062 582
            periods = []
paul@112 583
        else:
paul@1062 584
            periods = map(lambda t: FreeBusyPeriod(*t),
paul@702 585
                (get_table or self._get_table_atomic)(user, filename, [(4, None)]))
paul@702 586
paul@1062 587
        return FreeBusyCollection(periods)
paul@1062 588
paul@702 589
    def get_freebusy_for_other(self, user, other, get_table=None):
paul@112 590
paul@112 591
        "For the given 'user', get free/busy details for the 'other' user."
paul@112 592
paul@112 593
        filename = self.get_object_in_store(user, "freebusy-other", other)
paul@1062 594
paul@808 595
        if not filename or not isfile(filename):
paul@1062 596
            periods = []
paul@112 597
        else:
paul@1062 598
            periods = map(lambda t: FreeBusyPeriod(*t),
paul@702 599
                (get_table or self._get_table_atomic)(user, filename, [(4, None)]))
paul@702 600
paul@1062 601
        return FreeBusyCollection(periods)
paul@1062 602
paul@702 603
    def set_freebusy(self, user, freebusy, name=None, set_table=None):
paul@15 604
paul@15 605
        "For the given 'user', set 'freebusy' details."
paul@15 606
paul@702 607
        filename = self.get_object_in_store(user, name or "freebusy")
paul@15 608
        if not filename:
paul@15 609
            return False
paul@15 610
paul@702 611
        (set_table or self._set_table_atomic)(user, filename,
paul@1062 612
            map(lambda fb: fb.as_tuple(strings_only=True), freebusy.periods))
paul@15 613
        return True
paul@15 614
paul@702 615
    def set_freebusy_for_other(self, user, freebusy, other, set_table=None):
paul@110 616
paul@110 617
        "For the given 'user', set 'freebusy' details for the 'other' user."
paul@110 618
paul@110 619
        filename = self.get_object_in_store(user, "freebusy-other", other)
paul@110 620
        if not filename:
paul@110 621
            return False
paul@110 622
paul@702 623
        (set_table or self._set_table_atomic)(user, filename,
paul@1062 624
            map(lambda fb: fb.as_tuple(strings_only=True), freebusy.periods))
paul@112 625
        return True
paul@112 626
paul@710 627
    # Tentative free/busy periods related to countering.
paul@710 628
paul@710 629
    def get_freebusy_offers(self, user):
paul@710 630
paul@710 631
        "Get free/busy offers for the given 'user'."
paul@710 632
paul@710 633
        offers = []
paul@710 634
        expired = []
paul@741 635
        now = to_timezone(datetime.utcnow(), "UTC")
paul@710 636
paul@710 637
        # Expire old offers and save the collection if modified.
paul@710 638
paul@730 639
        self.acquire_lock(user)
paul@710 640
        try:
paul@730 641
            l = self.get_freebusy(user, "freebusy-offers")
paul@710 642
            for fb in l:
paul@710 643
                if fb.expires and get_datetime(fb.expires) <= now:
paul@710 644
                    expired.append(fb)
paul@710 645
                else:
paul@710 646
                    offers.append(fb)
paul@710 647
paul@710 648
            if expired:
paul@730 649
                self.set_freebusy_offers(user, offers)
paul@710 650
        finally:
paul@730 651
            self.release_lock(user)
paul@710 652
paul@1062 653
        return FreeBusyCollection(offers)
paul@710 654
paul@710 655
    def set_freebusy_offers(self, user, freebusy):
paul@710 656
paul@710 657
        "For the given 'user', set 'freebusy' offers."
paul@710 658
paul@710 659
        return self.set_freebusy(user, freebusy, "freebusy-offers")
paul@710 660
paul@747 661
    # Requests and counter-proposals.
paul@648 662
paul@142 663
    def _get_requests(self, user, queue):
paul@66 664
paul@142 665
        "Get requests for the given 'user' from the given 'queue'."
paul@66 666
paul@142 667
        filename = self.get_object_in_store(user, queue)
paul@808 668
        if not filename or not isfile(filename):
paul@66 669
            return None
paul@66 670
paul@747 671
        return self._get_table_atomic(user, filename, [(1, None), (2, None)])
paul@66 672
paul@142 673
    def get_requests(self, user):
paul@142 674
paul@142 675
        "Get requests for the given 'user'."
paul@142 676
paul@142 677
        return self._get_requests(user, "requests")
paul@142 678
paul@142 679
    def _set_requests(self, user, requests, queue):
paul@66 680
paul@142 681
        """
paul@142 682
        For the given 'user', set the list of queued 'requests' in the given
paul@142 683
        'queue'.
paul@142 684
        """
paul@142 685
paul@142 686
        filename = self.get_object_in_store(user, queue)
paul@66 687
        if not filename:
paul@66 688
            return False
paul@66 689
paul@747 690
        self._set_table_atomic(user, filename, requests, [(1, ""), (2, "")])
paul@66 691
        return True
paul@66 692
paul@142 693
    def set_requests(self, user, requests):
paul@142 694
paul@142 695
        "For the given 'user', set the list of queued 'requests'."
paul@142 696
paul@142 697
        return self._set_requests(user, requests, "requests")
paul@142 698
paul@747 699
    def _set_request(self, user, request, queue):
paul@142 700
paul@343 701
        """
paul@747 702
        For the given 'user', set the given 'request' in the given 'queue'.
paul@343 703
        """
paul@142 704
paul@142 705
        filename = self.get_object_in_store(user, queue)
paul@55 706
        if not filename:
paul@55 707
            return False
paul@55 708
paul@303 709
        self.acquire_lock(user)
paul@55 710
        try:
paul@747 711
            f = codecs.open(filename, "ab", encoding="utf-8")
paul@303 712
            try:
paul@747 713
                self._set_table_item(f, request, [(1, ""), (2, "")])
paul@303 714
            finally:
paul@303 715
                f.close()
paul@303 716
                fix_permissions(filename)
paul@55 717
        finally:
paul@303 718
            self.release_lock(user)
paul@55 719
paul@55 720
        return True
paul@55 721
paul@747 722
    def set_request(self, user, uid, recurrenceid=None, type=None):
paul@142 723
paul@747 724
        """
paul@747 725
        For the given 'user', set the queued 'uid' and 'recurrenceid',
paul@747 726
        indicating a request, along with any given 'type'.
paul@747 727
        """
paul@142 728
paul@747 729
        return self._set_request(user, (uid, recurrenceid, type), "requests")
paul@747 730
paul@747 731
    def queue_request(self, user, uid, recurrenceid=None, type=None):
paul@142 732
paul@343 733
        """
paul@343 734
        Queue a request for 'user' having the given 'uid'. If the optional
paul@747 735
        'recurrenceid' is specified, the entry refers to a specific instance
paul@747 736
        or occurrence of an event. The 'type' parameter can be used to indicate
paul@747 737
        a specific type of request.
paul@747 738
        """
paul@747 739
paul@747 740
        requests = self.get_requests(user) or []
paul@747 741
paul@747 742
        if not self.have_request(requests, uid, recurrenceid):
paul@747 743
            return self.set_request(user, uid, recurrenceid, type)
paul@747 744
paul@747 745
        return False
paul@747 746
paul@840 747
    def dequeue_request(self, user, uid, recurrenceid=None):
paul@747 748
paul@747 749
        """
paul@747 750
        Dequeue all requests for 'user' having the given 'uid'. If the optional
paul@747 751
        'recurrenceid' is specified, all requests for that specific instance or
paul@747 752
        occurrence of an event are dequeued.
paul@343 753
        """
paul@66 754
paul@81 755
        requests = self.get_requests(user) or []
paul@750 756
        result = []
paul@747 757
paul@750 758
        for request in requests:
paul@808 759
            if request[:2] != (uid, recurrenceid):
paul@750 760
                result.append(request)
paul@747 761
paul@750 762
        self.set_requests(user, result)
paul@747 763
        return True
paul@747 764
paul@817 765
    def has_request(self, user, uid, recurrenceid=None, type=None, strict=False):
paul@817 766
        return self.have_request(self.get_requests(user) or [], uid, recurrenceid, type, strict)
paul@817 767
paul@754 768
    def have_request(self, requests, uid, recurrenceid=None, type=None, strict=False):
paul@754 769
paul@754 770
        """
paul@754 771
        Return whether 'requests' contains a request with the given 'uid' and
paul@754 772
        any specified 'recurrenceid' and 'type'. If 'strict' is set to a true
paul@754 773
        value, the precise type of the request must match; otherwise, any type
paul@754 774
        of request for the identified object may be matched.
paul@754 775
        """
paul@754 776
paul@750 777
        for request in requests:
paul@762 778
            if request[:2] == (uid, recurrenceid) and (
paul@762 779
                not strict or
paul@762 780
                not request[2:] and not type or
paul@762 781
                request[2:] and request[2] == type):
paul@762 782
paul@750 783
                return True
paul@762 784
paul@750 785
        return False
paul@747 786
paul@760 787
    def get_counters(self, user, uid, recurrenceid=None):
paul@754 788
paul@754 789
        """
paul@766 790
        For the given 'user', return a list of users from whom counter-proposals
paul@766 791
        have been received for the given 'uid' and optional 'recurrenceid'.
paul@754 792
        """
paul@754 793
paul@754 794
        filename = self.get_event_filename(user, uid, recurrenceid, "counters")
paul@808 795
        if not filename or not isdir(filename):
paul@754 796
            return False
paul@754 797
paul@766 798
        return [name for name in listdir(filename) if isfile(join(filename, name))]
paul@760 799
paul@760 800
    def get_counter(self, user, other, uid, recurrenceid=None):
paul@105 801
paul@343 802
        """
paul@760 803
        For the given 'user', return the counter-proposal from 'other' for the
paul@760 804
        given 'uid' and optional 'recurrenceid'.
paul@760 805
        """
paul@760 806
paul@760 807
        filename = self.get_event_filename(user, uid, recurrenceid, "counters", other)
paul@760 808
        if not filename:
paul@760 809
            return False
paul@760 810
paul@760 811
        return self._get_object(user, filename)
paul@760 812
paul@760 813
    def set_counter(self, user, other, node, uid, recurrenceid=None):
paul@760 814
paul@760 815
        """
paul@760 816
        For the given 'user', store a counter-proposal received from 'other' the
paul@760 817
        given 'node' representing that proposal for the given 'uid' and
paul@760 818
        'recurrenceid'.
paul@760 819
        """
paul@760 820
paul@760 821
        filename = self.get_event_filename(user, uid, recurrenceid, "counters", other)
paul@760 822
        if not filename:
paul@760 823
            return False
paul@760 824
paul@760 825
        return self._set_object(user, filename, node)
paul@760 826
paul@760 827
    def remove_counters(self, user, uid, recurrenceid=None):
paul@760 828
paul@760 829
        """
paul@760 830
        For the given 'user', remove all counter-proposals associated with the
paul@760 831
        given 'uid' and 'recurrenceid'.
paul@343 832
        """
paul@105 833
paul@747 834
        filename = self.get_event_filename(user, uid, recurrenceid, "counters")
paul@808 835
        if not filename or not isdir(filename):
paul@747 836
            return False
paul@747 837
paul@760 838
        removed = False
paul@747 839
paul@760 840
        for other in listdir(filename):
paul@760 841
            counter_filename = self.get_event_filename(user, uid, recurrenceid, "counters", other)
paul@760 842
            removed = removed or self._remove_object(counter_filename)
paul@760 843
paul@760 844
        return removed
paul@760 845
paul@760 846
    def remove_counter(self, user, other, uid, recurrenceid=None):
paul@105 847
paul@747 848
        """
paul@760 849
        For the given 'user', remove any counter-proposal from 'other'
paul@760 850
        associated with the given 'uid' and 'recurrenceid'.
paul@747 851
        """
paul@747 852
paul@760 853
        filename = self.get_event_filename(user, uid, recurrenceid, "counters", other)
paul@808 854
        if not filename or not isfile(filename):
paul@105 855
            return False
paul@747 856
paul@747 857
        return self._remove_object(filename)
paul@747 858
paul@747 859
    # Event cancellation.
paul@105 860
paul@343 861
    def cancel_event(self, user, uid, recurrenceid=None):
paul@142 862
paul@343 863
        """
paul@694 864
        Cancel an event for 'user' having the given 'uid'. If the optional
paul@694 865
        'recurrenceid' is specified, a specific instance or occurrence of an
paul@694 866
        event is cancelled.
paul@343 867
        """
paul@142 868
paul@694 869
        filename = self.get_event_filename(user, uid, recurrenceid)
paul@694 870
        cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations")
paul@142 871
paul@808 872
        if filename and cancelled_filename and isfile(filename):
paul@694 873
            return self.move_object(filename, cancelled_filename)
paul@142 874
paul@142 875
        return False
paul@142 876
paul@863 877
    def uncancel_event(self, user, uid, recurrenceid=None):
paul@863 878
paul@863 879
        """
paul@863 880
        Uncancel an event for 'user' having the given 'uid'. If the optional
paul@863 881
        'recurrenceid' is specified, a specific instance or occurrence of an
paul@863 882
        event is uncancelled.
paul@863 883
        """
paul@863 884
paul@863 885
        filename = self.get_event_filename(user, uid, recurrenceid)
paul@863 886
        cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations")
paul@863 887
paul@863 888
        if filename and cancelled_filename and isfile(cancelled_filename):
paul@863 889
            return self.move_object(cancelled_filename, filename)
paul@863 890
paul@863 891
        return False
paul@863 892
paul@796 893
    def remove_cancellations(self, user, uid, recurrenceid=None):
paul@796 894
paul@796 895
        """
paul@796 896
        Remove cancellations for 'user' for any event having the given 'uid'. If
paul@796 897
        the optional 'recurrenceid' is specified, a specific instance or
paul@796 898
        occurrence of an event is affected.
paul@796 899
        """
paul@796 900
paul@796 901
        # Remove all recurrence cancellations if a general event is indicated.
paul@796 902
paul@796 903
        if not recurrenceid:
paul@796 904
            for _recurrenceid in self.get_cancelled_recurrences(user, uid):
paul@796 905
                self.remove_cancellation(user, uid, _recurrenceid)
paul@796 906
paul@796 907
        return self.remove_cancellation(user, uid, recurrenceid)
paul@796 908
paul@796 909
    def remove_cancellation(self, user, uid, recurrenceid=None):
paul@796 910
paul@796 911
        """
paul@796 912
        Remove a cancellation for 'user' for the event having the given 'uid'.
paul@796 913
        If the optional 'recurrenceid' is specified, a specific instance or
paul@796 914
        occurrence of an event is affected.
paul@796 915
        """
paul@796 916
paul@796 917
        # Remove any parent event cancellation or a specific recurrence
paul@796 918
        # cancellation if indicated.
paul@796 919
paul@796 920
        filename = self.get_event_filename(user, uid, recurrenceid, "cancellations")
paul@796 921
paul@808 922
        if filename and isfile(filename):
paul@796 923
            return self._remove_object(filename)
paul@796 924
paul@796 925
        return False
paul@796 926
paul@30 927
class FilePublisher(FileBase):
paul@30 928
paul@30 929
    "A publisher of objects."
paul@30 930
paul@597 931
    def __init__(self, store_dir=None):
paul@597 932
        FileBase.__init__(self, store_dir or PUBLISH_DIR)
paul@30 933
paul@30 934
    def set_freebusy(self, user, freebusy):
paul@30 935
paul@30 936
        "For the given 'user', set 'freebusy' details."
paul@30 937
paul@52 938
        filename = self.get_object_in_store(user, "freebusy")
paul@30 939
        if not filename:
paul@30 940
            return False
paul@30 941
paul@30 942
        record = []
paul@30 943
        rwrite = record.append
paul@30 944
paul@30 945
        rwrite(("ORGANIZER", {}, user))
paul@30 946
        rwrite(("UID", {}, user))
paul@30 947
        rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")))
paul@30 948
paul@458 949
        for fb in freebusy:
paul@458 950
            if not fb.transp or fb.transp == "OPAQUE":
paul@529 951
                rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join(
paul@563 952
                    map(format_datetime, [fb.get_start_point(), fb.get_end_point()]))))
paul@30 953
paul@395 954
        f = open(filename, "wb")
paul@30 955
        try:
paul@30 956
            to_stream(f, make_calendar([("VFREEBUSY", {}, record)], "PUBLISH"))
paul@30 957
        finally:
paul@30 958
            f.close()
paul@103 959
            fix_permissions(filename)
paul@30 960
paul@30 961
        return True
paul@30 962
paul@1039 963
class FileJournal(FileStoreBase):
paul@1039 964
paul@1039 965
    "A journal system to support quotas."
paul@1039 966
paul@1039 967
    def __init__(self, store_dir=None):
paul@1039 968
        FileBase.__init__(self, store_dir or JOURNAL_DIR)
paul@1039 969
paul@1049 970
    # Quota and user identity/group discovery.
paul@1049 971
paul@1049 972
    def get_quotas(self):
paul@1049 973
paul@1049 974
        "Return a list of quotas."
paul@1049 975
paul@1049 976
        return listdir(self.store_dir)
paul@1049 977
paul@1049 978
    def get_quota_users(self, quota):
paul@1049 979
paul@1049 980
        "Return a list of quota users."
paul@1049 981
paul@1049 982
        filename = self.get_object_in_store(quota, "journal")
paul@1049 983
        if not filename or not isdir(filename):
paul@1049 984
            return []
paul@1049 985
paul@1049 986
        return listdir(filename)
paul@1049 987
paul@1039 988
    # Groups of users sharing quotas.
paul@1039 989
paul@1039 990
    def get_groups(self, quota):
paul@1039 991
paul@1039 992
        "Return the identity mappings for the given 'quota' as a dictionary."
paul@1039 993
paul@1039 994
        filename = self.get_object_in_store(quota, "groups")
paul@1039 995
        if not filename or not isfile(filename):
paul@1039 996
            return {}
paul@1039 997
paul@1046 998
        return dict(self._get_table_atomic(quota, filename, tab_separated=False))
paul@1039 999
paul@1039 1000
    def get_limits(self, quota):
paul@1039 1001
paul@1039 1002
        """
paul@1039 1003
        Return the limits for the 'quota' as a dictionary mapping identities or
paul@1039 1004
        groups to durations.
paul@1039 1005
        """
paul@1039 1006
paul@1039 1007
        filename = self.get_object_in_store(quota, "limits")
paul@1039 1008
        if not filename or not isfile(filename):
paul@1039 1009
            return None
paul@1039 1010
paul@1046 1011
        return dict(self._get_table_atomic(quota, filename, tab_separated=False))
paul@1039 1012
paul@1048 1013
    # Free/busy period access for users within quota groups.
paul@1039 1014
paul@1039 1015
    def get_freebusy(self, quota, user, get_table=None):
paul@1039 1016
paul@1039 1017
        "Get free/busy details for the given 'quota' and 'user'."
paul@1039 1018
paul@1039 1019
        filename = self.get_object_in_store(quota, "freebusy", user)
paul@1059 1020
paul@1062 1021
        if not filename or not isfile(filename):
paul@1062 1022
            periods = []
paul@1062 1023
        else:
paul@1062 1024
            periods = map(lambda t: FreeBusyPeriod(*t),
paul@1062 1025
                (get_table or self._get_table_atomic)(quota, filename, [(4, None)]))
paul@1062 1026
paul@1062 1027
        return FreeBusyCollection(periods)
paul@1039 1028
paul@1039 1029
    def set_freebusy(self, quota, user, freebusy, set_table=None):
paul@1039 1030
paul@1039 1031
        "For the given 'quota' and 'user', set 'freebusy' details."
paul@1039 1032
paul@1039 1033
        filename = self.get_object_in_store(quota, "freebusy", user)
paul@1039 1034
        if not filename:
paul@1039 1035
            return False
paul@1039 1036
paul@1039 1037
        (set_table or self._set_table_atomic)(quota, filename,
paul@1062 1038
            map(lambda fb: fb.as_tuple(strings_only=True), freebusy.periods))
paul@1039 1039
        return True
paul@1039 1040
paul@1039 1041
    # Journal entry methods.
paul@1039 1042
paul@1039 1043
    def get_entries(self, quota, group):
paul@1039 1044
paul@1039 1045
        """
paul@1039 1046
        Return a list of journal entries for the given 'quota' for the indicated
paul@1039 1047
        'group'.
paul@1039 1048
        """
paul@1039 1049
paul@1039 1050
        filename = self.get_object_in_store(quota, "journal", group)
paul@1039 1051
paul@1062 1052
        if not filename or not isfile(filename):
paul@1062 1053
            periods = []
paul@1062 1054
        else:
paul@1062 1055
            periods = map(lambda t: FreeBusyPeriod(*t),
paul@1062 1056
                self._get_table_atomic(quota, filename, [(4, None)]))
paul@1062 1057
paul@1062 1058
        return FreeBusyCollection(periods)
paul@1039 1059
paul@1039 1060
    def set_entries(self, quota, group, entries):
paul@1039 1061
paul@1039 1062
        """
paul@1039 1063
        For the given 'quota' and indicated 'group', set the list of journal
paul@1039 1064
        'entries'.
paul@1039 1065
        """
paul@1039 1066
paul@1039 1067
        filename = self.get_object_in_store(quota, "journal", group)
paul@1039 1068
        if not filename:
paul@1039 1069
            return False
paul@1039 1070
paul@1059 1071
        self._set_table_atomic(quota, filename,
paul@1062 1072
            map(lambda fb: fb.as_tuple(strings_only=True), entries.periods))
paul@1039 1073
        return True
paul@1039 1074
paul@2 1075
# vim: tabstop=4 expandtab shiftwidth=4