imip-agent

Changeset

1176:6bc9f39224a9
2016-05-12 Paul Boddie raw files shortlog changelog graph Added initial support for delegating attendance, introducing a quota scheduling function that appoints delegates within a quota group when a recipient cannot itself attend. Changed the journal methods setting user-group mappings and quota limits to operate using entire collections rather than with individual items.
conf/postgresql/schema.sql (file) imiptools/client.py (file) imiptools/data.py (file) imiptools/handlers/__init__.py (file) imiptools/handlers/resource.py (file) imiptools/handlers/scheduling/__init__.py (file) imiptools/handlers/scheduling/quota.py (file) imiptools/stores/database/common.py (file) imiptools/stores/file.py (file) tests/common.sh (file) tests/templates/event-request-car-delegating.txt (file) tests/test_resource_invitation_constraints_quota.sh (file) tests/test_resource_invitation_constraints_quota_delegation.sh (file) tests/test_resource_invitation_constraints_quota_recurring.sh (file) tests/test_resource_invitation_constraints_quota_recurring_limits.sh (file) tools/copy_store.py (file) tools/install.sh (file) tools/set_delegates.py (file) tools/set_quota_limit.py (file)
     1.1 --- a/conf/postgresql/schema.sql	Thu May 12 23:05:48 2016 +0200
     1.2 +++ b/conf/postgresql/schema.sql	Thu May 12 23:15:18 2016 +0200
     1.3 @@ -128,6 +128,12 @@
     1.4  create index quota_freebusy_start on quota_freebusy(quota, user_group, "start");
     1.5  create index quota_freebusy_end on quota_freebusy(quota, user_group, "end");
     1.6  
     1.7 +create table quota_delegates (
     1.8 +    quota varchar not null,
     1.9 +    store_user varchar not null,
    1.10 +    primary key(quota, store_user)
    1.11 +);
    1.12 +
    1.13  create table user_freebusy (
    1.14      quota varchar not null,
    1.15      store_user varchar not null,
     2.1 --- a/imiptools/client.py	Thu May 12 23:05:48 2016 +0200
     2.2 +++ b/imiptools/client.py	Thu May 12 23:15:18 2016 +0200
     2.3 @@ -523,6 +523,19 @@
     2.4              if attendee_map.has_key(attendee):
     2.5                  attendee_map[attendee] = attendee_attr
     2.6  
     2.7 +        # Check for delegated attendees.
     2.8 +
     2.9 +        for attendee, attendee_attr in attendees.items():
    2.10 +
    2.11 +            # Identify delegates and check the delegation using the updated
    2.12 +            # attendee information.
    2.13 +
    2.14 +            if not attendee_map.has_key(attendee) and \
    2.15 +               attendee_attr.has_key("DELEGATED-FROM") and \
    2.16 +               check_delegation(attendee_map, attendee, attendee_attr):
    2.17 +
    2.18 +                attendee_map[attendee] = attendee_attr
    2.19 +
    2.20          # Set the new details and store the object.
    2.21  
    2.22          obj["ATTENDEE"] = attendee_map.items()
    2.23 @@ -861,7 +874,8 @@
    2.24          # organiser property attributes.
    2.25  
    2.26          attr = self.get_attendance(user, obj)
    2.27 -        return as_organiser or attr is not None and not attr or attr and attr.get("PARTSTAT") not in ("DECLINED", "NEEDS-ACTION")
    2.28 +        return as_organiser or attr is not None and not attr or \
    2.29 +            attr and attr.get("PARTSTAT") not in ("DECLINED", "DELEGATED", "NEEDS-ACTION")
    2.30  
    2.31      def has_indicated_attendance(self, user=None, obj=None):
    2.32  
     3.1 --- a/imiptools/data.py	Thu May 12 23:05:48 2016 +0200
     3.2 +++ b/imiptools/data.py	Thu May 12 23:15:18 2016 +0200
     3.3 @@ -968,6 +968,28 @@
     3.4  
     3.5      return is_same_sequence and ignore_dtstamp or not is_old_sequence
     3.6  
     3.7 +def check_delegation(attendee_map, attendee, attendee_attr):
     3.8 +
     3.9 +    """
    3.10 +    Using the 'attendee_map', check the attributes for the given 'attendee'
    3.11 +    provided as 'attendee_attr', following the delegation chain back to the
    3.12 +    delegator and forward again to yield the delegate identity. Return
    3.13 +    whether this identity is the given 'attendee', providing the delegator
    3.14 +    identity; otherwise return None.
    3.15 +    """
    3.16 +
    3.17 +    # The recipient should have a reference to the delegator.
    3.18 +
    3.19 +    delegated_from = attendee_attr and attendee_attr.get("DELEGATED-FROM")
    3.20 +    delegated_from = delegated_from and delegated_from[0]
    3.21 +    delegator = delegated_from and attendee_map.get(delegated_from)
    3.22 +
    3.23 +    # The delegator should have a reference to the recipient.
    3.24 +
    3.25 +    delegated_to = delegator and delegator.get("DELEGATED-TO")
    3.26 +    delegated_to = delegated_to and delegated_to[0]
    3.27 +    return delegated_to == attendee and delegated_from or None
    3.28 +
    3.29  def get_periods(obj, tzid, end=None, inclusive=False):
    3.30  
    3.31      """
     4.1 --- a/imiptools/handlers/__init__.py	Thu May 12 23:05:48 2016 +0200
     4.2 +++ b/imiptools/handlers/__init__.py	Thu May 12 23:15:18 2016 +0200
     4.3 @@ -22,8 +22,8 @@
     4.4  from email.mime.text import MIMEText
     4.5  from imiptools.client import ClientForObject
     4.6  from imiptools.config import MANAGER_PATH, MANAGER_URL, MANAGER_URL_SCHEME
     4.7 -from imiptools.data import get_address, get_uri, get_sender_identities, \
     4.8 -                           uri_dict, uri_item
     4.9 +from imiptools.data import check_delegation, get_address, get_uri, \
    4.10 +                           get_sender_identities, uri_dict, uri_item
    4.11  from socket import gethostname
    4.12  
    4.13  # References to the Web interface.
    4.14 @@ -150,13 +150,25 @@
    4.15          else:
    4.16              return mapping
    4.17  
    4.18 +    def is_delegation(self):
    4.19 +
    4.20 +        """
    4.21 +        Return whether delegation is occurring by returning any delegator.
    4.22 +        """
    4.23 +
    4.24 +        attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE"))
    4.25 +        attendee_attr = attendee_map.get(self.user)
    4.26 +        return check_delegation(attendee_map, self.user, attendee_attr)
    4.27 +
    4.28      def require_organiser(self, from_organiser=True):
    4.29  
    4.30          """
    4.31 -        Return the organiser for the current object, filtered for the sender or
    4.32 -        recipient of interest. Return None if no identities are eligible.
    4.33 +        Return the normalised organiser for the current object, filtered for the
    4.34 +        sender or recipient of interest. Return None if no identities are
    4.35 +        eligible.
    4.36  
    4.37 -        The organiser identity is normalized.
    4.38 +        If the sender is not the organiser but is delegating to the recipient,
    4.39 +        the actual organiser is returned.
    4.40          """
    4.41  
    4.42          organiser, organiser_attr = organiser_item = uri_item(self.obj.get_item("ORGANIZER"))
    4.43 @@ -164,11 +176,16 @@
    4.44          if not organiser:
    4.45              return None
    4.46  
    4.47 -        # Only provide details for an organiser who sent/receives the message.
    4.48 +        # Check the delegate status of the recipient.
    4.49 +
    4.50 +        delegated = from_organiser and self.is_delegation()
    4.51 +
    4.52 +        # Only provide details for an organiser who sent/receives the message or
    4.53 +        # is presiding over a delegation.
    4.54  
    4.55          organiser_filter_fn = from_organiser and self.filter_by_senders or self.filter_by_recipient
    4.56  
    4.57 -        if not organiser_filter_fn(dict([organiser_item])):
    4.58 +        if not delegated and not organiser_filter_fn(dict([organiser_item])):
    4.59              return None
    4.60  
    4.61          # Test against any previously-received organiser details.
     5.1 --- a/imiptools/handlers/resource.py	Thu May 12 23:05:48 2016 +0200
     5.2 +++ b/imiptools/handlers/resource.py	Thu May 12 23:15:18 2016 +0200
     5.3 @@ -20,7 +20,7 @@
     5.4  """
     5.5  
     5.6  from email.mime.text import MIMEText
     5.7 -from imiptools.data import get_address, to_part, uri_dict
     5.8 +from imiptools.data import get_address, uri_dict
     5.9  from imiptools.handlers import Handler
    5.10  from imiptools.handlers.common import CommonFreebusy, CommonEvent
    5.11  from imiptools.handlers.scheduling import apply_scheduling_functions, \
    5.12 @@ -90,6 +90,7 @@
    5.13          "Attempt to schedule the current object for the current user."
    5.14  
    5.15          attendee_attr = uri_dict(self.obj.get_value_map("ATTENDEE"))[self.user]
    5.16 +        delegate = None
    5.17  
    5.18          # Attempt to schedule the event.
    5.19  
    5.20 @@ -123,6 +124,19 @@
    5.21                  if scheduled == "ACCEPTED":
    5.22                      self.confirm_scheduling()
    5.23  
    5.24 +            # For delegated proposals, prepare a request to the delegate in
    5.25 +            # addition to the usual response.
    5.26 +
    5.27 +            elif scheduled == "DELEGATED":
    5.28 +                method = "REPLY"
    5.29 +                attendee_attr = self.update_participation("DELEGATED")
    5.30 +
    5.31 +                # The recipient will have indicated the delegate whose details
    5.32 +                # will have been added to the object.
    5.33 +
    5.34 +                delegated_to = attendee_attr["DELEGATED-TO"]
    5.35 +                delegate = delegated_to and delegated_to[0]
    5.36 +
    5.37              # For countered proposals, record the offer in the resource's
    5.38              # free/busy collection.
    5.39  
    5.40 @@ -141,6 +155,8 @@
    5.41          finally:
    5.42              self.finish_scheduling()
    5.43  
    5.44 +        # Determine the recipients of the outgoing messages.
    5.45 +
    5.46          recipients = map(get_address, self.obj.get_values("ORGANIZER"))
    5.47  
    5.48          # Add any description of the scheduling decision.
    5.49 @@ -151,9 +167,35 @@
    5.50          # DTSTAMP in the response, and return the object for sending.
    5.51  
    5.52          self.update_sender(attendee_attr)
    5.53 -        self.obj["ATTENDEE"] = [(self.user, attendee_attr)]
    5.54 +        attendees = [(self.user, attendee_attr)]
    5.55 +
    5.56 +        # Add the delegate if delegating (RFC 5546 being inconsistent here since
    5.57 +        # it provides an example reply to the organiser without the delegate).
    5.58 +
    5.59 +        if delegate:
    5.60 +            delegate_attr = uri_dict(self.obj.get_value_map("ATTENDEE"))[delegate]
    5.61 +            attendees.append((delegate, delegate_attr))
    5.62 +
    5.63 +        # Reply to the delegator in addition to the organiser if replying to a
    5.64 +        # delegation request.
    5.65 +
    5.66 +        delegator = self.is_delegation()
    5.67 +        if delegator:
    5.68 +            delegator_attr = uri_dict(self.obj.get_value_map("ATTENDEE"))[delegator]
    5.69 +            attendees.append((delegator, delegator_attr))
    5.70 +            recipients.append(get_address(delegator))
    5.71 +
    5.72 +        # Prepare the response for the organiser plus any delegator.
    5.73 +
    5.74 +        self.obj["ATTENDEE"] = attendees
    5.75          self.update_dtstamp()
    5.76 -        self.add_result(method, recipients, to_part(method, [self.obj.to_node()]))
    5.77 +        self.add_result(method, recipients, self.object_to_part(method, self.obj))
    5.78 +
    5.79 +        # If delegating, send a request to the delegate.
    5.80 +
    5.81 +        if delegate:
    5.82 +            method = "REQUEST"
    5.83 +            self.add_result(method, [get_address(delegate)], self.object_to_part(method, self.obj))
    5.84  
    5.85      def _cancel_for_attendee(self):
    5.86  
     6.1 --- a/imiptools/handlers/scheduling/__init__.py	Thu May 12 23:05:48 2016 +0200
     6.2 +++ b/imiptools/handlers/scheduling/__init__.py	Thu May 12 23:15:18 2016 +0200
     6.3 @@ -59,8 +59,8 @@
     6.4          if not fn:
     6.5              return "DECLINED", None
     6.6  
     6.7 -        # Keep evaluating scheduling functions, stopping only if one
     6.8 -        # declines or gives a null response.
     6.9 +        # Keep evaluating scheduling functions, stopping if one declines or
    6.10 +        # gives a null response, or if one delegates to another resource.
    6.11  
    6.12          else:
    6.13              result = fn(handler, args)
    6.14 @@ -68,7 +68,7 @@
    6.15  
    6.16              # Return a negative result immediately.
    6.17  
    6.18 -            if result == "DECLINED":
    6.19 +            if result in ("DECLINED", "DELEGATED"):
    6.20                  return result, description
    6.21  
    6.22              # Modify the eventual response from acceptance if a countering
     7.1 --- a/imiptools/handlers/scheduling/quota.py	Thu May 12 23:05:48 2016 +0200
     7.2 +++ b/imiptools/handlers/scheduling/quota.py	Thu May 12 23:15:18 2016 +0200
     7.3 @@ -20,7 +20,7 @@
     7.4  """
     7.5  
     7.6  from imiptools.dates import get_duration, to_utc_datetime
     7.7 -from imiptools.data import get_uri
     7.8 +from imiptools.data import get_uri, uri_dict
     7.9  from imiptools.period import Endless
    7.10  from datetime import timedelta
    7.11  
    7.12 @@ -250,6 +250,81 @@
    7.13  
    7.14      return quota, organiser
    7.15  
    7.16 +# Delegation of reservations.
    7.17 +
    7.18 +def schedule_for_delegate(handler, args):
    7.19 +
    7.20 +    """
    7.21 +    Check the current object of the given 'handler' against the schedules
    7.22 +    managed by the quota, delegating to a specific recipient according to the
    7.23 +    given policy.
    7.24 +    """
    7.25 +
    7.26 +    _ = handler.get_translator()
    7.27 +
    7.28 +    quota, group = _get_quota_and_group(handler, args)
    7.29 +    policy = args and (args[1:] or ["arbitrary"])[0]
    7.30 +
    7.31 +    # Determine the status of the recipient.
    7.32 +
    7.33 +    attendee_map = uri_dict(handler.obj.get_value_map("ATTENDEE"))
    7.34 +    attendee_attr = attendee_map[handler.user]
    7.35 +
    7.36 +    # Prevent delegation by a delegate.
    7.37 +
    7.38 +    if attendee_attr.get("DELEGATED-FROM"):
    7.39 +        delegates = set([handler.user])
    7.40 +
    7.41 +    # Obtain the delegate pool for the quota.
    7.42 +
    7.43 +    else:
    7.44 +        delegates = handler.get_journal().get_delegates(quota)
    7.45 +
    7.46 +        # Obtain the remaining delegates not already involved in the event.
    7.47 +
    7.48 +        delegates = set(delegates).difference(attendee_map)
    7.49 +        delegates.add(handler.user)
    7.50 +
    7.51 +    # Get the quota's schedule for the requested periods and identify
    7.52 +    # unavailable delegates.
    7.53 +
    7.54 +    entries = handler.get_journal().get_entries(quota, group)
    7.55 +    unavailable = set()
    7.56 +
    7.57 +    for period in handler.get_periods(handler.obj):
    7.58 +        overlapping = entries.get_overlapping(period)
    7.59 +
    7.60 +        # Where scheduling cannot occur, find the busy potential delegates.
    7.61 +
    7.62 +        if overlapping:
    7.63 +            for p in overlapping:
    7.64 +                unavailable.add(p.attendee)
    7.65 +
    7.66 +    # Get the remaining, available delegates.
    7.67 +
    7.68 +    available = delegates.difference(unavailable)
    7.69 +
    7.70 +    # Apply the policy to choose an available delegate.
    7.71 +    # NOTE: Currently an arbitrary delegate is chosen if not the recipient.
    7.72 +
    7.73 +    if available:
    7.74 +        delegate = handler.user in available and handler.user or list(available)[0]
    7.75 +
    7.76 +        # Add attendee for delegate, obtaining the original attendee dictionary.
    7.77 +        # Modify this user's status to refer to the delegate.
    7.78 +
    7.79 +        if delegate != handler.user:
    7.80 +            attendee_map = handler.obj.get_value_map("ATTENDEE")
    7.81 +            attendee_map[delegate] = {"DELEGATED-FROM" : [handler.user]}
    7.82 +            attendee_attr["DELEGATED-TO"] = [delegate]
    7.83 +            handler.obj["ATTENDEE"] = attendee_map.items()
    7.84 +
    7.85 +            return "DELEGATED", _("The recipient has delegated the requested period.")
    7.86 +        else:
    7.87 +            return "ACCEPTED", _("The recipient has scheduled the requested period.")
    7.88 +    else:
    7.89 +        return "DECLINED", _("The requested period cannot be scheduled.")
    7.90 +
    7.91  # Locking and unlocking.
    7.92  
    7.93  def lock_journal(handler, args):
    7.94 @@ -275,6 +350,7 @@
    7.95  scheduling_functions = {
    7.96      "check_quota" : check_quota,
    7.97      "schedule_across_quota" : schedule_across_quota,
    7.98 +    "schedule_for_delegate" : schedule_for_delegate,
    7.99      }
   7.100  
   7.101  # Registries of locking and unlocking functions.
   7.102 @@ -282,11 +358,13 @@
   7.103  locking_functions = {
   7.104      "check_quota" : lock_journal,
   7.105      "schedule_across_quota" : lock_journal,
   7.106 +    "schedule_for_delegate" : lock_journal,
   7.107      }
   7.108  
   7.109  unlocking_functions = {
   7.110      "check_quota" : unlock_journal,
   7.111      "schedule_across_quota" : unlock_journal,
   7.112 +    "schedule_for_delegate" : unlock_journal,
   7.113      }
   7.114  
   7.115  # Registries of listener functions.
     8.1 --- a/imiptools/stores/database/common.py	Thu May 12 23:05:48 2016 +0200
     8.2 +++ b/imiptools/stores/database/common.py	Thu May 12 23:15:18 2016 +0200
     8.3 @@ -831,18 +831,62 @@
     8.4  
     8.5      def get_quota_users(self, quota):
     8.6  
     8.7 -        "Return a list of quota users."
     8.8 +        "Return a list of quota users for the 'quota'."
     8.9 +
    8.10 +        columns = ["quota"]
    8.11 +        values = [quota]
    8.12 +
    8.13 +        query, values = self.get_query(
    8.14 +            "select distinct user_group from (" \
    8.15 +            "select user_group from quota_freebusy :condition " \
    8.16 +            "union all select user_group from quota_delegates :condition" \
    8.17 +            ") as users",
    8.18 +            columns, values)
    8.19 +
    8.20 +        self.cursor.execute(query, values)
    8.21 +        return [r[0] for r in self.cursor.fetchall()]
    8.22 +
    8.23 +    # Delegate information for the quota.
    8.24 +
    8.25 +    def get_delegates(self, quota):
    8.26 +
    8.27 +        "Return a list of delegates for 'quota'."
    8.28  
    8.29          columns = ["quota"]
    8.30          values = [quota]
    8.31  
    8.32          query, values = self.get_query(
    8.33 -            "select distinct user_group from quota_freebusy :condition",
    8.34 +            "select distinct store_user from quota_delegates :condition",
    8.35              columns, values)
    8.36  
    8.37          self.cursor.execute(query, values)
    8.38          return [r[0] for r in self.cursor.fetchall()]
    8.39  
    8.40 +    def set_delegates(self, quota, delegates):
    8.41 +
    8.42 +        "For the given 'quota', set the list of 'delegates'."
    8.43 +
    8.44 +        columns = ["quota"]
    8.45 +        values = [quota]
    8.46 +
    8.47 +        query, values = self.get_query(
    8.48 +            "delete from quota_delegates :condition",
    8.49 +            columns, values)
    8.50 +
    8.51 +        self.cursor.execute(query, values)
    8.52 +
    8.53 +        for store_user in delegates:
    8.54 +
    8.55 +            columns = ["quota", "store_user"]
    8.56 +            values = [quota, store_user]
    8.57 +
    8.58 +            query, values = self.get_query(
    8.59 +                "insert into quota_delegates (:columns) values (:values)",
    8.60 +                columns, values)
    8.61 +
    8.62 +        self.cursor.execute(query, values)
    8.63 +        return True
    8.64 +
    8.65      # Groups of users sharing quotas.
    8.66  
    8.67      def get_groups(self, quota):
    8.68 @@ -859,32 +903,27 @@
    8.69          self.cursor.execute(query, values)
    8.70          return dict(self.cursor.fetchall())
    8.71  
    8.72 -    def set_group(self, quota, store_user, user_group):
    8.73 +    def set_groups(self, quota, groups):
    8.74  
    8.75 -        """
    8.76 -        For the given 'quota', set a mapping from 'store_user' to 'user_group'.
    8.77 -        """
    8.78 +        "For the given 'quota', set 'groups' mapping users to groups."
    8.79  
    8.80 -        columns = ["quota", "store_user"]
    8.81 -        values = [quota, store_user]
    8.82 -        setcolumns = ["user_group"]
    8.83 -        setvalues = [user_group]
    8.84 +        columns = ["quota"]
    8.85 +        values = [quota]
    8.86  
    8.87          query, values = self.get_query(
    8.88 -            "update user_groups :set :condition",
    8.89 -            columns, values, setcolumns, setvalues)
    8.90 +            "delete from user_groups :condition",
    8.91 +            columns, values)
    8.92  
    8.93          self.cursor.execute(query, values)
    8.94  
    8.95 -        if self.cursor.rowcount > 0:
    8.96 -            return True
    8.97 +        for store_user, user_group in groups.items():
    8.98  
    8.99 -        columns = ["quota", "store_user", "user_group"]
   8.100 -        values = [quota, store_user, user_group]
   8.101 +            columns = ["quota", "store_user", "user_group"]
   8.102 +            values = [quota, store_user, user_group]
   8.103  
   8.104 -        query, values = self.get_query(
   8.105 -            "insert into user_groups (:columns) values (:values)",
   8.106 -            columns, values)
   8.107 +            query, values = self.get_query(
   8.108 +                "insert into user_groups (:columns) values (:values)",
   8.109 +                columns, values)
   8.110  
   8.111          self.cursor.execute(query, values)
   8.112          return True
   8.113 @@ -906,33 +945,30 @@
   8.114          self.cursor.execute(query, values)
   8.115          return dict(self.cursor.fetchall())
   8.116  
   8.117 -    def set_limit(self, quota, group, limit):
   8.118 +    def set_limits(self, quota, limits):
   8.119  
   8.120          """
   8.121 -        For the given 'quota', set for a user 'group' the given 'limit' on
   8.122 -        resource usage.
   8.123 +        For the given 'quota', set the given 'limits' on resource usage mapping
   8.124 +        groups to limits.
   8.125          """
   8.126  
   8.127 -        columns = ["quota", "user_group"]
   8.128 -        values = [quota, group]
   8.129 -        setcolumns = ["quota_limit"]
   8.130 -        setvalues = [limit]
   8.131 +        columns = ["quota"]
   8.132 +        values = [quota]
   8.133  
   8.134          query, values = self.get_query(
   8.135 -            "update quota_limits :set :condition",
   8.136 -            columns, values, setcolumns, setvalues)
   8.137 +            "delete from quota_limits :condition",
   8.138 +            columns, values)
   8.139  
   8.140          self.cursor.execute(query, values)
   8.141  
   8.142 -        if self.cursor.rowcount > 0:
   8.143 -            return True
   8.144 +        for user_group, limit in limits.items():
   8.145  
   8.146 -        columns = ["quota", "user_group", "quota_limit"]
   8.147 -        values = [quota, group, limit]
   8.148 +            columns = ["quota", "user_group", "quota_limit"]
   8.149 +            values = [quota, user_group, limit]
   8.150  
   8.151 -        query, values = self.get_query(
   8.152 -            "insert into quota_limits (:columns) values (:values)",
   8.153 -            columns, values)
   8.154 +            query, values = self.get_query(
   8.155 +                "insert into quota_limits (:columns) values (:values)",
   8.156 +                columns, values)
   8.157  
   8.158          self.cursor.execute(query, values)
   8.159          return True
     9.1 --- a/imiptools/stores/file.py	Thu May 12 23:05:48 2016 +0200
     9.2 +++ b/imiptools/stores/file.py	Thu May 12 23:15:18 2016 +0200
     9.3 @@ -796,13 +796,36 @@
     9.4  
     9.5      def get_quota_users(self, quota):
     9.6  
     9.7 -        "Return a list of quota users."
     9.8 +        "Return a list of quota users for 'quota'."
     9.9  
    9.10          filename = self.get_object_in_store(quota, "journal")
    9.11          if not filename or not isdir(filename):
    9.12              return []
    9.13  
    9.14 -        return listdir(filename)
    9.15 +        return list(set(self.get_delegates(quota)).union(listdir(filename)))
    9.16 +
    9.17 +    # Delegate information for the quota.
    9.18 +
    9.19 +    def get_delegates(self, quota):
    9.20 +
    9.21 +        "Return a list of delegates for 'quota'."
    9.22 +
    9.23 +        filename = self.get_object_in_store(quota, "delegates")
    9.24 +        if not filename or not isfile(filename):
    9.25 +            return []
    9.26 +
    9.27 +        return [value for (value,) in self._get_table_atomic(quota, filename)]
    9.28 +
    9.29 +    def set_delegates(self, quota, delegates):
    9.30 +
    9.31 +        "For the given 'quota', set the list of 'delegates'."
    9.32 +
    9.33 +        filename = self.get_object_in_store(quota, "delegates")
    9.34 +        if not filename:
    9.35 +            return False
    9.36 +
    9.37 +        self._set_table_atomic(quota, filename, [(value,) for value in delegates])
    9.38 +        return True
    9.39  
    9.40      # Groups of users sharing quotas.
    9.41  
    9.42 @@ -816,19 +839,14 @@
    9.43  
    9.44          return dict(self._get_table_atomic(quota, filename, tab_separated=False))
    9.45  
    9.46 -    def set_group(self, quota, store_user, user_group):
    9.47 +    def set_groups(self, quota, groups):
    9.48  
    9.49 -        """
    9.50 -        For the given 'quota', set a mapping from 'store_user' to 'user_group'.
    9.51 -        """
    9.52 +        "For the given 'quota', set 'groups' mapping users to groups."
    9.53  
    9.54          filename = self.get_object_in_store(quota, "groups")
    9.55          if not filename:
    9.56              return False
    9.57  
    9.58 -        groups = self.get_groups(quota) or {}
    9.59 -        groups[store_user] = user_group
    9.60 -
    9.61          self._set_table_atomic(quota, filename, groups.items())
    9.62          return True
    9.63  
    9.64 @@ -845,20 +863,17 @@
    9.65  
    9.66          return dict(self._get_table_atomic(quota, filename, tab_separated=False))
    9.67  
    9.68 -    def set_limit(self, quota, group, limit):
    9.69 +    def set_limits(self, quota, limits):
    9.70  
    9.71          """
    9.72 -        For the given 'quota', set for a user 'group' the given 'limit' on
    9.73 -        resource usage.
    9.74 +        For the given 'quota', set the given 'limits' on resource usage mapping
    9.75 +        groups to limits.
    9.76          """
    9.77  
    9.78          filename = self.get_object_in_store(quota, "limits")
    9.79          if not filename:
    9.80              return False
    9.81  
    9.82 -        limits = self.get_limits(quota) or {}
    9.83 -        limits[group] = limit
    9.84 -
    9.85          self._set_table_atomic(quota, filename, limits.items())
    9.86          return True
    9.87  
    10.1 --- a/tests/common.sh	Thu May 12 23:05:48 2016 +0200
    10.2 +++ b/tests/common.sh	Thu May 12 23:15:18 2016 +0200
    10.3 @@ -21,6 +21,9 @@
    10.4  
    10.5  PERSON_SCRIPT="$BASE_DIR/imip_person.py"
    10.6  
    10.7 +SET_DELEGATES="$BASE_DIR/tools/set_delegates.py"
    10.8 +SET_DELEGATES_ARGS="-T $STORE_TYPE -j $JOURNAL" 
    10.9 +
   10.10  SET_QUOTA_LIMIT="$BASE_DIR/tools/set_quota_limit.py"
   10.11  SET_QUOTA_LIMIT_ARGS="-T $STORE_TYPE -j $JOURNAL" 
   10.12  
    11.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
    11.2 +++ b/tests/templates/event-request-car-delegating.txt	Thu May 12 23:15:18 2016 +0200
    11.3 @@ -0,0 +1,34 @@
    11.4 +Content-Type: multipart/alternative; boundary="===============0047278175=="
    11.5 +MIME-Version: 1.0
    11.6 +From: paul.boddie@example.com
    11.7 +To: resource-car-porsche911@example.com
    11.8 +Subject: Invitation!
    11.9 +
   11.10 +--===============0047278175==
   11.11 +Content-Type: text/plain; charset="us-ascii"
   11.12 +MIME-Version: 1.0
   11.13 +Content-Transfer-Encoding: 7bit
   11.14 +
   11.15 +This message contains an event.
   11.16 +--===============0047278175==
   11.17 +MIME-Version: 1.0
   11.18 +Content-Transfer-Encoding: 7bit
   11.19 +Content-Type: text/calendar; charset="us-ascii"; method="REQUEST"
   11.20 +
   11.21 +BEGIN:VCALENDAR
   11.22 +PRODID:-//imip-agent/test//EN
   11.23 +METHOD:REQUEST
   11.24 +VERSION:2.0
   11.25 +BEGIN:VEVENT
   11.26 +ORGANIZER:mailto:paul.boddie@example.com
   11.27 +ATTENDEE;ROLE=CHAIR:mailto:paul.boddie@example.com
   11.28 +ATTENDEE;RSVP=TRUE:mailto:resource-car-porsche911@example.com
   11.29 +DTSTAMP:20141125T004600Z
   11.30 +DTSTART;TZID=Europe/Oslo:20141126T163000
   11.31 +DTEND;TZID=Europe/Oslo:20141126T173000
   11.32 +SUMMARY:Another test drive
   11.33 +UID:event27@example.com
   11.34 +END:VEVENT
   11.35 +END:VCALENDAR
   11.36 +
   11.37 +--===============0047278175==--
    12.1 --- a/tests/test_resource_invitation_constraints_quota.sh	Thu May 12 23:05:48 2016 +0200
    12.2 +++ b/tests/test_resource_invitation_constraints_quota.sh	Thu May 12 23:15:18 2016 +0200
    12.3 @@ -24,8 +24,12 @@
    12.4  check_quota $QUOTA
    12.5  EOF
    12.6  
    12.7 -"$SET_QUOTA_LIMIT" "$QUOTA" '*' 'PT1H' $SET_QUOTA_LIMIT_ARGS
    12.8 -"$SET_QUOTA_LIMIT" "$OTHER_QUOTA" '*' 'PT1H' $SET_QUOTA_LIMIT_ARGS
    12.9 +cat <<EOF | "$SET_QUOTA_LIMIT" "$QUOTA" $SET_QUOTA_LIMIT_ARGS
   12.10 +* PT1H
   12.11 +EOF
   12.12 +cat <<EOF | "$SET_QUOTA_LIMIT" "$OTHER_QUOTA" $SET_QUOTA_LIMIT_ARGS
   12.13 +* PT1H
   12.14 +EOF
   12.15  
   12.16    "$RESOURCE_SCRIPT" $ARGS < "$TEMPLATES/fb-request-car.txt" 2>> $ERROR \
   12.17  | "$SHOWMAIL" \
   12.18 @@ -113,7 +117,9 @@
   12.19  
   12.20  # Increase the quota.
   12.21  
   12.22 -"$SET_QUOTA_LIMIT" "$QUOTA" '*' 'PT2H' $SET_QUOTA_LIMIT_ARGS
   12.23 +cat <<EOF | "$SET_QUOTA_LIMIT" "$QUOTA" $SET_QUOTA_LIMIT_ARGS
   12.24 +* PT2H
   12.25 +EOF
   12.26  
   12.27  # Attempt to schedule the event again.
   12.28  
   12.29 @@ -315,7 +321,9 @@
   12.30  
   12.31  # Increase the quota.
   12.32  
   12.33 -"$SET_QUOTA_LIMIT" "$QUOTA" '*' 'PT3H' $SET_QUOTA_LIMIT_ARGS
   12.34 +cat <<EOF | "$SET_QUOTA_LIMIT" "$QUOTA" $SET_QUOTA_LIMIT_ARGS
   12.35 +* PT3H
   12.36 +EOF
   12.37  
   12.38  # Attempt to schedule an event involving both resources.
   12.39  
    13.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
    13.2 +++ b/tests/test_resource_invitation_constraints_quota_delegation.sh	Thu May 12 23:15:18 2016 +0200
    13.3 @@ -0,0 +1,264 @@
    13.4 +#!/bin/sh
    13.5 +
    13.6 +. "`dirname \"$0\"`/common.sh"
    13.7 +
    13.8 +USER1="mailto:resource-car-porsche911@example.com"
    13.9 +USER2="mailto:resource-car-fiat500@example.com"
   13.10 +SENDER="mailto:paul.boddie@example.com"
   13.11 +USER1ADDRESS="resource-car-porsche911@example.com"
   13.12 +USER2ADDRESS="resource-car-fiat500@example.com"
   13.13 +SENDERADDRESS="paul.boddie@example.com"
   13.14 +QUOTA=cars
   13.15 +OTHER_QUOTA=rooms
   13.16 +
   13.17 +mkdir -p "$PREFS/$USER1"
   13.18 +echo 'Europe/Oslo' > "$PREFS/$USER1/TZID"
   13.19 +echo 'share' > "$PREFS/$USER1/freebusy_sharing"
   13.20 +cat > "$PREFS/$USER1/scheduling_function" <<EOF
   13.21 +schedule_for_delegate $QUOTA
   13.22 +schedule_in_freebusy
   13.23 +check_quota $QUOTA
   13.24 +EOF
   13.25 +
   13.26 +mkdir -p "$PREFS/$USER2"
   13.27 +echo 'Europe/Oslo' > "$PREFS/$USER2/TZID"
   13.28 +echo 'share' > "$PREFS/$USER2/freebusy_sharing"
   13.29 +cat > "$PREFS/$USER2/scheduling_function" <<EOF
   13.30 +schedule_for_delegate $QUOTA
   13.31 +schedule_in_freebusy
   13.32 +check_quota $QUOTA
   13.33 +EOF
   13.34 +
   13.35 +cat <<EOF | "$SET_QUOTA_LIMIT" "$QUOTA" $SET_QUOTA_LIMIT_ARGS
   13.36 +* PT2H
   13.37 +EOF
   13.38 +cat <<EOF | "$SET_QUOTA_LIMIT" "$OTHER_QUOTA" $SET_QUOTA_LIMIT_ARGS
   13.39 +* PT2H
   13.40 +EOF
   13.41 +
   13.42 +# Allow cars to delegate to each other.
   13.43 +
   13.44 +cat <<EOF | "$SET_DELEGATES" "$QUOTA" $SET_DELEGATES_ARGS
   13.45 +mailto:resource-car-porsche911@example.com
   13.46 +mailto:resource-car-fiat500@example.com
   13.47 +EOF
   13.48 +
   13.49 +  "$RESOURCE_SCRIPT" $ARGS < "$TEMPLATES/fb-request-car.txt" 2>> $ERROR \
   13.50 +| "$SHOWMAIL" \
   13.51 +> out0.tmp
   13.52 +
   13.53 +   grep -q 'METHOD:REPLY' out0.tmp \
   13.54 +&& ! grep -q '^FREEBUSY' out0.tmp \
   13.55 +&& echo "Success" \
   13.56 +|| echo "Failed"
   13.57 +
   13.58 +# Attempt to schedule an event.
   13.59 +
   13.60 +"$OUTGOING_SCRIPT" $ARGS < "$TEMPLATES/event-request-car.txt" 2>> $ERROR
   13.61 +
   13.62 +   "$LIST_SCRIPT" $LIST_ARGS "$SENDER" "freebusy" \
   13.63 +|  tee out0s.tmp \
   13.64 +|  grep -q "^20141126T150000Z${TAB}20141126T160000Z" \
   13.65 +&& echo "Success" \
   13.66 +|| echo "Failed"
   13.67 +
   13.68 +# Present the request to the resource.
   13.69 +
   13.70 +  "$RESOURCE_SCRIPT" $ARGS < "$TEMPLATES/event-request-car.txt" 2>> $ERROR \
   13.71 +| tee out1r.tmp \
   13.72 +| "$SHOWMAIL" \
   13.73 +> out1.tmp
   13.74 +
   13.75 +   grep -q 'METHOD:REPLY' out1.tmp \
   13.76 +&& grep -q 'ATTENDEE.*;PARTSTAT=ACCEPTED' out1.tmp \
   13.77 +&& echo "Success" \
   13.78 +|| echo "Failed"
   13.79 +
   13.80 +   "$LIST_SCRIPT" $LIST_ARGS "$USER1" "freebusy" \
   13.81 +|  tee out1f.tmp \
   13.82 +|  grep -q "^20141126T150000Z${TAB}20141126T160000Z" \
   13.83 +&& echo "Success" \
   13.84 +|| echo "Failed"
   13.85 +
   13.86 +# Check the quota (event is confirmed).
   13.87 +
   13.88 +   "$LIST_SCRIPT" $LIST_ARGS "$QUOTA" "entries" "$SENDER" \
   13.89 +|  tee out1e.tmp \
   13.90 +|  grep -q "event21@example.com" \
   13.91 +&& echo "Success" \
   13.92 +|| echo "Failed"
   13.93 +
   13.94 +# Attempt to schedule another event.
   13.95 +
   13.96 +"$OUTGOING_SCRIPT" $ARGS < "$TEMPLATES/event-request-car-delegating.txt" 2>> $ERROR
   13.97 +
   13.98 +   "$LIST_SCRIPT" $LIST_ARGS "$SENDER" "freebusy" \
   13.99 +|  tee out1s.tmp \
  13.100 +|  grep -q "^20141126T153000Z${TAB}20141126T163000Z" \
  13.101 +&& echo "Success" \
  13.102 +|| echo "Failed"
  13.103 +
  13.104 +# Present the request to the resource.
  13.105 +
  13.106 +  "$RESOURCE_SCRIPT" $ARGS < "$TEMPLATES/event-request-car-delegating.txt" 2>> $ERROR \
  13.107 +> out2r.tmp
  13.108 +
  13.109 +  "$SHOWMAIL" < out2r.tmp \
  13.110 +> out2p0.tmp
  13.111 +
  13.112 +  "$SHOWMAIL" 1 < out2r.tmp \
  13.113 +> out2p1.tmp
  13.114 +
  13.115 +if grep -q "To: $SENDERADDRESS" out2p0.tmp ; then
  13.116 +    ORGFN=out2p0.tmp ; DELFN=out2p1.tmp
  13.117 +else
  13.118 +    ORGFN=out2p1.tmp ; DELFN=out2p0.tmp
  13.119 +fi
  13.120 +
  13.121 +# One of the responses will be a request sent to the delegate.
  13.122 +
  13.123 +   grep -q "To: $USER2ADDRESS" "$DELFN" \
  13.124 +&& grep -q 'METHOD:REQUEST' "$DELFN" \
  13.125 +&& grep -q 'ATTENDEE.*;PARTSTAT=DELEGATED.*:'"$USER1" "$DELFN" \
  13.126 +&& grep -q 'ATTENDEE.*:'"$USER2" "$DELFN" \
  13.127 +&& echo "Success" \
  13.128 +|| echo "Failed"
  13.129 +
  13.130 +# The other will be a reply to the organiser.
  13.131 +
  13.132 +   grep -q "To: $SENDERADDRESS" "$ORGFN" \
  13.133 +&& grep -q 'METHOD:REPLY' "$ORGFN" \
  13.134 +&& grep -q 'ATTENDEE.*;PARTSTAT=DELEGATED.*:'"$USER1" "$ORGFN" \
  13.135 +&& grep -q 'ATTENDEE.*:'"$USER2" "$ORGFN" \
  13.136 +&& echo "Success" \
  13.137 +|| echo "Failed"
  13.138 +
  13.139 +# Neither the delegator or the delegate will have changed their schedules.
  13.140 +
  13.141 +   "$LIST_SCRIPT" $LIST_ARGS "$USER1" "freebusy" \
  13.142 +>  out2f1.tmp
  13.143 +
  13.144 +   ! grep -q "^20141126T153000Z${TAB}20141126T163000Z" "out2f1.tmp" \
  13.145 +&& echo "Success" \
  13.146 +|| echo "Failed"
  13.147 +
  13.148 +   "$LIST_SCRIPT" $LIST_ARGS "$USER2" "freebusy" \
  13.149 +>  out2f2.tmp
  13.150 +
  13.151 +   ! grep -q "^20141126T153000Z${TAB}20141126T163000Z" "out2f2.tmp" \
  13.152 +&& echo "Success" \
  13.153 +|| echo "Failed"
  13.154 +
  13.155 +# Check the quota (event is not confirmed).
  13.156 +
  13.157 +   "$LIST_SCRIPT" $LIST_ARGS "$QUOTA" "entries" "$SENDER" \
  13.158 +>  out2e.tmp
  13.159 +
  13.160 +   grep -q "event21@example.com" "out2e.tmp" \
  13.161 +&& ! grep -q "event27@example.com" "out2e.tmp" \
  13.162 +&& echo "Success" \
  13.163 +|| echo "Failed"
  13.164 +
  13.165 +# Present the reply to the organiser.
  13.166 +
  13.167 +  "$PERSON_SCRIPT" $ARGS < "$ORGFN" 2>> "$ERROR" \
  13.168 +| tee out3r.tmp \
  13.169 +| "$SHOWMAIL" \
  13.170 +> out3.tmp
  13.171 +
  13.172 +# Check the free/busy status of the attendees at the organiser.
  13.173 +# Currently, neither are attending.
  13.174 +
  13.175 +   "$LIST_SCRIPT" $LIST_ARGS "$SENDER" "freebusy_other" "$USER1" \
  13.176 +>  out3s0.tmp \
  13.177 +
  13.178 +   ! grep -q "^20141126T153000Z${TAB}20141126T163000Z" out3s0.tmp \
  13.179 +&& echo "Success" \
  13.180 +|| echo "Failed"
  13.181 +
  13.182 +   "$LIST_SCRIPT" $LIST_ARGS "$SENDER" "freebusy_other" "$USER2" \
  13.183 +>  out3s1.tmp \
  13.184 +
  13.185 +   ! grep -q "^20141126T153000Z${TAB}20141126T163000Z" out3s1.tmp \
  13.186 +&& echo "Success" \
  13.187 +|| echo "Failed"
  13.188 +
  13.189 +# Present the request to the delegate.
  13.190 +
  13.191 +  "$RESOURCE_SCRIPT" $ARGS < "$DELFN" 2>> "$ERROR" \
  13.192 +> out4r.tmp
  13.193 +
  13.194 +  "$SHOWMAIL" < out4r.tmp \
  13.195 +> out4p0.tmp
  13.196 +
  13.197 +  "$SHOWMAIL" 1 < out4r.tmp \
  13.198 +> out4p1.tmp
  13.199 +
  13.200 +if grep -q "To: $SENDERADDRESS" out4p0.tmp ; then
  13.201 +    ORGFN=out4p0.tmp ; DELFN=out4p1.tmp
  13.202 +else
  13.203 +    ORGFN=out4p1.tmp ; DELFN=out4p0.tmp
  13.204 +fi
  13.205 +
  13.206 +# One of the responses will be a reply sent to the organiser.
  13.207 +
  13.208 +   grep -q "To: $SENDERADDRESS" "$ORGFN" \
  13.209 +&& grep -q 'METHOD:REPLY' "$ORGFN" \
  13.210 +&& grep -q 'ATTENDEE.*;PARTSTAT=DELEGATED.*:'"$USER1" "$ORGFN" \
  13.211 +&& grep -q 'ATTENDEE.*;PARTSTAT=ACCEPTED.*:'"$USER2" "$ORGFN" \
  13.212 +&& echo "Success" \
  13.213 +|| echo "Failed"
  13.214 +
  13.215 +# The other will be a reply to the delegator.
  13.216 +
  13.217 +   grep -q "To: $USER1ADDRESS" "$DELFN" \
  13.218 +&& grep -q 'METHOD:REPLY' "$DELFN" \
  13.219 +&& grep -q 'ATTENDEE.*;PARTSTAT=DELEGATED.*:'"$USER1" "$DELFN" \
  13.220 +&& grep -q 'ATTENDEE.*;PARTSTAT=ACCEPTED.*:'"$USER2" "$DELFN" \
  13.221 +&& echo "Success" \
  13.222 +|| echo "Failed"
  13.223 +
  13.224 +# The delegate should now have a changed schedule.
  13.225 +
  13.226 +   "$LIST_SCRIPT" $LIST_ARGS "$USER1" "freebusy" \
  13.227 +>  out4f1.tmp
  13.228 +
  13.229 +   ! grep -q "^20141126T153000Z${TAB}20141126T163000Z" "out4f1.tmp" \
  13.230 +&& echo "Success" \
  13.231 +|| echo "Failed"
  13.232 +
  13.233 +   "$LIST_SCRIPT" $LIST_ARGS "$USER2" "freebusy" \
  13.234 +>  out4f2.tmp
  13.235 +
  13.236 +   grep -q "^20141126T153000Z${TAB}20141126T163000Z" "out4f2.tmp" \
  13.237 +&& echo "Success" \
  13.238 +|| echo "Failed"
  13.239 +
  13.240 +# Present the reply to the organiser.
  13.241 +
  13.242 +  "$PERSON_SCRIPT" $ARGS < "$ORGFN" 2>> "$ERROR" \
  13.243 +| tee out5r.tmp \
  13.244 +| "$SHOWMAIL" \
  13.245 +> out5.tmp
  13.246 +
  13.247 +# Check the free/busy status of the attendees at the organiser.
  13.248 +# Now, the delegate is attending.
  13.249 +
  13.250 +   "$LIST_SCRIPT" $LIST_ARGS "$SENDER" "freebusy_other" "$USER1" \
  13.251 +>  out5s0.tmp \
  13.252 +
  13.253 +   ! grep -q "^20141126T153000Z${TAB}20141126T163000Z" out5s0.tmp \
  13.254 +&& echo "Success" \
  13.255 +|| echo "Failed"
  13.256 +
  13.257 +   "$LIST_SCRIPT" $LIST_ARGS "$SENDER" "freebusy_other" "$USER2" \
  13.258 +>  out5s1.tmp \
  13.259 +
  13.260 +   grep -q "^20141126T153000Z${TAB}20141126T163000Z" out5s1.tmp \
  13.261 +&& echo "Success" \
  13.262 +|| echo "Failed"
  13.263 +
  13.264 +# Present the reply to the delegator.
  13.265 +
  13.266 +  "$RESOURCE_SCRIPT" $ARGS < "$DELFN" 2>> "$ERROR" \
  13.267 +> out6r.tmp
    14.1 --- a/tests/test_resource_invitation_constraints_quota_recurring.sh	Thu May 12 23:05:48 2016 +0200
    14.2 +++ b/tests/test_resource_invitation_constraints_quota_recurring.sh	Thu May 12 23:15:18 2016 +0200
    14.3 @@ -16,7 +16,9 @@
    14.4  
    14.5  # Employ a user-specific quota (no argument with the functions above).
    14.6  
    14.7 -"$SET_QUOTA_LIMIT" "$QUOTA" '*' 'PT10H' $SET_QUOTA_LIMIT_ARGS
    14.8 +cat <<EOF | "$SET_QUOTA_LIMIT" "$QUOTA" $SET_QUOTA_LIMIT_ARGS
    14.9 +* PT10H
   14.10 +EOF
   14.11  
   14.12    "$RESOURCE_SCRIPT" $ARGS < "$TEMPLATES/fb-request-car-all.txt" 2>> $ERROR \
   14.13  | "$SHOWMAIL" \
    15.1 --- a/tests/test_resource_invitation_constraints_quota_recurring_limits.sh	Thu May 12 23:05:48 2016 +0200
    15.2 +++ b/tests/test_resource_invitation_constraints_quota_recurring_limits.sh	Thu May 12 23:15:18 2016 +0200
    15.3 @@ -26,8 +26,10 @@
    15.4  check_quota $QUOTA
    15.5  EOF
    15.6  
    15.7 -"$SET_QUOTA_LIMIT" "$QUOTA" 'mailto:vincent.vole@example.com' 'PT10H' $SET_QUOTA_LIMIT_ARGS
    15.8 -"$SET_QUOTA_LIMIT" "$QUOTA" '*' 'PT5H' $SET_QUOTA_LIMIT_ARGS
    15.9 +cat <<EOF | "$SET_QUOTA_LIMIT" "$QUOTA" $SET_QUOTA_LIMIT_ARGS
   15.10 +mailto:vincent.vole@example.com PT10H
   15.11 +* PT5H
   15.12 +EOF
   15.13  
   15.14    "$RESOURCE_SCRIPT" $ARGS < "$TEMPLATES/fb-request-car-all.txt" 2>> $ERROR \
   15.15  | "$SHOWMAIL" \
    16.1 --- a/tools/copy_store.py	Thu May 12 23:05:48 2016 +0200
    16.2 +++ b/tools/copy_store.py	Thu May 12 23:15:18 2016 +0200
    16.3 @@ -104,13 +104,15 @@
    16.4  
    16.5          # Copy quota limits.
    16.6  
    16.7 -        for user_group, limit in from_journal.get_limits(quota).items():
    16.8 -            to_journal.set_limit(quota, user_group, limit)
    16.9 +        to_journal.set_limits(quota, from_journal.get_limits(quota))
   16.10  
   16.11          # Copy group mappings.
   16.12  
   16.13 -        for store_user, user_group in from_journal.get_groups(quota).items():
   16.14 -            to_journal.set_group(quota, store_user, user_group)
   16.15 +        to_journal.set_groups(quota, from_journal.get_groups(quota))
   16.16 +
   16.17 +        # Copy delegates.
   16.18 +
   16.19 +        to_journal.set_delegates(quota, from_journal.get_delegates(quota))
   16.20  
   16.21          # Copy journal details.
   16.22  
    17.1 --- a/tools/install.sh	Thu May 12 23:05:48 2016 +0200
    17.2 +++ b/tools/install.sh	Thu May 12 23:15:18 2016 +0200
    17.3 @@ -103,7 +103,7 @@
    17.4  
    17.5  # Tools
    17.6  
    17.7 -TOOLS="copy_store.py fix.sh init.sh init_user.sh make_freebusy.py set_quota_limit.py update_quotas.py update_scheduling_modules.py"
    17.8 +TOOLS="copy_store.py fix.sh init.sh init_user.sh make_freebusy.py set_delegates.py set_quota_limit.py update_quotas.py update_scheduling_modules.py"
    17.9  
   17.10  if [ ! -e "$INSTALL_DIR/tools" ]; then
   17.11      mkdir -p "$INSTALL_DIR/tools"
    18.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
    18.2 +++ b/tools/set_delegates.py	Thu May 12 23:15:18 2016 +0200
    18.3 @@ -0,0 +1,89 @@
    18.4 +#!/usr/bin/env python
    18.5 +
    18.6 +"""
    18.7 +Set delegates for a particular quota.
    18.8 +
    18.9 +Copyright (C) 2016 Paul Boddie <paul@boddie.org.uk>
   18.10 +
   18.11 +This program is free software; you can redistribute it and/or modify it under
   18.12 +the terms of the GNU General Public License as published by the Free Software
   18.13 +Foundation; either version 3 of the License, or (at your option) any later
   18.14 +version.
   18.15 +
   18.16 +This program is distributed in the hope that it will be useful, but WITHOUT
   18.17 +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
   18.18 +FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
   18.19 +details.
   18.20 +
   18.21 +You should have received a copy of the GNU General Public License along with
   18.22 +this program.  If not, see <http://www.gnu.org/licenses/>.
   18.23 +"""
   18.24 +
   18.25 +from codecs import getreader
   18.26 +from os.path import abspath, split
   18.27 +import sys
   18.28 +
   18.29 +# Find the modules.
   18.30 +
   18.31 +try:
   18.32 +    import imiptools
   18.33 +except ImportError:
   18.34 +    parent = abspath(split(split(__file__)[0])[0])
   18.35 +    if split(parent)[1] == "imip-agent":
   18.36 +        sys.path.append(parent)
   18.37 +
   18.38 +from imiptools import config
   18.39 +from imiptools.stores import get_journal
   18.40 +from imiptools.text import get_table_from_stream
   18.41 +
   18.42 +# Main program.
   18.43 +
   18.44 +if __name__ == "__main__":
   18.45 +
   18.46 +    # Interpret the command line arguments.
   18.47 +
   18.48 +    args = []
   18.49 +    store_type = []
   18.50 +    journal_dir = []
   18.51 +
   18.52 +    # Collect quota details first, switching to other arguments when encountering
   18.53 +    # switches.
   18.54 +
   18.55 +    l = args
   18.56 +
   18.57 +    for arg in sys.argv[1:]:
   18.58 +        if arg == "-T":
   18.59 +            l = store_type
   18.60 +        elif arg == "-j":
   18.61 +            l = journal_dir
   18.62 +        else:
   18.63 +            l.append(arg)
   18.64 +
   18.65 +    try:
   18.66 +        quota, = args
   18.67 +    except ValueError:
   18.68 +        print >>sys.stderr, """\
   18.69 +Usage: %s <quota> <delegate>... [ <options> ]
   18.70 +
   18.71 +General options:
   18.72 +
   18.73 +-j  Indicates the journal directory location
   18.74 +-T  Indicates the store type (the configured value if omitted)
   18.75 +""" % split(sys.argv[0])[1]
   18.76 +        sys.exit(1)
   18.77 +
   18.78 +    # Override defaults if indicated.
   18.79 +
   18.80 +    getvalue = lambda value, default=None: value and value[0] or default
   18.81 +
   18.82 +    store_type = getvalue(store_type, config.STORE_TYPE)
   18.83 +    journal_dir = getvalue(journal_dir)
   18.84 +
   18.85 +    # Obtain store-related objects.
   18.86 +
   18.87 +    journal = get_journal(store_type, journal_dir)
   18.88 +    f = getreader("utf-8")(sys.stdin)
   18.89 +    delegates = get_table_from_stream(f, tab_separated=False)
   18.90 +    journal.set_delegates(quota, [value for (value,) in delegates])
   18.91 +
   18.92 +# vim: tabstop=4 expandtab shiftwidth=4
    19.1 --- a/tools/set_quota_limit.py	Thu May 12 23:05:48 2016 +0200
    19.2 +++ b/tools/set_quota_limit.py	Thu May 12 23:15:18 2016 +0200
    19.3 @@ -1,7 +1,7 @@
    19.4  #!/usr/bin/env python
    19.5  
    19.6  """
    19.7 -Set a quota limit for a user group.
    19.8 +Set quota limits for a collection of user groups.
    19.9  
   19.10  Copyright (C) 2016 Paul Boddie <paul@boddie.org.uk>
   19.11  
   19.12 @@ -19,6 +19,7 @@
   19.13  this program.  If not, see <http://www.gnu.org/licenses/>.
   19.14  """
   19.15  
   19.16 +from codecs import getreader
   19.17  from os.path import abspath, split
   19.18  import sys
   19.19  
   19.20 @@ -33,6 +34,7 @@
   19.21  
   19.22  from imiptools import config
   19.23  from imiptools.stores import get_journal
   19.24 +from imiptools.text import get_table_from_stream
   19.25  
   19.26  # Main program.
   19.27  
   19.28 @@ -58,10 +60,21 @@
   19.29              l.append(arg)
   19.30  
   19.31      try:
   19.32 -        quota, group, limit = args
   19.33 +        quota, = args
   19.34      except ValueError:
   19.35          print >>sys.stderr, """\
   19.36 -Usage: %s <quota> <group> <limit> [ <options> ]
   19.37 +Usage: %s <quota> [ <options> ]
   19.38 +
   19.39 +Read from standard input a list of group-to-limit mappings of the following
   19.40 +form:
   19.41 +
   19.42 +<user or group> <limit>
   19.43 +
   19.44 +For example:
   19.45 +
   19.46 +* PT1H
   19.47 +
   19.48 +The values may be separated using any whitespace characters.
   19.49  
   19.50  General options:
   19.51  
   19.52 @@ -80,6 +93,8 @@
   19.53      # Obtain store-related objects.
   19.54  
   19.55      journal = get_journal(store_type, journal_dir)
   19.56 -    journal.set_limit(quota, group, limit)
   19.57 +    f = getreader("utf-8")(sys.stdin)
   19.58 +    limits = dict(get_table_from_stream(f, tab_separated=False))
   19.59 +    journal.set_limits(quota, limits)
   19.60  
   19.61  # vim: tabstop=4 expandtab shiftwidth=4