diff --git a/backend/reviews/adapters.py b/backend/reviews/adapters.py index e07863e137..2c05f8c2c9 100644 --- a/backend/reviews/adapters.py +++ b/backend/reviews/adapters.py @@ -467,6 +467,7 @@ def get_shortlist_context( all_reimbursement_categories=GrantReimbursementCategory.objects.for_conference( conference=review_session.conference ), + country_type_choices=[*Grant.CountryType.choices, ("", "Unknown")], review_session=review_session, title="Shortlist", ) diff --git a/backend/reviews/templates/grants-shortlist.html b/backend/reviews/templates/grants-shortlist.html index 7c2d883b7e..e5a3a689ee 100644 --- a/backend/reviews/templates/grants-shortlist.html +++ b/backend/reviews/templates/grants-shortlist.html @@ -452,34 +452,48 @@ }); }); - const filterByStatusInputs = [...document.querySelectorAll('input[name="filter-by-status"]')]; - filterByStatusInputs.forEach( - filterByStatusInput => { - filterByStatusInput.addEventListener('change', e => { - e.preventDefault(); - - const filterValue = e.target.value; - const visibleStatuses = filterByStatusInputs.filter( - input => input.checked - ).map( - input => input.value - ); - - document.querySelectorAll('.grant-item').forEach( - grantRow => { - const grantId = parseInt(grantRow.id.split('-')[1], 10); - const grantData = grantsById[grantId]; - - if (visibleStatuses.includes(grantData.originalStatus)) { - grantRow.classList.remove('hidden') - } else { - grantRow.classList.add('hidden') - } - } - ); - }); - } - ); + const getCheckedValues = (name) => + [...document.querySelectorAll(`input[name="${name}"]`)] + .filter(i => i.checked) + .map(i => i.value); + + // An empty checked list means the filter group is ignored (show all). + const matchesFilter = (checkedValues, rowValue) => + checkedValues.length === 0 || checkedValues.includes(rowValue); + + const applyFilters = () => { + const visibleStatuses = getCheckedValues('filter-by-status'); + const visibleNeedsFunds = getCheckedValues('filter-needs-funds-for-travel'); + const visibleNeedVisa = getCheckedValues('filter-need-visa'); + const visibleNeedAccommodation = getCheckedValues('filter-need-accommodation'); + const visibleCountryTypes = getCheckedValues('filter-country-type'); + + document.querySelectorAll('.grant-item').forEach(grantRow => { + const matches = + matchesFilter(visibleStatuses, grantRow.dataset.originalStatus) && + matchesFilter(visibleNeedsFunds, grantRow.dataset.needsFundsForTravel) && + matchesFilter(visibleNeedVisa, grantRow.dataset.needVisa) && + matchesFilter(visibleNeedAccommodation, grantRow.dataset.needAccommodation) && + matchesFilter(visibleCountryTypes, grantRow.dataset.countryType); + + grantRow.classList.toggle('hidden', !matches); + }); + }; + + [ + 'input[name="filter-by-status"]', + 'input[name="filter-needs-funds-for-travel"]', + 'input[name="filter-need-visa"]', + 'input[name="filter-need-accommodation"]', + 'input[name="filter-country-type"]', + ].forEach(selector => { + document.querySelectorAll(selector).forEach(input => { + input.addEventListener('change', applyFilters); + }); + }); + + // Sync visible rows with any restored checkbox state (e.g. bfcache back-nav). + applyFilters(); @@ -663,6 +677,56 @@

{% endfor %} +
+

Needs funds for travel:

+
+ + +
+
+
+

Need visa:

+
+ + +
+
+
+

Need accommodation:

+
+ + +
+
+
+

Country type:

+
+ {% for ct in country_type_choices %} + + {% endfor %} +
+
@@ -753,6 +817,10 @@

data-num-of-votes="{{ item.userreview_set.count }}" data-score="{{ item.score|default_if_none:'-999' }}" data-std-dev="{{ item.std_dev|default_if_none:'-1' }}" + data-needs-funds-for-travel="{{ item.needs_funds_for_travel|yesno:'true,false' }}" + data-need-visa="{{ item.need_visa|yesno:'true,false' }}" + data-need-accommodation="{{ item.need_accommodation|yesno:'true,false' }}" + data-country-type="{{ item.country_type|default:'' }}" > {{ forloop.counter }} diff --git a/backend/reviews/tests/test_admin.py b/backend/reviews/tests/test_admin.py index 611e04a8cf..28769c8586 100644 --- a/backend/reviews/tests/test_admin.py +++ b/backend/reviews/tests/test_admin.py @@ -857,13 +857,13 @@ def test_proposals_review_get_shortlist_context_with_multiple_submissions_per_sp # Create a speaker with multiple submissions speaker = UserFactory() - submission_1 = SubmissionFactory(conference=conference, speaker=speaker) - submission_2 = SubmissionFactory(conference=conference, speaker=speaker) - submission_3 = SubmissionFactory(conference=conference, speaker=speaker) + SubmissionFactory(conference=conference, speaker=speaker) + SubmissionFactory(conference=conference, speaker=speaker) + SubmissionFactory(conference=conference, speaker=speaker) # Create another speaker with only one submission single_speaker = UserFactory() - single_submission = SubmissionFactory(conference=conference, speaker=single_speaker) + SubmissionFactory(conference=conference, speaker=single_speaker) request = rf.get("/") request.user = user @@ -946,6 +946,98 @@ def test_proposals_review_get_review_context(rf): assert context["review_session_id"] == review_session.id +# --- GrantsReviewAdapter Tests --- + + +def test_grants_review_get_shortlist_context_includes_country_type_choices(rf): + user = UserFactory(is_staff=True, is_superuser=True) + conference = ConferenceFactory() + + review_session = ReviewSessionFactory( + conference=conference, + session_type=ReviewSession.SessionType.GRANTS, + status=ReviewSession.Status.COMPLETED, + ) + + request = rf.get("/") + request.user = user + + adapter = get_review_adapter(review_session) + items = adapter.get_shortlist_items_queryset(review_session).all() + context = adapter.get_shortlist_context(request, review_session, items, AdminSite()) + + assert "country_type_choices" in context + values = [ct[0] for ct in context["country_type_choices"]] + assert Grant.CountryType.italy in values + assert Grant.CountryType.europe in values + assert Grant.CountryType.extra_eu in values + # Sentinel for grants with no departure_country (country_type is null) + assert "" in values + + +def test_grants_review_get_shortlist_context_grant_with_no_departure_country(rf): + """Grant with departure_country=None has country_type=None, which must map + to the empty-string sentinel in country_type_choices so the template filter + value='""' matches data-country-type="".""" + user = UserFactory(is_staff=True, is_superuser=True) + conference = ConferenceFactory() + + review_session = ReviewSessionFactory( + conference=conference, + session_type=ReviewSession.SessionType.GRANTS, + status=ReviewSession.Status.COMPLETED, + ) + grant = GrantFactory( + conference=conference, + departure_country=None, + departure_city=None, + ) + + request = rf.get("/") + request.user = user + + adapter = get_review_adapter(review_session) + items = adapter.get_shortlist_items_queryset(review_session).all() + context = adapter.get_shortlist_context(request, review_session, items, AdminSite()) + + grant_item = next(item for item in context["items"] if item.id == grant.id) + assert grant_item.country_type is None + + # The template renders {{ item.country_type|default:'' }} which produces "". + # Confirm the Unknown sentinel in the choices list uses the same empty string. + sentinel_values = [ + ct[0] for ct in context["country_type_choices"] if ct[1] == "Unknown" + ] + assert sentinel_values == [""] + + +def test_grants_review_get_shortlist_context_needs_funds_for_travel(rf): + """needs_funds_for_travel is rendered via |yesno:'true,false' in the template. + Verify the queryset correctly exposes the boolean so the template filter + can produce the 'true'/'false' strings matched by the JS checkboxes.""" + user = UserFactory(is_staff=True, is_superuser=True) + conference = ConferenceFactory() + + review_session = ReviewSessionFactory( + conference=conference, + session_type=ReviewSession.SessionType.GRANTS, + status=ReviewSession.Status.COMPLETED, + ) + grant_needs_funds = GrantFactory(conference=conference, needs_funds_for_travel=True) + grant_no_funds = GrantFactory(conference=conference, needs_funds_for_travel=False) + + request = rf.get("/") + request.user = user + + adapter = get_review_adapter(review_session) + items = adapter.get_shortlist_items_queryset(review_session).all() + context = adapter.get_shortlist_context(request, review_session, items, AdminSite()) + + items_by_id = {item.id: item for item in context["items"]} + assert items_by_id[grant_needs_funds.id].needs_funds_for_travel is True + assert items_by_id[grant_no_funds.id].needs_funds_for_travel is False + + def test_get_review_adapter_with_invalid_session_type(): conference = ConferenceFactory() review_session = ReviewSessionFactory(