aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site
diff options
context:
space:
mode:
authorGravatar mbaruh <[email protected]>2023-01-27 22:22:06 +0200
committerGravatar mbaruh <[email protected]>2023-01-27 22:57:51 +0200
commit963d9368b8e07d8aab04662f27bf47087f5b574c (patch)
tree72ea3b5e60babe27b169b8816a4d39853d180742 /pydis_site
parentUpdate tests (diff)
parentMerge pull request #860 from python-discord/official-tutorial-resource (diff)
Merge branch 'main' into new-filter-schema
Diffstat (limited to 'pydis_site')
-rw-r--r--pydis_site/apps/api/github_utils.py4
-rw-r--r--pydis_site/apps/api/migrations/0085_add_thread_id_to_nominations.py18
-rw-r--r--pydis_site/apps/api/migrations/0086_new_filter_schema.py (renamed from pydis_site/apps/api/migrations/0085_new_filter_schema.py)2
-rw-r--r--pydis_site/apps/api/migrations/0087_unique_constraint_filters.py (renamed from pydis_site/apps/api/migrations/0086_unique_constraint_filters.py)2
-rw-r--r--pydis_site/apps/api/migrations/0088_unique_filter_list.py (renamed from pydis_site/apps/api/migrations/0087_unique_filter_list.py)2
-rw-r--r--pydis_site/apps/api/migrations/0089_antispam_filter_list.py (renamed from pydis_site/apps/api/migrations/0088_antispam_filter_list.py)2
-rw-r--r--pydis_site/apps/api/models/bot/metricity.py28
-rw-r--r--pydis_site/apps/api/models/bot/nomination.py4
-rw-r--r--pydis_site/apps/api/serializers.py10
-rw-r--r--pydis_site/apps/api/tests/test_github_utils.py7
-rw-r--r--pydis_site/apps/api/tests/test_nominations.py32
-rw-r--r--pydis_site/apps/api/tests/test_users.py84
-rw-r--r--pydis_site/apps/api/viewsets/bot/nomination.py21
-rw-r--r--pydis_site/apps/api/viewsets/bot/user.py57
-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/guides/pydis-guides/asking-good-questions.md2
-rw-r--r--pydis_site/apps/content/resources/guides/pydis-guides/contributing.md6
-rw-r--r--pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md10
-rw-r--r--pydis_site/apps/content/resources/guides/pydis-guides/contributing/commit-messages.md (renamed from pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/commit-messages.md)0
-rw-r--r--pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md2
-rw-r--r--pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/_info.yml2
-rw-r--r--pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md79
-rw-r--r--pydis_site/apps/content/resources/guides/pydis-guides/helping-others.md4
-rw-r--r--pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md43
-rw-r--r--pydis_site/apps/content/resources/guides/pydis-guides/off-topic-etiquette.md2
-rw-r--r--pydis_site/apps/content/resources/guides/python-guides/app-commands.md418
-rw-r--r--pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md23
-rw-r--r--pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md323
-rw-r--r--pydis_site/apps/content/resources/guides/python-guides/fix-ssl-certificate.md23
-rw-r--r--pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md29
-rw-r--r--pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md70
-rw-r--r--pydis_site/apps/content/resources/guides/python-guides/setting-different-statuses-on-your-bot.md48
-rw-r--r--pydis_site/apps/content/resources/guides/python-guides/vps-services.md41
-rw-r--r--pydis_site/apps/content/resources/guides/python-guides/vps_services.md58
-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.py27
-rw-r--r--pydis_site/apps/content/utils.py282
-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/apps/resources/resources/python_org.yaml14
-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/images/content/discord_colored_messages/ansi-colors.pngbin43004 -> 100261 bytes
-rw-r--r--pydis_site/static/images/content/discord_colored_messages/result.pngbin13740 -> 9608 bytes
-rw-r--r--pydis_site/static/images/content/fix-ssl-certificate/pem.pngbin0 -> 11619 bytes
-rw-r--r--pydis_site/static/images/content/help_channels/available_channels.pngbin6556 -> 0 bytes
-rw-r--r--pydis_site/static/images/content/help_channels/available_message.pngbin89386 -> 0 bytes
-rw-r--r--pydis_site/static/images/content/help_channels/claimed_channel.pngbin26100 -> 0 bytes
-rw-r--r--pydis_site/static/images/content/help_channels/dormant_channels.pngbin22386 -> 0 bytes
-rw-r--r--pydis_site/static/images/content/help_channels/help-system-category.pngbin0 -> 7425 bytes
-rw-r--r--pydis_site/static/images/content/help_channels/new-post-button.pngbin0 -> 15804 bytes
-rw-r--r--pydis_site/static/images/content/help_channels/new-post-form.pngbin0 -> 35364 bytes
-rw-r--r--pydis_site/static/images/content/help_channels/newly-created-thread-example.pngbin0 -> 149793 bytes
-rw-r--r--pydis_site/static/images/content/help_channels/occupied_channels.pngbin10950 -> 0 bytes
-rw-r--r--pydis_site/static/images/content/help_channels/question-example.pngbin0 -> 45436 bytes
-rw-r--r--pydis_site/static/images/content/regenerating_token.jpgbin0 -> 180570 bytes
-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
72 files changed, 2546 insertions, 175 deletions
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/migrations/0085_add_thread_id_to_nominations.py b/pydis_site/apps/api/migrations/0085_add_thread_id_to_nominations.py
new file mode 100644
index 00000000..56a24cc3
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0085_add_thread_id_to_nominations.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.2 on 2022-11-12 14:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0084_infraction_last_applied'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='nomination',
+ name='thread_id',
+ field=models.BigIntegerField(help_text="The nomination vote's thread id.", null=True),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0085_new_filter_schema.py b/pydis_site/apps/api/migrations/0086_new_filter_schema.py
index 96d03bf4..9067a380 100644
--- a/pydis_site/apps/api/migrations/0085_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0086_new_filter_schema.py
@@ -89,7 +89,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
class Migration(migrations.Migration):
dependencies = [
- ('api', '0084_infraction_last_applied'),
+ ('api', '0085_add_thread_id_to_nominations'),
]
operations = [
diff --git a/pydis_site/apps/api/migrations/0086_unique_constraint_filters.py b/pydis_site/apps/api/migrations/0087_unique_constraint_filters.py
index b83e395c..b2ff91f1 100644
--- a/pydis_site/apps/api/migrations/0086_unique_constraint_filters.py
+++ b/pydis_site/apps/api/migrations/0087_unique_constraint_filters.py
@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('api', '0085_new_filter_schema'),
+ ('api', '0086_new_filter_schema'),
]
operations = [
diff --git a/pydis_site/apps/api/migrations/0087_unique_filter_list.py b/pydis_site/apps/api/migrations/0088_unique_filter_list.py
index b8087d9c..3f3a34bb 100644
--- a/pydis_site/apps/api/migrations/0087_unique_filter_list.py
+++ b/pydis_site/apps/api/migrations/0088_unique_filter_list.py
@@ -91,7 +91,7 @@ def create_unique_list(apps: Apps, _):
class Migration(migrations.Migration):
dependencies = [
- ('api', '0086_unique_constraint_filters'),
+ ('api', '0087_unique_constraint_filters'),
]
operations = [
diff --git a/pydis_site/apps/api/migrations/0088_antispam_filter_list.py b/pydis_site/apps/api/migrations/0089_antispam_filter_list.py
index fcb56781..284d87e7 100644
--- a/pydis_site/apps/api/migrations/0088_antispam_filter_list.py
+++ b/pydis_site/apps/api/migrations/0089_antispam_filter_list.py
@@ -41,7 +41,7 @@ def create_antispam_list(apps: Apps, _):
class Migration(migrations.Migration):
dependencies = [
- ('api', '0087_unique_filter_list'),
+ ('api', '0088_unique_filter_list'),
]
operations = [
diff --git a/pydis_site/apps/api/models/bot/metricity.py b/pydis_site/apps/api/models/bot/metricity.py
index abd25ef0..f53dd33c 100644
--- a/pydis_site/apps/api/models/bot/metricity.py
+++ b/pydis_site/apps/api/models/bot/metricity.py
@@ -130,3 +130,31 @@ class Metricity:
raise NotFoundError()
return values
+
+ def total_messages_in_past_n_days(
+ self,
+ user_ids: list[str],
+ days: int
+ ) -> list[tuple[str, int]]:
+ """
+ Query activity by a list of users in the past `days` days.
+
+ Returns a list of (user_id, message_count) tuples.
+ """
+ self.cursor.execute(
+ """
+ SELECT
+ author_id, COUNT(*)
+ FROM messages
+ WHERE
+ author_id IN %s
+ AND NOT is_deleted
+ AND channel_id NOT IN %s
+ AND created_at > now() - interval '%s days'
+ GROUP BY author_id
+ """,
+ [tuple(user_ids), EXCLUDE_CHANNELS, days]
+ )
+ values = self.cursor.fetchall()
+
+ return values
diff --git a/pydis_site/apps/api/models/bot/nomination.py b/pydis_site/apps/api/models/bot/nomination.py
index 221d8534..58e70a83 100644
--- a/pydis_site/apps/api/models/bot/nomination.py
+++ b/pydis_site/apps/api/models/bot/nomination.py
@@ -35,6 +35,10 @@ class Nomination(ModelReprMixin, models.Model):
default=False,
help_text="Whether a review was made."
)
+ thread_id = models.BigIntegerField(
+ help_text="The nomination vote's thread id.",
+ null=True,
+ )
def __str__(self):
"""Representation that makes the target and state of the nomination immediately evident."""
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 83ab4584..7f9461ec 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -640,7 +640,15 @@ class NominationSerializer(ModelSerializer):
model = Nomination
fields = (
- 'id', 'active', 'user', 'inserted_at', 'end_reason', 'ended_at', 'reviewed', 'entries'
+ 'id',
+ 'active',
+ 'user',
+ 'inserted_at',
+ 'end_reason',
+ 'ended_at',
+ 'reviewed',
+ 'entries',
+ 'thread_id'
)
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/api/tests/test_nominations.py b/pydis_site/apps/api/tests/test_nominations.py
index 62b2314c..b3742cdd 100644
--- a/pydis_site/apps/api/tests/test_nominations.py
+++ b/pydis_site/apps/api/tests/test_nominations.py
@@ -524,3 +524,35 @@ class NominationTests(AuthenticatedAPITestCase):
self.assertEqual(response.json(), {
'actor': ["The actor doesn't exist or has not nominated the user."]
})
+
+ def test_patch_nomination_set_thread_id_of_active_nomination(self):
+ url = reverse('api:bot:nomination-detail', args=(self.active_nomination.id,))
+ data = {'thread_id': 9876543210}
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 200)
+
+ def test_patch_nomination_set_thread_id_and_reviewed_of_active_nomination(self):
+ url = reverse('api:bot:nomination-detail', args=(self.active_nomination.id,))
+ data = {'thread_id': 9876543210, "reviewed": True}
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 200)
+
+ def test_modifying_thread_id_when_ending_nomination(self):
+ url = reverse('api:bot:nomination-detail', args=(self.active_nomination.id,))
+ data = {'thread_id': 9876543210, 'active': False, 'end_reason': "What?"}
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {
+ 'thread_id': ['This field cannot be set when ending a nomination.']
+ })
+
+ def test_patch_thread_id_for_inactive_nomination(self):
+ url = reverse('api:bot:nomination-detail', args=(self.inactive_nomination.id,))
+ data = {'thread_id': 9876543210}
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {
+ 'thread_id': ['This field cannot be set if the nomination is inactive.']
+ })
diff --git a/pydis_site/apps/api/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py
index 5d10069d..d86e80bb 100644
--- a/pydis_site/apps/api/tests/test_users.py
+++ b/pydis_site/apps/api/tests/test_users.py
@@ -502,6 +502,90 @@ class UserMetricityTests(AuthenticatedAPITestCase):
"total_messages": total_messages
})
+ def test_metricity_activity_data(self):
+ # Given
+ self.mock_no_metricity_user() # Other functions shouldn't be used.
+ self.metricity.total_messages_in_past_n_days.return_value = [(0, 10)]
+
+ # When
+ url = reverse("api:bot:user-metricity-activity-data")
+ response = self.client.post(
+ url,
+ data=[0, 1],
+ QUERY_STRING="days=10",
+ )
+
+ # Then
+ self.assertEqual(response.status_code, 200)
+ self.metricity.total_messages_in_past_n_days.assert_called_once_with(["0", "1"], 10)
+ self.assertEqual(response.json(), {"0": 10, "1": 0})
+
+ def test_metricity_activity_data_invalid_days(self):
+ # Given
+ self.mock_no_metricity_user() # Other functions shouldn't be used.
+
+ # When
+ url = reverse("api:bot:user-metricity-activity-data")
+ response = self.client.post(
+ url,
+ data=[0, 1],
+ QUERY_STRING="days=fifty",
+ )
+
+ # Then
+ self.assertEqual(response.status_code, 400)
+ self.metricity.total_messages_in_past_n_days.assert_not_called()
+ self.assertEqual(response.json(), {"days": ["This query parameter must be an integer."]})
+
+ def test_metricity_activity_data_no_days(self):
+ # Given
+ self.mock_no_metricity_user() # Other functions shouldn't be used.
+
+ # When
+ url = reverse('api:bot:user-metricity-activity-data')
+ response = self.client.post(
+ url,
+ data=[0, 1],
+ )
+
+ # Then
+ self.assertEqual(response.status_code, 400)
+ self.metricity.total_messages_in_past_n_days.assert_not_called()
+ self.assertEqual(response.json(), {'days': ["This query parameter is required."]})
+
+ def test_metricity_activity_data_no_users(self):
+ # Given
+ self.mock_no_metricity_user() # Other functions shouldn't be used.
+
+ # When
+ url = reverse('api:bot:user-metricity-activity-data')
+ response = self.client.post(
+ url,
+ QUERY_STRING="days=10",
+ )
+
+ # Then
+ self.assertEqual(response.status_code, 400)
+ self.metricity.total_messages_in_past_n_days.assert_not_called()
+ self.assertEqual(response.json(), ['Expected a list of items but got type "dict".'])
+
+ def test_metricity_activity_data_invalid_users(self):
+ # Given
+ self.mock_no_metricity_user() # Other functions shouldn't be used.
+
+ # When
+ url = reverse('api:bot:user-metricity-activity-data')
+ response = self.client.post(
+ url,
+ data=[123, 'username'],
+ QUERY_STRING="days=10",
+ )
+
+ # Then
+ self.assertEqual(response.status_code, 400)
+ self.metricity.total_messages_in_past_n_days.assert_not_called()
+ self.assertEqual(response.json(), {'1': ['A valid integer is required.']})
+
def mock_metricity_user(self, joined_at, total_messages, total_blocks, top_channel_activity):
patcher = patch("pydis_site.apps.api.viewsets.bot.user.Metricity")
self.metricity = patcher.start()
diff --git a/pydis_site/apps/api/viewsets/bot/nomination.py b/pydis_site/apps/api/viewsets/bot/nomination.py
index 6af42bcb..78687e0e 100644
--- a/pydis_site/apps/api/viewsets/bot/nomination.py
+++ b/pydis_site/apps/api/viewsets/bot/nomination.py
@@ -273,6 +273,11 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
{'reviewed': ['This field cannot be set while you are ending a nomination.']}
)
+ if 'thread_id' in request.data:
+ raise ValidationError(
+ {'thread_id': ['This field cannot be set when ending a nomination.']}
+ )
+
instance.ended_at = timezone.now()
elif 'active' in data:
@@ -281,13 +286,15 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
{'active': ['This field can only be used to end a nomination']}
)
- # This is actually covered, but for some reason coverage don't think so.
- elif 'reviewed' in request.data: # pragma: no cover
- # 4. We are altering the reviewed state of the nomination.
- if not instance.active:
- raise ValidationError(
- {'reviewed': ['This field cannot be set if the nomination is inactive.']}
- )
+ elif not instance.active and 'reviewed' in request.data:
+ raise ValidationError(
+ {'reviewed': ['This field cannot be set if the nomination is inactive.']}
+ )
+
+ elif not instance.active and 'thread_id' in request.data:
+ raise ValidationError(
+ {'thread_id': ['This field cannot be set if the nomination is inactive.']}
+ )
if 'reason' in request.data:
if 'actor' not in request.data:
diff --git a/pydis_site/apps/api/viewsets/bot/user.py b/pydis_site/apps/api/viewsets/bot/user.py
index ba1bcd9d..db73a83c 100644
--- a/pydis_site/apps/api/viewsets/bot/user.py
+++ b/pydis_site/apps/api/viewsets/bot/user.py
@@ -3,8 +3,9 @@ from collections import OrderedDict
from django.db.models import Q
from django_filters.rest_framework import DjangoFilterBackend
-from rest_framework import status
+from rest_framework import fields, status
from rest_framework.decorators import action
+from rest_framework.exceptions import ParseError
from rest_framework.pagination import PageNumberPagination
from rest_framework.request import Request
from rest_framework.response import Response
@@ -138,6 +139,29 @@ class UserViewSet(ModelViewSet):
- 200: returned on success
- 404: if a user with the given `snowflake` could not be found
+ ### POST /bot/users/metricity_activity_data
+ Returns a mapping of user ID to message count in a given period for
+ the given user IDs.
+
+ #### Required Query Parameters
+ - days: how many days into the past to count message from.
+
+ #### Request Format
+ >>> [
+ ... 409107086526644234,
+ ... 493839819168808962
+ ... ]
+
+ #### Response format
+ >>> {
+ ... "409107086526644234": 54,
+ ... "493839819168808962": 0
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 400: if request body or query parameters were missing or invalid
+
### POST /bot/users
Adds a single or multiple new users.
The roles attached to the user(s) must be roles known by the site.
@@ -298,3 +322,34 @@ class UserViewSet(ModelViewSet):
except NotFoundError:
return Response(dict(detail="User not found in metricity"),
status=status.HTTP_404_NOT_FOUND)
+
+ @action(detail=False, methods=["POST"])
+ def metricity_activity_data(self, request: Request) -> Response:
+ """Request handler for metricity_activity_data endpoint."""
+ if "days" in request.query_params:
+ try:
+ days = int(request.query_params["days"])
+ except ValueError:
+ raise ParseError(detail={
+ "days": ["This query parameter must be an integer."]
+ })
+ else:
+ raise ParseError(detail={
+ "days": ["This query parameter is required."]
+ })
+
+ user_id_list_validator = fields.ListField(
+ child=fields.IntegerField(min_value=0),
+ allow_empty=False
+ )
+ user_ids = [
+ str(user_id) for user_id in
+ user_id_list_validator.run_validation(request.data)
+ ]
+
+ with Metricity() as metricity:
+ data = metricity.total_messages_in_past_n_days(user_ids, days)
+
+ default_data = {user_id: 0 for user_id in user_ids}
+ response_data = default_data | dict(data)
+ return Response(response_data, status=status.HTTP_200_OK)
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/guides/pydis-guides/asking-good-questions.md b/pydis_site/apps/content/resources/guides/pydis-guides/asking-good-questions.md
index 971989a9..b08ba7c6 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/asking-good-questions.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/asking-good-questions.md
@@ -26,7 +26,7 @@ If none of the above steps help you or you're not sure how to do some of the abo
# A Good Question
-When you're ready to ask a question, there's a few things you should have to hand before forming a query.
+When you're ready to ask a question, there are a few things you should have to hand before forming a query.
* A code example that illustrates your problem
* If possible, make this a minimal example rather than an entire application
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
index 2822d046..07e9a7bd 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
@@ -112,16 +112,16 @@ You have the source code on your local computer, now how do you actually run it?
### 3. Read our Contributing Guidelines
We have a few short rules that all contributors must follow. Make sure you read and follow them while working on our projects.
-[**Contributing Guidelines**](./contributing-guidelines/).
+[**Read our Contributing Guidelines here.**](./contributing-guidelines/)
As mentioned in the Contributing Guidelines, we have a simple style guide for our projects based on PEP 8. Give it a read to keep your code consistent with the rest of the codebase.
-[**Style Guide**](./style-guide/)
+[**Read our Style Guide here.**](./style-guide/)
### 4. Create an issue
The first step to any new contribution is an issue describing a problem with the current codebase or proposing a new feature. All the open issues are viewable on the GitHub repositories, for instance here is the [issues page for Sir Lancebot](https://github.com/python-discord/sir-lancebot/issues). If you have something that you want to implement open a new issue to present your idea. Otherwise, you can browse the unassigned issues and ask to be assigned to one that you're interested in, either in the comments on the issue or in the [`#dev-contrib`](https://discord.gg/2h3qBv8Xaa) channel on Discord.
-[**How to write a good issue**](./issues/)
+[**Find out how to write a good issue here.**](./issues/)
Don't move forward until your issue is approved by a Core Developer. Issues are not guaranteed to be approved so your work may be wasted.
{: .notification .is-warning }
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
index 633289f2..02316bca 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
@@ -88,6 +88,7 @@ urls:
# Snekbox
snekbox_eval_api: "http://localhost:8060/eval"
+ snekbox_311_eval_api: "http://localhost:8065/eval"
##### << Replace the following � characters with the channel IDs in your test server >> #####
# This assumes the template was used: https://discord.new/zmHtscpYN9E3
@@ -481,10 +482,14 @@ You are now almost ready to run the Python bot. The simplest way to do so is wit
In your `config.yml` file:
* Set `urls.site` to `"web:8000"`.
-* If you wish to work with snekbox set `urls.snekbox_eval_api` to `"http://snekbox:8060/eval"`.
+* If you wish to work with snekbox set the following:
+ * `urls.snekbox_eval_api` to `"http://snekbox:8060/eval"`
+ * `urls.snekbox_311_eval_api` to `"http://snekbox-311:8060/eval"`.
Assuming you have Docker installed **and running**, enter the cloned repo in the command line and type `docker-compose up`.
+If working with snekbox you can run `docker-compose --profile 3.10 up` to also start up a 3.10 snekbox container, in addition to the default 3.11 container!
+
After pulling the images and building the containers, your bot will start. Enter your server and type `!help` (or whatever prefix you chose instead of `!`).
Your bot is now running, but this method makes debugging with an IDE a fairly involved process. For additional running methods, continue reading the following sections.
@@ -494,12 +499,13 @@ The advantage of this method is that you can run the bot's code in your preferre
* Append the following line to your `.env` file: `BOT_API_KEY=badbot13m0n8f570f942013fc818f234916ca531`.
* In your `config.yml` file, set `urls.site` to `"localhost:8000"`. If you wish to keep using `web:8000`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set.
-* To work with snekbox, set `urls.snekbox_eval_api` to `"http://localhost:8060/eval"`
+* To work with snekbox, set `urls.snekbox_eval_api` to `"http://localhost:8060/eval"` and `urls.snekbox_311_eval_api` to `"http://localhost:8065/eval"`
You will need to start the services separately, but if you got the previous section with Docker working, that's pretty simple:
* `docker-compose up web` to start the site container. This is required.
* `docker-compose up snekbox` to start the snekbox container. You only need this if you're planning on working on the snekbox cog.
+* `docker-compose up snekbox-311` to start the snekbox 3.11 container. You only need this if you're planning on working on the snekbox cog.
* `docker-compose up redis` to start the Redis container. You only need this if you're not using fakeredis. For more info refer to [Working with Redis](#optional-working-with-redis).
You can start several services together: `docker-compose up web snekbox redis`.
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/commit-messages.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/commit-messages.md
index ba476b65..ba476b65 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/commit-messages.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/commit-messages.md
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md
index 73c5dcab..d1e4250d 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md
@@ -12,7 +12,7 @@ We have simple but strict style rules that are enforced through linting.
Not all of the style rules are enforced by linting, so make sure to read the [style guide](../style-guide/) as well.
2. **Make great commits.**
Great commits should be atomic, with a commit message explaining what and why.
-Check out [Writing Good Commit Messages](./commit-messages) for details.
+Check out [Writing Good Commit Messages](../commit-messages/) for details.
3. **Do not open a pull request if you aren't assigned to the issue.**
If someone is already working on it, consider offering to collaborate with that person.
4. **Use assets licensed for public use.**
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/_info.yml b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/_info.yml
deleted file mode 100644
index 80c8e772..00000000
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/_info.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-title: Contributing Guidelines
-description: Guidelines to adhere to when contributing to our projects.
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md
index 2be845d3..bef2df9b 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md
@@ -9,7 +9,7 @@ relevant_links:
toc: 3
---
-At Python Discord we have two different kinds of help channels: **topical help channels** and **general help channels**.
+At Python Discord we have two different kinds of help channels: **topical help channels** and **help forum posts**.
# Topical Help Channels
@@ -24,71 +24,82 @@ For example, `#data-science-and-ai` covers scientific Python, statistics, and ma
Each channel on the server has a channel description which briefly describes the topics covered by that channel. If you're not sure where to post, feel free to ask us which channel is appropriate in `#community-meta`.
-# General Help Channels
+# Help Forum Posts
-General help channels can be used for all Python-related help, and have the advantage of attracting a more diverse spectrum of helpers. There is also the added benefit of receiving individual focus and attention on your question. These channels are a great choice for generic Python help, but can be used for domain-specific Python help as well.
+Help forum posts can be used for all Python-related help, and have the advantage of attracting a more diverse spectrum of helpers. There is also the added benefit of receiving individual focus and attention on your question. These posts are a great choice for generic Python help, but can be used for domain-specific Python help as well.
-## How to Claim a Channel
+## How to Create A New Post
-There are always three help channels waiting to be claimed in the **Available Help Channels** category.
+There are 4 easy needed steps to make this happen
-![Available help channels](/static/images/content/help_channels/available_channels.png)
-*The Available Help Channels category is always at the top of the server's channel list.*
+1. Navigate to the **Python Help System** category.<br>
+![Python help system category](/static/images/content/help_channels/help-system-category.png)
+2. Open the **python-help** forum channel.
+3. Click on the **New Post** button in the top-right corner.<br>
+![New post button](/static/images/content/help_channels/new-post-button.png)
+4. Choose a brief title that best describes your issue, along with a message explaining it more in details, and **post** it.
+Note that you can also choose one or more tags which can help attract experts of that tag easily.<br>
+![New post form](/static/images/content/help_channels/new-post-form.png)
-![Available message](/static/images/content/help_channels/available_message.png)
-*This message indicates that a channel is available.*
+Be sure to [ask questions with enough information](../asking-good-questions) in order to give yourself the best chances of getting help!
-In order to claim one, simply ask your question in one of the available channels. Be sure to [ask questions with enough information](../asking-good-questions) in order to give yourself the best chances of getting help!
+At this point you will have the **Help Cooldown** role which will remain on your profile until you close your newly created post. This ensures that users can only have one post at any given time, giving everyone a chance to have their question seen.
-![Channel claimed embed](/static/images/content/help_channels/claimed_channel.png)
-*This messages indicates that you've claimed the channel.*
+# Frequently Asked Questions
-At this point you will have the **Help Cooldown** role which will remain on your profile until you close your help channel. This ensures that users can claim only one help channel at any given time, giving everyone a chance to have their question seen.
+### I created a new help post, what happens now?
+Once you click on `Post`, these events take place:<br>
+1. A new channel will be created for you, and you'll have an `OP` next to you username, which tells people you're the `Original Poster`, or in other words, the owner of the help topic in that channel.<br>
+2. Your original question/message will always be the first one in that channel.<br>
+3. Our Python bot will send a message reminding you of what you should include in your question/message in case you could have missed anything.<br>
+4. People will be able to jump on that channel, and you can have a discussion with anyone who's volunteering to help you by asking as many followup questions as you want.<br>
-# Frequently Asked Questions
+#### Example
+Suppose we're trying to find the minimum value in a list of integers.
+Once we've chosen our title and message content, we are ready to make a new post.<br><br>
+![Filled form example](/static/images/content/help_channels/question-example.png)<br><br>
+Note how we've checked the **Algos & data structs** tag here, whose circumference is highlighted in blue, since this is a question about an algorithm to find the minimum.<br>
+This will greatly help others pinpoint where they can help you best based on a combination of your title and tag from a first glance.<br><br>
+Once you click on post, a new channel is created, and you can see the original message on top along with the `OP` tag next to the poster's avatar.<br>
+You will also see the message that our Python bot sends instantly right after yours.<br><br>
+![Newly created thread example](/static/images/content/help_channels/newly-created-thread-example.png)
-### How long does my help channel stay active?
+### How long does my help post stay active?
-The channel remains open for **30 minutes** after your last message, or 10 minutes after the last message sent by another user (whichever time comes later).
+The post remains open for **30 minutes** after your last message, or 10 minutes after the last message sent by another user (whichever time comes later).
![Channel dormant message](/static/images/content/help_channels/dormant_message.png)
-*You'll see this message in your channel once it goes dormant.*
+*You'll see this message in your post once it goes dormant.*
+
### No one answered my question. How come?
-The server has users active all over the world and all hours of the day, but some time periods are less active than others. It's also possible that the users that read your question didn't have the knowledge required to help you. If no one responded, feel free to claim another help channel a little later, or try an appropriate topical channel.
+The server has users active all over the world and all hours of the day, but some time periods are less active than others. It's also possible that the users that read your question didn't have the knowledge required to help you. If no one responded, feel free to open another post a little later, or try an appropriate topical channel.
If you feel like your question is continuously being overlooked, read our guide on [asking good questions](../asking-good-questions) to increase your chances of getting a response.
### My question was answered. What do I do?
-Go ahead use the `!close` command if you've satisfactorily solved your problem. You will only be able to run this command in your own help channel, and no one (outside of staff) will be able to close your channel for you.
+Go ahead and use one of the `!close` or `!solved` commands if you've satisfactorily solved your problem. You will only be able to run this command in your own post, and no one (outside of staff) will be able to close your post for you.
-Closing your help channel once you are finished leads to less occupied channels, which means more attention can be given to other users that still need help.
+Closing your post once you are finished leads to less occupied ones, which means more attention can be given to other users that still need help.
### Can only Helpers answer help questions?
-Definitely not! We encourage all members of the community to participate in giving help. If you'd like to help answer some questions, head over to the **Occupied Help Channels** or **Topical Chat/Help** categories.
+Definitely not! We encourage all members of the community to participate in giving help. If you'd like to help answer some questions, you can either browse all posts in the **python-help** forum channel or head over to the **Topical Chat/Help** category.
Before jumping in, please read our guide on [helping others](../helping-others) which explains our expectations for the culture and quailty of help that we aim for on the server.
-Tip: run the `!helpdm on` command in `#bot-commands` to get notified via DM with jumplinks to help channels you're participating in.
-
-### What are the available, occupied, and dormant categories?
-
-The three help channels under **Available Help Channels** are free for anyone to claim. Claimed channels are then moved to **Occupied Help Channels**. Once they close, they are moved to the **Python Help: Dormant** category until they are needed again for **Available Help Channels**.
+Tip: run the `!helpdm on` command in the `#bot-commands` channel to get notified via DM with jumplinks to help posts you're participating in.
### Can I save my help session for future reference?
-Yes! Because the help channels are continuously cycled in and out without being deleted, this means you can always refer to a previous help session if you found one particularly helpful.
+Yes! Because the help posts are only closed without being deleted, this means you can always refer to a previous help session if you found one particularly helpful.
Tip: reply to a message and run the `.bm` command to get bookmarks sent to you via DM for future reference.
-### I lost my help channel!
+### I lost my help post!
-No need to panic. Your channel was probably just closed due to inactivity.
-All the dormant help channels are still available at the bottom of the channel list, in the **Python Help: Dormant** category, and also through search.
-If you're not sure what the name of your help channel was, you can easily find it by using the Discord Search feature.
+No need to panic. Your post was probably just closed due to inactivity.
+All the dormant help posts are still available at the bottom of the **python-help** forum channel and also through search in the **Python Help System** category.
+If you're not sure what the title of your help post was, you can easily find it by using the Discord Search feature.
Try searching for `from:<your nickname>` to find the last messages sent by yourself, and from there you will be able to jump directly into the channel by pressing the Jump button on your message.
-
-![Dormant help channels](/static/images/content/help_channels/dormant_channels.png)
-*The dormant help channels can be found at the bottom of the channel list.*
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/helping-others.md b/pydis_site/apps/content/resources/guides/pydis-guides/helping-others.md
index a7f1ce1d..9f0d947f 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/helping-others.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/helping-others.md
@@ -9,7 +9,7 @@ relevant_links:
toc: 2
---
-Python Discord has a lot of people asking questions, be it in the help channels, topical channels, or any other part of the server.
+Python Discord has a lot of people asking questions, be it in the help forum, topical channels, or any other part of the server.
Therefore, you might sometimes want to give people the answers you have in mind.
But you might not be sure how best to approach the issue, or maybe you'd like to see how others handle it.
This article aims to present a few of the general principles which guide the staff on a day-to-day basis on the server.
@@ -64,7 +64,7 @@ At other times, it might not be as obvious, and it might be a good idea to kindl
The path is often more important than the answer.
Your goal should primarily be to allow the helpee to apply, at least to a degree, the concepts you introduce in your answer.
Otherwise, they might keep struggling with the same problem over and over again.
-That means that simply showing your answer might close the help channel for the moment, but won't be very helpful in the long-term.
+That means that simply showing your answer might close the help post for the moment, but won't be very helpful in the long-term.
A common approach is to walk the helpee through to an answer:
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md b/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md
index 716250b1..0d14ef41 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md
@@ -169,7 +169,7 @@ path = os.path.join("foo", "bar")
### HTML Attributes
To add HTML attributes to certain lines/paragraphs, [see this page](https://python-markdown.github.io/extensions/attr_list/#the-list) for the format and where to put it.
-This can be useful for setting the image size when adding an image using markdown (see the [Image Captions](#image-captions) section for an example), or for adding bulma styles to certain elements (like the warning notification [here](/pages/guides/pydis-guides/contributing/sir-lancebot#setup-instructions)).<br>
+This can be useful for setting the image size when adding an image using markdown (see the [Image Captions](#image-captions) section for an example), or for adding bulma styles to certain elements (like the warning notification [here](/pages/guides/pydis-guides/contributing/sir-lancebot#run-with-docker)).<br>
**This should be used sparingly, as it reduces readability and simplicity of the article.**
---
@@ -215,3 +215,44 @@ To use a custom label in the table of contents for a heading, set the `data-toc-
```markdown
# Header 1 {: data-toc-label="Header One" }
```
+
+## Tips
+
+### Nested/Unhighlighted Code Blocks
+To nest code blocks, increase the number of ticks in the outer block by 1. To remove highlighting from code blocks (ie. no dark background), you can use the `nohighlight` language.
+`````nohighlight
+````nohighlight
+```python
+print("Some inner code block text.")
+```
+````
+`````
+
+### Images in Lists
+To properly indent images in lists, keep the image on the line directly after the previous line and add `<br>` to the end of the text, like this:
+
+```markdown
+1. List item text one.<br>
+![Image text one](image/link/one)
+
+2. List item text two.<br>
+![Image text two](image/link/two)
+```
+
+### Keeping Text In The Same Paragraph
+You can also use `<br>` to break lines while keeping them in the same paragraph (avoiding the vertical spacing added between paragraphs).
+
+```nohighlight
+##### Same line, same paragraph
+Line A
+Line B
+
+##### Different line, different paragraph
+Line A
+
+Line B
+
+##### Different line, same paragraph
+Line A<br>
+Line B
+```
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/off-topic-etiquette.md b/pydis_site/apps/content/resources/guides/pydis-guides/off-topic-etiquette.md
index f8031834..5e785cd9 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/off-topic-etiquette.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/off-topic-etiquette.md
@@ -5,7 +5,7 @@ icon: fab fa-discord
---
## Why do we need off-topic etiquette?
-Everyone wants to have good conversations in our off-topic channels, but with tens of thousands of members, this might mean different things to different people.
+Everyone wants to have good conversations in our off-topic channels, but with hundreds of thousands of members, this might mean different things to different people.
To facilitate the best experience for everyone, here are some guidelines on conversation etiquette.
## Three things you shouldn't do
diff --git a/pydis_site/apps/content/resources/guides/python-guides/app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/app-commands.md
new file mode 100644
index 00000000..713cd650
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/python-guides/app-commands.md
@@ -0,0 +1,418 @@
+---
+title: Discord.py 2.0 changes
+description: Changes and new features in version 2.0 of discord.py
+---
+
+Upon the return of the most popular discord API wrapper library for Python, `discord.py`, while catching on to the latest features of the discord API, there have been numerous changes with additions of features to the library. Additions to the library include support for Buttons, Select Menus, Forms (AKA Modals), Slash Commands (AKA Application Commands) and a bunch more handy features! All the changes can be found [here](https://discordpy.readthedocs.io/en/latest/migrating.html). Original discord.py Gist regarding resumption can be found [here](https://gist.github.com/Rapptz/c4324f17a80c94776832430007ad40e6).
+
+
+# Install the latest version of discord.py
+
+Before you can make use of any of the new 2.0 features, you need to install the latest version of discord.py. Make sure that the version is 2.0 or above!
+Also, make sure to uninstall any third party libraries intended to add slash-command support to pre-2.0 discord.py, as they are no longer necessary and will likely cause issues.
+
+The latest and most up-to-date stable discord.py version can be installed using `pip install -U discord.py`.
+
+**Before migrating to discord.py 2.0, make sure you read the migration guide [here](https://discordpy.readthedocs.io/en/latest/migrating.html) as there are lots of breaking changes.**.
+{: .notification .is-warning }
+
+# What are Slash Commands?
+
+Slash Commands are an exciting new way to build and interact with bots on Discord. As soon as you type "/", you can easily see all the commands a bot has. It also comes with autocomplete, validation and error handling, which will all help users of your bot get the command right the first time.
+
+# Basic structure for discord.py Slash Commands!
+
+### Note that Slash Commands in discord.py are also referred to as **Application Commmands** and **App Commands** and every *interaction* is a *webhook*.
+Slash commands in discord.py are held by a container, [CommandTree](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=commandtree#discord.app_commands.CommandTree). A command tree is required to create Slash Commands in discord.py. This command tree provides a `command` method which decorates an asynchronous function indicating to discord.py that the decorated function is intended to be a slash command. This asynchronous function expects a default argument which acts as the interaction which took place that invoked the slash command. This default argument is an instance of the **Interaction** class from discord.py. Further up, the command logic takes over the behaviour of the slash command.
+
+# Fundamentals for this Gist!
+
+One new feature added in discord.py v2 is `setup_hook`. `setup_hook` is a special asynchronous method of the Client and Bot classes which can be overwritten to perform numerous tasks. This method is safe to use as it is always triggered before any events are dispatched, i.e. this method is triggered before the *IDENTIFY* payload is sent to the discord gateway.
+Note that methods of the Bot class such as `change_presence` will not work in setup_hook as the current application does not have an active connection to the gateway at this point.
+A full list of commands you can't use in setup_hook can be found [here](https://discord.com/developers/docs/topics/gateway-events#send-events).
+
+__**THE FOLLOWING ARE EXAMPLES OF HOW A `SETUP_HOOK` FUNCTION CAN BE DEFINED**__
+
+Note that the default intents are defined [here](https://discordpy.readthedocs.io/en/stable/api.html?highlight=discord%20intents%20default#discord.Intents.default) to have all intents enabled except presences, members, and message_content.
+
+```python
+import discord
+
+# You can create the setup_hook directly in the class definition
+
+class SlashClient(discord.Client):
+ def __init__(self) -> None:
+ super().__init__(intents=discord.Intents.default())
+
+ async def setup_hook(self) -> None:
+ ...
+
+# Or add it to the client after creating it
+
+client = discord.Client(intents=discord.Intents.default())
+async def my_setup_hook() -> None:
+ ...
+
+client.setup_hook = my_setup_hook
+```
+
+# Basic Slash Command application using discord.py.
+
+#### The `CommandTree` class resides within the `app_commands` of the discord.py package.
+
+## Slash Command Application with a Client
+
+```python
+import discord
+
+class SlashClient(discord.Client):
+ def __init__(self) -> None:
+ super().__init__(intents=discord.Intents.default())
+ self.tree = discord.app_commands.CommandTree(self)
+
+ async def setup_hook(self) -> None:
+ self.tree.copy_global_to(guild=discord.Object(id=12345678900987654))
+ await self.tree.sync()
+
+client = SlashClient()
+
[email protected](name="ping", description="...")
+async def _ping(interaction: discord.Interaction) -> None:
+ await interaction.response.send_message("pong")
+
+client.run("token")
+```
+
+
+__**EXPLANATION**__
+
+- `import discord` imports the **discord.py** package.
+- `class SlashClient(discord.Client)` is a class subclassing **Client**. Though there is no particular reason except readability to subclass the **Client** class, using the `Client.setup_hook = my_func` is equally valid.
+- Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commands.
+- Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. **Essential to creation of commands** Further up, `self.tree.sync()` updates the API with any changes to the Slash Commands.
+- Finishing up with the **Client** subclass, we create an instance of the subclassed Client class which here has been named as `SlashClient` with `client = SlashClient()`.
+- Then using the `command` method of the `CommandTree` we decorate a function with it as `client.tree` is an instance of `CommandTree` for the current application. The command function takes a default argument as said, which acts as the interaction that took place. Catching up is `await interaction.response.send_message("pong")` which sends back a message to the slash command invoker.
+- And the classic old `client.run("token")` is used to connect the client to the discord gateway.
+- Note that the `send_message` is a method of the `InteractionResponse` class and `interaction.response` in this case is an instance of the `InteractionResponse` object. The `send_message` method will not function if the response is not sent within 3 seconds of command invocation. We will discuss how to handle this issue later following the Gist.
+
+## Slash Command application with the Bot class
+
+```python
+import discord
+
+class SlashBot(commands.Bot):
+ def __init__(self) -> None:
+ super().__init__(command_prefix=".", intents=discord.Intents.default())
+
+ async def setup_hook(self) -> None:
+ self.tree.copy_global_to(guild=discord.Object(id=12345678900987654))
+ await self.tree.sync()
+
+bot = SlashBot()
+
[email protected](name="ping", description="...")
+async def _ping(interaction: discord.Interaction) -> None:
+ await interaction.response.send_message("pong")
+
+bot.run("token")
+```
+
+The above example shows a basic Slash Commands within discord.py using the Bot class.
+
+__**EXPLANATION**__
+
+Most of the explanation is the same as the prior example that featured `SlashClient` which was a subclass of **discord.Client**. Though some minor changes are discussed below.
+
+- The `SlashBot` class now subclasses `discord.ext.commands.Bot` following the passing in of the required arguments to its `__init__` method.
+- `discord.ext.commands.Bot` already consists of an instance of the `CommandTree` class which can be accessed using the `tree` property.
+
+# Slash Commands within a Cog!
+
+A cog is a collection of commands, listeners, and optional state to help group commands together. More information on them can be found on the [Cogs](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html#ext-commands-cogs) page.
+
+## An Example to using cogs with discord.py for Slash Commands!
+
+```python
+import discord
+from discord.ext import commands
+from discord import app_commands
+
+class MySlashCog(commands.Cog):
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+
+ @app_commands.command(name="ping", description="...")
+ async def _ping(self, interaction: discord.Interaction):
+ await interaction.response.send_message("pong!")
+
+class MySlashBot(commands.Bot):
+ def __init__(self) -> None:
+ super().__init__(command_prefix="!", intents=discord.Intents.default())
+
+ async def setup_hook(self) -> None:
+ await self.add_cog(MySlashCog(self))
+ await self.tree.copy_global_to(discord.Object(id=123456789098765432))
+ await self.tree.sync()
+
+bot = MySlashBot()
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+
+- Firstly, `import discord` imports the **discord.py** package. `from discord import app_commands` imports the `app_commands` module from the **discord.py** root module. `from discord.ext import commands` imports the commands extension.
+- Further up, `class MySlashCog(commands.Cog)` is a class subclassing the `Cog` class. You can read more about this [here](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html#ext-commands-cogs).
+- `def __init__(self, bot: commands.Bot): self.bot = bot` is the constructor method of the class that is always run when the class is instantiated and that is why we pass in a **Bot** object whenever we create an instance of the cog class.
+- Following up is the `@app_commands.command(name="ping", description="...")` decorator. This decorator basically functions the same as a `bot.tree.command` but since the cog currently does not have a **bot**, the `app_commands.command` decorator is used instead. The next two lines follow the same structure for Slash Commands with **self** added as the first parameter to the function as it is a method of a class.
+- The next up lines are mostly the same.
+- Talking about the first line inside the `setup_hook` is the `add_cog` method of the **Bot** class. And since **self** acts as the **instance** of the current class, we use **self** to use the `add_cog` method of the **Bot** class as we are inside a subclassed class of the **Bot** class. Then we pass in **self** to the `add_cog` method as the `__init__` function of the **MySlashCog** cog accepts a `Bot` object.
+- After that we instantiate the `MySlashBot` class and run the bot using the **run** method which executes our setup_hook function and our commands get loaded and synced. The bot is now ready to use!
+
+# An Example to using groups with discord.py for Slash Commands!
+
+## An example with optional group!
+
+```python
+import discord
+from discord.ext import commands
+from discord import app_commands
+
+class MySlashGroupCog(commands.Cog):
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+
+ #--------------------------------------------------------
+ group = app_commands.Group(name="uwu", description="...")
+ #--------------------------------------------------------
+
+ @app_commands.command(name="ping", description="...")
+ async def _ping(self, interaction: discord.) -> None:
+ await interaction.response.send_message("pong!")
+
+ @group.command(name="command", description="...")
+ async def _cmd(self, interaction: discord.Interaction) -> None:
+ await interaction.response.send_message("uwu")
+
+class MySlashBot(commands.Bot):
+ def __init__(self) -> None:
+ super().__init__(command_prefix="!", intents=discord.Intents.default())
+
+ async def setup_hook(self) -> None:
+ await self.add_cog(MySlashGroupCog(self))
+ await self.tree.copy_global_to(discord.Object(id=123456789098765432))
+ await self.tree.sync()
+
+bot = MySlashBot()
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+- The only difference used here is `group = app_commands.Group(name="uwu", description="...")` and `group.command`. `app_commands.Group` is used to initiate a group while `group.command` registers a command under a group. For example, the ping command can be run using **/ping** but this is not the case for group commands. They are registered with the format of `group_name command_name`. So here, the **command** command of the **uwu** group would be run using **/uwu command**. Note that only group commands can have a single space between them.
+
+## An example with a **Group** subclass!
+
+```python
+import discord
+from discord.ext import commands
+from discord import app_commands
+
+class MySlashGroup(app_commands.Group, name="uwu"):
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+ super().__init__()
+
+ @app_commands.command(name="ping", description="...")
+ async def _ping(self, interaction: discord.) -> None:
+ await interaction.response.send_message("pong!")
+
+ @app_commands.command(name="command", description="...")
+ async def _cmd(self, interaction: discord.Interaction) -> None:
+ await interaction.response.send_message("uwu")
+
+class MySlashBot(commands.Bot):
+ def __init__(self) -> None:
+ super().__init__(command_prefix="!", intents=discord.Intents.default())
+
+ async def setup_hook(self) -> None:
+ await self.add_cog(MySlashGroup(self))
+ await self.tree.copy_global_to(discord.Object(id=123456789098765432))
+ await self.tree.sync()
+
+bot = MySlashBot()
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+- The only difference here too is that the `MySlashGroup` class directly subclasses the **Group** class from discord.app_commands which automatically registers all the methods within the group class to be commands of that specific group. So now, the commands such as `ping` can be run using **/uwu ping** and `command` using **/uwu command**.
+
+# Some common methods and features used for Slash Commands.
+
+### A common function used for Slash Commands is the `describe` function. This is used to add descriptions to the arguments of a slash command. The command function can decorated with this function. It goes by the following syntax as shown below.
+
+```python
+from discord.ext import commands
+from discord import app_commands
+import discord
+
+bot = commands.Bot(command_prefix=".", intents=discord.Intents.default())
+#sync the commands
+
[email protected](name="echo", description="...")
+@app_commands.describe(text="The text to send!", channel="The channel to send the message in!")
+async def _echo(interaction: discord.Interaction, text: str, channel: discord.TextChannel=None):
+ channel = channel or interaction.channel
+ await channel.send(text)
+```
+
+### Another common issue that most people come across is the time duration of sending a message with `send_message`. This issue can be tackled by deferring the interaction response using the `defer` method of the `InteractionResponse` class. An example for fixing this issue is shown below.
+
+```python
+import discord
+from discord.ext import commands
+import asyncio
+
+bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
+#sync the commands
+
[email protected](name="time", description="...")
+async def _time(interaction: discord.Interaction, time_to_wait: int):
+ # -------------------------------------------------------------
+ await interaction.response.defer(ephemeral=True)
+ # -------------------------------------------------------------
+ await interaction.edit_original_response(content=f"I will notify you after {time_to_wait} seconds have passed!")
+ await asyncio.sleep(time_to_wait)
+ await interaction.edit_original_response(content=f"{interaction.user.mention}, {time_to_wait} seconds have already passed!")
+```
+
+# Checking for Permissions and Roles!
+
+To add a permissions check to a command, the methods are imported through `discord.app_commands.checks`. To check for a member's permissions, the function can be decorated with the [discord.app_commands.checks.has_permissions](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=has_permissions#discord.app_commands.checks.has_permissions) method. An example to this as follows.
+
+```py
+from discord import app_commands
+from discord.ext import commands
+import discord
+
+bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
+#sync commands
+
[email protected](name="ping")
+@app_commands.checks.has_permissions(manage_messages=True, manage_channels=True) #example permissions
+async def _ping(interaction: discord.Interaction):
+ await interaction.response.send_message("pong!")
+
+```
+
+If the check fails, it will raise a `MissingPermissions` error which can be handled within an app commands error handler! We will discuss making an error handler later in the Gist. All the permissions can be found [here](https://discordpy.readthedocs.io/en/latest/api.html?highlight=discord%20permissions#discord.Permissions).
+
+Other methods that you can decorate the commands with are -
+- `bot_has_permissions` | This checks if the bot has the required permissions for executing the slash command. This raises a [BotMissingPermissions](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.BotMissingPermissions) exception.
+- `has_role` | This checks if the slash command user has the required role or not. Only **ONE** role name or role ID can be passed to this. If the name is being passed, make sure to have the exact same name as the role name. This raises a [MissingRole](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.MissingRole) exception.
+- To pass in several role names or role IDs, `has_any_role` can be used to decorate a command. This raises two exceptions -> [MissingAnyRole](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.MissingAnyRole) and [NoPrivateMessage](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.NoPrivateMessage)
+
+
+# Adding cooldowns to Slash Commands!
+
+Slash Commands within discord.py can be applied cooldowns to in order to prevent spamming of the commands. This can be done through the `discord.app_commands.checks.cooldown` method which can be used to decorate a slash command function and register a cooldown to the function. This raises a [CommandOnCooldown](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=checks%20cooldown#discord.app_commands.CommandOnCooldown) exception if the command is currently on cooldown.
+An example is as follows.
+
+```python
+from discord.ext import commands
+import discord
+
+class Bot(commands.Bot):
+ def __init__(self):
+ super().__init__(command_prefix="uwu", intents=discord.Intents.all())
+
+ async def setup_hook(self):
+ self.tree.copy_global_to(guild=discord.Object(id=12345678909876543))
+ await self.tree.sync()
+
+
+bot = Bot()
+
[email protected](name="ping")
+# -----------------------------------------
[email protected]_commands.checks.cooldown(1, 30)
+# -----------------------------------------
+async def ping(interaction: discord.Interaction):
+ await interaction.response.send_message("pong!")
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+- The first argument is the number of times this command can be invoked before the cooldown is triggered.
+- The second argument it takes is the period of time in which the command can be run the specified number of times.
+- The `CommandOnCooldown` exception can be handled using an error handler. We will discuss making an error handler for Slash Commands later in the Gist.
+
+
+# Handling errors for Slash Commands!
+
+The Slash Commands exceptions can be handled by overwriting the `on_error` method of the `CommandTree`. The error handler takes two arguments. The first argument is the `Interaction` that took place when the error occurred and the second argument is the error that occurred when the Slash Commands was invoked. The error is an instance of [discord.app_commands.AppCommandError](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=appcommanderror#discord.app_commands.AppCommandError) which is a subclass of [DiscordException](https://discordpy.readthedocs.io/en/latest/api.html?highlight=discordexception#discord.DiscordException).
+An example to creating an error handler for Slash Commands is as follows.
+
+```python
+from discord.ext import commands
+from discord import app_commands
+import discord
+
+bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
+#sync commands
+
[email protected](name="ping")
+@app_commands.checks.cooldown(1, 30)
+async def ping(interaction: discord.Interaction):
+ await interaction.response.send_message("pong!")
+
+async def on_tree_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
+ if isinstance(error, app_commands.CommandOnCooldown):
+ return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!")
+ elif isinstance(error, ...):
+ ...
+ else:
+ raise error
+
+bot.tree.on_error = on_tree_error
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+
+First we create a simple asynchronous function named `on_tree_error` here. To which the first two required arguments are passed, `Interaction` which is named as `interaction` here and `AppCommandError` which is named as `error` here. Then using simple functions and keywords, we make an error handler like above. Here we have used the `isinstance` function which takes in an object and a base class as the second argument, this function returns a bool value. The `raise error` is just for displaying unhandled errors, i.e. the ones which have not been handled manually. If this is **removed**, you will not be able to see any exceptions raised by Slash Commands and makes debugging the code harder.
+After creating the error handler function, we set the function as the error handler for the Slash Commands. Here, `bot.tree.on_error = on_tree_error` overwrites the default `on_error` method of the **CommandTree** class with our custom error handler which has been named as `on_tree_error` here.
+
+### Creating an error handler for a specific error!
+
+```python
+from discord.ext import commands
+from discord import app_commands
+import discord
+
+bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
+#sync commands
+
[email protected](name="ping")
+@app_commands.checks.cooldown(1, 30)
+async def ping(interaction: discord.Interaction):
+ await interaction.response.send_message("pong!")
+
+async def ping_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
+ if isinstance(error, app_commands.CommandOnCooldown):
+ return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!")
+ elif isinstance(error, ...):
+ ...
+ else:
+ raise error
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+
+Here the command name is simply used to access the `error` method to decorate a function which acts as the `on_error` but for a specific command. You should not need to call the `error` method manually.
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md b/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md
index 62ff61f9..0e88490e 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md
@@ -3,9 +3,10 @@ title: Discord Messages with Colors
description: A guide on how to add colors to your codeblocks on Discord
---
-Discord is now slowly rolling out the ability to send colored text within code blocks. This is done using ANSI color codes which is also how you print colored text in your terminal.
+Discord is now *slowly rolling out* the ability to send colored messages within code blocks. It uses the ANSI color codes, so if you've tried to print colored text in your terminal or console with Python or other languages then it will be easy for you.
-To send colored text in a code block you need to first specify the `ansi` language and use the prefixes similar to the one below:
+## Quick Explanation
+To be able to send a colored text, you need to use the `ansi` language for your code block and provide a prefix of this format before writing your text:
```ansi
\u001b[{format};{color}m
```
@@ -13,15 +14,17 @@ To send colored text in a code block you need to first specify the `ansi` langua
After you've written this, you can now type the text you wish to color. If you want to reset the color back to normal, then you need to use the `\u001b[0m` prefix again.
+## Formats
Here is the list of values you can use to replace `{format}`:
* 0: Normal
* 1: **Bold**
* 4: <ins>Underline</ins>
+## Colors
Here is the list of values you can use to replace `{color}`:
-*The following values will change the **text** color.*
+### Text Colors
* 30: Gray
* 31: Red
@@ -32,7 +35,7 @@ Here is the list of values you can use to replace `{color}`:
* 36: Cyan
* 37: White
-*The following values will change the **text background** color.*
+### Background Colors
* 40: Firefly dark blue
* 41: Orange
@@ -43,7 +46,9 @@ Here is the list of values you can use to replace `{color}`:
* 46: Light gray
* 47: White
-Let's take an example, I want a bold green colored text with the very dark blue background.
+## Example
+
+Let's take an example, I want a bold green colored text with the firefly dark blue background.
I simply use `\u001b[0;40m` (background color) and `\u001b[1;32m` (text color) as prefix. Note that the order is **important**, first you give the background color and then the text color.
Alternatively you can also directly combine them into a single prefix like the following: `\u001b[1;40;32m` and you can also use multiple values. Something like `\u001b[1;40;4;32m` would underline the text, make it bold, make it green and have a dark blue background.
@@ -61,8 +66,14 @@ Result:
![Background and text color result](/static/images/content/discord_colored_messages/result.png)
+### ANSI Colors Showcase
+
The way the colors look like on Discord is shown in the image below:
![ANSI Colors](/static/images/content/discord_colored_messages/ansi-colors.png)
-Note: If the change as not been brought to you yet, or other users, then you can use other code blocks in the meantime to get colored text. See **[this gist](https://gist.github.com/matthewzring/9f7bbfd102003963f9be7dbcf7d40e51)**.
+*Message sent to get the output of above can be found [here](https://gist.github.com/kkrypt0nn/a02506f3712ff2d1c8ca7c9e0aed7c06#file-ansi-colors-showcase-md).*
+
+#### Disclaimer
+
+***Note**: The change has been brought to all stable desktop clients. Since syntax highlighting on mobile is far behind, ANSI is not supported on mobile as well. Refer to [this gist](https://gist.github.com/matthewzring/9f7bbfd102003963f9be7dbcf7d40e51) for other syntax highlighting methods.*
diff --git a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md
new file mode 100644
index 00000000..57d86e99
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md
@@ -0,0 +1,323 @@
+---
+title: How to host a bot with Docker and GitHub Actions on Ubuntu VPS
+description: This guide shows how to host a bot with Docker and GitHub Actions on Ubuntu VPS
+---
+
+## Contents
+
+1. [You will learn](#you-will-learn)
+2. [Introduction](#introduction)
+3. [Installing Docker](#installing-docker)
+4. [Creating Dockerfile](#creating-dockerfile)
+5. [Building Image and Running Container](#building-image-and-running-container)
+6. [Creating Volumes](#creating-volumes)
+7. [Using GitHub Actions for full automation](#using-github-actions-for-full-automation)
+
+## You will learn how to
+
+- write Dockerfile
+- build Docker image and run the container
+- use Docker Compose
+- make docker keep the files throughout the container's runs
+- parse environment variables into container
+- use GitHub Actions for automation
+- set up self-hosted runner
+- use runner secrets
+
+## Introduction
+
+Let's say you have got a nice discord bot written in python and you have a VPS to host it on. Now the only question is
+how to run it 24/7. You might have been suggested to use *screen multiplexer*, but it has some disadvantages:
+
+1. Every time you update the bot you have to SSH to your server, attach to screen, shutdown the bot, run `git pull` and
+ run the bot again. You might have good extensions management that allows you to update the bot without restarting it,
+ but there are some other cons as well
+2. If you update some dependencies, you have to update them manually
+3. The bot doesn't run in an isolated environment, which is not good for security.
+
+But there's a nice and easy solution to these problems - **Docker**! Docker is a containerization utility that automates
+some stuff like dependencies update and running the application in the background. So let's get started.
+
+## Installing Docker
+
+The best way to install Docker is to use
+the [convenience script](https://docs.docker.com/engine/install/ubuntu/#install-using-the-convenience-script) provided
+by Docker developers themselves. You just need 2 lines:
+
+```shell
+$ curl -fsSL https://get.docker.com -o get-docker.sh
+$ sudo sh get-docker.sh
+```
+
+## Creating Dockerfile
+
+To tell Docker what it has to do to run the application, we need to create a file named `Dockerfile` in our project's
+root.
+
+1. First we need to specify the *base image*, which is the OS that the docker container will be running. Doing that will
+ make Docker install some apps we need to run our bot, for
+ example the Python interpreter
+
+```dockerfile
+FROM python:3.10-bullseye
+```
+
+2. Next, we need to copy our Python project's external dependencies to some directory *inside the container*. Let's call
+ it `/app`
+
+```dockerfile
+COPY requirements.txt /app/
+```
+
+3. Now we need to set the directory as working and install the requirements
+
+```dockerfile
+WORKDIR /app
+RUN pip install -r requirements.txt
+```
+
+4. The only thing that is left to do is to copy the rest of project's files and run the main executable
+
+```dockerfile
+COPY . .
+CMD ["python3", "main.py"]
+```
+
+The final version of Dockerfile looks like this:
+
+```dockerfile
+FROM python:3.10-bullseye
+COPY requirements.txt /app/
+WORKDIR /app
+RUN pip install -r requirements.txt
+COPY . .
+CMD ["python3", "main.py"]
+```
+
+## Building Image and Running Container
+
+Now update the project on your VPS, so we can run the bot with Docker.
+
+1. Build the image (dot at the end is very important)
+
+```shell
+$ docker build -t mybot .
+```
+
+- the `-t` flag specifies a **tag** that will be assigned to the image. With it, we can easily run the image that the
+ tag was assigned to.
+- the dot at the end is basically the path to search for Dockerfile. The dot means current directory (`./`)
+
+2. Run the container
+
+```shell
+$ docker run -d --name mybot mybot:latest
+```
+
+- `-d` flag tells Docker to run the container in detached mode, meaning it will run the container in the background of
+ your terminal and not give us
+ any output from it. If we don't
+ provide it, the `run` will be giving us the output until the application exits. Discord bots aren't supposed to exit
+ after certain time, so we do need this flag
+- `--name` assigns a name to the container. By default, container is identified by id that is not human-readable. To
+ conveniently refer to container when needed,
+ we can assign it a name
+- `mybot:latest` means "latest version of `mybot` image"
+
+3. Read bot logs (keep in mind that this utility only allows to read STDERR)
+
+```shell
+$ docker logs -f mybot
+```
+
+- `-f` flag tells the docker to keep reading logs as they appear in container and is called "follow mode". To exit
+ press `CTRL + C`.
+
+If everything went successfully, your bot will go online and will keep running!
+
+## Using Docker Compose
+
+Just 2 commands to run a container is cool, but we can shorten it down to just 1 simple command. For that, create
+a `docker-compose.yml` file in project's root and fill it with the following contents:
+
+```yml
+version: "3.8"
+services:
+ main:
+ build: .
+ container_name: mybot
+```
+
+- `version` tells Docker what version of Compose to use. You may check all the
+ versions [here](https://docs.docker.com/compose/compose-file/compose-versioning/)
+- `services` contains services to build and run. Read more about
+ services [here](https://docs.docker.com/compose/compose-file/#services-top-level-element)
+- `main` is a service. We can call it whatever we would like to, not necessarily `main`
+- `build: .` is a path to search for Dockerfile, just like `docker build` command's dot
+- `container_name: mybot` is a container name to use for a bot, just like `docker run --name mybot`
+
+Update the project on VPS, remove the previous container with `docker rm -f mybot` and run this command
+
+```shell
+docker compose up -d --build
+```
+
+Now the docker will automatically build the image for you and run the container.
+
+### Why docker-compose
+
+The main purpose of Compose is to run several services at once. Mostly we
+don't need this in discord bots, however.
+For us, it has the following benefits:
+
+- we can build and run the container with just one command
+- if we need to parse some environment variables or volumes (more about them further in tutorial) our run command would
+ look like this
+
+```shell
+$ docker run -d --name mybot -e TOKEN=... -e WEATHER_API_APIKEY=... -e SOME_USEFUL_ENVIRONMENT_VARIABLE=... --net=host -v /home/exenifix/bot-data/data:/app/data -v /home/exenifix/bot-data/images:/app/data/images
+```
+
+This is pretty long and unreadable. Compose allows us to transfer those flags into single config file and still
+use just one short command to run the container.
+
+## Creating Volumes
+
+The files creating during container run are destroyed after its recreation. To prevent some files from getting
+destroyed, we need to use *volumes* that basically save the files from directory inside of container somewhere on drive.
+
+1. Create a new directory somewhere and copy path to it
+
+```shell
+$ mkdir mybot-data
+$ echo $(pwd)/mybot-data
+```
+
+My path is `/home/exenifix/mybot-data`, yours is most likely **different**!
+
+2. In your project, store the files that need to be persistent in a separate directory (eg. `data`)
+3. Add `volumes` to `docker-compose.yaml` so it looks like this:
+
+```yml
+version: "3.8"
+services:
+ main:
+ build: .
+ container_name: mybot
+ volumes:
+ - /home/exenifix/mybot-data:/app/data
+```
+
+The path before the colon `:` is the directory *on server's drive, outside of container*, and the second path is the
+directory *inside of container*.
+All the files saved in container in that directory will be saved on drive's directory as well and Docker will be
+accessing them *from drive*.
+
+## Using GitHub Actions for full automation
+
+Now it's time to fully automate the process and make Docker update the bot automatically on every commit or release. For
+that, we will use a **GitHub Actions workflow**, which basically runs some commands when we need to. You may read more
+about them [here](https://docs.github.com/en/actions/using-workflows).
+
+### Create repository secret
+
+We will not have the ability to use `.env` files with the workflow, so it's better to store the environment variables
+as **actions secrets**. Let's add your discord bot's token as a secret
+
+1. Head to your repository page -> Settings -> Secrets -> Actions
+2. Press `New repository secret`
+3. Give it a name like `TOKEN` and paste the token.
+ Now we will be able to access its value in workflow like `${{ secrets.TOKEN }}`. However, we also need to parse the
+ variable into container now. Edit `docker-compose` so it looks like this:
+
+```yml
+version: "3.8"
+services:
+ main:
+ build: .
+ container_name: mybot
+ volumes:
+ - /home/exenifix/mybot-data:/app/data
+ environment:
+ - TOKEN
+```
+
+### Setup self-hosted runner
+
+To run the workflow on our VPS, we will need to register it as *self-hosted runner*.
+
+1. Head to Settings -> Actions -> Runners
+2. Press `New self-hosted runner`
+3. Select runner image and architecture
+4. Follow the instructions but don't run the runner
+5. Instead, create a service
+
+```shell
+$ sudo ./svc.sh install
+$ sudo ./svc.sh start
+```
+
+Now we have registered our VPS as a self-hosted runner and we can run the workflow on it now.
+
+### Write a workflow
+
+Create a new file `.github/workflows/runner.yml` and paste the following content into it. Please pay attention to
+the `branches` instruction.
+The GitHub's standard main branch name is `main`, however it may be named `master` or something else if you edited its
+name. Make sure to put
+the correct branch name, otherwise it won't work. More about GitHub workflows
+syntax [here](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions)
+
+```yml
+name: Docker Runner
+
+on:
+ push:
+ branches: [ main ]
+
+jobs:
+ run:
+ runs-on: self-hosted
+ environment: production
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Run Container
+ run: docker compose up -d --build
+ env:
+ TOKEN: ${{ secrets.TOKEN }}
+
+ - name: Cleanup Unused Images
+ run: docker image prune -f
+```
+
+Run `docker rm -f mybot` (it only needs to be done once) and push to GitHub. Now if you open `Actions` tab on your
+repository, you should see a workflow running your bot. Congratulations!
+
+### Displaying logs in actions terminal
+
+There's a nice utility for reading docker container's logs and stopping upon meeting a certain phrase and it might be
+useful for you as well.
+
+1. Install the utility on your VPS with
+
+```shell
+$ pip install exendlr
+```
+
+2. Add a step to your workflow that would show the logs until it meets `"ready"` phrase. I recommend putting it before
+ the cleanup.
+
+```yml
+- name: Display Logs
+ run: python3 -m exendlr mybot "ready"
+```
+
+Now you should see the logs of your bot until the stop phrase is met.
+
+**WARNING**
+> The utility only reads from STDERR and redirects to STDERR, if you are using STDOUT for logs, it will not work and
+> will be waiting for stop phrase forever. The utility automatically exits if bot's container is stopped (e.g. error
+> occurred during starting) or if a log line contains a stop phrase. Make sure that your bot 100% displays a stop phrase
+> when it's ready otherwise your workflow will get stuck.
diff --git a/pydis_site/apps/content/resources/guides/python-guides/fix-ssl-certificate.md b/pydis_site/apps/content/resources/guides/python-guides/fix-ssl-certificate.md
new file mode 100644
index 00000000..096e3a90
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/python-guides/fix-ssl-certificate.md
@@ -0,0 +1,23 @@
+---
+title: Fixing an SSL Certificate Verification Error
+description: A guide on fixing verification of an SSL certificate.
+---
+
+We're fixing the error Python specifies as [ssl.SSLCertVerificationError](https://docs.python.org/3/library/ssl.html#ssl.SSLCertVerificationError).
+
+# How to fix SSL Certificate issue on Windows
+
+Firstly, try updating your OS, wouldn't hurt to try.
+
+Now, if you're still having an issue, you would need to download the certificate for the SSL.
+
+The SSL Certificate, Sectigo (cert vendor) provides a download link of an [SSL certificate](https://crt.sh/?id=2835394). You should find it in the bottom left corner, shown below:
+
+A picture where to find the certificate in the website is:
+![location of certificate](/static/images/content/fix-ssl-certificate/pem.png)
+
+You have to setup the certificate yourself. To do that you can just click on it, or if that doesn't work, refer to [this link](https://portal.threatpulse.com/docs/sol/Solutions/ManagePolicy/SSL/ssl_chrome_cert_ta.htm)
+
+# How to fix SSL Certificate issue on Mac
+
+Navigate to your `Applications/Python 3.x/` folder and double-click the `Install Certificates.command` to fix this.
diff --git a/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md b/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md
new file mode 100644
index 00000000..9d523b4b
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md
@@ -0,0 +1,29 @@
+---
+title: Keeping Discord Bot Tokens Safe
+description: How to keep your bot tokens safe and safety measures you can take.
+---
+It's **very** important to keep a bot token safe,
+primarily because anyone who has the bot token can do whatever they want with the bot --
+such as destroying servers your bot has been added to and getting your bot banned from the API.
+
+# How to Avoid Leaking your Token
+To help prevent leaking your token,
+you should ensure that you don't upload it to an open source program/website,
+such as replit and github, as they show your code publicly.
+The best practice for storing tokens is generally utilising .env files
+([click here](https://vcokltfre.dev/tips/tokens/.) for more information on storing tokens safely).
+
+# What should I do if my token does get leaked?
+
+If for whatever reason your token gets leaked, you should immediately follow these steps:
+- Go to the list of [Discord Bot Applications](https://discord.com/developers/applications) you have and select the bot application that had the token leaked.
+- Select the Bot (1) tab on the left-hand side, next to a small image of a puzzle piece. After doing so you should see a small section named TOKEN (under your bot USERNAME and next to his avatar image)
+- Press the Regenerate button to regenerate your bot token and invalidate the old one.
+
+![Steps to Take to Reset your Discord Bot](/static/images/content/regenerating_token.jpg)
+
+Following these steps will create a new token for your bot, making it secure again and terminating any connections from the leaked token.
+The old token will stop working though, so make sure to replace the old token with the new one in your code if you haven't already.
+
+# Summary
+Make sure you keep your token secure by storing it safely, not sending it to anyone you don't trust, and regenerating your token if it does get leaked.
diff --git a/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md b/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md
new file mode 100644
index 00000000..74b0f59b
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md
@@ -0,0 +1,70 @@
+---
+title: Proper error handling in discord.py
+description: Are you not getting any errors? This might be why!
+---
+If you're not recieving any errors in your console, even though you know you should be, try this:
+
+# With bot subclass:
+```py
+import discord
+from discord.ext import commands
+
+import traceback
+import sys
+
+class MyBot(commands.Bot):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ async def on_command_error(self, ctx: commands.Context, error):
+ # Handle your errors here
+ if isinstance(error, commands.MemberNotFound):
+ await ctx.send("I could not find member '{error.argument}'. Please try again")
+
+ elif isinstance(error, commands.MissingRequiredArgument):
+ await ctx.send(f"'{error.param.name}' is a required argument.")
+ else:
+ # All unhandled errors will print their original traceback
+ print(f'Ignoring exception in command {ctx.command}:', file=sys.stderr)
+ traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
+
+bot = MyBot(command_prefix="!", intents=discord.Intents.default())
+
+bot.run("token")
+```
+
+# Without bot subclass
+```py
+import discord
+from discord.ext import commands
+
+import traceback
+import sys
+
+async def on_command_error(self, ctx: commands.Context, error):
+ # Handle your errors here
+ if isinstance(error, commands.MemberNotFound):
+ await ctx.send("I could not find member '{error.argument}'. Please try again")
+
+ elif isinstance(error, commands.MissingRequiredArgument):
+ await ctx.send(f"'{error.param.name}' is a required argument.")
+ else:
+ # All unhandled errors will print their original traceback
+ print(f'Ignoring exception in command {ctx.command}:', file=sys.stderr)
+ traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
+
+bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
+bot.on_command_error = on_command_error
+
+bot.run("token")
+```
+
+
+Make sure to import `traceback` and `sys`!
+
+-------------------------------------------------------------------------------------------------------------
+
+Useful Links:
+- [FAQ](https://discordpy.readthedocs.io/en/latest/faq.html)
+- [Simple Error Handling](https://gist.github.com/EvieePy/7822af90858ef65012ea500bcecf1612)
diff --git a/pydis_site/apps/content/resources/guides/python-guides/setting-different-statuses-on-your-bot.md b/pydis_site/apps/content/resources/guides/python-guides/setting-different-statuses-on-your-bot.md
new file mode 100644
index 00000000..45c7b37c
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/python-guides/setting-different-statuses-on-your-bot.md
@@ -0,0 +1,48 @@
+---
+title: Setting Different Statuses on Your Bot
+description: How to personalize your Discord bot status
+---
+
+You've probably seen a bot or two have a status message under their username in the member bar set to something such as `Playing Commands: .help`.
+
+This guide shows how to set such a status, so your bot can have one as well.
+
+**Please note:**
+
+If you want to change the bot status, it is suggested to not do so during the `on_ready` event, since it would be called many times and making an API call on that event has a chance to disconnect the bot.
+
+The status should not have a problem being set during runtime with `change_presence`, in the examples shown below.
+
+Instead, set the desired status using the activity / status kwarg of commands.Bot, for example:
+```python
+bot = commands.Bot(command_prefix="!", activity=..., status=...)
+```
+
+The following are examples of what you can put into the `activity` keyword argument.
+
+#### Setting 'Playing' Status
+```python
+await client.change_presence(activity=discord.Game(name="a game"))
+```
+
+#### Setting 'Streaming' Status
+```python
+await client.change_presence(activity=discord.Streaming(name="My Stream", url=my_twitch_url))
+```
+
+#### Setting 'Listening' Status
+```python
+await client.change_presence(activity=discord.Activity(type=discord.ActivityType.listening, name="a song"))
+```
+
+#### Setting 'Watching' Status
+```python
+await client.change_presence(activity=discord.Activity(type=discord.ActivityType.watching, name="a movie"))
+```
+
+### Add Optional Status as Well:
+
+* `discord.Status.online` (default, green icon)
+* `discord.Status.idle` (yellow icon)
+* `discord.Status.do_not_disturb` (red icon)
+* `discord.Status.offline` (gray icon)
diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
index 0acd3e55..710fd914 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
@@ -1,9 +1,12 @@
---
-title: VPS Services
-description: On different VPS services
+title: VPS and Free Hosting Service for Discord bots
+description: This article lists recommended VPS services and covers the disasdvantages of utilising a free hosting service to run a discord bot.
+toc: 2
---
-If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS). This is a list of VPS services that are sufficient for running Discord bots.
+## Recommended VPS services
+
+If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS). Here is a list of VPS services that are sufficient for running Discord bots.
* Europe
* [netcup](https://www.netcup.eu/)
@@ -25,7 +28,31 @@ If you need to run your bot 24/7 (with no downtime), you should consider using a
* [OVHcloud](https://www.ovhcloud.com/)
* [Vultr](https://www.vultr.com/)
----
-# Free hosts
-There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting.
-Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi.
+
+## Why not to use free hosting services for bots?
+While these may seem like nice and free services, it has a lot more caveats than you may think. For example, the drawbacks of using common free hosting services to host a discord bot are discussed below.
+
+### Replit
+
+- The machines are super underpowered, resulting in your bot lagging a lot as it gets bigger.
+
+- You need to run a webserver alongside your bot to prevent it from being shut off. This uses extra machine power.
+
+- Repl.it uses an ephemeral file system. This means any file you saved through your bot will be overwritten when you next launch.
+
+- They use a shared IP for everything running on the service.
+This one is important - if someone is running a user bot on their service and gets banned, everyone on that IP will be banned. Including you.
+
+### Heroku
+- Bots are not what the platform is designed for. Heroku is designed to provide web servers (like Django, Flask, etc). This is why they give you a domain name and open a port on their local emulator.
+
+- Heroku's environment is heavily containerized, making it significantly underpowered for a standard use case.
+
+- Heroku's environment is volatile. In order to handle the insane amount of users trying to use it for their own applications, Heroku will dispose your environment every time your application dies unless you pay.
+
+- Heroku has minimal system dependency control. If any of your Python requirements need C bindings (such as PyNaCl
+ binding to libsodium, or lxml binding to libxml), they are unlikely to function properly, if at all, in a native
+ environment. As such, you often need to resort to adding third-party buildpacks to facilitate otherwise normal
+ CPython extension functionality. (This is the reason why voice doesn't work natively on heroku)
+
+- Heroku only offers a limited amount of time on their free programme for your applications. If you exceed this limit, which you probably will, they'll shut down your application until your free credit resets.
diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps_services.md b/pydis_site/apps/content/resources/guides/python-guides/vps_services.md
deleted file mode 100644
index 710fd914..00000000
--- a/pydis_site/apps/content/resources/guides/python-guides/vps_services.md
+++ /dev/null
@@ -1,58 +0,0 @@
----
-title: VPS and Free Hosting Service for Discord bots
-description: This article lists recommended VPS services and covers the disasdvantages of utilising a free hosting service to run a discord bot.
-toc: 2
----
-
-## Recommended VPS services
-
-If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS). Here is a list of VPS services that are sufficient for running Discord bots.
-
-* Europe
- * [netcup](https://www.netcup.eu/)
- * Germany & Austria data centres.
- * Great affiliate program.
- * [Yandex Cloud](https://cloud.yandex.ru/)
- * Vladimir, Ryazan, and Moscow region data centres.
- * [Scaleway](https://www.scaleway.com/)
- * France data centre.
- * [Time 4 VPS](https://www.time4vps.eu/)
- * Lithuania data centre.
-* US
- * [GalaxyGate](https://galaxygate.net/)
- * New York data centre.
- * Great affiliate program.
-* Global
- * [Linode](https://www.linode.com/)
- * [Digital Ocean](https://www.digitalocean.com/)
- * [OVHcloud](https://www.ovhcloud.com/)
- * [Vultr](https://www.vultr.com/)
-
-
-## Why not to use free hosting services for bots?
-While these may seem like nice and free services, it has a lot more caveats than you may think. For example, the drawbacks of using common free hosting services to host a discord bot are discussed below.
-
-### Replit
-
-- The machines are super underpowered, resulting in your bot lagging a lot as it gets bigger.
-
-- You need to run a webserver alongside your bot to prevent it from being shut off. This uses extra machine power.
-
-- Repl.it uses an ephemeral file system. This means any file you saved through your bot will be overwritten when you next launch.
-
-- They use a shared IP for everything running on the service.
-This one is important - if someone is running a user bot on their service and gets banned, everyone on that IP will be banned. Including you.
-
-### Heroku
-- Bots are not what the platform is designed for. Heroku is designed to provide web servers (like Django, Flask, etc). This is why they give you a domain name and open a port on their local emulator.
-
-- Heroku's environment is heavily containerized, making it significantly underpowered for a standard use case.
-
-- Heroku's environment is volatile. In order to handle the insane amount of users trying to use it for their own applications, Heroku will dispose your environment every time your application dies unless you pay.
-
-- Heroku has minimal system dependency control. If any of your Python requirements need C bindings (such as PyNaCl
- binding to libsodium, or lxml binding to libxml), they are unlikely to function properly, if at all, in a native
- environment. As such, you often need to resort to adding third-party buildpacks to facilitate otherwise normal
- CPython extension functionality. (This is the reason why voice doesn't work natively on heroku)
-
-- Heroku only offers a limited amount of time on their free programme for your applications. If you exceed this limit, which you probably will, they'll shut down your application until your free credit resets.
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..a7695a27 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,38 @@ 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."""
+ # We instantiate the set with None here to make filtering it out later easier
+ # whether it was added in the loop or not
+ 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..c12893ef 100644
--- a/pydis_site/apps/content/utils.py
+++ b/pydis_site/apps/content/utils.py
@@ -1,14 +1,41 @@
+import datetime
+import functools
+import json
+import tarfile
+import tempfile
+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 +43,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 +54,253 @@ 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.
+ """
+ with github_client() as 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()
+
+ 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 new_tag in tags:
+ try:
+ old_tag = Tag.objects.get(name=new_tag.name)
+ except Tag.DoesNotExist:
+ # The tag is not in the database yet,
+ # pretend it's previous state is the current state
+ old_tag = new_tag
+
+ if old_tag.sha == new_tag.sha and old_tag.last_commit is not None:
+ # We still have an up-to-date commit entry
+ new_tag.last_commit = old_tag.last_commit
+
+ new_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 list(Tag.objects.all())
+
+
+def get_tag(path: str, *, skip_sync: bool = False) -> 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
+ 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: list[Tag] | None = 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"].casefold())}
+
+
+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 +311,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/apps/resources/resources/python_org.yaml b/pydis_site/apps/resources/resources/python_org.yaml
new file mode 100644
index 00000000..ece954dd
--- /dev/null
+++ b/pydis_site/apps/resources/resources/python_org.yaml
@@ -0,0 +1,14 @@
+name: The Python Tutorial
+description: The official Python tutorial by Python.org
+title_image: https://www.python.org/static/community_logos/python-logo-master-v3-TM.png
+title_url: https://docs.python.org/3/tutorial/
+tags:
+ topics:
+ - general
+ payment_tiers:
+ - free
+ difficulty:
+ - beginner
+ - intermediate
+ type:
+ - tutorial
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/images/content/discord_colored_messages/ansi-colors.png b/pydis_site/static/images/content/discord_colored_messages/ansi-colors.png
index d7176393..d980ab4c 100644
--- a/pydis_site/static/images/content/discord_colored_messages/ansi-colors.png
+++ b/pydis_site/static/images/content/discord_colored_messages/ansi-colors.png
Binary files differ
diff --git a/pydis_site/static/images/content/discord_colored_messages/result.png b/pydis_site/static/images/content/discord_colored_messages/result.png
index a666804e..41ed555c 100644
--- a/pydis_site/static/images/content/discord_colored_messages/result.png
+++ b/pydis_site/static/images/content/discord_colored_messages/result.png
Binary files differ
diff --git a/pydis_site/static/images/content/fix-ssl-certificate/pem.png b/pydis_site/static/images/content/fix-ssl-certificate/pem.png
new file mode 100644
index 00000000..face520f
--- /dev/null
+++ b/pydis_site/static/images/content/fix-ssl-certificate/pem.png
Binary files differ
diff --git a/pydis_site/static/images/content/help_channels/available_channels.png b/pydis_site/static/images/content/help_channels/available_channels.png
deleted file mode 100644
index 0b9cfd03..00000000
--- a/pydis_site/static/images/content/help_channels/available_channels.png
+++ /dev/null
Binary files differ
diff --git a/pydis_site/static/images/content/help_channels/available_message.png b/pydis_site/static/images/content/help_channels/available_message.png
deleted file mode 100644
index 09668c9b..00000000
--- a/pydis_site/static/images/content/help_channels/available_message.png
+++ /dev/null
Binary files differ
diff --git a/pydis_site/static/images/content/help_channels/claimed_channel.png b/pydis_site/static/images/content/help_channels/claimed_channel.png
deleted file mode 100644
index 777e31ea..00000000
--- a/pydis_site/static/images/content/help_channels/claimed_channel.png
+++ /dev/null
Binary files differ
diff --git a/pydis_site/static/images/content/help_channels/dormant_channels.png b/pydis_site/static/images/content/help_channels/dormant_channels.png
deleted file mode 100644
index 7c9ba61e..00000000
--- a/pydis_site/static/images/content/help_channels/dormant_channels.png
+++ /dev/null
Binary files differ
diff --git a/pydis_site/static/images/content/help_channels/help-system-category.png b/pydis_site/static/images/content/help_channels/help-system-category.png
new file mode 100644
index 00000000..bea5a92c
--- /dev/null
+++ b/pydis_site/static/images/content/help_channels/help-system-category.png
Binary files differ
diff --git a/pydis_site/static/images/content/help_channels/new-post-button.png b/pydis_site/static/images/content/help_channels/new-post-button.png
new file mode 100644
index 00000000..4ceabf0f
--- /dev/null
+++ b/pydis_site/static/images/content/help_channels/new-post-button.png
Binary files differ
diff --git a/pydis_site/static/images/content/help_channels/new-post-form.png b/pydis_site/static/images/content/help_channels/new-post-form.png
new file mode 100644
index 00000000..3e90bf7d
--- /dev/null
+++ b/pydis_site/static/images/content/help_channels/new-post-form.png
Binary files differ
diff --git a/pydis_site/static/images/content/help_channels/newly-created-thread-example.png b/pydis_site/static/images/content/help_channels/newly-created-thread-example.png
new file mode 100644
index 00000000..d7b1eed4
--- /dev/null
+++ b/pydis_site/static/images/content/help_channels/newly-created-thread-example.png
Binary files differ
diff --git a/pydis_site/static/images/content/help_channels/occupied_channels.png b/pydis_site/static/images/content/help_channels/occupied_channels.png
deleted file mode 100644
index 6ccb4ed6..00000000
--- a/pydis_site/static/images/content/help_channels/occupied_channels.png
+++ /dev/null
Binary files differ
diff --git a/pydis_site/static/images/content/help_channels/question-example.png b/pydis_site/static/images/content/help_channels/question-example.png
new file mode 100644
index 00000000..da181351
--- /dev/null
+++ b/pydis_site/static/images/content/help_channels/question-example.png
Binary files differ
diff --git a/pydis_site/static/images/content/regenerating_token.jpg b/pydis_site/static/images/content/regenerating_token.jpg
new file mode 100644
index 00000000..7b2588dc
--- /dev/null
+++ b/pydis_site/static/images/content/regenerating_token.jpg
Binary files differ
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')),
)