Skip to content

Commit 20e9985

Browse files
committed
Show only available filter options on CommunityLibraryList page
1 parent 91bd36c commit 20e9985

7 files changed

Lines changed: 226 additions & 35 deletions

File tree

contentcuration/contentcuration/frontend/channelList/views/Channel/CommunityLibraryList/CommunityLibraryFilters.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
v-model="countriesFilter"
1717
:label="countryLabel$()"
1818
:options="countryOptions"
19+
:disabled="disabled || !countryOptions.length"
1920
multiple
2021
clearable
2122
/>
@@ -24,6 +25,7 @@
2425
v-model="languagesFilter"
2526
:label="languagesLabel$()"
2627
:options="languageOptions"
28+
:disabled="disabled || !languageOptions.length"
2729
multiple
2830
clearable
2931
/>
@@ -32,6 +34,7 @@
3234
v-model="categoriesFilter"
3335
:label="categoriesLabel$()"
3436
:options="categoryOptions"
37+
:disabled="disabled || !categoryOptions.length"
3538
multiple
3639
clearable
3740
/>

contentcuration/contentcuration/frontend/channelList/views/Channel/CommunityLibraryList/index.vue

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@
203203

204204
<script>
205205
206-
import { computed, ref, watch } from 'vue';
206+
import { computed, onMounted, ref, watch } from 'vue';
207207
import { useRoute, useRouter } from 'vue-router/composables';
208208
import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow';
209209
import { themeTokens } from 'kolibri-design-system/lib/styles/theme';
@@ -214,7 +214,7 @@
214214
import useCommunityChannelsFilters from './useCommunityChannelsFilters';
215215
import AboutCommunityLibraryModal from './AboutCommunityLibraryModal.vue';
216216
import ChannelTokenModal from 'shared/views/channel/ChannelTokenModal';
217-
import { listPublicChannels } from 'shared/data/public';
217+
import { listPublicChannels, getPublicChannelLabels } from 'shared/data/public';
218218
import useStore from 'shared/composables/useStore';
219219
import StudioChip from 'shared/views/StudioChip';
220220
import Pagination from 'shared/views/Pagination';
@@ -277,6 +277,8 @@
277277
} = communityChannelsStrings;
278278
const { copyChannelTokenAction$ } = commonStrings;
279279
280+
const availableLabels = ref(null);
281+
280282
const {
281283
countriesFilter,
282284
categoriesFilter,
@@ -285,9 +287,11 @@
285287
keywordInput,
286288
clearSearch,
287289
removeFilterValue,
288-
} = useCommunityChannelsFilters();
290+
} = useCommunityChannelsFilters({ availableLabels });
289291
290-
const loading = ref(false);
292+
const loadingChannels = ref(false);
293+
const loadingLabels = ref(false);
294+
const loading = computed(() => loadingChannels.value || loadingLabels.value);
291295
const loadError = ref(false);
292296
const channels = ref([]);
293297
const showFiltersSidePanel = ref(false);
@@ -328,7 +332,7 @@
328332
});
329333
330334
async function loadCommunityLibrary() {
331-
loading.value = true;
335+
loadingChannels.value = true;
332336
loadError.value = false;
333337
try {
334338
const response = await listPublicChannels(fetchQueryParams.value);
@@ -343,7 +347,7 @@
343347
totalPages.value = 0;
344348
store.dispatch('errors/handleAxiosError', error);
345349
} finally {
346-
loading.value = false;
350+
loadingChannels.value = false;
347351
}
348352
}
349353
@@ -439,6 +443,18 @@
439443
{ immediate: true, deep: true },
440444
);
441445
446+
onMounted(async () => {
447+
loadingLabels.value = true;
448+
try {
449+
availableLabels.value = await getPublicChannelLabels({ public: false });
450+
} catch {
451+
// Silently ignore: filter options will show all values rather than
452+
// restricting to those that have channels.
453+
} finally {
454+
loadingLabels.value = false;
455+
}
456+
});
457+
442458
return {
443459
windowIsSmall,
444460
windowBreakpoint,

contentcuration/contentcuration/frontend/channelList/views/Channel/CommunityLibraryList/useCommunityChannelsFilters.js

Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { computed, inject, provide } from 'vue';
1+
import { computed, inject, provide, unref } from 'vue';
22
import { useFilter } from 'shared/composables/useFilter';
33
import { useKeywordSearch } from 'shared/composables/useKeywordSearch';
44
import countriesUtil from 'shared/utils/countries';
@@ -19,43 +19,46 @@ const FILTERS_QUERY_PARAMS = Symbol('filtersQueryParams');
1919
const REMOVE_FILTER_VALUE = Symbol('removeFilterValue');
2020
const CLEAR_SEARCH = Symbol('clearSearch');
2121

22-
export default function useCommunityChannelsFilters() {
22+
export default function useCommunityChannelsFilters({ availableLabels } = {}) {
2323
const countryFilterMap = computed(() => {
2424
const [lang] = currentLanguage.split('-');
2525
const allCountries = countriesUtil.getNames(lang);
26+
const _availableLabels = unref(availableLabels);
27+
const availableCodes = _availableLabels?.countries
28+
? new Set(_availableLabels.countries.map(c => c.code))
29+
: null;
2630
return Object.fromEntries(
27-
Object.entries(allCountries).map(([code, name]) => [
28-
code,
29-
{
30-
label: name,
31-
params: { countries: code },
32-
},
33-
]),
31+
Object.entries(allCountries)
32+
.filter(([code]) => !availableCodes || availableCodes.has(code))
33+
.map(([code, name]) => [code, { label: name, params: { countries: code } }]),
3434
);
3535
});
3636

3737
const languageFilterMap = computed(() => {
38+
const _availableLabels = unref(availableLabels);
39+
const availableIds = _availableLabels?.languages
40+
? new Set(_availableLabels.languages.map(l => l.id))
41+
: null;
3842
return Object.fromEntries(
39-
LanguagesList.map(lang => [
40-
lang.id,
41-
{
42-
label: lang.readable_name,
43-
params: { languages: lang.id },
44-
},
45-
]),
43+
LanguagesList.filter(lang => !availableIds || availableIds.has(lang.id))
44+
.sort((a, b) => a.readable_name.localeCompare(b.readable_name))
45+
.map(lang => [lang.id, { label: lang.readable_name, params: { languages: lang.id } }]),
4646
);
4747
});
4848

4949
const categoryFilterMap = computed(() => {
5050
const sortedCategories = getSortedCategories();
51+
const _availableLabels = unref(availableLabels);
52+
const availableCategories = _availableLabels?.categories
53+
? new Set(_availableLabels.categories)
54+
: null;
5155
return Object.fromEntries(
52-
Object.entries(sortedCategories).map(([value, name]) => [
53-
value,
54-
{
55-
label: translateMetadataString(name),
56-
params: { categories: value },
57-
},
58-
]),
56+
Object.entries(sortedCategories)
57+
.filter(([value]) => !availableCategories || availableCategories.has(value))
58+
.map(([value, name]) => [
59+
value,
60+
{ label: translateMetadataString(name), params: { categories: value } },
61+
]),
5962
);
6063
});
6164

@@ -67,15 +70,10 @@ export default function useCommunityChannelsFilters() {
6770

6871
const {
6972
filter: languagesFilter,
70-
options: _languageOptions,
73+
options: languageOptions,
7174
fetchQueryParams: languagesFetchQueryParams,
7275
} = useFilter({ name: 'languages', filterMap: languageFilterMap, multi: true });
7376

74-
const languageOptions = computed(() => {
75-
// Sort language options alphabetically by label
76-
return _languageOptions.value.sort((a, b) => a.label.localeCompare(b.label));
77-
});
78-
7977
const {
8078
filter: categoriesFilter,
8179
options: categoryOptions,

contentcuration/contentcuration/frontend/shared/data/public.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,22 @@ export function listPublicChannels(params = {}) {
165165
return client.get(urls.publicchannel_list(), { params }).then(response => response.data);
166166
}
167167

168+
/**
169+
* Get available filter label options for the public channel list.
170+
* Returns an object with available values for each filterable field
171+
* (categories, languages, countries).
172+
*
173+
* @param {Object} params
174+
* @return {Promise<{
175+
* categories: string[],
176+
* languages: Array<{id: string, lang_name: string}>,
177+
* countries: Array<{code: string, name: string}>
178+
* }>}
179+
*/
180+
export function getPublicChannelLabels(params = {}) {
181+
return client.get(urls.publicchannel_labels(), { params }).then(response => response.data);
182+
}
183+
168184
/**
169185
* Get a content node from the public API
170186
* @param {String} nodeId

contentcuration/kolibri_public/search.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,58 @@ def _get_available_channels(base_queryset):
101101
)
102102

103103

104+
def _get_available_channel_languages(base_queryset):
105+
from contentcuration.models import Language
106+
107+
return list(
108+
Language.objects.filter(public_channels__in=base_queryset)
109+
.values("id", lang_name=F("native_name"))
110+
.distinct()
111+
)
112+
113+
114+
def _get_available_countries(base_queryset):
115+
from contentcuration.models import Country
116+
117+
return list(
118+
Country.objects.filter(public_channels__in=base_queryset)
119+
.values("code", "name")
120+
.distinct()
121+
)
122+
123+
124+
def get_channel_available_metadata_labels(base_queryset):
125+
from kolibri_public.models import ChannelMetadata
126+
127+
content_cache_key = str(
128+
ChannelMetadata.objects.all().aggregate(updated=Max("last_updated"))["updated"]
129+
)
130+
cache_key = "channel-labels:{}:{}".format(
131+
content_cache_key,
132+
hashlib.md5(str(base_queryset.query).encode("utf8")).hexdigest(),
133+
)
134+
if cache_key not in cache:
135+
base_queryset = base_queryset.order_by()
136+
aggregates = {}
137+
for field in channelmetadata_bitmask_fieldnames:
138+
field_agg = field + "_agg"
139+
aggregates[field_agg] = BitOr(field)
140+
output = {}
141+
if aggregates:
142+
agg = base_queryset.aggregate(**aggregates)
143+
for field, values in channelmetadata_bitmask_fieldnames.items():
144+
bit_value = agg[field + "_agg"]
145+
for value in values:
146+
if value["field_name"] not in output:
147+
output[value["field_name"]] = []
148+
if bit_value is not None and bit_value & value["bits"]:
149+
output[value["field_name"]].append(value["label"])
150+
output["languages"] = _get_available_channel_languages(base_queryset)
151+
output["countries"] = _get_available_countries(base_queryset)
152+
cache.set(cache_key, output, timeout=None)
153+
return cache.get(cache_key)
154+
155+
104156
# Remove the SQLite Bitwise OR definition as not needed.
105157

106158

contentcuration/kolibri_public/tests/test_channelmetadata_viewset.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from uuid import UUID
22

3+
from django.urls import reverse
34
from kolibri_public.models import ChannelMetadata
45
from kolibri_public.tests.utils.mixer import KolibriPublicMixer
56
from le_utils.constants.labels.subjects import SUBJECTSLIST
@@ -293,3 +294,96 @@ def test_filter_languages_combined_with_search(self):
293294
ids = [UUID(item["id"]) for item in response.data]
294295
self.assertIn(UUID(self.channel_en.id), ids)
295296
self.assertNotIn(UUID(self.channel_multi.id), ids)
297+
298+
299+
class ChannelMetadataLabelsActionTestCase(StudioAPITestCase):
300+
def setUp(self):
301+
super().setUp()
302+
303+
mixer = KolibriPublicMixer()
304+
self.user = testdata.user("labels@user.com")
305+
306+
self.categories = [SUBJECTSLIST[0], SUBJECTSLIST[1]]
307+
308+
self.lang_en = Language.objects.get_or_create(
309+
id="en", defaults={"lang_code": "en", "readable_name": "English"}
310+
)[0]
311+
self.lang_fr = Language.objects.get_or_create(
312+
id="fr", defaults={"lang_code": "fr", "readable_name": "French"}
313+
)[0]
314+
315+
self.country_us = Country.objects.get_or_create(
316+
code="US", defaults={"name": "United States"}
317+
)[0]
318+
self.country_mx = Country.objects.get_or_create(
319+
code="MX", defaults={"name": "Mexico"}
320+
)[0]
321+
# Country not associated with any channel
322+
self.country_br = Country.objects.get_or_create(
323+
code="BR", defaults={"name": "Brazil"}
324+
)[0]
325+
326+
self.channel1 = mixer.blend(
327+
ChannelMetadata,
328+
categories_bitmask_0=1 | 2, # SUBJECTSLIST[0] and [1]
329+
public=False,
330+
)
331+
self.channel1.included_languages.add(self.lang_en)
332+
self.channel1.countries.add(self.country_us)
333+
334+
self.channel2 = mixer.blend(
335+
ChannelMetadata,
336+
categories_bitmask_0=2, # SUBJECTSLIST[1] only
337+
public=False,
338+
)
339+
self.channel2.included_languages.add(self.lang_fr)
340+
self.channel2.countries.add(self.country_mx)
341+
342+
def _labels(self, query=None):
343+
self.client.force_authenticate(self.user)
344+
url = reverse("publicchannel-labels")
345+
return self.client.get(url, query or {})
346+
347+
def test_labels_returns_200(self):
348+
response = self._labels({"public": "false"})
349+
self.assertEqual(response.status_code, 200, response.content)
350+
351+
def test_labels_returns_available_languages(self):
352+
response = self._labels({"public": "false"})
353+
language_ids = [lang["id"] for lang in response.data["languages"]]
354+
self.assertIn("en", language_ids)
355+
self.assertIn("fr", language_ids)
356+
357+
def test_labels_returns_available_countries(self):
358+
response = self._labels({"public": "false"})
359+
country_codes = [c["code"] for c in response.data["countries"]]
360+
self.assertIn("US", country_codes)
361+
self.assertIn("MX", country_codes)
362+
# Country not linked to any channel should not appear
363+
self.assertNotIn("BR", country_codes)
364+
365+
def test_labels_returns_available_categories(self):
366+
response = self._labels({"public": "false"})
367+
categories = response.data.get("categories", [])
368+
self.assertIn(self.categories[0], categories)
369+
self.assertIn(self.categories[1], categories)
370+
371+
def test_labels_respects_filter_params(self):
372+
# When filtering to only channel1's language, only channel1's country should appear
373+
response = self._labels({"public": "false", "languages": "en"})
374+
self.assertEqual(response.status_code, 200, response.content)
375+
country_codes = [c["code"] for c in response.data["countries"]]
376+
self.assertIn("US", country_codes)
377+
self.assertNotIn("MX", country_codes)
378+
379+
def test_labels_country_objects_have_code_and_name(self):
380+
response = self._labels({"public": "false"})
381+
for country in response.data["countries"]:
382+
self.assertIn("code", country)
383+
self.assertIn("name", country)
384+
385+
def test_labels_language_objects_have_id_and_lang_name(self):
386+
response = self._labels({"public": "false"})
387+
for lang in response.data["languages"]:
388+
self.assertIn("id", lang)
389+
self.assertIn("lang_name", lang)

0 commit comments

Comments
 (0)