← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~sinzui/launchpad/edit-team-description into lp:launchpad

 

Curtis Hovey has proposed merging lp:~sinzui/launchpad/edit-team-description into lp:launchpad.

Requested reviews:
  j.c.sackett (jcsackett)
Related bugs:
  Bug #5283 in Launchpad itself: ""Home page" vs. "Description" is misleading"
  https://bugs.launchpad.net/launchpad/+bug/5283

For more details, see:
https://code.launchpad.net/~sinzui/launchpad/edit-team-description/+merge/119759

We want to make team registration faster so that it can be done during
project registration. The team description is optional. It is not required,
but it is hard for users to find and edit if they have never seen it.

We want to make it easier for team owners to edit team descriptions so
that it does not need to be included in forms. This branch is too large.
The secondary cleanup work to remove uses of the old attributes were much
larger than I anticipated.

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

RULES

    Pre-implementation: jcsackett
    * Use the TextAreaEditorWidget with the team description field so that
      users can easily set the optional data.
    * /o\ There are still two description fields because work on
      Bug #5283 stopped.
      * Add IPerson.description and have it fallback to the obsolete
        teamdescription and homepage_content attributes.

    ADDENDUM
    * I forgot that spammers like to abuse the user description. The
      editor widget must honour the rule to never link URLs in for
      probationary users.
    * The is_probationary_or_invalid_user user is only needed to render
      the meta robots tag. Lp never shows the description for invalid users.


QA

    * Visit http://qastaging.launchpad.net/~
    * Verify you can edit your own profile description.
    * Visit http://qastaging.launchpad.net/~registry
    * Verify you can edit the team description for a team you own.
    * Visit https://qastaging.launchpad.net/~joel-contato
    * Verify the URLs in the description are not rendered as links.


LINT

    lib/lp/app/stories/form/xx-form-layout.txt
    lib/lp/app/templates/launchpad-search.pt
    lib/lp/registry/browser/configure.zcml
    lib/lp/registry/browser/person.py
    lib/lp/registry/browser/team.py
    lib/lp/registry/browser/tests/person-views.txt
    lib/lp/registry/browser/tests/test_person.py
    lib/lp/registry/browser/tests/test_team.py
    lib/lp/registry/interfaces/person.py
    lib/lp/registry/model/person.py
    lib/lp/registry/stories/team/xx-team-claim.txt
    lib/lp/registry/stories/teammembership/xx-teammembership.txt
    lib/lp/registry/stories/webservice/xx-person.txt
    lib/lp/registry/templates/person-index.pt
    lib/lp/registry/templates/team-index.pt
    lib/lp/registry/tests/test_person.py
    lib/lp/registry/tests/test_team_webservice.py
    lib/lp/services/verification/browser/logintoken.py
    lib/lp/services/webapp/doc/webapp-publication.txt
    lib/lp/testing/factory.py

    ^ There is lint in the doctests that I can fix before landing.


TEST

    ./bin/test -vvc -t webservice/xx-person lp.registry.tests.test_doc
    ./bin/test -vvc -t test_description lp.registry.tests.test_person
    ./bin/test -vvc -t TestPersonIndexView lp.registry.browser.tests.test_person
    ./bin/test -vvc -t person-views lp.registry.browser.tests.test_views


IMPLEMENTATION

Added IPerson.description and exported it. Marked .teamdescription and
.homepage_content as obsolete. Added Person.description with a fallback
to the obsolete attributes. Updated the user and team pages to use the
TextAreaEditorWidget for .description. Then realised that the the widget
had to ensure links are not rendered for probationary users (because
1000+ spam users were created in late 2009). I converted the old doctest
to a unit test because the views and templates they tests had subtly
changed over the years -- IPerson.is_probationary is all that is needed
to know when to render description links, and
.is_probationary_or_invalid_user is only used to know when to renter the
meta robots tag.
    lib/lp/registry/browser/person.py
    lib/lp/registry/browser/tests/person-views.txt
    lib/lp/registry/browser/tests/test_person.py
    lib/lp/registry/interfaces/person.py
    lib/lp/registry/model/person.py
    lib/lp/registry/stories/webservice/xx-person.txt
    lib/lp/registry/templates/person-index.pt
    lib/lp/registry/templates/team-index.pt
    lib/lp/registry/tests/test_person.py

Updated Lp to use IPerson.description instead of
IPerson.homepage_content and ITeam.teamdescription. Removed the
edithomepage link and PersonEditHomePageView because users can edit the
description in-line.
    lib/lp/app/stories/form/xx-form-layout.txt
    lib/lp/app/templates/launchpad-search.pt
    lib/lp/registry/browser/configure.zcml
    lib/lp/registry/browser/person.py
    lib/lp/registry/browser/team.py
    lib/lp/registry/browser/tests/test_team.py
    lib/lp/registry/stories/team/xx-team-claim.txt
    lib/lp/registry/stories/teammembership/xx-teammembership.txt
    lib/lp/registry/tests/test_team_webservice.py
    lib/lp/services/verification/browser/logintoken.py
    lib/lp/services/webapp/doc/webapp-publication.txt
    lib/lp/testing/factory.py
-- 
https://code.launchpad.net/~sinzui/launchpad/edit-team-description/+merge/119759
Your team Launchpad code reviewers is subscribed to branch lp:launchpad.
=== modified file 'lib/lp/app/stories/form/xx-form-layout.txt'
--- lib/lp/app/stories/form/xx-form-layout.txt	2012-07-12 00:46:07 +0000
+++ lib/lp/app/stories/form/xx-form-layout.txt	2012-08-15 18:23:28 +0000
@@ -62,9 +62,9 @@
     <tr>
       <td colspan="2" style="text-align: left">
         <div>
-        <label for="field.teamdescription">Team Description:</label>
+        <label for="field.description">Description:</label>
         <span ...
-        <div><textarea ... name="field.teamdescription" ...></textarea></div>
+        <div><textarea ... name="field.description" ...></textarea></div>
         <p class="formHelp">Details about the team's work, highlights, goals,
           and how to contribute. Use plain text, paragraphs are preserved and
           URLs are linked in pages.</p>
@@ -81,9 +81,9 @@
     <tr>
       <td colspan="2" style="text-align: left">
         <div>
-        <label for="field.teamdescription">Team Description:</label>
+        <label for="field.description">Description:</label>
         <span class="fieldRequired">(Optional)</span>
-        <div><textarea ... name="field.teamdescription" ...></textarea></div>
+        <div><textarea ... name="field.description" ...></textarea></div>
         <p class="formHelp">Details about the team's work, highlights, goals,
           and how to contribute. Use plain text, paragraphs are preserved and
           URLs are linked in pages.</p>

=== modified file 'lib/lp/app/templates/launchpad-search.pt'
--- lib/lp/app/templates/launchpad-search.pt	2011-12-08 22:41:00 +0000
+++ lib/lp/app/templates/launchpad-search.pt	2012-08-15 18:23:28 +0000
@@ -130,8 +130,8 @@
                   <tal:team
                     tal:condition="view/person_or_team/is_team" >
                     <div
-                      tal:content="view/person_or_team/teamdescription"
-                      tal:condition="view/person_or_team/teamdescription">
+                      tal:content="view/person_or_team/description"
+                      tal:condition="view/person_or_team/description">
                       The Launchpad team creates Launchpad. That's what they do.
                       They rock!
                     </div>

=== modified file 'lib/lp/registry/browser/configure.zcml'
--- lib/lp/registry/browser/configure.zcml	2012-07-20 03:15:04 +0000
+++ lib/lp/registry/browser/configure.zcml	2012-08-15 18:23:28 +0000
@@ -870,12 +870,6 @@
             class="lp.registry.browser.person.PersonBrandingView"
             permission="launchpad.Edit"
             template="../templates/object-branding.pt"/>
-        <browser:page
-            name="+edithomepage"
-            for="lp.registry.interfaces.person.IPerson"
-            class="lp.registry.browser.person.PersonEditHomePageView"
-            permission="launchpad.Edit"
-            template="../templates/person-edithomepage.pt"/>
         <browser:pages
             for="lp.registry.interfaces.person.IPerson"
             permission="zope.Public"

=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py	2012-08-14 23:27:07 +0000
+++ lib/lp/registry/browser/person.py	2012-08-15 18:23:28 +0000
@@ -19,7 +19,6 @@
     'PersonCodeOfConductEditView',
     'PersonDeactivateAccountView',
     'PersonEditEmailsView',
-    'PersonEditHomePageView',
     'PersonEditIRCNicknamesView',
     'PersonEditJabberIDsView',
     'PersonEditTimeZoneView',
@@ -125,7 +124,9 @@
     LaunchpadEditFormView,
     LaunchpadFormView,
     )
-from lp.app.browser.stringformatter import FormattersAPI
+from lp.app.browser.lazrjs import (
+    TextAreaEditorWidget,
+    )
 from lp.app.browser.tales import (
     DateTimeFormatterAPI,
     PersonFormatterAPI,
@@ -637,12 +638,6 @@
         return self.context
 
     @enabled_with_permission('launchpad.Edit')
-    def common_edithomepage(self):
-        target = '+edithomepage'
-        text = 'Change home page'
-        return Link(target, text, icon='edit')
-
-    @enabled_with_permission('launchpad.Edit')
     def activate_ppa(self):
         target = "+activate-ppa"
         text = 'Create a new PPA'
@@ -731,7 +726,6 @@
     links = [
         'edit',
         'branding',
-        'common_edithomepage',
         'editemailaddresses',
         'editlanguages',
         'editircnicknames',
@@ -1663,25 +1657,6 @@
         return user.is_probationary or not user.is_valid_person_or_team
 
     @cachedproperty
-    def homepage_content(self):
-        """The user's HTML formatted homepage content.
-
-        The markup is simply escaped for probationary or invalid users.
-        The homepage content is reformatted as HTML and linkified if the user
-        is active.
-        """
-        content = self.context.homepage_content
-        if content is None:
-            return None
-        elif self.is_probationary_or_invalid_user:
-            # XXX: Is this really useful?  They can post links in many other
-            # places. -- mbp 2011-11-20.
-            return cgi.escape(content)
-        else:
-            formatter = FormattersAPI
-            return formatter(content).markdown()
-
-    @cachedproperty
     def recently_approved_members(self):
         members = self.context.getMembersByStatus(
             TeamMembershipStatus.APPROVED,
@@ -2238,13 +2213,19 @@
         else:
             return "%s does not use Launchpad" % context.displayname
 
+    @property
+    def description_widget(self):
+        """The description as a widget."""
+        non_probationary = not self.context.is_probationary
+        return TextAreaEditorWidget(
+            self.context, IPerson['description'], title="",
+            edit_title='Edit description', hide_empty=False,
+            linkify_text=non_probationary)
+
     @cachedproperty
     def page_description(self):
-        context = self.context
-        if context.is_valid_person_or_team:
-            return (
-                self.context.homepage_content
-                or self.context.teamdescription)
+        if self.context.is_valid_person_or_team:
+            return self.context.description
         else:
             return None
 
@@ -2710,24 +2691,10 @@
     cancel_url = next_url
 
 
-class PersonEditHomePageView(BasePersonEditView):
-
-    field_names = ['homepage_content']
-    custom_widget(
-        'homepage_content', TextAreaWidget, height=30, width=30)
-
-    @property
-    def label(self):
-        """The form label."""
-        return 'Change home page for %s' % self.context.displayname
-
-    page_title = label
-
-
 class PersonEditView(PersonRenameFormMixin, BasePersonEditView):
     """The Person 'Edit' page."""
 
-    field_names = ['displayname', 'name', 'mugshot', 'homepage_content',
+    field_names = ['displayname', 'name', 'mugshot', 'description',
                    'hide_email_addresses', 'verbose_bugnotifications',
                    'selfgenerated_bugnotifications']
     custom_widget('mugshot', ImageChangeWidget, ImageChangeWidget.EDIT_STYLE)

=== modified file 'lib/lp/registry/browser/team.py'
--- lib/lp/registry/browser/team.py	2012-08-14 23:27:07 +0000
+++ lib/lp/registry/browser/team.py	2012-08-15 18:23:28 +0000
@@ -233,7 +233,7 @@
     """
     field_names = [
         "name", "visibility", "displayname",
-        "teamdescription", "membership_policy",
+        "description", "membership_policy",
         "defaultmembershipperiod", "renewal_policy",
         "defaultrenewalperiod", "teamowner",
         ]
@@ -304,7 +304,7 @@
     custom_widget(
         'membership_policy', LaunchpadRadioWidgetWithDescription,
         orientation='vertical')
-    custom_widget('teamdescription', TextAreaWidget, height=10, width=30)
+    custom_widget('description', TextAreaWidget, height=10, width=30)
 
     def setUpFields(self):
         """See `LaunchpadViewForm`."""
@@ -1005,7 +1005,7 @@
     custom_widget(
         'membership_policy', LaunchpadRadioWidgetWithDescription,
         orientation='vertical')
-    custom_widget('teamdescription', TextAreaWidget, height=10, width=30)
+    custom_widget('description', TextAreaWidget, height=10, width=30)
     custom_widget('defaultrenewalperiod', IntWidget,
         widget_class='field subordinate')
 
@@ -1022,13 +1022,13 @@
     def create_action(self, action, data):
         name = data.get('name')
         displayname = data.get('displayname')
-        teamdescription = data.get('teamdescription')
+        description = data.get('description')
         defaultmembershipperiod = data.get('defaultmembershipperiod')
         defaultrenewalperiod = data.get('defaultrenewalperiod')
         membership_policy = data.get('membership_policy')
         teamowner = data.get('teamowner')
         team = getUtility(IPersonSet).newTeam(
-            teamowner, name, displayname, teamdescription,
+            teamowner, name, displayname, description,
             membership_policy, defaultmembershipperiod, defaultrenewalperiod)
         visibility = data.get('visibility')
         if visibility:
@@ -1623,7 +1623,6 @@
     links = [
         'edit',
         'branding',
-        'common_edithomepage',
         'members',
         'mugshots',
         'add_member',
@@ -2157,8 +2156,7 @@
     usedfor = ITeamEditMenu
     facet = 'overview'
     title = 'Change team'
-    links = ('branding', 'common_edithomepage', 'editlanguages', 'reassign',
-             'editemail')
+    links = ('branding', 'editlanguages', 'reassign', 'editemail')
 
 
 class TeamMugshotView(LaunchpadView):

=== modified file 'lib/lp/registry/browser/tests/person-views.txt'
--- lib/lp/registry/browser/tests/person-views.txt	2012-08-08 11:48:29 +0000
+++ lib/lp/registry/browser/tests/person-views.txt	2012-08-15 18:23:28 +0000
@@ -5,93 +5,6 @@
 information.
 
 
-Probationary and invalid users
-------------------------------
-
-The person +index view provides the is_probationary_or_invalid_user so
-that page features can be disabled because the user may abuse them.
-Active users with karma are not on probation; the user's
-homepage_content is formatted as HTML.
-
-    >>> from lp.registry.interfaces.person import IPersonSet
-
-    >>> homepage_content = "line one <script>\n\nhttp://aa.aa/";
-    >>> person_set = getUtility(IPersonSet)
-    >>> active_user = person_set.getByName('name12')
-    >>> ignored = login_person(active_user)
-    >>> active_user.homepage_content = homepage_content
-    >>> login(ANONYMOUS)
-    >>> view = create_initialized_view(active_user, '+index')
-    >>> view.is_probationary_or_invalid_user
-    False
-
-    >>> print view.homepage_content
-    <p>line one &lt;script&gt;</p>
-    <BLANKLINE>
-    <p><a rel="nofollow" href="http://aa.aa/";>http://<wbr />aa.aa/</a></p>
-
-Teams are always valid and do not have probation rules; the homepage
-content is formatted HTML.
-
-    >>> team = factory.makeTeam()
-    >>> ignored = login_person(team.teamowner)
-    >>> team.homepage_content = homepage_content
-    >>> login(ANONYMOUS)
-    >>> view = create_initialized_view(team, '+index')
-    >>> view.is_probationary_or_invalid_user
-    False
-
-    >>> print view.homepage_content
-    <p>line one &lt;script&gt;</p>
-    <BLANKLINE>
-    <p><a rel="nofollow" href="http://aa.aa/";>http://<wbr />aa.aa/</a></p>
-
-New users are on probation; the homepage content is escaped HTML.
-
-    >>> from zope.security.proxy import removeSecurityProxy
-    >>> new_user = factory.makePerson()
-    >>> removeSecurityProxy(new_user).homepage_content = homepage_content
-    >>> view = create_initialized_view(new_user, '+index')
-    >>> view.is_probationary_or_invalid_user
-    True
-
-    >>> print view.homepage_content
-    line one &lt;script&gt;
-    <BLANKLINE>
-    http://aa.aa/
-
-Inactive and suspended users are invalid; the homepage content is
-escaped HTML.
-
-    >>> from lp.services.identity.interfaces.account import AccountStatus
-    >>> from lp.services.database.lpstorm import IMasterObject
-
-    # Only admins can change an account.
-
-    >>> admin_user = person_set.getByName('name16')
-    >>> ignored = login_person(admin_user)
-    >>> invalid_user = factory.makePerson(name="ugh")
-    >>> invalid_user.homepage_content = homepage_content
-    >>> IMasterObject(invalid_user.account).status = AccountStatus.NOACCOUNT
-    >>> view = create_initialized_view(invalid_user, '+index')
-    >>> view.is_probationary_or_invalid_user
-    True
-
-    >>> print view.homepage_content
-    line one &lt;script&gt;
-    <BLANKLINE>
-    http://aa.aa/
-
-    >>> login(ANONYMOUS)
-
-If the user has no homepage content, the view's value is None.
-
-    >>> removeSecurityProxy(new_user).homepage_content = None
-    >>> view = create_initialized_view(new_user, '+index')
-    >>> print view.homepage_content
-    None
-
-
 Email address disclosure
 ------------------------
 
@@ -108,6 +21,8 @@
 Mark has a registered email address, and he has chosen to disclose it to
 anyone in Launchpad..
 
+    >>> from lp.registry.interfaces.person import IPersonSet
+    >>> person_set = getUtility(IPersonSet)
     >>> login('test@xxxxxxxxxxxxx')
     >>> mark = person_set.getByEmail('mark@xxxxxxxxxxx')
     >>> mark.preferredemail.email

=== modified file 'lib/lp/registry/browser/tests/test_person.py'
--- lib/lp/registry/browser/tests/test_person.py	2012-08-14 23:27:07 +0000
+++ lib/lp/registry/browser/tests/test_person.py	2012-08-15 18:23:28 +0000
@@ -19,6 +19,9 @@
 from zope.component import getUtility
 from zope.publisher.interfaces import NotFound
 
+from lp.app.browser.lazrjs import (
+    TextAreaEditorWidget,
+    )
 from lp.app.errors import NotFoundError
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import BuildStatus
@@ -54,9 +57,11 @@
 from lp.testing import (
     ANONYMOUS,
     BrowserTestCase,
+    celebrity_logged_in,
     login,
     login_celebrity,
     login_person,
+    monkey_patch,
     person_logged_in,
     StormStatementRecorder,
     TestCaseWithFactory,
@@ -137,22 +142,69 @@
             "... Asia/Kolkata (UTC+0530) ..."), doctest.ELLIPSIS
             | doctest.NORMALIZE_WHITESPACE | doctest.REPORT_NDIFF))
 
+    def test_description_widget(self):
+        # The view provides a widget to render ond edit the person description.
+        person = self.factory.makePerson()
+        view = create_initialized_view(person, '+index')
+        self.assertIsInstance(view.description_widget, TextAreaEditorWidget)
+        self.assertEqual(
+            'description', view.description_widget.exported_field.__name__)
+
+    def test_description_widget_is_probationary(self):
+        # Description text is not linkified when the user is probationary.
+        person = self.factory.makePerson()
+        view = create_initialized_view(person, '+index')
+        self.assertIs(True, person.is_probationary)
+        self.assertIs(False, view.description_widget.linkify_text)
+
+    def test_description_widget_non_probationary(self):
+        # Description text is linkified when the user is non-probationary.
+        person = self.factory.makeTeam()
+        view = create_initialized_view(person, '+index')
+        self.assertIs(False, person.is_probationary)
+        self.assertIs(True, view.description_widget.linkify_text)
+
+    @staticmethod
+    def get_markup(view, person):
+        def fake_method():
+            return canonical_url(person)
+        with monkey_patch(view, _getURL=fake_method):
+            markup = view.render()
+        return markup
+
+    def test_is_probationary_or_invalid_user_with_non_probationary(self):
+        team = self.factory.makeTeam()
+        view = create_initialized_view(
+            team, '+index', principal=team.teamowner)
+        self.assertIs(False, view.is_probationary_or_invalid_user)
+        markup = view.render()
+        self.assertFalse(
+            'name="robots" content="noindex,nofollow"' in markup)
+
+    def test_is_probationary_or_invalid_user_with_probationary(self):
+        person = self.factory.makePerson()
+        view = create_initialized_view(person, '+index', principal=person)
+        self.assertIs(True, view.is_probationary_or_invalid_user)
+        markup = self.get_markup(view, person)
+        self.assertTrue(
+            'name="robots" content="noindex,nofollow"' in markup)
+
+    def test_is_probationary_or_invalid_user_with_invalid(self):
+        person = self.factory.makePerson()
+        with celebrity_logged_in('admin'):
+            person.account.status = AccountStatus.NOACCOUNT
+        observer = self.factory.makePerson()
+        view = create_initialized_view(person, '+index', principal=observer)
+        self.assertIs(True, view.is_probationary_or_invalid_user)
+        markup = self.get_markup(view, person)
+        self.assertTrue(
+            'name="robots" content="noindex,nofollow"' in markup)
+
     def test_person_view_page_description(self):
         person_description = self.factory.getUniqueString()
-        person = self.factory.makePerson(
-            homepage_content=person_description)
-        view = create_initialized_view(person, '+index')
-        self.assertThat(view.page_description,
-            Equals(person_description))
-
-    def test_team_page_description(self):
-        description = self.factory.getUniqueString()
-        person = self.factory.makeTeam(
-            description=description)
-        view = create_initialized_view(person, '+index')
-        self.assertThat(
-            view.page_description,
-            Equals(description))
+        person = self.factory.makePerson(description=person_description)
+        view = create_initialized_view(person, '+index')
+        self.assertThat(view.page_description, Equals(person_description))
 
 
 class TestPersonViewKarma(TestCaseWithFactory):

=== modified file 'lib/lp/registry/browser/tests/test_team.py'
--- lib/lp/registry/browser/tests/test_team.py	2012-08-14 23:27:07 +0000
+++ lib/lp/registry/browser/tests/test_team.py	2012-08-15 18:23:28 +0000
@@ -285,7 +285,7 @@
             self.assertEqual(
                 'A Team', view.widgets['displayname']._data)
             self.assertEqual(
-                'A great team', view.widgets['teamdescription']._data)
+                'A great team', view.widgets['description']._data)
             self.assertEqual(
                 TeamMembershipPolicy.MODERATED,
                 view.widgets['membership_policy']._data)
@@ -414,7 +414,7 @@
         browser.getLink('Change details').click()
         browser.getControl('Name', index=0).value = 'ubuntuteam'
         browser.getControl('Display Name').value = 'Ubuntu Team'
-        browser.getControl('Team Description').value = ''
+        browser.getControl('Description').value = ''
         browser.getControl('Restricted Team').selected = True
         browser.getControl('Save').click()
 
@@ -429,7 +429,7 @@
         self.assertEqual(
             'Ubuntu Team', browser.getControl('Display Name', index=0).value)
         self.assertEqual(
-            '', browser.getControl('Team Description', index=0).value)
+            '', browser.getControl('Description', index=0).value)
         self.assertTrue(
             browser.getControl('Restricted Team', index=0).selected)
 

=== modified file 'lib/lp/registry/interfaces/person.py'
--- lib/lp/registry/interfaces/person.py	2012-08-14 15:11:37 +0000
+++ lib/lp/registry/interfaces/person.py	2012-08-15 18:23:28 +0000
@@ -694,9 +694,13 @@
             description=_('The cached total karma for this person.')))
     homepage_content = exported(
         Text(title=_("Homepage Content"), required=False,
+            description=_("Obsolete. Use description.")))
+
+    description = exported(
+        Text(title=_("Description"), required=False,
             description=_(
-                "The content of your profile page. Use plain text, "
-                "paragraphs are preserved and URLs are linked in pages.")))
+                "Details about interests and goals. Use plain text, "
+                "paragraphs are preserved and URLs are linked.")))
 
     mugshot = exported(MugshotImageUpload(
         title=_("Mugshot"), required=False,
@@ -1822,10 +1826,7 @@
 
     teamdescription = exported(
         Text(title=_('Team Description'), required=False, readonly=False,
-             description=_(
-                "Details about the team's work, highlights, goals, "
-                "and how to contribute. Use plain text, paragraphs are "
-                "preserved and URLs are linked in pages.")),
+             description=_("Obsolete. Use description.")),
         exported_as='team_description')
 
     membership_policy = exported(

=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py	2012-08-14 23:27:07 +0000
+++ lib/lp/registry/model/person.py	2012-08-15 18:23:28 +0000
@@ -542,6 +542,7 @@
 
     teamdescription = StringCol(dbName='teamdescription', default=None)
     homepage_content = StringCol(default=None)
+    _description = StringCol(dbName='description', default=None)
     icon = ForeignKey(
         dbName='icon', foreignKey='LibraryFileAlias', default=None)
     logo = ForeignKey(
@@ -627,6 +628,26 @@
 
     personal_standing_reason = StringCol(default=None)
 
+    @property
+    def description(self):
+        """See `IPerson`."""
+        if self._description is not None:
+            return self._description
+        else:
+            # Fallback to obsolete sources.
+            texts = [
+                val for val in [self.homepage_content, self.teamdescription]
+                if val is not None]
+            if len(texts) > 0:
+                return '\n'.join(texts)
+            return None
+
+    @description.setter  # pyflakes:ignore
+    def description(self, value):
+        self._description = value
+        self.homepage_content = None
+        self.teamdescription = None
+
     @cachedproperty
     def ircnicknames(self):
         return list(self._ircnicknames)
@@ -3312,7 +3333,7 @@
             # Support 1.0 API.
             membership_policy = subscription_policy
         team = Person(teamowner=teamowner, name=name, displayname=displayname,
-                teamdescription=teamdescription,
+                description=teamdescription,
                 defaultmembershipperiod=defaultmembershipperiod,
                 defaultrenewalperiod=defaultrenewalperiod,
                 membership_policy=membership_policy)

=== modified file 'lib/lp/registry/stories/team/xx-team-claim.txt'
--- lib/lp/registry/stories/team/xx-team-claim.txt	2011-12-20 10:21:46 +0000
+++ lib/lp/registry/stories/team/xx-team-claim.txt	2012-08-15 18:23:28 +0000
@@ -93,7 +93,7 @@
     Team Owner: No Privileges Person...
 
     >>> user_browser.getControl('Display Name').value = 'Ubuntu Doc Team'
-    >>> user_browser.getControl('Team Description').value = 'The doc team'
+    >>> user_browser.getControl('Description').value = 'The doc team'
     >>> user_browser.getControl('Continue').click()
 
 Once the conversion is finished the user is redirected to the team's home

=== modified file 'lib/lp/registry/stories/teammembership/xx-teammembership.txt'
--- lib/lp/registry/stories/teammembership/xx-teammembership.txt	2012-08-13 20:07:00 +0000
+++ lib/lp/registry/stories/teammembership/xx-teammembership.txt	2012-08-15 18:23:28 +0000
@@ -17,7 +17,7 @@
     >>> browser.getControl(name='field.name').value = 'myemail'
     >>> browser.getControl('Display Name').value = 'your own team'
     >>> browser.getControl(
-    ...     'Team Description').value = 'my own team description'
+    ...     'Description').value = 'my own team description'
     >>> browser.getControl('Subscription period').value = '365'
     >>> browser.getControl('Open Team').selected = True
     >>> browser.getControl('Create').click()

=== modified file 'lib/lp/registry/stories/webservice/xx-person.txt'
--- lib/lp/registry/stories/webservice/xx-person.txt	2012-08-14 15:10:06 +0000
+++ lib/lp/registry/stories/webservice/xx-person.txt	2012-08-15 18:23:28 +0000
@@ -16,6 +16,7 @@
     date_created: u'2005-06-06T08:59:51.596025+00:00'
     deactivated_members_collection_link:
         u'http://.../~salgado/deactivated_members'
+    description: None
     display_name: u'Guilherme Salgado'
     expired_members_collection_link: u'http://.../~salgado/expired_members'
     gpg_keys_collection_link: u'http://.../~salgado/gpg_keys'
@@ -73,6 +74,7 @@
         u'http://.../~ubuntu-team/deactivated_members'
     default_membership_period: None
     default_renewal_period: None
+    description: None
     display_name: u'Ubuntu Team'
     expired_members_collection_link:
         u'http://.../~ubuntu-team/expired_members'

=== modified file 'lib/lp/registry/templates/person-index.pt'
--- lib/lp/registry/templates/person-index.pt	2012-02-16 20:37:55 +0000
+++ lib/lp/registry/templates/person-index.pt	2012-08-15 18:23:28 +0000
@@ -56,11 +56,9 @@
   tal:define="overview_menu context/menu:overview">
   <tal:is-valid-person tal:condition="context/is_valid_person">
 
-    <div
-      class="description"
-      tal:condition="view/homepage_content"
-      tal:content="structure view/homepage_content"
-    />
+    <div class="description">
+      <tal:widget replace="structure view/description_widget" />
+   </div>
     <ul class="horizontal">
       <tal:comment condition="nothing">
         This link name is different from the menu, since it refers

=== modified file 'lib/lp/registry/templates/team-index.pt'
--- lib/lp/registry/templates/team-index.pt	2012-04-05 13:05:04 +0000
+++ lib/lp/registry/templates/team-index.pt	2012-08-15 18:23:28 +0000
@@ -42,16 +42,10 @@
 <div metal:fill-slot="main"
      tal:define="overview_menu context/menu:overview">
 
-  <div
-    class="description"
-    tal:condition="context/homepage_content"
-    tal:content="structure context/homepage_content/fmt:text-to-html"
-  />
+  <div class="description">
+      <tal:widget replace="structure view/description_widget" />
+  </div>
 
-  <div
-    class="description"
-    tal:content="structure context/teamdescription/fmt:text-to-html"
-  />
   <ul class="horizontal">
     <tal:comment condition="nothing">
       This link name is different from the menu, since it refers

=== modified file 'lib/lp/registry/tests/test_person.py'
--- lib/lp/registry/tests/test_person.py	2012-08-13 21:33:47 +0000
+++ lib/lp/registry/tests/test_person.py	2012-08-15 18:23:28 +0000
@@ -283,6 +283,40 @@
         title = smartquote('"%s" team') % team.displayname
         self.assertEqual(title, team.title)
 
+    def test_description_not_exists(self):
+        # When the person does not have a description, teamdescription or
+        # homepage_content, the value is None.
+        person = self.factory.makePerson()
+        self.assertEqual(None, person.description)
+
+    def test_description_fallback_for_person(self):
+        # When the person does not have a description, but does have a
+        # teamdescription or homepage_content, they are used.
+        person = self.factory.makePerson()
+        with person_logged_in(person):
+            person.homepage_content = 'babble'
+            person.teamdescription = 'fish'
+        self.assertEqual('babble\nfish', person.description)
+
+    def test_description_exists(self):
+        # When the person has a description, it is returned.
+        person = self.factory.makePerson()
+        with person_logged_in(person):
+            person.description = 'babble'
+        self.assertEqual('babble', person.description)
+
+    def test_description_setting_reconciles_obsolete_sources(self):
+        # When the description is set, the homepage_content and teamdescription
+        # are set to None.
+        person = self.factory.makePerson()
+        with person_logged_in(person):
+            person.homepage_content = 'babble'
+            person.teamdescription = 'fish'
+            person.description = "What's this fish doing?"
+        self.assertEqual("What's this fish doing?", person.description)
+        self.assertEqual(None, person.homepage_content)
+        self.assertEqual(None, person.teamdescription)
+
     def test_getOwnedOrDrivenPillars(self):
         user = self.factory.makePerson()
         active_project = self.factory.makeProject(owner=user)

=== modified file 'lib/lp/registry/tests/test_team_webservice.py'
--- lib/lp/registry/tests/test_team_webservice.py	2012-08-14 15:10:06 +0000
+++ lib/lp/registry/tests/test_team_webservice.py	2012-08-15 18:23:28 +0000
@@ -167,7 +167,7 @@
         # prohibited detail, like attributes on IPersonViewRestricted.
         launchpad = self.factory.makeLaunchpadService(self.authorised_person)
         team = launchpad.people['private-team']
-        self.assertIn(':redacted', team.homepage_content)
+        self.assertIn(':redacted', team.description)
         failure_regex = '(.|\n)*api_activemembers.*launchpad.View(.|\n)*'
         with ExpectedException(Unauthorized, failure_regex):
             members = team.members

=== modified file 'lib/lp/services/verification/browser/logintoken.py'
--- lib/lp/services/verification/browser/logintoken.py	2012-08-13 19:51:16 +0000
+++ lib/lp/services/verification/browser/logintoken.py	2012-08-15 18:23:28 +0000
@@ -206,10 +206,10 @@
 
     schema = ITeam
     field_names = [
-        'teamowner', 'displayname', 'teamdescription', 'membership_policy',
+        'teamowner', 'displayname', 'description', 'membership_policy',
         'defaultmembershipperiod', 'renewal_policy', 'defaultrenewalperiod']
     label = 'Claim Launchpad team'
-    custom_widget('teamdescription', TextAreaWidget, height=10, width=30)
+    custom_widget('description', TextAreaWidget, height=10, width=30)
     custom_widget(
         'renewal_policy', LaunchpadRadioWidget, orientation='vertical')
     custom_widget(

=== modified file 'lib/lp/services/webapp/doc/webapp-publication.txt'
--- lib/lp/services/webapp/doc/webapp-publication.txt	2011-12-29 05:29:36 +0000
+++ lib/lp/services/webapp/doc/webapp-publication.txt	2012-08-15 18:23:28 +0000
@@ -911,9 +911,9 @@
     ...         Person.id == EmailAddress.personID,
     ...         EmailAddress.email == 'foo.bar@xxxxxxxxxxxxx').one()
     >>> foo_bar = get_foo_bar_person()
-    >>> print foo_bar.homepage_content
+    >>> print foo_bar.description
     None
-    >>> foo_bar.homepage_content = 'Montreal'
+    >>> foo_bar.description = 'Montreal'
 
     >>> request, publication = get_request_and_publication(method='GET')
 
@@ -925,13 +925,13 @@
     >>> publication.afterCall(request, None)
     >>> txn = transaction.begin()
     >>> foo_bar = get_foo_bar_person()
-    >>> print foo_bar.homepage_content
+    >>> print foo_bar.description
     None
 
 But not if the request uses POST, the changes will be preserved.
 
     >>> txn = transaction.begin()
-    >>> get_foo_bar_person().homepage_content = 'Darwin'
+    >>> get_foo_bar_person().description = 'Darwin'
 
     >>> request, publication = get_request_and_publication(method='POST')
 
@@ -942,7 +942,7 @@
     >>> request._publicationticks_start = 1345
     >>> publication.afterCall(request, None)
     >>> txn = transaction.begin()
-    >>> print get_foo_bar_person().homepage_content
+    >>> print get_foo_bar_person().description
     Darwin
 
 

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2012-08-13 21:04:17 +0000
+++ lib/lp/testing/factory.py	2012-08-15 18:23:28 +0000
@@ -604,7 +604,7 @@
     def makePerson(
         self, email=None, name=None, displayname=None, account_status=None,
         email_address_status=None, hide_email_addresses=False,
-        time_zone=None, latitude=None, longitude=None, homepage_content=None,
+        time_zone=None, latitude=None, longitude=None, description=None,
         selfgenerated_bugnotifications=False, member_of=()):
         """Create and return a new, arbitrary Person.
 
@@ -633,8 +633,8 @@
             displayname=displayname,
             hide_email_addresses=hide_email_addresses)
         naked_person = removeSecurityProxy(person)
-        if homepage_content is not None:
-            naked_person.homepage_content = homepage_content
+        if description is not None:
+            naked_person.description = description
 
         if (time_zone is not None or latitude is not None or
             longitude is not None):
@@ -782,7 +782,7 @@
             displayname = SPACE.join(
                 word.capitalize() for word in name.split('-'))
         team = getUtility(IPersonSet).newTeam(
-            owner, name, displayname, teamdescription=description,
+            owner, name, displayname, description,
             membership_policy=membership_policy)
         naked_team = removeSecurityProxy(team)
         if visibility is not None:


Follow ups