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
=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py 2010-06-04 18:58:48 +0000
+++ lib/lp/registry/browser/person.py 2010-06-08 12:58:26 +0000
@@ -3072,22 +3072,51 @@
3072 def label(self):3072 def label(self):
3073 return 'Team participation for ' + self.context.displayname3073 return 'Team participation for ' + self.context.displayname
30743074
3075 @property3075 def _asParticipation(self, membership, team_path=None):
3076 def indirect_teams_via(self):3076 """Return a dict of participation information for the membership."""
3077 """Information about indirect membership.3077 if team_path is None:
3078 via = None
3079 else:
3080 via = COMMASPACE.join(team.displayname for team in team_path)
3081 team = membership.team
3082 if membership.person == team.teamowner:
3083 role = 'Owner'
3084 elif membership.status == TeamMembershipStatus.ADMIN:
3085 role = 'Admin'
3086 else:
3087 role = 'Member'
3088 if team.mailing_list is not None and team.mailing_list.is_usable:
3089 subscription = team.mailing_list.getSubscription(self.context)
3090 if subscription is None:
3091 subscribed = 'Not subscribed'
3092 else:
3093 subscribed = 'Subscribed'
3094 else:
3095 subscribed = None
3096 return dict(
3097 displayname=team.displayname, team=team, membership=membership,
3098 role=role, via=via, subscribed=subscribed)
30783099
3079 :return: A list of dictionaries, where each dictionary has a team in3100 @cachedproperty
3080 which the person is an indirect member, and a path to membership in3101 def active_participations(self):
3081 that team.3102 """Return the participation information for active memberships."""
3082 :rtype: a list of dictionaries3103 participations = [self._asParticipation(membership)
3083 """3104 for membership in self.context.myactivememberships
3084 indirect_teams = []3105 if check_permission('launchpad.View', membership)]
3106 membership_set = getUtility(ITeamMembershipSet)
3085 for team in self.context.teams_indirectly_participated_in:3107 for team in self.context.teams_indirectly_participated_in:
3086 via = COMMASPACE.join(viateam.displayname3108 # The key points of the path for presentation are:
3087 for viateam3109 # [-?] indirect memberships, [-2] direct membership, [-1] team.
3088 in self.context.findPathToTeam(team)[:-1])3110 team_path = self.context.findPathToTeam(team)
3089 indirect_teams.append(dict(team=team, via=via))3111 membership = membership_set.getByPersonAndTeam(
3090 return indirect_teams3112 team_path[-2], team)
3113 participations.append(
3114 self._asParticipation(membership, team_path=team_path[:-1]))
3115 return sorted(participations, key=itemgetter('displayname'))
3116
3117 @cachedproperty
3118 def has_participations(self):
3119 return len(self.active_participations) > 0
30913120
30923121
3093class EmailAddressVisibleState:3122class EmailAddressVisibleState:
30943123
=== modified file 'lib/lp/registry/browser/tests/test_person_view.py'
--- lib/lp/registry/browser/tests/test_person_view.py 2010-05-11 12:31:49 +0000
+++ lib/lp/registry/browser/tests/test_person_view.py 2010-06-08 12:58:26 +0000
@@ -12,12 +12,16 @@
12from canonical.launchpad.webapp.interfaces import NotFoundError12from canonical.launchpad.webapp.interfaces import NotFoundError
13from lp.registry.interfaces.karma import IKarmaCacheManager13from lp.registry.interfaces.karma import IKarmaCacheManager
14from canonical.launchpad.webapp.servers import LaunchpadTestRequest14from canonical.launchpad.webapp.servers import LaunchpadTestRequest
15from canonical.testing import LaunchpadFunctionalLayer, LaunchpadZopelessLayer15from canonical.testing import (
16 DatabaseFunctionalLayer, LaunchpadFunctionalLayer, LaunchpadZopelessLayer)
16from lp.registry.browser.person import PersonEditView, PersonView17from lp.registry.browser.person import PersonEditView, PersonView
18from lp.registry.interfaces.person import PersonVisibility
19from lp.registry.interfaces.teammembership import TeamMembershipStatus
17from lp.registry.model.karma import KarmaCategory20from lp.registry.model.karma import KarmaCategory
18from lp.soyuz.tests.test_publishing import SoyuzTestPublisher21from lp.soyuz.tests.test_publishing import SoyuzTestPublisher
19from lp.soyuz.interfaces.archive import ArchiveStatus22from lp.soyuz.interfaces.archive import ArchiveStatus
20from lp.testing import TestCaseWithFactory, login_person23from lp.testing import TestCaseWithFactory, login_person
24from lp.testing.views import create_view
2125
2226
23class TestPersonViewKarma(TestCaseWithFactory):27class TestPersonViewKarma(TestCaseWithFactory):
@@ -112,7 +116,7 @@
112116
113 def test_viewing_self(self):117 def test_viewing_self(self):
114 # If the current user has edit access to the context person then118 # If the current user has edit access to the context person then
115 # the section should always display 119 # the section should always display.
116 login_person(self.owner)120 login_person(self.owner)
117 person_view = PersonView(self.owner, LaunchpadTestRequest())121 person_view = PersonView(self.owner, LaunchpadTestRequest())
118 self.failUnless(person_view.should_show_ppa_section)122 self.failUnless(person_view.should_show_ppa_section)
@@ -226,5 +230,112 @@
226 self.assertFalse(self.view.form_fields['name'].for_display)230 self.assertFalse(self.view.form_fields['name'].for_display)
227231
228232
233class TestPersonParticipationView(TestCaseWithFactory):
234
235 layer = DatabaseFunctionalLayer
236
237 def setUp(self):
238 super(TestPersonParticipationView, self).setUp()
239 self.user = self.factory.makePerson()
240 self.view = create_view(self.user, name='+participation')
241
242 def test__asParticpation_owner(self):
243 # Team owners have the role of 'Owner'.
244 self.factory.makeTeam(owner=self.user)
245 [participation] = self.view.active_participations
246 self.assertEqual('Owner', participation['role'])
247
248 def test__asParticpation_admin(self):
249 # Team admins have the role of 'Admin'.
250 team = self.factory.makeTeam()
251 login_person(team.teamowner)
252 team.addMember(self.user, team.teamowner)
253 for membership in self.user.myactivememberships:
254 membership.setStatus(
255 TeamMembershipStatus.ADMIN, team.teamowner)
256 [participation] = self.view.active_participations
257 self.assertEqual('Admin', participation['role'])
258
259 def test__asParticpation_member(self):
260 # The default team role is 'Member'.
261 team = self.factory.makeTeam()
262 login_person(team.teamowner)
263 team.addMember(self.user, team.teamowner)
264 [participation] = self.view.active_participations
265 self.assertEqual('Member', participation['role'])
266
267 def test__asParticpation_without_mailing_list(self):
268 # The default team role is 'Member'.
269 team = self.factory.makeTeam()
270 login_person(team.teamowner)
271 team.addMember(self.user, team.teamowner)
272 [participation] = self.view.active_participations
273 self.assertEqual(None, participation['subscribed'])
274
275 def test__asParticpation_unsubscribed_to_mailing_list(self):
276 # The default team role is 'Member'.
277 team = self.factory.makeTeam()
278 self.factory.makeMailingList(team, team.teamowner)
279 login_person(team.teamowner)
280 team.addMember(self.user, team.teamowner)
281 [participation] = self.view.active_participations
282 self.assertEqual('Not subscribed', participation['subscribed'])
283
284 def test__asParticpation_subscribed_to_mailing_list(self):
285 # The default team role is 'Member'.
286 team = self.factory.makeTeam()
287 mailing_list = self.factory.makeMailingList(team, team.teamowner)
288 mailing_list.subscribe(self.user)
289 login_person(team.teamowner)
290 team.addMember(self.user, team.teamowner)
291 [participation] = self.view.active_participations
292 self.assertEqual('Subscribed', participation['subscribed'])
293
294 def test_active_participations_with_private_team(self):
295 # Users cannot see private teams that they are not members of.
296 team = self.factory.makeTeam(visibility=PersonVisibility.PRIVATE)
297 login_person(team.teamowner)
298 team.addMember(self.user, team.teamowner)
299 # The team is included in active_participations.
300 login_person(self.user)
301 view = create_view(
302 self.user, name='+participation', principal=self.user)
303 self.assertEqual(1, len(view.active_participations))
304 # The team is not included in active_participations.
305 observer = self.factory.makePerson()
306 login_person(observer)
307 view = create_view(
308 self.user, name='+participation', principal=observer)
309 self.assertEqual(0, len(view.active_participations))
310
311 def test_active_participations_indirect_membership(self):
312 # Verify the path of indirect membership.
313 a_team = self.factory.makeTeam(name='a')
314 b_team = self.factory.makeTeam(name='b', owner=a_team)
315 c_team = self.factory.makeTeam(name='c', owner=b_team)
316 login_person(a_team.teamowner)
317 a_team.addMember(self.user, a_team.teamowner)
318 transaction.commit()
319 participations = self.view.active_participations
320 self.assertEqual(3, len(participations))
321 display_names = [
322 participation['displayname'] for participation in participations]
323 self.assertEqual(['A', 'B', 'C'], display_names)
324 self.assertEqual(None, participations[0]['via'])
325 self.assertEqual('A', participations[1]['via'])
326 self.assertEqual('A, B', participations[2]['via'])
327
328 def test_has_participations_false(self):
329 participations = self.view.active_participations
330 self.assertEqual(0, len(participations))
331 self.assertEqual(False, self.view.has_participations)
332
333 def test_has_participations_true(self):
334 self.factory.makeTeam(owner=self.user)
335 participations = self.view.active_participations
336 self.assertEqual(1, len(participations))
337 self.assertEqual(True, self.view.has_participations)
338
339
229def test_suite():340def test_suite():
230 return unittest.TestLoader().loadTestsFromName(__name__)341 return unittest.TestLoader().loadTestsFromName(__name__)
231342
=== 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-08 12:58:26 +0000
@@ -204,62 +204,48 @@
204=======================204=======================
205205
206The team participation page shows the team in which a person is a direct206The team participation page shows the team in which a person is a direct
207member, as well as the teams in which they are an indirect member. We will207member, as well as the teams in which they are an indirect member.
208just make sure that this page is displaying without errors in several cases:208
209209Kiko has not joined any teams:
210 - for a person with no memberships at all210
211 - for a person with direct memberships only211 >>> anon_browser.open('http://launchpad.dev/~kiko/+participation')
212 - for a person with some indirect memberships212 >>> print extract_text(
213 - for a team with direct memberships only213 ... find_tag_by_id(anon_browser.contents, 'no-participation'))
214214 Christian Reis has not yet joined any teams.
215First, Kiko has not joined any teams:215 >>> print find_tag_by_id(anon_browser.contents, 'participation')
216216 None
217 >>> browser = setupBrowser()217
218 >>> browser.open('http://launchpad.dev/~kiko/+participation')218Sample Person has both direct and indirect memberships:
219 >>> 'has not yet joined any teams' in browser.contents219
220 True220 >>> anon_browser.open('http://launchpad.dev/~name12/+participation')
221 >>> direct = find_portlet(browser.contents, 'Direct membership')221 >>> content = find_main_content(anon_browser.contents)
222 >>> print direct222 >>> print find_tag_by_id(content, 'no-participation')
223 None223 None
224 >>> indirect = find_portlet(browser.contents, 'Indirect membership')224
225 >>> print indirect225 >>> print extract_text(
226 None226 ... find_tag_by_id(content, 'participation'))
227227 Team Joined Role Via Mailing List
228228 HWDB Team 2009-07-09 Member &mdash; &mdash;
229Next, Marilize Coetzee is only a direct member:229 Landscape Developers 2006-07-11 Owner &mdash; &mdash;
230230 Launchpad Users 2008-11-26 Owner &mdash; &mdash;
231 >>> browser.open('http://launchpad.dev/~marilize/+participation')231 Ubuntu Gnome Team &mdash; Member Warty Security Team &mdash;
232 >>> 'has not yet joined any teams' in browser.contents232 Warty Security Team 2007-01-26 Member &mdash; &mdash;
233 False233
234 >>> print extract_text(find_portlet(browser.contents, 'Direct membership'))234User can see links to register teams and change their mailing list
235 Direct membership235subscriptions on their own participation page.
236 ShipIt Administrators236
237 Joined on ...237 >>> print find_tag_by_id(content, 'participation-actions')
238 >>> print find_portlet(browser.contents, 'Indirect membership')238 None
239 None239
240240 >>> user_browser.open('http://launchpad.dev/~no-priv/+participation')
241241 >>> actions = find_tag_by_id(
242Next, Sample Person has both direct and indirect memberships:242 ... user_browser.contents, 'participation-actions')
243243 >>> print extract_text(actions)
244 >>> browser.open('http://launchpad.dev/~name12/+participation')244 Register a team
245 >>> 'has not yet joined any teams' in browser.contents245 Change mailing list subscriptions
246 False246
247 >>> direct = find_portlet(browser.contents, 'Direct membership')247 >>> user_browser.getLink('Register a team')
248 >>> 'Landscape Developers' in direct.renderContents()248 <Link ... url='http://.../people/+newteam'>
249 True249 >>> user_browser.getLink('Change mailing list subscriptions')
250 >>> indirect = find_portlet(browser.contents, 'Indirect membership')250 <Link ... url='http://.../~no-priv/+editemails'>
251 >>> 'Ubuntu Gnome Team' in indirect.renderContents()251
252 True
253
254
255The Ubuntu Team is only a direct member of other teams:
256
257 >>> browser.open('http://launchpad.dev/~ubuntu-team/+participation')
258 >>> 'has not yet joined any teams' in browser.contents
259 False
260 >>> direct = find_portlet(browser.contents, 'Direct membership')
261 >>> 'GuadaMen' in direct.renderContents()
262 True
263 >>> indirect = find_portlet(browser.contents, 'Indirect membership')
264 >>> print indirect
265 None
266252
=== modified file 'lib/lp/registry/stories/teammembership/xx-private-membership.txt'
--- lib/lp/registry/stories/teammembership/xx-private-membership.txt 2010-04-23 13:50:57 +0000
+++ lib/lp/registry/stories/teammembership/xx-private-membership.txt 2010-06-08 12:58:26 +0000
@@ -118,57 +118,6 @@
118 Other Team118 Other Team
119119
120120
121=== Indirect Membership ===
122
123If a person is a member of a public team that is a member of a private
124membership team then he is indirectly a member of the private
125membership team. The rules for disclosing that information are the
126same as for direct membership.
127
128My Team invites the Launchpad Admins team to join them.
129
130 >>> owner_browser = setupBrowser(auth='Basic owner@canonical.com:test')
131 >>> owner_browser.open('http://launchpad.dev/~myteam/+addmember')
132 >>> owner_browser.getControl('New member').value = 'admins'
133 >>> owner_browser.getControl('Add Member').click()
134
135Foo Bar accepts the invitation for the Launchpad Admins, which makes
136him, and all other admins, an indirect member of My Team.
137
138 >>> admin_browser.open('http://launchpad.dev/~admins/+invitation/myteam')
139 >>> admin_browser.getControl('Accept').click()
140
141All Launchpad Admin members are indirectly members of My Team and
142their participation is visible to other team members.
143
144 >>> owner_browser.open('http://launchpad.dev/~name16/+participation')
145 >>> div = find_tag_by_id(owner_browser.contents, 'indirect participation')
146 >>> a_tags = div.findAll('a')
147 >>> for a_tag in a_tags:
148 ... print a_tag.contents
149 [u'Mailing List Experts']
150 [u'My Team']
151
152People who are not members of My Team, such as No Privileges Person,
153do not see it in the indirect participation list for the members who
154are.
155
156 >>> user_browser.open('http://launchpad.dev/~name16/+participation')
157 >>> div = find_tag_by_id(user_browser.contents, 'indirect participation')
158 >>> a_tags = div.findAll('a')
159 >>> for a_tag in a_tags:
160 ... print a_tag.contents
161 [u'Mailing List Experts']
162
163And anonymous users do not see the membership either.
164
165 >>> anon_browser.open('http://launchpad.dev/~name16/+participation')
166 >>> div = find_tag_by_id(anon_browser.contents, 'indirect participation')
167 >>> a_tags = div.findAll('a')
168 >>> for a_tag in a_tags:
169 ... print a_tag.contents
170 [u'Mailing List Experts']
171
172== Teams with Icons ==121== Teams with Icons ==
173122
174The person page also shows a list of icons for all the teams that123The person page also shows a list of icons for all the teams that
@@ -214,6 +163,7 @@
214Even the owner of the team with private membership should not see163Even the owner of the team with private membership should not see
215MyTeam as an option in the +answer-contact form.164MyTeam as an option in the +answer-contact form.
216165
166 >>> owner_browser = setupBrowser(auth='Basic owner@canonical.com:test')
217 >>> owner_browser.open(167 >>> owner_browser.open(
218 ... 'http://answers.launchpad.dev/ubuntu/+answer-contact')168 ... 'http://answers.launchpad.dev/ubuntu/+answer-contact')
219 >>> team_div = find_tag_by_id(owner_browser.contents,169 >>> team_div = find_tag_by_id(owner_browser.contents,
220170
=== modified file 'lib/lp/registry/templates/person-participation.pt'
--- lib/lp/registry/templates/person-participation.pt 2009-09-15 21:16:37 +0000
+++ lib/lp/registry/templates/person-participation.pt 2010-06-08 12:58:26 +0000
@@ -8,55 +8,79 @@
8 >8 >
99
10<body>10<body>
11 <div metal:fill-slot="main"11 <div metal:fill-slot="main">
12 tal:define="direct context/myactivememberships;
13 indirect context/teams_indirectly_participated_in">
1412
15 <p tal:condition="direct/count">13 <p tal:condition="view/has_participations">
16 <span tal:replace="context/title">Foo Bar</span>14 <span tal:replace="context/title">Foo Bar</span>
17 is a member of the following teams:15 is a member of the following teams:
18 </p>16 </p>
19 <p tal:condition="not: direct/count">17
18 <p id="no-participation" tal:condition="not: view/has_participations">
20 <span tal:replace="context/title">Foo Bar</span>19 <span tal:replace="context/title">Foo Bar</span>
21 has not yet joined any teams.20 has not yet joined any teams.
22 </p>21 </p>
2322
24 <div class="left" tal:condition="direct/count">23 <table id="participation" class="listing sortable"
25 <div class="portlet">24 tal:condition="view/has_participations">
26 <h2>Direct membership</h2>25 <thead>
27 <table id="participation">26 <tr>
28 <tal:loop repeat="membership context/myactivememberships">27 <th>Team</th>
29 <tr tal:replace="structure membership/@@+listing-simple"28 <th>Joined</th>
30 tal:condition="membership/team/@@+restricted-membership/userCanViewMembership"29 <th>Role</th>
31 />30 <th>Via</th>
32 </tal:loop>31 <th>Mailing List</th>
33 </table>32 </tr>
34 </div>33 </thead>
35 </div>34 <tbody>
35 <tr tal:repeat="participation view/active_participations">
36 <td>
37 <a tal:replace="structure participation/team/fmt:link">name</a>
38 </td>
39 <td>
40 <tal:date condition="not: participation/via"
41 tal:replace="participation/membership/datejoined/fmt:date">
42 2005-06-17
43 </tal:date>
44 <tal:no-date condition="participation/via">
45 &mdash;
46 </tal:no-date>
47 </td>
48 <td tal:content="participation/role">
49 Member
50 </td>
51 <td>
52 <tal:indirect condition="participation/via"
53 replace="participation/via">
54 a, b, c
55 </tal:indirect>
56 <tal:direct condition="not: participation/via">
57 &mdash;
58 </tal:direct>
59 </td>
60 <td>
61 <tal:subscribed condition="participation/team/mailing_list"
62 replace="participation/subscribed">
63 yes
64 </tal:subscribed>
65 <tal:no-list condition="not: participation/team/mailing_list">
66 &mdash;
67 </tal:no-list>
68 </td>
69 </tr>
70 </tbody>
71 </table>
3672
37 <div class="right" tal:condition="indirect/count">73 <ul id="participation-actions" class="horizontal"
38 <div class="portlet">74 tal:condition="context/required:launchpad.Edit">
39 <h2>Indirect membership</h2>75 <li>
40 <table id="indirect participation">76 <a class="sprite add" href="/people/+newteam">Register a team</a>
41 <tal:loop repeat="team_via view/indirect_teams_via">77 </li>
42 <tr tal:condition="team_via/team/@@+restricted-membership/userCanViewMembership">78 <li>
43 <td><img tal:replace="structure team_via/team/image:icon" />79 <a class="sprite edit"
44 </td>80 tal:attributes="href context/menu:overview/editemailaddresses/fmt:url"
45 <td>81 >Change mailing list subscriptions</a>
46 <div>82 </li>
47 <a tal:replace="structure team_via/team/fmt:link"83 </ul>
48 >Team name</a>
49 </div>
50 <div>
51 Via
52 <span tal:replace="team_via/via">guadamen</span>.
53 </div>
54 </td>
55 </tr>
56 </tal:loop>
57 </table>
58 </div>
59 </div>
60 </div>84 </div>
61</body>85</body>
62</html>86</html>