Skip to content

Commit

Permalink
Merge pull request #139 from SuffolkLITLab/answer-browsing-improvements
Browse files Browse the repository at this point in the history
Improvements to view answer dashboard - allow sharing on a per-interview basis, show more detail, and allow deleting sessions
  • Loading branch information
nonprofittechy authored May 16, 2024
2 parents 1ecf86b + d5d357a commit 747aadc
Show file tree
Hide file tree
Showing 4 changed files with 230 additions and 34 deletions.
67 changes: 43 additions & 24 deletions docassemble/ALDashboard/aldashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
word,
DAFileList,
get_config,
user_has_privilege,
)
from ruamel.yaml import YAML
from ruamel.yaml.compat import StringIO
Expand Down Expand Up @@ -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]]:
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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]

Expand Down
70 changes: 61 additions & 9 deletions docassemble/ALDashboard/data/questions/list_sessions.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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: |
Expand All @@ -50,34 +87,41 @@ question: |
Recently started sessions for all users
% endif
subquestion: |
<table class="table">
<thead>
<tr>
<th>Session ID</th>
<th>Title and session ID</th>
<th>User</th>
<th>Modified</th>
<th>Page</th>
<th>Step / Progress</th>
<th>Actions</th>
</tr>
</thead>
% for interview in sessions_list:
% for interview in speedy_get_sessions(user_id=chosen_user, filename=filename, filter_step1=filter_step1):
<tr>
<td class="text-wrap text-break">
<strong>${ interview.title if interview.title else interview.auto_title }</strong>
<br/>
${ interview.key }
% if not filename:
<br/>
${ nicer_interview_filename(interview.filename) }
% endif
</td>
<td>${ interview.user_id }</td>
<td>${ users_and_name_dict[interview.user_id] if interview.user_id else 'Anonymous'}</td>
<td>${ format_date(interview.modtime, "MMM d YYYY") }</td>
<td>${ interview.num_keys }</td>
<td>${ f"Step {interview.num_keys}" if not int(interview.progress) else f"{interview.progress}%" }</td>
<td>
<a href="${ interview_url(i=interview.filename, session=interview.key) }">
<i class="fa-solid fa-folder-open"></i>&nbsp;Join</a>
<br/>
<a href="${ interview_url_action('view_session_variables', session_id=interview.key, filename=interview.filename) }"><i class="fa-solid fa-eye"></i>&nbsp;Vars</a>
% if user_has_privilege('admin'):
<br/>
<a href="${ url_action('delete_session', session_id=interview.key, filename=interview.filename) }"><i class="fa-solid fa-trash"></i>&nbsp;Delete</a>
% endif
</td>
</tr>
% endfor
Expand All @@ -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")
123 changes: 123 additions & 0 deletions docassemble/ALDashboard/data/questions/manage_answer_viewers.yml
Original file line number Diff line number Diff line change
@@ -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:
<h2 class="h4">${ privilege }</h2>
${ 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
4 changes: 3 additions & 1 deletion docassemble/ALDashboard/data/questions/menu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 747aadc

Please sign in to comment.