Repository: fedora-infra/bodhi

Issue:
[RFE] Expire overrides using CLI.
It seems it is not possible to expire override using CLI. The web UI seems to be the only option ATM. Please consider adding this feature.

```
$ rpm -q bodhi-client
bodhi-client-2.2.4-1.fc26.noarch
```

I noted this originally here:

https://bugzilla.redhat.com/show_bug.cgi?id=1366114#c6



Discussion:
I marked this high priority since it sounds like it is a feature regression since the 0.9 client.


Resolving patch:
diff --git a/bodhi/client/__init__.py b/bodhi/client/__init__.py
--- a/bodhi/client/__init__.py
+++ b/bodhi/client/__init__.py
@@ -22,6 +22,32 @@
 
 from bodhi.client import bindings
 
+
+def _warn_if_url_and_staging_set(ctx, param, value):
+    """
+    This function will print a warning to stderr if the user has set both the --url and --staging
+    flags, so that the user is aware that --staging supercedes --url.
+
+    Args:
+        ctx (click.core.Context): The Click context, used to find out if the --staging flag is set.
+        param (click.core.Option): The option being handled. Unused.
+        value (unicode): The value of the --url flag.
+
+    Returns:
+        unicode: The value of the --url flag.
+    """
+    if ctx.params.get('staging', False):
+        click.echo('\nWarning: url and staging flags are both set. url will be ignored.\n',
+                   err=True)
+    return value
+
+
+url_option = click.option('--url', envvar='BODHI_URL', default=bindings.BASE_URL,
+                          help=('URL of a Bodhi server. Ignored if --staging is set. Can be set '
+                                'with BODHI_URL environment variable'),
+                          callback=_warn_if_url_and_staging_set)
+
+
 new_edit_options = [
     click.option('--user', envvar='USERNAME'),
     click.option('--password', prompt=True, hide_input=True),
@@ -42,6 +68,20 @@
                  is_flag=True, default=False)]
 
 
+# Common options for the overrides save and edit command
+save_edit_options = [
+    click.argument('nvr'),
+    click.option('--duration', default=7, type=click.INT,
+                 help='Number of days the override should exist.'),
+    click.option('--notes', default="No explanation given...",
+                 help='Notes on why this override is in place.'),
+    click.option('--user', envvar='USERNAME'),
+    click.option('--password', prompt=True, hide_input=True),
+    click.option('--staging', help='Use the staging bodhi instance',
+                 is_flag=True, default=False),
+    url_option]
+
+
 def add_options(options):
     """ Given a list of click options this creates a decorator that
     will return a function used to add the options to a click command.
@@ -58,29 +98,27 @@ def _add_options(func):
     return _add_options
 
 
-def _warn_if_url_and_staging_set(ctx, param, value):
+def _save_override(url, user, password, staging, edit=False, **kwargs):
     """
-    This function will print a warning to stderr if the user has set both the --url and --staging
-    flags, so that the user is aware that --staging supercedes --url.
+    Helper function to create or edit a buildroot override.
 
     Args:
-        ctx (click.core.Context): The Click context, used to find out if the --staging flag is set.
-        param (click.core.Option): The option being handled. Unused.
-        value (unicode): The value of the --url flag.
-
-    Returns:
-        unicode: The value of the --url flag.
+        url (unicode): The URL of a Bodhi server to create the update on. Ignored if staging is
+                       True.
+        user (unicode): The username to authenticate as.
+        password (unicode): The user's password.
+        staging (bool): Whether to use the staging server or not.
+        edit (bool): Set to True to edit an existing buildroot override.
+        kwargs (dict): Other keyword arguments passed to us by click.
     """
-    if ctx.params.get('staging', False):
-        click.echo('\nWarning: url and staging flags are both set. url will be ignored.\n',
-                   err=True)
-    return value
-
 
-url_option = click.option('--url', envvar='BODHI_URL', default=bindings.BASE_URL,
-                          help=('URL of a Bodhi server. Ignored if --staging is set. Can be set '
-                                'with BODHI_URL environment variable'),
-                          callback=_warn_if_url_and_staging_set)
+    client = bindings.BodhiClient(base_url=url, username=user, password=password, staging=staging)
+    resp = client.save_override(nvr=kwargs['nvr'],
+                                duration=kwargs['duration'],
+                                notes=kwargs['notes'],
+                                edit=edit,
+                                expired=kwargs.get('expire', False))
+    print_resp(resp, client)
 
 
 @click.group()
@@ -386,34 +424,38 @@ def query_buildroot_overrides(url, user=None, **kwargs):
 
 
 @overrides.command('save')
-@click.argument('nvr')
-@click.option('--duration', default=7, type=click.INT,
-              help='Number of days the override should exist.')
-@click.option('--notes', default="No explanation given...",
-              help='Notes on why this override is in place.')
-@click.option('--user', envvar='USERNAME')
-@click.option('--password', prompt=True, hide_input=True)
-@click.option('--staging', help='Use the staging bodhi instance',
-              is_flag=True, default=False)
-@url_option
-def save_buildroot_overrides(nvr, duration, notes, user, password, staging, url):
+@add_options(save_edit_options)
+def save_buildroot_overrides(user, password, url, staging, **kwargs):
     """
-    Create or modify a buildroot override.
+    Create a buildroot override.
 
     Args:
-        nvr (unicode): The NVR of the update you wish to submit as a buildroot override.
-        duration (int): The number of days you wish the override to be active for.
-        notes (unicode): Notes to leave on the override.
         user (unicode): The username to authenticate as.
         password (unicode): The user's password.
+        url (unicode): The URL of a Bodhi server to create the update on. Ignored if staging is
+                       True.
         staging (bool): Whether to use the staging server or not.
+        kwargs (dict): Other keyword arguments passed to us by click.
+    """
+    _save_override(url=url, user=user, password=password, staging=staging, **kwargs)
+
+
+@overrides.command('edit')
+@add_options(save_edit_options)
+@click.option('--expire', help='Expire the override', is_flag=True, default=False)
+def edit_buildroot_overrides(user, password, url, staging, **kwargs):
+    """
+    Edit a buildroot override.
+
+    Args:
+        user (unicode): The username to authenticate as.
+        password (unicode): The user's password.
         url (unicode): The URL of a Bodhi server to create the update on. Ignored if staging is
                        True.
+        staging (bool): Whether to use the staging server or not.
         kwargs (dict): Other keyword arguments passed to us by click.
     """
-    client = bindings.BodhiClient(base_url=url, username=user, password=password, staging=staging)
-    resp = client.save_override(nvr=nvr, duration=duration, notes=notes)
-    print_resp(resp, client)
+    _save_override(url=url, user=user, password=password, staging=staging, edit=True, **kwargs)
 
 
 def print_resp(resp, client):
@@ -432,14 +474,14 @@ def print_resp(resp, client):
         click.echo(client.update_str(resp))
     elif 'overrides' in resp:
         if len(resp.overrides) == 1:
-            click.echo(client.override_str(resp.overrides[0]))
+            click.echo(client.override_str(resp.overrides[0], minimal=False))
         else:
             for override in resp.overrides:
                 click.echo(client.override_str(override).strip())
         click.echo(
             '%s overrides found (%d shown)' % (resp.total, len(resp.overrides)))
     elif 'build' in resp:
-        click.echo(client.override_str(resp))
+        click.echo(client.override_str(resp, minimal=False))
     elif 'comment' in resp:
         click.echo('The following comment was added to %s' % resp.comment['update'].title)
         click.echo(resp.comment.text)
diff --git a/bodhi/client/bindings.py b/bodhi/client/bindings.py
--- a/bodhi/client/bindings.py
+++ b/bodhi/client/bindings.py
@@ -273,7 +273,7 @@ def comment(self, update, comment, karma=0, email=None):
                   'csrf_token': self.csrf()})
 
     @errorhandled
-    def save_override(self, nvr, duration, notes):
+    def save_override(self, nvr, duration, notes, edit=False, expired=False):
         """ Save a buildroot override.
 
         This entails either creating a new buildroot override, or editing an
@@ -283,18 +283,22 @@ def save_override(self, nvr, duration, notes):
         :kwarg duration: Number of days from now that this override should
             expire.
         :kwarg notes: Notes about why this override is in place.
+        :kwargs edit: A boolean to edit an existing override.
+        :kwargs expired: A boolean to expire an override.
 
         """
         expiration_date = datetime.datetime.utcnow() + \
             datetime.timedelta(days=duration)
-
-        return self.send_request(
-            'overrides/', verb='POST', auth=True, data={
-                'nvr': nvr,
+        data = {'nvr': nvr,
                 'expiration_date': expiration_date,
                 'notes': notes,
-                'csrf_token': self.csrf(),
-            })
+                'csrf_token': self.csrf()}
+        if edit:
+            data['edited'] = nvr
+        if expired:
+            data['expired'] = expired
+        return self.send_request(
+            'overrides/', verb='POST', auth=True, data=data)
 
     @errorhandled
     def list_overrides(self, user=None):
@@ -391,21 +395,32 @@ def testable(self):
                     yield update
 
     @staticmethod
-    def override_str(override):
+    def override_str(override, minimal=True):
         """ Return a string representation of a given override dictionary.
 
         :arg override: An override dictionary.
+        :kwarg minimal: Return a minimal one-line representation of the update.
 
         """
         if isinstance(override, six.string_types):
             return override
 
-        # TODO -- make this fancy.
-        return "{submitter}'s {build} override (expires {expiry})".format(
-            submitter=override['submitter']['name'],
-            build=override['build']['nvr'],
-            expiry=override['expiration_date'],
-        )
+        if minimal:
+            return "{submitter}'s {build} override (expires {expiry})".format(
+                submitter=override['submitter']['name'],
+                build=override['build']['nvr'],
+                expiry=override['expiration_date'],
+            )
+
+        val = "%s\n%s\n%s\n" % ('=' * 60, '\n'.join(
+            textwrap.wrap(override['build']['nvr'].replace(',', ', '), width=60,
+                          initial_indent=' ' * 5, subsequent_indent=' ' * 5)), '=' * 60)
+        val += "  Submitter: {}\n".format(override['submitter']['name'])
+        val += "  Expiration Date: {}\n".format(override['expiration_date'])
+        val += "  Notes: {}\n".format(override['notes'])
+        val += "  Expired: {}".format(override['expired_date'] is not None)
+
+        return val
 
     def update_str(self, update, minimal=False):
         """ Return a string representation of a given update dictionary.


Test patch:
diff --git a/bodhi/tests/client/__init__.py b/bodhi/tests/client/__init__.py
--- a/bodhi/tests/client/__init__.py
+++ b/bodhi/tests/client/__init__.py
@@ -78,6 +78,14 @@
         u'openid': None, u'name': u'bowlofeggs', u'show_popups': True, u'id': 2897, u'avatar': None,
         u'groups': [Munch({u'name': u'packager'})], u'email': u'email@example.com'})})
 
+EXAMPLE_EXPIRED_OVERRIDE_MUNCH = Munch({
+    u'build_id': 108570, u'submission_date': u'2017-02-28 23:05:32', u'caveats': [],
+    u'nvr': u'js-tag-it-2.0-1.fc25', u'expiration_date': u'2017-03-07 23:05:31',
+    u'notes': u'This is an expired override', u'submitter_id': 2897,
+    u'build': Munch({u'epoch': 0, u'nvr': u'js-tag-it-2.0-1.fc25', u'signed': True}),
+    u'expired_date': '2017-03-07 23:05:31', u'submitter': Munch({
+        u'openid': None, u'name': u'bowlofeggs', u'show_popups': True, u'id': 2897, u'avatar': None,
+        u'groups': [Munch({u'name': u'packager'})], u'email': u'email@example.com'})})
 
 EXAMPLE_QUERY_MUNCH = Munch({
     u'chrome': True,
@@ -499,3 +507,21 @@
 
   http://example.com/tests/updates/FEDORA-EPEL-2016-3081a94111
 """
+
+EXPECTED_OVERRIDES_OUTPUT = u"""============================================================
+     js-tag-it-2.0-1.fc25
+============================================================
+  Submitter: bowlofeggs
+  Expiration Date: 2017-03-07 23:05:31
+  Notes: No explanation given...
+  Expired: False
+"""
+
+EXPECTED_EXPIRED_OVERRIDES_OUTPUT = u"""============================================================
+     js-tag-it-2.0-1.fc25
+============================================================
+  Submitter: bowlofeggs
+  Expiration Date: 2017-03-07 23:05:31
+  Notes: This is an expired override
+  Expired: True
+"""
diff --git a/bodhi/tests/client/test___init__.py b/bodhi/tests/client/test___init__.py
--- a/bodhi/tests/client/test___init__.py
+++ b/bodhi/tests/client/test___init__.py
@@ -295,9 +295,7 @@ def test_url_flag(self, send_request):
              'http://localhost:6543/'])
 
         self.assertEqual(result.exit_code, 0)
-        self.assertEqual(
-            result.output,
-            "bowlofeggs's js-tag-it-2.0-1.fc25 override (expires 2017-03-07 23:05:31)\n")
+        self.assertEqual(result.output, client_test_data.EXPECTED_OVERRIDES_OUTPUT)
         bindings_client = send_request.mock_calls[0][1][0]
         # datetime is a C extension that can't be mocked, so let's just assert that the time is
         # about a week away.
@@ -450,3 +448,39 @@ def test_notes_and_notes_file(self):
 
             self.assertEqual(result.exit_code, 1)
             self.assertEqual(result.output, u'ERROR: Cannot specify --notes and --notes-file\n')
+
+
+class TestEditBuilrootOverrides(unittest.TestCase):
+    """
+    Test the edit_buildroot_overrides() function.
+    """
+    @mock.patch('bodhi.client.bindings.BodhiClient.csrf',
+                mock.MagicMock(return_value='a_csrf_token'))
+    @mock.patch('bodhi.client.bindings.BodhiClient.send_request',
+                return_value=client_test_data.EXAMPLE_EXPIRED_OVERRIDE_MUNCH, autospec=True)
+    def test_expired_override(self, send_request):
+        """
+        Assert that a successful overrides edit request expires the request
+        when --expired flag is set.
+        """
+        runner = testing.CliRunner()
+
+        result = runner.invoke(
+            client.edit_buildroot_overrides,
+            ['--user', 'bowlofeggs', '--password', 's3kr3t', 'js-tag-it-2.0-1.fc25', '--url',
+             'http://localhost:6543/', '--notes', 'This is an expired override', '--expire'])
+
+        self.assertEqual(result.exit_code, 0)
+        self.assertEqual(result.output, client_test_data.EXPECTED_EXPIRED_OVERRIDES_OUTPUT)
+        bindings_client = send_request.mock_calls[0][1][0]
+        # datetime is a C extension that can't be mocked, so let's just assert that the time is
+        # about a week away.
+        expire_time = send_request.mock_calls[0][2]['data']['expiration_date']
+        self.assertTrue((datetime.datetime.utcnow() - expire_time) < datetime.timedelta(seconds=5))
+        send_request.assert_called_once_with(
+            bindings_client, 'overrides/', verb='POST', auth=True,
+            data={
+                'expiration_date': expire_time, 'notes': u'This is an expired override',
+                'nvr': u'js-tag-it-2.0-1.fc25', 'edited': u'js-tag-it-2.0-1.fc25',
+                'csrf_token': 'a_csrf_token', 'expired': True})
+        self.assertEqual(bindings_client.base_url, 'http://localhost:6543/')
diff --git a/bodhi/tests/client/test_bindings.py b/bodhi/tests/client/test_bindings.py
--- a/bodhi/tests/client/test_bindings.py
+++ b/bodhi/tests/client/test_bindings.py
@@ -191,9 +191,9 @@ class TestBodhiClient_override_str(unittest.TestCase):
     """
     Test the BodhiClient.override_str() method.
     """
-    def test_with_dict(self):
+    def test_with_min_dict(self):
         """
-        Test override_str() with a dict argument.
+        Test override_str() with a dict argument and minimal set to true.
         """
         override = {
             'submitter': {'name': 'bowlofeggs'}, 'build': {'nvr': 'python-pyramid-1.5.6-3.el7'},
@@ -204,6 +204,19 @@ def test_with_dict(self):
         self.assertEqual(override,
                          "bowlofeggs's python-pyramid-1.5.6-3.el7 override (expires 2017-02-24)")
 
+    def test_with_dict(self):
+        """
+        Test override_str() with a dict argument.
+        """
+        override = {
+            'submitter': {'name': 'bowlofeggs'}, 'build': {'nvr': 'js-tag-it-2.0-1.fc25'},
+            'expiration_date': '2017-03-07 23:05:31', 'notes': 'No explanation given...',
+            'expired_date': None}
+
+        override = bindings.BodhiClient.override_str(override, minimal=False)
+
+        self.assertEqual(override, client_test_data.EXPECTED_OVERRIDES_OUTPUT.rstrip())
+
     def test_with_str(self):
         """
         Test override_str() with a str argument.
@@ -457,9 +470,9 @@ def test_save_override(self):
         client.send_request = mock.MagicMock(return_value='return_value')
         client.csrf_token = 'a token'
         now = datetime.utcnow()
-
-        response = client.save_override('python-pyramid-1.5.6-3.el7', 2,
-                                        'This is needed to build bodhi-2.4.0.')
+        response = client.save_override(nvr='python-pyramid-1.5.6-3.el7',
+                                        duration=2,
+                                        notes='This is needed to build bodhi-2.4.0.')
 
         self.assertEqual(response, 'return_value')
         actual_expiration = client.send_request.mock_calls[0][2]['data']['expiration_date']
@@ -467,7 +480,7 @@ def test_save_override(self):
             'overrides/', verb='POST', auth=True,
             data={'nvr': 'python-pyramid-1.5.6-3.el7',
                   'expiration_date': actual_expiration,
-                  'notes': 'This is needed to build bodhi-2.4.0.', 'csrf_token': 'a token'})
+                  'csrf_token': 'a token', 'notes': 'This is needed to build bodhi-2.4.0.'})
         # Since we can't mock utcnow() since it's a C extension, let's just make sure the expiration
         # date sent is within 5 minutes of the now variable. It would be surprising if it took more
         # than 5 minutes to start the function and execute its first instruction!