aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Pipfile1
-rw-r--r--Pipfile.lock53
-rw-r--r--pydis_site/apps/content/__init__.py0
-rw-r--r--pydis_site/apps/content/apps.py7
-rw-r--r--pydis_site/apps/content/migrations/__init__.py0
-rw-r--r--pydis_site/apps/content/resources/content/guides/_info.yml2
-rw-r--r--pydis_site/apps/content/resources/content/guides/how-to-write-a-article.md78
-rw-r--r--pydis_site/apps/content/tests/__init__.py0
-rw-r--r--pydis_site/apps/content/tests/test_content/category/_info.yml2
-rw-r--r--pydis_site/apps/content/tests/test_content/category/test3.md6
-rw-r--r--pydis_site/apps/content/tests/test_content/test.md8
-rw-r--r--pydis_site/apps/content/tests/test_content/test2.md6
-rw-r--r--pydis_site/apps/content/tests/test_utils.py136
-rw-r--r--pydis_site/apps/content/tests/test_views.py100
-rw-r--r--pydis_site/apps/content/urls.py15
-rw-r--r--pydis_site/apps/content/utils.py81
-rw-r--r--pydis_site/apps/content/views/__init__.py5
-rw-r--r--pydis_site/apps/content/views/article.py47
-rw-r--r--pydis_site/apps/content/views/articles.py18
-rw-r--r--pydis_site/apps/content/views/category.py22
-rw-r--r--pydis_site/apps/home/urls.py1
-rw-r--r--pydis_site/settings.py1
-rw-r--r--pydis_site/static/css/content/articles.css7
-rw-r--r--pydis_site/templates/content/article.html57
-rw-r--r--pydis_site/templates/content/articles.html53
-rw-r--r--pydis_site/templates/content/category.html44
26 files changed, 706 insertions, 44 deletions
diff --git a/Pipfile b/Pipfile
index 61b65d9b..a6aa4a95 100644
--- a/Pipfile
+++ b/Pipfile
@@ -19,6 +19,7 @@ pyuwsgi = {version = "~=2.0", sys_platform = "!='win32'"}
django-allauth = "~=0.41"
sentry-sdk = "~=0.14"
gitpython = "~=3.1.7"
+markdown2 = "~=2.3.9"
[dev-packages]
coverage = "~=5.0"
diff --git a/Pipfile.lock b/Pipfile.lock
index 51ca9a65..8bdfc477 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -21,7 +21,6 @@
"sha256:7e51911ee147dd685c3c8b805c0ad0cb58d360987b56953878f8c06d2d1c6f1a",
"sha256:9fc6fb5d39b8af147ba40765234fa822b39818b12cc80b35ad9b0cef3a476aed"
],
- "markers": "python_version >= '3.5'",
"version": "==3.2.10"
},
"certifi": {
@@ -43,7 +42,6 @@
"sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93",
"sha256:f684034d135af4c6cbb949b8a4d2ed61634515257a67299e5f940fbaa34377f5"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==0.6.0"
},
"django": {
@@ -113,7 +111,6 @@
"sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac",
"sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9"
],
- "markers": "python_version >= '3.4'",
"version": "==4.0.5"
},
"gitpython": {
@@ -129,7 +126,6 @@
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.10"
},
"libsass": {
@@ -150,12 +146,19 @@
],
"version": "==0.20.1"
},
+ "markdown2": {
+ "hashes": [
+ "sha256:89526090907ae5ece66d783c434b35c29ee500c1986309e306ce2346273ada6a",
+ "sha256:e6b401ec80b75e76a6b3dbb2c8ade513156fa55fa6c30b9640a1abf6184a07c8"
+ ],
+ "index": "pypi",
+ "version": "==2.3.9"
+ },
"oauthlib": {
"hashes": [
"sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889",
"sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.1.0"
},
"psycopg2-binary": {
@@ -257,8 +260,7 @@
"requests-oauthlib": {
"hashes": [
"sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d",
- "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a",
- "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc"
+ "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"
],
"version": "==1.3.0"
},
@@ -275,7 +277,6 @@
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0"
},
"smmap": {
@@ -283,7 +284,6 @@
"sha256:54c44c197c819d5ef1991799a7e30b662d1e520f2ac75c9efbeb54a742214cf4",
"sha256:9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.0.4"
},
"sqlparse": {
@@ -291,7 +291,6 @@
"sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e",
"sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.3.1"
},
"urllib3": {
@@ -299,7 +298,6 @@
"sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a",
"sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
"version": "==1.25.10"
},
"whitenoise": {
@@ -339,7 +337,6 @@
"sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d",
"sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"
],
- "markers": "python_full_version >= '3.6.1'",
"version": "==3.2.0"
},
"coverage": {
@@ -478,7 +475,6 @@
"sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac",
"sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9"
],
- "markers": "python_version >= '3.4'",
"version": "==4.0.5"
},
"gitpython": {
@@ -549,7 +545,6 @@
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
"sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.6.0"
},
"pydocstyle": {
@@ -565,7 +560,6 @@
"sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
"sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.2.0"
},
"pyyaml": {
@@ -590,7 +584,6 @@
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0"
},
"smmap": {
@@ -598,7 +591,6 @@
"sha256:54c44c197c819d5ef1991799a7e30b662d1e520f2ac75c9efbeb54a742214cf4",
"sha256:9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.0.4"
},
"snowballstemmer": {
@@ -623,33 +615,6 @@
],
"version": "==0.10.1"
},
- "typed-ast": {
- "hashes": [
- "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
- "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
- "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
- "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
- "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
- "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
- "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
- "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
- "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
- "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
- "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
- "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
- "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
- "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
- "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
- "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
- "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
- "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
- "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
- "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
- "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
- ],
- "markers": "python_version < '3.8'",
- "version": "==1.4.1"
- },
"unittest-xml-reporting": {
"hashes": [
"sha256:7bf515ea8cb244255a25100cd29db611a73f8d3d0aaf672ed3266307e14cc1ca",
diff --git a/pydis_site/apps/content/__init__.py b/pydis_site/apps/content/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/pydis_site/apps/content/__init__.py
diff --git a/pydis_site/apps/content/apps.py b/pydis_site/apps/content/apps.py
new file mode 100644
index 00000000..1e300a48
--- /dev/null
+++ b/pydis_site/apps/content/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class ContentConfig(AppConfig):
+ """Django AppConfig for content app."""
+
+ name = 'content'
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/resources/content/guides/_info.yml b/pydis_site/apps/content/resources/content/guides/_info.yml
new file mode 100644
index 00000000..369f05d4
--- /dev/null
+++ b/pydis_site/apps/content/resources/content/guides/_info.yml
@@ -0,0 +1,2 @@
+name: Guides
+description: Python and PyDis guides.
diff --git a/pydis_site/apps/content/resources/content/guides/how-to-write-a-article.md b/pydis_site/apps/content/resources/content/guides/how-to-write-a-article.md
new file mode 100644
index 00000000..0ad45cc3
--- /dev/null
+++ b/pydis_site/apps/content/resources/content/guides/how-to-write-a-article.md
@@ -0,0 +1,78 @@
+---
+title: How to Write a Article
+short_description: Learn how to write a article for this website
+icon_class: fas
+icon: fa-info
+---
+
+When you are interested about how to write articles for this site (like this), then you can learn about it here.
+PyDis use Markdown (GitHub Markdown) files for articles.
+
+## Getting Started
+Before you can get started with writing a article, you need idea.
+Best way to find out is your idea good is to discuss about it in #dev-contrib channel. There can other peoples give their opinion about your idea. Even better, open issue in site repository first, then PyDis staff can see it and approve/decline this idea.
+It's good idea to wait for staff decision before starting to write guide to avoid case when you write a long long article, but then this don't get approved.
+
+To start with contributing, you should read [how to contribute to site](https://pythondiscord.com/pages/contributing/site/).
+You should also read our [Git workflow](https://pythondiscord.com/pages/contributing/working-with-git/), because you need to push your guide to GitHub.
+
+## Creating a File
+All articles is located at `site` repository, in `pydis_site/apps/content/resources/content`. Under this is root level articles (.md files) and categories (directories). Learn more about categories in [categories section](#categories).
+
+When you are writing guides, then these are located under `guides` category.
+
+At this point, you will need your article name for filename. Replace all your article name spaces with `-` and make all lowercase. Save this as `.md` (Markdown) file. This name (without Markdown extension) is path of article in URL.
+
+## Markdown Metadata
+Article files have some required metadata, like title, description, relevant pages. Metadata is first thing in file, YAML-like key-value pairs:
+
+```md
+---
+title: My Article
+short_description: This is my short description.
+relevant_links: url1,url2,url3
+relevant_link_values: Text for url1,Text for url2,Text for url3
+---
+
+Here comes content of article...
+```
+
+You can read more about Markdown metadata [here](https://github.com/trentm/python-markdown2/wiki/metadata).
+
+### Fields
+- **Name:** Easily-readable name for your article.
+- **Short Description:** Small, 1-2 line description that describe what your article explain.
+- **Relevant Links and Values:** URLs and values is under different fields, separated with comma.
+
+## Content
+For content, mostly you can use standard markdown, but there is a few addition that is available.
+
+### IDs for quick jumps
+System automatically assign IDs to headers, so like this header will get ID `ids-for-quick-jumps`.
+
+### Tables
+Tables like in GitHub is supported too:
+
+| This is header | This is too header |
+| -------------- | ------------------ |
+| My item | My item too |
+
+### Codeblocks
+Also this system supports codeblocks and provides syntax highlighting with `highlight.js`.
+To activate syntax highlight, just put language directly after starting backticks.
+
+```py
+import os
+
+path = os.path.join("foo", "bar")
+```
+
+## Categories
+To have some systematic sorting of guides, site support guides categories. Currently this system support only 1 level of categories. Categories live at `site` repo in `pydis_site/apps/content/resources/content` subdirectories. Directory name is path of category in URL. Inside category directory, there is 1 file required: `_info.yml`. This file need 2 key-value pairs defined:
+
+```yml
+name: Category name
+description: Category description
+```
+
+Then all Markdown files in this folder will be under this category.
diff --git a/pydis_site/apps/content/tests/__init__.py b/pydis_site/apps/content/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/pydis_site/apps/content/tests/__init__.py
diff --git a/pydis_site/apps/content/tests/test_content/category/_info.yml b/pydis_site/apps/content/tests/test_content/category/_info.yml
new file mode 100644
index 00000000..8311509d
--- /dev/null
+++ b/pydis_site/apps/content/tests/test_content/category/_info.yml
@@ -0,0 +1,2 @@
+name: My Category
+description: My Description
diff --git a/pydis_site/apps/content/tests/test_content/category/test3.md b/pydis_site/apps/content/tests/test_content/category/test3.md
new file mode 100644
index 00000000..03ddd67b
--- /dev/null
+++ b/pydis_site/apps/content/tests/test_content/category/test3.md
@@ -0,0 +1,6 @@
+---
+title: Test 3
+short_description: Testing 3
+---
+
+This is too test content, but in category.
diff --git a/pydis_site/apps/content/tests/test_content/test.md b/pydis_site/apps/content/tests/test_content/test.md
new file mode 100644
index 00000000..175c1fdb
--- /dev/null
+++ b/pydis_site/apps/content/tests/test_content/test.md
@@ -0,0 +1,8 @@
+---
+title: Test
+short_description: Testing
+relevant_links: https://pythondiscord.com/pages/resources/guides/asking-good-questions/,https://pythondiscord.com/pages/resources/guides/help-channels/,https://pythondiscord.com/pages/code-of-conduct/
+relevant_link_values: Asking Good Questions,Help Channel Guide,Code of Conduct
+---
+
+This is test content.
diff --git a/pydis_site/apps/content/tests/test_content/test2.md b/pydis_site/apps/content/tests/test_content/test2.md
new file mode 100644
index 00000000..14d8a54b
--- /dev/null
+++ b/pydis_site/apps/content/tests/test_content/test2.md
@@ -0,0 +1,6 @@
+---
+title: Test 2
+short_description: Testing 2
+---
+
+This is too test content.
diff --git a/pydis_site/apps/content/tests/test_utils.py b/pydis_site/apps/content/tests/test_utils.py
new file mode 100644
index 00000000..9c7c4f31
--- /dev/null
+++ b/pydis_site/apps/content/tests/test_utils.py
@@ -0,0 +1,136 @@
+from pathlib import Path
+from unittest.mock import patch
+
+from django.conf import settings
+from django.http import Http404
+from django.test import TestCase
+from markdown2 import markdown
+
+from pydis_site.apps.content import utils
+
+BASE_PATH = Path(settings.BASE_DIR, "pydis_site", "apps", "content", "tests", "test_content")
+
+
+class TestGetBasePath(TestCase):
+ def test_get_base_path(self):
+ """Test does function return content base path."""
+ self.assertEqual(
+ utils._get_base_path(),
+ Path(settings.BASE_DIR, "pydis_site", "apps", "content", "resources", "content")
+ )
+
+
+class TestGetCategory(TestCase):
+ def test_get_category_successfully(self):
+ """Check does this get right data from category data file."""
+ with patch("pydis_site.apps.content.utils._get_base_path", return_value=BASE_PATH):
+ result = utils.get_category("category")
+
+ self.assertEqual(result, {"name": "My Category", "description": "My Description"})
+
+ def test_get_category_not_exists(self):
+ """Check does this raise 404 error when category don't exists."""
+ with patch("pydis_site.apps.content.utils._get_base_path", return_value=BASE_PATH):
+ with self.assertRaises(Http404):
+ utils.get_category("invalid")
+
+ def test_get_category_not_directory(self):
+ """Check does this raise 404 error when category isn't directory."""
+ with patch("pydis_site.apps.content.utils._get_base_path", return_value=BASE_PATH):
+ with self.assertRaises(Http404):
+ utils.get_category("test.md")
+
+
+class TestGetCategories(TestCase):
+ def test_get_categories(self):
+ """Check does this return test content categories."""
+ with patch("pydis_site.apps.content.utils._get_base_path", return_value=BASE_PATH):
+ result = utils.get_categories()
+
+ self.assertEqual(
+ result, {"category": {"name": "My Category", "description": "My Description"}}
+ )
+
+
+class TestGetArticles(TestCase):
+ def test_get_all_root_articles(self):
+ """Check does this return all root level testing content."""
+ with patch("pydis_site.apps.content.utils._get_base_path", return_value=BASE_PATH):
+ result = utils.get_articles()
+
+ for case in ["test", "test2"]:
+ with self.subTest(guide=case):
+ md = markdown(BASE_PATH.joinpath(f"{case}.md").read_text(), extras=["metadata"])
+
+ self.assertIn(case, result)
+ self.assertEqual(md.metadata, result[case])
+
+ def test_get_all_category_articles(self):
+ """Check does this return all category testing content."""
+ with patch("pydis_site.apps.content.utils._get_base_path", return_value=BASE_PATH):
+ result = utils.get_articles("category")
+
+ md = markdown(BASE_PATH.joinpath("category", "test3.md").read_text(), extras=["metadata"])
+
+ self.assertIn("test3", result)
+ self.assertEqual(md.metadata, result["test3"])
+
+
+class TestGetArticle(TestCase):
+ def test_get_root_article_success(self):
+ """Check does this return article HTML and metadata when root article exist."""
+ with patch("pydis_site.apps.content.utils._get_base_path", return_value=BASE_PATH):
+ result = utils.get_article("test", None)
+
+ md = markdown(
+ BASE_PATH.joinpath("test.md").read_text(),
+ extras=[
+ "metadata",
+ "fenced-code-blocks",
+ "header-ids",
+ "strike",
+ "target-blank-links",
+ "tables",
+ "task_list"
+ ]
+ )
+
+ self.assertEqual(result, {"article": str(md), "metadata": md.metadata})
+
+ def test_get_root_article_dont_exist(self):
+ """Check does this raise Http404 when root article don't exist."""
+ with patch("pydis_site.apps.content.utils._get_base_path", return_value=BASE_PATH):
+ with self.assertRaises(Http404):
+ utils.get_article("invalid", None)
+
+ def test_get_category_article_success(self):
+ """Check does this return article HTML and metadata when category guide exist."""
+ with patch("pydis_site.apps.content.utils._get_base_path", return_value=BASE_PATH):
+ result = utils.get_article("test3", "category")
+
+ md = markdown(
+ BASE_PATH.joinpath("category", "test3.md").read_text(),
+ extras=[
+ "metadata",
+ "fenced-code-blocks",
+ "header-ids",
+ "strike",
+ "target-blank-links",
+ "tables",
+ "task_list"
+ ]
+ )
+
+ self.assertEqual(result, {"article": str(md), "metadata": md.metadata})
+
+ def test_get_category_article_dont_exist(self):
+ """Check does this raise Http404 when category article don't exist."""
+ with patch("pydis_site.apps.content.utils._get_base_path", return_value=BASE_PATH):
+ with self.assertRaises(Http404):
+ utils.get_article("invalid", "category")
+
+ def test_get_category_article_category_dont_exist(self):
+ """Check does this raise Http404 when category don't exist."""
+ with patch("pydis_site.apps.content.utils._get_base_path", return_value=BASE_PATH):
+ with self.assertRaises(Http404):
+ utils.get_article("some-guide", "invalid")
diff --git a/pydis_site/apps/content/tests/test_views.py b/pydis_site/apps/content/tests/test_views.py
new file mode 100644
index 00000000..0901c67f
--- /dev/null
+++ b/pydis_site/apps/content/tests/test_views.py
@@ -0,0 +1,100 @@
+from unittest.mock import patch
+
+from django.http import Http404
+from django.test import TestCase
+from django_hosts.resolvers import reverse
+
+
+class TestGuidesIndexView(TestCase):
+ @patch("pydis_site.apps.content.views.articles.get_articles")
+ @patch("pydis_site.apps.content.views.articles.get_categories")
+ def test_articles_index_return_200(self, get_categories_mock, get_articles_mock):
+ """Check that content index return HTTP code 200."""
+ get_categories_mock.return_value = {}
+ get_articles_mock.return_value = {}
+
+ url = reverse('content:content')
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ get_articles_mock.assert_called_once()
+ get_categories_mock.assert_called_once()
+
+
+class TestGuideView(TestCase):
+ @patch("pydis_site.apps.content.views.article.get_article")
+ @patch("pydis_site.apps.content.views.article.get_category")
+ def test_guide_return_code_200(self, get_category_mock, get_article_mock):
+ get_article_mock.return_value = {"guide": "test", "metadata": {}}
+
+ url = reverse("content:article", args=["test-guide"])
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ get_category_mock.assert_not_called()
+ get_article_mock.assert_called_once_with("test-guide", None)
+
+ @patch("pydis_site.apps.content.views.article.get_article")
+ @patch("pydis_site.apps.content.views.article.get_category")
+ def test_guide_return_404(self, get_category_mock, get_article_mock):
+ """Check that return code is 404 when invalid article provided."""
+ get_article_mock.side_effect = Http404("Article not found.")
+
+ url = reverse("content:article", args=["invalid-guide"])
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
+ get_article_mock.assert_called_once_with("invalid-guide", None)
+ get_category_mock.assert_not_called()
+
+
+class TestCategoryView(TestCase):
+ @patch("pydis_site.apps.content.views.category.get_category")
+ @patch("pydis_site.apps.content.views.category.get_articles")
+ def test_valid_category_code_200(self, get_articles_mock, get_category_mock):
+ """Check that return code is 200 when visiting valid category."""
+ get_category_mock.return_value = {"name": "test", "description": "test"}
+ get_articles_mock.return_value = {}
+
+ url = reverse("content:category", args=["category"])
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ get_articles_mock.assert_called_once_with("category")
+ get_category_mock.assert_called_once_with("category")
+
+ @patch("pydis_site.apps.content.views.category.get_category")
+ @patch("pydis_site.apps.content.views.category.get_articles")
+ def test_invalid_category_code_404(self, get_articles_mock, get_category_mock):
+ """Check that return code is 404 when trying to visit invalid category."""
+ get_category_mock.side_effect = Http404("Category not found.")
+
+ url = reverse("content:category", args=["invalid-category"])
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 404)
+ get_category_mock.assert_called_once_with("invalid-category")
+ get_articles_mock.assert_not_called()
+
+
+class TestCategoryGuidesView(TestCase):
+ @patch("pydis_site.apps.content.views.article.get_article")
+ @patch("pydis_site.apps.content.views.article.get_category")
+ def test_valid_category_article_code_200(self, get_category_mock, get_article_mock):
+ """Check that return code is 200 when visiting valid category article."""
+ get_article_mock.return_value = {"guide": "test", "metadata": {}}
+
+ url = reverse("content:category_article", args=["category", "test3"])
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ get_article_mock.assert_called_once_with("test3", "category")
+ get_category_mock.assert_called_once_with("category")
+
+ @patch("pydis_site.apps.content.views.article.get_article")
+ @patch("pydis_site.apps.content.views.article.get_category")
+ def test_invalid_category_article_code_404(self, get_category_mock, get_article_mock):
+ """Check that return code is 200 when trying to visit invalid category article."""
+ get_article_mock.side_effect = Http404("Article not found.")
+
+ url = reverse("content:category_article", args=["category", "invalid"])
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
+ get_article_mock.assert_called_once_with("invalid", "category")
+ get_category_mock.assert_not_called()
diff --git a/pydis_site/apps/content/urls.py b/pydis_site/apps/content/urls.py
new file mode 100644
index 00000000..5a4ee37a
--- /dev/null
+++ b/pydis_site/apps/content/urls.py
@@ -0,0 +1,15 @@
+from django.urls import path
+
+from . import views
+
+app_name = "content"
+urlpatterns = [
+ path("", views.ArticlesView.as_view(), name='content'),
+ path("category/<str:category>/", views.CategoryView.as_view(), name='category'),
+ path(
+ "category/<str:category>/<str:article>/",
+ views.ArticleView.as_view(),
+ name='category_article'
+ ),
+ path("<str:article>/", views.ArticleView.as_view(), name='article')
+]
diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py
new file mode 100644
index 00000000..e164c39f
--- /dev/null
+++ b/pydis_site/apps/content/utils.py
@@ -0,0 +1,81 @@
+import os
+from pathlib import Path
+from typing import Dict, Optional, Union
+
+import yaml
+from django.conf import settings
+from django.http import Http404
+from markdown2 import markdown
+
+
+def _get_base_path() -> Path:
+ """Have extra function for base path getting for testability."""
+ return Path(settings.BASE_DIR, "pydis_site", "apps", "content", "resources", "content")
+
+
+def get_category(category: str) -> Dict[str, str]:
+ """Load category information by name from _info.yml."""
+ path = _get_base_path().joinpath(category)
+ if not path.exists() or not path.is_dir():
+ raise Http404("Category not found.")
+
+ return yaml.safe_load(path.joinpath("_info.yml").read_text())
+
+
+def get_categories() -> Dict[str, Dict]:
+ """Get all categories information."""
+ base_path = _get_base_path()
+ categories = {}
+
+ for name in base_path.iterdir():
+ if name.is_dir():
+ categories[name.name] = get_category(name.name)
+
+ return categories
+
+
+def get_articles(category: Optional[str] = None) -> Dict[str, Dict]:
+ """Get all root or category articles."""
+ if category is None:
+ base_dir = _get_base_path()
+ else:
+ base_dir = _get_base_path().joinpath(category)
+
+ articles = {}
+
+ for item in base_dir.iterdir():
+ if item.is_file() and item.name.endswith(".md"):
+ md = markdown(item.read_text(), extras=["metadata"])
+ articles[os.path.splitext(item.name)[0]] = md.metadata
+
+ return articles
+
+
+def get_article(article: str, category: Optional[str]) -> Dict[str, Union[str, Dict]]:
+ """Get one specific article. When category is specified, get it from there."""
+ if category is None:
+ base_path = _get_base_path()
+ else:
+ base_path = _get_base_path().joinpath(category)
+
+ if not base_path.exists() or not base_path.is_dir():
+ raise Http404("Category not found.")
+
+ article_path = base_path.joinpath(f"{article}.md")
+ if not article_path.exists() or not article_path.is_file():
+ raise Http404("Article not found.")
+
+ html = markdown(
+ article_path.read_text(),
+ extras=[
+ "metadata",
+ "fenced-code-blocks",
+ "header-ids",
+ "strike",
+ "target-blank-links",
+ "tables",
+ "task_list"
+ ]
+ )
+
+ return {"article": str(html), "metadata": html.metadata}
diff --git a/pydis_site/apps/content/views/__init__.py b/pydis_site/apps/content/views/__init__.py
new file mode 100644
index 00000000..616bc850
--- /dev/null
+++ b/pydis_site/apps/content/views/__init__.py
@@ -0,0 +1,5 @@
+from .article import ArticleView
+from .articles import ArticlesView
+from .category import CategoryView
+
+__all__ = ["ArticleView", "ArticlesView", "CategoryView"]
diff --git a/pydis_site/apps/content/views/article.py b/pydis_site/apps/content/views/article.py
new file mode 100644
index 00000000..f4c834db
--- /dev/null
+++ b/pydis_site/apps/content/views/article.py
@@ -0,0 +1,47 @@
+from typing import Optional
+
+from django.core.handlers.wsgi import WSGIRequest
+from django.http import HttpResponse
+from django.shortcuts import render
+from django.views import View
+
+from pydis_site.apps.content.utils import get_article, get_category
+
+
+class ArticleView(View):
+ """Shows specific guide page."""
+
+ def get(
+ self,
+ request: WSGIRequest,
+ article: str,
+ category: Optional[str] = None
+ ) -> HttpResponse:
+ """Collect guide content and display it. When guide don't exist, return 404."""
+ article_result = get_article(article, category)
+
+ if category is not None:
+ category_data = get_category(category)
+ category_data["raw_name"] = category
+ else:
+ category_data = {"name": None, "raw_name": None}
+
+ relevant_links = {
+ link: value for link, value in zip(
+ article_result["metadata"].get("relevant_links", "").split(","),
+ article_result["metadata"].get("relevant_link_values", "").split(",")
+ )
+ }
+
+ if relevant_links == {"": ""}:
+ relevant_links = {}
+
+ return render(
+ request,
+ "content/article.html",
+ {
+ "article": article_result,
+ "category_data": category_data,
+ "relevant_links": relevant_links
+ }
+ )
diff --git a/pydis_site/apps/content/views/articles.py b/pydis_site/apps/content/views/articles.py
new file mode 100644
index 00000000..cce601e1
--- /dev/null
+++ b/pydis_site/apps/content/views/articles.py
@@ -0,0 +1,18 @@
+from django.core.handlers.wsgi import WSGIRequest
+from django.http import HttpResponse
+from django.shortcuts import render
+from django.views import View
+
+from pydis_site.apps.content.utils import get_articles, get_categories
+
+
+class ArticlesView(View):
+ """Shows all content and categories."""
+
+ def get(self, request: WSGIRequest) -> HttpResponse:
+ """Shows all content and categories."""
+ return render(
+ request,
+ "content/articles.html",
+ {"content": get_articles(), "categories": get_categories()}
+ )
diff --git a/pydis_site/apps/content/views/category.py b/pydis_site/apps/content/views/category.py
new file mode 100644
index 00000000..9d2a978e
--- /dev/null
+++ b/pydis_site/apps/content/views/category.py
@@ -0,0 +1,22 @@
+from django.core.handlers.wsgi import WSGIRequest
+from django.http import HttpResponse
+from django.shortcuts import render
+from django.views import View
+
+from pydis_site.apps.content.utils import get_articles, get_category
+
+
+class CategoryView(View):
+ """Handles content category page."""
+
+ def get(self, request: WSGIRequest, category: str) -> HttpResponse:
+ """Handles page that displays category content."""
+ return render(
+ request,
+ "content/category.html",
+ {
+ "category_info": get_category(category),
+ "content": get_articles(category),
+ "category_name": category
+ }
+ )
diff --git a/pydis_site/apps/home/urls.py b/pydis_site/apps/home/urls.py
index 5a58e002..c7e36156 100644
--- a/pydis_site/apps/home/urls.py
+++ b/pydis_site/apps/home/urls.py
@@ -33,4 +33,5 @@ urlpatterns = [
path('logout', LogoutView.as_view(), name="logout"),
path('admin/', admin.site.urls),
+ path('content/', include('pydis_site.apps.content.urls', namespace='content')),
]
diff --git a/pydis_site/settings.py b/pydis_site/settings.py
index cbfa2fe3..01807dc5 100644
--- a/pydis_site/settings.py
+++ b/pydis_site/settings.py
@@ -84,6 +84,7 @@ INSTALLED_APPS = [
'pydis_site.apps.api',
'pydis_site.apps.home',
'pydis_site.apps.staff',
+ 'pydis_site.apps.content',
'django.contrib.admin',
'django.contrib.auth',
diff --git a/pydis_site/static/css/content/articles.css b/pydis_site/static/css/content/articles.css
new file mode 100644
index 00000000..fa7a0ba5
--- /dev/null
+++ b/pydis_site/static/css/content/articles.css
@@ -0,0 +1,7 @@
+.breadcrumb-section {
+ padding: 1rem;
+}
+
+i.has-icon-padding {
+ padding: 0 10px 25px 0;
+}
diff --git a/pydis_site/templates/content/article.html b/pydis_site/templates/content/article.html
new file mode 100644
index 00000000..c340cdf6
--- /dev/null
+++ b/pydis_site/templates/content/article.html
@@ -0,0 +1,57 @@
+{% extends 'base/base.html' %}
+{% load static %}
+
+{% block title %}{{ article.metadata.title }}{% endblock %}
+{% block head %}
+ <meta property="og:title" content="Python Discord - {{ article.metadata.title|first }}" />
+ <meta property="og:type" content="website" />
+ <meta property="og:description" content="{{ article.metadata.short_description }}" />
+ <link rel="stylesheet" href="{% static "css/content/articles.css" %}">
+ <link rel="stylesheet" href="//cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/styles/default.min.css">
+ <script src="//cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/highlight.min.js"></script>
+ <script>hljs.initHighlightingOnLoad();</script>
+{% endblock %}
+
+{% block content %}
+ {% include "base/navbar.html" %}
+
+ <section class="breadcrumb-section section">
+ <div class="container">
+ <nav class="breadcrumb is-pulled-left" aria-label="breadcrumbs">
+ <ul>
+ <li><a href="/content">Pages</a></li>
+ {% if category_data.raw_name is not None %}
+ <li><a href="/content/category/{{ category_data.raw_name }}">{{ category_data.name }}</a></li>
+ {% endif %}
+ <li class="is-active"><a href="#">{{ article.metadata.title }}</a></li>
+ </ul>
+ </nav>
+ </div>
+ </section>
+
+ <section class="section">
+ <div class="content">
+ <div class="container">
+ <h1 class="title">{{ article.metadata.title }}</h1>
+ <div class="columns is-variable is-8">
+ <div class="column is-two-thirds">
+ {{ article.article|safe }}
+ </div>
+ <div class="column">
+ {% if relevant_links|length > 0 %}
+ <div class="box">
+ <p class="menu-label">Relevant links</p>
+ <ul class="menu-list">
+ {% for link, value in relevant_links.items %}
+ <li><a class="has-text-link" href="{{link}}">{{ value }}</a></li>
+ {% endfor %}
+ </ul>
+ </div>
+ {% endif %}
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+
+{% endblock %}
diff --git a/pydis_site/templates/content/articles.html b/pydis_site/templates/content/articles.html
new file mode 100644
index 00000000..35e5db16
--- /dev/null
+++ b/pydis_site/templates/content/articles.html
@@ -0,0 +1,53 @@
+{% extends 'base/base.html' %}
+{% load static %}
+
+{% block title %}Guides{% endblock %}
+{% block head %}
+ <link rel="stylesheet" href="{% static "css/content/articles.css" %}">
+{% endblock %}
+
+{% block content %}
+ {% include "base/navbar.html" %}
+
+ <section class="breadcrumb-section section">
+ <div class="container">
+ <nav class="breadcrumb is-pulled-left" aria-label="breadcrumbs">
+ <ul>
+ <li class="is-active"><a href="/content">Pages</a></li>
+ </ul>
+ </nav>
+ </div>
+ </section>
+
+ <section class="section">
+ <div class="container">
+ <div class="content">
+ <h1>Articles</h1>
+ {% for article, data in content.items %}
+ <div class="box" style="max-width: 800px;">
+ <span class="icon is-size-4 is-medium">
+ <i class="{{ data.icon_class|default:"fab" }} {{ data.icon|default:"fa-python" }} is-size-3 is-black has-icon-padding" aria-hidden="true"></i>
+ </span>
+ <a href="{{ article }}/">
+ <span class="is-size-4 has-text-weight-bold">{{ data.title }}</span>
+ </a>
+ <p class="is-italic">{{ data.short_description }}</p>
+ </div>
+ {% endfor %}
+ {% for category, data in categories.items %}
+ <div class="box" style="max-width: 800px;">
+ <span class="icon is-size-4 is-medium">
+ <i class="fas fa-folder is-size-3 is-black has-icon-padding" aria-hidden="true"></i>
+ </span>
+
+
+ <a href="category/{{ category }}/">
+ <span class="is-size-4 has-text-weight-bold">{{ data.name }}</span>
+ </a>
+ <p class="is-italic">{{ data.description }}</p>
+ </div>
+ {% endfor %}
+ </div>
+ </div>
+ </section>
+{% endblock %}
diff --git a/pydis_site/templates/content/category.html b/pydis_site/templates/content/category.html
new file mode 100644
index 00000000..3dec9259
--- /dev/null
+++ b/pydis_site/templates/content/category.html
@@ -0,0 +1,44 @@
+{% extends 'base/base.html' %}
+{% load static %}
+
+{% block title %}{{ category_info.name }}{% endblock %}
+{% block head %}
+ <meta property="og:title" content="Python Discord - {{ category_info.name }}" />
+ <meta property="og:type" content="website" />
+ <meta property="og:description" content="{{ category_info.description }}" />
+ <link rel="stylesheet" href="{% static "css/content/articles.css" %}">
+{% endblock %}
+
+{% block content %}
+ {% include "base/navbar.html" %}
+
+ <section class="breadcrumb-section section">
+ <div class="container">
+ <nav class="breadcrumb is-pulled-left" aria-label="breadcrumbs">
+ <ul>
+ <li><a href="/content">Pages</a></li>
+ <li class="is-active"><a href="#">{{ category_info.name }}</a></li>
+ </ul>
+ </nav>
+ </div>
+ </section>
+
+ <section class="section">
+ <div class="container">
+ <div class="content">
+ <h1>{{ category_info.name }}</h1>
+ {% for article, data in content.items %}
+ <div class="box" style="max-width: 800px;">
+ <span class="icon is-size-4 is-medium">
+ <i class="{{ data.icon_class|default:"fab" }} {{ data.icon|default:"fa-python" }} is-size-3 is-black has-icon-padding" aria-hidden="true"></i>
+ </span>
+ <a href="/content/category/{{ category_name }}/{{ article }}/">
+ <span class="is-size-4 has-text-weight-bold">{{ data.title }}</span>
+ </a>
+ <p class="is-italic">{{ data.short_description }}</p>
+ </div>
+ {% endfor %}
+ </div>
+ </div>
+ </section>
+{% endblock %}