Merge lp:~sinzui/launchpad/closed-teams-0 into lp:launchpad

Proposed by Curtis Hovey
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 12018
Proposed branch: lp:~sinzui/launchpad/closed-teams-0
Merge into: lp:launchpad
Diff against target: 762 lines (+264/-144)
15 files modified
lib/canonical/launchpad/vocabularies/dbobjects.py (+2/-0)
lib/canonical/launchpad/webapp/vocabulary.py (+5/-1)
lib/canonical/widgets/popup.py (+21/-13)
lib/lp/answers/vocabulary.py (+1/-2)
lib/lp/app/widgets/tests/test_popup.py (+43/-0)
lib/lp/blueprints/vocabularies/specificationdependency.py (+1/-0)
lib/lp/code/vocabularies/branch.py (+1/-0)
lib/lp/registry/browser/configure.zcml (+1/-1)
lib/lp/registry/browser/person.py (+7/-0)
lib/lp/registry/browser/tests/test_team.py (+14/-0)
lib/lp/registry/doc/vocabularies.txt (+1/-93)
lib/lp/registry/javascript/team.js (+2/-2)
lib/lp/registry/templates/team-portlet-membership.pt (+4/-2)
lib/lp/registry/tests/test_person_vocabularies.py (+99/-18)
lib/lp/registry/vocabularies.py (+62/-12)
To merge this branch: bzr merge lp:~sinzui/launchpad/closed-teams-0
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+42500@code.launchpad.net

Description of the change

Need a closed team and user vocabulary and picker

    Launchpad bug: https://bugs.launchpad.net/bugs/681035
    Pre-implementation: jcsackett, flacoste, gary_poster
    Test command: ./bin/test -vv \
        -t test_popup -t test_team -t test_person_vocabularies

There are several cases where Launchpad must prevent users from selecting a
team with an open subscription policy because it compromises security or
discloses information. The person picker and forms need a vocabulary that is
composed on closed teams (restricted and moderated), and users. The
picker/vocabulary is needed is needed in:

1. closed team owner role
2. closed team +addmember
3. moderated team +proposemember

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

RULES

    * Update the ValidTeamMemberVocabulary and ValidTeamOwnerVocabulary
      to exclude open teams when the context team is closed.
    * Update IHugeVocabulary, <intermediate classes>, VocabularyPickerWidget
      to use the vocabs step_title so that users understand what can and
      cannot be searched for. This issue was discovered in user testing.
    * Update all IHugeVocabularies to have a step_title
    * Update the add member ajax feature to also use the vocabulary's
      step_title

QA

    Using a moderated or restricted team to test.
    * Verify that open teams to not appear in the person picker to add
      a new member from the team's front page.
    * Verify that open teams to not appear in the person picker to add
      a new member from the teams +addmember page.
    * Verify that open teams to not appear in the person picker to change
      the team owner

LINT

    lib/canonical/launchpad/vocabularies/dbobjects.py
    lib/canonical/launchpad/webapp/vocabulary.py
    lib/canonical/widgets/popup.py
    lib/lp/answers/vocabulary.py
    lib/lp/app/widgets/
    lib/lp/app/widgets/__init__.py
    lib/lp/app/widgets/tests/
    lib/lp/app/widgets/tests/__init__.py
    lib/lp/app/widgets/tests/test_popup.py
    lib/lp/blueprints/vocabularies/specificationdependency.py
    lib/lp/code/vocabularies/branch.py
    lib/lp/registry/vocabularies.py
    lib/lp/registry/browser/configure.zcml
    lib/lp/registry/browser/person.py
    lib/lp/registry/browser/tests/test_team.py
    lib/lp/registry/javascript/team.js
    lib/lp/registry/templates/team-portlet-membership.pt
    lib/lp/registry/tests/test_person_vocabularies.py

IMPLEMENTATION

Updated ValidTeamMemberVocabulary and ValidTeamOwnerVocabulary to exclude
open teams when the context team is closed.
    lib/lp/registry/vocabularies.py
    lib/lp/registry/tests/test_person_vocabularies.py

Added step_title to the vocabulary. The value is the same that appears in
the UI now.
    lib/canonical/launchpad/webapp/vocabulary.py
    lib/canonical/launchpad/vocabularies/dbobjects.py
    lib/lp/answers/vocabulary.py
    lib/lp/blueprints/vocabularies/specificationdependency.py
    lib/lp/code/vocabularies/branch.py
    lib/lp/registry/vocabularies.py

Updated VocabularyPickerWidget to use the vocab's step_title. There are
no direct tests of this widget. Moving the whole module would make the diff
hard to read so I create a test where the module should be moved.
    lib/canonical/widgets/popup.py
    lib/lp/app/widgets/
    lib/lp/app/widgets/__init__.py
    lib/lp/app/widgets/tests/
    lib/lp/app/widgets/tests/__init__.py
    lib/lp/app/widgets/tests/test_popup.py

Updated the TeamIndexView and team membership portlet to use the step_title
from the vocabulary.
    lib/lp/registry/browser/configure.zcml
    lib/lp/registry/browser/person.py
    lib/lp/registry/browser/tests/test_team.py
    lib/lp/registry/javascript/team.js
    lib/lp/registry/templates/team-portlet-membership.pt

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

Hi Curtis. Some incredibly minor comments, +1.

[1]

         js = js_template % simplejson.dumps(args)
         # If the YUI widget or javascript is not supported in the browser,
         # it will degrade to being this "Find..." link instead of the
@@ -154,8 +163,7 @@
             css = ''
         return ('<span class="%s">(<a id="%s" href="/people/">'
                 'Find&hellip;</a>)</span>'
- '\n<script>\n%s\n</script>'
- ) % (css, self.show_widget_id, js)
+ '\n<script>\n%s\n</script>') % (css, self.show_widget_id, js)

It's not part of your change, but using simplejson.dumps() can be
hazardous for embedding javascript into <script> tags. See:

  http://code.google.com/p/simplejson/issues/detail?id=66

Instead the following should be used:

  simplejson.dumps(args, cls=simplejson.encoder.JSONEncoderForHTML)

Or:

  simplejson.encoder.JSONEncoderForHTML().encode(obj)

[2]

+ Y.lp.registry.team.setup_add_member_handler(
+ '${step_title}');

The lack of escaping and all that jazz makes me a little uneasy, but
it's not user-supplied so I'm not too worried.

[3]

+ def test_open_team_cannot_be_a_member_or_a_closed_team(self):

s/or/of/

[4]

+ def test_open_team_can_be_a_member_or_an_open_team(self):

s/or/of/

[5]

+ return 'Search for a restricted or moderated team or person'

This could be parsed as:

  Search for a (restricted or moderated) (team or person)

Perhaps the following would be less ambiguous:

  Search for a restricted team, a moderated team, or a person

?

[6]

+ Person.subscriptionpolicy != TeamSubscriptionPolicy.OPEN)

Maybe it's worth taking a defensive approach here (to someone adding
another TeamSubscriptionPolicy member) instead:

  In(Person.subscriptionpolicy, (
      TeamSubscriptionPolicy.RESTRICTED,
      TeamSubscriptionPolicy.MODERATED))

The same clause appears twice.

review: Approve
Revision history for this message
Curtis Hovey (sinzui) wrote :

On Thu, 2010-12-02 at 17:12 +0000, Gavin Panella wrote:
> Review: Approve
> Hi Curtis. Some incredibly minor comments, +1.
>
>
> [1]
>
> js = js_template % simplejson.dumps(args)
> # If the YUI widget or javascript is not supported in the browser,
> # it will degrade to being this "Find..." link instead of the
> @@ -154,8 +163,7 @@
> css = ''
> return ('<span class="%s">(<a id="%s" href="/people/">'
> 'Find&hellip;</a>)</span>'
> - '\n<script>\n%s\n</script>'
> - ) % (css, self.show_widget_id, js)
> + '\n<script>\n%s\n</script>') % (css, self.show_widget_id, js)
>
> It's not part of your change, but using simplejson.dumps() can be
> hazardous for embedding javascript into <script> tags. See:
>
> http://code.google.com/p/simplejson/issues/detail?id=66
>
> Instead the following should be used:
>
> simplejson.dumps(args, cls=simplejson.encoder.JSONEncoderForHTML)
>
> Or:
>
> simplejson.encoder.JSONEncoderForHTML().encode(obj)

Interesting. We cannot do this right now because Lp is using 2.0.9. I
can see it my version of 2.1.1

> [2]
>
> + Y.lp.registry.team.setup_add_member_handler(
> + '${step_title}');
>
> The lack of escaping and all that jazz makes me a little uneasy, but
> it's not user-supplied so I'm not too worried.

This content is in a tal:content rule and is escaped. A single-quote
will break the script. I updated the method to escape quotes and added a
test.

> [3]
>
> + def test_open_team_cannot_be_a_member_or_a_closed_team(self):
>
> s/or/of/

fixed

> [4]
>
> + def test_open_team_can_be_a_member_or_an_open_team(self):
>
> s/or/of/

fixed

> [5]
>
> + return 'Search for a restricted or moderated team or person'
>
> This could be parsed as:
>
> Search for a (restricted or moderated) (team or person)
>
> Perhaps the following would be less ambiguous:
>
> Search for a restricted team, a moderated team, or a person

I accept your proposal.

> [6]
>
> + Person.subscriptionpolicy != TeamSubscriptionPolicy.OPEN)
>
> Maybe it's worth taking a defensive approach here (to someone adding
> another TeamSubscriptionPolicy member) instead:
>
> In(Person.subscriptionpolicy, (
> TeamSubscriptionPolicy.RESTRICTED,
> TeamSubscriptionPolicy.MODERATED))

We have not changed the policies in 5 years. I do not think we will be
adding other policies. If I am wrong, we may need to update the proposed
revision. Since this issue is about OPEN, I think the clause should
remain as is.

--
__Curtis C. Hovey_________
http://launchpad.net/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/vocabularies/dbobjects.py'
2--- lib/canonical/launchpad/vocabularies/dbobjects.py 2010-11-09 08:38:23 +0000
3+++ lib/canonical/launchpad/vocabularies/dbobjects.py 2010-12-03 00:04:45 +0000
4@@ -141,6 +141,7 @@
5 class BugTrackerVocabulary(SQLObjectVocabularyBase):
6 """All web and email based external bug trackers."""
7 displayname = 'Select a bug tracker'
8+ step_title = 'Search'
9 implements(IHugeVocabulary)
10 _table = BugTracker
11 _filter = True
12@@ -401,6 +402,7 @@
13 Person.q.id == Archive.q.ownerID,
14 Archive.q.purpose == ArchivePurpose.PPA)
15 displayname = 'Select a PPA'
16+ step_title = 'Search'
17
18 def toTerm(self, archive):
19 """See `IVocabulary`."""
20
21=== modified file 'lib/canonical/launchpad/webapp/vocabulary.py'
22--- lib/canonical/launchpad/webapp/vocabulary.py 2010-11-09 08:38:23 +0000
23+++ lib/canonical/launchpad/webapp/vocabulary.py 2010-12-03 00:04:45 +0000
24@@ -70,7 +70,10 @@
25 """
26
27 displayname = Attribute(
28- 'A name for this vocabulary, to be displayed in the popup window.')
29+ 'A name for this vocabulary, to be displayed in the picker window.')
30+
31+ step_title = Attribute(
32+ 'The search step title in the picker window.')
33
34 def searchForTerms(query=None):
35 """Return a `CountableIterator` of `SimpleTerm`s that match the query.
36@@ -365,6 +368,7 @@
37 implements(IHugeVocabulary)
38 _orderBy = 'name'
39 displayname = None
40+ step_title = 'Search'
41 # The iterator class will be used to wrap the results; its iteration
42 # methods should return SimpleTerms, as the reference implementation
43 # CountableIterator does.
44
45=== modified file 'lib/canonical/widgets/popup.py'
46--- lib/canonical/widgets/popup.py 2010-08-24 10:45:57 +0000
47+++ lib/canonical/widgets/popup.py 2010-12-03 00:04:45 +0000
48@@ -37,7 +37,7 @@
49 style = ''
50 cssClass = ''
51
52- step_title = 'Search'
53+ step_title = None
54 # Defaults to self.vocabulary.displayname.
55 header = None
56
57@@ -79,19 +79,20 @@
58
59 def inputField(self):
60 d = {
61- 'formToken' : cgi.escape(self.formToken, quote=True),
62+ 'formToken': cgi.escape(self.formToken, quote=True),
63 'name': self.name,
64 'displayWidth': self.displayWidth,
65 'displayMaxWidth': self.displayMaxWidth,
66 'onKeyPress': self.onKeyPress,
67 'style': self.style,
68- 'cssClass': self.cssClass
69- }
70+ 'cssClass': self.cssClass,
71+ }
72 return """<input type="text" value="%(formToken)s" id="%(name)s"
73 name="%(name)s" size="%(displayWidth)s"
74 maxlength="%(displayMaxWidth)s"
75 onKeyPress="%(onKeyPress)s" style="%(style)s"
76 class="%(cssClass)s" />""" % d
77+
78 @property
79 def suffix(self):
80 return self.name.replace('.', '-')
81@@ -126,23 +127,31 @@
82 % (choice.context, choice.__name__))
83 return choice.vocabularyName
84
85- def chooseLink(self):
86- js_file = os.path.join(os.path.dirname(__file__),
87- 'templates/vocabulary-picker.js.template')
88- js_template = open(js_file).read()
89-
90+ def js_template_args(self):
91+ """return a dict of args to configure the picker javascript."""
92 if self.header is None:
93 header = self.vocabulary.displayname
94 else:
95 header = self.header
96
97- args = dict(
98+ if self.step_title is None:
99+ step_title = self.vocabulary.step_title
100+ else:
101+ step_title = self.step_title
102+
103+ return dict(
104 vocabulary=self.vocabulary_name,
105 header=header,
106- step_title=self.step_title,
107+ step_title=step_title,
108 show_widget_id=self.show_widget_id,
109 input_id=self.name,
110 extra_no_results_message=self.extra_no_results_message)
111+
112+ def chooseLink(self):
113+ js_file = os.path.join(os.path.dirname(__file__),
114+ 'templates/vocabulary-picker.js.template')
115+ js_template = open(js_file).read()
116+ args = self.js_template_args()
117 js = js_template % simplejson.dumps(args)
118 # If the YUI widget or javascript is not supported in the browser,
119 # it will degrade to being this "Find..." link instead of the
120@@ -154,8 +163,7 @@
121 css = ''
122 return ('<span class="%s">(<a id="%s" href="/people/">'
123 'Find&hellip;</a>)</span>'
124- '\n<script>\n%s\n</script>'
125- ) % (css, self.show_widget_id, js)
126+ '\n<script>\n%s\n</script>') % (css, self.show_widget_id, js)
127
128 @property
129 def nonajax_uri(self):
130
131=== modified file 'lib/lp/answers/vocabulary.py'
132--- lib/lp/answers/vocabulary.py 2010-08-20 20:31:18 +0000
133+++ lib/lp/answers/vocabulary.py 2010-12-03 00:04:45 +0000
134@@ -24,6 +24,7 @@
135 implements(IHugeVocabulary)
136
137 displayname = 'Select a FAQ'
138+ step_title = 'Search'
139
140 def __init__(self, context):
141 """Create a new vocabulary for the context.
142@@ -72,5 +73,3 @@
143 """See `IHugeVocabulary`."""
144 results = self.context.findSimilarFAQs(query)
145 return CountableIterator(results.count(), results, self.toTerm)
146-
147-
148
149=== added directory 'lib/lp/app/widgets'
150=== added file 'lib/lp/app/widgets/__init__.py'
151=== added directory 'lib/lp/app/widgets/tests'
152=== added file 'lib/lp/app/widgets/tests/__init__.py'
153=== added file 'lib/lp/app/widgets/tests/test_popup.py'
154--- lib/lp/app/widgets/tests/test_popup.py 1970-01-01 00:00:00 +0000
155+++ lib/lp/app/widgets/tests/test_popup.py 2010-12-03 00:04:45 +0000
156@@ -0,0 +1,43 @@
157+# Copyright 2010 Canonical Ltd. This software is licensed under the
158+# GNU Affero General Public License version 3 (see the file LICENSE).
159+
160+__metaclass__ = type
161+
162+from zope.schema.vocabulary import getVocabularyRegistry
163+
164+from canonical.launchpad.webapp.servers import LaunchpadTestRequest
165+from canonical.testing.layers import DatabaseFunctionalLayer
166+from canonical.widgets.popup import VocabularyPickerWidget
167+from lp.registry.interfaces.person import ITeam
168+from lp.testing import TestCaseWithFactory
169+
170+
171+class TestVocabularyPickerWidget(TestCaseWithFactory):
172+
173+ layer = DatabaseFunctionalLayer
174+
175+ def setUp(self):
176+ super(TestVocabularyPickerWidget, self).setUp()
177+ context = self.factory.makeTeam()
178+ field = ITeam['teamowner']
179+ self.bound_field = field.bind(context)
180+ vocabulary_registry = getVocabularyRegistry()
181+ self.vocabulary = vocabulary_registry.get(context, 'ValidTeamOwner')
182+ self.request = LaunchpadTestRequest()
183+
184+ def test_js_template_args(self):
185+ picker_widget = VocabularyPickerWidget(
186+ self.bound_field, self.vocabulary, self.request)
187+ js_template_args = picker_widget.js_template_args()
188+ self.assertEqual(
189+ 'ValidTeamOwner', js_template_args['vocabulary'])
190+ self.assertEqual(
191+ self.vocabulary.displayname, js_template_args['header'])
192+ self.assertEqual(
193+ self.vocabulary.step_title, js_template_args['step_title'])
194+ self.assertEqual(
195+ 'show-widget-field-teamowner', js_template_args['show_widget_id'])
196+ self.assertEqual(
197+ 'field.teamowner', js_template_args['input_id'])
198+ self.assertEqual(
199+ None, js_template_args['extra_no_results_message'])
200
201=== modified file 'lib/lp/blueprints/vocabularies/specificationdependency.py'
202--- lib/lp/blueprints/vocabularies/specificationdependency.py 2010-11-01 03:36:04 +0000
203+++ lib/lp/blueprints/vocabularies/specificationdependency.py 2010-12-03 00:04:45 +0000
204@@ -58,6 +58,7 @@
205 _table = Specification
206 _orderBy = 'name'
207 displayname = 'Select a blueprint'
208+ step_title = 'Search'
209
210 def _is_valid_candidate(self, spec, check_target=False):
211 """Is `spec` a valid candidate spec for self.context?
212
213=== modified file 'lib/lp/code/vocabularies/branch.py'
214--- lib/lp/code/vocabularies/branch.py 2010-09-20 01:06:36 +0000
215+++ lib/lp/code/vocabularies/branch.py 2010-12-03 00:04:45 +0000
216@@ -44,6 +44,7 @@
217 _table = Branch
218 _orderBy = ['name', 'id']
219 displayname = 'Select a branch'
220+ step_title = 'Search'
221
222 def toTerm(self, branch):
223 """The display should include the URL if there is one."""
224
225=== modified file 'lib/lp/registry/browser/configure.zcml'
226--- lib/lp/registry/browser/configure.zcml 2010-11-30 20:34:31 +0000
227+++ lib/lp/registry/browser/configure.zcml 2010-12-03 00:04:45 +0000
228@@ -1127,7 +1127,7 @@
229 template="../templates/team-portlet-map.pt"/>
230 <browser:page
231 for="lp.registry.interfaces.person.ITeam"
232- class="lp.registry.browser.person.PersonIndexView"
233+ class="lp.registry.browser.person.TeamIndexView"
234 permission="zope.Public"
235 name="+portlet-membership"
236 template="../templates/team-portlet-membership.pt"/>
237
238=== modified file 'lib/lp/registry/browser/person.py'
239--- lib/lp/registry/browser/person.py 2010-11-30 12:53:20 +0000
240+++ lib/lp/registry/browser/person.py 2010-12-03 00:04:45 +0000
241@@ -3567,6 +3567,13 @@
242 return 'portlet'
243 return 'portlet private'
244
245+ @property
246+ def add_member_step_title(self):
247+ """A string for setup_add_member_handler with escaped quotes."""
248+ vocabulary_registry = getVocabularyRegistry()
249+ vocabulary = vocabulary_registry.get(self.context, 'ValidTeamMember')
250+ return vocabulary.step_title.replace("'", "\\'").replace('"', '\\"')
251+
252
253 class PersonCodeOfConductEditView(LaunchpadView):
254 """View for the ~person/+codesofconduct pages."""
255
256=== modified file 'lib/lp/registry/browser/tests/test_team.py'
257--- lib/lp/registry/browser/tests/test_team.py 2010-11-23 04:42:24 +0000
258+++ lib/lp/registry/browser/tests/test_team.py 2010-12-03 00:04:45 +0000
259@@ -135,3 +135,17 @@
260 self.assertEqual(
261 "You can't add a team that doesn't have any active members.",
262 view.errors[0])
263+
264+
265+class TestTeamIndexView(TestCaseWithFactory):
266+
267+ layer = DatabaseFunctionalLayer
268+
269+ def setUp(self):
270+ super(TestTeamIndexView, self).setUp()
271+ self.team = self.factory.makeTeam(name='test-team')
272+ login_person(self.team.teamowner)
273+
274+ def test_add_member_step_title(self):
275+ view = create_initialized_view(self.team, '+index')
276+ self.assertEqual('Search', view.add_member_step_title)
277
278=== modified file 'lib/lp/registry/doc/vocabularies.txt'
279--- lib/lp/registry/doc/vocabularies.txt 2010-11-05 14:35:36 +0000
280+++ lib/lp/registry/doc/vocabularies.txt 2010-12-03 00:04:45 +0000
281@@ -1052,103 +1052,11 @@
282 [(u'Ubuntu Team', u'Mark Shuttleworth')]
283
284
285-=== ValidTeamMember ===
286-
287-With the exception of all teams that have this team as a member and the
288-team itself, all valid persons and teams are valid members.
289-
290- >>> login('foo.bar@canonical.com')
291- >>> team = person_set.getByName('ubuntu-team')
292- >>> team2 = person_set.getByName('guadamen')
293- >>> person = person_set.getByName('name16')
294-
295-ValidTeamMember needs a context:
296-
297- >>> vocab = get_naked_vocab(None, "ValidTeamMember")
298- Traceback (most recent call last):
299- ...
300- AssertionError: ...
301-
302-ValidTeamMember's context must implement ITeam:
303-
304- >>> vocab = get_naked_vocab(person, "ValidTeamMember")
305- Traceback (most recent call last):
306- ...
307- AssertionError: ...
308-
309-'name16' is a valid member for 'ubuntu-team':
310-
311- >>> vocab = get_naked_vocab(team, "ValidTeamMember")
312- >>> vocab.displayname
313- 'Select a Person or Team'
314-
315- >>> person in vocab
316- True
317- >>> [person.name for person in vocab.search('foo.bar')]
318- [u'name16']
319-
320-'ubuntu-team' is not a valid member for itself:
321-
322- >>> team in vocab
323- False
324- >>> [person.name for person in vocab.search('ubuntu-team')]
325- [u'name18', u'ubuntu-security']
326-
327-'ubuntu-team' is a member of 'guadamen', so 'guadamen' can't be a member
328-of 'ubuntu-team'.
329-
330- >>> team2 in vocab
331- False
332- >>> [person.name for person in vocab.search('guadamen')]
333- []
334-
335-
336-=== ValidTeamOwner ===
337-
338-With the exception of the team itself and all teams owned by that team,
339-all valid persons and teams are valid owners for the team in context.
340-
341-ValidTeamOwner needs a context.
342-
343- >>> vocab = get_naked_vocab(None, "ValidTeamOwner")
344- Traceback (most recent call last):
345- ...
346- AssertionError: ...
347-
348-ValidTeamOwner's context must provide IPerson or IPersonSet.
349-
350- >>> get_naked_vocab(team, "ValidTeamOwner")
351- <...ValidTeamOwnerVocabulary...
352- >>> get_naked_vocab(person_set, "ValidTeamOwner")
353- <...ValidTeamOwnerVocabulary...
354- >>> get_naked_vocab(firefox, "ValidTeamOwner")
355- Traceback (most recent call last):
356- ...
357- AssertionError: ...
358-
359-'ubuntu-team' is not a valid owner for itself.
360-
361- >>> vocab = get_naked_vocab(team, "ValidTeamOwner")
362- >>> vocab.displayname
363- 'Select a Person or Team'
364-
365- >>> team in vocab
366- False
367- >>> [person.name for person in vocab.search('ubuntu-team')]
368- [u'name18', u'ubuntu-security']
369-
370-'name16' is a valid owner for 'ubuntu-team'.
371-
372- >>> person in vocab
373- True
374- >>> [person.name for person in vocab.search('foo.bar')]
375- [u'name16']
376-
377-
378 === ValidPerson ===
379
380 All 'valid' persons who are not a team.
381
382+ >>> login('foo.bar@canonical.com')
383 >>> vocab = get_naked_vocab(None, "ValidPerson")
384 >>> vocab.displayname
385 'Select a Person'
386
387=== modified file 'lib/lp/registry/javascript/team.js'
388--- lib/lp/registry/javascript/team.js 2010-10-21 22:06:57 +0000
389+++ lib/lp/registry/javascript/team.js 2010-12-03 00:04:45 +0000
390@@ -14,14 +14,14 @@
391 *
392 * @method setup_add_member_handler
393 */
394-module.setup_add_member_handler = function() {
395+module.setup_add_member_handler = function(step_title) {
396 if (Y.UA.ie) {
397 return;
398 }
399
400 var config = {
401 header: 'Add a member',
402- step_title: 'Search',
403+ step_title: step_title,
404 picker_activator: '.menu-link-add_member'
405 };
406
407
408=== modified file 'lib/lp/registry/templates/team-portlet-membership.pt'
409--- lib/lp/registry/templates/team-portlet-membership.pt 2010-11-10 15:33:47 +0000
410+++ lib/lp/registry/templates/team-portlet-membership.pt 2010-12-03 00:04:45 +0000
411@@ -101,14 +101,16 @@
412 <table style="margin: 0px 0px .5em 0px;">
413 <tr>
414 <td style="padding: 0px 1em 1em 0px;"
415- tal:define="link context/menu:overview/add_member"
416+ tal:define="link context/menu:overview/add_member;
417+ step_title view/add_member_step_title;"
418 tal:condition="link/enabled">
419 <script type="text/javascript"
420 tal:content="string:
421 LPS.use('lp.registry.team', function(Y) {
422 Y.on('load',
423 function(e) {
424- Y.lp.registry.team.setup_add_member_handler();
425+ Y.lp.registry.team.setup_add_member_handler(
426+ '${step_title}');
427 },
428 window);
429 });
430
431=== modified file 'lib/lp/registry/tests/test_person_vocabularies.py'
432--- lib/lp/registry/tests/test_person_vocabularies.py 2010-10-20 15:45:40 +0000
433+++ lib/lp/registry/tests/test_person_vocabularies.py 2010-12-03 00:04:45 +0000
434@@ -11,36 +11,117 @@
435
436 from canonical.launchpad.ftests import login_person
437 from canonical.testing.layers import DatabaseFunctionalLayer
438-from lp.registry.interfaces.person import PersonVisibility
439+from lp.registry.interfaces.person import (
440+ PersonVisibility,
441+ TeamSubscriptionPolicy,
442+ )
443 from lp.testing import TestCaseWithFactory
444
445
446-class TestValidTeamMemberVocabulary(TestCaseWithFactory):
447+class VocabularyTestBase:
448+
449+ vocabulary_name = None
450+
451+ def setUp(self):
452+ super(VocabularyTestBase, self).setUp()
453+ self.vocabulary_registry = getVocabularyRegistry()
454+
455+ def getVocabulary(self, context):
456+ return self.vocabulary_registry.get(context, self.vocabulary_name)
457+
458+ def searchVocabulary(self, context, text):
459+ Store.of(context).flush()
460+ vocabulary = self.getVocabulary(context)
461+ return removeSecurityProxy(vocabulary)._doSearch(text)
462+
463+ def test_open_team_cannot_be_a_member_of_a_closed_team(self):
464+ context_team = self.factory.makeTeam(
465+ subscription_policy=TeamSubscriptionPolicy.MODERATED)
466+ open_team = self.factory.makeTeam(
467+ subscription_policy=TeamSubscriptionPolicy.OPEN)
468+ moderated_team = self.factory.makeTeam(
469+ subscription_policy=TeamSubscriptionPolicy.MODERATED)
470+ restricted_team = self.factory.makeTeam(
471+ subscription_policy=TeamSubscriptionPolicy.RESTRICTED)
472+ user = self.factory.makePerson()
473+ all_possible_members = self.searchVocabulary(context_team, '')
474+ self.assertNotIn(open_team, all_possible_members)
475+ self.assertIn(moderated_team, all_possible_members)
476+ self.assertIn(restricted_team, all_possible_members)
477+ self.assertIn(user, all_possible_members)
478+
479+ def test_open_team_can_be_a_member_of_an_open_team(self):
480+ context_team = self.factory.makeTeam(
481+ subscription_policy=TeamSubscriptionPolicy.OPEN)
482+ open_team = self.factory.makeTeam(
483+ subscription_policy=TeamSubscriptionPolicy.OPEN)
484+ moderated_team = self.factory.makeTeam(
485+ subscription_policy=TeamSubscriptionPolicy.MODERATED)
486+ restricted_team = self.factory.makeTeam(
487+ subscription_policy=TeamSubscriptionPolicy.RESTRICTED)
488+ user = self.factory.makePerson()
489+ all_possible_members = self.searchVocabulary(context_team, '')
490+ self.assertIn(open_team, all_possible_members)
491+ self.assertIn(moderated_team, all_possible_members)
492+ self.assertIn(restricted_team, all_possible_members)
493+ self.assertIn(user, all_possible_members)
494+
495+ def test_vocabulary_displayname(self):
496+ context_team = self.factory.makeTeam(
497+ subscription_policy=TeamSubscriptionPolicy.OPEN)
498+ vocabulary = self.getVocabulary(context_team)
499+ self.assertEqual(
500+ 'Select a Team or Person', vocabulary.displayname)
501+
502+ def test_open_team_vocabulary_step_title(self):
503+ context_team = self.factory.makeTeam(
504+ subscription_policy=TeamSubscriptionPolicy.OPEN)
505+ vocabulary = self.getVocabulary(context_team)
506+ self.assertEqual('Search', vocabulary.step_title)
507+
508+ def test_closed_team_vocabulary_step_title(self):
509+ context_team = self.factory.makeTeam(
510+ subscription_policy=TeamSubscriptionPolicy.MODERATED)
511+ vocabulary = self.getVocabulary(context_team)
512+ self.assertEqual(
513+ 'Search for a restricted team, a moderated team, or a person',
514+ vocabulary.step_title)
515+
516+
517+class TestValidTeamMemberVocabulary(VocabularyTestBase, TestCaseWithFactory):
518 """Test that the ValidTeamMemberVocabulary behaves as expected."""
519
520 layer = DatabaseFunctionalLayer
521-
522- def searchVocabulary(self, team, text):
523- vocabulary_registry = getVocabularyRegistry()
524- naked_vocabulary = removeSecurityProxy(
525- vocabulary_registry.get(team, 'ValidTeamMember'))
526- return naked_vocabulary._doSearch(text)
527+ vocabulary_name = 'ValidTeamMember'
528
529 def test_public_team_cannot_be_a_member_of_itself(self):
530 # A public team should be filtered by the vocab.extra_clause
531 # when provided a search term.
532- team_owner = self.factory.makePerson()
533- login_person(team_owner)
534- team = self.factory.makeTeam(owner=team_owner)
535- Store.of(team).flush()
536- self.assertFalse(team in self.searchVocabulary(team, team.name))
537+ team = self.factory.makeTeam()
538+ self.assertNotIn(team, self.searchVocabulary(team, team.name))
539
540 def test_private_team_cannot_be_a_member_of_itself(self):
541 # A private team should be filtered by the vocab.extra_clause
542 # when provided a search term.
543- team_owner = self.factory.makePerson()
544- login_person(team_owner)
545 team = self.factory.makeTeam(
546- owner=team_owner, visibility=PersonVisibility.PRIVATE)
547- Store.of(team).flush()
548- self.assertFalse(team in self.searchVocabulary(team, team.name))
549+ visibility=PersonVisibility.PRIVATE)
550+ login_person(team.teamowner)
551+ self.assertNotIn(team, self.searchVocabulary(team, team.name))
552+
553+
554+class TestValidTeamOwnerVocabulary(VocabularyTestBase, TestCaseWithFactory):
555+ """Test that the ValidTeamOwnerVocabulary behaves as expected."""
556+
557+ layer = DatabaseFunctionalLayer
558+ vocabulary_name = 'ValidTeamOwner'
559+
560+ def test_team_cannot_own_itself(self):
561+ context_team = self.factory.makeTeam()
562+ results = self.searchVocabulary(context_team, context_team.name)
563+ self.assertNotIn(context_team, results)
564+
565+ def test_team_cannot_own_its_owner(self):
566+ context_team = self.factory.makeTeam()
567+ owned_team = self.factory.makeTeam(owner=context_team)
568+ results = self.searchVocabulary(context_team, owned_team.name)
569+ self.assertNotIn(owned_team, results)
570
571=== modified file 'lib/lp/registry/vocabularies.py'
572--- lib/lp/registry/vocabularies.py 2010-11-09 08:38:23 +0000
573+++ lib/lp/registry/vocabularies.py 2010-12-03 00:04:45 +0000
574@@ -47,6 +47,7 @@
575 'SourcePackageNameVocabulary',
576 'UserTeamsParticipationVocabulary',
577 'UserTeamsParticipationPlusSelfVocabulary',
578+ 'ValidPersonOrClosedTeamVocabulary',
579 'ValidPersonOrTeamVocabulary',
580 'ValidPersonVocabulary',
581 'ValidTeamMemberVocabulary',
582@@ -143,6 +144,7 @@
583 IPersonSet,
584 ITeam,
585 PersonVisibility,
586+ TeamSubscriptionPolicy,
587 )
588 from lp.registry.interfaces.pillar import IPillarName
589 from lp.registry.interfaces.product import (
590@@ -219,6 +221,7 @@
591 class ProductVocabulary(SQLObjectVocabularyBase):
592 """All `IProduct` objects vocabulary."""
593 implements(IHugeVocabulary)
594+ step_title = 'Search'
595
596 _table = Product
597 _orderBy = 'displayname'
598@@ -271,6 +274,7 @@
599 _table = ProjectGroup
600 _orderBy = 'displayname'
601 displayname = 'Select a project group'
602+ step_title = 'Search'
603
604 def __contains__(self, obj):
605 where = "active='t' and id=%d"
606@@ -361,6 +365,7 @@
607
608 _orderBy = ['displayname']
609 displayname = 'Select a Person or Team'
610+ step_title = 'Search'
611
612 def __contains__(self, obj):
613 return obj in self._select()
614@@ -391,6 +396,7 @@
615
616 _orderBy = ['displayname']
617 displayname = 'Select a Person to Merge'
618+ step_title = 'Search'
619 must_have_email = True
620
621 def __contains__(self, obj):
622@@ -439,7 +445,7 @@
623 implements(IHugeVocabulary)
624
625 displayname = 'Select a Person or Team'
626-
627+ step_title = 'Search'
628 # This is what subclasses must change if they want any extra filtering of
629 # results.
630 extra_clause = True
631@@ -722,11 +728,32 @@
632 cache_table_name = 'ValidPersonCache'
633
634
635-class ValidTeamMemberVocabulary(ValidPersonOrTeamVocabulary):
636+class TeamVocabularyMixin:
637+ """Common methods for team vocabularies."""
638+
639+ displayname = 'Select a Team or Person'
640+
641+ @property
642+ def is_closed_team(self):
643+ return self.team.subscriptionpolicy != TeamSubscriptionPolicy.OPEN
644+
645+ @property
646+ def step_title(self):
647+ """See `IHugeVocabulary`."""
648+ if self.is_closed_team:
649+ return (
650+ 'Search for a restricted team, a moderated team, or a person')
651+ else:
652+ return 'Search'
653+
654+
655+class ValidTeamMemberVocabulary(TeamVocabularyMixin,
656+ ValidPersonOrTeamVocabulary):
657 """The set of valid members of a given team.
658
659 With the exception of all teams that have this team as a member and the
660- team itself, all valid persons and teams are valid members.
661+ team itself, all valid persons and teams are valid members. Restricted
662+ and moderated teams cannot have open teams as members.
663 """
664
665 def __init__(self, context):
666@@ -740,19 +767,29 @@
667 "Got %s" % str(context))
668
669 ValidPersonOrTeamVocabulary.__init__(self, context)
670- self.extra_clause = """
671+
672+ @property
673+ def extra_clause(self):
674+ clause = SQL("""
675 Person.id NOT IN (
676 SELECT team FROM TeamParticipation
677 WHERE person = %d
678 )
679- """ % self.team.id
680-
681-
682-class ValidTeamOwnerVocabulary(ValidPersonOrTeamVocabulary):
683+ """ % self.team.id)
684+ if self.is_closed_team:
685+ clause = And(
686+ clause,
687+ Person.subscriptionpolicy != TeamSubscriptionPolicy.OPEN)
688+ return clause
689+
690+
691+class ValidTeamOwnerVocabulary(TeamVocabularyMixin,
692+ ValidPersonOrTeamVocabulary):
693 """The set of Persons/Teams that can be owner of a team.
694
695 With the exception of the team itself and all teams owned by that team,
696- all valid persons and teams are valid owners for the team.
697+ all valid persons and teams are valid owners for the team. Restricted
698+ and moderated teams cannot have open teams as members.
699 """
700
701 def __init__(self, context):
702@@ -760,9 +797,7 @@
703 raise AssertionError('ValidTeamOwnerVocabulary needs a context.')
704
705 if IPerson.providedBy(context):
706- self.extra_clause = """
707- (person.teamowner != %d OR person.teamowner IS NULL) AND
708- person.id != %d""" % (context.id, context.id)
709+ self.team = context
710 elif IPersonSet.providedBy(context):
711 # The context is an IPersonSet, which means we're creating a new
712 # team and thus we don't need any extra_clause --any valid person
713@@ -774,6 +809,17 @@
714 "or IPersonSet.")
715 ValidPersonOrTeamVocabulary.__init__(self, context)
716
717+ @property
718+ def extra_clause(self):
719+ clause = SQL("""
720+ (person.teamowner != %d OR person.teamowner IS NULL) AND
721+ person.id != %d""" % (self.team.id, self.team.id))
722+ if self.is_closed_team:
723+ clause = And(
724+ clause,
725+ Person.subscriptionpolicy != TeamSubscriptionPolicy.OPEN)
726+ return clause
727+
728
729 class AllUserTeamsParticipationVocabulary(ValidTeamVocabulary):
730 """The set of teams where the current user is a member.
731@@ -844,6 +890,7 @@
732 implements(IHugeVocabulary)
733
734 displayname = 'Select an active mailing list.'
735+ step_title = 'Search'
736
737 def __init__(self, context):
738 assert context is None, (
739@@ -965,6 +1012,7 @@
740 implements(IHugeVocabulary)
741
742 displayname = 'Select a Product Release'
743+ step_title = 'Search'
744 _table = ProductRelease
745 # XXX carlos Perello Marin 2005-05-16 bugs=687:
746 # Sorting by version won't give the expected results, because it's just a
747@@ -1032,6 +1080,7 @@
748 implements(IHugeVocabulary)
749
750 displayname = 'Select a Release Series'
751+ step_title = 'Search'
752 _table = ProductSeries
753 _order_by = [Product.name, ProductSeries.name]
754 _clauseTables = ['Product']
755@@ -1265,6 +1314,7 @@
756
757 _table = Product
758 _orderBy = 'displayname'
759+ step_title = 'Search'
760
761 @property
762 def displayname(self):