diff options
Diffstat (limited to 'pydis_site/apps')
19 files changed, 534 insertions, 0 deletions
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')), ] |