Merge lp:~sinzui/launchpad/valid-targets-1 into lp:launchpad

Proposed by Curtis Hovey
Status: Merged
Approved by: Brad Crittenden
Approved revision: no longer in the source branch.
Merged at revision: 14024
Proposed branch: lp:~sinzui/launchpad/valid-targets-1
Merge into: lp:launchpad
Diff against target: 343 lines (+216/-6)
7 files modified
lib/lp/answers/browser/question.py (+19/-1)
lib/lp/answers/browser/tests/test_question.py (+84/-0)
lib/lp/answers/configure.zcml (+8/-0)
lib/lp/answers/interfaces/question.py (+3/-2)
lib/lp/answers/tests/test_vocabulary.py (+69/-0)
lib/lp/answers/vocabulary.py (+32/-2)
lib/lp/bugs/tests/test_vocabulary.py (+1/-1)
To merge this branch: bzr merge lp:~sinzui/launchpad/valid-targets-1
Reviewer Review Type Date Requested Status
Brad Crittenden (community) code Approve
Review via email: mp+76781@code.launchpad.net

Description of the change

Restrict the target widget to pillars that use Launchpad.

    Launchpad bug: https://bugs.launchpad.net/bugs/857448
    Pre-implementation: jcsackett

LaunchpadTargetWidget is used for Questions and it does not
constrain the choice to pillars that use Launchpad. The widget lists
every distribution in the DB, but very few use Launchpad.

This issue is largely about the ui and the moment. Distributions can
change which support tracker they use, and the existing questions will
continue to exist. The existing constraint of any distro is correct,
but when I choose to retarget a question, it should only be to the distros
that currently use Lp. User can use the API to target a question's distro,
and we assume the user knows what he is doing.

Note that a previous attempt to restrict targets to the current state
of what is valid failed because historic data became invalid--you could
not load a bug if one of the bugtasks belonged to a project that did not
use Lp, or the package was never published.

--------------------------------------------------------------------

RULES

    * Create vocabularies that constrain the the pillars to those that
      use Launchpad, or the current distro so historic data is accepted.
    * Subclass the widget to use the appropriate question vocabulary

QA

    * Visit https://answers.qastaging.launchpad.net/ubuntu/+question/162149
    * Choose to retarget the question.
    * Verify the target widget field reads:
      This question is about
    * Verify the "product" is not used in the target widget's description.
    * Verify that Ubuntu and gentoo are listed.
    * Verify that archlinux and samsung are not listed.

    * Visit any question in the launchpad project.
    * Choose to edit the question.
    * Change the whiteboard and retarget the question to sikuli
    * Save the changes.
    * Verify there was not a form error.

LINT

    lib/lp/answers/configure.zcml
    lib/lp/answers/vocabulary.py
    lib/lp/answers/browser/question.py
    lib/lp/answers/browser/tests/test_question.py
    lib/lp/answers/interfaces/question.py
    lib/lp/answers/tests/test_vocabulary.py

IMPLEMENTATION

I saw 'product' was used in the edit form and found a related bug that
'project' is ambiguous in the form: I used MPT's suggested text and fixed
the field's description. See https://bugs.launchpad.net/launchpad/+bug/164424
    lib/lp/answers/interfaces/question.py

Added a vocabulary that constrains the distro to only those that use Lp.
    lib/lp/answers/configure.zcml
    lib/lp/answers/vocabulary.py
    lib/lp/answers/tests/test_vocabulary.py

Subclassed LaunchpadTargetWidget to use the new vocabulary.
    lib/lp/answers/browser/question.py
    lib/lp/answers/browser/tests/test_question.py

I realised that a form submission error reported by Matthew related to
permission changes implicit with retargeting. Question target changes must
always be the last change processed. See
https://bugs.launchpad.net/launchpad/+bug/117525
    lib/lp/answers/browser/question.py
    lib/lp/answers/browser/tests/test_question.py

To post a comment you must log in.
Revision history for this message
Brad Crittenden (bac) :
review: Approve (code)
Revision history for this message
Brad Crittenden (bac) wrote :

Hi Curtis,

This branch looks good and I like your approach.

You have a type here:
# The vocabulary contains does not contain the context if the

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/answers/browser/question.py'
2--- lib/lp/answers/browser/question.py 2011-09-13 05:23:16 +0000
3+++ lib/lp/answers/browser/question.py 2011-09-23 19:03:25 +0000
4@@ -97,6 +97,7 @@
5 IAnswersFrontPageSearchForm,
6 IQuestionTarget,
7 )
8+from lp.answers.vocabulary import UsesAnswersDistributionVocabulary
9 from lp.app.browser.launchpadform import (
10 action,
11 custom_widget,
12@@ -725,6 +726,15 @@
13 cancel_url = next_url
14
15
16+class QuestionTargetWidget(LaunchpadTargetWidget):
17+ """A targeting widget that is aware of pillars that use Answers."""
18+
19+ def getDistributionVocabulary(self):
20+ distro = self.context.context.distribution
21+ vocabulary = UsesAnswersDistributionVocabulary(distro)
22+ return vocabulary
23+
24+
25 class QuestionEditView(LaunchpadEditFormView):
26 """View for editing a Question."""
27 schema = IQuestion
28@@ -735,7 +745,7 @@
29
30 custom_widget('title', TextWidget, displayWidth=40)
31 custom_widget('whiteboard', TextAreaWidget, height=5)
32- custom_widget('target', LaunchpadTargetWidget)
33+ custom_widget('target', QuestionTargetWidget)
34
35 @property
36 def page_title(self):
37@@ -762,7 +772,15 @@
38 @action(_("Save Changes"), name="change")
39 def change_action(self, action, data):
40 """Update the Question from the request form data."""
41+ # Target must be the last field processed because it implicitly
42+ # changes the user's permissions.
43+ target_data = {'target': self.context.target}
44+ if 'target' in data:
45+ target_data['target'] = data['target']
46+ del data['target']
47 self.updateContextFromData(data)
48+ if target_data['target'] != self.context.target:
49+ self.updateContextFromData(target_data)
50
51 @property
52 def next_url(self):
53
54=== modified file 'lib/lp/answers/browser/tests/test_question.py'
55--- lib/lp/answers/browser/tests/test_question.py 2011-01-28 21:11:07 +0000
56+++ lib/lp/answers/browser/tests/test_question.py 2011-09-23 19:03:25 +0000
57@@ -7,7 +7,10 @@
58
59 __all__ = []
60
61+from canonical.launchpad.webapp.servers import LaunchpadTestRequest
62 from canonical.testing.layers import DatabaseFunctionalLayer
63+from lp.answers.browser.question import QuestionTargetWidget
64+from lp.answers.interfaces.question import IQuestion
65 from lp.answers.publisher import AnswersLayer
66 from lp.testing import (
67 login_person,
68@@ -51,3 +54,84 @@
69 self.assertEqual(1, len(view.errors))
70 self.assertEqual(
71 'The summary cannot exceed 250 characters.', view.errors[0])
72+
73+
74+class QuestionEditViewTestCase(TestCaseWithFactory):
75+ """Verify the behavior of the QuestionEditView."""
76+
77+ layer = DatabaseFunctionalLayer
78+
79+ def getForm(self, question):
80+ if question.assignee is None:
81+ assignee = ''
82+ else:
83+ assignee = question.assignee.name
84+ return {
85+ 'field.title': question.title,
86+ 'field.description': question.description,
87+ 'field.language': question.language.code,
88+ 'field.assignee': assignee,
89+ 'field.target': 'product',
90+ 'field.target.distribution': '',
91+ 'field.target.package': '',
92+ 'field.target.product': question.target.name,
93+ 'field.whiteboard': question.whiteboard,
94+ 'field.actions.change': 'Change',
95+ }
96+
97+ def test_retarget_with_other_changed(self):
98+ # Retargeting must be the last change made to the question
99+ # to ensure that user permission do not change while there
100+ # are more changes to make.
101+ target = self.factory.makeProduct()
102+ question = self.factory.makeQuestion(target=target)
103+ other_target = self.factory.makeProduct()
104+ login_person(target.owner)
105+ form = self.getForm(question)
106+ form['field.whiteboard'] = 'comment'
107+ form['field.target.product'] = other_target.name
108+ view = create_initialized_view(
109+ question, name='+edit', layer=AnswersLayer, form=form)
110+ self.assertEqual([], view.errors)
111+ self.assertEqual(other_target, question.target)
112+ self.assertEqual('comment', question.whiteboard)
113+
114+
115+class QuestionTargetWidgetTestCase(TestCaseWithFactory):
116+ """Test that QuestionTargetWidgetTestCase behaves as expected."""
117+ layer = DatabaseFunctionalLayer
118+
119+ def getWidget(self, question):
120+ field = IQuestion['target']
121+ bound_field = field.bind(question)
122+ request = LaunchpadTestRequest()
123+ return QuestionTargetWidget(bound_field, request)
124+
125+ def test_getDistributionVocabulary_with_product_question(self):
126+ # The vocabulary does not contain distros that do not use
127+ # launchpad to track answers.
128+ distribution = self.factory.makeDistribution()
129+ product = self.factory.makeProduct()
130+ question = self.factory.makeQuestion(target=product)
131+ target_widget = self.getWidget(question)
132+ vocabulary = target_widget.getDistributionVocabulary()
133+ self.assertEqual(None, vocabulary.distribution)
134+ self.assertFalse(
135+ distribution in vocabulary,
136+ "Vocabulary contains distros that do not use Launchpad Answers.")
137+
138+ def test_getDistributionVocabulary_with_distribution_question(self):
139+ # The vocabulary does not contain distros that do not use
140+ # launchpad to track answers.
141+ distribution = self.factory.makeDistribution()
142+ other_distribution = self.factory.makeDistribution()
143+ question = self.factory.makeQuestion(target=distribution)
144+ target_widget = self.getWidget(question)
145+ vocabulary = target_widget.getDistributionVocabulary()
146+ self.assertEqual(distribution, vocabulary.distribution)
147+ self.assertTrue(
148+ distribution in vocabulary,
149+ "Vocabulary missing context distribution.")
150+ self.assertFalse(
151+ other_distribution in vocabulary,
152+ "Vocabulary contains distros that do not use Launchpad Answers.")
153
154=== modified file 'lib/lp/answers/configure.zcml'
155--- lib/lp/answers/configure.zcml 2011-08-12 04:17:29 +0000
156+++ lib/lp/answers/configure.zcml 2011-09-23 19:03:25 +0000
157@@ -182,6 +182,14 @@
158 factory=".adapters.sourcepackage_to_faqtarget"
159 />
160
161+ <securedutility
162+ name="UsesAnswersDistribution"
163+ component=".vocabulary.UsesAnswersDistributionVocabulary"
164+ provides="zope.schema.interfaces.IVocabularyFactory"
165+ >
166+ <allow interface="zope.schema.interfaces.IVocabularyFactory"/>
167+ </securedutility>
168+
169 <!-- QuestionSet -->
170 <class class=".model.question.QuestionSet">
171 <allow interface=".interfaces.questioncollection.IQuestionSet" />
172
173=== modified file 'lib/lp/answers/interfaces/question.py'
174--- lib/lp/answers/interfaces/question.py 2011-09-22 20:50:40 +0000
175+++ lib/lp/answers/interfaces/question.py 2011-09-23 19:03:25 +0000
176@@ -157,8 +157,9 @@
177 description=_('Up-to-date notes on the status of the question.'))
178 # other attributes
179 target = exported(Reference(
180- title=_('Project'), required=True, schema=IQuestionTarget,
181- description=_('The distribution, source package, or product the '
182+ title=_('This question is about'), required=True,
183+ schema=IQuestionTarget,
184+ description=_('The distribution, source package, or project the '
185 'question pertains to.')),
186 as_of="devel")
187 faq = Object(
188
189=== added file 'lib/lp/answers/tests/test_vocabulary.py'
190--- lib/lp/answers/tests/test_vocabulary.py 1970-01-01 00:00:00 +0000
191+++ lib/lp/answers/tests/test_vocabulary.py 2011-09-23 19:03:25 +0000
192@@ -0,0 +1,69 @@
193+# Copyright 2011 Canonical Ltd. This software is licensed under the
194+# GNU Affero General Public License version 3 (see the file LICENSE).
195+
196+"""Test the answers domain vocabularies."""
197+
198+__metaclass__ = type
199+
200+from canonical.testing.layers import DatabaseFunctionalLayer
201+from lp.answers.vocabulary import UsesAnswersDistributionVocabulary
202+from lp.testing import (
203+ person_logged_in,
204+ TestCaseWithFactory,
205+ )
206+
207+
208+class UsesAnswersDistributionVocabularyTestCase(TestCaseWithFactory):
209+ """Test that the vocabulary behaves as expected."""
210+ layer = DatabaseFunctionalLayer
211+
212+ def test_init_with_distribution(self):
213+ # When the context is adaptable to IDistribution, the distribution
214+ # property is the distribution.
215+ distribution = self.factory.makeDistribution()
216+ vocabulary = UsesAnswersDistributionVocabulary(distribution)
217+ self.assertEqual(distribution, vocabulary.context)
218+ self.assertEqual(distribution, vocabulary.distribution)
219+
220+ def test_init_without_distribution(self):
221+ # When the context is not adaptable to IDistribution, the
222+ # distribution property is None
223+ thing = self.factory.makeProduct()
224+ vocabulary = UsesAnswersDistributionVocabulary(thing)
225+ self.assertEqual(thing, vocabulary.context)
226+ self.assertEqual(None, vocabulary.distribution)
227+
228+ def test_contains_distros_that_use_answers(self):
229+ # The vocabulary contains distributions that also use
230+ # Launchpad to track answers.
231+ distro_less_answers = self.factory.makeDistribution()
232+ distro_uses_answers = self.factory.makeDistribution()
233+ with person_logged_in(distro_uses_answers.owner):
234+ distro_uses_answers.official_answers = True
235+ vocabulary = UsesAnswersDistributionVocabulary()
236+ self.assertFalse(
237+ distro_less_answers in vocabulary,
238+ "Vocabulary contains distros that do not use Launchpad Answers.")
239+ self.assertTrue(
240+ distro_uses_answers in vocabulary,
241+ "Vocabulary missing distros that use Launchpad Answers.")
242+
243+ def test_contains_context_distro(self):
244+ # The vocabulary contains the context distro even it it does not
245+ # use Launchpad to track answers. The distro may have tracked answers
246+ # in the past so it is a legitimate choise for historic data.
247+ distro_less_answers = self.factory.makeDistribution()
248+ vocabulary = UsesAnswersDistributionVocabulary(distro_less_answers)
249+ self.assertFalse(distro_less_answers.official_answers)
250+ self.assertTrue(
251+ distro_less_answers in vocabulary,
252+ "Vocabulary missing context distro.")
253+
254+ def test_contains_missing_context(self):
255+ # The vocabulary does not contain the context if the
256+ # context is not adaptable to a distribution.
257+ thing = self.factory.makeProduct()
258+ vocabulary = UsesAnswersDistributionVocabulary(thing)
259+ self.assertFalse(
260+ thing in vocabulary,
261+ "Vocabulary contains a non-distribution.")
262
263=== modified file 'lib/lp/answers/vocabulary.py'
264--- lib/lp/answers/vocabulary.py 2011-08-20 10:53:02 +0000
265+++ lib/lp/answers/vocabulary.py 2011-09-23 19:03:25 +0000
266@@ -1,4 +1,4 @@
267-# Copyright 2009 Canonical Ltd. This software is licensed under the
268+# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
269 # GNU Affero General Public License version 3 (see the file LICENSE).
270
271 """Named vocabularies defined by the Answers application."""
272@@ -6,8 +6,11 @@
273 __metaclass__ = type
274 __all__ = [
275 'FAQVocabulary',
276+ 'UsesAnswersDistributionVocabulary',
277 ]
278
279+from sqlobject import OR
280+
281 from zope.interface import implements
282 from zope.schema.vocabulary import SimpleTerm
283
284@@ -18,6 +21,8 @@
285 )
286 from lp.answers.interfaces.faq import IFAQ
287 from lp.answers.interfaces.faqtarget import IFAQTarget
288+from lp.registry.interfaces.distribution import IDistribution
289+from lp.registry.vocabularies import DistributionVocabulary
290
291
292 class FAQVocabulary(FilteredVocabularyBase):
293@@ -58,7 +63,7 @@
294 def getTermByToken(self, token):
295 """See `IVocabularyTokenized`."""
296 try:
297- faq_id = int(token)
298+ int(token)
299 except ValueError:
300 raise LookupError(token)
301 faq = self.context.getFAQ(token)
302@@ -74,3 +79,28 @@
303 """See `IHugeVocabulary`."""
304 results = self.context.findSimilarFAQs(query)
305 return CountableIterator(results.count(), results, self.toTerm)
306+
307+
308+class UsesAnswersDistributionVocabulary(DistributionVocabulary):
309+ """Distributions that use Launchpad to track questions.
310+
311+ If the context is a distribution, it is always included in the
312+ vocabulary. Historic data is not invalidated if a distro stops
313+ using Launchpad to track questions. This vocabulary offers the correct
314+ choices of distributions at this moment.
315+ """
316+
317+ def __init__(self, context=None):
318+ super(UsesAnswersDistributionVocabulary, self).__init__(
319+ context=context)
320+ self.distribution = IDistribution(self.context, None)
321+
322+ @property
323+ def _filter(self):
324+ if self.distribution is None:
325+ distro_id = 0
326+ else:
327+ distro_id = self.distribution.id
328+ return OR(
329+ self._table.q.official_answers == True,
330+ self._table.id == distro_id)
331
332=== modified file 'lib/lp/bugs/tests/test_vocabulary.py'
333--- lib/lp/bugs/tests/test_vocabulary.py 2011-09-22 19:28:03 +0000
334+++ lib/lp/bugs/tests/test_vocabulary.py 2011-09-23 19:03:25 +0000
335@@ -60,7 +60,7 @@
336 "Vocabulary missing context distro.")
337
338 def test_contains_missing_context(self):
339- # The vocabulary contains does not contain the context if the
340+ # The vocabulary does not contain the context if the
341 # context is not adaptable to a distribution.
342 thing = self.factory.makeProduct()
343 vocabulary = UsesBugsDistributionVocabulary(thing)