diff --git a/docassemble/AssemblyLine/data/questions/al_document.yml b/docassemble/AssemblyLine/data/questions/al_document.yml index 80902b7a..809f16e9 100644 --- a/docassemble/AssemblyLine/data/questions/al_document.yml +++ b/docassemble/AssemblyLine/data/questions/al_document.yml @@ -139,13 +139,48 @@ code: | --- ############## Background document assembly ################# --- +# Use these saved completion variables in interview order blocks. Celery's +# task.ready() becomes False again when its result expires (normally one day). +# Error callbacks also save durable state. While waiting, check completed tasks +# for failures from before these callbacks existed. failed() can block, so it +# must only be called after ready(). Neither is needed when a saved result exists. generic object: ALDocumentBundle code: | + x.generate_downloads_task + if not defined(x.attr_name("_downloadable_files")): + if defined(x.attr_name("_download_error")) or ( + x.generate_downloads_task.ready() and x.generate_downloads_task.failed() + ): + x.downloads_error_screen + al_download_waiting_screen + x.downloads_ready = True +--- +generic object: ALDocumentBundle +code: | + x.generate_downloads_with_docx_task + if not defined(x.attr_name("_downloadable_files")): + if defined(x.attr_name("_download_error")) or ( + x.generate_downloads_with_docx_task.ready() and x.generate_downloads_with_docx_task.failed() + ): + x.downloads_error_screen + al_download_waiting_screen + x.downloads_with_docx_ready = True +--- +generic object: ALDocumentBundle +code: | + undefine( + x.attr_name("_downloadable_files"), + x.attr_name("_download_error"), + x.attr_name("downloads_ready"), + x.attr_name("downloads_with_docx_ready"), + x.attr_name("generate_downloads_with_docx_task"), + ) x.generate_downloads_task = background_action(x.attr_name("create_downloads")) --- generic object: ALDocumentBundle event: x.create_downloads code: | + background_error_action(x.attr_name("save_download_error")) download_response = x.get_cacheable_documents( key="final", pdf=True, include_full_pdf=True ) @@ -155,6 +190,13 @@ code: | --- generic object: ALDocumentBundle code: | + undefine( + x.attr_name("_downloadable_files"), + x.attr_name("_download_error"), + x.attr_name("downloads_ready"), + x.attr_name("downloads_with_docx_ready"), + x.attr_name("generate_downloads_task"), + ) x.generate_downloads_with_docx_task = background_action( x.attr_name("create_downloads_with_docx") ) @@ -162,6 +204,7 @@ code: | generic object: ALDocumentBundle event: x.create_downloads_with_docx code: | + background_error_action(x.attr_name("save_download_error")) download_response = x.get_cacheable_documents( key="final", pdf=True, docx=True, include_full_pdf=True ) @@ -176,6 +219,30 @@ code: | background_response() --- +generic object: ALDocumentBundle +event: x.save_download_error +code: | + x._download_error = True + background_response() +--- +generic object: ALDocumentBundle +event: x.downloads_error_screen +id: background downloads error screen +question: | + We could not make your documents +subquestion: | + Your answers have been saved. You can try again. + + ${ action_button_html(url_action(x.attr_name('retry_downloads')), label='Try again') } +--- +generic object: ALDocumentBundle +event: x.retry_downloads +code: | + if defined(x.attr_name("generate_downloads_with_docx_task")): + reconsider(x.attr_name("generate_downloads_with_docx_task")) + else: + reconsider(x.attr_name("generate_downloads_task")) +--- id: waiting screen question: | Please wait while we make your documents @@ -192,11 +259,28 @@ reload: True --- generic object: ALDocumentBundle code: | + x.generate_preview_task + if not defined(x.attr_name("_preview_file")): + if defined(x.attr_name("_preview_error")) or ( + x.generate_preview_task.ready() and x.generate_preview_task.failed() + ): + x.preview_error_screen + al_preview_waiting_screen + x.preview_ready = True +--- +generic object: ALDocumentBundle +code: | + undefine( + x.attr_name("_preview_file"), + x.attr_name("_preview_error"), + x.attr_name("preview_ready"), + ) x.generate_preview_task = background_action(x.attr_name("generate_preview_event")) --- generic object: ALDocumentBundle event: x.generate_preview_event code: | + background_error_action(x.attr_name("save_preview_error")) preview_response = x.as_pdf(key="preview") background_response_action( x.attr_name("save_preview"), preview_response=preview_response @@ -208,6 +292,27 @@ code: | x._preview_file = action_argument("preview_response") background_response() --- +generic object: ALDocumentBundle +event: x.save_preview_error +code: | + x._preview_error = True + background_response() +--- +generic object: ALDocumentBundle +event: x.preview_error_screen +id: background preview error screen +question: | + We could not generate a preview of your document +subquestion: | + Your answers have been saved. You can try again. + + ${ action_button_html(url_action(x.attr_name('retry_preview')), label='Try again') } +--- +generic object: ALDocumentBundle +event: x.retry_preview +code: | + reconsider(x.attr_name("generate_preview_task")) +--- id: preview waiting screen question: | Please wait while we generate a preview of your document diff --git a/docassemble/AssemblyLine/data/questions/test_aldocument_background_assembly.yml b/docassemble/AssemblyLine/data/questions/test_aldocument_background_assembly.yml index 4401fe8f..daefdd53 100644 --- a/docassemble/AssemblyLine/data/questions/test_aldocument_background_assembly.yml +++ b/docassemble/AssemblyLine/data/questions/test_aldocument_background_assembly.yml @@ -8,12 +8,10 @@ metadata: mandatory: True code: | intro_screen - if not al_user_bundle.generate_preview_task.ready(): - al_preview_waiting_screen + al_user_bundle.preview_ready preview_screen - # if not al_user_bundle.generate_downloads_task.ready(): # Without DOCX - if not al_user_bundle.generate_downloads_with_docx_task.ready(): # With DOCX - al_download_waiting_screen + # Use al_user_bundle.downloads_ready for PDF-only downloads. + al_user_bundle.downloads_with_docx_ready download_screen --- id: intro-screen @@ -40,6 +38,15 @@ subquestion: | Your documents are ready for download. ${ al_user_bundle.download_list_html(use_previously_cached_files=True, include_full_pdf=True) } + + ${ action_button_html(url_action('regenerate_downloads'), label='Regenerate documents') } +--- +# Use this action after collecting any changed attachment inputs. Starting the +# task clears its old saved files and completion gate; the order block waits for +# the replacement files. Do not reconsider the task on every mandatory pass. +event: regenerate_downloads +code: | + reconsider("al_user_bundle.generate_downloads_with_docx_task") --- objects: - al_user_bundle: ALDocumentBundle.using( diff --git a/docassemble/AssemblyLine/test_background_assembly.py b/docassemble/AssemblyLine/test_background_assembly.py new file mode 100644 index 00000000..920ef6c7 --- /dev/null +++ b/docassemble/AssemblyLine/test_background_assembly.py @@ -0,0 +1,277 @@ +# do not pre-load + +"""Exercise durable background assembly completion, failure, and retry.""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +import yaml + +BLOCKS = list( + yaml.safe_load_all( + (Path(__file__).parent / "data/questions/al_document.yml").read_text() + ) +) +MODES = [ + ( + "generate_preview_task", + "preview_ready", + "_preview_file", + "al_preview_waiting_screen", + ), + ( + "generate_downloads_task", + "downloads_ready", + "_downloadable_files", + "al_download_waiting_screen", + ), + ( + "generate_downloads_with_docx_task", + "downloads_with_docx_ready", + "_downloadable_files", + "al_download_waiting_screen", + ), +] +ERROR_MODES = [ + ( + "generate_preview_task", + "_preview_error", + "preview_error_screen", + "retry_preview", + "generate_preview_event", + "save_preview_error", + ), + ( + "generate_downloads_task", + "_download_error", + "downloads_error_screen", + "retry_downloads", + "create_downloads", + "save_download_error", + ), + ( + "generate_downloads_with_docx_task", + "_download_error", + "downloads_error_screen", + "retry_downloads", + "create_downloads_with_docx", + "save_download_error", + ), +] + + +def code_defining(attribute): + return next( + block["code"] + for block in BLOCKS + if block + and any( + line.startswith(f"x.{attribute} =") + for line in block.get("code", "").splitlines() + ) + ) + + +def context(bundle): + def undefine(*names): + for name in names: + bundle.__dict__.pop(name.split(".", 1)[1], None) + + return { + "x": bundle, + "defined": lambda name: name.split(".", 1)[1] in vars(bundle), + "undefine": undefine, + "background_action": Mock( + return_value=Mock( + ready=Mock(return_value=False), failed=Mock(return_value=False) + ) + ), + } + + +def event_block(event): + return next( + block for block in BLOCKS if block and block.get("event") == "x." + event + ) + + +@pytest.mark.parametrize("task,gate,cache,waiting", MODES) +@pytest.mark.parametrize("saved_result", [None, (), "saved file"]) +def test_saved_callback_result_survives_expired_task( + task, gate, cache, waiting, saved_result +): + bundle = SimpleNamespace(attr_name=lambda name: "bundle." + name) + expired = Mock() + expired.ready.return_value = False + setattr(bundle, task, expired) + setattr(bundle, cache, saved_result) + env = context(bundle) + exec(code_defining(gate), env) + assert getattr(bundle, gate) is True + expired.ready.assert_not_called() + expired.failed.assert_not_called() + env["background_action"].assert_not_called() + + +@pytest.mark.parametrize("task,gate,cache,waiting", MODES) +@pytest.mark.parametrize("task_ready", [False, True]) +def test_missing_callback_result_waits_without_marking_complete( + task, gate, cache, waiting, task_ready +): + bundle = SimpleNamespace(attr_name=lambda name: "bundle." + name) + pending = Mock(ready=Mock(return_value=task_ready), failed=Mock(return_value=False)) + setattr(bundle, task, pending) + with pytest.raises(NameError, match=waiting): + exec(code_defining(gate), context(bundle)) + assert gate not in vars(bundle) + if task_ready: + pending.failed.assert_called_once_with() + else: + pending.failed.assert_not_called() + + +@pytest.mark.parametrize("task,gate,cache,waiting", MODES) +def test_restarting_task_discards_old_files_and_completion(task, gate, cache, waiting): + bundle = SimpleNamespace(attr_name=lambda name: "bundle." + name) + setattr(bundle, gate, True) + setattr(bundle, cache, "outdated file") + env = context(bundle) + exec(code_defining(task), env) + assert gate not in vars(bundle) + assert cache not in vars(bundle) + env["background_action"].assert_called_once() + with pytest.raises(NameError, match=waiting): + exec(code_defining(gate), env) + setattr(bundle, cache, "replacement file") + exec(code_defining(gate), env) + assert getattr(bundle, gate) is True + + +@pytest.mark.parametrize( + "task,other_task", + [ + ("generate_downloads_task", "generate_downloads_with_docx_task"), + ("generate_downloads_with_docx_task", "generate_downloads_task"), + ], +) +def test_switching_download_mode_invalidates_shared_results(task, other_task): + bundle = SimpleNamespace( + attr_name=lambda name: "bundle." + name, + downloads_ready=True, + downloads_with_docx_ready=True, + _downloadable_files="files from the other mode", + ) + setattr(bundle, other_task, Mock()) + exec(code_defining(task), context(bundle)) + for stale in ( + other_task, + "downloads_ready", + "downloads_with_docx_ready", + "_downloadable_files", + ): + assert stale not in vars(bundle) + assert task in vars(bundle) + + +@pytest.mark.parametrize("task,error,screen,retry,create,save", ERROR_MODES) +def test_failed_task_stops_waiting_without_marking_complete( + task, error, screen, retry, create, save +): + gate = next(mode[1] for mode in MODES if mode[0] == task) + bundle = SimpleNamespace(attr_name=lambda name: "bundle." + name) + failed = Mock(ready=Mock(return_value=True), failed=Mock(return_value=True)) + setattr(bundle, task, failed) + with pytest.raises(AttributeError, match=screen): + exec(code_defining(gate), context(bundle)) + assert gate not in vars(bundle) + failed.failed.assert_called_once_with() + + +@pytest.mark.parametrize("task,error,screen,retry,create,save", ERROR_MODES) +def test_saved_failure_survives_expiration_and_retry( + task, error, screen, retry, create, save +): + gate, cache, waiting = next(mode[1:] for mode in MODES if mode[0] == task) + bundle = SimpleNamespace(attr_name=lambda name: "bundle." + name) + expired = Mock(failed=Mock(return_value=False), ready=Mock(return_value=False)) + setattr(bundle, task, expired) + env = context(bundle) + env["background_response"] = Mock() + exec(event_block(save)["code"], env) + assert getattr(bundle, error) is True + env["background_response"].assert_called_once_with() + + with pytest.raises(AttributeError, match=screen): + exec(code_defining(gate), env) + assert gate not in vars(bundle) + expired.failed.assert_not_called() + expired.ready.assert_not_called() + env["background_action"].assert_not_called() + + def reconsider(name): + assert name == "bundle." + task + exec(code_defining(task), env) + + env["reconsider"] = Mock(side_effect=reconsider) + exec(event_block(retry)["code"], env) + env["reconsider"].assert_called_once_with("bundle." + task) + assert error not in vars(bundle) + env["background_action"].assert_called_once() + with pytest.raises(NameError, match=waiting): + exec(code_defining(gate), env) + setattr(bundle, cache, "replacement file") + exec(code_defining(gate), env) + assert getattr(bundle, gate) is True + + +@pytest.mark.parametrize("task,error,screen,retry,create,save", ERROR_MODES) +def test_error_callback_registered_before_assembly( + task, error, screen, retry, create, save +): + assemble = Mock(side_effect=RuntimeError("attachment failed")) + registered = Mock() + bundle = SimpleNamespace( + attr_name=lambda name: "bundle." + name, + get_cacheable_documents=assemble, + as_pdf=assemble, + ) + calls = Mock() + calls.attach_mock(registered, "register") + calls.attach_mock(assemble, "assemble") + with pytest.raises(RuntimeError, match="attachment failed"): + exec( + event_block(create)["code"], + {"x": bundle, "background_error_action": registered}, + ) + registered.assert_called_once_with("bundle." + save) + assert [call[0] for call in calls.mock_calls] == ["register", "assemble"] + + +@pytest.mark.parametrize("task,error,screen,retry,create,save", ERROR_MODES) +def test_regeneration_clears_failure_for_its_output_only( + task, error, screen, retry, create, save +): + bundle = SimpleNamespace( + attr_name=lambda name: "bundle." + name, + _download_error=True, + _preview_error=True, + ) + exec(code_defining(task), context(bundle)) + assert error not in vars(bundle) + other_error = "_download_error" if error == "_preview_error" else "_preview_error" + assert getattr(bundle, other_error) is True + + +@pytest.mark.parametrize( + "screen,retry", + [ + ("downloads_error_screen", "retry_downloads"), + ("preview_error_screen", "retry_preview"), + ], +) +def test_error_screen_offers_retry_without_reloading(screen, retry): + block = event_block(screen) + assert not block.get("reload") + assert "url_action(x.attr_name('" + retry + "'))" in block["subquestion"]