← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~allenap/launchpad/bug-stats-key-error-bug-871076 into lp:launchpad

 

Gavin Panella has proposed merging lp:~allenap/launchpad/bug-stats-key-error-bug-871076 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #871076 in Launchpad itself: "Product:+series OOPSes on INCOMPLETE_WITH_RESPONSE and INCOMPLETE_WITHOUT_RESPONSE bug tasks"
  https://bugs.launchpad.net/launchpad/+bug/871076

For more details, see:
https://code.launchpad.net/~allenap/launchpad/bug-stats-key-error-bug-871076/+merge/78870

This problem was a palette of crates of tinned worms. I exaggerate,
but there was quite a lot of yak shaving involved in fixing this bug.

- There are three enums - BugTaskStatus, BugTaskStatusSearch,
  BugTaskStatusSearchDisplay - that can be used somewhat
  interchangeably. However, passing something other than a member of
  BugTaskStatus into BugTask.canTransitionToStatus or
  .transitionToStatus will not always work, so I've added a step to
  normalize statuses, using a new function normalize_bugtask_status().

- I've changed the behaviour of getStatusCountsForProductSeries() so
  that it returns a dict, with keys being enum members. Previously it
  returned a list of 2-tuples, each containing a status *ID* and a
  count. This required a new function, get_bugtask_status().

- I have updated the ProductSeries:+status view, and added a test for
  bugtask_status_counts specifically.

-- 
https://code.launchpad.net/~allenap/launchpad/bug-stats-key-error-bug-871076/+merge/78870
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/launchpad/bug-stats-key-error-bug-871076 into lp:launchpad.
=== modified file 'lib/lp/bugs/interfaces/bugtask.py'
--- lib/lp/bugs/interfaces/bugtask.py	2011-10-05 18:24:09 +0000
+++ lib/lp/bugs/interfaces/bugtask.py	2011-10-11 12:09:42 +0000
@@ -20,6 +20,7 @@
     'DB_INCOMPLETE_BUGTASK_STATUSES',
     'DB_UNRESOLVED_BUGTASK_STATUSES',
     'DEFAULT_SEARCH_BUGTASK_STATUSES_FOR_DISPLAY',
+    'get_bugtask_status',
     'IAddBugTaskForm',
     'IAddBugTaskWithProductCreationForm',
     'IBugTask',
@@ -28,12 +29,13 @@
     'IBugTaskSet',
     'ICreateQuestionFromBugTaskForm',
     'IFrontPageBugTaskSearch',
+    'IllegalRelatedBugTasksParams',
+    'IllegalTarget',
     'INominationsReviewTableBatchNavigator',
     'IPersonBugTaskSearch',
     'IRemoveQuestionFromBugTaskForm',
     'IUpstreamProductBugTaskSearch',
-    'IllegalRelatedBugTasksParams',
-    'IllegalTarget',
+    'normalize_bugtask_status',
     'RESOLVED_BUGTASK_STATUSES',
     'UNRESOLVED_BUGTASK_STATUSES',
     'UserCannotEditBugTaskAssignee',
@@ -305,6 +307,33 @@
         """)
 
 
+def get_bugtask_status(status_id):
+    """Get a member of `BugTaskStatus` or `BugTaskStatusSearch` by value.
+
+    `BugTaskStatus` and `BugTaskStatusSearch` intersect, but neither is a
+    subset of the other, so this searches first in `BugTaskStatus` then in
+    `BugTaskStatusSearch` for a member with the given ID.
+    """
+    try:
+        return BugTaskStatus.items[status_id]
+    except KeyError:
+        return BugTaskStatusSearch.items[status_id]
+
+
+def normalize_bugtask_status(status):
+    """Normalize `status`.
+
+    It might be a member of any of three related enums: `BugTaskStatus`,
+    `BugTaskStatusSearch`, or `BugTaskStatusSearchDisplay`. This tries to
+    normalize by value back to the first of those three enums in which the
+    status appears.
+    """
+    try:
+        return BugTaskStatus.items[status.value]
+    except KeyError:
+        return BugTaskStatusSearch.items[status.value]
+
+
 class BugTagsSearchCombinator(EnumeratedType):
     """Bug Tags Search Combinator
 

=== added directory 'lib/lp/bugs/interfaces/tests'
=== added file 'lib/lp/bugs/interfaces/tests/__init__.py'
=== added file 'lib/lp/bugs/interfaces/tests/test_bugtask.py'
--- lib/lp/bugs/interfaces/tests/test_bugtask.py	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/interfaces/tests/test_bugtask.py	2011-10-11 12:09:42 +0000
@@ -0,0 +1,107 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for BugTask interfaces."""
+
+__metaclass__ = type
+
+from lp.testing import TestCase
+from lp.bugs.interfaces.bugtask import (
+    BugTaskStatus,
+    BugTaskStatusSearch,
+    BugTaskStatusSearchDisplay,
+    get_bugtask_status,
+    normalize_bugtask_status,
+    )
+
+
+class TestFunctions(TestCase):
+
+    def test_get_bugtask_status(self):
+        # Compose a map of BugTaskStatusSearch members from their values.
+        expected = dict(
+            (status.value, status)
+            for status in BugTaskStatusSearch.items)
+        # Update the expected status map - overwriting some entries - from
+        # BugTaskStatus members and their values.
+        expected.update(
+            (status.value, status)
+            for status in BugTaskStatus.items)
+        # Compose a map of statuses as discovered by value for each member of
+        # BugTaskStatusSearch.
+        observed = dict(
+            (status.value, get_bugtask_status(status.value))
+            for status in BugTaskStatusSearch.items)
+        # Update the expected status map with statuses discovered by value for
+        # each member of BugTaskStatus.
+        observed.update(
+            (status.value, get_bugtask_status(status.value))
+            for status in BugTaskStatus.items)
+        self.assertEqual(expected, observed)
+
+    def test_normalize_bugtask_status_from_BugTaskStatus(self):
+        expected = {
+            BugTaskStatus.CONFIRMED: BugTaskStatus.CONFIRMED,
+            BugTaskStatus.EXPIRED: BugTaskStatus.EXPIRED,
+            BugTaskStatus.FIXCOMMITTED: BugTaskStatus.FIXCOMMITTED,
+            BugTaskStatus.FIXRELEASED: BugTaskStatus.FIXRELEASED,
+            BugTaskStatus.INCOMPLETE: BugTaskStatus.INCOMPLETE,
+            BugTaskStatus.INPROGRESS: BugTaskStatus.INPROGRESS,
+            BugTaskStatus.INVALID: BugTaskStatus.INVALID,
+            BugTaskStatus.NEW: BugTaskStatus.NEW,
+            BugTaskStatus.OPINION: BugTaskStatus.OPINION,
+            BugTaskStatus.TRIAGED: BugTaskStatus.TRIAGED,
+            BugTaskStatus.UNKNOWN: BugTaskStatus.UNKNOWN,
+            BugTaskStatus.WONTFIX: BugTaskStatus.WONTFIX,
+            }
+        observed = dict(
+            (status, normalize_bugtask_status(status))
+            for status in BugTaskStatus.items)
+        self.assertEqual(expected, observed)
+
+    def test_normalize_bugtask_status_from_BugTaskStatusSearch(self):
+        expected = {
+            BugTaskStatusSearch.CONFIRMED: BugTaskStatus.CONFIRMED,
+            BugTaskStatusSearch.EXPIRED: BugTaskStatus.EXPIRED,
+            BugTaskStatusSearch.FIXCOMMITTED: BugTaskStatus.FIXCOMMITTED,
+            BugTaskStatusSearch.FIXRELEASED: BugTaskStatus.FIXRELEASED,
+            BugTaskStatusSearch.INCOMPLETE: BugTaskStatus.INCOMPLETE,
+            BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE:
+                BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE,
+            BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE:
+                BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE,
+            BugTaskStatusSearch.INPROGRESS: BugTaskStatus.INPROGRESS,
+            BugTaskStatusSearch.INVALID: BugTaskStatus.INVALID,
+            BugTaskStatusSearch.NEW: BugTaskStatus.NEW,
+            BugTaskStatusSearch.OPINION: BugTaskStatus.OPINION,
+            BugTaskStatusSearch.TRIAGED: BugTaskStatus.TRIAGED,
+            BugTaskStatusSearch.WONTFIX: BugTaskStatus.WONTFIX,
+            }
+        observed = dict(
+            (status, normalize_bugtask_status(status))
+            for status in BugTaskStatusSearch.items)
+        self.assertEqual(expected, observed)
+
+    def test_normalize_bugtask_status_from_BugTaskStatusSearchDisplay(self):
+        expected = {
+            BugTaskStatusSearchDisplay.CONFIRMED: BugTaskStatus.CONFIRMED,
+            BugTaskStatusSearchDisplay.EXPIRED: BugTaskStatus.EXPIRED,
+            BugTaskStatusSearchDisplay.FIXCOMMITTED:
+                BugTaskStatus.FIXCOMMITTED,
+            BugTaskStatusSearchDisplay.FIXRELEASED:
+                BugTaskStatus.FIXRELEASED,
+            BugTaskStatusSearchDisplay.INCOMPLETE_WITH_RESPONSE:
+                BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE,
+            BugTaskStatusSearchDisplay.INCOMPLETE_WITHOUT_RESPONSE:
+                BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE,
+            BugTaskStatusSearchDisplay.INPROGRESS: BugTaskStatus.INPROGRESS,
+            BugTaskStatusSearchDisplay.INVALID: BugTaskStatus.INVALID,
+            BugTaskStatusSearchDisplay.NEW: BugTaskStatus.NEW,
+            BugTaskStatusSearchDisplay.OPINION: BugTaskStatus.OPINION,
+            BugTaskStatusSearchDisplay.TRIAGED: BugTaskStatus.TRIAGED,
+            BugTaskStatusSearchDisplay.WONTFIX: BugTaskStatus.WONTFIX,
+            }
+        observed = dict(
+            (status, normalize_bugtask_status(status))
+            for status in BugTaskStatusSearchDisplay.items)
+        self.assertEqual(expected, observed)

=== modified file 'lib/lp/bugs/model/bugtask.py'
--- lib/lp/bugs/model/bugtask.py	2011-10-10 09:04:37 +0000
+++ lib/lp/bugs/model/bugtask.py	2011-10-11 12:09:42 +0000
@@ -116,11 +116,13 @@
     BugTaskStatusSearch,
     DB_INCOMPLETE_BUGTASK_STATUSES,
     DB_UNRESOLVED_BUGTASK_STATUSES,
+    get_bugtask_status,
     IBugTask,
     IBugTaskDelta,
     IBugTaskSet,
     IllegalRelatedBugTasksParams,
     IllegalTarget,
+    normalize_bugtask_status,
     RESOLVED_BUGTASK_STATUSES,
     UserCannotEditBugTaskAssignee,
     UserCannotEditBugTaskImportance,
@@ -857,6 +859,7 @@
 
     def canTransitionToStatus(self, new_status, user):
         """See `IBugTask`."""
+        new_status = normalize_bugtask_status(new_status)
         celebrities = getUtility(ILaunchpadCelebrities)
         if (self.status == BugTaskStatus.FIXRELEASED and
            (user.id == self.bug.ownerID or user.inTeam(self.bug.owner))):
@@ -880,6 +883,8 @@
             # testing the edit form.
             return
 
+        new_status = normalize_bugtask_status(new_status)
+
         if not self.canTransitionToStatus(new_status, user):
             raise UserCannotEditBugTaskStatus(
                 "Only Bug Supervisors may change status to %s." % (
@@ -2804,13 +2809,12 @@
             # since the get_bug_privacy_filter() check for non-admins is
             # costly, don't filter those bugs at all.
             bug_privacy_filter = ''
-        cur = cursor()
-
         # The union is actually much faster than a LEFT JOIN with the
         # Milestone table, since postgres optimizes it to perform index
         # scans instead of sequential scans on the BugTask table.
         query = """
-            SELECT status, count(*)
+            SELECT
+                status, COUNT(*)
             FROM (
                 SELECT BugTask.status
                 FROM BugTask
@@ -2818,9 +2822,7 @@
                 WHERE
                     BugTask.productseries = %(series)s
                     %(privacy)s
-
                 UNION ALL
-
                 SELECT BugTask.status
                 FROM BugTask
                     JOIN Bug ON BugTask.bug = Bug.id
@@ -2831,11 +2833,15 @@
                     %(privacy)s
                 ) AS subquery
             GROUP BY status
-            """ % dict(series=quote(product_series),
-                       privacy=bug_privacy_filter)
-
+            """
+        query %= dict(
+            series=quote(product_series),
+            privacy=bug_privacy_filter)
+        cur = cursor()
         cur.execute(query)
-        return cur.fetchall()
+        return dict(
+            (get_bugtask_status(status_id), count)
+            for (status_id, count) in cur.fetchall())
 
     def findExpirableBugTasks(self, min_days_old, user,
                               bug=None, target=None, limit=None):

=== modified file 'lib/lp/bugs/model/tests/test_bugtask_status.py'
--- lib/lp/bugs/model/tests/test_bugtask_status.py	2011-09-28 14:36:34 +0000
+++ lib/lp/bugs/model/tests/test_bugtask_status.py	2011-10-11 12:09:42 +0000
@@ -10,8 +10,12 @@
 
 from canonical.testing.layers import DatabaseFunctionalLayer
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
-from lp.bugs.interfaces.bugtask import UserCannotEditBugTaskStatus
-from lp.bugs.model.bugtask import BugTaskStatus
+from lp.bugs.interfaces.bugtask import (
+    BugTaskStatus,
+    BugTaskStatusSearch,
+    BugTaskStatusSearchDisplay,
+    UserCannotEditBugTaskStatus,
+    )
 from lp.testing import (
     person_logged_in,
     TestCaseWithFactory,
@@ -147,6 +151,35 @@
                 BugTaskStatus.NEW, self.user),
             False)
 
+    def test_transitionToStatus_normalization(self):
+        # The new status is normalized using normalize_bugtask_status, so
+        # members of BugTaskStatusSearch or BugTaskStatusSearchDisplay can
+        # also be used.
+        with person_logged_in(self.user):
+            self.task.transitionToStatus(
+                BugTaskStatusSearch.CONFIRMED, self.user)
+            self.assertEqual(BugTaskStatus.CONFIRMED, self.task.status)
+            self.task.transitionToStatus(
+                BugTaskStatusSearchDisplay.CONFIRMED, self.user)
+            self.assertEqual(BugTaskStatus.CONFIRMED, self.task.status)
+
+    def test_canTransitionToStatus_normalization(self):
+        # The new status is normalized using normalize_bugtask_status, so
+        # members of BugTaskStatusSearch or BugTaskStatusSearchDisplay can
+        # also be used.
+        self.assertTrue(
+            self.task.canTransitionToStatus(
+                BugTaskStatusSearch.CONFIRMED, self.user))
+        self.assertFalse(
+            self.task.canTransitionToStatus(
+                BugTaskStatusSearch.WONTFIX, self.user))
+        self.assertTrue(
+            self.task.canTransitionToStatus(
+                BugTaskStatusSearchDisplay.CONFIRMED, self.user))
+        self.assertFalse(
+            self.task.canTransitionToStatus(
+                BugTaskStatusSearchDisplay.WONTFIX, self.user))
+
 
 class TestBugTaskStatusTransitionForReporter(TestCaseWithFactory):
     """Tests for bug reporter status transitions."""

=== modified file 'lib/lp/bugs/tests/test_bugtaskset.py'
--- lib/lp/bugs/tests/test_bugtaskset.py	2011-03-23 16:28:51 +0000
+++ lib/lp/bugs/tests/test_bugtaskset.py	2011-10-11 12:09:42 +0000
@@ -7,9 +7,11 @@
 
 from zope.component import getUtility
 
+from canonical.database.sqlbase import flush_database_updates
 from canonical.testing.layers import DatabaseFunctionalLayer
 from lp.bugs.interfaces.bugtask import (
     BugTaskStatus,
+    BugTaskStatusSearch,
     IBugTaskSet,
     )
 from lp.testing import (
@@ -33,11 +35,8 @@
         self.milestone = self.factory.makeMilestone(productseries=self.series)
 
     def get_counts(self, user):
-        counts = self.bugtask_set.getStatusCountsForProductSeries(
-                user, self.series)
-        return [
-            (BugTaskStatus.items[status_id], count)
-            for status_id, count in counts]
+        return self.bugtask_set.getStatusCountsForProductSeries(
+            user, self.series)
 
     def test_privacy_and_counts_for_unauthenticated_user(self):
         # An unauthenticated user should see bug counts for each status
@@ -47,7 +46,7 @@
         self.factory.makeBug(series=self.series)
         self.factory.makeBug(series=self.series, private=True)
         self.assertEqual(
-            [(BugTaskStatus.NEW, 2)],
+            {BugTaskStatus.NEW: 2},
             self.get_counts(None))
 
     def test_privacy_and_counts_for_owner(self):
@@ -58,7 +57,7 @@
         self.factory.makeBug(series=self.series)
         self.factory.makeBug(series=self.series, private=True)
         self.assertEqual(
-            [(BugTaskStatus.NEW, 4)],
+            {BugTaskStatus.NEW: 4},
             self.get_counts(self.owner))
 
     def test_privacy_and_counts_for_other_user(self):
@@ -72,22 +71,42 @@
         self.factory.makeBug(series=self.series, private=True)
         other = self.factory.makePerson()
         self.assertEqual(
-            [(BugTaskStatus.NEW, 4)],
+            {BugTaskStatus.NEW: 4},
             self.get_counts(other))
 
     def test_multiple_statuses(self):
         # Test that separate counts are provided for each status that
         # bugs are found in.
-        for status in (BugTaskStatus.INVALID, BugTaskStatus.OPINION):
+        statuses = [
+            BugTaskStatus.INVALID,
+            BugTaskStatus.OPINION,
+            ]
+        for status in statuses:
             self.factory.makeBug(milestone=self.milestone, status=status)
             self.factory.makeBug(series=self.series, status=status)
         for i in range(3):
             self.factory.makeBug(series=self.series)
         self.assertEqual(
-            [(BugTaskStatus.INVALID, 2),
-             (BugTaskStatus.OPINION, 2),
-             (BugTaskStatus.NEW, 3),
-            ],
+            {BugTaskStatus.INVALID: 2,
+             BugTaskStatus.OPINION: 2,
+             BugTaskStatus.NEW: 3},
+            self.get_counts(None))
+
+    def test_incomplete_status(self):
+        # INCOMPLETE is stored as either INCOMPLETE_WITH_RESPONSE or
+        # INCOMPLETE_WITHOUT_RESPONSE so the stats do not include a count of
+        # INCOMPLETE tasks.
+        statuses = [
+            BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE,
+            BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE,
+            BugTaskStatus.INCOMPLETE,
+            ]
+        for status in statuses:
+            self.factory.makeBug(series=self.series, status=status)
+        flush_database_updates()
+        self.assertEqual(
+            {BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE: 1,
+             BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE: 2},
             self.get_counts(None))
 
 

=== modified file 'lib/lp/registry/browser/productseries.py'
--- lib/lp/registry/browser/productseries.py	2011-09-28 10:28:47 +0000
+++ lib/lp/registry/browser/productseries.py	2011-10-11 12:09:42 +0000
@@ -491,13 +491,14 @@
     def bugtask_status_counts(self):
         """A list StatusCounts summarising the targeted bugtasks."""
         bugtaskset = getUtility(IBugTaskSet)
-        status_id_counts = bugtaskset.getStatusCountsForProductSeries(
+        status_counts = bugtaskset.getStatusCountsForProductSeries(
             self.user, self.context)
-        status_counts = dict([(BugTaskStatus.items[status_id], count)
-                              for status_id, count in status_id_counts])
-        return [StatusCount(status, status_counts[status])
-                for status in sorted(status_counts,
-                                     key=attrgetter('sortkey'))]
+        # We sort by value before sortkey because the statuses returned can be
+        # from different (though related) enums.
+        statuses = sorted(status_counts, key=attrgetter('value', 'sortkey'))
+        return [
+            StatusCount(status, status_counts[status])
+            for status in statuses]
 
     @cachedproperty
     def specification_status_counts(self):

=== modified file 'lib/lp/registry/browser/tests/test_productseries_views.py'
--- lib/lp/registry/browser/tests/test_productseries_views.py	2011-07-29 14:26:49 +0000
+++ lib/lp/registry/browser/tests/test_productseries_views.py	2011-10-11 12:09:42 +0000
@@ -6,13 +6,17 @@
 __metaclass__ = type
 
 from canonical.testing.layers import DatabaseFunctionalLayer
+from lp.bugs.interfaces.bugtask import (
+    BugTaskStatus,
+    BugTaskStatusSearch,
+    )
 from lp.testing import (
     BrowserTestCase,
+    person_logged_in,
     TestCaseWithFactory,
-    person_logged_in
     )
+from lp.testing.matchers import Contains
 from lp.testing.views import create_initialized_view
-from lp.testing.matchers import Contains
 
 
 class TestProductSeriesHelp(TestCaseWithFactory):
@@ -40,3 +44,43 @@
         """Test that rendering the graph does not raise an exception."""
         productseries = self.factory.makeProductSeries()
         self.getViewBrowser(productseries, view_name='+timeline-graph')
+
+
+class TestProductSeriesStatus(TestCaseWithFactory):
+    """Tests for ProductSeries:+status."""
+
+    layer = DatabaseFunctionalLayer
+
+    def test_bugtask_status_counts(self):
+        """Test that `bugtask_status_counts` is sane."""
+        product = self.factory.makeProduct()
+        series = self.factory.makeProductSeries(product=product)
+        for status in BugTaskStatusSearch.items:
+            self.factory.makeBug(
+                series=series, status=status,
+                owner=product.owner)
+        self.factory.makeBug(
+            series=series, status=BugTaskStatus.UNKNOWN,
+            owner=product.owner)
+        with person_logged_in(product.owner):
+            view = create_initialized_view(series, '+status')
+            self.assertEqual(
+                [(BugTaskStatus.NEW, 1),
+                 (BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE, 1),
+                 # 2 because INCOMPLETE is stored as INCOMPLETE_WITH_RESPONSE
+                 # or INCOMPLETE_WITHOUT_RESPONSE, and there was no response
+                 # for the bug created as INCOMPLETE.
+                 (BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE, 2),
+                 (BugTaskStatus.OPINION, 1),
+                 (BugTaskStatus.INVALID, 1),
+                 (BugTaskStatus.WONTFIX, 1),
+                 (BugTaskStatus.EXPIRED, 1),
+                 (BugTaskStatus.CONFIRMED, 1),
+                 (BugTaskStatus.TRIAGED, 1),
+                 (BugTaskStatus.INPROGRESS, 1),
+                 (BugTaskStatus.FIXCOMMITTED, 1),
+                 (BugTaskStatus.FIXRELEASED, 1),
+                 (BugTaskStatus.UNKNOWN, 1)],
+                [(status_count.status, status_count.count)
+                 for status_count in view.bugtask_status_counts],
+                )


References