diff --git a/src/backend/core/management/commands/migrate_documents.py b/src/backend/core/management/commands/migrate_documents.py index 0e7d965bf..078776d21 100644 --- a/src/backend/core/management/commands/migrate_documents.py +++ b/src/backend/core/management/commands/migrate_documents.py @@ -10,13 +10,17 @@ It is meant to be run again: every document it finishes is recorded, and the collaboration server answers "already" for anything it has already migrated, so a run that is interrupted, rate limited or killed simply picks up where it stopped. Nothing is destroyed, on either side. + +One document can also be handed over on its own with `--document-id`, which is +how a document a run left behind is dealt with once its cause is understood. """ import logging import time +import uuid from concurrent.futures import ThreadPoolExecutor -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandError from django.utils import timezone from core import models @@ -46,6 +50,16 @@ class Command(BaseCommand): def add_arguments(self, parser): """Define the arguments of the command.""" + parser.add_argument( + "--document-id", + type=uuid.UUID, + default=None, + help=( + "Migrate this document alone, whatever a previous run recorded " + "for it. The filters selecting a corpus (--created-before, " + "--limit, --retry-failed) do not apply to it." + ), + ) parser.add_argument( "--concurrency", type=int, @@ -136,7 +150,20 @@ class Command(BaseCommand): A document is opened before it is missed: the ones edited recently are the ones users are about to read, and until a document is migrated the collaboration server only seeds its latest state, without its history. + + Naming one document is an instruction rather than a filter: it is handed + over even when a previous run recorded it as done, which costs a call + the collaboration server answers with "already". Nothing else would be + useful — a command asked for one document and reporting that it had + nothing to do says neither what happened nor why. """ + if options["document_id"]: + queryset = models.Document.objects.filter(pk=options["document_id"]) + if not queryset.exists(): + raise CommandError(f"No document with id {options['document_id']}") + + return queryset + queryset = models.Document.objects.all() if options["created_before"]: diff --git a/src/backend/core/tests/commands/test_migrate_documents.py b/src/backend/core/tests/commands/test_migrate_documents.py index de580689e..e33e8b436 100644 --- a/src/backend/core/tests/commands/test_migrate_documents.py +++ b/src/backend/core/tests/commands/test_migrate_documents.py @@ -1,9 +1,11 @@ """Unit tests for the `migrate_documents` command.""" +import uuid from io import StringIO from unittest import mock from django.core.management import call_command +from django.core.management.base import CommandError import pytest @@ -167,3 +169,47 @@ def test_commands_migrate_documents_dry_run(collaboration_server): assert "2 documents to migrate" in output collaboration_server.assert_not_called() assert not models.DocumentMigration.objects.exists() + + +def test_commands_migrate_documents_document_id(collaboration_server): + """Naming a document should hand over that one and leave the corpus alone.""" + factories.DocumentFactory.create_batch(3) + document = factories.DocumentFactory() + + output = run_command(document_id=document.pk) + + assert collaboration_server.call_count == 1 + assert collaboration_server.call_args[0][0].pk == document.pk + assert models.DocumentMigration.objects.get().document_id == document.pk + assert "ok=1" in output + + +def test_commands_migrate_documents_document_id_already_migrated( + collaboration_server, +): + """ + A document already recorded as done should be handed over again when named. + + Asking for a document by its id is an instruction, not a filter over what is + left to do: the collaboration server answers "already" when it has nothing + to replay, which is the answer the run records. + """ + document = factories.DocumentFactory() + run_command() + collaboration_server.return_value = migrated(status="already") + + run_command(document_id=document.pk) + + assert collaboration_server.call_count == 2 + assert ( + models.DocumentMigration.objects.get(document=document).status + == models.DocumentMigrationStatus.ALREADY + ) + + +def test_commands_migrate_documents_document_id_unknown(collaboration_server): + """An id that is no document should stop the command, not migrate nothing.""" + with pytest.raises(CommandError, match="No document with id"): + run_command(document_id=uuid.uuid4()) + + collaboration_server.assert_not_called()