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