launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #04525
[Merge] lp:~wallyworld/launchpad/remove-answer-contacts-424156 into lp:launchpad
Ian Booth has proposed merging lp:~wallyworld/launchpad/remove-answer-contacts-424156 into lp:launchpad.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
Bug #424156 in Launchpad itself: "Allow users with authority to remove answer contacts"
https://bugs.launchpad.net/launchpad/+bug/424156
For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/remove-answer-contacts-424156/+merge/70857
== Implementation ==
Provide a new daily garbo job to remove answer contacts which are deactivated (after 1 day) or suspended (after 7 days). The job is implemented by subclassing BulkPruner.
An update to db permissions is required - the garbo job needs delete privileges on the answercontact table.
Also did a few lint fixes.
== Tests ==
Add tests to test_garbo.py:
test_AnswerContactPruner_deactivated_accounts
test_AnswerContactPruner_suspended_accounts
test_AnswerContactPruner_doesnt_prune_recently_changed_accounts
== Lint ==
Checking for conflicts and issues in changed files.
Linting changed files:
database/schema/security.cfg
lib/canonical/database/constants.py
lib/lp/scripts/garbo.py
lib/lp/scripts/tests/test_garbo.py
--
https://code.launchpad.net/~wallyworld/launchpad/remove-answer-contacts-424156/+merge/70857
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/remove-answer-contacts-424156 into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg 2011-08-08 12:38:50 +0000
+++ database/schema/security.cfg 2011-08-09 11:50:56 +0000
@@ -2147,6 +2147,7 @@
[garbo]
groups=script,read
+public.answercontact = SELECT, DELETE
public.branchjob = SELECT, DELETE
public.bug = SELECT, UPDATE
public.bugaffectsperson = SELECT
=== modified file 'lib/canonical/database/constants.py'
--- lib/canonical/database/constants.py 2009-08-03 09:39:14 +0000
+++ lib/canonical/database/constants.py 2011-08-09 11:50:56 +0000
@@ -17,3 +17,9 @@
THIRTY_DAYS_AGO = SQL(
"CURRENT_TIMESTAMP AT TIME ZONE 'UTC' - interval '30 days'")
+
+SEVEN_DAYS_AGO = SQL(
+ "CURRENT_TIMESTAMP AT TIME ZONE 'UTC' - interval '7 days'")
+
+ONE_DAY_AGO = SQL(
+ "CURRENT_TIMESTAMP AT TIME ZONE 'UTC' - interval '1 day'")
=== modified file 'lib/lp/scripts/garbo.py'
--- lib/lp/scripts/garbo.py 2011-08-03 12:42:24 +0000
+++ lib/lp/scripts/garbo.py 2011-08-09 11:50:56 +0000
@@ -49,6 +49,7 @@
from canonical.launchpad.database.librarian import TimeLimitedToken
from canonical.launchpad.database.oauth import OAuthNonce
from canonical.launchpad.database.openidconsumer import OpenIDConsumerNonce
+from canonical.launchpad.interfaces.account import AccountStatus
from canonical.launchpad.interfaces.emailaddress import EmailAddressStatus
from canonical.launchpad.interfaces.lpstorm import IMasterStore
from canonical.launchpad.utilities.looptuner import TunableLoop
@@ -57,6 +58,7 @@
MAIN_STORE,
MASTER_FLAVOR,
)
+from lp.answers.model.answercontact import AnswerContact
from lp.bugs.interfaces.bug import IBugSet
from lp.bugs.model.bug import Bug
from lp.bugs.model.bugattachment import BugAttachment
@@ -94,7 +96,7 @@
)
-ONE_DAY_IN_SECONDS = 24*60*60
+ONE_DAY_IN_SECONDS = 24 * 60 * 60
class BulkPruner(TunableLoop):
@@ -305,7 +307,7 @@
We remove all OpenIDConsumerNonce records older than 1 day.
"""
- maximum_chunk_size = 6*60*60 # 6 hours in seconds.
+ maximum_chunk_size = 6 * 60 * 60 # 6 hours in seconds.
def __init__(self, log, abort_time=None):
super(OpenIDConsumerNoncePruner, self).__init__(log, abort_time)
@@ -611,7 +613,7 @@
self.max_offset = self.store.execute(
"SELECT MAX(id) FROM UnlinkedPeople").get_one()[0]
if self.max_offset is None:
- self.max_offset = -1 # Trigger isDone() now.
+ self.max_offset = -1 # Trigger isDone() now.
self.log.debug("No Person records to remove.")
else:
self.log.info("%d Person records to remove." % self.max_offset)
@@ -677,6 +679,34 @@
"""
+class AnswerContactPruner(BulkPruner):
+ """Remove old answer contacts which are no longer required.
+
+ Remove a person as an answer contact if:
+ their account has been deactivated for more than one day, or
+ suspended for more than one week.
+ """
+ target_table_class = AnswerContact
+ ids_to_prune_query = """
+ SELECT DISTINCT AnswerContact.id
+ FROM AnswerContact, Person, Account
+ WHERE
+ AnswerContact.person = Person.id
+ AND Person.account = Account.id
+ AND (
+ (Account.date_status_set <
+ CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
+ - CAST('1 day' AS interval)
+ AND Account.status = %s)
+ OR
+ (Account.date_status_set <
+ CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
+ - CAST('7 days' AS interval)
+ AND Account.status = %s)
+ )
+ """ % (AccountStatus.DEACTIVATED.value, AccountStatus.SUSPENDED.value)
+
+
class BranchJobPruner(BulkPruner):
"""Prune `BranchJob`s that are in a final state and more than a month old.
@@ -700,8 +730,8 @@
Only needed until they are all set, after that triggers will maintain it.
"""
- # Test migration did 3M in 2 hours, so 5000 is ~ 10 seconds - and thats the
- # max we want to hold a DB lock open for.
+ # Test migration did 3M in 2 hours, so 5000 is ~ 10 seconds - and that's
+ # the max we want to hold a DB lock open for.
minimum_chunk_size = 1000
maximum_chunk_size = 5000
@@ -709,7 +739,7 @@
super(MirrorBugMessageOwner, self).__init__(log, abort_time)
self.store = IMasterStore(BugMessage)
self.isDone = IMasterStore(BugMessage).find(
- BugMessage, BugMessage.ownerID==None).is_empty
+ BugMessage, BugMessage.ownerID == None).is_empty
def __call__(self, chunk_size):
"""See `ITunableLoop`."""
@@ -811,7 +841,7 @@
class OldTimeLimitedTokenDeleter(TunableLoop):
"""Delete expired url access tokens from the session DB."""
- maximum_chunk_size = 24*60*60 # 24 hours in seconds.
+ maximum_chunk_size = 24 * 60 * 60 # 24 hours in seconds.
def __init__(self, log, abort_time=None):
super(OldTimeLimitedTokenDeleter, self).__init__(log, abort_time)
@@ -933,10 +963,10 @@
class BaseDatabaseGarbageCollector(LaunchpadCronScript):
"""Abstract base class to run a collection of TunableLoops."""
- script_name = None # Script name for locking and database user. Override.
- tunable_loops = None # Collection of TunableLoops. Override.
- continue_on_failure = False # If True, an exception in a tunable loop
- # does not cause the script to abort.
+ script_name = None # Script name for locking and database user. Override.
+ tunable_loops = None # Collection of TunableLoops. Override.
+ continue_on_failure = False # If True, an exception in a tunable loop
+ # does not cause the script to abort.
# Default run time of the script in seconds. Override.
default_abort_script_time = None
@@ -987,7 +1017,7 @@
for count in range(0, self.options.threads):
thread = threading.Thread(
target=self.run_tasks_in_thread,
- name='Worker-%d' % (count+1,),
+ name='Worker-%d' % (count + 1,),
args=(tunable_loops,))
thread.start()
threads.add(thread)
@@ -1021,7 +1051,7 @@
@property
def script_timeout(self):
- a_very_long_time = 31536000 # 1 year
+ a_very_long_time = 31536000 # 1 year
return self.options.abort_script or a_very_long_time
def get_loop_logger(self, loop_name):
@@ -1034,7 +1064,7 @@
loop_logger = logging.getLogger('garbo.' + loop_name)
for filter in loop_logger.filters:
if isinstance(filter, PrefixFilter):
- return loop_logger # Already have a PrefixFilter attached.
+ return loop_logger # Already have a PrefixFilter attached.
loop_logger.addFilter(PrefixFilter(loop_name))
return loop_logger
@@ -1106,7 +1136,7 @@
loop_logger.debug3(
"Unable to acquire lock %s. Running elsewhere?",
loop_lock_path)
- time.sleep(0.3) # Avoid spinning.
+ time.sleep(0.3) # Avoid spinning.
tunable_loops.append(tunable_loop_class)
# Otherwise, emit a warning and skip the task.
else:
@@ -1169,6 +1199,7 @@
class DailyDatabaseGarbageCollector(BaseDatabaseGarbageCollector):
script_name = 'garbo-daily'
tunable_loops = [
+ AnswerContactPruner,
BranchJobPruner,
BugNotificationPruner,
BugWatchActivityPruner,
=== modified file 'lib/lp/scripts/tests/test_garbo.py'
--- lib/lp/scripts/tests/test_garbo.py 2011-08-02 09:57:41 +0000
+++ lib/lp/scripts/tests/test_garbo.py 2011-08-09 11:50:56 +0000
@@ -33,6 +33,8 @@
from canonical.config import config
from canonical.database import sqlbase
from canonical.database.constants import (
+ ONE_DAY_AGO,
+ SEVEN_DAYS_AGO,
THIRTY_DAYS_AGO,
UTC_NOW,
)
@@ -43,6 +45,7 @@
OAuthNonce,
)
from canonical.launchpad.database.openidconsumer import OpenIDConsumerNonce
+from canonical.launchpad.interfaces.account import AccountStatus
from canonical.launchpad.interfaces.emailaddress import EmailAddressStatus
from canonical.launchpad.interfaces.lpstorm import IMasterStore
from canonical.launchpad.scripts.tests import run_script
@@ -57,6 +60,7 @@
LaunchpadZopelessLayer,
ZopelessDatabaseLayer,
)
+from lp.answers.model.answercontact import AnswerContact
from lp.bugs.model.bugmessage import BugMessage
from lp.bugs.model.bugnotification import (
BugNotification,
@@ -93,11 +97,12 @@
SessionData,
SessionPkgData,
)
+from lp.services.worlddata.interfaces.language import ILanguageSet
from lp.testing import (
+ person_logged_in,
TestCase,
TestCaseWithFactory,
)
-from lp.translations.model.potemplate import POTemplate
from lp.translations.model.potmsgset import POTMsgSet
from lp.translations.model.translationtemplateitem import (
TranslationTemplateItem,
@@ -392,10 +397,10 @@
def test_OAuthNoncePruner(self):
now = datetime.now(UTC)
timestamps = [
- now - timedelta(days=2), # Garbage
- now - timedelta(days=1) - timedelta(seconds=60), # Garbage
- now - timedelta(days=1) + timedelta(seconds=60), # Not garbage
- now, # Not garbage
+ now - timedelta(days=2), # Garbage
+ now - timedelta(days=1) - timedelta(seconds=60), # Garbage
+ now - timedelta(days=1) + timedelta(seconds=60), # Not garbage
+ now, # Not garbage
]
LaunchpadZopelessLayer.switchDbUser('testadmin')
store = IMasterStore(OAuthNonce)
@@ -406,14 +411,14 @@
for timestamp in timestamps:
store.add(OAuthNonce(
access_token=OAuthAccessToken.get(1),
- request_timestamp = timestamp,
- nonce = str(timestamp)))
+ request_timestamp=timestamp,
+ nonce=str(timestamp)))
transaction.commit()
# Make sure we have 4 nonces now.
self.failUnlessEqual(store.find(OAuthNonce).count(), 4)
- self.runHourly(maximum_chunk_size=60) # 1 minute maximum chunk size
+ self.runHourly(maximum_chunk_size=60) # 1 minute maximum chunk size
store = IMasterStore(OAuthNonce)
@@ -435,10 +440,10 @@
HOURS = 60 * 60
DAYS = 24 * HOURS
timestamps = [
- now - 2 * DAYS, # Garbage
- now - 1 * DAYS - 1 * MINUTES, # Garbage
- now - 1 * DAYS + 1 * MINUTES, # Not garbage
- now, # Not garbage
+ now - 2 * DAYS, # Garbage
+ now - 1 * DAYS - 1 * MINUTES, # Garbage
+ now - 1 * DAYS + 1 * MINUTES, # Not garbage
+ now, # Not garbage
]
LaunchpadZopelessLayer.switchDbUser('testadmin')
@@ -456,7 +461,7 @@
self.failUnlessEqual(store.find(OpenIDConsumerNonce).count(), 4)
# Run the garbage collector.
- self.runHourly(maximum_chunk_size=60) # 1 minute maximum chunks.
+ self.runHourly(maximum_chunk_size=60) # 1 minute maximum chunks.
store = IMasterStore(OpenIDConsumerNonce)
@@ -465,7 +470,8 @@
# And none of them are older than 1 day
earliest = store.find(Min(OpenIDConsumerNonce.timestamp)).one()
- self.failUnless(earliest >= now - 24*60*60, 'Still have old nonces')
+ self.failUnless(
+ earliest >= now - 24 * 60 * 60, 'Still have old nonces')
def test_CodeImportResultPruner(self):
now = datetime.now(UTC)
@@ -492,7 +498,7 @@
new_code_import_result(now - timedelta(days=60))
for i in range(results_to_keep_count - 1):
- new_code_import_result(now - timedelta(days=19+i))
+ new_code_import_result(now - timedelta(days=19 + i))
# Run the garbage collector
self.runDaily()
@@ -565,7 +571,7 @@
store.execute("""
INSERT INTO %s (server_url, handle, issued, lifetime)
VALUES (%s, %s, %d, %d)
- """ % (table_name, str(delta), str(delta), now-10, delta))
+ """ % (table_name, str(delta), str(delta), now - 10, delta))
transaction.commit()
# Ensure that we created at least one expirable row (using the
@@ -779,6 +785,60 @@
BugNotification.date_emailed < THIRTY_DAYS_AGO).count(),
0)
+ def _test_AnswerContactPruner(self, status, interval, expected_count=0):
+ # Garbo should remove answer contacts for accounts with given 'status'
+ # which was set more than 'interval' days ago.
+ LaunchpadZopelessLayer.switchDbUser('testadmin')
+ store = IMasterStore(AnswerContact)
+
+ person = self.factory.makePerson()
+ person.addLanguage(getUtility(ILanguageSet)['en'])
+ question = self.factory.makeQuestion()
+ with person_logged_in(question.owner):
+ question.target.addAnswerContact(person, person)
+ Store.of(question).flush()
+ self.assertEqual(
+ store.find(
+ AnswerContact,
+ AnswerContact.person == person.id).count(),
+ 1)
+
+ account = person.account
+ account.status = status
+ # We flush because a trigger sets the date_status_set and we need to
+ # modify it ourselves.
+ Store.of(account).flush()
+ if interval is not None:
+ account.date_status_set = interval
+
+ self.runDaily()
+
+ LaunchpadZopelessLayer.switchDbUser('testadmin')
+ self.assertEqual(
+ store.find(
+ AnswerContact,
+ AnswerContact.person == person.id).count(),
+ expected_count)
+
+ def test_AnswerContactPruner_deactivated_accounts(self):
+ # Answer contacts with an account deactivated at least one day ago
+ # should be pruned.
+ self._test_AnswerContactPruner(AccountStatus.DEACTIVATED, ONE_DAY_AGO)
+
+ def test_AnswerContactPruner_suspended_accounts(self):
+ # Answer contacts with an account suspended at least seven days ago
+ # should be pruned.
+ self._test_AnswerContactPruner(
+ AccountStatus.SUSPENDED, SEVEN_DAYS_AGO)
+
+ def test_AnswerContactPruner_doesnt_prune_recently_changed_accounts(self):
+ # Answer contacts which are suspended or deactivated inside the
+ # minimum time interval are not pruned.
+ self._test_AnswerContactPruner(
+ AccountStatus.DEACTIVATED, None, expected_count=1)
+ self._test_AnswerContactPruner(
+ AccountStatus.SUSPENDED, ONE_DAY_AGO, expected_count=1)
+
def test_BranchJobPruner(self):
# Garbo should remove jobs completed over 30 days ago.
LaunchpadZopelessLayer.switchDbUser('testadmin')
@@ -900,7 +960,7 @@
finally:
con.close()
store = IMasterStore(BugMessage)
- unmigrated = store.find(BugMessage, BugMessage.ownerID==None).count
+ unmigrated = store.find(BugMessage, BugMessage.ownerID == None).count
self.assertNotEqual(0, unmigrated())
self.runHourly()
self.assertEqual(0, unmigrated())