Merge lp:~sinzui/launchpad/person-owned-teams into lp:launchpad

Proposed by Curtis Hovey
Status: Merged
Approved by: Curtis Hovey
Approved revision: no longer in the source branch.
Merged at revision: 16363
Proposed branch: lp:~sinzui/launchpad/person-owned-teams
Merge into: lp:launchpad
Diff against target: 394 lines (+216/-5)
10 files modified
lib/lp/registry/browser/configure.zcml (+6/-0)
lib/lp/registry/browser/person.py (+20/-1)
lib/lp/registry/browser/tests/test_person.py (+43/-0)
lib/lp/registry/interfaces/person.py (+13/-1)
lib/lp/registry/model/person.py (+12/-0)
lib/lp/registry/templates/person-owned-teams.pt (+70/-0)
lib/lp/registry/templates/person-related-software-navlinks.pt (+4/-0)
lib/lp/registry/templates/product-portlet-requires-subscription.pt (+3/-3)
lib/lp/registry/tests/test_person.py (+44/-0)
lib/lp/soyuz/stories/soyuz/xx-person-packages.txt (+1/-0)
To merge this branch: bzr merge lp:~sinzui/launchpad/person-owned-teams
Reviewer Review Type Date Requested Status
Benji York (community) code Approve
Review via email: mp+139091@code.launchpad.net

Commit message

List teams a person owns.

Description of the change

Team owners are not necessarily team members. All code that lists teams
for people are based on membership. There is no way to discover which
teams a person owns. This is a problem for cases where a person leaves
an organisation -- the team owner does not show up when auditing,
and owners can add themselves back to the team to gain team privileges.

RULES

    Pre-implementation: no one
    * Add a method to IPerson to get the teams a person owns. The
      method must take a user argument to filter out private teams the
      observing user cannot see.
    * Add a related teams page to the related packages/projects group
      of pages. The page must batch the teams.

QA

    * Visit https://qastaging.launchpad.net/~
    * Verify there is a link to owned teams.
    * View the link's page.
    * Verify the page lists the teams.

    * Run this script:
    {{{
    from launchpadlib.launchpad import Launchpad
    lp = Launchpad.login_with(
        'testing', service_root='https://api.qastaging.launchpad.net',
        version='devel')
    person = lp.people['sinzui']
    teams = person.getOwnedTeams()
    for team in teams:
        print team.name

    lp = Launchpad.login_anonymously(
        'testing', service_root='https://api.qastaging.launchpad.net',
        version='devel')
    person = lp.people['sinzui']
    teams = person.getOwnedTeams()
    for team in teams:
        print team.name
    }}}

LINT

    lib/lp/registry/browser/configure.zcml
    lib/lp/registry/browser/person.py
    lib/lp/registry/browser/tests/test_person.py
    lib/lp/registry/interfaces/person.py
    lib/lp/registry/model/person.py
    lib/lp/registry/templates/person-owned-teams.pt
    lib/lp/registry/templates/person-related-software-navlinks.pt
    lib/lp/registry/tests/test_person.py

LoC

    I have more than 10,000 lines of credit this week.

TEST

    ./bin/test -vc -t OwnedTeams lp.registry

IMPLEMENTATION

I added getOwnedTeams to IPerson that return the teams for a Person that
a user is permitted to see. I exported it to use the requesting user as
the user passed to the method.
    lib/lp/registry/interfaces/person.py
    lib/lp/registry/model/person.py
    lib/lp/registry/tests/test_person.py

I added PersonOwnedTeamsView to the related-software group of pages. I
then added a link to the IPersonRelatedSoftwareMenu to complete the
integration.
    lib/lp/registry/browser/configure.zcml
    lib/lp/registry/browser/person.py
    lib/lp/registry/browser/tests/test_person.py
    lib/lp/registry/templates/person-owned-teams.pt
    lib/lp/registry/templates/person-related-software-navlinks.pt

To post a comment you must log in.
Revision history for this message
Benji York (benji) wrote :

This branch looks good. I had two small observations:

A tad more commentary in PersonOwnedTeamsViewTestCase would be good.

It looks like line 267 of the diff should be dedented two spaces.

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/registry/browser/configure.zcml'
2--- lib/lp/registry/browser/configure.zcml 2012-12-07 19:59:37 +0000
3+++ lib/lp/registry/browser/configure.zcml 2012-12-11 19:03:23 +0000
4@@ -909,6 +909,12 @@
5 template="../templates/person-related-projects.pt"/>
6 <browser:page
7 for="lp.registry.interfaces.person.IPerson"
8+ permission="zope.Public"
9+ class="lp.registry.browser.person.PersonOwnedTeamsView"
10+ name="+owned-teams"
11+ template="../templates/person-owned-teams.pt"/>
12+ <browser:page
13+ for="lp.registry.interfaces.person.IPerson"
14 class="lp.registry.browser.person.PersonRelatedSoftwareView"
15 permission="zope.Public"
16 name="+related-software-navlinks"
17
18=== modified file 'lib/lp/registry/browser/person.py'
19--- lib/lp/registry/browser/person.py 2012-12-07 19:59:53 +0000
20+++ lib/lp/registry/browser/person.py 2012-12-11 19:03:23 +0000
21@@ -34,6 +34,7 @@
22 'PersonNavigation',
23 'PersonOAuthTokensView',
24 'PersonOverviewMenu',
25+ 'PersonOwnedTeamsView',
26 'PersonRdfContentsView',
27 'PersonRdfView',
28 'PersonRelatedSoftwareView',
29@@ -632,6 +633,11 @@
30 enabled = bool(self.person.getAffiliatedPillars(user))
31 return Link(target, text, enabled=enabled, icon='info')
32
33+ def owned_teams(self):
34+ target = '+owned-teams'
35+ text = 'Owned teams'
36+ return Link(target, text, icon='info')
37+
38 def subscriptions(self):
39 target = '+subscriptions'
40 text = 'Direct subscriptions'
41@@ -694,6 +700,7 @@
42 'activate_ppa',
43 'maintained',
44 'manage_vouchers',
45+ 'owned_teams',
46 'synchronised',
47 'view_ppa_subscriptions',
48 'ppa',
49@@ -837,7 +844,7 @@
50 usedfor = IPersonRelatedSoftwareMenu
51 facet = 'overview'
52 links = ('related_software_summary', 'maintained', 'uploaded', 'ppa',
53- 'synchronised', 'projects')
54+ 'synchronised', 'projects', 'owned_teams')
55
56 @property
57 def person(self):
58@@ -3778,6 +3785,18 @@
59 return "Related projects"
60
61
62+class PersonOwnedTeamsView(PersonRelatedSoftwareView):
63+ """View for +owned-teams."""
64+ page_title = "Owned teams"
65+
66+ def initialize(self):
67+ """Set up the batch navigation."""
68+ self.batchnav = BatchNavigator(
69+ self.context.getOwnedTeams(self.user), self.request)
70+ self.batchnav.setHeadings('team', 'teams')
71+ self.batch = list(self.batchnav.currentBatch())
72+
73+
74 class PersonOAuthTokensView(LaunchpadView):
75 """Where users can see/revoke their non-expired access tokens."""
76
77
78=== modified file 'lib/lp/registry/browser/tests/test_person.py'
79--- lib/lp/registry/browser/tests/test_person.py 2012-12-10 13:43:47 +0000
80+++ lib/lp/registry/browser/tests/test_person.py 2012-12-11 19:03:23 +0000
81@@ -79,6 +79,7 @@
82 from lp.testing.matchers import HasQueryCount
83 from lp.testing.pages import (
84 extract_text,
85+ find_tag_by_id,
86 setupBrowserForUser,
87 )
88 from lp.testing.views import (
89@@ -970,6 +971,48 @@
90 self.view.max_results_to_display)
91
92
93+class PersonOwnedTeamsViewTestCase(TestCaseWithFactory):
94+ """Test +owned-teams view."""
95+
96+ layer = DatabaseFunctionalLayer
97+
98+ def test_properties(self):
99+ # The batch is created when the view is initialized.
100+ owner = self.factory.makePerson()
101+ team = self.factory.makeTeam(owner=owner)
102+ view = create_initialized_view(owner, '+owned-teams')
103+ self.assertEqual('Owned teams', view.page_title)
104+ self.assertEqual('team', view.batchnav._singular_heading)
105+ self.assertEqual([team], view.batch)
106+
107+ def test_page_text_with_teams(self):
108+ # When the person owns teams, the page shows a a listing
109+ # table. There is always a link to the team participation page.
110+ owner = self.factory.makePerson(name='snarf')
111+ self.factory.makeTeam(owner=owner, name='pting')
112+ with person_logged_in(owner):
113+ view = create_initialized_view(
114+ owner, '+owned-teams', principal=owner)
115+ markup = view()
116+ soup = find_tag_by_id(markup, 'maincontent')
117+ participation_link = 'http://launchpad.dev/~snarf/+participation'
118+ self.assertIsNotNone(soup.find('a', {'href': participation_link}))
119+ self.assertIsNotNone(soup.find('table', {'id': 'owned-teams'}))
120+ self.assertIsNotNone(soup.find('a', {'href': '/~pting'}))
121+ self.assertIsNotNone(soup.find('table', {'class': 'upper-batch-nav'}))
122+ self.assertIsNotNone(soup.find('table', {'class': 'lower-batch-nav'}))
123+
124+ def test_page_text_without_teams(self):
125+ # When the person does not own teams, the page states the case.
126+ owner = self.factory.makePerson(name='pting')
127+ with person_logged_in(owner):
128+ view = create_initialized_view(
129+ owner, '+owned-teams', principal=owner)
130+ markup = view()
131+ soup = find_tag_by_id(markup, 'maincontent')
132+ self.assertIsNotNone(soup.find('p', {'id': 'no-teams'}))
133+
134+
135 class TestPersonSynchronisedPackagesView(TestCaseWithFactory):
136 """Test the synchronised packages view."""
137
138
139=== modified file 'lib/lp/registry/interfaces/person.py'
140--- lib/lp/registry/interfaces/person.py 2012-11-19 21:59:03 +0000
141+++ lib/lp/registry/interfaces/person.py 2012-12-11 19:03:23 +0000
142@@ -1301,6 +1301,17 @@
143 The person's membership may be direct or indirect.
144 """
145
146+ @call_with(user=REQUEST_USER)
147+ @operation_returns_collection_of(Interface) # Really ITeam.
148+ @export_read_operation()
149+ @operation_for_version("devel")
150+ def getOwnedTeams(user=None):
151+ """Return the teams that this person owns.
152+
153+ The iterator includes the teams that the user owns, but it not
154+ a member of.
155+ """
156+
157 def getAdministratedTeams():
158 """Return the teams that this person/team is an administrator of.
159
160@@ -1308,7 +1319,6 @@
161 member with admin privilege, or member of a team with such
162 privileges. It excludes teams which have been merged.
163 """
164-
165 def getTeamAdminsEmailAddresses():
166 """Return a set containing the email addresses of all administrators
167 of this team.
168@@ -2547,6 +2557,8 @@
169 # 'lazr.webservice.exported')['return_type'].value_type.schema = IPerson
170 IPersonViewRestricted['getMembersByStatus'].queryTaggedValue(
171 LAZR_WEBSERVICE_EXPORTED)['return_type'].value_type.schema = IPerson
172+IPersonViewRestricted['getOwnedTeams'].queryTaggedValue(
173+ LAZR_WEBSERVICE_EXPORTED)['return_type'].value_type.schema = ITeam
174
175 # Fix schema of ITeamMembership fields. Has to be done here because of
176 # circular dependencies.
177
178=== modified file 'lib/lp/registry/model/person.py'
179--- lib/lp/registry/model/person.py 2012-12-07 10:04:07 +0000
180+++ lib/lp/registry/model/person.py 2012-12-11 19:03:23 +0000
181@@ -1765,6 +1765,18 @@
182 tm.setExpirationDate(expires, reviewer)
183 tm.setStatus(status, reviewer, comment=comment)
184
185+ def getOwnedTeams(self, user=None):
186+ """See `IPerson`."""
187+ query = And(
188+ get_person_visibility_terms(user),
189+ Person.teamowner == self.id,
190+ Person.merged == None)
191+ store = IStore(Person)
192+ results = store.find(
193+ Person, query).order_by(
194+ Upper(Person.displayname), Upper(Person.name))
195+ return results
196+
197 @cachedproperty
198 def administrated_teams(self):
199 return list(self.getAdministratedTeams())
200
201=== added file 'lib/lp/registry/templates/person-owned-teams.pt'
202--- lib/lp/registry/templates/person-owned-teams.pt 1970-01-01 00:00:00 +0000
203+++ lib/lp/registry/templates/person-owned-teams.pt 2012-12-11 19:03:23 +0000
204@@ -0,0 +1,70 @@
205+<html
206+ xmlns="http://www.w3.org/1999/xhtml"
207+ xmlns:tal="http://xml.zope.org/namespaces/tal"
208+ xmlns:metal="http://xml.zope.org/namespaces/metal"
209+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
210+ metal:use-macro="view/macro:page/main_only"
211+ i18n:domain="launchpad"
212+>
213+
214+<body>
215+
216+<div metal:fill-slot="heading">
217+ <h1 tal:content="view/page_title"/>
218+</div>
219+
220+<div metal:fill-slot="main">
221+ <div class="top-portlet">
222+ <tal:navlinks replace="structure context/@@+related-software-navlinks"/>
223+ </div>
224+
225+
226+ <div id="teams" class="top-portlet">
227+ <p>
228+ Team owners are not always team members. The
229+ <a tal:attributes="href context/menu:overview/memberships/fmt:url">team
230+ participation</a> page shows the teams that
231+ <tal:name replace="context/displayname" /> is a member of.
232+ </p>
233+
234+ <tal:navigation_top
235+ replace="structure view/batchnav/@@+navigation-links-upper" />
236+
237+ <tal:maintained-packages define="teams view/batch">
238+
239+ <table id="owned-teams" class="listing"
240+ tal:condition="teams">
241+ <cols><col width="30%" /><col width="auto" /></cols>
242+
243+ <thead>
244+ <tr>
245+ <th>Name</th>
246+ <th>Summary</th>
247+ </tr>
248+ </thead>
249+
250+ <tbody>
251+ <tr tal:repeat="team teams">
252+ <td>
253+ <a tal:replace="structure team/fmt:link" />
254+ </td>
255+ <td>
256+ <tal:summary replace="team/description/fmt:shorten/60"/>
257+ </td>
258+ </tr>
259+ </tbody>
260+ </table>
261+
262+ <tal:navigation_bottom
263+ replace="structure view/batchnav/@@+navigation-links-lower" />
264+
265+ <p id="no-teams"
266+ tal:condition="not: teams">
267+ <span tal:replace="context/displayname">Foo Bar</span>
268+ doesn't own any teams.
269+ </p>
270+ </tal:maintained-packages>
271+ </div>
272+</div>
273+</body>
274+</html>
275
276=== modified file 'lib/lp/registry/templates/person-related-software-navlinks.pt'
277--- lib/lp/registry/templates/person-related-software-navlinks.pt 2011-09-23 07:49:54 +0000
278+++ lib/lp/registry/templates/person-related-software-navlinks.pt 2012-12-11 19:03:23 +0000
279@@ -29,6 +29,10 @@
280 tal:define="link view/menu:navigation/projects"
281 tal:condition="link/enabled"
282 tal:content="structure link/fmt:link" />
283+ <li
284+ tal:define="link view/menu:navigation/owned_teams"
285+ tal:condition="link/enabled"
286+ tal:content="structure link/fmt:link" />
287 </ul>
288
289 </tal:root>
290
291=== modified file 'lib/lp/registry/templates/product-portlet-requires-subscription.pt'
292--- lib/lp/registry/templates/product-portlet-requires-subscription.pt 2012-12-07 19:45:34 +0000
293+++ lib/lp/registry/templates/product-portlet-requires-subscription.pt 2012-12-11 19:03:23 +0000
294@@ -21,7 +21,7 @@
295 <span
296 tal:define="user_menu view/user/menu:overview"
297 tal:condition="context/qualifies_for_free_hosting">
298- <br />Purchase a commercial subcription from your
299+ <br />Purchase a commercial subscription from your
300 <a tal:replace="structure user_menu/manage_vouchers/fmt:link" />
301 page.
302 </span>
303@@ -36,7 +36,7 @@
304
305 <ul class="bulleted" style="clear:left;">
306 <li tal:define="user_menu view/user/menu:overview">
307- Purchase a commercial subcription from your
308+ Purchase a commercial subscription from your
309 <a tal:replace="structure user_menu/manage_vouchers/fmt:link" />
310 page.
311 </li>
312@@ -45,7 +45,7 @@
313 <a class="sprite maybe"
314 tal:define="config modules/lp.services.config/config"
315 tal:attributes="href config/commercial/licensing_policy_url"
316- >Launchpad's licensing policies</a>
317+ >Launchpad's licensing policies</a>.
318 </li>
319 </ul>
320 </tal:proprietary>
321
322=== modified file 'lib/lp/registry/tests/test_person.py'
323--- lib/lp/registry/tests/test_person.py 2012-11-26 08:33:03 +0000
324+++ lib/lp/registry/tests/test_person.py 2012-12-11 19:03:23 +0000
325@@ -70,6 +70,7 @@
326 from lp.soyuz.enums import ArchivePurpose
327 from lp.testing import (
328 celebrity_logged_in,
329+ launchpadlib_for,
330 login,
331 login_person,
332 logout,
333@@ -266,6 +267,49 @@
334 retrieved_members = sorted(list(self.a_team.all_members_prepopulated))
335 self.assertEqual(expected_members, retrieved_members)
336
337+ def test_getOwnedTeams(self):
338+ # The iterator contains the teams that person owns, regardless of
339+ # membership.
340+ owner = self.a_team.teamowner
341+ with person_logged_in(owner):
342+ owner.leave(self.a_team)
343+ results = list(owner.getOwnedTeams(self.user))
344+ self.assertEqual([self.a_team], results)
345+
346+ def test_getOwnedTeams_visibility(self):
347+ # The iterator contains the teams that the user can see.
348+ owner = self.a_team.teamowner
349+ p_team = self.factory.makeTeam(
350+ name='p', owner=owner, visibility=PersonVisibility.PRIVATE)
351+ results = list(owner.getOwnedTeams(self.user))
352+ self.assertEqual([self.a_team], results)
353+ results = list(owner.getOwnedTeams(owner))
354+ self.assertEqual([self.a_team, p_team], results)
355+
356+ def test_getOwnedTeams_webservice(self):
357+ # The user in the interaction is used as the user arg.
358+ owner = self.a_team.teamowner
359+ self.factory.makeTeam(
360+ name='p', owner=owner, visibility=PersonVisibility.PRIVATE)
361+ owner_name = owner.name
362+ lp = launchpadlib_for('test', person=self.user)
363+ lp_owner = lp.people[owner_name]
364+ results = lp_owner.getOwnedTeams()
365+ self.assertEqual(['a'], [t.name for t in results])
366+
367+ def test_getOwnedTeams_webservice_anonymous(self):
368+ # The user in the interaction is used as the user arg.
369+ # Anonymous scripts also do not reveal private teams.
370+ owner = self.a_team.teamowner
371+ self.factory.makeTeam(
372+ name='p', owner=owner, visibility=PersonVisibility.PRIVATE)
373+ owner_name = owner.name
374+ logout()
375+ lp = launchpadlib_for('test', person=None)
376+ lp_owner = lp.people[owner_name]
377+ results = lp_owner.getOwnedTeams()
378+ self.assertEqual(['a'], [t.name for t in results])
379+
380 def test_administrated_teams(self):
381 # The property Person.administrated_teams is a cached copy of
382 # the result of Person.getAdministratedTeams().
383
384=== modified file 'lib/lp/soyuz/stories/soyuz/xx-person-packages.txt'
385--- lib/lp/soyuz/stories/soyuz/xx-person-packages.txt 2012-11-19 07:31:45 +0000
386+++ lib/lp/soyuz/stories/soyuz/xx-person-packages.txt 2012-12-11 19:03:23 +0000
387@@ -20,6 +20,7 @@
388 Uploaded packages
389 Related PPA packages
390 Related projects
391+ Owned teams
392
393 >>> print browser.getLink("Maintained packages").url
394 http://launchpad.dev/~mark/+maintained-packages