From a0eaa0ed1afbb4bca1dd74f62360d24b961cf46c Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 30 Jun 2026 09:38:42 -0400 Subject: [PATCH 1/4] Support docassemble 1.10 session APIs --- docassemble/AssemblyLine/sessions.py | 131 ++++++++++++++++----------- 1 file changed, 77 insertions(+), 54 deletions(-) diff --git a/docassemble/AssemblyLine/sessions.py b/docassemble/AssemblyLine/sessions.py index 2516bc46..68095103 100644 --- a/docassemble/AssemblyLine/sessions.py +++ b/docassemble/AssemblyLine/sessions.py @@ -31,9 +31,8 @@ validation_error, word, ) -from docassemble.webapp.db_object import init_sqlalchemy from sqlalchemy.sql import text -from docassemble.base.functions import server, safe_json, serializable_dict +from docassemble.base.functions import safe_json, serializable_dict from .al_document import ( ALDocument, ALDocumentBundle, @@ -47,6 +46,33 @@ import hashlib import struct +try: + from docassemble.base.hooks import write_answer_json as _write_answer_json +except ModuleNotFoundError as err: + if err.name != "docassemble.base.hooks": + raise + # docassemble < 1.10 exposes webapp hooks through the legacy server object. + from docassemble.base.functions import server as _legacy_server + + def _write_answer_json(*args, **kwargs): + return _legacy_server.write_answer_json(*args, **kwargs) + + +try: + from docassemble.webapp.db import ( + get_session as _get_session, + session_scope as _session_scope, + ) +except ModuleNotFoundError as err: + if err.name != "docassemble.webapp.db": + raise + # docassemble < 1.10 uses a SQLAlchemy engine instead of session context managers. + from docassemble.webapp.db_object import init_sqlalchemy + + _legacy_db = init_sqlalchemy() + _get_session = _legacy_db.connect + _session_scope = _legacy_db.begin + try: import zoneinfo # type: ignore except ImportError: @@ -80,8 +106,6 @@ "update_session_metadata", ] -db = init_sqlalchemy() - al_sessions_variables_to_remove: Set = { # Internal fields "_internal", @@ -278,7 +302,7 @@ def set_interview_metadata( data (Dict): The metadata to add. metadata_key_name (str, optional): The name of the metadata key. Defaults to "metadata". """ - server.write_answer_json( + _write_answer_json( session_id, filename, safe_json(data), tags=metadata_key_name, persistent=True ) @@ -304,8 +328,8 @@ def get_interview_metadata( AND tags = :tags AND key = :session_id """) - with db.connect() as con: - row = con.execute( + with _get_session() as session: + row = session.execute( sql, {"filename": filename, "tags": metadata_key_name, "session_id": session_id}, ).fetchone() @@ -448,8 +472,8 @@ def get_saved_interview_list( return [] sessions = [] - with db.connect() as con: - rs = con.execute( + with _get_session() as session: + rs = session.execute( get_sessions_query, { "metadata": metadata_key_name, @@ -464,8 +488,8 @@ def get_saved_interview_list( ), # We need to pass a value to the query, but it's treated as a flag }, ) - for session in rs: - sessions.append(dict(session._mapping)) + for row in rs: + sessions.append(dict(row._mapping)) return sessions @@ -666,11 +690,11 @@ def find_matching_sessions( parameters[f"{column}_filter"] = val_tuple[0] sessions = [] - with db.connect() as con: - rs = con.execute(get_sessions_query, parameters) + with _get_session() as session: + rs = session.execute(get_sessions_query, parameters) - for session in rs: - sessions.append(dict(session._mapping)) + for row in rs: + sessions.append(dict(row._mapping)) return sessions @@ -720,8 +744,8 @@ def delete_interview_sessions( log(f"Deleting sessions with {user_id} {filename_to_exclude} {current_filename}") - with db.connect() as connection: - connection.execute( + with _session_scope() as session: + session.execute( delete_sessions_query, { "user_id": user_id, @@ -1638,11 +1662,11 @@ def get_filenames_having_sessions( sql_all = text("SELECT DISTINCT filename FROM userdict") sql_user = text("SELECT DISTINCT filename FROM userdict WHERE user_id = :user_id") - with db.connect() as conn: + with _get_session() as session: if user_id is None: - rows = conn.execute(sql_all).mappings().all() + rows = session.execute(sql_all).mappings().all() else: - rows = conn.execute(sql_user, {"user_id": user_id}).mappings().all() + rows = session.execute(sql_user, {"user_id": user_id}).mappings().all() return [row["filename"] for row in rows] @@ -1723,49 +1747,48 @@ def to_signed_32(x: int) -> int: h1 = to_signed_32(high_u32) h2 = to_signed_32(low_u32) - with db.connect() as con: - # Wrap in a transaction so the advisory lock holds until COMMIT - with con.begin(): - # 3) Acquire the advisory lock on (h1,h2) - con.execute( - text("SELECT pg_advisory_xact_lock(:h1, :h2)"), - {"h1": h1, "h2": h2}, - ) + # The advisory lock and upsert must share one transaction. + with _session_scope() as session: + # 3) Acquire the advisory lock on (h1,h2) + session.execute( + text("SELECT pg_advisory_xact_lock(:h1, :h2)"), + {"h1": h1, "h2": h2}, + ) - # 4) Try UPDATE first, using CAST() instead of ::jsonb - update_sql = text(""" - UPDATE jsonstorage - SET data = jsonstorage.data || CAST(:data AS jsonb) - WHERE key = :session_id - AND filename = :filename - AND tags = :tags + # 4) Try UPDATE first, using CAST() instead of ::jsonb + update_sql = text(""" + UPDATE jsonstorage + SET data = jsonstorage.data || CAST(:data AS jsonb) + WHERE key = :session_id + AND filename = :filename + AND tags = :tags + """) + result = session.execute( + update_sql, + { + "data": json_data_string, + "session_id": session_id, + "filename": filename, + "tags": metadata_key_name, + }, + ) + + # 5) If nothing was updated, INSERT + if (result.rowcount or 0) == 0: + insert_sql = text(""" + INSERT INTO jsonstorage (key, filename, tags, data) + VALUES (:session_id, :filename, :tags, CAST(:data AS jsonb)) """) - result = con.execute( - update_sql, + session.execute( + insert_sql, { - "data": json_data_string, "session_id": session_id, "filename": filename, "tags": metadata_key_name, + "data": json_data_string, }, ) - # 5) If nothing was updated, INSERT - if (result.rowcount or 0) == 0: - insert_sql = text(""" - INSERT INTO jsonstorage (key, filename, tags, data) - VALUES (:session_id, :filename, :tags, CAST(:data AS jsonb)) - """) - con.execute( - insert_sql, - { - "session_id": session_id, - "filename": filename, - "tags": metadata_key_name, - "data": json_data_string, - }, - ) - def update_current_session_metadata( data: Dict[str, Any], From b1791861efb18bfaacef09b66d05331a8dff53e3 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 30 Jun 2026 11:06:06 -0400 Subject: [PATCH 2/4] Add test files --- .../data/questions/test_saving_snapshots.yml | 15 +++++++++++++++ .../data/sources/test_snapshots.feature | 10 ++++++++++ 2 files changed, 25 insertions(+) create mode 100644 docassemble/AssemblyLine/data/questions/test_saving_snapshots.yml create mode 100644 docassemble/AssemblyLine/data/sources/test_snapshots.feature diff --git a/docassemble/AssemblyLine/data/questions/test_saving_snapshots.yml b/docassemble/AssemblyLine/data/questions/test_saving_snapshots.yml new file mode 100644 index 00000000..c06bfc2c --- /dev/null +++ b/docassemble/AssemblyLine/data/questions/test_saving_snapshots.yml @@ -0,0 +1,15 @@ +--- +include: + - docassemble.AssemblyLine:assembly_line.yml +--- +mandatory: True +code: | + al_sessions_snapshot_label + al_sessions_save_session_snapshot + finish_screen +--- +id: finish screen +event: finish_screen +question: | + Done +--- diff --git a/docassemble/AssemblyLine/data/sources/test_snapshots.feature b/docassemble/AssemblyLine/data/sources/test_snapshots.feature new file mode 100644 index 00000000..b724fc1c --- /dev/null +++ b/docassemble/AssemblyLine/data/sources/test_snapshots.feature @@ -0,0 +1,10 @@ +@snapshots +Feature: Test saving snapshots + +Scenario: User can save an answer snapshot + Given I log in with "ADMIN_EMAIL" and "ADMIN_PASSWORD" + And I start the interview at "test_saving_snapshots" + And I should see the phrase "Save an answer set" + And I set the var "al_sessions_snapshot_label" to "My test snapshot" + And I tap to continue + And I should see the phrase "Your answer set was successfully saved" From 7a91f442ff6894f22911cf57671d56b4afc75407 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 30 Jun 2026 12:52:19 -0400 Subject: [PATCH 3/4] Fix test script; catch potential infinite loop issue/crash while checking filtered vars --- .../AssemblyLine/data/questions/al_saved_sessions.yml | 2 +- .../AssemblyLine/data/questions/test_saving_snapshots.yml | 4 +++- docassemble/AssemblyLine/sessions.py | 5 +++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docassemble/AssemblyLine/data/questions/al_saved_sessions.yml b/docassemble/AssemblyLine/data/questions/al_saved_sessions.yml index 5f9825dd..7971b577 100644 --- a/docassemble/AssemblyLine/data/questions/al_saved_sessions.yml +++ b/docassemble/AssemblyLine/data/questions/al_saved_sessions.yml @@ -195,7 +195,7 @@ code: | if user_logged_in(): new_session_id = save_interview_answers( metadata={"title": al_sessions_snapshot_label}, - additional_variables_to_filter=al_sessions_additional_variables_to_filter, + additional_variables_to_filter=showifdef('al_sessions_additional_variables_to_filter', []), ) log(f"Saved interview {al_sessions_snapshot_label} with id {new_session_id}") else: diff --git a/docassemble/AssemblyLine/data/questions/test_saving_snapshots.yml b/docassemble/AssemblyLine/data/questions/test_saving_snapshots.yml index c06bfc2c..536f53b4 100644 --- a/docassemble/AssemblyLine/data/questions/test_saving_snapshots.yml +++ b/docassemble/AssemblyLine/data/questions/test_saving_snapshots.yml @@ -1,11 +1,13 @@ --- include: - - docassemble.AssemblyLine:assembly_line.yml + - assembly_line.yml --- mandatory: True code: | + al_sessions_additional_variables_to_filter = [] al_sessions_snapshot_label al_sessions_save_session_snapshot + al_sessions_save_status finish_screen --- id: finish screen diff --git a/docassemble/AssemblyLine/sessions.py b/docassemble/AssemblyLine/sessions.py index 68095103..c214bf00 100644 --- a/docassemble/AssemblyLine/sessions.py +++ b/docassemble/AssemblyLine/sessions.py @@ -1373,6 +1373,7 @@ def get_filtered_session_variables( all_vars = {k: v for k, v in all_vars.items() if k not in variables_to_filter} items_to_check = list(all_vars.items()) + visited = set() while items_to_check: key, value = items_to_check.pop() @@ -1381,6 +1382,10 @@ def get_filtered_session_variables( if is_file_like(value): del all_vars[key] continue + + if id(value) in visited: + continue + visited.add(id(value)) if isinstance(value, DAObject): # docassemble overrides both __dir__ and __getattr__ for reasons unknown From 2b2bb219616e57dc349412dc53c9fd56357e37cb Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 30 Jun 2026 16:59:23 -0400 Subject: [PATCH 4/4] Format with black --- docassemble/AssemblyLine/sessions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docassemble/AssemblyLine/sessions.py b/docassemble/AssemblyLine/sessions.py index c214bf00..ba2b4cbe 100644 --- a/docassemble/AssemblyLine/sessions.py +++ b/docassemble/AssemblyLine/sessions.py @@ -1382,7 +1382,7 @@ def get_filtered_session_variables( if is_file_like(value): del all_vars[key] continue - + if id(value) in visited: continue visited.add(id(value))