diff options
32 files changed, 1378 insertions, 158 deletions
| diff --git a/docker-compose.yml b/docker-compose.yml index a6f4fd18..61554ae4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@  version: "3.8"  services:    postgres: -    image: postgres:13-alpine +    image: postgres:15-alpine      ports:        - "7777:5432"      environment: @@ -195,7 +195,7 @@ def main() -> None:      # Pass any others directly to standard management commands      else: -        _static_build = "distill" in sys.argv[1] +        _static_build = len(sys.argv) > 1 and "distill" in sys.argv[1]          if _static_build:              # Build a static version of the site with no databases and API support diff --git a/poetry.lock b/poetry.lock index 90b34684..c17c3286 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,6 +1,6 @@  [[package]]  name = "anyio" -version = "3.6.1" +version = "3.6.2"  description = "High level compatibility layer for multiple asynchronous event loop implementations"  category = "main"  optional = false @@ -13,7 +13,7 @@ sniffio = ">=1.1"  [package.extras]  doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]  test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"] -trio = ["trio (>=0.16)"] +trio = ["trio (>=0.16,<0.22)"]  [[package]]  name = "asgiref" @@ -99,11 +99,11 @@ unicode-backport = ["unicodedata2"]  [[package]]  name = "colorama" -version = "0.4.5" +version = "0.4.6"  description = "Cross-platform colored terminal text."  category = "dev"  optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"  [[package]]  name = "coverage" @@ -118,7 +118,7 @@ toml = ["tomli"]  [[package]]  name = "cryptography" -version = "38.0.1" +version = "38.0.3"  description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."  category = "main"  optional = false @@ -145,7 +145,7 @@ python-versions = "*"  [[package]]  name = "django" -version = "4.1.2" +version = "4.1.3"  description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."  category = "main"  optional = false @@ -285,7 +285,7 @@ flake8 = ">=5.0.0"  [[package]]  name = "flake8-bugbear" -version = "22.10.25" +version = "22.10.27"  description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle."  category = "dev"  optional = false @@ -368,7 +368,7 @@ smmap = ">=3.0.1,<6"  [[package]]  name = "GitPython" -version = "3.1.27" +version = "3.1.29"  description = "GitPython is a python library used to interact with Git repositories"  category = "dev"  optional = false @@ -442,7 +442,7 @@ socks = ["socksio (>=1.0.0,<2.0.0)"]  [[package]]  name = "identify" -version = "2.5.5" +version = "2.5.8"  description = "File identification library for Python"  category = "dev"  optional = false @@ -510,7 +510,7 @@ setuptools = "*"  [[package]]  name = "pbr" -version = "5.10.0" +version = "5.11.0"  description = "Python Build Reasonableness"  category = "dev"  optional = false @@ -557,7 +557,7 @@ virtualenv = ">=20.0.8"  [[package]]  name = "prometheus-client" -version = "0.14.1" +version = "0.15.0"  description = "Python client for the Prometheus monitoring system."  category = "main"  optional = false @@ -568,7 +568,7 @@ twisted = ["twisted"]  [[package]]  name = "psutil" -version = "5.9.2" +version = "5.9.3"  description = "Cross-platform lib for process and system monitoring in Python."  category = "dev"  optional = false @@ -632,7 +632,7 @@ optional = false  python-versions = ">=3.6"  [[package]] -name = "pyjwt" +name = "PyJWT"  version = "2.6.0"  description = "JSON Web Token implementation in Python"  category = "main" @@ -649,6 +649,17 @@ docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"]  tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]  [[package]] +name = "pymdown-extensions" +version = "9.8" +description = "Extension pack for Python Markdown." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +markdown = ">=3.2" + +[[package]]  name = "python-dotenv"  version = "0.21.0"  description = "Read key-value pairs from a .env file and set them as environment variables" @@ -676,7 +687,7 @@ test = ["pyaml", "pytest", "toml"]  [[package]]  name = "pytz" -version = "2022.2.1" +version = "2022.5"  description = "World timezone definitions, modern and historical"  category = "main"  optional = false @@ -724,7 +735,7 @@ idna2008 = ["idna"]  [[package]]  name = "sentry-sdk" -version = "1.10.1" +version = "1.11.0"  description = "Python client for Sentry (https://sentry.io)"  category = "main"  optional = false @@ -746,6 +757,7 @@ fastapi = ["fastapi (>=0.79.0)"]  flask = ["blinker (>=1.1)", "flask (>=0.11)"]  httpx = ["httpx (>=0.16.0)"]  pure-eval = ["asttokens", "executing", "pure-eval"] +pymongo = ["pymongo (>=3.1)"]  pyspark = ["pyspark (>=2.4.4)"]  quart = ["blinker (>=1.1)", "quart (>=0.16.1)"]  rq = ["rq (>=0.6)"] @@ -756,7 +768,7 @@ tornado = ["tornado (>=5)"]  [[package]]  name = "setuptools" -version = "65.4.1" +version = "65.5.0"  description = "Easily download, build, install, upgrade, and uninstall Python packages"  category = "main"  optional = false @@ -809,7 +821,7 @@ python-versions = ">=3.5"  [[package]]  name = "stevedore" -version = "4.0.0" +version = "4.1.0"  description = "Manage dynamic plugins for Python applications"  category = "dev"  optional = false @@ -850,7 +862,7 @@ python-versions = ">=3.7"  [[package]]  name = "tzdata" -version = "2022.4" +version = "2022.5"  description = "Provider of IANA time zone data"  category = "main"  optional = false @@ -871,19 +883,19 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]  [[package]]  name = "virtualenv" -version = "20.16.5" +version = "20.16.6"  description = "Virtual Python Environment builder"  category = "dev"  optional = false  python-versions = ">=3.6"  [package.dependencies] -distlib = ">=0.3.5,<1" +distlib = ">=0.3.6,<1"  filelock = ">=3.4.1,<4"  platformdirs = ">=2.4,<3"  [package.extras] -docs = ["proselint (>=0.13)", "sphinx (>=5.1.1)", "sphinx-argparse (>=0.3.1)", "sphinx-rtd-theme (>=1)", "towncrier (>=21.9)"] +docs = ["proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-argparse (>=0.3.2)", "sphinx-rtd-theme (>=1)", "towncrier (>=22.8)"]  testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=21.3)", "pytest (>=7.0.1)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.6.1)", "pytest-randomly (>=3.10.3)", "pytest-timeout (>=2.1)"]  [[package]] @@ -900,12 +912,12 @@ brotli = ["Brotli"]  [metadata]  lock-version = "1.1"  python-versions = "3.10.*" -content-hash = "0fb1e1b24b47c7f5ee078cf297a719c319f8ee71bf655fd315194a9e83da8e21" +content-hash = "bd31b8df83e8098e6a18a2cddc41ef40215cc0e20269900bedd59330a7363951"  [metadata.files]  anyio = [ -    {file = "anyio-3.6.1-py3-none-any.whl", hash = "sha256:cb29b9c70620506a9a8f87a309591713446953302d7d995344d0d7c6c0c9a7be"}, -    {file = "anyio-3.6.1.tar.gz", hash = "sha256:413adf95f93886e442aea925f3ee43baa5a765a64a0f52c6081894f9992fdd0b"}, +    {file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"}, +    {file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"},  ]  asgiref = [      {file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"}, @@ -998,8 +1010,8 @@ charset-normalizer = [      {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"},  ]  colorama = [ -    {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, -    {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, +    {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, +    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},  ]  coverage = [      {file = "coverage-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53"}, @@ -1054,40 +1066,40 @@ coverage = [      {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"},  ]  cryptography = [ -    {file = "cryptography-38.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:10d1f29d6292fc95acb597bacefd5b9e812099d75a6469004fd38ba5471a977f"}, -    {file = "cryptography-38.0.1-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:3fc26e22840b77326a764ceb5f02ca2d342305fba08f002a8c1f139540cdfaad"}, -    {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:3b72c360427889b40f36dc214630e688c2fe03e16c162ef0aa41da7ab1455153"}, -    {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:194044c6b89a2f9f169df475cc167f6157eb9151cc69af8a2a163481d45cc407"}, -    {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca9f6784ea96b55ff41708b92c3f6aeaebde4c560308e5fbbd3173fbc466e94e"}, -    {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:16fa61e7481f4b77ef53991075de29fc5bacb582a1244046d2e8b4bb72ef66d0"}, -    {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d4ef6cc305394ed669d4d9eebf10d3a101059bdcf2669c366ec1d14e4fb227bd"}, -    {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3261725c0ef84e7592597606f6583385fed2a5ec3909f43bc475ade9729a41d6"}, -    {file = "cryptography-38.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:0297ffc478bdd237f5ca3a7dc96fc0d315670bfa099c04dc3a4a2172008a405a"}, -    {file = "cryptography-38.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:89ed49784ba88c221756ff4d4755dbc03b3c8d2c5103f6d6b4f83a0fb1e85294"}, -    {file = "cryptography-38.0.1-cp36-abi3-win32.whl", hash = "sha256:ac7e48f7e7261207d750fa7e55eac2d45f720027d5703cd9007e9b37bbb59ac0"}, -    {file = "cryptography-38.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:ad7353f6ddf285aeadfaf79e5a6829110106ff8189391704c1d8801aa0bae45a"}, -    {file = "cryptography-38.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:896dd3a66959d3a5ddcfc140a53391f69ff1e8f25d93f0e2e7830c6de90ceb9d"}, -    {file = "cryptography-38.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:d3971e2749a723e9084dd507584e2a2761f78ad2c638aa31e80bc7a15c9db4f9"}, -    {file = "cryptography-38.0.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:79473cf8a5cbc471979bd9378c9f425384980fcf2ab6534b18ed7d0d9843987d"}, -    {file = "cryptography-38.0.1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:d9e69ae01f99abe6ad646947bba8941e896cb3aa805be2597a0400e0764b5818"}, -    {file = "cryptography-38.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5067ee7f2bce36b11d0e334abcd1ccf8c541fc0bbdaf57cdd511fdee53e879b6"}, -    {file = "cryptography-38.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:3e3a2599e640927089f932295a9a247fc40a5bdf69b0484532f530471a382750"}, -    {file = "cryptography-38.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c2e5856248a416767322c8668ef1845ad46ee62629266f84a8f007a317141013"}, -    {file = "cryptography-38.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:64760ba5331e3f1794d0bcaabc0d0c39e8c60bf67d09c93dc0e54189dfd7cfe5"}, -    {file = "cryptography-38.0.1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b6c9b706316d7b5a137c35e14f4103e2115b088c412140fdbd5f87c73284df61"}, -    {file = "cryptography-38.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0163a849b6f315bf52815e238bc2b2346604413fa7c1601eea84bcddb5fb9ac"}, -    {file = "cryptography-38.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:d1a5bd52d684e49a36582193e0b89ff267704cd4025abefb9e26803adeb3e5fb"}, -    {file = "cryptography-38.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:765fa194a0f3372d83005ab83ab35d7c5526c4e22951e46059b8ac678b44fa5a"}, -    {file = "cryptography-38.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:52e7bee800ec869b4031093875279f1ff2ed12c1e2f74923e8f49c916afd1d3b"}, -    {file = "cryptography-38.0.1.tar.gz", hash = "sha256:1db3d807a14931fa317f96435695d9ec386be7b84b618cc61cfa5d08b0ae33d7"}, +    {file = "cryptography-38.0.3-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:984fe150f350a3c91e84de405fe49e688aa6092b3525f407a18b9646f6612320"}, +    {file = "cryptography-38.0.3-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:ed7b00096790213e09eb11c97cc6e2b757f15f3d2f85833cd2d3ec3fe37c1722"}, +    {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bbf203f1a814007ce24bd4d51362991d5cb90ba0c177a9c08825f2cc304d871f"}, +    {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554bec92ee7d1e9d10ded2f7e92a5d70c1f74ba9524947c0ba0c850c7b011828"}, +    {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b52c9e5f8aa2b802d48bd693190341fae201ea51c7a167d69fc48b60e8a959"}, +    {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:728f2694fa743a996d7784a6194da430f197d5c58e2f4e278612b359f455e4a2"}, +    {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfb4f4dd568de1b6af9f4cda334adf7d72cf5bc052516e1b2608b683375dd95c"}, +    {file = "cryptography-38.0.3-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5419a127426084933076132d317911e3c6eb77568a1ce23c3ac1e12d111e61e0"}, +    {file = "cryptography-38.0.3-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:9b24bcff7853ed18a63cfb0c2b008936a9554af24af2fb146e16d8e1aed75748"}, +    {file = "cryptography-38.0.3-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:25c1d1f19729fb09d42e06b4bf9895212292cb27bb50229f5aa64d039ab29146"}, +    {file = "cryptography-38.0.3-cp36-abi3-win32.whl", hash = "sha256:7f836217000342d448e1c9a342e9163149e45d5b5eca76a30e84503a5a96cab0"}, +    {file = "cryptography-38.0.3-cp36-abi3-win_amd64.whl", hash = "sha256:c46837ea467ed1efea562bbeb543994c2d1f6e800785bd5a2c98bc096f5cb220"}, +    {file = "cryptography-38.0.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06fc3cc7b6f6cca87bd56ec80a580c88f1da5306f505876a71c8cfa7050257dd"}, +    {file = "cryptography-38.0.3-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:65535bc550b70bd6271984d9863a37741352b4aad6fb1b3344a54e6950249b55"}, +    {file = "cryptography-38.0.3-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:5e89468fbd2fcd733b5899333bc54d0d06c80e04cd23d8c6f3e0542358c6060b"}, +    {file = "cryptography-38.0.3-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:6ab9516b85bebe7aa83f309bacc5f44a61eeb90d0b4ec125d2d003ce41932d36"}, +    {file = "cryptography-38.0.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:068147f32fa662c81aebab95c74679b401b12b57494872886eb5c1139250ec5d"}, +    {file = "cryptography-38.0.3-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:402852a0aea73833d982cabb6d0c3bb582c15483d29fb7085ef2c42bfa7e38d7"}, +    {file = "cryptography-38.0.3-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b1b35d9d3a65542ed2e9d90115dfd16bbc027b3f07ee3304fc83580f26e43249"}, +    {file = "cryptography-38.0.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:6addc3b6d593cd980989261dc1cce38263c76954d758c3c94de51f1e010c9a50"}, +    {file = "cryptography-38.0.3-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:be243c7e2bfcf6cc4cb350c0d5cdf15ca6383bbcb2a8ef51d3c9411a9d4386f0"}, +    {file = "cryptography-38.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78cf5eefac2b52c10398a42765bfa981ce2372cbc0457e6bf9658f41ec3c41d8"}, +    {file = "cryptography-38.0.3-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:4e269dcd9b102c5a3d72be3c45d8ce20377b8076a43cbed6f660a1afe365e436"}, +    {file = "cryptography-38.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8d41a46251bf0634e21fac50ffd643216ccecfaf3701a063257fe0b2be1b6548"}, +    {file = "cryptography-38.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:785e4056b5a8b28f05a533fab69febf5004458e20dad7e2e13a3120d8ecec75a"}, +    {file = "cryptography-38.0.3.tar.gz", hash = "sha256:bfbe6ee19615b07a98b1d2287d6a6073f734735b49ee45b11324d85efc4d5cbd"},  ]  distlib = [      {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"},      {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"},  ]  django = [ -    {file = "Django-4.1.2-py3-none-any.whl", hash = "sha256:26dc24f99c8956374a054bcbf58aab8dc0cad2e6ac82b0fe036b752c00eee793"}, -    {file = "Django-4.1.2.tar.gz", hash = "sha256:b8d843714810ab88d59344507d4447be8b2cf12a49031363b6eed9f1b9b2280f"}, +    {file = "Django-4.1.3-py3-none-any.whl", hash = "sha256:6b1de6886cae14c7c44d188f580f8ba8da05750f544c80ae5ad43375ab293cd5"}, +    {file = "Django-4.1.3.tar.gz", hash = "sha256:678bbfc8604eb246ed54e2063f0765f13b321a50526bdc8cb1f943eda7fa31f1"},  ]  django-distill = [      {file = "django-distill-3.0.1.tar.gz", hash = "sha256:8bbac5e45d2afc61cc718d587c6026267c985305f5e599465f2ebc4b0cba9ebf"}, @@ -1129,8 +1141,8 @@ flake8-bandit = [      {file = "flake8_bandit-4.1.1.tar.gz", hash = "sha256:068e09287189cbfd7f986e92605adea2067630b75380c6b5733dab7d87f9a84e"},  ]  flake8-bugbear = [ -    {file = "flake8-bugbear-22.10.25.tar.gz", hash = "sha256:89e51284eb929fbb7f23fbd428491e7427f7cdc8b45a77248daffe86a039d696"}, -    {file = "flake8_bugbear-22.10.25-py3-none-any.whl", hash = "sha256:584631b608dc0d7d3f9201046d5840a45502da4732d5e8df6c7ac1694a91cb9e"}, +    {file = "flake8-bugbear-22.10.27.tar.gz", hash = "sha256:a6708608965c9e0de5fff13904fed82e0ba21ac929fe4896459226a797e11cd5"}, +    {file = "flake8_bugbear-22.10.27-py3-none-any.whl", hash = "sha256:6ad0ab754507319060695e2f2be80e6d8977cfcea082293089a9226276bd825d"},  ]  flake8-docstrings = [      {file = "flake8-docstrings-1.6.0.tar.gz", hash = "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b"}, @@ -1156,8 +1168,8 @@ gitdb = [      {file = "gitdb-4.0.9.tar.gz", hash = "sha256:bac2fd45c0a1c9cf619e63a90d62bdc63892ef92387424b855792a6cabe789aa"},  ]  GitPython = [ -    {file = "GitPython-3.1.27-py3-none-any.whl", hash = "sha256:5b68b000463593e05ff2b261acff0ff0972df8ab1b70d3cdbd41b546c8b8fc3d"}, -    {file = "GitPython-3.1.27.tar.gz", hash = "sha256:1c885ce809e8ba2d88a29befeb385fcea06338d3640712b59ca623c220bb5704"}, +    {file = "GitPython-3.1.29-py3-none-any.whl", hash = "sha256:41eea0deec2deea139b459ac03656f0dd28fc4a3387240ec1d3c259a2c47850f"}, +    {file = "GitPython-3.1.29.tar.gz", hash = "sha256:cc36bfc4a3f913e66805a28e84703e419d9c264c1077e537b54f0e1af85dbefd"},  ]  gunicorn = [      {file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"}, @@ -1176,8 +1188,8 @@ httpx = [      {file = "httpx-0.23.0.tar.gz", hash = "sha256:f28eac771ec9eb4866d3fb4ab65abd42d38c424739e80c08d8d20570de60b0ef"},  ]  identify = [ -    {file = "identify-2.5.5-py2.py3-none-any.whl", hash = "sha256:ef78c0d96098a3b5fe7720be4a97e73f439af7cf088ebf47b620aeaa10fadf97"}, -    {file = "identify-2.5.5.tar.gz", hash = "sha256:322a5699daecf7c6fd60e68852f36f2ecbb6a36ff6e6e973e0d2bb6fca203ee6"}, +    {file = "identify-2.5.8-py2.py3-none-any.whl", hash = "sha256:48b7925fe122720088aeb7a6c34f17b27e706b72c61070f27fe3789094233440"}, +    {file = "identify-2.5.8.tar.gz", hash = "sha256:7a214a10313b9489a0d61467db2856ae8d0b8306fc923e03a9effa53d8aedc58"},  ]  idna = [      {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, @@ -1212,8 +1224,8 @@ nodeenv = [      {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"},  ]  pbr = [ -    {file = "pbr-5.10.0-py2.py3-none-any.whl", hash = "sha256:da3e18aac0a3c003e9eea1a81bd23e5a3a75d745670dcf736317b7d966887fdf"}, -    {file = "pbr-5.10.0.tar.gz", hash = "sha256:cfcc4ff8e698256fc17ea3ff796478b050852585aa5bae79ecd05b2ab7b39b9a"}, +    {file = "pbr-5.11.0-py2.py3-none-any.whl", hash = "sha256:db2317ff07c84c4c63648c9064a79fe9d9f5c7ce85a9099d4b6258b3db83225a"}, +    {file = "pbr-5.11.0.tar.gz", hash = "sha256:b97bc6695b2aff02144133c2e7399d5885223d42b7912ffaec2ca3898e673bfe"},  ]  pep8-naming = [      {file = "pep8-naming-0.13.2.tar.gz", hash = "sha256:93eef62f525fd12a6f8c98f4dcc17fa70baae2f37fa1f73bec00e3e44392fa48"}, @@ -1228,42 +1240,46 @@ pre-commit = [      {file = "pre_commit-2.20.0.tar.gz", hash = "sha256:a978dac7bc9ec0bcee55c18a277d553b0f419d259dadb4b9418ff2d00eb43959"},  ]  prometheus-client = [ -    {file = "prometheus_client-0.14.1-py3-none-any.whl", hash = "sha256:522fded625282822a89e2773452f42df14b5a8e84a86433e3f8a189c1d54dc01"}, -    {file = "prometheus_client-0.14.1.tar.gz", hash = "sha256:5459c427624961076277fdc6dc50540e2bacb98eebde99886e59ec55ed92093a"}, +    {file = "prometheus_client-0.15.0-py3-none-any.whl", hash = "sha256:db7c05cbd13a0f79975592d112320f2605a325969b270a94b71dcabc47b931d2"}, +    {file = "prometheus_client-0.15.0.tar.gz", hash = "sha256:be26aa452490cfcf6da953f9436e95a9f2b4d578ca80094b4458930e5f584ab1"},  ]  psutil = [ -    {file = "psutil-5.9.2-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:8f024fbb26c8daf5d70287bb3edfafa22283c255287cf523c5d81721e8e5d82c"}, -    {file = "psutil-5.9.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:b2f248ffc346f4f4f0d747ee1947963613216b06688be0be2e393986fe20dbbb"}, -    {file = "psutil-5.9.2-cp27-cp27m-win32.whl", hash = "sha256:b1928b9bf478d31fdffdb57101d18f9b70ed4e9b0e41af751851813547b2a9ab"}, -    {file = "psutil-5.9.2-cp27-cp27m-win_amd64.whl", hash = "sha256:404f4816c16a2fcc4eaa36d7eb49a66df2d083e829d3e39ee8759a411dbc9ecf"}, -    {file = "psutil-5.9.2-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:94e621c6a4ddb2573d4d30cba074f6d1aa0186645917df42c811c473dd22b339"}, -    {file = "psutil-5.9.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:256098b4f6ffea6441eb54ab3eb64db9ecef18f6a80d7ba91549195d55420f84"}, -    {file = "psutil-5.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:614337922702e9be37a39954d67fdb9e855981624d8011a9927b8f2d3c9625d9"}, -    {file = "psutil-5.9.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39ec06dc6c934fb53df10c1672e299145ce609ff0611b569e75a88f313634969"}, -    {file = "psutil-5.9.2-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3ac2c0375ef498e74b9b4ec56df3c88be43fe56cac465627572dbfb21c4be34"}, -    {file = "psutil-5.9.2-cp310-cp310-win32.whl", hash = "sha256:e4c4a7636ffc47b7141864f1c5e7d649f42c54e49da2dd3cceb1c5f5d29bfc85"}, -    {file = "psutil-5.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:f4cb67215c10d4657e320037109939b1c1d2fd70ca3d76301992f89fe2edb1f1"}, -    {file = "psutil-5.9.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:dc9bda7d5ced744622f157cc8d8bdd51735dafcecff807e928ff26bdb0ff097d"}, -    {file = "psutil-5.9.2-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75291912b945a7351d45df682f9644540d564d62115d4a20d45fa17dc2d48f8"}, -    {file = "psutil-5.9.2-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4018d5f9b6651f9896c7a7c2c9f4652e4eea53f10751c4e7d08a9093ab587ec"}, -    {file = "psutil-5.9.2-cp36-cp36m-win32.whl", hash = "sha256:f40ba362fefc11d6bea4403f070078d60053ed422255bd838cd86a40674364c9"}, -    {file = "psutil-5.9.2-cp36-cp36m-win_amd64.whl", hash = "sha256:9770c1d25aee91417eba7869139d629d6328a9422ce1cdd112bd56377ca98444"}, -    {file = "psutil-5.9.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:42638876b7f5ef43cef8dcf640d3401b27a51ee3fa137cb2aa2e72e188414c32"}, -    {file = "psutil-5.9.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91aa0dac0c64688667b4285fa29354acfb3e834e1fd98b535b9986c883c2ce1d"}, -    {file = "psutil-5.9.2-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fb54941aac044a61db9d8eb56fc5bee207db3bc58645d657249030e15ba3727"}, -    {file = "psutil-5.9.2-cp37-cp37m-win32.whl", hash = "sha256:7cbb795dcd8ed8fd238bc9e9f64ab188f3f4096d2e811b5a82da53d164b84c3f"}, -    {file = "psutil-5.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:5d39e3a2d5c40efa977c9a8dd4f679763c43c6c255b1340a56489955dbca767c"}, -    {file = "psutil-5.9.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fd331866628d18223a4265371fd255774affd86244fc307ef66eaf00de0633d5"}, -    {file = "psutil-5.9.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b315febaebae813326296872fdb4be92ad3ce10d1d742a6b0c49fb619481ed0b"}, -    {file = "psutil-5.9.2-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7929a516125f62399d6e8e026129c8835f6c5a3aab88c3fff1a05ee8feb840d"}, -    {file = "psutil-5.9.2-cp38-cp38-win32.whl", hash = "sha256:561dec454853846d1dd0247b44c2e66a0a0c490f937086930ec4b8f83bf44f06"}, -    {file = "psutil-5.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:67b33f27fc0427483b61563a16c90d9f3b547eeb7af0ef1b9fe024cdc9b3a6ea"}, -    {file = "psutil-5.9.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b3591616fa07b15050b2f87e1cdefd06a554382e72866fcc0ab2be9d116486c8"}, -    {file = "psutil-5.9.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14b29f581b5edab1f133563272a6011925401804d52d603c5c606936b49c8b97"}, -    {file = "psutil-5.9.2-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4642fd93785a29353d6917a23e2ac6177308ef5e8be5cc17008d885cb9f70f12"}, -    {file = "psutil-5.9.2-cp39-cp39-win32.whl", hash = "sha256:ed29ea0b9a372c5188cdb2ad39f937900a10fb5478dc077283bf86eeac678ef1"}, -    {file = "psutil-5.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:68b35cbff92d1f7103d8f1db77c977e72f49fcefae3d3d2b91c76b0e7aef48b8"}, -    {file = "psutil-5.9.2.tar.gz", hash = "sha256:feb861a10b6c3bb00701063b37e4afc754f8217f0f09c42280586bd6ac712b5c"}, +    {file = "psutil-5.9.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b4a247cd3feaae39bb6085fcebf35b3b8ecd9b022db796d89c8f05067ca28e71"}, +    {file = "psutil-5.9.3-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:5fa88e3d5d0b480602553d362c4b33a63e0c40bfea7312a7bf78799e01e0810b"}, +    {file = "psutil-5.9.3-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:767ef4fa33acda16703725c0473a91e1832d296c37c63896c7153ba81698f1ab"}, +    {file = "psutil-5.9.3-cp27-cp27m-win32.whl", hash = "sha256:9a4af6ed1094f867834f5f07acd1250605a0874169a5fcadbcec864aec2496a6"}, +    {file = "psutil-5.9.3-cp27-cp27m-win_amd64.whl", hash = "sha256:fa5e32c7d9b60b2528108ade2929b115167fe98d59f89555574715054f50fa31"}, +    {file = "psutil-5.9.3-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:fe79b4ad4836e3da6c4650cb85a663b3a51aef22e1a829c384e18fae87e5e727"}, +    {file = "psutil-5.9.3-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:db8e62016add2235cc87fb7ea000ede9e4ca0aa1f221b40cef049d02d5d2593d"}, +    {file = "psutil-5.9.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:941a6c2c591da455d760121b44097781bc970be40e0e43081b9139da485ad5b7"}, +    {file = "psutil-5.9.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:71b1206e7909792d16933a0d2c1c7f04ae196186c51ba8567abae1d041f06dcb"}, +    {file = "psutil-5.9.3-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f57d63a2b5beaf797b87024d018772439f9d3103a395627b77d17a8d72009543"}, +    {file = "psutil-5.9.3-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7507f6c7b0262d3e7b0eeda15045bf5881f4ada70473b87bc7b7c93b992a7d7"}, +    {file = "psutil-5.9.3-cp310-cp310-win32.whl", hash = "sha256:1b540599481c73408f6b392cdffef5b01e8ff7a2ac8caae0a91b8222e88e8f1e"}, +    {file = "psutil-5.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:547ebb02031fdada635452250ff39942db8310b5c4a8102dfe9384ee5791e650"}, +    {file = "psutil-5.9.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d8c3cc6bb76492133474e130a12351a325336c01c96a24aae731abf5a47fe088"}, +    {file = "psutil-5.9.3-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07d880053c6461c9b89cd5d4808f3b8336665fa3acdefd6777662c5ed73a851a"}, +    {file = "psutil-5.9.3-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e8b50241dd3c2ed498507f87a6602825073c07f3b7e9560c58411c14fe1e1c9"}, +    {file = "psutil-5.9.3-cp36-cp36m-win32.whl", hash = "sha256:828c9dc9478b34ab96be75c81942d8df0c2bb49edbb481f597314d92b6441d89"}, +    {file = "psutil-5.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:ed15edb14f52925869250b1375f0ff58ca5c4fa8adefe4883cfb0737d32f5c02"}, +    {file = "psutil-5.9.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d266cd05bd4a95ca1c2b9b5aac50d249cf7c94a542f47e0b22928ddf8b80d1ef"}, +    {file = "psutil-5.9.3-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e4939ff75149b67aef77980409f156f0082fa36accc475d45c705bb00c6c16a"}, +    {file = "psutil-5.9.3-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68fa227c32240c52982cb931801c5707a7f96dd8927f9102d6c7771ea1ff5698"}, +    {file = "psutil-5.9.3-cp37-cp37m-win32.whl", hash = "sha256:beb57d8a1ca0ae0eb3d08ccaceb77e1a6d93606f0e1754f0d60a6ebd5c288837"}, +    {file = "psutil-5.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:12500d761ac091f2426567f19f95fd3f15a197d96befb44a5c1e3cbe6db5752c"}, +    {file = "psutil-5.9.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba38cf9984d5462b506e239cf4bc24e84ead4b1d71a3be35e66dad0d13ded7c1"}, +    {file = "psutil-5.9.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:46907fa62acaac364fff0b8a9da7b360265d217e4fdeaca0a2397a6883dffba2"}, +    {file = "psutil-5.9.3-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a04a1836894c8279e5e0a0127c0db8e198ca133d28be8a2a72b4db16f6cf99c1"}, +    {file = "psutil-5.9.3-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a4e07611997acf178ad13b842377e3d8e9d0a5bac43ece9bfc22a96735d9a4f"}, +    {file = "psutil-5.9.3-cp38-cp38-win32.whl", hash = "sha256:6ced1ad823ecfa7d3ce26fe8aa4996e2e53fb49b7fed8ad81c80958501ec0619"}, +    {file = "psutil-5.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:35feafe232d1aaf35d51bd42790cbccb882456f9f18cdc411532902370d660df"}, +    {file = "psutil-5.9.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:538fcf6ae856b5e12d13d7da25ad67f02113c96f5989e6ad44422cb5994ca7fc"}, +    {file = "psutil-5.9.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a3d81165b8474087bb90ec4f333a638ccfd1d69d34a9b4a1a7eaac06648f9fbe"}, +    {file = "psutil-5.9.3-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a7826e68b0cf4ce2c1ee385d64eab7d70e3133171376cac53d7c1790357ec8f"}, +    {file = "psutil-5.9.3-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ec296f565191f89c48f33d9544d8d82b0d2af7dd7d2d4e6319f27a818f8d1cc"}, +    {file = "psutil-5.9.3-cp39-cp39-win32.whl", hash = "sha256:9ec95df684583b5596c82bb380c53a603bb051cf019d5c849c47e117c5064395"}, +    {file = "psutil-5.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:4bd4854f0c83aa84a5a40d3b5d0eb1f3c128f4146371e03baed4589fe4f3c931"}, +    {file = "psutil-5.9.3.tar.gz", hash = "sha256:7ccfcdfea4fc4b0a02ca2c31de7fcd186beb9cff8207800e14ab66f79c773af6"},  ]  psycopg2-binary = [      {file = "psycopg2-binary-2.9.5.tar.gz", hash = "sha256:33e632d0885b95a8b97165899006c40e9ecdc634a529dca7b991eb7de4ece41c"}, @@ -1289,6 +1305,8 @@ psycopg2-binary = [      {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e67b3c26e9b6d37b370c83aa790bbc121775c57bfb096c2e77eacca25fd0233b"},      {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5fc447058d083b8c6ac076fc26b446d44f0145308465d745fba93a28c14c9e32"},      {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d892bfa1d023c3781a3cab8dd5af76b626c483484d782e8bd047c180db590e4c"}, +    {file = "psycopg2_binary-2.9.5-cp311-cp311-win32.whl", hash = "sha256:2abccab84d057723d2ca8f99ff7b619285d40da6814d50366f61f0fc385c3903"}, +    {file = "psycopg2_binary-2.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:bef7e3f9dc6f0c13afdd671008534be5744e0e682fb851584c8c3a025ec09720"},      {file = "psycopg2_binary-2.9.5-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:6e63814ec71db9bdb42905c925639f319c80e7909fb76c3b84edc79dadef8d60"},      {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:212757ffcecb3e1a5338d4e6761bf9c04f750e7d027117e74aa3cd8a75bb6fbd"},      {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f8a9bcab7b6db2e3dbf65b214dfc795b4c6b3bb3af922901b6a67f7cb47d5f8"}, @@ -1356,10 +1374,14 @@ pyflakes = [      {file = "pyflakes-2.5.0-py2.py3-none-any.whl", hash = "sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2"},      {file = "pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"},  ] -pyjwt = [ +PyJWT = [      {file = "PyJWT-2.6.0-py3-none-any.whl", hash = "sha256:d83c3d892a77bbb74d3e1a2cfa90afaadb60945205d1095d9221f04466f64c14"},      {file = "PyJWT-2.6.0.tar.gz", hash = "sha256:69285c7e31fc44f68a1feb309e948e0df53259d579295e6cfe2b1792329f05fd"},  ] +pymdown-extensions = [ +    {file = "pymdown_extensions-9.8-py3-none-any.whl", hash = "sha256:8e62688a8b1128acd42fa823f3d429d22f4284b5e6dd4d3cd56721559a5a211b"}, +    {file = "pymdown_extensions-9.8.tar.gz", hash = "sha256:1bd4a173095ef8c433b831af1f3cb13c10883be0c100ae613560668e594651f7"}, +]  python-dotenv = [      {file = "python-dotenv-0.21.0.tar.gz", hash = "sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045"},      {file = "python_dotenv-0.21.0-py3-none-any.whl", hash = "sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5"}, @@ -1369,8 +1391,8 @@ python-frontmatter = [      {file = "python_frontmatter-1.0.0-py3-none-any.whl", hash = "sha256:766ae75f1b301ffc5fe3494339147e0fd80bc3deff3d7590a93991978b579b08"},  ]  pytz = [ -    {file = "pytz-2022.2.1-py2.py3-none-any.whl", hash = "sha256:220f481bdafa09c3955dfbdddb7b57780e9a94f5127e35456a48589b9e0c0197"}, -    {file = "pytz-2022.2.1.tar.gz", hash = "sha256:cea221417204f2d1a2aa03ddae3e867921971d0d76f14d87abb4414415bbdcf5"}, +    {file = "pytz-2022.5-py2.py3-none-any.whl", hash = "sha256:335ab46900b1465e714b4fda4963d87363264eb662aab5e65da039c25f1f5b22"}, +    {file = "pytz-2022.5.tar.gz", hash = "sha256:c4d88f472f54d615e9cd582a5004d1e5f624854a6a27a6211591c251f22a6914"},  ]  PyYAML = [      {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, @@ -1423,12 +1445,12 @@ rfc3986 = [      {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},  ]  sentry-sdk = [ -    {file = "sentry-sdk-1.10.1.tar.gz", hash = "sha256:105faf7bd7b7fa25653404619ee261527266b14103fe1389e0ce077bd23a9691"}, -    {file = "sentry_sdk-1.10.1-py2.py3-none-any.whl", hash = "sha256:06c0fa9ccfdc80d7e3b5d2021978d6eb9351fa49db9b5847cf4d1f2a473414ad"}, +    {file = "sentry-sdk-1.11.0.tar.gz", hash = "sha256:e7b78a1ddf97a5f715a50ab8c3f7a93f78b114c67307785ee828ef67a5d6f117"}, +    {file = "sentry_sdk-1.11.0-py2.py3-none-any.whl", hash = "sha256:f467e6c7fac23d4d42bc83eb049c400f756cd2d65ab44f0cc1165d0c7c3d40bc"},  ]  setuptools = [ -    {file = "setuptools-65.4.1-py3-none-any.whl", hash = "sha256:1b6bdc6161661409c5f21508763dc63ab20a9ac2f8ba20029aaaa7fdb9118012"}, -    {file = "setuptools-65.4.1.tar.gz", hash = "sha256:3050e338e5871e70c72983072fe34f6032ae1cdeeeb67338199c2f74e083a80e"}, +    {file = "setuptools-65.5.0-py3-none-any.whl", hash = "sha256:f62ea9da9ed6289bfe868cd6845968a2c854d1427f8548d52cae02a42b4f0356"}, +    {file = "setuptools-65.5.0.tar.gz", hash = "sha256:512e5536220e38146176efb833d4a62aa726b7bbff82cfbc8ba9eaa3996e0b17"},  ]  six = [      {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, @@ -1451,8 +1473,8 @@ sqlparse = [      {file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"},  ]  stevedore = [ -    {file = "stevedore-4.0.0-py3-none-any.whl", hash = "sha256:87e4d27fe96d0d7e4fc24f0cbe3463baae4ec51e81d95fbe60d2474636e0c7d8"}, -    {file = "stevedore-4.0.0.tar.gz", hash = "sha256:f82cc99a1ff552310d19c379827c2c64dd9f85a38bcd5559db2470161867b786"}, +    {file = "stevedore-4.1.0-py3-none-any.whl", hash = "sha256:3b1cbd592a87315f000d05164941ee5e164899f8fc0ce9a00bb0f321f40ef93e"}, +    {file = "stevedore-4.1.0.tar.gz", hash = "sha256:02518a8f0d6d29be8a445b7f2ac63753ff29e8f2a2faa01777568d5500d777a6"},  ]  taskipy = [      {file = "taskipy-1.10.3-py3-none-any.whl", hash = "sha256:4c0070ca53868d97989f7ab5c6f237525d52ee184f9b967576e8fe427ed9d0b8"}, @@ -1467,16 +1489,16 @@ tomli = [      {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},  ]  tzdata = [ -    {file = "tzdata-2022.4-py2.py3-none-any.whl", hash = "sha256:74da81ecf2b3887c94e53fc1d466d4362aaf8b26fc87cda18f22004544694583"}, -    {file = "tzdata-2022.4.tar.gz", hash = "sha256:ada9133fbd561e6ec3d1674d3fba50251636e918aa97bd59d63735bef5a513bb"}, +    {file = "tzdata-2022.5-py2.py3-none-any.whl", hash = "sha256:323161b22b7802fdc78f20ca5f6073639c64f1a7227c40cd3e19fd1d0ce6650a"}, +    {file = "tzdata-2022.5.tar.gz", hash = "sha256:e15b2b3005e2546108af42a0eb4ccab4d9e225e2dfbf4f77aad50c70a4b1f3ab"},  ]  urllib3 = [      {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"},      {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"},  ]  virtualenv = [ -    {file = "virtualenv-20.16.5-py3-none-any.whl", hash = "sha256:d07dfc5df5e4e0dbc92862350ad87a36ed505b978f6c39609dc489eadd5b0d27"}, -    {file = "virtualenv-20.16.5.tar.gz", hash = "sha256:227ea1b9994fdc5ea31977ba3383ef296d7472ea85be9d6732e42a91c04e80da"}, +    {file = "virtualenv-20.16.6-py3-none-any.whl", hash = "sha256:186ca84254abcbde98180fd17092f9628c5fe742273c02724972a1d8a2035108"}, +    {file = "virtualenv-20.16.6.tar.gz", hash = "sha256:530b850b523c6449406dfba859d6345e48ef19b8439606c5d74d7d3c9e14d76e"},  ]  whitenoise = [      {file = "whitenoise-6.2.0-py3-none-any.whl", hash = "sha256:8e9c600a5c18bd17655ef668ad55b5edf6c24ce9bdca5bf607649ca4b1e8e2c2"}, diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py index 986c64e1..44c571c3 100644 --- a/pydis_site/apps/api/github_utils.py +++ b/pydis_site/apps/api/github_utils.py @@ -11,8 +11,6 @@ from pydis_site import settings  MAX_RUN_TIME = datetime.timedelta(minutes=10)  """The maximum time allowed before an action is declared timed out.""" -ISO_FORMAT_STRING = "%Y-%m-%dT%H:%M:%SZ" -"""The datetime string format GitHub uses."""  class ArtifactProcessingError(Exception): @@ -147,7 +145,7 @@ def authorize(owner: str, repo: str) -> httpx.Client:  def check_run_status(run: WorkflowRun) -> str:      """Check if the provided run has been completed, otherwise raise an exception.""" -    created_at = datetime.datetime.strptime(run.created_at, ISO_FORMAT_STRING) +    created_at = datetime.datetime.strptime(run.created_at, settings.GITHUB_TIMESTAMP_FORMAT)      run_time = datetime.datetime.utcnow() - created_at      if run.status != "completed": diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py index 2eaf48d9..95bafec0 100644 --- a/pydis_site/apps/api/tests/test_github_utils.py +++ b/pydis_site/apps/api/tests/test_github_utils.py @@ -11,6 +11,7 @@ import rest_framework.response  import rest_framework.test  from django.urls import reverse +from pydis_site import settings  from .. import github_utils @@ -49,7 +50,7 @@ class CheckRunTests(unittest.TestCase):          "head_sha": "sha",          "status": "completed",          "conclusion": "success", -        "created_at": datetime.datetime.utcnow().strftime(github_utils.ISO_FORMAT_STRING), +        "created_at": datetime.datetime.utcnow().strftime(settings.GITHUB_TIMESTAMP_FORMAT),          "artifacts_url": "url",      } @@ -74,7 +75,7 @@ class CheckRunTests(unittest.TestCase):          # to guarantee the right conclusion          kwargs["created_at"] = (              datetime.datetime.utcnow() - github_utils.MAX_RUN_TIME - datetime.timedelta(minutes=10) -        ).strftime(github_utils.ISO_FORMAT_STRING) +        ).strftime(settings.GITHUB_TIMESTAMP_FORMAT)          with self.assertRaises(github_utils.RunTimeoutError):              github_utils.check_run_status(github_utils.WorkflowRun(**kwargs)) @@ -178,7 +179,7 @@ class ArtifactFetcherTests(unittest.TestCase):                  run = github_utils.WorkflowRun(                      name="action_name",                      head_sha="action_sha", -                    created_at=datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING), +                    created_at=datetime.datetime.now().strftime(settings.GITHUB_TIMESTAMP_FORMAT),                      status="completed",                      conclusion="success",                      artifacts_url="artifacts_url" diff --git a/pydis_site/apps/content/migrations/0001_add_tags.py b/pydis_site/apps/content/migrations/0001_add_tags.py new file mode 100644 index 00000000..2c31e4c1 --- /dev/null +++ b/pydis_site/apps/content/migrations/0001_add_tags.py @@ -0,0 +1,35 @@ +# Generated by Django 4.0.6 on 2022-08-23 09:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    initial = True + +    dependencies = [ +    ] + +    operations = [ +        migrations.CreateModel( +            name='Commit', +            fields=[ +                ('sha', models.CharField(help_text='The SHA hash of this commit.', max_length=40, primary_key=True, serialize=False)), +                ('message', models.TextField(help_text='The commit message.')), +                ('date', models.DateTimeField(help_text='The date and time the commit was created.')), +                ('authors', models.TextField(help_text='The person(s) who created the commit. This is a serialized JSON object. Refer to the GitHub documentation on the commit endpoint (schema/commit.author & schema/commit.committer) for more info. https://docs.github.com/en/rest/commits/commits#get-a-commit')), +            ], +        ), +        migrations.CreateModel( +            name='Tag', +            fields=[ +                ('last_updated', models.DateTimeField(auto_now=True, help_text='The date and time this data was last fetched.')), +                ('sha', models.CharField(help_text="The tag's hash, as calculated by GitHub.", max_length=40)), +                ('name', models.CharField(help_text="The tag's name.", max_length=50, primary_key=True, serialize=False)), +                ('group', models.CharField(help_text='The group the tag belongs to.', max_length=50, null=True)), +                ('body', models.TextField(help_text='The content of the tag.')), +                ('last_commit', models.ForeignKey(help_text='The commit this file was last touched in.', null=True, on_delete=django.db.models.deletion.CASCADE, to='content.commit')), +            ], +        ), +    ] diff --git a/pydis_site/apps/content/migrations/__init__.py b/pydis_site/apps/content/migrations/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/content/migrations/__init__.py diff --git a/pydis_site/apps/content/models/__init__.py b/pydis_site/apps/content/models/__init__.py new file mode 100644 index 00000000..60007e27 --- /dev/null +++ b/pydis_site/apps/content/models/__init__.py @@ -0,0 +1,3 @@ +from .tag import Commit, Tag + +__all__ = ["Commit", "Tag"] diff --git a/pydis_site/apps/content/models/tag.py b/pydis_site/apps/content/models/tag.py new file mode 100644 index 00000000..1a20d775 --- /dev/null +++ b/pydis_site/apps/content/models/tag.py @@ -0,0 +1,80 @@ +import collections.abc +import json + +from django.db import models + + +class Commit(models.Model): +    """A git commit from the Python Discord Bot project.""" + +    URL_BASE = "https://github.com/python-discord/bot/commit/" + +    sha = models.CharField( +        help_text="The SHA hash of this commit.", +        primary_key=True, +        max_length=40, +    ) +    message = models.TextField(help_text="The commit message.") +    date = models.DateTimeField(help_text="The date and time the commit was created.") +    authors = models.TextField(help_text=( +        "The person(s) who created the commit. This is a serialized JSON object. " +        "Refer to the GitHub documentation on the commit endpoint " +        "(schema/commit.author & schema/commit.committer) for more info. " +        "https://docs.github.com/en/rest/commits/commits#get-a-commit" +    )) + +    @property +    def url(self) -> str: +        """The URL to the commit on GitHub.""" +        return self.URL_BASE + self.sha + +    def lines(self) -> collections.abc.Iterable[str]: +        """Return each line in the commit message.""" +        for line in self.message.split("\n"): +            yield line + +    def format_authors(self) -> collections.abc.Iterable[str]: +        """Return a nice representation of the author(s)' name and email.""" +        for author in json.loads(self.authors): +            yield f"{author['name']} <{author['email']}>" + + +class Tag(models.Model): +    """A tag from the python-discord bot repository.""" + +    URL_BASE = "https://github.com/python-discord/bot/tree/main/bot/resources/tags" + +    last_updated = models.DateTimeField( +        help_text="The date and time this data was last fetched.", +        auto_now=True, +    ) +    sha = models.CharField( +        help_text="The tag's hash, as calculated by GitHub.", +        max_length=40, +    ) +    last_commit = models.ForeignKey( +        Commit, +        help_text="The commit this file was last touched in.", +        null=True, +        on_delete=models.CASCADE, +    ) +    name = models.CharField( +        help_text="The tag's name.", +        primary_key=True, +        max_length=50, +    ) +    group = models.CharField( +        help_text="The group the tag belongs to.", +        null=True, +        max_length=50, +    ) +    body = models.TextField(help_text="The content of the tag.") + +    @property +    def url(self) -> str: +        """Get the URL of the tag on GitHub.""" +        url = Tag.URL_BASE +        if self.group: +            url += f"/{self.group}" +        url += f"/{self.name}.md" +        return url diff --git a/pydis_site/apps/content/resources/guides/python-guides/fix-ssl-certificate.md b/pydis_site/apps/content/resources/guides/python-guides/fix-ssl-certificate.md new file mode 100644 index 00000000..096e3a90 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/fix-ssl-certificate.md @@ -0,0 +1,23 @@ +--- +title: Fixing an SSL Certificate Verification Error +description: A guide on fixing verification of an SSL certificate. +--- + +We're fixing the error Python specifies as [ssl.SSLCertVerificationError](https://docs.python.org/3/library/ssl.html#ssl.SSLCertVerificationError). + +# How to fix SSL Certificate issue on Windows + +Firstly, try updating your OS, wouldn't hurt to try. + +Now, if you're still having an issue, you would need to download the certificate for the SSL. + +The SSL Certificate, Sectigo (cert vendor) provides a download link of an [SSL certificate](https://crt.sh/?id=2835394). You should find it in the bottom left corner, shown below: + +A picture where to find the certificate in the website is: + + +You have to setup the certificate yourself. To do that you can just click on it, or if that doesn't work, refer to [this link](https://portal.threatpulse.com/docs/sol/Solutions/ManagePolicy/SSL/ssl_chrome_cert_ta.htm) + +# How to fix SSL Certificate issue on Mac + +Navigate to your `Applications/Python 3.x/` folder and double-click the `Install Certificates.command` to fix this. diff --git a/pydis_site/apps/content/resources/tags/_info.yml b/pydis_site/apps/content/resources/tags/_info.yml new file mode 100644 index 00000000..054125ec --- /dev/null +++ b/pydis_site/apps/content/resources/tags/_info.yml @@ -0,0 +1,3 @@ +title: Tags +description: Useful snippets that are often used in the server. +icon: fas fa-tags diff --git a/pydis_site/apps/content/tests/test_utils.py b/pydis_site/apps/content/tests/test_utils.py index be5ea897..462818b5 100644 --- a/pydis_site/apps/content/tests/test_utils.py +++ b/pydis_site/apps/content/tests/test_utils.py @@ -1,12 +1,34 @@ +import datetime +import json +import tarfile +import tempfile +import textwrap  from pathlib import Path +from unittest import mock +import httpx +import markdown  from django.http import Http404 +from django.test import TestCase -from pydis_site.apps.content import utils +from pydis_site import settings +from pydis_site.apps.content import models, utils  from pydis_site.apps.content.tests.helpers import (      BASE_PATH, MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA  ) +_time = datetime.datetime(2022, 10, 10, 10, 10, 10, tzinfo=datetime.timezone.utc) +_time_str = _time.strftime(settings.GITHUB_TIMESTAMP_FORMAT) +TEST_COMMIT_KWARGS = { +    "sha": "123", +    "message": "Hello world\n\nThis is a commit message", +    "date": _time, +    "authors": json.dumps([ +        {"name": "Author 1", "email": "[email protected]", "date": _time_str}, +        {"name": "Author 2", "email": "[email protected]", "date": _time_str}, +    ]), +} +  class GetCategoryTests(MockPagesTestCase):      """Tests for the get_category function.""" @@ -96,3 +118,268 @@ class GetPageTests(MockPagesTestCase):      def test_get_nonexistent_page_returns_404(self):          with self.assertRaises(Http404):              utils.get_page(Path(BASE_PATH, "invalid")) + + +class TagUtilsTests(TestCase): +    """Tests for the tag-related utilities.""" + +    def setUp(self) -> None: +        super().setUp() +        self.commit = models.Commit.objects.create(**TEST_COMMIT_KWARGS) + +    @mock.patch.object(utils, "fetch_tags") +    def test_static_fetch(self, fetch_mock: mock.Mock): +        """Test that the static fetch function is only called at most once during static builds.""" +        tags = [models.Tag(name="Name", body="body")] +        fetch_mock.return_value = tags +        result = utils.get_tags_static() +        second_result = utils.get_tags_static() + +        fetch_mock.assert_called_once() +        self.assertEqual(tags, result) +        self.assertEqual(tags, second_result) + +    @mock.patch("httpx.Client.get") +    def test_mocked_fetch(self, get_mock: mock.Mock): +        """Test that proper data is returned from fetch, but with a mocked API response.""" +        fake_request = httpx.Request("GET", "https://google.com") + +        # Metadata requests +        returns = [httpx.Response( +            request=fake_request, +            status_code=200, +            json=[ +                {"type": "file", "name": "first_tag.md", "sha": "123"}, +                {"type": "file", "name": "second_tag.md", "sha": "456"}, +                {"type": "dir", "name": "some_group", "sha": "789", "url": "/some_group"}, +            ] +        ), httpx.Response( +            request=fake_request, +            status_code=200, +            json=[{"type": "file", "name": "grouped_tag.md", "sha": "789123"}] +        )] + +        # Main content request +        bodies = ( +            "This is the first tag!", +            textwrap.dedent(""" +                --- +                frontmatter: empty +                --- +                This tag has frontmatter! +            """), +            "This is a grouped tag!", +        ) + +        # Generate a tar archive with a few tags +        with tempfile.TemporaryDirectory() as tar_folder: +            tar_folder = Path(tar_folder) +            with tempfile.TemporaryDirectory() as folder: +                folder = Path(folder) +                (folder / "ignored_file.md").write_text("This is an ignored file.") +                tags_folder = folder / "bot/resources/tags" +                tags_folder.mkdir(parents=True) + +                (tags_folder / "first_tag.md").write_text(bodies[0]) +                (tags_folder / "second_tag.md").write_text(bodies[1]) + +                group_folder = tags_folder / "some_group" +                group_folder.mkdir() +                (group_folder / "grouped_tag.md").write_text(bodies[2]) + +                with tarfile.open(tar_folder / "temp.tar", "w") as file: +                    file.add(folder, recursive=True) + +                body = (tar_folder / "temp.tar").read_bytes() + +        returns.append(httpx.Response( +            status_code=200, +            content=body, +            request=fake_request, +        )) + +        get_mock.side_effect = returns +        result = utils.fetch_tags() + +        def sort(_tag: models.Tag) -> str: +            return _tag.name + +        self.assertEqual(sorted([ +            models.Tag(name="first_tag", body=bodies[0], sha="123"), +            models.Tag(name="second_tag", body=bodies[1], sha="245"), +            models.Tag(name="grouped_tag", body=bodies[2], group=group_folder.name, sha="789123"), +        ], key=sort), sorted(result, key=sort)) + +    def test_get_real_tag(self): +        """Test that a single tag is returned if it exists.""" +        tag = models.Tag.objects.create(name="real-tag", last_commit=self.commit) +        result = utils.get_tag("real-tag") + +        self.assertEqual(tag, result) + +    def test_get_grouped_tag(self): +        """Test fetching a tag from a group.""" +        tag = models.Tag.objects.create( +            name="real-tag", group="real-group", last_commit=self.commit +        ) +        result = utils.get_tag("real-group/real-tag") + +        self.assertEqual(tag, result) + +    def test_get_group(self): +        """Test fetching a group of tags.""" +        included = [ +            models.Tag.objects.create(name="tag-1", group="real-group"), +            models.Tag.objects.create(name="tag-2", group="real-group"), +            models.Tag.objects.create(name="tag-3", group="real-group"), +        ] + +        models.Tag.objects.create(name="not-included-1") +        models.Tag.objects.create(name="not-included-2", group="other-group") + +        result = utils.get_tag("real-group") +        self.assertListEqual(included, result) + +    def test_get_tag_404(self): +        """Test that an error is raised when we fetch a non-existing tag.""" +        models.Tag.objects.create(name="real-tag") +        with self.assertRaises(models.Tag.DoesNotExist): +            utils.get_tag("fake") + +    @mock.patch.object(utils, "get_tag_category") +    def test_category_pages(self, get_mock: mock.Mock): +        """Test that the category pages function calls the correct method for tags.""" +        tag = models.Tag.objects.create(name="tag") +        get_mock.return_value = tag +        result = utils.get_category_pages(settings.CONTENT_PAGES_PATH / "tags") +        self.assertEqual(tag, result) +        get_mock.assert_called_once_with(collapse_groups=True) + +    def test_get_category_root(self): +        """Test that all tags are returned and formatted properly for the tag root page.""" +        body = "normal body" +        base = {"description": markdown.markdown(body), "icon": "fas fa-tag"} + +        models.Tag.objects.create(name="tag-1", body=body), +        models.Tag.objects.create(name="tag-2", body=body), +        models.Tag.objects.create(name="tag-3", body=body), + +        models.Tag.objects.create(name="tag-4", body=body, group="tag-group") +        models.Tag.objects.create(name="tag-5", body=body, group="tag-group") + +        result = utils.get_tag_category(collapse_groups=True) + +        self.assertDictEqual({ +            "tag-1": {**base, "title": "tag-1"}, +            "tag-2": {**base, "title": "tag-2"}, +            "tag-3": {**base, "title": "tag-3"}, +            "tag-group": { +                "title": "tag-group", +                "description": "Contains the following tags: tag-4, tag-5", +                "icon": "fas fa-tags" +            } +        }, result) + +    def test_get_category_group(self): +        """Test the function for a group root page.""" +        body = "normal body" +        base = {"description": markdown.markdown(body), "icon": "fas fa-tag"} + +        included = [ +            models.Tag.objects.create(name="tag-1", body=body, group="group"), +            models.Tag.objects.create(name="tag-2", body=body, group="group"), +        ] +        models.Tag.objects.create(name="not-included", body=body) + +        result = utils.get_tag_category(included, collapse_groups=False) +        self.assertDictEqual({ +            "tag-1": {**base, "title": "tag-1"}, +            "tag-2": {**base, "title": "tag-2"}, +        }, result) + +    def test_tag_url(self): +        """Test that tag URLs are generated correctly.""" +        cases = [ +            ({"name": "tag"}, f"{models.Tag.URL_BASE}/tag.md"), +            ({"name": "grouped", "group": "abc"}, f"{models.Tag.URL_BASE}/abc/grouped.md"), +        ] + +        for options, url in cases: +            tag = models.Tag(**options) +            with self.subTest(tag=tag): +                self.assertEqual(url, tag.url) + +    @mock.patch("httpx.Client.get") +    def test_get_tag_commit(self, get_mock: mock.Mock): +        """Test the get commit function with a normal tag.""" +        tag = models.Tag.objects.create(name="example") + +        authors = json.loads(self.commit.authors) + +        get_mock.return_value = httpx.Response( +            request=httpx.Request("GET", "https://google.com"), +            status_code=200, +            json=[{ +                "sha": self.commit.sha, +                "commit": { +                    "message": self.commit.message, +                    "author": authors[0], +                    "committer": authors[1], +                } +            }] +        ) + +        result = utils.get_tag(tag.name) +        self.assertEqual(tag, result) + +        get_mock.assert_called_once() +        call_params = get_mock.call_args[1]["params"] + +        self.assertEqual({"path": "/bot/resources/tags/example.md"}, call_params) +        self.assertEqual(self.commit, models.Tag.objects.get(name=tag.name).last_commit) + +    @mock.patch("httpx.Client.get") +    def test_get_group_tag_commit(self, get_mock: mock.Mock): +        """Test the get commit function with a group tag.""" +        tag = models.Tag.objects.create(name="example", group="group-name") + +        authors = json.loads(self.commit.authors) +        authors.pop() +        self.commit.authors = json.dumps(authors) +        self.commit.save() + +        get_mock.return_value = httpx.Response( +            request=httpx.Request("GET", "https://google.com"), +            status_code=200, +            json=[{ +                "sha": self.commit.sha, +                "commit": { +                    "message": self.commit.message, +                    "author": authors[0], +                    "committer": authors[0], +                } +            }] +        ) + +        utils.set_tag_commit(tag) + +        get_mock.assert_called_once() +        call_params = get_mock.call_args[1]["params"] + +        self.assertEqual({"path": "/bot/resources/tags/group-name/example.md"}, call_params) +        self.assertEqual(self.commit, models.Tag.objects.get(name=tag.name).last_commit) + +    @mock.patch.object(utils, "set_tag_commit") +    def test_exiting_commit(self, set_commit_mock: mock.Mock): +        """Test that a commit is saved when the data has not changed.""" +        tag = models.Tag.objects.create(name="tag-name", body="old body", last_commit=self.commit) + +        # This is only applied to the object, not to the database +        tag.last_commit = None + +        utils.record_tags([tag]) +        self.assertEqual(self.commit, tag.last_commit) + +        result = utils.get_tag("tag-name") +        self.assertEqual(tag, result) +        set_commit_mock.assert_not_called() diff --git a/pydis_site/apps/content/tests/test_views.py b/pydis_site/apps/content/tests/test_views.py index a09d22d8..3ef9bcc4 100644 --- a/pydis_site/apps/content/tests/test_views.py +++ b/pydis_site/apps/content/tests/test_views.py @@ -1,12 +1,18 @@ +import textwrap  from pathlib import Path  from unittest import TestCase +import django.test +import markdown  from django.http import Http404  from django.test import RequestFactory, SimpleTestCase, override_settings +from django.urls import reverse +from pydis_site.apps.content.models import Commit, Tag  from pydis_site.apps.content.tests.helpers import (      BASE_PATH, MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA  ) +from pydis_site.apps.content.tests.test_utils import TEST_COMMIT_KWARGS  from pydis_site.apps.content.views import PageOrCategoryView @@ -180,3 +186,217 @@ class PageOrCategoryViewTests(MockPagesTestCase, SimpleTestCase, TestCase):                  {"name": PARSED_CATEGORY_INFO["title"], "path": Path("category/subcategory")},              ]          ) + + +class TagViewTests(django.test.TestCase): +    """Tests for the TagView class.""" + +    def setUp(self): +        """Set test helpers, then set up fake filesystem.""" +        super().setUp() +        self.commit = Commit.objects.create(**TEST_COMMIT_KWARGS) + +    def test_routing(self): +        """Test that the correct template is returned for each route.""" +        Tag.objects.create(name="example", last_commit=self.commit) +        Tag.objects.create(name="grouped-tag", group="group-name", last_commit=self.commit) + +        cases = [ +            ("/pages/tags/example/", "content/tag.html"), +            ("/pages/tags/group-name/", "content/listing.html"), +            ("/pages/tags/group-name/grouped-tag/", "content/tag.html"), +        ] + +        for url, template in cases: +            with self.subTest(url=url): +                response = self.client.get(url) +                self.assertEqual(200, response.status_code) +                self.assertTemplateUsed(response, template) + +    def test_valid_tag_returns_200(self): +        """Test that a page is returned for a valid tag.""" +        Tag.objects.create(name="example", body="This is the tag body.", last_commit=self.commit) +        response = self.client.get("/pages/tags/example/") +        self.assertEqual(200, response.status_code) +        self.assertIn("This is the tag body", response.content.decode("utf-8")) +        self.assertTemplateUsed(response, "content/tag.html") + +    def test_invalid_tag_404(self): +        """Test that a tag which doesn't exist raises a 404.""" +        response = self.client.get("/pages/tags/non-existent/") +        self.assertEqual(404, response.status_code) + +    def test_context_tag(self): +        """Test that the context contains the required data for a tag.""" +        body = textwrap.dedent(""" +        --- +        unused: frontmatter +        ---- +        Tag content here. +        """) + +        tag = Tag.objects.create(name="example", body=body, last_commit=self.commit) +        response = self.client.get("/pages/tags/example/") +        expected = { +            "page_title": "example", +            "page": markdown.markdown("Tag content here."), +            "tag": tag, +            "breadcrumb_items": [ +                {"name": "Pages", "path": "."}, +                {"name": "Tags", "path": "tags"}, +            ] +        } +        for key in expected: +            self.assertEqual( +                expected[key], response.context.get(key), f"context.{key} did not match" +            ) + +    def test_context_grouped_tag(self): +        """ +        Test the context for a tag in a group. + +        The only difference between this and a regular tag are the breadcrumbs, +        so only those are checked. +        """ +        Tag.objects.create( +            name="example", body="Body text", group="group-name", last_commit=self.commit +        ) +        response = self.client.get("/pages/tags/group-name/example/") +        self.assertListEqual([ +            {"name": "Pages", "path": "."}, +            {"name": "Tags", "path": "tags"}, +            {"name": "group-name", "path": "tags/group-name"}, +        ], response.context.get("breadcrumb_items")) + +    def test_group_page(self): +        """Test rendering of a group's root page.""" +        Tag.objects.create(name="tag-1", body="Body 1", group="group-name", last_commit=self.commit) +        Tag.objects.create(name="tag-2", body="Body 2", group="group-name", last_commit=self.commit) +        Tag.objects.create(name="not-included", last_commit=self.commit) + +        response = self.client.get("/pages/tags/group-name/") +        content = response.content.decode("utf-8") + +        self.assertInHTML("<div class='level-left'>group-name</div>", content) +        self.assertInHTML( +            f"<a class='level-item fab fa-github' href='{Tag.URL_BASE}/group-name'>", +            content +        ) +        self.assertIn(">tag-1</span>", content) +        self.assertIn(">tag-2</span>", content) +        self.assertNotIn( +            ">not-included</span>", +            content, +            "Tags not in this group shouldn't be rendered." +        ) + +        self.assertInHTML("<p>Body 1</p>", content) + +    def test_markdown(self): +        """Test that markdown content is rendered properly.""" +        body = textwrap.dedent(""" +        ```py +        Hello world! +        ``` + +        **This text is in bold** +        """) + +        Tag.objects.create(name="example", body=body, last_commit=self.commit) +        response = self.client.get("/pages/tags/example/") +        content = response.content.decode("utf-8") + +        self.assertInHTML('<code class="language-py">Hello world!</code>', content) +        self.assertInHTML("<strong>This text is in bold</strong>", content) + +    def test_embed(self): +        """Test that an embed from the frontmatter is treated correctly.""" +        body = textwrap.dedent(""" +        --- +        embed: +            title: Embed title +            image: +                url: https://google.com +        --- +        Tag body. +        """) + +        Tag.objects.create(name="example", body=body, last_commit=self.commit) +        response = self.client.get("/pages/tags/example/") +        content = response.content.decode("utf-8") + +        self.assertInHTML('<img alt="Embed title" src="https://google.com"/>', content) +        self.assertInHTML("<p>Tag body.</p>", content) + +    def test_embed_title(self): +        """Test that the page title gets set to the embed title.""" +        body = textwrap.dedent(""" +        --- +        embed: +            title: Embed title +        --- +        """) + +        Tag.objects.create(name="example", body=body, last_commit=self.commit) +        response = self.client.get("/pages/tags/example/") +        self.assertEqual( +            "Embed title", +            response.context.get("page_title"), +            "The page title must match the embed title." +        ) + +    def test_hyperlinked_item(self): +        """Test hyperlinking of tags works as intended.""" +        filler_before, filler_after = "empty filler text\n\n", "more\nfiller" +        body = filler_before + "`!tags return`" + filler_after +        Tag.objects.create(name="example", body=body, last_commit=self.commit) + +        other_url = reverse("content:tag", kwargs={"location": "return"}) +        response = self.client.get("/pages/tags/example/") +        self.assertEqual( +            markdown.markdown(filler_before + f"[`!tags return`]({other_url})" + filler_after), +            response.context.get("page") +        ) + +    def test_hyperlinked_group(self): +        """Test hyperlinking with a group works as intended.""" +        Tag.objects.create( +            name="example", body="!tags group-name grouped-tag", last_commit=self.commit +        ) +        Tag.objects.create(name="grouped-tag", group="group-name") + +        other_url = reverse("content:tag", kwargs={"location": "group-name/grouped-tag"}) +        response = self.client.get("/pages/tags/example/") +        self.assertEqual( +            markdown.markdown(f"[!tags group-name grouped-tag]({other_url})"), +            response.context.get("page") +        ) + +    def test_hyperlinked_extra_text(self): +        """Test hyperlinking when a tag is followed by extra, unrelated text.""" +        Tag.objects.create( +            name="example", body="!tags other unrelated text", last_commit=self.commit +        ) +        Tag.objects.create(name="other") + +        other_url = reverse("content:tag", kwargs={"location": "other"}) +        response = self.client.get("/pages/tags/example/") +        self.assertEqual( +            markdown.markdown(f"[!tags other]({other_url}) unrelated text"), +            response.context.get("page") +        ) + +    def test_tag_root_page(self): +        """Test the root tag page which lists all tags.""" +        Tag.objects.create(name="tag-1", last_commit=self.commit) +        Tag.objects.create(name="tag-2", last_commit=self.commit) +        Tag.objects.create(name="tag-3", last_commit=self.commit) + +        response = self.client.get("/pages/tags/") +        content = response.content.decode("utf-8") + +        self.assertTemplateUsed(response, "content/listing.html") +        self.assertInHTML('<div class="level-left">Tags</div>', content) + +        for tag_number in range(1, 4): +            self.assertIn(f"tag-{tag_number}</span>", content) diff --git a/pydis_site/apps/content/urls.py b/pydis_site/apps/content/urls.py index f8496095..a7695a27 100644 --- a/pydis_site/apps/content/urls.py +++ b/pydis_site/apps/content/urls.py @@ -3,7 +3,7 @@ from pathlib import Path  from django_distill import distill_path -from . import views +from . import utils, views  app_name = "content" @@ -29,15 +29,38 @@ def __get_all_files(root: Path, folder: typing.Optional[Path] = None) -> list[st      return results -def get_all_pages() -> typing.Iterator[dict[str, str]]: +DISTILL_RETURN = typing.Iterator[dict[str, str]] + + +def get_all_pages() -> DISTILL_RETURN:      """Yield a dict of all page categories."""      for location in __get_all_files(Path("pydis_site", "apps", "content", "resources")):          yield {"location": location} +def get_all_tags() -> DISTILL_RETURN: +    """Return all tag names and groups in static builds.""" +    # We instantiate the set with None here to make filtering it out later easier +    # whether it was added in the loop or not +    groups = {None} +    for tag in utils.get_tags_static(): +        groups.add(tag.group) +        yield {"location": (f"{tag.group}/" if tag.group else "") + tag.name} + +    groups.remove(None) +    for group in groups: +        yield {"location": group} + +  urlpatterns = [      distill_path("", views.PageOrCategoryView.as_view(), name='pages'),      distill_path( +        "tags/<path:location>/", +        views.TagView.as_view(), +        name="tag", +        distill_func=get_all_tags +    ), +    distill_path(          "<path:location>/",          views.PageOrCategoryView.as_view(),          name='page_category', diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py index d3f270ff..c12893ef 100644 --- a/pydis_site/apps/content/utils.py +++ b/pydis_site/apps/content/utils.py @@ -1,14 +1,41 @@ +import datetime +import functools +import json +import tarfile +import tempfile +from io import BytesIO  from pathlib import Path -from typing import Dict, Tuple  import frontmatter +import httpx  import markdown  import yaml  from django.http import Http404 +from django.utils import timezone  from markdown.extensions.toc import TocExtension +from pydis_site import settings +from .models import Commit, Tag -def get_category(path: Path) -> Dict[str, str]: +TAG_CACHE_TTL = datetime.timedelta(hours=1) + + +def github_client(**kwargs) -> httpx.Client: +    """Get a client to access the GitHub API with important settings pre-configured.""" +    client = httpx.Client( +        base_url=settings.GITHUB_API, +        follow_redirects=True, +        timeout=settings.TIMEOUT_PERIOD, +        **kwargs +    ) +    if settings.GITHUB_TOKEN:  # pragma: no cover +        if not client.headers.get("Authorization"): +            client.headers = {"Authorization": f"token {settings.GITHUB_TOKEN}"} + +    return client + + +def get_category(path: Path) -> dict[str, str]:      """Load category information by name from _info.yml."""      if not path.is_dir():          raise Http404("Category not found.") @@ -16,7 +43,7 @@ def get_category(path: Path) -> Dict[str, str]:      return yaml.safe_load(path.joinpath("_info.yml").read_text(encoding="utf-8")) -def get_categories(path: Path) -> Dict[str, Dict]: +def get_categories(path: Path) -> dict[str, dict]:      """Get information for all categories."""      categories = {} @@ -27,8 +54,253 @@ def get_categories(path: Path) -> Dict[str, Dict]:      return categories -def get_category_pages(path: Path) -> Dict[str, Dict]: +def get_tags_static() -> list[Tag]: +    """ +    Fetch tag information in static builds. + +    This also includes some fake tags to preview the tag groups feature. +    This will return a cached value, so it should only be used for static builds. +    """ +    tags = fetch_tags() +    for tag in tags[3:5]:  # pragma: no cover +        tag.group = "very-cool-group" +    return tags + + +def fetch_tags() -> list[Tag]: +    """ +    Fetch tag data from the GitHub API. + +    The entire repository is downloaded and extracted locally because +    getting file content would require one request per file, and can get rate-limited. +    """ +    with github_client() as client: +        # Grab metadata +        metadata = client.get("/repos/python-discord/bot/contents/bot/resources") +        metadata.raise_for_status() + +        hashes = {} +        for entry in metadata.json(): +            if entry["type"] == "dir": +                # Tag group +                files = client.get(entry["url"]) +                files.raise_for_status() +                files = files.json() +            else: +                files = [entry] + +            for file in files: +                hashes[file["name"]] = file["sha"] + +        # Download the files +        tar_file = client.get("/repos/python-discord/bot/tarball") +        tar_file.raise_for_status() + +    tags = [] +    with tempfile.TemporaryDirectory() as folder: +        with tarfile.open(fileobj=BytesIO(tar_file.content)) as repo: +            included = [] +            for file in repo.getmembers(): +                if "/bot/resources/tags" in file.path: +                    included.append(file) +            repo.extractall(folder, included) + +        for tag_file in Path(folder).rglob("*.md"): +            name = tag_file.name +            group = None +            if tag_file.parent.name != "tags": +                # Tags in sub-folders are considered part of a group +                group = tag_file.parent.name + +            tags.append(Tag( +                name=name.removesuffix(".md"), +                sha=hashes[name], +                group=group, +                body=tag_file.read_text(encoding="utf-8"), +                last_commit=None, +            )) + +    return tags + + +def set_tag_commit(tag: Tag) -> None: +    """Fetch commit information from the API, and save it for the tag.""" +    if settings.STATIC_BUILD:  # pragma: no cover +        # Static builds request every page during build, which can ratelimit it. +        # Instead, we return some fake data. +        tag.last_commit = Commit( +            sha="68da80efc00d9932a209d5cccd8d344cec0f09ea", +            message="Initial Commit\n\nTHIS IS FAKE DEMO DATA", +            date=datetime.datetime(2018, 2, 3, 12, 20, 26, tzinfo=datetime.timezone.utc), +            authors=json.dumps([{"name": "Joseph", "email": "[email protected]"}]), +        ) +        return + +    path = "/bot/resources/tags" +    if tag.group: +        path += f"/{tag.group}" +    path += f"/{tag.name}.md" + +    # Fetch and set the commit +    with github_client() as client: +        data = client.get("/repos/python-discord/bot/commits", params={"path": path}) +        data.raise_for_status() +        data = data.json()[0] + +    commit = data["commit"] +    author, committer = commit["author"], commit["committer"] + +    date = datetime.datetime.strptime(committer["date"], settings.GITHUB_TIMESTAMP_FORMAT) +    date = date.replace(tzinfo=datetime.timezone.utc) + +    if author["email"] == committer["email"]: +        authors = [author] +    else: +        authors = [author, committer] + +    commit_obj, _ = Commit.objects.get_or_create( +        sha=data["sha"], +        message=commit["message"], +        date=date, +        authors=json.dumps(authors), +    ) +    tag.last_commit = commit_obj +    tag.save() + + +def record_tags(tags: list[Tag]) -> None: +    """Sync the database with an updated set of tags.""" +    # Remove entries which no longer exist +    Tag.objects.exclude(name__in=[tag.name for tag in tags]).delete() + +    # Insert/update the tags +    for new_tag in tags: +        try: +            old_tag = Tag.objects.get(name=new_tag.name) +        except Tag.DoesNotExist: +            # The tag is not in the database yet, +            # pretend it's previous state is the current state +            old_tag = new_tag + +        if old_tag.sha == new_tag.sha and old_tag.last_commit is not None: +            # We still have an up-to-date commit entry +            new_tag.last_commit = old_tag.last_commit + +        new_tag.save() + +    # Drop old, unused commits +    Commit.objects.filter(tag__isnull=True).delete() + + +def get_tags() -> list[Tag]: +    """Return a list of all tags visible to the application, from the cache or API.""" +    if settings.STATIC_BUILD:  # pragma: no cover +        last_update = None +    else: +        last_update = ( +            Tag.objects.values_list("last_updated", flat=True) +            .order_by("last_updated").first() +        ) + +    if last_update is None or timezone.now() >= (last_update + TAG_CACHE_TTL): +        # Stale or empty cache +        if settings.STATIC_BUILD:  # pragma: no cover +            tags = get_tags_static() +        else: +            tags = fetch_tags() +            record_tags(tags) + +        return tags +    else: +        # Get tags from database +        return list(Tag.objects.all()) + + +def get_tag(path: str, *, skip_sync: bool = False) -> Tag | list[Tag]: +    """ +    Return a tag based on the search location. + +    If certain tag data is out of sync (for instance a commit date is missing), +    an extra request will be made to sync the information. + +    The tag name and group must match. If only one argument is provided in the path, +    it's assumed to either be a group name, or a no-group tag name. + +    If it's a group name, a list of tags which belong to it is returned. +    """ +    path = path.split("/") +    if len(path) == 2: +        group, name = path +    else: +        name = path[0] +        group = None + +    matches = [] +    for tag in get_tags(): +        if tag.name == name and tag.group == group: +            if tag.last_commit is None and not skip_sync: +                set_tag_commit(tag) +            return tag +        elif tag.group == name and group is None: +            matches.append(tag) + +    if matches: +        return matches + +    raise Tag.DoesNotExist() + + +def get_tag_category(tags: list[Tag] | None = None, *, collapse_groups: bool) -> dict[str, dict]: +    """ +    Generate context data for `tags`, or all tags if None. + +    If `tags` is None, `get_tag` is used to populate the data. +    If `collapse_groups` is True, tags with parent groups are not included in the list, +    and instead the parent itself is included as a single entry with it's sub-tags +    in the description. +    """ +    if not tags: +        tags = get_tags() + +    data = [] +    groups = {} + +    # Create all the metadata for the tags +    for tag in tags: +        if tag.group is None or not collapse_groups: +            content = frontmatter.parse(tag.body)[1] +            data.append({ +                "title": tag.name, +                "description": markdown.markdown(content, extensions=["pymdownx.superfences"]), +                "icon": "fas fa-tag", +            }) +        else: +            if tag.group not in groups: +                groups[tag.group] = { +                    "title": tag.group, +                    "description": [tag.name], +                    "icon": "fas fa-tags", +                } +            else: +                groups[tag.group]["description"].append(tag.name) + +    # Flatten group description into a single string +    for group in groups.values(): +        # If the following string is updated, make sure to update it in the frontend JS as well +        group["description"] = "Contains the following tags: " + ", ".join(group["description"]) +        data.append(group) + +    # Sort the tags, and return them in the proper format +    return {tag["title"]: tag for tag in sorted(data, key=lambda tag: tag["title"].casefold())} + + +def get_category_pages(path: Path) -> dict[str, dict]:      """Get all page names and their metadata at a category path.""" +    # Special handling for tags +    if path == Path(__file__).parent / "resources/tags": +        return get_tag_category(collapse_groups=True) +      pages = {}      for item in path.glob("*.md"): @@ -39,7 +311,7 @@ def get_category_pages(path: Path) -> Dict[str, Dict]:      return pages -def get_page(path: Path) -> Tuple[str, Dict]: +def get_page(path: Path) -> tuple[str, dict]:      """Get one specific page."""      if not path.is_file():          raise Http404("Page not found.") diff --git a/pydis_site/apps/content/views/__init__.py b/pydis_site/apps/content/views/__init__.py index 70ea1c7a..a969b1dc 100644 --- a/pydis_site/apps/content/views/__init__.py +++ b/pydis_site/apps/content/views/__init__.py @@ -1,3 +1,4 @@  from .page_category import PageOrCategoryView +from .tags import TagView -__all__ = ["PageOrCategoryView"] +__all__ = ["PageOrCategoryView", "TagView"] diff --git a/pydis_site/apps/content/views/page_category.py b/pydis_site/apps/content/views/page_category.py index 356eb021..062c2bc1 100644 --- a/pydis_site/apps/content/views/page_category.py +++ b/pydis_site/apps/content/views/page_category.py @@ -1,4 +1,3 @@ -import typing as t  from pathlib import Path  import frontmatter @@ -6,7 +5,7 @@ from django.conf import settings  from django.http import Http404, HttpRequest, HttpResponse  from django.views.generic import TemplateView -from pydis_site.apps.content import utils +from pydis_site.apps.content import models, utils  class PageOrCategoryView(TemplateView): @@ -25,7 +24,7 @@ class PageOrCategoryView(TemplateView):          return super().dispatch(request, *args, **kwargs) -    def get_template_names(self) -> t.List[str]: +    def get_template_names(self) -> list[str]:          """Checks if the view uses the page template or listing template."""          if self.page_path.is_file():              template_name = "content/page.html" @@ -36,7 +35,7 @@ class PageOrCategoryView(TemplateView):          return [template_name] -    def get_context_data(self, **kwargs) -> t.Dict[str, t.Any]: +    def get_context_data(self, **kwargs) -> dict[str, any]:          """Assign proper context variables based on what resource user requests."""          context = super().get_context_data(**kwargs) @@ -73,7 +72,7 @@ class PageOrCategoryView(TemplateView):          return context      @staticmethod -    def _get_page_context(path: Path) -> t.Dict[str, t.Any]: +    def _get_page_context(path: Path) -> dict[str, any]:          page, metadata = utils.get_page(path)          return {              "page": page, @@ -84,7 +83,7 @@ class PageOrCategoryView(TemplateView):          }      @staticmethod -    def _get_category_context(path: Path) -> t.Dict[str, t.Any]: +    def _get_category_context(path: Path) -> dict[str, any]:          category = utils.get_category(path)          return {              "categories": utils.get_categories(path), @@ -92,4 +91,7 @@ class PageOrCategoryView(TemplateView):              "page_title": category["title"],              "page_description": category["description"],              "icon": category.get("icon"), +            "app_name": "content:page_category", +            "is_tag_listing": "/resources/tags" in path.as_posix(), +            "tag_url": models.Tag.URL_BASE,          } diff --git a/pydis_site/apps/content/views/tags.py b/pydis_site/apps/content/views/tags.py new file mode 100644 index 00000000..4f4bb5a2 --- /dev/null +++ b/pydis_site/apps/content/views/tags.py @@ -0,0 +1,124 @@ +import re +import typing + +import frontmatter +import markdown +from django.conf import settings +from django.http import Http404 +from django.urls import reverse +from django.views.generic import TemplateView + +from pydis_site.apps.content import utils +from pydis_site.apps.content.models import Tag + +# The following regex tries to parse a tag command +# It'll read up to two words seperated by spaces +# If the command does not include a group, the tag name will be in the `first` group +# If there's a second word after the command, or if there's a tag group, extra logic +# is necessary to determine whether it's a tag with a group, or a tag with text after it +COMMAND_REGEX = re.compile(r"`*!tags? (?P<first>[\w-]+)(?P<second> [\w-]+)?`*") + + +class TagView(TemplateView): +    """Handles tag pages.""" + +    tag: typing.Union[Tag, list[Tag]] +    is_group: bool + +    def setup(self, *args, **kwargs) -> None: +        """Look for a tag, and configure the view.""" +        super().setup(*args, **kwargs) + +        try: +            self.tag = utils.get_tag(kwargs.get("location")) +            self.is_group = isinstance(self.tag, list) +        except Tag.DoesNotExist: +            raise Http404 + +    def get_template_names(self) -> list[str]: +        """Either return the tag page template, or the listing.""" +        if self.is_group: +            template_name = "content/listing.html" +        else: +            template_name = "content/tag.html" + +        return [template_name] + +    def get_context_data(self, **kwargs) -> dict: +        """Get the relevant context for this tag page or group.""" +        context = super().get_context_data(**kwargs) +        context["breadcrumb_items"] = [{ +            "name": utils.get_category(settings.CONTENT_PAGES_PATH / location)["title"], +            "path": location, +        } for location in (".", "tags")] + +        if self.is_group: +            self._set_group_context(context, self.tag) +        else: +            self._set_tag_context(context, self.tag) + +        return context + +    @staticmethod +    def _set_tag_context(context: dict[str, any], tag: Tag) -> None: +        """Update the context with the information for a tag page.""" +        context.update({ +            "page_title": tag.name, +            "tag": tag, +        }) + +        if tag.group: +            # Add group names to the breadcrumbs +            context["breadcrumb_items"].append({ +                "name": tag.group, +                "path": f"tags/{tag.group}", +            }) + +        # Clean up tag body +        body = frontmatter.parse(tag.body) +        content = body[1] + +        # Check for tags which can be hyperlinked +        def sub(match: re.Match) -> str: +            first, second = match.groups() +            location = first +            text, extra = match.group(), "" + +            if second is not None: +                # Possibly a tag group +                try: +                    new_location = f"{first}/{second.strip()}" +                    utils.get_tag(new_location, skip_sync=True) +                    location = new_location +                except Tag.DoesNotExist: +                    # Not a group, remove the second argument from the link +                    extra = text[text.find(second):] +                    text = text[:text.find(second)] + +            link = reverse("content:tag", kwargs={"location": location}) +            return f"[{text}]({link}){extra}" +        content = COMMAND_REGEX.sub(sub, content) + +        # Add support for some embed elements +        if embed := body[0].get("embed"): +            context["page_title"] = embed["title"] +            if image := embed.get("image"): +                content = f"![{embed['title']}]({image['url']})\n\n" + content + +        # Insert the content +        context["page"] = markdown.markdown(content, extensions=["pymdownx.superfences"]) + +    @staticmethod +    def _set_group_context(context: dict[str, any], tags: list[Tag]) -> None: +        """Update the context with the information for a group of tags.""" +        group = tags[0].group +        context.update({ +            "categories": {}, +            "pages": utils.get_tag_category(tags, collapse_groups=False), +            "page_title": group, +            "icon": "fab fa-tags", +            "is_tag_listing": True, +            "app_name": "content:tag", +            "path": f"{group}/", +            "tag_url": f"{tags[0].URL_BASE}/{group}" +        }) diff --git a/pydis_site/apps/home/views/home.py b/pydis_site/apps/home/views/home.py index 9bb1f8fd..8a165682 100644 --- a/pydis_site/apps/home/views/home.py +++ b/pydis_site/apps/home/views/home.py @@ -32,9 +32,7 @@ class HomeView(View):      def __init__(self):          """Clean up stale RepositoryMetadata.""" -        self._static_build = settings.env("STATIC_BUILD") - -        if not self._static_build: +        if not settings.STATIC_BUILD:              RepositoryMetadata.objects.exclude(repo_name__in=self.repos).delete()          # If no token is defined (for example in local development), then @@ -94,7 +92,7 @@ class HomeView(View):      def _get_repo_data(self) -> List[RepositoryMetadata]:          """Build a list of RepositoryMetadata objects that we can use to populate the front page."""          # First off, load the timestamp of the least recently updated entry. -        if self._static_build: +        if settings.STATIC_BUILD:              last_update = None          else:              last_update = ( @@ -121,7 +119,7 @@ class HomeView(View):                  for api_data in api_repositories.values()              ] -            if settings.env("STATIC_BUILD"): +            if settings.STATIC_BUILD:                  return data              else:                  return RepositoryMetadata.objects.bulk_create(data) diff --git a/pydis_site/apps/redirect/urls.py b/pydis_site/apps/redirect/urls.py index f86fe665..067cccc3 100644 --- a/pydis_site/apps/redirect/urls.py +++ b/pydis_site/apps/redirect/urls.py @@ -32,7 +32,7 @@ class Redirect:  def map_redirect(name: str, data: Redirect) -> list[URLPattern]:      """Return a pattern using the Redirects app, or a static HTML redirect for static builds.""" -    if not settings.env("STATIC_BUILD"): +    if not settings.STATIC_BUILD:          # Normal dynamic redirect          return [path(              data.original_path, diff --git a/pydis_site/settings.py b/pydis_site/settings.py index cb05956b..e9e0ba67 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -38,12 +38,16 @@ GITHUB_API = "https://api.github.com"  GITHUB_TOKEN = env("GITHUB_TOKEN")  GITHUB_APP_ID = env("GITHUB_APP_ID")  GITHUB_APP_KEY = env("GITHUB_APP_KEY") +GITHUB_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ" +"""The datetime string format GitHub uses.""" + +STATIC_BUILD: bool = env("STATIC_BUILD")  if GITHUB_APP_KEY and (key_file := Path(GITHUB_APP_KEY)).is_file():      # Allow the OAuth key to be loaded from a file      GITHUB_APP_KEY = key_file.read_text(encoding="utf-8") -if not env("STATIC_BUILD"): +if not STATIC_BUILD:      sentry_sdk.init(          dsn=env('SITE_DSN'),          integrations=[DjangoIntegration()], @@ -100,7 +104,7 @@ else:  NON_STATIC_APPS = [      'pydis_site.apps.api',      'pydis_site.apps.staff', -] if not env("STATIC_BUILD") else [] +] if not STATIC_BUILD else []  INSTALLED_APPS = [      *NON_STATIC_APPS, @@ -129,7 +133,7 @@ INSTALLED_APPS = [  if not env("BUILDING_DOCKER"):      INSTALLED_APPS.append("django_prometheus") -if env("STATIC_BUILD"): +if STATIC_BUILD:      # The only middleware required during static builds      MIDDLEWARE = [          'django.contrib.sessions.middleware.SessionMiddleware', @@ -180,7 +184,7 @@ WSGI_APPLICATION = 'pydis_site.wsgi.application'  DATABASES = {      'default': env.db(),      'metricity': env.db('METRICITY_DB_URL'), -} if not env("STATIC_BUILD") else {} +} if not STATIC_BUILD else {}  # Password validation  # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators diff --git a/pydis_site/static/css/content/color.css b/pydis_site/static/css/content/color.css new file mode 100644 index 00000000..f4801c28 --- /dev/null +++ b/pydis_site/static/css/content/color.css @@ -0,0 +1,7 @@ +.content .fa-github { +    color: black; +} + +.content .fa-github:hover { +    color: #7289DA; +} diff --git a/pydis_site/static/css/content/tag.css b/pydis_site/static/css/content/tag.css new file mode 100644 index 00000000..79795f9e --- /dev/null +++ b/pydis_site/static/css/content/tag.css @@ -0,0 +1,13 @@ +.content a * { +    /* This is the original color, but propagated down the chain */ +    /* which allows for elements inside links, such as codeblocks */ +    color: #7289DA; +} + +.content a *:hover { +    color: dimgray; +} + +span.update-time { +    text-decoration: black underline dotted; +} diff --git a/pydis_site/static/images/content/fix-ssl-certificate/pem.png b/pydis_site/static/images/content/fix-ssl-certificate/pem.pngBinary files differ new file mode 100644 index 00000000..face520f --- /dev/null +++ b/pydis_site/static/images/content/fix-ssl-certificate/pem.png diff --git a/pydis_site/static/js/content/listing.js b/pydis_site/static/js/content/listing.js new file mode 100644 index 00000000..4b722632 --- /dev/null +++ b/pydis_site/static/js/content/listing.js @@ -0,0 +1,41 @@ +/** + * Trim a tag listing to only show a few lines of content. + */ +function trimTag() { +    const containers = document.getElementsByClassName("tag-container"); +    for (const container of containers) { +        if (container.textContent.startsWith("Contains the following tags:")) { +            // Tag group, no need to trim +            continue; +        } + +        // Remove every element after the first two paragraphs +        while (container.children.length > 2) { +            container.removeChild(container.lastChild); +        } + +        // Trim down the elements if they are too long +        const containerLength = container.textContent.length; +        if (containerLength > 300) { +            if (containerLength - container.firstChild.textContent.length > 300) { +                // The first element alone takes up more than 300 characters +                container.removeChild(container.lastChild); +            } + +            let last = container.lastChild.lastChild; +            while (container.textContent.length > 300 && container.lastChild.childNodes.length > 0) { +                last = container.lastChild.lastChild; +                last.remove(); +            } + +            if (container.textContent.length > 300 && (last instanceof HTMLElement && last.tagName !== "CODE")) { +                // Add back the final element (up to a period if possible) +                const stop = last.textContent.indexOf("."); +                last.textContent = last.textContent.slice(0, stop > 0 ? stop + 1: null); +                container.lastChild.appendChild(last); +            } +        } +    } +} + +trimTag(); diff --git a/pydis_site/templates/base/navbar.html b/pydis_site/templates/base/navbar.html index d7fb4f4c..931693c8 100644 --- a/pydis_site/templates/base/navbar.html +++ b/pydis_site/templates/base/navbar.html @@ -67,6 +67,9 @@            <a class="navbar-item" href="{% url "resources:index" %}">              Resources            </a> +          <a class="navbar-item" href="{% url "content:pages" %}"> +              Content +          </a>            <a class="navbar-item" href="{% url "events:index" %}">              Events            </a> diff --git a/pydis_site/templates/content/base.html b/pydis_site/templates/content/base.html index 4a19a275..2fd721a3 100644 --- a/pydis_site/templates/content/base.html +++ b/pydis_site/templates/content/base.html @@ -8,6 +8,10 @@      <meta property="og:description" content="{{ page_description }}" />      <link rel="stylesheet" href="{% static "css/content/page.css" %}">      <link rel="stylesheet" href="{% static "css/collapsibles.css" %}"> +    <link rel="stylesheet" +      href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/styles/atom-one-dark.min.css"> +    <script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/highlight.min.js"></script> +    <script>hljs.highlightAll();</script>      <script src="{% static "js/collapsibles.js" %}"></script>  {% endblock %} @@ -35,7 +39,7 @@      <section class="section">          <div class="container">              <div class="content"> -                <h1 class="title">{{ page_title }}</h1> +                <h1 class="title">{% block title_element %}{{ page_title }}{% endblock %}</h1>                  {% block page_content %}{% endblock %}              </div>          </div> diff --git a/pydis_site/templates/content/listing.html b/pydis_site/templates/content/listing.html index ef0ef919..934b95f6 100644 --- a/pydis_site/templates/content/listing.html +++ b/pydis_site/templates/content/listing.html @@ -1,6 +1,22 @@ +{# Base navigation screen for resources #}  {% extends 'content/base.html' %} +{% load static %} + +{# Show a GitHub button on tag pages #} +{% block title_element %} +{% if is_tag_listing %} +    <link rel="stylesheet" href="{% static "css/content/color.css" %}"> +    <div class="level"> +        <div class="level-left">{{ block.super }}</div> +        <div class="level-right"> +            <a class="level-item fab fa-github" href="{{ tag_url }}"></a> +        </div> +    </div> +{% endif %} +{% endblock %}  {% block page_content %} +    {# Nested Categories #}      {% for category, data in categories.items %}          <div class="box" style="max-width: 800px;">              <span class="icon is-size-4 is-medium"> @@ -13,15 +29,22 @@              <p class="is-italic">{{ data.description }}</p>          </div>      {% endfor %} + +    {# Single Pages #}      {% for page, data in pages.items %}          <div class="box" style="max-width: 800px;">              <span class="icon is-size-4 is-medium">                  <i class="{{ data.icon|default:"fab fa-python" }} is-size-3 is-black has-icon-padding" aria-hidden="true"></i>              </span> -            <a href="{% url "content:page_category" location=path|add:page %}"> +            <a href="{% url app_name location=path|add:page %}">                  <span class="is-size-4 has-text-weight-bold">{{ data.title }}</span>              </a> -            <p class="is-italic">{{ data.description }}</p> +            {% if is_tag_listing %} +                <div class="tag-container">{{ data.description | safe }}</div> +            {% else %} +                <p class="is-italic">{{ data.description }}</p> +            {% endif %}          </div>      {% endfor %} +    <script src="{% static 'js/content/listing.js' %}"></script>  {% endblock %} diff --git a/pydis_site/templates/content/page.html b/pydis_site/templates/content/page.html index 759286f6..679ecec6 100644 --- a/pydis_site/templates/content/page.html +++ b/pydis_site/templates/content/page.html @@ -1,13 +1,5 @@  {% extends 'content/base.html' %} -{% block head %} -    {{ block.super }} -    <link rel="stylesheet" -      href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/styles/atom-one-dark.min.css"> -    <script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/highlight.min.js"></script> -    <script>hljs.initHighlightingOnLoad();</script> -{% endblock %} -  {% block page_content %}      {% if relevant_links or toc %}          <div class="columns is-variable is-8"> diff --git a/pydis_site/templates/content/tag.html b/pydis_site/templates/content/tag.html new file mode 100644 index 00000000..fa9e44f5 --- /dev/null +++ b/pydis_site/templates/content/tag.html @@ -0,0 +1,40 @@ +{% extends "content/page.html" %} +{% load static %} + +{% block head %} +    {{ block.super }} +    <link rel="stylesheet" href="{% static 'css/content/color.css' %}"/> +    <link rel="stylesheet" href="{% static 'css/content/tag.css' %}"/> +    <title>{{ tag.name }}</title> +{% endblock %} + +{% block title_element %} +    <div class="level mb-2"> +        <div class="level-left">{{ block.super }}</div> +        <div class="level-right"> +            <a class="level-item fab fa-github" href="{{ tag.url }}"></a> +        </div> +    </div> + +    <div class="dropdown is-size-6 is-hoverable"> +        <div class="dropdown-trigger "> +            <a aria-haspopup="menu" href="{{ tag.last_commit.url }}"> +                <span class="update-time"> +                    Last Updated: {{ tag.last_commit.date | date:"F j, Y g:i A e" }} +                </span> +            </a> +        </div> +        <div class="dropdown-menu"> +            <div class="dropdown-content"> +                <div class="dropdown-item">Last edited by:</div> +                {% for user in tag.last_commit.format_authors %} +                    <div class="dropdown-item">{{ user }}</div> +                {% endfor %} +                <div class="dropdown-divider"></div> +                {% for line in tag.last_commit.lines %} +                    <div class="dropdown-item">{{ line }}</div> +                {% endfor %} +            </div> +        </div> +    </div> +{% endblock %} diff --git a/pydis_site/urls.py b/pydis_site/urls.py index 6cd31f26..0f2f6aeb 100644 --- a/pydis_site/urls.py +++ b/pydis_site/urls.py @@ -12,7 +12,7 @@ NON_STATIC_PATTERNS = [      path('pydis-api/', include('pydis_site.apps.api.urls', namespace='internal_api')),      path('', include('django_prometheus.urls')), -] if not settings.env("STATIC_BUILD") else [] +] if not settings.STATIC_BUILD else []  urlpatterns = ( @@ -29,7 +29,7 @@ urlpatterns = (  ) -if not settings.env("STATIC_BUILD"): +if not settings.STATIC_BUILD:      urlpatterns += (          path('staff/', include('pydis_site.apps.staff.urls', namespace='staff')),      ) diff --git a/pyproject.toml b/pyproject.toml index 78acb69a..79f2ecc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ license = "MIT"  [tool.poetry.dependencies]  python = "3.10.*" -django = "4.1.2" +django = "4.1.3"  django-environ = "0.9.0"  django-filter = "22.1"  djangorestframework = "3.14.0" @@ -17,19 +17,20 @@ whitenoise = "6.2.0"  httpx = "0.23.0"  pyyaml = "6.0"  gunicorn = "20.1.0" -sentry-sdk = "1.10.1" +sentry-sdk = "1.11.0"  markdown = "3.4.1"  python-frontmatter = "1.0.0"  django-prometheus = "2.2.0"  django-distill = "3.0.1"  PyJWT = {version = "2.6.0", extras = ["crypto"]} +pymdown-extensions = "9.8"  [tool.poetry.dev-dependencies]  coverage = "6.5.0"  flake8 = "5.0.4"  flake8-annotations = "2.9.1"  flake8-bandit = "4.1.1" -flake8-bugbear = "22.10.25" +flake8-bugbear = "22.10.27"  flake8-docstrings = "1.6.0"  flake8-import-order = "0.18.1"  flake8-tidy-imports = "4.8.0" | 
