Merge lp:~wgrant/launchpad/account-status-api into lp:launchpad
- account-status-api
- Merge into devel
Proposed by
William Grant
on 2015-01-06
| Status: | Merged |
|---|---|
| Merged at revision: | 17297 |
| Proposed branch: | lp:~wgrant/launchpad/account-status-api |
| Merge into: | lp:launchpad |
| Prerequisite: | lp:~wgrant/launchpad/blueprint-moderation-api |
| Diff against target: |
897 lines (+246/-122) 21 files modified
lib/lp/app/browser/tests/test_launchpad.py (+1/-1) lib/lp/code/model/tests/test_revision.py (+1/-1) lib/lp/registry/browser/person.py (+26/-16) lib/lp/registry/browser/tests/person-admin-views.txt (+13/-18) lib/lp/registry/browser/tests/test_person_webservice.py (+63/-1) lib/lp/registry/configure.zcml (+1/-0) lib/lp/registry/doc/person-account.txt (+11/-7) lib/lp/registry/doc/person.txt (+4/-2) lib/lp/registry/interfaces/person.py (+33/-8) lib/lp/registry/model/person.py (+15/-27) lib/lp/registry/stories/person/xx-admin-person-review.txt (+1/-0) lib/lp/registry/templates/person-review.pt (+6/-0) lib/lp/registry/tests/test_person.py (+3/-3) lib/lp/registry/tests/test_personset.py (+4/-3) lib/lp/scripts/tests/test_garbo.py (+2/-2) lib/lp/services/identity/configure.zcml (+1/-1) lib/lp/services/identity/doc/account.txt (+6/-6) lib/lp/services/identity/interfaces/account.py (+23/-13) lib/lp/services/identity/model/account.py (+22/-3) lib/lp/services/identity/tests/test_account.py (+7/-7) lib/lp/soyuz/doc/distribution.txt (+3/-3) |
| To merge this branch: | bzr merge lp:~wgrant/launchpad/account-status-api |
| Related bugs: |
| Reviewer | Review Type | Date Requested | Status |
|---|---|---|---|
| Colin Watson | 2015-01-06 | Approve on 2015-01-07 | |
|
Review via email:
|
|||
Commit Message
Log account status changes to account_
Description of the Change
Log account status changes to account_
To post a comment you must log in.
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
| 1 | === modified file 'lib/lp/app/browser/tests/test_launchpad.py' |
| 2 | --- lib/lp/app/browser/tests/test_launchpad.py 2012-12-20 22:52:31 +0000 |
| 3 | +++ lib/lp/app/browser/tests/test_launchpad.py 2015-01-07 00:40:21 +0000 |
| 4 | @@ -358,7 +358,7 @@ |
| 5 | name = 'suspended-person' |
| 6 | person = self.factory.makePerson(name=name) |
| 7 | login_person(self.admin) |
| 8 | - removeSecurityProxy(person).account_status = AccountStatus.SUSPENDED |
| 9 | + person.setAccountStatus(AccountStatus.SUSPENDED, None, 'Go away') |
| 10 | segment = '~%s' % name |
| 11 | # Admins can see the suspended user. |
| 12 | traversed = self.traverse(segment, segment) |
| 13 | |
| 14 | === modified file 'lib/lp/code/model/tests/test_revision.py' |
| 15 | --- lib/lp/code/model/tests/test_revision.py 2013-08-13 06:53:11 +0000 |
| 16 | +++ lib/lp/code/model/tests/test_revision.py 2015-01-07 00:40:21 +0000 |
| 17 | @@ -127,7 +127,7 @@ |
| 18 | author = self.factory.makePerson() |
| 19 | rev = self.factory.makeRevision( |
| 20 | author=author.preferredemail.email) |
| 21 | - author.account.status = AccountStatus.SUSPENDED |
| 22 | + author.setAccountStatus(AccountStatus.SUSPENDED, None, 'spammer!') |
| 23 | branch = self.factory.makeProductBranch() |
| 24 | branch.createBranchRevision(1, rev) |
| 25 | self.assertTrue(rev.karma_allocated) |
| 26 | |
| 27 | === modified file 'lib/lp/registry/browser/person.py' |
| 28 | --- lib/lp/registry/browser/person.py 2014-11-24 01:20:26 +0000 |
| 29 | +++ lib/lp/registry/browser/person.py 2015-01-07 00:40:21 +0000 |
| 30 | @@ -945,8 +945,8 @@ |
| 31 | |
| 32 | |
| 33 | class DeactivateAccountSchema(Interface): |
| 34 | - comment = copy_field( |
| 35 | - IPerson['account_status_comment'], readonly=False, __name__='comment') |
| 36 | + comment = Text( |
| 37 | + title=_("Why are you deactivating your account?"), required=False) |
| 38 | |
| 39 | |
| 40 | class PersonDeactivateAccountView(LaunchpadFormView): |
| 41 | @@ -1176,13 +1176,19 @@ |
| 42 | self.updateContextFromData(data) |
| 43 | |
| 44 | |
| 45 | -class PersonAccountAdministerView(LaunchpadEditFormView): |
| 46 | +class IAccountAdministerSchema(Interface): |
| 47 | + |
| 48 | + status = copy_field(IAccount['status'], required=True, readonly=False) |
| 49 | + comment = Text( |
| 50 | + title=_('Status change comment'), required=True, readonly=False) |
| 51 | + |
| 52 | + |
| 53 | +class PersonAccountAdministerView(LaunchpadFormView): |
| 54 | """Administer an `IAccount` belonging to an `IPerson`.""" |
| 55 | - schema = IAccount |
| 56 | + schema = IAccountAdministerSchema |
| 57 | label = "Review person's account" |
| 58 | - field_names = ['displayname', 'status', 'status_comment'] |
| 59 | - custom_widget( |
| 60 | - 'status_comment', TextAreaWidget, height=5, width=60) |
| 61 | + field_names = ['status', 'comment'] |
| 62 | + custom_widget('comment', TextAreaWidget, height=5, width=60) |
| 63 | |
| 64 | def __init__(self, context, request): |
| 65 | """See `LaunchpadEditFormView`.""" |
| 66 | @@ -1193,6 +1199,10 @@ |
| 67 | self.context = self.person.account |
| 68 | |
| 69 | @property |
| 70 | + def initial_values(self): |
| 71 | + return {'status': self.context.status} |
| 72 | + |
| 73 | + @property |
| 74 | def is_viewing_person(self): |
| 75 | """Is the view showing an `IPerson`? |
| 76 | |
| 77 | @@ -1232,20 +1242,20 @@ |
| 78 | @action('Change', name='change') |
| 79 | def change_action(self, action, data): |
| 80 | """Update the IAccount.""" |
| 81 | - if (data['status'] == AccountStatus.SUSPENDED |
| 82 | - and self.context.status != AccountStatus.SUSPENDED): |
| 83 | + if data['status'] == self.context.status: |
| 84 | + return |
| 85 | + if data['status'] == AccountStatus.SUSPENDED: |
| 86 | # The preferred email address is removed to ensure no email |
| 87 | # is sent to the user. |
| 88 | self.person.setPreferredEmail(None) |
| 89 | self.request.response.addInfoNotification( |
| 90 | - u'The account "%s" has been suspended.' % ( |
| 91 | - self.context.displayname)) |
| 92 | - if (data['status'] == AccountStatus.ACTIVE |
| 93 | - and self.context.status != AccountStatus.ACTIVE): |
| 94 | + u'The account "%s" has been suspended.' |
| 95 | + % self.context.displayname) |
| 96 | + elif data['status'] == AccountStatus.DEACTIVATED: |
| 97 | self.request.response.addInfoNotification( |
| 98 | - u'The user is reactivated. He must use the ' |
| 99 | - u'"forgot password" to log in.') |
| 100 | - self.updateContextFromData(data) |
| 101 | + u'The account "%s" is now deactivated. The user can log in ' |
| 102 | + u'to reactivate it.' % self.context.displayname) |
| 103 | + self.context.setStatus(data['status'], self.user, data['comment']) |
| 104 | |
| 105 | |
| 106 | class PersonVouchersView(LaunchpadFormView): |
| 107 | |
| 108 | === modified file 'lib/lp/registry/browser/tests/person-admin-views.txt' |
| 109 | --- lib/lp/registry/browser/tests/person-admin-views.txt 2012-12-05 18:43:04 +0000 |
| 110 | +++ lib/lp/registry/browser/tests/person-admin-views.txt 2015-01-07 00:40:21 +0000 |
| 111 | @@ -65,7 +65,7 @@ |
| 112 | The +reviewaccount allows admins to see and edit user account information. |
| 113 | Non-admins cannot access it. |
| 114 | |
| 115 | - >>> view = create_initialized_view(user, '+reviewaccount') |
| 116 | + >>> view = create_view(user, '+reviewaccount') |
| 117 | >>> check_permission('launchpad.Admin', view) |
| 118 | False |
| 119 | |
| 120 | @@ -79,7 +79,7 @@ |
| 121 | >>> print view.errors |
| 122 | [] |
| 123 | >>> view.field_names |
| 124 | - ['displayname', 'status', 'status_comment'] |
| 125 | + ['status', 'comment'] |
| 126 | >>> view.label |
| 127 | "Review person's account" |
| 128 | |
| 129 | @@ -99,28 +99,24 @@ |
| 130 | The admin can change the user's account information. |
| 131 | |
| 132 | >>> form = { |
| 133 | - ... 'field.displayname': 'The Zaphod Beeblebrox', |
| 134 | ... 'field.status': 'ACTIVE', |
| 135 | ... 'field.actions.change': 'Change', |
| 136 | ... } |
| 137 | >>> view = create_initialized_view(user, '+reviewaccount', form=form) |
| 138 | >>> print view.errors |
| 139 | [] |
| 140 | - >>> print user.displayname |
| 141 | - Zaphod Beeblebrox |
| 142 | |
| 143 | An admin can suspend a user's account using the +reviewaccount view. When |
| 144 | an account is suspended, the preferred email address is disabled. |
| 145 | |
| 146 | >>> user.account_status |
| 147 | <DBItem AccountStatus.ACTIVE, ...> |
| 148 | - >>> print user.account_status_comment |
| 149 | + >>> print user.account_status_history |
| 150 | None |
| 151 | |
| 152 | >>> form = { |
| 153 | - ... 'field.displayname': 'Zaphod Beeblebrox', |
| 154 | ... 'field.status': 'SUSPENDED', |
| 155 | - ... 'field.status_comment': "Wanted by the galactic police.", |
| 156 | + ... 'field.comment': "Wanted by the galactic police.", |
| 157 | ... 'field.actions.change': 'Change', |
| 158 | ... } |
| 159 | >>> view = create_initialized_view(user, '+reviewaccount', form=form) |
| 160 | @@ -129,23 +125,22 @@ |
| 161 | >>> transaction.commit() |
| 162 | >>> user.account_status |
| 163 | <DBItem AccountStatus.SUSPENDED, ...> |
| 164 | - >>> print user.account_status_comment |
| 165 | - Wanted by the galactic police. |
| 166 | + >>> user.account_status_history |
| 167 | + u'... name16: Active -> Suspended: |
| 168 | + Wanted by the galactic police.\n' |
| 169 | >>> print user.preferredemail |
| 170 | None |
| 171 | |
| 172 | No one can force account status to an invalid transition: |
| 173 | |
| 174 | >>> form = { |
| 175 | - ... 'field.displayname': 'Zaphod Beeblebrox', |
| 176 | ... 'field.status': 'ACTIVE', |
| 177 | - ... 'field.status_comment': "Zaphod's a hoopy frood.", |
| 178 | + ... 'field.status_history': "Zaphod's a hoopy frood.", |
| 179 | ... 'field.actions.change': 'Change', |
| 180 | ... } |
| 181 | >>> view = create_initialized_view(user, '+reviewaccount', form=form) |
| 182 | >>> [e[2] for e in view.errors] |
| 183 | - [AccountStatusError(u'The status cannot change |
| 184 | - from Suspended Launchpad account to Active account')] |
| 185 | + [AccountStatusError(u'The status cannot change from Suspended to Active')] |
| 186 | |
| 187 | |
| 188 | An admin can deactivate a suspended user's account too. Unlike the act of |
| 189 | @@ -153,9 +148,8 @@ |
| 190 | user must log in to restore the email addresses using the reactivate step. |
| 191 | |
| 192 | >>> form = { |
| 193 | - ... 'field.displayname': 'Zaphod Beeblebrox', |
| 194 | ... 'field.status': 'DEACTIVATED', |
| 195 | - ... 'field.status_comment': "Zaphod's a hoopy frood.", |
| 196 | + ... 'field.comment': "Zaphod's a hoopy frood.", |
| 197 | ... 'field.actions.change': 'Change', |
| 198 | ... } |
| 199 | >>> view = create_initialized_view(user, '+reviewaccount', form=form) |
| 200 | @@ -163,7 +157,8 @@ |
| 201 | [] |
| 202 | >>> user.account_status |
| 203 | <DBItem AccountStatus.DEACTIVATED, ...> |
| 204 | - >>> print user.account_status_comment |
| 205 | - Zaphod's a hoopy frood. |
| 206 | + >>> user.account_status_history |
| 207 | + u"... name16: Active -> Suspended: Wanted by the galactic police.\n... |
| 208 | + name16: Suspended -> Deactivated: Zaphod's a hoopy frood.\n" |
| 209 | >>> print user.preferredemail |
| 210 | None |
| 211 | |
| 212 | === modified file 'lib/lp/registry/browser/tests/test_person_webservice.py' |
| 213 | --- lib/lp/registry/browser/tests/test_person_webservice.py 2014-11-17 22:32:30 +0000 |
| 214 | +++ lib/lp/registry/browser/tests/test_person_webservice.py 2015-01-07 00:40:21 +0000 |
| 215 | @@ -8,10 +8,16 @@ |
| 216 | from zope.security.management import endInteraction |
| 217 | from zope.security.proxy import removeSecurityProxy |
| 218 | |
| 219 | -from lp.registry.interfaces.person import TeamMembershipStatus |
| 220 | +from lp.registry.interfaces.person import ( |
| 221 | + IPersonSet, |
| 222 | + TeamMembershipStatus, |
| 223 | + ) |
| 224 | from lp.registry.interfaces.teammembership import ITeamMembershipSet |
| 225 | +from lp.services.identity.interfaces.account import AccountStatus |
| 226 | +from lp.services.webapp.interfaces import OAuthPermission |
| 227 | from lp.testing import ( |
| 228 | admin_logged_in, |
| 229 | + api_url, |
| 230 | launchpadlib_for, |
| 231 | login, |
| 232 | logout, |
| 233 | @@ -61,6 +67,62 @@ |
| 234 | self.assertEqual([], emails) |
| 235 | |
| 236 | |
| 237 | +class TestPersonAccountStatus(TestCaseWithFactory): |
| 238 | + |
| 239 | + layer = DatabaseFunctionalLayer |
| 240 | + |
| 241 | + def test_account_status_history_restricted(self): |
| 242 | + person = self.factory.makePerson() |
| 243 | + registrar = self.factory.makePerson( |
| 244 | + member_of=[getUtility(IPersonSet).getByName('registry')]) |
| 245 | + removeSecurityProxy(person.account).status_history = u'Test' |
| 246 | + person_url = api_url(person) |
| 247 | + |
| 248 | + # A normal user cannot read account_status_history. Not even |
| 249 | + # their own. |
| 250 | + body = webservice_for_person( |
| 251 | + person, permission=OAuthPermission.WRITE_PRIVATE).get( |
| 252 | + person_url, api_version='devel').jsonBody() |
| 253 | + self.assertEqual('Active', body['account_status']) |
| 254 | + self.assertEqual( |
| 255 | + 'tag:launchpad.net:2008:redacted', body['account_status_history']) |
| 256 | + |
| 257 | + # A member of ~registry can see it all. |
| 258 | + body = webservice_for_person( |
| 259 | + registrar, permission=OAuthPermission.WRITE_PRIVATE).get( |
| 260 | + person_url, api_version='devel').jsonBody() |
| 261 | + self.assertEqual('Active', body['account_status']) |
| 262 | + self.assertEqual('Test', body['account_status_history']) |
| 263 | + |
| 264 | + def test_setAccountStatus(self): |
| 265 | + person = self.factory.makePerson() |
| 266 | + registrar = self.factory.makePerson( |
| 267 | + name='registrar', |
| 268 | + member_of=[getUtility(IPersonSet).getByName('registry')]) |
| 269 | + person_url = api_url(person) |
| 270 | + |
| 271 | + # A normal user cannot set even their own account status. |
| 272 | + webservice = webservice_for_person( |
| 273 | + person, permission=OAuthPermission.WRITE_PRIVATE) |
| 274 | + response = webservice.named_post( |
| 275 | + person_url, 'setAccountStatus', status='Suspended', |
| 276 | + comment='Go away', api_version='devel') |
| 277 | + self.assertEqual(401, response.status) |
| 278 | + |
| 279 | + # A member of ~registry can do what they wish. |
| 280 | + webservice = webservice_for_person( |
| 281 | + registrar, permission=OAuthPermission.WRITE_PRIVATE) |
| 282 | + response = webservice.named_post( |
| 283 | + person_url, 'setAccountStatus', status='Suspended', |
| 284 | + comment='Go away', api_version='devel') |
| 285 | + self.assertEqual(200, response.status) |
| 286 | + with admin_logged_in(): |
| 287 | + self.assertEqual(AccountStatus.SUSPENDED, person.account_status) |
| 288 | + self.assertEndsWith( |
| 289 | + person.account_status_history, |
| 290 | + 'registrar: Active -> Suspended: Go away\n') |
| 291 | + |
| 292 | + |
| 293 | class TestPersonRepresentation(TestCaseWithFactory): |
| 294 | |
| 295 | layer = DatabaseFunctionalLayer |
| 296 | |
| 297 | === modified file 'lib/lp/registry/configure.zcml' |
| 298 | --- lib/lp/registry/configure.zcml 2014-11-24 01:20:26 +0000 |
| 299 | +++ lib/lp/registry/configure.zcml 2015-01-07 00:40:21 +0000 |
| 300 | @@ -990,6 +990,7 @@ |
| 301 | set_attributes="displayname icon logo visibility"/> |
| 302 | <require |
| 303 | permission="launchpad.Moderate" |
| 304 | + interface="lp.registry.interfaces.person.IPersonModerateRestricted" |
| 305 | set_attributes="name"/> |
| 306 | <require |
| 307 | permission="launchpad.Special" |
| 308 | |
| 309 | === modified file 'lib/lp/registry/doc/person-account.txt' |
| 310 | --- lib/lp/registry/doc/person-account.txt 2013-05-02 00:40:14 +0000 |
| 311 | +++ lib/lp/registry/doc/person-account.txt 2015-01-07 00:40:21 +0000 |
| 312 | @@ -49,8 +49,8 @@ |
| 313 | True |
| 314 | >>> matsubara.account.status |
| 315 | <DBItem AccountStatus.ACTIVE, ...> |
| 316 | - >>> matsubara.account.status_comment |
| 317 | - u'test' |
| 318 | + >>> removeSecurityProxy(matsubara.account).status_history |
| 319 | + u'...: Unactivated -> Active: test\n' |
| 320 | >>> removeSecurityProxy(matsubara.preferredemail).email |
| 321 | u'matsubara@async.com.br' |
| 322 | |
| 323 | @@ -144,11 +144,12 @@ |
| 324 | |
| 325 | ...an account status of DEACTIVATED... |
| 326 | |
| 327 | - >>> foobar.account_status |
| 328 | + >>> foobar.account.status |
| 329 | <DBItem AccountStatus.DEACTIVATED... |
| 330 | |
| 331 | - >>> foobar.account_status_comment |
| 332 | - u"I'm a person who doesn't want to be listed as a Launchpad user." |
| 333 | + >>> removeSecurityProxy(foobar.account).status_history |
| 334 | + u"... name16: Active -> Deactivated: |
| 335 | + I'm a person who doesn't want to be listed as a Launchpad user.\n" |
| 336 | |
| 337 | ...to have no team memberships... |
| 338 | |
| 339 | @@ -222,8 +223,11 @@ |
| 340 | >>> foobar.account.status |
| 341 | <DBItem AccountStatus.ACTIVE... |
| 342 | |
| 343 | - >>> foobar.account.status_comment |
| 344 | - u'User reactivated the account using reset password.' |
| 345 | + >>> removeSecurityProxy(foobar.account).status_history |
| 346 | + u"... name16: Active -> Deactivated: I'm a person |
| 347 | + who doesn't want to be listed as a Launchpad user.\n...: |
| 348 | + Deactivated -> Active: |
| 349 | + User reactivated the account using reset password.\n" |
| 350 | |
| 351 | The person name is fixed if it was altered when it was deactivated. |
| 352 | |
| 353 | |
| 354 | === modified file 'lib/lp/registry/doc/person.txt' |
| 355 | --- lib/lp/registry/doc/person.txt 2014-03-11 10:14:20 +0000 |
| 356 | +++ lib/lp/registry/doc/person.txt 2015-01-07 00:40:21 +0000 |
| 357 | @@ -858,7 +858,8 @@ |
| 358 | |
| 359 | >>> from lp.services.identity.interfaces.account import AccountStatus |
| 360 | >>> dave = personset.getByName('justdave') |
| 361 | - >>> removeSecurityProxy(dave).account_status = AccountStatus.DEACTIVATED |
| 362 | + >>> removeSecurityProxy(dave).setAccountStatus( |
| 363 | + ... AccountStatus.DEACTIVATED, None, 'gbcw') |
| 364 | >>> transaction.commit() |
| 365 | >>> list(personset.findPerson('dave')) |
| 366 | [] |
| 367 | @@ -868,7 +869,8 @@ |
| 368 | Dave Miller (justdave): [u'dave.miller@ubuntulinux.com', |
| 369 | u'justdave@bugzilla.org'] |
| 370 | |
| 371 | - >>> removeSecurityProxy(dave).account_status = AccountStatus.ACTIVE |
| 372 | + >>> removeSecurityProxy(dave).setAccountStatus( |
| 373 | + ... AccountStatus.ACTIVE, None, 'Welcome back') |
| 374 | >>> flush_database_updates() |
| 375 | >>> login(ANONYMOUS) |
| 376 | |
| 377 | |
| 378 | === modified file 'lib/lp/registry/interfaces/person.py' |
| 379 | --- lib/lp/registry/interfaces/person.py 2014-11-28 22:28:40 +0000 |
| 380 | +++ lib/lp/registry/interfaces/person.py 2015-01-07 00:40:21 +0000 |
| 381 | @@ -27,6 +27,7 @@ |
| 382 | 'ITeamContactAddressForm', |
| 383 | 'ITeamReassignment', |
| 384 | 'ImmutableVisibilityError', |
| 385 | + 'NoAccountError', |
| 386 | 'NoSuchPerson', |
| 387 | 'PersonCreationRationale', |
| 388 | 'PersonalStanding', |
| 389 | @@ -572,12 +573,11 @@ |
| 390 | exported_as='is_valid') |
| 391 | is_team = exported( |
| 392 | Bool(title=_('Is this object a team?'), readonly=True)) |
| 393 | - account_status = Choice( |
| 394 | - title=_("The status of this person's account"), required=False, |
| 395 | - readonly=True, vocabulary=AccountStatus) |
| 396 | - account_status_comment = Text( |
| 397 | - title=_("Why are you deactivating your account?"), required=False, |
| 398 | - readonly=True) |
| 399 | + account_status = exported( |
| 400 | + Choice( |
| 401 | + title=_("The status of this person's account"), required=True, |
| 402 | + readonly=True, vocabulary=AccountStatus), |
| 403 | + as_of='devel') |
| 404 | visibility = exported( |
| 405 | Choice(title=_("Visibility"), |
| 406 | description=_( |
| 407 | @@ -1843,9 +1843,29 @@ |
| 408 | """ |
| 409 | |
| 410 | |
| 411 | +class IPersonModerateRestricted(Interface): |
| 412 | + """IPerson attributes that require launchpad.Moderate permission.""" |
| 413 | + |
| 414 | + @call_with(user=REQUEST_USER) |
| 415 | + @operation_parameters( |
| 416 | + status=copy_field(IAccount['status']), |
| 417 | + comment=Text(title=_("Status change comment"), required=True)) |
| 418 | + @export_write_operation() |
| 419 | + @operation_for_version('devel') |
| 420 | + def setAccountStatus(status, user, comment): |
| 421 | + """Set the status of this person's account.""" |
| 422 | + |
| 423 | + account_status_history = exported( |
| 424 | + Text( |
| 425 | + title=_("Account status history"), required=False, |
| 426 | + readonly=True), |
| 427 | + as_of='devel') |
| 428 | + |
| 429 | + |
| 430 | class IPerson(IPersonPublic, IPersonLimitedView, IPersonViewRestricted, |
| 431 | - IPersonEditRestricted, IPersonSpecialRestricted, IHasStanding, |
| 432 | - ISetLocation, IHeadingContext): |
| 433 | + IPersonEditRestricted, IPersonModerateRestricted, |
| 434 | + IPersonSpecialRestricted, IHasStanding, ISetLocation, |
| 435 | + IHeadingContext): |
| 436 | """A Person.""" |
| 437 | export_as_webservice_entry(plural_name='people') |
| 438 | |
| 439 | @@ -2486,6 +2506,11 @@ |
| 440 | """A change in team membership visibility is not allowed.""" |
| 441 | |
| 442 | |
| 443 | +@error_status(httplib.BAD_REQUEST) |
| 444 | +class NoAccountError(Exception): |
| 445 | + """The person has no account.""" |
| 446 | + |
| 447 | + |
| 448 | class NoSuchPerson(NameLookupFailed): |
| 449 | """Raised when we try to look up an IPerson that doesn't exist.""" |
| 450 | |
| 451 | |
| 452 | === modified file 'lib/lp/registry/model/person.py' |
| 453 | --- lib/lp/registry/model/person.py 2014-11-17 22:32:30 +0000 |
| 454 | +++ lib/lp/registry/model/person.py 2015-01-07 00:40:21 +0000 |
| 455 | @@ -189,6 +189,7 @@ |
| 456 | IPersonSet, |
| 457 | IPersonSettings, |
| 458 | ITeam, |
| 459 | + NoAccountError, |
| 460 | PersonalStanding, |
| 461 | PersonCreationRationale, |
| 462 | TeamEmailAddressError, |
| 463 | @@ -560,34 +561,22 @@ |
| 464 | mugshot = ForeignKey( |
| 465 | dbName='mugshot', foreignKey='LibraryFileAlias', default=None) |
| 466 | |
| 467 | - def _get_account_status(self): |
| 468 | - account = IStore(Account).get(Account, self.accountID) |
| 469 | - if account is not None: |
| 470 | - return account.status |
| 471 | + @property |
| 472 | + def account_status(self): |
| 473 | + if self.account is not None: |
| 474 | + return self.account.status |
| 475 | else: |
| 476 | return AccountStatus.NOACCOUNT |
| 477 | |
| 478 | - def _set_account_status(self, value): |
| 479 | - assert self.accountID is not None, 'No account for this Person' |
| 480 | - self.account.status = value |
| 481 | - |
| 482 | - # Deprecated - this value has moved to the Account table. |
| 483 | - # We provide this shim for backwards compatibility. |
| 484 | - account_status = property(_get_account_status, _set_account_status) |
| 485 | - |
| 486 | - def _get_account_status_comment(self): |
| 487 | - account = IStore(Account).get(Account, self.accountID) |
| 488 | - if account is not None: |
| 489 | - return account.status_comment |
| 490 | - |
| 491 | - def _set_account_status_comment(self, value): |
| 492 | - assert self.accountID is not None, 'No account for this Person' |
| 493 | - self.account.status_comment = value |
| 494 | - |
| 495 | - # Deprecated - this value has moved to the Account table. |
| 496 | - # We provide this shim for backwards compatibility. |
| 497 | - account_status_comment = property( |
| 498 | - _get_account_status_comment, _set_account_status_comment) |
| 499 | + @property |
| 500 | + def account_status_history(self): |
| 501 | + if self.account is not None: |
| 502 | + return self.account.status_history |
| 503 | + |
| 504 | + def setAccountStatus(self, status, user, comment): |
| 505 | + if self.is_team or self.account is None: |
| 506 | + raise NoAccountError() |
| 507 | + self.account.setStatus(status, user, comment) |
| 508 | |
| 509 | teamowner = ForeignKey( |
| 510 | dbName='teamowner', foreignKey='Person', default=None, |
| 511 | @@ -2207,10 +2196,9 @@ |
| 512 | return errors |
| 513 | |
| 514 | def preDeactivate(self, comment): |
| 515 | + self.account.setStatus(AccountStatus.DEACTIVATED, self, comment) |
| 516 | for email in self.validatedemails: |
| 517 | email.status = EmailAddressStatus.NEW |
| 518 | - self.account_status = AccountStatus.DEACTIVATED |
| 519 | - self.account_status_comment = comment |
| 520 | self.preferredemail.status = EmailAddressStatus.NEW |
| 521 | del get_property_cache(self).preferredemail |
| 522 | |
| 523 | |
| 524 | === modified file 'lib/lp/registry/stories/person/xx-admin-person-review.txt' |
| 525 | --- lib/lp/registry/stories/person/xx-admin-person-review.txt 2013-01-16 05:07:37 +0000 |
| 526 | +++ lib/lp/registry/stories/person/xx-admin-person-review.txt 2015-01-07 00:40:21 +0000 |
| 527 | @@ -67,6 +67,7 @@ |
| 528 | OpenID identifiers: salgado_oid |
| 529 | Email addresses: guilherme.salgado@canonical.com |
| 530 | Guessed email addresses: salgado@ubuntu.com |
| 531 | + Status history: |
| 532 | |
| 533 | The page also contains a link back to the +review page. |
| 534 | |
| 535 | |
| 536 | === modified file 'lib/lp/registry/templates/person-review.pt' |
| 537 | --- lib/lp/registry/templates/person-review.pt 2013-01-16 05:07:37 +0000 |
| 538 | +++ lib/lp/registry/templates/person-review.pt 2015-01-07 00:40:21 +0000 |
| 539 | @@ -78,6 +78,12 @@ |
| 540 | </tal:emails> |
| 541 | </td> |
| 542 | </tr> |
| 543 | + <tr> |
| 544 | + <th>Status history:</th> |
| 545 | + <td tal:content="structure context/status_history/fmt:text-to-html"> |
| 546 | + 2015-01-05 cprov Active -> Suspended: Suspended for evil. |
| 547 | + </td> |
| 548 | + </tr> |
| 549 | </table> |
| 550 | </tal:review-account> |
| 551 | </div> |
| 552 | |
| 553 | === modified file 'lib/lp/registry/tests/test_person.py' |
| 554 | --- lib/lp/registry/tests/test_person.py 2014-10-22 17:32:38 +0000 |
| 555 | +++ lib/lp/registry/tests/test_person.py 2015-01-07 00:40:21 +0000 |
| 556 | @@ -1348,7 +1348,7 @@ |
| 557 | """ |
| 558 | owner = self.factory.makePerson(email='foo@bar.com') |
| 559 | team = self.factory.makeTeam(owner) |
| 560 | - owner.account.status = AccountStatus.DEACTIVATED |
| 561 | + owner.setAccountStatus(AccountStatus.DEACTIVATED, None, 'gbcw') |
| 562 | self.assertContentEqual([], get_recipients(team)) |
| 563 | |
| 564 | def test_get_recipients_team_with_disabled_member_account(self): |
| 565 | @@ -1357,7 +1357,7 @@ |
| 566 | See <https://bugs.launchpad.net/launchpad/+bug/855150> |
| 567 | """ |
| 568 | person = self.factory.makePerson(email='foo@bar.com') |
| 569 | - person.account.status = AccountStatus.DEACTIVATED |
| 570 | + person.setAccountStatus(AccountStatus.DEACTIVATED, None, 'gbcw') |
| 571 | team = self.factory.makeTeam(members=[person]) |
| 572 | self.assertContentEqual([team.teamowner], get_recipients(team)) |
| 573 | |
| 574 | @@ -1367,7 +1367,7 @@ |
| 575 | See <https://bugs.launchpad.net/launchpad/+bug/855150> |
| 576 | """ |
| 577 | person = self.factory.makePerson(email='foo@bar.com') |
| 578 | - person.account.status = AccountStatus.DEACTIVATED |
| 579 | + person.setAccountStatus(AccountStatus.DEACTIVATED, None, 'gbcw') |
| 580 | team1 = self.factory.makeTeam(members=[person]) |
| 581 | team2 = self.factory.makeTeam(members=[team1]) |
| 582 | self.assertContentEqual( |
| 583 | |
| 584 | === modified file 'lib/lp/registry/tests/test_personset.py' |
| 585 | --- lib/lp/registry/tests/test_personset.py 2014-11-17 23:52:20 +0000 |
| 586 | +++ lib/lp/registry/tests/test_personset.py 2015-01-07 00:40:21 +0000 |
| 587 | @@ -552,9 +552,10 @@ |
| 588 | self.assertEqual(person, found_person) |
| 589 | self.assertEqual(AccountStatus.ACTIVE, person.account.status) |
| 590 | self.assertTrue(db_updated) |
| 591 | - self.assertEqual( |
| 592 | - "when purchasing an application via Software Center.", |
| 593 | - removeSecurityProxy(person.account).status_comment) |
| 594 | + self.assertEndsWith( |
| 595 | + removeSecurityProxy(person.account).status_history, |
| 596 | + ": Deactivated -> Active: " |
| 597 | + "when purchasing an application via Software Center.\n") |
| 598 | |
| 599 | def test_existing_suspended_account(self): |
| 600 | # An existing suspended account will raise an exception. |
| 601 | |
| 602 | === modified file 'lib/lp/scripts/tests/test_garbo.py' |
| 603 | --- lib/lp/scripts/tests/test_garbo.py 2014-11-06 02:22:57 +0000 |
| 604 | +++ lib/lp/scripts/tests/test_garbo.py 2015-01-07 00:40:21 +0000 |
| 605 | @@ -843,12 +843,12 @@ |
| 606 | 1) |
| 607 | |
| 608 | account = person.account |
| 609 | - account.status = status |
| 610 | + account.setStatus(status, None, 'Go away') |
| 611 | # We flush because a trigger sets the date_status_set and we need to |
| 612 | # modify it ourselves. |
| 613 | Store.of(account).flush() |
| 614 | if interval is not None: |
| 615 | - account.date_status_set = interval |
| 616 | + removeSecurityProxy(account).date_status_set = interval |
| 617 | |
| 618 | self.runDaily() |
| 619 | |
| 620 | |
| 621 | === modified file 'lib/lp/services/identity/configure.zcml' |
| 622 | --- lib/lp/services/identity/configure.zcml 2013-01-16 04:51:47 +0000 |
| 623 | +++ lib/lp/services/identity/configure.zcml 2015-01-07 00:40:21 +0000 |
| 624 | @@ -53,7 +53,7 @@ |
| 625 | interface="lp.services.identity.interfaces.account.IAccountViewRestricted"/> |
| 626 | <require |
| 627 | permission="launchpad.Moderate" |
| 628 | - set_attributes="status date_status_set status_comment"/> |
| 629 | + interface="lp.services.identity.interfaces.account.IAccountModerateRestricted"/> |
| 630 | <require |
| 631 | permission="launchpad.Edit" |
| 632 | set_attributes="displayname"/> |
| 633 | |
| 634 | === modified file 'lib/lp/services/identity/doc/account.txt' |
| 635 | --- lib/lp/services/identity/doc/account.txt 2012-12-05 16:16:20 +0000 |
| 636 | +++ lib/lp/services/identity/doc/account.txt 2015-01-07 00:40:21 +0000 |
| 637 | @@ -81,7 +81,7 @@ |
| 638 | Account objects have a useful string representation. |
| 639 | |
| 640 | >>> account |
| 641 | - <Account 'No Privileges Person' (Active account)> |
| 642 | + <Account 'No Privileges Person' (Active)> |
| 643 | |
| 644 | The account has other metadata. |
| 645 | |
| 646 | @@ -97,7 +97,8 @@ |
| 647 | >>> account.date_status_set >= account.date_created |
| 648 | True |
| 649 | |
| 650 | - >>> print account.status_comment |
| 651 | + >>> from zope.security.proxy import removeSecurityProxy |
| 652 | + >>> print removeSecurityProxy(account).status_history |
| 653 | None |
| 654 | |
| 655 | >>> print account.displayname |
| 656 | @@ -110,16 +111,15 @@ |
| 657 | |
| 658 | >>> original_date_status_set = account.date_status_set |
| 659 | >>> login('admin@canonical.com') |
| 660 | - >>> account.status = AccountStatus.SUSPENDED |
| 661 | + >>> account.setStatus(AccountStatus.SUSPENDED, None, 'spammer') |
| 662 | |
| 663 | # Shouldn't be necessary with Storm! |
| 664 | - >>> from zope.security.proxy import removeSecurityProxy |
| 665 | >>> removeSecurityProxy(account).sync() |
| 666 | >>> account.date_status_set > original_date_status_set |
| 667 | True |
| 668 | |
| 669 | - >>> account.status = AccountStatus.DEACTIVATED |
| 670 | - >>> account.status = AccountStatus.ACTIVE |
| 671 | + >>> account.setStatus(AccountStatus.DEACTIVATED, None, 'welcome') |
| 672 | + >>> account.setStatus(AccountStatus.ACTIVE, None, 'logged in!') |
| 673 | >>> login('no-priv@canonical.com') |
| 674 | |
| 675 | An Account has at least one OpenID identifier used to generate the |
| 676 | |
| 677 | === modified file 'lib/lp/services/identity/interfaces/account.py' |
| 678 | --- lib/lp/services/identity/interfaces/account.py 2013-01-16 04:51:47 +0000 |
| 679 | +++ lib/lp/services/identity/interfaces/account.py 2015-01-07 00:40:21 +0000 |
| 680 | @@ -11,9 +11,9 @@ |
| 681 | 'AccountSuspendedError', |
| 682 | 'AccountCreationRationale', |
| 683 | 'IAccount', |
| 684 | + 'IAccountModerateRestricted', |
| 685 | 'IAccountPublic', |
| 686 | 'IAccountSet', |
| 687 | - 'IAccountSpecialRestricted', |
| 688 | 'IAccountViewRestricted', |
| 689 | 'INACTIVE_ACCOUNT_STATUSES', |
| 690 | ] |
| 691 | @@ -48,25 +48,25 @@ |
| 692 | """The status of an account.""" |
| 693 | |
| 694 | NOACCOUNT = DBItem(10, """ |
| 695 | - Unactivated account |
| 696 | + Unactivated |
| 697 | |
| 698 | The account has not yet been activated. |
| 699 | """) |
| 700 | |
| 701 | ACTIVE = DBItem(20, """ |
| 702 | - Active account |
| 703 | + Active |
| 704 | |
| 705 | The account is active. |
| 706 | """) |
| 707 | |
| 708 | DEACTIVATED = DBItem(30, """ |
| 709 | - Deactivated account |
| 710 | + Deactivated |
| 711 | |
| 712 | The account has been deactivated by the account's owner. |
| 713 | """) |
| 714 | |
| 715 | SUSPENDED = DBItem(40, """ |
| 716 | - Suspended Launchpad account |
| 717 | + Suspended |
| 718 | |
| 719 | The account has been suspended by a Launchpad admin. |
| 720 | """) |
| 721 | @@ -253,7 +253,7 @@ |
| 722 | |
| 723 | status = AccountStatusChoice( |
| 724 | title=_("The status of this account"), required=True, |
| 725 | - readonly=False, vocabulary=AccountStatus) |
| 726 | + readonly=True, vocabulary=AccountStatus) |
| 727 | |
| 728 | |
| 729 | class IAccountViewRestricted(Interface): |
| 730 | @@ -269,12 +269,7 @@ |
| 731 | openid_identifiers = Attribute(_("Linked OpenId Identifiers")) |
| 732 | |
| 733 | date_status_set = Datetime( |
| 734 | - title=_('Date status last modified.'), |
| 735 | - required=True, readonly=False) |
| 736 | - |
| 737 | - status_comment = Text( |
| 738 | - title=_("Why are you deactivating your account?"), |
| 739 | - required=False, readonly=False) |
| 740 | + title=_('Date status last modified.'), required=True, readonly=True) |
| 741 | |
| 742 | def reactivate(comment): |
| 743 | """Activate this account. |
| 744 | @@ -285,7 +280,22 @@ |
| 745 | """ |
| 746 | |
| 747 | |
| 748 | -class IAccount(IAccountPublic, IAccountViewRestricted): |
| 749 | +class IAccountModerateRestricted(Interface): |
| 750 | + |
| 751 | + status_history = Text( |
| 752 | + title=_("Account status comments"), required=False, readonly=True) |
| 753 | + |
| 754 | + def setStatus(status, user, comment): |
| 755 | + """Change the status of this account. |
| 756 | + |
| 757 | + :param user: The user performing the action or None. |
| 758 | + :param comment: An explanation of the change to be logged in |
| 759 | + status_history. |
| 760 | + """ |
| 761 | + |
| 762 | + |
| 763 | +class IAccount(IAccountPublic, IAccountViewRestricted, |
| 764 | + IAccountModerateRestricted): |
| 765 | """Interface describing an `Account`.""" |
| 766 | |
| 767 | |
| 768 | |
| 769 | === modified file 'lib/lp/services/identity/model/account.py' |
| 770 | --- lib/lp/services/identity/model/account.py 2013-06-20 05:50:00 +0000 |
| 771 | +++ lib/lp/services/identity/model/account.py 2015-01-07 00:40:21 +0000 |
| 772 | @@ -9,6 +9,8 @@ |
| 773 | 'AccountSet', |
| 774 | ] |
| 775 | |
| 776 | +import datetime |
| 777 | + |
| 778 | from sqlobject import StringCol |
| 779 | from storm.locals import ReferenceSet |
| 780 | from zope.interface import implements |
| 781 | @@ -54,7 +56,7 @@ |
| 782 | status = AccountStatusEnumCol( |
| 783 | enum=AccountStatus, default=AccountStatus.NOACCOUNT, notNull=True) |
| 784 | date_status_set = UtcDateTimeCol(notNull=True, default=UTC_NOW) |
| 785 | - status_comment = StringCol(dbName='status_comment', default=None) |
| 786 | + status_history = StringCol(dbName='status_comment', default=None) |
| 787 | |
| 788 | openid_identifiers = ReferenceSet( |
| 789 | "Account.id", OpenIdIdentifier.account_id) |
| 790 | @@ -64,10 +66,27 @@ |
| 791 | return "<%s '%s' (%s)>" % ( |
| 792 | self.__class__.__name__, displayname, self.status) |
| 793 | |
| 794 | + def addStatusComment(self, user, comment): |
| 795 | + """See `IAccountModerateRestricted`.""" |
| 796 | + prefix = datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') |
| 797 | + if user is not None: |
| 798 | + prefix += ' %s' % user.name |
| 799 | + old_lines = ( |
| 800 | + self.status_history.splitlines() if self.status_history else []) |
| 801 | + self.status_history = '\n'.join( |
| 802 | + old_lines + ['%s: %s' % (prefix, comment), '']) |
| 803 | + |
| 804 | + def setStatus(self, status, user, comment): |
| 805 | + """See `IAccountModerateRestricted`.""" |
| 806 | + comment = comment or '' |
| 807 | + self.addStatusComment( |
| 808 | + user, '%s -> %s: %s' % (self.status.title, status.title, comment)) |
| 809 | + # date_status_set is maintained by a DB trigger. |
| 810 | + self.status = status |
| 811 | + |
| 812 | def reactivate(self, comment): |
| 813 | """See `IAccountSpecialRestricted`.""" |
| 814 | - self.status = AccountStatus.ACTIVE |
| 815 | - self.status_comment = comment |
| 816 | + self.setStatus(AccountStatus.ACTIVE, None, comment) |
| 817 | |
| 818 | |
| 819 | class AccountSet: |
| 820 | |
| 821 | === modified file 'lib/lp/services/identity/tests/test_account.py' |
| 822 | --- lib/lp/services/identity/tests/test_account.py 2012-12-04 23:31:37 +0000 |
| 823 | +++ lib/lp/services/identity/tests/test_account.py 2015-01-07 00:40:21 +0000 |
| 824 | @@ -25,14 +25,14 @@ |
| 825 | def test_account_repr_ansii(self): |
| 826 | # Verify that ANSI displayname is ascii safe. |
| 827 | distro = self.factory.makeAccount(u'\xdc-account') |
| 828 | - ignore, displayname, status_1, status_2 = repr(distro).rsplit(' ', 3) |
| 829 | + ignore, displayname, status = repr(distro).rsplit(' ', 2) |
| 830 | self.assertEqual("'\\xdc-account'", displayname) |
| 831 | - self.assertEqual('(Active account)>', '%s %s' % (status_1, status_2)) |
| 832 | + self.assertEqual('(Active)>', status) |
| 833 | |
| 834 | def test_account_repr_unicode(self): |
| 835 | # Verify that Unicode displayname is ascii safe. |
| 836 | distro = self.factory.makeAccount(u'\u0170-account') |
| 837 | - ignore, displayname, status_1, status_2 = repr(distro).rsplit(' ', 3) |
| 838 | + ignore, displayname, status = repr(distro).rsplit(' ', 2) |
| 839 | self.assertEqual("'\\u0170-account'", displayname) |
| 840 | |
| 841 | def assertCannotTransition(self, account, statuses): |
| 842 | @@ -40,13 +40,13 @@ |
| 843 | self.assertFalse( |
| 844 | IAccount['status'].bind(account).constraint(status)) |
| 845 | self.assertRaises( |
| 846 | - AccountStatusError, setattr, account, 'status', status) |
| 847 | + AccountStatusError, account.setStatus, status, None, 'Go away') |
| 848 | |
| 849 | def assertCanTransition(self, account, statuses): |
| 850 | for status in statuses: |
| 851 | self.assertTrue( |
| 852 | IAccount['status'].bind(account).constraint(status)) |
| 853 | - account.status = status |
| 854 | + account.setStatus(status, None, 'No reason') |
| 855 | self.assertEqual(status, account.status) |
| 856 | |
| 857 | def test_status_from_noaccount(self): |
| 858 | @@ -69,7 +69,7 @@ |
| 859 | # The status may change from DEACTIVATED to ACTIVATED. |
| 860 | account = self.factory.makeAccount() |
| 861 | login_celebrity('admin') |
| 862 | - account.status = AccountStatus.DEACTIVATED |
| 863 | + account.setStatus(AccountStatus.DEACTIVATED, None, 'gbcw') |
| 864 | self.assertCannotTransition( |
| 865 | account, [AccountStatus.NOACCOUNT, AccountStatus.SUSPENDED]) |
| 866 | self.assertCanTransition(account, [AccountStatus.ACTIVE]) |
| 867 | @@ -78,7 +78,7 @@ |
| 868 | # The status may change from SUSPENDED to DEACTIVATED. |
| 869 | account = self.factory.makeAccount() |
| 870 | login_celebrity('admin') |
| 871 | - account.status = AccountStatus.SUSPENDED |
| 872 | + account.setStatus(AccountStatus.SUSPENDED, None, 'spammer!') |
| 873 | self.assertCannotTransition( |
| 874 | account, [AccountStatus.NOACCOUNT, AccountStatus.ACTIVE]) |
| 875 | self.assertCanTransition(account, [AccountStatus.DEACTIVATED]) |
| 876 | |
| 877 | === modified file 'lib/lp/soyuz/doc/distribution.txt' |
| 878 | --- lib/lp/soyuz/doc/distribution.txt 2013-08-01 00:14:03 +0000 |
| 879 | +++ lib/lp/soyuz/doc/distribution.txt 2015-01-07 00:40:21 +0000 |
| 880 | @@ -111,14 +111,14 @@ |
| 881 | |
| 882 | >>> from lp.services.identity.interfaces.account import AccountStatus |
| 883 | >>> login('admin@canonical.com') |
| 884 | - >>> no_priv.account.status = AccountStatus.SUSPENDED |
| 885 | + >>> no_priv.setAccountStatus(AccountStatus.SUSPENDED, None, 'spammer!') |
| 886 | |
| 887 | >>> result = ubuntu.searchPPAs(text=u'priv', show_inactive=True) |
| 888 | >>> [archive for archive in result] |
| 889 | [] |
| 890 | |
| 891 | - >>> no_priv.account.status = AccountStatus.DEACTIVATED |
| 892 | - >>> no_priv.account.status = AccountStatus.ACTIVE |
| 893 | + >>> no_priv.setAccountStatus(AccountStatus.DEACTIVATED, None, 'oops') |
| 894 | + >>> no_priv.setAccountStatus(AccountStatus.ACTIVE, None, 'login') |
| 895 | |
| 896 | |
| 897 | Retrieving only pending-acceptance PPAs |
