Merge lp:~jtv/launchpad/bug-994650-scrub-in-batches into lp:launchpad

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Jeroen T. Vermeulen
Approved revision: no longer in the source branch.
Merge reported by: Jeroen T. Vermeulen
Merged at revision: not available
Proposed branch: lp:~jtv/launchpad/bug-994650-scrub-in-batches
Merge into: lp:launchpad
Prerequisite: lp:~jtv/launchpad/bug-994650-scrub-faster
Diff against target: 609 lines (+571/-0)
4 files modified
database/schema/security.cfg (+1/-0)
lib/lp/scripts/garbo.py (+4/-0)
lib/lp/translations/scripts/scrub_pofiletranslator.py (+278/-0)
lib/lp/translations/scripts/tests/test_scrub_pofiletranslator.py (+288/-0)
To merge this branch: bzr merge lp:~jtv/launchpad/bug-994650-scrub-in-batches
Reviewer Review Type Date Requested Status
j.c.sackett (community) Approve
Review via email: mp+105189@code.launchpad.net

Commit message

Further speed up scrubbing of POFileTranslator.

Description of the change

This is one of two further optimizations for POFileTranslator scrubbing as mentioned in the preceding MP: https://code.launchpad.net/~jtv/launchpad/bug-994650-scrub-faster

Here you see two parts of the scrubbing process separated further: finding which POFiles in a batch need their POFileTranslator entries fixed, and actually doing so. The benefit is in a new, intervening step: bulk-load all objects needed for doing this work. Disappointingly, about half of the relevant object graph is needed for log output. But we probably should have it anyway, because tweaking such processes without helpful logs can be highly demotivating.

As an added bonus, it turns out we don't really need to load full POFileTranslator records, let alone cache them across these steps. That's one big memory load off my mind. I deliberately didn't make the scrubber check and correct dates on existing POFileTranslator records; a bit of imprecision is fine.

As a next step, which should further reduce memory load as well as DB querying, we can cache sets of POTMsgSet ids per template across items in a batch. (But not outside batches, since they may change between transactions).

The tests don't go into the new components. They already cover the aggregate behaviour of fix_pofile() and the main loop; there'd be a lot of duplication and I'd hate to lose integration test coverage as a side effect of moving things into more fine-grained unit tests. Also I'm trying to correct for a past of focusing too much on fine-grained tests. But, dear reviewer, if you see anything that you do want tested then please say so.

Jeroen

To post a comment you must log in.
Revision history for this message
j.c.sackett (jcsackett) wrote :

Jeroen--

This looks good. Thanks.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'database/schema/security.cfg'
2--- database/schema/security.cfg 2012-05-09 13:50:03 +0000
3+++ database/schema/security.cfg 2012-05-24 05:14:30 +0000
4@@ -2181,6 +2181,7 @@
5 public.openidconsumerassociation = SELECT, DELETE
6 public.openidconsumernonce = SELECT, DELETE
7 public.person = SELECT, DELETE
8+public.pofiletranslator = SELECT, INSERT, UPDATE, DELETE
9 public.potranslation = SELECT, DELETE
10 public.potmsgset = SELECT, DELETE
11 public.product = SELECT
12
13=== modified file 'lib/lp/scripts/garbo.py'
14--- lib/lp/scripts/garbo.py 2012-05-09 01:35:41 +0000
15+++ lib/lp/scripts/garbo.py 2012-05-24 05:14:30 +0000
16@@ -105,6 +105,9 @@
17 from lp.translations.model.translationtemplateitem import (
18 TranslationTemplateItem,
19 )
20+from lp.translations.scripts.scrub_pofiletranslator import (
21+ ScrubPOFileTranslator,
22+ )
23
24
25 ONE_DAY_IN_SECONDS = 24 * 60 * 60
26@@ -1418,6 +1421,7 @@
27 ObsoleteBugAttachmentPruner,
28 OldTimeLimitedTokenDeleter,
29 RevisionAuthorEmailLinker,
30+ ScrubPOFileTranslator,
31 SuggestiveTemplatesCacheUpdater,
32 POTranslationPruner,
33 UnusedPOTMsgSetPruner,
34
35=== added file 'lib/lp/translations/scripts/scrub_pofiletranslator.py'
36--- lib/lp/translations/scripts/scrub_pofiletranslator.py 1970-01-01 00:00:00 +0000
37+++ lib/lp/translations/scripts/scrub_pofiletranslator.py 2012-05-24 05:14:30 +0000
38@@ -0,0 +1,278 @@
39+# Copyright 2012 Canonical Ltd. This software is licensed under the
40+# GNU Affero General Public License version 3 (see the file LICENSE).
41+
42+"""Keep `POFileTranslator` more or less consistent with the real data."""
43+
44+__metaclass__ = type
45+__all__ = [
46+ 'ScrubPOFileTranslator',
47+ ]
48+
49+from collections import namedtuple
50+
51+from storm.expr import (
52+ Coalesce,
53+ Desc,
54+ )
55+import transaction
56+
57+from lp.registry.model.distribution import Distribution
58+from lp.registry.model.distroseries import DistroSeries
59+from lp.registry.model.product import Product
60+from lp.registry.model.productseries import ProductSeries
61+from lp.services.database.bulk import (
62+ load,
63+ load_related,
64+ )
65+from lp.services.database.lpstorm import IStore
66+from lp.services.looptuner import TunableLoop
67+from lp.services.worlddata.model.language import Language
68+from lp.translations.model.pofile import POFile
69+from lp.translations.model.pofiletranslator import POFileTranslator
70+from lp.translations.model.potemplate import POTemplate
71+from lp.translations.model.translationmessage import TranslationMessage
72+from lp.translations.model.translationtemplateitem import (
73+ TranslationTemplateItem,
74+ )
75+
76+
77+def get_pofile_ids():
78+ """Retrieve ids of POFiles to scrub.
79+
80+ The result's ordering is aimed at maximizing cache effectiveness:
81+ by POTemplate name for locality of shared POTMsgSets, and by language
82+ for locality of shared TranslationMessages.
83+ """
84+ store = IStore(POFile)
85+ query = store.find(
86+ POFile.id,
87+ POFile.potemplateID == POTemplate.id,
88+ POTemplate.iscurrent == True)
89+ return query.order_by(POTemplate.name, POFile.languageID)
90+
91+
92+def summarize_pofiles(pofile_ids):
93+ """Retrieve relevant parts of `POFile`s with given ids.
94+
95+ This gets just enough information to determine whether any of the
96+ `POFile`s need their `POFileTranslator` records fixed.
97+
98+ :param pofile_ids: Iterable of `POFile` ids.
99+ :return: Dict mapping each id in `pofile_ids` to a duple of
100+ `POTemplate` id and `Language` id for the associated `POFile`.
101+ """
102+ store = IStore(POFile)
103+ rows = store.find(
104+ (POFile.id, POFile.potemplateID, POFile.languageID),
105+ POFile.id.is_in(pofile_ids))
106+ return dict((row[0], row[1:]) for row in rows)
107+
108+
109+def get_potmsgset_ids(potemplate_id):
110+ """Get the ids for each current `POTMsgSet` in a `POTemplate`."""
111+ store = IStore(POTemplate)
112+ return store.find(
113+ TranslationTemplateItem.potmsgsetID,
114+ TranslationTemplateItem.potemplateID == potemplate_id,
115+ TranslationTemplateItem.sequence > 0)
116+
117+
118+def summarize_contributors(potemplate_id, language_id, potmsgset_ids):
119+ """Return the set of ids of persons who contributed to a `POFile`.
120+
121+ This is a limited version of `get_contributions` that is easier to
122+ compute.
123+ """
124+ store = IStore(POFile)
125+ contribs = store.find(
126+ TranslationMessage.submitterID,
127+ TranslationMessage.potmsgsetID.is_in(potmsgset_ids),
128+ TranslationMessage.languageID == language_id,
129+ TranslationMessage.msgstr0 != None,
130+ Coalesce(TranslationMessage.potemplateID, potemplate_id) ==
131+ potemplate_id)
132+ contribs.config(distinct=True)
133+ return set(contribs)
134+
135+
136+def get_contributions(pofile, potmsgset_ids):
137+ """Map all users' most recent contributions to a `POFile`.
138+
139+ Returns a dict mapping `Person` id to the creation time of their most
140+ recent `TranslationMessage` in `POFile`.
141+
142+ This leaves some small room for error: a contribution that is masked by
143+ a diverged entry in this POFile will nevertheless produce a
144+ POFileTranslator record. Fixing that would complicate the work more than
145+ it is probably worth.
146+
147+ :param pofile: The `POFile` to find contributions for.
148+ :param potmsgset_ids: The ids of the `POTMsgSet`s to look for, as returned
149+ by `get_potmsgset_ids`.
150+ """
151+ store = IStore(pofile)
152+ language_id = pofile.language.id
153+ template_id = pofile.potemplate.id
154+ contribs = store.find(
155+ (TranslationMessage.submitterID, TranslationMessage.date_created),
156+ TranslationMessage.potmsgsetID.is_in(potmsgset_ids),
157+ TranslationMessage.languageID == language_id,
158+ TranslationMessage.msgstr0 != None,
159+ Coalesce(TranslationMessage.potemplateID, template_id) ==
160+ template_id)
161+ contribs = contribs.config(distinct=(TranslationMessage.submitterID,))
162+ contribs = contribs.order_by(
163+ TranslationMessage.submitterID, Desc(TranslationMessage.date_created))
164+ return dict(contribs)
165+
166+
167+def get_pofiletranslators(pofile_id):
168+ """Get `Person` ids from `POFileTranslator` entries for a `POFile`.
169+
170+ Returns a `set` of `Person` ids.
171+ """
172+ store = IStore(POFileTranslator)
173+ return set(store.find(
174+ POFileTranslator.personID,
175+ POFileTranslator.pofileID == pofile_id))
176+
177+
178+def remove_pofiletranslators(logger, pofile, person_ids):
179+ """Delete `POFileTranslator` records."""
180+ logger.debug(
181+ "Removing %d POFileTranslator(s) for %s.",
182+ len(person_ids), pofile.title)
183+ store = IStore(pofile)
184+ pofts = store.find(
185+ POFileTranslator,
186+ POFileTranslator.pofileID == pofile.id,
187+ POFileTranslator.personID.is_in(person_ids))
188+ pofts.remove()
189+
190+
191+def remove_unwarranted_pofiletranslators(logger, pofile, pofts, contribs):
192+ """Delete `POFileTranslator` records that shouldn't be there."""
193+ excess = pofts - set(contribs)
194+ if len(excess) > 0:
195+ remove_pofiletranslators(logger, pofile, excess)
196+
197+
198+def create_missing_pofiletranslators(logger, pofile, pofts, contribs):
199+ """Create `POFileTranslator` records that were missing."""
200+ shortage = set(contribs) - pofts
201+ if len(shortage) == 0:
202+ return
203+ logger.debug(
204+ "Adding %d POFileTranslator(s) for %s.",
205+ len(shortage), pofile.title)
206+ store = IStore(pofile)
207+ for missing_contributor in shortage:
208+ store.add(POFileTranslator(
209+ pofile=pofile, personID=missing_contributor,
210+ date_last_touched=contribs[missing_contributor]))
211+
212+
213+def fix_pofile(logger, pofile, potmsgset_ids, pofiletranslators):
214+ """This `POFile` needs fixing. Load its data & fix it."""
215+ contribs = get_contributions(pofile, potmsgset_ids)
216+ remove_unwarranted_pofiletranslators(
217+ logger, pofile, pofiletranslators, contribs)
218+ create_missing_pofiletranslators(
219+ logger, pofile, pofiletranslators, contribs)
220+
221+
222+def needs_fixing(template_id, language_id, potmsgset_ids, pofiletranslators):
223+ """Does the `POFile` with given details need `POFileTranslator` changes?
224+
225+ :param template_id: id of the `POTemplate` for the `POFile`.
226+ :param language_id: id of the `Language` the `POFile` translates to.
227+ :param potmsgset_ids: ids of the `POTMsgSet` items participating in the
228+ template.
229+ :param pofiletranslators: `POFileTranslator` objects for the `POFile`.
230+ :return: Bool: does the existing set of `POFileTranslator` need fixing?
231+ """
232+ contributors = summarize_contributors(
233+ template_id, language_id, potmsgset_ids)
234+ return pofiletranslators != set(contributors)
235+
236+
237+# A tuple describing a POFile that needs its POFileTranslators fixed.
238+WorkItem = namedtuple("WorkItem", [
239+ 'pofile_id',
240+ 'potmsgset_ids',
241+ 'pofiletranslators',
242+ ])
243+
244+
245+def gather_work_items(pofile_ids):
246+ """Produce `WorkItem`s for those `POFile`s that need fixing.
247+
248+ :param pofile_ids: An iterable of `POFile` ids to check.
249+ :param pofile_summaries: Dict as returned by `summarize_pofiles`.
250+ :return: A sequence of `WorkItem`s for those `POFile`s that need fixing.
251+ """
252+ pofile_summaries = summarize_pofiles(pofile_ids)
253+ work_items = []
254+ for pofile_id in pofile_ids:
255+ template_id, language_id = pofile_summaries[pofile_id]
256+ potmsgset_ids = get_potmsgset_ids(template_id)
257+ pofts = get_pofiletranslators(pofile_id)
258+ if needs_fixing(template_id, language_id, potmsgset_ids, pofts):
259+ work_items.append(WorkItem(pofile_id, potmsgset_ids, pofts))
260+
261+ return work_items
262+
263+
264+def preload_work_items(work_items):
265+ """Bulk load data that will be needed to process `work_items`.
266+
267+ :param work_items: A sequence of `WorkItem` records.
268+ :return: A dict mapping `POFile` ids from `work_items` to their
269+ respective `POFile` objects.
270+ """
271+ pofiles = load(POFile, [work_item.pofile_id for work_item in work_items])
272+ load_related(Language, pofiles, ['languageID'])
273+ templates = load_related(POTemplate, pofiles, ['potemplateID'])
274+ distroseries = load_related(DistroSeries, templates, ['distroseriesID'])
275+ load_related(Distribution, distroseries, ['distributionID'])
276+ productseries = load_related(
277+ ProductSeries, templates, ['productseriesID'])
278+ load_related(Product, productseries, ['productID'])
279+ return dict((pofile.id, pofile) for pofile in pofiles)
280+
281+
282+def process_work_items(logger, work_items, pofiles):
283+ """Fix the `POFileTranslator` records covered by `work_items`."""
284+ for work_item in work_items:
285+ pofile = pofiles[work_item.pofile_id]
286+ fix_pofile(
287+ logger, pofile, work_item.potmsgset_ids,
288+ work_item.pofiletranslators)
289+
290+
291+class ScrubPOFileTranslator(TunableLoop):
292+ """Tunable loop, meant for running from inside Garbo."""
293+
294+ maximum_chunk_size = 500
295+
296+ def __init__(self, *args, **kwargs):
297+ super(ScrubPOFileTranslator, self).__init__(*args, **kwargs)
298+ self.pofile_ids = tuple(get_pofile_ids())
299+ self.next_offset = 0
300+
301+ def __call__(self, chunk_size):
302+ """See `ITunableLoop`."""
303+ start_offset = self.next_offset
304+ self.next_offset = start_offset + int(chunk_size)
305+ batch = self.pofile_ids[start_offset:self.next_offset]
306+ if len(batch) == 0:
307+ self.next_offset = None
308+ else:
309+ work_items = gather_work_items(batch)
310+ pofiles = preload_work_items(work_items)
311+ process_work_items(self.log, work_items, pofiles)
312+ transaction.commit()
313+
314+ def isDone(self):
315+ """See `ITunableLoop`."""
316+ return self.next_offset is None
317
318=== added file 'lib/lp/translations/scripts/tests/test_scrub_pofiletranslator.py'
319--- lib/lp/translations/scripts/tests/test_scrub_pofiletranslator.py 1970-01-01 00:00:00 +0000
320+++ lib/lp/translations/scripts/tests/test_scrub_pofiletranslator.py 2012-05-24 05:14:30 +0000
321@@ -0,0 +1,288 @@
322+# Copyright 2012 Canonical Ltd. This software is licensed under the
323+# GNU Affero General Public License version 3 (see the file LICENSE).
324+
325+"""Test scrubbing of `POFileTranslator`."""
326+
327+__metaclass__ = type
328+
329+from datetime import (
330+ datetime,
331+ timedelta,
332+ )
333+
334+import pytz
335+import transaction
336+
337+from lp.services.database.constants import UTC_NOW
338+from lp.services.database.lpstorm import IStore
339+from lp.services.log.logger import DevNullLogger
340+from lp.testing import TestCaseWithFactory
341+from lp.testing.layers import ZopelessDatabaseLayer
342+from lp.translations.model.pofiletranslator import POFileTranslator
343+from lp.translations.scripts.scrub_pofiletranslator import (
344+ fix_pofile,
345+ get_contributions,
346+ get_pofile_ids,
347+ get_pofiletranslators,
348+ get_potmsgset_ids,
349+ ScrubPOFileTranslator,
350+ summarize_contributors,
351+ summarize_pofiles,
352+ )
353+
354+
355+fake_logger = DevNullLogger()
356+
357+
358+def size_distance(sequence, item1, item2):
359+ """Return the absolute distance between items in a sequence."""
360+ container = list(sequence)
361+ return abs(container.index(item2) - container.index(item1))
362+
363+
364+class TestScrubPOFileTranslator(TestCaseWithFactory):
365+
366+ layer = ZopelessDatabaseLayer
367+
368+ def query_pofiletranslator(self, pofile, person):
369+ """Query `POFileTranslator` for a specific record.
370+
371+ :return: Storm result set.
372+ """
373+ store = IStore(pofile)
374+ return store.find(POFileTranslator, pofile=pofile, person=person)
375+
376+ def make_message_with_pofiletranslator(self, pofile=None):
377+ """Create a normal `TranslationMessage` with `POFileTranslator`."""
378+ if pofile is None:
379+ pofile = self.factory.makePOFile()
380+ potmsgset = self.factory.makePOTMsgSet(
381+ potemplate=pofile.potemplate, sequence=1)
382+ # A database trigger on TranslationMessage automatically creates
383+ # a POFileTranslator record for each new TranslationMessage.
384+ return self.factory.makeSuggestion(pofile=pofile, potmsgset=potmsgset)
385+
386+ def make_message_without_pofiletranslator(self, pofile=None):
387+ """Create a `TranslationMessage` without `POFileTranslator`."""
388+ tm = self.make_message_with_pofiletranslator(pofile)
389+ IStore(pofile).flush()
390+ self.becomeDbUser('postgres')
391+ self.query_pofiletranslator(pofile, tm.submitter).remove()
392+ return tm
393+
394+ def make_pofiletranslator_without_message(self, pofile=None):
395+ """Create a `POFileTranslator` without `TranslationMessage`."""
396+ if pofile is None:
397+ pofile = self.factory.makePOFile()
398+ poft = POFileTranslator(
399+ pofile=pofile, person=self.factory.makePerson(),
400+ date_last_touched=UTC_NOW)
401+ IStore(poft.pofile).add(poft)
402+ return poft
403+
404+ def test_get_pofile_ids_gets_pofiles_for_active_templates(self):
405+ pofile = self.factory.makePOFile()
406+ self.assertIn(pofile.id, get_pofile_ids())
407+
408+ def test_get_pofile_ids_skips_inactive_templates(self):
409+ pofile = self.factory.makePOFile()
410+ pofile.potemplate.iscurrent = False
411+ self.assertNotIn(pofile.id, get_pofile_ids())
412+
413+ def test_get_pofile_ids_clusters_by_template_name(self):
414+ # POFiles for templates with the same name are bunched together
415+ # in the get_pofile_ids() output.
416+ templates = [
417+ self.factory.makePOTemplate(name='shared'),
418+ self.factory.makePOTemplate(name='other'),
419+ self.factory.makePOTemplate(name='andanother'),
420+ self.factory.makePOTemplate(
421+ name='shared', distroseries=self.factory.makeDistroSeries()),
422+ ]
423+ pofiles = [
424+ self.factory.makePOFile(potemplate=template)
425+ for template in templates]
426+ ordering = get_pofile_ids()
427+ self.assertEqual(
428+ 1, size_distance(ordering, pofiles[0].id, pofiles[-1].id))
429+
430+ def test_get_pofile_ids_clusters_by_language(self):
431+ # POFiles for sharing templates and the same language are
432+ # bunched together in the get_pofile_ids() output.
433+ templates = [
434+ self.factory.makePOTemplate(
435+ name='shared', distroseries=self.factory.makeDistroSeries())
436+ for counter in range(2)]
437+ # POFiles per language & template. We create these in a strange
438+ # way to avoid the risk of mistaking accidental orderings such
439+ # as per-id from being mistaken for the proper order.
440+ languages = ['nl', 'fr']
441+ pofiles_per_language = dict((language, []) for language in languages)
442+ for language, pofiles in pofiles_per_language.items():
443+ for template in templates:
444+ pofiles.append(
445+ self.factory.makePOFile(language, potemplate=template))
446+
447+ ordering = get_pofile_ids()
448+ for pofiles in pofiles_per_language.values():
449+ self.assertEqual(
450+ 1, size_distance(ordering, pofiles[0].id, pofiles[1].id))
451+
452+ def test_summarize_pofiles_maps_id_to_template_and_language_ids(self):
453+ pofile = self.factory.makePOFile()
454+ self.assertEqual(
455+ {pofile.id: (pofile.potemplate.id, pofile.language.id)},
456+ summarize_pofiles([pofile.id]))
457+
458+ def test_get_potmsgset_ids_returns_potmsgset_ids(self):
459+ pofile = self.factory.makePOFile()
460+ potmsgset = self.factory.makePOTMsgSet(
461+ potemplate=pofile.potemplate, sequence=1)
462+ self.assertContentEqual(
463+ [potmsgset.id], get_potmsgset_ids(pofile.potemplate.id))
464+
465+ def test_get_potmsgset_ids_ignores_inactive_messages(self):
466+ pofile = self.factory.makePOFile()
467+ self.factory.makePOTMsgSet(
468+ potemplate=pofile.potemplate, sequence=0)
469+ self.assertContentEqual([], get_potmsgset_ids(pofile.potemplate.id))
470+
471+ def test_summarize_contributors_gets_contributors(self):
472+ pofile = self.factory.makePOFile()
473+ tm = self.factory.makeSuggestion(pofile=pofile)
474+ potmsgset_ids = get_potmsgset_ids(pofile.potemplate.id)
475+ self.assertContentEqual(
476+ [tm.submitter.id],
477+ summarize_contributors(
478+ pofile.potemplate.id, pofile.language.id, potmsgset_ids))
479+
480+ def test_summarize_contributors_ignores_inactive_potmsgsets(self):
481+ pofile = self.factory.makePOFile()
482+ potmsgset = self.factory.makePOTMsgSet(
483+ potemplate=pofile.potemplate, sequence=0)
484+ self.factory.makeSuggestion(pofile=pofile, potmsgset=potmsgset)
485+ potmsgset_ids = get_potmsgset_ids(pofile.potemplate.id)
486+ self.assertContentEqual(
487+ [],
488+ summarize_contributors(
489+ pofile.potemplate.id, pofile.language.id, potmsgset_ids))
490+
491+ def test_summarize_contributors_includes_diverged_msgs_for_template(self):
492+ pofile = self.factory.makePOFile()
493+ tm = self.factory.makeSuggestion(pofile=pofile)
494+ tm.potemplate = pofile.potemplate
495+ potmsgset_ids = get_potmsgset_ids(pofile.potemplate.id)
496+ self.assertContentEqual(
497+ [tm.submitter.id],
498+ summarize_contributors(
499+ pofile.potemplate.id, pofile.language.id, potmsgset_ids))
500+
501+ def test_summarize_contributors_excludes_other_diverged_messages(self):
502+ pofile = self.factory.makePOFile()
503+ tm = self.factory.makeSuggestion(pofile=pofile)
504+ tm.potemplate = self.factory.makePOTemplate()
505+ potmsgset_ids = get_potmsgset_ids(pofile.potemplate.id)
506+ self.assertContentEqual(
507+ [],
508+ summarize_contributors(
509+ pofile.potemplate.id, pofile.language.id, potmsgset_ids))
510+
511+ def test_get_contributions_gets_contributions(self):
512+ pofile = self.factory.makePOFile()
513+ tm = self.factory.makeSuggestion(pofile=pofile)
514+ potmsgset_ids = get_potmsgset_ids(pofile.potemplate.id)
515+ self.assertEqual(
516+ {tm.submitter.id: tm.date_created},
517+ get_contributions(pofile, potmsgset_ids))
518+
519+ def test_get_contributions_uses_latest_contribution(self):
520+ pofile = self.factory.makePOFile()
521+ today = datetime.now(pytz.UTC)
522+ yesterday = today - timedelta(1, 1, 1)
523+ old_tm = self.factory.makeSuggestion(
524+ pofile=pofile, date_created=yesterday)
525+ new_tm = self.factory.makeSuggestion(
526+ translator=old_tm.submitter, pofile=pofile, date_created=today)
527+ potmsgset_ids = get_potmsgset_ids(pofile.potemplate.id)
528+ self.assertNotEqual(old_tm.date_created, new_tm.date_created)
529+ self.assertContentEqual(
530+ [new_tm.date_created],
531+ get_contributions(pofile, potmsgset_ids).values())
532+
533+ def test_get_contributions_ignores_inactive_potmsgsets(self):
534+ pofile = self.factory.makePOFile()
535+ potmsgset = self.factory.makePOTMsgSet(
536+ potemplate=pofile.potemplate, sequence=0)
537+ self.factory.makeSuggestion(pofile=pofile, potmsgset=potmsgset)
538+ potmsgset_ids = get_potmsgset_ids(pofile.potemplate.id)
539+ self.assertEqual({}, get_contributions(pofile, potmsgset_ids))
540+
541+ def test_get_contributions_includes_diverged_messages_for_template(self):
542+ pofile = self.factory.makePOFile()
543+ tm = self.factory.makeSuggestion(pofile=pofile)
544+ tm.potemplate = pofile.potemplate
545+ potmsgset_ids = get_potmsgset_ids(pofile.potemplate.id)
546+ self.assertContentEqual(
547+ [tm.submitter.id], get_contributions(pofile, potmsgset_ids))
548+
549+ def test_get_contributions_excludes_other_diverged_messages(self):
550+ pofile = self.factory.makePOFile()
551+ tm = self.factory.makeSuggestion(pofile=pofile)
552+ tm.potemplate = self.factory.makePOTemplate()
553+ potmsgset_ids = get_potmsgset_ids(pofile.potemplate.id)
554+ self.assertEqual({}, get_contributions(pofile, potmsgset_ids))
555+
556+ def test_get_pofiletranslators_gets_translators_for_pofile(self):
557+ pofile = self.factory.makePOFile()
558+ tm = self.make_message_with_pofiletranslator(pofile)
559+ self.assertContentEqual(
560+ [tm.submitter.id], get_pofiletranslators(pofile.id))
561+
562+ def test_fix_pofile_leaves_good_pofiletranslator_in_place(self):
563+ pofile = self.factory.makePOFile()
564+ tm = self.make_message_with_pofiletranslator(pofile)
565+ old_poft = self.query_pofiletranslator(pofile, tm.submitter).one()
566+
567+ fix_pofile(
568+ fake_logger, pofile, [tm.potmsgset.id], set([tm.submitter.id]))
569+
570+ new_poft = self.query_pofiletranslator(pofile, tm.submitter).one()
571+ self.assertEqual(old_poft, new_poft)
572+
573+ def test_fix_pofile_deletes_unwarranted_entries(self):
574+ # Deleting POFileTranslator records is not something the app
575+ # server ever does, so it requires special privileges.
576+ self.becomeDbUser('postgres')
577+ poft = self.make_pofiletranslator_without_message()
578+ (pofile, person) = (poft.pofile, poft.person)
579+ fix_pofile(fake_logger, pofile, [], set([person.id]))
580+ self.assertIsNone(self.query_pofiletranslator(pofile, person).one())
581+
582+ def test_fix_pofile_adds_missing_entries(self):
583+ pofile = self.factory.makePOFile()
584+ tm = self.make_message_without_pofiletranslator(pofile)
585+
586+ fix_pofile(fake_logger, pofile, [tm.potmsgset.id], set())
587+
588+ new_poft = self.query_pofiletranslator(pofile, tm.submitter).one()
589+ self.assertEqual(tm.submitter, new_poft.person)
590+ self.assertEqual(pofile, new_poft.pofile)
591+
592+ def test_tunable_loop(self):
593+ pofile = self.factory.makePOFile()
594+ tm = self.make_message_without_pofiletranslator(pofile)
595+ bad_poft = self.make_pofiletranslator_without_message(pofile)
596+ noncontributor = bad_poft.person
597+ transaction.commit()
598+
599+ self.becomeDbUser('garbo')
600+ ScrubPOFileTranslator(fake_logger).run()
601+ # Try to break the loop if it failed to commit its changes.
602+ transaction.abort()
603+
604+ # An unwarranted POFileTranslator record has been deleted.
605+ self.assertIsNotNone(
606+ self.query_pofiletranslator(pofile, tm.submitter).one())
607+ # A missing POFileTranslator has been created.
608+ self.assertIsNone(
609+ self.query_pofiletranslator(pofile, noncontributor).one())