Skip to content
Open
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
41 changes: 41 additions & 0 deletions backend/generic_forms/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,44 @@ def _validate_value(question: FormQuestion, value) -> list[str]:

def _option_ids(question: FormQuestion) -> set[str]:
return {option["id"] for option in question.options}


def display_answer_value(question: FormQuestion | None, value) -> str:
"""Human-readable rendering of a stored answer value (admin, exports)."""
if value is None:
return ""
if question is None:
return str(value)

types = FormQuestion.QuestionType
if question.question_type == types.BOOLEAN:
return "Yes" if value else "No"

option_labels = {option["id"]: option["label"] for option in question.options}
if question.question_type == types.SELECT:
return option_labels.get(value, str(value))
if question.question_type == types.MULTI_SELECT:
return ", ".join(option_labels.get(item, str(item)) for item in value)

return str(value)


def display_answers(form_answer) -> list[tuple[str, str]]:
"""(question label, human-readable value) pairs for the answered
questions, in question order. Answers to questions that no longer exist
are appended as "Question <id>".
"""
# copy: unwrap returns the stored dict and we pop from it below
answers = dict(unwrap_answers(form_answer.answers))
pairs = []

for question in form_answer.form.questions.all():
question_id = str(question.pk)
if question_id in answers:
value = display_answer_value(question, answers.pop(question_id))
pairs.append((question.label, value))

for question_id, value in answers.items():
pairs.append((f"Question {question_id}", display_answer_value(None, value)))

return pairs
62 changes: 61 additions & 1 deletion backend/generic_forms/tests/test_services.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import pytest

from generic_forms.models import FormQuestion
from generic_forms.services import unwrap_answers, validate_answers, wrap_answers
from generic_forms.services import (
display_answer_value,
display_answers,
unwrap_answers,
validate_answers,
wrap_answers,
)
from generic_forms.tests.factories import FormFactory, FormQuestionFactory

pytestmark = pytest.mark.django_db
Expand Down Expand Up @@ -217,3 +223,57 @@ def test_unwrap_answers_rejects_malformed_envelopes():
unwrap_answers({"version": 1})
with pytest.raises(ValueError, match="missing answers map"):
unwrap_answers({"version": 1, "answers": "not a dict"})


def test_display_answer_value_formats_by_question_type():
select = FormQuestionFactory(
question_type=FormQuestion.QuestionType.SELECT, options=OPTIONS
)
multi = FormQuestionFactory(
question_type=FormQuestion.QuestionType.MULTI_SELECT, options=OPTIONS
)
boolean = FormQuestionFactory(question_type=FormQuestion.QuestionType.BOOLEAN)
text = FormQuestionFactory(question_type=FormQuestion.QuestionType.TEXT)

assert display_answer_value(select, "vegan") == "Vegan"
assert display_answer_value(select, "unknown-id") == "unknown-id"
assert display_answer_value(multi, ["vegan", "veggie"]) == "Vegan, Veggie"
assert display_answer_value(boolean, True) == "Yes"
assert display_answer_value(boolean, False) == "No"
assert display_answer_value(text, "hello") == "hello"
assert display_answer_value(text, None) == ""


def test_display_answers_pairs_labels_with_values_in_question_order():
from generic_forms.tests.factories import FormAnswerFactory

form = FormFactory()
second = FormQuestionFactory(form=form, label="Second", order=1)
first = FormQuestionFactory(
form=form,
label="First",
order=0,
question_type=FormQuestion.QuestionType.BOOLEAN,
)
FormQuestionFactory(form=form, label="Unanswered", order=2)
inactive = FormQuestionFactory(
form=form, label="Deactivated", order=3, active=False
)
answer = FormAnswerFactory(
form=form,
answers=wrap_answers(
{
str(second.pk): "text answer",
str(first.pk): True,
str(inactive.pk): "historical",
"9999": "orphan",
}
),
)

assert display_answers(answer) == [
("First", "Yes"),
("Second", "text answer"),
("Deactivated", "historical"),
("Question 9999", "orphan"),
]
54 changes: 54 additions & 0 deletions backend/grants/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@
from django.db.models.query import QuerySet
from django.urls import reverse
from django.utils import timezone
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe
from import_export.admin import ExportMixin
from import_export.fields import Field
from import_export.resources import ModelResource

from conferences.models.conference_voucher import ConferenceVoucher
from generic_forms.models import Form
from generic_forms.services import (
display_answer_value,
display_answers,
unwrap_answers,
)
from conferences.vouchers import create_conference_voucher
from countries import countries
from countries.filters import CountryFilter
Expand Down Expand Up @@ -147,6 +154,11 @@ def dehydrate_submission_admin_link(self, obj):
def dehydrate_grant_admin_link(self, obj: Grant):
return f"https://admin.pycon.it/admin/grants/grant/?q={'+'.join(obj.full_name.split(' '))}" # noqa: E501

def __init__(self, **kwargs):
super().__init__(**kwargs)
# dynamic per-question export columns, filled by before_export
self._question_fields = {}

def before_export(self, queryset: QuerySet, *args, **kwargs):
super().before_export(queryset, *args, **kwargs)
conference_id = queryset.values_list("conference_id").first()
Expand All @@ -163,8 +175,33 @@ def before_export(self, queryset: QuerySet, *args, **kwargs):
self.USERS_SUBMISSIONS.setdefault(submission.speaker_id, [])
self.USERS_SUBMISSIONS[submission.speaker_id].append(submission)

self._add_form_question_fields(conference_id)

return queryset

def _add_form_question_fields(self, conference_id):
# one column per question of the conference's grant form (the export
# is single-conference); before_export runs before headers are built
form = Form.objects.filter(
conference_id=conference_id, purpose=Form.Purpose.GRANT
).first()
if form is None:
return

for question in form.questions.all():
key = f"form_question_{question.pk}"
self.fields[key] = Field(column_name=question.label)
self._question_fields[key] = question

def export_field(self, field, obj):
question = self._question_fields.get(self.get_field_name(field))
if question is not None:
if obj.form_answer_id is None:
return ""
answers = unwrap_answers(obj.form_answer.answers)
return display_answer_value(question, answers.get(str(question.pk)))
return super().export_field(field, obj)

class Meta:
model = Grant
fields = EXPORT_GRANTS_FIELDS
Expand Down Expand Up @@ -525,6 +562,7 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
]
autocomplete_fields = ("user",)
inlines = [GrantReimbursementInline]
readonly_fields = ("dynamic_answers",)

fieldsets = (
(
Expand Down Expand Up @@ -570,11 +608,27 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
"been_to_other_events",
"community_contribution",
"notes",
"dynamic_answers",
)
},
),
)

@admin.display(description="Dynamic form answers")
def dynamic_answers(self, obj):
if obj.form_answer_id is None:
return "No dynamic answers"
pairs = display_answers(obj.form_answer)
if not pairs:
return "No dynamic answers"
return format_html(
"<dl>{}</dl>",
format_html_join("", "<dt><strong>{}</strong></dt><dd>{}</dd>", pairs),
)

def get_export_queryset(self, request):
return super().get_export_queryset(request).select_related("form_answer")

def save_model(self, request, obj, form, change):
"""
Override to log admin actions when status is changed.
Expand Down
101 changes: 101 additions & 0 deletions backend/grants/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,17 @@

from conferences.models.conference_voucher import ConferenceVoucher
from conferences.tests.factories import ConferenceFactory, ConferenceVoucherFactory
from generic_forms.models import Form, FormQuestion
from generic_forms.services import wrap_answers
from generic_forms.tests.factories import (
FormAnswerFactory,
FormFactory,
FormQuestionFactory,
)
from grants.admin import (
GrantAdmin,
GrantReimbursementAdmin,
GrantResource,
confirm_pending_status,
create_grant_vouchers,
mark_rejected_and_send_email,
Expand Down Expand Up @@ -794,3 +802,96 @@ def test_save_grant_in_admin_logs_audit_log_entry_for_pending_status_change(
object_id=grant.id,
change_message="Pending status changed from 'pending' to 'confirmed'.",
).exists()


def test_dynamic_answers_shows_labels_and_formatted_values():
grant = GrantFactory()
form = FormFactory(conference=grant.conference, purpose=Form.Purpose.GRANT)
why = FormQuestionFactory(
form=form,
label="Why do you need it?",
question_type=FormQuestion.QuestionType.TEXTAREA,
order=0,
)
diet = FormQuestionFactory(
form=form,
label="Diet",
question_type=FormQuestion.QuestionType.MULTI_SELECT,
options=[
{"id": "vegan", "label": "Vegan"},
{"id": "veggie", "label": "Veggie"},
],
order=1,
)
grant.form_answer = FormAnswerFactory(
form=form,
user=grant.user,
answers=wrap_answers({str(why.pk): "My motivation", str(diet.pk): ["vegan"]}),
)
grant.save()

html = str(GrantAdmin(Grant, AdminSite()).dynamic_answers(grant))

assert "Why do you need it?" in html
assert "My motivation" in html
assert "Diet" in html
assert "Vegan" in html


def test_dynamic_answers_has_an_empty_state_for_legacy_grants():
grant = GrantFactory()

assert (
str(GrantAdmin(Grant, AdminSite()).dynamic_answers(grant))
== "No dynamic answers"
)


def _export_grants(conference):
queryset = Grant.objects.filter(conference=conference).order_by("id")
return GrantResource().export(queryset=queryset)


def test_export_includes_one_column_per_form_question():
conference = ConferenceFactory()
form = FormFactory(conference=conference, purpose=Form.Purpose.GRANT)
why = FormQuestionFactory(
form=form,
label="Why do you need it?",
question_type=FormQuestion.QuestionType.TEXTAREA,
order=0,
)
boolean = FormQuestionFactory(
form=form,
label="First time?",
question_type=FormQuestion.QuestionType.BOOLEAN,
order=1,
)
answered = GrantFactory(conference=conference)
answered.form_answer = FormAnswerFactory(
form=form,
user=answered.user,
answers=wrap_answers({str(why.pk): "My motivation", str(boolean.pk): True}),
)
answered.save()
GrantFactory(conference=conference) # legacy grant, no FormAnswer

dataset = _export_grants(conference)

assert "Why do you need it?" in dataset.headers
assert "First time?" in dataset.headers
rows = dataset.dict
assert rows[0]["Why do you need it?"] == "My motivation"
assert rows[0]["First time?"] == "Yes"
assert rows[1]["Why do you need it?"] == ""
assert rows[1]["First time?"] == ""


def test_export_without_a_form_has_no_extra_columns():
conference = ConferenceFactory()
GrantFactory(conference=conference)

dataset = _export_grants(conference)

assert "Why do you need it?" not in dataset.headers
assert "created" in dataset.headers
Loading