1 #!/usr/bin/env python 2 3 """ 4 Quota-related scheduling functionality. 5 6 Copyright (C) 2016, 2017 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 from imiptools.dates import get_duration, to_utc_datetime 23 from imiptools.data import get_uri, uri_dict 24 from imiptools.handlers.scheduling.common import get_scheduling_conflicts, \ 25 standard_responses 26 from imiptools.period import Endless 27 from datetime import timedelta 28 29 # Quota maintenance. 30 31 def check_quota(handler, args): 32 33 """ 34 Check the current object of the given 'handler' against the applicable 35 quota. 36 """ 37 38 _ = handler.get_translator() 39 40 quota, group = _get_quota_and_group(handler, args) 41 42 # Obtain the journal entries and check the balance. 43 44 journal = handler.get_journal() 45 entries = journal.get_entries(quota, group) 46 limits = journal.get_limits(quota) 47 48 # Obtain a limit for the group or any general limit. 49 # Decline invitations if no limit has been set. 50 51 limit = limits.get(group) or limits.get("*") 52 if not limit: 53 return "DECLINED", _("You have no quota allocation for the recipient.") 54 55 # Where the quota is unlimited, accept the invitation. 56 57 if limit == "*": 58 return "ACCEPTED", _("The recipient has scheduled the requested period.") 59 60 # Decline endless events for limited quotas. 61 62 total = _get_duration(handler) 63 64 if total == Endless(): 65 return "DECLINED", _("The event period exceeds your quota allocation for the recipient.") 66 67 # Decline events whose durations exceed the balance. 68 69 balance = get_duration(limit) - _get_usage(entries) 70 71 if total > balance: 72 return "DECLINED", _("The event period exceeds your quota allocation for the recipient.") 73 else: 74 return "ACCEPTED", _("The recipient has scheduled the requested period.") 75 76 def add_to_quota(handler, args): 77 78 """ 79 Record details of the current object of the given 'handler' in the 80 applicable quota. 81 """ 82 83 quota, group = _get_quota_and_group(handler, args) 84 _add_to_quota(handler, quota, group, handler.user, False) 85 86 def remove_from_quota(handler, args): 87 88 """ 89 Remove details of the current object of the given 'handler' from the 90 applicable quota. 91 """ 92 93 quota, group = _get_quota_and_group(handler, args) 94 _remove_from_quota(handler, quota, group, handler.user) 95 96 def update_event(handler, args): 97 98 "Update a stored version of the current object of the given 'handler'." 99 100 quota, group = _get_quota_and_group(handler, args) 101 journal = handler.get_journal() 102 103 # Where an existing version of the object exists, merge the recipient's 104 # attendance information. 105 106 obj = journal.get_event(quota, handler.uid, handler.recurrenceid) 107 if not obj: 108 obj = handler.obj 109 110 # Set attendance. 111 112 attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) 113 attendee_map[handler.user]["PARTSTAT"] = "ACCEPTED" 114 obj["ATTENDEE"] = attendee_map.items() 115 116 # Record the object so that recurrences can be generated. 117 118 journal.set_event(quota, handler.uid, handler.recurrenceid, obj.to_node()) 119 120 def remove_event(handler, args): 121 122 "Remove a stored version of the current object of the given 'handler'." 123 124 quota, group = _get_quota_and_group(handler, args) 125 journal = handler.get_journal() 126 127 # Where an existing version of the object exists, remove the recipient's 128 # attendance information. 129 130 obj = journal.get_event(quota, handler.uid, handler.recurrenceid) 131 if not obj: 132 return 133 134 attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) 135 delegates = journal.get_delegates(quota) 136 137 # Determine whether any of the delegates are still involved. 138 139 attendees = set(delegates).intersection(attendee_map.keys()) 140 if handler.user in attendees: 141 attendees.remove(handler.user) 142 143 # Remove event details where no delegates will be involved. 144 145 if not attendees: 146 journal.remove_event(quota, handler.uid, handler.recurrenceid) 147 return 148 149 del attendee_map[handler.user] 150 obj["ATTENDEE"] = attendee_map.items() 151 152 # Record the object so that recurrences can be generated. 153 154 journal.set_event(quota, handler.uid, handler.recurrenceid, obj.to_node()) 155 156 def _get_quota_and_group(handler, args): 157 158 """ 159 Combine information about the current object from the 'handler' with the 160 given 'args' to return a tuple containing the quota group and the user 161 identity or group involved. 162 """ 163 164 quota = args and args[0] or handler.user 165 166 # Obtain the identity to whom the quota will apply. 167 168 organiser = get_uri(handler.obj.get_value("ORGANIZER")) 169 170 # Obtain any user group to which the quota will apply instead. 171 172 journal = handler.get_journal() 173 groups = journal.get_groups(quota) 174 175 return quota, groups.get(organiser) or groups.get("*") or organiser 176 177 def _get_duration(handler): 178 179 "Return the duration of the current object provided by the 'handler'." 180 181 # Reject indefinitely recurring events. 182 183 if handler.obj.possibly_recurring_indefinitely(): 184 return Endless() 185 186 # Otherwise, return a sum of the period durations. 187 188 total = timedelta(0) 189 190 for period in handler.get_periods(handler.obj, future_only=True): 191 duration = period.get_duration() 192 193 # Decline events whose period durations are endless. 194 195 if duration == Endless(): 196 return duration 197 else: 198 total += duration 199 200 return total 201 202 def _get_expiry_time(handler): 203 204 """ 205 Return the expiry time for quota purposes of the current object provided by 206 the 'handler'. 207 """ 208 209 # Reject indefinitely recurring events. 210 211 if handler.obj.possibly_recurring_indefinitely(): 212 return None 213 214 periods = handler.get_periods(handler.obj, future_only=True) 215 return periods and to_utc_datetime(periods[-1].get_end_point()) or None 216 217 def _get_usage(entries): 218 219 "Return the usage total according to the given 'entries'." 220 221 total = timedelta(0) 222 for period in entries: 223 total += period.get_duration() 224 return total 225 226 def _add_to_quota(handler, quota, user, participant, is_organiser): 227 228 """ 229 Record details of the current object of the 'handler' in the applicable 230 free/busy resource. 231 """ 232 233 journal = handler.get_journal() 234 freebusy = journal.get_entries_for_update(quota, user) 235 handler.update_freebusy(freebusy, participant, is_organiser) 236 237 # Remove original recurrence details replaced by additional 238 # recurrences, as well as obsolete additional recurrences. 239 240 handler.remove_freebusy_for_recurrences(freebusy, journal.get_recurrences(quota, handler.uid)) 241 242 # Update free/busy provider information if the event may recur indefinitely. 243 244 if handler.possibly_recurring_indefinitely(): 245 journal.append_freebusy_provider(quota, handler.obj) 246 247 journal.set_entries(quota, user, freebusy) 248 249 def _remove_from_quota(handler, quota, user, participant): 250 251 """ 252 Remove details of the current object of the 'handler' from the applicable 253 free/busy resource. 254 """ 255 256 journal = handler.get_journal() 257 freebusy = journal.get_entries_for_update(quota, user) 258 259 # Remove only the entries associated with this recipient. 260 261 handler.remove_from_freebusy(freebusy, participant) 262 263 # Update free/busy provider information if the event may recur indefinitely. 264 265 if handler.possibly_recurring_indefinitely(): 266 journal.remove_freebusy_provider(quota, handler.obj) 267 268 journal.set_entries(quota, user, freebusy) 269 270 # Collective free/busy maintenance. 271 272 def schedule_across_quota(handler, args): 273 274 """ 275 Check the current object of the given 'handler' against the individual 276 schedules managed by the quota. The consolidated schedules are not tested, 277 nor are the quotas themselves. 278 """ 279 280 quota, organiser = _get_quota_and_identity(handler, args) 281 282 # Check the event periods against the quota's consolidated record of the 283 # organiser's reservations. 284 285 periods = handler.get_periods(handler.obj, future_only=True) 286 freebusy = handler.get_journal().get_entries(quota, organiser) 287 scheduled = handler.can_schedule(freebusy, periods) 288 289 return standard_responses(handler, scheduled and "ACCEPTED" or "DECLINED") 290 291 def add_to_quota_freebusy(handler, args): 292 293 """ 294 Record details of the current object of the 'handler' in the applicable 295 free/busy resource. 296 """ 297 298 quota, organiser = _get_quota_and_identity(handler, args) 299 _add_to_quota(handler, quota, organiser, organiser, True) 300 301 def remove_from_quota_freebusy(handler, args): 302 303 """ 304 Remove details of the current object of the 'handler' from the applicable 305 free/busy resource. 306 """ 307 308 quota, organiser = _get_quota_and_identity(handler, args) 309 _remove_from_quota(handler, quota, organiser, organiser) 310 311 def _get_quota_and_identity(handler, args): 312 313 """ 314 Combine information about the current object from the 'handler' with the 315 given 'args' to return a tuple containing the quota group and the user 316 identity involved. 317 """ 318 319 quota = args and args[0] or handler.user 320 321 # Obtain the identity for whom the scheduling will apply. 322 323 organiser = get_uri(handler.obj.get_value("ORGANIZER")) 324 325 return quota, organiser 326 327 # Delegation of reservations. 328 329 def schedule_for_delegate(handler, args): 330 331 """ 332 Check the current object of the given 'handler' against the schedules 333 managed by the quota, delegating to a specific recipient according to the 334 given policies. 335 """ 336 337 # First check the quota and decline any request that would exceed the quota. 338 339 scheduled = check_quota(handler, args) 340 response, description = scheduled or ("DECLINED", None) 341 342 if response == "DECLINED": 343 return response, description 344 345 # Obtain the quota and organiser group details to evaluate delegation. 346 347 quota, group = _get_quota_and_group(handler, args) 348 policies = args and args[1:] or ["available"] 349 350 # Determine the status of the recipient. 351 352 attendee_map = uri_dict(handler.obj.get_value_map("ATTENDEE")) 353 attendee_attr = attendee_map[handler.user] 354 355 # Prevent delegation by a delegate. 356 357 if attendee_attr.get("DELEGATED-FROM"): 358 delegates = set([handler.user]) 359 360 # Obtain the delegate pool for the quota. 361 362 else: 363 delegates = handler.get_journal().get_delegates(quota) 364 365 # Obtain the remaining delegates not already involved in the event. 366 367 delegates = set(delegates).difference(attendee_map) 368 delegates.add(handler.user) 369 370 # Get the quota's schedule for the requested periods and identify 371 # unavailable delegates. 372 373 entries = handler.get_journal().get_entries(quota, group) 374 conflicts = get_scheduling_conflicts(handler, entries, delegates, attendee=True) 375 376 # Get the delegates in order of increasing unavailability (or decreasing 377 # availability). 378 379 unavailability = conflicts.items() 380 381 # Apply the policies to choose a suitable delegate. 382 383 if "most-available" in policies: 384 unavailability.sort(key=lambda t: t[1]) 385 available = [delegate for (delegate, commitments) in unavailability] 386 delegate = available and available[0] 387 388 # The default is to select completely available delegates. 389 390 else: 391 available = [delegate for (delegate, commitments) in unavailability if not commitments] 392 delegate = available and (handler.user in available and handler.user or available[0]) 393 394 # Only accept or delegate if a suitably available delegate is found. 395 396 if delegate: 397 398 # Add attendee for delegate, obtaining the original attendee dictionary. 399 # Modify this user's status to refer to the delegate. 400 401 if delegate != handler.user: 402 attendee_map = handler.obj.get_value_map("ATTENDEE") 403 attendee_map[delegate] = {"DELEGATED-FROM" : [handler.user]} 404 attendee_attr["DELEGATED-TO"] = [delegate] 405 handler.obj["ATTENDEE"] = attendee_map.items() 406 407 response = "DELEGATED" 408 else: 409 response = "ACCEPTED" 410 else: 411 response = "DECLINED" 412 413 return standard_responses(handler, response) 414 415 # Locking and unlocking. 416 417 def lock_journal(handler, args): 418 419 "Using the 'handler' and 'args', lock the journal for the quota." 420 421 handler.get_journal().acquire_lock(_get_quota(handler, args)) 422 423 def unlock_journal(handler, args): 424 425 "Using the 'handler' and 'args', unlock the journal for the quota." 426 427 handler.get_journal().release_lock(_get_quota(handler, args)) 428 429 def _get_quota(handler, args): 430 431 "Return the quota using the 'handler' and 'args'." 432 433 return args and args[0] or handler.user 434 435 # Registry of scheduling functions. 436 437 scheduling_functions = { 438 "check_quota" : [check_quota], 439 "schedule_across_quota" : [schedule_across_quota], 440 "schedule_for_delegate" : [schedule_for_delegate], 441 } 442 443 # Registries of locking and unlocking functions. 444 445 locking_functions = { 446 "check_quota" : [lock_journal], 447 "schedule_across_quota" : [lock_journal], 448 "schedule_for_delegate" : [lock_journal], 449 } 450 451 unlocking_functions = { 452 "check_quota" : [unlock_journal], 453 "schedule_across_quota" : [unlock_journal], 454 "schedule_for_delegate" : [unlock_journal], 455 } 456 457 # Registries of listener functions. 458 459 confirmation_functions = { 460 "check_quota" : [add_to_quota, update_event], 461 "schedule_across_quota" : [add_to_quota_freebusy, update_event], 462 "schedule_for_delegate" : [add_to_quota, update_event], 463 } 464 465 retraction_functions = { 466 "check_quota" : [remove_from_quota, remove_event], 467 "schedule_across_quota" : [remove_from_quota_freebusy, remove_event], 468 "schedule_for_delegate" : [remove_from_quota, remove_event], 469 } 470 471 # vim: tabstop=4 expandtab shiftwidth=4