Merge lp:~edwin-grubbs/launchpad/bug-482176-add-team-member-ajax-part1 into lp:launchpad

Proposed by Edwin Grubbs
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: not available
Proposed branch: lp:~edwin-grubbs/launchpad/bug-482176-add-team-member-ajax-part1
Merge into: lp:launchpad
Prerequisite: lp:launchpad/db-devel
Diff against target: 830 lines (+294/-82)
17 files modified
lib/canonical/launchpad/javascript/bugs/bugtask-index.js (+4/-37)
lib/canonical/launchpad/javascript/code/codereview.js (+9/-1)
lib/canonical/launchpad/javascript/lp/picker.js (+5/-2)
lib/canonical/launchpad/javascript/registry/team.js (+102/-0)
lib/lp/app/templates/base-layout-macros.pt (+2/-0)
lib/lp/bugs/tests/test_bugs_webservice.py (+0/-10)
lib/lp/registry/browser/configure.zcml (+3/-0)
lib/lp/registry/browser/person.py (+43/-12)
lib/lp/registry/browser/tests/teammembership-views.txt (+1/-0)
lib/lp/registry/browser/tests/test_person_webservice.py (+38/-0)
lib/lp/registry/doc/teammembership-email-notification.txt (+6/-0)
lib/lp/registry/doc/teammembership.txt (+39/-2)
lib/lp/registry/interfaces/teammembership.py (+2/-0)
lib/lp/registry/model/person.py (+3/-1)
lib/lp/registry/model/teammembership.py (+4/-5)
lib/lp/registry/templates/team-index.pt (+10/-0)
lib/lp/registry/templates/team-portlet-membership.pt (+23/-12)
To merge this branch: bzr merge lp:~edwin-grubbs/launchpad/bug-482176-add-team-member-ajax-part1
Reviewer Review Type Date Requested Status
Gavin Panella (community) code Approve
Review via email: mp+16226@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :

Summary
-------

This is the first branch on the way to adding a picker
to add members to a team with a link on its index page.

Salgado started this branch. I did a little bit of cleanup, but
the following things should be done in a followup branch.

 * Add windmill test.
 * Handle adding teams, which will go into a PROPOSED or INVITED status.

Implementation details
----------------------

The picker widget now clears the search term by default when
the SAVE event is processed (when an item is selected from the results).
The picker widget will also add an onclick handler for you.
    lib/canonical/launchpad/javascript/bugs/bugtask-index.js
    lib/canonical/launchpad/javascript/code/codereview.js

The main part of this feature:
    lib/canonical/launchpad/javascript/registry/team.js
    lib/lp/app/templates/base-layout-macros.pt
    lib/lp/registry/browser/configure.zcml
    lib/lp/registry/browser/person.py
    lib/lp/registry/templates/team-index.pt
    lib/lp/registry/browser/tests/test_person_webservice.py
    lib/lp/registry/templates/team-portlet-membership.pt

Change affecting existing tests:
    lib/lp/registry/model/person.py
    lib/lp/registry/model/teammembership.py
    lib/lp/registry/browser/tests/teammembership-views.txt
    lib/lp/registry/doc/teammembership-email-notification.txt
    lib/lp/registry/doc/teammembership.txt

Tests
-----

./bin/test -vv -t 'test_person_webservice|teammembership-views.txt|teammembership-email-notification.txt|/teammembership.txt'

Demo and Q/A
------------

* Open http://launchpad.dev/~guadamen
  * The "Add member" link should be green, click on it to show the picker.
  * When you click on a person in the picker, the progress spinner
    should display where the (+) icon was.
  * Then there should be a green flash where the person is added to the
    "Latest members" list.

Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (33.3 KiB)

Hi Edwin,

I've put a lot of comments in the diff below, but none of them are
critical, and a lot of them are suggestions. This is a nice new
feature that works well :)

 review approve code
 merge approve

Gavin.

> === modified file 'lib/canonical/launchpad/javascript/bugs/bugtask-index.js'
> --- lib/canonical/launchpad/javascript/bugs/bugtask-index.js 2009-12-14 15:49:13 +0000
> +++ lib/canonical/launchpad/javascript/bugs/bugtask-index.js 2009-12-16 15:51:58 +0000
> @@ -239,21 +239,6 @@
>
>
> /*
> - * Clear the subscribe someone else picker.
> - *
> - * @method clear_picker
> - * @param e {Object} The event object.
> - */
> -function clear_picker(e) {
> - var input = Y.one('.yui-picker-search-box input');
> - input.set('value', '');
> - this.set('error', '');
> - this.set('results', [{}]);
> - this._results_box.set('innerHTML', '');
> - this.set('batches', []);
> -}
> -
> -/*
> * Initialize click handler for the subscribe someone else link.
> *
> * @method setup_subscribe_someone_else_handler
> @@ -262,23 +247,14 @@
> function setup_subscribe_someone_else_handler(subscription) {
> var config = {
> header: 'Subscribe someone else',
> - step_title: 'Search'
> + step_title: 'Search',
> + picker_activator: '.menu-link-addsubscriber'
> };
>
> var picker = Y.lp.picker.create(
> 'ValidPersonOrTeam',
> function(result) { subscribe_someone_else(result, subscription); },
> config);
> - // Clear results and search terms on cancel or save.
> - picker.on('save', clear_picker, picker);
> - picker.on('cancel', clear_picker, picker);
> -
> - var subscription_link_someone_else = Y.one('.menu-link-addsubscriber');
> - subscription_link_someone_else.on('click', function(e) {
> - e.halt();
> - picker.show();
> - });
> - subscription_link_someone_else.addClass('js-action');
> }
>
> /*
> @@ -626,21 +602,12 @@
> if (Y.Lang.isValue(link_branch_link)) {
> var config = {
> header: 'Link a related branch',
> - step_title: 'Search'
> + step_title: 'Search',
> + picker_activator: '.menu-link-addbranch'
> };
>
> var picker = Y.lp.picker.create(
> 'Branch', get_branch_and_link_to_bug, config);
> -
> - // Clear results and search terms on cancel or save.
> - picker.on('save', clear_picker, picker);
> - picker.on('cancel', clear_picker, picker);
> -
> - link_branch_link.on('click', function(e) {
> - e.halt();
> - picker.show();
> - });
> - link_branch_link.addClass('js-action');
> }
> }

Thanks for fixing this up.

>
>
> === modified file 'lib/canonical/launchpad/javascript/code/codereview.js'
> --- lib/canonical/launchpad/javascript/code/codereview.js 2009-11-30 23:51:34 +0000
> +++ lib/canonical/launchpad/javascript/code/codereview.js 2009-12-16 15:51:58 +0000
> @@ -22,6 +22,14 @@
> var link = Y.one('#request-review');
> if (link !== null) {
> link.addClass('js-action');
> + /* XXX: salgado, 2009-11-11: This will cause the picker to be
> + * rec...

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/canonical/launchpad/javascript/bugs/bugtask-index.js'
--- lib/canonical/launchpad/javascript/bugs/bugtask-index.js 2009-12-10 20:59:49 +0000
+++ lib/canonical/launchpad/javascript/bugs/bugtask-index.js 2009-12-16 02:39:23 +0000
@@ -239,21 +239,6 @@
239239
240240
241/*241/*
242 * Clear the subscribe someone else picker.
243 *
244 * @method clear_picker
245 * @param e {Object} The event object.
246 */
247function clear_picker(e) {
248 var input = Y.one('.yui-picker-search-box input');
249 input.set('value', '');
250 this.set('error', '');
251 this.set('results', [{}]);
252 this._results_box.set('innerHTML', '');
253 this.set('batches', []);
254}
255
256/*
257 * Initialize click handler for the subscribe someone else link.242 * Initialize click handler for the subscribe someone else link.
258 *243 *
259 * @method setup_subscribe_someone_else_handler244 * @method setup_subscribe_someone_else_handler
@@ -262,23 +247,14 @@
262function setup_subscribe_someone_else_handler(subscription) {247function setup_subscribe_someone_else_handler(subscription) {
263 var config = {248 var config = {
264 header: 'Subscribe someone else',249 header: 'Subscribe someone else',
265 step_title: 'Search'250 step_title: 'Search',
251 picker_activator: '.menu-link-addsubscriber'
266 };252 };
267253
268 var picker = Y.lp.picker.create(254 var picker = Y.lp.picker.create(
269 'ValidPersonOrTeam',255 'ValidPersonOrTeam',
270 function(result) { subscribe_someone_else(result, subscription); },256 function(result) { subscribe_someone_else(result, subscription); },
271 config);257 config);
272 // Clear results and search terms on cancel or save.
273 picker.on('save', clear_picker, picker);
274 picker.on('cancel', clear_picker, picker);
275
276 var subscription_link_someone_else = Y.one('.menu-link-addsubscriber');
277 subscription_link_someone_else.on('click', function(e) {
278 e.halt();
279 picker.show();
280 });
281 subscription_link_someone_else.addClass('js-action');
282}258}
283259
284/*260/*
@@ -626,21 +602,12 @@
626 if (Y.Lang.isValue(link_branch_link)) {602 if (Y.Lang.isValue(link_branch_link)) {
627 var config = {603 var config = {
628 header: 'Link a related branch',604 header: 'Link a related branch',
629 step_title: 'Search'605 step_title: 'Search',
606 picker_activator: '.menu-link-addbranch'
630 };607 };
631608
632 var picker = Y.lp.picker.create(609 var picker = Y.lp.picker.create(
633 'Branch', get_branch_and_link_to_bug, config);610 'Branch', get_branch_and_link_to_bug, config);
634
635 // Clear results and search terms on cancel or save.
636 picker.on('save', clear_picker, picker);
637 picker.on('cancel', clear_picker, picker);
638
639 link_branch_link.on('click', function(e) {
640 e.halt();
641 picker.show();
642 });
643 link_branch_link.addClass('js-action');
644 }611 }
645}612}
646613
647614
=== modified file 'lib/canonical/launchpad/javascript/code/codereview.js'
--- lib/canonical/launchpad/javascript/code/codereview.js 2009-11-30 23:51:34 +0000
+++ lib/canonical/launchpad/javascript/code/codereview.js 2009-12-16 02:39:23 +0000
@@ -22,6 +22,14 @@
22 var link = Y.one('#request-review');22 var link = Y.one('#request-review');
23 if (link !== null) {23 if (link !== null) {
24 link.addClass('js-action');24 link.addClass('js-action');
25 /* XXX: salgado, 2009-11-11: This will cause the picker to be
26 * recreated every time the user clicks on the link. Although that
27 * makes it unnecessary to have the widget cleared, it makes it
28 * impossible to persist the state of the picker between clicks on the
29 * link. We should probably have a policy to enforce that we
30 * just hide/show widgets when a link is clicked more than once,
31 * instead of recreating the widgets every time.
32 */
25 link.on('click', show_request_review_form);33 link.on('click', show_request_review_form);
26 }34 }
27 link = Y.one('.menu-link-set_commit_message');35 link = Y.one('.menu-link-set_commit_message');
@@ -51,7 +59,7 @@
51 */59 */
52function commit_message_listener(message, saved)60function commit_message_listener(message, saved)
53{61{
54 if (message == '') {62 if (message === '') {
55 // Hide the multiline editor63 // Hide the multiline editor
56 Y.one('#edit-commit-message').addClass('unseen');64 Y.one('#edit-commit-message').addClass('unseen');
57 // Show the link again65 // Show the link again
5866
=== modified file 'lib/canonical/launchpad/javascript/lp/picker.js'
--- lib/canonical/launchpad/javascript/lp/picker.js 2009-12-01 15:11:51 +0000
+++ lib/canonical/launchpad/javascript/lp/picker.js 2009-12-16 02:39:23 +0000
@@ -15,6 +15,8 @@
15 * @param {String} resource_uri The object being modified.15 * @param {String} resource_uri The object being modified.
16 * @param {String} attribute_name The attribute on the resource being16 * @param {String} attribute_name The attribute on the resource being
17 * modified.17 * modified.
18 * @param {Bool} show_remove_button Should the remove button be shown?
19 * @param {Bool} show_assign_me_button Should the 'assign me' button be shown?
18 * @param {String} content_box_id20 * @param {String} content_box_id
19 * @param {Object} config Object literal of config name/value pairs.21 * @param {Object} config Object literal of config name/value pairs.
20 * config.header is a line of text at the top of22 * config.header is a line of text at the top of
@@ -219,10 +221,10 @@
219 "string: " + vocabulary);221 "string: " + vocabulary);
220 }222 }
221223
222 var picker = new Y.Picker({224 var new_config = Y.merge(config, {
223 align: {225 align: {
224 points: [Y.WidgetPositionExt.CC,226 points: [Y.WidgetPositionExt.CC,
225 Y.WidgetPositionExt.CC]227 Y.WidgetPositionExt.CC]
226 },228 },
227 progressbar: true,229 progressbar: true,
228 progress: 100,230 progress: 100,
@@ -231,6 +233,7 @@
231 zIndex: 1000,233 zIndex: 1000,
232 visible: false234 visible: false
233 });235 });
236 var picker = new Y.Picker(new_config);
234237
235 picker.subscribe('save', function (e) {238 picker.subscribe('save', function (e) {
236 Y.log('Got save event.');239 Y.log('Got save event.');
237240
=== added file 'lib/canonical/launchpad/javascript/registry/team.js'
--- lib/canonical/launchpad/javascript/registry/team.js 1970-01-01 00:00:00 +0000
+++ lib/canonical/launchpad/javascript/registry/team.js 2009-12-16 02:39:23 +0000
@@ -0,0 +1,102 @@
1/** Copyright (c) 2009, Canonical Ltd. All rights reserved.
2 *
3 * Objects for subscription handling.
4 *
5 * @module lp.subscriber
6 */
7
8YUI.add('registry.team', function(Y) {
9
10var module = Y.namespace('registry.team');
11
12/*
13 * Initialize click handler for the add member link
14 *
15 * @method setup_add_member_handler
16 */
17module.setup_add_member_handler = function() {
18 var config = {
19 header: 'Add a member',
20 step_title: 'Search',
21 picker_activator: '.menu-link-add_member'
22 };
23
24 var picker = Y.lp.picker.create(
25 'ValidTeamMember',
26 function(result) { _add_member(result); },
27 config);
28};
29
30var _add_member = function(result) {
31 var spinner = Y.one('#add-member-spinner');
32 var addmember_link = Y.one('.menu-link-add_member');
33 addmember_link.setStyle('display', 'none');
34 spinner.setStyle('display', 'inline');
35 function disable_spinner() {
36 addmember_link.setStyle('display', 'inline');
37 spinner.setStyle('display', 'none');
38 }
39 lp_client = new LP.client.Launchpad();
40
41 var error_handler = new LP.client.ErrorHandler();
42 error_handler.clearProgressUI = disable_spinner;
43 error_handler.showError = function(error_msg) {
44 Y.lp.display_error(Y.one('.menu-link-add_member'), error_msg);
45 };
46
47 config = {
48 on: {
49 success: function(member_added) {
50 if (!member_added) {
51 disable_spinner();
52 alert('Already a member.');
53 return;
54 }
55 if (result.css.match("team")) {
56 disable_spinner();
57 alert('This is a team');
58 return;
59 }
60 var members_section = Y.one('#recently-approved');
61 var members_ul = Y.one('#recently-approved-ul');
62 var first_node = members_ul.get('firstChild');
63 config = {
64 on: {
65 success: function(person_html) {
66 var total_members = Y.one(
67 '#member-count').get('innerHTML');
68 total_members = parseInt(total_members, 10) + 1;
69 Y.one('#member-count').set(
70 'innerHTML', total_members);
71 person_repr = Y.Node.create(
72 '<li>' + person_html + '</li>');
73 members_section.setStyle('visibility', 'visible');
74 members_ul.insertBefore(
75 person_repr, first_node);
76 anim = Y.lazr.anim.green_flash(
77 {node: person_repr});
78 anim.run();
79 disable_spinner();
80 },
81 failure: error_handler.getFailureHandler()
82 },
83 accept: LP.client.XHTML
84 };
85 lp_client.get(result.api_uri, config);
86 },
87 failure: error_handler.getFailureHandler()
88 },
89 parameters: {
90 // XXX: Why do I always have to get absolute URIs out of the URIs
91 // in the picker's result/client.links?
92 reviewer: LP.client.get_absolute_uri(LP.client.links.me),
93 person: LP.client.get_absolute_uri(result.api_uri)
94 }
95 };
96
97 lp_client.named_post(
98 LP.client.cache.context.self_link, 'addMember', config);
99};
100
101}, '0.1', {requires: [
102 'node', 'lazr.anim', 'lp.picker', 'lp.errors', 'lp.client.plugins']});
0103
=== modified file 'lib/lp/app/templates/base-layout-macros.pt'
--- lib/lp/app/templates/base-layout-macros.pt 2009-12-08 16:43:44 +0000
+++ lib/lp/app/templates/base-layout-macros.pt 2009-12-16 02:39:23 +0000
@@ -181,6 +181,8 @@
181 tal:attributes="src string:${lp_js}/lp/comment.js"></script>181 tal:attributes="src string:${lp_js}/lp/comment.js"></script>
182 <script type="text/javascript"182 <script type="text/javascript"
183 tal:attributes="src string:${lp_js}/lp/errors.js"></script>183 tal:attributes="src string:${lp_js}/lp/errors.js"></script>
184 <script type="text/javascript"
185 tal:attributes="src string:${lp_js}/registry/team.js"></script>
184186
185 </tal:devmode>187 </tal:devmode>
186 <tal:production condition="not:devmode">188 <tal:production condition="not:devmode">
187189
=== modified file 'lib/lp/bugs/tests/test_bugs_webservice.py'
--- lib/lp/bugs/tests/test_bugs_webservice.py 2009-12-01 12:21:56 +0000
+++ lib/lp/bugs/tests/test_bugs_webservice.py 2009-12-16 02:39:22 +0000
@@ -120,16 +120,6 @@
120 self.assertEqual(response.status, 200)120 self.assertEqual(response.status, 200)
121121
122 rendered_comment = response.body122 rendered_comment = response.body
123 # XXX Bjorn Tillenius 2009-05-15 bug=377003
124 # The current request is a web service request when rendering
125 # the HTML, causing canonical_url to produce links pointing to the
126 # web service. Adjust the test to compensate for this, and accept
127 # that the links will be incorrect for now. We should fix this
128 # before using it for anything useful.
129 rendered_comment = rendered_comment.replace(
130 'http://api.launchpad.dev/beta/',
131 'http://launchpad.dev/')
132
133 self.assertRenderedCommentsEqual(123 self.assertRenderedCommentsEqual(
134 rendered_comment, self.expected_comment_html)124 rendered_comment, self.expected_comment_html)
135125
136126
=== modified file 'lib/lp/registry/browser/configure.zcml'
--- lib/lp/registry/browser/configure.zcml 2009-12-05 18:37:28 +0000
+++ lib/lp/registry/browser/configure.zcml 2009-12-16 02:39:22 +0000
@@ -751,6 +751,9 @@
751 for="lp.registry.interfaces.person.IPerson"751 for="lp.registry.interfaces.person.IPerson"
752 layer="canonical.launchpad.layers.BugsLayer"752 layer="canonical.launchpad.layers.BugsLayer"
753 name="+bugs"/>753 name="+bugs"/>
754 <adapter
755 factory="lp.registry.browser.person.PersonXHTMLRepresentation"
756 name="lazr.restful.EntryResource" />
754 <browser:page757 <browser:page
755 name="+review"758 name="+review"
756 for="lp.registry.interfaces.person.IPerson"759 for="lp.registry.interfaces.person.IPerson"
757760
=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py 2009-12-08 10:20:37 +0000
+++ lib/lp/registry/browser/person.py 2009-12-16 02:39:23 +0000
@@ -102,7 +102,7 @@
102from zope.interface import classImplements, implements, Interface102from zope.interface import classImplements, implements, Interface
103from zope.interface.exceptions import Invalid103from zope.interface.exceptions import Invalid
104from zope.interface.interface import invariant104from zope.interface.interface import invariant
105from zope.component import getUtility105from zope.component import adapts, getUtility
106from zope.publisher.interfaces import NotFound106from zope.publisher.interfaces import NotFound
107from zope.publisher.interfaces.browser import IBrowserPublisher107from zope.publisher.interfaces.browser import IBrowserPublisher
108from zope.schema import Bool, Choice, List, Text, TextLine108from zope.schema import Bool, Choice, List, Text, TextLine
@@ -117,6 +117,7 @@
117from lazr.delegates import delegates117from lazr.delegates import delegates
118from lazr.config import as_timedelta118from lazr.config import as_timedelta
119from lazr.restful.interface import copy_field, use_template119from lazr.restful.interface import copy_field, use_template
120from lazr.restful.interfaces import IWebServiceClientRequest
120from canonical.lazr.utils import safe_hasattr121from canonical.lazr.utils import safe_hasattr
121from canonical.database.sqlbase import flush_database_updates122from canonical.database.sqlbase import flush_database_updates
122123
@@ -226,7 +227,8 @@
226 logoutPerson, allowUnauthenticatedSession)227 logoutPerson, allowUnauthenticatedSession)
227from canonical.launchpad.webapp.menu import get_current_view228from canonical.launchpad.webapp.menu import get_current_view
228from canonical.launchpad.webapp.publisher import LaunchpadView229from canonical.launchpad.webapp.publisher import LaunchpadView
229from canonical.launchpad.webapp.tales import DateTimeFormatterAPI230from canonical.launchpad.webapp.tales import (
231 DateTimeFormatterAPI, PersonFormatterAPI)
230from lazr.uri import URI, InvalidURIError232from lazr.uri import URI, InvalidURIError
231233
232from canonical.launchpad import _234from canonical.launchpad import _
@@ -2460,7 +2462,7 @@
24602462
2461 @property2463 @property
2462 def next_url(self):2464 def next_url(self):
2463 """Redirect back to the +languages page if request originated there."""2465 """Redirect back to the originating page."""
2464 redirection_url = self.request.get('redirection_url')2466 redirection_url = self.request.get('redirection_url')
2465 if redirection_url:2467 if redirection_url:
2466 return redirection_url2468 return redirection_url
@@ -2468,7 +2470,7 @@
24682470
2469 @property2471 @property
2470 def cancel_url(self):2472 def cancel_url(self):
2471 """Redirect back to the +languages page if request originated there."""2473 """Redirect back to the originating page."""
2472 redirection_url = self.getRedirectionURL()2474 redirection_url = self.getRedirectionURL()
2473 if redirection_url:2475 if redirection_url:
2474 return redirection_url2476 return redirection_url
@@ -2676,12 +2678,29 @@
2676 orderBy='-TeamMembership.date_proposed')2678 orderBy='-TeamMembership.date_proposed')
2677 return members[:5]2679 return members[:5]
26782680
2679 @cachedproperty2681 @property
2680 def has_recent_approved_or_proposed_members(self):2682 def recently_approved_hidden(self):
2681 """Does the team have recently approved or proposed members?"""2683 """Optionally hide the div.
2682 approved = self.recently_approved_members.count() > 02684
2683 proposed = self.recently_proposed_members.count() > 02685 The AJAX on the page needs the elements to be present
2684 return approved or proposed2686 but hidden in case it adds a member to the list.
2687 """
2688 if self.recently_approved_members.count() == 0:
2689 return 'visibility: collapse'
2690 else:
2691 return ''
2692
2693 @property
2694 def recently_proposed_hidden(self):
2695 """Optionally hide the div.
2696
2697 The AJAX on the page needs the elements to be present
2698 but hidden in case it adds a member to the list.
2699 """
2700 if self.recently_proposed_members.count() == 0:
2701 return 'visibility: collapse'
2702 else:
2703 return ''
26852704
2686 @cachedproperty2705 @cachedproperty
2687 def openpolls(self):2706 def openpolls(self):
@@ -5826,8 +5845,7 @@
5826 usedfor = ITeamIndexMenu5845 usedfor = ITeamIndexMenu
5827 facet = 'overview'5846 facet = 'overview'
5828 title = 'Change team'5847 title = 'Change team'
5829 links = ('edit', 'join', 'add_member', 'add_my_teams',5848 links = ('edit', 'join', 'add_my_teams', 'leave')
5830 'leave')
58315849
58325850
5833class TeamEditMenu(TeamNavigationMenuBase):5851class TeamEditMenu(TeamNavigationMenuBase):
@@ -5843,3 +5861,16 @@
5843classImplements(TeamIndexView, ITeamIndexMenu)5861classImplements(TeamIndexView, ITeamIndexMenu)
5844classImplements(TeamEditView, ITeamEditMenu)5862classImplements(TeamEditView, ITeamEditMenu)
5845classImplements(PersonIndexView, IPersonIndexMenu)5863classImplements(PersonIndexView, IPersonIndexMenu)
5864
5865
5866class PersonXHTMLRepresentation:
5867 adapts(IPerson, IWebServiceClientRequest)
5868 implements(Interface)
5869
5870 def __init__(self, person, request):
5871 self.person = person
5872 self.request = request
5873
5874 def __call__(self):
5875 """Render `Person` as XHTML using the webservice."""
5876 return PersonFormatterAPI(self.person).link(None)
58465877
=== modified file 'lib/lp/registry/browser/tests/teammembership-views.txt'
--- lib/lp/registry/browser/tests/teammembership-views.txt 2009-11-13 13:06:50 +0000
+++ lib/lp/registry/browser/tests/teammembership-views.txt 2009-12-16 02:39:23 +0000
@@ -63,6 +63,7 @@
6363
64 >>> login_person(team_owner)64 >>> login_person(team_owner)
65 >>> super_team.addMember(team, team_owner)65 >>> super_team.addMember(team, team_owner)
66 True
66 >>> membership = membership_set.getByPersonAndTeam(team, super_team)67 >>> membership = membership_set.getByPersonAndTeam(team, super_team)
67 >>> login_person(team.teamowner)68 >>> login_person(team.teamowner)
68 >>> view = TeamInvitationView(membership, request)69 >>> view = TeamInvitationView(membership, request)
6970
=== added file 'lib/lp/registry/browser/tests/test_person_webservice.py'
--- lib/lp/registry/browser/tests/test_person_webservice.py 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/browser/tests/test_person_webservice.py 2009-12-16 02:39:22 +0000
@@ -0,0 +1,38 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4__metaclass__ = type
5
6import unittest
7
8from canonical.launchpad.ftests import login
9from lp.testing import TestCaseWithFactory
10from canonical.launchpad.testing.pages import LaunchpadWebServiceCaller
11from canonical.testing import DatabaseFunctionalLayer
12
13
14class TestPersonRepresentation(TestCaseWithFactory):
15 layer = DatabaseFunctionalLayer
16
17 def setUp(self):
18 TestCaseWithFactory.setUp(self)
19 login('guilherme.salgado@canonical.com ')
20 self.person = self.factory.makePerson(
21 name='test-person', displayname='Test Person')
22 self.webservice = LaunchpadWebServiceCaller(
23 'launchpad-library', 'salgado-change-anything')
24
25 def test_GET_xhtml_representation(self):
26 response = self.webservice.get(
27 '/~%s' % self.person.name, 'application/xhtml+xml')
28
29 self.assertEqual(response.status, 200)
30
31 rendered_comment = response.body
32 self.assertEquals(
33 rendered_comment,
34 '<a href="/~test-person" class="sprite person">Test Person</a>')
35
36
37def test_suite():
38 return unittest.TestLoader().loadTestsFromName(__name__)
039
=== modified file 'lib/lp/registry/doc/teammembership-email-notification.txt'
--- lib/lp/registry/doc/teammembership-email-notification.txt 2009-08-24 03:01:12 +0000
+++ lib/lp/registry/doc/teammembership-email-notification.txt 2009-12-16 02:39:23 +0000
@@ -226,6 +226,7 @@
226 >>> cprov = personset.getByName('cprov')226 >>> cprov = personset.getByName('cprov')
227 >>> marilize = personset.getByName('marilize')227 >>> marilize = personset.getByName('marilize')
228 >>> ubuntu_team.addMember(marilize, reviewer=cprov)228 >>> ubuntu_team.addMember(marilize, reviewer=cprov)
229 True
229 >>> transaction.commit()230 >>> transaction.commit()
230 >>> len(stub.test_emails)231 >>> len(stub.test_emails)
231 6232 6
@@ -275,6 +276,7 @@
275 >>> mirror_admins.getTeamAdminsEmailAddresses()276 >>> mirror_admins.getTeamAdminsEmailAddresses()
276 ['mark@example.com']277 ['mark@example.com']
277 >>> ubuntu_team.addMember(mirror_admins, reviewer=cprov)278 >>> ubuntu_team.addMember(mirror_admins, reviewer=cprov)
279 True
278 >>> transaction.commit()280 >>> transaction.commit()
279 >>> len(stub.test_emails)281 >>> len(stub.test_emails)
280 1282 1
@@ -326,6 +328,7 @@
326328
327 >>> landscape = personset.getByName('landscape-developers')329 >>> landscape = personset.getByName('landscape-developers')
328 >>> ubuntu_team.addMember(landscape, reviewer=cprov)330 >>> ubuntu_team.addMember(landscape, reviewer=cprov)
331 True
329332
330 # Reset stub.test_emails as we don't care about the notification triggered333 # Reset stub.test_emails as we don't care about the notification triggered
331 # by the addMember() call.334 # by the addMember() call.
@@ -359,6 +362,7 @@
359362
360 >>> launchpad = personset.getByName('launchpad')363 >>> launchpad = personset.getByName('launchpad')
361 >>> ubuntu_team.addMember(launchpad, reviewer=cprov, force_team_add=True)364 >>> ubuntu_team.addMember(launchpad, reviewer=cprov, force_team_add=True)
365 True
362 >>> flush_database_updates()366 >>> flush_database_updates()
363 >>> transaction.commit()367 >>> transaction.commit()
364 >>> len(stub.test_emails)368 >>> len(stub.test_emails)
@@ -812,6 +816,7 @@
812 >>> member = factory.makePerson(816 >>> member = factory.makePerson(
813 ... name='team-member', email='team-member@example.com')817 ... name='team-member', email='team-member@example.com')
814 >>> team_one.addMember(member, owner)818 >>> team_one.addMember(member, owner)
819 True
815 >>> print_distinct_emails()820 >>> print_distinct_emails()
816 From: Team One ...821 From: Team One ...
817 ----------------------------------------822 ----------------------------------------
@@ -838,6 +843,7 @@
838 >>> team_two = factory.makeTeam(843 >>> team_two = factory.makeTeam(
839 ... name='team-two', email='team-two@example.com', owner=owner)844 ... name='team-two', email='team-two@example.com', owner=owner)
840 >>> team_one.addMember(team_two, owner, force_team_add=True)845 >>> team_one.addMember(team_two, owner, force_team_add=True)
846 True
841 >>> print_distinct_emails()847 >>> print_distinct_emails()
842 From: Team One ...848 From: Team One ...
843 ----------------------------------------849 ----------------------------------------
844850
=== modified file 'lib/lp/registry/doc/teammembership.txt'
--- lib/lp/registry/doc/teammembership.txt 2009-11-30 20:18:42 +0000
+++ lib/lp/registry/doc/teammembership.txt 2009-12-16 02:39:22 +0000
@@ -132,7 +132,6 @@
132Other users must use the join method if they are going to add themselves132Other users must use the join method if they are going to add themselves
133to a team.133to a team.
134134
135 >>> from zope.security.interfaces import Unauthorized
136 >>> mark = personset.getByName('mark')135 >>> mark = personset.getByName('mark')
137 >>> t3.addMember(salgado, reviewer=mark, status=TeamMembershipStatus.ADMIN)136 >>> t3.addMember(salgado, reviewer=mark, status=TeamMembershipStatus.ADMIN)
138 Traceback (most recent call last):137 Traceback (most recent call last):
@@ -142,8 +141,12 @@
142 # Log in as the team owner.141 # Log in as the team owner.
143 >>> login_person(t3.teamowner)142 >>> login_person(t3.teamowner)
144143
144addMember returns True if the member got added (i.e. he wasn't already a
145member of the team).
146
145 >>> t3.addMember(147 >>> t3.addMember(
146 ... salgado, reviewer=mark, status=TeamMembershipStatus.ADMIN)148 ... salgado, reviewer=mark, status=TeamMembershipStatus.ADMIN)
149 True
147 >>> from canonical.launchpad.interfaces import ITeamMembershipSet150 >>> from canonical.launchpad.interfaces import ITeamMembershipSet
148 >>> membershipset = getUtility(ITeamMembershipSet)151 >>> membershipset = getUtility(ITeamMembershipSet)
149 >>> flush_database_updates()152 >>> flush_database_updates()
@@ -155,13 +158,27 @@
155 >>> salgado in t3.activemembers158 >>> salgado in t3.activemembers
156 True159 True
157160
161addMember returns True also when the member is added as a proposed
162member.
163
158 >>> marilize = personset.getByName('marilize')164 >>> marilize = personset.getByName('marilize')
159 >>> t3.addMember(165 >>> t3.addMember(
160 ... marilize, reviewer=mark, status=TeamMembershipStatus.PROPOSED)166 ... marilize, reviewer=mark, status=TeamMembershipStatus.PROPOSED)
167 True
161 >>> flush_database_updates()168 >>> flush_database_updates()
162 >>> marilize in t3.activemembers169 >>> marilize in t3.activemembers
163 False170 False
164171
172If addMember is called with a person that is already a member, it
173returns False.
174
175 >>> t3.addMember(
176 ... salgado, reviewer=mark, status=TeamMembershipStatus.ADMIN)
177 False
178 >>> t3.addMember(
179 ... marilize, reviewer=mark, status=TeamMembershipStatus.PROPOSED)
180 False
181
165As expected, the membership object implements ITeamMembership.182As expected, the membership object implements ITeamMembership.
166183
167 >>> from canonical.launchpad.webapp.testing import verifyObject184 >>> from canonical.launchpad.webapp.testing import verifyObject
@@ -175,6 +192,7 @@
175invitation before the team is made a member.192invitation before the team is made a member.
176193
177 >>> t1.addMember(t2, reviewer)194 >>> t1.addMember(t2, reviewer)
195 True
178 >>> membership = membershipset.getByPersonAndTeam(t2, t1)196 >>> membership = membershipset.getByPersonAndTeam(t2, t1)
179 >>> membership.status == TeamMembershipStatus.INVITED197 >>> membership.status == TeamMembershipStatus.INVITED
180 True198 True
@@ -192,6 +210,7 @@
192A team admin can also decline an invitation made to his team.210A team admin can also decline an invitation made to his team.
193211
194 >>> t2.addMember(t3, reviewer=mark)212 >>> t2.addMember(t3, reviewer=mark)
213 True
195 >>> login_person(t3.teamowner)214 >>> login_person(t3.teamowner)
196 >>> t3.declineInvitationToBeMemberOf(t2, comment='something')215 >>> t3.declineInvitationToBeMemberOf(t2, comment='something')
197 >>> membership = membershipset.getByPersonAndTeam(t3, t2)216 >>> membership = membershipset.getByPersonAndTeam(t3, t2)
@@ -205,6 +224,7 @@
205224
206 >>> login_person(t3.teamowner)225 >>> login_person(t3.teamowner)
207 >>> t2.addMember(t3, reviewer=mark, force_team_add=True)226 >>> t2.addMember(t3, reviewer=mark, force_team_add=True)
227 True
208 >>> [m.displayname for m in t2.allmembers]228 >>> [m.displayname for m in t2.allmembers]
209 [u'Foo Bar', u'Guilherme Salgado', u't3']229 [u'Foo Bar', u'Guilherme Salgado', u't3']
210230
@@ -226,6 +246,7 @@
226Adding t2 as a member of t5 will add all t2 members as t5 members too.246Adding t2 as a member of t5 will add all t2 members as t5 members too.
227247
228 >>> t5.addMember(t2, reviewer, force_team_add=True)248 >>> t5.addMember(t2, reviewer, force_team_add=True)
249 True
229 >>> [m.displayname for m in t5.allmembers]250 >>> [m.displayname for m in t5.allmembers]
230 [u'Foo Bar', u'Guilherme Salgado', u't2', u't3']251 [u'Foo Bar', u'Guilherme Salgado', u't2', u't3']
231252
@@ -233,7 +254,9 @@
233members too.254members too.
234255
235 >>> t4.addMember(t5, reviewer, force_team_add=True)256 >>> t4.addMember(t5, reviewer, force_team_add=True)
257 True
236 >>> t4.addMember(t1, reviewer, force_team_add=True)258 >>> t4.addMember(t1, reviewer, force_team_add=True)
259 True
237 >>> [m.displayname for m in t4.allmembers]260 >>> [m.displayname for m in t4.allmembers]
238 [u'Foo Bar', u'Guilherme Salgado', u't1', u't2', u't3', u't5']261 [u'Foo Bar', u'Guilherme Salgado', u't1', u't2', u't3', u't5']
239262
@@ -327,6 +350,7 @@
327350
328 >>> cprov = getUtility(IPersonSet).getByName('cprov')351 >>> cprov = getUtility(IPersonSet).getByName('cprov')
329 >>> t3.addMember(cprov, reviewer)352 >>> t3.addMember(cprov, reviewer)
353 True
330 >>> [m.displayname for m in t3.allmembers]354 >>> [m.displayname for m in t3.allmembers]
331 [u'Celso Providelo', u'Foo Bar']355 [u'Celso Providelo', u'Foo Bar']
332356
@@ -391,15 +415,22 @@
391 None415 None
392416
393When we approve his membership, the datejoined will contain the date that it417When we approve his membership, the datejoined will contain the date that it
394was approved.418was approved. It returns True to indicate that the status was changed.
395419
396 >>> membership.setStatus(TeamMembershipStatus.APPROVED, foobar)420 >>> membership.setStatus(TeamMembershipStatus.APPROVED, foobar)
421 True
397 >>> print membership.status.title422 >>> print membership.status.title
398 Approved423 Approved
399 >>> utc_now = datetime.now(pytz.timezone('UTC'))424 >>> utc_now = datetime.now(pytz.timezone('UTC'))
400 >>> membership.datejoined.date() == utc_now.date()425 >>> membership.datejoined.date() == utc_now.date()
401 True426 True
402427
428If setStatus is called again with the same status, it returns False,
429to indicate that the status didn't change.
430
431 >>> membership.setStatus(TeamMembershipStatus.APPROVED, foobar)
432 False
433
403Other status updates won't change datejoined, regardless of the status.434Other status updates won't change datejoined, regardless of the status.
404That's because datejoined stores the date in which the membership was first435That's because datejoined stores the date in which the membership was first
405made active.436made active.
@@ -415,6 +446,7 @@
415446
416 >>> foobar_on_buildd.setStatus(447 >>> foobar_on_buildd.setStatus(
417 ... TeamMembershipStatus.DEACTIVATED, foobar)448 ... TeamMembershipStatus.DEACTIVATED, foobar)
449 True
418 >>> print foobar_on_buildd.status.title450 >>> print foobar_on_buildd.status.title
419 Deactivated451 Deactivated
420 >>> foobar_on_buildd.datejoined <= utc_now452 >>> foobar_on_buildd.datejoined <= utc_now
@@ -422,6 +454,7 @@
422454
423 >>> foobar_on_buildd.setStatus(455 >>> foobar_on_buildd.setStatus(
424 ... TeamMembershipStatus.APPROVED, foobar)456 ... TeamMembershipStatus.APPROVED, foobar)
457 True
425 >>> print foobar_on_buildd.status.title458 >>> print foobar_on_buildd.status.title
426 Approved459 Approved
427 >>> foobar_on_buildd.datejoined <= utc_now460 >>> foobar_on_buildd.datejoined <= utc_now
@@ -790,6 +823,7 @@
790 >>> admins = getUtility(IPersonSet).getByName('admins')823 >>> admins = getUtility(IPersonSet).getByName('admins')
791 >>> login_person(t1.teamowner)824 >>> login_person(t1.teamowner)
792 >>> t1.addMember(admins, reviewer=t1.teamowner, force_team_add=True)825 >>> t1.addMember(admins, reviewer=t1.teamowner, force_team_add=True)
826 True
793 >>> flush_database_updates()827 >>> flush_database_updates()
794 >>> print '\n'.join(sorted(828 >>> print '\n'.join(sorted(
795 ... team.name for team in salgado.teams_participated_in))829 ... team.name for team in salgado.teams_participated_in))
@@ -804,6 +838,7 @@
804for Salgado.838for Salgado.
805839
806 >>> admins.addMember(t2, reviewer=admins.teamowner, force_team_add=True)840 >>> admins.addMember(t2, reviewer=admins.teamowner, force_team_add=True)
841 True
807 >>> flush_database_updates()842 >>> flush_database_updates()
808 >>> print '\n'.join(sorted(843 >>> print '\n'.join(sorted(
809 ... team.name for team in salgado.teams_participated_in))844 ... team.name for team in salgado.teams_participated_in))
@@ -845,6 +880,7 @@
845Or changed:880Or changed:
846881
847 >>> membership.setStatus(TeamMembershipStatus.DEACTIVATED, mark)882 >>> membership.setStatus(TeamMembershipStatus.DEACTIVATED, mark)
883 True
848 >>> no_priv._inTeam_cache884 >>> no_priv._inTeam_cache
849 {}885 {}
850 >>> no_priv.inTeam(admins)886 >>> no_priv.inTeam(admins)
@@ -902,3 +938,4 @@
902 >>> bad_membership.sendAutoRenewalNotification()938 >>> bad_membership.sendAutoRenewalNotification()
903939
904 >>> bad_membership.setStatus(TeamMembershipStatus.EXPIRED, bad_user)940 >>> bad_membership.setStatus(TeamMembershipStatus.EXPIRED, bad_user)
941 True
905942
=== modified file 'lib/lp/registry/interfaces/teammembership.py'
--- lib/lp/registry/interfaces/teammembership.py 2009-06-25 04:06:00 +0000
+++ lib/lp/registry/interfaces/teammembership.py 2009-12-16 02:39:23 +0000
@@ -224,6 +224,8 @@
224 transition.224 transition.
225225
226 The given status must be different than the current status.226 The given status must be different than the current status.
227
228 Return True if the status got changed, otherwise False.
227 """229 """
228230
229231
230232
=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py 2009-12-05 18:37:28 +0000
+++ lib/lp/registry/model/person.py 2009-12-16 02:39:23 +0000
@@ -1236,6 +1236,7 @@
1236 status = TeamMembershipStatus.INVITED1236 status = TeamMembershipStatus.INVITED
1237 event = TeamInvitationEvent1237 event = TeamInvitationEvent
12381238
1239 member_added = True
1239 expires = self.defaultexpirationdate1240 expires = self.defaultexpirationdate
1240 tm = TeamMembership.selectOneBy(person=person, team=self)1241 tm = TeamMembership.selectOneBy(person=person, team=self)
1241 if tm is None:1242 if tm is None:
@@ -1250,11 +1251,12 @@
1250 # We can't use tm.setExpirationDate() here because the reviewer1251 # We can't use tm.setExpirationDate() here because the reviewer
1251 # here will be the member themselves when they join an OPEN team.1252 # here will be the member themselves when they join an OPEN team.
1252 tm.dateexpires = expires1253 tm.dateexpires = expires
1253 tm.setStatus(status, reviewer, comment)1254 member_added = tm.setStatus(status, reviewer, comment)
12541255
1255 if not person.is_team and may_subscribe_to_list:1256 if not person.is_team and may_subscribe_to_list:
1256 person.autoSubscribeToMailingList(self.mailing_list,1257 person.autoSubscribeToMailingList(self.mailing_list,
1257 requester=reviewer)1258 requester=reviewer)
1259 return member_added
12581260
1259 # The three methods below are not in the IPerson interface because we want1261 # The three methods below are not in the IPerson interface because we want
1260 # to protect them with a launchpad.Edit permission. We could do that by1262 # to protect them with a launchpad.Edit permission. We could do that by
12611263
=== modified file 'lib/lp/registry/model/teammembership.py'
--- lib/lp/registry/model/teammembership.py 2009-06-25 04:06:00 +0000
+++ lib/lp/registry/model/teammembership.py 2009-12-16 02:39:23 +0000
@@ -272,7 +272,7 @@
272 def setStatus(self, status, user, comment=None):272 def setStatus(self, status, user, comment=None):
273 """See `ITeamMembership`."""273 """See `ITeamMembership`."""
274 if status == self.status:274 if status == self.status:
275 return275 return False
276276
277 approved = TeamMembershipStatus.APPROVED277 approved = TeamMembershipStatus.APPROVED
278 admin = TeamMembershipStatus.ADMIN278 admin = TeamMembershipStatus.ADMIN
@@ -357,10 +357,9 @@
357 # When a member proposes himself, a more detailed notification is357 # When a member proposes himself, a more detailed notification is
358 # sent to the team admins by a subscriber of JoinTeamEvent; that's358 # sent to the team admins by a subscriber of JoinTeamEvent; that's
359 # why we don't send anything here.359 # why we don't send anything here.
360 if self.person == self.last_changed_by and self.status == proposed:360 if self.person != self.last_changed_by or self.status != proposed:
361 return361 self._sendStatusChangeNotification(old_status)
362362 return True
363 self._sendStatusChangeNotification(old_status)
364363
365 def _sendStatusChangeNotification(self, old_status):364 def _sendStatusChangeNotification(self, old_status):
366 """Send a status change notification to all team admins and the365 """Send a status change notification to all team admins and the
367366
=== modified file 'lib/lp/registry/templates/team-index.pt'
--- lib/lp/registry/templates/team-index.pt 2009-10-26 21:12:49 +0000
+++ lib/lp/registry/templates/team-index.pt 2009-12-16 02:39:22 +0000
@@ -17,6 +17,16 @@
17 rel="meta" type="application/rdf+xml"17 rel="meta" type="application/rdf+xml"
18 title="FOAF" href="+rdf"18 title="FOAF" href="+rdf"
19 />19 />
20 <script type="text/javascript"
21 tal:content="string:
22 YUI().use('registry.team', function(Y) {
23 Y.on('load',
24 function(e) {
25 Y.registry.team.setup_add_member_handler();
26 },
27 window);
28 });
29 "/>
20 </tal:block>30 </tal:block>
21</head>31</head>
2232
2333
=== modified file 'lib/lp/registry/templates/team-portlet-membership.pt'
--- lib/lp/registry/templates/team-portlet-membership.pt 2009-10-23 21:11:12 +0000
+++ lib/lp/registry/templates/team-portlet-membership.pt 2009-12-16 02:39:23 +0000
@@ -18,7 +18,9 @@
18 <div id="membership-summary">18 <div id="membership-summary">
19 <div>19 <div>
20 <img src="/@@/team" alt="team" />20 <img src="/@@/team" alt="team" />
21 <strong><tal:active content="context/all_member_count" /></strong>21 <strong id="member-count">
22 <tal:active content="context/all_member_count" />
23 </strong>
22 <a tal:attributes="href string:${context/fmt:url/+members}#active"24 <a tal:attributes="href string:${context/fmt:url/+members}#active"
23 >active members</a><tal:invited25 >active members</a><tal:invited
24 define="invited_member_count context/invited_member_count"26 define="invited_member_count context/invited_member_count"
@@ -90,24 +92,33 @@
90 <tal:can-view92 <tal:can-view
91 condition="context/@@+restricted-membership/userCanViewMembership"93 condition="context/@@+restricted-membership/userCanViewMembership"
92 define="overview_menu context/menu:overview">94 define="overview_menu context/menu:overview">
93 <table style="margin: 0px 0px .5em 0px;"95 <table style="margin: 0px 0px .5em 0px;">
94 tal:condition="view/has_recent_approved_or_proposed_members">
95 <tr>96 <tr>
97 <td style="padding: 0px 1em 1em 0px;"
98 tal:define="link context/menu:overview/add_member">
99 <span id="add-member-spinner" class="update-in-progress-message"
100 style="display: none">
101 Saving...
102 </span>
103 <tal:add-member replace="structure link/fmt:link-icon" />
104 </td>
96 <td style="padding: 0px 0px 1em 0px;"105 <td style="padding: 0px 0px 1em 0px;"
97 tal:define="link context/menu:overview/mugshots">106 tal:define="link context/menu:overview/mugshots">
98 <tal:mugshots replace="structure link/fmt:link-icon" />107 <tal:mugshots replace="structure link/fmt:link-icon" />
99 </td>108 </td>
100 </tr>109 </tr>
101 <tr>110 <tr>
102 <td style="padding: 3px 3em 0px 0px;" id="recently-approved"111 <td style="padding: 3px 3em 0px 0px;">
103 tal:condition="view/recently_approved_members">112 <div id="recently-approved"
104 <h3 style="color:black; font-weight:bold; margin: 0px">113 tal:attributes="style view/recently_approved_hidden">
105 Recently approved114 <h3 style="color:black; font-weight:bold; margin: 0px">
106 </h3>115 Latest members
107 <ul tal:condition="view/recently_approved_members">116 </h3>
108 <li tal:repeat="person view/recently_approved_members"117 <ul id="recently-approved-ul">
109 tal:content="structure person/fmt:link" />118 <li tal:repeat="person view/recently_approved_members"
110 </ul>119 tal:content="structure person/fmt:link" />
120 </ul>
121 </div>
111 </td>122 </td>
112 <td style="padding: 0px;" id="recently-applied"123 <td style="padding: 0px;" id="recently-applied"
113 tal:condition="view/recently_proposed_members">124 tal:condition="view/recently_proposed_members">