Merge lp:~sinzui/launchpad/team-participation-0 into lp:launchpad

Proposed by Curtis Hovey
Status: Merged
Approved by: Graham Binns
Approved revision: no longer in the source branch.
Merged at revision: 10964
Proposed branch: lp:~sinzui/launchpad/team-participation-0
Merge into: lp:launchpad
Diff against target: 519 lines (+266/-166)
5 files modified
lib/lp/registry/browser/person.py (+43/-14)
lib/lp/registry/browser/tests/test_person_view.py (+113/-2)
lib/lp/registry/stories/team/xx-team-membership.txt (+45/-59)
lib/lp/registry/stories/teammembership/xx-private-membership.txt (+1/-51)
lib/lp/registry/templates/person-participation.pt (+64/-40)
To merge this branch: bzr merge lp:~sinzui/launchpad/team-participation-0
Reviewer Review Type Date Requested Status
Graham Binns (community) code Approve
Paul Hummer (community) ui Approve
Review via email: mp+26996@code.launchpad.net

Description of the change

This is my branch to improve the team participation page. I recently had
to delete a number of teams wrongly created by a user. His +participation
page could not help identify which teams he owned and admined, and which
had mailing lists. I am still not certain I fixed everything, but I hope
that when this branch lands, I can see.

I think this design satisfies my need and the two related bugs. If we
choose to land this design. We should consider reporting new bugs to
permit users to leave team from this page, and to edit their mailing
list subscriptions (this is the first time we have every shown a persons
mailing list subscriptions in one place)

    lp:~sinzui/launchpad/team-participation-0
    Diff size: 521
    Launchpad bug:
          https://bugs.launchpad.net/bugs/122530
          https://bugs.launchpad.net/bugs/276953
    Test command: ./bin/test -vv \
          -t TestPersonParticipationView
          -t xx-team-membership -t xx-private-membership
    Pre-implementation: no one
    Target release: 10.06

Improve the team participation page
------------------------------------

What you see: A page with a completely empty rightmost column, and which
doesn't tell you what role you play in each of the teams.

What you should see: A tabular listing of the teams you're a member of,
including whether you're the owner, an administrator, or an ordinary member

In addition, bug 276953 suggests allowing joining or creating teams.
There is not enough information on this page to support joining, the team
page is the place to do that. But allowing a user to create a team (at least
from his own participation page) would make it easier for users to create
them

Rules
-----

    * Create a single table of teams ordered by display name
    * The table lists
      * team icon and name,
      * membership date
      * roles (owner, admin, member)
      * indirect path to team
      * Subscribed to mailing list?
    * The roles are more complex than most people realise
      * You can be an owner, but not a member
      * The owner is implicitly an admin
      * indirect membership age for for the indirect team, not the user,
        We do not really know when a user joined a team when there are
        indirect memberships in the path.
    * It would be nice to have sortable headers.
    * Allow users to create teams from their team participation page.

QA
--

UI

    * http://people.canonical.com/~curtis/other-participation.png
    * http://people.canonical.com/~curtis/self-participation.png

Use verification

    * Visit https://edge.launchpad.net/~drsganesh/+participation
    * Verify you know which teams he is an owner, admin, member or indirect
      member of.
    * Verify you know which lists he is subscribed to
    * Verify you know when he joined..
    * Visit https://edge.launchpad.net/people/+me/+participation
    * Verify your roles.
    * Verify you can access the register a team page.
    * Verify you can access your email subscriptions page.

Lint
----

Linting changed files:
  lib/lp/registry/browser/person.py
  lib/lp/registry/browser/tests/test_person_view.py
  lib/lp/registry/stories/team/xx-team-membership.txt
  lib/lp/registry/stories/teammembership/xx-private-membership.txt
  lib/lp/registry/templates/person-participation.pt

Test
----

    * lib/lp/registry/browser/tests/test_person_view.py
    * lib/lp/registry/stories/team/xx-team-membership.txt
    * lib/lp/registry/stories/teammembership/xx-private-membership.txt

Implementation
--------------

    * lib/lp/registry/browser/person.py
    * lib/lp/registry/templates/person-participation.pt

To post a comment you must log in.
Revision history for this message
Paul Hummer (rockstar) wrote :

Thanks for the detailed description. It makes it easier to see how these changes directly affect the problem. I think "We should consider reporting new bugs to permit users to leave team from this page, and to edit their mailing list subscriptions (this is the first time we have every shown a persons mailing list subscriptions in one place)" is probably the right thing.

review: Approve (ui)
Revision history for this message
Graham Binns (gmb) wrote :

Hi Curtis,

This looks good to me with just one minor quibble (below).

> === modified file 'lib/lp/registry/stories/team/xx-team-membership.txt'
> --- lib/lp/registry/stories/team/xx-team-membership.txt 2010-01-15 13:36:09 +0000
> +++ lib/lp/registry/stories/team/xx-team-membership.txt 2010-06-07 23:25:40 +0000
> @@ -204,62 +204,49 @@
>[...SNIP...]
> + >>> print find_tag_by_id(content, 'participation-actions')
> + None
> +
> + >>> sample_browser = setupBrowser(auth="Basic <email address hidden>:test")

Why create a new browser here rather than using user_browser?

> + >>> sample_browser.open('http://launchpad.dev/~name12/+participation')
> + >>> actions = find_tag_by_id(
> + ... sample_browser.contents, 'participation-actions')
> + >>> print extract_text(actions)
> + Register a team
> + Change mailing list subscriptions
> +
> + >>> sample_browser.getLink('Register a team')
> + <Link ... url='http://.../people/+newteam'>
> + >>> sample_browser.getLink('Change mailing list subscriptions')
> + <Link ... url='http://.../~name12/+editemails'>
> +
>

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

On Tue, 2010-06-08 at 09:32 +0000, Graham Binns wrote:
>
> Why create a new browser here rather than using user_browser?

It one point, this part of the test was looking at sample person's page
in comparison to another browser. no-priv works fine in the current case
so I switched to no-priv.

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

Revision history for this message
Graham Binns (gmb) wrote :

On 8 Jun 2010, at 13:31, Curtis Hovey wrote:

> On Tue, 2010-06-08 at 09:32 +0000, Graham Binns wrote:
>>
>> Why create a new browser here rather than using user_browser?
>
> It one point, this part of the test was looking at sample person's page
> in comparison to another browser. no-priv works fine in the current case
> so I switched to no-priv.

Cool, works for me. r=me, then.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/registry/browser/person.py'
2--- lib/lp/registry/browser/person.py 2010-06-04 18:58:48 +0000
3+++ lib/lp/registry/browser/person.py 2010-06-08 12:58:26 +0000
4@@ -3072,22 +3072,51 @@
5 def label(self):
6 return 'Team participation for ' + self.context.displayname
7
8- @property
9- def indirect_teams_via(self):
10- """Information about indirect membership.
11+ def _asParticipation(self, membership, team_path=None):
12+ """Return a dict of participation information for the membership."""
13+ if team_path is None:
14+ via = None
15+ else:
16+ via = COMMASPACE.join(team.displayname for team in team_path)
17+ team = membership.team
18+ if membership.person == team.teamowner:
19+ role = 'Owner'
20+ elif membership.status == TeamMembershipStatus.ADMIN:
21+ role = 'Admin'
22+ else:
23+ role = 'Member'
24+ if team.mailing_list is not None and team.mailing_list.is_usable:
25+ subscription = team.mailing_list.getSubscription(self.context)
26+ if subscription is None:
27+ subscribed = 'Not subscribed'
28+ else:
29+ subscribed = 'Subscribed'
30+ else:
31+ subscribed = None
32+ return dict(
33+ displayname=team.displayname, team=team, membership=membership,
34+ role=role, via=via, subscribed=subscribed)
35
36- :return: A list of dictionaries, where each dictionary has a team in
37- which the person is an indirect member, and a path to membership in
38- that team.
39- :rtype: a list of dictionaries
40- """
41- indirect_teams = []
42+ @cachedproperty
43+ def active_participations(self):
44+ """Return the participation information for active memberships."""
45+ participations = [self._asParticipation(membership)
46+ for membership in self.context.myactivememberships
47+ if check_permission('launchpad.View', membership)]
48+ membership_set = getUtility(ITeamMembershipSet)
49 for team in self.context.teams_indirectly_participated_in:
50- via = COMMASPACE.join(viateam.displayname
51- for viateam
52- in self.context.findPathToTeam(team)[:-1])
53- indirect_teams.append(dict(team=team, via=via))
54- return indirect_teams
55+ # The key points of the path for presentation are:
56+ # [-?] indirect memberships, [-2] direct membership, [-1] team.
57+ team_path = self.context.findPathToTeam(team)
58+ membership = membership_set.getByPersonAndTeam(
59+ team_path[-2], team)
60+ participations.append(
61+ self._asParticipation(membership, team_path=team_path[:-1]))
62+ return sorted(participations, key=itemgetter('displayname'))
63+
64+ @cachedproperty
65+ def has_participations(self):
66+ return len(self.active_participations) > 0
67
68
69 class EmailAddressVisibleState:
70
71=== modified file 'lib/lp/registry/browser/tests/test_person_view.py'
72--- lib/lp/registry/browser/tests/test_person_view.py 2010-05-11 12:31:49 +0000
73+++ lib/lp/registry/browser/tests/test_person_view.py 2010-06-08 12:58:26 +0000
74@@ -12,12 +12,16 @@
75 from canonical.launchpad.webapp.interfaces import NotFoundError
76 from lp.registry.interfaces.karma import IKarmaCacheManager
77 from canonical.launchpad.webapp.servers import LaunchpadTestRequest
78-from canonical.testing import LaunchpadFunctionalLayer, LaunchpadZopelessLayer
79+from canonical.testing import (
80+ DatabaseFunctionalLayer, LaunchpadFunctionalLayer, LaunchpadZopelessLayer)
81 from lp.registry.browser.person import PersonEditView, PersonView
82+from lp.registry.interfaces.person import PersonVisibility
83+from lp.registry.interfaces.teammembership import TeamMembershipStatus
84 from lp.registry.model.karma import KarmaCategory
85 from lp.soyuz.tests.test_publishing import SoyuzTestPublisher
86 from lp.soyuz.interfaces.archive import ArchiveStatus
87 from lp.testing import TestCaseWithFactory, login_person
88+from lp.testing.views import create_view
89
90
91 class TestPersonViewKarma(TestCaseWithFactory):
92@@ -112,7 +116,7 @@
93
94 def test_viewing_self(self):
95 # If the current user has edit access to the context person then
96- # the section should always display
97+ # the section should always display.
98 login_person(self.owner)
99 person_view = PersonView(self.owner, LaunchpadTestRequest())
100 self.failUnless(person_view.should_show_ppa_section)
101@@ -226,5 +230,112 @@
102 self.assertFalse(self.view.form_fields['name'].for_display)
103
104
105+class TestPersonParticipationView(TestCaseWithFactory):
106+
107+ layer = DatabaseFunctionalLayer
108+
109+ def setUp(self):
110+ super(TestPersonParticipationView, self).setUp()
111+ self.user = self.factory.makePerson()
112+ self.view = create_view(self.user, name='+participation')
113+
114+ def test__asParticpation_owner(self):
115+ # Team owners have the role of 'Owner'.
116+ self.factory.makeTeam(owner=self.user)
117+ [participation] = self.view.active_participations
118+ self.assertEqual('Owner', participation['role'])
119+
120+ def test__asParticpation_admin(self):
121+ # Team admins have the role of 'Admin'.
122+ team = self.factory.makeTeam()
123+ login_person(team.teamowner)
124+ team.addMember(self.user, team.teamowner)
125+ for membership in self.user.myactivememberships:
126+ membership.setStatus(
127+ TeamMembershipStatus.ADMIN, team.teamowner)
128+ [participation] = self.view.active_participations
129+ self.assertEqual('Admin', participation['role'])
130+
131+ def test__asParticpation_member(self):
132+ # The default team role is 'Member'.
133+ team = self.factory.makeTeam()
134+ login_person(team.teamowner)
135+ team.addMember(self.user, team.teamowner)
136+ [participation] = self.view.active_participations
137+ self.assertEqual('Member', participation['role'])
138+
139+ def test__asParticpation_without_mailing_list(self):
140+ # The default team role is 'Member'.
141+ team = self.factory.makeTeam()
142+ login_person(team.teamowner)
143+ team.addMember(self.user, team.teamowner)
144+ [participation] = self.view.active_participations
145+ self.assertEqual(None, participation['subscribed'])
146+
147+ def test__asParticpation_unsubscribed_to_mailing_list(self):
148+ # The default team role is 'Member'.
149+ team = self.factory.makeTeam()
150+ self.factory.makeMailingList(team, team.teamowner)
151+ login_person(team.teamowner)
152+ team.addMember(self.user, team.teamowner)
153+ [participation] = self.view.active_participations
154+ self.assertEqual('Not subscribed', participation['subscribed'])
155+
156+ def test__asParticpation_subscribed_to_mailing_list(self):
157+ # The default team role is 'Member'.
158+ team = self.factory.makeTeam()
159+ mailing_list = self.factory.makeMailingList(team, team.teamowner)
160+ mailing_list.subscribe(self.user)
161+ login_person(team.teamowner)
162+ team.addMember(self.user, team.teamowner)
163+ [participation] = self.view.active_participations
164+ self.assertEqual('Subscribed', participation['subscribed'])
165+
166+ def test_active_participations_with_private_team(self):
167+ # Users cannot see private teams that they are not members of.
168+ team = self.factory.makeTeam(visibility=PersonVisibility.PRIVATE)
169+ login_person(team.teamowner)
170+ team.addMember(self.user, team.teamowner)
171+ # The team is included in active_participations.
172+ login_person(self.user)
173+ view = create_view(
174+ self.user, name='+participation', principal=self.user)
175+ self.assertEqual(1, len(view.active_participations))
176+ # The team is not included in active_participations.
177+ observer = self.factory.makePerson()
178+ login_person(observer)
179+ view = create_view(
180+ self.user, name='+participation', principal=observer)
181+ self.assertEqual(0, len(view.active_participations))
182+
183+ def test_active_participations_indirect_membership(self):
184+ # Verify the path of indirect membership.
185+ a_team = self.factory.makeTeam(name='a')
186+ b_team = self.factory.makeTeam(name='b', owner=a_team)
187+ c_team = self.factory.makeTeam(name='c', owner=b_team)
188+ login_person(a_team.teamowner)
189+ a_team.addMember(self.user, a_team.teamowner)
190+ transaction.commit()
191+ participations = self.view.active_participations
192+ self.assertEqual(3, len(participations))
193+ display_names = [
194+ participation['displayname'] for participation in participations]
195+ self.assertEqual(['A', 'B', 'C'], display_names)
196+ self.assertEqual(None, participations[0]['via'])
197+ self.assertEqual('A', participations[1]['via'])
198+ self.assertEqual('A, B', participations[2]['via'])
199+
200+ def test_has_participations_false(self):
201+ participations = self.view.active_participations
202+ self.assertEqual(0, len(participations))
203+ self.assertEqual(False, self.view.has_participations)
204+
205+ def test_has_participations_true(self):
206+ self.factory.makeTeam(owner=self.user)
207+ participations = self.view.active_participations
208+ self.assertEqual(1, len(participations))
209+ self.assertEqual(True, self.view.has_participations)
210+
211+
212 def test_suite():
213 return unittest.TestLoader().loadTestsFromName(__name__)
214
215=== modified file 'lib/lp/registry/stories/team/xx-team-membership.txt'
216--- lib/lp/registry/stories/team/xx-team-membership.txt 2010-01-15 13:36:09 +0000
217+++ lib/lp/registry/stories/team/xx-team-membership.txt 2010-06-08 12:58:26 +0000
218@@ -204,62 +204,48 @@
219 =======================
220
221 The team participation page shows the team in which a person is a direct
222-member, as well as the teams in which they are an indirect member. We will
223-just make sure that this page is displaying without errors in several cases:
224-
225- - for a person with no memberships at all
226- - for a person with direct memberships only
227- - for a person with some indirect memberships
228- - for a team with direct memberships only
229-
230-First, Kiko has not joined any teams:
231-
232- >>> browser = setupBrowser()
233- >>> browser.open('http://launchpad.dev/~kiko/+participation')
234- >>> 'has not yet joined any teams' in browser.contents
235- True
236- >>> direct = find_portlet(browser.contents, 'Direct membership')
237- >>> print direct
238- None
239- >>> indirect = find_portlet(browser.contents, 'Indirect membership')
240- >>> print indirect
241- None
242-
243-
244-Next, Marilize Coetzee is only a direct member:
245-
246- >>> browser.open('http://launchpad.dev/~marilize/+participation')
247- >>> 'has not yet joined any teams' in browser.contents
248- False
249- >>> print extract_text(find_portlet(browser.contents, 'Direct membership'))
250- Direct membership
251- ShipIt Administrators
252- Joined on ...
253- >>> print find_portlet(browser.contents, 'Indirect membership')
254- None
255-
256-
257-Next, Sample Person has both direct and indirect memberships:
258-
259- >>> browser.open('http://launchpad.dev/~name12/+participation')
260- >>> 'has not yet joined any teams' in browser.contents
261- False
262- >>> direct = find_portlet(browser.contents, 'Direct membership')
263- >>> 'Landscape Developers' in direct.renderContents()
264- True
265- >>> indirect = find_portlet(browser.contents, 'Indirect membership')
266- >>> 'Ubuntu Gnome Team' in indirect.renderContents()
267- True
268-
269-
270-The Ubuntu Team is only a direct member of other teams:
271-
272- >>> browser.open('http://launchpad.dev/~ubuntu-team/+participation')
273- >>> 'has not yet joined any teams' in browser.contents
274- False
275- >>> direct = find_portlet(browser.contents, 'Direct membership')
276- >>> 'GuadaMen' in direct.renderContents()
277- True
278- >>> indirect = find_portlet(browser.contents, 'Indirect membership')
279- >>> print indirect
280- None
281+member, as well as the teams in which they are an indirect member.
282+
283+Kiko has not joined any teams:
284+
285+ >>> anon_browser.open('http://launchpad.dev/~kiko/+participation')
286+ >>> print extract_text(
287+ ... find_tag_by_id(anon_browser.contents, 'no-participation'))
288+ Christian Reis has not yet joined any teams.
289+ >>> print find_tag_by_id(anon_browser.contents, 'participation')
290+ None
291+
292+Sample Person has both direct and indirect memberships:
293+
294+ >>> anon_browser.open('http://launchpad.dev/~name12/+participation')
295+ >>> content = find_main_content(anon_browser.contents)
296+ >>> print find_tag_by_id(content, 'no-participation')
297+ None
298+
299+ >>> print extract_text(
300+ ... find_tag_by_id(content, 'participation'))
301+ Team Joined Role Via Mailing List
302+ HWDB Team 2009-07-09 Member &mdash; &mdash;
303+ Landscape Developers 2006-07-11 Owner &mdash; &mdash;
304+ Launchpad Users 2008-11-26 Owner &mdash; &mdash;
305+ Ubuntu Gnome Team &mdash; Member Warty Security Team &mdash;
306+ Warty Security Team 2007-01-26 Member &mdash; &mdash;
307+
308+User can see links to register teams and change their mailing list
309+subscriptions on their own participation page.
310+
311+ >>> print find_tag_by_id(content, 'participation-actions')
312+ None
313+
314+ >>> user_browser.open('http://launchpad.dev/~no-priv/+participation')
315+ >>> actions = find_tag_by_id(
316+ ... user_browser.contents, 'participation-actions')
317+ >>> print extract_text(actions)
318+ Register a team
319+ Change mailing list subscriptions
320+
321+ >>> user_browser.getLink('Register a team')
322+ <Link ... url='http://.../people/+newteam'>
323+ >>> user_browser.getLink('Change mailing list subscriptions')
324+ <Link ... url='http://.../~no-priv/+editemails'>
325+
326
327=== modified file 'lib/lp/registry/stories/teammembership/xx-private-membership.txt'
328--- lib/lp/registry/stories/teammembership/xx-private-membership.txt 2010-04-23 13:50:57 +0000
329+++ lib/lp/registry/stories/teammembership/xx-private-membership.txt 2010-06-08 12:58:26 +0000
330@@ -118,57 +118,6 @@
331 Other Team
332
333
334-=== Indirect Membership ===
335-
336-If a person is a member of a public team that is a member of a private
337-membership team then he is indirectly a member of the private
338-membership team. The rules for disclosing that information are the
339-same as for direct membership.
340-
341-My Team invites the Launchpad Admins team to join them.
342-
343- >>> owner_browser = setupBrowser(auth='Basic owner@canonical.com:test')
344- >>> owner_browser.open('http://launchpad.dev/~myteam/+addmember')
345- >>> owner_browser.getControl('New member').value = 'admins'
346- >>> owner_browser.getControl('Add Member').click()
347-
348-Foo Bar accepts the invitation for the Launchpad Admins, which makes
349-him, and all other admins, an indirect member of My Team.
350-
351- >>> admin_browser.open('http://launchpad.dev/~admins/+invitation/myteam')
352- >>> admin_browser.getControl('Accept').click()
353-
354-All Launchpad Admin members are indirectly members of My Team and
355-their participation is visible to other team members.
356-
357- >>> owner_browser.open('http://launchpad.dev/~name16/+participation')
358- >>> div = find_tag_by_id(owner_browser.contents, 'indirect participation')
359- >>> a_tags = div.findAll('a')
360- >>> for a_tag in a_tags:
361- ... print a_tag.contents
362- [u'Mailing List Experts']
363- [u'My Team']
364-
365-People who are not members of My Team, such as No Privileges Person,
366-do not see it in the indirect participation list for the members who
367-are.
368-
369- >>> user_browser.open('http://launchpad.dev/~name16/+participation')
370- >>> div = find_tag_by_id(user_browser.contents, 'indirect participation')
371- >>> a_tags = div.findAll('a')
372- >>> for a_tag in a_tags:
373- ... print a_tag.contents
374- [u'Mailing List Experts']
375-
376-And anonymous users do not see the membership either.
377-
378- >>> anon_browser.open('http://launchpad.dev/~name16/+participation')
379- >>> div = find_tag_by_id(anon_browser.contents, 'indirect participation')
380- >>> a_tags = div.findAll('a')
381- >>> for a_tag in a_tags:
382- ... print a_tag.contents
383- [u'Mailing List Experts']
384-
385 == Teams with Icons ==
386
387 The person page also shows a list of icons for all the teams that
388@@ -214,6 +163,7 @@
389 Even the owner of the team with private membership should not see
390 MyTeam as an option in the +answer-contact form.
391
392+ >>> owner_browser = setupBrowser(auth='Basic owner@canonical.com:test')
393 >>> owner_browser.open(
394 ... 'http://answers.launchpad.dev/ubuntu/+answer-contact')
395 >>> team_div = find_tag_by_id(owner_browser.contents,
396
397=== modified file 'lib/lp/registry/templates/person-participation.pt'
398--- lib/lp/registry/templates/person-participation.pt 2009-09-15 21:16:37 +0000
399+++ lib/lp/registry/templates/person-participation.pt 2010-06-08 12:58:26 +0000
400@@ -8,55 +8,79 @@
401 >
402
403 <body>
404- <div metal:fill-slot="main"
405- tal:define="direct context/myactivememberships;
406- indirect context/teams_indirectly_participated_in">
407+ <div metal:fill-slot="main">
408
409- <p tal:condition="direct/count">
410+ <p tal:condition="view/has_participations">
411 <span tal:replace="context/title">Foo Bar</span>
412 is a member of the following teams:
413 </p>
414- <p tal:condition="not: direct/count">
415+
416+ <p id="no-participation" tal:condition="not: view/has_participations">
417 <span tal:replace="context/title">Foo Bar</span>
418 has not yet joined any teams.
419 </p>
420
421- <div class="left" tal:condition="direct/count">
422- <div class="portlet">
423- <h2>Direct membership</h2>
424- <table id="participation">
425- <tal:loop repeat="membership context/myactivememberships">
426- <tr tal:replace="structure membership/@@+listing-simple"
427- tal:condition="membership/team/@@+restricted-membership/userCanViewMembership"
428- />
429- </tal:loop>
430- </table>
431- </div>
432- </div>
433+ <table id="participation" class="listing sortable"
434+ tal:condition="view/has_participations">
435+ <thead>
436+ <tr>
437+ <th>Team</th>
438+ <th>Joined</th>
439+ <th>Role</th>
440+ <th>Via</th>
441+ <th>Mailing List</th>
442+ </tr>
443+ </thead>
444+ <tbody>
445+ <tr tal:repeat="participation view/active_participations">
446+ <td>
447+ <a tal:replace="structure participation/team/fmt:link">name</a>
448+ </td>
449+ <td>
450+ <tal:date condition="not: participation/via"
451+ tal:replace="participation/membership/datejoined/fmt:date">
452+ 2005-06-17
453+ </tal:date>
454+ <tal:no-date condition="participation/via">
455+ &mdash;
456+ </tal:no-date>
457+ </td>
458+ <td tal:content="participation/role">
459+ Member
460+ </td>
461+ <td>
462+ <tal:indirect condition="participation/via"
463+ replace="participation/via">
464+ a, b, c
465+ </tal:indirect>
466+ <tal:direct condition="not: participation/via">
467+ &mdash;
468+ </tal:direct>
469+ </td>
470+ <td>
471+ <tal:subscribed condition="participation/team/mailing_list"
472+ replace="participation/subscribed">
473+ yes
474+ </tal:subscribed>
475+ <tal:no-list condition="not: participation/team/mailing_list">
476+ &mdash;
477+ </tal:no-list>
478+ </td>
479+ </tr>
480+ </tbody>
481+ </table>
482
483- <div class="right" tal:condition="indirect/count">
484- <div class="portlet">
485- <h2>Indirect membership</h2>
486- <table id="indirect participation">
487- <tal:loop repeat="team_via view/indirect_teams_via">
488- <tr tal:condition="team_via/team/@@+restricted-membership/userCanViewMembership">
489- <td><img tal:replace="structure team_via/team/image:icon" />
490- </td>
491- <td>
492- <div>
493- <a tal:replace="structure team_via/team/fmt:link"
494- >Team name</a>
495- </div>
496- <div>
497- Via
498- <span tal:replace="team_via/via">guadamen</span>.
499- </div>
500- </td>
501- </tr>
502- </tal:loop>
503- </table>
504- </div>
505- </div>
506+ <ul id="participation-actions" class="horizontal"
507+ tal:condition="context/required:launchpad.Edit">
508+ <li>
509+ <a class="sprite add" href="/people/+newteam">Register a team</a>
510+ </li>
511+ <li>
512+ <a class="sprite edit"
513+ tal:attributes="href context/menu:overview/editemailaddresses/fmt:url"
514+ >Change mailing list subscriptions</a>
515+ </li>
516+ </ul>
517 </div>
518 </body>
519 </html>