Merge lp:~gmb/launchpad/dont-piss-the-losas-off-bug-596877 into lp:launchpad/db-devel

Proposed by Graham Binns on 2010-07-17
Status: Merged
Merged at revision: 9700
Proposed branch: lp:~gmb/launchpad/dont-piss-the-losas-off-bug-596877
Merge into: lp:launchpad/db-devel
Diff against target: 656 lines (+299/-89)
12 files modified
lib/canonical/launchpad/security.py (+11/-0)
lib/lp/bugs/browser/bugtracker.py (+24/-2)
lib/lp/bugs/browser/tests/test_bugtracker_views.py (+29/-0)
lib/lp/bugs/configure.zcml (+2/-1)
lib/lp/bugs/doc/bugtracker.txt (+10/-60)
lib/lp/bugs/interfaces/bugtracker.py (+5/-4)
lib/lp/bugs/model/bugtracker.py (+13/-8)
lib/lp/bugs/scripts/checkwatches/core.py (+2/-1)
lib/lp/bugs/stories/bugtracker/xx-reschedule-all-watches.txt (+94/-0)
lib/lp/bugs/tests/test_bugtracker.py (+97/-11)
lib/lp/testing/factory.py (+3/-2)
lib/lp/testing/sampledata.py (+9/-0)
To merge this branch: bzr merge lp:~gmb/launchpad/dont-piss-the-losas-off-bug-596877
Reviewer Review Type Date Requested Status
Gavin Panella (community) 2010-07-17 Approve on 2010-08-25
Robert Collins (community) Needs Information on 2010-07-20
Review via email: mp+30186@code.launchpad.net

Commit Message

A button, visible to admins and LP developers, has been added to the BugTracker +edit page to allow all of a tracker's watches to be rescheduled en masse.

Description of the Change

A button to reschedule all watches
==================================

This branch adds a button to the BugTracker +edit page which will
allow a user with sufficient privileges to reschedule all the watches on
a given BugTracker[1]. This is useful in situations where a bug tracker
has not been updated for a long period of time, so all the watches are
out of date. In the past we've done this by asking a LOSA to run some
SQL, but since that's a drain on their time and ours, a button seemed
more useful.

One of the problems associated with a large number of out-of-date
watches, however, is that checkwatches attempts to burn through them all
at once. This can lead to us adding a great deal of load to the remote
server, which is generally a Bad Thing and can get Launchpad blocked
from communicating with the upstream bug tracker (at the very least).

In order to obviate this problem, we have in the past used SQL that
would randomise the next_check time for each bug watch within a range of
up to twenty-four hours. This would then take some of the strain off the
remote server since checkwatches wouldn't be trying to update everything
as quickly as possible. It seemed sensible to use this same SQL when
creating the reschedule button. Note that 24 hours is the optimal
maximum time frame in which to schedule watches; anything longer means
that watches remain unchecked whilst other watches go out of date and
need to be checked again, potentially creating further load issues.

Finally, I cleaned up some lint in this branch, but I'm not wholly
certain that the new linter isn't getting some things wrong about our
coding standards.

Changes made
------------

lib/lp/bugs/browser/bugtracker.py
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

 - I've added an exception, UserCannotResetWatchesError, which is raised
   when a user attempts to all watches on a bug tracker without
   permission.
 - I've added a user_can_reset_watches property to BugTrackerEditView.
   This returns True if the user is either a Launchpad Admin or is a
   member of ~launchpad.
 - I've added a method, resetBugTrackerWatches() to BugTrackerEditView.
   This calls BugTracker.resetWatches(). I've made this a separate
   method so that it could be tested properly in view tests rather than
   putting all the implementation in the body of the action.
 - I've added a reschedule_action_condition() method to
   BugTrackerEditView. This returns true if there are watches to
   reschedule and user_can_reset_watches returns True. This is used to
   determine whether the reschedule button shows up on the +edit page.
 - I've added a rescheduleAction() method / action to
   BugTrackerEditView. This calls resetBugTrackerWatches() and adds an
   info notification to the page stating that all watches have been
   updated. Note that it makes no attempt to catch
   UserCannotResetWatchesErrors, since we want these to be dealt with by
   the OOPS machinery as they are indicative of someone trying to craft
   an HTTP POST to reschedule watches.

lib/lp/bugs/browser/tests/test_bugtracker_views.py
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

 - I've added tests to cover the resetBugTrackerWatches() method of
   BugTrackerEditView. The tests are designed to check that
   resetBugTrackerWatches() calls BugTracker.resetWatches() correctly
   and that it also checks that the user is allowed to reset a bug
   tracker's watches.

lib/lp/bugs/configure.zcml
~~~~~~~~~~~~~~~~~~~~~~~~~~

 - I've updated the ZCML and moved BugTracker.resetWatches() out of the
   launchpad.Admin section and into launchpad.Edit (hence the need for
   stricter checking of permissions in
   BugTrackerEditView.resetBugTrackerWatches()). This move was necessary
   to allow Launchpad Developers to reset watches.

lib/lp/bugs/doc/bugtracker.txt
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

 - I've updated the existing tests for BugTracker.resetWatches() to
   cover the new randomisation-over-24-hours code. I was tempted to
   refactor the whole of bugtracker.txt into unittest format, but
   decided against it for reasons of space and sanity.
 - I've fixed a bunch of lint issues with the doctest.

lib/lp/bugs/model/bugtracker.py
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

 - I've updated BugTracker.resetWatches() so that it now sets next_check
   to a random time between now and one day in the future, rather than
   just setting it to now as it used to.
 - I've also fixed a minor typo.

lib/lp/bugs/stories/bugtracker/xx-reschedule-all-watches.txt
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

 - This pagetest covers the Reschedule all watches button which will now
   appear on the BugTracker +edit page when it is viewed by admins or LP
   developers.

lib/lp/testing/factory.py
~~~~~~~~~~~~~~~~~~~~~~~~~

 - I updated makeBugTracker() so that it now accepts name and title
   arguments.

lib/lp/testing/sampledata.py
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

 - I added constants for ADMIN_EMAIL (foo.bar@) and USER_EMAIL(test@).

Test commands
-------------

`bin/test -cvv -t /bugtracker.txt -t xx-reschedule-all-watches \
          -t test_bugtracker_views`

 [1] http://launchpad.net/bugs/596877

To post a comment you must log in.
Robert Collins (lifeless) wrote :

Shouldn't the permission check be in the model? the LBYP idiom seems likely to miss things...

review: Needs Information
Robert Collins (lifeless) wrote :

Also as discussed there are no particularly special permission issues here so raising the normal permission denied stuff should be all thats needed.

review: Needs Fixing
Graham Binns (gmb) wrote :

I've updated the branch per our discussions. Here comes the diff:

=== modified file 'lib/lp/bugs/browser/bugtracker.py'
--- lib/lp/bugs/browser/bugtracker.py 2010-07-17 19:19:16 +0000
+++ lib/lp/bugs/browser/bugtracker.py 2010-07-20 16:17:59 +0000
@@ -27,6 +27,7 @@
 from zope.formlib import form
 from zope.schema import Choice
 from zope.schema.vocabulary import SimpleVocabulary
+from zope.security.interfaces import Unauthorized

 from canonical.cachedproperty import cachedproperty
 from canonical.database.sqlbase import flush_database_updates
@@ -63,10 +64,6 @@
     )

-class UserCannotResetWatchesError(Exception):
- """Raised when a user cannot reset a bug tracker's watches."""
-
-
 class BugTrackerSetNavigation(GetitemNavigation):

     usedfor = IBugTrackerSet
@@ -387,11 +384,13 @@
     def resetBugTrackerWatches(self):
         """Call the resetWatches() method of the current context.

- :raises `UserCannotResetWatchesError` if the user isn't an admin
+ :raises `Unauthorized` if the user isn't an admin
                 or a member of the Launchpad Developers team.
         """
         if not self.user_can_reset_watches:
- raise UserCannotResetWatchesError
+ raise Unauthorized(
+ "You don't have permission to reset all the watches for "
+ "this bug tracker.")

         self.context.resetWatches()

=== modified file 'lib/lp/bugs/browser/tests/test_bugtracker_views.py'
--- lib/lp/bugs/browser/tests/test_bugtracker_views.py 2010-07-17 16:59:12 +0000
+++ lib/lp/bugs/browser/tests/test_bugtracker_views.py 2010-07-20 16:17:59 +0000
@@ -11,12 +11,13 @@
 from pytz import utc

 from zope.component import getUtility
+from zope.security.interfaces import Unauthorized

 from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
 from canonical.testing.layers import DatabaseFunctionalLayer

 from lp.bugs.browser.bugtracker import (
- BugTrackerEditView, UserCannotResetWatchesError)
+ BugTrackerEditView)
 from lp.registry.interfaces.person import IPersonSet
 from lp.testing import login, TestCaseWithFactory
 from lp.testing.sampledata import ADMIN_EMAIL, NO_PRIVILEGE_EMAIL
@@ -70,7 +71,7 @@
         login(NO_PRIVILEGE_EMAIL)
         view = create_initialized_view(self.bug_tracker, '+edit')
         self.assertRaises(
- UserCannotResetWatchesError, view.resetBugTrackerWatches)
+ Unauthorized, view.resetBugTrackerWatches)

     def test_admin_can_reset_watches(self):
         # Launchpad admins can reset the watches on a bugtracker.

Robert Collins (lifeless) wrote :

Thanks for improving it.

review: Approve
Robert Collins (lifeless) wrote :

Sorry, I missed that its still in the view. Shouldn't it be in the model?

review: Needs Information
Graham Binns (gmb) wrote :
Download full text (12.8 KiB)

It should, and I've moved it there (for some reason I had it in my head that we should always check stuff in the view, but that turns out not to be true).

Here's a diff:

=== modified file 'lib/lp/bugs/browser/bugtracker.py'
--- lib/lp/bugs/browser/bugtracker.py 2010-07-20 16:17:59 +0000
+++ lib/lp/bugs/browser/bugtracker.py 2010-07-23 11:11:13 +0000
@@ -374,38 +374,22 @@
     def cancel_url(self):
         return canonical_url(self.context)

- @property
- def user_can_reset_watches(self):
+ def reschedule_action_condition(self, action):
+ """Return True if the user can see the reschedule action."""
         lp_developers = getUtility(ILaunchpadCelebrities).launchpad_developers
- return (
+ user_can_reset_watches = (
             check_permission("launchpad.Admin", self.user) or
             self.user.inTeam(lp_developers))
-
- def resetBugTrackerWatches(self):
- """Call the resetWatches() method of the current context.
-
- :raises `Unauthorized` if the user isn't an admin
- or a member of the Launchpad Developers team.
- """
- if not self.user_can_reset_watches:
- raise Unauthorized(
- "You don't have permission to reset all the watches for "
- "this bug tracker.")
-
- self.context.resetWatches()
-
- def reschedule_action_condition(self, action):
- """Return True if the user can see the reschedule action."""
         return (
             self.context.watches.count() > 0 and
- self.user_can_reset_watches)
+ user_can_reset_watches)

     @action(
         'Reschedule all watches', name='reschedule',
         condition=reschedule_action_condition)
     def rescheduleAction(self, action, data):
         """Reschedule all the watches for the bugtracker."""
- self.resetBugTrackerWatches()
+ self.context.resetWatches(user=self.user)
         self.request.response.addInfoNotification(
             structured(
                 "All bug watches on %s have been rescheduled." %

=== modified file 'lib/lp/bugs/browser/tests/test_bugtracker_views.py'
--- lib/lp/bugs/browser/tests/test_bugtracker_views.py 2010-07-20 16:17:59 +0000
+++ lib/lp/bugs/browser/tests/test_bugtracker_views.py 2010-07-23 11:11:13 +0000
@@ -24,77 +24,6 @@
 from lp.testing.views import create_initialized_view

-class BugTrackerEditViewTestCase(TestCaseWithFactory):
- """A TestCase for the `BugTrackerEditView`."""
-
- layer = DatabaseFunctionalLayer
-
- def setUp(self):
- super(BugTrackerEditViewTestCase, self).setUp()
- self.bug_tracker = self.factory.makeBugTracker()
- for i in range(5):
- self.factory.makeBugWatch(bugtracker=self.bug_tracker)
-
- self.now = datetime.now(utc)
-
- def _assertBugWatchesAreCheckedInTheFuture(self):
- """Check the dates of all self.bug_tracker.watches.
-
- Raise an error if:
- * The next_check dates aren't in the future.
- * The next_check dates aren't <= 1 day in the future.
- * The lastcheck dates are not None
- * The last_error_types are not None.
- """
- for watch i...

Gavin Panella (allenap) wrote :

This branch is a really cool addition. I think it can be simplified by
leaning more heavily on Zope security, and there are a few other
things to consider.

I wrote the review notes in the Landscape style before I remembered
that this isn't Landscape. However, I think it's quite readable,
especially since responses need only reference the number given to
each comment, so I'll leave it. I hope you don't mind. Let me know
what you think.

[1]

+ user_can_reset_watches = (
+ check_permission("launchpad.Admin", self.user) or
+ self.user.inTeam(lp_developers))

This would perhaps be better placed on the model, something like
canResetWatches(user).

[2]

+ check_permission("launchpad.Admin", self.user) or

I may well be wrong, but this looks like it checks if the current user
has the launchpad.Admin permission on self.user, which will always be
true.

I think it might be worth defining a new AdminBugTracker authorization
adapter in canonical.launchpad.security, something like:

class AdminBugTracker(AuthorizationBase):
    permission = 'launchpad.Admin'
    usedfor = IBugTracker

    def checkAuthenticated(self, user):
        return (user.in_admin or
                user.in_launchpad_developers or
                user.in_janitor)

Then the changes to lib/lp/bugs/configure.zcml can be reverted.

[3]

=== added file 'lib/lp/bugs/browser/tests/test_bugtracker_views.py'

There are no tests in this file! :)

[4]

+ :param new_next_check: If specified, contains the datetime to
+ which to set the BugWatches' next_check times.
+ If not specified, the watches' next_check
+ times will be set to a point between now
+ and 24 hours hence.

I /think/ it's usual to indent the second and subsequent lines by 4
spaces. Doesn't matter much though.

[5]

+ def resetWatches(self, user, new_next_check=None):
         """See `IBugTracker`."""
- if now is None:
- now = datetime.now(timezone('UTC'))
+ celebrities = getUtility(ILaunchpadCelebrities)
+ launchpad_developers = celebrities.launchpad_developers
+ admin_team = celebrities.admin
+ janitor = celebrities.janitor
+
+ if (not user.inTeam(admin_team) and
+ not user.inTeam(launchpad_developers) and
+ user != janitor):
+ raise Unauthorized(
+ "You do not have permission to reset the watches for "
+ "this bug tracker.")

If you add the security adapter in [2] then the user checking stuff
here can be dropped.

[6]

+ self.request.response.addInfoNotification(
+ structured(
+ "All bug watches on %s have been rescheduled." %
+ self.context.title))

I don't think this should be a structured() string; that would allow
markup/script injection via the bug tracker title.

review: Needs Fixing
Graham Binns (gmb) wrote :
Download full text (5.6 KiB)

Diff of changes:

=== modified file 'lib/canonical/launchpad/security.py'
--- lib/canonical/launchpad/security.py 2010-08-22 18:31:30 +0000
+++ lib/canonical/launchpad/security.py 2010-08-25 15:59:28 +0000
@@ -2561,3 +2561,14 @@
         if parent is None:
             return False
         return check_permission(self.permission, parent)
+
+
+class AdminBugTracker(AuthorizationBase):
+ permission = 'launchpad.Admin'
+ usedfor = IBugTracker
+
+ def checkAuthenticated(self, user):
+ return (
+ user.in_janitor or
+ user.in_admin or
+ user.in_launchpad_developers)

=== modified file 'lib/lp/bugs/browser/bugtracker.py'
--- lib/lp/bugs/browser/bugtracker.py 2010-08-25 10:45:59 +0000
+++ lib/lp/bugs/browser/bugtracker.py 2010-08-25 15:59:28 +0000
@@ -397,10 +397,8 @@

     def reschedule_action_condition(self, action):
         """Return True if the user can see the reschedule action."""
- lp_developers = getUtility(ILaunchpadCelebrities).launchpad_developers
- user_can_reset_watches = (
- check_permission("launchpad.Admin", self.user) or
- self.user.inTeam(lp_developers))
+ user_can_reset_watches = check_permission(
+ "launchpad.Admin", self.context)
         return (
             self.context.watches.count() > 0 and
             user_can_reset_watches)

=== modified file 'lib/lp/bugs/configure.zcml'
--- lib/lp/bugs/configure.zcml 2010-08-25 10:45:59 +0000
+++ lib/lp/bugs/configure.zcml 2010-08-25 15:59:28 +0000
@@ -394,7 +394,7 @@
                     destroySelf
                     ensurePersonForSelf
                     linkPersonToSelf
- resetWatches"
+ "
                 set_attributes="
                     aliases
                     baseurl
@@ -408,6 +408,7 @@
                     "/>
             <require
                 permission="launchpad.Admin"
+ attributes="resetWatches"
                 set_attributes="active"/>
         </class>
         <adapter

=== modified file 'lib/lp/bugs/interfaces/bugtracker.py'
--- lib/lp/bugs/interfaces/bugtracker.py 2010-08-25 10:45:59 +0000
+++ lib/lp/bugs/interfaces/bugtracker.py 2010-08-25 16:03:56 +0000
@@ -344,14 +344,13 @@
     def destroySelf():
         """Delete this bug tracker."""

- def resetWatches(user, new_next_check=None):
+ def resetWatches(new_next_check=None):
         """Reset the next_check times of this BugTracker's `BugWatch`es.

         :param new_next_check: If specified, contains the datetime to
- which to set the BugWatches' next_check times.
- If not specified, the watches' next_check
- times will be set to a point between now
- and 24 hours hence.
+ which to set the BugWatches' next_check times. If not
+ specified, the watches' next_check times will be set to a
+ point between now and 24 hours hence.
         """

=== modified file 'lib/lp/bugs/model/bugtracker.py'
--- lib/lp/bugs/model/bugtracker.py 2010-08-25 10:45:59 +0000
+++ lib/lp/bugs/model/bugt...

Read more...

Gavin Panella (allenap) wrote :

I think [6] still needs to be addressed, but the rest looks good. I do
have a couple of comments:

[7]

         return (
             self.context.watches.count() > 0 and
             user_can_reset_watches)

The order here could be reversed; user_can_reset_watches is already
calculated so it may as well short circuit on it and avoid a query if
possible:

         return (
             user_can_reset_watches and
             self.context.watches.count() > 0)

Also it would be more efficient to use is_empty():

         return (
             user_can_reset_watches and
             not self.context.watches.is_empty())

[8]

+ user_can_reset_watches = check_permission(
+ "launchpad.Admin", self.context)

This works fine, and is nothing controversial, but if, say,
resetWatches is given a new permission then it will no longer check
the right thing. Would something like the following work better?

    try:
        self.context.resetWatches
    except Unauthorized:
        return False
    else:
        return not self.context.watches.is_empty()

Gavin Panella (allenap) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/security.py'
2--- lib/canonical/launchpad/security.py 2010-08-24 09:51:26 +0000
3+++ lib/canonical/launchpad/security.py 2010-08-26 10:08:48 +0000
4@@ -2561,3 +2561,14 @@
5 if parent is None:
6 return False
7 return check_permission(self.permission, parent)
8+
9+
10+class AdminBugTracker(AuthorizationBase):
11+ permission = 'launchpad.Admin'
12+ usedfor = IBugTracker
13+
14+ def checkAuthenticated(self, user):
15+ return (
16+ user.in_janitor or
17+ user.in_admin or
18+ user.in_launchpad_developers)
19
20=== modified file 'lib/lp/bugs/browser/bugtracker.py'
21--- lib/lp/bugs/browser/bugtracker.py 2010-08-20 20:31:18 +0000
22+++ lib/lp/bugs/browser/bugtracker.py 2010-08-26 10:08:48 +0000
23@@ -27,6 +27,7 @@
24 from zope.interface import implements
25 from zope.schema import Choice
26 from zope.schema.vocabulary import SimpleVocabulary
27+from zope.security.interfaces import Unauthorized
28
29 from canonical.cachedproperty import cachedproperty
30 from canonical.database.sqlbase import flush_database_updates
31@@ -79,7 +80,9 @@
32 # of.
33 NO_DIRECT_CREATION_TRACKERS = (
34 SINGLE_INSTANCE_TRACKERS + (
35- BugTrackerType.EMAILADDRESS,))
36+ BugTrackerType.EMAILADDRESS,
37+ )
38+ )
39
40
41 class BugTrackerSetNavigation(GetitemNavigation):
42@@ -185,7 +188,7 @@
43 has_more_pillars = False
44 return {
45 'pillars': pillars[:self.pillar_limit],
46- 'has_more_pillars': has_more_pillars
47+ 'has_more_pillars': has_more_pillars,
48 }
49
50
51@@ -392,6 +395,25 @@
52 def cancel_url(self):
53 return canonical_url(self.context)
54
55+ def reschedule_action_condition(self, action):
56+ """Return True if the user can see the reschedule action."""
57+ user_can_reset_watches = check_permission(
58+ "launchpad.Admin", self.context)
59+ return (
60+ user_can_reset_watches and
61+ self.context.watches.count() > 0)
62+
63+ @action(
64+ 'Reschedule all watches', name='reschedule',
65+ condition=reschedule_action_condition)
66+ def rescheduleAction(self, action, data):
67+ """Reschedule all the watches for the bugtracker."""
68+ self.context.resetWatches()
69+ self.request.response.addInfoNotification(
70+ "All bug watches on %s have been rescheduled." %
71+ self.context.title)
72+ self.next_url = canonical_url(self.context)
73+
74
75 class BugTrackerNavigation(Navigation):
76
77
78=== added file 'lib/lp/bugs/browser/tests/test_bugtracker_views.py'
79--- lib/lp/bugs/browser/tests/test_bugtracker_views.py 1970-01-01 00:00:00 +0000
80+++ lib/lp/bugs/browser/tests/test_bugtracker_views.py 2010-08-26 10:08:48 +0000
81@@ -0,0 +1,29 @@
82+# Copyright 2010 Canonical Ltd. This software is licensed under the
83+# GNU Affero General Public License version 3 (see the file LICENSE).
84+
85+"""Tests for BugTracker views."""
86+
87+__metaclass__ = type
88+
89+import unittest
90+
91+from datetime import datetime, timedelta
92+from pytz import utc
93+
94+from zope.component import getUtility
95+from zope.security.interfaces import Unauthorized
96+
97+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
98+from canonical.testing.layers import DatabaseFunctionalLayer
99+
100+from lp.bugs.browser.bugtracker import (
101+ BugTrackerEditView)
102+from lp.registry.interfaces.person import IPersonSet
103+from lp.testing import login, TestCaseWithFactory
104+from lp.testing.sampledata import ADMIN_EMAIL, NO_PRIVILEGE_EMAIL
105+from lp.testing.views import create_initialized_view
106+
107+
108+
109+def test_suite():
110+ return unittest.TestLoader().loadTestsFromName(__name__)
111
112=== modified file 'lib/lp/bugs/configure.zcml'
113--- lib/lp/bugs/configure.zcml 2010-08-23 09:18:51 +0000
114+++ lib/lp/bugs/configure.zcml 2010-08-26 10:08:48 +0000
115@@ -390,7 +390,8 @@
116 attributes="
117 destroySelf
118 ensurePersonForSelf
119- linkPersonToSelf"
120+ linkPersonToSelf
121+ "
122 set_attributes="
123 aliases
124 baseurl
125
126=== modified file 'lib/lp/bugs/doc/bugtracker.txt'
127--- lib/lp/bugs/doc/bugtracker.txt 2010-08-09 13:59:58 +0000
128+++ lib/lp/bugs/doc/bugtracker.txt 2010-08-26 10:08:48 +0000
129@@ -203,7 +203,8 @@
130 True
131
132
133-=== Aliases ===
134+Aliases
135+-------
136
137 A bug tracker can have a number of alias URLs associated with it.
138
139@@ -293,7 +294,8 @@
140 >>> mozilla_bugzilla.baseurl = 'https://bugzilla.mozilla.org/'
141
142
143-== Pillars for bugtrackers ===
144+Pillars for bugtrackers
145+-----------------------
146
147 >>> trackers = list(bugtracker_set)
148 >>> pillars = bugtracker_set.getPillarsForBugtrackers(trackers)
149@@ -473,7 +475,7 @@
150 >>> links = example_mantis.getBugFilingAndSearchLinks(
151 ... remote_product='testproduct', summary="Foo", description="Bar")
152 >>> print_links(links)
153- bug_filing_url: .../bug_report_advanced_page.php?summary=Foo&description=Bar
154+ bug_filing_url: .../bug_..._advanced_page.php?summary=Foo&description=Bar
155 bug_search_url: .../view_all_bug_page.php
156
157 The EMAILADDRESS BugTrackerType is a special case and returns None for
158@@ -530,16 +532,16 @@
159 ------------------------
160
161 As described above, some bug trackers don't need to have a remote
162-product passed to `getBugFilingAndSearchLinks()` in order to be able to return a
163-bug filing URL because they use static URLs for bug filing or only track
164-one product.
165+product passed to `getBugFilingAndSearchLinks()` in order to be able to
166+return a bug filing URL because they use static URLs for bug filing or
167+only track one product.
168
169 `IBugTracker` defines an attribute, `multi_product` which can be used to
170 check whether a given bug tracker can return a bug filing URL without
171 being passed a remote product.
172
173-Our example Trac bug tracker's `multi_product` property will
174-be False, since it only tracks one product at a time.
175+Our example Trac bug tracker's `multi_product` property will be False,
176+since it only tracks one product at a time.
177
178 >>> print example_trac.multi_product
179 False
180@@ -586,55 +588,3 @@
181 bug_search_url:
182 http://.../query.cgi?product=testproduct&short_desc=Foo
183
184-
185-Resetting a tracker's watches
186------------------------------
187-
188-It's possible to reset all the watches on a bug tracker by calling its
189-resetWatches() method.
190-
191- >>> from operator import attrgetter
192- >>> from pytz import utc
193- >>> definitely_now = datetime.now(utc)
194-
195- >>> def print_tracker_watch_times(bugtracker):
196- ... for watch in sorted(
197- ... bugtracker.watches, key=attrgetter('id')):
198- ... if watch.next_check != definitely_now:
199- ... print watch.bug.id, "not now"
200- ... else:
201- ... print watch.bug.id, "now"
202-
203- >>> print_tracker_watch_times(mozilla_bugzilla)
204- 2 not now
205- 1 not now
206- 1 not now
207- 1 not now
208-
209-Calling resetWatches() on mozilla_bugzilla will set all its next_check
210-times to datetime.now(). For testing purposes, we'll pass it a value to
211-use in place of now().
212-
213- >>> mozilla_bugzilla.resetWatches(now=definitely_now)
214- >>> transaction.commit()
215- >>> print_tracker_watch_times(mozilla_bugzilla)
216- 2 now
217- 1 now
218- 1 now
219- 1 now
220-
221-resetWatches() also clears the last_error_type field of the watches.
222-
223- >>> from lp.bugs.interfaces.bugwatch import BugWatchActivityStatus
224- >>> for watch in mozilla_bugzilla.watches:
225- ... watch.last_error_type = BugWatchActivityStatus.UNKNOWN
226-
227- >>> txn = transaction.begin()
228- >>> mozilla_bugzilla.resetWatches()
229- >>> txn.commit()
230- >>> for watch in mozilla_bugzilla.watches:
231- ... print watch.last_error_type
232- None
233- None
234- None
235- None
236
237=== modified file 'lib/lp/bugs/interfaces/bugtracker.py'
238--- lib/lp/bugs/interfaces/bugtracker.py 2010-08-20 20:31:18 +0000
239+++ lib/lp/bugs/interfaces/bugtracker.py 2010-08-26 10:08:48 +0000
240@@ -344,12 +344,13 @@
241 def destroySelf():
242 """Delete this bug tracker."""
243
244- def resetWatches(now=None):
245+ def resetWatches(new_next_check=None):
246 """Reset the next_check times of this BugTracker's `BugWatch`es.
247
248- :param now: If specified, contains the datetime to which to set
249- the BugWatches' next_check times. Defaults to
250- datetime.now().
251+ :param new_next_check: If specified, contains the datetime to
252+ which to set the BugWatches' next_check times. If not
253+ specified, the watches' next_check times will be set to a
254+ point between now and 24 hours hence.
255 """
256
257
258
259=== modified file 'lib/lp/bugs/model/bugtracker.py'
260--- lib/lp/bugs/model/bugtracker.py 2010-08-20 20:31:18 +0000
261+++ lib/lp/bugs/model/bugtracker.py 2010-08-26 10:08:48 +0000
262@@ -21,6 +21,10 @@
263 splittype,
264 )
265
266+from zope.component import getUtility
267+from zope.interface import implements
268+from zope.security.interfaces import Unauthorized
269+
270 from lazr.uri import URI
271 from pytz import timezone
272 from sqlobject import (
273@@ -36,6 +40,7 @@
274 Count,
275 Desc,
276 Not,
277+ SQL,
278 )
279 from storm.locals import Bool
280 from storm.store import Store
281@@ -46,7 +51,6 @@
282 from canonical.database.sqlbase import (
283 flush_database_updates,
284 SQLBase,
285- sqlvalues,
286 )
287 from canonical.launchpad.helpers import shortlist
288 from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
289@@ -500,19 +504,20 @@
290
291 return person
292
293- def resetWatches(self, now=None):
294+ def resetWatches(self, new_next_check=None):
295 """See `IBugTracker`."""
296- if now is None:
297- now = datetime.now(timezone('UTC'))
298+ if new_next_check is None:
299+ new_next_check = SQL(
300+ "now() at time zone 'UTC' + (random() * interval '1 day')")
301
302 store = Store.of(self)
303- store.execute(
304- "UPDATE BugWatch SET next_check = %s WHERE bugtracker = %s" %
305- sqlvalues(now, self))
306+ store.find(BugWatch, BugWatch.bugtracker == self).set(
307+ next_check=new_next_check, lastchecked=None,
308+ last_error_type=None)
309
310
311 class BugTrackerSet:
312- """Implements IBugTrackerSet for a container or set of BugTracker's,
313+ """Implements IBugTrackerSet for a container or set of BugTrackers,
314 either the full set in the db, or a subset.
315 """
316
317
318=== modified file 'lib/lp/bugs/scripts/checkwatches/core.py'
319--- lib/lp/bugs/scripts/checkwatches/core.py 2010-08-20 20:31:18 +0000
320+++ lib/lp/bugs/scripts/checkwatches/core.py 2010-08-26 10:08:48 +0000
321@@ -301,7 +301,8 @@
322 self.logger.info(
323 "Resetting %s bug watches for bug tracker '%s'" %
324 (bug_tracker.watches.count(), bug_tracker_name))
325- bug_tracker.resetWatches()
326+ bug_tracker.resetWatches(
327+ new_next_check=datetime.now(pytz.timezone('UTC')))
328
329 # Loop over the bug watches in batches as specificed by
330 # batch_size until there are none left to update.
331
332=== added file 'lib/lp/bugs/stories/bugtracker/xx-reschedule-all-watches.txt'
333--- lib/lp/bugs/stories/bugtracker/xx-reschedule-all-watches.txt 1970-01-01 00:00:00 +0000
334+++ lib/lp/bugs/stories/bugtracker/xx-reschedule-all-watches.txt 2010-08-26 10:08:48 +0000
335@@ -0,0 +1,94 @@
336+Rescheduling watches on a bugtracker
337+====================================
338+
339+It's possible for all the watches on a bug tracker to be rescheduled, in
340+much the same way as it's possible to reschedule a single bug watch that
341+is failing to update.
342+
343+ >>> from canonical.launchpad.webapp import canonical_url
344+ >>> from lp.testing.sampledata import ADMIN_EMAIL
345+ >>> from lp.testing import login, logout
346+
347+ >>> login(ADMIN_EMAIL)
348+ >>> bug_tracker = factory.makeBugTracker(
349+ ... name='our-bugtracker', title='Our BugTracker')
350+ >>> bug_watch = factory.makeBugWatch(bugtracker=bug_tracker)
351+ >>> logout()
352+
353+ >>> bug_tracker_edit_url = (
354+ ... canonical_url(bug_tracker) + '/+edit')
355+
356+The functionality to do this is available from the bug tracker's +edit
357+page. It isn't visible to ordinary users, however.
358+
359+ >>> user_browser.open(bug_tracker_edit_url)
360+ >>> user_browser.getControl('Reschedule all watches')
361+ Traceback (most recent call last):
362+ ...
363+ LookupError: label 'Reschedule all watches'
364+
365+However, the reschedule button will appear to administrators.
366+
367+ >>> admin_browser.open(bug_tracker_edit_url)
368+ >>> admin_browser.getControl('Reschedule all watches')
369+ <SubmitControl...>
370+
371+It will also appear for non-admin members of the Launchpad Developers
372+team.
373+
374+ >>> from zope.component import getUtility
375+ >>> from canonical.launchpad.interfaces.launchpad import (
376+ ... ILaunchpadCelebrities)
377+ >>> from lp.registry.interfaces.person import IPersonSet
378+
379+ >>> login(ADMIN_EMAIL)
380+ >>> admin_user = getUtility(IPersonSet).getByEmail(ADMIN_EMAIL)
381+ >>> new_lp_developer = factory.makePerson(password='newpassword')
382+ >>> launchpad_developers = getUtility(
383+ ... ILaunchpadCelebrities).launchpad_developers
384+ >>> dev_added = launchpad_developers.addMember(
385+ ... new_lp_developer, admin_user)
386+
387+ >>> lp_dev_browser = setupBrowser(
388+ ... auth='Basic %s:newpassword' %
389+ ... new_lp_developer.preferredemail.email)
390+ >>> logout()
391+
392+ >>> lp_dev_browser.open(bug_tracker_edit_url)
393+ >>> reschedule_button = lp_dev_browser.getControl(
394+ ... 'Reschedule all watches')
395+
396+Clicking the button will reschedule the watches for the bug tracker for
397+checking at some future date.
398+
399+ >>> reschedule_button.click()
400+ >>> print lp_dev_browser.url
401+ http://bugs.launchpad.dev/bugs/bugtrackers/our-bugtracker
402+
403+ >>> for message in find_tags_by_class(
404+ ... lp_dev_browser.contents, 'informational message'):
405+ ... print extract_text(message)
406+ All bug watches on Our BugTracker have been rescheduled.
407+
408+If we look at the bug watch on our bugtracker we can see that it has
409+been scheduled for checking at some point in the future.
410+
411+ >>> from datetime import datetime
412+ >>> from pytz import utc
413+
414+ >>> login(ADMIN_EMAIL)
415+ >>> print bug_watch.next_check >= datetime.now(utc)
416+ True
417+
418+Should the bug watch be deleted the reschedule button will no longer
419+appear on the bugtracker page.
420+
421+ >>> bug_watch.destroySelf()
422+ >>> logout()
423+
424+ >>> lp_dev_browser.open(bug_tracker_edit_url)
425+ >>> reschedule_button = lp_dev_browser.getControl(
426+ ... 'Reschedule all watches')
427+ Traceback (most recent call last):
428+ ...
429+ LookupError: label 'Reschedule all watches'
430
431=== modified file 'lib/lp/bugs/tests/test_bugtracker.py'
432--- lib/lp/bugs/tests/test_bugtracker.py 2010-08-20 20:31:18 +0000
433+++ lib/lp/bugs/tests/test_bugtracker.py 2010-08-26 10:08:48 +0000
434@@ -3,6 +3,8 @@
435
436 __metaclass__ = type
437
438+import unittest
439+import transaction
440 from datetime import (
441 datetime,
442 timedelta,
443@@ -12,7 +14,6 @@
444 ELLIPSIS,
445 NORMALIZE_WHITESPACE,
446 )
447-import unittest
448 from urllib2 import (
449 HTTPError,
450 Request,
451@@ -20,10 +21,13 @@
452
453 from lazr.lifecycle.snapshot import Snapshot
454 from pytz import utc
455-import transaction
456+
457+from zope.component import getUtility
458+from zope.security.interfaces import Unauthorized
459 from zope.security.proxy import removeSecurityProxy
460
461 from canonical.launchpad.ftests import login_person
462+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
463 from canonical.testing import LaunchpadFunctionalLayer
464 from lp.bugs.externalbugtracker import (
465 BugTrackerConnectError,
466@@ -35,15 +39,23 @@
467 IBugTracker,
468 )
469 from lp.bugs.tests.externalbugtracker import Urlib2TransportTestHandler
470-from lp.testing import TestCaseWithFactory
471-
472-
473-class TestBugTracker(TestCaseWithFactory):
474+from lp.registry.interfaces.person import IPersonSet
475+from lp.testing import login, login_person, TestCaseWithFactory
476+from lp.testing.sampledata import ADMIN_EMAIL
477+
478+
479+class BugTrackerTestCase(TestCaseWithFactory):
480+ """Unit tests for the `BugTracker` class."""
481+
482 layer = LaunchpadFunctionalLayer
483
484 def setUp(self):
485- super(TestBugTracker, self).setUp()
486- transaction.commit()
487+ super(BugTrackerTestCase, self).setUp()
488+ self.bug_tracker = self.factory.makeBugTracker()
489+ for i in range(5):
490+ self.factory.makeBugWatch(bugtracker=self.bug_tracker)
491+
492+ self.now = datetime.now(utc)
493
494 def test_multi_product_constraints_observed(self):
495 """BugTrackers for which multi_product=True should return None
496@@ -141,6 +153,82 @@
497 removeSecurityProxy(bug_message).remote_comment_id = 'brains'
498 self.failUnless(bug_tracker.watches_with_unpushed_comments.is_empty())
499
500+ def _assertBugWatchesAreCheckedInTheFuture(self):
501+ """Check the dates of all self.bug_tracker.watches.
502+
503+ Raise an error if:
504+ * The next_check dates aren't in the future.
505+ * The next_check dates aren't <= 1 day in the future.
506+ * The lastcheck dates are not None
507+ * The last_error_types are not None.
508+ """
509+ for watch in self.bug_tracker.watches:
510+ self.assertTrue(
511+ watch.next_check is not None,
512+ "BugWatch next_check time should not be None.")
513+ self.assertTrue(
514+ watch.next_check >= self.now,
515+ "BugWatch next_check time should be in the future.")
516+ self.assertTrue(
517+ watch.next_check <= self.now + timedelta(days=1),
518+ "BugWatch next_check time should be one day or less in "
519+ "the future.")
520+ self.assertTrue(
521+ watch.lastchecked is None,
522+ "BugWatch lastchecked should be None.")
523+ self.assertTrue(
524+ watch.last_error_type is None,
525+ "BugWatch last_error_type should be None.")
526+
527+ def test_unprivileged_user_cant_reset_watches(self):
528+ # It isn't possible for a user who isn't an admin or a member of
529+ # the Launchpad Developers team to reset the watches for a bug
530+ # tracker.
531+ unprivileged_user = self.factory.makePerson()
532+ login_person(unprivileged_user)
533+ self.assertRaises(
534+ Unauthorized, getattr, self.bug_tracker, 'resetWatches',
535+ "Unprivileged users should not be allowed to reset a "
536+ "tracker's watches.")
537+
538+ def test_admin_can_reset_watches(self):
539+ # Launchpad admins can reset the watches on a bugtracker.
540+ admin_user = getUtility(IPersonSet).getByEmail(ADMIN_EMAIL)
541+ login_person(admin_user)
542+ self.bug_tracker.resetWatches()
543+ self._assertBugWatchesAreCheckedInTheFuture()
544+
545+ def test_lp_dev_can_reset_watches(self):
546+ # Launchpad developers can reset the watches on a bugtracker.
547+ login(ADMIN_EMAIL)
548+ admin = getUtility(IPersonSet).getByEmail(ADMIN_EMAIL)
549+ launchpad_developers = getUtility(
550+ ILaunchpadCelebrities).launchpad_developers
551+ lp_dev = self.factory.makePerson()
552+ launchpad_developers.addMember(lp_dev, admin)
553+ login_person(lp_dev)
554+ self.bug_tracker.resetWatches()
555+ self._assertBugWatchesAreCheckedInTheFuture()
556+
557+ def test_janitor_can_reset_watches(self):
558+ # The Janitor can reset the watches on a bug tracker.
559+ janitor = getUtility(ILaunchpadCelebrities).janitor
560+ login_person(janitor)
561+ self.bug_tracker.resetWatches()
562+ self._assertBugWatchesAreCheckedInTheFuture()
563+
564+
565+class TestMantis(TestCaseWithFactory):
566+ """Tests for the Mantis-specific bug tracker code."""
567+
568+ layer = LaunchpadFunctionalLayer
569+
570+ def setUp(self):
571+ super(TestMantis, self).setUp()
572+ # We need to commit to avoid there being errors from the
573+ # checkwatches isolation protection code.
574+ transaction.commit()
575+
576 def test_mantis_login_redirects(self):
577 # The Mantis bug tracker needs a special HTTP redirect handler
578 # in order to login in. Ensure that redirects to the page with
579@@ -230,11 +318,9 @@
580
581
582 def test_suite():
583- suite = unittest.TestSuite()
584+ suite = unittest.TestLoader().loadTestsFromName(__name__)
585 doctest_suite = DocTestSuite(
586 'lp.bugs.model.bugtracker',
587 optionflags=NORMALIZE_WHITESPACE|ELLIPSIS)
588-
589- suite.addTest(unittest.makeSuite(TestBugTracker))
590 suite.addTest(doctest_suite)
591 return suite
592
593=== modified file 'lib/lp/testing/factory.py'
594--- lib/lp/testing/factory.py 2010-08-23 08:12:39 +0000
595+++ lib/lp/testing/factory.py 2010-08-26 10:08:48 +0000
596@@ -1310,7 +1310,8 @@
597
598 return removeSecurityProxy(bug).addTask(owner, target)
599
600- def makeBugTracker(self, base_url=None, bugtrackertype=None):
601+ def makeBugTracker(self, base_url=None, bugtrackertype=None, title=None,
602+ name=None):
603 """Make a new bug tracker."""
604 owner = self.makePerson()
605
606@@ -1320,7 +1321,7 @@
607 bugtrackertype = BugTrackerType.BUGZILLA
608
609 return getUtility(IBugTrackerSet).ensureBugTracker(
610- base_url, owner, bugtrackertype)
611+ base_url, owner, bugtrackertype, title=title, name=name)
612
613 def makeBugWatch(self, remote_bug=None, bugtracker=None, bug=None,
614 owner=None, bug_task=None):
615
616=== modified file 'lib/lp/testing/sampledata.py'
617--- lib/lp/testing/sampledata.py 2010-08-26 06:45:38 +0000
618+++ lib/lp/testing/sampledata.py 2010-08-26 10:08:48 +0000
619@@ -9,6 +9,7 @@
620
621 __metaclass__ = type
622 __all__ = [
623+ 'ADMIN_EMAIL',
624 'BUILDD_ADMIN_USERNAME',
625 'CHROOT_LIBRARYFILEALIAS',
626 'ADMIN_EMAIL',
627@@ -23,6 +24,8 @@
628 'UBUNTU_DEVELOPER_ADMIN_NAME',
629 'UBUNTU_DISTRIBUTION_NAME',
630 'UBUNTU_UPLOAD_TEAM_NAME',
631+ 'UBUNTUTEST_DISTRIBUTION_NAME',
632+ 'USER_EMAIL',
633 'WARTY_DISTROSERIES_NAME',
634 'WARTY_ONLY_SOURCEPACKAGENAME',
635 'WARTY_ONLY_SOURCEPACKAGEVERSION',
636@@ -34,6 +37,9 @@
637 # UBUNTU_DEVELOPER_NAME. Where intent is tricky to convey in the
638 # name, please leave a comment as well.
639
640+# A user with Launchpad Admin privileges.
641+ADMIN_EMAIL = 'foo.bar@canonical.com'
642+
643 # A user with buildd admin rights and upload rights to Ubuntu.
644 BUILDD_ADMIN_USERNAME = 'cprov'
645 # The LibraryFileAlias of a chroot for attaching to a DistroArchSeries
646@@ -43,7 +49,10 @@
647 LAUNCHPAD_ADMIN = 'admin@canonical.com'
648 LAUNCHPAD_DBUSER_NAME = 'launchpad'
649 MAIN_COMPONENT_NAME = 'main'
650+
651 NO_PRIVILEGE_EMAIL = 'no-priv@canonical.com'
652+USER_EMAIL = 'test@canonical.com'
653+VCS_IMPORTS_MEMBER_EMAIL = 'david.allouche@canonical.com'
654 COMMERCIAL_ADMIN_EMAIL = 'commercial-member@canonical.com'
655 ADMIN_EMAIL = 'foo.bar@canonical.com'
656 SAMPLE_PERSON_EMAIL = 'test@canonical.com'

Subscribers

People subscribed via source and target branches

to status/vote changes: