From d5d357a5d5605ee4b0cdfde3ccabf55285c00b72 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Thu, 16 May 2024 16:21:15 -0400 Subject: [PATCH] Fix #136 --- docassemble/ALDashboard/aldashboard.py | 67 ++++++---- .../data/questions/list_sessions.yml | 70 ++++++++-- .../data/questions/manage_answer_viewers.yml | 123 ++++++++++++++++++ .../ALDashboard/data/questions/menu.yml | 4 +- 4 files changed, 230 insertions(+), 34 deletions(-) create mode 100644 docassemble/ALDashboard/data/questions/manage_answer_viewers.yml diff --git a/docassemble/ALDashboard/aldashboard.py b/docassemble/ALDashboard/aldashboard.py index 74fdce8..7facaf2 100644 --- a/docassemble/ALDashboard/aldashboard.py +++ b/docassemble/ALDashboard/aldashboard.py @@ -33,6 +33,7 @@ word, DAFileList, get_config, + user_has_privilege, ) from ruamel.yaml import YAML from ruamel.yaml.compat import StringIO @@ -138,6 +139,7 @@ def da_write_config(data: Dict): with open(daconfig["config file"], "w", encoding="utf-8") as fp: fp.write(yaml_data) restart_all() + return True def speedy_get_users() -> List[Dict[int, str]]: @@ -161,6 +163,7 @@ def speedy_get_sessions( user_id: Optional[int] = None, filename: Optional[str] = None, filter_step1: bool = True, + metadata_key_name: str = "metadata", ) -> List[Tuple]: """ Return a list of the most recent 500 sessions, optionally tied to a specific user ID. @@ -173,33 +176,49 @@ def speedy_get_sessions( """ get_sessions_query = text( """ - SELECT userdict.filename as filename, - num_keys, - userdictkeys.user_id as user_id, - modtime, - userdict.key as key - FROM userdict - NATURAL JOIN - ( - SELECT key, - MAX(modtime) AS modtime, - COUNT(key) AS num_keys - FROM userdict - GROUP BY key - HAVING COUNT(key) > 1 OR :filter_step1 = False - ) mostrecent - LEFT JOIN userdictkeys - ON userdictkeys.key = userdict.key - WHERE (userdict.user_id = :user_id OR :user_id is null) - AND (userdict.filename = :filename OR :filename is null) - ORDER BY modtime DESC - LIMIT 500; +SELECT + userdict.filename as filename, + num_keys, + userdictkeys.user_id as user_id, + mostrecent.modtime as modtime, -- This retrieves the most recent modification time for each key + userdict.key as key, + jsonstorage.data->>'auto_title' as auto_title, + jsonstorage.data->>'title' as title, + jsonstorage.data->>'description' as description, + jsonstorage.data->>'steps' as steps, + jsonstorage.data->>'progress' as progress +FROM + userdict +NATURAL JOIN + ( + SELECT + key, + MAX(modtime) AS modtime, -- Calculate the most recent modification time for each key + COUNT(key) AS num_keys + FROM + userdict + GROUP BY + key + HAVING + COUNT(key) > 1 OR :filter_step1 = False + ) mostrecent +LEFT JOIN + userdictkeys ON userdictkeys.key = userdict.key +LEFT JOIN + jsonstorage ON jsonstorage.key = userdict.key AND jsonstorage.tags = :metadata +WHERE + (userdict.user_id = :user_id OR :user_id is null) + AND (userdict.filename = :filename OR :filename is null) +ORDER BY + modtime DESC +LIMIT 500; """ ) - # Assuming `filename`, `user_id`, and `filter_step1` are provided elsewhere in your code if not filename: + if not user_has_privilege(['admin', 'developer']): + raise Exception("You must provide a filename to filter sessions unless you are a developer or administrator.") filename = None # Explicitly treat empty string as equivalent to None - if not user_id: # TODO: verify that 0 is not a valid value for user ID + if not user_id: user_id = None # Ensure filter_step1 is a boolean @@ -208,7 +227,7 @@ def speedy_get_sessions( with db.connect() as con: rs = con.execute( get_sessions_query, - {"user_id": user_id, "filename": filename, "filter_step1": filter_step1}, + {"user_id": user_id, "filename": filename, "filter_step1": filter_step1, "metadata": metadata_key_name}, ) sessions = [session for session in rs] diff --git a/docassemble/ALDashboard/data/questions/list_sessions.yml b/docassemble/ALDashboard/data/questions/list_sessions.yml index bff7d78..9d64d13 100644 --- a/docassemble/ALDashboard/data/questions/list_sessions.yml +++ b/docassemble/ALDashboard/data/questions/list_sessions.yml @@ -1,6 +1,13 @@ --- -include: - - nav.yml +metadata: + temporary session: True + sessions are unique: True +--- +default screen parts: + right: | + % if user_has_privilege(['admin', 'developer']): + ${ action_button_html(interview_url(i=f"{user_info().package}:menu.yml"), label="Back to Dashboard") } + % endif --- modules: - .aldashboard @@ -11,18 +18,37 @@ question: | Splash screen --- code: | - interviews = {interview['filename']:interview for interview in interview_menu()} + if user_has_privilege(['admin', 'developer']): + interviews = {interview['filename']:interview for interview in interview_menu()} + else: + allowed_interviews = set() + for privilege in user_privileges(): + allowed_interviews.update(get_config("assembly line",{}).get("interview viewers",{}).get(privilege,[])) + interviews = {interview['filename']:interview for interview in interview_menu() if interview['filename'] in allowed_interviews} --- question: | - What interview do you want to view sessions for? + What interview do you want to view sessions for? subquestion: | + % if user_has_privilege(['admin', 'developer']): Pick an interview from the list below, or type a filename like: `docassemble.playground1:data/questions/interview.yml` + % else: + You can only view sessions for interviews you have access to. + % endif fields: - Filename: filename required: False datatype: combobox code: | sorted([{interview: interviews[interview].get('title')} for interview in interviews], key=lambda y: next(iter(y.values()), '')) + show if: + code: | + user_has_privilege(['admin', 'developer']) + - Filename: filename + code: | + sorted([{interview: interviews[interview].get('title')} for interview in interviews], key=lambda y: next(iter(y.values()), '')) + show if: + code: | + not user_has_privilege(['admin', 'developer']) - User (leave blank to view all sessions): chosen_user required: False datatype: integer @@ -37,8 +63,19 @@ fields: # next((item.get('title') for item in interviews if item.get('filename') == interview['filename']), interview['filename'] ) --- code: | + # For debugging purposes sessions_list = speedy_get_sessions(user_id=chosen_user, filename=filename, filter_step1=filter_step1) --- +code: | + # users_and_names() returns a tuple of user ID to email, name, last + # Convert to a dict with key of user id, formatted email, name, last + + users_and_name_dict = { + user_id: f"{email} {name} {last}" + for user_id, email, name, last + in get_users_and_name() + } +--- mandatory: True event: load_answer question: | @@ -50,34 +87,41 @@ question: | Recently started sessions for all users % endif subquestion: | + - + - + - % for interview in sessions_list: + % for interview in speedy_get_sessions(user_id=chosen_user, filename=filename, filter_step1=filter_step1): - + - + % endfor @@ -87,3 +131,11 @@ question back button: True event: view_session_variables code: | response(binaryresponse=json.dumps(dashboard_get_session_variables(session_id=action_argument('session_id'), filename=action_argument('filename'))).encode('utf-8'), content_type="application/json", response_code=200) +--- +event: delete_session +code: | + if interview_list(action="delete", filename=action_argument("filename"), session=action_argument("session_id"), user_id="all", delete_shared=True): + log("Deleted session", "success") + else: + log("Something went wrong deleting session", "danger") + \ No newline at end of file diff --git a/docassemble/ALDashboard/data/questions/manage_answer_viewers.yml b/docassemble/ALDashboard/data/questions/manage_answer_viewers.yml new file mode 100644 index 0000000..e674e87 --- /dev/null +++ b/docassemble/ALDashboard/data/questions/manage_answer_viewers.yml @@ -0,0 +1,123 @@ +--- +include: + - nav.yml +--- +modules: + - .aldashboard +--- +metadata: + required privileges: + - admin + title: | + Manage limited answer viewers +--- +code: | + # Get the list of dispatch interviews + interviews = {interview['filename']:interview for interview in interview_menu()} +--- +objects: + - viewers: DADict.using(object_type = DAObject, auto_gather=False) + - viewers[i].allowed_interviews: DAList.using(auto_gather=False, gathered=True) +--- +code: | + viewers[i].allowed_interviews.gathered = True +--- +table: viewers.table +rows: viewers +columns: + - Privilege: | + row_index + - Allowed Interviews: | + comma_and_list(row_item.allowed_interviews) +--- +table: viewers[i].allowed_interviews.table +rows: viewers[i].allowed_interviews +columns: + - Interview: | + row_item +delete buttons: True +--- +code: | + existing_viewers = get_config("assembly line",{}).get("interview viewers",{}) + + for privilege in manage_privileges('list'): + viewers.initializeObject(privilege) + if privilege in existing_viewers: + viewers[privilege].allowed_interviews = DAList( + viewers[privilege].attr_name("allowed_interviews"), + elements=existing_viewers[privilege], + auto_gather=False, + gathered=True + ) + viewers.gathered = True +--- +id: interview order +mandatory: True +code: | + view_viewers +--- +id: allowed interviews i +question: | + Add an interview that users with the privilege "${ i }" are allowed to view +subquestion: | + % if len(viewers[i].allowed_interviews): + The following interviews are currently allowed for this privilege: + + ${ comma_and_list(viewers[i].allowed_interviews) } + % endif +fields: + - Interview name: viewers[i].allowed_interviews[j] + datatype: combobox + code: | + sorted([{interview: interviews[interview].get('title')} for interview in interviews], key=lambda y: next(iter(y.values()), '')) +validation code: | + if viewers[i].allowed_interviews[j] in viewers[i].allowed_interviews[:j]: + validation_error("This interview is already in the list", field="viewers[i].allowed_interviews[j]") +--- +event: view_viewers +id: viewers +question: | + Who is allowed to view limited answers? +subquestion: | + The answer viewing feature makes use of Docassemble's built-in privilege system. + + To assign a user the right to view a particular interview's sessions, you must add a matching + privilege and then assign the interview to that privilege. + + You can also edit this list manually in the global configuration, under: + + ``` + assembly line: + interview viewers: + privilege_name: + - interview1 + - interview2 + ``` + + When you have finished adding privileges and interviews, click the "Save to global configuration" button to save your changes. + + % for privilege in viewers: +

${ privilege }

+ + ${ viewers[privilege].allowed_interviews.table } + + ${ viewers[privilege].allowed_interviews.add_action() } + % endfor + + ${ action_button_html(url_action("save_changes"), label="Save to global configuration", color="primary", ) } +--- +code: | + the_config = da_get_config() + if not "assembly line" in the_config: + the_config["assembly line"] = {} + if not "interview viewers" in the_config["assembly line"]: + the_config["assembly line"]["interview viewers"] = {} + for privilege in viewers: + if viewers[privilege].allowed_interviews or privilege in the_config["assembly line"]["interview viewers"]: # handle deletion but don't add new empty entries + the_config["assembly line"]["interview viewers"][privilege] = list(viewers[privilege].allowed_interviews) + results = da_write_config(the_config) + if results: + log("Changes saved", "success") + else: + log("Failed to save changes", "danger") + save_changes = True \ No newline at end of file diff --git a/docassemble/ALDashboard/data/questions/menu.yml b/docassemble/ALDashboard/data/questions/menu.yml index 61054e2..5732d6f 100644 --- a/docassemble/ALDashboard/data/questions/menu.yml +++ b/docassemble/ALDashboard/data/questions/menu.yml @@ -58,9 +58,11 @@ data: - name: View answer files url: ${ interview_url(i=user_info().package + ":list_sessions.yml", reset=1) } image: file-alt + - name: Manage answer viewers + url: ${ interview_url(i=user_info().package + ":manage_answer_viewers.yml", reset=1) } + image: users-cog privilege: - admin - - developer - name: Generate a review screen draft url: ${ interview_url(i=user_info().package + ":review_screen_generator.yml", reset=1) } image: pencil-alt
Session IDTitle and session ID User ModifiedPageStep / Progress Actions
+ ${ interview.title if interview.title else interview.auto_title } +
${ interview.key } % if not filename:
${ nicer_interview_filename(interview.filename) } % endif
${ interview.user_id }${ users_and_name_dict[interview.user_id] if interview.user_id else 'Anonymous'} ${ format_date(interview.modtime, "MMM d YYYY") }${ interview.num_keys }${ f"Step {interview.num_keys}" if not int(interview.progress) else f"{interview.progress}%" }  Join
 Vars + % if user_has_privilege('admin'): +
+  Delete + % endif