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

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
Reviewer Review Type Date Requested Status
Colin Watson 2015-01-06 Approve on 2015-01-07
Review via email: mp+245649@code.launchpad.net

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.

Description of the Change

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.

To post a comment you must log in.
Colin Watson (cjwatson) :
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 -&gt; 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