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:

    {
      "Difficulty": {
        "filters": [
          "Beginner",
          "Intermediate"
        ],
        "icon": "fas fa-brain",
        "hidden": false,
        "filter_slugs": [
          "beginner",
          "intermediate"
        ],
        "slug": "difficulty"
      },
      "Type": {
        "filters": [
          "Book",
          "Community",
          "Course",
          "Interactive",
          "Podcast",
          "Project Ideas",
          "Tool",
          "Tutorial",
          "Video"
        ],
        "icon": "fas fa-photo-video",
        "hidden": false,
        "filter_slugs": [
          "book",
          "community",
          "course",
          "interactive",
          "podcast",
          "project-ideas",
          "tool",
          "tutorial",
          "video"
        ],
        "slug": "type"
      },
      "Payment tiers": {
        "filters": [
          "Free",
          "Paid",
          "Subscription"
        ],
        "icon": "fas fa-dollar-sign",
        "hidden": true,
        "filter_slugs": [
          "free",
          "paid",
          "subscription"
        ],
        "slug": "payment-tiers"
      },
      "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,
        "filter_slugs": [
          "algorithms-and-data-structures",
          "data-science",
          "databases",
          "discord-bots",
          "game-development",
          "general",
          "microcontrollers",
          "security",
          "software-design",
          "testing",
          "tooling",
          "user-interface",
          "web-development",
          "other"
        ],
        "slug": "topics"
      }
    }

Closes #710.
  • Loading branch information
jchristgit committed Jun 14, 2024
1 parent 54455ff commit fcc97ea
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 97 deletions.
86 changes: 0 additions & 86 deletions pydis_site/apps/resources/apps.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
from pathlib import Path

import yaml
from django.apps import AppConfig

from pydis_site import settings
from pydis_site.apps.resources.templatetags.to_kebabcase import to_kebabcase

RESOURCES_PATH = Path(settings.BASE_DIR, "pydis_site", "apps", "resources", "resources")

Expand All @@ -13,87 +11,3 @@ class ResourcesConfig(AppConfig):
"""AppConfig instance for Resources app."""

name = 'pydis_site.apps.resources'

@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


def ready(self) -> None:
"""Set up all the resources."""
# 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")
}

# 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,
}
}

# 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"]],
}
12 changes: 12 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,15 @@ 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('Difficulty', content)
self.assertIsInstance(content['Difficulty']['filter_slugs'], list)
self.assertEqual(content['Difficulty']['slug'], 'difficulty')
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"),
]
128 changes: 118 additions & 10 deletions pydis_site/apps/resources/views.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,116 @@
import json
import yaml

from django.apps import apps
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

from pydis_site.apps.resources.apps import RESOURCES_PATH
from pydis_site.apps.resources.templatetags.to_kebabcase import to_kebabcase

APP_NAME = "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,
},
}

# The bottom topic should always be "Other".
filters["Topics"]["filters"].remove("Other")
filters["Topics"]["filters"].append("Other")

# 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"]],
}

return (resources, filters, valid_filters)


class ResourceView(View):
"""Our curated list of good learning resources."""

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."""
# Add type filtering if the request is made to somewhere like /resources/video.
# We also convert all spaces to dashes, so they'll correspond with the filters.

app = apps.get_app_config(APP_NAME)

if resource_type:
dashless_resource_type = resource_type.replace("-", " ")

if dashless_resource_type.title() not in app.filters["Type"]["filters"]:
if dashless_resource_type.title() not in self.filters["Type"]["filters"]:
return HttpResponseNotFound()

resource_type = resource_type.replace(" ", "-")
Expand All @@ -32,9 +119,30 @@ def get(self, request: WSGIRequest, resource_type: str | None = None) -> HttpRes
request,
template_name="resources/resources.html",
context={
"resources": app.resources,
"filters": app.filters,
"valid_filters": json.dumps(app.valid_filters),
"resources": self.resources,
"filters": self.filters,
"valid_filters": json.dumps(self.valid_filters),
"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)
_, filters, _valid_filters = load_resources()
self.filters = {
name: {
**data,
"filter_slugs": tuple(to_kebabcase(name) for name in data["filters"]),
"slug": to_kebabcase(name),
}
for name, data in filters.items()
}

def get(self, request: WSGIRequest) -> HttpResponse:
"""Return resource filters as JSON."""
return JsonResponse(self.filters)

0 comments on commit fcc97ea

Please sign in to comment.