Merge lp:~sinzui/launchpad/register-project-maintainer into lp:launchpad

Proposed by Curtis Hovey
Status: Merged
Approved by: Curtis Hovey
Approved revision: no longer in the source branch.
Merged at revision: 15721
Proposed branch: lp:~sinzui/launchpad/register-project-maintainer
Merge into: lp:launchpad
Diff against target: 262 lines (+141/-7)
4 files modified
lib/canonical/launchpad/icing/css/forms.css (+3/-0)
lib/lp/registry/browser/product.py (+22/-3)
lib/lp/registry/browser/tests/project-add-views.txt (+2/-0)
lib/lp/registry/browser/tests/test_product.py (+114/-4)
To merge this branch: bzr merge lp:~sinzui/launchpad/register-project-maintainer
Reviewer Review Type Date Requested Status
Ian Booth (community) Approve
Review via email: mp+117335@code.launchpad.net

Commit message

Allow the project registrant to choose the maintainer.

Description of the change

Project are often maintained by teams, but that must be done in a
separate step. When the maintainer is changed after registration then
sharing must also be reconfigured. Launchpad needs to let users setup
project correctly from the start to avoid loosing data or disclosing
data to the wrong person.

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

RULES

    Pre-implementation: wallyworld
    * Add the maintainer field to project registration.
    * The default value is the registrant, but it picker can be used
      to find or register a new team.
    * The user can still give the project to the Registry Admins.

QA

    * Visit https://qastaging.launchpad.net/projects/+new
    * Enter the information to get to step 2.
    * For the maintainer field, choose to create a new team.
    * Verify the new team is listed in the maintainer field.
    * Complete registration.
    * Verify the new team is the maintainer.
    * Verify the project shares everything with the maintainer.

LINT

    lib/canonical/launchpad/icing/css/forms.css
    lib/lp/registry/browser/product.py
    lib/lp/registry/browser/tests/project-add-views.txt
    lib/lp/registry/browser/tests/test_product.py

TEST

    ./bin/test -vv -t TestProductAddView lp.registry.browser.tests.test_product
    ./bin/test -vv -t project-add-views lp.registry.browser

IMPLEMENTATION

Update the css rules to indent the checkbox field description when the
checkbox is indented. This fixes the alignment on the old +edit people
page too. There may be other cases where this is fixed, but we rarely
provide descriptions for checkboxes that we add to schemas.
    lib/canonical/launchpad/icing/css/forms.css

Added the owner field to the list of field to render. I discovered that
the hidden fields in the form created white-space between the owner
field and disclaim_maintainer field, so I forced the hidden fields to
the end of the table. The validation() method was updated to ignore
owner field errors if the disclaim_maintainer field was checked; the
field can be empty of contain an invalid name because it will not be
used
    lib/lp/registry/browser/product.py
    lib/lp/registry/browser/tests/test_product.py

Updated an existing test to pass the required owner field.
    lib/lp/registry/browser/tests/project-add-views.txt

To post a comment you must log in.
Revision history for this message
Ian Booth (wallyworld) wrote :

This looks great. Perhaps a subsequent branch could make it so that the disclaim owner checkbox, when ticked, hides or disables the owner field. This will make it clearer to the user what's about to happen I think.

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/icing/css/forms.css'
2--- lib/canonical/launchpad/icing/css/forms.css 2012-06-23 13:44:13 +0000
3+++ lib/canonical/launchpad/icing/css/forms.css 2012-07-30 20:16:39 +0000
4@@ -73,6 +73,9 @@
5 margin: 0.2em 0 0.5em 0.2em;
6 color: #666;
7 }
8+.subordinate[type="checkbox"] + label + .formHelp {
9+ margin-left: 3.4em;
10+ }
11 .listbox {
12 /* a scrolling list of checkboxes or radio buttons */
13 border: 1px solid #8cacbb;
14
15=== modified file 'lib/lp/registry/browser/product.py'
16--- lib/lp/registry/browser/product.py 2012-07-27 13:12:41 +0000
17+++ lib/lp/registry/browser/product.py 2012-07-30 20:16:39 +0000
18@@ -2008,6 +2008,7 @@
19
20 _field_names = ['displayname', 'name', 'title', 'summary',
21 'description', 'homepageurl', 'licenses', 'license_info',
22+ 'owner',
23 ]
24 schema = IProduct
25 step_name = 'projectaddstep2'
26@@ -2021,6 +2022,11 @@
27 custom_widget('homepageurl', TextWidget, displayWidth=30)
28 custom_widget('licenses', LicenseWidget)
29 custom_widget('license_info', GhostWidget)
30+ custom_widget(
31+ 'owner', PersonPickerWidget, header="Select the maintainer",
32+ show_create_team_link=True)
33+ custom_widget(
34+ 'disclaim_maintainer', CheckBoxWidget, cssClass="subordinate")
35
36 @property
37 def main_action_label(self):
38@@ -2048,12 +2054,20 @@
39 return 'Check for duplicate projects'
40 return 'Registration details'
41
42+ @property
43+ def initial_values(self):
44+ return {'owner': self.user.name}
45+
46 def setUpFields(self):
47 """See `LaunchpadFormView`."""
48 super(ProjectAddStepTwo, self).setUpFields()
49- self.form_fields = (self.form_fields +
50+ hidden_names = ('__visited_steps__', 'license_info')
51+ hidden_fields = self.form_fields.select(*hidden_names)
52+ visible_fields = self.form_fields.omit(*hidden_names)
53+ self.form_fields = (visible_fields +
54 self._createDisclaimMaintainerField() +
55- create_source_package_fields())
56+ create_source_package_fields() +
57+ hidden_fields)
58
59 def _createDisclaimMaintainerField(self):
60 """Return a Bool field for disclaiming maintainer.
61@@ -2150,6 +2164,11 @@
62 def validateStep(self, data):
63 """See `MultiStepView`."""
64 ProductLicenseMixin.validate(self, data)
65+ if data.get('disclaim_maintainer') and self.errors:
66+ # The checkbox supersedes the owner text input.
67+ errors = [error for error in self.errors if error[0] == 'owner']
68+ for error in errors:
69+ self.errors.remove(error)
70
71 @property
72 def label(self):
73@@ -2167,7 +2186,7 @@
74 if disclaim_maintainer:
75 owner = getUtility(ILaunchpadCelebrities).registry_experts
76 else:
77- owner = self.user
78+ owner = data.get('owner')
79 return getUtility(IProductSet).createProduct(
80 registrant=self.user,
81 owner=owner,
82
83=== modified file 'lib/lp/registry/browser/tests/project-add-views.txt'
84--- lib/lp/registry/browser/tests/project-add-views.txt 2012-05-24 20:25:54 +0000
85+++ lib/lp/registry/browser/tests/project-add-views.txt 2012-07-30 20:16:39 +0000
86@@ -135,6 +135,7 @@
87
88 # The form keys have the 'field.' prefix here because the form data will
89 # be processed.
90+ >>> registrant = factory.makePerson()
91 >>> form = {
92 ... 'field.displayname': 'Snowdog',
93 ... 'field.name': 'snowdog',
94@@ -142,6 +143,7 @@
95 ... 'field.summary': 'By-tor and the Snowdog',
96 ... 'field.licenses': ['PYTHON'],
97 ... 'field.license_info': '',
98+ ... 'field.owner': registrant.name,
99 ... 'field.__visited_steps__': '%s|%s' % (
100 ... ProjectAddStepOne.step_name, ProjectAddStepTwo.step_name),
101 ... 'field.actions.continue': 'Continue',
102
103=== modified file 'lib/lp/registry/browser/tests/test_product.py'
104--- lib/lp/registry/browser/tests/test_product.py 2012-07-24 06:39:54 +0000
105+++ lib/lp/registry/browser/tests/test_product.py 2012-07-30 20:16:39 +0000
106@@ -5,13 +5,21 @@
107
108 __metaclass__ = type
109
110+import transaction
111 from lazr.restful.interfaces import IJSONRequestCache
112 from zope.component import getUtility
113 from zope.schema.vocabulary import SimpleVocabulary
114
115 from lp.app.browser.lazrjs import vocabulary_to_choice_edit_items
116 from lp.app.enums import ServiceUsage
117-from lp.registry.interfaces.person import CLOSED_TEAM_POLICY
118+from lp.registry.browser.product import (
119+ ProjectAddStepOne,
120+ ProjectAddStepTwo,
121+ )
122+from lp.registry.interfaces.person import (
123+ CLOSED_TEAM_POLICY,
124+ TeamSubscriptionPolicy,
125+ )
126 from lp.registry.interfaces.product import (
127 IProductSet,
128 License,
129@@ -21,6 +29,7 @@
130 from lp.testing import (
131 BrowserTestCase,
132 login_celebrity,
133+ login_person,
134 person_logged_in,
135 TestCaseWithFactory,
136 )
137@@ -73,9 +82,31 @@
138 def setUp(self):
139 super(TestProductAddView, self).setUp()
140 self.product_set = getUtility(IProductSet)
141- # Marker allowing us to reset the config.
142- config.push(self.id(), '')
143- self.addCleanup(config.pop, self.id())
144+
145+ def makeForm(self, action):
146+ if action == 1:
147+ return {
148+ 'field.actions.continue': 'Continue',
149+ 'field.__visited_steps__': ProjectAddStepOne.step_name,
150+ 'field.displayname': 'Fnord',
151+ 'field.name': 'fnord',
152+ 'field.title': 'fnord',
153+ 'field.summary': 'fnord summary',
154+ }
155+ else:
156+ return {
157+ 'field.actions.continue': 'Continue',
158+ 'field.__visited_steps__': '%s|%s' % (
159+ ProjectAddStepOne.step_name, ProjectAddStepTwo.step_name),
160+ 'field.displayname': 'Fnord',
161+ 'field.name': 'fnord',
162+ 'field.title': 'fnord',
163+ 'field.summary': 'fnord summary',
164+ 'field.owner': '',
165+ 'field.licenses': ['MIT'],
166+ 'field.license_info': '',
167+ 'field.disclaim_maintainer': 'off',
168+ }
169
170 def test_staging_message_is_not_demo(self):
171 view = create_initialized_view(self.product_set, '+new')
172@@ -83,11 +114,90 @@
173 self.assertTrue(message is not None)
174
175 def test_staging_message_is_demo(self):
176+ config.push(self.id(), '')
177+ self.addCleanup(config.pop, self.id())
178 self.useFixture(DemoMode())
179 view = create_initialized_view(self.product_set, '+new')
180 message = find_tag_by_id(view.render(), 'staging-message')
181 self.assertEqual(None, message)
182
183+ def test_step_two_initialize(self):
184+ # Step two collects additional license, owner, and packaging info.
185+ registrant = self.factory.makePerson(name='pting')
186+ transaction.commit()
187+ login_person(registrant)
188+ form = self.makeForm(action=1)
189+ view = create_initialized_view(self.product_set, '+new', form=form)
190+ owner_widget = view.view.widgets['owner']
191+ self.assertEqual('pting', view.view.initial_values['owner'])
192+ self.assertEqual('Select the maintainer', owner_widget.header)
193+ self.assertIs(True, owner_widget.show_create_team_link)
194+ disclaim_widget = view.view.widgets['disclaim_maintainer']
195+ self.assertEqual('subordinate', disclaim_widget.cssClass)
196+ self.assertEqual(
197+ ['displayname', 'name', 'title', 'summary', 'description',
198+ 'homepageurl', 'licenses', 'license_info', 'owner',
199+ '__visited_steps__'],
200+ view.view.field_names)
201+ self.assertEqual(
202+ ['displayname', 'name', 'title', 'summary', 'description',
203+ 'homepageurl', 'licenses', 'owner', 'disclaim_maintainer',
204+ 'source_package_name', 'distroseries', '__visited_steps__',
205+ 'license_info'],
206+ [f.__name__ for f in view.view.form_fields])
207+
208+ def test_owner_can_be_team(self):
209+ # An owner can be any valid user or team selected.
210+ registrant = self.factory.makePerson()
211+ team = self.factory.makeTeam(
212+ subscription_policy=TeamSubscriptionPolicy.RESTRICTED)
213+ transaction.commit()
214+ login_person(registrant)
215+ form = self.makeForm(action=2)
216+ form['field.owner'] = team.name
217+ view = create_initialized_view(self.product_set, '+new', form=form)
218+ self.assertEqual(0, len(view.view.errors))
219+ product = self.product_set.getByName('fnord')
220+ self.assertEqual(team, product.owner)
221+
222+ def test_disclaim_maitainer_supersedes_owner(self):
223+ # When the disclaim_maintainer is selected, the owner field is ignored
224+ # and the registry team is made the maintainer.
225+ registrant = self.factory.makePerson()
226+ login_person(registrant)
227+ form = self.makeForm(action=2)
228+ form['field.owner'] = registrant.name
229+ form['field.disclaim_maintainer'] = 'on'
230+ view = create_initialized_view(self.product_set, '+new', form=form)
231+ self.assertEqual(0, len(view.view.errors))
232+ product = self.product_set.getByName('fnord')
233+ self.assertEqual('registry', product.owner.name)
234+
235+ def test_owner_is_requried_without_disclaim_maitainer(self):
236+ # A valid owner name is required if disclaim_maintainer is
237+ # not selected.
238+ registrant = self.factory.makePerson()
239+ login_person(registrant)
240+ form = self.makeForm(action=2)
241+ form['field.owner'] = ''
242+ del form['field.disclaim_maintainer']
243+ view = create_initialized_view(self.product_set, '+new', form=form)
244+ self.assertEqual(1, len(view.view.errors))
245+ self.assertEqual('owner', view.view.errors[0][0])
246+
247+ def test_disclaim_maitainer_empty_supersedes_owner(self):
248+ # Errors for the owner field are ignored when disclaim_maintainer is
249+ # selected.
250+ registrant = self.factory.makePerson()
251+ login_person(registrant)
252+ form = self.makeForm(action=2)
253+ form['field.owner'] = ''
254+ form['field.disclaim_maintainer'] = 'on'
255+ view = create_initialized_view(self.product_set, '+new', form=form)
256+ self.assertEqual(0, len(view.view.errors))
257+ product = self.product_set.getByName('fnord')
258+ self.assertEqual('registry', product.owner.name)
259+
260
261 class TestProductView(TestCaseWithFactory):
262 """Tests the ProductView."""