Skip to content
Merged
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
1 change: 1 addition & 0 deletions backend/reviews/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand Down
124 changes: 96 additions & 28 deletions backend/reviews/templates/grants-shortlist.html
Original file line number Diff line number Diff line change
Expand Up @@ -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();



Expand Down Expand Up @@ -663,6 +677,56 @@ <h3>
{% endfor %}
</div>
</div>
<div class="opt-filter">
<h3>Needs funds for travel:</h3>
<div>
<label>
<input checked type="checkbox" name="filter-needs-funds-for-travel" value="true" />
<span>Yes</span>
</label>
<label>
<input checked type="checkbox" name="filter-needs-funds-for-travel" value="false" />
<span>No</span>
</label>
</div>
</div>
<div class="opt-filter">
<h3>Need visa:</h3>
<div>
<label>
<input checked type="checkbox" name="filter-need-visa" value="true" />
<span>Yes</span>
</label>
<label>
<input checked type="checkbox" name="filter-need-visa" value="false" />
<span>No</span>
</label>
</div>
</div>
<div class="opt-filter">
<h3>Need accommodation:</h3>
<div>
<label>
<input checked type="checkbox" name="filter-need-accommodation" value="true" />
<span>Yes</span>
</label>
<label>
<input checked type="checkbox" name="filter-need-accommodation" value="false" />
<span>No</span>
</label>
</div>
</div>
<div class="opt-filter">
<h3>Country type:</h3>
<div>
{% for ct in country_type_choices %}
<label>
<input checked type="checkbox" name="filter-country-type" value="{{ ct.0 }}" />
<span>{{ ct.1 }}</span>
</label>
{% endfor %}
</div>
</div>
</div>
<div class="module filtered" id="changelist">
<div class="changelist-form-container">
Expand Down Expand Up @@ -753,6 +817,10 @@ <h3>
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:'' }}"
>
<td>{{ forloop.counter }}</td>
<td class="results-item">
Expand Down
100 changes: 96 additions & 4 deletions backend/reviews/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading