diff options
Diffstat (limited to 'pydis_site')
26 files changed, 840 insertions, 1 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/pydis-guides/_info.yml b/pydis_site/apps/content/resources/content/guides/pydis-guides/_info.yml new file mode 100644 index 00000000..64111a83 --- /dev/null +++ b/pydis_site/apps/content/resources/content/guides/pydis-guides/_info.yml @@ -0,0 +1,2 @@ +name: Python Discord Guides +description: Python Discord server and community guides. diff --git a/pydis_site/apps/content/resources/content/guides/pydis-guides/how-to-write-a-article.md b/pydis_site/apps/content/resources/content/guides/pydis-guides/how-to-write-a-article.md new file mode 100644 index 00000000..ec89988c --- /dev/null +++ b/pydis_site/apps/content/resources/content/guides/pydis-guides/how-to-write-a-article.md @@ -0,0 +1,80 @@ +--- +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. +- **Icon class:** `icon_class` field have one of the favicons classes. Default is `fab`. +- **Icon:** `icon` field have favicon name. Default `fa-python`. + +## 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/subcategory/_info.yml b/pydis_site/apps/content/tests/test_content/category/subcategory/_info.yml new file mode 100644 index 00000000..f1c40264 --- /dev/null +++ b/pydis_site/apps/content/tests/test_content/category/subcategory/_info.yml @@ -0,0 +1,2 @@ +name: My Category 1 +description: My Description 1 diff --git a/pydis_site/apps/content/tests/test_content/category/subcategory/test4.md b/pydis_site/apps/content/tests/test_content/category/subcategory/test4.md new file mode 100644 index 00000000..8031131d --- /dev/null +++ b/pydis_site/apps/content/tests/test_content/category/subcategory/test4.md @@ -0,0 +1,6 @@ +--- +title: Test 4 +short_description: Testing 4 +--- + +This is also test content and in subcategory. 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..85f1139a --- /dev/null +++ b/pydis_site/apps/content/tests/test_utils.py @@ -0,0 +1,188 @@ +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock, patch + +from django.conf import settings +from django.http import Http404 +from django.test import TestCase, override_settings +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 TestGetCategory(TestCase): +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_get_category_successfully(self): +        """Check does this get right data from category data file.""" +        result = utils.get_category(["category"]) + +        self.assertEqual(result, {"name": "My Category", "description": "My Description"}) + +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_get_category_not_exists(self): +        """Check does this raise 404 error when category don't exists.""" +        with self.assertRaises(Http404): +            utils.get_category(["invalid"]) + +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_get_category_not_directory(self): +        """Check does this raise 404 error when category isn't directory.""" +        with self.assertRaises(Http404): +            utils.get_category(["test.md"]) + + +class TestGetCategories(TestCase): +    @override_settings(ARTICLES_PATH=BASE_PATH) +    @patch("pydis_site.apps.content.utils.get_category") +    def test_get_categories(self, get_category_mock): +        """Check does this return test content categories.""" +        get_category_mock.return_value = {"name": "My Category", "description": "My Description"} + +        result = utils.get_categories() +        get_category_mock.assert_called_once_with(["category"]) + +        self.assertEqual( +            result, {"category": {"name": "My Category", "description": "My Description"}} +        ) + +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_get_categories_root_path(self): +        """Check does this doesn't call joinpath when getting root categories.""" +        result = utils.get_categories() +        self.assertEqual( +            result, {"category": {"name": "My Category", "description": "My Description"}} +        ) + +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_get_categories_in_category(self): +        """Check does this call joinpath when getting subcategories.""" +        result = utils.get_categories(["category"]) +        self.assertEqual( +            result, {"subcategory": {"name": "My Category 1", "description": "My Description 1"}} +        ) + + +class TestGetArticles(TestCase): +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_get_all_root_articles(self): +        """Check does this return all root level testing content.""" +        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]) + +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_get_all_category_articles(self): +        """Check does this return all category testing content.""" +        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): +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_get_root_article_success(self): +        """Check does this return article HTML and metadata when root article exist.""" +        result = utils.get_article(["test"]) + +        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}) + +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_get_root_article_dont_exist(self): +        """Check does this raise Http404 when root article don't exist.""" +        with self.assertRaises(Http404): +            utils.get_article(["invalid"]) + +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_get_category_article_success(self): +        """Check does this return article HTML and metadata when category guide exist.""" +        result = utils.get_article(["category", "test3"]) + +        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}) + +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_get_category_article_dont_exist(self): +        """Check does this raise Http404 when category article don't exist.""" +        with self.assertRaises(Http404): +            utils.get_article(["category", "invalid"]) + +    @patch("pydis_site.settings.ARTICLES_PATH", new=BASE_PATH) +    def test_get_category_article_category_dont_exist(self): +        """Check does this raise Http404 when category don't exist.""" +        with self.assertRaises(Http404): +            utils.get_article(["invalid", "some-guide"]) + + +class GetGitHubInformationTests(TestCase): +    @patch("pydis_site.apps.content.utils.requests.get") +    @patch("pydis_site.apps.content.utils.COMMITS_URL", "foobar") +    def test_call_get_github_information_requests_get(self, requests_get_mock): +        """Check does this call requests.get function with proper URL.""" +        utils.get_github_information(["foo"]) +        requests_get_mock.assert_called_once_with("foobar") + +    @patch("pydis_site.apps.content.utils.requests.get") +    def test_github_status_code_200_response(self, requests_get_mock): +        """Check does this return provided modified date and contributors.""" +        requests_get_mock.return_value = MagicMock(status_code=200) +        requests_get_mock.return_value.json.return_value = [{ +            "commit": { +                "committer": { +                    "date": datetime(2020, 10, 1).isoformat(), +                    "name": "foobar", +                } +            }, +            "committer": { +                "html_url": "abc1234" +            } +        }] +        result = utils.get_github_information(["foo"]) +        self.assertEqual(result, { +            "last_modified": datetime(2020, 10, 1).strftime("%dth %B %Y"), +            "contributors": {"foobar": "abc1234"} +        }) + +    @patch("pydis_site.apps.content.utils.requests.get") +    def test_github_other_status_code_response(self, requests_get_mock): +        """Check does this return provided modified date and contributors.""" +        requests_get_mock.return_value = MagicMock(status_code=404) +        result = utils.get_github_information(["foo"]) +        self.assertEqual(result, { +            "last_modified": "N/A", +            "contributors": {} +        }) 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..98b99b83 --- /dev/null +++ b/pydis_site/apps/content/tests/test_views.py @@ -0,0 +1,161 @@ +from pathlib import Path +from unittest.mock import patch + +from django.conf import settings +from django.http import Http404 +from django.test import RequestFactory, TestCase, override_settings +from django_hosts.resolvers import reverse + +from pydis_site.apps.content.views import ArticleOrCategoryView + +BASE_PATH = Path(settings.BASE_DIR, "pydis_site", "apps", "content", "tests", "test_content") + + +class TestArticlesIndexView(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:articles') +        response = self.client.get(url) +        self.assertEqual(response.status_code, 200) +        get_articles_mock.assert_called_once() +        get_categories_mock.assert_called_once() + + +class TestArticleOrCategoryView(TestCase): +    @override_settings(ARTICLES_PATH=BASE_PATH) +    @patch("pydis_site.apps.content.views.article_category.utils.get_article") +    @patch("pydis_site.apps.content.views.article_category.utils.get_category") +    @patch("pydis_site.apps.content.views.article_category.utils.get_github_information") +    def test_article_return_code_200(self, gh_info_mock, get_category_mock, get_article_mock): +        get_article_mock.return_value = {"guide": "test", "metadata": {}} + +        url = reverse("content:article_category", args=["test2"]) +        response = self.client.get(url) +        self.assertEqual(response.status_code, 200) +        get_category_mock.assert_not_called() +        get_article_mock.assert_called_once() +        gh_info_mock.assert_called_once() + +    @patch("pydis_site.apps.content.views.article_category.utils.get_article") +    @patch("pydis_site.apps.content.views.article_category.utils.get_category") +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_article_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_category", args=["invalid-guide"]) +        response = self.client.get(url) +        self.assertEqual(response.status_code, 404) +        get_article_mock.assert_not_called() +        get_category_mock.assert_not_called() + +    @patch("pydis_site.apps.content.views.article_category.utils.get_category") +    @patch("pydis_site.apps.content.views.article_category.utils.get_articles") +    @patch("pydis_site.apps.content.views.article_category.utils.get_categories") +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_valid_category_code_200( +            self, +            get_categories_mock, +            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:article_category", args=["category"]) +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 200) +        get_articles_mock.assert_called_once() +        get_category_mock.assert_called_once() +        get_categories_mock.assert_called_once() + +    @patch("pydis_site.apps.content.views.article_category.utils.get_category") +    @patch("pydis_site.apps.content.views.article_category.utils.get_articles") +    @patch("pydis_site.apps.content.views.article_category.utils.get_categories") +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_invalid_category_code_404( +            self, +            get_categories_mock, +            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:article_category", args=["invalid-category"]) +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 404) +        get_category_mock.assert_not_called() +        get_articles_mock.assert_not_called() +        get_categories_mock.assert_not_called() + +    @patch("pydis_site.apps.content.views.article_category.utils.get_article") +    @patch("pydis_site.apps.content.views.article_category.utils.get_category") +    @patch("pydis_site.apps.content.views.article_category.utils.get_github_information") +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_valid_category_article_code_200( +            self, +            gh_info_mock, +            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:article_category", args=["category/test3"]) +        response = self.client.get(url) +        self.assertEqual(response.status_code, 200) +        get_article_mock.assert_called_once() +        self.assertEqual(get_category_mock.call_count, 2) +        gh_info_mock.assert_called_once() + +    @patch("pydis_site.apps.content.views.article_category.utils.get_article") +    @patch("pydis_site.apps.content.views.article_category.utils.get_category") +    @patch("pydis_site.apps.content.views.article_category.utils.get_github_information") +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_invalid_category_article_code_404( +            self, +            gh_info_mock, +            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:article_category", args=["category/invalid"]) +        response = self.client.get(url) +        self.assertEqual(response.status_code, 404) +        get_article_mock.assert_not_called() +        get_category_mock.assert_not_called() +        gh_info_mock.assert_not_called() + +    @override_settings(ARTICLES_PATH=BASE_PATH) +    def test_article_category_template_names(self): +        """Check that this return category, article template or raise Http404.""" +        factory = RequestFactory() +        cases = [ +            {"location": "category", "output": ["content/listing.html"]}, +            {"location": "test", "output": ["content/article.html"]}, +            {"location": "invalid", "output": None, "raises": Http404} +        ] + +        for case in cases: +            with self.subTest(location=case["location"], output=case["output"]): +                request = factory.get(f"/articles/{case['location']}") +                instance = ArticleOrCategoryView() +                instance.request = request +                instance.kwargs = {"location": case["location"]} + +                if "raises" in case: +                    with self.assertRaises(case["raises"]): +                        instance.get_template_names() +                else: +                    self.assertEqual(case["output"], instance.get_template_names()) diff --git a/pydis_site/apps/content/urls.py b/pydis_site/apps/content/urls.py new file mode 100644 index 00000000..49a5a2ef --- /dev/null +++ b/pydis_site/apps/content/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = "content" +urlpatterns = [ +    path("", views.ArticlesView.as_view(), name='articles'), +    path("<path:location>/", views.ArticleOrCategoryView.as_view(), name='article_category'), +] diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py new file mode 100644 index 00000000..a89db83c --- /dev/null +++ b/pydis_site/apps/content/utils.py @@ -0,0 +1,112 @@ +import os +from typing import Dict, List, Optional, Union + +import requests +import yaml +from dateutil import parser +from django.conf import settings +from django.http import Http404 +from markdown2 import markdown + +COMMITS_URL = "https://api.github.com/repos/{owner}/{name}/commits?path={path}&sha={branch}" +BASE_ARTICLES_LOCATION = "pydis_site/apps/content/resources/content/" + + +def get_category(path: List[str]) -> Dict[str, str]: +    """Load category information by name from _info.yml.""" +    path = settings.ARTICLES_PATH.joinpath(*path) +    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(path: Optional[List[str]] = None) -> Dict[str, Dict]: +    """Get all categories information.""" +    categories = {} +    if path is None: +        categories_path = settings.ARTICLES_PATH +        path = [] +    else: +        categories_path = settings.ARTICLES_PATH.joinpath(*path) + +    for name in categories_path.iterdir(): +        if name.is_dir(): +            categories[name.name] = get_category([*path, name.name]) + +    return categories + + +def get_articles(path: Optional[List[str]] = None) -> Dict[str, Dict]: +    """Get all root or category articles.""" +    if path is None: +        base_dir = settings.ARTICLES_PATH +    else: +        base_dir = settings.ARTICLES_PATH.joinpath(*path) + +    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(path: List[str]) -> Dict[str, Union[str, Dict]]: +    """Get one specific article. When category is specified, get it from there.""" +    article_path = settings.ARTICLES_PATH.joinpath(*path[:-1]) + +    # We need to include extension MD +    article_path = article_path.joinpath(f"{path[-1]}.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} + + +def get_github_information( +        path: List[str] +) -> Dict[str, Union[List[str], str]]: +    """Get article last modified date and contributors from GitHub.""" +    result = requests.get( +        COMMITS_URL.format( +            owner=settings.SITE_REPOSITORY_OWNER, +            name=settings.SITE_REPOSITORY_NAME, +            branch=settings.SITE_REPOSITORY_BRANCH, +            path=( +                f"{BASE_ARTICLES_LOCATION}{'/'.join(path[:-1])}" +                f"{'/' if len(path) > 1 else ''}{path[-1]}.md" +            ) +        ) +    ) + +    if result.status_code == 200 and len(result.json()): +        data = result.json() +        return { +            "last_modified": parser.isoparse( +                data[0]["commit"]["committer"]["date"] +            ).strftime("%dth %B %Y"), +            "contributors": { +                c["commit"]["committer"]["name"]: c["committer"]["html_url"] for c in data +            } +        } +    else: +        return { +            "last_modified": "N/A", +            "contributors": {} +        } diff --git a/pydis_site/apps/content/views/__init__.py b/pydis_site/apps/content/views/__init__.py new file mode 100644 index 00000000..f92660d6 --- /dev/null +++ b/pydis_site/apps/content/views/__init__.py @@ -0,0 +1,4 @@ +from .article_category import ArticleOrCategoryView +from .articles import ArticlesView + +__all__ = ["ArticleOrCategoryView", "ArticlesView"] diff --git a/pydis_site/apps/content/views/article_category.py b/pydis_site/apps/content/views/article_category.py new file mode 100644 index 00000000..0c22b5e8 --- /dev/null +++ b/pydis_site/apps/content/views/article_category.py @@ -0,0 +1,75 @@ +import typing as t + +from django.conf import settings +from django.http import Http404 +from django.views.generic import TemplateView + +from pydis_site.apps.content import utils + + +class ArticleOrCategoryView(TemplateView): +    """Handles article and category pages.""" + +    def get_template_names(self) -> t.List[str]: +        """Checks does this use article template or listing template.""" +        location = self.kwargs["location"].split("/") +        full_location = settings.ARTICLES_PATH.joinpath(*location) + +        if full_location.is_dir(): +            template_name = "content/listing.html" +        elif full_location.with_suffix(".md").is_file(): +            template_name = "content/article.html" +        else: +            raise Http404 + +        return [template_name] + +    def get_context_data(self, **kwargs) -> t.Dict[str, t.Any]: +        """Assign proper context variables based on what resource user requests.""" +        context = super().get_context_data(**kwargs) + +        location: list = self.kwargs["location"].split("/") +        full_location = settings.ARTICLES_PATH.joinpath(*location) + +        if full_location.is_dir(): +            context["category_info"] = utils.get_category(location) +            context["content"] = utils.get_articles(location) +            context["categories"] = utils.get_categories(location) +            # Add trailing slash here to simplify template +            context["path"] = "/".join(location) + "/" +            context["in_category"] = True +        elif full_location.with_suffix(".md").is_file(): +            article_result = utils.get_article(location) + +            if len(location) > 1: +                context["category_data"] = utils.get_category(location[:-1]) +                context["category_data"]["raw_name"] = location[:-1][-1] +            else: +                context["category_data"] = {"name": None, "raw_name": None} + +            context["article"] = article_result +            context["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 link != "" and value != "" +            } +            context["github_data"] = utils.get_github_information(location) +        else: +            raise Http404 + +        location.pop() +        breadcrumb_items = [] +        while len(location): +            breadcrumb_items.insert( +                0, +                { +                    "name": utils.get_category(location)["name"], +                    "path": "/".join(location) +                } +            ) +            location.pop() + +        context["breadcrumb_items"] = breadcrumb_items + +        return context diff --git a/pydis_site/apps/content/views/articles.py b/pydis_site/apps/content/views/articles.py new file mode 100644 index 00000000..999002d0 --- /dev/null +++ b/pydis_site/apps/content/views/articles.py @@ -0,0 +1,16 @@ +from django.views.generic import TemplateView + +from pydis_site.apps.content.utils import get_articles, get_categories + + +class ArticlesView(TemplateView): +    """Shows all content and categories.""" + +    template_name = "content/listing.html" + +    def get_context_data(self, **kwargs) -> dict: +        """Add articles and categories data to template context.""" +        context = super().get_context_data(**kwargs) +        context["content"] = get_articles() +        context["categories"] = get_categories() +        return context diff --git a/pydis_site/apps/home/urls.py b/pydis_site/apps/home/urls.py index d7db6ff1..bd7c0625 100644 --- a/pydis_site/apps/home/urls.py +++ b/pydis_site/apps/home/urls.py @@ -8,5 +8,6 @@ urlpatterns = [      path('', HomeView.as_view(), name='home'),      path('admin/', admin.site.urls),      path('resources/', include('pydis_site.apps.resources.urls')), +    path('articles/', include('pydis_site.apps.content.urls')),      path('events/', include('pydis_site.apps.events.urls', namespace='events')),  ] diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 67afdbcb..d509d980 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -85,6 +85,7 @@ INSTALLED_APPS = [      'pydis_site.apps.home',      'pydis_site.apps.staff',      'pydis_site.apps.resources', +    'pydis_site.apps.content',      'pydis_site.apps.events',      'django.contrib.admin', @@ -280,3 +281,10 @@ BULMA_SETTINGS = {          "footer-padding": "1rem 1.5rem 1rem",      }  } + +# Information about site repository +SITE_REPOSITORY_OWNER = "python-discord" +SITE_REPOSITORY_NAME = "site" +SITE_REPOSITORY_BRANCH = "master" + +ARTICLES_PATH = Path(BASE_DIR, "pydis_site", "apps", "content", "resources", "content") 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..c7b85567 --- /dev/null +++ b/pydis_site/templates/content/article.html @@ -0,0 +1,75 @@ +{% extends 'base/base.html' %} +{% load static %} + +{% block title %}{{ article.metadata.title }}{% endblock %} +{% block head %} +  <meta property="og:title" content="Python Discord - {{ article.metadata.title }}" /> +  <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="{% url "content:articles" %}">Articles</a></li> +                    {% for item in breadcrumb_items %} +                        <li><a href="{% url "content:article_category" location=item.path %}">{{ item.name }}</a></li> +                    {% endfor %} +                    <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> +                <p class="subtitle is-size-6"><strong>Last modified:</strong> {{ github_data.last_modified }}</p> +                <div class="columns is-variable is-8"> +                    <div class="column is-two-thirds"> +                        {{ article.article|safe }} +                    </div> +                    <div class="column"> +                        <div class="card"> +                            <div class="card-header"> +                                <p class="card-header-title">Contributors</p> +                            </div> +                            <div class="card-content"> +                                {% if github_data.contributors|length %} +                                    <div class="tags"> +                                        {% for user, profile_url in github_data.contributors.items %} +                                            <span class="tag"><a href="{{ profile_url }}">{{ user }}</a></span> +                                        {% endfor %} +                                    </div> +                                {% else %} +                                <p>N/A</p> +                                {% endif %} +                            </div> +                        </div> + +                        {% 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/listing.html b/pydis_site/templates/content/listing.html new file mode 100644 index 00000000..8c06bccc --- /dev/null +++ b/pydis_site/templates/content/listing.html @@ -0,0 +1,62 @@ +{% extends 'base/base.html' %} +{% load static %} + +{% block title %}{{ category_info.name|default:"Articles" }}{% endblock %} +{% block head %} +    <meta property="og:title" content="Python Discord - {{ category_info.name|default:"Articles" }}" /> +    <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> +                    {% if in_category %} +                        <li><a href="{% url "content:articles" %}">Articles</a></li> +                    {% endif %} +                    {% for item in breadcrumb_items %} +                        <li><a href="{% url "content:article_category" location=item.path %}">{{ item.name }}</a></li> +                    {% endfor %} +                    <li class="is-active"><a href="#">{{ category_info.name|default:"Articles" }}</a></li> +                </ul> +            </nav> +        </div> +    </section> + +    <section class="section"> +        <div class="container"> +            <div class="content"> +                <h1>{{ category_info.name|default:"Articles" }}</h1> +                {% 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="{% url "content:article_category" location=path|add:category %}"> +                            <span class="is-size-4 has-text-weight-bold">{{ data.name }}</span> +                        </a> +                        <p class="is-italic">{{ data.description }}</p> +                    </div> +                {% endfor %} +                {% 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="{% url "content:article_category" location=path|add: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 %} diff --git a/pydis_site/templates/resources/resources.html b/pydis_site/templates/resources/resources.html index 6eb32c97..2dc88a8c 100644 --- a/pydis_site/templates/resources/resources.html +++ b/pydis_site/templates/resources/resources.html @@ -15,7 +15,7 @@                <h1>Resources</h1>                <div class="tile is-ancestor"> -                  <a class="tile is-parent" href="/articles/category/guides"> +                  <a class="tile is-parent" href="{% url "content:article_category" location="guides" %}">                        <article class="tile is-child box hero is-primary is-bold">                            <p class="title is-size-1"><i class="fad fa-info-circle" aria-hidden="true"></i> Guides</p>                            <p class="subtitle is-size-4">Made by us, for you</p> | 
