← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wgrant/launchpad/account-status-api into lp:launchpad

 

William Grant has proposed merging lp:~wgrant/launchpad/account-status-api into lp:launchpad with lp:~wgrant/launchpad/blueprint-moderation-api as a prerequisite.

Commit message:
Log account status changes to account_status_comment, revise +reviewaccount to append to the history, and expose Person.account_status, Person.account_status_comment and Person.setAccountStatus on the webservice.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~wgrant/launchpad/account-status-api/+merge/245649

Log account status changes to account_status_comment, revise +reviewaccount to append to the history, and expose Person.account_status, Person.account_status_comment and Person.setAccountStatus on the webservice.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wgrant/launchpad/account-status-api into lp:launchpad.
=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py	2014-11-24 01:20:26 +0000
+++ lib/lp/registry/browser/person.py	2015-01-06 12:57:24 +0000
@@ -945,8 +945,8 @@
 
 
 class DeactivateAccountSchema(Interface):
-    comment = copy_field(
-        IPerson['account_status_comment'], readonly=False, __name__='comment')
+    comment = Text(
+        title=_("Why are you deactivating your account?"), required=False)
 
 
 class PersonDeactivateAccountView(LaunchpadFormView):
@@ -1176,13 +1176,19 @@
         self.updateContextFromData(data)
 
 
-class PersonAccountAdministerView(LaunchpadEditFormView):
+class IAccountAdministerSchema(Interface):
+
+    status = copy_field(IAccount['status'], required=True, readonly=False)
+    comment = Text(
+        title=_('Status change comment'), required=True, readonly=False)
+
+
+class PersonAccountAdministerView(LaunchpadFormView):
     """Administer an `IAccount` belonging to an `IPerson`."""
-    schema = IAccount
+    schema = IAccountAdministerSchema
     label = "Review person's account"
-    field_names = ['displayname', 'status', 'status_comment']
-    custom_widget(
-        'status_comment', TextAreaWidget, height=5, width=60)
+    field_names = ['status', 'comment']
+    custom_widget('comment', TextAreaWidget, height=5, width=60)
 
     def __init__(self, context, request):
         """See `LaunchpadEditFormView`."""
@@ -1193,6 +1199,10 @@
         self.context = self.person.account
 
     @property
+    def initial_values(self):
+        return {'status': self.context.status}
+
+    @property
     def is_viewing_person(self):
         """Is the view showing an `IPerson`?
 
@@ -1232,20 +1242,20 @@
     @action('Change', name='change')
     def change_action(self, action, data):
         """Update the IAccount."""
-        if (data['status'] == AccountStatus.SUSPENDED
-            and self.context.status != AccountStatus.SUSPENDED):
+        if data['status'] == self.context.status:
+            return
+        if data['status'] == AccountStatus.SUSPENDED:
             # The preferred email address is removed to ensure no email
             # is sent to the user.
             self.person.setPreferredEmail(None)
             self.request.response.addInfoNotification(
-                u'The account "%s" has been suspended.' % (
-                    self.context.displayname))
-        if (data['status'] == AccountStatus.ACTIVE
-            and self.context.status != AccountStatus.ACTIVE):
+                u'The account "%s" has been suspended.'
+                % self.context.displayname)
+        elif data['status'] == AccountStatus.DEACTIVATED:
             self.request.response.addInfoNotification(
-                u'The user is reactivated. He must use the '
-                u'"forgot password" to log in.')
-        self.updateContextFromData(data)
+                u'The account "%s" is now deactivated. The user can log in '
+                u'to reactivate it.' % self.context.displayname)
+        self.context.setStatus(data['status'], self.user, data['comment'])
 
 
 class PersonVouchersView(LaunchpadFormView):

=== modified file 'lib/lp/registry/browser/tests/person-admin-views.txt'
--- lib/lp/registry/browser/tests/person-admin-views.txt	2012-12-05 18:43:04 +0000
+++ lib/lp/registry/browser/tests/person-admin-views.txt	2015-01-06 12:57:24 +0000
@@ -65,7 +65,7 @@
 The +reviewaccount allows admins to see and edit user account information.
 Non-admins cannot access it.
 
-    >>> view = create_initialized_view(user, '+reviewaccount')
+    >>> view = create_view(user, '+reviewaccount')
     >>> check_permission('launchpad.Admin', view)
     False
 
@@ -79,7 +79,7 @@
     >>> print view.errors
     []
     >>> view.field_names
-    ['displayname', 'status', 'status_comment']
+    ['status', 'comment']
     >>> view.label
     "Review person's account"
 
@@ -99,15 +99,12 @@
 The admin can change the user's account information.
 
     >>> form = {
-    ...     'field.displayname': 'The Zaphod Beeblebrox',
     ...     'field.status': 'ACTIVE',
     ...     'field.actions.change': 'Change',
     ...     }
     >>> view = create_initialized_view(user, '+reviewaccount', form=form)
     >>> print view.errors
     []
-    >>> print user.displayname
-    Zaphod Beeblebrox
 
 An admin can suspend a user's account using the +reviewaccount view. When
 an account is suspended, the preferred email address is disabled.
@@ -118,9 +115,8 @@
     None
 
     >>> form = {
-    ...     'field.displayname': 'Zaphod Beeblebrox',
     ...     'field.status': 'SUSPENDED',
-    ...     'field.status_comment': "Wanted by the galactic police.",
+    ...     'field.comment': "Wanted by the galactic police.",
     ...     'field.actions.change': 'Change',
     ...     }
     >>> view = create_initialized_view(user, '+reviewaccount', form=form)
@@ -129,23 +125,22 @@
     >>> transaction.commit()
     >>> user.account_status
     <DBItem AccountStatus.SUSPENDED, ...>
-    >>> print user.account_status_comment
-    Wanted by the galactic police.
+    >>> user.account_status_comment
+    u'... name16: Active -> Suspended:
+    Wanted by the galactic police.\n'
     >>> print user.preferredemail
     None
 
 No one can force account status to an invalid transition:
 
     >>> form = {
-    ...     'field.displayname': 'Zaphod Beeblebrox',
     ...     'field.status': 'ACTIVE',
     ...     'field.status_comment': "Zaphod's a hoopy frood.",
     ...     'field.actions.change': 'Change',
     ...     }
     >>> view = create_initialized_view(user, '+reviewaccount', form=form)
     >>> [e[2] for e in view.errors]
-    [AccountStatusError(u'The status cannot change
-                        from Suspended Launchpad account to Active account')]
+    [AccountStatusError(u'The status cannot change from Suspended to Active')]
 
 
 An admin can deactivate a suspended user's account too. Unlike the act of
@@ -153,9 +148,8 @@
 user must log in to restore the email addresses using the reactivate step.
 
     >>> form = {
-    ...     'field.displayname': 'Zaphod Beeblebrox',
     ...     'field.status': 'DEACTIVATED',
-    ...     'field.status_comment': "Zaphod's a hoopy frood.",
+    ...     'field.comment': "Zaphod's a hoopy frood.",
     ...     'field.actions.change': 'Change',
     ...     }
     >>> view = create_initialized_view(user, '+reviewaccount', form=form)
@@ -163,7 +157,8 @@
     []
     >>> user.account_status
     <DBItem AccountStatus.DEACTIVATED, ...>
-    >>> print user.account_status_comment
-    Zaphod's a hoopy frood.
+    >>> user.account_status_comment
+    u"... name16: Active -> Suspended: Wanted by the galactic police.\n...
+    name16: Suspended -> Deactivated: Zaphod's a hoopy frood.\n"
     >>> print user.preferredemail
     None

=== modified file 'lib/lp/registry/browser/tests/test_person_webservice.py'
--- lib/lp/registry/browser/tests/test_person_webservice.py	2014-11-17 22:32:30 +0000
+++ lib/lp/registry/browser/tests/test_person_webservice.py	2015-01-06 12:57:24 +0000
@@ -8,10 +8,16 @@
 from zope.security.management import endInteraction
 from zope.security.proxy import removeSecurityProxy
 
-from lp.registry.interfaces.person import TeamMembershipStatus
+from lp.registry.interfaces.person import (
+    IPersonSet,
+    TeamMembershipStatus,
+    )
 from lp.registry.interfaces.teammembership import ITeamMembershipSet
+from lp.services.identity.interfaces.account import AccountStatus
+from lp.services.webapp.interfaces import OAuthPermission
 from lp.testing import (
     admin_logged_in,
+    api_url,
     launchpadlib_for,
     login,
     logout,
@@ -61,6 +67,62 @@
         self.assertEqual([], emails)
 
 
+class TestPersonAccountStatus(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_account_status_comment_restricted(self):
+        person = self.factory.makePerson()
+        registrar = self.factory.makePerson(
+            member_of=[getUtility(IPersonSet).getByName('registry')])
+        removeSecurityProxy(person.account).status_comment = u'Test'
+        person_url = api_url(person)
+
+        # A normal user cannot read account_status_comment. Not even
+        # their own.
+        body = webservice_for_person(
+                person, permission=OAuthPermission.WRITE_PRIVATE).get(
+            person_url, api_version='devel').jsonBody()
+        self.assertEqual('Active', body['account_status'])
+        self.assertEqual(
+            'tag:launchpad.net:2008:redacted', body['account_status_comment'])
+
+        # A member of ~registry can see it all.
+        body = webservice_for_person(
+                registrar, permission=OAuthPermission.WRITE_PRIVATE).get(
+            person_url, api_version='devel').jsonBody()
+        self.assertEqual('Active', body['account_status'])
+        self.assertEqual('Test', body['account_status_comment'])
+
+    def test_setAccountStatus(self):
+        person = self.factory.makePerson()
+        registrar = self.factory.makePerson(
+            name='registrar',
+            member_of=[getUtility(IPersonSet).getByName('registry')])
+        person_url = api_url(person)
+
+        # A normal user cannot set even their own account status.
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.WRITE_PRIVATE)
+        response = webservice.named_post(
+            person_url, 'setAccountStatus', status='Suspended',
+            comment='Go away', api_version='devel')
+        self.assertEqual(401, response.status)
+
+        # A member of ~registry can do what they wish.
+        webservice = webservice_for_person(
+            registrar, permission=OAuthPermission.WRITE_PRIVATE)
+        response = webservice.named_post(
+            person_url, 'setAccountStatus', status='Suspended',
+            comment='Go away', api_version='devel')
+        self.assertEqual(200, response.status)
+        with admin_logged_in():
+            self.assertEqual(AccountStatus.SUSPENDED, person.account_status)
+            self.assertEndsWith(
+                person.account_status_comment,
+                'registrar: Active -> Suspended: Go away\n')
+
+
 class TestPersonRepresentation(TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer

=== modified file 'lib/lp/registry/configure.zcml'
--- lib/lp/registry/configure.zcml	2014-11-24 01:20:26 +0000
+++ lib/lp/registry/configure.zcml	2015-01-06 12:57:24 +0000
@@ -990,6 +990,7 @@
                 set_attributes="displayname icon logo visibility"/>
             <require
                 permission="launchpad.Moderate"
+                interface="lp.registry.interfaces.person.IPersonModerateRestricted"
                 set_attributes="name"/>
             <require
                 permission="launchpad.Special"

=== modified file 'lib/lp/registry/doc/person-account.txt'
--- lib/lp/registry/doc/person-account.txt	2013-05-02 00:40:14 +0000
+++ lib/lp/registry/doc/person-account.txt	2015-01-06 12:57:24 +0000
@@ -49,8 +49,8 @@
     True
     >>> matsubara.account.status
     <DBItem AccountStatus.ACTIVE, ...>
-    >>> matsubara.account.status_comment
-    u'test'
+    >>> removeSecurityProxy(matsubara.account).status_comment
+    u'...: Unactivated -> Active: test\n'
     >>> removeSecurityProxy(matsubara.preferredemail).email
     u'matsubara@xxxxxxxxxxxx'
 
@@ -148,7 +148,8 @@
     <DBItem AccountStatus.DEACTIVATED...
 
     >>> foobar.account_status_comment
-    u"I'm a person who doesn't want to be listed as a Launchpad user."
+    u"... name16: Active -> Deactivated:
+    I'm a person who doesn't want to be listed as a Launchpad user.\n"
 
 ...to have no team memberships...
 
@@ -222,8 +223,11 @@
     >>> foobar.account.status
     <DBItem AccountStatus.ACTIVE...
 
-    >>> foobar.account.status_comment
-    u'User reactivated the account using reset password.'
+    >>> removeSecurityProxy(foobar.account).status_comment
+    u"... name16: Active -> Deactivated: I'm a person
+    who doesn't want to be listed as a Launchpad user.\n...:
+    Deactivated -> Active :
+    User reactivated the account using reset password.\n"
 
 The person name is fixed if it was altered when it was deactivated.
 

=== modified file 'lib/lp/registry/interfaces/person.py'
--- lib/lp/registry/interfaces/person.py	2014-11-28 22:28:40 +0000
+++ lib/lp/registry/interfaces/person.py	2015-01-06 12:57:24 +0000
@@ -572,12 +572,11 @@
         exported_as='is_valid')
     is_team = exported(
         Bool(title=_('Is this object a team?'), readonly=True))
-    account_status = Choice(
-        title=_("The status of this person's account"), required=False,
-        readonly=True, vocabulary=AccountStatus)
-    account_status_comment = Text(
-        title=_("Why are you deactivating your account?"), required=False,
-        readonly=True)
+    account_status = exported(
+        Choice(
+            title=_("The status of this person's account"), required=True,
+            readonly=True, vocabulary=AccountStatus),
+        as_of='devel')
     visibility = exported(
         Choice(title=_("Visibility"),
                description=_(
@@ -1843,9 +1842,29 @@
         """
 
 
+class IPersonModerateRestricted(Interface):
+    """IPerson attributes that require launchpad.Moderate permission."""
+
+    @call_with(user=REQUEST_USER)
+    @operation_parameters(
+        status=copy_field(IAccount['status']),
+        comment=Text(title=_("Status change comment"), required=True))
+    @export_write_operation()
+    @operation_for_version('devel')
+    def setAccountStatus(status, user, comment):
+        """Set the status of this person's account."""
+
+    account_status_comment = exported(
+        Text(
+            title=_("Account status history"), required=False,
+            readonly=True),
+        as_of='devel')
+
+
 class IPerson(IPersonPublic, IPersonLimitedView, IPersonViewRestricted,
-              IPersonEditRestricted, IPersonSpecialRestricted, IHasStanding,
-              ISetLocation, IHeadingContext):
+              IPersonEditRestricted, IPersonModerateRestricted,
+              IPersonSpecialRestricted, IHasStanding, ISetLocation,
+              IHeadingContext):
     """A Person."""
     export_as_webservice_entry(plural_name='people')
 
@@ -2486,6 +2505,11 @@
     """A change in team membership visibility is not allowed."""
 
 
+@error_status(httplib.BAD_REQUEST)
+class NoAccountError(Exception):
+    """The person has no account."""
+
+
 class NoSuchPerson(NameLookupFailed):
     """Raised when we try to look up an IPerson that doesn't exist."""
 

=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py	2014-11-17 22:32:30 +0000
+++ lib/lp/registry/model/person.py	2015-01-06 12:57:24 +0000
@@ -189,6 +189,7 @@
     IPersonSet,
     IPersonSettings,
     ITeam,
+    NoAccountError,
     PersonalStanding,
     PersonCreationRationale,
     TeamEmailAddressError,
@@ -560,34 +561,22 @@
     mugshot = ForeignKey(
         dbName='mugshot', foreignKey='LibraryFileAlias', default=None)
 
-    def _get_account_status(self):
-        account = IStore(Account).get(Account, self.accountID)
-        if account is not None:
-            return account.status
+    @property
+    def account_status(self):
+        if self.account is not None:
+            return self.account.status
         else:
             return AccountStatus.NOACCOUNT
 
-    def _set_account_status(self, value):
-        assert self.accountID is not None, 'No account for this Person'
-        self.account.status = value
-
-    # Deprecated - this value has moved to the Account table.
-    # We provide this shim for backwards compatibility.
-    account_status = property(_get_account_status, _set_account_status)
-
-    def _get_account_status_comment(self):
-        account = IStore(Account).get(Account, self.accountID)
-        if account is not None:
-            return account.status_comment
-
-    def _set_account_status_comment(self, value):
-        assert self.accountID is not None, 'No account for this Person'
-        self.account.status_comment = value
-
-    # Deprecated - this value has moved to the Account table.
-    # We provide this shim for backwards compatibility.
-    account_status_comment = property(
-            _get_account_status_comment, _set_account_status_comment)
+    @property
+    def account_status_comment(self):
+        if self.account is not None:
+            return self.account.status_comment
+
+    def setAccountStatus(self, status, user, comment):
+        if self.is_team or self.account is None:
+            raise NoAccountError()
+        self.account.setStatus(status, user, comment)
 
     teamowner = ForeignKey(
         dbName='teamowner', foreignKey='Person', default=None,
@@ -2207,10 +2196,9 @@
         return errors
 
     def preDeactivate(self, comment):
+        self.account.setStatus(AccountStatus.DEACTIVATED, self, comment)
         for email in self.validatedemails:
             email.status = EmailAddressStatus.NEW
-        self.account_status = AccountStatus.DEACTIVATED
-        self.account_status_comment = comment
         self.preferredemail.status = EmailAddressStatus.NEW
         del get_property_cache(self).preferredemail
 

=== modified file 'lib/lp/registry/stories/person/xx-admin-person-review.txt'
--- lib/lp/registry/stories/person/xx-admin-person-review.txt	2013-01-16 05:07:37 +0000
+++ lib/lp/registry/stories/person/xx-admin-person-review.txt	2015-01-06 12:57:24 +0000
@@ -67,6 +67,7 @@
     OpenID identifiers: salgado_oid
     Email addresses: guilherme.salgado@xxxxxxxxxxxxx
     Guessed email addresses: salgado@xxxxxxxxxx
+    Status history:
 
 The page also contains a link back to the +review page.
 

=== modified file 'lib/lp/registry/templates/person-review.pt'
--- lib/lp/registry/templates/person-review.pt	2013-01-16 05:07:37 +0000
+++ lib/lp/registry/templates/person-review.pt	2015-01-06 12:57:24 +0000
@@ -78,6 +78,12 @@
                   </tal:emails>
                 </td>
               </tr>
+              <tr>
+                <th>Status history:</th>
+                <td tal:content="structure context/status_comment/fmt:text-to-html">
+                  2015-01-05 cprov Active -&gt; Suspended: Suspended for evil.
+                </td>
+              </tr>
             </table>
           </tal:review-account>
         </div>

=== modified file 'lib/lp/registry/tests/test_personset.py'
--- lib/lp/registry/tests/test_personset.py	2014-11-17 23:52:20 +0000
+++ lib/lp/registry/tests/test_personset.py	2015-01-06 12:57:24 +0000
@@ -552,9 +552,10 @@
         self.assertEqual(person, found_person)
         self.assertEqual(AccountStatus.ACTIVE, person.account.status)
         self.assertTrue(db_updated)
-        self.assertEqual(
-            "when purchasing an application via Software Center.",
-            removeSecurityProxy(person.account).status_comment)
+        self.assertEndsWith(
+            removeSecurityProxy(person.account).status_comment,
+            ": Deactivated -> Active: "
+            "when purchasing an application via Software Center.\n")
 
     def test_existing_suspended_account(self):
         # An existing suspended account will raise an exception.

=== modified file 'lib/lp/services/identity/configure.zcml'
--- lib/lp/services/identity/configure.zcml	2013-01-16 04:51:47 +0000
+++ lib/lp/services/identity/configure.zcml	2015-01-06 12:57:24 +0000
@@ -53,7 +53,7 @@
             interface="lp.services.identity.interfaces.account.IAccountViewRestricted"/>
         <require
             permission="launchpad.Moderate"
-            set_attributes="status date_status_set status_comment"/>
+            interface="lp.services.identity.interfaces.account.IAccountModerateRestricted"/>
         <require
             permission="launchpad.Edit"
             set_attributes="displayname"/>

=== modified file 'lib/lp/services/identity/doc/account.txt'
--- lib/lp/services/identity/doc/account.txt	2012-12-05 16:16:20 +0000
+++ lib/lp/services/identity/doc/account.txt	2015-01-06 12:57:24 +0000
@@ -81,7 +81,7 @@
 Account objects have a useful string representation.
 
     >>> account
-    <Account 'No Privileges Person' (Active account)>
+    <Account 'No Privileges Person' (Active)>
 
 The account has other metadata.
 

=== modified file 'lib/lp/services/identity/interfaces/account.py'
--- lib/lp/services/identity/interfaces/account.py	2013-01-16 04:51:47 +0000
+++ lib/lp/services/identity/interfaces/account.py	2015-01-06 12:57:24 +0000
@@ -11,9 +11,9 @@
     'AccountSuspendedError',
     'AccountCreationRationale',
     'IAccount',
+    'IAccountModerateRestricted',
     'IAccountPublic',
     'IAccountSet',
-    'IAccountSpecialRestricted',
     'IAccountViewRestricted',
     'INACTIVE_ACCOUNT_STATUSES',
     ]
@@ -48,25 +48,25 @@
     """The status of an account."""
 
     NOACCOUNT = DBItem(10, """
-        Unactivated account
+        Unactivated
 
         The account has not yet been activated.
         """)
 
     ACTIVE = DBItem(20, """
-        Active account
+        Active
 
         The account is active.
         """)
 
     DEACTIVATED = DBItem(30, """
-        Deactivated account
+        Deactivated
 
         The account has been deactivated by the account's owner.
         """)
 
     SUSPENDED = DBItem(40, """
-        Suspended Launchpad account
+        Suspended
 
         The account has been suspended by a Launchpad admin.
         """)
@@ -253,7 +253,7 @@
 
     status = AccountStatusChoice(
         title=_("The status of this account"), required=True,
-        readonly=False, vocabulary=AccountStatus)
+        readonly=True, vocabulary=AccountStatus)
 
 
 class IAccountViewRestricted(Interface):
@@ -269,12 +269,7 @@
     openid_identifiers = Attribute(_("Linked OpenId Identifiers"))
 
     date_status_set = Datetime(
-        title=_('Date status last modified.'),
-        required=True, readonly=False)
-
-    status_comment = Text(
-        title=_("Why are you deactivating your account?"),
-        required=False, readonly=False)
+        title=_('Date status last modified.'), required=True, readonly=True)
 
     def reactivate(comment):
         """Activate this account.
@@ -285,7 +280,22 @@
         """
 
 
-class IAccount(IAccountPublic, IAccountViewRestricted):
+class IAccountModerateRestricted(Interface):
+
+    status_comment = Text(
+        title=_("Account status comments"), required=False, readonly=True)
+
+    def setStatus(status, user, comment):
+        """Change the status of this account.
+
+        :param user: The user performing the action or None.
+        :param comment: An explanation of the change to be logged in
+            status_comment.
+        """
+
+
+class IAccount(IAccountPublic, IAccountViewRestricted,
+               IAccountModerateRestricted):
     """Interface describing an `Account`."""
 
 

=== modified file 'lib/lp/services/identity/model/account.py'
--- lib/lp/services/identity/model/account.py	2013-06-20 05:50:00 +0000
+++ lib/lp/services/identity/model/account.py	2015-01-06 12:57:24 +0000
@@ -9,6 +9,8 @@
     'AccountSet',
     ]
 
+import datetime
+
 from sqlobject import StringCol
 from storm.locals import ReferenceSet
 from zope.interface import implements
@@ -64,10 +66,27 @@
         return "<%s '%s' (%s)>" % (
             self.__class__.__name__, displayname, self.status)
 
+    def addStatusComment(self, user, comment):
+        """See `IAccountModerateRestricted`."""
+        prefix = datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
+        if user is not None:
+            prefix += ' %s' % user.name
+        old_lines = (
+            self.status_comment.splitlines() if self.status_comment else [])
+        self.status_comment = '\n'.join(
+            old_lines + ['%s: %s' % (prefix, comment), ''])
+
+    def setStatus(self, status, user, comment):
+        """See `IAccountModerateRestricted`."""
+        comment = comment or ''
+        self.addStatusComment(
+            user, '%s -> %s: %s' % (self.status.title, status.title, comment))
+        # date_status_set is maintained by a DB trigger.
+        self.status = status
+
     def reactivate(self, comment):
         """See `IAccountSpecialRestricted`."""
-        self.status = AccountStatus.ACTIVE
-        self.status_comment = comment
+        self.setStatus(AccountStatus.ACTIVE, None, comment)
 
 
 class AccountSet:

=== modified file 'lib/lp/services/identity/tests/test_account.py'
--- lib/lp/services/identity/tests/test_account.py	2012-12-04 23:31:37 +0000
+++ lib/lp/services/identity/tests/test_account.py	2015-01-06 12:57:24 +0000
@@ -27,7 +27,7 @@
         distro = self.factory.makeAccount(u'\xdc-account')
         ignore, displayname, status_1, status_2 = repr(distro).rsplit(' ', 3)
         self.assertEqual("'\\xdc-account'", displayname)
-        self.assertEqual('(Active account)>', '%s %s' % (status_1, status_2))
+        self.assertEqual('(Active)>', '%s %s' % (status_1, status_2))
 
     def test_account_repr_unicode(self):
         # Verify that Unicode displayname is ascii safe.


Follow ups