← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~abentley/launchpad/new-bug-fields into lp:launchpad

 

Aaron Bentley has proposed merging lp:~abentley/launchpad/new-bug-fields into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~abentley/launchpad/new-bug-fields/+merge/81310

= Summary =
Provide new fields for dynamic bug listings: last_updated, assignee, reporter, age, tags.

== Proposed fix ==
See above

== Pre-implementation notes ==
Discussed with deryck

== Implementation details ==
Wrote DateTimeFormatterAPI.durationsince() to match the text given in the mockups.  There doesn't seem to be an existing implementation.

Leap years make deriving a year count from a number of days tricky, so _yearDelta simply subtracts one year from another, and then adjusts for the possibility that the difference between the years may be greater than the difference between the two values.


== Tests ==
bin/test -t hiding -t test_model bugtask

== Demo and Q/A ==
The dynamic bug listings should work normally and look the same, but the model should contain the new fields.

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/app/tests/test_tales.py
  lib/lp/bugs/browser/tests/test_bugtask.py
  lib/lp/bugs/browser/bugtask.py
  lib/lp/bugs/templates/buglisting.mustache
  lib/lp/app/browser/tales.py
-- 
https://code.launchpad.net/~abentley/launchpad/new-bug-fields/+merge/81310
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~abentley/launchpad/new-bug-fields into lp:launchpad.
=== modified file 'lib/lp/app/browser/tales.py'
--- lib/lp/app/browser/tales.py	2011-10-28 15:23:20 +0000
+++ lib/lp/app/browser/tales.py	2011-11-04 18:44:28 +0000
@@ -2146,6 +2146,41 @@
     def isodate(self):
         return self._datetime.isoformat()
 
+    @staticmethod
+    def _yearDelta(old, new):
+        """Return the difference in years between two datetimes.
+
+        :param old: The old date
+        :param new: The new date
+        """
+        year_delta = new.year - old.year
+        year_timedelta = datetime(new.year, 1, 1) - datetime(old.year, 1, 1)
+        if new - old < year_timedelta:
+            year_delta -= 1
+        return year_delta
+
+    def durationsince(self):
+        """How long since the datetime, as a string."""
+        now = self._now()
+        number = self._yearDelta(self._datetime, now)
+        unit = 'year'
+        if number < 1:
+            delta = now - self._datetime
+            if delta.days > 0:
+                number = delta.days
+                unit = 'day'
+            else:
+                number = delta.seconds / 60
+                unit = 'minute'
+                if number >= 60:
+                    number /= 60
+                    unit = 'hour'
+                if number == 0:
+                    return 'less than a minute'
+        if number != 1:
+            unit += 's'
+        return '%d %s' % (number, unit)
+
 
 class SeriesSourcePackageBranchFormatter(ObjectFormatterAPI):
     """Formatter for a SourcePackage, Pocket -> Branch link.

=== modified file 'lib/lp/app/tests/test_tales.py'
--- lib/lp/app/tests/test_tales.py	2011-10-20 00:53:01 +0000
+++ lib/lp/app/tests/test_tales.py	2011-11-04 18:44:28 +0000
@@ -3,8 +3,10 @@
 
 """tales.py doctests."""
 
+from datetime import datetime, timedelta
+
 from lxml import html
-
+from pytz import utc
 from zope.component import (
     getAdapter,
     getUtility
@@ -21,12 +23,14 @@
     )
 from lp.app.browser.tales import (
     format_link,
+    DateTimeFormatterAPI,
     ObjectImageDisplayAPI,
     PersonFormatterAPI,
     )
 from lp.registry.interfaces.irc import IIrcIDSet
 from lp.testing import (
     test_tales,
+    TestCase,
     TestCaseWithFactory,
     )
 
@@ -324,3 +328,52 @@
         product = self.factory.makeProduct(icon=icon)
         display_api = ObjectImageDisplayAPI(product)
         self.assertEqual(icon.getURL(), display_api.custom_icon_url())
+
+
+class TestDateTimeFormatterAPI(TestCase):
+
+    def test_yearDelta(self):
+        """Test that year delta gives reasonable values."""
+        def assert_delta(expected, old, new):
+            old = datetime(*old, tzinfo=utc)
+            new = datetime(*new, tzinfo=utc)
+            delta = DateTimeFormatterAPI._yearDelta(old, new)
+            self.assertEqual(expected, delta)
+        assert_delta(1, (2000, 1, 1), (2001, 1, 1))
+        assert_delta(0, (2000, 1, 2), (2001, 1, 1))
+        # Check leap year handling (2004 is an actual leap year)
+        assert_delta(0, (2003, 10, 10), (2004, 2, 29))
+        assert_delta(0, (2004, 2, 29), (2005, 2, 28))
+
+    def getDurationsince(self, delta):
+        """Return the durationsince for a given delta."""
+        creation = datetime(2000, 1, 1, tzinfo=utc)
+        formatter = DateTimeFormatterAPI(creation)
+        formatter._now = lambda: creation + delta
+        return formatter.durationsince()
+
+    def test_durationsince_in_years(self):
+        """Values with different years are measured in years."""
+        self.assertEqual('1 year', self.getDurationsince(timedelta(366)))
+        self.assertEqual('2 years', self.getDurationsince(timedelta(731)))
+
+    def test_durationsince_in_day(self):
+        """Values with different days are measured in days."""
+        self.assertEqual('1 day', self.getDurationsince(timedelta(1)))
+        self.assertEqual('365 days', self.getDurationsince(timedelta(365)))
+
+    def test_durationsince_in_hours(self):
+        """Values with different hours are measured in hours."""
+        self.assertEqual('2 hours', self.getDurationsince(timedelta(0, 7200)))
+        self.assertEqual('1 hour', self.getDurationsince(timedelta(0, 3600)))
+
+    def test_durationsince_in_minutes(self):
+        """Values with different minutes are measured in minutes."""
+        five = self.getDurationsince(timedelta(0, 300))
+        self.assertEqual('5 minutes', five)
+        self.assertEqual('1 minute', self.getDurationsince(timedelta(0, 60)))
+
+    def test_durationsince_in_seconds(self):
+        """Values in seconds are reported as "less than a minute."""
+        self.assertEqual('less than a minute',
+            self.getDurationsince(timedelta(0, 59)))

=== modified file 'lib/lp/bugs/browser/bugtask.py'
--- lib/lp/bugs/browser/bugtask.py	2011-11-03 14:35:04 +0000
+++ lib/lp/bugs/browser/bugtask.py	2011-11-04 18:44:28 +0000
@@ -167,6 +167,7 @@
     )
 from lp.app.browser.stringformatter import FormattersAPI
 from lp.app.browser.tales import (
+    DateTimeFormatterAPI,
     ObjectImageDisplayAPI,
     PersonFormatterAPI,
     )
@@ -2143,25 +2144,41 @@
     @property
     def model(self):
         """Provide flattened data about bugtask for simple templaters."""
+        age = DateTimeFormatterAPI(self.bug.datecreated).durationsince()
+        age += ' old'
+        date_last_updated = self.bug.date_last_message
+        if (date_last_updated is None or
+            self.bug.date_last_updated > date_last_updated):
+            date_last_updated = self.bug.date_last_updated
+        last_updated_formatter = DateTimeFormatterAPI(date_last_updated)
+        last_updated = last_updated_formatter.displaydate()
         badges = getAdapter(self.bugtask, IPathAdapter, 'image').badges()
         target_image = getAdapter(self.target, IPathAdapter, 'image')
         if self.bugtask.milestone is not None:
             milestone_name = self.bugtask.milestone.displayname
         else:
             milestone_name = None
+        assignee = None
+        if self.assignee is not None:
+            assignee = self.assignee.displayname
         return {
-            'importance': self.importance.title,
-            'importance_class': 'importance' + self.importance.name,
-            'status': self.status.title,
-            'status_class': 'status' + self.status.name,
-            'title': self.bug.title,
-            'id': self.bug.id,
+            'age': age,
+            'assignee': assignee,
             'bug_url': canonical_url(self.bugtask),
             'bugtarget': self.bugtargetdisplayname,
             'bugtarget_css': target_image.sprite_css(),
             'bug_heat_html': self.bug_heat_html,
             'badges': badges,
+            'id': self.bug.id,
+            'importance': self.importance.title,
+            'importance_class': 'importance' + self.importance.name,
+            'last_updated': last_updated,
             'milestone_name': milestone_name,
+            'reporter': self.bug.owner.displayname,
+            'status': self.status.title,
+            'status_class': 'status' + self.status.name,
+            'tags': ' '.join(self.bug.tags),
+            'title': self.bug.title,
             }
 
 
@@ -2175,13 +2192,18 @@
         self.request = request
         self.target_context = target_context
         self.field_visibility = {
+            'show_age': False,
+            'show_assignee': False,
             'show_bugtarget': True,
             'show_bug_heat': True,
             'show_id': True,
             'show_importance': True,
+            'show_last_updated': False,
+            'show_milestone_name': False,
+            'show_reporter': False,
             'show_status': True,
+            'show_tags': False,
             'show_title': True,
-            'show_milestone_name': False,
         }
         TableBatchNavigator.__init__(
             self, tasks, request, columns_to_show=columns_to_show, size=size)

=== modified file 'lib/lp/bugs/browser/tests/test_bugtask.py'
--- lib/lp/bugs/browser/tests/test_bugtask.py	2011-11-03 16:36:53 +0000
+++ lib/lp/bugs/browser/tests/test_bugtask.py	2011-11-04 18:44:28 +0000
@@ -4,7 +4,10 @@
 __metaclass__ = type
 
 from contextlib import contextmanager
-from datetime import datetime
+from datetime import (
+    datetime,
+    timedelta,
+    )
 import re
 import urllib
 
@@ -1488,16 +1491,21 @@
         navigator = BugListingBatchNavigator([], request, [], 1)
         cache = IJSONRequestCache(request)
         bugtask = {
-            'id': '3.14159',
-            'title': 'title1',
-            'status': 'status1',
-            'importance': 'importance1',
-            'importance_class': 'importance_class1',
+            'age': 'age1',
+            'assignee': 'assignee1',
             'bugtarget': 'bugtarget1',
             'bugtarget_css': 'bugtarget_css1',
             'bug_heat_html': 'bug_heat_html1',
             'bug_url': 'bug_url1',
-            'milestone_name': 'milestone_name1'
+            'id': '3.14159',
+            'importance': 'importance1',
+            'importance_class': 'importance_class1',
+            'last_updated': 'updated1',
+            'milestone_name': 'milestone_name1',
+            'status': 'status1',
+            'reporter': 'reporter1',
+            'tags': 'tags1',
+            'title': 'title1',
         }
         bugtask.update(navigator.field_visibility)
         cache.objects['mustache_model'] = {
@@ -1563,6 +1571,46 @@
         mustache_model['bugtasks'][0]['show_milestone_name'] = True
         self.assertIn('milestone_name1', navigator.mustache)
 
+    def test_hiding_assignee(self):
+        """Showing milestone name shows the text."""
+        navigator, mustache_model = self.getNavigator()
+        self.assertIn('show_assignee', navigator.field_visibility)
+        self.assertNotIn('Assignee: assignee1', navigator.mustache)
+        mustache_model['bugtasks'][0]['show_assignee'] = True
+        self.assertIn('Assignee: assignee1', navigator.mustache)
+
+    def test_hiding_age(self):
+        """Showing age shows the text."""
+        navigator, mustache_model = self.getNavigator()
+        self.assertIn('show_age', navigator.field_visibility)
+        self.assertNotIn('age1', navigator.mustache)
+        mustache_model['bugtasks'][0]['show_age'] = True
+        self.assertIn('age1', navigator.mustache)
+
+    def test_hiding_tags(self):
+        """Showing tags shows the text."""
+        navigator, mustache_model = self.getNavigator()
+        self.assertIn('show_tags', navigator.field_visibility)
+        self.assertNotIn('tags1', navigator.mustache)
+        mustache_model['bugtasks'][0]['show_tags'] = True
+        self.assertIn('tags1', navigator.mustache)
+
+    def test_hiding_reporter(self):
+        """Showing reporter shows the text."""
+        navigator, mustache_model = self.getNavigator()
+        self.assertIn('show_reporter', navigator.field_visibility)
+        self.assertNotIn('Reporter: reporter1', navigator.mustache)
+        mustache_model['bugtasks'][0]['show_reporter'] = True
+        self.assertIn('Reporter: reporter1', navigator.mustache)
+
+    def test_hiding_last_updated(self):
+        """Showing last_updated shows the text."""
+        navigator, mustache_model = self.getNavigator()
+        self.assertIn('show_last_updated', navigator.field_visibility)
+        self.assertNotIn('last updated updated1', navigator.mustache)
+        mustache_model['bugtasks'][0]['show_last_updated'] = True
+        self.assertIn('last updated updated1', navigator.mustache)
+
 
 class TestBugListingBatchNavigator(TestCaseWithFactory):
 
@@ -1603,3 +1651,52 @@
                 product=item.bugtask.target)
             milestone_name = item.milestone.displayname
             self.assertEqual(milestone_name, item.model['milestone_name'])
+
+    def test_model_assignee(self):
+        """Model contains expected fields with expected values."""
+        owner, item = make_bug_task_listing_item(self.factory)
+        with person_logged_in(owner):
+            self.assertIs(None, item.model['assignee'])
+            assignee = self.factory.makePerson(displayname='Example Person')
+            item.bugtask.transitionToAssignee(assignee)
+            self.assertEqual('Example Person', item.model['assignee'])
+
+    def test_model_age(self):
+        """Model contains bug age."""
+        owner, item = make_bug_task_listing_item(self.factory)
+        with person_logged_in(owner):
+            item.bug.datecreated = datetime.now(UTC) - timedelta(3, 0, 0)
+            self.assertEqual('3 days old', item.model['age'])
+
+    def test_model_tags(self):
+        """Model contains bug tags."""
+        owner, item = make_bug_task_listing_item(self.factory)
+        with person_logged_in(owner):
+            item.bug.tags = ['tag1', 'tag2']
+            self.assertEqual('tag1 tag2', item.model['tags'])
+
+    def test_model_reporter(self):
+        """Model contains bug reporter."""
+        owner, item = make_bug_task_listing_item(self.factory)
+        with person_logged_in(owner):
+            self.assertEqual(owner.displayname, item.model['reporter'])
+
+    def test_model_last_updated_date_last_updated(self):
+        """last_updated uses date_last_updated if newer."""
+        owner, item = make_bug_task_listing_item(self.factory)
+        with person_logged_in(owner):
+            item.bug.date_last_updated = datetime(2001, 1, 1, tzinfo=UTC)
+            removeSecurityProxy(item.bug).date_last_message = datetime(
+                2000, 1, 1, tzinfo=UTC)
+            self.assertEqual(
+                'on 2001-01-01', item.model['last_updated'])
+
+    def test_model_last_updated_date_last_message(self):
+        """last_updated uses date_last_message if newer."""
+        owner, item = make_bug_task_listing_item(self.factory)
+        with person_logged_in(owner):
+            item.bug.date_last_updated = datetime(2000, 1, 1, tzinfo=UTC)
+            removeSecurityProxy(item.bug).date_last_message = datetime(
+                2001, 1, 1, tzinfo=UTC)
+            self.assertEqual(
+                'on 2001-01-01', item.model['last_updated'])

=== modified file 'lib/lp/bugs/templates/buglisting.mustache'
--- lib/lp/bugs/templates/buglisting.mustache	2011-11-03 13:59:45 +0000
+++ lib/lp/bugs/templates/buglisting.mustache	2011-11-04 18:44:28 +0000
@@ -37,6 +37,31 @@
                 </span>
             {{/milestone_name}}
             {{/show_milestone_name}}
+            {{#show_last_updated}}
+                <span class="sprite milestone field">
+                    last updated {{last_updated}}
+                </span>
+            {{/show_last_updated}}
+            {{#show_assignee}}
+            {{#assignee}}
+                <span class="sprite person field">
+                    Assignee: {{assignee}}
+                </span>
+            {{/assignee}}
+            {{/show_assignee}}
+            {{#show_reporter}}
+                <span class="sprite person field">
+                    Reporter: {{reporter}}
+                </span>
+            {{/show_reporter}}
+            {{#show_age}}
+                <span class="sprite milestone field">
+                    {{age}}
+                </span>
+            {{/show_age}}
+            {{#show_tags}}
+                <span class="field">{{tags}}</span>
+            {{/show_tags}}
         </div>
     </div>