Repository: python-discord/site

Issue:
Only read resource yaml files once on startup
Currently resources are loaded and parsed etc. in the `__init__` of the resources view here:
https://github.com/python-discord/site/blob/4ce61bddff9fd459b769305f302dcd9eb8ded395/pydis_site/apps/resources/views.py#L28-L36

I assume the intention here is for this to be run once on application startup, but in reality it seems this is run every time the `/resources` view is accessed. This leads to non-ideal performance. From sentry:
![image](https://github.com/python-discord/site/assets/22353562/b1604cd6-3e8d-4979-ac1e-4df2439fb6da)

Cloudflare caching makes this issue less noticeable on the actual website, but it will still be an issue sometimes so is probably worth improving.


Discussion:
This can be solved using django [AppConfig.ready() method](https://docs.djangoproject.com/en/5.0/ref/applications/#django.apps.AppConfig.ready). If no one is working on it, I can patch it.

Resolving patch:
diff --git a/pydis_site/apps/resources/apps.py b/pydis_site/apps/resources/apps.py
--- a/pydis_site/apps/resources/apps.py
+++ b/pydis_site/apps/resources/apps.py
@@ -1,7 +1,99 @@
+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")
+
 
 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"]],
+        }
diff --git a/pydis_site/apps/resources/views.py b/pydis_site/apps/resources/views.py
--- a/pydis_site/apps/resources/views.py
+++ b/pydis_site/apps/resources/views.py
@@ -1,114 +1,29 @@
 import json
-from pathlib import Path
 
-import yaml
+from django.apps import apps
 from django.core.handlers.wsgi import WSGIRequest
 from django.http import HttpResponse, HttpResponseNotFound
 from django.shortcuts import render
 from django.views import View
 
-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")
+APP_NAME = "resources"
 
 
 class ResourceView(View):
     """Our curated list of good learning 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 __init__(self, *args, **kwargs):
-        """Set up all the resources."""
-        super().__init__(*args, **kwargs)
-
-        # 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"]],
-        }
-
     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 self.filters["Type"]["filters"]:
+            if dashless_resource_type.title() not in app.filters["Type"]["filters"]:
                 return HttpResponseNotFound()
 
             resource_type = resource_type.replace(" ", "-")
@@ -117,9 +32,9 @@ def get(self, request: WSGIRequest, resource_type: str | None = None) -> HttpRes
             request,
             template_name="resources/resources.html",
             context={
-                "resources": self.resources,
-                "filters": self.filters,
-                "valid_filters": json.dumps(self.valid_filters),
+                "resources": app.resources,
+                "filters": app.filters,
+                "valid_filters": json.dumps(app.valid_filters),
                 "resource_type": resource_type,
             }
         )


Test patch:
diff --git a/pydis_site/apps/resources/tests/test_resource_data.py b/pydis_site/apps/resources/tests/test_resource_data.py
--- a/pydis_site/apps/resources/tests/test_resource_data.py
+++ b/pydis_site/apps/resources/tests/test_resource_data.py
@@ -1,7 +1,7 @@
 import yaml
 from django.test import TestCase
 
-from pydis_site.apps.resources.views import RESOURCES_PATH
+from pydis_site.apps.resources.apps import RESOURCES_PATH
 
 
 class TestResourceData(TestCase):