Merge lp:~jtv/launchpad/bug-146855 into lp:launchpad

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Jeroen T. Vermeulen
Approved revision: no longer in the source branch.
Merged at revision: not available
Proposed branch: lp:~jtv/launchpad/bug-146855
Merge into: lp:launchpad
Diff against target: 867 lines
8 files modified
cronscripts/rosetta-approve-imports.py (+5/-21)
database/schema/security.cfg (+17/-3)
lib/lp/translations/doc/poimport.txt (+19/-12)
lib/lp/translations/interfaces/translationimportqueue.py (+20/-13)
lib/lp/translations/model/translationimportqueue.py (+86/-38)
lib/lp/translations/scripts/import_queue_gardener.py (+50/-0)
lib/lp/translations/scripts/po_import.py (+4/-55)
lib/lp/translations/tests/test_autoapproval.py (+153/-1)
To merge this branch: bzr merge lp:~jtv/launchpad/bug-146855
Reviewer Review Type Date Requested Status
Aaron Bentley (community) Approve
Review via email: mp+12069@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :
Download full text (3.1 KiB)

= Bug 146855 =

Actually this branch resolves more long-standing gripes with the
translations import auto-approver than just that one bug.

But let's start with the original bug. An entry on the translations
import queue can be in one of several states: Needs Review, Approved,
Imported, Failed, Blocked, or Deleted. Failed is the state it ends up
in when its import failed, typically because of a syntax error.

Successfully imported entries get gc'ed off the queue after a few days.
Currently, Failed ones do not. The idea is that its owner uploads an
updated copy of the same file; it reuses the same queue entry; it gets
processed; it ends up successfully Imported; and so finally the entry
drops out of the queue. But in practice, especially for Ubuntu, Failed
entries lie around effectively forever.

So I fixed that. We want to clean up these entries less aggressively
than we do Imported ones, so the owner gets a fair chance to notice and
fix the problem (and sometimes, so that we get to restart a bunch of
entries that failed because of operational problems). This branch makes
the garbage-collection of entries in various states data-driven: 3 days
for Imported or Deleted entries, one month for Failed ones. There is no
need to test each state's "grace period" separately since we'd only be
unit-testing the contents of a dict.

== Other gripes ==

The approver's LaunchpadCronScript still lived in the same module as the
script it was originally spliced out of. Bit of a Zeus-and-Athena
scenario there. I gave it its own module in lp.translations.scripts.

Also, the class in there now inherits from LaunchpadCronScript instead
of having it instantiated from a separate LaunchpadCronScript-derived
class in the main script file. Unfortunately this did move some debug
output into the doctest; I switched from the FakeLogger to the
MockLogger so I could set a higher debug level and avoid this.

You may noticed that where transaction managers are passed around, I
changed their names from ztm (Zopeless Transaction Manager) to txn. We
don't use the ztm any more. Actually txn is just an alias for the
transaction module, so the argument isn't needed at all now. But this
is a relatively elegant way of telling a method whether you want it to
commit transactions. Passing a boolean for "please commit" is just too
ugly.

Oh, and another biggie: entries for obsolete distroseries. Right now
perhaps a fifth of the queue (certainly over 25K entries, out of 150K!)
are for obsolete Ubuntu releases that will never need any further
translations updates--they won't even get security updates. So I made
the script do some cleanup on those entries.

Deleting all of these would cause major database mayhem every time an
Ubuntu release slipped into obsolescence, so this cleanup is limited to
100 entries per run. Experience shows that that's not a noticeable load
for database replication, yet it'll get rid of all of these entries in a
few days. I didn't bother testing this since it's an operational detail
but I did consistently test that none of these cleanups affect rows that
shouldn't be affected.

To test:
{{{
./bin/test -vv -t poimport -t approval
}}}

No lint.

...

Read more...

Revision history for this message
Aaron Bentley (abentley) wrote :
Download full text (28.7 KiB)

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

Jeroen T. Vermeulen wrote:
> Actually this branch resolves more long-standing gripes with the
> translations import auto-approver than just that one bug.

Was there a preimplementation call?

> But let's start with the original bug. An entry on the translations
> import queue can be in one of several states: Needs Review, Approved,
> Imported, Failed, Blocked, or Deleted. Failed is the state it ends up
> in when its import failed, typically because of a syntax error.
>
> Successfully imported entries get gc'ed off the queue after a few days.
> Currently, Failed ones do not. The idea is that its owner uploads an
> updated copy of the same file; it reuses the same queue entry; it gets
> processed; it ends up successfully Imported; and so finally the entry
> drops out of the queue. But in practice, especially for Ubuntu, Failed
> entries lie around effectively forever.
>
> So I fixed that. We want to clean up these entries less aggressively
> than we do Imported ones, so the owner gets a fair chance to notice and
> fix the problem (and sometimes, so that we get to restart a bunch of
> entries that failed because of operational problems). This branch makes
> the garbage-collection of entries in various states data-driven: 3 days
> for Imported or Deleted entries, one month for Failed ones. There is no
> need to test each state's "grace period" separately since we'd only be
> unit-testing the contents of a dict.
>
>
> == Other gripes ==
>
> The approver's LaunchpadCronScript still lived in the same module as the
> script it was originally spliced out of. Bit of a Zeus-and-Athena
> scenario there. I gave it its own module in lp.translations.scripts.

> Also, the class in there now inherits from LaunchpadCronScript instead
> of having it instantiated from a separate LaunchpadCronScript-derived
> class in the main script file.

Do you think AutoApproveProcess is still a good name, since it's also
responsible for other queue maintenance? What about ProcessImportQueue
or something?

> Unfortunately this did move some debug
> output into the doctest; I switched from the FakeLogger to the
> MockLogger so I could set a higher debug level and avoid this.
>
> You may noticed that where transaction managers are passed around, I
> changed their names from ztm (Zopeless Transaction Manager) to txn. We
> don't use the ztm any more. Actually txn is just an alias for the
> transaction module, so the argument isn't needed at all now. But this
> is a relatively elegant way of telling a method whether you want it to
> commit transactions. Passing a boolean for "please commit" is just too
> ugly.

But functionally equivalent? (Would we ever pass in a different
transaction manager?)

> Oh, and another biggie: entries for obsolete distroseries. Right now
> perhaps a fifth of the queue (certainly over 25K entries, out of 150K!)
> are for obsolete Ubuntu releases that will never need any further
> translations updates--they won't even get security updates. So I made
> the script do some cleanup on those entries.
>
> Deleting all of these would cause major database mayhem every time an
> Ubuntu releas...

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :
Download full text (7.9 KiB)

> Jeroen T. Vermeulen wrote:
> > Actually this branch resolves more long-standing gripes with the
> > translations import auto-approver than just that one bug.
>
> Was there a preimplementation call?

Not as such. The approach is pretty straightforward and we've been
mulling some of these points for years. I don't particularly see anything
that raises questions of approach or direction. We did go into what I'm
doing in this branch a few times in our meetings.

> > == Other gripes ==
> >
> > The approver's LaunchpadCronScript still lived in the same module as the
> > script it was originally spliced out of. Bit of a Zeus-and-Athena
> > scenario there. I gave it its own module in lp.translations.scripts.
>
>
> > Also, the class in there now inherits from LaunchpadCronScript instead
> > of having it instantiated from a separate LaunchpadCronScript-derived
> > class in the main script file.
>
> Do you think AutoApproveProcess is still a good name, since it's also
> responsible for other queue maintenance? What about ProcessImportQueue
> or something?

It wasn't a good name when I first saw it, to be honest. You're right, and
I'm changing this. See below.

> > Unfortunately this did move some debug
> > output into the doctest; I switched from the FakeLogger to the
> > MockLogger so I could set a higher debug level and avoid this.
> >
> > You may noticed that where transaction managers are passed around, I
> > changed their names from ztm (Zopeless Transaction Manager) to txn. We
> > don't use the ztm any more. Actually txn is just an alias for the
> > transaction module, so the argument isn't needed at all now. But this
> > is a relatively elegant way of telling a method whether you want it to
> > commit transactions. Passing a boolean for "please commit" is just too
> > ugly.
>
> But functionally equivalent? (Would we ever pass in a different
> transaction manager?)

Functionally equivalent to a boolean, yes; if we ever pass a different
transaction manager then it'll be hidden somewhere under these interfaces
anyway.

Operational note: I've checked with Stuart and it seems that there are no
big replication problems to be expected from deleting our entire backlog of
Failed entries. Otherwise we'd have to clean up the queue a bit before
this change could roll out.

> > === modified file 'cronscripts/rosetta-approve-imports.py'
> > --- cronscripts/rosetta-approve-imports.py 2009-07-17 00:26:05 +0000
> > +++ cronscripts/rosetta-approve-imports.py 2009-09-18 07:39:51 +0000
> > @@ -10,26 +10,10 @@
> > import _pythonpath
> >
> > from canonical.config import config
> > -from canonical.database.sqlbase import ISOLATION_LEVEL_READ_COMMITTED
> > -from lp.translations.scripts.po_import import AutoApproveProcess
> > -from lp.services.scripts.base import LaunchpadCronScript
> > -
> > -
> > -class RosettaImportApprover(LaunchpadCronScript):
> > - def main(self):
> > - self.txn.set_isolation_level(ISOLATION_LEVEL_READ_COMMITTED)
> > - process = AutoApproveProcess(self.txn, self.logger)
> > - self.logger.debug('Starting auto-approval of translation imports')
> > - process.run()
> > - self.logger....

Read more...

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :
Download full text (7.1 KiB)

=== modified file 'cronscripts/rosetta-approve-imports.py'
--- cronscripts/rosetta-approve-imports.py 2009-09-18 07:39:51 +0000
+++ cronscripts/rosetta-approve-imports.py 2009-09-21 19:14:06 +0000
@@ -9,11 +9,11 @@

 import _pythonpath

-from canonical.config import config
-from lp.translations.scripts.import_approval import AutoApproveProcess
+from lp.translations.scripts.import_queue_gardener import ImportQueueGardener

 if __name__ == '__main__':
- script = AutoApproveProcess(
- 'rosetta-approve-imports', dbuser=config.poimport.dbuser)
+ script = ImportQueueGardener(
+ 'translations-import-queue-gardener',
+ dbuser='translations_import_queue_gardener')
     script.lock_and_run()

=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg 2009-09-02 19:06:19 +0000
+++ database/schema/security.cfg 2009-09-21 18:55:31 +0000
@@ -435,13 +435,29 @@
 public.account = SELECT, INSERT
 public.customlanguagecode = SELECT
 public.translationgroup = SELECT
-public.translationimportqueueentry = SELECT, DELETE
+public.translationimportqueueentry = SELECT
 public.translationmessage = SELECT, INSERT, UPDATE
 public.translationrelicensingagreement = SELECT
 public.translator = SELECT
 public.validpersoncache = SELECT
 public.validpersonorteamcache = SELECT

+[translations_import_queue_gardener]
+# Translations import queue management
+type=user
+groups=script
+public.customlanguagecode = SELECT
+public.distribution = SELECT
+public.distroseries = SELECT
+public.language = SELECT
+public.person = SELECT
+public.pofile = SELECT
+public.potemplate = SELECT
+public.product = SELECT
+public.productseries = SELECT
+public.sourcepackagename = SELECT
+public.translationimportqueueentry = SELECT, DELETE, UPDATE
+
 [poexport]
 # Rosetta export script
 type=user

=== modified file 'lib/lp/translations/doc/poimport.txt'
--- lib/lp/translations/doc/poimport.txt 2009-09-18 13:48:00 +0000
+++ lib/lp/translations/doc/poimport.txt 2009-09-21 18:58:48 +0000
@@ -16,8 +16,8 @@
     >>> from lp.registry.model.sourcepackagename import SourcePackageName
     >>> from lp.translations.model.potemplate import POTemplateSubset
     >>> from lp.translations.scripts.po_import import ImportProcess
- >>> from lp.translations.scripts.import_approval import (
- ... AutoApproveProcess)
+ >>> from lp.translations.scripts.import_queue_gardener import (
+ ... ImportQueueGardener)
     >>> import datetime
     >>> import pytz
     >>> UTC = pytz.timezone('UTC')
@@ -674,7 +674,7 @@
     <BLANKLINE>
     The Launchpad team

-Now the auto-approval script runs. This can happen anytime, since it's
+Now the queue gardener runs. This can happen anytime, since it's
 asynchronous to the po-import script. The script tries to approve any
 entries that have not been approved, but look like th...

Read more...

Revision history for this message
Aaron Bentley (abentley) wrote :

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

 review approve

Approved conditionally on switching the database user in the test case
(see below)

Jeroen T. Vermeulen wrote:
>> Jeroen T. Vermeulen wrote:
>> Do you think AutoApproveProcess is still a good name, since it's also
>> responsible for other queue maintenance? What about ProcessImportQueue
>> or something?
>
> It wasn't a good name when I first saw it, to be honest. You're right, and
> I'm changing this. See below.

I like it.

>> It seems a bit odd to be wrapping the script in if __name__ = '__main__'
>> blocks. It can't be loaded as a module because of its name, and even if
>> it could, I don't see any value in it.
>
> It's standard practice; I believe some Python syntax checkers will fool
> themselves into thinking the script has an importable name. I know
> that at least one—don't remember which—does its work by importing even
> a main executable Python file as a module.

Fair enough.

>> Does it make sense to specify a timedelta here? If you always specify
>> your durations in days, it's less repetitive convert to a timedelta in
>> _cleanUpObsoleteEntries
>
> I thought it was nice and explicit. Storing number-of-days struck me as
> unnecessarily C-like; timedeltas let me store a nondescript "time
> interval" that you can just add to or subtract from dates. And who
> knows, maybe someday we'll want other kinds of intervals.

There often is a tension between being explicit and being succinct. I'm
fine either way.

>> If you're specifying a timedelta, shouldn't the comment say "Length of
>> time" instead of "Number of days"? Is FAILED really a terminal status?
>> It seems like it's possibly not terminal, and that's why you're
>> delaying 30 days.
>
> Absolutely. I should have updated that part of the comment as well now.

Hmm. I think you missed the "Is FAILED really a terminal status?"
question. Not that fixing a comment is worth delaying the branch.

>> I find the way the deletion_criteria is constructed a bit surprising.
>> What do you think about building up a list of clauses first, and then
>> ORing them? For example:

> I considered that and felt that it was a toss-up. But I'm used my ways
> of doing things, and now that you mention it, your way is clearer.
> Changed.

Cool.

>>> === added file 'lib/lp/translations/scripts/import_approval.py'
>> Since this does more than approving imports, is import_approval a good
>> name? Maybe queue_gardener?
>
> I renamed it import_queue_gardener.py, and the class ImportQueueGardener.

Great.

> While I was at it I also gave the script its own database user, which
> requires a lot fewer database privileges than the original one.

That change also looks good, but I'd like to see the database user being
set to the new value in the unit tests. IME, it's very easy to miss a
required DB permission unless you test with the script user.

Aaron
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.4.9 (GNU/Linux)
Comment: Using GnuPG with Mozilla - http://enigmail.mozdev.org

iEYEARECAAYFAkq32UUACgkQ0F+nu1YWqI39DQCeMlPGurWQ4CxwplR0CaKKBVJA
SgAAn3+54mFu6LLI2TXnVixy0LKGG9/O
=6+zS
-----END PGP SIGNATURE-----

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'cronscripts/rosetta-approve-imports.py'
--- cronscripts/rosetta-approve-imports.py 2009-08-04 09:24:21 +0000
+++ cronscripts/rosetta-approve-imports.py 2009-09-26 06:25:27 +0000
@@ -9,27 +9,11 @@
99
10import _pythonpath10import _pythonpath
1111
12from canonical.config import config12from lp.translations.scripts.import_queue_gardener import ImportQueueGardener
13from canonical.database.sqlbase import ISOLATION_LEVEL_READ_COMMITTED
14from lp.translations.scripts.po_import import AutoApproveProcess
15from lp.services.scripts.base import LaunchpadCronScript
16
17
18class RosettaImportApprover(LaunchpadCronScript):
19 def main(self):
20 self.txn.set_isolation_level(ISOLATION_LEVEL_READ_COMMITTED)
21 process = AutoApproveProcess(self.txn, self.logger)
22 self.logger.debug('Starting auto-approval of translation imports')
23 process.run()
24 self.logger.debug('Completed auto-approval of translation imports')
2513
2614
27if __name__ == '__main__':15if __name__ == '__main__':
28 script = RosettaImportApprover('rosetta-approve-imports',16 script = ImportQueueGardener(
29 dbuser='poimportapprover')17 'translations-import-queue-gardener',
30 script.lock_or_quit()18 dbuser='translations_import_queue_gardener')
31 try:19 script.lock_and_run()
32 script.run()
33 finally:
34 script.unlock()
35
3620
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg 2009-09-19 04:06:14 +0000
+++ database/schema/security.cfg 2009-09-26 06:25:27 +0000
@@ -441,16 +441,30 @@
441public.account = SELECT, INSERT441public.account = SELECT, INSERT
442public.customlanguagecode = SELECT442public.customlanguagecode = SELECT
443public.translationgroup = SELECT443public.translationgroup = SELECT
444public.translationimportqueueentry = SELECT, DELETE444public.translationimportqueueentry = SELECT
445public.translationmessage = SELECT, INSERT, UPDATE445public.translationmessage = SELECT, INSERT, UPDATE
446public.translationrelicensingagreement = SELECT446public.translationrelicensingagreement = SELECT
447public.translator = SELECT447public.translator = SELECT
448public.validpersoncache = SELECT448public.validpersoncache = SELECT
449public.validpersonorteamcache = SELECT449public.validpersonorteamcache = SELECT
450450
451[poimportapprover]451[translations_import_queue_gardener]
452# Translations import queue management
452type=user453type=user
453groups=poimport454groups=script
455public.customlanguagecode = SELECT
456public.distribution = SELECT
457public.distroseries = SELECT
458public.language = SELECT
459public.person = SELECT
460public.pofile = SELECT, INSERT, UPDATE
461public.potemplate = SELECT, INSERT, UPDATE
462public.product = SELECT
463public.productseries = SELECT
464public.sourcepackagename = SELECT
465public.teamparticipation = SELECT
466public.translationimportqueueentry = SELECT, DELETE, UPDATE
467public.translationrelicensingagreement = SELECT
454468
455[poexport]469[poexport]
456# Rosetta export script470# Rosetta export script
457471
=== modified file 'lib/lp/translations/doc/poimport.txt'
--- lib/lp/translations/doc/poimport.txt 2009-09-03 09:10:49 +0000
+++ lib/lp/translations/doc/poimport.txt 2009-09-26 06:25:27 +0000
@@ -15,8 +15,9 @@
15 ... ITranslationImportQueue, RosettaImportStatus)15 ... ITranslationImportQueue, RosettaImportStatus)
16 >>> from lp.registry.model.sourcepackagename import SourcePackageName16 >>> from lp.registry.model.sourcepackagename import SourcePackageName
17 >>> from lp.translations.model.potemplate import POTemplateSubset17 >>> from lp.translations.model.potemplate import POTemplateSubset
18 >>> from lp.translations.scripts.po_import import (18 >>> from lp.translations.scripts.po_import import ImportProcess
19 ... AutoApproveProcess, ImportProcess)19 >>> from lp.translations.scripts.import_queue_gardener import (
20 ... ImportQueueGardener)
20 >>> import datetime21 >>> import datetime
21 >>> import pytz22 >>> import pytz
22 >>> UTC = pytz.timezone('UTC')23 >>> UTC = pytz.timezone('UTC')
@@ -673,7 +674,7 @@
673 <BLANKLINE>674 <BLANKLINE>
674 The Launchpad team675 The Launchpad team
675676
676Now the auto-approval script runs. This can happen anytime, since it's677Now the queue gardener runs. This can happen anytime, since it's
677asynchronous to the po-import script. The script tries to approve any678asynchronous to the po-import script. The script tries to approve any
678entries that have not been approved, but look like they could be,679entries that have not been approved, but look like they could be,
679without human intervention. This involves a bit of guesswork about what680without human intervention. This involves a bit of guesswork about what
@@ -682,9 +683,13 @@
682entries from the queue. Running at this point, all it does is purge the683entries from the queue. Running at this point, all it does is purge the
683two hand-approved Welsh translations that have just been imported.684two hand-approved Welsh translations that have just been imported.
684685
685 >>> process = AutoApproveProcess(transaction, FakeLogger())686 >>> import logging
686 >>> process.run()687 >>> from canonical.launchpad.ftests.logger import MockLogger
687 INFO Removed 2 entries from the queue.688 >>> process = ImportQueueGardener('approver', test_args=[])
689 >>> process.logger = MockLogger()
690 >>> process.logger.setLevel(logging.INFO)
691 >>> process.main()
692 log> Removed 2 entries from the queue.
688 >>> transaction.commit()693 >>> transaction.commit()
689694
690If users upload two versions of the same file, they are imported in the695If users upload two versions of the same file, they are imported in the
@@ -761,13 +766,15 @@
761 >>> print entry.status.name766 >>> print entry.status.name
762 NEEDS_REVIEW767 NEEDS_REVIEW
763768
764The auto-approval script runs again. This time it sees the two769The queue gardener runs again. This time it sees the two submitted
765submitted translations and approves them for import based on some770translations and approves them for import based on some heuristic
766heuristic intelligence.771intelligence.
767772
768 >>> process = AutoApproveProcess(transaction, FakeLogger())773 >>> process = ImportQueueGardener('approver', test_args=[])
769 >>> process.run()774 >>> process.logger = MockLogger()
770 INFO The automatic approval system approved some entries.775 >>> process.logger.setLevel(logging.INFO)
776 >>> process.main()
777 log> The automatic approval system approved some entries.
771 >>> print entry.status.name778 >>> print entry.status.name
772 APPROVED779 APPROVED
773 >>> syncUpdate(entry)780 >>> syncUpdate(entry)
774781
=== modified file 'lib/lp/translations/interfaces/translationimportqueue.py'
--- lib/lp/translations/interfaces/translationimportqueue.py 2009-08-14 16:35:06 +0000
+++ lib/lp/translations/interfaces/translationimportqueue.py 2009-09-26 06:25:27 +0000
@@ -399,30 +399,37 @@
399 All returned items will implement `IHasTranslationImports`.399 All returned items will implement `IHasTranslationImports`.
400 """400 """
401401
402 def executeOptimisticApprovals(ztm):402 def executeOptimisticApprovals(txn=None):
403 """Try to move entries from the Needs Review status to Approved one.403 """Try to approve Needs-Review entries.
404404
405 :arg ztm: Zope transaction manager object.405 :arg txn: Optional transaction manager. If given, will be
406 committed regularly.
406407
407 This method moves all entries that we know where should they be408 This method moves all entries that we know where should they be
408 imported from the Needs Review status to the Accepted one.409 imported from the Needs Review status to the Accepted one.
409 """410 """
410411
411 def executeOptimisticBlock(ztm):412 def executeOptimisticBlock(txn=None):
412 """Try to move entries from the Needs Review status to Blocked one.413 """Try to move entries from the Needs Review status to Blocked one.
413414
414 :arg ztm: Zope transaction manager object or None.415 :arg txn: Optional transaction manager. If given, will be
415416 committed regularly.
416 This method moves all .po entries that are on the same directory that417
417 a .pot entry that has the status Blocked to that same status.418 This method moves uploaded translations for Blocked templates to
418419 the Blocked status as well. This lets you block a template plus
419 Return the number of items blocked.420 all its present or future translations in one go.
421
422 :return: The number of items blocked.
420 """423 """
421424
422 def cleanUpQueue():425 def cleanUpQueue():
423 """Remove old DELETED and IMPORTED entries.426 """Remove old entries in terminal states.
424427
425 Only entries older than 5 days will be removed.428 This "garbage-collects" entries from the queue based on their
429 status (e.g. Deleted and Imported ones) and how long they have
430 been in that status.
431
432 :return: The number of entries deleted.
426 """433 """
427434
428 def remove(entry):435 def remove(entry):
429436
=== modified file 'lib/lp/translations/model/translationimportqueue.py'
--- lib/lp/translations/model/translationimportqueue.py 2009-09-03 05:14:07 +0000
+++ lib/lp/translations/model/translationimportqueue.py 2009-09-26 06:25:27 +0000
@@ -23,6 +23,7 @@
23from zope.interface import implements23from zope.interface import implements
24from zope.component import getUtility24from zope.component import getUtility
25from sqlobject import SQLObjectNotFound, StringCol, ForeignKey, BoolCol25from sqlobject import SQLObjectNotFound, StringCol, ForeignKey, BoolCol
26from storm.expr import And, Or
26from storm.locals import Int, Reference27from storm.locals import Int, Reference
2728
28from canonical.database.sqlbase import (29from canonical.database.sqlbase import (
@@ -32,9 +33,11 @@
32from canonical.database.enumcol import EnumCol33from canonical.database.enumcol import EnumCol
33from canonical.launchpad.helpers import shortlist34from canonical.launchpad.helpers import shortlist
34from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities35from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
36from canonical.launchpad.interfaces.lpstorm import IMasterStore
35from canonical.launchpad.webapp.interfaces import NotFoundError37from canonical.launchpad.webapp.interfaces import NotFoundError
36from lp.registry.interfaces.distribution import IDistribution38from lp.registry.interfaces.distribution import IDistribution
37from lp.registry.interfaces.distroseries import IDistroSeries39from lp.registry.interfaces.distroseries import (
40 IDistroSeries, DistroSeriesStatus)
38from lp.registry.interfaces.person import IPerson41from lp.registry.interfaces.person import IPerson
39from lp.registry.interfaces.product import IProduct42from lp.registry.interfaces.product import IProduct
40from lp.registry.interfaces.productseries import IProductSeries43from lp.registry.interfaces.productseries import IProductSeries
@@ -61,9 +64,13 @@
61from lp.registry.interfaces.person import validate_public_person64from lp.registry.interfaces.person import validate_public_person
6265
6366
64# Number of days when the DELETED and IMPORTED entries are removed from the67# Period to wait before entries with terminal statuses are removed from
65# queue.68# the queue.
66DAYS_TO_KEEP = 369entry_gc_age = {
70 RosettaImportStatus.DELETED: datetime.timedelta(days=3),
71 RosettaImportStatus.IMPORTED: datetime.timedelta(days=3),
72 RosettaImportStatus.FAILED: datetime.timedelta(days=30),
73}
6774
6875
69def is_gettext_name(path):76def is_gettext_name(path):
@@ -1068,7 +1075,7 @@
10681075
1069 return distroseriess + products1076 return distroseriess + products
10701077
1071 def executeOptimisticApprovals(self, ztm):1078 def executeOptimisticApprovals(self, txn=None):
1072 """See ITranslationImportQueue."""1079 """See ITranslationImportQueue."""
1073 there_are_entries_approved = False1080 there_are_entries_approved = False
1074 importer = getUtility(ITranslationImporter)1081 importer = getUtility(ITranslationImporter)
@@ -1108,12 +1115,13 @@
1108 # Already know where it should be imported. The entry is approved1115 # Already know where it should be imported. The entry is approved
1109 # automatically.1116 # automatically.
1110 entry.setStatus(RosettaImportStatus.APPROVED)1117 entry.setStatus(RosettaImportStatus.APPROVED)
1111 # Do the commit to save the changes.1118
1112 ztm.commit()1119 if txn is not None:
1120 txn.commit()
11131121
1114 return there_are_entries_approved1122 return there_are_entries_approved
11151123
1116 def executeOptimisticBlock(self, ztm=None):1124 def executeOptimisticBlock(self, txn=None):
1117 """See ITranslationImportQueue."""1125 """See ITranslationImportQueue."""
1118 importer = getUtility(ITranslationImporter)1126 importer = getUtility(ITranslationImporter)
1119 num_blocked = 01127 num_blocked = 0
@@ -1143,40 +1151,80 @@
1143 # blocked, so we can block it too.1151 # blocked, so we can block it too.
1144 entry.setStatus(RosettaImportStatus.BLOCKED)1152 entry.setStatus(RosettaImportStatus.BLOCKED)
1145 num_blocked += 11153 num_blocked += 1
1146 if ztm is not None:1154 if txn is not None:
1147 # Do the commit to save the changes.1155 txn.commit()
1148 ztm.commit()
11491156
1150 return num_blocked1157 return num_blocked
11511158
1159 def _cleanUpObsoleteEntries(self, store):
1160 """Delete obsolete queue entries.
1161
1162 :param store: The Store to delete from.
1163 :return: Number of entries deleted.
1164 """
1165 now = datetime.datetime.now(pytz.UTC)
1166 deletion_clauses = []
1167 for status, gc_age in entry_gc_age.iteritems():
1168 cutoff = now - gc_age
1169 deletion_clauses.append(And(
1170 TranslationImportQueueEntry.status == status,
1171 TranslationImportQueueEntry.date_status_changed < cutoff))
1172
1173 entries = store.find(
1174 TranslationImportQueueEntry, Or(*deletion_clauses))
1175
1176 return entries.remove()
1177
1178 def _cleanUpInactiveProductEntries(self, store):
1179 """Delete queue entries for deactivated `Product`s.
1180
1181 :param store: The Store to delete from.
1182 :return: Number of entries deleted.
1183 """
1184 # XXX JeroenVermeulen 2009-09-18 bug=271938: Stormify this once
1185 # the Storm remove() syntax starts working properly for joins.
1186 cur = cursor()
1187 cur.execute("""
1188 DELETE FROM TranslationImportQueueEntry AS Entry
1189 USING ProductSeries, Product
1190 WHERE
1191 ProductSeries.id = Entry.productseries AND
1192 Product.id = ProductSeries.product AND
1193 Product.active IS FALSE
1194 """)
1195 return cur.rowcount
1196
1197 def _cleanUpObsoleteDistroEntries(self, store):
1198 """Delete some queue entries for obsolete `DistroSeries`.
1199
1200 :param store: The Store to delete from.
1201 :return: Number of entries deleted.
1202 """
1203 # XXX JeroenVermeulen 2009-09-18 bug=271938,432484: Stormify
1204 # this once Storm's remove() supports joins and slices.
1205 cur = cursor()
1206 cur.execute("""
1207 DELETE FROM TranslationImportQueueEntry
1208 WHERE id IN (
1209 SELECT Entry.id
1210 FROM TranslationImportQueueEntry Entry
1211 JOIN DistroSeries ON
1212 DistroSeries.id = Entry.distroseries
1213 JOIN Distribution ON
1214 Distribution.id = DistroSeries.distribution
1215 WHERE DistroSeries.releasestatus = %s
1216 LIMIT 100)
1217 """ % quote(DistroSeriesStatus.OBSOLETE))
1218 return cur.rowcount
1219
1152 def cleanUpQueue(self):1220 def cleanUpQueue(self):
1153 """See ITranslationImportQueue."""1221 """See `ITranslationImportQueue`."""
1154 cur = cursor()1222 store = IMasterStore(TranslationImportQueueEntry)
11551223
1156 # Delete outdated DELETED and IMPORTED entries.1224 return (
1157 delta = datetime.timedelta(DAYS_TO_KEEP)1225 self._cleanUpObsoleteEntries(store) +
1158 last_date = datetime.datetime.utcnow() - delta1226 self._cleanUpInactiveProductEntries(store) +
1159 cur.execute("""1227 self._cleanUpObsoleteDistroEntries(store))
1160 DELETE FROM TranslationImportQueueEntry
1161 WHERE
1162 (status = %s OR status = %s) AND date_status_changed < %s
1163 """ % sqlvalues(RosettaImportStatus.DELETED.value,
1164 RosettaImportStatus.IMPORTED.value,
1165 last_date))
1166 n_entries = cur.rowcount
1167
1168 # Delete entries belonging to inactive product series.
1169 cur.execute("""
1170 DELETE FROM TranslationImportQueueEntry AS entry
1171 USING ProductSeries AS series, Product AS product
1172 WHERE
1173 entry.productseries = series.id AND
1174 series.product = product.id AND
1175 product.active IS FALSE
1176 """)
1177 n_entries += cur.rowcount
1178
1179 return n_entries
11801228
1181 def remove(self, entry):1229 def remove(self, entry):
1182 """See ITranslationImportQueue."""1230 """See ITranslationImportQueue."""
11831231
=== added file 'lib/lp/translations/scripts/import_queue_gardener.py'
--- lib/lp/translations/scripts/import_queue_gardener.py 1970-01-01 00:00:00 +0000
+++ lib/lp/translations/scripts/import_queue_gardener.py 2009-09-26 06:25:27 +0000
@@ -0,0 +1,50 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Translations auto-approval script."""
5
6__metaclass__ = type
7
8__all__ = [
9 'ImportQueueGardener',
10 ]
11
12from zope.component import getUtility
13
14from lp.services.scripts.base import LaunchpadCronScript
15from lp.translations.interfaces.translationimportqueue import (
16 ITranslationImportQueue)
17
18
19class ImportQueueGardener(LaunchpadCronScript):
20 """Automated gardening for the Translations import queue."""
21 def main(self):
22 """Manage import queue.
23
24 Approve uploads that can be approved automatically.
25 Garbage-collect ones that are no longer needed. Block
26 translations on the queue for templates that are blocked.
27 """
28 self.logger.debug("Starting gardening of translation imports")
29
30 translation_import_queue = getUtility(ITranslationImportQueue)
31
32 if translation_import_queue.executeOptimisticApprovals(self.txn):
33 self.logger.info(
34 'The automatic approval system approved some entries.')
35
36 removed_entries = translation_import_queue.cleanUpQueue()
37 if removed_entries > 0:
38 self.logger.info('Removed %d entries from the queue.' %
39 removed_entries)
40
41 if self.txn:
42 self.txn.commit()
43
44 blocked_entries = (
45 translation_import_queue.executeOptimisticBlock(self.txn))
46 if blocked_entries > 0:
47 self.logger.info('Blocked %d entries from the queue.' %
48 blocked_entries)
49
50 self.logger.debug("Completed gardening of translation imports.")
051
=== modified file 'lib/lp/translations/scripts/po_import.py'
--- lib/lp/translations/scripts/po_import.py 2009-09-03 09:10:49 +0000
+++ lib/lp/translations/scripts/po_import.py 2009-09-26 06:25:27 +0000
@@ -6,6 +6,10 @@
6__metaclass__ = type6__metaclass__ = type
77
88
9__all__ = [
10 'ImportProcess',
11 ]
12
9import time13import time
1014
11from zope.component import getUtility15from zope.component import getUtility
@@ -191,58 +195,3 @@
191 self.logger.info("Import requests completed.")195 self.logger.info("Import requests completed.")
192 else:196 else:
193 self.logger.info("Used up available time.")197 self.logger.info("Used up available time.")
194
195
196class AutoApproveProcess:
197 """Attempt to approve some PO/POT imports without human intervention."""
198 def __init__(self, ztm, logger):
199 self.ztm = ztm
200 self.logger = logger
201
202 def run(self):
203 """Attempt to approve requests without human intervention.
204
205 Look for entries in translation_import_queue that look like they can
206 be approved automatically.
207
208 Also, detect requests that should be blocked, and block them in their
209 entirety (with all their .pot and .po files); and purges completed or
210 removed entries from the queue.
211 """
212
213 translation_import_queue = getUtility(ITranslationImportQueue)
214
215 # There may be corner cases where an 'optimistic approval' could
216 # import a .po file to the wrong IPOFile (but the right language).
217 # The savings justify that risk. The problem can only occur where,
218 # for a given productseries/sourcepackage, we have two potemplates in
219 # the same directory, each with its own set of .po files, and for some
220 # reason one of the .pot files has not been added to the queue. Then
221 # we would import both sets of .po files to that template. This is
222 # not a big issue because the two templates will rarely share an
223 # identical msgid, and especially because it's not a very common
224 # layout in the free software world.
225 if translation_import_queue.executeOptimisticApprovals(self.ztm):
226 self.logger.info(
227 'The automatic approval system approved some entries.')
228
229 removed_entries = translation_import_queue.cleanUpQueue()
230 if removed_entries > 0:
231 self.logger.info('Removed %d entries from the queue.' %
232 removed_entries)
233 self.ztm.commit()
234 self.ztm.begin()
235
236 # We need to block entries automatically to save Rosetta experts some
237 # work when a complete set of .po files and a .pot file should not be
238 # imported into the system. We have the same corner case as with the
239 # previous approval method, but in this case it's a matter of changing
240 # the status back from "blocked" to "needs review," or approving it
241 # directly so no data will be lost and a lot of work is saved.
242 blocked_entries = (
243 translation_import_queue.executeOptimisticBlock(self.ztm))
244 if blocked_entries > 0:
245 self.logger.info('Blocked %d entries from the queue.' %
246 blocked_entries)
247 self.ztm.commit()
248
249198
=== modified file 'lib/lp/translations/tests/test_autoapproval.py'
--- lib/lp/translations/tests/test_autoapproval.py 2009-09-24 15:02:58 +0000
+++ lib/lp/translations/tests/test_autoapproval.py 2009-09-26 06:25:27 +0000
@@ -8,8 +8,14 @@
8through the possibilities should go here.8through the possibilities should go here.
9"""9"""
1010
11from datetime import datetime, timedelta
12from pytz import UTC
13import transaction
11import unittest14import unittest
1215
16from canonical.launchpad.interfaces.lpstorm import IMasterStore
17
18from lp.registry.interfaces.distroseries import DistroSeriesStatus
13from lp.registry.model.distribution import Distribution19from lp.registry.model.distribution import Distribution
14from lp.registry.model.sourcepackagename import (20from lp.registry.model.sourcepackagename import (
15 SourcePackageName,21 SourcePackageName,
@@ -20,7 +26,7 @@
20 POTemplateSet,26 POTemplateSet,
21 POTemplateSubset)27 POTemplateSubset)
22from lp.translations.model.translationimportqueue import (28from lp.translations.model.translationimportqueue import (
23 TranslationImportQueue)29 TranslationImportQueue, TranslationImportQueueEntry)
24from lp.translations.interfaces.customlanguagecode import ICustomLanguageCode30from lp.translations.interfaces.customlanguagecode import ICustomLanguageCode
25from lp.translations.interfaces.translationimportqueue import (31from lp.translations.interfaces.translationimportqueue import (
26 RosettaImportStatus)32 RosettaImportStatus)
@@ -30,6 +36,12 @@
30from canonical.testing import LaunchpadZopelessLayer36from canonical.testing import LaunchpadZopelessLayer
3137
3238
39def become_the_gardener(layer):
40 """Switch to the translations import queue gardener database role."""
41 transaction.commit()
42 layer.switchDbUser('translations_import_queue_gardener')
43
44
33class TestCustomLanguageCode(unittest.TestCase):45class TestCustomLanguageCode(unittest.TestCase):
34 """Unit tests for `CustomLanguageCode`."""46 """Unit tests for `CustomLanguageCode`."""
3547
@@ -166,6 +178,7 @@
166 # Of course matching will work without custom language codes.178 # Of course matching will work without custom language codes.
167 tr_file = self._makePOFile('tr')179 tr_file = self._makePOFile('tr')
168 entry = self._makeQueueEntry('tr')180 entry = self._makeQueueEntry('tr')
181 become_the_gardener(self.layer)
169 self.assertEqual(entry.getGuessedPOFile(), tr_file)182 self.assertEqual(entry.getGuessedPOFile(), tr_file)
170183
171 def test_CustomLanguageCodeEnablesMatch(self):184 def test_CustomLanguageCodeEnablesMatch(self):
@@ -177,6 +190,7 @@
177190
178 self._setCustomLanguageCode('fy_NL', 'fy')191 self._setCustomLanguageCode('fy_NL', 'fy')
179192
193 become_the_gardener(self.layer)
180 self.assertEqual(entry.getGuessedPOFile(), fy_file)194 self.assertEqual(entry.getGuessedPOFile(), fy_file)
181195
182 def test_CustomLanguageCodeParsesBogusLanguage(self):196 def test_CustomLanguageCodeParsesBogusLanguage(self):
@@ -187,6 +201,7 @@
187201
188 self._setCustomLanguageCode('flemish', 'nl')202 self._setCustomLanguageCode('flemish', 'nl')
189203
204 become_the_gardener(self.layer)
190 nl_file = entry.getGuessedPOFile()205 nl_file = entry.getGuessedPOFile()
191 self.assertEqual(nl_file.language.code, 'nl')206 self.assertEqual(nl_file.language.code, 'nl')
192207
@@ -199,6 +214,7 @@
199214
200 self._setCustomLanguageCode('sv', None)215 self._setCustomLanguageCode('sv', None)
201216
217 become_the_gardener(self.layer)
202 self.assertEqual(entry.getGuessedPOFile(), None)218 self.assertEqual(entry.getGuessedPOFile(), None)
203 self.assertEqual(entry.status, RosettaImportStatus.DELETED)219 self.assertEqual(entry.status, RosettaImportStatus.DELETED)
204220
@@ -211,6 +227,7 @@
211227
212 self._setCustomLanguageCode('elx', 'el')228 self._setCustomLanguageCode('elx', 'el')
213229
230 become_the_gardener(self.layer)
214 el_file = entry.getGuessedPOFile()231 el_file = entry.getGuessedPOFile()
215 self.failIfEqual(el_file, elx_file)232 self.failIfEqual(el_file, elx_file)
216 self.assertEqual(el_file.language.code, 'el')233 self.assertEqual(el_file.language.code, 'el')
@@ -225,6 +242,7 @@
225242
226 self._setCustomLanguageCode('nb', 'nn')243 self._setCustomLanguageCode('nb', 'nn')
227244
245 become_the_gardener(self.layer)
228 self.assertEqual(entry.getGuessedPOFile(), nn_file)246 self.assertEqual(entry.getGuessedPOFile(), nn_file)
229247
230 def test_CustomLanguageCodeReplacesMatch(self):248 def test_CustomLanguageCodeReplacesMatch(self):
@@ -237,6 +255,7 @@
237 self._setCustomLanguageCode('pt', None)255 self._setCustomLanguageCode('pt', None)
238 self._setCustomLanguageCode('pt_PT', 'pt')256 self._setCustomLanguageCode('pt_PT', 'pt')
239257
258 become_the_gardener(self.layer)
240 self.assertEqual(pt_entry.getGuessedPOFile(), None)259 self.assertEqual(pt_entry.getGuessedPOFile(), None)
241 self.assertEqual(pt_PT_entry.getGuessedPOFile(), pt_file)260 self.assertEqual(pt_PT_entry.getGuessedPOFile(), pt_file)
242261
@@ -250,6 +269,7 @@
250 self._setCustomLanguageCode('zh_CN', 'zh_TW')269 self._setCustomLanguageCode('zh_CN', 'zh_TW')
251 self._setCustomLanguageCode('zh_TW', 'zh_CN')270 self._setCustomLanguageCode('zh_TW', 'zh_CN')
252271
272 become_the_gardener(self.layer)
253 self.assertEqual(zh_CN_entry.getGuessedPOFile(), zh_TW_file)273 self.assertEqual(zh_CN_entry.getGuessedPOFile(), zh_TW_file)
254 self.assertEqual(zh_TW_entry.getGuessedPOFile(), zh_CN_file)274 self.assertEqual(zh_TW_entry.getGuessedPOFile(), zh_CN_file)
255275
@@ -294,6 +314,7 @@
294 # When multiple templates match for a product series,314 # When multiple templates match for a product series,
295 # getPOTemplateByPathAndOrigin returns none.315 # getPOTemplateByPathAndOrigin returns none.
296 self._setUpProduct()316 self._setUpProduct()
317 become_the_gardener(self.layer)
297 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(318 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(
298 'test.pot', productseries=self.productseries)319 'test.pot', productseries=self.productseries)
299 self.assertEqual(None, guessed_template)320 self.assertEqual(None, guessed_template)
@@ -302,6 +323,7 @@
302 # When multiple templates match on sourcepackagename,323 # When multiple templates match on sourcepackagename,
303 # getPOTemplateByPathAndOrigin returns none.324 # getPOTemplateByPathAndOrigin returns none.
304 self._setUpDistro()325 self._setUpDistro()
326 become_the_gardener(self.layer)
305 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(327 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(
306 'test.pot', sourcepackagename=self.packagename)328 'test.pot', sourcepackagename=self.packagename)
307 self.assertEqual(None, guessed_template)329 self.assertEqual(None, guessed_template)
@@ -310,6 +332,7 @@
310 # When multiple templates match on from_sourcepackagename,332 # When multiple templates match on from_sourcepackagename,
311 # getPOTemplateByPathAndOrigin returns none.333 # getPOTemplateByPathAndOrigin returns none.
312 self._setUpDistro()334 self._setUpDistro()
335 become_the_gardener(self.layer)
313 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(336 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(
314 'test.pot', sourcepackagename=self.from_packagename)337 'test.pot', sourcepackagename=self.from_packagename)
315 self.assertEqual(None, guessed_template)338 self.assertEqual(None, guessed_template)
@@ -343,6 +366,7 @@
343 self.distrotemplate1.sourcepackagename = match_package366 self.distrotemplate1.sourcepackagename = match_package
344 self.distrotemplate2.from_sourcepackagename = match_package367 self.distrotemplate2.from_sourcepackagename = match_package
345368
369 become_the_gardener(self.layer)
346 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(370 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(
347 'test.pot', sourcepackagename=match_package)371 'test.pot', sourcepackagename=match_package)
348 self.assertEqual(self.distrotemplate2, guessed_template)372 self.assertEqual(self.distrotemplate2, guessed_template)
@@ -353,6 +377,7 @@
353 self._setUpProduct()377 self._setUpProduct()
354 self.producttemplate1.iscurrent = False378 self.producttemplate1.iscurrent = False
355 self.producttemplate2.iscurrent = True379 self.producttemplate2.iscurrent = True
380 become_the_gardener(self.layer)
356 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(381 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(
357 'test.pot', productseries=self.productseries)382 'test.pot', productseries=self.productseries)
358 self.assertEqual(guessed_template, self.producttemplate2)383 self.assertEqual(guessed_template, self.producttemplate2)
@@ -362,6 +387,7 @@
362 self._setUpProduct()387 self._setUpProduct()
363 self.producttemplate1.iscurrent = False388 self.producttemplate1.iscurrent = False
364 self.producttemplate2.iscurrent = False389 self.producttemplate2.iscurrent = False
390 become_the_gardener(self.layer)
365 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(391 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(
366 'test.pot', productseries=self.productseries)392 'test.pot', productseries=self.productseries)
367 self.assertEqual(guessed_template, None)393 self.assertEqual(guessed_template, None)
@@ -375,6 +401,7 @@
375 self.distrotemplate2.iscurrent = True401 self.distrotemplate2.iscurrent = True
376 self.distrotemplate1.from_sourcepackagename = None402 self.distrotemplate1.from_sourcepackagename = None
377 self.distrotemplate2.from_sourcepackagename = None403 self.distrotemplate2.from_sourcepackagename = None
404 become_the_gardener(self.layer)
378 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(405 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(
379 'test.pot', distroseries=self.distroseries,406 'test.pot', distroseries=self.distroseries,
380 sourcepackagename=self.packagename)407 sourcepackagename=self.packagename)
@@ -387,6 +414,7 @@
387 self.distrotemplate2.iscurrent = False414 self.distrotemplate2.iscurrent = False
388 self.distrotemplate1.from_sourcepackagename = None415 self.distrotemplate1.from_sourcepackagename = None
389 self.distrotemplate2.from_sourcepackagename = None416 self.distrotemplate2.from_sourcepackagename = None
417 become_the_gardener(self.layer)
390 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(418 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(
391 'test.pot', distroseries=self.distroseries,419 'test.pot', distroseries=self.distroseries,
392 sourcepackagename=self.packagename)420 sourcepackagename=self.packagename)
@@ -401,6 +429,7 @@
401 self.distrotemplate2.iscurrent = True429 self.distrotemplate2.iscurrent = True
402 self.distrotemplate1.from_sourcepackagename = self.from_packagename430 self.distrotemplate1.from_sourcepackagename = self.from_packagename
403 self.distrotemplate2.from_sourcepackagename = self.from_packagename431 self.distrotemplate2.from_sourcepackagename = self.from_packagename
432 become_the_gardener(self.layer)
404 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(433 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(
405 'test.pot', distroseries=self.distroseries,434 'test.pot', distroseries=self.distroseries,
406 sourcepackagename=self.from_packagename)435 sourcepackagename=self.from_packagename)
@@ -414,6 +443,7 @@
414 self.distrotemplate2.iscurrent = False443 self.distrotemplate2.iscurrent = False
415 self.distrotemplate1.from_sourcepackagename = self.from_packagename444 self.distrotemplate1.from_sourcepackagename = self.from_packagename
416 self.distrotemplate2.from_sourcepackagename = self.from_packagename445 self.distrotemplate2.from_sourcepackagename = self.from_packagename
446 become_the_gardener(self.layer)
417 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(447 guessed_template = self.templateset.getPOTemplateByPathAndOrigin(
418 'test.pot', distroseries=self.distroseries,448 'test.pot', distroseries=self.distroseries,
419 sourcepackagename=self.from_packagename)449 sourcepackagename=self.from_packagename)
@@ -424,6 +454,7 @@
424 # translation domain.454 # translation domain.
425 self._setUpDistro()455 self._setUpDistro()
426 subset = POTemplateSubset(distroseries=self.distroseries)456 subset = POTemplateSubset(distroseries=self.distroseries)
457 become_the_gardener(self.layer)
427 potemplate = subset.getPOTemplateByTranslationDomain('test1')458 potemplate = subset.getPOTemplateByTranslationDomain('test1')
428 self.assertEqual(potemplate, self.distrotemplate1)459 self.assertEqual(potemplate, self.distrotemplate1)
429460
@@ -431,6 +462,7 @@
431 # Test getPOTemplateByTranslationDomain for the zero-match case.462 # Test getPOTemplateByTranslationDomain for the zero-match case.
432 self._setUpDistro()463 self._setUpDistro()
433 subset = POTemplateSubset(distroseries=self.distroseries)464 subset = POTemplateSubset(distroseries=self.distroseries)
465 become_the_gardener(self.layer)
434 potemplate = subset.getPOTemplateByTranslationDomain('notesthere')466 potemplate = subset.getPOTemplateByTranslationDomain('notesthere')
435 self.assertEqual(potemplate, None)467 self.assertEqual(potemplate, None)
436468
@@ -445,6 +477,7 @@
445 clashing_template = other_subset.new(477 clashing_template = other_subset.new(
446 'test3', 'test1', 'test3.pot', self.distro.owner)478 'test3', 'test1', 'test3.pot', self.distro.owner)
447 distro_subset = POTemplateSubset(distroseries=self.distroseries)479 distro_subset = POTemplateSubset(distroseries=self.distroseries)
480 become_the_gardener(self.layer)
448 potemplate = distro_subset.getPOTemplateByTranslationDomain('test1')481 potemplate = distro_subset.getPOTemplateByTranslationDomain('test1')
449 self.assertEqual(potemplate, None)482 self.assertEqual(potemplate, None)
450483
@@ -474,6 +507,7 @@
474 'program/nl.po', 'other contents', False, template.owner,507 'program/nl.po', 'other contents', False, template.owner,
475 productseries=template.productseries, potemplate=template)508 productseries=template.productseries, potemplate=template)
476509
510 become_the_gardener(self.layer)
477 entry1.getGuessedPOFile()511 entry1.getGuessedPOFile()
478512
479 self.assertEqual(entry1.potemplate, None)513 self.assertEqual(entry1.potemplate, None)
@@ -531,6 +565,7 @@
531 poname, self.pocontents, False, self.distroseries.owner,565 poname, self.pocontents, False, self.distroseries.owner,
532 sourcepackagename=self.kde_i18n_ca,566 sourcepackagename=self.kde_i18n_ca,
533 distroseries=self.distroseries)567 distroseries=self.distroseries)
568 become_the_gardener(self.layer)
534 pofile = entry.getGuessedPOFile()569 pofile = entry.getGuessedPOFile()
535 self.assertEqual(pofile, self.pofile_ca)570 self.assertEqual(pofile, self.pofile_ca)
536571
@@ -542,6 +577,7 @@
542 poname, self.pocontents, False, self.distroseries.owner,577 poname, self.pocontents, False, self.distroseries.owner,
543 sourcepackagename=self.kde_l10n_nl,578 sourcepackagename=self.kde_l10n_nl,
544 distroseries=self.distroseries)579 distroseries=self.distroseries)
580 become_the_gardener(self.layer)
545 pofile = entry.getGuessedPOFile()581 pofile = entry.getGuessedPOFile()
546 self.assertEqual(pofile, self.pofile_nl)582 self.assertEqual(pofile, self.pofile_nl)
547583
@@ -569,6 +605,7 @@
569 entry = self.queue.addOrUpdateEntry(605 entry = self.queue.addOrUpdateEntry(
570 'nl.po', '# ...', False, template.owner, productseries=trunk)606 'nl.po', '# ...', False, template.owner, productseries=trunk)
571607
608 become_the_gardener(self.layer)
572 pofile = entry._get_pofile_from_language('nl', 'domain')609 pofile = entry._get_pofile_from_language('nl', 'domain')
573 self.assertNotEqual(None, pofile)610 self.assertNotEqual(None, pofile)
574611
@@ -586,9 +623,124 @@
586 entry = self.queue.addOrUpdateEntry(623 entry = self.queue.addOrUpdateEntry(
587 'nl.po', '# ...', False, template.owner, productseries=trunk)624 'nl.po', '# ...', False, template.owner, productseries=trunk)
588625
626 become_the_gardener(self.layer)
589 pofile = entry._get_pofile_from_language('nl', 'domain')627 pofile = entry._get_pofile_from_language('nl', 'domain')
590 self.assertEqual(None, pofile)628 self.assertEqual(None, pofile)
591629
592630
631class TestCleanup(TestCaseWithFactory):
632 """Test `TranslationImportQueueEntry` garbage collection."""
633
634 layer = LaunchpadZopelessLayer
635
636 def setUp(self):
637 super(TestCleanup, self).setUp()
638 self.queue = TranslationImportQueue()
639 self.store = IMasterStore(TranslationImportQueueEntry)
640
641 def _makeProductEntry(self):
642 """Simulate upload for a product."""
643 product = self.factory.makeProduct()
644 product.official_rosetta = True
645 trunk = product.getSeries('trunk')
646 return self.queue.addOrUpdateEntry(
647 'foo.pot', '# contents', False, product.owner,
648 productseries=trunk)
649
650 def _makeDistroEntry(self):
651 """Simulate upload for a distribution package."""
652 package = self.factory.makeSourcePackage()
653 owner = package.distroseries.owner
654 return self.queue.addOrUpdateEntry(
655 'bar.pot', '# contents', False, owner,
656 sourcepackagename=package.sourcepackagename,
657 distroseries=package.distroseries)
658
659 def _exists(self, entry_id):
660 """Is the entry with the given id still on the queue?"""
661 entry = self.store.find(
662 TranslationImportQueueEntry,
663 TranslationImportQueueEntry.id == entry_id).any()
664 return entry is not None
665
666 def _setStatus(self, entry, status, when=None):
667 """Simulate status on queue entry having been set at a given time."""
668 entry.setStatus(status)
669 if when is not None:
670 entry.date_status_changed = when
671 entry.syncUpdate()
672
673 def test_cleanUpObsoleteEntries_unaffected_statuses(self):
674 # _cleanUpObsoleteEntries leaves entries in non-terminal states
675 # (Needs Review, Approved, Blocked) alone no matter how old they
676 # are.
677 one_year_ago = datetime.now(UTC) - timedelta(days=366)
678 entry = self._makeProductEntry()
679 entry_id = entry.id
680
681 self._setStatus(entry, RosettaImportStatus.APPROVED, one_year_ago)
682 self.queue._cleanUpObsoleteEntries(self.store)
683 self.assertTrue(self._exists(entry_id))
684
685 self._setStatus(entry, RosettaImportStatus.BLOCKED, one_year_ago)
686 self.queue._cleanUpObsoleteEntries(self.store)
687 self.assertTrue(self._exists(entry_id))
688
689 self._setStatus(entry, RosettaImportStatus.NEEDS_REVIEW, one_year_ago)
690 become_the_gardener(self.layer)
691 self.queue._cleanUpObsoleteEntries(self.store)
692 self.assertTrue(self._exists(entry_id))
693
694 def test_cleanUpObsoleteEntries_affected_statuses(self):
695 # _cleanUpObsoleteEntries deletes entries in terminal states
696 # (Imported, Failed, Deleted) after a few days. The exact
697 # period depends on the state.
698 entry = self._makeProductEntry()
699 self._setStatus(entry, RosettaImportStatus.IMPORTED, None)
700 entry_id = entry.id
701
702 self.queue._cleanUpObsoleteEntries(self.store)
703 self.assertTrue(self._exists(entry_id))
704
705 entry.date_status_changed -= timedelta(days=7)
706 entry.syncUpdate()
707
708 become_the_gardener(self.layer)
709 self.queue._cleanUpObsoleteEntries(self.store)
710 self.assertFalse(self._exists(entry_id))
711
712 def test_cleanUpInactiveProductEntries(self):
713 # After a product is deactivated, _cleanUpInactiveProductEntries
714 # will clean up any entries it may have on the queue.
715 entry = self._makeProductEntry()
716 entry_id = entry.id
717
718 self.queue._cleanUpInactiveProductEntries(self.store)
719 self.assertTrue(self._exists(entry_id))
720
721 entry.productseries.product.active = False
722 entry.productseries.product.syncUpdate()
723
724 become_the_gardener(self.layer)
725 self.queue._cleanUpInactiveProductEntries(self.store)
726 self.assertFalse(self._exists(entry_id))
727
728 def test_cleanUpObsoleteDistroEntries(self):
729 # _cleanUpObsoleteDistroEntries cleans up entries for
730 # distroseries that are in the Obsolete state.
731 entry = self._makeDistroEntry()
732 entry_id = entry.id
733
734 self.queue._cleanUpObsoleteDistroEntries(self.store)
735 self.assertTrue(self._exists(entry_id))
736
737 entry.distroseries.status = DistroSeriesStatus.OBSOLETE
738 entry.distroseries.syncUpdate()
739
740 become_the_gardener(self.layer)
741 self.queue._cleanUpObsoleteDistroEntries(self.store)
742 self.assertFalse(self._exists(entry_id))
743
744
593def test_suite():745def test_suite():
594 return unittest.TestLoader().loadTestsFromName(__name__)746 return unittest.TestLoader().loadTestsFromName(__name__)