Skip to content

Commit

Permalink
Add endpoint to fetch filters in JSON format
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jchristgit committed Dec 10, 2023
1 parent 42cd247 commit ec96414
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 83 deletions.
11 changes: 11 additions & 0 deletions pydis_site/apps/resources/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
3 changes: 2 additions & 1 deletion pydis_site/apps/resources/urls.py
Original file line number Diff line number Diff line change
@@ -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("<resource_type>/", ResourceView.as_view(), name="index"),
]
188 changes: 106 additions & 82 deletions pydis_site/apps/resources/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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."""
Expand All @@ -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
}
)

0 comments on commit ec96414

Please sign in to comment.