aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--poetry.lock25
-rw-r--r--pydis_site/apps/api/github_utils.py4
-rw-r--r--pydis_site/apps/api/tests/test_github_utils.py7
-rw-r--r--pydis_site/apps/content/migrations/0001_add_tags.py35
-rw-r--r--pydis_site/apps/content/migrations/__init__.py0
-rw-r--r--pydis_site/apps/content/models/__init__.py3
-rw-r--r--pydis_site/apps/content/models/tag.py80
-rw-r--r--pydis_site/apps/content/resources/tags/_info.yml3
-rw-r--r--pydis_site/apps/content/tests/test_utils.py289
-rw-r--r--pydis_site/apps/content/tests/test_views.py220
-rw-r--r--pydis_site/apps/content/urls.py25
-rw-r--r--pydis_site/apps/content/utils.py288
-rw-r--r--pydis_site/apps/content/views/__init__.py3
-rw-r--r--pydis_site/apps/content/views/page_category.py14
-rw-r--r--pydis_site/apps/content/views/tags.py124
-rw-r--r--pydis_site/apps/home/views/home.py8
-rw-r--r--pydis_site/apps/redirect/urls.py2
-rw-r--r--pydis_site/settings.py12
-rw-r--r--pydis_site/static/css/content/color.css7
-rw-r--r--pydis_site/static/css/content/tag.css13
-rw-r--r--pydis_site/static/js/content/listing.js41
-rw-r--r--pydis_site/templates/base/navbar.html3
-rw-r--r--pydis_site/templates/content/base.html6
-rw-r--r--pydis_site/templates/content/listing.html27
-rw-r--r--pydis_site/templates/content/page.html8
-rw-r--r--pydis_site/templates/content/tag.html40
-rw-r--r--pydis_site/urls.py4
-rw-r--r--pyproject.toml1
28 files changed, 1243 insertions, 49 deletions
diff --git a/poetry.lock b/poetry.lock
index a9e2d1fc..08679f25 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -38,7 +38,7 @@ python-versions = ">=3.5"
dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"]
docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"]
tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"]
-tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"]
+tests_no_zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"]
[[package]]
name = "bandit"
@@ -95,7 +95,7 @@ optional = false
python-versions = ">=3.6.0"
[package.extras]
-unicode-backport = ["unicodedata2"]
+unicode_backport = ["unicodedata2"]
[[package]]
name = "colorama"
@@ -650,6 +650,17 @@ docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"]
tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
[[package]]
+name = "pymdown-extensions"
+version = "9.7"
+description = "Extension pack for Python Markdown."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+markdown = ">=3.2"
+
+[[package]]
name = "python-dotenv"
version = "0.21.0"
description = "Read key-value pairs from a .env file and set them as environment variables"
@@ -707,7 +718,7 @@ urllib3 = ">=1.21.1,<1.27"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
-use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "rfc3986"
@@ -746,7 +757,7 @@ falcon = ["falcon (>=1.4)"]
fastapi = ["fastapi (>=0.79.0)"]
flask = ["blinker (>=1.1)", "flask (>=0.11)"]
httpx = ["httpx (>=0.16.0)"]
-pure-eval = ["asttokens", "executing", "pure-eval"]
+pure_eval = ["asttokens", "executing", "pure-eval"]
pyspark = ["pyspark (>=2.4.4)"]
quart = ["blinker (>=1.1)", "quart (>=0.16.1)"]
rq = ["rq (>=0.6)"]
@@ -909,7 +920,7 @@ brotli = ["Brotli"]
[metadata]
lock-version = "1.1"
python-versions = "3.10.*"
-content-hash = "34d02f4c1b50a7fd657a5cc139c6c73011d3765915d52e9ba715e34b7a007c9a"
+content-hash = "862b3fbd60b2f0757340a9810a5979329b93041fbd560cf3c950205e3f2f63b7"
[metadata.files]
anyio = [
@@ -1359,6 +1370,10 @@ PyJWT = [
{file = "PyJWT-2.5.0-py3-none-any.whl", hash = "sha256:8d82e7087868e94dd8d7d418e5088ce64f7daab4b36db654cbaedb46f9d1ca80"},
{file = "PyJWT-2.5.0.tar.gz", hash = "sha256:e77ab89480905d86998442ac5788f35333fa85f65047a534adc38edf3c88fc3b"},
]
+pymdown-extensions = [
+ {file = "pymdown_extensions-9.7-py3-none-any.whl", hash = "sha256:767d07d9dead0f52f5135545c01f4ed627f9a7918ee86c646d893e24c59db87d"},
+ {file = "pymdown_extensions-9.7.tar.gz", hash = "sha256:651b0107bc9ee790aedea3673cb88832c0af27d2569cf45c2de06f1d65292e96"},
+]
python-dotenv = [
{file = "python-dotenv-0.21.0.tar.gz", hash = "sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045"},
{file = "python_dotenv-0.21.0-py3-none-any.whl", hash = "sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5"},
diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py
index 986c64e1..44c571c3 100644
--- a/pydis_site/apps/api/github_utils.py
+++ b/pydis_site/apps/api/github_utils.py
@@ -11,8 +11,6 @@ from pydis_site import settings
MAX_RUN_TIME = datetime.timedelta(minutes=10)
"""The maximum time allowed before an action is declared timed out."""
-ISO_FORMAT_STRING = "%Y-%m-%dT%H:%M:%SZ"
-"""The datetime string format GitHub uses."""
class ArtifactProcessingError(Exception):
@@ -147,7 +145,7 @@ def authorize(owner: str, repo: str) -> httpx.Client:
def check_run_status(run: WorkflowRun) -> str:
"""Check if the provided run has been completed, otherwise raise an exception."""
- created_at = datetime.datetime.strptime(run.created_at, ISO_FORMAT_STRING)
+ created_at = datetime.datetime.strptime(run.created_at, settings.GITHUB_TIMESTAMP_FORMAT)
run_time = datetime.datetime.utcnow() - created_at
if run.status != "completed":
diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py
index 2eaf48d9..95bafec0 100644
--- a/pydis_site/apps/api/tests/test_github_utils.py
+++ b/pydis_site/apps/api/tests/test_github_utils.py
@@ -11,6 +11,7 @@ import rest_framework.response
import rest_framework.test
from django.urls import reverse
+from pydis_site import settings
from .. import github_utils
@@ -49,7 +50,7 @@ class CheckRunTests(unittest.TestCase):
"head_sha": "sha",
"status": "completed",
"conclusion": "success",
- "created_at": datetime.datetime.utcnow().strftime(github_utils.ISO_FORMAT_STRING),
+ "created_at": datetime.datetime.utcnow().strftime(settings.GITHUB_TIMESTAMP_FORMAT),
"artifacts_url": "url",
}
@@ -74,7 +75,7 @@ class CheckRunTests(unittest.TestCase):
# to guarantee the right conclusion
kwargs["created_at"] = (
datetime.datetime.utcnow() - github_utils.MAX_RUN_TIME - datetime.timedelta(minutes=10)
- ).strftime(github_utils.ISO_FORMAT_STRING)
+ ).strftime(settings.GITHUB_TIMESTAMP_FORMAT)
with self.assertRaises(github_utils.RunTimeoutError):
github_utils.check_run_status(github_utils.WorkflowRun(**kwargs))
@@ -178,7 +179,7 @@ class ArtifactFetcherTests(unittest.TestCase):
run = github_utils.WorkflowRun(
name="action_name",
head_sha="action_sha",
- created_at=datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING),
+ created_at=datetime.datetime.now().strftime(settings.GITHUB_TIMESTAMP_FORMAT),
status="completed",
conclusion="success",
artifacts_url="artifacts_url"
diff --git a/pydis_site/apps/content/migrations/0001_add_tags.py b/pydis_site/apps/content/migrations/0001_add_tags.py
new file mode 100644
index 00000000..2c31e4c1
--- /dev/null
+++ b/pydis_site/apps/content/migrations/0001_add_tags.py
@@ -0,0 +1,35 @@
+# Generated by Django 4.0.6 on 2022-08-23 09:06
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Commit',
+ fields=[
+ ('sha', models.CharField(help_text='The SHA hash of this commit.', max_length=40, primary_key=True, serialize=False)),
+ ('message', models.TextField(help_text='The commit message.')),
+ ('date', models.DateTimeField(help_text='The date and time the commit was created.')),
+ ('authors', models.TextField(help_text='The person(s) who created the commit. This is a serialized JSON object. Refer to the GitHub documentation on the commit endpoint (schema/commit.author & schema/commit.committer) for more info. https://docs.github.com/en/rest/commits/commits#get-a-commit')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Tag',
+ fields=[
+ ('last_updated', models.DateTimeField(auto_now=True, help_text='The date and time this data was last fetched.')),
+ ('sha', models.CharField(help_text="The tag's hash, as calculated by GitHub.", max_length=40)),
+ ('name', models.CharField(help_text="The tag's name.", max_length=50, primary_key=True, serialize=False)),
+ ('group', models.CharField(help_text='The group the tag belongs to.', max_length=50, null=True)),
+ ('body', models.TextField(help_text='The content of the tag.')),
+ ('last_commit', models.ForeignKey(help_text='The commit this file was last touched in.', null=True, on_delete=django.db.models.deletion.CASCADE, to='content.commit')),
+ ],
+ ),
+ ]
diff --git a/pydis_site/apps/content/migrations/__init__.py b/pydis_site/apps/content/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/pydis_site/apps/content/migrations/__init__.py
diff --git a/pydis_site/apps/content/models/__init__.py b/pydis_site/apps/content/models/__init__.py
new file mode 100644
index 00000000..60007e27
--- /dev/null
+++ b/pydis_site/apps/content/models/__init__.py
@@ -0,0 +1,3 @@
+from .tag import Commit, Tag
+
+__all__ = ["Commit", "Tag"]
diff --git a/pydis_site/apps/content/models/tag.py b/pydis_site/apps/content/models/tag.py
new file mode 100644
index 00000000..1a20d775
--- /dev/null
+++ b/pydis_site/apps/content/models/tag.py
@@ -0,0 +1,80 @@
+import collections.abc
+import json
+
+from django.db import models
+
+
+class Commit(models.Model):
+ """A git commit from the Python Discord Bot project."""
+
+ URL_BASE = "https://github.com/python-discord/bot/commit/"
+
+ sha = models.CharField(
+ help_text="The SHA hash of this commit.",
+ primary_key=True,
+ max_length=40,
+ )
+ message = models.TextField(help_text="The commit message.")
+ date = models.DateTimeField(help_text="The date and time the commit was created.")
+ authors = models.TextField(help_text=(
+ "The person(s) who created the commit. This is a serialized JSON object. "
+ "Refer to the GitHub documentation on the commit endpoint "
+ "(schema/commit.author & schema/commit.committer) for more info. "
+ "https://docs.github.com/en/rest/commits/commits#get-a-commit"
+ ))
+
+ @property
+ def url(self) -> str:
+ """The URL to the commit on GitHub."""
+ return self.URL_BASE + self.sha
+
+ def lines(self) -> collections.abc.Iterable[str]:
+ """Return each line in the commit message."""
+ for line in self.message.split("\n"):
+ yield line
+
+ def format_authors(self) -> collections.abc.Iterable[str]:
+ """Return a nice representation of the author(s)' name and email."""
+ for author in json.loads(self.authors):
+ yield f"{author['name']} <{author['email']}>"
+
+
+class Tag(models.Model):
+ """A tag from the python-discord bot repository."""
+
+ URL_BASE = "https://github.com/python-discord/bot/tree/main/bot/resources/tags"
+
+ last_updated = models.DateTimeField(
+ help_text="The date and time this data was last fetched.",
+ auto_now=True,
+ )
+ sha = models.CharField(
+ help_text="The tag's hash, as calculated by GitHub.",
+ max_length=40,
+ )
+ last_commit = models.ForeignKey(
+ Commit,
+ help_text="The commit this file was last touched in.",
+ null=True,
+ on_delete=models.CASCADE,
+ )
+ name = models.CharField(
+ help_text="The tag's name.",
+ primary_key=True,
+ max_length=50,
+ )
+ group = models.CharField(
+ help_text="The group the tag belongs to.",
+ null=True,
+ max_length=50,
+ )
+ body = models.TextField(help_text="The content of the tag.")
+
+ @property
+ def url(self) -> str:
+ """Get the URL of the tag on GitHub."""
+ url = Tag.URL_BASE
+ if self.group:
+ url += f"/{self.group}"
+ url += f"/{self.name}.md"
+ return url
diff --git a/pydis_site/apps/content/resources/tags/_info.yml b/pydis_site/apps/content/resources/tags/_info.yml
new file mode 100644
index 00000000..054125ec
--- /dev/null
+++ b/pydis_site/apps/content/resources/tags/_info.yml
@@ -0,0 +1,3 @@
+title: Tags
+description: Useful snippets that are often used in the server.
+icon: fas fa-tags
diff --git a/pydis_site/apps/content/tests/test_utils.py b/pydis_site/apps/content/tests/test_utils.py
index be5ea897..462818b5 100644
--- a/pydis_site/apps/content/tests/test_utils.py
+++ b/pydis_site/apps/content/tests/test_utils.py
@@ -1,12 +1,34 @@
+import datetime
+import json
+import tarfile
+import tempfile
+import textwrap
from pathlib import Path
+from unittest import mock
+import httpx
+import markdown
from django.http import Http404
+from django.test import TestCase
-from pydis_site.apps.content import utils
+from pydis_site import settings
+from pydis_site.apps.content import models, utils
from pydis_site.apps.content.tests.helpers import (
BASE_PATH, MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA
)
+_time = datetime.datetime(2022, 10, 10, 10, 10, 10, tzinfo=datetime.timezone.utc)
+_time_str = _time.strftime(settings.GITHUB_TIMESTAMP_FORMAT)
+TEST_COMMIT_KWARGS = {
+ "sha": "123",
+ "message": "Hello world\n\nThis is a commit message",
+ "date": _time,
+ "authors": json.dumps([
+ {"name": "Author 1", "email": "[email protected]", "date": _time_str},
+ {"name": "Author 2", "email": "[email protected]", "date": _time_str},
+ ]),
+}
+
class GetCategoryTests(MockPagesTestCase):
"""Tests for the get_category function."""
@@ -96,3 +118,268 @@ class GetPageTests(MockPagesTestCase):
def test_get_nonexistent_page_returns_404(self):
with self.assertRaises(Http404):
utils.get_page(Path(BASE_PATH, "invalid"))
+
+
+class TagUtilsTests(TestCase):
+ """Tests for the tag-related utilities."""
+
+ def setUp(self) -> None:
+ super().setUp()
+ self.commit = models.Commit.objects.create(**TEST_COMMIT_KWARGS)
+
+ @mock.patch.object(utils, "fetch_tags")
+ def test_static_fetch(self, fetch_mock: mock.Mock):
+ """Test that the static fetch function is only called at most once during static builds."""
+ tags = [models.Tag(name="Name", body="body")]
+ fetch_mock.return_value = tags
+ result = utils.get_tags_static()
+ second_result = utils.get_tags_static()
+
+ fetch_mock.assert_called_once()
+ self.assertEqual(tags, result)
+ self.assertEqual(tags, second_result)
+
+ @mock.patch("httpx.Client.get")
+ def test_mocked_fetch(self, get_mock: mock.Mock):
+ """Test that proper data is returned from fetch, but with a mocked API response."""
+ fake_request = httpx.Request("GET", "https://google.com")
+
+ # Metadata requests
+ returns = [httpx.Response(
+ request=fake_request,
+ status_code=200,
+ json=[
+ {"type": "file", "name": "first_tag.md", "sha": "123"},
+ {"type": "file", "name": "second_tag.md", "sha": "456"},
+ {"type": "dir", "name": "some_group", "sha": "789", "url": "/some_group"},
+ ]
+ ), httpx.Response(
+ request=fake_request,
+ status_code=200,
+ json=[{"type": "file", "name": "grouped_tag.md", "sha": "789123"}]
+ )]
+
+ # Main content request
+ bodies = (
+ "This is the first tag!",
+ textwrap.dedent("""
+ ---
+ frontmatter: empty
+ ---
+ This tag has frontmatter!
+ """),
+ "This is a grouped tag!",
+ )
+
+ # Generate a tar archive with a few tags
+ with tempfile.TemporaryDirectory() as tar_folder:
+ tar_folder = Path(tar_folder)
+ with tempfile.TemporaryDirectory() as folder:
+ folder = Path(folder)
+ (folder / "ignored_file.md").write_text("This is an ignored file.")
+ tags_folder = folder / "bot/resources/tags"
+ tags_folder.mkdir(parents=True)
+
+ (tags_folder / "first_tag.md").write_text(bodies[0])
+ (tags_folder / "second_tag.md").write_text(bodies[1])
+
+ group_folder = tags_folder / "some_group"
+ group_folder.mkdir()
+ (group_folder / "grouped_tag.md").write_text(bodies[2])
+
+ with tarfile.open(tar_folder / "temp.tar", "w") as file:
+ file.add(folder, recursive=True)
+
+ body = (tar_folder / "temp.tar").read_bytes()
+
+ returns.append(httpx.Response(
+ status_code=200,
+ content=body,
+ request=fake_request,
+ ))
+
+ get_mock.side_effect = returns
+ result = utils.fetch_tags()
+
+ def sort(_tag: models.Tag) -> str:
+ return _tag.name
+
+ self.assertEqual(sorted([
+ models.Tag(name="first_tag", body=bodies[0], sha="123"),
+ models.Tag(name="second_tag", body=bodies[1], sha="245"),
+ models.Tag(name="grouped_tag", body=bodies[2], group=group_folder.name, sha="789123"),
+ ], key=sort), sorted(result, key=sort))
+
+ def test_get_real_tag(self):
+ """Test that a single tag is returned if it exists."""
+ tag = models.Tag.objects.create(name="real-tag", last_commit=self.commit)
+ result = utils.get_tag("real-tag")
+
+ self.assertEqual(tag, result)
+
+ def test_get_grouped_tag(self):
+ """Test fetching a tag from a group."""
+ tag = models.Tag.objects.create(
+ name="real-tag", group="real-group", last_commit=self.commit
+ )
+ result = utils.get_tag("real-group/real-tag")
+
+ self.assertEqual(tag, result)
+
+ def test_get_group(self):
+ """Test fetching a group of tags."""
+ included = [
+ models.Tag.objects.create(name="tag-1", group="real-group"),
+ models.Tag.objects.create(name="tag-2", group="real-group"),
+ models.Tag.objects.create(name="tag-3", group="real-group"),
+ ]
+
+ models.Tag.objects.create(name="not-included-1")
+ models.Tag.objects.create(name="not-included-2", group="other-group")
+
+ result = utils.get_tag("real-group")
+ self.assertListEqual(included, result)
+
+ def test_get_tag_404(self):
+ """Test that an error is raised when we fetch a non-existing tag."""
+ models.Tag.objects.create(name="real-tag")
+ with self.assertRaises(models.Tag.DoesNotExist):
+ utils.get_tag("fake")
+
+ @mock.patch.object(utils, "get_tag_category")
+ def test_category_pages(self, get_mock: mock.Mock):
+ """Test that the category pages function calls the correct method for tags."""
+ tag = models.Tag.objects.create(name="tag")
+ get_mock.return_value = tag
+ result = utils.get_category_pages(settings.CONTENT_PAGES_PATH / "tags")
+ self.assertEqual(tag, result)
+ get_mock.assert_called_once_with(collapse_groups=True)
+
+ def test_get_category_root(self):
+ """Test that all tags are returned and formatted properly for the tag root page."""
+ body = "normal body"
+ base = {"description": markdown.markdown(body), "icon": "fas fa-tag"}
+
+ models.Tag.objects.create(name="tag-1", body=body),
+ models.Tag.objects.create(name="tag-2", body=body),
+ models.Tag.objects.create(name="tag-3", body=body),
+
+ models.Tag.objects.create(name="tag-4", body=body, group="tag-group")
+ models.Tag.objects.create(name="tag-5", body=body, group="tag-group")
+
+ result = utils.get_tag_category(collapse_groups=True)
+
+ self.assertDictEqual({
+ "tag-1": {**base, "title": "tag-1"},
+ "tag-2": {**base, "title": "tag-2"},
+ "tag-3": {**base, "title": "tag-3"},
+ "tag-group": {
+ "title": "tag-group",
+ "description": "Contains the following tags: tag-4, tag-5",
+ "icon": "fas fa-tags"
+ }
+ }, result)
+
+ def test_get_category_group(self):
+ """Test the function for a group root page."""
+ body = "normal body"
+ base = {"description": markdown.markdown(body), "icon": "fas fa-tag"}
+
+ included = [
+ models.Tag.objects.create(name="tag-1", body=body, group="group"),
+ models.Tag.objects.create(name="tag-2", body=body, group="group"),
+ ]
+ models.Tag.objects.create(name="not-included", body=body)
+
+ result = utils.get_tag_category(included, collapse_groups=False)
+ self.assertDictEqual({
+ "tag-1": {**base, "title": "tag-1"},
+ "tag-2": {**base, "title": "tag-2"},
+ }, result)
+
+ def test_tag_url(self):
+ """Test that tag URLs are generated correctly."""
+ cases = [
+ ({"name": "tag"}, f"{models.Tag.URL_BASE}/tag.md"),
+ ({"name": "grouped", "group": "abc"}, f"{models.Tag.URL_BASE}/abc/grouped.md"),
+ ]
+
+ for options, url in cases:
+ tag = models.Tag(**options)
+ with self.subTest(tag=tag):
+ self.assertEqual(url, tag.url)
+
+ @mock.patch("httpx.Client.get")
+ def test_get_tag_commit(self, get_mock: mock.Mock):
+ """Test the get commit function with a normal tag."""
+ tag = models.Tag.objects.create(name="example")
+
+ authors = json.loads(self.commit.authors)
+
+ get_mock.return_value = httpx.Response(
+ request=httpx.Request("GET", "https://google.com"),
+ status_code=200,
+ json=[{
+ "sha": self.commit.sha,
+ "commit": {
+ "message": self.commit.message,
+ "author": authors[0],
+ "committer": authors[1],
+ }
+ }]
+ )
+
+ result = utils.get_tag(tag.name)
+ self.assertEqual(tag, result)
+
+ get_mock.assert_called_once()
+ call_params = get_mock.call_args[1]["params"]
+
+ self.assertEqual({"path": "/bot/resources/tags/example.md"}, call_params)
+ self.assertEqual(self.commit, models.Tag.objects.get(name=tag.name).last_commit)
+
+ @mock.patch("httpx.Client.get")
+ def test_get_group_tag_commit(self, get_mock: mock.Mock):
+ """Test the get commit function with a group tag."""
+ tag = models.Tag.objects.create(name="example", group="group-name")
+
+ authors = json.loads(self.commit.authors)
+ authors.pop()
+ self.commit.authors = json.dumps(authors)
+ self.commit.save()
+
+ get_mock.return_value = httpx.Response(
+ request=httpx.Request("GET", "https://google.com"),
+ status_code=200,
+ json=[{
+ "sha": self.commit.sha,
+ "commit": {
+ "message": self.commit.message,
+ "author": authors[0],
+ "committer": authors[0],
+ }
+ }]
+ )
+
+ utils.set_tag_commit(tag)
+
+ get_mock.assert_called_once()
+ call_params = get_mock.call_args[1]["params"]
+
+ self.assertEqual({"path": "/bot/resources/tags/group-name/example.md"}, call_params)
+ self.assertEqual(self.commit, models.Tag.objects.get(name=tag.name).last_commit)
+
+ @mock.patch.object(utils, "set_tag_commit")
+ def test_exiting_commit(self, set_commit_mock: mock.Mock):
+ """Test that a commit is saved when the data has not changed."""
+ tag = models.Tag.objects.create(name="tag-name", body="old body", last_commit=self.commit)
+
+ # This is only applied to the object, not to the database
+ tag.last_commit = None
+
+ utils.record_tags([tag])
+ self.assertEqual(self.commit, tag.last_commit)
+
+ result = utils.get_tag("tag-name")
+ self.assertEqual(tag, result)
+ set_commit_mock.assert_not_called()
diff --git a/pydis_site/apps/content/tests/test_views.py b/pydis_site/apps/content/tests/test_views.py
index a09d22d8..3ef9bcc4 100644
--- a/pydis_site/apps/content/tests/test_views.py
+++ b/pydis_site/apps/content/tests/test_views.py
@@ -1,12 +1,18 @@
+import textwrap
from pathlib import Path
from unittest import TestCase
+import django.test
+import markdown
from django.http import Http404
from django.test import RequestFactory, SimpleTestCase, override_settings
+from django.urls import reverse
+from pydis_site.apps.content.models import Commit, Tag
from pydis_site.apps.content.tests.helpers import (
BASE_PATH, MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA
)
+from pydis_site.apps.content.tests.test_utils import TEST_COMMIT_KWARGS
from pydis_site.apps.content.views import PageOrCategoryView
@@ -180,3 +186,217 @@ class PageOrCategoryViewTests(MockPagesTestCase, SimpleTestCase, TestCase):
{"name": PARSED_CATEGORY_INFO["title"], "path": Path("category/subcategory")},
]
)
+
+
+class TagViewTests(django.test.TestCase):
+ """Tests for the TagView class."""
+
+ def setUp(self):
+ """Set test helpers, then set up fake filesystem."""
+ super().setUp()
+ self.commit = Commit.objects.create(**TEST_COMMIT_KWARGS)
+
+ def test_routing(self):
+ """Test that the correct template is returned for each route."""
+ Tag.objects.create(name="example", last_commit=self.commit)
+ Tag.objects.create(name="grouped-tag", group="group-name", last_commit=self.commit)
+
+ cases = [
+ ("/pages/tags/example/", "content/tag.html"),
+ ("/pages/tags/group-name/", "content/listing.html"),
+ ("/pages/tags/group-name/grouped-tag/", "content/tag.html"),
+ ]
+
+ for url, template in cases:
+ with self.subTest(url=url):
+ response = self.client.get(url)
+ self.assertEqual(200, response.status_code)
+ self.assertTemplateUsed(response, template)
+
+ def test_valid_tag_returns_200(self):
+ """Test that a page is returned for a valid tag."""
+ Tag.objects.create(name="example", body="This is the tag body.", last_commit=self.commit)
+ response = self.client.get("/pages/tags/example/")
+ self.assertEqual(200, response.status_code)
+ self.assertIn("This is the tag body", response.content.decode("utf-8"))
+ self.assertTemplateUsed(response, "content/tag.html")
+
+ def test_invalid_tag_404(self):
+ """Test that a tag which doesn't exist raises a 404."""
+ response = self.client.get("/pages/tags/non-existent/")
+ self.assertEqual(404, response.status_code)
+
+ def test_context_tag(self):
+ """Test that the context contains the required data for a tag."""
+ body = textwrap.dedent("""
+ ---
+ unused: frontmatter
+ ----
+ Tag content here.
+ """)
+
+ tag = Tag.objects.create(name="example", body=body, last_commit=self.commit)
+ response = self.client.get("/pages/tags/example/")
+ expected = {
+ "page_title": "example",
+ "page": markdown.markdown("Tag content here."),
+ "tag": tag,
+ "breadcrumb_items": [
+ {"name": "Pages", "path": "."},
+ {"name": "Tags", "path": "tags"},
+ ]
+ }
+ for key in expected:
+ self.assertEqual(
+ expected[key], response.context.get(key), f"context.{key} did not match"
+ )
+
+ def test_context_grouped_tag(self):
+ """
+ Test the context for a tag in a group.
+
+ The only difference between this and a regular tag are the breadcrumbs,
+ so only those are checked.
+ """
+ Tag.objects.create(
+ name="example", body="Body text", group="group-name", last_commit=self.commit
+ )
+ response = self.client.get("/pages/tags/group-name/example/")
+ self.assertListEqual([
+ {"name": "Pages", "path": "."},
+ {"name": "Tags", "path": "tags"},
+ {"name": "group-name", "path": "tags/group-name"},
+ ], response.context.get("breadcrumb_items"))
+
+ def test_group_page(self):
+ """Test rendering of a group's root page."""
+ Tag.objects.create(name="tag-1", body="Body 1", group="group-name", last_commit=self.commit)
+ Tag.objects.create(name="tag-2", body="Body 2", group="group-name", last_commit=self.commit)
+ Tag.objects.create(name="not-included", last_commit=self.commit)
+
+ response = self.client.get("/pages/tags/group-name/")
+ content = response.content.decode("utf-8")
+
+ self.assertInHTML("<div class='level-left'>group-name</div>", content)
+ self.assertInHTML(
+ f"<a class='level-item fab fa-github' href='{Tag.URL_BASE}/group-name'>",
+ content
+ )
+ self.assertIn(">tag-1</span>", content)
+ self.assertIn(">tag-2</span>", content)
+ self.assertNotIn(
+ ">not-included</span>",
+ content,
+ "Tags not in this group shouldn't be rendered."
+ )
+
+ self.assertInHTML("<p>Body 1</p>", content)
+
+ def test_markdown(self):
+ """Test that markdown content is rendered properly."""
+ body = textwrap.dedent("""
+ ```py
+ Hello world!
+ ```
+
+ **This text is in bold**
+ """)
+
+ Tag.objects.create(name="example", body=body, last_commit=self.commit)
+ response = self.client.get("/pages/tags/example/")
+ content = response.content.decode("utf-8")
+
+ self.assertInHTML('<code class="language-py">Hello world!</code>', content)
+ self.assertInHTML("<strong>This text is in bold</strong>", content)
+
+ def test_embed(self):
+ """Test that an embed from the frontmatter is treated correctly."""
+ body = textwrap.dedent("""
+ ---
+ embed:
+ title: Embed title
+ image:
+ url: https://google.com
+ ---
+ Tag body.
+ """)
+
+ Tag.objects.create(name="example", body=body, last_commit=self.commit)
+ response = self.client.get("/pages/tags/example/")
+ content = response.content.decode("utf-8")
+
+ self.assertInHTML('<img alt="Embed title" src="https://google.com"/>', content)
+ self.assertInHTML("<p>Tag body.</p>", content)
+
+ def test_embed_title(self):
+ """Test that the page title gets set to the embed title."""
+ body = textwrap.dedent("""
+ ---
+ embed:
+ title: Embed title
+ ---
+ """)
+
+ Tag.objects.create(name="example", body=body, last_commit=self.commit)
+ response = self.client.get("/pages/tags/example/")
+ self.assertEqual(
+ "Embed title",
+ response.context.get("page_title"),
+ "The page title must match the embed title."
+ )
+
+ def test_hyperlinked_item(self):
+ """Test hyperlinking of tags works as intended."""
+ filler_before, filler_after = "empty filler text\n\n", "more\nfiller"
+ body = filler_before + "`!tags return`" + filler_after
+ Tag.objects.create(name="example", body=body, last_commit=self.commit)
+
+ other_url = reverse("content:tag", kwargs={"location": "return"})
+ response = self.client.get("/pages/tags/example/")
+ self.assertEqual(
+ markdown.markdown(filler_before + f"[`!tags return`]({other_url})" + filler_after),
+ response.context.get("page")
+ )
+
+ def test_hyperlinked_group(self):
+ """Test hyperlinking with a group works as intended."""
+ Tag.objects.create(
+ name="example", body="!tags group-name grouped-tag", last_commit=self.commit
+ )
+ Tag.objects.create(name="grouped-tag", group="group-name")
+
+ other_url = reverse("content:tag", kwargs={"location": "group-name/grouped-tag"})
+ response = self.client.get("/pages/tags/example/")
+ self.assertEqual(
+ markdown.markdown(f"[!tags group-name grouped-tag]({other_url})"),
+ response.context.get("page")
+ )
+
+ def test_hyperlinked_extra_text(self):
+ """Test hyperlinking when a tag is followed by extra, unrelated text."""
+ Tag.objects.create(
+ name="example", body="!tags other unrelated text", last_commit=self.commit
+ )
+ Tag.objects.create(name="other")
+
+ other_url = reverse("content:tag", kwargs={"location": "other"})
+ response = self.client.get("/pages/tags/example/")
+ self.assertEqual(
+ markdown.markdown(f"[!tags other]({other_url}) unrelated text"),
+ response.context.get("page")
+ )
+
+ def test_tag_root_page(self):
+ """Test the root tag page which lists all tags."""
+ Tag.objects.create(name="tag-1", last_commit=self.commit)
+ Tag.objects.create(name="tag-2", last_commit=self.commit)
+ Tag.objects.create(name="tag-3", last_commit=self.commit)
+
+ response = self.client.get("/pages/tags/")
+ content = response.content.decode("utf-8")
+
+ self.assertTemplateUsed(response, "content/listing.html")
+ self.assertInHTML('<div class="level-left">Tags</div>', content)
+
+ for tag_number in range(1, 4):
+ self.assertIn(f"tag-{tag_number}</span>", content)
diff --git a/pydis_site/apps/content/urls.py b/pydis_site/apps/content/urls.py
index f8496095..163d05bc 100644
--- a/pydis_site/apps/content/urls.py
+++ b/pydis_site/apps/content/urls.py
@@ -3,7 +3,7 @@ from pathlib import Path
from django_distill import distill_path
-from . import views
+from . import utils, views
app_name = "content"
@@ -29,15 +29,36 @@ def __get_all_files(root: Path, folder: typing.Optional[Path] = None) -> list[st
return results
-def get_all_pages() -> typing.Iterator[dict[str, str]]:
+DISTILL_RETURN = typing.Iterator[dict[str, str]]
+
+
+def get_all_pages() -> DISTILL_RETURN:
"""Yield a dict of all page categories."""
for location in __get_all_files(Path("pydis_site", "apps", "content", "resources")):
yield {"location": location}
+def get_all_tags() -> DISTILL_RETURN:
+ """Return all tag names and groups in static builds."""
+ groups = {None}
+ for tag in utils.get_tags_static():
+ groups.add(tag.group)
+ yield {"location": (f"{tag.group}/" if tag.group else "") + tag.name}
+
+ groups.remove(None)
+ for group in groups:
+ yield {"location": group}
+
+
urlpatterns = [
distill_path("", views.PageOrCategoryView.as_view(), name='pages'),
distill_path(
+ "tags/<path:location>/",
+ views.TagView.as_view(),
+ name="tag",
+ distill_func=get_all_tags
+ ),
+ distill_path(
"<path:location>/",
views.PageOrCategoryView.as_view(),
name='page_category',
diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py
index d3f270ff..a1171a45 100644
--- a/pydis_site/apps/content/utils.py
+++ b/pydis_site/apps/content/utils.py
@@ -1,14 +1,42 @@
+import datetime
+import functools
+import json
+import tarfile
+import tempfile
+import typing
+from io import BytesIO
from pathlib import Path
-from typing import Dict, Tuple
import frontmatter
+import httpx
import markdown
import yaml
from django.http import Http404
+from django.utils import timezone
from markdown.extensions.toc import TocExtension
+from pydis_site import settings
+from .models import Commit, Tag
-def get_category(path: Path) -> Dict[str, str]:
+TAG_CACHE_TTL = datetime.timedelta(hours=1)
+
+
+def github_client(**kwargs) -> httpx.Client:
+ """Get a client to access the GitHub API with important settings pre-configured."""
+ client = httpx.Client(
+ base_url=settings.GITHUB_API,
+ follow_redirects=True,
+ timeout=settings.TIMEOUT_PERIOD,
+ **kwargs
+ )
+ if settings.GITHUB_TOKEN: # pragma: no cover
+ if not client.headers.get("Authorization"):
+ client.headers = {"Authorization": f"token {settings.GITHUB_TOKEN}"}
+
+ return client
+
+
+def get_category(path: Path) -> dict[str, str]:
"""Load category information by name from _info.yml."""
if not path.is_dir():
raise Http404("Category not found.")
@@ -16,7 +44,7 @@ def get_category(path: Path) -> Dict[str, str]:
return yaml.safe_load(path.joinpath("_info.yml").read_text(encoding="utf-8"))
-def get_categories(path: Path) -> Dict[str, Dict]:
+def get_categories(path: Path) -> dict[str, dict]:
"""Get information for all categories."""
categories = {}
@@ -27,8 +55,258 @@ def get_categories(path: Path) -> Dict[str, Dict]:
return categories
-def get_category_pages(path: Path) -> Dict[str, Dict]:
+def get_tags_static() -> list[Tag]:
+ """
+ Fetch tag information in static builds.
+
+ This also includes some fake tags to preview the tag groups feature.
+ This will return a cached value, so it should only be used for static builds.
+ """
+ tags = fetch_tags()
+ for tag in tags[3:5]: # pragma: no cover
+ tag.group = "very-cool-group"
+ return tags
+
+
+def fetch_tags() -> list[Tag]:
+ """
+ Fetch tag data from the GitHub API.
+
+ The entire repository is downloaded and extracted locally because
+ getting file content would require one request per file, and can get rate-limited.
+ """
+ client = github_client()
+
+ # Grab metadata
+ metadata = client.get("/repos/python-discord/bot/contents/bot/resources")
+ metadata.raise_for_status()
+
+ hashes = {}
+ for entry in metadata.json():
+ if entry["type"] == "dir":
+ # Tag group
+ files = client.get(entry["url"])
+ files.raise_for_status()
+ files = files.json()
+ else:
+ files = [entry]
+
+ for file in files:
+ hashes[file["name"]] = file["sha"]
+
+ # Download the files
+ tar_file = client.get("/repos/python-discord/bot/tarball")
+ tar_file.raise_for_status()
+
+ client.close()
+
+ tags = []
+ with tempfile.TemporaryDirectory() as folder:
+ with tarfile.open(fileobj=BytesIO(tar_file.content)) as repo:
+ included = []
+ for file in repo.getmembers():
+ if "/bot/resources/tags" in file.path:
+ included.append(file)
+ repo.extractall(folder, included)
+
+ for tag_file in Path(folder).rglob("*.md"):
+ name = tag_file.name
+ group = None
+ if tag_file.parent.name != "tags":
+ # Tags in sub-folders are considered part of a group
+ group = tag_file.parent.name
+
+ tags.append(Tag(
+ name=name.removesuffix(".md"),
+ sha=hashes[name],
+ group=group,
+ body=tag_file.read_text(encoding="utf-8"),
+ last_commit=None,
+ ))
+
+ return tags
+
+
+def set_tag_commit(tag: Tag) -> None:
+ """Fetch commit information from the API, and save it for the tag."""
+ if settings.STATIC_BUILD: # pragma: no cover
+ # Static builds request every page during build, which can ratelimit it.
+ # Instead, we return some fake data.
+ tag.last_commit = Commit(
+ sha="68da80efc00d9932a209d5cccd8d344cec0f09ea",
+ message="Initial Commit\n\nTHIS IS FAKE DEMO DATA",
+ date=datetime.datetime(2018, 2, 3, 12, 20, 26, tzinfo=datetime.timezone.utc),
+ authors=json.dumps([{"name": "Joseph", "email": "[email protected]"}]),
+ )
+ return
+
+ path = "/bot/resources/tags"
+ if tag.group:
+ path += f"/{tag.group}"
+ path += f"/{tag.name}.md"
+
+ # Fetch and set the commit
+ with github_client() as client:
+ data = client.get("/repos/python-discord/bot/commits", params={"path": path})
+ data.raise_for_status()
+ data = data.json()[0]
+
+ commit = data["commit"]
+ author, committer = commit["author"], commit["committer"]
+
+ date = datetime.datetime.strptime(committer["date"], settings.GITHUB_TIMESTAMP_FORMAT)
+ date = date.replace(tzinfo=datetime.timezone.utc)
+
+ if author["email"] == committer["email"]:
+ authors = [author]
+ else:
+ authors = [author, committer]
+
+ commit_obj, _ = Commit.objects.get_or_create(
+ sha=data["sha"],
+ message=commit["message"],
+ date=date,
+ authors=json.dumps(authors),
+ )
+ tag.last_commit = commit_obj
+ tag.save()
+
+
+def record_tags(tags: list[Tag]) -> None:
+ """Sync the database with an updated set of tags."""
+ # Remove entries which no longer exist
+ Tag.objects.exclude(name__in=[tag.name for tag in tags]).delete()
+
+ # Insert/update the tags
+ for tag in tags:
+ try:
+ old_tag = Tag.objects.get(name=tag.name)
+ except Tag.DoesNotExist:
+ # The tag is not in the database yet,
+ # pretend it's previous state is the current state
+ old_tag = tag
+
+ if old_tag.sha == tag.sha and old_tag.last_commit is not None:
+ # We still have an up-to-date commit entry
+ tag.last_commit = old_tag.last_commit
+
+ tag.save()
+
+ # Drop old, unused commits
+ Commit.objects.filter(tag__isnull=True).delete()
+
+
+def get_tags() -> list[Tag]:
+ """Return a list of all tags visible to the application, from the cache or API."""
+ if settings.STATIC_BUILD: # pragma: no cover
+ last_update = None
+ else:
+ last_update = (
+ Tag.objects.values_list("last_updated", flat=True)
+ .order_by("last_updated").first()
+ )
+
+ if last_update is None or timezone.now() >= (last_update + TAG_CACHE_TTL):
+ # Stale or empty cache
+ if settings.STATIC_BUILD: # pragma: no cover
+ tags = get_tags_static()
+ else:
+ tags = fetch_tags()
+ record_tags(tags)
+
+ return tags
+ else:
+ # Get tags from database
+ return Tag.objects.all()
+
+
+def get_tag(path: str, *, skip_sync: bool = False) -> typing.Union[Tag, list[Tag]]:
+ """
+ Return a tag based on the search location.
+
+ If certain tag data is out of sync (for instance a commit date is missing),
+ an extra request will be made to sync the information.
+
+ The tag name and group must match. If only one argument is provided in the path,
+ it's assumed to either be a group name, or a no-group tag name.
+
+ If it's a group name, a list of tags which belong to it is returned.
+ """
+ path = path.split("/")
+ if len(path) == 2:
+ group, name = path[0], path[1]
+ else:
+ name = path[0]
+ group = None
+
+ matches = []
+ for tag in get_tags():
+ if tag.name == name and tag.group == group:
+ if tag.last_commit is None and not skip_sync:
+ set_tag_commit(tag)
+ return tag
+ elif tag.group == name and group is None:
+ matches.append(tag)
+
+ if matches:
+ return matches
+
+ raise Tag.DoesNotExist()
+
+
+def get_tag_category(
+ tags: typing.Optional[list[Tag]] = None, *, collapse_groups: bool
+) -> dict[str, dict]:
+ """
+ Generate context data for `tags`, or all tags if None.
+
+ If `tags` is None, `get_tag` is used to populate the data.
+ If `collapse_groups` is True, tags with parent groups are not included in the list,
+ and instead the parent itself is included as a single entry with it's sub-tags
+ in the description.
+ """
+ if not tags:
+ tags = get_tags()
+
+ data = []
+ groups = {}
+
+ # Create all the metadata for the tags
+ for tag in tags:
+ if tag.group is None or not collapse_groups:
+ content = frontmatter.parse(tag.body)[1]
+ data.append({
+ "title": tag.name,
+ "description": markdown.markdown(content, extensions=["pymdownx.superfences"]),
+ "icon": "fas fa-tag",
+ })
+ else:
+ if tag.group not in groups:
+ groups[tag.group] = {
+ "title": tag.group,
+ "description": [tag.name],
+ "icon": "fas fa-tags",
+ }
+ else:
+ groups[tag.group]["description"].append(tag.name)
+
+ # Flatten group description into a single string
+ for group in groups.values():
+ # If the following string is updated, make sure to update it in the frontend JS as well
+ group["description"] = "Contains the following tags: " + ", ".join(group["description"])
+ data.append(group)
+
+ # Sort the tags, and return them in the proper format
+ return {tag["title"]: tag for tag in sorted(data, key=lambda tag: tag["title"].lower())}
+
+
+def get_category_pages(path: Path) -> dict[str, dict]:
"""Get all page names and their metadata at a category path."""
+ # Special handling for tags
+ if path == Path(__file__).parent / "resources/tags":
+ return get_tag_category(collapse_groups=True)
+
pages = {}
for item in path.glob("*.md"):
@@ -39,7 +317,7 @@ def get_category_pages(path: Path) -> Dict[str, Dict]:
return pages
-def get_page(path: Path) -> Tuple[str, Dict]:
+def get_page(path: Path) -> tuple[str, dict]:
"""Get one specific page."""
if not path.is_file():
raise Http404("Page not found.")
diff --git a/pydis_site/apps/content/views/__init__.py b/pydis_site/apps/content/views/__init__.py
index 70ea1c7a..a969b1dc 100644
--- a/pydis_site/apps/content/views/__init__.py
+++ b/pydis_site/apps/content/views/__init__.py
@@ -1,3 +1,4 @@
from .page_category import PageOrCategoryView
+from .tags import TagView
-__all__ = ["PageOrCategoryView"]
+__all__ = ["PageOrCategoryView", "TagView"]
diff --git a/pydis_site/apps/content/views/page_category.py b/pydis_site/apps/content/views/page_category.py
index 356eb021..062c2bc1 100644
--- a/pydis_site/apps/content/views/page_category.py
+++ b/pydis_site/apps/content/views/page_category.py
@@ -1,4 +1,3 @@
-import typing as t
from pathlib import Path
import frontmatter
@@ -6,7 +5,7 @@ from django.conf import settings
from django.http import Http404, HttpRequest, HttpResponse
from django.views.generic import TemplateView
-from pydis_site.apps.content import utils
+from pydis_site.apps.content import models, utils
class PageOrCategoryView(TemplateView):
@@ -25,7 +24,7 @@ class PageOrCategoryView(TemplateView):
return super().dispatch(request, *args, **kwargs)
- def get_template_names(self) -> t.List[str]:
+ def get_template_names(self) -> list[str]:
"""Checks if the view uses the page template or listing template."""
if self.page_path.is_file():
template_name = "content/page.html"
@@ -36,7 +35,7 @@ class PageOrCategoryView(TemplateView):
return [template_name]
- def get_context_data(self, **kwargs) -> t.Dict[str, t.Any]:
+ def get_context_data(self, **kwargs) -> dict[str, any]:
"""Assign proper context variables based on what resource user requests."""
context = super().get_context_data(**kwargs)
@@ -73,7 +72,7 @@ class PageOrCategoryView(TemplateView):
return context
@staticmethod
- def _get_page_context(path: Path) -> t.Dict[str, t.Any]:
+ def _get_page_context(path: Path) -> dict[str, any]:
page, metadata = utils.get_page(path)
return {
"page": page,
@@ -84,7 +83,7 @@ class PageOrCategoryView(TemplateView):
}
@staticmethod
- def _get_category_context(path: Path) -> t.Dict[str, t.Any]:
+ def _get_category_context(path: Path) -> dict[str, any]:
category = utils.get_category(path)
return {
"categories": utils.get_categories(path),
@@ -92,4 +91,7 @@ class PageOrCategoryView(TemplateView):
"page_title": category["title"],
"page_description": category["description"],
"icon": category.get("icon"),
+ "app_name": "content:page_category",
+ "is_tag_listing": "/resources/tags" in path.as_posix(),
+ "tag_url": models.Tag.URL_BASE,
}
diff --git a/pydis_site/apps/content/views/tags.py b/pydis_site/apps/content/views/tags.py
new file mode 100644
index 00000000..4f4bb5a2
--- /dev/null
+++ b/pydis_site/apps/content/views/tags.py
@@ -0,0 +1,124 @@
+import re
+import typing
+
+import frontmatter
+import markdown
+from django.conf import settings
+from django.http import Http404
+from django.urls import reverse
+from django.views.generic import TemplateView
+
+from pydis_site.apps.content import utils
+from pydis_site.apps.content.models import Tag
+
+# The following regex tries to parse a tag command
+# It'll read up to two words seperated by spaces
+# If the command does not include a group, the tag name will be in the `first` group
+# If there's a second word after the command, or if there's a tag group, extra logic
+# is necessary to determine whether it's a tag with a group, or a tag with text after it
+COMMAND_REGEX = re.compile(r"`*!tags? (?P<first>[\w-]+)(?P<second> [\w-]+)?`*")
+
+
+class TagView(TemplateView):
+ """Handles tag pages."""
+
+ tag: typing.Union[Tag, list[Tag]]
+ is_group: bool
+
+ def setup(self, *args, **kwargs) -> None:
+ """Look for a tag, and configure the view."""
+ super().setup(*args, **kwargs)
+
+ try:
+ self.tag = utils.get_tag(kwargs.get("location"))
+ self.is_group = isinstance(self.tag, list)
+ except Tag.DoesNotExist:
+ raise Http404
+
+ def get_template_names(self) -> list[str]:
+ """Either return the tag page template, or the listing."""
+ if self.is_group:
+ template_name = "content/listing.html"
+ else:
+ template_name = "content/tag.html"
+
+ return [template_name]
+
+ def get_context_data(self, **kwargs) -> dict:
+ """Get the relevant context for this tag page or group."""
+ context = super().get_context_data(**kwargs)
+ context["breadcrumb_items"] = [{
+ "name": utils.get_category(settings.CONTENT_PAGES_PATH / location)["title"],
+ "path": location,
+ } for location in (".", "tags")]
+
+ if self.is_group:
+ self._set_group_context(context, self.tag)
+ else:
+ self._set_tag_context(context, self.tag)
+
+ return context
+
+ @staticmethod
+ def _set_tag_context(context: dict[str, any], tag: Tag) -> None:
+ """Update the context with the information for a tag page."""
+ context.update({
+ "page_title": tag.name,
+ "tag": tag,
+ })
+
+ if tag.group:
+ # Add group names to the breadcrumbs
+ context["breadcrumb_items"].append({
+ "name": tag.group,
+ "path": f"tags/{tag.group}",
+ })
+
+ # Clean up tag body
+ body = frontmatter.parse(tag.body)
+ content = body[1]
+
+ # Check for tags which can be hyperlinked
+ def sub(match: re.Match) -> str:
+ first, second = match.groups()
+ location = first
+ text, extra = match.group(), ""
+
+ if second is not None:
+ # Possibly a tag group
+ try:
+ new_location = f"{first}/{second.strip()}"
+ utils.get_tag(new_location, skip_sync=True)
+ location = new_location
+ except Tag.DoesNotExist:
+ # Not a group, remove the second argument from the link
+ extra = text[text.find(second):]
+ text = text[:text.find(second)]
+
+ link = reverse("content:tag", kwargs={"location": location})
+ return f"[{text}]({link}){extra}"
+ content = COMMAND_REGEX.sub(sub, content)
+
+ # Add support for some embed elements
+ if embed := body[0].get("embed"):
+ context["page_title"] = embed["title"]
+ if image := embed.get("image"):
+ content = f"![{embed['title']}]({image['url']})\n\n" + content
+
+ # Insert the content
+ context["page"] = markdown.markdown(content, extensions=["pymdownx.superfences"])
+
+ @staticmethod
+ def _set_group_context(context: dict[str, any], tags: list[Tag]) -> None:
+ """Update the context with the information for a group of tags."""
+ group = tags[0].group
+ context.update({
+ "categories": {},
+ "pages": utils.get_tag_category(tags, collapse_groups=False),
+ "page_title": group,
+ "icon": "fab fa-tags",
+ "is_tag_listing": True,
+ "app_name": "content:tag",
+ "path": f"{group}/",
+ "tag_url": f"{tags[0].URL_BASE}/{group}"
+ })
diff --git a/pydis_site/apps/home/views/home.py b/pydis_site/apps/home/views/home.py
index 9bb1f8fd..8a165682 100644
--- a/pydis_site/apps/home/views/home.py
+++ b/pydis_site/apps/home/views/home.py
@@ -32,9 +32,7 @@ class HomeView(View):
def __init__(self):
"""Clean up stale RepositoryMetadata."""
- self._static_build = settings.env("STATIC_BUILD")
-
- if not self._static_build:
+ if not settings.STATIC_BUILD:
RepositoryMetadata.objects.exclude(repo_name__in=self.repos).delete()
# If no token is defined (for example in local development), then
@@ -94,7 +92,7 @@ class HomeView(View):
def _get_repo_data(self) -> List[RepositoryMetadata]:
"""Build a list of RepositoryMetadata objects that we can use to populate the front page."""
# First off, load the timestamp of the least recently updated entry.
- if self._static_build:
+ if settings.STATIC_BUILD:
last_update = None
else:
last_update = (
@@ -121,7 +119,7 @@ class HomeView(View):
for api_data in api_repositories.values()
]
- if settings.env("STATIC_BUILD"):
+ if settings.STATIC_BUILD:
return data
else:
return RepositoryMetadata.objects.bulk_create(data)
diff --git a/pydis_site/apps/redirect/urls.py b/pydis_site/apps/redirect/urls.py
index f86fe665..067cccc3 100644
--- a/pydis_site/apps/redirect/urls.py
+++ b/pydis_site/apps/redirect/urls.py
@@ -32,7 +32,7 @@ class Redirect:
def map_redirect(name: str, data: Redirect) -> list[URLPattern]:
"""Return a pattern using the Redirects app, or a static HTML redirect for static builds."""
- if not settings.env("STATIC_BUILD"):
+ if not settings.STATIC_BUILD:
# Normal dynamic redirect
return [path(
data.original_path,
diff --git a/pydis_site/settings.py b/pydis_site/settings.py
index cb05956b..e9e0ba67 100644
--- a/pydis_site/settings.py
+++ b/pydis_site/settings.py
@@ -38,12 +38,16 @@ GITHUB_API = "https://api.github.com"
GITHUB_TOKEN = env("GITHUB_TOKEN")
GITHUB_APP_ID = env("GITHUB_APP_ID")
GITHUB_APP_KEY = env("GITHUB_APP_KEY")
+GITHUB_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
+"""The datetime string format GitHub uses."""
+
+STATIC_BUILD: bool = env("STATIC_BUILD")
if GITHUB_APP_KEY and (key_file := Path(GITHUB_APP_KEY)).is_file():
# Allow the OAuth key to be loaded from a file
GITHUB_APP_KEY = key_file.read_text(encoding="utf-8")
-if not env("STATIC_BUILD"):
+if not STATIC_BUILD:
sentry_sdk.init(
dsn=env('SITE_DSN'),
integrations=[DjangoIntegration()],
@@ -100,7 +104,7 @@ else:
NON_STATIC_APPS = [
'pydis_site.apps.api',
'pydis_site.apps.staff',
-] if not env("STATIC_BUILD") else []
+] if not STATIC_BUILD else []
INSTALLED_APPS = [
*NON_STATIC_APPS,
@@ -129,7 +133,7 @@ INSTALLED_APPS = [
if not env("BUILDING_DOCKER"):
INSTALLED_APPS.append("django_prometheus")
-if env("STATIC_BUILD"):
+if STATIC_BUILD:
# The only middleware required during static builds
MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
@@ -180,7 +184,7 @@ WSGI_APPLICATION = 'pydis_site.wsgi.application'
DATABASES = {
'default': env.db(),
'metricity': env.db('METRICITY_DB_URL'),
-} if not env("STATIC_BUILD") else {}
+} if not STATIC_BUILD else {}
# Password validation
# https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators
diff --git a/pydis_site/static/css/content/color.css b/pydis_site/static/css/content/color.css
new file mode 100644
index 00000000..f4801c28
--- /dev/null
+++ b/pydis_site/static/css/content/color.css
@@ -0,0 +1,7 @@
+.content .fa-github {
+ color: black;
+}
+
+.content .fa-github:hover {
+ color: #7289DA;
+}
diff --git a/pydis_site/static/css/content/tag.css b/pydis_site/static/css/content/tag.css
new file mode 100644
index 00000000..79795f9e
--- /dev/null
+++ b/pydis_site/static/css/content/tag.css
@@ -0,0 +1,13 @@
+.content a * {
+ /* This is the original color, but propagated down the chain */
+ /* which allows for elements inside links, such as codeblocks */
+ color: #7289DA;
+}
+
+.content a *:hover {
+ color: dimgray;
+}
+
+span.update-time {
+ text-decoration: black underline dotted;
+}
diff --git a/pydis_site/static/js/content/listing.js b/pydis_site/static/js/content/listing.js
new file mode 100644
index 00000000..4b722632
--- /dev/null
+++ b/pydis_site/static/js/content/listing.js
@@ -0,0 +1,41 @@
+/**
+ * Trim a tag listing to only show a few lines of content.
+ */
+function trimTag() {
+ const containers = document.getElementsByClassName("tag-container");
+ for (const container of containers) {
+ if (container.textContent.startsWith("Contains the following tags:")) {
+ // Tag group, no need to trim
+ continue;
+ }
+
+ // Remove every element after the first two paragraphs
+ while (container.children.length > 2) {
+ container.removeChild(container.lastChild);
+ }
+
+ // Trim down the elements if they are too long
+ const containerLength = container.textContent.length;
+ if (containerLength > 300) {
+ if (containerLength - container.firstChild.textContent.length > 300) {
+ // The first element alone takes up more than 300 characters
+ container.removeChild(container.lastChild);
+ }
+
+ let last = container.lastChild.lastChild;
+ while (container.textContent.length > 300 && container.lastChild.childNodes.length > 0) {
+ last = container.lastChild.lastChild;
+ last.remove();
+ }
+
+ if (container.textContent.length > 300 && (last instanceof HTMLElement && last.tagName !== "CODE")) {
+ // Add back the final element (up to a period if possible)
+ const stop = last.textContent.indexOf(".");
+ last.textContent = last.textContent.slice(0, stop > 0 ? stop + 1: null);
+ container.lastChild.appendChild(last);
+ }
+ }
+ }
+}
+
+trimTag();
diff --git a/pydis_site/templates/base/navbar.html b/pydis_site/templates/base/navbar.html
index d7fb4f4c..931693c8 100644
--- a/pydis_site/templates/base/navbar.html
+++ b/pydis_site/templates/base/navbar.html
@@ -67,6 +67,9 @@
<a class="navbar-item" href="{% url "resources:index" %}">
Resources
</a>
+ <a class="navbar-item" href="{% url "content:pages" %}">
+ Content
+ </a>
<a class="navbar-item" href="{% url "events:index" %}">
Events
</a>
diff --git a/pydis_site/templates/content/base.html b/pydis_site/templates/content/base.html
index 4a19a275..2fd721a3 100644
--- a/pydis_site/templates/content/base.html
+++ b/pydis_site/templates/content/base.html
@@ -8,6 +8,10 @@
<meta property="og:description" content="{{ page_description }}" />
<link rel="stylesheet" href="{% static "css/content/page.css" %}">
<link rel="stylesheet" href="{% static "css/collapsibles.css" %}">
+ <link rel="stylesheet"
+ href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/styles/atom-one-dark.min.css">
+ <script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/highlight.min.js"></script>
+ <script>hljs.highlightAll();</script>
<script src="{% static "js/collapsibles.js" %}"></script>
{% endblock %}
@@ -35,7 +39,7 @@
<section class="section">
<div class="container">
<div class="content">
- <h1 class="title">{{ page_title }}</h1>
+ <h1 class="title">{% block title_element %}{{ page_title }}{% endblock %}</h1>
{% block page_content %}{% endblock %}
</div>
</div>
diff --git a/pydis_site/templates/content/listing.html b/pydis_site/templates/content/listing.html
index ef0ef919..934b95f6 100644
--- a/pydis_site/templates/content/listing.html
+++ b/pydis_site/templates/content/listing.html
@@ -1,6 +1,22 @@
+{# Base navigation screen for resources #}
{% extends 'content/base.html' %}
+{% load static %}
+
+{# Show a GitHub button on tag pages #}
+{% block title_element %}
+{% if is_tag_listing %}
+ <link rel="stylesheet" href="{% static "css/content/color.css" %}">
+ <div class="level">
+ <div class="level-left">{{ block.super }}</div>
+ <div class="level-right">
+ <a class="level-item fab fa-github" href="{{ tag_url }}"></a>
+ </div>
+ </div>
+{% endif %}
+{% endblock %}
{% block page_content %}
+ {# Nested Categories #}
{% for category, data in categories.items %}
<div class="box" style="max-width: 800px;">
<span class="icon is-size-4 is-medium">
@@ -13,15 +29,22 @@
<p class="is-italic">{{ data.description }}</p>
</div>
{% endfor %}
+
+ {# Single Pages #}
{% for page, data in pages.items %}
<div class="box" style="max-width: 800px;">
<span class="icon is-size-4 is-medium">
<i class="{{ data.icon|default:"fab fa-python" }} is-size-3 is-black has-icon-padding" aria-hidden="true"></i>
</span>
- <a href="{% url "content:page_category" location=path|add:page %}">
+ <a href="{% url app_name location=path|add:page %}">
<span class="is-size-4 has-text-weight-bold">{{ data.title }}</span>
</a>
- <p class="is-italic">{{ data.description }}</p>
+ {% if is_tag_listing %}
+ <div class="tag-container">{{ data.description | safe }}</div>
+ {% else %}
+ <p class="is-italic">{{ data.description }}</p>
+ {% endif %}
</div>
{% endfor %}
+ <script src="{% static 'js/content/listing.js' %}"></script>
{% endblock %}
diff --git a/pydis_site/templates/content/page.html b/pydis_site/templates/content/page.html
index 759286f6..679ecec6 100644
--- a/pydis_site/templates/content/page.html
+++ b/pydis_site/templates/content/page.html
@@ -1,13 +1,5 @@
{% extends 'content/base.html' %}
-{% block head %}
- {{ block.super }}
- <link rel="stylesheet"
- href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/styles/atom-one-dark.min.css">
- <script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/highlight.min.js"></script>
- <script>hljs.initHighlightingOnLoad();</script>
-{% endblock %}
-
{% block page_content %}
{% if relevant_links or toc %}
<div class="columns is-variable is-8">
diff --git a/pydis_site/templates/content/tag.html b/pydis_site/templates/content/tag.html
new file mode 100644
index 00000000..fa9e44f5
--- /dev/null
+++ b/pydis_site/templates/content/tag.html
@@ -0,0 +1,40 @@
+{% extends "content/page.html" %}
+{% load static %}
+
+{% block head %}
+ {{ block.super }}
+ <link rel="stylesheet" href="{% static 'css/content/color.css' %}"/>
+ <link rel="stylesheet" href="{% static 'css/content/tag.css' %}"/>
+ <title>{{ tag.name }}</title>
+{% endblock %}
+
+{% block title_element %}
+ <div class="level mb-2">
+ <div class="level-left">{{ block.super }}</div>
+ <div class="level-right">
+ <a class="level-item fab fa-github" href="{{ tag.url }}"></a>
+ </div>
+ </div>
+
+ <div class="dropdown is-size-6 is-hoverable">
+ <div class="dropdown-trigger ">
+ <a aria-haspopup="menu" href="{{ tag.last_commit.url }}">
+ <span class="update-time">
+ Last Updated: {{ tag.last_commit.date | date:"F j, Y g:i A e" }}
+ </span>
+ </a>
+ </div>
+ <div class="dropdown-menu">
+ <div class="dropdown-content">
+ <div class="dropdown-item">Last edited by:</div>
+ {% for user in tag.last_commit.format_authors %}
+ <div class="dropdown-item">{{ user }}</div>
+ {% endfor %}
+ <div class="dropdown-divider"></div>
+ {% for line in tag.last_commit.lines %}
+ <div class="dropdown-item">{{ line }}</div>
+ {% endfor %}
+ </div>
+ </div>
+ </div>
+{% endblock %}
diff --git a/pydis_site/urls.py b/pydis_site/urls.py
index 6cd31f26..0f2f6aeb 100644
--- a/pydis_site/urls.py
+++ b/pydis_site/urls.py
@@ -12,7 +12,7 @@ NON_STATIC_PATTERNS = [
path('pydis-api/', include('pydis_site.apps.api.urls', namespace='internal_api')),
path('', include('django_prometheus.urls')),
-] if not settings.env("STATIC_BUILD") else []
+] if not settings.STATIC_BUILD else []
urlpatterns = (
@@ -29,7 +29,7 @@ urlpatterns = (
)
-if not settings.env("STATIC_BUILD"):
+if not settings.STATIC_BUILD:
urlpatterns += (
path('staff/', include('pydis_site.apps.staff.urls', namespace='staff')),
)
diff --git a/pyproject.toml b/pyproject.toml
index 0c1e6e58..faec6a2e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,6 +23,7 @@ python-frontmatter = "1.0.0"
django-prometheus = "2.2.0"
django-distill = "3.0.1"
PyJWT = {version = "2.5.0", extras = ["crypto"]}
+pymdown-extensions = "9.7"
[tool.poetry.dev-dependencies]
coverage = "6.5.0"