Repository: fedora-infra/bodhi

Issue:
Create a web interface that will display the status of mashing
The ```/masher``` interface is currently very difficult to interpret and takes a very long time to load (and often times out without loading). Once we have a PushStatus object as proposed in #2014, we can create a new mash status web UI that shows the current state of the mashing processes. We can even display which step each process is at and display whether a maintenance mode is currently requested. Make it fancy.

Depends on #2014.


Discussion:

Resolving patch:
diff --git a/bodhi/server/services/composes.py b/bodhi/server/services/composes.py
new file mode 100644
--- /dev/null
+++ b/bodhi/server/services/composes.py
@@ -0,0 +1,85 @@
+# -*- coding: utf-8 -*-
+# Copyright © 2017 Red Hat, Inc.
+#
+# This file is part of Bodhi.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+"""Defines service endpoints pertaining to Composes."""
+
+from cornice.resource import resource, view
+from pyramid import httpexceptions
+from pyramid.security import Allow, Everyone
+from sqlalchemy.orm import exc
+
+from bodhi.server import models, security
+from bodhi.server.services import errors
+
+
+@resource(collection_path='/composes/', path='/composes/{release_name}/{request}',
+          description='Compose service', error_handler=errors.html_handler)
+class Compose(object):
+    """Defines resources for interacting with Compose objects."""
+
+    def __init__(self, request, context=None):
+        """
+        Initialize the Compose resource.
+
+        Args:
+            request (pyramid.util.Request): The current web request.
+            context (None): Unused.
+        """
+        self.request = request
+
+    def __acl__(self):
+        """
+        Define ACLs for the Compose resource.
+
+        Returns:
+            list: A list of ACLs for this Resource.
+        """
+        return [(Allow, Everyone, 'view_composes')]
+
+    @view(accept=('text/html',), renderer='composes.html', cors_origins=security.cors_origins_ro,
+          permission='view_composes')
+    def collection_get(self):
+        """
+        List composes.
+
+        Returns:
+            dict: A dictionary mapping the key 'composes' to an iterable of all Compose objects.
+        """
+        return {'composes': sorted(models.Compose.query.all())}
+
+    @view(accept=('text/html',), renderer='compose.html', cors_origins=security.cors_origins_ro,
+          permission='view_composes')
+    def get(self):
+        """
+        Retrieve and render a single compose.
+
+        Returns:
+            dict: A dictionary mapping the key 'compose' to a single Compose object.
+        """
+        try:
+            release = models.Release.query.filter_by(
+                name=self.request.matchdict['release_name']).one()
+            compose = models.Compose.query.filter_by(
+                release_id=release.id,
+                request=models.UpdateRequest.from_string(self.request.matchdict['request'])).one()
+        except (exc.NoResultFound, ValueError):
+            # NoResultFound means that either the Release or the Compose does not exist. ValueError
+            # can happen if the request component of the URL does not match one of the enums.
+            raise httpexceptions.HTTPNotFound()
+
+        return {'compose': compose}
diff --git a/bodhi/server/util.py b/bodhi/server/util.py
--- a/bodhi/server/util.py
+++ b/bodhi/server/util.py
@@ -486,6 +486,30 @@ def markup(context, text):
     return bleach.clean(markdown_text, tags=markdown_tags, attributes=markdown_attrs)
 
 
+def composestate2html(context, state):
+    """
+    Render the given UpdateStatus as a span containing text.
+
+    Args:
+        context (mako.runtime.Context): Unused.
+        state (bodhi.server.models.ComposeState): The ComposeState to render as a span
+            tag.
+    Returns:
+        basestring: An HTML span tag representing the ComposeState.
+    """
+    cls = {
+        'requested': 'primary',
+        'pending': 'primary',
+        'initializing': 'warning',
+        'updateinfo': 'warning',
+        'punging': 'warning',
+        'notifying': 'warning',
+        'success': 'success',
+        'failed': 'danger',
+    }[state.value]
+    return "<span class='label label-%s'>%s</span>" % (cls, state.description)
+
+
 def status2html(context, status):
     """
     Render the given UpdateStatus as a span containing text.


Test patch:
diff --git a/bodhi/tests/server/services/test_composes.py b/bodhi/tests/server/services/test_composes.py
new file mode 100644
--- /dev/null
+++ b/bodhi/tests/server/services/test_composes.py
@@ -0,0 +1,111 @@
+# -*- coding: utf-8 -*-
+# Copyright 2017 Red Hat, Inc.
+#
+# This file is part of Bodhi.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+"""This module contains tests for bodhi.server.services.composes."""
+
+from pyramid import testing
+from pyramid import security
+
+from bodhi.server import models
+from bodhi.server.services import composes
+from bodhi.tests.server import base
+
+
+class TestCompose__init__(base.BaseTestCase):
+    """This class contains tests for the Compose.__init__() method."""
+    def test___init__(self):
+        """Assert the request is stored properly."""
+        request = testing.DummyRequest()
+
+        compose = composes.Compose(request)
+
+        self.assertIs(compose.request, request)
+
+
+class TestCompose__acl__(base.BaseTestCase):
+    """This class contains tests for the Compose.__acl__() method."""
+    def test___acl__(self):
+        """Assert the permissions are correct."""
+        request = testing.DummyRequest()
+        compose = composes.Compose(request)
+
+        acls = compose.__acl__()
+
+        self.assertEqual(acls, [(security.Allow, security.Everyone, 'view_composes')])
+
+
+class TestComposeCollectionGet(base.BaseTestCase):
+    """This class contains tests for the Compose.collection_get() method."""
+    def test_no_composes(self):
+        """Assert correct behavior when there are no composes."""
+        response = self.app.get('/composes/', status=200, headers={'Accept': 'text/html'})
+
+        # The Composes header should still appear in the page
+        self.assertTrue('<h1>Composes</h1>' in response)
+
+    def test_with_compose(self):
+        """Assert correct behavior when there is a compose."""
+        update = models.Update.query.first()
+        compose = models.Compose(release=update.release, request=update.request)
+        self.db.add(compose)
+        self.db.flush()
+
+        response = self.app.get('/composes/', status=200, headers={'Accept': 'text/html'})
+
+        # The Composes header should still appear in the page
+        self.assertTrue('<h1>Composes</h1>' in response)
+        self.assertTrue(
+            '/composes/{}/{}'.format(compose.release.name, compose.request.value) in response)
+        self.assertTrue(compose.state.description in response)
+
+
+class TestComposeGet(base.BaseTestCase):
+    """This class contains tests for the Compose.get() method."""
+    def test_404_compose(self):
+        """Assert a 404 error code when there isn't a Compose matching the URL."""
+        release = models.Release.query.first()
+
+        self.app.get('/composes/{}/testing'.format(release.name), status=404,
+                     headers={'Accept': 'text/html'})
+
+    def test_404_release(self):
+        """Assert a 404 error code when the release component of the URL does not exist."""
+        self.app.get('/composes/dne/testing', status=404, headers={'Accept': 'text/html'})
+
+    def test_404_request(self):
+        """Assert a 404 error code when the request component of the URL does not exist."""
+        release = models.Release.query.first()
+
+        self.app.get('/composes/{}/hahahwhatisthis'.format(release.name), status=404,
+                     headers={'Accept': 'text/html'})
+
+    def test_with_compose(self):
+        """Assert correct behavior when there is a compose."""
+        update = models.Update.query.first()
+        update.locked = True
+        compose = models.Compose(release=update.release, request=update.request)
+        self.db.add(compose)
+        self.db.flush()
+
+        response = self.app.get(
+            '/composes/{}/{}'.format(compose.release.name, compose.request.value),
+            status=200, headers={'Accept': 'text/html'})
+
+        self.assertTrue(compose.state.description in response)
+        self.assertTrue('{} {}'.format(compose.release.name, compose.request.value) in response)
+        self.assertTrue(update.beautify_title(amp=True, nvr=True) in response)
diff --git a/bodhi/tests/server/test_util.py b/bodhi/tests/server/test_util.py
--- a/bodhi/tests/server/test_util.py
+++ b/bodhi/tests/server/test_util.py
@@ -17,6 +17,7 @@
 # along with this program; if not, write to the Free Software
 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 import subprocess
+import unittest
 
 import mock
 import pkgdb2client
@@ -25,7 +26,8 @@
 from bodhi.server import util
 from bodhi.server.buildsys import setup_buildsystem, teardown_buildsystem
 from bodhi.server.config import config
-from bodhi.server.models import TestGatingStatus, Update, UpdateRequest, UpdateSeverity
+from bodhi.server.models import (ComposeState, TestGatingStatus, Update, UpdateRequest,
+                                 UpdateSeverity)
 from bodhi.tests.server import base
 
 
@@ -155,6 +157,49 @@ def test_severity_is_urgent(self):
         self.assertTrue('</span> Push to Stable</a>' in a)
 
 
+class TestComposeState2HTML(unittest.TestCase):
+    """Assert correct behavior from the composestate2html() function."""
+    def test_requested(self):
+        """Assert correct return value with the requested state."""
+        self.assertEqual(util.composestate2html(None, ComposeState.requested),
+                         "<span class='label label-primary'>Requested</span>")
+
+    def test_pending(self):
+        """Assert correct return value with the pending state."""
+        self.assertEqual(util.composestate2html(None, ComposeState.pending),
+                         "<span class='label label-primary'>Pending</span>")
+
+    def test_initializing(self):
+        """Assert correct return value with the initializing state."""
+        self.assertEqual(util.composestate2html(None, ComposeState.initializing),
+                         "<span class='label label-warning'>Initializing</span>")
+
+    def test_updateinfo(self):
+        """Assert correct return value with the updateinfo state."""
+        self.assertEqual(util.composestate2html(None, ComposeState.updateinfo),
+                         "<span class='label label-warning'>Generating updateinfo.xml</span>")
+
+    def test_punging(self):
+        """Assert correct return value with the punging state."""
+        self.assertEqual(util.composestate2html(None, ComposeState.punging),
+                         "<span class='label label-warning'>Waiting for Pungi to finish</span>")
+
+    def test_notifying(self):
+        """Assert correct return value with the notifying state."""
+        self.assertEqual(util.composestate2html(None, ComposeState.notifying),
+                         "<span class='label label-warning'>Sending notifications</span>")
+
+    def test_success(self):
+        """Assert correct return value with the success state."""
+        self.assertEqual(util.composestate2html(None, ComposeState.success),
+                         "<span class='label label-success'>Success</span>")
+
+    def test_failed(self):
+        """Assert correct return value with the failed state."""
+        self.assertEqual(util.composestate2html(None, ComposeState.failed),
+                         "<span class='label label-danger'>Failed</span>")
+
+
 class TestUtils(base.BaseTestCase):
 
     def setUp(self):