launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #05429
[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>