Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions docassemble/AssemblyLine/data/questions/test_saving_snapshots.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
include:
- 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
event: finish_screen
question: |
Done
---
10 changes: 10 additions & 0 deletions docassemble/AssemblyLine/data/sources/test_snapshots.feature
Original file line number Diff line number Diff line change
@@ -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"
136 changes: 82 additions & 54 deletions docassemble/AssemblyLine/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -80,8 +106,6 @@
"update_session_metadata",
]

db = init_sqlalchemy()

al_sessions_variables_to_remove: Set = {
# Internal fields
"_internal",
Expand Down Expand Up @@ -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
)

Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1349,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()
Expand All @@ -1358,6 +1383,10 @@ def get_filtered_session_variables(
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
# we need to use the base Python versions to get what we expect
Expand Down Expand Up @@ -1638,11 +1667,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]

Expand Down Expand Up @@ -1723,49 +1752,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],
Expand Down
Loading