From ec9641492241f27a179a0c7c6724b934747268f5 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 10 Dec 2023 18:22:39 +0100 Subject: [PATCH] Add endpoint to fetch filters in JSON format While this is an API endpoint consumed by the bot, keep it in the `resources` app instead of the `api` app, as all the logic and data for resources is contained within the `resources` app and we don't want to start messing around with that. The response format from the endpoint is as follows: { "filters": { "Difficulty": { "filters": [ "Beginner", "Intermediate" ], "icon": "fas fa-brain", "hidden": false }, "Type": { "filters": [ "Book", "Community", "Course", "Interactive", "Podcast", "Project Ideas", "Tool", "Tutorial", "Video" ], "icon": "fas fa-photo-video", "hidden": false }, "Payment tiers": { "filters": [ "Free", "Paid", "Subscription" ], "icon": "fas fa-dollar-sign", "hidden": true }, "Topics": { "filters": [ "Algorithms and Data Structures", "Data Science", "Databases", "Discord Bots", "Game Development", "General", "Microcontrollers", "Security", "Software Design", "Testing", "Tooling", "User Interface", "Web Development", "Other" ], "icon": "fas fa-lightbulb", "hidden": true } }, "valid_filters": { "topics": [ "algorithms-and-data-structures", "data-science", "databases", "discord-bots", "game-development", "general", "microcontrollers", "security", "software-design", "testing", "tooling", "user-interface", "web-development", "other" ], "payment_tiers": [ "free", "paid", "subscription" ], "type": [ "book", "community", "course", "interactive", "podcast", "project-ideas", "tool", "tutorial", "video" ], "difficulty": [ "beginner", "intermediate" ] } } Closes #710. --- pydis_site/apps/resources/tests/test_views.py | 11 + pydis_site/apps/resources/urls.py | 3 +- pydis_site/apps/resources/views.py | 188 ++++++++++-------- 3 files changed, 119 insertions(+), 83 deletions(-) diff --git a/pydis_site/apps/resources/tests/test_views.py b/pydis_site/apps/resources/tests/test_views.py index a2a203ce9d..4906d56f91 100644 --- a/pydis_site/apps/resources/tests/test_views.py +++ b/pydis_site/apps/resources/tests/test_views.py @@ -27,3 +27,14 @@ def test_resources_with_invalid_argument(self): url = reverse("resources:index", kwargs={"resource_type": "urinal-cake"}) response = self.client.get(url) self.assertEqual(response.status_code, 404) + + +class TestResourceFilterView(TestCase): + def test_resource_filter_response(self): + """Check that the filter endpoint returns JSON-formatted filters.""" + url = reverse('resources:filters') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + content = response.json() + self.assertIn('filters', content) + self.assertIn('valid_filters', content) diff --git a/pydis_site/apps/resources/urls.py b/pydis_site/apps/resources/urls.py index cb33a9d701..3a10d1083b 100644 --- a/pydis_site/apps/resources/urls.py +++ b/pydis_site/apps/resources/urls.py @@ -1,11 +1,12 @@ from django_distill import distill_path -from pydis_site.apps.resources.views import ResourceView +from pydis_site.apps.resources.views import ResourceView, ResourceFilterView app_name = "resources" urlpatterns = [ # Using `distill_path` instead of `path` allows this to be available # in static preview builds. distill_path("", ResourceView.as_view(), name="index"), + distill_path("filters", ResourceFilterView.as_view(), name="filters"), distill_path("/", ResourceView.as_view(), name="index"), ] diff --git a/pydis_site/apps/resources/views.py b/pydis_site/apps/resources/views.py index a2cd8d0c9c..cf7b157f34 100644 --- a/pydis_site/apps/resources/views.py +++ b/pydis_site/apps/resources/views.py @@ -3,7 +3,7 @@ import yaml from django.core.handlers.wsgi import WSGIRequest -from django.http import HttpResponse, HttpResponseNotFound +from django.http import HttpResponse, HttpResponseNotFound, JsonResponse from django.shortcuts import render from django.views import View @@ -13,93 +13,99 @@ RESOURCES_PATH = Path(settings.BASE_DIR, "pydis_site", "apps", "resources", "resources") -class ResourceView(View): - """Our curated list of good learning resources.""" +def sort_key_disregard_the(tuple_: tuple) -> str: + """Sort a tuple by its key alphabetically, disregarding 'the' as a prefix.""" + name, resource = tuple_ + name = name.casefold() + if name.startswith(("the ", "the_")): + return name[4:] + return name + + +def load_resources() -> tuple[dict, dict, dict]: + """Return resources, filters, and valid filters as parsed from the resources data directory.""" + # Load the resources from the yaml files in /resources/ + resources = { + path.stem: yaml.safe_load(path.read_text()) + for path in RESOURCES_PATH.rglob("*.yaml") + } + + # Sort the resources alphabetically + resources = dict(sorted(resources.items(), key=sort_key_disregard_the)) + + # Parse out all current tags + resource_tags = { + "topics": set(), + "payment_tiers": set(), + "difficulty": set(), + "type": set(), + } + for resource_name, resource in resources.items(): + css_classes = [] + for tag_type in resource_tags: + # Store the tags into `resource_tags` + tags = resource.get("tags", {}).get(tag_type, []) + for tag in tags: + tag = tag.title() + tag = tag.replace("And", "and") + resource_tags[tag_type].add(tag) + + # Make a CSS class friendly representation too, while we're already iterating. + for tag in tags: + css_tag = to_kebabcase(f"{tag_type}-{tag}") + css_classes.append(css_tag) + + # Now add the css classes back to the resource, so we can use them in the template. + resources[resource_name]["css_classes"] = " ".join(css_classes) + + # Set up all the filter checkbox metadata + filters = { + "Difficulty": { + "filters": sorted(resource_tags.get("difficulty")), + "icon": "fas fa-brain", + "hidden": False, + }, + "Type": { + "filters": sorted(resource_tags.get("type")), + "icon": "fas fa-photo-video", + "hidden": False, + }, + "Payment tiers": { + "filters": sorted(resource_tags.get("payment_tiers")), + "icon": "fas fa-dollar-sign", + "hidden": True, + }, + "Topics": { + "filters": sorted(resource_tags.get("topics")), + "icon": "fas fa-lightbulb", + "hidden": True, + } + } - @staticmethod - def _sort_key_disregard_the(tuple_: tuple) -> str: - """Sort a tuple by its key alphabetically, disregarding 'the' as a prefix.""" - name, resource = tuple_ - name = name.casefold() - if name.startswith(("the ", "the_")): - return name[4:] - return name + # The bottom topic should always be "Other". + filters["Topics"]["filters"].remove("Other") + filters["Topics"]["filters"].append("Other") - def __init__(self, *args, **kwargs): - """Set up all the resources.""" - super().__init__(*args, **kwargs) + # A complete list of valid filter names + valid_filters = { + "topics": [to_kebabcase(topic) for topic in filters["Topics"]["filters"]], + "payment_tiers": [ + to_kebabcase(tier) for tier in filters["Payment tiers"]["filters"] + ], + "type": [to_kebabcase(type_) for type_ in filters["Type"]["filters"]], + "difficulty": [to_kebabcase(tier) for tier in filters["Difficulty"]["filters"]], + } - # Load the resources from the yaml files in /resources/ - self.resources = { - path.stem: yaml.safe_load(path.read_text()) - for path in RESOURCES_PATH.rglob("*.yaml") - } + return (resources, filters, valid_filters) - # Sort the resources alphabetically - self.resources = dict(sorted(self.resources.items(), key=self._sort_key_disregard_the)) - # Parse out all current tags - resource_tags = { - "topics": set(), - "payment_tiers": set(), - "difficulty": set(), - "type": set(), - } - for resource_name, resource in self.resources.items(): - css_classes = [] - for tag_type in resource_tags: - # Store the tags into `resource_tags` - tags = resource.get("tags", {}).get(tag_type, []) - for tag in tags: - tag = tag.title() - tag = tag.replace("And", "and") - resource_tags[tag_type].add(tag) - - # Make a CSS class friendly representation too, while we're already iterating. - for tag in tags: - css_tag = to_kebabcase(f"{tag_type}-{tag}") - css_classes.append(css_tag) - - # Now add the css classes back to the resource, so we can use them in the template. - self.resources[resource_name]["css_classes"] = " ".join(css_classes) - - # Set up all the filter checkbox metadata - self.filters = { - "Difficulty": { - "filters": sorted(resource_tags.get("difficulty")), - "icon": "fas fa-brain", - "hidden": False, - }, - "Type": { - "filters": sorted(resource_tags.get("type")), - "icon": "fas fa-photo-video", - "hidden": False, - }, - "Payment tiers": { - "filters": sorted(resource_tags.get("payment_tiers")), - "icon": "fas fa-dollar-sign", - "hidden": True, - }, - "Topics": { - "filters": sorted(resource_tags.get("topics")), - "icon": "fas fa-lightbulb", - "hidden": True, - } - } +class ResourceView(View): + """Our curated list of good learning resources.""" - # The bottom topic should always be "Other". - self.filters["Topics"]["filters"].remove("Other") - self.filters["Topics"]["filters"].append("Other") - - # A complete list of valid filter names - self.valid_filters = { - "topics": [to_kebabcase(topic) for topic in self.filters["Topics"]["filters"]], - "payment_tiers": [ - to_kebabcase(tier) for tier in self.filters["Payment tiers"]["filters"] - ], - "type": [to_kebabcase(type_) for type_ in self.filters["Type"]["filters"]], - "difficulty": [to_kebabcase(tier) for tier in self.filters["Difficulty"]["filters"]], - } + def __init__(self, *args, **kwargs): + """Set up all the resources.""" + super().__init__(*args, **kwargs) + self.resources, self.filters, self.valid_filters = load_resources() def get(self, request: WSGIRequest, resource_type: str | None = None) -> HttpResponse: """List out all the resources, and any filtering options from the URL.""" @@ -123,3 +129,21 @@ def get(self, request: WSGIRequest, resource_type: str | None = None) -> HttpRes "resource_type": resource_type, } ) + + +class ResourceFilterView(View): + """Exposes resource filters for the bot.""" + + def __init__(self, *args, **kwargs): + """Load resource filters.""" + super().__init__(*args, **kwargs) + _, self.filters, self.valid_filters = load_resources() + + def get(self, request: WSGIRequest) -> HttpResponse: + """Return resource filters as JSON.""" + return JsonResponse( + { + 'filters': self.filters, + 'valid_filters': self.valid_filters + } + )