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
22 changes: 13 additions & 9 deletions exp/tests/test_response_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,14 +204,15 @@ def setUp(self):
),
reverse("exp:study-attachments", kwargs={"pk": self.study.pk}),
]
# For testing researcher-editable response fields: researcher_session_status, researcher_payment_status, researcher_star
# For testing researcher-editable response fields: researcher_session_status, researcher_payment_status, researcher_star, is_valid
self.editable_fields = StudyResponseSetResearcherFields.EDITABLE_FIELDS
default_values = [
"",
"",
False,
] # These correspond to session status, payment status, and star
new_values = ["follow_up", "to_pay", True]
True,
] # These correspond to session status, payment status, star, and valid response
new_values = ["follow_up", "to_pay", True, False]
self.fields_default_values = {
self.editable_fields[i]: default_values[i]
for i in range(len(self.editable_fields))
Expand Down Expand Up @@ -678,7 +679,8 @@ def setUp(self):
"",
"",
False,
] # These correspond to session status, payment status, and star
True,
] # These correspond to session status, payment status, star, and valid response
self.fields_default_values = {
self.editable_fields[i]: default_values[i]
for i in range(len(self.editable_fields))
Expand Down Expand Up @@ -1457,15 +1459,16 @@ def setUp(self):
action="accepted",
arbiter=self.other_researcher,
)
# For testing researcher-editable response fields: researcher_session_status, researcher_payment_status, researcher_star
# For testing researcher-editable response fields: researcher_session_status, researcher_payment_status, researcher_star, is_valid
self.editable_fields = StudyResponseSetResearcherFields.EDITABLE_FIELDS
default_values = [
"",
"",
False,
] # These correspond to session status, payment status, and star
new_values = ["follow_up", "to_pay", True]
invalid_values = ["some_other_string", 42, "true"]
True,
] # These correspond to session status, payment status, star, and valid response
new_values = ["follow_up", "to_pay", True, False]
invalid_values = ["some_other_string", 42, "true", "not_a_bool"]
self.fields_default_values = {
self.editable_fields[i]: default_values[i]
for i in range(len(self.editable_fields))
Expand Down Expand Up @@ -1517,11 +1520,12 @@ def test_update_fails_with_invalid_values(self):
url = reverse(
"exp:study-responses-researcher-update", kwargs={"pk": self.study.pk}
)
# These correspond to the fields: session status, payment status, star
# These correspond to the fields: session status, payment status, star, valid response
err_strings = [
"Invalid request: Session Status must be one of ",
"Invalid request: Payment Status must be one of ",
"Invalid request: Star field must be a boolean value.",
"Invalid request: Valid Response must be a boolean value.",
]
fields_err_strings = {
self.editable_fields[i]: err_strings[i]
Expand Down
12 changes: 11 additions & 1 deletion exp/views/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def get_response_headers(
}
selected_standard_header_ids = [
col.id
for col in RESPONSE_COLUMNS[0:-2]
for col in RESPONSE_COLUMNS[0:-3]
if col.id not in unselected_optional_ids
]
return selected_standard_header_ids + sorted(
Expand Down Expand Up @@ -879,6 +879,7 @@ def get_context_data(self, **kwargs):
"response__researcher_payment_status",
"response__researcher_session_status",
"response__researcher_star",
"response__is_valid",
]

context["session_status_options"] = list(Response.SESSION_STATUS_CHOICES)
Expand Down Expand Up @@ -1120,6 +1121,7 @@ class StudyResponseSetResearcherFields(
"researcher_session_status",
"researcher_payment_status",
"researcher_star",
"is_valid",
]

def user_can_edit_response(self):
Expand Down Expand Up @@ -1195,6 +1197,14 @@ def post(self, request, *args, **kwargs):
{"error": "Invalid request: Star field must be a boolean value."},
status=400,
)
elif field_id == self.EDITABLE_FIELDS[3]:
if not isinstance(value, bool):
return JsonResponse(
{
"error": "Invalid request: Valid Response must be a boolean value."
},
status=400,
)

# Try updating the Response object
try:
Expand Down
6 changes: 6 additions & 0 deletions exp/views/responses_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,12 @@ class ResponseDataColumn(NamedTuple):
extractor=lambda resp: resp.researcher_star,
name="Star",
),
ResponseDataColumn(
id="response__is_valid",
description="Whether this response is counted as valid",
extractor=lambda resp: resp.is_valid,
name="Valid Response",
),
]

# Columns for demographic data downloads. Extractor functions expect Response values dict,
Expand Down
23 changes: 22 additions & 1 deletion scss/study-responses.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,32 @@ select.researcher-editable:disabled {
cursor: not-allowed;
}

input[type="checkbox"].researcher-editable:disabled+label .icon-star {
input[type="checkbox"].researcher-editable:disabled+label .icon-star,
input[type="checkbox"].researcher-editable:disabled+label .icon-valid-check,
input[type="checkbox"].researcher-editable:disabled+label .icon-valid-xmark {
color: lightgray;
fill: lightgray;
cursor: not-allowed;
}

// Valid/invalid response icons: show check or xmark, only one at a time
.icon-valid-check,
.icon-valid-xmark {
display: none;
}

.icon-valid-check.icon-valid-filled {
display: inline;
color: var(--bs-success);
fill: var(--bs-success);
}

.icon-valid-xmark.icon-invalid-filled {
display: inline;
color: $red;
fill: $red;
}

// Response info box
.truncate-parent-feedback {
overflow: hidden;
Expand Down
66 changes: 66 additions & 0 deletions studies/migrations/0106_add_researcher_valid_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from django.db import migrations, models

REJECTED = "rejected"
EXTERNAL_STUDY_TYPE_ID = 2


def compute_is_valid(apps, schema_editor):
"""Compute is_valid for all existing responses using the valid_response_count criteria.

A response is valid if:
- is_preview is False
- eligibility is "Eligible" or blank/empty

For internal studies, responses must also:
- completed is True
- completed_consent_frame is True
- the most recent consent ruling is not "rejected"
"""
Response = apps.get_model("studies", "Response")
ConsentRuling = apps.get_model("studies", "ConsentRuling")

# Step 1: Mark all preview responses as invalid
Response.objects.filter(is_preview=True).update(is_valid=False)

# Step 2: Mark responses with ineligible eligibility as invalid
# Valid eligibility: empty list OR contains "Eligible"
# Invalid: non-empty list that doesn't contain "Eligible"
Response.objects.exclude(
models.Q(eligibility=[]) | models.Q(eligibility__contains=["Eligible"])
).update(is_valid=False)

# Step 3: For internal studies only, mark incomplete responses and responses without consent frames as invalid
Response.objects.exclude(study__study_type_id=EXTERNAL_STUDY_TYPE_ID).filter(
models.Q(completed=False) | models.Q(completed_consent_frame=False)
).update(is_valid=False)

# Step 4: For internal studies, mark responses with rejected consent as invalid
# Get the most recent consent ruling for each response using a subquery
newest_ruling_subquery = models.Subquery(
ConsentRuling.objects.filter(response=models.OuterRef("pk"))
.order_by("-created_at")
.values("action")[:1]
)
rejected_response_ids = list(
Response.objects.exclude(study__study_type_id=EXTERNAL_STUDY_TYPE_ID)
.annotate(current_ruling=newest_ruling_subquery)
.filter(current_ruling=REJECTED)
.values_list("id", flat=True)
)
if rejected_response_ids:
Response.objects.filter(id__in=rejected_response_ids).update(is_valid=False)


class Migration(migrations.Migration):
dependencies = [
("studies", "0105_add_max_responses_to_study"),
]

operations = [
migrations.AddField(
model_name="response",
name="is_valid",
field=models.BooleanField(default=True),
),
migrations.RunPython(compute_is_valid, migrations.RunPython.noop),
]
2 changes: 1 addition & 1 deletion studies/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1172,7 +1172,6 @@ class Response(models.Model):
("communication_complete", _("Communication complete")),
("withdrawn_closed", _("Withdrawn or closed")),
)

uuid = models.UUIDField(default=uuid.uuid4, unique=True, db_index=True)
study = models.ForeignKey(
Study, on_delete=models.PROTECT, related_name="responses"
Expand Down Expand Up @@ -1214,6 +1213,7 @@ class Response(models.Model):
choices=SESSION_STATUS_CHOICES, max_length=22, blank=True
)
researcher_star = models.BooleanField(default=False)
is_valid = models.BooleanField(default=True)

def __str__(self):
return self.display_name
Expand Down
46 changes: 41 additions & 5 deletions studies/templates/studies/study_responses.html
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,24 @@
id="response-{{ forloop.counter }}"
data-response-id="{{ response.response__id }}"
data-response-uuid="{{ response.response__uuid }}">
<td>
{% if response.response__is_preview %}P{% endif %}
<td data-sort="{{response.response__is_valid}}"
data-filter="{% if response.response__is_preview %}Preview{% elif response.response__is_valid %}Valid &#10004;{% else %}Invalid &#10008;{% endif %}">
{% if response.response__is_preview %}
P
{% else %}
<input type="checkbox"
name="is-valid"
data-field="is_valid"
id="valid-checkbox-{{ forloop.counter }}"
class="researcher-editable input-checkbox-hidden valid-checkbox"
aria-label="Toggle valid response status"
{% if response.response__is_valid %}checked{% endif %}
{% if not can_edit_feedback %}disabled{% endif %} />
<label for="valid-checkbox-{{ forloop.counter }}">
<svg class="icon-valid-check {% if response.response__is_valid %}icon-valid-filled{% endif %}" xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>
<svg class="icon-valid-xmark {% if not response.response__is_valid %}icon-invalid-filled{% endif %}" xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM175 175c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z"/></svg>
</label>
{% endif %}
</td>
<td>{{ response.child_id_slug }}</td>
<td>{{ response.response__id }}</td>
Expand Down Expand Up @@ -169,7 +185,8 @@
{% endfor %}
</select>
</td>
<td data-sort="{{response.response__researcher_star}}">
<td data-sort="{{response.response__researcher_star}}"
data-filter="{% if response.response__researcher_star %}Starred &#9733;{% else %}Unstarred &#9734;{% endif %}">
<input type="checkbox"
name="star"
id="star-checkbox-{{ forloop.counter }}"
Expand All @@ -186,7 +203,18 @@
</tbody>
<tfoot>
<tr>
<th scope="col"></th>
<th scope="col">
<select name="filter-valid-status"
id="filter-valid-status"
class="form-select"
autocomplete="off"
aria-label="Filter on preview or valid response status">
<option value="" selected=""></option>
<option value="preview">Preview</option>
<option value="valid">Valid &#10004;</option>
<option value="invalid">Invalid &#10008;</option>
</select>
</th>
<th scope="col">
<input type="text" class="form-control" placeholder="Filter Child ID" />
</th>
Expand Down Expand Up @@ -242,7 +270,15 @@
</select>
</th>
<th scope="col">
{% comment %} Empty footer element for the star column {% endcomment %}
<select name="filter-star-status"
id="filter-star-status"
class="form-select"
autocomplete="off"
aria-label="Filter on star status">
<option value="" selected=""></option>
<option value="starred">Starred &#9733;</option>
<option value="unstarred">Unstarred &#9734;</option>
</select>
</th>
</tr>
</tfoot>
Expand Down
19 changes: 18 additions & 1 deletion web/static/custom_bootstrap5.css
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,27 @@ select.researcher-editable:disabled {
background-color: lightgray;
cursor: not-allowed; }

input[type="checkbox"].researcher-editable:disabled + label .icon-star {
input[type="checkbox"].researcher-editable:disabled + label .icon-star,
input[type="checkbox"].researcher-editable:disabled + label .icon-valid-check,
input[type="checkbox"].researcher-editable:disabled + label .icon-valid-xmark {
color: lightgray;
fill: lightgray;
cursor: not-allowed; }

.icon-valid-check,
.icon-valid-xmark {
display: none; }

.icon-valid-check.icon-valid-filled {
display: inline;
color: var(--bs-success);
fill: var(--bs-success); }

.icon-valid-xmark.icon-invalid-filled {
display: inline;
color: #ff5f5c;
fill: #ff5f5c; }

.truncate-parent-feedback {
overflow: hidden;
text-overflow: ellipsis;
Expand Down
16 changes: 12 additions & 4 deletions web/static/js/study-responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ $('.researcher-editable').change(
const target = event.target;
target.disabled = true;
const currentResponseId = target.closest('tr').dataset.responseId;
const fieldName = 'researcher_' + target.name.replace("-", "_");
// The Star element's value is "on"/"off" but the database needs a boolean
const fieldValue = (target.name == "star") ? target.checked : target.value;
const fieldName = target.dataset.field || ('researcher_' + target.name.replace("-", "_"));
// Checkbox elements' values are "on"/"off" but the database needs a boolean
const fieldValue = (target.type === "checkbox") ? target.checked : target.value;
const data = {
responseId: currentResponseId,
field: fieldName,
Expand Down Expand Up @@ -210,7 +210,7 @@ const resp_table = $("#individualResponsesTable").DataTable({
order: [[2, 'desc']], // Sort on "Response ID" column (most recent first). Sorting on Date doesn't work as expected.
columnDefs: [
{ className: "column-text-search", targets: [1, 2, 4] }, // add class to text search columns
{ className: "column-dropdown-search", targets: [5, 6, 7] }, // add class to dropdown search columns
{ className: "column-dropdown-search", targets: [0, 5, 6, 7, 8] }, // add class to dropdown search columns
// This tells datatables to sort "Time Elapsed" and "Date" by "Response ID" column's data. Date sorting isn't working (it doesn't take the full timestamp into account). The right way to do this is to pass the full timestamp into the template and tell datatables how to parse and display it, but I couldn't get that to work (docs https://datatables.net/examples/datetime/.)
{ orderData: 2, targets: [2, 3, 4] },
{ targets: 3, type: 'date', render: dateColRender}
Expand Down Expand Up @@ -238,6 +238,14 @@ function updateAJAXCellData(target) {
el.classList.toggle('icon-star-filled')
})
td.dataset.sort = "False" == td.dataset.sort ? "True" : "False"
// JS uses unicode rather than HTML &# chars. &#9733; = \u2605, &#9734; = \u2606
td.dataset.filter = target.checked ? "Starred \u2605" : "Unstarred \u2606"
} else if (classes.contains("valid-checkbox")) {
td.querySelector('.icon-valid-check').classList.toggle('icon-valid-filled')
td.querySelector('.icon-valid-xmark').classList.toggle('icon-invalid-filled')
td.dataset.sort = "False" == td.dataset.sort ? "True" : "False"
// JS uses unicode rather than HTML &# chars. &#10004; = \u2714, &#10008; = \u2718
td.dataset.filter = target.checked ? "Valid \u2714" : "Invalid \u2718"
}

resp_table.rows().invalidate("dom").draw(false);
Expand Down